Compare commits

...
Author SHA1 Message Date
ciaranbor 13ce4e9052 Warmup = 2 2026-05-10 18:44:55 +01:00
ciaranbor 4ea5244e85 Eco-integrated context scaling benchmarks 2026-05-10 18:11:53 +01:00
Andrei Cravtov 45df74ba98 Andrei/mp capture stdio (#2056)
## Motivation

Process-isolated runner crashes and C-extension failures can write
directly to fd-level stdout/stderr, bypassing Python/loguru. We need to
capture that output per runner process without polluting the main
process or other workers, and without breaking operation when the parent
stdio is detached.

## Changes

- Added `AsyncProcess`, a spawn-only multiprocessing wrapper that
redirects child stdout/stderr to pipes and exposes them as in-memory
`Receiver[bytes]`s
- Replaced runner-supervisor's raw `multiprocessing.Process` usage with
`AsyncProcess`
- Added `--no-stdio`, redirecting stdin/stdout/stderr to `/dev/null`
after logging is configured
- Disabled verbose MLX
- Added tests covering stdio capture, child crashes, repeated bad
children, SIGTERM/SIGKILL shutdown escalation, stdio detachment, and
spawning captured children from a stdio-detached parent

## Why It Works

The parent can redirect its own stdio fds to `/dev/null`, while
`AsyncProcess` installs fresh pipe fds over fd 1 and 2 inside each
spawned child. That keeps stdio-detached parents quiet while preserving
per-runner stdout/stderr capture. Runner shutdown is still bounded:
SIGTERM grace first, then SIGKILL escalation if needed.

Next direction: the runner supervisor currently drains captured output
and logs it as stdout/debug and stderr/warning. This should be split
into more useful process-isolated error reporting instead of just log
forwarding (regex match on errors to obtain "reason" string, best
effort).

## Test Plan

### Manual Testing

Ran on 4 Mac Minis in a Thunderbolt 4 ring, can see that runner's
stdout/stderr contents are being captured.

### Automated Testing

- Added async-process tests for fd-level stdout/stderr capture, Python
traceback capture, bounded-buffer output, child `exit`/abort, parent
stdio preservation, fd leak checks, spawn-context mp channels, and
SIGTERM/SIGKILL shutdown behavior
- Added stdio-detach tests proving stdio detaches to `/dev/null`, a
stdio-detached parent can still spawn and capture a child, and the same
stdio-detached parent can spawn/capture multiple children sequentially
- Updated runner-supervisor tests for the new `AsyncProcess.exitcode`
path
2026-05-09 22:45:14 +01:00
Kerollos Magdy ce37bdceb6 fix: Create directory for PID file if it doesn't exist (#2075)
Ensure the directory for the PID file exists before creating it.

## Motivation

Fixes https://github.com/exo-explore/exo/issues/2074

## Changes

<!-- Describe what you changed in detail -->

## Why It Works

<!-- Explain why your approach solves the problem -->

## Test Plan

### Manual Testing
<!-- Hardware: (e.g., MacBook Pro M1 Max 32GB, Mac Mini M2 16GB,
connected via Thunderbolt 4) -->
<!-- What you did: -->
<!-- - -->

### Automated Testing
<!-- Describe changes to automated tests, or how existing tests cover
this change -->
<!-- - -->
2026-05-09 12:10:22 +00:00
Andrei Cravtov e5a1e5dadb Create PID file locking for EXO (#2072)
## Motivation

EXO should be PID file locked, to prevent duplicate processes from
clobbering the log, right now this isn't the case.

## Changes

I added a wrapper around a Rust PID file lock library, and used it to
implement PID locking for EXO, with the PID file being in exo cache
directory.

## Test Plan

### Manual Testing
Tested on e11, trying to spawn duplicate EXO processes prevented.
2026-05-08 18:50:18 +01:00
ciaranbor fa57131374 Integration tests infra (#1995)
## Motivation

No automated integration tests exist for exo. Manual testing against
real hardware clusters is slow and error-prone. We need a pytest
framework that deploys clusters via `eco`, runs inference scenarios, and
tears down cleanly.

## Changes

- **`tools/src/exo_tools/`** — New workspace member shared by bench,
eval, and tests:
- `client.py` — `ExoClient` HTTP client (extracted from
`bench/harness.py`)
- `harness.py` — instance lifecycle helpers (placement, wait-for-ready,
etc.)
- `cluster.py` — `EcoSession` for eco cluster lifecycle
(deploy/stop/start/release/logs/exec) with unique `USER=<prefix>-<uuid>`
per session and atexit/signal cleanup
- **`tests/integration/`** — 17 pytest tests across 5 files:
- `test_1node.py` — place, chat, multi-turn, delete, state/models
endpoints, cluster snapshot, download-from-scratch
- `test_2node.py` — parametrized tensor/jaccl + pipeline/ring inference
and multi-turn
- `test_4node.py` — parametrized 4-node pipeline/ring inference, cluster
state
- `test_resilience.py` — full disconnect/reconnect cycle (2-node →
disconnect → 1-node → reconnect → 2-node)
- `test_dashboard.py` — Playwright: dashboard loads, shows node info,
chat flow
- `helpers.py` — placement/inference helpers, re-exports from
`exo_tools`
- `conftest.py` — session-scoped cluster fixtures with constraint-based
eco reservations; `--hosts` override; `EXO_REF` env var for CI
deployments from a GitHub branch
- **`bench/`** — Updated imports from `exo_tools.client` /
`exo_tools.harness`
- **`pyproject.toml`** — Added `tools` workspace member, `playwright`
dev dep, `--ignore=tests/integration`

## Why It Works

Tests use `eco` for cluster lifecycle and `ExoClient` for API
interactions — same tools humans use. Session-scoped fixtures deploy
once per file. Unique eco users prevent test runs from interfering with
each other or manual usage.

## Test Plan

### Automated Testing

- `uv run pytest tests/integration/ -v -s` — full suite (~4-5 min, 17/17
passing)
- `uv run pytest tests/integration/ -v -s --hosts s4,s9,s10,s22` — pin
specific hosts
- `EXO_REF=main uv run pytest tests/integration/ -v` — deploy from a
GitHub branch (CI)
- `uv run pytest` — confirms integration tests are excluded from default
runs
2026-05-08 17:15:08 +01:00
Alex Cheema 414132ae9c Use time-weighted power sampling (#2038)
## Why

The power sampler currently averages sampled wattage values
arithmetically. That can be materially wrong when sample intervals are
uneven: a short high-power spike gets the same weight as a long steady
interval. Energy should be computed by integrating power over time, and
average power should be derived from energy / elapsed time.

## How

- Store each power sample with its relative timestamp.
- Anchor the first sample at `t=0` and take a final sample at `elapsed`
when producing results.
- Integrate per-node power using the trapezoidal rule.
- Sum node energy for total cluster energy, then derive total average
system power from total energy / elapsed.
- Add focused unit tests for uneven sample intervals and the
single-sample fallback.

## Tests

- `uv run pytest src/exo/utils/tests/test_power_sampler.py`
- `uv run basedpyright`
- `uv run ruff check src/exo/utils/power_sampler.py
src/exo/utils/tests/test_power_sampler.py`
- `nix fmt`
2026-05-07 10:42:14 +00:00
74 changed files with 6872 additions and 636 deletions

No files matched your search

+1
View File
@@ -40,3 +40,4 @@ bench/**/*.json
tmp/models
/build/exo
/.claude/skills
/.claude
+75
View File
@@ -120,6 +120,81 @@ From .cursorrules:
Tests use pytest-asyncio with `asyncio_mode = "auto"`. Tests are in `tests/` subdirectories alongside the code they test. The `EXO_TESTS=1` env var is set during tests.
Integration tests live in `tests/` (root) and are opt-in via `--ignore=tests` in the default pytest addopts. They require an `eco`-managed cluster:
```bash
uv run pytest tests/ -v # constraint-driven host pick
uv run pytest tests/ -v --hosts s4 # explicit host override
```
## Benchmarking
Benchmarks live in `bench/`. The framework is a CLI with subcommands; each benchmark is a small library module under `bench/lib/<name>.py` plus a CLI front-end under `bench/cli/<name>.py`.
```
bench/
├── lib/ # composable, typed building blocks
│ ├── prompt.py # PromptSizer, load_tokenizer_for_bench
│ ├── completion.py # run_one_completion + typed payloads
│ ├── session.py # BenchSession (cluster + client + instance)
│ ├── results.py # RunMetadata, ResultsBundle, JSON schema
│ ├── model_meta.py # HF API: total weights size, max context, layers
│ ├── cluster.py # managed_cluster + managed_instance ctx-managers
│ └── context_scaling.py # prompt-TPS / decode-TPS vs context-size sweep
├── cli/ # CLI subcommands
│ ├── _common.py # shared argparse args + SharedOptions
│ ├── context_scaling.py # `python -m bench.cli context-scaling …`
│ └── __main__.py # subcommand dispatcher
└── exo_bench.py, prefill_decode_bench.py
# legacy CLI scripts; PromptSizer / run_one_completion
# / load_tokenizer_for_bench are re-exports of bench.lib.
```
Run a benchmark:
```bash
# Defaults assume a multi-node, Thunderbolt-connected cluster with tensor
# parallelism + JACCL: --sharding Tensor --comm MlxJaccl --thunderbolt a2a.
# Memory + disk minimums are auto-derived from HF metadata.
uv run python -m bench.cli context-scaling \
--model mlx-community/Qwen3-30B-A3B-4bit --nodes 2 --num-steps 32
# Single-node smoke: opt out of TB / tensor / jaccl
uv run python -m bench.cli context-scaling --hosts s4 \
--model mlx-community/Llama-3.2-1B-Instruct-4bit --num-steps 4 \
--sharding Pipeline --comm MlxRing --thunderbolt none
# From a TOML config (CLI flags override config values)
uv run python -m bench.cli context-scaling \
--config bench/configs/context_scaling.example.toml --hosts s4,s9
```
Shared CLI flags (every subcommand inherits these via `bench/cli/_common.py`):
- `--config <path>.toml` — load run parameters from a TOML file
- `--model`, `--sharding {Pipeline,Tensor}` (default Tensor), `--comm {MlxRing,MlxJaccl}` (default MlxJaccl), `--min-nodes` — placement
- `--hosts`, `--nodes` (number of cluster hosts; distinct from `--min-nodes`), `--thunderbolt {a2a,ring,none}` (default a2a), `--chip` — host pool
- `--min-memory-gb`, `--max-memory-gb`, `--min-disk-gb`, `--max-disk-gb` (minimums auto-derived from HF model size when not supplied)
- `--evict-downloads` (default on; auto-evicts smallest-first when disk is short)
- `--cleanup-instance` (default on; deletes the instance on exit)
- `--output-dir`, `--tag key=value` (repeatable)
Run a multi-run campaign from a single TOML file (each `[[runs]]` = its own cluster deploy + bench + teardown; `[defaults]` is shared, per-run keys override; `[plot]` triggers a comparison PNG):
```bash
uv run python -m bench.cli campaign bench/configs/llama-family-smoke.toml
```
Plot any results JSON to a PNG (auto-detects benchmark type from `metadata.benchmark`):
```bash
uv run python -m bench.cli plot bench/results/context_scaling/latest.json
uv run python -m bench.cli plot a.json b.json --label-tag operator # multi-run comparison
```
Adding a new benchmark = (1) write a `bench/lib/<name>.py` exposing a typed `run(session, params, bundle)` callable; (2) add a `bench/cli/<name>.py` with `add_subparser(...)` + `run(args) -> Path`; (3) register the imports in `bench/cli/__main__.py`. To enable plotting for the new benchmark, add a `render_<name>(inputs)` function in `bench/lib/plotting.py` and a dispatch entry in `bench/cli/plot.py::run`.
Results land at `bench/results/<benchmark>/<run_id>.json` (with a `latest.json` symlink alongside) containing metadata (exo SHA, hostname, platform, ISO timestamps, methodology version, user tags), full cluster snapshot, the resolved + derived params, per-step rows, optional cold-control rows, and any derived summaries (e.g. `t_cum_seconds[]` for context-scaling).
## Dashboard UI Testing & Screenshots
### Building and Running the Dashboard
Generated
+39 -3
View File
@@ -916,11 +916,13 @@ dependencies = [
"libp2p",
"log",
"networking",
"pidfile-rs",
"pin-project",
"pyo3",
"pyo3-async-runtimes",
"pyo3-log",
"pyo3-stub-gen",
"thiserror 2.0.17",
"tokio",
"util",
]
@@ -964,6 +966,16 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844"
[[package]]
name = "flopen"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbfb8b5fbd1f27929f216650081a07b6ceb0741f0542c8c43ff7ef8e93a35a5d"
dependencies = [
"libc",
"nix 0.31.2",
]
[[package]]
name = "fnv"
version = "1.0.7"
@@ -1789,9 +1801,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.178"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libp2p"
@@ -2807,6 +2819,18 @@ dependencies = [
"libc",
]
[[package]]
name = "nix"
version = "0.31.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3"
dependencies = [
"bitflags 2.10.0",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nohash-hasher"
version = "0.2.0"
@@ -3060,6 +3084,18 @@ dependencies = [
"siphasher",
]
[[package]]
name = "pidfile-rs"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1a8aa9a30b1b65ef48b333931b80f2324a14e00208eb2b8f5788f1180791bcc"
dependencies = [
"flopen",
"libc",
"log",
"thiserror 1.0.69",
]
[[package]]
name = "pin-project"
version = "1.1.10"
@@ -3668,7 +3704,7 @@ dependencies = [
"netlink-packet-utils",
"netlink-proto",
"netlink-sys",
"nix",
"nix 0.26.4",
"thiserror 1.0.69",
"tokio",
]
+114
View File
@@ -550,6 +550,120 @@ uv run bench/exo_bench.py \
The tool outputs performance metrics including prompt tokens per second (prompt_tps), generation tokens per second (generation_tps), and peak memory usage for each configuration.
### Composable benchmarks (CLI)
For benchmarks that need an `eco`-managed cluster and a stable JSON result format, exo ships a CLI under `bench/cli/`. The CLI handles cluster lifecycle, instance placement, model-metadata resolution (HuggingFace), and result capture; benchmark logic lives in `bench/lib/` so each new benchmark is a small library module + a CLI subcommand.
**Run the prompt-TPS / decode-TPS vs context-size sweep:**
The defaults assume a multi-node, Thunderbolt-connected cluster with tensor parallelism + JACCL — the typical exo benchmarking setup:
```bash
# Defaults: --sharding Tensor --comm MlxJaccl --thunderbolt a2a, with
# memory/disk minimums auto-derived from the HF model size. eco picks
# `--nodes` hosts from its inventory that form a TB clique and satisfy
# those constraints.
uv run python -m bench.cli context-scaling \
--model mlx-community/Qwen3-30B-A3B-4bit --nodes 2 --num-steps 32
# Pin to specific hosts (defaults still apply for sharding/comm/topology)
uv run python -m bench.cli context-scaling --hosts s4,s9 \
--model mlx-community/Qwen3-30B-A3B-4bit --num-steps 16
# Single-node smoke test: explicit single-node placement overrides
uv run python -m bench.cli context-scaling --hosts s4 \
--model mlx-community/Llama-3.2-1B-Instruct-4bit --num-steps 4 \
--sharding Pipeline --comm MlxRing --thunderbolt none
# Override the auto-derived ramp / cold controls
uv run python -m bench.cli context-scaling --hosts s4,s9 --model X \
--pp-step 4096 --num-steps 32 --cold-controls 8192,32768,65536,131072
# Custom output dir + tags
uv run python -m bench.cli context-scaling --hosts s4,s9 --model X \
--output-dir bench/results/2026-05-10/ --tag operator=$USER --tag run=full
# Run from a TOML config (CLI flags override values from the file)
uv run python -m bench.cli context-scaling \
--config bench/configs/context_scaling.example.toml
```
**Shared flags (every benchmark subcommand has these):**
- `--model` — HuggingFace model id (required)
- `--config <path>.toml` — load run parameters from a TOML file
- `--sharding {Pipeline,Tensor}` (default **Tensor**) — sharding mode
- `--comm {MlxRing,MlxJaccl}` (default **MlxJaccl**) — inter-node comm mode
- `--min-nodes N` (default 1) — minimum nodes for the placement
- `--hosts s4,s9` — pin to specific hosts; bypasses constraint search
- `--nodes N` (default 1) — number of cluster hosts to reserve when `--hosts` is unset (distinct from `--min-nodes`, which controls the model's instance placement)
- `--thunderbolt {a2a,ring,none}` (default **a2a**) — required Thunderbolt topology
- `--chip "M3 Ultra"` — required chip (substring match; comment to allow any)
- `--min-memory-gb`, `--max-memory-gb`, `--min-disk-gb`, `--max-disk-gb` — host RAM / disk constraints. The minimums are auto-derived from the HF model size (×1.30 + 1 GiB for memory, ×1.10 + 1 GiB for disk) when not supplied; explicit values always win.
- `--evict-downloads` (default **on**) — auto-evict existing models smallest-first on disk-full; pass `--no-evict-downloads` to keep
- `--cleanup-instance` (default **on**) — delete the placed instance after exit; pass `--no-cleanup-instance` to leave it running for debugging
- `--output-dir bench/results` — base directory for JSON results (subcommands add their own subfolder)
- `--tag key=value` — append to `metadata.tags` (repeatable)
**Context-scaling-specific flags:**
- `--num-steps N` — number of equally-spaced ramp points (default 32)
- `--pp-step Δ` — explicit Δ in tokens (overrides auto-derivation from `max_position_embeddings`)
- `--fraction-of-max F` — when Δ is auto-derived, use `F × max_context` as the upper bound
- `--tg` — tokens generated per step (default 64)
- `--warmup` — warmup requests at `pp=Δ` (default 1)
- `--cold-controls auto` (4 evenly-spaced points across the ramp) **or** `--cold-controls 8192,32768,…` (explicit pp values). Default: no cold controls.
**Output:** each run writes `bench/results/<benchmark>/<run_id>.json` plus a `latest.json` symlink. The JSON contains metadata (exo SHA, hostname, platform, user tags), the full cluster snapshot at run start, the resolved + derived params, per-step rows, cold-control rows, and derived summaries (`t_cum_seconds`, `control_gaps`).
**Multi-run campaigns** — `bench campaign` runs a list of bench invocations from a single TOML file. Each `[[runs]]` entry is its own cluster deploy + bench + teardown, with a shared `[defaults]` table for DRY config:
```toml
# bench/configs/llama-family-smoke.toml
[defaults]
nodes = 4
num_steps = 8
fraction_of_max = 0.5
[[runs]]
subcommand = "context-scaling"
model = "mlx-community/Llama-3.2-3B-Instruct-4bit"
[runs.tags]
model_short = "llama-3.2-3b-4bit"
[[runs]]
subcommand = "context-scaling"
model = "mlx-community/Meta-Llama-3.1-8B-Instruct-4bit"
[runs.tags]
model_short = "llama-3.1-8b-4bit"
[plot]
label_tag = "model_short"
```
```bash
uv run python -m bench.cli campaign bench/configs/llama-family-smoke.toml
```
After all runs finish, an optional `[plot]` table triggers a comparison plot per benchmark group (one PNG per benchmark type with ≥2 runs).
**Plotting** — `bench plot` renders any results JSON to a 2-panel PNG (prompt_tps + generation_tps vs pp_tokens, cold controls overlaid as 'x' markers):
```bash
# Plot the most recent run next to its JSON
uv run python -m bench.cli plot bench/results/context_scaling/latest.json
# Compare multiple runs (one line per run; legend label = the chosen tag)
uv run python -m bench.cli plot run_a.json run_b.json --label-tag operator
# Custom output path + title
uv run python -m bench.cli plot run.json --output /tmp/scaling.png --title "30B 4-node sweep"
```
The benchmark type is detected from each JSON's `metadata.benchmark`, so the same `plot` command will work for future benchmarks once their renderer is registered in `bench/lib/plotting.py`.
Methodology for the context-scaling benchmark is documented in detail in `bench/lib/context_scaling.py`'s module docstring and in `bench/METHODOLOGY.md`.
---
## Hardware Accelerator Support
+14
View File
@@ -0,0 +1,14 @@
"""CLI front-ends for bench library benchmarks.
Each benchmark is a sub-package / module with two pieces:
- a ``run(...)`` callable in ``bench.lib.<name>`` that does the actual
measurement (no argparse, no eco, no I/O)
- an ``add_subparser(subparsers)`` helper here that wires CLI args to a
handler invoking the lib
The main entry point dispatches to the requested subcommand:
uv run python -m bench.cli context-scaling --hosts s4 \\
--model mlx-community/Qwen3-30B-A3B-4bit
"""
+56
View File
@@ -0,0 +1,56 @@
"""``python -m bench.cli`` — dispatcher for benchmark subcommands.
To add a new benchmark:
1. Implement the methodology in ``bench.lib.<name>`` exposing a typed
``run(session, params, bundle)`` callable (no argparse, no eco I/O).
2. Implement a ``bench.cli.<name>`` module with an ``add_subparser`` and
a ``run(args) -> Path`` handler.
3. Add an ``import + add_subparser(subparsers)`` line below.
"""
from __future__ import annotations
import argparse
import sys
from collections.abc import Callable
from pathlib import Path
from typing import cast
from bench.cli import campaign, context_scaling, plot
from bench.cli._common import expand_config_in_argv
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="python -m bench.cli",
description=(
"Composable, eco-managed benchmarks for exo. "
"Pick a subcommand and pass its model / cluster options."
),
)
subparsers = parser.add_subparsers(
dest="subcommand",
required=True,
metavar="SUBCOMMAND",
)
context_scaling.add_subparser(subparsers)
plot.add_subparser(subparsers)
campaign.add_subparser(subparsers)
return parser
def main(argv: list[str] | None = None) -> int:
raw_argv = list(argv if argv is not None else sys.argv[1:])
expanded = expand_config_in_argv(raw_argv)
args = _build_parser().parse_args(expanded)
handler = getattr(args, "handler", None)
if not callable(handler):
subcommand = getattr(args, "subcommand", "<unknown>")
raise SystemExit(f"subcommand {subcommand!r} did not register a handler")
cast("Callable[[argparse.Namespace], Path]", handler)(args)
return 0
if __name__ == "__main__":
sys.exit(main())
+345
View File
@@ -0,0 +1,345 @@
"""Shared CLI argument parsing for the bench command-line interface.
Every benchmark subcommand inherits the same model / cluster / output
arguments via :func:`add_shared_args` and consumes them through
:class:`SharedOptions`. argparse's ``Namespace.<attr>`` is fundamentally
typed ``Any``; the :func:`get_arg` / :func:`get_arg_optional` helpers are
the single boundary where we coerce to typed values.
A ``--config <path>.toml`` flag lets the caller capture a run definition
in a TOML file. :func:`expand_config_in_argv` rewrites argv in place,
substituting the config's keys as CLI flags placed *before* any explicit
user args so that explicit CLI flags always win.
"""
from __future__ import annotations
import argparse
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, TypeVar
from exo_tools.cluster import Chip, Thunderbolt
from exo_tools.harness import Comm, Sharding
_T = TypeVar("_T")
def get_arg(args: argparse.Namespace, name: str, type_: type[_T]) -> _T:
"""Return ``args.<name>``, asserting it's an instance of ``type_``.
For ``int`` and ``float`` we additionally accept inputs that ``int(.)`` /
``float(.)`` would parse, since argparse's ``type=int`` already coerces
cleanly on input but post-`set_defaults` callers may pass raw values.
"""
raw: Any = getattr(args, name) # type: ignore[reportAny]
if isinstance(raw, type_):
return raw
if type_ is int and isinstance(raw, (int, str)):
return int(raw) # type: ignore[return-value]
if type_ is float and isinstance(raw, (int, float, str)):
return float(raw) # type: ignore[return-value]
raise TypeError(
f"argparse field {name!r} expected {type_.__name__}, got {type(raw).__name__}" # type: ignore[reportUnknownArgumentType]
)
def get_arg_optional(args: argparse.Namespace, name: str, type_: type[_T]) -> _T | None:
"""Like :func:`get_arg` but allows the field to be missing or None."""
raw = getattr(args, name, None)
if raw is None:
return None
if isinstance(raw, type_):
return raw
if type_ is int and isinstance(raw, (int, str)):
return int(raw) # type: ignore[return-value]
if type_ is float and isinstance(raw, (int, float, str)):
return float(raw) # type: ignore[return-value]
raise TypeError(
f"argparse field {name!r} expected {type_.__name__} or None, "
f"got {type(raw).__name__}" # type: ignore[reportUnknownArgumentType]
)
@dataclass(frozen=True)
class SharedOptions:
"""Parsed shared CLI options for any benchmark."""
model: str
hosts: tuple[str, ...]
nodes: int
thunderbolt: Thunderbolt | None
chip: Chip | None
min_memory_gb: float | None
max_memory_gb: float | None
min_disk_gb: float | None
max_disk_gb: float | None
evict_downloads: bool
sharding: Sharding
comm: Comm
min_nodes: int
output_dir: Path
tags: dict[str, str]
cleanup_instance: bool
user_prefix: str
@classmethod
def from_namespace(cls, args: argparse.Namespace) -> SharedOptions:
hosts_raw = get_arg_optional(args, "hosts", str)
thunderbolt_raw = get_arg_optional(args, "thunderbolt", str)
chip_raw = get_arg_optional(args, "chip", str)
tag_list_raw: object = getattr(args, "tag", None) or []
if isinstance(tag_list_raw, list):
tag_list: list[str] = [
str(t) # type: ignore[reportUnknownArgumentType]
for t in tag_list_raw # type: ignore[reportUnknownVariableType]
]
else:
tag_list = []
return cls(
model=get_arg(args, "model", str),
hosts=tuple(_parse_csv(hosts_raw)) if hosts_raw else (),
nodes=get_arg(args, "nodes", int),
thunderbolt=Thunderbolt(thunderbolt_raw) if thunderbolt_raw else None,
chip=Chip(chip_raw) if chip_raw else None,
min_memory_gb=get_arg_optional(args, "min_memory_gb", float),
max_memory_gb=get_arg_optional(args, "max_memory_gb", float),
min_disk_gb=get_arg_optional(args, "min_disk_gb", float),
max_disk_gb=get_arg_optional(args, "max_disk_gb", float),
evict_downloads=get_arg(args, "evict_downloads", bool),
sharding=Sharding(get_arg(args, "sharding", str)),
comm=Comm(get_arg(args, "comm", str)),
min_nodes=get_arg(args, "min_nodes", int),
output_dir=Path(get_arg(args, "output_dir", str)),
tags=_parse_tags(tag_list),
cleanup_instance=get_arg(args, "cleanup_instance", bool),
user_prefix=get_arg(args, "eco_user_prefix", str),
)
def add_shared_args(parser: argparse.ArgumentParser) -> None:
"""Register the shared-arg group on ``parser``.
The bool flags (``--auto-constrain``, ``--evict-downloads``,
``--cleanup-instance``) all default to True and use
:class:`argparse.BooleanOptionalAction` so callers opt out via the
``--no-X`` form (or set ``X = false`` in a TOML config).
"""
g_config = parser.add_argument_group("config file")
g_config.add_argument(
"--config",
default=None,
help="TOML file with run parameters. CLI flags placed after --config "
"override values from the file.",
)
g_model = parser.add_argument_group("model")
g_model.add_argument(
"--model",
required=True,
help="HuggingFace model id. To run multiple models in one go, use "
"the 'campaign' subcommand with a TOML file listing each as a "
"separate [[runs]] entry.",
)
g_model.add_argument(
"--sharding",
default=Sharding.TENSOR.value,
choices=[s.value for s in Sharding],
help="Sharding mode for the placed instance. Default 'Tensor' (splits "
"layers within nodes; pairs with --comm MlxJaccl for high throughput "
"on TB-connected clusters). Use 'Pipeline' for layer-per-node sharding "
"(typical for single-node smoke tests).",
)
g_model.add_argument(
"--comm",
default=Comm.JACCL.value,
choices=[c.value for c in Comm],
help="Inter-node communication mode. Default 'MlxJaccl' (RDMA over "
"Thunderbolt; pairs with --sharding Tensor and --thunderbolt a2a). "
"Use 'MlxRing' for ring all-reduce over the regular network.",
)
g_model.add_argument("--min-nodes", type=int, default=1)
g_cluster = parser.add_argument_group("cluster")
g_cluster.add_argument(
"--hosts",
default=None,
help="Comma-separated host list (e.g. s4,s9). Bypasses constraint search.",
)
g_cluster.add_argument(
"--nodes",
type=int,
default=1,
help="Number of cluster nodes (hosts) to deploy on. "
"Distinct from --min-nodes which controls the model's instance placement.",
)
g_cluster.add_argument(
"--thunderbolt",
default=Thunderbolt.A2A.value,
choices=[t.value for t in Thunderbolt],
help="Thunderbolt topology required: 'a2a' (clique, default; needed "
"for tensor parallelism + JACCL), 'ring' (cycle; for pipeline + JACCL), "
"or 'none' (exclude TB-connected hosts; pair with --sharding Pipeline "
"--comm MlxRing).",
)
g_cluster.add_argument(
"--chip",
default=None,
choices=[c.value for c in Chip],
help="Chip required (e.g. 'M3 Ultra')",
)
g_cluster.add_argument(
"--min-memory-gb",
type=float,
default=None,
help="Min RAM (GB) on each host. If unset, auto-derived from the HF "
"model size (×1.30 + 1 GiB).",
)
g_cluster.add_argument(
"--max-memory-gb",
type=float,
default=None,
help="Max RAM (GB) on each host. Useful to leave bigger machines "
"free for other workloads.",
)
g_cluster.add_argument(
"--min-disk-gb",
type=float,
default=None,
help="Min free disk (GB) on each host. If unset, auto-derived from "
"the HF model size (×1.10 + 1 GiB).",
)
g_cluster.add_argument(
"--max-disk-gb",
type=float,
default=None,
help="Max disk (GB) on each host.",
)
g_runtime = parser.add_argument_group("runtime")
g_runtime.add_argument(
"--evict-downloads",
action=argparse.BooleanOptionalAction,
default=True,
help="Auto-evict existing models (smallest first) when disk is short to "
"make room for the bench model. Default on; pass --no-evict-downloads "
"to keep existing downloads.",
)
g_runtime.add_argument(
"--cleanup-instance",
action=argparse.BooleanOptionalAction,
default=True,
help="Clean up the placed instance after the benchmark exits. "
"Default on; pass --no-cleanup-instance to leave it running for debugging.",
)
g_runtime.add_argument(
"--eco-user-prefix",
default="bench",
help="USER prefix for the eco session (default: 'bench').",
)
g_output = parser.add_argument_group("output")
g_output.add_argument(
"--output-dir",
default="bench/results",
help="Base directory for JSON results. Subcommands may add a sub-folder.",
)
g_output.add_argument(
"--tag",
action="append",
default=[],
help="Add a 'key=value' tag to metadata.tags (repeatable).",
)
# ---------------------------------------------------------------------------
# TOML config expansion
# ---------------------------------------------------------------------------
def expand_config_in_argv(argv: list[str]) -> list[str]:
"""If ``--config <path>`` appears in ``argv``, splice the TOML's contents in.
The TOML file's keys are converted to CLI flags (``foo_bar`` →
``--foo-bar``) and inserted *before* the user's other args, so explicit
CLI flags always override the config. The ``--config <path>`` pair
itself is removed from argv. The first arg (the subcommand name) is
preserved at index 0.
Special handling:
- ``[tags]`` table → repeated ``--tag key=value`` occurrences
- lists → joined as a comma-separated value (matches the parser's
CSV handling for ``--hosts``)
- bool true/false → ``--key`` / ``--no-key`` (assumes the underlying
flag uses :class:`argparse.BooleanOptionalAction`)
"""
if "--config" not in argv:
return list(argv)
idx = argv.index("--config")
if idx + 1 >= len(argv):
raise ValueError("--config requires a path argument")
config_path = Path(argv[idx + 1])
if not config_path.is_file():
raise FileNotFoundError(f"Config file not found: {config_path}")
with config_path.open("rb") as f:
config_data: dict[str, Any] = tomllib.load(f)
expanded = _config_to_argv(config_data)
stripped = list(argv[:idx]) + list(argv[idx + 2 :])
if not stripped:
return expanded
# The subcommand name must come first; insert config-derived args
# right after it so that the user's later explicit args override.
return [stripped[0]] + expanded + stripped[1:]
def _config_to_argv(data: dict[str, Any]) -> list[str]:
"""Convert a TOML-loaded dict to a list of argv-style CLI flags."""
out: list[str] = []
for key in data:
value: Any = data[key] # type: ignore[reportAny]
if key == "tags" and isinstance(value, dict):
for tag_key, tag_value in value.items(): # type: ignore[reportUnknownVariableType]
out.extend(["--tag", f"{tag_key}={tag_value}"])
continue
if value is None:
continue
flag = "--" + key.replace("_", "-")
if isinstance(value, bool):
out.append(flag if value else f"--no-{key.replace('_', '-')}")
elif isinstance(value, list):
joined = ",".join(
str(x) # type: ignore[reportUnknownArgumentType]
for x in value # type: ignore[reportUnknownVariableType]
)
out.extend([flag, joined])
else:
out.extend([flag, str(value)]) # type: ignore[reportAny]
return out
def _parse_csv(raw: str) -> list[str]:
return [s.strip() for s in raw.split(",") if s.strip()]
def _parse_tags(raw: list[str]) -> dict[str, str]:
out: dict[str, str] = {}
for entry in raw:
if "=" not in entry:
raise argparse.ArgumentTypeError(
f"--tag must be 'key=value', got {entry!r}"
)
k, v = entry.split("=", 1)
out[k.strip()] = v.strip()
return out
@dataclass
class CommandResult:
"""Return value from a benchmark CLI handler."""
output_path: Path | None = None
extra: dict[str, str] = field(default_factory=dict)
+220
View File
@@ -0,0 +1,220 @@
"""Run a campaign of bench invocations from a single TOML file.
A campaign config has a ``[defaults]`` table (applied to every run) and a
list of ``[[runs]]`` entries (each a fully-formed invocation with its own
``subcommand``). The campaign runner merges defaults with each run's
overrides, dispatches to the matching subcommand handler, and collects
the output JSON paths.
Each run gets its own cluster — the deploy / teardown happens per-run.
After all runs finish, an optional ``[plot]`` table triggers a comparison
plot per benchmark group.
Schema::
[defaults]
nodes = 4
num_steps = 8
[[runs]]
subcommand = "context-scaling"
model = "mlx-community/Llama-3.2-3B-Instruct-4bit"
[runs.tags]
model_short = "llama-3.2-3b"
[[runs]]
subcommand = "context-scaling"
model = "mlx-community/Meta-Llama-3.1-8B-Instruct-4bit"
[runs.tags]
model_short = "llama-3.1-8b"
[plot]
label_tag = "model_short"
"""
from __future__ import annotations
import argparse
import tomllib
from collections.abc import Callable
from pathlib import Path
from typing import Any, cast
from loguru import logger
from bench.cli import context_scaling
from bench.cli._common import (
_config_to_argv, # type: ignore[reportPrivateUsage]
get_arg,
)
from bench.lib.plotting import PlotInputs, render_context_scaling
# Each subcommand exposes its argparse via add_subparser. The campaign
# runner builds a one-off parser per run with only the chosen subcommand
# registered, parses the run-derived argv, and invokes the handler.
_SUBCOMMAND_PARSERS: dict[
str,
Callable[
[Any], None
], # subparsers action — argparse private; Any-typed at boundary
] = {
"context-scaling": context_scaling.add_subparser,
}
def add_subparser(
subparsers: argparse._SubParsersAction[argparse.ArgumentParser], # type: ignore[type-arg]
) -> None:
parser = subparsers.add_parser(
"campaign",
help="Run a list of bench invocations from a single TOML config.",
description=__doc__,
)
parser.add_argument(
"config",
type=str,
help="TOML campaign file (with [defaults] + [[runs]] tables).",
)
parser.add_argument(
"--no-plot",
action="store_true",
help="Skip the optional comparison plot at the end of the campaign.",
)
parser.set_defaults(handler=run)
# ---------------------------------------------------------------------------
def run(args: argparse.Namespace) -> Path | None:
config_path = Path(get_arg(args, "config", str))
if not config_path.is_file():
raise SystemExit(f"campaign: file not found: {config_path}")
with config_path.open("rb") as f:
raw = tomllib.load(f)
defaults = _table(raw, "defaults")
runs_obj = raw.get("runs")
if not isinstance(runs_obj, list) or not runs_obj:
raise SystemExit(f"campaign: {config_path}: missing or empty [[runs]] list")
runs_raw: list[Any] = cast("list[Any]", runs_obj)
plot_cfg = _table(raw, "plot")
n_runs = len(runs_raw)
output_paths: dict[str, list[Path]] = {}
for i, run_obj in enumerate(runs_raw): # type: ignore[reportAny]
if not isinstance(run_obj, dict):
raise SystemExit(
f"campaign: run #{i + 1}: expected a TOML table, "
f"got {type(run_obj).__name__}" # type: ignore[reportUnknownArgumentType]
)
run_cfg = cast("dict[str, Any]", run_obj)
merged = _merge(defaults, run_cfg)
subcommand_obj: Any = merged.pop("subcommand", None) # type: ignore[reportAny]
if not isinstance(subcommand_obj, str):
raise SystemExit(
f"campaign: run #{i + 1}: 'subcommand' field is required (str)"
)
if subcommand_obj not in _SUBCOMMAND_PARSERS:
raise SystemExit(
f"campaign: run #{i + 1}: unknown subcommand "
f"{subcommand_obj!r} (have {sorted(_SUBCOMMAND_PARSERS)})"
)
argv_for_run = _config_to_argv(merged)
sub_args = _parse_for_subcommand(subcommand_obj, argv_for_run)
handler = getattr(sub_args, "handler", None)
if not callable(handler):
raise SystemExit(f"campaign: subcommand {subcommand_obj!r} has no handler")
logger.info(
f"campaign: starting run {i + 1}/{n_runs} "
f"({subcommand_obj}; {len(merged)} flags)"
)
out = cast("Callable[[argparse.Namespace], Path]", handler)(sub_args)
output_paths.setdefault(subcommand_obj, []).append(Path(out))
logger.info(f"campaign: finished run {i + 1}/{n_runs}{out}")
last_path: Path | None = None
for paths in output_paths.values():
if paths:
last_path = paths[-1]
if get_arg(args, "no_plot", bool):
return last_path
comparison = _render_comparisons(output_paths, plot_cfg)
return comparison or last_path
# ---------------------------------------------------------------------------
def _table(data: dict[str, Any], key: str) -> dict[str, Any]:
"""Return ``data[key]`` if it's a table, else an empty dict."""
val: Any = data.get(key)
return cast("dict[str, Any]", val) if isinstance(val, dict) else {}
def _merge(defaults: dict[str, Any], run: dict[str, Any]) -> dict[str, Any]:
"""Shallow-merge ``defaults`` with ``run``; ``run`` wins on conflict.
The ``tags`` table is deep-merged (defaults' tags + run's tags) so a
campaign-level operator tag and a per-run model_short tag both survive.
"""
merged: dict[str, Any] = {**defaults, **run}
default_tags = _table(defaults, "tags")
run_tags = _table(run, "tags")
if default_tags or run_tags:
merged["tags"] = {**default_tags, **run_tags}
return merged
def _parse_for_subcommand(
subcommand: str, argv_for_run: list[str]
) -> argparse.Namespace:
"""Build a one-off parser with ``subcommand`` registered + parse argv."""
parser = argparse.ArgumentParser(prog=f"bench campaign:{subcommand}")
subparsers = parser.add_subparsers(dest="subcommand", required=True)
_SUBCOMMAND_PARSERS[subcommand](subparsers)
return parser.parse_args([subcommand] + argv_for_run)
def _render_comparisons(
output_paths: dict[str, list[Path]],
plot_cfg: dict[str, Any],
) -> Path | None:
"""Render one comparison plot per benchmark group with ≥2 outputs."""
label_tag = _str_or_none(plot_cfg.get("label_tag"))
title = _str_or_none(plot_cfg.get("title"))
last: Path | None = None
for subcommand, paths in output_paths.items():
if len(paths) < 2:
continue
if subcommand != "context-scaling":
logger.warning(
f"campaign: no comparison renderer registered for {subcommand!r}; "
"skipping comparison plot"
)
continue
out = paths[0].with_name(f"campaign_{subcommand}_compare.png")
last = render_context_scaling(
PlotInputs(
results=paths,
output=out,
label_tag=label_tag,
title=title,
)
)
logger.info(f"campaign: wrote comparison plot {last}")
return last
def _str_or_none(value: Any) -> str | None: # type: ignore[reportAny]
return value if isinstance(value, str) else None
__all__ = ["add_subparser", "run"]
+270
View File
@@ -0,0 +1,270 @@
"""Context-scaling benchmark — CLI subcommand.
Wraps :func:`bench.lib.context_scaling.run` with:
- HF model-metadata resolution
- Auto-derived constraints (memory, disk) and context ramp (Δ, K)
- eco cluster + instance lifecycle (managed_cluster + managed_instance)
- Cold-control isolation (delete sweep instance before controls)
- JSON results + ``latest.json`` symlink under ``<output-dir>/context_scaling/``
"""
from __future__ import annotations
import argparse
import os
from pathlib import Path
from exo_tools.cluster import EcoSession
from loguru import logger
from bench.cli._common import (
SharedOptions,
add_shared_args,
get_arg,
get_arg_optional,
)
from bench.lib import context_scaling
from bench.lib.cluster import managed_cluster, managed_instance
from bench.lib.context_scaling import (
ContextScalingParams,
make_cold_control_factory,
)
from bench.lib.model_meta import (
ModelMeta,
derive_cold_controls,
derive_context_ramp,
fetch_model_meta,
)
from bench.lib.results import ResultsBundle, RunMetadata, find_repo_root
def add_subparser(
subparsers: argparse._SubParsersAction[argparse.ArgumentParser], # type: ignore[type-arg]
) -> None:
parser = subparsers.add_parser(
"context-scaling",
help="Prompt-TPS / decode-TPS vs context-size sweep",
description=__doc__,
)
add_shared_args(parser)
g = parser.add_argument_group("context-scaling")
g.add_argument(
"--num-steps",
type=int,
default=32,
help="Number of equally-spaced PP points in the ramp (K).",
)
g.add_argument(
"--pp-step",
type=int,
default=None,
help="Δ (token step). If unset, derived from the model's max context.",
)
g.add_argument(
"--fraction-of-max",
type=float,
default=1.0,
help="When Δ is auto-derived, use this fraction of the model's "
"max_position_embeddings as the ramp's upper bound (0 < f ≤ 1).",
)
g.add_argument(
"--tg",
type=int,
default=64,
help="Tokens to generate per step (decode duration; constant across ramp).",
)
g.add_argument(
"--warmup",
type=int,
default=2,
help="Warmup requests at pp=Δ before the measured ramp. "
"First warmup is cache-disabled (kernel JIT only); subsequent "
"warmups are cache-enabled (the second is the one that primes "
"the cache entry with a hot-kernel rate). Default 2 is the "
"sweet spot: warmup=0 leaves JIT cost in step 0; warmup=1 has "
"step 0 as a 'none' hit (still hot-kernel cold prefill, just "
"classified differently).",
)
g.add_argument(
"--cold-controls",
type=str,
default=None,
help="Cold-control pp values to take after the cached sweep. Either "
"'auto' (4 evenly-spaced points across the ramp) or a comma-separated "
"list of explicit pp values (e.g. '8192,32768,65536'). "
"Default: no cold controls.",
)
g.add_argument(
"--sleep-between-s",
type=float,
default=1.0,
help="Seconds to sleep between consecutive sweep requests.",
)
parser.set_defaults(handler=run)
# ---------------------------------------------------------------------------
def run(args: argparse.Namespace) -> Path:
"""Execute the context-scaling benchmark per the parsed args.
Returns the path of the JSON results file.
"""
shared = SharedOptions.from_namespace(args)
repo_root = find_repo_root()
# 1. Fetch HF metadata up-front; everything else can be derived from it.
logger.info(f"fetching HuggingFace metadata for {shared.model}")
meta = fetch_model_meta(shared.model)
logger.info(
f" weights: {meta.total_weight_gb:.1f}GB; "
f"max context: {meta.max_position_embeddings} tokens; "
f"layers: {meta.num_hidden_layers}"
)
# 2. Derive constraints (user values always win; otherwise fall back to
# ModelMeta heuristics for the *minimums*).
min_memory_gb = (
shared.min_memory_gb
if shared.min_memory_gb is not None
else meta.memory_constraint_gb
)
min_disk_gb = (
shared.min_disk_gb
if shared.min_disk_gb is not None
else meta.disk_constraint_gb
)
logger.info(f" cluster constraint: min memory {min_memory_gb:.1f}GB")
logger.info(f" cluster constraint: min disk {min_disk_gb:.1f}GB")
if shared.max_memory_gb is not None:
logger.info(f" cluster constraint: max memory {shared.max_memory_gb:.1f}GB")
if shared.max_disk_gb is not None:
logger.info(f" cluster constraint: max disk {shared.max_disk_gb:.1f}GB")
explicit_pp_step = get_arg_optional(args, "pp_step", int)
num_steps = get_arg(args, "num_steps", int)
if explicit_pp_step is not None:
pp_step = explicit_pp_step
else:
pp_step, num_steps = derive_context_ramp(
meta,
num_steps=num_steps,
fraction_of_max=get_arg(args, "fraction_of_max", float),
)
logger.info(
f" derived ramp: Δ={pp_step} × K={num_steps} "
f"= {pp_step * num_steps} tokens (max {meta.max_position_embeddings})"
)
cold_controls = _resolve_cold_controls(
args, meta, pp_step=pp_step, num_steps=num_steps
)
if cold_controls:
logger.info(f" cold controls: {list(cold_controls)}")
# 3. Spin up cluster + instance + run.
eco = EcoSession(user_prefix=shared.user_prefix)
output_dir = (shared.output_dir / "context_scaling").resolve()
metadata = RunMetadata.new(
benchmark="context_scaling",
repo_root=repo_root,
tags={**shared.tags, "host_pool": ",".join(shared.hosts) or "<auto>"},
)
bundle = ResultsBundle(metadata=metadata)
with (
managed_cluster(
eco,
hosts=list(shared.hosts) or None,
count=shared.nodes,
thunderbolt=shared.thunderbolt,
chip=shared.chip,
min_memory_gb=min_memory_gb,
max_memory_gb=shared.max_memory_gb,
min_disk_gb=min_disk_gb,
max_disk_gb=shared.max_disk_gb,
) as cluster,
managed_instance(
cluster,
eco,
shared.model,
sharding=shared.sharding,
comm=shared.comm,
min_nodes=shared.min_nodes,
evict_downloads=shared.evict_downloads,
cleanup_on_exit=shared.cleanup_instance,
) as session,
):
params = ContextScalingParams(
pp_step=pp_step,
num_steps=num_steps,
tg=get_arg(args, "tg", int),
warmup=get_arg(args, "warmup", int),
cold_controls=cold_controls,
sleep_between_s=get_arg(args, "sleep_between_s", float),
)
factory = (
make_cold_control_factory(
session, shared.sharding, shared.comm, shared.min_nodes
)
if cold_controls
else None
)
context_scaling.run(session, params, bundle, cold_control_factory=factory)
out_path = bundle.write_json(output_dir)
_update_latest_symlink(out_path)
logger.info(f"wrote results → {out_path}")
_validate_partial_hits(bundle)
return out_path
# ---------------------------------------------------------------------------
def _resolve_cold_controls(
args: argparse.Namespace,
meta: ModelMeta,
*,
pp_step: int,
num_steps: int,
) -> tuple[int, ...]:
raw = get_arg_optional(args, "cold_controls", str)
if raw is None or not raw.strip():
return ()
if raw.strip().lower() == "auto":
return derive_cold_controls(meta, pp_step=pp_step, num_steps=num_steps, count=4)
return tuple(int(s.strip()) for s in raw.split(",") if s.strip())
def _update_latest_symlink(out_path: Path) -> None:
"""Update ``<dir>/latest.json`` to point at the newly-written file."""
link = out_path.parent / "latest.json"
try:
if link.is_symlink() or link.exists():
link.unlink()
os.symlink(out_path.name, link)
except OSError as e:
logger.warning(f"could not update latest.json symlink: {e}")
def _validate_partial_hits(bundle: ResultsBundle) -> None:
"""Hard-fail if the cached sweep didn't see ``partial`` on every step ≥ 1.
Step 0 is allowed to be ``exact`` (warmup primed the cache at pp=Δ); a
later ``exact`` means Δ was effectively absorbed into the cache and the
cold-rate measurement is meaningless. ``none`` means the cache was
discarded mid-sweep and ``T_cum`` is unreliable.
"""
cached = [r for r in bundle.runs if r.get("phase") == "cached_sweep"]
bad = [r for r in cached[1:] if r.get("prefix_cache_hit") != "partial"]
if bad:
bad_summary = [(r["step_index"], r["prefix_cache_hit"]) for r in bad]
raise RuntimeError(
f"{len(bad)} cached-sweep step(s) reported "
f"prefix_cache_hit != 'partial': {bad_summary!r}; "
"T_cum is unreliable."
)
+125
View File
@@ -0,0 +1,125 @@
"""Plot benchmark results — CLI subcommand.
uv run python -m bench.cli plot bench/results/context_scaling/latest.json
uv run python -m bench.cli plot run_a.json run_b.json --label-tag operator
uv run python -m bench.cli plot latest.json --output /tmp/scaling.png
The benchmark type is detected from each JSON's ``metadata.benchmark`` —
all input files must share the same benchmark.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any, cast
from loguru import logger
from bench.cli._common import get_arg_optional
from bench.lib.plotting import PlotInputs, render_context_scaling
def add_subparser(
subparsers: argparse._SubParsersAction[argparse.ArgumentParser], # type: ignore[type-arg]
) -> None:
parser = subparsers.add_parser(
"plot",
help="Render benchmark JSON result(s) as a PNG.",
description=__doc__,
)
parser.add_argument(
"paths",
nargs="+",
type=str,
help="One or more bench results JSON files. Multiple files are "
"rendered as a comparison plot (one line per file).",
)
parser.add_argument(
"--output",
type=str,
default=None,
help="PNG output path. Default: replace the first JSON's '.json' "
"suffix with '.png' (or '.compare.png' when multiple inputs).",
)
parser.add_argument(
"--label-tag",
type=str,
default=None,
help="Use metadata.tags[<KEY>] as the legend label for each run "
"(falls back to run_id if unset or missing).",
)
parser.add_argument(
"--title",
type=str,
default=None,
help="Override the auto-generated figure title.",
)
parser.set_defaults(handler=run)
def run(args: argparse.Namespace) -> Path:
paths_raw = getattr(args, "paths", None)
if not isinstance(paths_raw, list) or not paths_raw:
raise SystemExit("plot: at least one JSON path is required")
paths = [
Path(str(p)) # type: ignore[reportUnknownArgumentType]
for p in cast("list[Any]", paths_raw) # type: ignore[reportAny]
]
for p in paths:
if not p.is_file():
raise SystemExit(f"plot: file not found: {p}")
benchmarks = {_benchmark_for(p) for p in paths}
if len(benchmarks) != 1:
raise SystemExit(
f"plot: all input JSONs must share the same benchmark, got {benchmarks!r}"
)
benchmark = next(iter(benchmarks))
output_arg = get_arg_optional(args, "output", str)
output = Path(output_arg) if output_arg is not None else _default_output(paths)
inputs = PlotInputs(
results=paths,
output=output,
label_tag=get_arg_optional(args, "label_tag", str),
title=get_arg_optional(args, "title", str),
)
if benchmark == "context_scaling":
out_path = render_context_scaling(inputs)
else:
raise SystemExit(f"plot: no renderer registered for benchmark {benchmark!r}")
logger.info(f"plot: wrote {out_path}")
return out_path
# ---------------------------------------------------------------------------
def _benchmark_for(path: Path) -> str:
with path.open() as f:
loaded: Any = json.load(f) # type: ignore[reportAny]
if not isinstance(loaded, dict):
raise SystemExit(f"plot: {path}: expected top-level JSON object")
metadata: Any = loaded.get("metadata", {}) # type: ignore[reportAny]
if not isinstance(metadata, dict):
raise SystemExit(f"plot: {path}: metadata is not an object")
benchmark: Any = metadata.get("benchmark") # type: ignore[reportAny]
if not isinstance(benchmark, str):
raise SystemExit(f"plot: {path}: metadata.benchmark missing or not a string")
return benchmark
def _default_output(paths: list[Path]) -> Path:
"""Auto-derive a PNG path next to the first JSON.
Single input → ``<path>.png`` (replaces ``.json``).
Multiple inputs → ``<path>.compare.png`` next to the first JSON.
"""
first = paths[0]
if len(paths) == 1:
return first.with_suffix(".png")
return first.with_name(first.stem + ".compare.png")
File renamed without changes.
+109
View File
@@ -0,0 +1,109 @@
"""Unit tests for ``bench.cli.campaign``.
The pure helpers (defaults+run merge, table-lookup, str-or-none) are
tested here. End-to-end campaign execution requires a real eco cluster
and is exercised manually via ``bench campaign <toml>``.
"""
from __future__ import annotations
from bench.cli.campaign import (
_merge, # type: ignore[reportPrivateUsage]
_str_or_none, # type: ignore[reportPrivateUsage]
_table, # type: ignore[reportPrivateUsage]
)
# ---------------------------------------------------------------------------
# _table
# ---------------------------------------------------------------------------
class TestTable:
def test_present_table(self) -> None:
data = {"defaults": {"nodes": 4}}
assert _table(data, "defaults") == {"nodes": 4}
def test_missing_key_returns_empty(self) -> None:
assert _table({}, "absent") == {}
def test_non_table_value_returns_empty(self) -> None:
# `nodes = 4` is an int at top level, not a table; treat as empty.
assert _table({"nodes": 4}, "nodes") == {}
def test_list_value_returns_empty(self) -> None:
assert _table({"runs": [{"a": 1}]}, "runs") == {}
# ---------------------------------------------------------------------------
# _merge
# ---------------------------------------------------------------------------
class TestMerge:
def test_run_wins_on_conflict(self) -> None:
defaults = {"nodes": 4, "tg": 64}
run = {"nodes": 2}
assert _merge(defaults, run) == {"nodes": 2, "tg": 64}
def test_disjoint_keys(self) -> None:
defaults = {"nodes": 4}
run = {"model": "test/foo"}
assert _merge(defaults, run) == {"nodes": 4, "model": "test/foo"}
def test_run_only(self) -> None:
assert _merge({}, {"a": 1, "b": 2}) == {"a": 1, "b": 2}
def test_defaults_only(self) -> None:
assert _merge({"a": 1}, {}) == {"a": 1}
def test_tags_deep_merged_defaults_only(self) -> None:
defaults = {"tags": {"operator": "ciaranbor"}}
run = {"model": "test/foo"}
merged = _merge(defaults, run)
assert merged["tags"] == {"operator": "ciaranbor"}
def test_tags_deep_merged_run_only(self) -> None:
defaults = {"nodes": 4}
run = {"tags": {"model_short": "llama-3b"}}
merged = _merge(defaults, run)
assert merged["tags"] == {"model_short": "llama-3b"}
def test_tags_deep_merged_both(self) -> None:
defaults = {"tags": {"operator": "ciaranbor", "campaign": "smoke"}}
run = {"tags": {"model_short": "llama-3b"}}
merged = _merge(defaults, run)
assert merged["tags"] == {
"operator": "ciaranbor",
"campaign": "smoke",
"model_short": "llama-3b",
}
def test_run_tags_override_defaults_tags(self) -> None:
defaults = {"tags": {"operator": "ciaranbor"}}
run = {"tags": {"operator": "alice"}}
merged = _merge(defaults, run)
assert merged["tags"] == {"operator": "alice"}
def test_no_tags_table_means_no_tags_key(self) -> None:
# When neither side has tags, we don't synthesise an empty dict.
merged = _merge({"nodes": 4}, {"model": "test/foo"})
assert "tags" not in merged
# ---------------------------------------------------------------------------
# _str_or_none
# ---------------------------------------------------------------------------
class TestStrOrNone:
def test_str_passes_through(self) -> None:
assert _str_or_none("hello") == "hello"
def test_none_returns_none(self) -> None:
assert _str_or_none(None) is None
def test_int_returns_none(self) -> None:
assert _str_or_none(42) is None
def test_list_returns_none(self) -> None:
assert _str_or_none(["a", "b"]) is None
+275
View File
@@ -0,0 +1,275 @@
"""Unit tests for the argparse boundary helpers in ``bench.cli._common``."""
from __future__ import annotations
import argparse
from pathlib import Path
import pytest
from bench.cli._common import (
_config_to_argv, # type: ignore[reportPrivateUsage]
_parse_csv, # type: ignore[reportPrivateUsage]
_parse_tags, # type: ignore[reportPrivateUsage]
expand_config_in_argv,
get_arg,
get_arg_optional,
)
# ---------------------------------------------------------------------------
# _parse_csv
# ---------------------------------------------------------------------------
class TestParseCsv:
def test_simple_list(self) -> None:
assert _parse_csv("a,b,c") == ["a", "b", "c"]
def test_strips_whitespace(self) -> None:
assert _parse_csv(" a , b , c ") == ["a", "b", "c"]
def test_skips_empty_entries(self) -> None:
assert _parse_csv("a,,b,") == ["a", "b"]
assert _parse_csv(",,,") == []
def test_empty_string_returns_empty(self) -> None:
assert _parse_csv("") == []
def test_single_value(self) -> None:
assert _parse_csv("only") == ["only"]
# ---------------------------------------------------------------------------
# _parse_tags
# ---------------------------------------------------------------------------
class TestParseTags:
def test_empty_input_returns_empty_dict(self) -> None:
assert _parse_tags([]) == {}
def test_single_tag(self) -> None:
assert _parse_tags(["operator=ciaranbor"]) == {"operator": "ciaranbor"}
def test_multiple_tags(self) -> None:
assert _parse_tags(["a=1", "b=2", "c=3"]) == {"a": "1", "b": "2", "c": "3"}
def test_strips_whitespace_around_key_and_value(self) -> None:
assert _parse_tags([" key = value "]) == {"key": "value"}
def test_value_can_contain_equals(self) -> None:
assert _parse_tags(["url=http://example.com/?a=b"]) == {
"url": "http://example.com/?a=b"
}
def test_later_duplicate_key_wins(self) -> None:
# Standard dict behaviour; explicit so we notice if it changes.
assert _parse_tags(["k=v1", "k=v2"]) == {"k": "v2"}
def test_missing_equals_raises(self) -> None:
with pytest.raises(argparse.ArgumentTypeError, match="key=value"):
_ = _parse_tags(["malformed"])
def test_one_malformed_in_list_raises(self) -> None:
with pytest.raises(argparse.ArgumentTypeError):
_ = _parse_tags(["good=1", "bad", "alsogood=2"])
# ---------------------------------------------------------------------------
# get_arg / get_arg_optional
# ---------------------------------------------------------------------------
class TestGetArg:
def test_str_passes_through(self) -> None:
ns = argparse.Namespace(name="hello")
assert get_arg(ns, "name", str) == "hello"
def test_int_passes_through(self) -> None:
ns = argparse.Namespace(count=42)
assert get_arg(ns, "count", int) == 42
def test_int_coerces_from_string(self) -> None:
ns = argparse.Namespace(count="42")
assert get_arg(ns, "count", int) == 42
def test_float_passes_through(self) -> None:
ns = argparse.Namespace(rate=3.14)
assert get_arg(ns, "rate", float) == 3.14
def test_float_coerces_from_int(self) -> None:
ns = argparse.Namespace(rate=3)
assert get_arg(ns, "rate", float) == 3.0
def test_float_coerces_from_string(self) -> None:
ns = argparse.Namespace(rate="3.14")
assert get_arg(ns, "rate", float) == 3.14
def test_bool_passes_through(self) -> None:
ns = argparse.Namespace(flag=True)
assert get_arg(ns, "flag", bool) is True
def test_wrong_type_raises(self) -> None:
ns = argparse.Namespace(name=42)
with pytest.raises(TypeError, match="expected str"):
_ = get_arg(ns, "name", str)
def test_missing_attribute_raises(self) -> None:
ns = argparse.Namespace()
with pytest.raises(AttributeError):
_ = get_arg(ns, "missing", str)
class TestGetArgOptional:
def test_missing_returns_none(self) -> None:
ns = argparse.Namespace()
assert get_arg_optional(ns, "missing", str) is None
def test_explicit_none_returns_none(self) -> None:
ns = argparse.Namespace(value=None)
assert get_arg_optional(ns, "value", str) is None
def test_present_value_returns_typed(self) -> None:
ns = argparse.Namespace(value="present")
assert get_arg_optional(ns, "value", str) == "present"
def test_int_coerces_from_string(self) -> None:
ns = argparse.Namespace(value="42")
assert get_arg_optional(ns, "value", int) == 42
def test_float_coerces_from_int(self) -> None:
ns = argparse.Namespace(value=42)
assert get_arg_optional(ns, "value", float) == 42.0
def test_wrong_type_raises(self) -> None:
ns = argparse.Namespace(value=[1, 2, 3])
with pytest.raises(TypeError, match="expected str or None"):
_ = get_arg_optional(ns, "value", str)
# ---------------------------------------------------------------------------
# _config_to_argv
# ---------------------------------------------------------------------------
class TestConfigToArgv:
def test_empty(self) -> None:
assert _config_to_argv({}) == []
def test_string_value(self) -> None:
assert _config_to_argv({"model": "mlx/foo"}) == ["--model", "mlx/foo"]
def test_int_and_float_values(self) -> None:
out = _config_to_argv({"num_steps": 32, "fraction_of_max": 0.5})
assert out == ["--num-steps", "32", "--fraction-of-max", "0.5"]
def test_underscore_keys_become_hyphenated_flags(self) -> None:
out = _config_to_argv({"min_memory_gb": 21.0})
assert out == ["--min-memory-gb", "21.0"]
def test_bool_true_emits_flag(self) -> None:
assert _config_to_argv({"auto_constrain": True}) == ["--auto-constrain"]
def test_bool_false_emits_no_form(self) -> None:
assert _config_to_argv({"auto_constrain": False}) == ["--no-auto-constrain"]
def test_none_value_skipped(self) -> None:
assert _config_to_argv({"chip": None, "model": "foo"}) == [
"--model",
"foo",
]
def test_list_joined_as_csv(self) -> None:
out = _config_to_argv({"hosts": ["s4", "s9"], "cold_controls": [1024, 2048]})
assert out == [
"--hosts",
"s4,s9",
"--cold-controls",
"1024,2048",
]
def test_tags_table_expands_to_repeated_tag_args(self) -> None:
out = _config_to_argv({"tags": {"operator": "ciaranbor", "run": "full"}})
# Order within a TOML table is preserved by tomllib
assert out == [
"--tag",
"operator=ciaranbor",
"--tag",
"run=full",
]
# ---------------------------------------------------------------------------
# expand_config_in_argv
# ---------------------------------------------------------------------------
class TestExpandConfigInArgv:
def test_no_config_flag_passthrough(self) -> None:
argv = ["context-scaling", "--model", "foo"]
assert expand_config_in_argv(argv) == argv
def test_config_at_end(self, tmp_path: Path) -> None:
cfg = tmp_path / "run.toml"
_ = cfg.write_text('model = "from_config"\nnum_steps = 16\n')
argv = ["context-scaling", "--config", str(cfg)]
# Config flags are inserted right after the subcommand
assert expand_config_in_argv(argv) == [
"context-scaling",
"--model",
"from_config",
"--num-steps",
"16",
]
def test_explicit_cli_overrides_config(self, tmp_path: Path) -> None:
cfg = tmp_path / "run.toml"
_ = cfg.write_text('model = "from_config"\nnum_steps = 16\n')
# User overrides --num-steps explicitly. Argparse takes the last
# occurrence for non-append actions, so the user's 32 wins.
argv = ["context-scaling", "--config", str(cfg), "--num-steps", "32"]
out = expand_config_in_argv(argv)
assert out == [
"context-scaling",
"--model",
"from_config",
"--num-steps",
"16",
"--num-steps",
"32",
]
def test_missing_path_arg_raises(self) -> None:
with pytest.raises(ValueError, match="--config requires a path"):
_ = expand_config_in_argv(["context-scaling", "--config"])
def test_nonexistent_file_raises(self, tmp_path: Path) -> None:
with pytest.raises(FileNotFoundError, match="Config file not found"):
_ = expand_config_in_argv(
["context-scaling", "--config", str(tmp_path / "missing.toml")]
)
def test_bool_false_in_config(self, tmp_path: Path) -> None:
cfg = tmp_path / "run.toml"
_ = cfg.write_text("auto_constrain = false\n")
argv = ["context-scaling", "--config", str(cfg)]
assert expand_config_in_argv(argv) == [
"context-scaling",
"--no-auto-constrain",
]
def test_tags_table(self, tmp_path: Path) -> None:
cfg = tmp_path / "run.toml"
_ = cfg.write_text(
'model = "foo"\n[tags]\noperator = "ciaranbor"\nrun = "full"\n'
)
argv = ["context-scaling", "--config", str(cfg)]
assert expand_config_in_argv(argv) == [
"context-scaling",
"--model",
"foo",
"--tag",
"operator=ciaranbor",
"--tag",
"run=full",
]
@@ -0,0 +1,70 @@
# Example context-scaling run configuration.
#
# Use it like this:
#
# uv run python -m bench.cli context-scaling --config bench/configs/context_scaling.example.toml
#
# CLI flags placed after `--config` override individual values.
#
# All shared and subcommand-specific flags can appear here. Keys mirror the
# CLI flag names with hyphens replaced by underscores. Boolean keys map to
# `--key` / `--no-key`; lists are joined as CSV; the `[tags]` table maps to
# repeated `--tag key=value` flags.
#
# NOTE: TOML scoping — once a `[table]` header is opened, all subsequent
# top-level-looking assignments belong to that table until the next header.
# Keep tables (like `[tags]`) at the END of the file.
# ---- Model + placement ----
model = "mlx-community/Qwen3-30B-A3B-4bit"
# sharding = "Tensor" # default: "Tensor"; pairs with --comm MlxJaccl + --thunderbolt a2a
# comm = "MlxJaccl" # default: "MlxJaccl" (RDMA over Thunderbolt)
# min_nodes = 1
# ---- Cluster ----
# Either pin to specific hosts...
# hosts = ["s4"]
# ...or let eco pick hosts that satisfy the constraints below.
# nodes = 1
# chip = "M3 Ultra" # eco chip name (case-insensitive substring); comment to allow any
# thunderbolt = "a2a" # default: "a2a" (clique, for Tensor+JACCL)
# "ring" (cycle; for Pipeline+JACCL)
# "none" (exclude TB; pair with sharding=Pipeline + comm=MlxRing for non-TB hosts)
# Memory + disk minimums are auto-derived from the HF model size
# (×1.30 + 1 GiB for memory, ×1.10 + 1 GiB for disk). Set any of these
# explicitly to override the auto-derived value.
# min_memory_gb = 96.0
# max_memory_gb = 256.0 # leave bigger machines free for other workloads
# min_disk_gb = 24.0
# max_disk_gb = 4000.0
# ---- Runtime ----
# evict_downloads is true by default — frees disk smallest-first to fit
# the bench model. Set to false to keep existing downloads.
# evict_downloads = false
# cleanup_instance is true by default — deletes the placed instance on exit.
# Set to false to leave it running for debugging.
# cleanup_instance = false
# ---- Output ----
output_dir = "bench/results"
# ---- Context-scaling sweep ----
num_steps = 32 # K — number of equally-spaced ramp points
# pp_step = 1024 # Δ — explicit override; otherwise auto-derived
# fraction_of_max = 1.0 # use this fraction of max_position_embeddings
tg = 64 # tokens generated per step
# warmup = 2 # default: 2 (1 cache-disabled JIT warmup + 1 cache-priming warmup)
# cold_controls = "auto" # 4 evenly-spaced controls across the ramp, or:
# cold_controls = "8192,16384,32768,40960" # explicit pp values
sleep_between_s = 1.0
# ---- Tags ----
# Survive into metadata.tags in the output JSON; useful for filtering or
# grouping runs across SHAs / hosts / configs. `$USER` is NOT expanded
# (TOML is literal); pass `--tag operator=$USER` on the CLI for shell expansion.
# Must be the LAST table in the file (see TOML scoping note above).
[tags]
run = "full"
+34
View File
@@ -0,0 +1,34 @@
# 4-node smoke campaign: two small/medium Llama models, abbreviated ramps,
# auto-everything else (TB a2a + tensor + JACCL + auto-derived constraints).
#
# Run with:
# uv run python -m bench.cli campaign bench/configs/llama-family-smoke.toml
#
# Each [[runs]] gets its own cluster (deploy + bench + teardown). After
# both runs finish, a side-by-side comparison plot is written next to the
# JSONs.
[defaults]
nodes = 4
num_steps = 8
fraction_of_max = 0.5
[defaults.tags]
campaign = "llama-family-smoke"
[[runs]]
subcommand = "context-scaling"
model = "mlx-community/Llama-3.2-3B-Instruct-4bit"
[runs.tags]
model_short = "llama-3.2-3b-4bit"
[[runs]]
subcommand = "context-scaling"
model = "mlx-community/Meta-Llama-3.1-8B-Instruct-4bit"
[runs.tags]
model_short = "llama-3.1-8b-4bit"
# Final comparison plot (one PNG per benchmark group with ≥2 runs).
[plot]
label_tag = "model_short"
title = "Llama 3 family — 4-node tensor + JACCL smoke"
+2 -3
View File
@@ -15,9 +15,8 @@ from pathlib import Path
from typing import Any, Literal
import httpx
from harness import (
ExoClient,
ExoHttpError,
from exo_tools.client import ExoClient, ExoHttpError
from exo_tools.harness import (
add_common_instance_args,
capture_cluster_snapshot,
instance_id_from_instance,
+36 -273
View File
@@ -24,15 +24,12 @@ import json
import sys
import threading
import time
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from statistics import mean
from typing import Any
from harness import (
ExoClient,
ExoHttpError,
from exo_tools.client import ExoClient, ExoHttpError
from exo_tools.harness import (
add_common_instance_args,
capture_cluster_snapshot,
find_existing_instance,
@@ -46,125 +43,44 @@ from harness import (
wait_for_instance_ready,
)
from loguru import logger
from transformers import AutoTokenizer
# Monkey-patch for transformers 5.x compatibility
# Kimi's tokenization_kimi.py imports bytes_to_unicode from the old location
# which was moved in transformers 5.0.0rc2
try:
import transformers.models.gpt2.tokenization_gpt2 as gpt2_tokenization
from transformers.convert_slow_tokenizer import bytes_to_unicode
# PromptSizer / run_one_completion / load_tokenizer_for_bench are the
# canonical, fully-typed implementations under bench/lib/. They are
# re-exported here for backwards compatibility with prefill_decode_bench.py
# and any other consumers of `from exo_bench import …`.
from bench.lib.completion import run_one_completion as _lib_run_one_completion
from bench.lib.prompt import (
PromptSizer as _LibPromptSizer,
)
from bench.lib.prompt import (
load_tokenizer_for_bench as _lib_load_tokenizer_for_bench,
)
if not hasattr(gpt2_tokenization, "bytes_to_unicode"):
gpt2_tokenization.bytes_to_unicode = bytes_to_unicode # type: ignore[attr-defined]
except ImportError:
pass # transformers < 5.0 or bytes_to_unicode not available
PromptSizer = _LibPromptSizer
load_tokenizer_for_bench = _lib_load_tokenizer_for_bench
def load_tokenizer_for_bench(model_id: str) -> Any:
"""
Load tokenizer for benchmarking, with special handling for Kimi models.
Kimi uses a custom TikTokenTokenizer that transformers 5.x can't load via AutoTokenizer.
This function replicates the logic from utils_mlx.py for bench compatibility.
"""
model_id_lower = model_id.lower()
if "kimi-k2" in model_id_lower:
import importlib.util
import types
from huggingface_hub import snapshot_download
# Download/get the model path
model_path = Path(
snapshot_download(
model_id,
allow_patterns=["*.json", "*.py", "*.tiktoken", "*.model", "*.jinja"],
)
)
sys.path.insert(0, str(model_path))
# Load tool_declaration_ts first (tokenization_kimi imports it with relative import)
tool_decl_path = model_path / "tool_declaration_ts.py"
if tool_decl_path.exists():
spec = importlib.util.spec_from_file_location(
"tool_declaration_ts", tool_decl_path
)
if spec and spec.loader:
tool_decl_module = importlib.util.module_from_spec(spec)
sys.modules["tool_declaration_ts"] = tool_decl_module
spec.loader.exec_module(tool_decl_module)
# Load tokenization_kimi with patched source (convert relative to absolute import)
tok_path = model_path / "tokenization_kimi.py"
source = tok_path.read_text()
source = source.replace("from .tool_declaration_ts", "from tool_declaration_ts")
spec = importlib.util.spec_from_file_location("tokenization_kimi", tok_path)
if spec:
tok_module = types.ModuleType("tokenization_kimi")
tok_module.__file__ = str(tok_path)
sys.modules["tokenization_kimi"] = tok_module
exec(compile(source, tok_path, "exec"), tok_module.__dict__) # noqa: S102
TikTokenTokenizer = tok_module.TikTokenTokenizer # noqa: N806
else:
from tokenization_kimi import TikTokenTokenizer # type: ignore[import-not-found] # noqa: I001
hf_tokenizer: Any = TikTokenTokenizer.from_pretrained(model_path)
# Patch encode to use internal tiktoken model directly
# transformers 5.x has a bug in the encode->pad path for slow tokenizers
def _patched_encode(text: str, **kwargs: object) -> list[int]:
# Pass allowed_special="all" to handle special tokens like <|im_user|>
return list(hf_tokenizer.model.encode(text, allowed_special="all"))
hf_tokenizer.encode = _patched_encode
return hf_tokenizer
# TODO: Change back to using only transformers
try:
return AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
except (AttributeError, ValueError):
from huggingface_hub import snapshot_download
from transformers import PretrainedConfig
model_path = Path(
snapshot_download(
model_id,
allow_patterns=[
"*.json",
"*.py",
"tokenizer.model",
"*.tiktoken",
"tiktoken.model",
"*.txt",
"*.jsonl",
"*.jinja",
],
)
)
stub_kwargs: dict[str, Any] = {}
config_file = model_path / "config.json"
if config_file.exists():
with open(config_file) as f:
raw = json.load(f)
for key in (
"model_type",
"max_position_embeddings",
"vocab_size",
"bos_token_id",
"eos_token_id",
"pad_token_id",
):
if key in raw:
stub_kwargs[key] = raw[key]
return AutoTokenizer.from_pretrained(
str(model_path),
config=PretrainedConfig(**stub_kwargs),
trust_remote_code=True,
)
def run_one_completion(
client: ExoClient,
model_id: str,
pp_hint: int,
tg: int,
prompt_sizer: PromptSizer,
*,
use_prefix_cache: bool = False,
stream: bool = False,
) -> tuple[dict[str, Any], int]:
"""Backwards-compatible shim returning a plain ``dict`` row."""
row, pp_tokens = _lib_run_one_completion(
client,
model_id,
pp_hint,
tg,
prompt_sizer,
use_prefix_cache=use_prefix_cache,
stream=stream,
)
return dict(row), pp_tokens
def format_peak_memory(b: float) -> str:
@@ -270,159 +186,6 @@ def parse_int_list(values: list[str]) -> list[int]:
return items
def run_one_completion(
client: ExoClient,
model_id: str,
pp_hint: int,
tg: int,
prompt_sizer: PromptSizer,
*,
use_prefix_cache: bool = False,
stream: bool = False,
) -> tuple[dict[str, Any], int]:
content, pp_tokens = prompt_sizer.build(pp_hint)
payload: dict[str, Any] = {
"model": model_id,
"messages": [{"role": "user", "content": content}],
"max_tokens": tg,
"logprobs": False,
"use_prefix_cache": use_prefix_cache,
}
if not stream:
payload["stream"] = False
t0 = time.perf_counter()
out = client.post_bench_chat_completions(payload)
elapsed = time.perf_counter() - t0
stats = out.get("generation_stats")
choices = out.get("choices") or [{}]
message = choices[0].get("message", {}) if choices else {}
content = message.get("content") or ""
preview = content[:200] if content else ""
else:
tokens = 0
first_token_time = None
t0 = time.perf_counter()
text_parts: list[str] = []
stats = None
for raw_line in client.stream_bench_chat_completions(payload):
line = raw_line.strip()
if line.startswith(": generation_stats "):
with contextlib.suppress(json.JSONDecodeError):
stats = json.loads(line[len(": generation_stats ") :])
continue
if not line.startswith("data: "):
continue
data = line[6:]
if data == "[DONE]":
break
try:
chunk = json.loads(data)
delta = chunk.get("choices", [{}])[0].get("delta", {})
if delta.get("content"):
if first_token_time is None:
first_token_time = time.perf_counter()
tokens += 1
text_parts.append(delta["content"])
except json.JSONDecodeError:
pass
elapsed = time.perf_counter() - t0
preview = "".join(text_parts)[:200]
if not stats:
ttft = (first_token_time - t0) if first_token_time else elapsed
gen_time = elapsed - ttft if tokens > 1 else elapsed
gen_tps = (tokens - 1) / gen_time if tokens > 1 and gen_time > 0 else 0.0
prompt_tps = pp_tokens / ttft if ttft > 0 else 0.0
stats = {
"prompt_tokens": pp_tokens,
"generation_tokens": tokens,
"prompt_tps": round(prompt_tps, 2),
"generation_tps": round(gen_tps, 2),
"peak_memory_usage": {"inBytes": 0},
}
return {
"elapsed_s": elapsed,
"output_text_preview": preview,
"stats": stats,
}, pp_tokens
class PromptSizer:
def __init__(self, tokenizer: Any, atom: str = "a "):
self.tokenizer = tokenizer
self.atom = atom
self.count_fn = PromptSizer._make_counter(tokenizer)
self.base_tokens = self.count_fn("")
@staticmethod
def _make_counter(tokenizer: Any) -> Callable[[str], int]:
def count_fn(user_content: str) -> int:
messages = [{"role": "user", "content": user_content}]
try:
ids = tokenizer.apply_chat_template(
messages, tokenize=True, add_generation_prompt=True
)
except ValueError:
# Models without a Jinja chat template (e.g. DeepSeek V4 which
# ships its own Python encoder). Use the exo-side V4 encoder.
from exo.worker.engines.mlx.deepseek_v4_encoding import (
encode_messages as encode_v4,
)
prompt = encode_v4(messages, thinking_mode="thinking")
ids = tokenizer.encode(prompt, add_special_tokens=False)
# Fix for transformers 5.x
if hasattr(ids, "input_ids"):
ids = ids.input_ids
return int(len(ids))
return count_fn
def build(self, target_prompt_tokens: int) -> tuple[str, int]:
target = int(target_prompt_tokens)
if target < self.base_tokens:
raise RuntimeError(
f"Target ({target}) is smaller than template overhead ({self.base_tokens})."
)
# Estimate tokens per atom using a sample
sample_count = 100
sample_content = self.atom * sample_count
sample_tokens = self.count_fn(sample_content) - self.base_tokens
tokens_per_atom = sample_tokens / sample_count
# Estimate starting point
needed_tokens = target - self.base_tokens
estimated_atoms = int(needed_tokens / tokens_per_atom)
# Binary search to find exact atom count
low, high = 0, estimated_atoms * 2 + 100
while low < high:
mid = (low + high) // 2
tok = self.count_fn(self.atom * mid)
if tok < target:
low = mid + 1
else:
high = mid
content = self.atom * low
tok = self.count_fn(content)
logger.info(f"{tok=}")
if tok != target:
raise RuntimeError(
f"Overshot: got {tok} tokens (target {target}). "
f"Pick a different atom (try ' a' or '\\n' or '0 ')."
)
return content, tok
def main() -> int:
ap = argparse.ArgumentParser(
prog="exo-bench",
+2 -3
View File
@@ -42,9 +42,8 @@ from pathlib import Path
from typing import Any
import httpx
from harness import (
ExoClient,
ExoHttpError,
from exo_tools.client import ExoClient, ExoHttpError
from exo_tools.harness import (
add_common_instance_args,
capture_cluster_snapshot,
find_existing_instance,
+18
View File
@@ -0,0 +1,18 @@
"""Composable bench library for exo.
Provides reusable building blocks for benchmarks:
- :class:`bench.lib.session.BenchSession` — cluster + instance + client wrapper
- :class:`bench.lib.results.ResultsBundle` — structured results + JSON writer
- :func:`bench.lib.cluster.managed_cluster` /
:func:`bench.lib.cluster.managed_instance` — eco-managed lifecycle ctx-managers
- :func:`bench.lib.model_meta.fetch_model_meta` — HF metadata fetcher driving
cluster constraints + auto-derived context ramps
- :mod:`bench.lib.context_scaling` — prompt-TPS / decode-TPS vs context-size sweep
CLI entrypoints under ``bench/cli/`` consume this library via
``python -m bench.cli <subcommand>``. Adding a new benchmark = (i) write
``bench/lib/<name>.py`` exposing a typed ``run(session, params, bundle)``
callable, (ii) write ``bench/cli/<name>.py`` with an ``add_subparser`` and
a handler, (iii) register it in ``_REGISTRY`` in ``bench/cli/__main__.py``.
"""
+215
View File
@@ -0,0 +1,215 @@
"""Eco-managed cluster + instance lifecycle helpers for the bench CLI.
Two context managers:
- :func:`managed_cluster` deploys exo on the requested hosts (or via
constraint-based reservation) and tears it down on exit.
- :func:`managed_instance` resolves the model on the cluster, optionally
frees disk via ``--danger-delete-downloads`` (default on for benches),
places the instance, and deletes it on exit.
The library never reaches for global state — every call takes an
explicit :class:`EcoSession`. Callers are expected to instantiate one
session per CLI invocation and use it across both context managers.
"""
from __future__ import annotations
import contextlib
import time
from collections.abc import Iterator
from contextlib import contextmanager
from typing import Any, cast
from exo_tools.client import ExoClient
from exo_tools.cluster import Chip, ClusterInfo, EcoSession, Thunderbolt
from exo_tools.harness import (
Comm,
Sharding,
cleanup_all_instances,
place_instance,
resolve_model_short_id,
run_planning_phase,
)
from loguru import logger
from .session import BenchSession
@contextmanager
def managed_cluster(
eco: EcoSession,
*,
hosts: list[str] | None = None,
count: int = 1,
thunderbolt: Thunderbolt | None = None,
chip: Chip | None = None,
min_memory_gb: float | None = None,
max_memory_gb: float | None = None,
min_disk_gb: float | None = None,
max_disk_gb: float | None = None,
deploy_timeout_s: int = 600,
) -> Iterator[ClusterInfo]:
"""Deploy exo for the duration of the ``with`` block, then ``eco stop``.
If ``hosts`` is given, deploys on exactly those hosts (constraint flags
are ignored — eco doesn't re-validate the explicit list). Otherwise eco
reserves any matching hosts that satisfy all of:
- ``count`` (number of hosts)
- ``thunderbolt`` topology (``A2A``, ``RING``, or ``NONE`` to
exclude TB-connected hosts)
- ``chip`` (substring match against eco's chip names)
- memory bounds (``min_memory_gb`` / ``max_memory_gb``)
- disk bounds (``min_disk_gb`` / ``max_disk_gb``)
"""
if hosts:
cluster = eco.start_deploy(
hosts=hosts[:count],
wait=True,
timeout=deploy_timeout_s,
)
else:
cluster = eco.start_deploy(
count=count,
thunderbolt=thunderbolt,
chip=chip,
min_memory_gb=min_memory_gb,
max_memory_gb=max_memory_gb,
min_disk_gb=min_disk_gb,
max_disk_gb=max_disk_gb,
wait=True,
timeout=deploy_timeout_s,
)
logger.info(
f"cluster deployed: {len(cluster.hosts)} host(s) "
f"({', '.join(cluster.hosts)}); namespace={cluster.namespace}"
)
try:
yield cluster
finally:
with contextlib.suppress(Exception):
eco.stop(cluster.hosts)
logger.info("cluster stopped")
@contextmanager
def managed_instance(
cluster: ClusterInfo,
eco: EcoSession,
model_id: str,
*,
sharding: Sharding = Sharding.PIPELINE,
comm: Comm = Comm.RING,
min_nodes: int = 1,
evict_downloads: bool = True,
cleanup_on_exit: bool = True,
instance_timeout_s: float = 7200.0,
settle_timeout_s: float = 60.0,
) -> Iterator[BenchSession]:
"""Resolve the model on the cluster, place an instance, yield a session.
Steps on entry:
1. Resolve ``model_id`` to ``(short_id, full_id)`` against the cluster's
``/models`` endpoint (auto-adds from HuggingFace if missing).
2. Run the harness's planning phase: validates each node has enough
disk for the model and starts the download (or reuses an existing
download). When ``evict_downloads=True`` (the default for benches),
this also evicts smaller existing models if disk is short.
3. Place the instance, wait for it to be ``RunnerReady``.
4. Yield a :class:`BenchSession` pointing at the cluster's primary API.
On exit: deletes the placed instance (and any other lingering
instances) so the cluster is clean for the next benchmark.
"""
client = cluster.make_client(timeout_s=instance_timeout_s)
short_id, full_id = resolve_model_short_id(client, model_id, force_download=True)
logger.info(f"resolved model: short_id={short_id} full_id={full_id}")
# The planning phase needs a concrete preview (instance + runner-to-shard
# mapping) to know which nodes to download to. Pull the placements API
# directly and take the first valid one — bench cares about disk +
# download, not the specific shard mapping.
preview = _first_valid_preview(client, full_id, settle_timeout_s)
if preview is None:
raise RuntimeError(
f"No placement available for {full_id} on cluster {cluster.hosts}"
)
duration = run_planning_phase(
client,
full_id,
preview,
danger_delete=evict_downloads,
timeout=instance_timeout_s,
settle_deadline=None,
)
if duration is not None:
logger.info(f"download: {duration:.1f}s (freshly downloaded)")
else:
logger.info("download: model already cached on all nodes")
instance_id = place_instance(
client,
model_id,
sharding=sharding,
comm=comm,
min_nodes=min_nodes,
timeout=instance_timeout_s,
)
logger.info(f"placed instance {instance_id} ({sharding.value}/{comm.value})")
sess = BenchSession(
cluster=cluster,
eco=eco,
instance_id=instance_id,
model_id=short_id,
full_model_id=full_id,
)
try:
yield sess
finally:
if cleanup_on_exit:
with contextlib.suppress(Exception):
cleanup_all_instances(sess.client)
else:
logger.info(
f"cleanup_on_exit=False: leaving instance(s) on {cluster.hosts}"
)
def _first_valid_preview(
client: ExoClient, full_model_id: str, settle_timeout_s: float
) -> dict[str, Any] | None:
"""Poll ``/instance/previews`` until at least one valid preview comes back."""
deadline = time.monotonic() + settle_timeout_s
backoff_s = 1.0
while True:
resp_obj: Any = client.request_json( # type: ignore[reportAny]
"GET", "/instance/previews", params={"model_id": full_model_id}
)
resp: dict[str, Any] = (
cast("dict[str, Any]", resp_obj) if isinstance(resp_obj, dict) else {}
)
previews_raw: object = resp.get("previews") or []
previews: list[Any] = (
cast("list[Any]", previews_raw) if isinstance(previews_raw, list) else []
)
for raw in previews: # type: ignore[reportAny]
if not isinstance(raw, dict):
continue
entry = cast("dict[str, Any]", raw)
if entry.get("error") is not None:
continue
instance = entry.get("instance")
if isinstance(instance, dict):
return entry
if time.monotonic() >= deadline:
return None
logger.info(
f"waiting for placement to appear for {full_model_id} "
f"({deadline - time.monotonic():.0f}s remaining)..."
)
time.sleep(min(backoff_s, max(0.0, deadline - time.monotonic())))
backoff_s = min(backoff_s * 2, 30.0)
+194
View File
@@ -0,0 +1,194 @@
"""Typed wrapper around ``/bench/chat/completions`` for benchmarks.
The bench endpoint disables EOS suppression and KV prefix caching by
default (see ``bench/METHODOLOGY.md``). This module exposes a single
function :func:`run_one_completion` that:
1. Builds an exact-token-length prompt via :class:`PromptSizer`.
2. POSTs to ``/bench/chat/completions``.
3. Returns a ``(BenchRow, prompt_tokens)`` pair where ``BenchRow`` is a
:class:`typing.TypedDict` with the fields the caller needs.
Streaming is supported but rarely needed for context-scaling — the
non-streaming path is the default.
"""
from __future__ import annotations
import contextlib
import json
import time
from typing import Any, Literal, NotRequired, TypedDict, cast
from exo_tools.client import ExoClient
from .prompt import PromptSizer
PrefixCacheHit = Literal["none", "partial", "exact"]
class GenerationStats(TypedDict, total=False):
"""Server-reported per-task timing stats."""
prompt_tps: float
generation_tps: float
prompt_tokens: int
generation_tokens: int
peak_memory_usage: dict[str, int]
prefix_cache_hit: PrefixCacheHit
class BenchRow(TypedDict):
"""Per-request result row returned to callers."""
elapsed_s: float
output_text_preview: str
stats: GenerationStats
error: NotRequired[str]
def _as_dict(value: Any) -> dict[str, Any]: # type: ignore[reportAny]
"""Narrow an arbitrary JSON value to a typed ``dict[str, Any]``."""
if isinstance(value, dict):
return cast("dict[str, Any]", value)
return {}
def _as_list(value: Any) -> list[Any]: # type: ignore[reportAny]
if isinstance(value, list):
return cast("list[Any]", value)
return []
def _extract_stats(raw_response: dict[str, Any]) -> GenerationStats:
stats_obj = raw_response.get("generation_stats")
if not isinstance(stats_obj, dict):
return {}
return cast("GenerationStats", cast("object", stats_obj))
def _extract_preview(raw_response: dict[str, Any], limit: int = 200) -> str:
choices = _as_list(raw_response.get("choices"))
if not choices:
return ""
first = _as_dict(choices[0])
message = _as_dict(first.get("message"))
content_obj = message.get("content")
if isinstance(content_obj, str):
return content_obj[:limit]
return ""
def run_one_completion(
client: ExoClient,
model_id: str,
pp_hint: int,
tg: int,
prompt_sizer: PromptSizer,
*,
use_prefix_cache: bool = False,
stream: bool = False,
) -> tuple[BenchRow, int]:
"""Send one request to ``/bench/chat/completions`` and return its row.
``pp_hint`` is the *target* prompt-token count; the actual prompt is
sized via :class:`PromptSizer` and the verified value is returned as
the second element of the tuple.
"""
content, pp_tokens = prompt_sizer.build(pp_hint)
payload: dict[str, Any] = {
"model": model_id,
"messages": [{"role": "user", "content": content}],
"max_tokens": tg,
"logprobs": False,
"use_prefix_cache": use_prefix_cache,
}
if not stream:
payload["stream"] = False
t0 = time.perf_counter()
raw_obj = client.post_bench_chat_completions(payload)
elapsed = time.perf_counter() - t0
raw = _as_dict(raw_obj)
return (
BenchRow(
elapsed_s=elapsed,
output_text_preview=_extract_preview(raw),
stats=_extract_stats(raw),
),
pp_tokens,
)
return _run_streaming(client, payload, pp_tokens)
def _run_streaming(
client: ExoClient,
payload: dict[str, Any],
pp_tokens: int,
) -> tuple[BenchRow, int]:
"""Streaming variant: parse SSE lines, recover ``GenerationStats``."""
payload = {**payload, "stream": True}
tokens = 0
first_token_time: float | None = None
t0 = time.perf_counter()
text_parts: list[str] = []
stats: GenerationStats = {}
for raw_line in client.stream_bench_chat_completions(payload):
line = raw_line.strip()
if line.startswith(": generation_stats "):
with contextlib.suppress(json.JSONDecodeError):
parsed_obj: Any = json.loads( # type: ignore[reportAny]
line[len(": generation_stats ") :]
)
if isinstance(parsed_obj, dict):
stats = cast("GenerationStats", cast("object", parsed_obj))
continue
if not line.startswith("data: "):
continue
data = line[6:]
if data == "[DONE]":
break
try:
chunk_obj: Any = json.loads(data) # type: ignore[reportAny]
except json.JSONDecodeError:
continue
chunk = _as_dict(chunk_obj)
choices = _as_list(chunk.get("choices"))
if not choices:
continue
first = _as_dict(choices[0])
delta = _as_dict(first.get("delta"))
delta_content_obj = delta.get("content")
if isinstance(delta_content_obj, str) and delta_content_obj:
if first_token_time is None:
first_token_time = time.perf_counter()
tokens += 1
text_parts.append(delta_content_obj)
elapsed = time.perf_counter() - t0
preview = "".join(text_parts)[:200]
if not stats:
ttft = (first_token_time - t0) if first_token_time is not None else elapsed
gen_time = elapsed - ttft if tokens > 1 else elapsed
gen_tps = (tokens - 1) / gen_time if tokens > 1 and gen_time > 0 else 0.0
prompt_tps = pp_tokens / ttft if ttft > 0 else 0.0
stats = GenerationStats(
prompt_tokens=pp_tokens,
generation_tokens=tokens,
prompt_tps=round(prompt_tps, 2),
generation_tps=round(gen_tps, 2),
peak_memory_usage={"inBytes": 0},
)
return (
BenchRow(
elapsed_s=elapsed,
output_text_preview=preview,
stats=stats,
),
pp_tokens,
)
+428
View File
@@ -0,0 +1,428 @@
"""Prompt-TPS / decode-TPS vs context-size sweep.
Methodology (see also ``bench/METHODOLOGY.md``):
Run a single ascending ramp of equally-spaced prompt lengths
``pp ∈ {Δ, 2Δ, …, K·Δ}`` with ``prefix_cache=enabled``, ``repeat=1``,
``concurrency=1`` and one warmup at ``pp=Δ``.
Because each step's prefix is exactly what the previous step left in
the cache, every step beyond the first is a *partial* hit and the
server-reported ``prompt_tps`` reflects the true cold rate over the
fresh ``Δ``-token suffix. We accept the warmup's reported rate as the
cold equivalent for ``pp=Δ`` (the warmup itself is the cold prefill).
``decode TPS`` is independent of prefill mechanics — every step's
``generation_tps`` is a real decode-rate-at-N data point.
Cumulative cold-prefill upper bound:
``T_cum(pp_k) = Σ_{i=1..k} (Δ_i / prompt_tps_i)``
Optional cold-control points (``prefix_cache=disabled``) validate the
approximation; the gap quantifies per-task overhead. To preserve the
``none`` cache-hit classification AND ensure the request actually
hits a freshly-placed runner (the master picks the instance with the
lowest in-flight task count, which is non-deterministic when multiple
same-model instances exist), :func:`run` deletes the sweep instance
*before* invoking the cold-control factory. The factory itself places
a fresh instance per control and deletes it on exit; the
:func:`bench.lib.cluster.managed_instance` ctx-manager calls
``cleanup_all_instances`` on exit as a final safety net.
"""
from __future__ import annotations
import contextlib
import time
from collections.abc import Callable, Iterator
from contextlib import AbstractContextManager, contextmanager
from dataclasses import asdict, dataclass
from typing import Any
from exo_tools.client import ExoClient, ExoHttpError
from exo_tools.harness import (
Comm,
Sharding,
place_instance,
wait_for_instance_gone,
)
from loguru import logger
from .completion import GenerationStats, PrefixCacheHit, run_one_completion
from .prompt import PromptSizer
from .results import ResultsBundle
from .session import BenchSession
@dataclass(frozen=True)
class ContextScalingParams:
"""Inputs for a single context-scaling sweep."""
pp_step: int
num_steps: int
tg: int
warmup: int = 1
cold_controls: tuple[int, ...] = ()
sleep_between_s: float = 1.0
@dataclass
class StepResult:
pp_tokens: int
delta_tokens: int
prompt_tps: float
generation_tps: float
prefix_cache_hit: PrefixCacheHit | str
prompt_tokens: int
generation_tokens: int
elapsed_s: float
peak_memory_bytes: int = 0
output_text_preview: str = ""
def _peak_bytes(stats: GenerationStats) -> int:
pm = stats.get("peak_memory_usage") or {}
return int(pm.get("inBytes") or pm.get("in_bytes") or 0)
def _build_step_result(
pp_tokens: int,
delta_tokens: int,
elapsed_s: float,
output_text_preview: str,
stats: GenerationStats,
) -> StepResult:
return StepResult(
pp_tokens=pp_tokens,
delta_tokens=delta_tokens,
prompt_tps=float(stats.get("prompt_tps") or 0.0),
generation_tps=float(stats.get("generation_tps") or 0.0),
prefix_cache_hit=stats.get("prefix_cache_hit") or "unknown",
prompt_tokens=int(stats.get("prompt_tokens") or pp_tokens),
generation_tokens=int(stats.get("generation_tokens") or 0),
elapsed_s=elapsed_s,
peak_memory_bytes=_peak_bytes(stats),
output_text_preview=output_text_preview[:200],
)
def _run_request(
client: ExoClient,
full_model_id: str,
pp: int,
tg: int,
sizer: PromptSizer,
*,
use_prefix_cache: bool,
) -> tuple[StepResult, int]:
"""Send one request and return ``(StepResult, actual_pp_tokens)``."""
row, actual_pp = run_one_completion(
client,
full_model_id,
pp,
tg,
sizer,
use_prefix_cache=use_prefix_cache,
stream=False,
)
step = _build_step_result(
pp_tokens=actual_pp,
delta_tokens=actual_pp, # caller overrides for cached sweep
elapsed_s=row["elapsed_s"],
output_text_preview=row["output_text_preview"],
stats=row["stats"],
)
return step, actual_pp
def _compute_t_cum(steps: list[StepResult]) -> list[float]:
t_cum = 0.0
out: list[float] = []
for s in steps:
if s.prompt_tps > 0 and s.delta_tokens > 0:
t_cum += s.delta_tokens / s.prompt_tps
out.append(round(t_cum, 6))
return out
def run_cached_sweep(
session: BenchSession,
params: ContextScalingParams,
bundle: ResultsBundle,
) -> list[StepResult]:
"""Run the ascending PP sweep with ``prefix_cache=enabled``.
Mutates ``bundle.runs`` in place and returns the typed step list.
"""
if session.full_model_id is None:
raise RuntimeError(
"BenchSession.full_model_id must be set for context-scaling."
)
sizer = session.get_prompt_sizer()
client = session.client
pp_targets = [params.pp_step * i for i in range(1, params.num_steps + 1)]
logger.info(
f"context-scaling: K={params.num_steps} steps, Δ={params.pp_step} tokens, "
f"tg={params.tg}, warmup={params.warmup}, cached"
)
# Warmup discipline:
# - First warmup runs with the prefix cache DISABLED. This triggers
# the MLX kernel JIT compile + KV-buffer alloc for this exact
# (Δ, dtype, batch) shape, but does NOT write a cache entry — so
# the cold-with-JIT rate isn't fossilised.
# - Subsequent warmups run with the prefix cache ENABLED. The
# second one finds an empty cache, does a real cold prefill with
# a HOT kernel, and writes the resulting rate into the cache
# entry at pp=Δ.
# - Step 0 (also cache-enabled) is then an exact hit on that entry
# and reports the hot rate.
# Default warmup=2 gives both effects; warmup=1 still does the JIT
# warmup but leaves step 0 as a "none" hit (cold prefill at the hot
# kernel, creates the cache entry on the way through).
for w in range(params.warmup):
is_jit_warmup = w == 0
kind = "JIT warmup" if is_jit_warmup else "cache-prime warmup"
logger.info(
f" warmup {w + 1}/{params.warmup} ({kind}, pp={params.pp_step})"
)
_run_request(
client,
session.full_model_id,
params.pp_step,
params.tg,
sizer,
use_prefix_cache=not is_jit_warmup,
)
steps: list[StepResult] = []
prev_pp = 0
for i, pp in enumerate(pp_targets):
time.sleep(params.sleep_between_s)
try:
step, actual_pp = _run_request(
client,
session.full_model_id,
pp,
params.tg,
sizer,
use_prefix_cache=True,
)
except Exception as e:
logger.error(f"step {i + 1}/{params.num_steps} (pp={pp}) failed: {e}")
raise
step.delta_tokens = actual_pp - prev_pp
steps.append(step)
bundle.runs.append({"step_index": i, "phase": "cached_sweep", **asdict(step)})
logger.info(
f" step {i + 1}/{params.num_steps} pp={actual_pp} Δ={step.delta_tokens} "
f"prompt_tps={step.prompt_tps:.1f} gen_tps={step.generation_tps:.2f} "
f"hit={step.prefix_cache_hit}"
)
prev_pp = actual_pp
return steps
def run_cold_controls(
factory: Callable[[], AbstractContextManager[ExoClient]],
session: BenchSession,
params: ContextScalingParams,
bundle: ResultsBundle,
) -> list[StepResult]:
"""Run cold-control points on a fresh instance to preserve ``none`` hits.
A cold control is a single request at ``pp=N`` with
``prefix_cache=disabled``, executed against a freshly-placed instance
(and with no other same-model instance live, so the master's task
routing is deterministic). The caller is expected to delete the
sweep instance before invoking this — see :func:`run`.
"""
if not params.cold_controls:
return []
if session.full_model_id is None:
raise RuntimeError("BenchSession.full_model_id must be set for cold controls.")
sizer = session.get_prompt_sizer()
out: list[StepResult] = []
for control_pp in params.cold_controls:
logger.info(f"cold control: pp={control_pp} (fresh instance, cache disabled)")
with factory() as fresh_client:
step, actual_pp = _run_request(
fresh_client,
session.full_model_id,
control_pp,
params.tg,
sizer,
use_prefix_cache=False,
)
step.delta_tokens = actual_pp
out.append(step)
bundle.cold_controls.append({"phase": "cold_control", **asdict(step)})
logger.info(
f" cold pp={actual_pp} prompt_tps={step.prompt_tps:.1f} "
f"gen_tps={step.generation_tps:.2f} hit={step.prefix_cache_hit}"
)
if step.prefix_cache_hit != "none":
logger.warning(
f"cold control at pp={actual_pp} reported "
f"prefix_cache_hit={step.prefix_cache_hit!r}; "
f"control may not be cold."
)
return out
def derive_summary(
steps: list[StepResult],
cold_controls: list[StepResult],
) -> dict[str, Any]:
"""Compute the cumulative cold-prefill upper bound + control gaps."""
t_cum = _compute_t_cum(steps)
bracketed = sorted(
((s.pp_tokens, t) for s, t in zip(steps, t_cum, strict=True)),
key=lambda x: x[0],
)
control_gaps: list[dict[str, float]] = []
for ctrl in cold_controls:
cold_t = ctrl.pp_tokens / ctrl.prompt_tps if ctrl.prompt_tps > 0 else 0.0
cum_t = _interp(bracketed, ctrl.pp_tokens)
gap = cum_t - cold_t
control_gaps.append(
{
"pp_tokens": ctrl.pp_tokens,
"cold_t_seconds": round(cold_t, 4),
"t_cum_seconds_at_pp": round(cum_t, 4),
"gap_seconds": round(gap, 4),
"gap_fraction": round(gap / cold_t, 4) if cold_t > 0 else 0.0,
}
)
return {
"t_cum_seconds": t_cum,
"control_gaps": control_gaps,
}
def _interp(points: list[tuple[int, float]], x: int) -> float:
"""Linear interpolate y at x, given sorted ``(x, y)`` points."""
if not points:
return 0.0
if x <= points[0][0]:
return points[0][1]
if x >= points[-1][0]:
return points[-1][1]
for i in range(1, len(points)):
x0, y0 = points[i - 1]
x1, y1 = points[i]
if x0 <= x <= x1 and x1 != x0:
return y0 + (y1 - y0) * (x - x0) / (x1 - x0)
return points[-1][1]
# ---------------------------------------------------------------------------
# Cold-control instance factory
# ---------------------------------------------------------------------------
def make_cold_control_factory(
session: BenchSession,
sharding: Sharding,
comm: Comm,
min_nodes: int,
instance_timeout_s: float = 1800.0,
) -> Callable[[], AbstractContextManager[ExoClient]]:
"""Return a callable yielding a context manager that places a fresh instance.
Each ``with factory() as client:`` block places a brand-new instance,
yields its client, then deletes the instance on exit. Used to isolate
cold-control runs.
The caller is responsible for ensuring no other same-model instance is
live during the ``with`` block — otherwise master routing is
non-deterministic and the cold control may be served by a stale runner.
See :func:`run` for the orchestration.
"""
@contextmanager
def factory() -> Iterator[ExoClient]:
if session.full_model_id is None:
raise RuntimeError("session.full_model_id is unset")
client = session.client
instance_id = place_instance(
client,
session.full_model_id,
sharding=sharding,
comm=comm,
min_nodes=min_nodes,
timeout=instance_timeout_s,
)
try:
yield client
finally:
with contextlib.suppress(ExoHttpError):
client.request_json("DELETE", f"/instance/{instance_id}")
with contextlib.suppress(Exception):
wait_for_instance_gone(client, instance_id, timeout=60.0)
return factory
def _delete_instance(client: ExoClient, instance_id: str) -> None:
"""Best-effort delete of a placed instance."""
with contextlib.suppress(ExoHttpError):
client.request_json("DELETE", f"/instance/{instance_id}")
with contextlib.suppress(Exception):
wait_for_instance_gone(client, instance_id, timeout=60.0)
def run(
session: BenchSession,
params: ContextScalingParams,
bundle: ResultsBundle,
*,
cold_control_factory: Callable[[], AbstractContextManager[ExoClient]] | None = None,
) -> ResultsBundle:
"""End-to-end: cached sweep + optional cold controls + derived summary.
To make cold controls truly isolated from the sweep instance, we
delete the sweep instance *before* running the controls (otherwise
the master might route a control's request to the stale sweep
instance, since both match the same ``model_id``). The controls then
each place their own fresh instance via the factory.
"""
bundle.params.update(
{
"pp_step": params.pp_step,
"num_steps": params.num_steps,
"tg": params.tg,
"warmup": params.warmup,
"cold_controls": list(params.cold_controls),
"sleep_between_s": params.sleep_between_s,
"model_id": session.model_id,
"full_model_id": session.full_model_id,
}
)
bundle.capture_cluster(session.client)
cached_steps = run_cached_sweep(session, params, bundle)
cold_steps: list[StepResult] = []
if params.cold_controls and cold_control_factory is not None:
# Delete the sweep instance so the cold-control fresh instance is
# the only same-model instance live for the duration of the controls.
if session.instance_id is not None:
logger.info(
f"cold controls: deleting sweep instance {session.instance_id} "
"to isolate fresh instance routing"
)
_delete_instance(session.client, session.instance_id)
session.instance_id = None
cold_steps = run_cold_controls(cold_control_factory, session, params, bundle)
elif params.cold_controls and cold_control_factory is None:
logger.warning(
"Cold controls requested but no cold_control_factory supplied; skipping."
)
bundle.derived.update(derive_summary(cached_steps, cold_steps))
return bundle
+183
View File
@@ -0,0 +1,183 @@
"""Fetch HuggingFace model metadata for benchmark planning.
Two pieces of metadata drive every benchmark we run:
1. **Total weight size** — used to derive ``min-memory`` and ``min-disk``
constraints when picking a host. We sum the sizes of all
``.safetensors`` (or ``.bin``) shards from the repo's file listing.
2. **Max position embeddings** — the model's training context length.
Used to bound a context-scaling sweep at the model's max context, and
to derive a sensible Δ given a target step count.
The fetcher uses the ``huggingface_hub`` python API, which talks to the
public HF Hub HTTPS endpoints — no exo cluster required, no download
of weights.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any, cast
# Files that count toward the on-disk weight footprint.
_WEIGHT_SUFFIXES = (".safetensors", ".bin", ".gguf", ".pt", ".npz")
@dataclass(frozen=True)
class ModelMeta:
"""Subset of HF metadata that a benchmark needs."""
model_id: str
total_weight_bytes: int
max_position_embeddings: int
num_hidden_layers: int
raw_config: dict[str, Any] = field(default_factory=dict)
@property
def total_weight_gb(self) -> float:
return self.total_weight_bytes / (1024**3)
@property
def memory_constraint_gb(self) -> float:
"""Estimated minimum host memory to hold weights + overhead.
Picks the model size + 30 % headroom (KV cache, activations,
framework bookkeeping). Rounded up to the next whole GiB.
"""
return float(int(self.total_weight_gb * 1.30) + 1)
@property
def disk_constraint_gb(self) -> float:
"""Disk space the host must have free for the download."""
return float(int(self.total_weight_gb * 1.10) + 1)
def _read_config_json(model_id: str) -> dict[str, Any]:
from huggingface_hub import (
hf_hub_download, # type: ignore[reportUnknownVariableType]
)
raw_path = hf_hub_download(repo_id=model_id, filename="config.json", dry_run=False)
with open(raw_path) as f:
loaded: Any = json.load(f) # type: ignore[reportAny]
return cast("dict[str, Any]", loaded) if isinstance(loaded, dict) else {}
def _sum_weight_sizes(model_id: str) -> int:
"""Sum sizes of all weight-shard files in the repo's file listing."""
from huggingface_hub import HfApi
api = HfApi()
info = api.model_info(repo_id=model_id, files_metadata=True)
siblings = info.siblings or []
total = 0
for sib in siblings:
rfilename = getattr(sib, "rfilename", None)
size = getattr(sib, "size", None)
if not isinstance(rfilename, str) or not isinstance(size, int):
continue
if any(rfilename.endswith(suf) for suf in _WEIGHT_SUFFIXES):
total += size
return total
def _first_int(config: dict[str, Any], *keys: str) -> int:
"""Return the first key from ``config`` that holds a usable positive int."""
for key in keys:
value = config.get(key)
if isinstance(value, int) and value > 0:
return value
if isinstance(value, str):
try:
parsed = int(value)
except ValueError:
continue
if parsed > 0:
return parsed
return 0
def fetch_model_meta(model_id: str) -> ModelMeta:
"""Fetch the metadata our benchmarks care about for ``model_id``.
Args:
model_id: HuggingFace repo id, e.g. ``mlx-community/Qwen3-30B-A3B-4bit``.
Returns:
Populated :class:`ModelMeta`.
Raises:
Exception: any HTTP / parse error from ``huggingface_hub`` propagates.
"""
config = _read_config_json(model_id)
return ModelMeta(
model_id=model_id,
total_weight_bytes=_sum_weight_sizes(model_id),
max_position_embeddings=_first_int(
config,
"max_position_embeddings",
"max_seq_len",
"model_max_length",
"n_positions",
),
num_hidden_layers=_first_int(
config,
"num_hidden_layers",
"num_layers",
"n_layer",
"n_layers",
"num_decoder_layers",
),
raw_config=config,
)
def derive_context_ramp(
meta: ModelMeta,
*,
num_steps: int,
fraction_of_max: float = 1.0,
min_pp_step: int = 256,
round_to: int = 256,
) -> tuple[int, int]:
"""Pick ``(pp_step, num_steps)`` covering ``fraction_of_max`` of the context.
Δ is rounded down to the nearest ``round_to`` so the per-step prompt is a
clean number, and clamped to ``min_pp_step`` for tiny-context models.
"""
if meta.max_position_embeddings <= 0:
raise ValueError(
f"{meta.model_id} reports max_position_embeddings=0 in config.json"
)
if not (0.0 < fraction_of_max <= 1.0):
raise ValueError(f"fraction_of_max must be in (0, 1], got {fraction_of_max}")
if num_steps <= 0:
raise ValueError(f"num_steps must be >0, got {num_steps}")
target_max = int(meta.max_position_embeddings * fraction_of_max)
raw_step = max(min_pp_step, target_max // num_steps)
pp_step = (raw_step // round_to) * round_to or round_to
return pp_step, num_steps
def derive_cold_controls(
meta: ModelMeta,
*,
pp_step: int,
num_steps: int,
count: int = 4,
) -> tuple[int, ...]:
"""Pick ``count`` evenly-spaced cold-control points across the ramp.
Always includes the largest ramp point (``pp_step * num_steps``).
Returns control pp values in ascending order, deduped.
"""
if count <= 0:
return ()
max_pp = pp_step * num_steps
if count == 1:
return (max_pp,)
spaced = sorted({(max_pp * (i + 1)) // count for i in range(count)})
# Filter out anything below pp_step (a control at <Δ is meaningless).
return tuple(p for p in spaced if p >= pp_step)
+308
View File
@@ -0,0 +1,308 @@
"""Typed matplotlib renderers for benchmark JSON results.
This module owns the *visualisation* of bench results, mirroring how
``bench/lib/<name>.py`` owns the methodology and ``bench/cli/<name>.py``
owns the orchestration. Adding plotting for a new benchmark = a new
``render_<name>`` function here + a dispatch entry in ``bench/cli/plot.py``.
Functions take typed inputs (``Path`` lists, options) and write a PNG.
They never touch argparse or stdout — that's the CLI's job.
matplotlib's type stubs are thin (most return values are ``Any``), so all
calls into ``pyplot`` are concentrated at the bottom of this file with
targeted ``# type: ignore[reportUnknownMemberType, reportAny]`` per line.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any, cast
# Tab10 cycle from matplotlib's default; we pick colours by index ourselves
# instead of fishing them out of `Line2D.get_color()` so the strict-type
# fallout stays small and predictable.
_COLOR_CYCLE: tuple[str, ...] = (
"C0",
"C1",
"C2",
"C3",
"C4",
"C5",
"C6",
"C7",
"C8",
"C9",
)
@dataclass(frozen=True)
class PlotInputs:
"""Inputs for any benchmark renderer.
Attributes:
results: One or more bench JSON files. The first is used to
auto-derive the title when ``title`` is unset.
output: Path to write the PNG to.
label_tag: When set, use ``metadata.tags[label_tag]`` as the
legend label for each run; otherwise use the run id.
title: Override for the figure title.
"""
results: list[Path]
output: Path
label_tag: str | None = None
title: str | None = None
@dataclass(frozen=True)
class _RunSeries:
"""Pre-extracted plot data for one results JSON.
``cached_prefill_seconds`` is the cumulative cold-prefill estimate
(``T_cum`` from the methodology — read from ``derived.t_cum_seconds``).
``control_prefill_seconds`` is the actual cold prefill time per
control (``pp_tokens / prompt_tps`` from the cold-control row).
"""
label: str
cached_pp: list[int]
cached_prefill_seconds: list[float]
cached_gen_tps: list[float]
control_pp: list[int]
control_prefill_seconds: list[float]
# ---------------------------------------------------------------------------
# Pure data extraction (strict-typed, no matplotlib)
# ---------------------------------------------------------------------------
def _load(path: Path) -> dict[str, Any]:
"""Read a bench JSON file and assert top-level shape."""
with path.open() as f:
loaded: Any = json.load(f) # type: ignore[reportAny]
if not isinstance(loaded, dict):
raise ValueError(f"{path}: expected top-level JSON object")
return cast("dict[str, Any]", loaded)
# dict[str, Any].get(...) returns Any. The five _get_* helpers below
# concentrate the Any boundary so the rest of the module can be strict.
def _get_dict(d: dict[str, Any], key: str) -> dict[str, Any]:
val: Any = d.get(key)
return cast("dict[str, Any]", val) if isinstance(val, dict) else {}
def _get_list(d: dict[str, Any], key: str) -> list[Any]:
val: Any = d.get(key)
return cast("list[Any]", val) if isinstance(val, list) else []
def _get_str(d: dict[str, Any], key: str, default: str = "") -> str:
val: Any = d.get(key, default) # type: ignore[reportAny]
return val if isinstance(val, str) else default
def _get_int(row: dict[str, Any], key: str) -> int:
val: Any = row.get(key, 0) # type: ignore[reportAny]
if isinstance(val, bool): # bool is int; reject explicitly
return 0
if isinstance(val, (int, float)):
return int(val)
if isinstance(val, str):
try:
return int(float(val))
except ValueError:
return 0
return 0
def _get_float(row: dict[str, Any], key: str) -> float:
val: Any = row.get(key, 0.0) # type: ignore[reportAny]
if isinstance(val, bool):
return 0.0
if isinstance(val, (int, float)):
return float(val)
if isinstance(val, str):
try:
return float(val)
except ValueError:
return 0.0
return 0.0
def _label_for(data: dict[str, Any], label_tag: str | None) -> str:
if label_tag is not None:
tags = _get_dict(_get_dict(data, "metadata"), "tags")
if label_tag in tags:
return _get_str(tags, label_tag, "(unnamed)")
return _get_str(_get_dict(data, "metadata"), "run_id", "(unnamed)")
def _extract_series(data: dict[str, Any], label: str) -> _RunSeries:
"""Pre-extract typed lists from a context-scaling bench JSON."""
cached_pp: list[int] = []
cached_gen_tps: list[float] = []
for raw in _get_list(data, "runs"): # type: ignore[reportAny]
if not isinstance(raw, dict):
continue
row = cast("dict[str, Any]", raw)
if _get_str(row, "phase") != "cached_sweep":
continue
cached_pp.append(_get_int(row, "pp_tokens"))
cached_gen_tps.append(_get_float(row, "generation_tps"))
# Cumulative cold-prefill estimate is computed in derive_summary and
# written to derived.t_cum_seconds (parallel to the cached steps).
derived = _get_dict(data, "derived")
t_cum_raw = _get_list(derived, "t_cum_seconds")
cached_prefill_seconds: list[float] = []
for raw in t_cum_raw: # type: ignore[reportAny]
if isinstance(raw, (int, float)) and not isinstance(raw, bool):
cached_prefill_seconds.append(float(raw))
# Cold controls give us the actual cold prefill time directly:
# pp_tokens / prompt_tps. Skip rows with zero/missing prompt_tps.
control_pp: list[int] = []
control_prefill_seconds: list[float] = []
for raw in _get_list(data, "cold_controls"): # type: ignore[reportAny]
if not isinstance(raw, dict):
continue
row = cast("dict[str, Any]", raw)
pp = _get_int(row, "pp_tokens")
tps = _get_float(row, "prompt_tps")
if pp > 0 and tps > 0:
control_pp.append(pp)
control_prefill_seconds.append(pp / tps)
return _RunSeries(
label=label,
cached_pp=cached_pp,
cached_prefill_seconds=cached_prefill_seconds,
cached_gen_tps=cached_gen_tps,
control_pp=control_pp,
control_prefill_seconds=control_prefill_seconds,
)
def _auto_title(data: dict[str, Any]) -> str:
metadata = _get_dict(data, "metadata")
params = _get_dict(data, "params")
model = (
_get_str(params, "full_model_id") or _get_str(params, "model_id") or "(unknown)"
)
sha = _get_str(metadata, "exo_sha") or "(no-sha)"
host = _get_str(metadata, "hostname") or "(no-host)"
return f"{model}\n{sha} on {host}"
# ---------------------------------------------------------------------------
# Matplotlib boundary — each call site has a narrow, justified ignore.
# ---------------------------------------------------------------------------
def render_context_scaling(inputs: PlotInputs) -> Path:
"""Render a 2-panel context-scaling plot.
Top: pp_tokens vs prompt_tps (line per run; cold controls as 'x' scatter)
Bottom: pp_tokens vs generation_tps (line per run)
Each line is a separate result file. Multi-file mode is for comparing
runs across exo SHAs / hosts / configs; the title is taken from the
first file's metadata unless ``inputs.title`` is set.
"""
if not inputs.results:
raise ValueError("at least one results JSON path is required")
# Validate + extract first so any data-shape error surfaces before we
# even import matplotlib.
first_data: dict[str, Any] | None = None
series: list[_RunSeries] = []
for path in inputs.results:
data = _load(path)
if first_data is None:
first_data = data
benchmark = _get_str(_get_dict(data, "metadata"), "benchmark")
if benchmark != "context_scaling":
raise ValueError(
f"{path}: expected benchmark=='context_scaling', got {benchmark!r}"
)
series.append(_extract_series(data, _label_for(data, inputs.label_tag)))
title = inputs.title
if title is None and first_data is not None:
title = _auto_title(first_data)
if len(inputs.results) > 1:
title = f"{title}\n(comparison of {len(inputs.results)} runs)"
inputs.output.parent.mkdir(parents=True, exist_ok=True)
_draw(series, inputs.output, title=title)
return inputs.output
def _draw(series: list[_RunSeries], output: Path, *, title: str | None) -> None:
"""Concentrated matplotlib boundary."""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
fig, axes = plt.subplots( # type: ignore[reportUnknownMemberType]
2, 1, figsize=(10, 8), sharex=True
)
top: Any = axes[0] # type: ignore[reportAny]
bottom: Any = axes[1] # type: ignore[reportAny]
for i, run in enumerate(series):
color = _COLOR_CYCLE[i % len(_COLOR_CYCLE)]
# Cumulative cold-prefill estimate (T_cum). Only plot points where
# we have a t_cum value — skip if derived was empty for this run.
n = min(len(run.cached_pp), len(run.cached_prefill_seconds))
if n > 0:
top.plot( # type: ignore[reportAny, reportUnknownMemberType]
run.cached_pp[:n],
run.cached_prefill_seconds[:n],
"-o",
color=color,
label=run.label,
)
if run.control_pp:
top.scatter( # type: ignore[reportAny, reportUnknownMemberType]
run.control_pp,
run.control_prefill_seconds,
marker="x",
s=80,
color=color,
label=f"{run.label} (cold one-shot)",
)
bottom.plot( # type: ignore[reportAny, reportUnknownMemberType]
run.cached_pp,
run.cached_gen_tps,
"-o",
color=color,
label=run.label,
)
top.set_ylabel("prefill time (s)") # type: ignore[reportAny, reportUnknownMemberType]
top.set_title( # type: ignore[reportAny, reportUnknownMemberType]
"cumulative cold-prefill time vs context size "
"(line: T_cum estimate; ✕: cold one-shot control)"
)
top.grid(True, alpha=0.3) # type: ignore[reportAny, reportUnknownMemberType]
top.legend(loc="best", fontsize=8) # type: ignore[reportAny, reportUnknownMemberType]
bottom.set_xlabel("pp_tokens") # type: ignore[reportAny, reportUnknownMemberType]
bottom.set_ylabel("generation_tps (tok/s)") # type: ignore[reportAny, reportUnknownMemberType]
bottom.set_title("decode throughput vs context size") # type: ignore[reportAny, reportUnknownMemberType]
bottom.grid(True, alpha=0.3) # type: ignore[reportAny, reportUnknownMemberType]
if title is not None:
fig.suptitle(title, fontsize=10) # type: ignore[reportUnknownMemberType]
fig.tight_layout()
fig.savefig(output, dpi=120, bbox_inches="tight") # type: ignore[reportUnknownMemberType]
plt.close(fig)
+269
View File
@@ -0,0 +1,269 @@
"""Typed prompt-sizing utilities for benchmarks.
Wraps the HuggingFace ``transformers`` tokenizer (a fundamentally dynamic
object — different models return different types from
``apply_chat_template``) behind a small typed API so the rest of the bench
library can stay strict-typed.
``PromptSizer.build(target)`` returns a ``(content, exact_token_count)``
pair. Internally it:
1. Tokenises the empty user message to learn the chat-template overhead
(``base_tokens``).
2. Estimates tokens-per-atom from a 100-atom sample.
3. Binary-searches over the atom count so the resulting message
tokenises to *exactly* ``target`` tokens.
Callers downstream (``run_one_completion`` etc.) receive the verified
token count, so analysis can confirm the prompt hit its target.
"""
from __future__ import annotations
import importlib.util
import json
import sys
import types
from collections.abc import Callable
from pathlib import Path
from typing import Any, Final, cast
def _coerce_token_ids(raw: object) -> list[int]:
"""Normalise ``apply_chat_template`` output to a flat list of token ids.
transformers' ``apply_chat_template`` may return:
- ``list[int]`` (slow tokenizers, ``tokenize=True``)
- a ``BatchEncoding`` with ``.input_ids`` (fast tokenizers)
- a tensor wrapped object (some models)
We only need ``len(.)`` of the result, so we just need to flatten to a
list and return it.
"""
if isinstance(raw, list):
return cast("list[int]", raw)
input_ids = getattr(raw, "input_ids", None)
if isinstance(input_ids, list):
return cast("list[int]", input_ids)
raise TypeError(
f"Unsupported tokenizer output type {type(raw).__name__}; "
"expected list[int] or BatchEncoding-like with .input_ids."
)
def _build_token_counter(tokenizer: object) -> Callable[[str], int]:
"""Return a closure that counts tokens for a user message.
Tries ``apply_chat_template`` first; falls back to the DeepSeek-V4
Python encoder for models that don't ship a Jinja chat template.
"""
apply_chat_template = cast(
Callable[..., object],
tokenizer.apply_chat_template, # type: ignore[reportAttributeAccessIssue, reportUnknownMemberType]
)
encode = cast(
Callable[..., list[int]],
tokenizer.encode, # type: ignore[reportAttributeAccessIssue, reportUnknownMemberType]
)
def count_fn(user_content: str) -> int:
messages = [{"role": "user", "content": user_content}]
try:
raw = apply_chat_template(
messages, tokenize=True, add_generation_prompt=True
)
except ValueError:
# Models without a Jinja chat template (e.g. DeepSeek V4 which
# ships its own Python encoder). Use the exo-side V4 encoder.
from exo.worker.engines.mlx.vendor.deepseek_v4_encoding import ( # type: ignore[reportMissingTypeStubs]
encode_messages as encode_v4,
)
prompt = cast(str, encode_v4(messages, thinking_mode="thinking")) # type: ignore[reportUnknownArgumentType]
raw = encode(prompt, add_special_tokens=False)
return len(_coerce_token_ids(raw))
return count_fn
class PromptSizer:
"""Build a chat-completion content string of an exact token length."""
DEFAULT_ATOM: Final[str] = "a "
def __init__(self, tokenizer: object, atom: str = DEFAULT_ATOM):
self._tokenizer = tokenizer
self.atom = atom
self._count_fn = _build_token_counter(tokenizer)
self.base_tokens = self._count_fn("")
def count(self, content: str) -> int:
"""Return the token count for ``content`` after chat-template expansion."""
return self._count_fn(content)
def build(self, target_prompt_tokens: int) -> tuple[str, int]:
"""Return ``(content, exact_token_count)`` summing to ``target``.
Raises ``RuntimeError`` if the chosen ``atom`` overshoots the target
(try a different atom — see ``DEFAULT_ATOM``).
"""
target = int(target_prompt_tokens)
if target < self.base_tokens:
raise RuntimeError(
f"Target ({target}) is smaller than template overhead "
f"({self.base_tokens})."
)
# Estimate tokens per atom using a sample.
sample_count = 100
sample_tokens = self._count_fn(self.atom * sample_count) - self.base_tokens
tokens_per_atom = sample_tokens / sample_count
needed_tokens = target - self.base_tokens
estimated_atoms = int(needed_tokens / tokens_per_atom)
# Binary search to find exact atom count.
low, high = 0, estimated_atoms * 2 + 100
while low < high:
mid = (low + high) // 2
if self._count_fn(self.atom * mid) < target:
low = mid + 1
else:
high = mid
content = self.atom * low
actual = self._count_fn(content)
if actual != target:
raise RuntimeError(
f"Overshot: got {actual} tokens (target {target}). "
f"Pick a different atom (try ' a' or '\\n' or '0 ')."
)
return content, actual
def _load_kimi_tokenizer(model_id: str) -> object:
"""Special-case Kimi K2's custom TikTokenTokenizer (transformers 5.x quirk)."""
from huggingface_hub import (
snapshot_download, # type: ignore[reportUnknownVariableType]
)
raw_path = snapshot_download(
model_id,
allow_patterns=[
"*.json",
"*.py",
"*.tiktoken",
"*.model",
"*.jinja",
],
dry_run=False,
)
model_path = Path(raw_path)
sys.path.insert(0, str(model_path))
tool_decl_path = model_path / "tool_declaration_ts.py"
if tool_decl_path.exists():
spec = importlib.util.spec_from_file_location(
"tool_declaration_ts", tool_decl_path
)
if spec is not None and spec.loader is not None:
tool_decl_module = importlib.util.module_from_spec(spec)
sys.modules["tool_declaration_ts"] = tool_decl_module
spec.loader.exec_module(tool_decl_module)
tok_path = model_path / "tokenization_kimi.py"
source = tok_path.read_text().replace(
"from .tool_declaration_ts", "from tool_declaration_ts"
)
tok_module = types.ModuleType("tokenization_kimi")
tok_module.__file__ = str(tok_path)
sys.modules["tokenization_kimi"] = tok_module
exec(compile(source, str(tok_path), "exec"), tok_module.__dict__) # noqa: S102
tik_token_cls = cast(Any, tok_module).TikTokenTokenizer # type: ignore[reportAny]
hf_tokenizer = cast(Any, tik_token_cls.from_pretrained(model_path)) # type: ignore[reportAny]
# Patch encode to use internal tiktoken model directly (transformers 5.x
# bug in the encode→pad path for slow tokenizers).
def _patched_encode(text: str, **_kwargs: object) -> list[int]:
return list(
hf_tokenizer.model.encode(text, allowed_special="all") # type: ignore[reportAny, reportUnknownMemberType]
)
hf_tokenizer.encode = _patched_encode
return cast(object, hf_tokenizer)
def load_tokenizer_for_bench(model_id: str) -> object:
"""Load a HuggingFace tokenizer with bench-specific compatibility shims.
Returns the tokenizer as ``object`` because transformers' types are
fundamentally dynamic (concrete class depends on the model). Callers
should pass the result straight to :class:`PromptSizer`.
"""
# Monkey-patch for transformers 5.x: Kimi's tokenization_kimi.py imports
# bytes_to_unicode from gpt2_tokenization which moved.
try:
import transformers.models.gpt2.tokenization_gpt2 as gpt2_tokenization
from transformers.convert_slow_tokenizer import bytes_to_unicode
if not hasattr(gpt2_tokenization, "bytes_to_unicode"):
gpt2_tokenization.bytes_to_unicode = bytes_to_unicode # type: ignore[reportAttributeAccessIssue]
except ImportError:
pass
if "kimi-k2" in model_id.lower():
return _load_kimi_tokenizer(model_id)
from transformers import AutoTokenizer
try:
return cast(
object,
AutoTokenizer.from_pretrained(model_id, trust_remote_code=True), # type: ignore[reportUnknownMemberType]
)
except (AttributeError, ValueError):
# Some models ship a Jinja template / encoder that AutoTokenizer
# can't introspect from HF directly — download artefacts and load
# from the local snapshot path.
from huggingface_hub import (
snapshot_download, # type: ignore[reportUnknownVariableType]
)
from transformers import PretrainedConfig
raw_full_path = snapshot_download(
model_id,
allow_patterns=[
"*.json",
"*.py",
"tokenizer.model",
"*.tiktoken",
"tiktoken.model",
"*.txt",
"*.jsonl",
"*.jinja",
],
dry_run=False,
)
model_path = Path(raw_full_path)
stub_kwargs: dict[str, Any] = {}
config_file = model_path / "config.json"
if config_file.exists():
with config_file.open() as f:
raw_config: dict[str, Any] = json.load(f) # type: ignore[reportAny]
for key in (
"model_type",
"max_position_embeddings",
"vocab_size",
"bos_token_id",
"eos_token_id",
"pad_token_id",
):
if key in raw_config:
stub_kwargs[key] = raw_config[key]
return cast(
object,
AutoTokenizer.from_pretrained( # type: ignore[reportUnknownMemberType]
str(model_path),
config=PretrainedConfig(**stub_kwargs), # type: ignore[reportArgumentType, reportAny]
trust_remote_code=True,
),
)
+139
View File
@@ -0,0 +1,139 @@
"""Structured benchmark results — metadata capture + JSON output.
Every benchmark run produces a single JSON file with a stable schema:
- ``metadata``: exo SHA, ISO timestamps, hostnames, and any user-supplied
tags identifying the run.
- ``cluster``: snapshot from the API (node identities, topology, memory).
- ``params``: the benchmark's input parameters (sweep config, etc).
- ``runs``: per-request result rows.
- ``derived``: any computed summaries (``t_cum_seconds`` for context scaling).
The format is intentionally additive so downstream tooling (plot scripts,
dashboards) can rely on optional fields being absent rather than malformed.
"""
from __future__ import annotations
import json
import os
import platform
import socket
import subprocess
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from exo_tools.client import ExoClient
from exo_tools.harness import capture_cluster_snapshot
def _git_describe(repo_root: Path) -> str | None:
"""Return ``<short-sha>[-dirty]`` for the repo at ``repo_root`` or None."""
try:
sha = subprocess.run(
["git", "rev-parse", "--short=12", "HEAD"],
cwd=str(repo_root),
capture_output=True,
text=True,
timeout=5,
check=True,
).stdout.strip()
except (
subprocess.CalledProcessError,
FileNotFoundError,
subprocess.TimeoutExpired,
):
return None
try:
dirty = subprocess.run(
["git", "status", "--porcelain"],
cwd=str(repo_root),
capture_output=True,
text=True,
timeout=5,
check=True,
).stdout.strip()
return f"{sha}-dirty" if dirty else sha
except (
subprocess.CalledProcessError,
FileNotFoundError,
subprocess.TimeoutExpired,
):
return sha
@dataclass
class RunMetadata:
"""Identifies a single bench run."""
run_id: str
benchmark: str
started_at: str
finished_at: str | None = None
exo_sha: str | None = None
hostname: str = ""
platform: str = ""
tags: dict[str, str] = field(default_factory=dict)
@classmethod
def new(
cls,
benchmark: str,
repo_root: Path,
*,
tags: dict[str, str] | None = None,
) -> RunMetadata:
now = datetime.now(timezone.utc)
run_id = f"{benchmark}_{now.strftime('%Y%m%dT%H%M%SZ')}_{os.getpid()}"
return cls(
run_id=run_id,
benchmark=benchmark,
started_at=now.isoformat(),
exo_sha=_git_describe(repo_root),
hostname=socket.gethostname(),
platform=f"{platform.system()} {platform.release()} ({platform.machine()})",
tags=dict(tags or {}),
)
@dataclass
class ResultsBundle:
"""Container for a single benchmark's results, before being written."""
metadata: RunMetadata
params: dict[str, Any] = field(default_factory=dict)
cluster: dict[str, Any] = field(default_factory=dict)
runs: list[dict[str, Any]] = field(default_factory=list)
cold_controls: list[dict[str, Any]] = field(default_factory=list)
derived: dict[str, Any] = field(default_factory=dict)
def capture_cluster(self, client: ExoClient) -> None:
"""Snapshot the cluster state into ``self.cluster``."""
try:
snapshot = capture_cluster_snapshot(client)
if snapshot:
self.cluster.update(snapshot)
except Exception:
# Non-fatal: a benchmark without cluster snapshot is still valid
pass
def write_json(self, output_dir: Path) -> Path:
"""Write the bundle as ``<output_dir>/<run_id>.json`` and return the path."""
if self.metadata.finished_at is None:
self.metadata.finished_at = datetime.now(timezone.utc).isoformat()
output_dir.mkdir(parents=True, exist_ok=True)
path = output_dir / f"{self.metadata.run_id}.json"
with path.open("w", encoding="utf-8") as f:
json.dump(asdict(self), f, indent=2, ensure_ascii=False)
return path
def find_repo_root(start: Path | None = None) -> Path:
"""Walk upwards from ``start`` (or this file) until a ``.git`` dir is found."""
cur = (start or Path(__file__)).resolve()
for parent in (cur, *cur.parents):
if (parent / ".git").is_dir() or (parent / ".git").is_file():
return parent
raise RuntimeError(f"Could not locate repo root above {cur}")
+65
View File
@@ -0,0 +1,65 @@
"""BenchSession — wires together cluster + client + instance + tokenizer.
Holds the ``EcoSession``, a deployed ``ClusterInfo``, an ``ExoClient`` for
the cluster's primary endpoint, and (for benchmarks that need exact-token
prompts) a lazily-constructed :class:`PromptSizer`.
Benchmarks consume this via :func:`bench.lib.cluster.managed_instance`,
which yields a populated ``BenchSession``. Library helpers (e.g.
``context_scaling.run``) take a ``BenchSession`` and never reach for
global state.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, cast
from exo_tools.client import ExoClient
from exo_tools.cluster import ClusterInfo, EcoSession, make_client_from_url
from .prompt import PromptSizer, load_tokenizer_for_bench
@dataclass
class BenchSession:
"""Bundle of cluster + client + (optional) instance for benchmarks."""
cluster: ClusterInfo
eco: EcoSession
instance_id: str | None = None
model_id: str | None = None
full_model_id: str | None = None
_prompt_sizer: PromptSizer | None = field(default=None, repr=False)
@property
def client(self) -> ExoClient:
return make_client_from_url(self.cluster.api_url)
def state(self) -> dict[str, Any]:
raw: Any = self.client.request_json("GET", "/state") # type: ignore[reportAny]
if isinstance(raw, dict):
return cast("dict[str, Any]", raw)
return {}
def instances(self) -> dict[str, Any]:
result: Any = self.state().get("instances", {}) # type: ignore[reportAny]
if isinstance(result, dict):
return cast("dict[str, Any]", result)
return {}
def get_prompt_sizer(self) -> PromptSizer:
"""Return a cached :class:`PromptSizer` for ``self.full_model_id``.
Loaded lazily because tokenizer load is expensive and not every
benchmark needs prompt sizing.
"""
if self._prompt_sizer is not None:
return self._prompt_sizer
if self.full_model_id is None:
raise RuntimeError(
"BenchSession.full_model_id is not set; cannot build a PromptSizer."
)
tokenizer = load_tokenizer_for_bench(self.full_model_id)
self._prompt_sizer = PromptSizer(tokenizer)
return self._prompt_sizer
View File
Whitespace-only changes.
+186
View File
@@ -0,0 +1,186 @@
"""Unit tests for the pure helpers in ``bench.lib.context_scaling``.
The orchestration entry points (``run``, ``run_cached_sweep``,
``run_cold_controls``, ``make_cold_control_factory``) need a real
``BenchSession`` and exo cluster, so they're exercised end-to-end via
``python -m bench.cli context-scaling``. This module covers the
underscore-prefixed pure helpers via direct private-symbol access (the
private prefix discourages library users; tests for those helpers are
the explicit exception).
"""
from __future__ import annotations
import math
from typing import cast
from bench.lib.context_scaling import (
StepResult,
_compute_t_cum, # type: ignore[reportPrivateUsage]
_interp, # type: ignore[reportPrivateUsage]
derive_summary,
)
def _step(
*,
pp: int,
delta: int,
prompt_tps: float,
generation_tps: float = 100.0,
hit: str = "partial",
) -> StepResult:
return StepResult(
pp_tokens=pp,
delta_tokens=delta,
prompt_tps=prompt_tps,
generation_tps=generation_tps,
prefix_cache_hit=hit,
prompt_tokens=pp,
generation_tokens=32,
elapsed_s=delta / prompt_tps if prompt_tps else 0.0,
)
def _close(actual: float, expected: float, abs_tol: float = 1e-3) -> bool:
return math.isclose(actual, expected, abs_tol=abs_tol)
# ---------------------------------------------------------------------------
# _compute_t_cum
# ---------------------------------------------------------------------------
class TestComputeTCum:
def test_empty_returns_empty(self) -> None:
assert _compute_t_cum([]) == []
def test_single_step(self) -> None:
# 256 tokens at 1024 tps -> 0.25s
out = _compute_t_cum([_step(pp=256, delta=256, prompt_tps=1024.0)])
assert len(out) == 1
assert _close(out[0], 0.25)
def test_cumulative_sum_across_three_steps(self) -> None:
steps = [
_step(pp=256, delta=256, prompt_tps=1000.0), # 0.256s
_step(pp=512, delta=256, prompt_tps=2000.0), # +0.128s = 0.384s
_step(pp=768, delta=256, prompt_tps=512.0), # +0.500s = 0.884s
]
out = _compute_t_cum(steps)
assert _close(out[0], 0.256)
assert _close(out[1], 0.384)
assert _close(out[2], 0.884)
# Monotonically non-decreasing
assert out == sorted(out)
def test_zero_tps_step_skipped(self) -> None:
# A row with prompt_tps == 0 contributes nothing to the cumulative sum
steps = [
_step(pp=256, delta=256, prompt_tps=1024.0), # +0.25s
_step(pp=512, delta=256, prompt_tps=0.0), # +0
_step(pp=768, delta=256, prompt_tps=512.0), # +0.5s
]
out = _compute_t_cum(steps)
assert _close(out[0], 0.25)
assert _close(out[1], 0.25) # unchanged
assert _close(out[2], 0.75)
def test_zero_delta_step_skipped(self) -> None:
# Defensive: a Δ=0 row would otherwise add zero anyway, but we
# explicitly guard against negative delta + 0/0.
steps = [
_step(pp=256, delta=256, prompt_tps=1000.0),
_step(pp=256, delta=0, prompt_tps=1000.0), # explicit Δ=0
]
out = _compute_t_cum(steps)
assert _close(out[0], 0.256)
assert _close(out[1], 0.256)
# ---------------------------------------------------------------------------
# _interp
# ---------------------------------------------------------------------------
class TestInterp:
def test_empty_points_returns_zero(self) -> None:
assert _interp([], 100) == 0.0
def test_single_point_returns_y(self) -> None:
assert _interp([(100, 1.5)], 50) == 1.5
assert _interp([(100, 1.5)], 100) == 1.5
assert _interp([(100, 1.5)], 200) == 1.5
def test_clamps_below_first(self) -> None:
points = [(100, 0.1), (200, 0.3), (300, 0.6)]
assert _interp(points, 0) == 0.1
assert _interp(points, 50) == 0.1
assert _interp(points, 100) == 0.1
def test_clamps_above_last(self) -> None:
points = [(100, 0.1), (200, 0.3), (300, 0.6)]
assert _interp(points, 300) == 0.6
assert _interp(points, 500) == 0.6
assert _interp(points, 1_000_000) == 0.6
def test_mid_bracket_linear_interpolation(self) -> None:
points = [(100, 0.0), (200, 1.0)]
assert _close(_interp(points, 150), 0.5)
assert _close(_interp(points, 175), 0.75)
def test_multi_segment_linear_interpolation(self) -> None:
# Two adjacent segments, x=250 falls in the second one
points = [(100, 0.1), (200, 0.3), (300, 0.6)]
# 200..300: 0.3 + (0.6-0.3) * (250-200)/(300-200) = 0.3 + 0.15 = 0.45
assert _close(_interp(points, 250), 0.45)
# ---------------------------------------------------------------------------
# derive_summary
# ---------------------------------------------------------------------------
def _gap_at(summary: dict[str, object], index: int) -> dict[str, float]:
"""Cast ``summary['control_gaps'][index]`` into the typed shape we expect."""
raw = summary["control_gaps"]
assert isinstance(raw, list)
entry = cast("dict[str, float]", raw[index])
return entry
class TestDeriveSummary:
def test_no_controls_only_t_cum(self) -> None:
steps = [
_step(pp=256, delta=256, prompt_tps=1024.0),
_step(pp=512, delta=256, prompt_tps=1024.0),
]
summary = derive_summary(steps, [])
t_cum = cast("list[float]", summary["t_cum_seconds"])
assert _close(t_cum[0], 0.25)
assert _close(t_cum[1], 0.5)
assert summary["control_gaps"] == []
def test_control_gap_at_known_pp(self) -> None:
# Sweep: 0.25s @ pp=256, 0.5s @ pp=512
steps = [
_step(pp=256, delta=256, prompt_tps=1024.0),
_step(pp=512, delta=256, prompt_tps=1024.0),
]
# Cold control at pp=512, 2x faster than the per-step rate -> 0.25s
controls = [_step(pp=512, delta=512, prompt_tps=2048.0, hit="none")]
summary = derive_summary(steps, controls)
gap = _gap_at(summary, 0)
assert gap["pp_tokens"] == 512
assert _close(gap["cold_t_seconds"], 0.25, abs_tol=0.01)
assert _close(gap["t_cum_seconds_at_pp"], 0.5, abs_tol=0.01)
assert _close(gap["gap_seconds"], 0.25, abs_tol=0.01)
# gap_fraction = 0.25 / 0.25 = 1.0
assert _close(gap["gap_fraction"], 1.0, abs_tol=0.01)
def test_control_gap_zero_cold_tps_yields_zero_fraction(self) -> None:
steps = [_step(pp=256, delta=256, prompt_tps=1000.0)]
controls = [_step(pp=256, delta=256, prompt_tps=0.0, hit="none")]
gap = _gap_at(derive_summary(steps, controls), 0)
assert gap["cold_t_seconds"] == 0.0
assert gap["gap_fraction"] == 0.0
+171
View File
@@ -0,0 +1,171 @@
"""Unit tests for ``bench.lib.model_meta``.
These exercise the pure derivation helpers (no HF round-trip). The HTTP
fetchers (``fetch_model_meta``, ``_read_config_json``, ``_sum_weight_sizes``)
hit the public hub and aren't covered here.
"""
from __future__ import annotations
import math
import pytest
from bench.lib.model_meta import (
ModelMeta,
derive_cold_controls,
derive_context_ramp,
)
def _meta(
*,
weight_bytes: int = 0,
max_pos: int = 4096,
layers: int = 32,
) -> ModelMeta:
return ModelMeta(
model_id="test/model",
total_weight_bytes=weight_bytes,
max_position_embeddings=max_pos,
num_hidden_layers=layers,
)
# ---------------------------------------------------------------------------
# ModelMeta properties
# ---------------------------------------------------------------------------
class TestModelMetaConstraints:
def test_zero_weight_yields_one_gib_floor(self) -> None:
meta = _meta(weight_bytes=0)
# int(0 * 1.30) + 1 == 1; int(0 * 1.10) + 1 == 1
assert meta.memory_constraint_gb == 1.0
assert meta.disk_constraint_gb == 1.0
def test_one_gib_weight_rounds_up(self) -> None:
meta = _meta(weight_bytes=1 * (1024**3))
# int(1.0 * 1.30) + 1 = 2; int(1.0 * 1.10) + 1 = 2
assert meta.memory_constraint_gb == 2.0
assert meta.disk_constraint_gb == 2.0
def test_sixteen_gib_weight_uses_30pct_memory_10pct_disk(self) -> None:
meta = _meta(weight_bytes=16 * (1024**3))
# memory: int(16 * 1.30) + 1 = 21; disk: int(16 * 1.10) + 1 = 18
assert meta.memory_constraint_gb == 21.0
assert meta.disk_constraint_gb == 18.0
def test_total_weight_gb_property(self) -> None:
meta = _meta(weight_bytes=2_147_483_648) # 2 GiB exactly
assert math.isclose(meta.total_weight_gb, 2.0)
# ---------------------------------------------------------------------------
# derive_context_ramp
# ---------------------------------------------------------------------------
class TestDeriveContextRamp:
def test_full_max_evenly_divides_round_to(self) -> None:
meta = _meta(max_pos=131072) # 128k
pp_step, num_steps = derive_context_ramp(meta, num_steps=32)
# 131072 // 32 = 4096; rounded down to multiple of 256 = 4096
assert pp_step == 4096
assert num_steps == 32
# Top of ramp == max
assert pp_step * num_steps == 131072
def test_qwen30b_a3b_ramp(self) -> None:
meta = _meta(max_pos=40960) # Qwen3-30B-A3B
pp_step, num_steps = derive_context_ramp(meta, num_steps=32)
# 40960 // 32 = 1280; multiple of 256
assert pp_step == 1280
assert pp_step * num_steps == 40960
def test_fraction_of_max_half(self) -> None:
meta = _meta(max_pos=131072)
pp_step, num_steps = derive_context_ramp(meta, num_steps=8, fraction_of_max=0.5)
# half = 65536; 65536 // 8 = 8192
assert pp_step == 8192
assert num_steps == 8
def test_min_pp_step_floor(self) -> None:
meta = _meta(max_pos=512)
# 512 // 32 = 16, but min_pp_step=256 floors it; rounded to 256
pp_step, num_steps = derive_context_ramp(meta, num_steps=32)
assert pp_step == 256
assert num_steps == 32
def test_round_to_truncates_down(self) -> None:
meta = _meta(max_pos=10000)
pp_step, _ = derive_context_ramp(meta, num_steps=32, round_to=256)
# 10000 // 32 = 312; (312 // 256) * 256 = 256
assert pp_step == 256
def test_round_to_zero_step_falls_back_to_round_to(self) -> None:
# Pathological: huge round_to relative to per-step size
meta = _meta(max_pos=1024)
pp_step, _ = derive_context_ramp(meta, num_steps=8, round_to=1024)
# 1024 // 8 = 128, but min_pp_step=256 → 256; (256 // 1024) * 1024 = 0;
# `or round_to` rescues to 1024.
assert pp_step == 1024
def test_max_pos_zero_raises(self) -> None:
meta = _meta(max_pos=0)
with pytest.raises(ValueError, match="max_position_embeddings=0"):
_ = derive_context_ramp(meta, num_steps=32)
@pytest.mark.parametrize("fraction", [0.0, -0.1, 1.5, 2.0])
def test_fraction_outside_unit_interval_raises(self, fraction: float) -> None:
meta = _meta(max_pos=4096)
with pytest.raises(ValueError, match="fraction_of_max"):
_ = derive_context_ramp(meta, num_steps=4, fraction_of_max=fraction)
@pytest.mark.parametrize("steps", [0, -1, -100])
def test_num_steps_must_be_positive(self, steps: int) -> None:
meta = _meta(max_pos=4096)
with pytest.raises(ValueError, match="num_steps"):
_ = derive_context_ramp(meta, num_steps=steps)
# ---------------------------------------------------------------------------
# derive_cold_controls
# ---------------------------------------------------------------------------
class TestDeriveColdControls:
def test_count_zero_returns_empty_tuple(self) -> None:
meta = _meta()
assert derive_cold_controls(meta, pp_step=4096, num_steps=32, count=0) == ()
def test_count_one_returns_top_only(self) -> None:
meta = _meta()
assert derive_cold_controls(meta, pp_step=4096, num_steps=32, count=1) == (
131072,
)
def test_evenly_spaced_four(self) -> None:
meta = _meta()
out = derive_cold_controls(meta, pp_step=4096, num_steps=32, count=4)
# max_pp = 131072; (131072 * (i+1)) // 4 for i in {0,1,2,3}
# = {32768, 65536, 98304, 131072}
assert out == (32768, 65536, 98304, 131072)
def test_filters_below_pp_step(self) -> None:
meta = _meta()
out = derive_cold_controls(meta, pp_step=8192, num_steps=2, count=4)
# max_pp = 16384; spaced points = {4096, 8192, 12288, 16384};
# 4096 < pp_step=8192 → dropped.
assert out == (8192, 12288, 16384)
def test_dedups_at_low_count_high_step(self) -> None:
meta = _meta()
# max_pp = 1024; count=2 → spaced = {512, 1024}; 512 < pp_step? No (=).
out = derive_cold_controls(meta, pp_step=512, num_steps=2, count=2)
assert out == (512, 1024)
def test_returned_in_ascending_order(self) -> None:
meta = _meta()
out = derive_cold_controls(meta, pp_step=1024, num_steps=8, count=4)
assert list(out) == sorted(out)
+189
View File
@@ -0,0 +1,189 @@
"""Smoke tests for ``bench.lib.plotting``.
Renders a synthetic benchmark JSON to a tmp PNG and verifies the file is
non-empty. We deliberately don't assert on pixel values — matplotlib
output isn't byte-stable across versions — but a non-empty PNG with a
valid header is a strong signal the renderer didn't throw.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, cast
import pytest
from bench.lib.plotting import PlotInputs, render_context_scaling
def _write_synthetic_run(path: Path, *, run_id: str, model: str = "test/model") -> None:
"""Write a minimal context-scaling-shaped JSON for plotting tests."""
payload = {
"metadata": {
"run_id": run_id,
"benchmark": "context_scaling",
"started_at": "2026-05-10T00:00:00Z",
"exo_sha": "deadbeef",
"hostname": "test-host",
"platform": "Linux 6.0 (x86_64)",
"tags": {"operator": "tester"},
},
"params": {
"pp_step": 256,
"num_steps": 4,
"tg": 32,
"warmup": 1,
"full_model_id": model,
},
"cluster": {},
"runs": [
{
"step_index": 0,
"phase": "cached_sweep",
"pp_tokens": 256,
"delta_tokens": 256,
"prompt_tps": 1800.0,
"generation_tps": 410.0,
"prefix_cache_hit": "exact",
"prompt_tokens": 256,
"generation_tokens": 32,
"elapsed_s": 0.14,
"peak_memory_bytes": 1_000_000_000,
"output_text_preview": "",
},
{
"step_index": 1,
"phase": "cached_sweep",
"pp_tokens": 512,
"delta_tokens": 256,
"prompt_tps": 2000.0,
"generation_tps": 395.0,
"prefix_cache_hit": "partial",
"prompt_tokens": 512,
"generation_tokens": 32,
"elapsed_s": 0.13,
"peak_memory_bytes": 1_100_000_000,
"output_text_preview": "",
},
{
"step_index": 2,
"phase": "cached_sweep",
"pp_tokens": 768,
"delta_tokens": 256,
"prompt_tps": 2200.0,
"generation_tps": 378.0,
"prefix_cache_hit": "partial",
"prompt_tokens": 768,
"generation_tokens": 32,
"elapsed_s": 0.12,
"peak_memory_bytes": 1_200_000_000,
"output_text_preview": "",
},
],
"cold_controls": [
{
"phase": "cold_control",
"pp_tokens": 512,
"delta_tokens": 512,
"prompt_tps": 3200.0,
"generation_tps": 400.0,
"prefix_cache_hit": "none",
"prompt_tokens": 512,
"generation_tokens": 32,
"elapsed_s": 0.16,
"peak_memory_bytes": 1_500_000_000,
"output_text_preview": "",
},
],
"derived": {
"t_cum_seconds": [0.14, 0.27, 0.39],
"control_gaps": [],
},
}
_ = path.write_text(json.dumps(payload))
def _png_is_valid(path: Path) -> bool:
"""A PNG file starts with the 8-byte magic ``\\x89PNG\\r\\n\\x1a\\n``."""
if not path.is_file():
return False
if path.stat().st_size < 100:
return False
head = path.read_bytes()[:8]
return head == b"\x89PNG\r\n\x1a\n"
# ---------------------------------------------------------------------------
class TestRenderContextScaling:
def test_single_run(self, tmp_path: Path) -> None:
json_path = tmp_path / "run.json"
_write_synthetic_run(json_path, run_id="r1")
out = tmp_path / "out.png"
returned = render_context_scaling(PlotInputs(results=[json_path], output=out))
assert returned == out
assert _png_is_valid(out)
def test_creates_output_parent_dir(self, tmp_path: Path) -> None:
json_path = tmp_path / "run.json"
_write_synthetic_run(json_path, run_id="r1")
out = tmp_path / "nested" / "deep" / "out.png"
_ = render_context_scaling(PlotInputs(results=[json_path], output=out))
assert _png_is_valid(out)
def test_comparison_two_runs(self, tmp_path: Path) -> None:
a = tmp_path / "a.json"
b = tmp_path / "b.json"
_write_synthetic_run(a, run_id="run-a", model="test/model-a")
_write_synthetic_run(b, run_id="run-b", model="test/model-b")
out = tmp_path / "compare.png"
_ = render_context_scaling(PlotInputs(results=[a, b], output=out))
assert _png_is_valid(out)
def test_label_tag_uses_metadata_tag(self, tmp_path: Path) -> None:
# Smoke test: just confirm passing label_tag doesn't throw and the
# PNG renders. Label content is too matplotlib-internal to inspect.
json_path = tmp_path / "run.json"
_write_synthetic_run(json_path, run_id="r1")
out = tmp_path / "out.png"
_ = render_context_scaling(
PlotInputs(results=[json_path], output=out, label_tag="operator")
)
assert _png_is_valid(out)
def test_explicit_title(self, tmp_path: Path) -> None:
json_path = tmp_path / "run.json"
_write_synthetic_run(json_path, run_id="r1")
out = tmp_path / "out.png"
_ = render_context_scaling(
PlotInputs(results=[json_path], output=out, title="Custom Title")
)
assert _png_is_valid(out)
def test_empty_results_raises(self, tmp_path: Path) -> None:
with pytest.raises(ValueError, match="at least one"):
_ = render_context_scaling(
PlotInputs(results=[], output=tmp_path / "out.png")
)
def test_wrong_benchmark_raises(self, tmp_path: Path) -> None:
# Same shape but with the wrong metadata.benchmark
json_path = tmp_path / "run.json"
_write_synthetic_run(json_path, run_id="r1")
raw_loaded: Any = json.loads(json_path.read_text()) # type: ignore[reportAny]
assert isinstance(raw_loaded, dict)
data = cast("dict[str, dict[str, str]]", raw_loaded)
data["metadata"]["benchmark"] = "something_else"
_ = json_path.write_text(json.dumps(data))
with pytest.raises(ValueError, match="context_scaling"):
_ = render_context_scaling(
PlotInputs(results=[json_path], output=tmp_path / "out.png")
)
+2 -3
View File
@@ -35,9 +35,8 @@ from exo_bench import (
load_tokenizer_for_bench,
parse_int_list,
)
from harness import (
ExoClient,
ExoHttpError,
from exo_tools.client import ExoClient, ExoHttpError
from exo_tools.harness import (
add_common_instance_args,
instance_id_from_instance,
node_ids_from_instance,
+1
View File
@@ -16,6 +16,7 @@ dependencies = [
"lm-eval[api,math]>=0.4.0",
"human-eval>=1.0.3",
"numpy>=1.24.0",
"matplotlib>=3.8",
]
[build-system]
+17 -4
View File
@@ -40,6 +40,7 @@ exo = "exo.main:main"
dev = [
"basedpyright>=1.29.0",
"pyinstaller>=6.17.0",
"playwright>=1.52.0",
"pytest>=8.4.0",
"pytest-asyncio>=1.0.0",
"pytest-env",
@@ -75,7 +76,7 @@ cuda13 = [
###
[tool.uv.workspace]
members = ["rust/exo_pyo3_bindings", "bench"]
members = ["rust/exo_pyo3_bindings", "bench", "tools"]
[tool.uv.sources]
exo-pyo3-bindings = { workspace = true }
@@ -112,7 +113,7 @@ build-backend = "uv_build"
###
[tool.basedpyright]
include = ["src", "bench"]
include = ["src", "bench", "tools"]
typeCheckingMode = "strict"
failOnWarnings = true
@@ -146,6 +147,15 @@ reportMissingModuleSource = false
[[tool.basedpyright.executionEnvironments]]
root = "src"
[[tool.basedpyright.executionEnvironments]]
root = "bench"
# `.` keeps `from bench.lib.X import …` resolvable (pytest adds the project
# root to sys.path; we want type-checking to agree with runtime).
extraPaths = ["tools/src", "."]
[[tool.basedpyright.executionEnvironments]]
root = "tools/src"
###
# uv configuration
@@ -216,9 +226,12 @@ extend-exclude = [
extend-select = ["I", "N", "B", "A", "PIE", "SIM"]
[tool.pytest.ini_options]
pythonpath = "."
pythonpath = ["."]
asyncio_mode = "auto"
markers = ["slow: marks tests as slow (deselected by default)"]
env = ["EXO_TESTS=1"]
addopts = "-m 'not slow' --ignore=tests/start_distributed_test.py"
# `tests/` requires an eco cluster (opt-in). `tmp/` holds throwaway scripts
# that run a top-level `sys.exit(...)` at import time, which otherwise blows
# up the default pytest collection.
addopts = "-m 'not slow' --ignore=tests --ignore=tmp"
filterwarnings = ["ignore:builtin type Swig:DeprecationWarning"]
+3
View File
@@ -46,9 +46,12 @@ pyo3-async-runtimes = { version = "0.27.0", features = [
] }
pyo3-log = "0.13.2"
pidfile-rs = "0.3"
# macro dependencies
extend = { workspace = true }
delegate = { workspace = true }
thiserror = "2.0"
# async runtime
tokio = { workspace = true, features = ["full", "tracing"] }
@@ -2,6 +2,8 @@
# ruff: noqa: E501, F401
import builtins
import os
import pathlib
import typing
@typing.final
@@ -69,6 +71,48 @@ class NoPeersSubscribedToTopicError(builtins.Exception):
def __repr__(self) -> builtins.str: ...
def __str__(self) -> builtins.str: ...
@typing.final
class Pidfile:
r"""
A PID file protected with a lock.
An instance of `Pidfile` can be used to manage a PID file: create it,
lock it, detect already running daemons. It is backed by [`pidfile`][]
functions of `libbsd`/`libutil` which use `flopen` to lock the PID
file.
When a PID file is created, the process ID of the current process is
*not* written there, making it possible to lock the PID file before
forking and only write the ID of the forked process when it is ready.
The PID file is deleted automatically when the `Pidfile` comes out of
the scope. To close the PID file without deleting it, for example, in
the parent process of a forked daemon, call `close()`.
[`exit`]: https://doc.rust-lang.org/std/process/fn.exit.html
[`pidfile`]: https://linux.die.net/man/3/pidfile
[`daemon`(3)]: https://linux.die.net/man/3/daemon
"""
def __new__(cls, path: builtins.str | os.PathLike | pathlib.Path, mode: builtins.int) -> Pidfile:
r"""
Creates a new PID file and locks it.
If the PID file cannot be locked, returns `PidfileError::AlreadyRunning` with
a PID of the already running process, or `None` if no PID has been written to
the PID file yet.
"""
def write(self) -> None:
r"""
Writes the current process ID to the PID file.
The file is truncated before writing.
"""
@typing.final
class PidfileError(builtins.Exception):
def __repr__(self) -> builtins.str: ...
def __str__(self) -> builtins.str: ...
class PyFromSwarm:
@typing.final
class Connection(PyFromSwarm):
+3
View File
@@ -7,9 +7,11 @@
mod allow_threading;
mod ident;
mod networking;
mod pidfile;
use crate::ident::PyKeypair;
use crate::networking::networking_submodule;
use crate::pidfile::pidfile_submodule;
use pyo3::prelude::PyModule;
use pyo3::types::PyModuleMethods;
use pyo3::{Bound, PyResult, pyclass, pymodule};
@@ -164,6 +166,7 @@ fn main_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
// too many importing issues...
m.add_class::<PyKeypair>()?;
networking_submodule(m)?;
pidfile_submodule(m)?;
// top-level constructs
// TODO: ...
+87
View File
@@ -0,0 +1,87 @@
use pidfile_rs::{Pidfile, PidfileError};
use pyo3::exceptions::PyException;
use pyo3::prelude::{PyModule, PyModuleMethods};
use pyo3::{Bound, PyErr, PyResult, Python, pyclass, pymethods};
use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods};
use std::fs::Permissions;
use std::os::unix::prelude::PermissionsExt;
use std::path::PathBuf;
#[gen_stub_pyclass]
#[pyclass(frozen, extends=PyException, name="PidfileError")]
pub struct PyPidfileError(PidfileError);
impl PyPidfileError {
// TODO: I actually like this pattern a LOT more but how to abstract??
fn into_pyerr(self, py: Python) -> PyErr {
match Bound::new(py, self) {
Ok(err) => PyErr::from_value(err.into_any()),
Err(err) => err,
}
}
}
#[gen_stub_pymethods]
#[pymethods]
impl PyPidfileError {
fn __repr__(&self) -> String {
format!("PidfileError(\"{}\")", self.0)
}
fn __str__(&self) -> String {
self.0.to_string()
}
}
/// A PID file protected with a lock.
///
/// An instance of `Pidfile` can be used to manage a PID file: create it,
/// lock it, detect already running daemons. It is backed by [`pidfile`][]
/// functions of `libbsd`/`libutil` which use `flopen` to lock the PID
/// file.
///
/// When a PID file is created, the process ID of the current process is
/// *not* written there, making it possible to lock the PID file before
/// forking and only write the ID of the forked process when it is ready.
///
/// The PID file is deleted automatically when the `Pidfile` comes out of
/// the scope. To close the PID file without deleting it, for example, in
/// the parent process of a forked daemon, call `close()`.
///
/// [`exit`]: https://doc.rust-lang.org/std/process/fn.exit.html
/// [`pidfile`]: https://linux.die.net/man/3/pidfile
/// [`daemon`(3)]: https://linux.die.net/man/3/daemon
#[gen_stub_pyclass]
#[pyclass(name = "Pidfile")]
pub struct PyPidfile(Pidfile);
#[gen_stub_pymethods]
#[pymethods]
impl PyPidfile {
/// Creates a new PID file and locks it.
///
/// If the PID file cannot be locked, returns `PidfileError::AlreadyRunning` with
/// a PID of the already running process, or `None` if no PID has been written to
/// the PID file yet.
#[new]
fn py_new(py: Python, path: PathBuf, mode: u32) -> PyResult<Self> {
Ok(Self(
Pidfile::new(&path, Permissions::from_mode(mode))
.map_err(|e| PyPidfileError(e).into_pyerr(py))?,
))
}
/// Writes the current process ID to the PID file.
///
/// The file is truncated before writing.
fn write<'py>(&mut self, py: Python<'py>) -> PyResult<()> {
self.0.write().map_err(|e| PyPidfileError(e).into_pyerr(py))
}
}
pub fn pidfile_submodule(m: &Bound<PyModule>) -> PyResult<()> {
m.add_class::<PyPidfileError>()?;
m.add_class::<PyPidfile>()?;
Ok(())
}
@@ -1,10 +1,12 @@
import asyncio
import pytest
from _pytest.capture import CaptureFixture
from exo_pyo3_bindings import (
Keypair,
NetworkingHandle,
NoPeersSubscribedToTopicError,
Pidfile,
PyFromSwarm,
)
@@ -26,6 +28,13 @@ async def test_sleep_on_multiple_items() -> None:
print("caught it", e)
def test_pidfile(capsys: CaptureFixture[str]):
with capsys.disabled():
print("\nbefore python")
scoped_lock_file()
print("after python")
async def _await_recv(h: NetworkingHandle):
while True:
event = await h.recv()
@@ -34,3 +43,7 @@ async def _await_recv(h: NetworkingHandle):
print(f"PYTHON: connection update: {c}")
case PyFromSwarm.Message() as m:
print(f"PYTHON: message: {m}")
def scoped_lock_file():
a = Pidfile("/tmp/lock.pid", 0o0600)
+22
View File
@@ -3,6 +3,7 @@ import multiprocessing as mp
import os
import resource
import signal
import sys
from dataclasses import dataclass, field
from typing import Self
@@ -22,6 +23,8 @@ from exo.shared.election import Election, ElectionResult
from exo.shared.logging import logger_cleanup, logger_setup
from exo.shared.types.common import NodeId, SessionId
from exo.utils.channels import Receiver, channel
from exo.utils.daemon import detach_stdio_to_devnull
from exo.utils.pidfile import PidfileLockError, acquire_exo_pidfile
from exo.utils.pydantic_ext import FrozenModel
from exo.utils.task_group import TaskGroup
from exo.worker.main import Worker
@@ -264,14 +267,26 @@ class Node:
def main():
# Exit early if no PID file (not compatible with double-for daemonization yet)
try:
pidfile = acquire_exo_pidfile()
except PidfileLockError as exception:
print(exception, file=sys.stderr)
raise SystemExit(1) from exception
args = Args.parse()
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
target = min(max(soft, 65535), hard)
resource.setrlimit(resource.RLIMIT_NOFILE, (target, hard))
mp.set_start_method("spawn", force=True)
# TODO: Refactor the current verbosity system
logger_setup(EXO_LOG, args.verbosity)
if args.no_stdio:
detach_stdio_to_devnull()
logger.info("Detached stdio to /dev/null")
logger.info(f"{'=' * 40}")
logger.info(f"Starting EXO | pid={os.getpid()}")
logger.info(f"{'=' * 40}")
@@ -306,6 +321,7 @@ def main():
finally:
logger.info("EXO Shutdown complete")
logger_cleanup()
del pidfile
class Args(FrozenModel):
@@ -319,6 +335,7 @@ class Args(FrozenModel):
offline: bool = os.getenv("EXO_OFFLINE", "false").lower() == "true"
no_batch: bool = False
fast_synch: bool | None = None # None = auto, True = force on, False = force off
no_stdio: bool = False
bootstrap_peers: list[str] = []
libp2p_port: int
@@ -378,6 +395,11 @@ class Args(FrozenModel):
action="store_true",
help="Disable continuous batching, use sequential generation",
)
parser.add_argument(
"--no-stdio",
action="store_true",
help="Detach stdin/stdout/stderr to /dev/null after logging is configured",
)
parser.add_argument(
"--bootstrap-peers",
type=lambda s: [p for p in s.split(",") if p],
+1
View File
@@ -69,6 +69,7 @@ DASHBOARD_DIR = (
EXO_LOG_DIR = EXO_CACHE_HOME / "exo_log"
EXO_LOG = EXO_LOG_DIR / "exo.log"
EXO_TEST_LOG = EXO_CACHE_HOME / "exo_test.log"
EXO_PID_FILE = EXO_CACHE_HOME / "exo.pid"
# Identity (config)
EXO_NODE_ID_KEYPAIR = EXO_CONFIG_HOME / "node_id.keypair"
+290
View File
@@ -0,0 +1,290 @@
from __future__ import annotations
import contextlib
import faulthandler
import multiprocessing as mp
import os
import sys
from collections.abc import Callable, Iterable, Mapping
from multiprocessing.process import BaseProcess
from multiprocessing.resource_sharer import DupFd
from typing import final
from anyio import (
TASK_STATUS_IGNORED,
BrokenResourceError,
CancelScope,
ClosedResourceError,
Event,
create_task_group,
move_on_after,
sleep,
wait_readable,
)
from anyio.abc import TaskStatus
from loguru import logger
from exo.utils.channels import Receiver, Sender, channel
_STDOUT_FD = 1
_STDERR_FD = 2
_READ_CHUNK_SIZE = 64 * 1024
_TERMINATE_GRACE_SECONDS = 10.0
_TERMINATE_RETRY_GRACE_SECONDS = 2.0
_TERMINATE_ATTEMPTS = 10
_KILL_GRACE_SECONDS = 5.0
@final
class AsyncProcess:
def __init__(
self,
target: Callable[..., object] | None = None,
name: str | None = None,
args: Iterable[object] = (),
kwargs: Mapping[str, object] | None = None,
*,
daemon: bool | None = None,
) -> None:
# setup state
self._target = target
self._name = name
self._args = args
self._kwargs = kwargs
self._daemon = daemon
# lifecycle state
self._process: BaseProcess | None = None
self._pid: int | None = None
self._stdout_tx, self._stdout_rx = channel[bytes]()
self._stderr_tx, self._stderr_rx = channel[bytes]()
self._started = Event()
self._done = Event()
self._run_cancel_scope: CancelScope | None = None
self._start_error: BaseException | None = None
self._exitcode: int | None = None
async def run(self, *, task_status: TaskStatus[None] = TASK_STATUS_IGNORED) -> None:
if self._run_cancel_scope is not None or self._done.is_set():
raise RuntimeError("process has already been started")
stdout_read_fd: int | None = None
stdout_write_fd: int | None = None
stderr_read_fd: int | None = None
stderr_write_fd: int | None = None
def cleanup_stdio_fd() -> None:
nonlocal stdout_read_fd, stdout_write_fd, stderr_read_fd, stderr_write_fd
stdout_read_fd = _close_fd(stdout_read_fd)
stdout_write_fd = _close_fd(stdout_write_fd)
stderr_read_fd = _close_fd(stderr_read_fd)
stderr_write_fd = _close_fd(stderr_write_fd)
try:
with CancelScope() as run_cancel_scope:
self._run_cancel_scope = run_cancel_scope
stdout_read_fd, stdout_write_fd = os.pipe()
stderr_read_fd, stderr_write_fd = os.pipe()
process = mp.Process(
target=_run_with_captured_stdio,
name=self._name,
args=(
DupFd(stdout_write_fd),
DupFd(stderr_write_fd),
self._target,
*self._args,
),
kwargs={} if self._kwargs is None else self._kwargs,
daemon=self._daemon,
)
process.start()
pid = process.pid
if pid is None:
raise RuntimeError("started process has no pid")
# important to close parent write-side FD to prevent hangs
stdout_write_fd = _close_fd(stdout_write_fd)
stderr_write_fd = _close_fd(stderr_write_fd)
self._process = process
self._pid = pid
self._started.set()
async with create_task_group() as tg:
tg.start_soon(_drain_fd, stdout_read_fd, self._stdout_tx)
stdout_read_fd = None
tg.start_soon(_drain_fd, stderr_read_fd, self._stderr_tx)
stderr_read_fd = None
task_status.started()
await self.wait()
except BaseException as exc:
if not self._started.is_set():
self._start_error = exc
self._started.set()
raise
finally:
try:
with CancelScope(shield=True):
await self._terminate_if_still_alive()
finally:
cleanup_stdio_fd()
for tx in (self._stdout_tx, self._stderr_tx):
with contextlib.suppress(Exception):
await tx.aclose()
if self._process is not None:
with contextlib.suppress(ValueError):
self._process.close()
self._run_cancel_scope = None
self._done.set()
async def stop(self) -> None:
if self._run_cancel_scope is None and not self._done.is_set():
raise RuntimeError("process has not been started")
if self._run_cancel_scope is not None:
self._run_cancel_scope.cancel()
await self._done.wait()
async def aclose(self) -> None:
await self.stop()
async def wait(self) -> int:
if self._exitcode is not None:
return self._exitcode
await self._started.wait()
if self._start_error is not None:
raise self._start_error
assert self._process is not None
while True:
exitcode = self.exitcode
if exitcode is not None:
return exitcode
await sleep(0.01)
@property
def pid(self) -> int:
if self._pid is None:
raise RuntimeError("process has not been started")
return self._pid
@property
def exitcode(self) -> int | None:
if self._exitcode is not None:
return self._exitcode
if self._process is None:
return None
with contextlib.suppress(ValueError):
exitcode = self._process.exitcode
if exitcode is not None:
self._exitcode = exitcode
return exitcode
return None
def is_alive(self) -> bool:
if self._process is None:
return False
with contextlib.suppress(ValueError):
return self._process.is_alive()
return False
# TODO: maybe in the future if needed, create stdin that is also installed,
# and a ByteSendStream handle is provided for it :)
@property
def stdout(self) -> Receiver[bytes]:
return self._stdout_rx
@property
def stderr(self) -> Receiver[bytes]:
return self._stderr_rx
async def _terminate_if_still_alive(self) -> None:
process = self._process
if process is None:
return
if self.exitcode is not None:
return
with contextlib.suppress(ValueError):
if not process.is_alive():
return
logger.warning("Child process didn't shut down successfully, terminating")
process.terminate()
with move_on_after(_TERMINATE_GRACE_SECONDS):
await self.wait()
if self.exitcode is not None or not process.is_alive():
logger.warning("Terminated nicely in the first attempt!")
return
for attempt in range(2, _TERMINATE_ATTEMPTS + 1):
process.terminate()
with move_on_after(_TERMINATE_RETRY_GRACE_SECONDS):
await self.wait()
if self.exitcode is not None or not process.is_alive():
logger.warning(f"That took {attempt} attempts :)")
return
logger.critical("Child process didn't respond to SIGTERM, killing")
j = 0
while True:
process.kill()
with move_on_after(_KILL_GRACE_SECONDS):
await self.wait()
j += 1
if self.exitcode is not None or not process.is_alive():
break
logger.warning(f"That took {j} attempts :(")
# Spawn-mode multiprocessing requires a module-level target that can be pickled.
def _run_with_captured_stdio(
stdout: DupFd,
stderr: DupFd,
target: Callable[..., object] | None,
*target_args: object,
**target_kwargs: object,
) -> None:
stdout_fd = stdout.detach()
stderr_fd = stderr.detach()
try:
os.dup2(stdout_fd, _STDOUT_FD)
os.dup2(stderr_fd, _STDERR_FD)
finally:
for fd in (stdout_fd, stderr_fd):
if fd not in (_STDOUT_FD, _STDERR_FD):
_close_fd(fd)
faulthandler.enable(file=sys.stderr, all_threads=True)
if target is not None:
target(*target_args, **target_kwargs)
async def _drain_fd(fd: int, tx: Sender[bytes]) -> None:
try:
while True:
await wait_readable(fd)
chunk = os.read(fd, _READ_CHUNK_SIZE)
if not chunk:
return
await tx.send(chunk)
except (BrokenPipeError, BrokenResourceError, ClosedResourceError):
pass
finally:
_close_fd(fd)
await tx.aclose()
def _close_fd(fd: int | None) -> None:
if fd is None:
return
with contextlib.suppress(OSError):
os.close(fd)
+28
View File
@@ -0,0 +1,28 @@
import os
import sys
_STDIN_FD = 0
_STDOUT_FD = 1
_STDERR_FD = 2
def detach_stdio_to_devnull() -> None:
"""Redirect process stdio file descriptors to /dev/null."""
for stream in (sys.stdout, sys.stderr, sys.__stdout__, sys.__stderr__):
if stream is not None:
stream.flush()
stdin_fd = os.open(os.devnull, os.O_RDONLY)
stdout_fd = os.open(os.devnull, os.O_WRONLY)
stderr_fd = os.open(os.devnull, os.O_WRONLY)
try:
# dup2 closes the target fd first, but leaves the source fd open.
os.dup2(stdin_fd, _STDIN_FD)
os.dup2(stdout_fd, _STDOUT_FD)
os.dup2(stderr_fd, _STDERR_FD)
finally:
for fd in (stdin_fd, stdout_fd, stderr_fd):
if fd not in (_STDIN_FD, _STDOUT_FD, _STDERR_FD):
os.close(fd)
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
import os
from typing import Final
from exo_pyo3_bindings import Pidfile, PidfileError
from exo.shared.constants import EXO_PID_FILE
_PIDFILE_MODE: Final = 0o600
class PidfileLockError(RuntimeError):
pass
def acquire_exo_pidfile() -> Pidfile:
path = EXO_PID_FILE
os.makedirs(os.path.dirname(path), exist_ok=True)
try:
pidfile = Pidfile(path, _PIDFILE_MODE)
pidfile.write()
except (OSError, PidfileError) as exception:
raise PidfileLockError(
f"Failed to acquire EXO pidfile at {path}: {exception}"
) from exception
return pidfile
+40 -13
View File
@@ -19,19 +19,21 @@ class PowerSampler:
):
self._get_node_system = get_node_system
self._interval = interval
self._samples: defaultdict[NodeId, list[SystemPerformanceProfile]] = (
defaultdict(list)
)
self._samples: defaultdict[
NodeId, list[tuple[float, SystemPerformanceProfile]]
] = defaultdict(list)
self._start_time: float | None = None
self._stopped = False
def _take_sample(self) -> None:
def _take_sample(self, t_rel: float | None = None) -> None:
assert self._start_time is not None
ts = t_rel if t_rel is not None else time.perf_counter() - self._start_time
for node_id, profile in self._get_node_system().items():
self._samples[node_id].append(profile)
self._samples[node_id].append((ts, profile))
async def run(self) -> None:
self._start_time = time.perf_counter()
self._take_sample()
self._take_sample(t_rel=0.0)
while not self._stopped:
await anyio.sleep(self._interval)
self._take_sample()
@@ -39,26 +41,51 @@ class PowerSampler:
def result(self) -> PowerUsage:
self._stopped = True
assert self._start_time is not None, "result() called before run()"
self._take_sample()
elapsed = time.perf_counter() - self._start_time
self._take_sample(t_rel=elapsed)
node_stats: list[NodePowerStats] = []
for node_id, profiles in self._samples.items():
n = len(profiles)
total_energy_j = 0.0
for node_id, ts_profiles in self._samples.items():
n = len(ts_profiles)
if n == 0:
continue
node_energy_j = trapezoidal_energy(ts_profiles, elapsed)
avg_power_w = node_energy_j / elapsed if elapsed > 0 else 0.0
total_energy_j += node_energy_j
node_stats.append(
NodePowerStats(
node_id=node_id,
samples=n,
avg_sys_power=sum(p.sys_power for p in profiles) / n,
avg_sys_power=avg_power_w,
)
)
total_avg_sys = sum(ns.avg_sys_power for ns in node_stats)
total_avg_sys_w = total_energy_j / elapsed if elapsed > 0 else 0.0
return PowerUsage(
elapsed_seconds=elapsed,
nodes=node_stats,
total_avg_sys_power_watts=total_avg_sys,
total_energy_joules=total_avg_sys * elapsed,
total_avg_sys_power_watts=total_avg_sys_w,
total_energy_joules=total_energy_j,
)
def trapezoidal_energy(
ts_profiles: list[tuple[float, SystemPerformanceProfile]],
elapsed: float,
) -> float:
"""Integrate sys_power(t) over the sample window using the trapezoidal rule.
First sample is anchored at t=0 and last at t=elapsed (set by `run` /
`result`), so the integral spans the full request interval. Falls back to
power * elapsed when only one sample exists (constant-power assumption)."""
if len(ts_profiles) == 1:
return ts_profiles[0][1].sys_power * elapsed
energy_j = 0.0
for i in range(1, len(ts_profiles)):
t_prev, p_prev = ts_profiles[i - 1]
t_cur, p_cur = ts_profiles[i]
dt = t_cur - t_prev
if dt <= 0:
continue
energy_j += (p_prev.sys_power + p_cur.sys_power) / 2.0 * dt
return energy_j
+8
View File
@@ -0,0 +1,8 @@
import multiprocessing as mp
import pytest
@pytest.fixture(scope="session", autouse=True)
def mp_force_spawn():
mp.set_start_method("spawn", force=True)
+515
View File
@@ -0,0 +1,515 @@
import contextlib
import os
import signal
import sys
import time
from collections.abc import AsyncIterator, Callable
from types import FrameType
import mlx.core as mx
import pytest
from _pytest.capture import CaptureFixture
from anyio import EndOfStream, create_task_group, fail_after
from pytest import MonkeyPatch
import exo.utils.async_process as async_process
from exo.utils.async_process import (
AsyncProcess,
)
from exo.utils.channels import MpSender, Receiver, mp_channel
def _write_to_stdio(prefix: str, *, stderr_suffix: str) -> None:
print(f"{prefix}: python stdout")
print(f"{prefix}: python stderr {stderr_suffix}", file=sys.stderr)
os.write(1, f"{prefix}: fd stdout\n".encode())
os.write(2, f"{prefix}: fd stderr {stderr_suffix}\n".encode())
def _write_large_output() -> None:
os.write(1, b"stdout-0123456789")
os.write(2, b"stderr-0123456789")
def _write_all(fd: int, data: bytes) -> None:
remaining = memoryview(data)
while remaining:
written = os.write(fd, remaining)
remaining = remaining[written:]
def _write_large_exact_output(size: int) -> None:
_write_all(1, b"stdout:" + (b"x" * size))
_write_all(2, b"stderr:" + (b"y" * size))
def _raise_after_stderr_write() -> None:
os.write(2, b"stderr before exception\n")
raise RuntimeError("child boom")
def _exit_after_stdio_write(prefix: str, exitcode: int) -> None:
os.write(1, f"{prefix}: stdout before _exit\n".encode())
os.write(2, f"{prefix}: stderr before _exit\n".encode())
os._exit(exitcode)
def _abort_after_stdio_write(prefix: str) -> None:
os.write(1, f"{prefix}: stdout before abort\n".encode())
os.write(2, f"{prefix}: stderr before abort\n".encode())
os.abort()
def _close_stdio_and_exit() -> None:
os.close(1)
os.close(2)
os._exit(0)
def _exit_on_sigterm(exitcode: int) -> None:
def handle_sigterm(_signum: int, _frame: FrameType | None) -> None:
os._exit(exitcode)
signal.signal(signal.SIGTERM, handle_sigterm)
os.write(1, b"sigterm-ready\n")
while True:
time.sleep(0.1)
def _exit_after_repeated_sigterm(required_count: int, exitcode: int) -> None:
sigterm_count = 0
def handle_sigterm(_signum: int, _frame: FrameType | None) -> None:
nonlocal sigterm_count
sigterm_count += 1
if sigterm_count >= required_count:
os._exit(exitcode)
signal.signal(signal.SIGTERM, handle_sigterm)
os.write(1, b"sigterm-ready\n")
while True:
time.sleep(0.1)
def _ignore_sigterm_forever() -> None:
signal.signal(signal.SIGTERM, signal.SIG_IGN)
os.write(1, b"sigterm-ready\n")
while True:
time.sleep(0.1)
def _sleep_forever() -> None:
while True:
time.sleep(0.1)
def _send_over_mp_channel(send: MpSender[str]) -> None:
send.send("hello from child")
send.close()
def _mlx_force_oom(size: int = 40_000) -> None:
"""
Force an Out-Of-Memory (OOM) error in MLX by performing large tensor operations.
"""
print("CHILD: start")
mx.set_default_device(mx.gpu)
a = mx.random.uniform(shape=(size, size), dtype=mx.float32)
b = mx.random.uniform(shape=(size, size), dtype=mx.float32)
mx.eval(a, b)
c = mx.matmul(a, b)
d = mx.matmul(a, c)
e = mx.matmul(b, c)
f = mx.sigmoid(d + e)
mx.eval(f)
print("CHILD: end")
async def _collect_stream(
stream: Receiver[bytes],
output: bytearray,
) -> None:
while True:
try:
output.extend(await stream.receive())
except EndOfStream:
return
async def _collect_process_output(
process: AsyncProcess,
) -> tuple[int, bytes, bytes]:
stdout = bytearray()
stderr = bytearray()
exitcodes: list[int] = []
async with create_task_group() as task_group:
task_group.start_soon(_collect_stream, process.stdout, stdout)
task_group.start_soon(_collect_stream, process.stderr, stderr)
exitcodes.append(await process.wait())
if not exitcodes:
raise RuntimeError("process exited without a return code")
return exitcodes[0], bytes(stdout), bytes(stderr)
def _fd_identity(fd: int) -> tuple[int, int]:
fd_stat = os.fstat(fd)
return fd_stat.st_dev, fd_stat.st_ino
def _fd_count() -> int | None:
for fd_dir in ("/proc/self/fd", "/dev/fd"):
with contextlib.suppress(OSError):
return len(os.listdir(fd_dir))
return None
@contextlib.asynccontextmanager
async def _started_process(process: AsyncProcess) -> AsyncIterator[None]:
async with create_task_group() as task_group:
await task_group.start(process.run)
try:
yield
finally:
await process.stop()
async def _run_and_collect(
target: Callable[..., object] | None,
*,
args: tuple[object, ...] = (),
kwargs: dict[str, object] | None = None,
) -> tuple[int, bytes, bytes]:
process = AsyncProcess(
target,
args=args,
kwargs=kwargs,
)
async with _started_process(process):
return await _collect_process_output(process)
@pytest.mark.anyio
async def test_spawn_process_captures_stdout_and_stderr_separately(
capfd: CaptureFixture[str],
) -> None:
process = AsyncProcess(
_write_to_stdio,
args=("child",),
kwargs={"stderr_suffix": "error"},
)
async with _started_process(process):
exitcode, stdout_bytes, stderr_bytes = await _collect_process_output(process)
parent_output = capfd.readouterr()
stdout = stdout_bytes.decode("utf-8", errors="replace")
stderr = stderr_bytes.decode("utf-8", errors="replace")
assert exitcode == 0
assert "child: python stdout" in stdout
assert "child: fd stdout" in stdout
assert "child: python stderr error" in stderr
assert "child: fd stderr error" in stderr
assert "child:" not in parent_output.out
assert "child:" not in parent_output.err
@pytest.mark.anyio
async def test_process_with_no_target_exits_successfully() -> None:
exitcode, stdout, stderr = await _run_and_collect(None)
assert exitcode == 0
assert stdout == b""
assert stderr == b""
@pytest.mark.anyio
async def test_output_receivers_and_wait_are_safe_immediately_after_run_starts() -> (
None
):
process = AsyncProcess(
_write_to_stdio,
args=("immediate",),
kwargs={"stderr_suffix": "error"},
)
result: tuple[int, bytes, bytes] | None = None
async with create_task_group() as task_group:
await task_group.start(process.run)
try:
result = await _collect_process_output(process)
finally:
await process.stop()
assert result is not None
exitcode, stdout, stderr = result
assert exitcode == 0
assert b"immediate: fd stdout\n" in stdout
assert b"immediate: fd stderr error\n" in stderr
@pytest.mark.anyio
async def test_stop_before_run_raises() -> None:
process = AsyncProcess(
_write_to_stdio,
args=("never",),
kwargs={"stderr_suffix": "run"},
)
assert not process.is_alive()
with pytest.raises(RuntimeError, match="process has not been started"):
await process.stop()
@pytest.mark.anyio
async def test_process_run_is_one_shot() -> None:
process = AsyncProcess(None)
await process.run()
with pytest.raises(RuntimeError, match="process has already been started"):
await process.run()
@pytest.mark.anyio
async def test_process_started_with_task_group_start_can_stop_immediately() -> None:
process = AsyncProcess(_sleep_forever)
async with create_task_group() as task_group:
await task_group.start(process.run)
assert process.is_alive()
with fail_after(2):
await process.stop()
assert not process.is_alive()
@pytest.mark.anyio
async def test_stdout_receiver_yields_bytes_chunks() -> None:
process = AsyncProcess(_write_large_output)
async with _started_process(process):
first_stdout = await process.stdout.receive()
exitcode, remaining_stdout, stderr = await _collect_process_output(process)
assert exitcode == 0
assert first_stdout + remaining_stdout == b"stdout-0123456789"
assert stderr == b"stderr-0123456789"
@pytest.mark.anyio
async def test_output_can_be_read_after_process_exits() -> None:
process = AsyncProcess(_write_large_output)
async with create_task_group() as task_group:
await task_group.start(process.run)
assert await process.wait() == 0
assert await process.stdout.receive() == b"stdout-0123456789"
assert await process.stderr.receive() == b"stderr-0123456789"
with pytest.raises(EndOfStream):
await process.stdout.receive()
with pytest.raises(EndOfStream):
await process.stderr.receive()
@pytest.mark.anyio
async def test_large_stdout_and_stderr_are_not_lost() -> None:
size = 1024 * 1024
exitcode, stdout, stderr = await _run_and_collect(
_write_large_exact_output,
args=(size,),
)
assert exitcode == 0
assert stdout == b"stdout:" + (b"x" * size)
assert stderr == b"stderr:" + (b"y" * size)
@pytest.mark.anyio
async def test_child_exception_traceback_is_captured_from_stderr() -> None:
process = AsyncProcess(_raise_after_stderr_write)
async with _started_process(process):
exitcode, _, stderr_bytes = await _collect_process_output(process)
assert exitcode == 1
stderr = stderr_bytes.decode("utf-8", errors="replace")
assert "stderr before exception" in stderr
assert "RuntimeError: child boom" in stderr
@pytest.mark.anyio
async def test_repeated_bad_children_do_not_pollute_or_replace_parent_stdio(
capfd: CaptureFixture[str],
) -> None:
stdout_object = sys.stdout
stderr_object = sys.stderr
stdout_identity = _fd_identity(1)
stderr_identity = _fd_identity(2)
cases: tuple[tuple[Callable[..., object], tuple[object, ...]], ...] = (
(_raise_after_stderr_write, ()),
(_exit_after_stdio_write, ("exit-child", 17)),
(_abort_after_stdio_write, ("abort-child",)),
)
for iteration in range(3):
for target, args in cases:
exitcode, stdout, stderr = await _run_and_collect(
target,
args=args,
)
assert exitcode != 0
if target is _exit_after_stdio_write:
assert stdout == b"exit-child: stdout before _exit\n"
assert stderr == b"exit-child: stderr before _exit\n"
elif target is _abort_after_stdio_write:
assert b"abort-child: stdout before abort\n" in stdout
assert b"abort-child: stderr before abort\n" in stderr
assert exitcode == -signal.SIGABRT
else:
assert stdout == b""
assert b"stderr before exception\n" in stderr
assert b"RuntimeError: child boom" in stderr
print(f"parent stdout still works {iteration}")
print(f"parent stderr still works {iteration}", file=sys.stderr)
parent_output = capfd.readouterr()
assert sys.stdout is stdout_object
assert sys.stderr is stderr_object
assert _fd_identity(1) == stdout_identity
assert _fd_identity(2) == stderr_identity
assert "parent stdout still works 0" in parent_output.out
assert "parent stdout still works 2" in parent_output.out
assert "parent stderr still works 0" in parent_output.err
assert "parent stderr still works 2" in parent_output.err
assert "exit-child:" not in parent_output.out
assert "exit-child:" not in parent_output.err
assert "abort-child:" not in parent_output.out
assert "abort-child:" not in parent_output.err
assert "child boom" not in parent_output.err
@pytest.mark.anyio
async def test_child_can_close_stdio_without_corrupting_parent_stdio(
capfd: CaptureFixture[str],
) -> None:
stdout_identity = _fd_identity(1)
stderr_identity = _fd_identity(2)
exitcode, stdout, stderr = await _run_and_collect(_close_stdio_and_exit)
os.write(1, b"parent stdout after child closed stdio\n")
os.write(2, b"parent stderr after child closed stdio\n")
parent_output = capfd.readouterr()
assert exitcode == 0
assert stdout == b""
assert stderr == b""
assert _fd_identity(1) == stdout_identity
assert _fd_identity(2) == stderr_identity
assert "parent stdout after child closed stdio" in parent_output.out
assert "parent stderr after child closed stdio" in parent_output.err
@pytest.mark.anyio
async def test_repeated_crashing_children_do_not_grow_parent_fd_table() -> None:
await _run_and_collect(_exit_after_stdio_write, args=("warmup", 23))
before = _fd_count()
if before is None:
pytest.skip("fd table count is not available on this platform")
for iteration in range(20):
exitcode, stdout, stderr = await _run_and_collect(
_exit_after_stdio_write,
args=(f"fd-child-{iteration}", 31),
)
assert exitcode == 31
assert stdout == f"fd-child-{iteration}: stdout before _exit\n".encode()
assert stderr == f"fd-child-{iteration}: stderr before _exit\n".encode()
after = _fd_count()
assert after is not None
assert after <= before + 2
@pytest.mark.anyio
async def test_stop_allows_child_to_exit_after_sigterm() -> None:
process = AsyncProcess(_exit_on_sigterm, args=(43,))
async with _started_process(process):
assert await process.stdout.receive() == b"sigterm-ready\n"
with fail_after(2):
await process.stop()
assert process.exitcode == 43
@pytest.mark.anyio
async def test_stop_retries_sigterm_before_sigkill(monkeypatch: MonkeyPatch) -> None:
monkeypatch.setattr(async_process, "_TERMINATE_GRACE_SECONDS", 0.01)
monkeypatch.setattr(async_process, "_TERMINATE_RETRY_GRACE_SECONDS", 0.01)
process = AsyncProcess(_exit_after_repeated_sigterm, args=(3, 44))
async with _started_process(process):
assert await process.stdout.receive() == b"sigterm-ready\n"
with fail_after(2):
await process.stop()
assert process.exitcode == 44
@pytest.mark.anyio
async def test_stop_escalates_to_sigkill_when_child_ignores_sigterm(
monkeypatch: MonkeyPatch,
) -> None:
monkeypatch.setattr(async_process, "_TERMINATE_GRACE_SECONDS", 0.1)
monkeypatch.setattr(async_process, "_TERMINATE_RETRY_GRACE_SECONDS", 0.01)
process = AsyncProcess(_ignore_sigterm_forever)
async with _started_process(process):
assert await process.stdout.receive() == b"sigterm-ready\n"
with fail_after(3):
await process.stop()
assert process.exitcode == -signal.SIGKILL
@pytest.mark.anyio
async def test_process_can_use_mp_channel_with_global_spawn_context() -> None:
send, recv = mp_channel[str]()
process = AsyncProcess(_send_over_mp_channel, args=(send,))
async with _started_process(process):
with fail_after(2):
assert await recv.receive_async() == "hello from child"
assert await process.wait() == 0
with contextlib.suppress(Exception):
recv.close()
@pytest.mark.anyio
@pytest.mark.skip(reason="manual MLX OOM isolation check")
async def test_death(capsys: CaptureFixture[str]) -> None:
with capsys.disabled():
process = AsyncProcess(_mlx_force_oom)
stdout = b""
stderr = b""
async with _started_process(process):
_, stdout, stderr = await _collect_process_output(process)
print("PARENT: done")
print("CHILD out:", stdout.decode("utf-8", errors="replace"))
print("CHILD err:", stderr.decode("utf-8", errors="replace"), "hello :)")
+168
View File
@@ -0,0 +1,168 @@
import contextlib
import os
from collections.abc import AsyncIterator
import anyio
import pytest
from anyio import EndOfStream, create_task_group, fail_after
from exo.utils.async_process import AsyncProcess
from exo.utils.channels import MpReceiver, MpSender, Receiver, mp_channel
from exo.utils.daemon import detach_stdio_to_devnull
def _write_before_and_after_detach() -> None:
os.write(1, b"before stdout\n")
os.write(2, b"before stderr\n")
detach_stdio_to_devnull()
os.write(1, b"after stdout\n")
os.write(2, b"after stderr\n")
def _write_grandchild_stdio(label: str) -> None:
os.write(1, f"{label} stdout\n".encode())
os.write(2, f"{label} stderr\n".encode())
async def _spawn_grandchild_and_report(
result_sender: MpSender[tuple[int, bytes, bytes]],
label: str,
) -> None:
result_sender.send(await _collect_spawned_child(label))
result_sender.close()
async def _collect_spawned_child(label: str) -> tuple[int, bytes, bytes]:
process = AsyncProcess(_write_grandchild_stdio, args=(label,))
async with _started_process(process):
return await _collect_process_output(process)
def _detach_stdio_then_spawn_captured_child(
result_sender: MpSender[tuple[int, bytes, bytes]],
) -> None:
detach_stdio_to_devnull()
anyio.run(_spawn_grandchild_and_report, result_sender, "grandchild")
def _detach_stdio_then_spawn_captured_children_sequentially(
result_sender: MpSender[list[tuple[int, bytes, bytes]]],
) -> None:
async def run_children() -> list[tuple[int, bytes, bytes]]:
results: list[tuple[int, bytes, bytes]] = []
for index in range(5):
results.append(await _collect_spawned_child(f"grandchild-{index}"))
return results
detach_stdio_to_devnull()
result_sender.send(anyio.run(run_children))
result_sender.close()
async def _collect_stream(stream: Receiver[bytes], output: bytearray) -> None:
while True:
try:
output.extend(await stream.receive())
except EndOfStream:
return
async def _collect_process_output(
process: AsyncProcess,
) -> tuple[int, bytes, bytes]:
stdout = bytearray()
stderr = bytearray()
exitcodes: list[int] = []
async with create_task_group() as collect_group:
collect_group.start_soon(_collect_stream, process.stdout, stdout)
collect_group.start_soon(_collect_stream, process.stderr, stderr)
exitcodes.append(await process.wait())
if not exitcodes:
raise RuntimeError("process exited without a return code")
return exitcodes[0], bytes(stdout), bytes(stderr)
@contextlib.asynccontextmanager
async def _started_process(process: AsyncProcess) -> AsyncIterator[None]:
async with create_task_group() as task_group:
await task_group.start(process.run)
try:
yield
finally:
await process.stop()
async def _run_process_and_receive[T](
process: AsyncProcess,
recv: MpReceiver[T],
*,
timeout: float,
) -> tuple[int, T]:
async with _started_process(process):
with fail_after(timeout):
result = await recv.receive_async()
exitcode = await process.wait()
return exitcode, result
@pytest.mark.anyio
async def test_detach_stdio_to_devnull_redirects_stdio_away_from_capture() -> None:
process = AsyncProcess(_write_before_and_after_detach)
async with _started_process(process):
exitcode, stdout, stderr = await _collect_process_output(process)
assert exitcode == 0
assert stdout == b"before stdout\n"
assert stderr == b"before stderr\n"
@pytest.mark.anyio
async def test_detached_stdio_process_can_spawn_and_capture_child_stdio() -> None:
send, recv = mp_channel[tuple[int, bytes, bytes]]()
process = AsyncProcess(_detach_stdio_then_spawn_captured_child, args=(send,))
try:
daemonized_parent_exitcode, result = await _run_process_and_receive(
process, recv, timeout=5
)
finally:
recv.close()
child_exitcode, child_stdout, child_stderr = result
assert daemonized_parent_exitcode == 0
assert child_exitcode == 0
assert child_stdout == b"grandchild stdout\n"
assert child_stderr == b"grandchild stderr\n"
@pytest.mark.anyio
async def test_detached_stdio_process_can_spawn_captured_children_sequentially() -> (
None
):
send, recv = mp_channel[list[tuple[int, bytes, bytes]]]()
process = AsyncProcess(
_detach_stdio_then_spawn_captured_children_sequentially,
args=(send,),
)
try:
daemonized_parent_exitcode, results = await _run_process_and_receive(
process, recv, timeout=10
)
finally:
recv.close()
assert daemonized_parent_exitcode == 0
assert results == [
(
0,
f"grandchild-{index} stdout\n".encode(),
f"grandchild-{index} stderr\n".encode(),
)
for index in range(5)
]
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
import gc
import os
import subprocess
import sys
import textwrap
from pathlib import Path
from typing import Final
import pytest
import exo.utils.pidfile as pidfile
from exo.utils.pidfile import acquire_exo_pidfile
_CHILD_ACQUIRE_PIDFILE_SCRIPT: Final = textwrap.dedent(
"""
import sys
from pathlib import Path
from unittest.mock import patch
import exo.utils.pidfile as pidfile
from exo.utils.pidfile import PidfileLockError, acquire_exo_pidfile
with patch.object(pidfile, "EXO_PID_FILE", Path(sys.argv[1])):
try:
handle = acquire_exo_pidfile()
except PidfileLockError as exception:
print(str(exception))
raise SystemExit(73) from exception
del handle
"""
)
def _use_pidfile_path(monkeypatch: pytest.MonkeyPatch, path: Path) -> None:
monkeypatch.setattr(pidfile, "EXO_PID_FILE", path)
def _run_child_acquire_pidfile(path: Path) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, "-c", _CHILD_ACQUIRE_PIDFILE_SCRIPT, str(path)],
check=False,
capture_output=True,
text=True,
)
def test_acquire_exo_pidfile_writes_current_pid_and_removes_on_drop(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
path = tmp_path / "exo.pid"
_use_pidfile_path(monkeypatch, path)
handle = acquire_exo_pidfile()
assert path.read_text() == str(os.getpid())
del handle
gc.collect()
assert not path.exists()
def test_acquire_exo_pidfile_rejects_second_process(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
path = tmp_path / "exo.pid"
_use_pidfile_path(monkeypatch, path)
handle = acquire_exo_pidfile()
try:
blocked_child = _run_child_acquire_pidfile(path)
assert blocked_child.returncode == 73
assert "Failed to acquire EXO pidfile" in blocked_child.stdout
finally:
del handle
gc.collect()
unblocked_child = _run_child_acquire_pidfile(path)
assert unblocked_child.returncode == 0
assert unblocked_child.stdout == ""
+30
View File
@@ -111,6 +111,36 @@ async def test_empty_state() -> None:
assert result.total_energy_joules == 0.0
def test_trapezoidal_unit_dt_weighting() -> None:
"""Pure unit test on the integration helper. Crafted samples where the
arithmetic mean is wildly wrong vs the time-weighted result."""
from exo.utils.power_sampler import trapezoidal_energy
# 5 s window. Power = 10 W for the first 4.9 s, then 100 W for the last 0.1 s.
# Three samples: t=0 W=10, t=4.9 W=10, t=5.0 W=100.
samples = [
(0.0, _make_profile(10.0)),
(4.9, _make_profile(10.0)),
(5.0, _make_profile(100.0)),
]
energy = trapezoidal_energy(samples, elapsed=5.0)
# (10+10)/2 * 4.9 + (10+100)/2 * 0.1 = 49 + 5.5 = 54.5 J
assert abs(energy - 54.5) < 1e-9
avg = energy / 5.0 # 10.9 W
# Arithmetic mean of the three samples would be (10+10+100)/3 ≈ 40 W.
# Trapezoidal correctly weights each segment by its dt.
assert abs(avg - 10.9) < 1e-9
def test_trapezoidal_unit_single_sample() -> None:
"""One sample: no window to integrate over, so fall back to constant power
over the elapsed duration."""
from exo.utils.power_sampler import trapezoidal_energy
samples = [(0.0, _make_profile(42.0))]
assert trapezoidal_energy(samples, elapsed=3.0) == 42.0 * 3.0
async def test_result_stops_sampling() -> None:
"""Calling result() should stop the sampler's run loop."""
state: dict[NodeId, SystemPerformanceProfile] = {
+2 -1
View File
@@ -115,7 +115,8 @@ def mlx_distributed_init(
os.environ["MLX_HOSTFILE"] = coordination_file
os.environ["MLX_RANK"] = str(rank)
os.environ["MLX_RING_VERBOSE"] = "1"
# os.environ["MLX_RING_VERBOSE"] = "1" # NOTE: we don't use it enough to care (turn on again if need to)
group = mx.distributed.init(backend="ring", strict=True)
case MlxJacclInstance(
+48 -42
View File
@@ -1,5 +1,4 @@
import contextlib
import multiprocessing as mp
import signal
from dataclasses import dataclass, field
from typing import Self
@@ -8,7 +7,7 @@ import anyio
from anyio import (
BrokenResourceError,
ClosedResourceError,
to_thread,
EndOfStream,
)
from loguru import logger
@@ -41,7 +40,8 @@ from exo.shared.types.worker.runners import (
RunnerWarmingUp,
)
from exo.shared.types.worker.shards import ShardMetadata
from exo.utils.channels import MpReceiver, MpSender, Sender, mp_channel
from exo.utils.async_process import AsyncProcess
from exo.utils.channels import MpReceiver, MpSender, Receiver, Sender, mp_channel
from exo.utils.task_group import TaskGroup
from exo.worker.runner.bootstrap import entrypoint
@@ -53,7 +53,7 @@ DECODE_TIMEOUT_SECONDS = 5
class RunnerSupervisor:
shard_metadata: ShardMetadata
bound_instance: BoundInstance
runner_process: mp.Process
runner_process: AsyncProcess
initialize_timeout: float
_ev_recv: MpReceiver[Event]
_task_sender: MpSender[Task]
@@ -81,7 +81,7 @@ class RunnerSupervisor:
task_sender, task_recv = mp_channel[Task]()
cancel_sender, cancel_recv = mp_channel[TaskId]()
runner_process = mp.Process(
runner_process = AsyncProcess(
target=entrypoint,
args=(
bound_instance,
@@ -109,9 +109,25 @@ class RunnerSupervisor:
return self
async def run(self):
self.runner_process.start()
try:
async with self._tg as tg:
# start the process itself
await tg.start(self.runner_process.run)
# start tasks to drain/collect stdout/stderr into usable errors
#
# TODO: right now it logs them as warnings, but in the future they should be split
# into being logged AND a seperate task which tries to best-effort figure out cause
# of error and package into error enum, which then is used by rest of app to act on it;
# inferring what the error is would be done by pattern-matching in the text for things
# e.g. certain VLLM error codes and so on
tg.start_soon(
self._forward_runner_output, "stdout", self.runner_process.stdout
)
tg.start_soon(
self._forward_runner_output, "stderr", self.runner_process.stderr
)
tg.start_soon(self._watch_runner)
tg.start_soon(self._forward_events)
finally:
@@ -129,41 +145,11 @@ class RunnerSupervisor:
with contextlib.suppress(ClosedResourceError):
self._cancel_sender.close()
await to_thread.run_sync(self.runner_process.join, 5)
if self.runner_process.is_alive():
logger.warning(
"Runner process didn't shutdown succesfully, terminating"
with anyio.CancelScope(shield=True):
await self.runner_process.stop()
logger.info(
f"Runner process successfully terminated: {self.runner_process.exitcode}"
)
self.runner_process.terminate()
self.runner_process.join(timeout=10)
if not self.runner_process.is_alive():
logger.warning("Terminated nicely in the first attempt!")
else:
# Try really hard to terminate
for i in range(2, 11):
self.runner_process.terminate()
self.runner_process.join(timeout=2)
if not self.runner_process.is_alive():
logger.warning(f"That took {i} attempts :)")
break
# Try even harder to kill
else:
logger.critical(
"Runner process didn't respond to SIGTERM, killing"
)
j = 0
while self.runner_process.is_alive():
j += 1
self.runner_process.kill()
self.runner_process.join(timeout=5)
logger.warning(f"That took {j} attempts :(")
else:
logger.info("Runner process succesfully terminated")
self.runner_process.close()
def shutdown(self):
self._tg.cancel_tasks()
@@ -249,13 +235,33 @@ class RunnerSupervisor:
if not self.runner_process.is_alive():
await self._check_runner(RuntimeError("Runner found to be dead"))
async def _forward_runner_output(
self,
stream_name: str,
stream: Receiver[bytes],
) -> None:
while True:
try:
chunk = await stream.receive()
except (EndOfStream, ClosedResourceError, BrokenResourceError):
return
message = chunk.decode("utf-8", errors="replace").rstrip()
if not message:
continue
if stream_name == "stderr":
logger.warning(f"Runner stderr: {message}")
else:
logger.debug(f"Runner stdout: {message}")
async def _check_runner(self, e: Exception) -> None:
if not self._cancel_watch_runner.cancel_called:
self._cancel_watch_runner.cancel()
logger.info("Checking runner's status")
if self.runner_process.is_alive():
logger.info("Runner was found to be alive, attempting to join process")
await to_thread.run_sync(self.runner_process.join, 5)
logger.info("Runner was found to be alive, stopping process")
with anyio.CancelScope(shield=True):
await self.runner_process.stop()
rc = self.runner_process.exitcode
logger.info(f"Runner exited with exit code {rc}")
if rc == 0:
@@ -1,4 +1,3 @@
import multiprocessing as mp
from typing import cast
import anyio
@@ -16,6 +15,7 @@ from exo.shared.types.text_generation import (
)
from exo.shared.types.worker.instances import BoundInstance, InstanceId
from exo.shared.types.worker.runners import RunnerFailed, RunnerId
from exo.utils.async_process import AsyncProcess
from exo.utils.channels import channel, mp_channel
from exo.worker.runner.supervisor import RunnerSupervisor
from exo.worker.tests.unittests.conftest import get_bound_mlx_ring_instance
@@ -24,23 +24,11 @@ from exo.worker.tests.unittests.conftest import get_bound_mlx_ring_instance
class _DeadProcess:
exitcode = -6
def start(self) -> None:
return None
def is_alive(self) -> bool:
return False
def join(self, _timeout: float | None = None) -> None:
return None
def terminate(self) -> None:
return None
def kill(self) -> None:
return None
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_check_runner_emits_error_chunk_for_inflight_text_generation() -> None:
event_sender, event_receiver = channel[Event]()
task_sender, _ = mp_channel[Task]()
@@ -57,7 +45,7 @@ async def test_check_runner_emits_error_chunk_for_inflight_text_generation() ->
supervisor = RunnerSupervisor(
shard_metadata=bound_instance.bound_shard,
bound_instance=bound_instance,
runner_process=cast("mp.Process", cast(object, _DeadProcess())),
runner_process=cast(AsyncProcess, cast(object, _DeadProcess())),
initialize_timeout=400,
_ev_recv=ev_recv,
_task_sender=task_sender,
View File
Whitespace-only changes.
+181
View File
@@ -0,0 +1,181 @@
# type: ignore
"""Pytest configuration for marker-driven exo integration tests.
Test authors declare requirements via markers:
@pytest.mark.cluster(count=2, thunderbolt='a2a')
@pytest.mark.instance('mlx-community/Llama-3.2-1B-Instruct-4bit',
sharding='tensor', comm='jaccl')
def test_jaccl_inference(session):
resp = session.chat('What is 2+2?')
assert '4' in resp
Clusters are cached by `ClusterSpec`; tests with the same cluster_spec
share a deployment. Each test places its own instance (matching its
`@pytest.mark.instance`), and instances are cleaned up after the test.
Run with:
uv run pytest tests/ -v
uv run pytest tests/ -v --hosts s2,s4,s9,s10
"""
from __future__ import annotations
import contextlib
import json
import pytest
from exo_tools.cluster import ClusterInfo, EcoSession
from exo_tools.harness import cleanup_all_instances, place_instance
from .framework import (
ClusterSpec,
Session,
parse_cluster_marker,
parse_instance_marker,
)
# Single eco session for the entire test process.
eco = EcoSession(user_prefix="test")
# Cluster cache keyed by ClusterSpec — tests with the same spec share a deployment.
# Cleared at session teardown.
_cluster_cache: dict[ClusterSpec, ClusterInfo] = {}
def pytest_addoption(parser):
parser.addoption(
"--hosts",
default=None,
help="Comma-separated list of hosts (e.g. s2,s4,s9,s10). "
"Overrides constraint-based reservation.",
)
def pytest_configure(config):
"""Register custom markers."""
config.addinivalue_line(
"markers",
"cluster(count=N, thunderbolt=Thunderbolt|None, min_memory=GB, chip=PATTERN): "
"declare cluster requirements for a test",
)
config.addinivalue_line(
"markers",
"instance(model_id, sharding=Sharding, comm=Comm, min_nodes=N): "
"declare instance placement for a test",
)
def pytest_report_header(config):
"""Show the eco user and hosts for this test session."""
hosts = config.getoption("--hosts")
lines = [f"eco user: {eco.user}"]
if hosts:
lines.append(f"hosts override: {hosts}")
return lines
@pytest.fixture(scope="session")
def _host_pool(request) -> list[str] | None:
raw = request.config.getoption("--hosts")
if raw:
return [h.strip() for h in raw.split(",") if h.strip()]
return None
@pytest.fixture
def session(request, _host_pool) -> Session:
"""Per-test fixture providing a Session matching the test's markers.
Reads @pytest.mark.cluster and @pytest.mark.instance from the test, deploys
a matching cluster (cached across tests with the same spec), places the
model, and yields a Session for the test to interact with. Cleans up the
instance after the test, and invalidates the cluster cache if the test
left nodes disconnected.
"""
cluster_marker = request.node.get_closest_marker("cluster")
instance_marker = request.node.get_closest_marker("instance")
cluster_spec = parse_cluster_marker(cluster_marker)
instance_spec = parse_instance_marker(instance_marker)
# Deploy or reuse a cluster matching the spec
cluster = _cluster_cache.get(cluster_spec)
if cluster is None:
if _host_pool:
cluster = eco.start_deploy(
hosts=_host_pool[: cluster_spec.count], wait=True
)
else:
cluster = eco.start_deploy(
count=cluster_spec.count,
thunderbolt=cluster_spec.thunderbolt,
chip=cluster_spec.chip,
min_memory_gb=cluster_spec.min_memory_gb,
wait=True,
)
_cluster_cache[cluster_spec] = cluster
# Place an instance for this test if the test specified one
instance_id = None
if instance_spec is not None:
client = cluster.make_client()
instance_id = place_instance(
client,
instance_spec.model_id,
sharding=instance_spec.sharding,
comm=instance_spec.comm,
min_nodes=instance_spec.min_nodes,
)
sess = Session(
cluster=cluster,
eco=eco,
instance_spec=instance_spec,
instance_id=instance_id,
)
yield sess
# ---- Teardown ----
# If the test left nodes disconnected, invalidate the cluster cache and
# stop the cluster so the next test deploys fresh.
if sess._stopped_hosts:
_cluster_cache.pop(cluster_spec, None)
with contextlib.suppress(Exception):
eco.stop(sess.cluster.hosts)
return
# Otherwise, clean up any instances created during the test
with contextlib.suppress(Exception):
cleanup_all_instances(sess.client)
# ---------------------------------------------------------------------------
# Session-level teardown — stop all cached clusters
# ---------------------------------------------------------------------------
@pytest.fixture(scope="session", autouse=True)
def _teardown_clusters():
yield
for cluster in _cluster_cache.values():
with contextlib.suppress(Exception):
eco.stop(cluster.hosts)
_cluster_cache.clear()
def pytest_runtest_makereport(item, call):
"""Attach cluster logs to the test report when a test fails."""
if call.when != "call" or call.excinfo is None:
return
sess = item.funcargs.get("session")
if sess is None:
return
try:
logs = eco.logs(sess.cluster.hosts, lines=200)
item.add_report_section("call", "Cluster Logs", json.dumps(logs, indent=2))
except Exception:
pass
+199
View File
@@ -0,0 +1,199 @@
"""Marker-driven test framework for exo integration tests.
Test authors declare requirements via markers:
@pytest.mark.cluster(count=2, thunderbolt='a2a')
@pytest.mark.instance('mlx-community/Llama-3.2-1B-Instruct-4bit',
sharding='tensor', comm='jaccl')
def test_jaccl_inference(session):
resp = session.chat('What is 2+2?')
assert '4' in resp
The `session` fixture reads the markers, deploys the cluster, places the
instance, and provides a `Session` object. All cluster/instance orchestration
lives in `exo_tools.harness`; this module is purely the pytest-facing layer.
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Any
from exo_tools.client import ExoClient
from exo_tools.cluster import (
Chip,
ClusterInfo,
EcoSession,
Thunderbolt,
make_client_from_url,
)
from exo_tools.harness import Comm, Sharding
from exo.api.types.api import (
ChatCompletionChoice,
ChatCompletionRequest,
ChatCompletionResponse,
)
DEFAULT_MODEL = "mlx-community/Llama-3.2-1B-Instruct-4bit"
def _extract_content(resp: ChatCompletionResponse) -> str:
"""Extract plain-text content from a non-streaming chat completion."""
choice = resp.choices[0]
if not isinstance(choice, ChatCompletionChoice):
raise RuntimeError(
f"Expected non-streaming choice, got {type(choice).__name__}"
)
content = choice.message.content
if not isinstance(content, str):
raise RuntimeError(f"Expected string content, got {type(content).__name__}")
return content
@dataclass(frozen=True)
class ClusterSpec:
count: int = 1
thunderbolt: Thunderbolt | None = None
min_memory_gb: float | None = None
chip: Chip | None = None
@dataclass(frozen=True)
class InstanceSpec:
model_id: str
sharding: Sharding = Sharding.PIPELINE
comm: Comm = Comm.RING
min_nodes: int = 1
def parse_cluster_marker(marker) -> ClusterSpec:
if marker is None:
return ClusterSpec()
return ClusterSpec(
count=marker.kwargs.get("count", 1),
thunderbolt=marker.kwargs.get("thunderbolt"),
min_memory_gb=marker.kwargs.get("min_memory"),
chip=marker.kwargs.get("chip"),
)
def parse_instance_marker(marker) -> InstanceSpec | None:
if marker is None:
return None
if not marker.args:
raise ValueError(
"@pytest.mark.instance requires a positional model_id argument"
)
return InstanceSpec(
model_id=marker.args[0],
sharding=marker.kwargs.get("sharding", Sharding.PIPELINE),
comm=marker.kwargs.get("comm", Comm.RING),
min_nodes=marker.kwargs.get("min_nodes", 1),
)
@dataclass
class Session:
cluster: ClusterInfo
eco: EcoSession
instance_spec: InstanceSpec | None = None
instance_id: str | None = None
_stopped_hosts: set[str] = field(default_factory=set)
@property
def client(self) -> ExoClient:
for host in self.cluster.hosts:
if host not in self._stopped_hosts:
return make_client_from_url(self.cluster.api_endpoints[host])
return self.cluster.make_client()
@property
def state(self) -> dict[str, Any]:
return self.client.request_json("GET", "/state") or {}
@property
def instances(self) -> dict[str, Any]:
return self.state.get("instances", {})
# ---- Inference ----
def chat(self, prompt: str, max_tokens: int = 100) -> str:
resp = self.chat_raw(prompt, max_tokens=max_tokens)
return _extract_content(resp)
def chat_raw(self, prompt: str, **kwargs: Any) -> ChatCompletionResponse:
if not self.instance_spec:
raise RuntimeError(
"No instance placed; add @pytest.mark.instance to the test"
)
max_tokens = kwargs.pop("max_tokens", 100)
request = ChatCompletionRequest.model_validate(
{
"model": self.instance_spec.model_id,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": max_tokens,
**kwargs,
}
)
return self._post_chat(request)
def multi_turn(self, messages: list[dict[str, str]], max_tokens: int = 100) -> str:
if not self.instance_spec:
raise RuntimeError(
"No instance placed; add @pytest.mark.instance to the test"
)
request = ChatCompletionRequest.model_validate(
{
"model": self.instance_spec.model_id,
"messages": messages,
"max_tokens": max_tokens,
}
)
return _extract_content(self._post_chat(request))
def _post_chat(self, request: ChatCompletionRequest) -> ChatCompletionResponse:
raw = self.client.request_json(
"POST",
"/v1/chat/completions",
body=request.model_dump(exclude_none=True),
)
return ChatCompletionResponse.model_validate(raw)
def disconnect_node(self, index: int) -> None:
"""Stop exo on a node and wait for the cluster to observe the disconnect."""
host = self.cluster.hosts[index]
self.eco.stop([host], keep=True)
self._stopped_hosts.add(host)
def reconnect_node(self, index: int) -> None:
"""Restart a previously disconnected node into the existing namespace."""
host = self.cluster.hosts[index]
self.eco.start_hosts([host], namespace=self.cluster.namespace)
self._stopped_hosts.discard(host)
def wait_ready(
self, expected_nodes: int | None = None, timeout: float = 60
) -> None:
"""Wait until the cluster has exactly `expected_nodes` visible and reporting memory.
Defaults to the count of non-stopped hosts. Use this after
`disconnect_node` / `reconnect_node` to wait for the cluster to settle.
"""
if expected_nodes is None:
expected_nodes = len(self.cluster.hosts) - len(self._stopped_hosts)
start = time.time()
while time.time() - start < timeout:
try:
state = self.state
identities = len(state.get("nodeIdentities", {}))
memory = len(state.get("nodeMemory", {}))
if identities == expected_nodes and memory == expected_nodes:
return
except Exception:
pass
time.sleep(2.0)
raise TimeoutError(
f"Cluster did not reach exactly {expected_nodes} ready nodes within {timeout}s"
)
+75
View File
@@ -0,0 +1,75 @@
# type: ignore
"""Single-node integration tests.
Run with:
uv run pytest tests/test_1node.py -v
"""
from __future__ import annotations
import time
import pytest
from exo_tools.harness import is_model_downloaded, place_instance
from .framework import DEFAULT_MODEL, InstanceSpec
@pytest.mark.cluster(count=1)
@pytest.mark.instance(DEFAULT_MODEL)
def test_place_instance_and_chat(session):
resp = session.chat("Say hello in one sentence.")
assert len(resp) > 0
@pytest.mark.cluster(count=1)
@pytest.mark.instance(DEFAULT_MODEL)
def test_chat_multiple_turns(session):
first_reply = session.chat("What is 2 + 2?")
assert len(first_reply) > 0
second_reply = session.multi_turn(
[
{"role": "user", "content": "What is 2 + 2?"},
{"role": "assistant", "content": first_reply},
{"role": "user", "content": "Now multiply that by 3."},
]
)
assert len(second_reply) > 0
@pytest.mark.cluster(count=1)
@pytest.mark.instance(DEFAULT_MODEL)
def test_delete_instance(session):
from exo_tools.harness import wait_for_instance_gone
session.client.request_json("DELETE", f"/instance/{session.instance_id}")
wait_for_instance_gone(session.client, session.instance_id, timeout=30.0)
assert len(session.instances) == 0, (
f"Expected no instances, found {len(session.instances)}"
)
@pytest.mark.cluster(count=1)
def test_download_from_scratch(session):
"""Ensure the model is not on the cluster, then place an instance to
trigger a fresh download and verify inference.
"""
node_id = next(iter(session.state.get("nodeIdentities", {})))
# Delete any existing download — the API call is idempotent
session.client.request_json("DELETE", f"/download/{node_id}/{DEFAULT_MODEL}")
# Poll until the model is gone (it may already be gone)
deadline = time.time() + 60.0
while time.time() < deadline:
if not is_model_downloaded(session.client, DEFAULT_MODEL):
break
time.sleep(2.0)
else:
raise AssertionError(f"Expected {DEFAULT_MODEL} to be deleted from cluster")
place_instance(session.client, DEFAULT_MODEL, timeout=900.0)
session.instance_spec = InstanceSpec(model_id=DEFAULT_MODEL)
resp = session.chat("Say hello in one sentence.")
assert len(resp) > 0
+49
View File
@@ -0,0 +1,49 @@
# type: ignore
"""Two-node integration tests (ring + jaccl parallelism).
Run with:
uv run pytest tests/test_2node.py -v
"""
from __future__ import annotations
import pytest
from exo_tools.cluster import Thunderbolt
from exo_tools.harness import Comm, Sharding
from .framework import DEFAULT_MODEL
@pytest.mark.cluster(count=2, thunderbolt=Thunderbolt.A2A)
@pytest.mark.instance(
DEFAULT_MODEL, sharding=Sharding.TENSOR, comm=Comm.JACCL, min_nodes=2
)
def test_2node_jaccl(session):
resp = session.chat("Say hello in one sentence.")
assert len(resp) > 0
@pytest.mark.cluster(count=2, thunderbolt=Thunderbolt.A2A)
@pytest.mark.instance(
DEFAULT_MODEL, sharding=Sharding.PIPELINE, comm=Comm.RING, min_nodes=2
)
def test_2node_ring(session):
resp = session.chat("Say hello in one sentence.")
assert len(resp) > 0
@pytest.mark.cluster(count=2, thunderbolt=Thunderbolt.A2A)
@pytest.mark.instance(
DEFAULT_MODEL, sharding=Sharding.TENSOR, comm=Comm.JACCL, min_nodes=2
)
def test_2node_jaccl_multi_turn(session):
first = session.chat("What is the capital of France?")
assert len(first) > 0
second = session.multi_turn(
[
{"role": "user", "content": "What is the capital of France?"},
{"role": "assistant", "content": first},
{"role": "user", "content": "What country is it in?"},
]
)
assert len(second) > 0
+32
View File
@@ -0,0 +1,32 @@
# type: ignore
"""Four-node integration tests.
Run with:
uv run pytest tests/test_4node.py -v
"""
from __future__ import annotations
import pytest
from exo_tools.cluster import Thunderbolt
from exo_tools.harness import Comm, Sharding
from .framework import DEFAULT_MODEL
@pytest.mark.cluster(count=4, thunderbolt=Thunderbolt.A2A)
@pytest.mark.instance(
DEFAULT_MODEL, sharding=Sharding.PIPELINE, comm=Comm.RING, min_nodes=4
)
def test_4node_pipeline_ring(session):
resp = session.chat("Say hello in one sentence.")
assert len(resp) > 0
@pytest.mark.cluster(count=4, thunderbolt=Thunderbolt.A2A)
@pytest.mark.instance(
DEFAULT_MODEL, sharding=Sharding.TENSOR, comm=Comm.JACCL, min_nodes=4
)
def test_4node_tensor_jaccl(session):
resp = session.chat("Say hello in one sentence.")
assert len(resp) > 0
+102
View File
@@ -0,0 +1,102 @@
# type: ignore
"""Dashboard end-to-end tests using Playwright (headless Chromium).
Prerequisites:
uv run playwright install chromium
Run with:
uv run pytest tests/test_dashboard.py -v
"""
from __future__ import annotations
import contextlib
import pytest
try:
from playwright.sync_api import sync_playwright
_HAS_PLAYWRIGHT = True
except ImportError:
_HAS_PLAYWRIGHT = False
# Check if Chromium is installed by attempting a quick launch
_HAS_CHROMIUM = False
if _HAS_PLAYWRIGHT:
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
browser.close()
_HAS_CHROMIUM = True
except Exception:
pass
pytestmark = pytest.mark.skipif(
not _HAS_PLAYWRIGHT or not _HAS_CHROMIUM,
reason="playwright or chromium not installed (run: uv run playwright install chromium)",
)
def _mark_onboarding_complete(session) -> None:
"""Mark onboarding complete on the server so the wizard doesn't auto-launch a model."""
with contextlib.suppress(Exception):
session.client.request_json("POST", "/onboarding")
@pytest.mark.cluster(count=1)
def test_dashboard_chat_inference(session):
"""Full UI flow: open dashboard, pick a model, send a chat, verify response.
The instance is created via the dashboard UI (model picker → chat send
triggers the dashboard's auto-launch flow), not via @pytest.mark.instance.
"""
_mark_onboarding_complete(session)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1280, "height": 800})
page.goto(session.cluster.api_url, wait_until="networkidle")
page.wait_for_timeout(3000)
page.screenshot(path="/tmp/dashboard_initial.png")
# Open the model picker by clicking the "SELECT MODEL" button
page.get_by_text("SELECT MODEL", exact=False).first.click()
page.wait_for_timeout(1000)
page.screenshot(path="/tmp/dashboard_picker_open.png")
# Search for the model — uses the model id substring; the picker
# matches against name/id so "Llama-3.2-1B" filters to the small Llama.
search_input = page.locator('input[placeholder*="Search models"]').first
search_input.fill("Llama-3.2-1B")
page.wait_for_timeout(1500)
page.screenshot(path="/tmp/dashboard_picker_search.png")
# Click the only matching result. The picker shows the model's
# display name (e.g. "Llama 3.2 1B") which differs from the model_id.
# We click the first visible button-like row in the result list.
page.get_by_text("Llama 3.2 1B", exact=False).first.click()
page.wait_for_timeout(1500)
page.screenshot(path="/tmp/dashboard_model_selected.png")
# Type a chat message — sending triggers the dashboard's auto-launch
# flow: it picks an optimal placement for the selected model and POSTs
# to /instance, then sends the chat once the runner is ready.
chat_input = page.locator("textarea").first
chat_input.fill("Say hello")
chat_input.press("Enter")
page.screenshot(path="/tmp/dashboard_chat_sent.png")
# Wait for the instance to launch and respond. Generous timeout
# because this includes model placement + load + generation.
page.wait_for_timeout(60000)
page.screenshot(path="/tmp/dashboard_after_chat.png")
# Verify an instance was created and the chat got a response
instances = session.client.request_json("GET", "/state").get("instances", {})
assert len(instances) > 0, "Expected the dashboard to have created an instance"
body_text = page.text_content("body") or ""
assert len(body_text) > 0
browser.close()
+56
View File
@@ -0,0 +1,56 @@
# type: ignore
"""Resilience tests: disconnect/reconnect nodes and verify cluster recovery.
Run with:
uv run pytest tests/test_resilience.py -v
"""
from __future__ import annotations
import pytest
from exo_tools.cluster import Thunderbolt
from exo_tools.harness import Comm, Sharding, cleanup_all_instances, place_instance
from .framework import DEFAULT_MODEL, InstanceSpec
@pytest.mark.cluster(count=2, thunderbolt=Thunderbolt.A2A)
@pytest.mark.instance(
DEFAULT_MODEL, sharding=Sharding.PIPELINE, comm=Comm.RING, min_nodes=2
)
def test_node_recovery(session):
"""Full disconnect/reconnect cycle.
1. Place a 2-node instance, verify inference
2. Disconnect one node
3. Place a 1-node instance on remaining node, verify inference
4. Reconnect the stopped node, wait for the cluster to reform
5. Place a 2-node instance again, verify inference
"""
# --- Phase 1: 2-node inference ---
resp = session.chat("Hello")
assert len(resp) > 0
# --- Phase 2: disconnect one node ---
session.disconnect_node(1)
session.wait_ready(60)
# Clean up the now-broken 2-node instance
cleanup_all_instances(session.client)
# --- Phase 3: 1-node inference on the remaining node ---
place_instance(session.client, DEFAULT_MODEL, min_nodes=1)
session.instance_spec = InstanceSpec(model_id=DEFAULT_MODEL, min_nodes=1)
resp = session.chat("Hello")
assert len(resp) > 0
# --- Phase 4: reconnect and restore 2-node cluster ---
cleanup_all_instances(session.client)
session.reconnect_node(1)
session.wait_ready(60)
# --- Phase 5: 2-node inference again ---
place_instance(session.client, DEFAULT_MODEL, min_nodes=2)
session.instance_spec = InstanceSpec(model_id=DEFAULT_MODEL, min_nodes=2)
resp = session.chat("Hello again")
assert len(resp) > 0
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+10
View File
@@ -0,0 +1,10 @@
[project]
name = "exo-tools"
version = "0.1.0"
description = "Shared tooling for interacting with exo clusters"
requires-python = ">=3.13"
dependencies = ["loguru>=0.7.3"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
View File
Whitespace-only changes.
+117
View File
@@ -0,0 +1,117 @@
# type: ignore
"""HTTP client for the exo API."""
from __future__ import annotations
import http.client
import json
from collections.abc import Iterator
from typing import Any
from urllib.parse import urlencode
class ExoHttpError(RuntimeError):
def __init__(self, status: int, reason: str, body_preview: str):
super().__init__(f"HTTP {status} {reason}: {body_preview}")
self.status = status
class ExoClient:
def __init__(self, host: str, port: int, timeout_s: float = 7200.0):
self.host = host
self.port = port
self.timeout_s = timeout_s
def request_json(
self,
method: str,
path: str,
params: dict[str, Any] | None = None,
body: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
) -> Any:
if not path.startswith("/"):
path = "/" + path
if params:
path = path + "?" + urlencode(params)
conn = http.client.HTTPConnection(self.host, self.port, timeout=self.timeout_s)
try:
payload: bytes | None = None
hdrs: dict[str, str] = {"Accept": "application/json"}
if body is not None:
payload = json.dumps(body).encode("utf-8")
hdrs["Content-Type"] = "application/json"
if headers:
hdrs.update(headers)
conn.request(method.upper(), path, body=payload, headers=hdrs)
resp = conn.getresponse()
raw = resp.read()
text = raw.decode("utf-8", errors="replace") if raw else ""
if resp.status >= 400:
raise ExoHttpError(resp.status, resp.reason, text[:300])
if not text:
return None
return json.loads(text)
finally:
conn.close()
def post_bench_chat_completions(self, payload: dict[str, Any]) -> dict[str, Any]:
return self.request_json("POST", "/bench/chat/completions", body=payload)
def stream_bench_chat_completions(self, payload: dict[str, Any]) -> Iterator[str]:
"""POST /bench/chat/completions with stream=True, yielding raw SSE lines."""
payload = {**payload, "stream": True}
data = json.dumps(payload).encode("utf-8")
conn = http.client.HTTPConnection(self.host, self.port, timeout=self.timeout_s)
try:
conn.request(
"POST",
"/bench/chat/completions",
body=data,
headers={
"Content-Type": "application/json",
"Accept": "text/event-stream",
},
)
resp = conn.getresponse()
if resp.status >= 400:
raw = resp.read().decode("utf-8", errors="replace")
raise ExoHttpError(resp.status, resp.reason, raw[:300])
for line in resp:
yield line.decode("utf-8", errors="replace")
finally:
conn.close()
def get_state_path(self, path: str) -> Any:
try:
return self.request_json("GET", f"/state/{path}")
except ExoHttpError as e:
if e.status == 404:
return None
raise
def get_instance(self, instance_id: str) -> dict[str, Any] | None:
return self.get_state_path(f"instances/{instance_id}")
def get_runner(self, runner_id: str) -> dict[str, Any] | None:
return self.get_state_path(f"runners/{runner_id}")
def get_node_downloads(self, node_id: str) -> list[dict[str, Any]] | None:
return self.get_state_path(f"downloads/{node_id}")
def get_node_disk(self, node_id: str) -> dict[str, Any] | None:
return self.get_state_path(f"nodeDisk/{node_id}")
def get_node_system(self, node_id: str) -> dict[str, Any] | None:
return self.get_state_path(f"nodeSystem/{node_id}")
def get_node_identities(self) -> dict[str, Any] | None:
return self.get_state_path("nodeIdentities")
def get_topology(self) -> dict[str, Any] | None:
return self.get_state_path("topology")
+262
View File
@@ -0,0 +1,262 @@
# type: ignore
"""Cluster lifecycle management via eco.
Provides subprocess wrappers for eco commands (deploy, stop, start, release,
logs, exec) and a ClusterInfo dataclass. Reusable by integration tests,
bench, eval, and CI workflows.
"""
from __future__ import annotations
import atexit
import contextlib
import json
import logging
import math
import os
import signal
import subprocess
import uuid
from dataclasses import dataclass, field
from enum import Enum
from .client import ExoClient
class Thunderbolt(str, Enum):
A2A = "a2a" # all-to-all (eco --tb-a2a)
RING = "ring" # ring topology (eco --tb-ring)
NONE = "none" # exclude Thunderbolt-connected hosts (eco --no-thunderbolt)
class Chip(str, Enum):
M1 = "M1"
M1_PRO = "M1 Pro"
M1_MAX = "M1 Max"
M1_ULTRA = "M1 Ultra"
M2 = "M2"
M2_PRO = "M2 Pro"
M2_MAX = "M2 Max"
M2_ULTRA = "M2 Ultra"
M3 = "M3"
M3_PRO = "M3 Pro"
M3_MAX = "M3 Max"
M3_ULTRA = "M3 Ultra"
M4 = "M4"
M4_PRO = "M4 Pro"
M4_MAX = "M4 Max"
M4_ULTRA = "M4 Ultra"
logger = logging.getLogger("exo_tools.cluster")
# When set, deploy from a GitHub branch/tag instead of local source (rsync).
_EXO_REF = os.environ.get("EXO_REF")
@dataclass
class ClusterInfo:
"""Holds the result of an `eco start --deploy` invocation."""
hosts: list[str]
namespace: str
api_endpoints: dict[str, str] # host -> url
api_url: str # primary endpoint for ExoClient
primary_host: str = ""
_host: str = field(init=False, repr=False, default="")
_port: int = field(init=False, repr=False, default=52415)
def __post_init__(self) -> None:
if not self.primary_host:
self.primary_host = self.hosts[0]
url = self.api_url.replace("http://", "").replace("https://", "")
parts = url.split(":")
self._host = parts[0]
self._port = int(parts[1]) if len(parts) > 1 else 52415
def make_client(self, timeout_s: float = 7200.0) -> ExoClient:
return ExoClient(self._host, self._port, timeout_s=timeout_s)
class EcoSession:
"""Manages an eco session with a unique user and automatic cleanup.
Usage:
session = EcoSession(user_prefix="test")
cluster = session.start_deploy(count=2, thunderbolt=True)
...
session.stop_all() # or let atexit handle it
The session registers atexit and signal handlers to ensure cleanup
on normal exit, uncaught exceptions, SIGTERM, and SIGHUP. SIGINT
is left unhandled so KeyboardInterrupt propagates normally.
"""
def __init__(self, user_prefix: str = "test") -> None:
self._session_id = uuid.uuid4().hex[:8]
self.user = f"{user_prefix}-{self._session_id}"
self._env = {**os.environ, "USER": self.user}
# Register cleanup handlers
atexit.register(self.stop_all)
for sig in (signal.SIGTERM, signal.SIGHUP):
signal.signal(sig, self._signal_handler)
def _signal_handler(self, signum: int, _frame: object) -> None:
self.stop_all()
raise SystemExit(128 + signum)
def stop_all(self) -> None:
"""Stop all clusters and release all reservations for this session."""
with contextlib.suppress(Exception):
subprocess.run(
["eco", "stop"],
capture_output=True,
text=True,
timeout=30,
env=self._env,
)
def _run(
self, args: list[str], *, check: bool = True, timeout: int = 120
) -> subprocess.CompletedProcess[str]:
"""Run an eco command as this session's user.
stdout is captured (JSON output), stderr is passed through to the
console so eco's progress messages are visible.
"""
logger.info(f"eco: {' '.join(args)}")
return subprocess.run(
args,
stdout=subprocess.PIPE,
stderr=None,
text=True,
check=check,
timeout=timeout,
env=self._env,
)
def start_deploy(
self,
hosts: list[str] | None = None,
*,
count: int | None = None,
thunderbolt: Thunderbolt | None = None,
chip: Chip | None = None,
min_memory_gb: float | None = None,
max_memory_gb: float | None = None,
min_disk_gb: float | None = None,
max_disk_gb: float | None = None,
wait: bool = True,
ref: str | None = _EXO_REF,
timeout: int = 600,
) -> ClusterInfo:
"""Start and deploy exo on a set of hosts via eco.
By default, deploys from local source via rsync. Set EXO_REF
or pass ref= to deploy from a GitHub branch/tag instead (for CI).
Selection constraints (memory/disk in GiB, chip substring,
Thunderbolt topology) are forwarded as eco CLI flags. Pass
``thunderbolt=Thunderbolt.NONE`` to exclude TB-connected hosts.
"""
cmd: list[str] = ["eco", "--json", "start", "--deploy"]
if hosts:
cmd.extend(hosts)
if count is not None:
cmd.extend(["--count", str(count)])
if thunderbolt is Thunderbolt.NONE:
cmd.append("--no-thunderbolt")
elif thunderbolt is not None:
cmd.append(f"--tb-{thunderbolt.value}")
if chip is not None:
cmd.extend(["--chip", chip.value])
# eco's GB args are integer-typed. Round mins up + maxes down so
# we never relax the user's constraint.
if min_memory_gb is not None:
cmd.extend(["--min-memory", str(math.ceil(min_memory_gb))])
if max_memory_gb is not None:
cmd.extend(["--max-memory", str(math.floor(max_memory_gb))])
if min_disk_gb is not None:
cmd.extend(["--min-disk", str(math.ceil(min_disk_gb))])
if max_disk_gb is not None:
cmd.extend(["--max-disk", str(math.floor(max_disk_gb))])
if wait:
cmd.append("--wait")
if ref:
cmd.extend(["--ref", ref])
result = self._run(cmd, timeout=timeout)
data = json.loads(result.stdout)["data"]
endpoints: dict[str, str] = data["api_endpoints"]
primary_host = data["hosts"][0]
return ClusterInfo(
hosts=data["hosts"],
namespace=data["namespace"],
api_endpoints=endpoints,
api_url=endpoints[primary_host],
primary_host=primary_host,
)
def stop(self, hosts: list[str], *, keep: bool = False, timeout: int = 120) -> None:
"""Stop exo on the given hosts. If keep=True, keep the reservation."""
cmd: list[str] = ["eco", "stop"]
cmd.extend(hosts)
if keep:
cmd.append("--keep")
self._run(cmd, timeout=timeout)
def start_hosts(
self, hosts: list[str], *, namespace: str, timeout: int = 300
) -> None:
"""Start (previously stopped) hosts back into an existing namespace."""
cmd: list[str] = ["eco", "--json", "start"]
cmd.extend(hosts)
cmd.extend(["--namespace", namespace])
self._run(cmd, timeout=timeout)
def release(self, hosts: list[str], timeout: int = 120) -> None:
"""Release hosts from the reservation."""
cmd: list[str] = ["eco", "release"]
cmd.extend(hosts)
self._run(cmd, timeout=timeout)
def logs(
self, hosts: list[str], lines: int = 500, timeout: int = 60
) -> dict[str, list[str]]:
"""Fetch recent logs from cluster hosts."""
cmd: list[str] = ["eco", "--json", "logs"]
cmd.extend(hosts)
cmd.extend(["-n", str(lines), "--raw"])
result = self._run(cmd, check=False, timeout=timeout)
if result.returncode != 0:
return {"_error": [result.stderr]}
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
return {"_raw": result.stdout.splitlines()}
def exec(self, hosts: list[str], command: str, timeout: int = 120) -> str:
"""Run an arbitrary command on the given hosts via eco."""
cmd: list[str] = ["eco", "exec"]
cmd.extend(hosts)
cmd.append("--")
cmd.extend(command.split())
result = self._run(cmd, check=False, timeout=timeout)
return result.stdout
def make_client(cluster: ClusterInfo, timeout_s: float = 7200.0) -> ExoClient:
"""Create an ExoClient from a ClusterInfo."""
return cluster.make_client(timeout_s=timeout_s)
def make_client_from_url(url: str, timeout_s: float = 7200.0) -> ExoClient:
"""Create an ExoClient from a URL string like 'http://host:port'."""
url_clean = url.replace("http://", "").replace("https://", "")
parts = url_clean.split(":")
host = parts[0]
port = int(parts[1]) if len(parts) > 1 else 52415
return ExoClient(host, port, timeout_s=timeout_s)
@@ -1,129 +1,39 @@
# type: ignore
"""Instance lifecycle helpers for exo clusters.
Provides utilities for placing instances, waiting for readiness,
managing downloads, filtering placements, and common CLI arguments.
"""
from __future__ import annotations
import argparse
import http.client
import json
import contextlib
import os
import time
from collections.abc import Iterator
from enum import Enum
from typing import Any
from urllib.parse import urlencode
from loguru import logger
from .client import ExoClient, ExoHttpError
class Sharding(str, Enum):
PIPELINE = "Pipeline" # layers split across nodes
TENSOR = "Tensor" # layers split within (across nodes)
class Comm(str, Enum):
RING = "MlxRing" # ring all-reduce over network
JACCL = "MlxJaccl" # RDMA over Thunderbolt
_SETTLE_INITIAL_BACKOFF_S = 1.0
_SETTLE_MAX_BACKOFF_S = 60.0
_SETTLE_BACKOFF_MULTIPLIER = 2.0
class ExoHttpError(RuntimeError):
def __init__(self, status: int, reason: str, body_preview: str):
super().__init__(f"HTTP {status} {reason}: {body_preview}")
self.status = status
class ExoClient:
def __init__(self, host: str, port: int, timeout_s: float = 7200.0):
self.host = host
self.port = port
self.timeout_s = timeout_s
def request_json(
self,
method: str,
path: str,
params: dict[str, Any] | None = None,
body: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
) -> Any:
if not path.startswith("/"):
path = "/" + path
if params:
path = path + "?" + urlencode(params)
conn = http.client.HTTPConnection(self.host, self.port, timeout=self.timeout_s)
try:
payload: bytes | None = None
hdrs: dict[str, str] = {"Accept": "application/json"}
if body is not None:
payload = json.dumps(body).encode("utf-8")
hdrs["Content-Type"] = "application/json"
if headers:
hdrs.update(headers)
conn.request(method.upper(), path, body=payload, headers=hdrs)
resp = conn.getresponse()
raw = resp.read()
text = raw.decode("utf-8", errors="replace") if raw else ""
if resp.status >= 400:
raise ExoHttpError(resp.status, resp.reason, text[:300])
if not text:
return None
return json.loads(text)
finally:
conn.close()
def post_bench_chat_completions(self, payload: dict[str, Any]) -> dict[str, Any]:
return self.request_json("POST", "/bench/chat/completions", body=payload)
def stream_bench_chat_completions(self, payload: dict[str, Any]) -> Iterator[str]:
"""POST /bench/chat/completions with stream=True, yielding raw SSE lines."""
payload = {**payload, "stream": True}
data = json.dumps(payload).encode("utf-8")
conn = http.client.HTTPConnection(self.host, self.port, timeout=self.timeout_s)
try:
conn.request(
"POST",
"/bench/chat/completions",
body=data,
headers={
"Content-Type": "application/json",
"Accept": "text/event-stream",
},
)
resp = conn.getresponse()
if resp.status >= 400:
raw = resp.read().decode("utf-8", errors="replace")
raise ExoHttpError(resp.status, resp.reason, raw[:300])
for line in resp:
yield line.decode("utf-8", errors="replace")
finally:
conn.close()
def get_state_path(self, path: str) -> Any:
try:
return self.request_json("GET", f"/state/{path}")
except ExoHttpError as e:
if e.status == 404:
return None
raise
def get_instance(self, instance_id: str) -> dict[str, Any] | None:
return self.get_state_path(f"instances/{instance_id}")
def get_runner(self, runner_id: str) -> dict[str, Any] | None:
return self.get_state_path(f"runners/{runner_id}")
def get_node_downloads(self, node_id: str) -> list[dict[str, Any]] | None:
return self.get_state_path(f"downloads/{node_id}")
def get_node_disk(self, node_id: str) -> dict[str, Any] | None:
return self.get_state_path(f"nodeDisk/{node_id}")
def get_node_system(self, node_id: str) -> dict[str, Any] | None:
return self.get_state_path(f"nodeSystem/{node_id}")
def get_node_identities(self) -> dict[str, Any] | None:
return self.get_state_path("nodeIdentities")
def get_topology(self) -> dict[str, Any] | None:
return self.get_state_path("topology")
def unwrap_instance(instance: dict[str, Any]) -> dict[str, Any]:
if len(instance) != 1:
raise KeyError(f"Expected 1 key, got keys={list(instance.keys())}")
@@ -555,7 +465,6 @@ def find_existing_instance(client: ExoClient, model_id: str) -> str | None:
except Exception:
return None
for inst_id, inst in state.get("instances", {}).items():
# Instance structure is nested: {"MlxJacclInstance": {"shardAssignments": {"modelId": ...}}}
for _inst_type, inner in inst.items():
if not isinstance(inner, dict):
continue
@@ -623,3 +532,112 @@ def add_common_instance_args(ap: argparse.ArgumentParser) -> None:
action="store_true",
help="Reuse an existing running instance for this model instead of creating a new one.",
)
# ---------------------------------------------------------------------------
# Cluster/instance orchestration helpers (used by tests, bench, eval)
# ---------------------------------------------------------------------------
def get_instance_ids(client: ExoClient) -> set[str]:
"""Return the set of current instance IDs from cluster state."""
state = client.request_json("GET", "/state") or {}
result: set[str] = set()
for instance in state.get("instances", {}).values():
with contextlib.suppress(Exception):
result.add(instance_id_from_instance(instance))
return result
def wait_for_cluster_ready(
client: ExoClient, expected_nodes: int = 1, timeout: float = 120.0
) -> None:
"""Wait until the cluster has all expected nodes visible and reporting memory.
Placement requires nodeMemory for all nodes in a cycle. This polls until
both nodeIdentities and nodeMemory have at least `expected_nodes` entries.
"""
start = time.time()
while time.time() - start < timeout:
try:
state = client.request_json("GET", "/state") or {}
if (
len(state.get("nodeIdentities", {})) >= expected_nodes
and len(state.get("nodeMemory", {})) >= expected_nodes
):
return
except Exception:
pass
time.sleep(1.0)
raise TimeoutError(f"Cluster not ready: expected {expected_nodes} nodes")
def place_instance(
client: ExoClient,
model_id: str,
*,
sharding: Sharding = Sharding.PIPELINE,
comm: Comm = Comm.RING,
min_nodes: int = 1,
timeout: float = 600.0,
placement_retries: int = 10,
placement_retry_delay: float = 10.0,
) -> str:
"""Place an instance and wait for it to be ready. Returns the instance_id.
The /place_instance API returns a command_id, but instances are stored
under a separately-generated instance_id. This polls cluster state for the
new instance, retrying placement if the cluster is still settling.
"""
wait_for_cluster_ready(client, expected_nodes=min_nodes)
body = {
"model_id": model_id,
"sharding": sharding.value,
"instance_meta": comm.value,
"min_nodes": min_nodes,
}
instance_id: str | None = None
for attempt in range(placement_retries):
before_ids = get_instance_ids(client)
client.request_json("POST", "/place_instance", body=body)
poll_deadline = time.time() + 30.0
while time.time() < poll_deadline:
new_ids = get_instance_ids(client) - before_ids
if new_ids:
instance_id = next(iter(new_ids))
break
time.sleep(1.0)
if instance_id is not None:
break
if attempt < placement_retries - 1:
time.sleep(placement_retry_delay)
if instance_id is None:
raise TimeoutError(
f"Placement failed after {placement_retries} attempts "
f"({sharding.value}/{comm.value} for {model_id})"
)
wait_for_instance_ready(client, instance_id, timeout=timeout)
return instance_id
def cleanup_all_instances(client: ExoClient) -> None:
"""Remove all running instances from the cluster."""
state = client.request_json("GET", "/state") or {}
for instance in state.get("instances", {}).values():
with contextlib.suppress(Exception):
iid = instance_id_from_instance(instance)
client.request_json("DELETE", f"/instance/{iid}")
wait_for_instance_gone(client, iid, timeout=30.0)
def is_model_downloaded(client: ExoClient, model_id: str) -> bool:
response = client.request_json("GET", "/models", params={"status": "downloaded"})
data = (response or {}).get("data", [])
return all(model.get("id") == model_id for model in data)
Generated
+70 -164
View File
@@ -23,6 +23,7 @@ members = [
"exo",
"exo-bench",
"exo-pyo3-bindings",
"exo-tools",
]
constraints = [{ name = "transformers", specifier = ">=5.6.2" }]
overrides = [
@@ -87,8 +88,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" },
{ url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" },
{ url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" },
{ url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" },
{ url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" },
]
[[package]]
@@ -200,9 +199,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
]
[[package]]
@@ -233,9 +229,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
]
@@ -262,7 +255,7 @@ name = "contourpy"
version = "1.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy", marker = "sys_platform == 'darwin' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" }
wheels = [
@@ -274,9 +267,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" },
{ url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" },
{ url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" },
{ url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" },
{ url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" },
{ url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" },
{ url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" },
{ url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" },
{ url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" },
@@ -285,9 +275,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" },
{ url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" },
{ url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" },
{ url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" },
{ url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" },
{ url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" },
]
[[package]]
@@ -439,6 +426,7 @@ cuda13 = [
[package.dev-dependencies]
dev = [
{ name = "basedpyright", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "playwright", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "pyinstaller", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "pytest-asyncio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
@@ -498,6 +486,7 @@ provides-extras = ["build", "cpu", "cuda12", "cuda13"]
[package.metadata.requires-dev]
dev = [
{ name = "basedpyright", specifier = ">=1.29.0" },
{ name = "playwright", specifier = ">=1.52.0" },
{ name = "pyinstaller", specifier = ">=6.17.0" },
{ name = "pytest", specifier = ">=8.4.0" },
{ name = "pytest-asyncio", specifier = ">=1.0.0" },
@@ -518,6 +507,7 @@ dependencies = [
{ name = "lm-eval", extra = ["api", "math"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "loguru", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "math-verify", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "matplotlib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "tiktoken", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
@@ -534,6 +524,7 @@ requires-dist = [
{ name = "lm-eval", extras = ["api", "math"], specifier = ">=0.4.0" },
{ name = "loguru", specifier = ">=0.7.3" },
{ name = "math-verify", specifier = ">=0.7.0" },
{ name = "matplotlib", specifier = ">=3.8" },
{ name = "numpy", specifier = ">=1.24.0" },
{ name = "protobuf", specifier = ">=5.29.0" },
{ name = "tiktoken", specifier = ">=0.12.0" },
@@ -561,6 +552,17 @@ dev = [
{ name = "pytest-asyncio", specifier = ">=1.0.0" },
]
[[package]]
name = "exo-tools"
version = "0.1.0"
source = { editable = "tools" }
dependencies = [
{ name = "loguru", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
]
[package.metadata]
requires-dist = [{ name = "loguru", specifier = ">=0.7.3" }]
[[package]]
name = "fastapi"
version = "0.128.0"
@@ -609,8 +611,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/8d/6fb3494dfe61a46258cd93d979cf4725ded4eb46c2a4ca35e4490d84daea/fonttools-4.61.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c1b526c8d3f615a7b1867f38a9410849c8f4aef078535742198e942fba0e9bd", size = 4984460, upload-time = "2025-12-12T17:30:32.073Z" },
{ url = "https://files.pythonhosted.org/packages/f7/f1/a47f1d30b3dc00d75e7af762652d4cbc3dff5c2697a0dbd5203c81afd9c3/fonttools-4.61.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:41ed4b5ec103bd306bb68f81dc166e77409e5209443e5773cb4ed837bcc9b0d3", size = 4925800, upload-time = "2025-12-12T17:30:34.339Z" },
{ url = "https://files.pythonhosted.org/packages/a7/01/e6ae64a0981076e8a66906fab01539799546181e32a37a0257b77e4aa88b/fonttools-4.61.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b501c862d4901792adaec7c25b1ecc749e2662543f68bb194c42ba18d6eec98d", size = 5067859, upload-time = "2025-12-12T17:30:36.593Z" },
{ url = "https://files.pythonhosted.org/packages/73/aa/28e40b8d6809a9b5075350a86779163f074d2b617c15d22343fce81918db/fonttools-4.61.1-cp313-cp313-win32.whl", hash = "sha256:4d7092bb38c53bbc78e9255a59158b150bcdc115a1e3b3ce0b5f267dc35dd63c", size = 2267821, upload-time = "2025-12-12T17:30:38.478Z" },
{ url = "https://files.pythonhosted.org/packages/1a/59/453c06d1d83dc0951b69ef692d6b9f1846680342927df54e9a1ca91c6f90/fonttools-4.61.1-cp313-cp313-win_amd64.whl", hash = "sha256:21e7c8d76f62ab13c9472ccf74515ca5b9a761d1bde3265152a6dc58700d895b", size = 2318169, upload-time = "2025-12-12T17:30:40.951Z" },
{ url = "https://files.pythonhosted.org/packages/c7/4e/ce75a57ff3aebf6fc1f4e9d508b8e5810618a33d900ad6c19eb30b290b97/fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371", size = 1148996, upload-time = "2025-12-12T17:31:21.03Z" },
]
@@ -633,9 +633,6 @@ wheels = [
{ 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" },
@@ -649,9 +646,6 @@ wheels = [
{ 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/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
]
@@ -669,6 +663,22 @@ http = [
{ name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
]
[[package]]
name = "greenlet"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3c/3f/dbf99fb14bfeb88c28f16729215478c0e265cacd6dc22270c8f31bb6892f/greenlet-3.5.0.tar.gz", hash = "sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4", size = 196995, upload-time = "2026-04-27T13:37:15.544Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/58/fc576f99037ce19c5aa16628e4c3226b6d1419f72a62c79f5f40576e6eb3/greenlet-3.5.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106", size = 285066, upload-time = "2026-04-27T12:23:05.033Z" },
{ url = "https://files.pythonhosted.org/packages/4a/ba/b28ddbe6bfad6a8ac196ef0e8cff37bc65b79735995b9e410923fffeeb70/greenlet-3.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b", size = 604414, upload-time = "2026-04-27T12:52:42.358Z" },
{ url = "https://files.pythonhosted.org/packages/09/06/4b69f8f0b67603a8be2790e55107a190b376f2627fe0eaf5695d85ffb3cd/greenlet-3.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e", size = 617349, upload-time = "2026-04-27T12:59:43.32Z" },
{ url = "https://files.pythonhosted.org/packages/6a/15/a643b4ecd09969e30b8a150d5919960caae0abe4f5af75ab040b1ab85e78/greenlet-3.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d", size = 623234, upload-time = "2026-04-27T13:02:40.611Z" },
{ url = "https://files.pythonhosted.org/packages/8a/17/a3918541fd0ddefe024a69de6d16aa7b46d36ac19562adaa63c7fa180eff/greenlet-3.5.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13", size = 613927, upload-time = "2026-04-27T12:25:30.28Z" },
{ url = "https://files.pythonhosted.org/packages/77/18/3b13d5ef1275b0ffaf933b05efa21408ac4ca95823c7411d79682e4fdcff/greenlet-3.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae", size = 425243, upload-time = "2026-04-27T13:05:15.689Z" },
{ url = "https://files.pythonhosted.org/packages/ee/e1/bd0af6213c7dd33175d8a462d4c1fe1175124ebed4855bc1475a5b5242c2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba", size = 1570893, upload-time = "2026-04-27T12:53:29.483Z" },
{ url = "https://files.pythonhosted.org/packages/9b/2a/0789702f864f5382cb476b93d7a9c823c10472658102ccd65f415747d2e2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846", size = 1636060, upload-time = "2026-04-27T12:25:28.845Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
@@ -719,8 +729,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/82/1a/9c748befbe3decf7cb415e34f8a0c3789a0a9c55910dea73d581e48c0ce5/hf_transfer-0.1.9-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:dc7fff1345980d6c0ebb92c811d24afa4b98b3e07ed070c8e38cc91fd80478c5", size = 3390096, upload-time = "2025-01-07T10:04:59.98Z" },
{ url = "https://files.pythonhosted.org/packages/72/85/4c03da147b6b4b7cb12e074d3d44eee28604a387ed0eaf7eaaead5069c57/hf_transfer-0.1.9-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:1a6bd16c667ebe89a069ca163060127a794fa3a3525292c900b8c8cc47985b0d", size = 3664743, upload-time = "2025-01-07T10:05:05.416Z" },
{ url = "https://files.pythonhosted.org/packages/e7/6e/e597b04f753f1b09e6893075d53a82a30c13855cbaa791402695b01e369f/hf_transfer-0.1.9-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d2fde99d502093ade3ab1b53f80da18480e9902aa960dab7f74fb1b9e5bc5746", size = 3695243, upload-time = "2025-01-07T10:05:11.411Z" },
{ url = "https://files.pythonhosted.org/packages/09/89/d4e234727a26b2546c8fb70a276cd924260d60135f2165bf8b9ed67bb9a4/hf_transfer-0.1.9-cp38-abi3-win32.whl", hash = "sha256:435cc3cdc8524ce57b074032b8fd76eed70a4224d2091232fa6a8cef8fd6803e", size = 1086605, upload-time = "2025-01-07T10:05:18.873Z" },
{ url = "https://files.pythonhosted.org/packages/a1/14/f1e15b851d1c2af5b0b1a82bf8eb10bda2da62d98180220ba6fd8879bb5b/hf_transfer-0.1.9-cp38-abi3-win_amd64.whl", hash = "sha256:16f208fc678911c37e11aa7b586bc66a37d02e636208f18b6bc53d29b5df40ad", size = 1160240, upload-time = "2025-01-07T10:05:14.324Z" },
]
[[package]]
@@ -735,16 +743,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/97/c1/a0a44d1f98934f7bdf17f7a915b934f9fca44bb826628c553589900f6df8/hf_xet-1.4.2-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:769431385e746c92dc05492dde6f687d304584b89c33d79def8367ace06cb555", size = 3988266, upload-time = "2026-03-13T06:58:22.887Z" },
{ url = "https://files.pythonhosted.org/packages/7a/82/be713b439060e7d1f1d93543c8053d4ef2fe7e6922c5b31642eaa26f3c4b/hf_xet-1.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c9dd1c1bc4cc56168f81939b0e05b4c36dd2d28c13dc1364b17af89aa0082496", size = 4188513, upload-time = "2026-03-13T06:58:40.858Z" },
{ url = "https://files.pythonhosted.org/packages/21/a6/cbd4188b22abd80ebd0edbb2b3e87f2633e958983519980815fb8314eae5/hf_xet-1.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:fca58a2ae4e6f6755cc971ac6fcdf777ea9284d7e540e350bb000813b9a3008d", size = 4428287, upload-time = "2026-03-13T06:58:42.601Z" },
{ url = "https://files.pythonhosted.org/packages/b2/4e/84e45b25e2e3e903ed3db68d7eafa96dae9a1d1f6d0e7fc85120347a852f/hf_xet-1.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:163aab46854ccae0ab6a786f8edecbbfbaa38fcaa0184db6feceebf7000c93c0", size = 3665574, upload-time = "2026-03-13T06:58:53.881Z" },
{ url = "https://files.pythonhosted.org/packages/ee/71/c5ac2b9a7ae39c14e91973035286e73911c31980fe44e7b1d03730c00adc/hf_xet-1.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:09b138422ecbe50fd0c84d4da5ff537d27d487d3607183cd10e3e53f05188e82", size = 3528760, upload-time = "2026-03-13T06:58:52.187Z" },
{ url = "https://files.pythonhosted.org/packages/b4/86/b40b83a2ff03ef05c4478d2672b1fc2b9683ff870e2b25f4f3af240f2e7b/hf_xet-1.4.2-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:71f02d6e4cdd07f344f6844845d78518cc7186bd2bc52d37c3b73dc26a3b0bc5", size = 3800339, upload-time = "2026-03-13T06:58:36.245Z" },
{ url = "https://files.pythonhosted.org/packages/64/2e/af4475c32b4378b0e92a587adb1aa3ec53e3450fd3e5fe0372a874531c00/hf_xet-1.4.2-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:e9b38d876e94d4bdcf650778d6ebbaa791dd28de08db9736c43faff06ede1b5a", size = 3559664, upload-time = "2026-03-13T06:58:34.787Z" },
{ url = "https://files.pythonhosted.org/packages/3c/4c/781267da3188db679e601de18112021a5cb16506fe86b246e22c5401a9c4/hf_xet-1.4.2-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:77e8c180b7ef12d8a96739a4e1e558847002afe9ea63b6f6358b2271a8bdda1c", size = 4217422, upload-time = "2026-03-13T06:58:27.472Z" },
{ url = "https://files.pythonhosted.org/packages/68/47/d6cf4a39ecf6c7705f887a46f6ef5c8455b44ad9eb0d391aa7e8a2ff7fea/hf_xet-1.4.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c3b3c6a882016b94b6c210957502ff7877802d0dbda8ad142c8595db8b944271", size = 3992847, upload-time = "2026-03-13T06:58:25.989Z" },
{ url = "https://files.pythonhosted.org/packages/2d/ef/e80815061abff54697239803948abc665c6b1d237102c174f4f7a9a5ffc5/hf_xet-1.4.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9d9a634cc929cfbaf2e1a50c0e532ae8c78fa98618426769480c58501e8c8ac2", size = 4193843, upload-time = "2026-03-13T06:58:44.59Z" },
{ url = "https://files.pythonhosted.org/packages/54/75/07f6aa680575d9646c4167db6407c41340cbe2357f5654c4e72a1b01ca14/hf_xet-1.4.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6b0932eb8b10317ea78b7da6bab172b17be03bbcd7809383d8d5abd6a2233e04", size = 4432751, upload-time = "2026-03-13T06:58:46.533Z" },
{ url = "https://files.pythonhosted.org/packages/cd/71/193eabd7e7d4b903c4aa983a215509c6114915a5a237525ec562baddb868/hf_xet-1.4.2-cp37-abi3-win_amd64.whl", hash = "sha256:ad185719fb2e8ac26f88c8100562dbf9dbdcc3d9d2add00faa94b5f106aea53f", size = 3671149, upload-time = "2026-03-13T06:58:57.07Z" },
{ url = "https://files.pythonhosted.org/packages/b4/7e/ccf239da366b37ba7f0b36095450efae4a64980bdc7ec2f51354205fdf39/hf_xet-1.4.2-cp37-abi3-win_arm64.whl", hash = "sha256:32c012286b581f783653e718c1862aea5b9eb140631685bb0c5e7012c8719a87", size = 3533426, upload-time = "2026-03-13T06:58:55.46Z" },
]
[[package]]
@@ -969,8 +973,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/59/a3/cdc5fef9b8110d60e9185104067ef8a6b7c56b9315475cb73e5c10953633/kiwisolver-1.4.10rc0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3dde1fe2838d9ef93f0c66a564c9b369652127190b8da1e6378075d7a0176281", size = 2321418, upload-time = "2025-08-10T20:21:14.451Z" },
{ url = "https://files.pythonhosted.org/packages/16/b8/12c5187d08c79c053ba9bb0622720322991edfd3fd14e9ef3d2a2cfd4036/kiwisolver-1.4.10rc0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:319c1c56b4497fe729c5c9c2a319957b8bf70b5bd036f478c20b8dccb906f8ad", size = 2488384, upload-time = "2025-08-10T20:21:16.233Z" },
{ url = "https://files.pythonhosted.org/packages/b3/3e/4f6800de4b1ca9c0f011ffd46f4871cbf3b10b2d02a38a4c37c1445fe88e/kiwisolver-1.4.10rc0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:244946ee11b873e9ae4f01d8bc8cfe44d6c7369421e1980b3220b27e5dccae79", size = 2292042, upload-time = "2025-08-10T20:21:17.945Z" },
{ url = "https://files.pythonhosted.org/packages/00/16/fb202e13497ff1a9f62bbfb5362e49b7895718abdd33ebbeb2f7dc4373bd/kiwisolver-1.4.10rc0-cp313-cp313-win_amd64.whl", hash = "sha256:08362526667a90be7cca47bb67f8d4a17f43a835f31d06dbb6fadc097624d443", size = 73946, upload-time = "2025-08-10T20:21:19.232Z" },
{ url = "https://files.pythonhosted.org/packages/6f/31/f2f8296942535dbd8a7c36c7532c135a0bbe34b1eacbafdc58695bcb2621/kiwisolver-1.4.10rc0-cp313-cp313-win_arm64.whl", hash = "sha256:de14f1d8093397cfac557fb020db25c4082c2ae488d6127fbc9273b7ae9af3fd", size = 65078, upload-time = "2025-08-10T20:21:20.257Z" },
{ url = "https://files.pythonhosted.org/packages/11/f2/2b3ec9b63e57f948a0bf1867e7e5b6a1aca12623335a6a7bdbccd72fa49d/kiwisolver-1.4.10rc0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f0ec8b92ac6bee771883865afd9a8725fef2ad420f77b88c91313ff1d417b5f7", size = 126584, upload-time = "2025-08-10T20:21:21.345Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e3/c6647c859796dfb6b60b5c2b6216877831adec5558e21bc9bd061d8b2e08/kiwisolver-1.4.10rc0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0315b7f45a244696093b53308d2546879341b3e85d4bf4a66e21d35e076aa7eb", size = 67962, upload-time = "2025-08-10T20:21:22.449Z" },
{ url = "https://files.pythonhosted.org/packages/21/8a/85ef96d5f220887b60fee183a4ac977fab7189404b625382c6aeae297eb6/kiwisolver-1.4.10rc0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:65ff3f2320ced57b1d020a9c31ccdfa9eb8b58e2b40be1e47feafc8785c16a1a", size = 66478, upload-time = "2025-08-10T20:21:23.471Z" },
@@ -982,7 +984,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/bf/b91302b110eb3adabaa429d9597bb98dba4e43c39570a75c59460883ece5/kiwisolver-1.4.10rc0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59bb9e7089552273187c8e7b7af62543d3198684231f26d5da60b7bc31a73395", size = 2420031, upload-time = "2025-08-10T20:21:32.181Z" },
{ url = "https://files.pythonhosted.org/packages/8d/3a/8bc22b09b485775a4fda94a37fd1d6d0c8db2640481a2941277ce0c0fd81/kiwisolver-1.4.10rc0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:dcdbe9d777d2a55749db7ff810ba58f530c06f52e612e4e407fc19457709b148", size = 2594729, upload-time = "2025-08-10T20:21:33.959Z" },
{ url = "https://files.pythonhosted.org/packages/47/12/597a6c2f00a09ca83e7c0a567b756ac6ad7896428ea4677128cf9ee7e9b2/kiwisolver-1.4.10rc0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9b485e2e377a594dbcf131e8c90f2561d10b4e654025c0760a8bbd2e23427748", size = 2391799, upload-time = "2025-08-10T20:21:36.063Z" },
{ url = "https://files.pythonhosted.org/packages/cb/67/bcf5fe263a8da1ad3ce39830c3e9342fe9041f1806d1ac8493600e29fed1/kiwisolver-1.4.10rc0-cp313-cp313t-win_arm64.whl", hash = "sha256:6fac44a17ac78b8952a07f8261f25cc35f7b4d1278c835332576ec7bf9429ce4", size = 68698, upload-time = "2025-08-10T20:21:37.415Z" },
]
[[package]]
@@ -1073,9 +1074,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" },
{ url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" },
{ url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" },
{ url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" },
{ url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" },
{ url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" },
]
[[package]]
@@ -1116,9 +1114,6 @@ wheels = [
{ 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" },
@@ -1127,9 +1122,6 @@ wheels = [
{ 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" },
]
[[package]]
@@ -1154,15 +1146,15 @@ name = "matplotlib"
version = "3.10.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "contourpy", marker = "sys_platform == 'darwin' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "cycler", marker = "sys_platform == 'darwin' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "fonttools", marker = "sys_platform == 'darwin' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "kiwisolver", marker = "sys_platform == 'darwin' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "numpy", marker = "sys_platform == 'darwin' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "packaging", marker = "sys_platform == 'darwin' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "pillow", marker = "sys_platform == 'darwin' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "pyparsing", marker = "sys_platform == 'darwin' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "python-dateutil", marker = "sys_platform == 'darwin' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "contourpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "cycler", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "fonttools", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "kiwisolver", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "numpy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "pillow", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "pyparsing", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269, upload-time = "2025-12-10T22:56:51.155Z" }
wheels = [
@@ -1171,15 +1163,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/75/97/a471f1c3eb1fd6f6c24a31a5858f443891d5127e63a7788678d14e249aea/matplotlib-3.10.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486", size = 8718474, upload-time = "2025-12-10T22:55:47.864Z" },
{ url = "https://files.pythonhosted.org/packages/01/be/cd478f4b66f48256f42927d0acbcd63a26a893136456cd079c0cc24fbabf/matplotlib-3.10.8-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646d95230efb9ca614a7a594d4fcacde0ac61d25e37dd51710b36477594963ce", size = 9549637, upload-time = "2025-12-10T22:55:50.048Z" },
{ url = "https://files.pythonhosted.org/packages/5d/7c/8dc289776eae5109e268c4fb92baf870678dc048a25d4ac903683b86d5bf/matplotlib-3.10.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f89c151aab2e2e23cb3fe0acad1e8b82841fd265379c4cecd0f3fcb34c15e0f6", size = 9613678, upload-time = "2025-12-10T22:55:52.21Z" },
{ url = "https://files.pythonhosted.org/packages/64/40/37612487cc8a437d4dd261b32ca21fe2d79510fe74af74e1f42becb1bdb8/matplotlib-3.10.8-cp313-cp313-win_amd64.whl", hash = "sha256:e8ea3e2d4066083e264e75c829078f9e149fa119d27e19acd503de65e0b13149", size = 8142686, upload-time = "2025-12-10T22:55:54.253Z" },
{ url = "https://files.pythonhosted.org/packages/66/52/8d8a8730e968185514680c2a6625943f70269509c3dcfc0dcf7d75928cb8/matplotlib-3.10.8-cp313-cp313-win_arm64.whl", hash = "sha256:c108a1d6fa78a50646029cb6d49808ff0fc1330fda87fa6f6250c6b5369b6645", size = 8012917, upload-time = "2025-12-10T22:55:56.268Z" },
{ url = "https://files.pythonhosted.org/packages/b5/27/51fe26e1062f298af5ef66343d8ef460e090a27fea73036c76c35821df04/matplotlib-3.10.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ad3d9833a64cf48cc4300f2b406c3d0f4f4724a91c0bd5640678a6ba7c102077", size = 8305679, upload-time = "2025-12-10T22:55:57.856Z" },
{ url = "https://files.pythonhosted.org/packages/2c/1e/4de865bc591ac8e3062e835f42dd7fe7a93168d519557837f0e37513f629/matplotlib-3.10.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:eb3823f11823deade26ce3b9f40dcb4a213da7a670013929f31d5f5ed1055b22", size = 8198336, upload-time = "2025-12-10T22:55:59.371Z" },
{ url = "https://files.pythonhosted.org/packages/c6/cb/2f7b6e75fb4dce87ef91f60cac4f6e34f4c145ab036a22318ec837971300/matplotlib-3.10.8-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9050fee89a89ed57b4fb2c1bfac9a3d0c57a0d55aed95949eedbc42070fea39", size = 8731653, upload-time = "2025-12-10T22:56:01.032Z" },
{ url = "https://files.pythonhosted.org/packages/46/b3/bd9c57d6ba670a37ab31fb87ec3e8691b947134b201f881665b28cc039ff/matplotlib-3.10.8-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b44d07310e404ba95f8c25aa5536f154c0a8ec473303535949e52eb71d0a1565", size = 9561356, upload-time = "2025-12-10T22:56:02.95Z" },
{ url = "https://files.pythonhosted.org/packages/c0/3d/8b94a481456dfc9dfe6e39e93b5ab376e50998cddfd23f4ae3b431708f16/matplotlib-3.10.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a", size = 9614000, upload-time = "2025-12-10T22:56:05.411Z" },
{ url = "https://files.pythonhosted.org/packages/bd/cd/bc06149fe5585ba800b189a6a654a75f1f127e8aab02fd2be10df7fa500c/matplotlib-3.10.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958", size = 8220043, upload-time = "2025-12-10T22:56:07.551Z" },
{ url = "https://files.pythonhosted.org/packages/e3/de/b22cf255abec916562cc04eef457c13e58a1990048de0c0c3604d082355e/matplotlib-3.10.8-cp313-cp313t-win_arm64.whl", hash = "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5", size = 8062075, upload-time = "2025-12-10T22:56:09.178Z" },
]
[[package]]
@@ -1377,8 +1365,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/28/83/36557b04cfdc317ed8a525c4993b23e43a8fbcddaddd78619112ca07138c/msgspec-0.20.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fac7e9c92eddcd24c19d9e5f6249760941485dff97802461ae7c995a2450111", size = 224917, upload-time = "2025-11-24T03:55:48.06Z" },
{ url = "https://files.pythonhosted.org/packages/8f/56/362037a1ed5be0b88aced59272442c4b40065c659700f4b195a7f4d0ac88/msgspec-0.20.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f953a66f2a3eb8d5ea64768445e2bb301d97609db052628c3e1bcb7d87192a9f", size = 222821, upload-time = "2025-11-24T03:55:49.388Z" },
{ url = "https://files.pythonhosted.org/packages/92/75/fa2370ec341cedf663731ab7042e177b3742645c5dd4f64dc96bd9f18a6b/msgspec-0.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:247af0313ae64a066d3aea7ba98840f6681ccbf5c90ba9c7d17f3e39dbba679c", size = 227227, upload-time = "2025-11-24T03:55:51.125Z" },
{ url = "https://files.pythonhosted.org/packages/f1/25/5e8080fe0117f799b1b68008dc29a65862077296b92550632de015128579/msgspec-0.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:67d5e4dfad52832017018d30a462604c80561aa62a9d548fc2bd4e430b66a352", size = 189966, upload-time = "2025-11-24T03:55:52.458Z" },
{ url = "https://files.pythonhosted.org/packages/79/b6/63363422153937d40e1cb349c5081338401f8529a5a4e216865decd981bf/msgspec-0.20.0-cp313-cp313-win_arm64.whl", hash = "sha256:91a52578226708b63a9a13de287b1ec3ed1123e4a088b198143860c087770458", size = 175378, upload-time = "2025-11-24T03:55:53.721Z" },
]
[[package]]
@@ -1402,9 +1388,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/a4/a89abdb0229e533fb925e7c6e5c40201c2873efebc9abaf14046a4536ee6/multidict-6.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64", size = 261254, upload-time = "2025-10-06T14:50:12.28Z" },
{ url = "https://files.pythonhosted.org/packages/8d/aa/0e2b27bd88b40a4fb8dc53dd74eecac70edaa4c1dd0707eb2164da3675b3/multidict-6.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd", size = 257967, upload-time = "2025-10-06T14:50:14.16Z" },
{ url = "https://files.pythonhosted.org/packages/d0/8e/0c67b7120d5d5f6d874ed85a085f9dc770a7f9d8813e80f44a9fec820bb7/multidict-6.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288", size = 250085, upload-time = "2025-10-06T14:50:15.639Z" },
{ url = "https://files.pythonhosted.org/packages/ba/55/b73e1d624ea4b8fd4dd07a3bb70f6e4c7c6c5d9d640a41c6ffe5cdbd2a55/multidict-6.7.0-cp313-cp313-win32.whl", hash = "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17", size = 41713, upload-time = "2025-10-06T14:50:17.066Z" },
{ url = "https://files.pythonhosted.org/packages/32/31/75c59e7d3b4205075b4c183fa4ca398a2daf2303ddf616b04ae6ef55cffe/multidict-6.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390", size = 45915, upload-time = "2025-10-06T14:50:18.264Z" },
{ url = "https://files.pythonhosted.org/packages/31/2a/8987831e811f1184c22bc2e45844934385363ee61c0a2dcfa8f71b87e608/multidict-6.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e", size = 43077, upload-time = "2025-10-06T14:50:19.853Z" },
{ url = "https://files.pythonhosted.org/packages/e8/68/7b3a5170a382a340147337b300b9eb25a9ddb573bcdfff19c0fa3f31ffba/multidict-6.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00", size = 83114, upload-time = "2025-10-06T14:50:21.223Z" },
{ url = "https://files.pythonhosted.org/packages/55/5c/3fa2d07c84df4e302060f555bbf539310980362236ad49f50eeb0a1c1eb9/multidict-6.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb", size = 48442, upload-time = "2025-10-06T14:50:22.871Z" },
{ url = "https://files.pythonhosted.org/packages/fc/56/67212d33239797f9bd91962bb899d72bb0f4c35a8652dcdb8ed049bef878/multidict-6.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b", size = 46885, upload-time = "2025-10-06T14:50:24.258Z" },
@@ -1420,9 +1403,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/0a/4349d540d4a883863191be6eb9a928846d4ec0ea007d3dcd36323bb058ac/multidict-6.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c", size = 252312, upload-time = "2025-10-06T14:50:41.612Z" },
{ url = "https://files.pythonhosted.org/packages/26/64/d5416038dbda1488daf16b676e4dbfd9674dde10a0cc8f4fc2b502d8125d/multidict-6.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762", size = 246935, upload-time = "2025-10-06T14:50:43.972Z" },
{ url = "https://files.pythonhosted.org/packages/9f/8c/8290c50d14e49f35e0bd4abc25e1bc7711149ca9588ab7d04f886cdf03d9/multidict-6.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6", size = 243385, upload-time = "2025-10-06T14:50:45.648Z" },
{ url = "https://files.pythonhosted.org/packages/ef/a0/f83ae75e42d694b3fbad3e047670e511c138be747bc713cf1b10d5096416/multidict-6.7.0-cp313-cp313t-win32.whl", hash = "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d", size = 47777, upload-time = "2025-10-06T14:50:47.154Z" },
{ url = "https://files.pythonhosted.org/packages/dc/80/9b174a92814a3830b7357307a792300f42c9e94664b01dee8e457551fa66/multidict-6.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6", size = 53104, upload-time = "2025-10-06T14:50:48.851Z" },
{ url = "https://files.pythonhosted.org/packages/cc/28/04baeaf0428d95bb7a7bea0e691ba2f31394338ba424fb0679a9ed0f4c09/multidict-6.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792", size = 45503, upload-time = "2025-10-06T14:50:50.16Z" },
{ url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" },
]
@@ -1479,9 +1459,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b6/61/8f4d41c4ccdac30e4b1a4fa7be4b0f9914d8314a5058472f84c8e101a418/nh3-0.3.2-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:2ab70e8c6c7d2ce953d2a58102eefa90c2d0a5ed7aa40c7e29a487bc5e613131", size = 1075471, upload-time = "2025-10-30T11:17:38.225Z" },
{ url = "https://files.pythonhosted.org/packages/b0/c6/966aec0cb4705e69f6c3580422c239205d5d4d0e50fac380b21e87b6cf1b/nh3-0.3.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:1710f3901cd6440ca92494ba2eb6dc260f829fa8d9196b659fa10de825610ce0", size = 1002439, upload-time = "2025-10-30T11:17:39.553Z" },
{ url = "https://files.pythonhosted.org/packages/e2/c8/97a2d5f7a314cce2c5c49f30c6f161b7f3617960ade4bfc2fd1ee092cb20/nh3-0.3.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91e9b001101fb4500a2aafe3e7c92928d85242d38bf5ac0aba0b7480da0a4cd6", size = 987439, upload-time = "2025-10-30T11:17:40.81Z" },
{ url = "https://files.pythonhosted.org/packages/0d/95/2d6fc6461687d7a171f087995247dec33e8749a562bfadd85fb5dbf37a11/nh3-0.3.2-cp38-abi3-win32.whl", hash = "sha256:169db03df90da63286e0560ea0efa9b6f3b59844a9735514a1d47e6bb2c8c61b", size = 589826, upload-time = "2025-10-30T11:17:42.239Z" },
{ url = "https://files.pythonhosted.org/packages/64/9a/1a1c154f10a575d20dd634e5697805e589bbdb7673a0ad00e8da90044ba7/nh3-0.3.2-cp38-abi3-win_amd64.whl", hash = "sha256:562da3dca7a17f9077593214a9781a94b8d76de4f158f8c895e62f09573945fe", size = 596406, upload-time = "2025-10-30T11:17:43.773Z" },
{ url = "https://files.pythonhosted.org/packages/9e/7e/a96255f63b7aef032cbee8fc4d6e37def72e3aaedc1f72759235e8f13cb1/nh3-0.3.2-cp38-abi3-win_arm64.whl", hash = "sha256:cf5964d54edd405e68583114a7cba929468bcd7db5e676ae38ee954de1cfc104", size = 584162, upload-time = "2025-10-30T11:17:44.96Z" },
]
[[package]]
@@ -1511,8 +1488,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/0c/31f3d8c327df06df26393fdbe4082398e768429132f2690c57290da7d7ca/nodejs_wheel_binaries-25.2.1rc0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:ce9410db0cd11b9ce5e56774f58b9d4ca6f06a6a6237801a1d70a6a2b4d57ae9", size = 61289023, upload-time = "2025-11-24T22:55:56.446Z" },
{ url = "https://files.pythonhosted.org/packages/c5/e6/7b1680085d0fc863ab3d0c8fe43c71ea2999140b083130b506c69d4e5351/nodejs_wheel_binaries-25.2.1rc0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:30d9a0bb559006689c10561dbcc7748cd7e73d51d2d2318cfffc46ba08c2c539", size = 62740952, upload-time = "2025-11-24T22:56:00.693Z" },
{ url = "https://files.pythonhosted.org/packages/11/3a/865f45bca0f6daf6a6150e20ae4e1ef1757574967b5c1a55705eb1a3aa51/nodejs_wheel_binaries-25.2.1rc0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8c30fe61adfcf89002002438fe810ebd660a856417540578aeb6eb4b9ef88c74", size = 63431735, upload-time = "2025-11-24T22:56:07.462Z" },
{ url = "https://files.pythonhosted.org/packages/cb/67/edcaf9408b7da9cf1cf28bbb51e19c26abd98b02f5df073e29d12b2bc17c/nodejs_wheel_binaries-25.2.1rc0-py2.py3-none-win_amd64.whl", hash = "sha256:5f26d20e030c5604ab175b7942c5f6bcad4a162dde0176da897e03c0b78555b5", size = 41845476, upload-time = "2025-11-24T22:56:11.589Z" },
{ url = "https://files.pythonhosted.org/packages/d5/7d/f662bf1eb15168642ccfe23f2208a6cbb1bebc2c92b8fecb3ac31c860210/nodejs_wheel_binaries-25.2.1rc0-py2.py3-none-win_arm64.whl", hash = "sha256:843a502d7ddd394be67411bfb5816eb6325b915606ac473f659c9b96c5101bc9", size = 39441608, upload-time = "2025-11-24T22:56:15.525Z" },
]
[[package]]
@@ -1529,9 +1504,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/87/d341e519956273b39d8d47969dd1eaa1af740615394fe67d06f1efa68773/numpy-2.4.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e3087f53e2b4428766b54932644d148613c5a595150533ae7f00dab2f319a8", size = 16359305, upload-time = "2026-01-10T06:43:19.376Z" },
{ url = "https://files.pythonhosted.org/packages/32/91/789132c6666288eaa20ae8066bb99eba1939362e8f1a534949a215246e97/numpy-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:49e792ec351315e16da54b543db06ca8a86985ab682602d90c60ef4ff4db2a9c", size = 16181909, upload-time = "2026-01-10T06:43:21.808Z" },
{ url = "https://files.pythonhosted.org/packages/cf/b8/090b8bd27b82a844bb22ff8fdf7935cb1980b48d6e439ae116f53cdc2143/numpy-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79e9e06c4c2379db47f3f6fc7a8652e7498251789bf8ff5bd43bf478ef314ca2", size = 18284380, upload-time = "2026-01-10T06:43:23.957Z" },
{ url = "https://files.pythonhosted.org/packages/67/78/722b62bd31842ff029412271556a1a27a98f45359dea78b1548a3a9996aa/numpy-2.4.1-cp313-cp313-win32.whl", hash = "sha256:3d1a100e48cb266090a031397863ff8a30050ceefd798f686ff92c67a486753d", size = 5957089, upload-time = "2026-01-10T06:43:27.535Z" },
{ url = "https://files.pythonhosted.org/packages/da/a6/cf32198b0b6e18d4fbfa9a21a992a7fca535b9bb2b0cdd217d4a3445b5ca/numpy-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:92a0e65272fd60bfa0d9278e0484c2f52fe03b97aedc02b357f33fe752c52ffb", size = 12307230, upload-time = "2026-01-10T06:43:29.298Z" },
{ url = "https://files.pythonhosted.org/packages/44/6c/534d692bfb7d0afe30611320c5fb713659dcb5104d7cc182aff2aea092f5/numpy-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:20d4649c773f66cc2fc36f663e091f57c3b7655f936a4c681b4250855d1da8f5", size = 10313125, upload-time = "2026-01-10T06:43:31.782Z" },
{ url = "https://files.pythonhosted.org/packages/da/a1/354583ac5c4caa566de6ddfbc42744409b515039e085fab6e0ff942e0df5/numpy-2.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f93bc6892fe7b0663e5ffa83b61aab510aacffd58c16e012bb9352d489d90cb7", size = 12496156, upload-time = "2026-01-10T06:43:34.237Z" },
{ url = "https://files.pythonhosted.org/packages/51/b0/42807c6e8cce58c00127b1dc24d365305189991f2a7917aa694a109c8d7d/numpy-2.4.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:178de8f87948163d98a4c9ab5bee4ce6519ca918926ec8df195af582de28544d", size = 5324663, upload-time = "2026-01-10T06:43:36.211Z" },
{ url = "https://files.pythonhosted.org/packages/fe/55/7a621694010d92375ed82f312b2f28017694ed784775269115323e37f5e2/numpy-2.4.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:98b35775e03ab7f868908b524fc0a84d38932d8daf7b7e1c3c3a1b6c7a2c9f15", size = 6645224, upload-time = "2026-01-10T06:43:37.884Z" },
@@ -1539,9 +1511,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/03/d1/8cf62d8bb2062da4fb82dd5d49e47c923f9c0738032f054e0a75342faba7/numpy-2.4.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:529050522e983e00a6c1c6b67411083630de8b57f65e853d7b03d9281b8694d2", size = 16407279, upload-time = "2026-01-10T06:43:41.93Z" },
{ url = "https://files.pythonhosted.org/packages/86/1c/95c86e17c6b0b31ce6ef219da00f71113b220bcb14938c8d9a05cee0ff53/numpy-2.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2302dc0224c1cbc49bb94f7064f3f923a971bfae45c33870dcbff63a2a550505", size = 16248316, upload-time = "2026-01-10T06:43:44.121Z" },
{ url = "https://files.pythonhosted.org/packages/30/b4/e7f5ff8697274c9d0fa82398b6a372a27e5cef069b37df6355ccb1f1db1a/numpy-2.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9171a42fcad32dcf3fa86f0a4faa5e9f8facefdb276f54b8b390d90447cff4e2", size = 18329884, upload-time = "2026-01-10T06:43:46.613Z" },
{ url = "https://files.pythonhosted.org/packages/37/a4/b073f3e9d77f9aec8debe8ca7f9f6a09e888ad1ba7488f0c3b36a94c03ac/numpy-2.4.1-cp313-cp313t-win32.whl", hash = "sha256:382ad67d99ef49024f11d1ce5dcb5ad8432446e4246a4b014418ba3a1175a1f4", size = 6081138, upload-time = "2026-01-10T06:43:48.854Z" },
{ url = "https://files.pythonhosted.org/packages/16/16/af42337b53844e67752a092481ab869c0523bc95c4e5c98e4dac4e9581ac/numpy-2.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:62fea415f83ad8fdb6c20840578e5fbaf5ddd65e0ec6c3c47eda0f69da172510", size = 12447478, upload-time = "2026-01-10T06:43:50.476Z" },
{ url = "https://files.pythonhosted.org/packages/6c/f8/fa85b2eac68ec631d0b631abc448552cb17d39afd17ec53dcbcc3537681a/numpy-2.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a7870e8c5fc11aef57d6fea4b4085e537a3a60ad2cdd14322ed531fdca68d261", size = 10382981, upload-time = "2026-01-10T06:43:52.575Z" },
]
[[package]]
@@ -1551,7 +1520,6 @@ source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/a5/fce49e2ae977e0ccc084e5adafceb4f0ac0c8333cb6863501618a7277f67/nvidia_cublas-13.1.0.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:c86fc7f7ae36d7528288c5d88098edcb7b02c633d262e7ddbb86b0ad91be5df2", size = 542851226, upload-time = "2025-10-09T08:59:04.818Z" },
{ url = "https://files.pythonhosted.org/packages/e7/44/423ac00af4dd95a5aeb27207e2c0d9b7118702149bf4704c3ddb55bb7429/nvidia_cublas-13.1.0.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:ee8722c1f0145ab246bccb9e452153b5e0515fd094c3678df50b2a0888b8b171", size = 423133236, upload-time = "2025-10-09T08:59:32.536Z" },
{ url = "https://files.pythonhosted.org/packages/10/f5/f50bc3f5c2bb57ab8f5b4d78bc1146b57810d42cb8fcb28cbe2e14050376/nvidia_cublas-13.1.0.3-py3-none-win_amd64.whl", hash = "sha256:2a3b94a37def342471c59fad7856caee4926809a72dd5270155d6a31b5b277be", size = 404355960, upload-time = "2025-10-09T09:07:00.987Z" },
]
[[package]]
@@ -1564,7 +1532,6 @@ dependencies = [
wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/a2/c96163a0fff1839c0c9548bbdeae7b853b867009e33b9b9264adc238b1cf/nvidia_cublas_cu12-12.9.2.10-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:5572131a59c3eebeeb1c4c8144f772d49372c20124916e072a0e3fc30df421d5", size = 575012079, upload-time = "2026-04-08T18:51:47.303Z" },
{ url = "https://files.pythonhosted.org/packages/cb/c0/0a517bfe63ccd3b92eb254d264e28fca3c7cab75d07daea315250fb1bf73/nvidia_cublas_cu12-12.9.2.10-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:e4f53a8ca8c5d6e8c492d0d0a3d565ecb59a751b19cfdaa4f6da0ab2104c1702", size = 581240110, upload-time = "2026-04-08T18:52:31.532Z" },
{ url = "https://files.pythonhosted.org/packages/20/e2/fc9a0e985249d873150276d5afb02e39a66817fedbf1a385724393e505ed/nvidia_cublas_cu12-12.9.2.10-py3-none-win_amd64.whl", hash = "sha256:623f43027d40d44ceadf0043f002bd25cf353e8f13ce90b9a87057019f560661", size = 553162896, upload-time = "2026-04-08T18:53:10.035Z" },
]
[[package]]
@@ -1574,7 +1541,6 @@ source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/68/483a78f5e8f31b08fb1bb671559968c0ca3a065ac7acabfc7cee55214fd6/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575", size = 90215200, upload-time = "2025-09-04T08:28:44.204Z" },
{ url = "https://files.pythonhosted.org/packages/b7/dc/6bb80850e0b7edd6588d560758f17e0550893a1feaf436807d64d2da040f/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d27f20a0ca67a4bb34268a5e951033496c5b74870b868bacd046b1b8e0c3267b", size = 43015449, upload-time = "2025-09-04T08:28:20.239Z" },
{ url = "https://files.pythonhosted.org/packages/4a/af/345fedb9f4c76c84ab4fa445b36bd4048a4d9db60e6bc76b4f913ff4b852/nvidia_cuda_nvrtc-13.0.88-py3-none-win_amd64.whl", hash = "sha256:6bcd4e7f8e205cbe644f5a98f2f799bef9556fefc89dd786e79a16312ce49872", size = 76807835, upload-time = "2025-09-04T08:39:15.274Z" },
]
[[package]]
@@ -1584,7 +1550,6 @@ source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/85/e4af82cc9202023862090bfca4ea827d533329e925c758f0cde964cb54b7/nvidia_cuda_nvrtc_cu12-12.9.86-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:210cf05005a447e29214e9ce50851e83fc5f4358df8b453155d5e1918094dcb4", size = 89568129, upload-time = "2025-06-05T20:02:41.973Z" },
{ url = "https://files.pythonhosted.org/packages/64/eb/c2295044b8f3b3b08860e2f6a912b702fc92568a167259df5dddb78f325e/nvidia_cuda_nvrtc_cu12-12.9.86-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:096d4de6bda726415dfaf3198d4f5c522b8e70139c97feef5cd2ca6d4cd9cead", size = 44528905, upload-time = "2025-06-05T20:02:29.754Z" },
{ url = "https://files.pythonhosted.org/packages/52/de/823919be3b9d0ccbf1f784035423c5f18f4267fb0123558d58b813c6ec86/nvidia_cuda_nvrtc_cu12-12.9.86-py3-none-win_amd64.whl", hash = "sha256:72972ebdcf504d69462d3bcd67e7b81edd25d0fb85a2c46d3ea3517666636349", size = 76408187, upload-time = "2025-06-05T20:12:27.819Z" },
]
[[package]]
@@ -1597,7 +1562,6 @@ dependencies = [
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/e9/aea85c214a5dad046e56131428c22ff40d0359db3a930040698c0c6c8e68/nvidia_cudnn_cu12-9.21.0.82-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:8ba7c5067854d2b8d8dc21a65bbc5a642b31e4dddc8864cf3a093d25a92e874c", size = 759281118, upload-time = "2026-04-14T15:30:44.958Z" },
{ url = "https://files.pythonhosted.org/packages/5c/cf/47778414dd633ba93395f9d34c87283916c3163f57000c2aeef1f869c649/nvidia_cudnn_cu12-9.21.0.82-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:286af0a8ee51e3e5eb0e858b0b93a85a5ae6a686f22a0c83c8d7d4dc9151402f", size = 704763924, upload-time = "2026-04-15T16:43:40.04Z" },
{ url = "https://files.pythonhosted.org/packages/c6/e9/e91296c0d7b4b565f38de9ccbf757a8c3172b62a485222751d39f673a26c/nvidia_cudnn_cu12-9.21.0.82-py3-none-win_amd64.whl", hash = "sha256:29c69af1d2f8a6778ef6dbc7829416e6a4c8585b7ca2cab5fb1876bf2e393589", size = 686896833, upload-time = "2026-04-14T15:35:00.959Z" },
]
[[package]]
@@ -1610,7 +1574,6 @@ dependencies = [
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/84/26025437c1e6b61a707442184fa0c03d083b661adf3a3eecfd6d21677740/nvidia_cudnn_cu13-9.19.0.56-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:6ed29ffaee1176c612daf442e4dd6cfeb6a0caa43ddcbeb59da94953030b1be4", size = 433781201, upload-time = "2026-02-03T20:40:53.805Z" },
{ url = "https://files.pythonhosted.org/packages/a3/22/0b4b932655d17a6da1b92fa92ab12844b053bb2ac2475e179ba6f043da1e/nvidia_cudnn_cu13-9.19.0.56-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:d20e1734305e9d68889a96e3f35094d733ff1f83932ebe462753973e53a572bf", size = 366066321, upload-time = "2026-02-03T20:44:52.837Z" },
{ url = "https://files.pythonhosted.org/packages/91/a2/f020386683ee9ab2c9a9f7f79290d9b0d07f7241de54dc746af2abd188d2/nvidia_cudnn_cu13-9.19.0.56-py3-none-win_amd64.whl", hash = "sha256:40d8c375005bcb01495f8edf375230b203a411a0c05fb6dc92a3781edcb23eac", size = 350547366, upload-time = "2026-02-03T20:50:49.563Z" },
]
[[package]]
@@ -1650,8 +1613,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/10/4327dbf87f75ae813405fd9a9b4a5cde63d506ffed0a096a440a4cabd89c/openai_harmony-0.0.8-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:cbaa3bda75ef0d8836e1f8cc84af62f971b1d756d740efc95c38c3e04c0bfde2", size = 2932931, upload-time = "2025-11-05T19:07:01.437Z" },
{ url = "https://files.pythonhosted.org/packages/8a/c8/1774eec4f6f360ef57618fb8f52e3d3af245b2491bd0297513aa09eec04b/openai_harmony-0.0.8-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:772922a9bd24e133950fad71eb1550836f415a88e8c77870e12d0c3bd688ddc2", size = 2996140, upload-time = "2025-11-05T19:07:03.438Z" },
{ url = "https://files.pythonhosted.org/packages/60/c3/3d1e01e2dba517a91760e4a03e4f20ffc75039a6fe584d0e6f9b5c78fd15/openai_harmony-0.0.8-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:007b0476a1f331f8130783f901f1da6f5a7057af1a4891f1b6a31dec364189b5", size = 3205080, upload-time = "2025-11-05T19:07:05.078Z" },
{ url = "https://files.pythonhosted.org/packages/14/63/119de431572d7c70a7bf1037034a9be6ed0a7502a7498ba7302bca5b3242/openai_harmony-0.0.8-cp38-abi3-win32.whl", hash = "sha256:a9b5f893326b28d9e935ade14b4f655f5a840942473bc89b201c25f7a15af9cf", size = 2082457, upload-time = "2025-11-05T19:07:09.631Z" },
{ url = "https://files.pythonhosted.org/packages/40/1f/c83cf5a206c263ee70448a5ae4264682555f4d0b5bed0d2cc6ca1108103d/openai_harmony-0.0.8-cp38-abi3-win_amd64.whl", hash = "sha256:39d44f0d8f466bd56698e7ead708bead3141e27b9b87e3ab7d5a6d0e4a869ee5", size = 2438369, upload-time = "2025-11-05T19:07:08.1Z" },
]
[[package]]
@@ -1668,8 +1629,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cf/02/d9b73dbce28712204e85ae4c1e179505e9a771f95b33743a97e170caedde/opencv_python-4.13.0.90-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9911581e37b24169e4842069ff01d6645ea2bc4af7e10a022d9ebe340fd035ec", size = 70460479, upload-time = "2026-01-18T09:01:16.377Z" },
{ url = "https://files.pythonhosted.org/packages/fc/1c/87fa71968beb71481ed359e21772061ceff7c9b45a61b3e7daa71e5b0b66/opencv_python-4.13.0.90-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:1150b8f1947761b848bbfa9c96ceba8877743ffef157c08a04af6f7717ddd709", size = 46707819, upload-time = "2026-01-18T09:02:48.049Z" },
{ url = "https://files.pythonhosted.org/packages/af/16/915a94e5b537c328fa3e96b769c7d4eed3b67d1be978e0af658a3d3faed8/opencv_python-4.13.0.90-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:d6716f16149b04eea52f953b8ca983d60dd9cd4872c1fd5113f6e2fcebb90e93", size = 72926629, upload-time = "2026-01-18T09:04:29.23Z" },
{ url = "https://files.pythonhosted.org/packages/bf/84/9c63c84be013943dd4c5fff36157f1ec0ec894b69a2fc3026fd4e3c9280a/opencv_python-4.13.0.90-cp37-abi3-win32.whl", hash = "sha256:458a00f2ba47a877eca385be3e7bcc45e6d30a4361d107ce73c1800f516dab09", size = 30932151, upload-time = "2026-01-18T09:05:22.181Z" },
{ url = "https://files.pythonhosted.org/packages/13/de/291cbb17f44242ed6bfd3450fc2535d6bd298115c0ccd6f01cd51d4a11d7/opencv_python-4.13.0.90-cp37-abi3-win_amd64.whl", hash = "sha256:526bde4c33a86808a751e2bb57bf4921beb49794621810971926c472897f6433", size = 40211706, upload-time = "2026-01-18T09:06:06.749Z" },
]
[[package]]
@@ -1697,15 +1656,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f2/85/ab6d04733a7d6ff32bfc8382bf1b07078228f5d6ebec5266b91bfc5c4ff7/pandas-3.0.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ff8cf1d2896e34343197685f432450ec99a85ba8d90cce2030c5eee2ef98791", size = 10873196, upload-time = "2026-02-17T22:19:07.204Z" },
{ url = "https://files.pythonhosted.org/packages/48/a9/9301c83d0b47c23ac5deab91c6b39fd98d5b5db4d93b25df8d381451828f/pandas-3.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eca8b4510f6763f3d37359c2105df03a7a221a508f30e396a51d0713d462e68a", size = 11370859, upload-time = "2026-02-17T22:19:09.436Z" },
{ url = "https://files.pythonhosted.org/packages/59/fe/0c1fc5bd2d29c7db2ab372330063ad555fb83e08422829c785f5ec2176ca/pandas-3.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06aff2ad6f0b94a17822cf8b83bbb563b090ed82ff4fe7712db2ce57cd50d9b8", size = 11924584, upload-time = "2026-02-17T22:19:11.562Z" },
{ url = "https://files.pythonhosted.org/packages/d6/7d/216a1588b65a7aa5f4535570418a599d943c85afb1d95b0876fc00aa1468/pandas-3.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:9fea306c783e28884c29057a1d9baa11a349bbf99538ec1da44c8476563d1b25", size = 9742769, upload-time = "2026-02-17T22:19:13.926Z" },
{ url = "https://files.pythonhosted.org/packages/c4/cb/810a22a6af9a4e97c8ab1c946b47f3489c5bca5adc483ce0ffc84c9cc768/pandas-3.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:a8d37a43c52917427e897cb2e429f67a449327394396a81034a4449b99afda59", size = 9043855, upload-time = "2026-02-17T22:19:16.09Z" },
{ url = "https://files.pythonhosted.org/packages/92/fa/423c89086cca1f039cf1253c3ff5b90f157b5b3757314aa635f6bf3e30aa/pandas-3.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d54855f04f8246ed7b6fc96b05d4871591143c46c0b6f4af874764ed0d2d6f06", size = 10752673, upload-time = "2026-02-17T22:19:18.304Z" },
{ url = "https://files.pythonhosted.org/packages/22/23/b5a08ec1f40020397f0faba72f1e2c11f7596a6169c7b3e800abff0e433f/pandas-3.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e1b677accee34a09e0dc2ce5624e4a58a1870ffe56fc021e9caf7f23cd7668f", size = 10404967, upload-time = "2026-02-17T22:19:20.726Z" },
{ url = "https://files.pythonhosted.org/packages/5c/81/94841f1bb4afdc2b52a99daa895ac2c61600bb72e26525ecc9543d453ebc/pandas-3.0.1-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9cabbdcd03f1b6cd254d6dda8ae09b0252524be1592594c00b7895916cb1324", size = 10320575, upload-time = "2026-02-17T22:19:24.919Z" },
{ url = "https://files.pythonhosted.org/packages/0a/8b/2ae37d66a5342a83adadfd0cb0b4bf9c3c7925424dd5f40d15d6cfaa35ee/pandas-3.0.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ae2ab1f166668b41e770650101e7090824fd34d17915dd9cd479f5c5e0065e9", size = 10710921, upload-time = "2026-02-17T22:19:27.181Z" },
{ url = "https://files.pythonhosted.org/packages/a2/61/772b2e2757855e232b7ccf7cb8079a5711becb3a97f291c953def15a833f/pandas-3.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6bf0603c2e30e2cafac32807b06435f28741135cb8697eae8b28c7d492fc7d76", size = 11334191, upload-time = "2026-02-17T22:19:29.411Z" },
{ url = "https://files.pythonhosted.org/packages/1b/08/b16c6df3ef555d8495d1d265a7963b65be166785d28f06a350913a4fac78/pandas-3.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6c426422973973cae1f4a23e51d4ae85974f44871b24844e4f7de752dd877098", size = 11782256, upload-time = "2026-02-17T22:19:32.34Z" },
{ url = "https://files.pythonhosted.org/packages/55/80/178af0594890dee17e239fca96d3d8670ba0f5ff59b7d0439850924a9c09/pandas-3.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b03f91ae8c10a85c1613102c7bef5229b5379f343030a3ccefeca8a33414cf35", size = 10485047, upload-time = "2026-02-17T22:19:34.605Z" },
]
[[package]]
@@ -1743,9 +1699,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" },
{ url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" },
{ url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" },
{ url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" },
{ url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" },
{ url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" },
{ url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" },
{ url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" },
@@ -1754,9 +1707,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" },
{ url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" },
{ url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" },
{ url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" },
{ url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" },
{ url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" },
]
[[package]]
@@ -1768,6 +1718,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" },
]
[[package]]
name = "playwright"
version = "1.58.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
{ name = "pyee", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/c9/9c6061d5703267f1baae6a4647bfd1862e386fbfdb97d889f6f6ae9e3f64/playwright-1.58.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:96e3204aac292ee639edbfdef6298b4be2ea0a55a16b7068df91adac077cc606", size = 42251098, upload-time = "2026-01-30T15:09:24.028Z" },
{ url = "https://files.pythonhosted.org/packages/e0/40/59d34a756e02f8c670f0fee987d46f7ee53d05447d43cd114ca015cb168c/playwright-1.58.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:70c763694739d28df71ed578b9c8202bb83e8fe8fb9268c04dd13afe36301f71", size = 41039625, upload-time = "2026-01-30T15:09:27.558Z" },
{ url = "https://files.pythonhosted.org/packages/e1/ee/3ce6209c9c74a650aac9028c621f357a34ea5cd4d950700f8e2c4b7fe2c4/playwright-1.58.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:185e0132578733d02802dfddfbbc35f42be23a45ff49ccae5081f25952238117", size = 42251098, upload-time = "2026-01-30T15:09:30.461Z" },
{ url = "https://files.pythonhosted.org/packages/f1/af/009958cbf23fac551a940d34e3206e6c7eed2b8c940d0c3afd1feb0b0589/playwright-1.58.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c95568ba1eda83812598c1dc9be60b4406dffd60b149bc1536180ad108723d6b", size = 46235268, upload-time = "2026-01-30T15:09:33.787Z" },
{ url = "https://files.pythonhosted.org/packages/d9/a6/0e66ad04b6d3440dae73efb39540c5685c5fc95b17c8b29340b62abbd952/playwright-1.58.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f9999948f1ab541d98812de25e3a8c410776aa516d948807140aff797b4bffa", size = 45964214, upload-time = "2026-01-30T15:09:36.751Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -1813,9 +1779,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" },
{ url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" },
{ url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" },
{ url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" },
{ url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" },
{ url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" },
{ url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" },
{ url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" },
{ url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" },
@@ -1828,9 +1791,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" },
{ url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" },
{ url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" },
{ url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" },
{ url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" },
{ url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" },
{ url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" },
]
@@ -1840,8 +1800,6 @@ version = "5.29.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/57/394a763c103e0edf87f0938dafcd918d53b4c011dfc5c8ae80f3b0452dbb/protobuf-5.29.6.tar.gz", hash = "sha256:da9ee6a5424b6b30fd5e45c5ea663aef540ca95f9ad99d1e887e819cdf9b8723", size = 425623, upload-time = "2026-02-04T22:54:40.584Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/88/9ee58ff7863c479d6f8346686d4636dd4c415b0cbeed7a6a7d0617639c2a/protobuf-5.29.6-cp310-abi3-win32.whl", hash = "sha256:62e8a3114992c7c647bce37dcc93647575fc52d50e48de30c6fcb28a6a291eb1", size = 423357, upload-time = "2026-02-04T22:54:25.805Z" },
{ url = "https://files.pythonhosted.org/packages/1c/66/2dc736a4d576847134fb6d80bd995c569b13cdc7b815d669050bf0ce2d2c/protobuf-5.29.6-cp310-abi3-win_amd64.whl", hash = "sha256:7e6ad413275be172f67fdee0f43484b6de5a904cc1c3ea9804cb6fe2ff366eda", size = 435175, upload-time = "2026-02-04T22:54:28.592Z" },
{ url = "https://files.pythonhosted.org/packages/06/db/49b05966fd208ae3f44dcd33837b6243b4915c57561d730a43f881f24dea/protobuf-5.29.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:b5a169e664b4057183a34bdc424540e86eea47560f3c123a0d64de4e137f9269", size = 418619, upload-time = "2026-02-04T22:54:30.266Z" },
{ url = "https://files.pythonhosted.org/packages/b7/d7/48cbf6b0c3c39761e47a99cb483405f0fde2be22cf00d71ef316ce52b458/protobuf-5.29.6-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:a8866b2cff111f0f863c1b3b9e7572dc7eaea23a7fae27f6fc613304046483e6", size = 320284, upload-time = "2026-02-04T22:54:31.782Z" },
{ url = "https://files.pythonhosted.org/packages/e3/dd/cadd6ec43069247d91f6345fa7a0d2858bef6af366dbd7ba8f05d2c77d3b/protobuf-5.29.6-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:e3387f44798ac1106af0233c04fb8abf543772ff241169946f698b3a9a3d3ab9", size = 320478, upload-time = "2026-02-04T22:54:32.909Z" },
@@ -1858,16 +1816,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/26/97/a58a4968f8990617decee234258a2b4fc7cd9e35668387646c1963e69f26/psutil-7.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:81442dac7abfc2f4f4385ea9e12ddf5a796721c0f6133260687fec5c3780fa49", size = 130132, upload-time = "2025-12-29T08:26:06.228Z" },
{ url = "https://files.pythonhosted.org/packages/db/6d/ed44901e830739af5f72a85fa7ec5ff1edea7f81bfbf4875e409007149bd/psutil-7.2.1-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea46c0d060491051d39f0d2cff4f98d5c72b288289f57a21556cc7d504db37fc", size = 180612, upload-time = "2025-12-29T08:26:08.276Z" },
{ url = "https://files.pythonhosted.org/packages/c7/65/b628f8459bca4efbfae50d4bf3feaab803de9a160b9d5f3bd9295a33f0c2/psutil-7.2.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35630d5af80d5d0d49cfc4d64c1c13838baf6717a13effb35869a5919b854cdf", size = 183201, upload-time = "2025-12-29T08:26:10.622Z" },
{ url = "https://files.pythonhosted.org/packages/fb/23/851cadc9764edcc18f0effe7d0bf69f727d4cf2442deb4a9f78d4e4f30f2/psutil-7.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:923f8653416604e356073e6e0bccbe7c09990acef442def2f5640dd0faa9689f", size = 139081, upload-time = "2025-12-29T08:26:12.483Z" },
{ url = "https://files.pythonhosted.org/packages/59/82/d63e8494ec5758029f31c6cb06d7d161175d8281e91d011a4a441c8a43b5/psutil-7.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cfbe6b40ca48019a51827f20d830887b3107a74a79b01ceb8cc8de4ccb17b672", size = 134767, upload-time = "2025-12-29T08:26:14.528Z" },
{ url = "https://files.pythonhosted.org/packages/c5/cf/5180eb8c8bdf6a503c6919f1da28328bd1e6b3b1b5b9d5b01ae64f019616/psutil-7.2.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2e953fcfaedcfbc952b44744f22d16575d3aa78eb4f51ae74165b4e96e55f42", size = 128137, upload-time = "2025-12-29T08:26:27.759Z" },
{ url = "https://files.pythonhosted.org/packages/c5/2c/78e4a789306a92ade5000da4f5de3255202c534acdadc3aac7b5458fadef/psutil-7.2.1-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:05cc68dbb8c174828624062e73078e7e35406f4ca2d0866c272c2410d8ef06d1", size = 128947, upload-time = "2025-12-29T08:26:29.548Z" },
{ url = "https://files.pythonhosted.org/packages/29/f8/40e01c350ad9a2b3cb4e6adbcc8a83b17ee50dd5792102b6142385937db5/psutil-7.2.1-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e38404ca2bb30ed7267a46c02f06ff842e92da3bb8c5bfdadbd35a5722314d8", size = 154694, upload-time = "2025-12-29T08:26:32.147Z" },
{ url = "https://files.pythonhosted.org/packages/06/e4/b751cdf839c011a9714a783f120e6a86b7494eb70044d7d81a25a5cd295f/psutil-7.2.1-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab2b98c9fc19f13f59628d94df5cc4cc4844bc572467d113a8b517d634e362c6", size = 156136, upload-time = "2025-12-29T08:26:34.079Z" },
{ url = "https://files.pythonhosted.org/packages/44/ad/bbf6595a8134ee1e94a4487af3f132cef7fce43aef4a93b49912a48c3af7/psutil-7.2.1-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f78baafb38436d5a128f837fab2d92c276dfb48af01a240b861ae02b2413ada8", size = 148108, upload-time = "2025-12-29T08:26:36.225Z" },
{ url = "https://files.pythonhosted.org/packages/1c/15/dd6fd869753ce82ff64dcbc18356093471a5a5adf4f77ed1f805d473d859/psutil-7.2.1-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:99a4cd17a5fdd1f3d014396502daa70b5ec21bf4ffe38393e152f8e449757d67", size = 147402, upload-time = "2025-12-29T08:26:39.21Z" },
{ url = "https://files.pythonhosted.org/packages/34/68/d9317542e3f2b180c4306e3f45d3c922d7e86d8ce39f941bb9e2e9d8599e/psutil-7.2.1-cp37-abi3-win_amd64.whl", hash = "sha256:b1b0671619343aa71c20ff9767eced0483e4fc9e1f489d50923738caf6a03c17", size = 136938, upload-time = "2025-12-29T08:26:41.036Z" },
{ url = "https://files.pythonhosted.org/packages/3e/73/2ce007f4198c80fcf2cb24c169884f833fe93fbc03d55d302627b094ee91/psutil-7.2.1-cp37-abi3-win_arm64.whl", hash = "sha256:0d67c1822c355aa6f7314d92018fb4268a76668a536f133599b91edd48759442", size = 133836, upload-time = "2025-12-29T08:26:43.086Z" },
]
[[package]]
@@ -1882,14 +1836,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/93/10a48b5e238de6d562a411af6467e71e7aedbc9b87f8d3a35f1560ae30fb/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9b6f4f17b43bc39d56fec96e53fe89d94bac3eb134137964371b45352d40d0c2", size = 47585798, upload-time = "2026-02-16T10:11:09.401Z" },
{ url = "https://files.pythonhosted.org/packages/5c/20/476943001c54ef078dbf9542280e22741219a184a0632862bca4feccd666/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fc13fc6c403d1337acab46a2c4346ca6c9dec5780c3c697cf8abfd5e19b6b37", size = 48179446, upload-time = "2026-02-16T10:11:17.781Z" },
{ url = "https://files.pythonhosted.org/packages/4b/b6/5dd0c47b335fcd8edba9bfab78ad961bd0fd55ebe53468cc393f45e0be60/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c16ed4f53247fa3ffb12a14d236de4213a4415d127fe9cebed33d51671113e2", size = 50623972, upload-time = "2026-02-16T10:11:26.185Z" },
{ url = "https://files.pythonhosted.org/packages/d5/09/a532297c9591a727d67760e2e756b83905dd89adb365a7f6e9c72578bcc1/pyarrow-23.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:cecfb12ef629cf6be0b1887f9f86463b0dd3dc3195ae6224e74006be4736035a", size = 27540749, upload-time = "2026-02-16T10:12:23.297Z" },
{ url = "https://files.pythonhosted.org/packages/a5/8e/38749c4b1303e6ae76b3c80618f84861ae0c55dd3c2273842ea6f8258233/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:29f7f7419a0e30264ea261fdc0e5fe63ce5a6095003db2945d7cd78df391a7e1", size = 34471544, upload-time = "2026-02-16T10:11:32.535Z" },
{ url = "https://files.pythonhosted.org/packages/a3/73/f237b2bc8c669212f842bcfd842b04fc8d936bfc9d471630569132dc920d/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:33d648dc25b51fd8055c19e4261e813dfc4d2427f068bcecc8b53d01b81b0500", size = 35949911, upload-time = "2026-02-16T10:11:39.813Z" },
{ url = "https://files.pythonhosted.org/packages/0c/86/b912195eee0903b5611bf596833def7d146ab2d301afeb4b722c57ffc966/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd395abf8f91c673dd3589cadc8cc1ee4e8674fa61b2e923c8dd215d9c7d1f41", size = 44520337, upload-time = "2026-02-16T10:11:47.764Z" },
{ url = "https://files.pythonhosted.org/packages/69/c2/f2a717fb824f62d0be952ea724b4f6f9372a17eed6f704b5c9526f12f2f1/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:00be9576d970c31defb5c32eb72ef585bf600ef6d0a82d5eccaae96639cf9d07", size = 47548944, upload-time = "2026-02-16T10:11:56.607Z" },
{ url = "https://files.pythonhosted.org/packages/84/a7/90007d476b9f0dc308e3bc57b832d004f848fd6c0da601375d20d92d1519/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c2139549494445609f35a5cda4eb94e2c9e4d704ce60a095b342f82460c73a83", size = 48236269, upload-time = "2026-02-16T10:12:04.47Z" },
{ url = "https://files.pythonhosted.org/packages/b0/3f/b16fab3e77709856eb6ac328ce35f57a6d4a18462c7ca5186ef31b45e0e0/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7044b442f184d84e2351e5084600f0d7343d6117aabcbc1ac78eb1ae11eb4125", size = 50604794, upload-time = "2026-02-16T10:12:11.797Z" },
{ url = "https://files.pythonhosted.org/packages/e9/a1/22df0620a9fac31d68397a75465c344e83c3dfe521f7612aea33e27ab6c0/pyarrow-23.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a35581e856a2fafa12f3f54fce4331862b1cfb0bef5758347a858a4aa9d6bae8", size = 27660642, upload-time = "2026-02-16T10:12:17.746Z" },
]
[[package]]
@@ -1936,9 +1888,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
]
[[package]]
name = "pyee"
version = "13.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda12') or (extra == 'extra-3-exo-cpu' and extra == 'extra-3-exo-cuda13') or (extra == 'extra-3-exo-cuda12' and extra == 'extra-3-exo-cuda13')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" },
]
[[package]]
@@ -1971,9 +1932,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/1e/e8e36e1568f6865ac706c6e1f875c1a346ddaa9f9a8f923d66545d2240ed/pyinstaller-6.17.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:2a147b83cdebb07855bd5a663600891550062373a2ca375c58eacead33741a27", size = 737795, upload-time = "2025-11-24T19:42:50.675Z" },
{ url = "https://files.pythonhosted.org/packages/8d/15/9dc0f81ccb746c27bfa6ee53164422fe47ee079c7a717d9c4791aba78797/pyinstaller-6.17.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:f8cfbbfa6708e54fb936df6dd6eafaf133e84efb0d2fe25b91cfeefa793c4ca4", size = 736891, upload-time = "2025-11-24T19:42:54.458Z" },
{ url = "https://files.pythonhosted.org/packages/97/e6/bed54821c1ebe1275c559661d3e7bfa23c406673b515252dfbf89db56c65/pyinstaller-6.17.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:97f4c1942f7b4cd73f9e38b49cc8f5f8a6fbb44922cb60dd3073a189b77ee1ae", size = 736752, upload-time = "2025-11-24T19:42:58.144Z" },
{ url = "https://files.pythonhosted.org/packages/c7/84/897d759198676b910d69d42640b6d25d50b449f2209e18127a974cf59dbe/pyinstaller-6.17.0-py3-none-win32.whl", hash = "sha256:ce0be227a037fd4be672226db709088565484f597d6b230bceec19850fdd4c85", size = 1317851, upload-time = "2025-11-24T19:43:04.361Z" },
{ url = "https://files.pythonhosted.org/packages/2d/f5/6a122efe024433ecc34aab6f499e0bd2bbe059c639b77b0045aa2421b0bf/pyinstaller-6.17.0-py3-none-win_amd64.whl", hash = "sha256:b019940dbf7a01489d6b26f9fb97db74b504e0a757010f7ad078675befc85a82", size = 1378685, upload-time = "2025-11-24T19:43:10.395Z" },
{ url = "https://files.pythonhosted.org/packages/c4/96/14991773c9e599707a53594429ccf372f9ee638df3b7d26b65fd1a7433f0/pyinstaller-6.17.0-py3-none-win_arm64.whl", hash = "sha256:3c92a335e338170df7e615f75279cfeea97ade89e6dd7694943c8c185460f7b7", size = 1320032, upload-time = "2025-11-24T19:43:16.388Z" },
]
[[package]]
@@ -2098,9 +2056,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
]
[[package]]
@@ -2134,9 +2089,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/da/4b/732a0c5a9736a0b8d6d720d4945a2f1e6f38f87f48f3173559f53e8d5d82/regex-2025.11.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:75fa6f0056e7efb1f42a1c34e58be24072cb9e61a601340cc1196ae92326a4f9", size = 858462, upload-time = "2025-11-03T21:32:11.769Z" },
{ url = "https://files.pythonhosted.org/packages/0c/f5/a2a03df27dc4c2d0c769220f5110ba8c4084b0bfa9ab0f9b4fcfa3d2b0fc/regex-2025.11.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:dbe6095001465294f13f1adcd3311e50dd84e5a71525f20a10bd16689c61ce0b", size = 850528, upload-time = "2025-11-03T21:32:13.906Z" },
{ url = "https://files.pythonhosted.org/packages/d6/09/e1cd5bee3841c7f6eb37d95ca91cdee7100b8f88b81e41c2ef426910891a/regex-2025.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:454d9b4ae7881afbc25015b8627c16d88a597479b9dea82b8c6e7e2e07240dc7", size = 789866, upload-time = "2025-11-03T21:32:15.748Z" },
{ url = "https://files.pythonhosted.org/packages/eb/51/702f5ea74e2a9c13d855a6a85b7f80c30f9e72a95493260193c07f3f8d74/regex-2025.11.3-cp313-cp313-win32.whl", hash = "sha256:28ba4d69171fc6e9896337d4fc63a43660002b7da53fc15ac992abcf3410917c", size = 266189, upload-time = "2025-11-03T21:32:17.493Z" },
{ url = "https://files.pythonhosted.org/packages/8b/00/6e29bb314e271a743170e53649db0fdb8e8ff0b64b4f425f5602f4eb9014/regex-2025.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:bac4200befe50c670c405dc33af26dad5a3b6b255dd6c000d92fe4629f9ed6a5", size = 277054, upload-time = "2025-11-03T21:32:19.042Z" },
{ url = "https://files.pythonhosted.org/packages/25/f1/b156ff9f2ec9ac441710764dda95e4edaf5f36aca48246d1eea3f1fd96ec/regex-2025.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:2292cd5a90dab247f9abe892ac584cb24f0f54680c73fcb4a7493c66c2bf2467", size = 270325, upload-time = "2025-11-03T21:32:21.338Z" },
{ url = "https://files.pythonhosted.org/packages/20/28/fd0c63357caefe5680b8ea052131acbd7f456893b69cc2a90cc3e0dc90d4/regex-2025.11.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:1eb1ebf6822b756c723e09f5186473d93236c06c579d2cc0671a722d2ab14281", size = 491984, upload-time = "2025-11-03T21:32:23.466Z" },
{ url = "https://files.pythonhosted.org/packages/df/ec/7014c15626ab46b902b3bcc4b28a7bae46d8f281fc7ea9c95e22fcaaa917/regex-2025.11.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1e00ec2970aab10dc5db34af535f21fcf32b4a31d99e34963419636e2f85ae39", size = 292673, upload-time = "2025-11-03T21:32:25.034Z" },
{ url = "https://files.pythonhosted.org/packages/23/ab/3b952ff7239f20d05f1f99e9e20188513905f218c81d52fb5e78d2bf7634/regex-2025.11.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a4cb042b615245d5ff9b3794f56be4138b5adc35a4166014d31d1814744148c7", size = 291029, upload-time = "2025-11-03T21:32:26.528Z" },
@@ -2148,9 +2100,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/03/86/fd1063a176ffb7b2315f9a1b08d17b18118b28d9df163132615b835a26ee/regex-2025.11.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:dd16e78eb18ffdb25ee33a0682d17912e8cc8a770e885aeee95020046128f1ce", size = 868341, upload-time = "2025-11-03T21:32:38.042Z" },
{ url = "https://files.pythonhosted.org/packages/12/43/103fb2e9811205e7386366501bc866a164a0430c79dd59eac886a2822950/regex-2025.11.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:ffcca5b9efe948ba0661e9df0fa50d2bc4b097c70b9810212d6b62f05d83b2dd", size = 854666, upload-time = "2025-11-03T21:32:40.079Z" },
{ url = "https://files.pythonhosted.org/packages/7d/22/e392e53f3869b75804762c7c848bd2dd2abf2b70fb0e526f58724638bd35/regex-2025.11.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c56b4d162ca2b43318ac671c65bd4d563e841a694ac70e1a976ac38fcf4ca1d2", size = 799473, upload-time = "2025-11-03T21:32:42.148Z" },
{ url = "https://files.pythonhosted.org/packages/4f/f9/8bd6b656592f925b6845fcbb4d57603a3ac2fb2373344ffa1ed70aa6820a/regex-2025.11.3-cp313-cp313t-win32.whl", hash = "sha256:9ddc42e68114e161e51e272f667d640f97e84a2b9ef14b7477c53aac20c2d59a", size = 268792, upload-time = "2025-11-03T21:32:44.13Z" },
{ url = "https://files.pythonhosted.org/packages/e5/87/0e7d603467775ff65cd2aeabf1b5b50cc1c3708556a8b849a2fa4dd1542b/regex-2025.11.3-cp313-cp313t-win_amd64.whl", hash = "sha256:7a7c7fdf755032ffdd72c77e3d8096bdcb0eb92e89e17571a196f03d88b11b3c", size = 280214, upload-time = "2025-11-03T21:32:45.853Z" },
{ url = "https://files.pythonhosted.org/packages/8d/d0/2afc6f8e94e2b64bfb738a7c2b6387ac1699f09f032d363ed9447fd2bb57/regex-2025.11.3-cp313-cp313t-win_arm64.whl", hash = "sha256:df9eb838c44f570283712e7cff14c16329a9f0fb19ca492d21d4b7528ee6821e", size = 271469, upload-time = "2025-11-03T21:32:48.026Z" },
]
[[package]]
@@ -2235,9 +2184,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2f/2b/a73a2b6e6d2df1d74bf2b78098be1572191e54bec0e59e29382d13c3adc5/ruff-0.14.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c61782543c1231bf71041461c1f28c64b961d457d0f238ac388e2ab173d7ecb7", size = 12724637, upload-time = "2026-01-08T19:11:47.796Z" },
{ url = "https://files.pythonhosted.org/packages/f0/41/09100590320394401cd3c48fc718a8ba71c7ddb1ffd07e0ad6576b3a3df2/ruff-0.14.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:82ff352ea68fb6766140381748e1f67f83c39860b6446966cff48a315c3e2491", size = 13145837, upload-time = "2026-01-08T19:11:32.87Z" },
{ url = "https://files.pythonhosted.org/packages/3b/d8/e035db859d1d3edf909381eb8ff3e89a672d6572e9454093538fe6f164b0/ruff-0.14.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:728e56879df4ca5b62a9dde2dd0eb0edda2a55160c0ea28c4025f18c03f86984", size = 13850469, upload-time = "2026-01-08T19:12:11.694Z" },
{ url = "https://files.pythonhosted.org/packages/4e/02/bb3ff8b6e6d02ce9e3740f4c17dfbbfb55f34c789c139e9cd91985f356c7/ruff-0.14.11-py3-none-win32.whl", hash = "sha256:337c5dd11f16ee52ae217757d9b82a26400be7efac883e9e852646f1557ed841", size = 12851094, upload-time = "2026-01-08T19:11:45.163Z" },
{ url = "https://files.pythonhosted.org/packages/58/f1/90ddc533918d3a2ad628bc3044cdfc094949e6d4b929220c3f0eb8a1c998/ruff-0.14.11-py3-none-win_amd64.whl", hash = "sha256:f981cea63d08456b2c070e64b79cb62f951aa1305282974d4d5216e6e0178ae6", size = 14001379, upload-time = "2026-01-08T19:11:52.591Z" },
{ url = "https://files.pythonhosted.org/packages/c4/1c/1dbe51782c0e1e9cfce1d1004752672d2d4629ea46945d19d731ad772b3b/ruff-0.14.11-py3-none-win_arm64.whl", hash = "sha256:649fb6c9edd7f751db276ef42df1f3df41c38d67d199570ae2a7bd6cbc3590f0", size = 12938644, upload-time = "2026-01-08T19:11:50.027Z" },
]
[[package]]
@@ -2258,8 +2204,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/39/5b/281bb21d091ab4e36cf377088366d55d0875fa2347b3189c580ec62b44c7/rustworkx-0.17.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:246cc252053f89e36209535b9c58755960197e6ae08d48d3973760141c62ac95", size = 2221186, upload-time = "2025-08-13T01:43:38.598Z" },
{ url = "https://files.pythonhosted.org/packages/cc/2d/30a941a21b81e9db50c4c3ef8a64c5ee1c8eea3a90506ca0326ce39d021f/rustworkx-0.17.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c10d25e9f0e87d6a273d1ea390b636b4fb3fede2094bf0cb3fe565d696a91b48", size = 2123510, upload-time = "2025-08-13T01:43:40.288Z" },
{ url = "https://files.pythonhosted.org/packages/4f/ef/c9199e4b6336ee5a9f1979c11b5779c5cf9ab6f8386e0b9a96c8ffba7009/rustworkx-0.17.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:48784a673cf8d04f3cd246fa6b53fd1ccc4d83304503463bd561c153517bccc1", size = 2302783, upload-time = "2025-08-13T01:43:42.073Z" },
{ url = "https://files.pythonhosted.org/packages/30/3d/a49ab633e99fca4ccbb9c9f4bd41904186c175ebc25c530435529f71c480/rustworkx-0.17.1-cp39-abi3-win32.whl", hash = "sha256:5dbc567833ff0a8ad4580a4fe4bde92c186d36b4c45fca755fb1792e4fafe9b5", size = 1931541, upload-time = "2025-08-13T01:43:43.415Z" },
{ url = "https://files.pythonhosted.org/packages/a9/ec/cee878c1879b91ab8dc7d564535d011307839a2fea79d2a650413edf53be/rustworkx-0.17.1-cp39-abi3-win_amd64.whl", hash = "sha256:d0a48fb62adabd549f9f02927c3a159b51bf654c7388a12fc16d45452d5703ea", size = 2055049, upload-time = "2025-08-13T01:43:44.926Z" },
]
[[package]]
@@ -2297,8 +2241,6 @@ wheels = [
{ 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]]
@@ -2317,14 +2259,10 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" },
{ url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" },
{ url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" },
{ url = "https://files.pythonhosted.org/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" },
{ url = "https://files.pythonhosted.org/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" },
{ url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" },
{ url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" },
{ url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" },
{ url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" },
{ url = "https://files.pythonhosted.org/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" },
{ url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" },
]
[[package]]
@@ -2344,8 +2282,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" },
{ url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" },
{ url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" },
{ url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" },
{ url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" },
{ url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" },
{ url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" },
{ url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" },
@@ -2354,8 +2290,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" },
{ url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" },
{ url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" },
{ url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" },
{ url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" },
]
[[package]]
@@ -2369,17 +2303,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/de/5a007fb53b1ab0aafc69d11a5a3dd72a289d5a3e78dcf2c3a3d9b14ffe93/sentencepiece-0.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:097f3394e99456e9e4efba1737c3749d7e23563dd1588ce71a3d007f25475fff", size = 1253641, upload-time = "2025-08-12T06:59:56.562Z" },
{ url = "https://files.pythonhosted.org/packages/2c/d2/f552be5928105588f4f4d66ee37dd4c61460d8097e62d0e2e0eec41bc61d/sentencepiece-0.2.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7b670879c370d350557edabadbad1f6561a9e6968126e6debca4029e5547820", size = 1316271, upload-time = "2025-08-12T06:59:58.109Z" },
{ url = "https://files.pythonhosted.org/packages/96/df/0cfe748ace5485be740fed9476dee7877f109da32ed0d280312c94ec259f/sentencepiece-0.2.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7f0fd2f2693309e6628aeeb2e2faf6edd221134dfccac3308ca0de01f8dab47", size = 1387882, upload-time = "2025-08-12T07:00:00.701Z" },
{ url = "https://files.pythonhosted.org/packages/ac/dd/f7774d42a881ced8e1739f393ab1e82ece39fc9abd4779e28050c2e975b5/sentencepiece-0.2.1-cp313-cp313-win32.whl", hash = "sha256:92b3816aa2339355fda2c8c4e021a5de92180b00aaccaf5e2808972e77a4b22f", size = 999541, upload-time = "2025-08-12T07:00:02.709Z" },
{ url = "https://files.pythonhosted.org/packages/dd/e9/932b9eae6fd7019548321eee1ab8d5e3b3d1294df9d9a0c9ac517c7b636d/sentencepiece-0.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:10ed3dab2044c47f7a2e7b4969b0c430420cdd45735d78c8f853191fa0e3148b", size = 1054669, upload-time = "2025-08-12T07:00:04.915Z" },
{ url = "https://files.pythonhosted.org/packages/c9/3a/76488a00ea7d6931689cda28726a1447d66bf1a4837943489314593d5596/sentencepiece-0.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac650534e2251083c5f75dde4ff28896ce7c8904133dc8fef42780f4d5588fcd", size = 1033922, upload-time = "2025-08-12T07:00:06.496Z" },
{ url = "https://files.pythonhosted.org/packages/4a/b6/08fe2ce819e02ccb0296f4843e3f195764ce9829cbda61b7513f29b95718/sentencepiece-0.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:8dd4b477a7b069648d19363aad0cab9bad2f4e83b2d179be668efa672500dc94", size = 1946052, upload-time = "2025-08-12T07:00:08.136Z" },
{ url = "https://files.pythonhosted.org/packages/ab/d9/1ea0e740591ff4c6fc2b6eb1d7510d02f3fb885093f19b2f3abd1363b402/sentencepiece-0.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0c0f672da370cc490e4c59d89e12289778310a0e71d176c541e4834759e1ae07", size = 1327408, upload-time = "2025-08-12T07:00:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/99/7e/1fb26e8a21613f6200e1ab88824d5d203714162cf2883248b517deb500b7/sentencepiece-0.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ad8493bea8432dae8d6830365352350f3b4144415a1d09c4c8cb8d30cf3b6c3c", size = 1254857, upload-time = "2025-08-12T07:00:11.021Z" },
{ url = "https://files.pythonhosted.org/packages/bc/85/c72fd1f3c7a6010544d6ae07f8ddb38b5e2a7e33bd4318f87266c0bbafbf/sentencepiece-0.2.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b81a24733726e3678d2db63619acc5a8dccd074f7aa7a54ecd5ca33ca6d2d596", size = 1315722, upload-time = "2025-08-12T07:00:12.989Z" },
{ url = "https://files.pythonhosted.org/packages/4a/e8/661e5bd82a8aa641fd6c1020bd0e890ef73230a2b7215ddf9c8cd8e941c2/sentencepiece-0.2.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0a81799d0a68d618e89063fb423c3001a034c893069135ffe51fee439ae474d6", size = 1387452, upload-time = "2025-08-12T07:00:15.088Z" },
{ url = "https://files.pythonhosted.org/packages/99/5e/ae66c361023a470afcbc1fbb8da722c72ea678a2fcd9a18f1a12598c7501/sentencepiece-0.2.1-cp313-cp313t-win32.whl", hash = "sha256:89a3ea015517c42c0341d0d962f3e6aaf2cf10d71b1932d475c44ba48d00aa2b", size = 1002501, upload-time = "2025-08-12T07:00:16.966Z" },
{ url = "https://files.pythonhosted.org/packages/c1/03/d332828c4ff764e16c1b56c2c8f9a33488bbe796b53fb6b9c4205ddbf167/sentencepiece-0.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:33f068c9382dc2e7c228eedfd8163b52baa86bb92f50d0488bf2b7da7032e484", size = 1057555, upload-time = "2025-08-12T07:00:18.573Z" },
{ url = "https://files.pythonhosted.org/packages/88/14/5aee0bf0864df9bd82bd59e7711362908e4935e3f9cdc1f57246b5d5c9b9/sentencepiece-0.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:b3616ad246f360e52c85781e47682d31abfb6554c779e42b65333d4b5f44ecc0", size = 1036042, upload-time = "2025-08-12T07:00:20.209Z" },
]
[[package]]
@@ -2522,14 +2450,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/da/c028aa0babf77315e1cef357d4d768800c5f8a6de04d0eac0f377cb619fa/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be", size = 1151986, upload-time = "2025-10-06T20:22:05.173Z" },
{ url = "https://files.pythonhosted.org/packages/a0/5a/886b108b766aa53e295f7216b509be95eb7d60b166049ce2c58416b25f2a/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a", size = 1194222, upload-time = "2025-10-06T20:22:06.265Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f8/4db272048397636ac7a078d22773dd2795b1becee7bc4922fe6207288d57/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3", size = 1255097, upload-time = "2025-10-06T20:22:07.403Z" },
{ url = "https://files.pythonhosted.org/packages/8e/32/45d02e2e0ea2be3a9ed22afc47d93741247e75018aac967b713b2941f8ea/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697", size = 879117, upload-time = "2025-10-06T20:22:08.418Z" },
{ url = "https://files.pythonhosted.org/packages/ce/76/994fc868f88e016e6d05b0da5ac24582a14c47893f4474c3e9744283f1d5/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16", size = 1050309, upload-time = "2025-10-06T20:22:10.939Z" },
{ url = "https://files.pythonhosted.org/packages/f6/b8/57ef1456504c43a849821920d582a738a461b76a047f352f18c0b26c6516/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a", size = 993712, upload-time = "2025-10-06T20:22:12.115Z" },
{ url = "https://files.pythonhosted.org/packages/72/90/13da56f664286ffbae9dbcfadcc625439142675845baa62715e49b87b68b/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27", size = 1128725, upload-time = "2025-10-06T20:22:13.541Z" },
{ url = "https://files.pythonhosted.org/packages/05/df/4f80030d44682235bdaecd7346c90f67ae87ec8f3df4a3442cb53834f7e4/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb", size = 1151875, upload-time = "2025-10-06T20:22:14.559Z" },
{ url = "https://files.pythonhosted.org/packages/22/1f/ae535223a8c4ef4c0c1192e3f9b82da660be9eb66b9279e95c99288e9dab/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e", size = 1194451, upload-time = "2025-10-06T20:22:15.545Z" },
{ url = "https://files.pythonhosted.org/packages/78/a7/f8ead382fce0243cb625c4f266e66c27f65ae65ee9e77f59ea1653b6d730/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25", size = 1253794, upload-time = "2025-10-06T20:22:16.624Z" },
{ url = "https://files.pythonhosted.org/packages/93/e0/6cc82a562bc6365785a3ff0af27a2a092d57c47d7a81d9e2295d8c36f011/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f", size = 878777, upload-time = "2025-10-06T20:22:18.036Z" },
]
[[package]]
@@ -2553,9 +2479,6 @@ wheels = [
{ 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]]
@@ -2595,11 +2518,9 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d8/f0/72bf18847f58f877a6a8acf60614b14935e2f156d942483af1ffc081aea0/torch-2.10.0-3-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:46b3574d93a2a8134b3f5475cfb98e2eb46771794c57015f6ad1fb795ec25e49", size = 915523474, upload-time = "2026-03-11T14:17:44.422Z" },
{ url = "https://files.pythonhosted.org/packages/c9/6f/f2e91e34e3fcba2e3fc8d8f74e7d6c22e74e480bbd1db7bc8900fdf3e95c/torch-2.10.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:5c4d217b14741e40776dd7074d9006fd28b8a97ef5654db959d8635b2fe5f29b", size = 146004247, upload-time = "2026-01-21T16:24:29.335Z" },
{ url = "https://files.pythonhosted.org/packages/98/fb/5160261aeb5e1ee12ee95fe599d0541f7c976c3701d607d8fc29e623229f/torch-2.10.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6b71486353fce0f9714ca0c9ef1c850a2ae766b409808acd58e9678a3edb7738", size = 915716445, upload-time = "2026-01-21T16:22:45.353Z" },
{ url = "https://files.pythonhosted.org/packages/6a/16/502fb1b41e6d868e8deb5b0e3ae926bbb36dab8ceb0d1b769b266ad7b0c3/torch-2.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:c2ee399c644dc92ef7bc0d4f7e74b5360c37cdbe7c5ba11318dda49ffac2bc57", size = 113757050, upload-time = "2026-01-21T16:24:19.204Z" },
{ url = "https://files.pythonhosted.org/packages/1a/0b/39929b148f4824bc3ad6f9f72a29d4ad865bcf7ebfc2fa67584773e083d2/torch-2.10.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:3202429f58309b9fa96a614885eace4b7995729f44beb54d3e4a47773649d382", size = 79851305, upload-time = "2026-01-21T16:24:09.209Z" },
{ url = "https://files.pythonhosted.org/packages/d8/14/21fbce63bc452381ba5f74a2c0a959fdf5ad5803ccc0c654e752e0dbe91a/torch-2.10.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:aae1b29cd68e50a9397f5ee897b9c24742e9e306f88a807a27d617f07adb3bd8", size = 146005472, upload-time = "2026-01-21T16:22:29.022Z" },
{ url = "https://files.pythonhosted.org/packages/54/fd/b207d1c525cb570ef47f3e9f836b154685011fce11a2f444ba8a4084d042/torch-2.10.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:6021db85958db2f07ec94e1bc77212721ba4920c12a18dc552d2ae36a3eb163f", size = 915612644, upload-time = "2026-01-21T16:21:47.019Z" },
{ url = "https://files.pythonhosted.org/packages/36/53/0197f868c75f1050b199fe58f9bf3bf3aecac9b4e85cc9c964383d745403/torch-2.10.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff43db38af76fda183156153983c9a096fc4c78d0cd1e07b14a2314c7f01c2c8", size = 113997015, upload-time = "2026-01-21T16:23:00.767Z" },
{ url = "https://files.pythonhosted.org/packages/0e/13/e76b4d9c160e89fff48bf16b449ea324bda84745d2ab30294c37c2434c0d/torch-2.10.0-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:cdf2a523d699b70d613243211ecaac14fe9c5df8a0b0a9c02add60fb2a413e0f", size = 79498248, upload-time = "2026-01-21T16:23:09.315Z" },
]
@@ -2774,9 +2695,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/fd/2c0a00c97b9e18f72e1f240ad4e8f8a90fd9d408289ba9c7c495ed7dc05c/xxhash-3.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6f2580ffab1a8b68ef2b901cde7e55fa8da5e4be0977c68f78fc80f3c143de42", size = 210689, upload-time = "2025-10-02T14:35:09.438Z" },
{ url = "https://files.pythonhosted.org/packages/93/86/5dd8076a926b9a95db3206aba20d89a7fc14dd5aac16e5c4de4b56033140/xxhash-3.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40c391dd3cd041ebc3ffe6f2c862f402e306eb571422e0aa918d8070ba31da11", size = 414068, upload-time = "2025-10-02T14:35:11.162Z" },
{ url = "https://files.pythonhosted.org/packages/af/3c/0bb129170ee8f3650f08e993baee550a09593462a5cddd8e44d0011102b1/xxhash-3.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f205badabde7aafd1a31e8ca2a3e5a763107a71c397c4481d6a804eb5063d8bd", size = 191495, upload-time = "2025-10-02T14:35:12.971Z" },
{ url = "https://files.pythonhosted.org/packages/e9/3a/6797e0114c21d1725e2577508e24006fd7ff1d8c0c502d3b52e45c1771d8/xxhash-3.6.0-cp313-cp313-win32.whl", hash = "sha256:2577b276e060b73b73a53042ea5bd5203d3e6347ce0d09f98500f418a9fcf799", size = 30620, upload-time = "2025-10-02T14:35:14.129Z" },
{ url = "https://files.pythonhosted.org/packages/86/15/9bc32671e9a38b413a76d24722a2bf8784a132c043063a8f5152d390b0f9/xxhash-3.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:757320d45d2fbcce8f30c42a6b2f47862967aea7bf458b9625b4bbe7ee390392", size = 31542, upload-time = "2025-10-02T14:35:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/39/c5/cc01e4f6188656e56112d6a8e0dfe298a16934b8c47a247236549a3f7695/xxhash-3.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:457b8f85dec5825eed7b69c11ae86834a018b8e3df5e77783c999663da2f96d6", size = 27880, upload-time = "2025-10-02T14:35:16.315Z" },
{ url = "https://files.pythonhosted.org/packages/f3/30/25e5321c8732759e930c555176d37e24ab84365482d257c3b16362235212/xxhash-3.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a42e633d75cdad6d625434e3468126c73f13f7584545a9cf34e883aa1710e702", size = 32956, upload-time = "2025-10-02T14:35:17.413Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3c/0573299560d7d9f8ab1838f1efc021a280b5ae5ae2e849034ef3dee18810/xxhash-3.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:568a6d743219e717b07b4e03b0a828ce593833e498c3b64752e0f5df6bfe84db", size = 31072, upload-time = "2025-10-02T14:35:18.844Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1c/52d83a06e417cd9d4137722693424885cc9878249beb3a7c829e74bf7ce9/xxhash-3.6.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bec91b562d8012dae276af8025a55811b875baace6af510412a5e58e3121bc54", size = 196409, upload-time = "2025-10-02T14:35:20.31Z" },
@@ -2789,9 +2707,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/98/e8de5baa5109394baf5118f5e72ab21a86387c4f89b0e77ef3e2f6b0327b/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f01375c0e55395b814a679b3eea205db7919ac2af213f4a6682e01220e5fe292", size = 213304, upload-time = "2025-10-02T14:35:31.222Z" },
{ url = "https://files.pythonhosted.org/packages/7b/1d/71056535dec5c3177eeb53e38e3d367dd1d16e024e63b1cee208d572a033/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d706dca2d24d834a4661619dcacf51a75c16d65985718d6a7d73c1eeeb903ddf", size = 416930, upload-time = "2025-10-02T14:35:32.517Z" },
{ url = "https://files.pythonhosted.org/packages/dc/6c/5cbde9de2cd967c322e651c65c543700b19e7ae3e0aae8ece3469bf9683d/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f059d9faeacd49c0215d66f4056e1326c80503f51a1532ca336a385edadd033", size = 193787, upload-time = "2025-10-02T14:35:33.827Z" },
{ url = "https://files.pythonhosted.org/packages/19/fa/0172e350361d61febcea941b0cc541d6e6c8d65d153e85f850a7b256ff8a/xxhash-3.6.0-cp313-cp313t-win32.whl", hash = "sha256:1244460adc3a9be84731d72b8e80625788e5815b68da3da8b83f78115a40a7ec", size = 30916, upload-time = "2025-10-02T14:35:35.107Z" },
{ url = "https://files.pythonhosted.org/packages/ad/e6/e8cf858a2b19d6d45820f072eff1bea413910592ff17157cabc5f1227a16/xxhash-3.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b1e420ef35c503869c4064f4a2f2b08ad6431ab7b229a05cce39d74268bca6b8", size = 31799, upload-time = "2025-10-02T14:35:36.165Z" },
{ url = "https://files.pythonhosted.org/packages/56/15/064b197e855bfb7b343210e82490ae672f8bc7cdf3ddb02e92f64304ee8a/xxhash-3.6.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ec44b73a4220623235f67a996c862049f375df3b1052d9899f40a6382c32d746", size = 28044, upload-time = "2025-10-02T14:35:37.195Z" },
]
[[package]]
@@ -2818,9 +2733,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" },
{ url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" },
{ url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" },
{ url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" },
{ url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" },
{ url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" },
{ url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" },
{ url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" },
{ url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" },
@@ -2834,9 +2746,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" },
{ url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" },
{ url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" },
{ url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" },
{ url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" },
{ url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" },
{ url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" },
]
@@ -2860,7 +2769,4 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" },
{ url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" },
{ url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" },
{ url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" },
{ url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" },
{ url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" },
]