diff --git a/.agents/skills/nnmodelling-backend-admin/SKILL.md b/.agents/skills/nnmodelling-backend-admin/SKILL.md index 5be71b82..c6327904 100644 --- a/.agents/skills/nnmodelling-backend-admin/SKILL.md +++ b/.agents/skills/nnmodelling-backend-admin/SKILL.md @@ -37,6 +37,10 @@ reconfiguring anything. Treat that socket as host-administrator authority. - Worker images must be immutable digest references. Never silently use a mutable tag or a host-Python fallback. +- When code or dependencies executed by the worker change, run + `just --justfile converted/backend/justfile worker-build`, then restart + FastAPI with the printed `NNM_CONTAINER_IMAGE` digest. Restarting a backend + alone deliberately preserves its previously configured worker image. - Disabled and offline W&B jobs use `--network none` and receive no credential. Online jobs require an operator-managed egress network, allowlisting proxy, and owner-only credential file delivered to the worker over stdin. @@ -69,6 +73,51 @@ reconfiguring anything. sanitized configuration, and leave unrelated services and user changes untouched. +## Persistent Podman Compose deployment + +`converted/backend/docker-compose.yml` is the supported single-file +instantiation for the persistent local backend. On this machine use Podman: +`podman compose`, not `docker compose`. The file starts Valkey, the FastAPI +service, and the trusted container controller; it does not create the +operator-managed W&B egress network or proxy. Those must already exist when +online W&B jobs are enabled. + +The standard path is: + +```text +just --justfile converted/backend/justfile compose +``` + +That recipe builds the worker first, obtains its immutable image digest, starts +the rootless Podman socket, and passes the host paths required by the backend +and controller. It uses the unsafe-unlimited dataset-size mode and localhost +Origins from the Compose defaults. Do not put tokens or W&B keys in the +command line or Compose file. + +For a non-standard user requirement, the backend administrator should execute +`podman compose` directly instead of using `just compose`, explicitly setting +the needed `NNM_*` variables. At minimum, validate the expanded file before +starting it: + +```text +podman compose --project-name nnm-backend \ + -f converted/backend/docker-compose.yml config +podman compose --project-name nnm-backend \ + -f converted/backend/docker-compose.yml up --build -d +``` + +The required host-path variables are `NNM_HOST_VALKEY_DATA`, `NNM_HOST_JOBS`, +`NNM_HOST_DATA`, `NNM_HOST_ADMIN_TOKEN`, and +`NNM_HOST_CONTROLLER_TOKEN`; `NNM_CONTAINER_IMAGE` must be a digest-pinned +worker reference. Use `NNM_WANDB_CREDENTIAL_FILE`, `NNM_WANDB_NETWORK`, and +`NNM_WANDB_PROXY_URL` only for the already-configured W&B integration. The +controller is the only Compose service that receives the Podman socket; the +backend talks to it through the authenticated controller socket. + +Inspect without changing state with `podman compose ... ps` and stop the +deployment with `podman compose ... down`. Do not add `--volumes`: the Valkey +data and job directories are host-mounted persistent state. + ## Authorization and stopping rules Read-only health, status, capability, job, session, process, socket, image, and diff --git a/.agents/skills/nnmodelling-backend-admin/references/operations.md b/.agents/skills/nnmodelling-backend-admin/references/operations.md index 849ffb31..8c0c253f 100644 --- a/.agents/skills/nnmodelling-backend-admin/references/operations.md +++ b/.agents/skills/nnmodelling-backend-admin/references/operations.md @@ -26,6 +26,21 @@ Before startup, run: just --justfile converted/backend/justfile container-config ``` +## Worker-image lifecycle + +The package worker runs immutable code from `NNM_CONTAINER_IMAGE`; a FastAPI +restart does not rebuild or replace that image. Whenever a change affects +`package_worker.py`, training/runtime code, worker dependencies, the lockfile, +or the worker Dockerfile, build a fresh local image first: + +```bash +just --justfile converted/backend/justfile worker-build +``` + +The recipe prints an `NNM_CONTAINER_IMAGE=localhost/nnm-worker@sha256:...` +line. Use that exact digest to start or restart FastAPI. The local tag is only a +build label and must never be passed to the backend in place of the digest. + Inspect an occupied port and its owning PID before stopping anything. A healthy listener owned by the current workflow should be reused. diff --git a/.gitignore b/.gitignore index 685b0208..67165b53 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ *.bak_* -.agents/ .claude/ .qwen/ skills-lock.json diff --git a/analysis/uml/.nn.vpp.lck b/analysis/uml/.nn.vpp.lck deleted file mode 100644 index e69de29b..00000000 diff --git a/analysis/uml/nn.vpp b/analysis/uml/nn.vpp index 37fb9b57..5f37537f 100644 Binary files a/analysis/uml/nn.vpp and b/analysis/uml/nn.vpp differ diff --git a/analysis/uml/nn.vpp.bak2 b/analysis/uml/nn.vpp.bak2 new file mode 100644 index 00000000..37fb9b57 Binary files /dev/null and b/analysis/uml/nn.vpp.bak2 differ diff --git a/converted/.gitignore b/converted/.gitignore index 5eb3aa97..d63890d9 100644 --- a/converted/.gitignore +++ b/converted/.gitignore @@ -1,6 +1,7 @@ outputs jobs/ valkey-data/ +backend-secrets/ dump.rdb *.pyc multirun diff --git a/converted/README.md b/converted/README.md index 0e293aa0..3dc3bedb 100644 --- a/converted/README.md +++ b/converted/README.md @@ -46,6 +46,26 @@ a typed `network.format="package"` training request. Jobs are executed by the configured Podman or Docker container controller and expose status, logs, events, cancellation and the portable model wheel through the API. +## Package worker image + +The worker is an immutable container image. Rebuild it whenever a change affects +code or dependencies executed in the worker, then restart FastAPI with the +printed digest. Restarting FastAPI alone keeps using its previous worker image. + +```bash +just --justfile converted/backend/justfile worker-build +# Copy the printed NNM_CONTAINER_IMAGE=...@sha256:... value into the backend launch. +``` + +Pass an optional local tag when keeping multiple development images: + +```bash +just --justfile converted/backend/justfile worker-build classification-metrics +``` + +The tag is only a local build label. Always configure FastAPI with the printed +digest reference, never the tag. + ## Weights & Biases administration W&B uses one backend-administrator-owned account. Connect it from the repository @@ -61,7 +81,7 @@ just --justfile converted/backend/justfile wandb-disconnect ``` `wandb-connect` verifies the account before atomically writing -`converted/valkey-data/wandb-credentials.json` with mode `0600`. +`converted/backend-secrets/wandb-credentials.json` with mode `0600`. `wandb-status` prints only the schema version, entity and base URL. Override the machine-local path with `NNM_WANDB_CREDENTIAL_FILE` when required. diff --git a/converted/backend/Dockerfile b/converted/backend/Dockerfile index 5c266c8b..b065639e 100644 --- a/converted/backend/Dockerfile +++ b/converted/backend/Dockerfile @@ -2,21 +2,22 @@ FROM python:3.12-slim WORKDIR /app -COPY pyproject.toml uv.lock ./ RUN apt-get update \ && apt-get install -y --no-install-recommends podman ca-certificates \ && rm -rf /var/lib/apt/lists/* \ && pip install --no-cache-dir uv +COPY pyproject.toml uv.lock ./ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --no-dev --no-install-project + COPY src ./src -COPY README.md ./README.md ENV PYTHONPATH=/app/src ENV NNM_CONVERTED_DIR=/app ENV NNM_BACKEND_ARTIFACT_ROOT=/app/jobs ENV NNM_CONTAINER_ENGINE=podman - -RUN uv sync --no-dev +ENV UV_NO_SYNC=1 EXPOSE 8000 CMD ["uv", "run", "python", "-m", "backend.cli", "--host", "0.0.0.0", "--port", "8000"] diff --git a/converted/backend/README.md b/converted/backend/README.md index 958b6b99..a9102679 100644 --- a/converted/backend/README.md +++ b/converted/backend/README.md @@ -26,7 +26,7 @@ NNM_ALLOWED_ORIGINS=http://192.168.1.30:5174 \ just --justfile converted/backend/justfile backend ``` -`backend` creates an untracked `valkey-data/admin.token` with mode `0600` when +`backend` creates an untracked `backend-secrets/admin.token` with mode `0600` when needed. Approve the code displayed by the frontend: ```bash diff --git a/converted/backend/docker-compose.yml b/converted/backend/docker-compose.yml index 1c6bf81d..e80c2301 100644 --- a/converted/backend/docker-compose.yml +++ b/converted/backend/docker-compose.yml @@ -1,71 +1,129 @@ services: valkey: - image: valkey/valkey:8 - command: ["valkey-server", "/usr/local/etc/valkey/valkey.conf", "--dir", "/data", "--bind", "0.0.0.0", "--protected-mode", "no"] + image: ${NNM_VALKEY_IMAGE:-docker.io/valkey/valkey:8} + command: + - valkey-server + - /usr/local/etc/valkey/valkey.conf + - --dir + - /data + - --bind + - 0.0.0.0 + - --protected-mode + - "no" volumes: - - valkey-data:/data + # Preserve pairing, queue and job metadata from the host deployment. + - ${NNM_HOST_VALKEY_DATA:?set NNM_HOST_VALKEY_DATA to the Valkey data directory}:/data - ./valkey.conf:/usr/local/etc/valkey/valkey.conf:ro healthcheck: test: ["CMD", "valkey-cli", "ping"] interval: 5s timeout: 3s retries: 10 + restart: unless-stopped backend: + image: ${NNM_BACKEND_IMAGE:-localhost/nnm-backend:4c1083b} build: context: .. dockerfile: backend/Dockerfile + command: + - uv + - run + - python + - -m + - backend.cli + - --host + - 0.0.0.0 + - --port + - "8000" + - --unsafe-unlimited-dataset-size + user: "${NNM_CONTAINER_UID:-1000}:${NNM_CONTAINER_GID:-1000}" + # Preserve the invoking rootless user's numeric identity for owner-only + # host secrets and the shared controller socket volume. + userns_mode: "${NNM_CONTAINER_USERNS_MODE:-keep-id}" environment: + UV_CACHE_DIR: /tmp/uv-cache NNM_VALKEY_URL: valkey://valkey:6379/0 - NNM_BACKEND_ARTIFACT_ROOT: /app/jobs + NNM_BACKEND_ARTIFACT_ROOT: ${NNM_HOST_JOBS:?set NNM_HOST_JOBS to the absolute host jobs directory} + NNM_BACKEND_PACKAGE_ROOT: ${NNM_HOST_JOBS:?set NNM_HOST_JOBS to the absolute host jobs directory}/packages + NNM_BACKEND_DATASET_ROOT: ${NNM_HOST_JOBS:?set NNM_HOST_JOBS to the absolute host jobs directory}/datasets NNM_ADMIN_TOKEN_FILE: /run/secrets/nnm-admin-token - NNM_ALLOWED_ORIGINS: ${NNM_ALLOWED_ORIGINS:-http://127.0.0.1:5174} + NNM_ALLOWED_ORIGINS: ${NNM_ALLOWED_ORIGINS:-http://127.0.0.1:5174,http://localhost:5174,http://127.0.0.1:5173,http://localhost:5173} NNM_SESSION_TTL: ${NNM_SESSION_TTL:-24h} - NNM_CONTAINER_ENGINE: ${NNM_CONTAINER_ENGINE:-podman} + NNM_CONTAINER_ENGINE: "${NNM_CONTAINER_ENGINE:-podman}" NNM_CONTAINER_IMAGE: ${NNM_CONTAINER_IMAGE:?set NNM_CONTAINER_IMAGE to a digest-pinned worker image} - NNM_CONTAINER_DATA_ROOT: /app/data + NNM_CONTAINER_DATA_ROOT: ${NNM_HOST_DATA:?set NNM_HOST_DATA to the absolute host data directory} NNM_CONTAINER_CONTROLLER_SOCKET: /run/nnm/controller.sock NNM_CONTAINER_CONTROLLER_TOKEN_FILE: /run/secrets/nnm-controller-token ports: - - "8000:8000" + - "127.0.0.1:8000:8000" volumes: - - backend-jobs:/app/jobs - - ../valkey-data/admin.token:/run/secrets/nnm-admin-token:ro + # These paths must match the host paths seen by Podman workers. + - ${NNM_HOST_JOBS:?set NNM_HOST_JOBS to the absolute host jobs directory}:${NNM_HOST_JOBS}:rw - controller-socket:/run/nnm:ro - - ${NNM_CONTAINER_CONTROLLER_TOKEN_FILE:-../valkey-data/controller.token}:/run/secrets/nnm-controller-token:ro + - ${NNM_HOST_ADMIN_TOKEN:?set NNM_HOST_ADMIN_TOKEN to the admin token path}:/run/secrets/nnm-admin-token:ro + - ${NNM_HOST_CONTROLLER_TOKEN:?set NNM_HOST_CONTROLLER_TOKEN to the controller token path}:/run/secrets/nnm-controller-token:ro depends_on: valkey: condition: service_healthy + controller: + condition: service_healthy + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')"] + interval: 5s + timeout: 3s + retries: 12 + restart: unless-stopped - # The only service with access to the host container-engine socket. FastAPI - # can reach this service only through the owner-only authenticated Unix - # socket above; it has no engine CLI capability or socket mount. + # The only service allowed to access the host Podman socket. FastAPI reaches + # it through the owner-only authenticated Unix socket above. controller: + image: ${NNM_BACKEND_IMAGE:-localhost/nnm-backend:4c1083b} build: context: .. dockerfile: backend/Dockerfile + user: "${NNM_CONTAINER_UID:-1000}:${NNM_CONTAINER_GID:-1000}" + # The rootless Podman socket is owned by the invoking host user. Without + # keep-id, UID 1000 in the container is remapped to a subordinate host UID. + userns_mode: "${NNM_CONTAINER_USERNS_MODE:-keep-id}" + security_opt: + - label=disable environment: - NNM_CONTAINER_ENGINE: ${NNM_CONTAINER_ENGINE:-podman} - NNM_CONTAINER_DATA_ROOT: ${NNM_CONTAINER_DATA_ROOT:-/app/data} + HOME: /tmp + UV_CACHE_DIR: /tmp/uv-cache + NNM_CONTAINER_ENGINE: "${NNM_CONTAINER_ENGINE:-podman}" + NNM_CONTAINER_DATA_ROOT: ${NNM_HOST_DATA:?set NNM_HOST_DATA to the absolute host data directory} NNM_WANDB_CREDENTIAL_FILE: /run/secrets/nnm-wandb-credentials - NNM_WANDB_NETWORK: ${NNM_WANDB_NETWORK:-} - NNM_WANDB_PROXY_URL: ${NNM_WANDB_PROXY_URL:-} + NNM_WANDB_NETWORK: ${NNM_WANDB_NETWORK:-nnm-wandb-egress} + NNM_WANDB_PROXY_URL: ${NNM_WANDB_PROXY_URL:-http://10.89.42.2:3128} CONTAINER_HOST: ${NNM_CONTAINER_HOST:-unix:///run/podman/podman.sock} DOCKER_HOST: ${NNM_CONTAINER_HOST:-unix:///run/podman/podman.sock} - command: ["uv", "run", "python", "-m", "backend.container_controller", "serve", "/run/nnm/controller.sock", "@/run/secrets/nnm-controller-token", "/app/jobs", "/app/jobs", "${NNM_CONTAINER_ENGINE:-podman}"] + command: + - uv + - run + - python + - -m + - backend.container_controller + - serve + - /run/nnm/controller.sock + - "@/run/secrets/nnm-controller-token" + - ${NNM_HOST_JOBS:?set NNM_HOST_JOBS to the absolute host jobs directory} + - ${NNM_HOST_JOBS:?set NNM_HOST_JOBS to the absolute host jobs directory} + - "${NNM_CONTAINER_ENGINE:-podman}" volumes: - - backend-jobs:/app/jobs - - ${NNM_CONTAINER_DATA_ROOT:-../data}:/app/data:ro + - ${NNM_HOST_JOBS:?set NNM_HOST_JOBS to the absolute host jobs directory}:${NNM_HOST_JOBS}:rw + - ${NNM_HOST_DATA:?set NNM_HOST_DATA to the absolute host data directory}:${NNM_HOST_DATA}:ro - controller-socket:/run/nnm - - ${NNM_CONTAINER_CONTROLLER_TOKEN_FILE:-../valkey-data/controller.token}:/run/secrets/nnm-controller-token:ro - - ${NNM_WANDB_CREDENTIAL_FILE:-../valkey-data/wandb-credentials.json}:/run/secrets/nnm-wandb-credentials:ro + - ${NNM_HOST_CONTROLLER_TOKEN:?set NNM_HOST_CONTROLLER_TOKEN to the controller token path}:/run/secrets/nnm-controller-token:ro + - ${NNM_WANDB_CREDENTIAL_FILE:-../backend-secrets/wandb-credentials.json}:/run/secrets/nnm-wandb-credentials:ro # This is deliberately mounted only in the controller service. - - ${NNM_CONTAINER_ENGINE_SOCKET:-/run/user/1000/podman/podman.sock}:${NNM_CONTAINER_ENGINE_SOCKET_TARGET:-/run/podman/podman.sock} - depends_on: - valkey: - condition: service_healthy + - ${NNM_CONTAINER_ENGINE_SOCKET:-/run/user/1000/podman/podman.sock}:/run/podman/podman.sock + healthcheck: + test: ["CMD-SHELL", "test -S /run/nnm/controller.sock && podman info >/dev/null"] + interval: 2s + timeout: 3s + retries: 15 + restart: unless-stopped volumes: - valkey-data: - backend-jobs: controller-socket: diff --git a/converted/backend/justfile b/converted/backend/justfile index 51f1b91a..50079253 100644 --- a/converted/backend/justfile +++ b/converted/backend/justfile @@ -3,25 +3,32 @@ set shell := ["bash", "-cu"] backend_dir := justfile_directory() converted_dir := backend_dir / ".." valkey_data_dir := converted_dir / "valkey-data" +backend_secrets_dir := converted_dir / "backend-secrets" valkey_port := env_var_or_default("NNM_VALKEY_PORT", "6379") api_host := env_var_or_default("NNM_BACKEND_HOST", "127.0.0.1") api_port := env_var_or_default("NNM_BACKEND_PORT", "8000") valkey_url := "valkey://127.0.0.1:" + valkey_port + "/0" uv_cache_dir := env_var_or_default("UV_CACHE_DIR", "/tmp/nnmodelling-uv-cache") -admin_token_file := env_var_or_default("NNM_ADMIN_TOKEN_FILE", valkey_data_dir / "admin.token") +admin_token_file := env_var_or_default("NNM_ADMIN_TOKEN_FILE", backend_secrets_dir / "admin.token") admin_url := env_var_or_default("NNM_ADMIN_URL", "http://127.0.0.1:" + api_port) container_engine := env_var_or_default("NNM_CONTAINER_ENGINE", "podman") container_image := env_var_or_default("NNM_CONTAINER_IMAGE", "") controller_socket := env_var_or_default("NNM_CONTAINER_CONTROLLER_SOCKET", "/tmp/nnm-controller/controller.sock") -controller_token_file := env_var_or_default("NNM_CONTAINER_CONTROLLER_TOKEN_FILE", valkey_data_dir / "controller.token") +controller_token_file := env_var_or_default("NNM_CONTAINER_CONTROLLER_TOKEN_FILE", backend_secrets_dir / "controller.token") container_data_root := env_var_or_default("NNM_CONTAINER_DATA_ROOT", converted_dir / "data") -wandb_credentials_file := env_var_or_default("NNM_WANDB_CREDENTIAL_FILE", valkey_data_dir / "wandb-credentials.json") +wandb_credentials_file := env_var_or_default("NNM_WANDB_CREDENTIAL_FILE", backend_secrets_dir / "wandb-credentials.json") wandb_network := env_var_or_default("NNM_WANDB_NETWORK", "") wandb_proxy_url := env_var_or_default("NNM_WANDB_PROXY_URL", "") default: @just --list +# Build the package worker from this checkout and print its immutable reference. +# The printed digest, not the mutable local tag, is the value for NNM_CONTAINER_IMAGE. +worker-build tag="local": + podman build --file "{{backend_dir}}/Dockerfile" --tag "localhost/nnm-worker:{{tag}}" "{{converted_dir}}" + @podman image inspect --format '{{ "{{" }}.Digest{{ "}}" }}' "localhost/nnm-worker:{{tag}}" | sed 's#^#NNM_CONTAINER_IMAGE=localhost/nnm-worker@#' + # Start Valkey in the foreground with persistent local data. valkey: mkdir -p "{{valkey_data_dir}}" @@ -43,7 +50,7 @@ backend-unsafe-unlimited-dataset-size: admin-init # Start the trusted container controller separately. Only this process needs # access to the local Podman/Docker socket; FastAPI connects through its RPC. controller: admin-init - mkdir -p "$(dirname '{{controller_socket}}')" "{{valkey_data_dir}}"; if [[ ! -s "{{controller_token_file}}" ]]; then (umask 077; python -c 'import secrets; print(secrets.token_hex(32))' > "{{controller_token_file}}"); fi; cd "{{converted_dir}}"; UV_CACHE_DIR="{{uv_cache_dir}}" PYTHONPATH=src NNM_CONTAINER_DATA_ROOT="{{container_data_root}}" NNM_WANDB_CREDENTIAL_FILE="{{wandb_credentials_file}}" NNM_WANDB_NETWORK="{{wandb_network}}" NNM_WANDB_PROXY_URL="{{wandb_proxy_url}}" uv run python -m backend.container_controller serve "{{controller_socket}}" @"{{controller_token_file}}" "{{converted_dir}}/jobs" "{{converted_dir}}/jobs" "{{container_engine}}" + mkdir -p "$(dirname '{{controller_socket}}')" "{{backend_secrets_dir}}"; if [[ ! -s "{{controller_token_file}}" ]]; then (umask 077; python -c 'import secrets; print(secrets.token_hex(32))' > "{{controller_token_file}}"); fi; cd "{{converted_dir}}"; UV_CACHE_DIR="{{uv_cache_dir}}" PYTHONPATH=src NNM_CONTAINER_DATA_ROOT="{{container_data_root}}" NNM_WANDB_CREDENTIAL_FILE="{{wandb_credentials_file}}" NNM_WANDB_NETWORK="{{wandb_network}}" NNM_WANDB_PROXY_URL="{{wandb_proxy_url}}" uv run python -m backend.container_controller serve "{{controller_socket}}" @"{{controller_token_file}}" "{{converted_dir}}/jobs" "{{converted_dir}}/jobs" "{{container_engine}}" # Configure the shared administrator-owned W&B account. The command prompts # for the key and never accepts it as an argument. @@ -108,6 +115,13 @@ test: docker-up: admin-init NNM_CONTAINER_ENGINE="{{container_engine}}" NNM_CONTAINER_IMAGE="{{container_image}}" docker compose -f "{{backend_dir}}/docker-compose.yml" up --build +# Start the standard local Podman Compose deployment. This builds the worker +# first and passes its immutable digest to the backend services. +# For non-standard user requirements, the backend administrator should invoke +# `podman compose` directly instead of using this standard `just compose` path. +compose: worker-build admin-init + mkdir -p "$(dirname '{{controller_socket}}')" "{{backend_secrets_dir}}" "{{converted_dir}}/jobs" "{{container_data_root}}"; if [[ ! -s "{{controller_token_file}}" ]]; then (umask 077; python -c 'import secrets; print(secrets.token_hex(32))' > "{{controller_token_file}}"); fi; systemctl --user start podman.socket; NNM_CONTAINER_IMAGE="$(podman image inspect --format '{{ "{{" }}.Digest{{ "}}" }}' localhost/nnm-worker:local | sed 's#^#localhost/nnm-worker@#')" NNM_HOST_VALKEY_DATA="{{valkey_data_dir}}" NNM_HOST_JOBS="{{converted_dir}}/jobs" NNM_HOST_DATA="{{container_data_root}}" NNM_HOST_ADMIN_TOKEN="{{admin_token_file}}" NNM_HOST_CONTROLLER_TOKEN="{{controller_token_file}}" NNM_WANDB_CREDENTIAL_FILE="{{wandb_credentials_file}}" podman compose --project-name nnm-backend -f "{{backend_dir}}/docker-compose.yml" up --build -d + # Stop the Docker deployment without deleting its volumes. docker-down: docker compose -f "{{backend_dir}}/docker-compose.yml" down diff --git a/converted/pyproject.toml b/converted/pyproject.toml index 2f41acbf..35214e8a 100644 --- a/converted/pyproject.toml +++ b/converted/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "uvicorn[standard]>=0.30.0", "valkey>=6.0.0", "numpy>=1.24.0", + "pillow>=11.0.0", "safetensors>=0.7.0", "wandb>=0.22.3", ] diff --git a/converted/src/backend/executors/container.py b/converted/src/backend/executors/container.py index b3b2df1f..a268dd4e 100644 --- a/converted/src/backend/executors/container.py +++ b/converted/src/backend/executors/container.py @@ -288,18 +288,38 @@ def _monitor_remote(self, job_id: str, on_heartbeat: HeartbeatCallback, on_finished: FinishedCallback) -> None: assert self._remote is not None while True: - status = self._remote.finished(job_id) - if status.get("state") == "finished": - on_finished(int(status["code"]), status) + try: + status = self._remote.finished(job_id) + if status.get("state") == "finished": + on_finished(int(status["code"]), status) + return + if status.get("state") == "unknown": + on_finished(1, _controller_lost_details(job_id)) + return + heartbeat = self._remote.heartbeat(job_id) + if heartbeat.get("state") == "unknown": + on_finished(1, _controller_lost_details(job_id)) + return + on_heartbeat(heartbeat) + except (ContainerCapabilityError, ControllerProtocolError, OSError, ConnectionError): + # A controller restart discards its in-memory process table. + # Treat that as a lost execution so JobManager can remove the + # stale active slot and let the scheduler continue. + on_finished(1, _controller_lost_details(job_id)) return - on_heartbeat(self._remote.heartbeat(job_id)) time.sleep(1) def cancel(self, job_id: str) -> bool: """Terminate the engine process group, which stops the container.""" - if self._remote is not None: - return self._remote.cancel(job_id) - return self._controller.cancel(job_id) if self._controller is not None else False + try: + if self._remote is not None: + return self._remote.cancel(job_id) + return self._controller.cancel(job_id) if self._controller is not None else False + except (ContainerCapabilityError, ControllerProtocolError, OSError, ConnectionError): + # The manager has already persisted ``cancelled``. A controller + # that disappears during the signal cannot confirm the process, + # but returning False lets the manager release its active slot. + return False def _unavailable_wandb_capabilities(reason: str) -> dict[str, Any]: @@ -309,3 +329,13 @@ def _unavailable_wandb_capabilities(reason: str) -> dict[str, Any]: "available_modes": ["disabled", "offline"], "online": {"configured": False, "entity": None, "base_url": None, "reason": reason}, } + + +def _controller_lost_details(job_id: str) -> dict[str, Any]: + """Describe an execution whose controller no longer tracks its process.""" + + return { + "job_id": job_id, + "controller_lost": True, + "error": "Container controller lost the job state; the job must be resubmitted.", + } diff --git a/converted/src/backend/manager.py b/converted/src/backend/manager.py index 31b2afe1..2ba88db2 100644 --- a/converted/src/backend/manager.py +++ b/converted/src/backend/manager.py @@ -485,6 +485,8 @@ def _failure_text(self, job: dict[str, Any], details: dict[str, Any]) -> str: content = Path(stderr_path).read_text(encoding="utf-8", errors="replace").strip() if content: return content[-4000:] + if isinstance(details.get("error"), str): + return details["error"] return f"Training executor failed: {details}" def cancel(self, job_id: str, *, owner_connection_id: str) -> JobStatus: @@ -496,21 +498,30 @@ def cancel(self, job_id: str, *, owner_connection_id: str) -> JobStatus: def admin_cancel(self, job_id: str) -> JobStatus: """Cancel any queued or active job as the backend administrator.""" - job = self.store.get_job(job_id) - if job is None: - raise KeyError(job_id) - if job["status"] == "queued": - self.store.remove_from_queue(job_id, int(job["priority"])) - self._set_status(job_id, "cancelled", finished_at=utc_now()) - self._event(job_id, "cancelled", {}) - elif job["status"] == "running": - with self._lock: + # Serialize cancellation with ``run_once``. Without this lock a job + # can be marked cancelled after it enters ``running`` but before its + # executor handle is registered, so the worker keeps running and the + # cancellation request never reaches it. + with self._lock: + job = self.store.get_job(job_id) + if job is None: + raise KeyError(job_id) + if job["status"] == "queued": + self.store.remove_from_queue(job_id, int(job["priority"])) + self._set_status(job_id, "cancelled", finished_at=utc_now()) + self._event(job_id, "cancelled", {}) + elif job["status"] == "running": active = self._active.get(job_id) - if active: - active[0].cancel(job_id) - self._set_status(job_id, "cancelled", finished_at=utc_now()) - self._event(job_id, "cancelled", {}) - return self.admin_status(job_id) + # Persist the terminal state before stopping the process. The + # executor monitor may report its signal exit concurrently; + # seeing ``cancelled`` makes that callback a no-op instead of + # overwriting the user's cancellation with ``failed``. + self._set_status(job_id, "cancelled", finished_at=utc_now()) + self._event(job_id, "cancelled", {}) + if active: + if not active[0].cancel(job_id): + self._drop_active(job_id) + return self.admin_status(job_id) def logs(self, job_id: str, *, owner_connection_id: str) -> dict[str, str]: """Read logs from a job owned by one connection.""" diff --git a/converted/src/backend/models.py b/converted/src/backend/models.py index ceca77d5..e476af2e 100644 --- a/converted/src/backend/models.py +++ b/converted/src/backend/models.py @@ -170,6 +170,7 @@ class TrainerRequest(BaseModel): accelerator: Literal["auto", "cpu", "cuda"] = "auto" patience: int = Field(default=3, ge=0, le=100_000) min_delta: float = Field(default=0.0, ge=0, allow_inf_nan=False) + log_every_n_steps: int = Field(default=10, ge=1, le=1_000_000) class WandbRequest(BaseModel): diff --git a/converted/src/model_package/exporter.py b/converted/src/model_package/exporter.py index d73350fa..fb7ad89d 100644 --- a/converted/src/model_package/exporter.py +++ b/converted/src/model_package/exporter.py @@ -272,6 +272,7 @@ def _copy_runtime(package_dir: Path) -> None: ) (runtime_dir / "compiler.py").write_text(compiler, encoding="utf-8") shutil.copy2(source_dir.parent / "package_runtime" / "loader.py", runtime_dir / "loader.py") + shutil.copy2(source_dir.parent / "package_runtime" / "jcs.py", runtime_dir / "jcs.py") stereotype_dir = package_dir / "stereotype_runtime" stereotype_dir.mkdir() source_stereotype = source_dir.parent / "stereotype_runtime" diff --git a/converted/src/package_runtime/jcs.py b/converted/src/package_runtime/jcs.py index d6122627..505cee46 100644 --- a/converted/src/package_runtime/jcs.py +++ b/converted/src/package_runtime/jcs.py @@ -4,7 +4,6 @@ import json import math -import re from typing import Any @@ -21,15 +20,18 @@ def _encode(value: Any) -> str: return "true" if value is False: return "false" - if isinstance(value, str): - return json.dumps(value, ensure_ascii=False, separators=(",", ":")) if isinstance(value, (int, float)) and not isinstance(value, bool): return _number(value) + if isinstance(value, str): + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) if isinstance(value, list): return "[" + ",".join(_encode(item) for item in value) + "]" if isinstance(value, dict): items = sorted(value.items(), key=lambda item: item[0]) - return "{" + ",".join(json.dumps(key, ensure_ascii=False) + ":" + _encode(item) for key, item in items) + "}" + return "{" + ",".join( + json.dumps(key, ensure_ascii=False) + ":" + _encode(item) + for key, item in items + ) + "}" raise TypeError(f"unsupported JSON value: {type(value).__name__}") @@ -49,7 +51,9 @@ def _number(value: int | float) -> str: sign = "" if digits.startswith("-"): sign, digits = "-", digits[1:] - decimal_index = (mantissa.lstrip("-").find(".") if "." in mantissa else len(mantissa.lstrip("-"))) + power + decimal_index = ( + mantissa.lstrip("-").find(".") if "." in mantissa else len(mantissa.lstrip("-")) + ) + power if -6 <= power < 21: if decimal_index <= 0: return sign + "0." + "0" * (-decimal_index) + digits diff --git a/converted/src/package_runtime/loader.py b/converted/src/package_runtime/loader.py index 3a71a8c0..9d4beb60 100644 --- a/converted/src/package_runtime/loader.py +++ b/converted/src/package_runtime/loader.py @@ -13,7 +13,7 @@ import torch -from package_runtime.jcs import canonicalize +from .jcs import canonicalize class PackageValidationError(ValueError): diff --git a/converted/src/package_worker.py b/converted/src/package_worker.py index be7d99c0..5e1d14d2 100644 --- a/converted/src/package_worker.py +++ b/converted/src/package_worker.py @@ -6,7 +6,9 @@ import json import random import sys -from collections.abc import Mapping +import time +from collections.abc import Callable, Mapping +from dataclasses import dataclass from pathlib import Path from typing import Any @@ -17,7 +19,7 @@ from package_runtime import CompiledPrograms, PackageValidationError, compile_package_graph from dataset.contracts import DatasetDefinition, TensorSlotContract, TrainingBatch, normalize_training_batch from training.datasets import resolve_dataset -from training.wandb_tracking import create_tracker, normalize_wandb_config +from training.wandb_tracking import CLASSIFICATION_METRICS, create_tracker, normalize_wandb_config def run( @@ -28,6 +30,7 @@ def run( ) -> dict[str, Any]: """Compile the submitted graph and execute its declared training task.""" + print("Training iniziato", flush=True) request = json.loads(input_path.read_text(encoding="utf-8")) package = request.get("package") if not isinstance(package, dict): @@ -77,12 +80,16 @@ def train( device = _training_device(training["trainer"]["accelerator"]) model.to(device) + classification = _classification_spec(model, definition, train_loader, device) _preflight_training_batch(model, request.get("package", {}), definition, train_loader, device) + if classification is not None: + _preflight_prediction(model, train_loader, device, classification) model.train() optimizer = _optimizer(model, training.get("optimizer", {})) max_epochs = max(1, int(training.get("trainer", {}).get("max_epochs", 1))) patience = int(training["trainer"].get("patience", 3)) min_delta = float(training["trainer"].get("min_delta", 0.0)) + log_every_n_steps = int(training["trainer"]["log_every_n_steps"]) history: list[dict[str, float]] = [] best_validation = float("inf") stale_epochs = 0 @@ -92,9 +99,20 @@ def train( artifacts_path, credentials=wandb_credentials, ) + started = time.monotonic() + final_test: dict[str, Any] | None = None + train_step = 0 + validation_step = 0 + + def log_validation_step(loss: float) -> None: + nonlocal validation_step + validation_step += 1 + if validation_step % log_every_n_steps == 0: + tracker.log_step_loss("validation", validation_step, loss) try: for epoch in range(max_epochs): + epoch_started = time.monotonic() total = 0.0 batches = 0 for raw_batch in train_loader: @@ -105,15 +123,36 @@ def train( raise RuntimeError("package training produced a non-finite loss") loss.backward() optimizer.step() - total += float(loss.detach()) + batch_loss = float(loss.detach()) + total += batch_loss batches += 1 + train_step += 1 + if train_step % log_every_n_steps == 0: + tracker.log_step_loss("train", train_step, batch_loss) train_loss = total / max(1, batches) - validation_loss = _evaluate(model, validation_loader, device) + train_metrics = _classification_pass(model, train_loader, device, classification) if classification else None + validation_loss, validation_metrics = _evaluate( + model, + validation_loader, + device, + classification, + on_batch_loss=log_validation_step, + ) epoch_number = epoch + 1 + epoch_seconds = max(0.0, time.monotonic() - epoch_started) history.append({"epoch": float(epoch_number), "train_loss": train_loss, "val_loss": validation_loss}) - tracker.log_epoch(epoch_number, train_loss, validation_loss) + tracker.log_epoch( + epoch_number, + train_loss, + validation_loss, + epoch_seconds=epoch_seconds, + train_classification=_epoch_classification_metrics(train_metrics), + validation_classification=_epoch_classification_metrics(validation_metrics), + binary_classification=classification.binary if classification else True, + ) print( - json.dumps({"epoch": epoch_number, "train_loss": train_loss, "val_loss": validation_loss}), + f"Epoch {epoch_number}/{max_epochs} | duration={epoch_seconds:.2f}s | " + f"train_loss={train_loss:.6f} | val_loss={validation_loss:.6f}", flush=True, ) if validation_loss < best_validation - min_delta: @@ -140,6 +179,10 @@ def train( "config": training, "num_parameters": sum(parameter.numel() for parameter in model.parameters()), } + if classification is not None: + final_test = _classification_pass(model, _dataset_test_loader(dataset), device, classification) + elapsed = max(0.0, time.monotonic() - started) + summary["classification"] = {"test": final_test, "training_seconds": elapsed} (artifacts_path / "training-summary.json").write_text( json.dumps(summary, indent=2, sort_keys=True), encoding="utf-8", @@ -148,6 +191,8 @@ def train( best_loss=best_validation, completed_epochs=len(history), num_parameters=summary["num_parameters"], + classification=final_test, + training_seconds=(summary.get("classification") or {}).get("training_seconds"), ) return summary except Exception: @@ -200,6 +245,7 @@ def _normalized_training(raw: dict[str, Any]) -> dict[str, Any]: result["trainer"].setdefault("accelerator", "auto") result["trainer"].setdefault("patience", 3) result["trainer"].setdefault("min_delta", 0.0) + result["trainer"].setdefault("log_every_n_steps", 10) result.setdefault("seed", 0) result["wandb"] = normalize_wandb_config(raw.get("wandb")) if any(field in result for field in ("api_key", "credentials", "token", "password")): @@ -221,6 +267,13 @@ def _validate_training_support( raise ValueError("W&B credentials are accepted only for online logging") _seed_from_training(training) trainer = training["trainer"] + log_every_n_steps = trainer.get("log_every_n_steps") + if ( + isinstance(log_every_n_steps, bool) + or not isinstance(log_every_n_steps, int) + or log_every_n_steps < 1 + ): + raise ValueError("trainer.log_every_n_steps must be a positive integer") accelerator = trainer.get("accelerator", "auto") if accelerator not in {"auto", "cpu", "cuda"}: raise ValueError(f"unsupported accelerator: {accelerator}") @@ -242,17 +295,177 @@ def _optimizer(model: torch.nn.Module, config: Any) -> torch.optim.Optimizer: return optimizer_class(model.parameters(), lr=learning_rate) +@dataclass(frozen=True) +class ClassificationSpec: + """Validated dataset/prediction contract for classification telemetry.""" + + classes: int + labels: list[str] + target_name: str + one_hot: bool + binary: bool + + +def _classification_spec( + model: CompiledPrograms, + definition: DatasetDefinition, + loader: Any, + device: torch.device, +) -> ClassificationSpec | None: + """Validate class metadata and the target/logit contract before training.""" + classes = definition.classes + if classes is None: + return None + if len(definition.batch.targets) != 1: + raise ValueError("classification datasets must declare exactly one target slot") + target_name, target = next(iter(definition.batch.targets.items())) + if len(target.shape) == 1 and (target.dtype.startswith("int") or target.dtype.startswith("uint")): + one_hot = False + elif len(target.shape) == 2 and target.shape[1] == classes.count and target.dtype != "bool": + one_hot = True + else: + raise ValueError("classification target must be integral [B] or numeric [B, C]") + labels = list(classes.names or tuple(str(index) for index in range(classes.count))) + spec = ClassificationSpec(classes.count, labels, target_name, one_hot, classes.count == 2) + _preflight_prediction(model, loader, device, spec) + return spec + + +def _preflight_prediction( + model: CompiledPrograms, loader: Any, device: torch.device, spec: ClassificationSpec +) -> None: + try: + batch = normalize_training_batch(next(iter(loader))).to(device) + logits = model.prediction(batch.inputs) + except StopIteration: + raise ValueError("classification dataset must provide a non-empty training loader") + except Exception as exc: + raise ValueError("classification prediction program is incompatible with the dataset") from exc + if not isinstance(logits, torch.Tensor) or logits.ndim != 2 or logits.shape[1] != spec.classes: + raise ValueError("classification prediction must return logits with shape [B, C]") + _target_indices(batch.targets[spec.target_name], spec) + + +def _target_indices(target: torch.Tensor, spec: ClassificationSpec) -> torch.Tensor: + if spec.one_hot: + if target.ndim != 2 or target.shape[1] != spec.classes: + raise ValueError("classification target must have shape [B, C]") + indices = target.argmax(dim=1) + else: + if target.ndim != 1 or target.dtype == torch.bool or target.dtype.is_floating_point: + raise ValueError("classification class-index target must be integral [B]") + indices = target.to(dtype=torch.long) + if indices.numel() and (int(indices.min()) < 0 or int(indices.max()) >= spec.classes): + raise ValueError("classification target contains an out-of-range class index") + return indices + + +def _classification_pass( + model: CompiledPrograms, loader: Any, device: torch.device, spec: ClassificationSpec +) -> dict[str, Any]: + accumulator = _ClassificationAccumulator(spec.classes) + total_loss = 0.0 + batches = 0 + model.eval() + with torch.no_grad(): + for raw_batch in loader: + batch = normalize_training_batch(raw_batch).to(device) + logits = model.prediction(batch.inputs) + if not isinstance(logits, torch.Tensor) or logits.ndim != 2 or logits.shape[1] != spec.classes: + raise ValueError("classification prediction must return logits with shape [B, C]") + targets = _target_indices(batch.targets[spec.target_name], spec) + if logits.shape[0] != targets.shape[0]: + raise ValueError("classification prediction and target batch sizes differ") + accumulator.add(targets, logits.argmax(dim=1)) + total_loss += float(model.objective(batch.inputs, batch.targets)) + batches += 1 + model.train() + metrics = accumulator.metrics(binary=spec.binary) + metrics.update( + { + "loss": total_loss / max(1, batches), + "count": len(accumulator.actual), + "labels": spec.labels, + "actual": accumulator.actual, + "predicted": accumulator.predicted, + "confusion_matrix": accumulator.matrix, + "binary": spec.binary, + } + ) + return metrics + + +def _epoch_classification_metrics(values: Mapping[str, Any] | None) -> dict[str, Any] | None: + if values is None: + return None + return {name: values[name] for name in CLASSIFICATION_METRICS if name in values} + + +class _ClassificationAccumulator: + def __init__(self, classes: int) -> None: + self.matrix = [[0 for _ in range(classes)] for _ in range(classes)] + self.actual: list[int] = [] + self.predicted: list[int] = [] + + def add(self, actual: torch.Tensor, predicted: torch.Tensor) -> None: + for truth, prediction in zip(actual.detach().cpu().tolist(), predicted.detach().cpu().tolist()): + truth, prediction = int(truth), int(prediction) + self.actual.append(truth) + self.predicted.append(prediction) + self.matrix[truth][prediction] += 1 + + def metrics(self, *, binary: bool) -> dict[str, float]: + total = sum(map(sum, self.matrix)) + accuracy = sum(self.matrix[i][i] for i in range(len(self.matrix))) / total if total else 0.0 + precision, recall, f1 = [], [], [] + for index, row in enumerate(self.matrix): + tp = row[index] + fp = sum(other[index] for other in self.matrix) - tp + fn = sum(row) - tp + p = tp / (tp + fp) if tp + fp else 0.0 + r = tp / (tp + fn) if tp + fn else 0.0 + precision.append(p) + recall.append(r) + f1.append(2 * p * r / (p + r) if p + r else 0.0) + result = { + "accuracy": accuracy, + "macro_precision": sum(precision) / len(precision), + "macro_recall": sum(recall) / len(recall), + "macro_f1": sum(f1) / len(f1), + } + if binary: + result.update({"precision": precision[1], "recall": recall[1], "f1": f1[1]}) + tn, fp = self.matrix[0][0], self.matrix[0][1] + result["specificity"] = tn / (tn + fp) if tn + fp else 0.0 + return result + + +def _dataset_test_loader(dataset: Any) -> Any: + division = dataset.division() + return division["test"] + + @torch.no_grad() -def _evaluate(model: CompiledPrograms, loader: Any, device: torch.device) -> float: +def _evaluate( + model: CompiledPrograms, + loader: Any, + device: torch.device, + classification: "ClassificationSpec | None" = None, + *, + on_batch_loss: Callable[[float], None] | None = None, +) -> tuple[float, dict[str, Any] | None]: model.eval() total = 0.0 batches = 0 for raw_batch in loader: batch = normalize_training_batch(raw_batch).to(device) - total += float(model.objective(batch.inputs, batch.targets)) + batch_loss = float(model.objective(batch.inputs, batch.targets)) + total += batch_loss batches += 1 + if on_batch_loss is not None: + on_batch_loss(batch_loss) model.train() - return total / max(1, batches) + return total / max(1, batches), (_classification_pass(model, loader, device, classification) if classification else None) def _validate_graph_bindings(package: Any, definition: DatasetDefinition) -> None: @@ -528,7 +741,8 @@ def main() -> None: credentials = read_credentials_stdin(stream) finally: sys.stdin.close() - print(json.dumps(run(args.input, args.artifacts, wandb_credentials=credentials), sort_keys=True), flush=True) + run(args.input, args.artifacts, wandb_credentials=credentials) + print(f"Training completed | result={args.artifacts / 'package-worker-result.json'}", flush=True) if __name__ == "__main__": diff --git a/converted/src/tests/test_backend_models.py b/converted/src/tests/test_backend_models.py index aa34e8a5..60d48e6d 100644 --- a/converted/src/tests/test_backend_models.py +++ b/converted/src/tests/test_backend_models.py @@ -58,6 +58,25 @@ def test_job_submission_accepts_only_package_network_format() -> None: assert package.network.format == "package" assert "overrides" not in package.training.model_dump() + assert package.training.trainer.log_every_n_steps == 10 + + configured = JobSubmission( + network={ + "format": "package", + "value": {"graph": {}, "bundle_ref": "bundle-1"}, + }, + training={**_training(), "trainer": {"log_every_n_steps": 7}}, + ) + assert configured.training.trainer.log_every_n_steps == 7 + + with pytest.raises(ValidationError, match="log_every_n_steps"): + JobSubmission( + network={ + "format": "package", + "value": {"graph": {}, "bundle_ref": "bundle-1"}, + }, + training={**_training(), "trainer": {"log_every_n_steps": 0}}, + ) with pytest.raises(ValidationError, match="reference"): JobSubmission( diff --git a/converted/src/tests/test_container_executor.py b/converted/src/tests/test_container_executor.py index a620c25c..6b4b08d1 100644 --- a/converted/src/tests/test_container_executor.py +++ b/converted/src/tests/test_container_executor.py @@ -173,3 +173,39 @@ def cancel(self, _job_id: str) -> bool: lambda _event: None, lambda _code, _metadata: None, ) assert result == {"pid": 7} + + +@pytest.mark.parametrize("failure", ["unknown", "broken-pipe"]) +def test_remote_monitor_reconciles_lost_controller_state(failure: str) -> None: + class Remote: + def finished(self, _job_id: str) -> dict[str, object]: + if failure == "broken-pipe": + raise BrokenPipeError("controller disconnected") + return {"state": "unknown"} + + executor = ContainerExecutor(image=IMAGE) + executor._remote = Remote() # type: ignore[assignment] + finished: list[tuple[int, dict[str, object]]] = [] + + executor._monitor_remote( + "lost-job", + lambda _heartbeat: pytest.fail("lost controller must not emit a heartbeat"), + lambda code, details: finished.append((code, details)), + ) + + assert finished == [(1, { + "job_id": "lost-job", + "controller_lost": True, + "error": "Container controller lost the job state; the job must be resubmitted.", + })] + + +def test_cancel_returns_false_when_remote_controller_disconnects() -> None: + class Remote: + def cancel(self, _job_id: str) -> bool: + raise BrokenPipeError("controller disconnected") + + executor = ContainerExecutor(image=IMAGE) + executor._remote = Remote() # type: ignore[assignment] + + assert executor.cancel("lost-job") is False diff --git a/converted/src/tests/test_package_worker.py b/converted/src/tests/test_package_worker.py index 38d48016..f9f05f27 100644 --- a/converted/src/tests/test_package_worker.py +++ b/converted/src/tests/test_package_worker.py @@ -11,10 +11,11 @@ import torch from torch.utils.data import DataLoader -from dataset.contracts import DatasetBatchContract, DatasetDefinition, DatasetReference, TensorSlotContract, TrainingBatch +from dataset.contracts import DatasetBatchContract, DatasetClassMetadata, DatasetDefinition, DatasetReference, TensorSlotContract, TrainingBatch from package_runtime import PackageValidationError from package_worker import ( _dataset_loaders, + _ClassificationAccumulator, _materialize_dataset_inputs, _normalized_training, _validate_graph_bindings, @@ -70,11 +71,15 @@ def test_project_dataset_loader_supports_dataclass_module_definitions(tmp_path: assert dataset.division() == {} -def test_run_rejects_missing_package(tmp_path: Path) -> None: +def test_run_reports_start_before_rejecting_missing_package( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: input_path = tmp_path / "job.json" input_path.write_text(json.dumps({"training": {}}), encoding="utf-8") with pytest.raises(ValueError, match="package is required"): run(input_path, tmp_path / "artifacts") + assert capsys.readouterr().out == "Training iniziato\n" def test_training_contract_requires_opaque_dataset_reference() -> None: @@ -103,6 +108,7 @@ def test_worker_accepts_frozen_wandb_modes_and_rejects_controller_fields() -> No def test_worker_reads_bounded_credentials_before_run_and_closes_stdin( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, + capsys: pytest.CaptureFixture[str], ) -> None: payload = json.dumps( { @@ -139,6 +145,9 @@ def fake_run(input_path: Path, artifacts_path: Path, *, wandb_credentials=None) assert captured["credentials"].api_key == "test-secret" assert stdin.closed + assert capsys.readouterr().out == ( + f"Training completed | result={tmp_path / 'package-worker-result.json'}\n" + ) def test_training_passes_named_batch_to_objective(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: @@ -165,6 +174,99 @@ def objective(self, inputs, targets) -> torch.Tensor: assert torch.equal(model.targets[0], torch.tensor([1, 0], dtype=torch.long)) +def test_classification_worker_logs_metrics_and_final_test( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + definition = DEFINITION.model_copy(update={"classes": DatasetClassMetadata(count=2, names=("ham", "spam"))}) + batches = [TrainingBatch({"image": torch.tensor([[1.0], [-1.0]])}, {"label": torch.tensor([1, 0])})] + + class Dataset: + def division(self): + loader = DataLoader(batches, batch_size=None) + return {"train": loader, "validation": loader, "test": loader} + + class Model(torch.nn.Module): + def __init__(self): + super().__init__() + self.weight = torch.nn.Parameter(torch.ones(())) + + def objective(self, inputs, targets): + return (inputs["image"].mean() * self.weight - targets["label"].float().mean()).square() + + def prediction(self, inputs): + value = inputs["image"][:, 0] * self.weight + return torch.stack((-value, value), dim=1) + + class Tracker: + def __init__(self): + self.epochs = [] + self.steps = [] + self.final = None + def log_epoch(self, *args, **kwargs): + self.epochs.append(kwargs) + def log_step_loss(self, split, step, loss): + self.steps.append((split, step, loss)) + def finish(self, **kwargs): + self.final = kwargs + def abort(self): + raise AssertionError("unexpected tracker abort") + + tracker = Tracker() + monkeypatch.setattr("package_worker.resolve_dataset", lambda _training: (Dataset(), definition, REFERENCE, {})) + monkeypatch.setattr("package_worker.create_tracker", lambda *args, **kwargs: tracker) + clock = iter((10.0, 11.0, 13.0, 15.0)) + monkeypatch.setattr("package_worker.time.monotonic", lambda: next(clock)) + summary = train(Model(), {"training": {"dataset": {"reference": REFERENCE.model_dump(), "parameters": {}}, "trainer": {"max_epochs": 1, "patience": 0, "log_every_n_steps": 1}}, "package": training_package()}, tmp_path) + + expected_epoch_metrics = { + "accuracy", + "precision", + "recall", + "f1", + "specificity", + "macro_precision", + "macro_recall", + "macro_f1", + } + assert set(tracker.epochs[0]["train_classification"]) == expected_epoch_metrics + assert set(tracker.epochs[0]["validation_classification"]) == expected_epoch_metrics + assert tracker.epochs[0]["train_classification"]["accuracy"] == 1.0 + assert tracker.epochs[0]["epoch_seconds"] == 2.0 + assert tracker.steps == [("train", 1, 0.25), ("validation", 1, 0.25)] + assert tracker.final["classification"]["labels"] == ["ham", "spam"] + assert tracker.final["classification"]["confusion_matrix"] == [[1, 0], [0, 1]] + assert summary["classification"]["training_seconds"] >= 0 + assert capsys.readouterr().out == ( + "Epoch 1/1 | duration=2.00s | train_loss=0.250000 | val_loss=0.250000\n" + ) + + +def test_classification_worker_rejects_invalid_prediction_shape(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + definition = DEFINITION.model_copy(update={"classes": DatasetClassMetadata(count=2)}) + batch = TrainingBatch({"image": torch.ones(1, 1)}, {"label": torch.zeros(1, dtype=torch.long)}) + class Dataset: + def division(self): + loader = DataLoader([batch], batch_size=None) + return {"train": loader, "validation": loader, "test": loader} + class Model(torch.nn.Module): + def objective(self, *_args): + return torch.ones((), requires_grad=True) + def prediction(self, _inputs): + return torch.ones(1) + monkeypatch.setattr("package_worker.resolve_dataset", lambda _training: (Dataset(), definition, REFERENCE, {})) + with pytest.raises(ValueError, match="prediction must return logits"): + train(Model(), {"training": {"dataset": {"reference": REFERENCE.model_dump(), "parameters": {}}, "trainer": {"max_epochs": 1}}, "package": training_package()}, tmp_path) + + +def test_multiclass_metrics_expose_only_macro_variants() -> None: + accumulator = _ClassificationAccumulator(3) + accumulator.add(torch.tensor([0, 1, 2]), torch.tensor([0, 2, 1])) + metrics = accumulator.metrics(binary=False) + assert set(metrics) == {"accuracy", "macro_precision", "macro_recall", "macro_f1"} + + def test_training_propagates_typed_missing_objective_error(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: class RegisteredDataset: def division(self): @@ -189,7 +291,13 @@ def test_loader_settings_stay_inside_dataset_parameters() -> None: _normalized_training({"dataset": {"reference": REFERENCE.model_dump(), "parameters": {}}, "batch_size": 7}) normalized = _normalized_training({"dataset": {"reference": REFERENCE.model_dump(), "parameters": {"batch_size": 64}}, "trainer": {"max_epochs": 3, "patience": 2}}) assert normalized["dataset"]["parameters"] == {"batch_size": 64} - assert normalized["trainer"] == {"max_epochs": 3, "patience": 2, "accelerator": "auto", "min_delta": 0.0} + assert normalized["trainer"] == { + "max_epochs": 3, + "patience": 2, + "accelerator": "auto", + "min_delta": 0.0, + "log_every_n_steps": 10, + } def test_dataset_loaders_reject_legacy_tuple_division() -> None: diff --git a/converted/src/tests/test_remote_backend.py b/converted/src/tests/test_remote_backend.py index d8a76a3a..ec521ad9 100644 --- a/converted/src/tests/test_remote_backend.py +++ b/converted/src/tests/test_remote_backend.py @@ -5,6 +5,7 @@ import asyncio import hashlib import json +import threading import zipfile from pathlib import Path from typing import Any @@ -140,6 +141,61 @@ def test_offline_download_requires_explicit_submission_mode(tmp_path: Path) -> N manager.wandb_offline_download(queued.id, owner_connection_id=OWNER) +def test_active_cancellation_persists_before_stopping_executor(tmp_path: Path) -> None: + observed_statuses: list[str] = [] + + class ObservingExecutor(ControllerExecutor): + def cancel(self, job_id: str) -> bool: + job = manager.store.get_job(job_id) + assert job is not None + observed_statuses.append(job["status"]) + return True + + manager = JobManager(InMemoryJobStore(), tmp_path, [ObservingExecutor()]) + queued = manager.submit(_submission(manager, mode="disabled"), owner_connection_id=OWNER) + manager._set_status(queued.id, "running", started_at=queued.created_at) + manager._active[queued.id] = (manager.executors[0], {"pid": 1}) + + manager.admin_cancel(queued.id) + + assert observed_statuses == ["cancelled"] + assert manager.admin_status(queued.id).status == "cancelled" + + +def test_cancelling_during_executor_startup_waits_for_the_executor_handle(tmp_path: Path) -> None: + class BlockingExecutor(ControllerExecutor): + name = "blocking-container" + + def __init__(self) -> None: + super().__init__() + self.cancelled = threading.Event() + + def cancel(self, job_id: str) -> bool: + del job_id + self.cancelled.set() + return True + + executor = BlockingExecutor() + manager = JobManager(InMemoryJobStore(), tmp_path, [executor]) + queued = manager.submit(_submission(manager, mode="disabled"), owner_connection_id=OWNER) + manager._set_status(queued.id, "running", started_at=queued.created_at) + + # Hold the same critical section used by the scheduler while it is + # between starting a job and registering its executor handle. + manager._lock.acquire() + cancel_thread = threading.Thread(target=manager.admin_cancel, args=(queued.id,)) + cancel_thread.start() + cancel_thread.join(timeout=0.2) + assert cancel_thread.is_alive() + manager._active[queued.id] = (executor, {"pid": 1}) + manager._lock.release() + + cancel_thread.join(timeout=2) + assert not cancel_thread.is_alive() + assert executor.cancelled.wait(timeout=2) + assert manager.admin_status(queued.id).status == "cancelled" + + def test_authenticated_capability_endpoint_is_scoped(tmp_path: Path) -> None: auth = AuthService(InMemoryAuthStore()) grant = auth.create_pairing("test", client_host="127.0.0.1") diff --git a/converted/src/tests/test_resnet_example_fixture.py b/converted/src/tests/test_resnet_example_fixture.py index b6a2f76a..bd244a55 100644 --- a/converted/src/tests/test_resnet_example_fixture.py +++ b/converted/src/tests/test_resnet_example_fixture.py @@ -2,10 +2,8 @@ from __future__ import annotations -import gzip import json from pathlib import Path -import struct ROOT = Path(__file__).parents[3] RESNET = ROOT / "examples" / "diagrams" / "package" / "models" / "resnet" @@ -29,17 +27,3 @@ def test_resnet_fixture_uses_named_image_and_label_contract() -> None: }] assert dataset["batch"]["inputs"]["image"] == {"shape": ["B", 1, 28, 28], "dtype": "float32"} assert dataset["batch"]["targets"]["target"] == {"shape": ["B"], "dtype": "int64"} - - -def test_resnet_fixture_contains_matching_mnist_archives() -> None: - expected = { - "train-images-idx3-ubyte.gz": (2051, 60000), - "train-labels-idx1-ubyte.gz": (2049, 60000), - "t10k-images-idx3-ubyte.gz": (2051, 10000), - "t10k-labels-idx1-ubyte.gz": (2049, 10000), - } - for name, (magic, count) in expected.items(): - with gzip.open(DATASET / "data" / name, "rb") as archive: - header = archive.read(16 if magic == 2051 else 8) - values = struct.unpack(">IIII", header)[:2] if magic == 2051 else struct.unpack(">II", header) - assert values == (magic, count) diff --git a/converted/src/tests/test_service_valkey.py b/converted/src/tests/test_service_valkey.py index 35cb06f6..e2f2b256 100644 --- a/converted/src/tests/test_service_valkey.py +++ b/converted/src/tests/test_service_valkey.py @@ -494,7 +494,11 @@ async def exercise_api() -> None: assert logs.status_code == 200 assert logs.json()["stdout"] == "training ok\n" - package = await client.get(f"/jobs/{queued.id}/package", headers=headers) + package = await client.get( + f"/jobs/{queued.id}/package", + params={"packageName": "nnm_demo_model"}, + headers=headers, + ) assert package.status_code == 404 asyncio.run(exercise_api()) diff --git a/converted/src/tests/test_wandb_tracking.py b/converted/src/tests/test_wandb_tracking.py index 5813b8ef..55d18349 100644 --- a/converted/src/tests/test_wandb_tracking.py +++ b/converted/src/tests/test_wandb_tracking.py @@ -8,6 +8,7 @@ import pytest import wandb +from PIL import Image from training.wandb_tracking import WandbTracker, create_tracker, normalize_wandb_config @@ -18,16 +19,20 @@ class FakeRun: def __init__(self) -> None: self.summary: dict[str, object] = {} - self.logs: list[tuple[dict[str, float], int]] = [] + self.logs: list[tuple[dict[str, object], int]] = [] + self.log_environments: list[dict[str, str | None]] = [] self.finish_count = 0 self.finish_exit_codes: list[int | None] = [] + self.finish_environments: list[dict[str, str | None]] = [] - def log(self, values: dict[str, float], *, step: int) -> None: + def log(self, values: dict[str, object], *, step: int | None = None) -> None: self.logs.append((values, step)) + self.log_environments.append(_wandb_storage_environment()) def finish(self, *, exit_code: int | None = None) -> None: self.finish_count += 1 self.finish_exit_codes.append(exit_code) + self.finish_environments.append(_wandb_storage_environment()) class FakeSettings: @@ -42,16 +47,27 @@ def __init__(self) -> None: self.kwargs: dict[str, object] | None = None self.init_environment: dict[str, str | None] | None = None self.run = FakeRun() + @staticmethod + def Image(path: str, *, caption: str) -> dict[str, object]: + return {"path": path, "caption": caption} def init(self, **kwargs: object) -> FakeRun: self.kwargs = kwargs self.init_environment = { "WANDB_API_KEY": os.environ.get("WANDB_API_KEY"), "WANDB_BASE_URL": os.environ.get("WANDB_BASE_URL"), + **_wandb_storage_environment(), } return self.run +def _wandb_storage_environment() -> dict[str, str | None]: + return { + "WANDB_CACHE_DIR": os.environ.get("WANDB_CACHE_DIR"), + "WANDB_DATA_DIR": os.environ.get("WANDB_DATA_DIR"), + } + + def test_normalize_wandb_config_is_closed_and_defaults_disabled() -> None: assert normalize_wandb_config(None) == {"mode": "disabled", "project": "NeuralNetworks"} assert normalize_wandb_config({"mode": "offline", "project": "demo"}) == { @@ -93,6 +109,8 @@ def test_tracker_records_metrics_and_writes_structured_manifest( assert sdk.init_environment == { "WANDB_API_KEY": "test-secret", "WANDB_BASE_URL": "https://wandb.example.test", + "WANDB_CACHE_DIR": str(tmp_path / "wandb" / "cache"), + "WANDB_DATA_DIR": str(tmp_path / "wandb" / "data"), } assert "WANDB_API_KEY" not in os.environ assert "WANDB_BASE_URL" not in os.environ @@ -104,16 +122,59 @@ def test_tracker_records_metrics_and_writes_structured_manifest( "project": "demo", "url": "https://wandb.example.test/runs/run-123", } - tracker.log_epoch(1, 1.5, 1.25) + tracker.log_step_loss("train", 10, 0.75) + tracker.log_epoch(1, 1.5, 1.25, epoch_seconds=3.5) tracker.finish(best_loss=1.25, completed_epochs=1, num_parameters=7) - assert sdk.run.logs == [({"train/loss": 1.5, "validation/loss": 1.25}, 1)] + assert sdk.run.logs == [ + ({"train/step": 10, "train/step_loss": 0.75}, None), + ( + { + "epoch": 1, + "train/loss": 1.5, + "validation/loss": 1.25, + "epoch/seconds": 3.5, + }, + None, + ), + ] assert sdk.run.summary == {"best_loss": 1.25, "completed_epochs": 1, "num_parameters": 7} assert sdk.run.finish_count == 1 assert sdk.run.finish_exit_codes == [None] assert "test-secret" not in (tmp_path / "wandb-run.json").read_text(encoding="utf-8") +def test_tracker_confines_wandb_storage_environment_to_job_artifacts( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("WANDB_CACHE_DIR", "/outside/cache") + monkeypatch.setenv("WANDB_DATA_DIR", "/outside/data") + sdk = FakeSdk() + tracker = WandbTracker( + mode="offline", + project="offline-test", + run_name="nnm-job-offline", + artifacts_path=tmp_path, + config={}, + sdk=sdk, + ) + tracker.log_epoch(1, 1.0, 0.5) + tracker.finish(best_loss=0.5, completed_epochs=1, num_parameters=3) + + expected = { + "WANDB_CACHE_DIR": str(tmp_path / "wandb" / "cache"), + "WANDB_DATA_DIR": str(tmp_path / "wandb" / "data"), + } + assert sdk.init_environment is not None + assert {name: sdk.init_environment[name] for name in expected} == expected + assert sdk.run.log_environments == [expected] + assert sdk.run.finish_environments == [expected] + assert _wandb_storage_environment() == { + "WANDB_CACHE_DIR": "/outside/cache", + "WANDB_DATA_DIR": "/outside/data", + } + + def test_disabled_tracker_does_not_import_or_initialize_sdk(tmp_path: Path) -> None: tracker = create_tracker( {"wandb": {"mode": "disabled", "project": "demo"}}, @@ -159,6 +220,51 @@ def test_real_offline_tracker_writes_a_wandb_run_file_without_credentials(tmp_pa assert manifest["url"] is None +def test_real_offline_confusion_matrix_works_with_read_only_home( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + read_only_parent = tmp_path / "read-only" + read_only_parent.mkdir(mode=0o500) + monkeypatch.setenv("HOME", str(read_only_parent / "home")) + artifacts_path = tmp_path / "artifacts" + metrics = { + "accuracy": 1.0, + "precision": 1.0, + "recall": 1.0, + "f1": 1.0, + "specificity": 1.0, + "macro_precision": 1.0, + "macro_recall": 1.0, + "macro_f1": 1.0, + } + try: + tracker = create_tracker( + {"wandb": {"mode": "offline", "project": "offline-test"}}, + {"id": "job-offline-classification"}, + artifacts_path, + sdk=wandb, + ) + tracker.finish( + best_loss=0.1, + completed_epochs=1, + num_parameters=3, + classification={ + "loss": 0.1, + **metrics, + "count": 2, + "labels": ["ham", "spam"], + "actual": [0, 1], + "predicted": [0, 1], + "confusion_matrix": [[1, 0], [0, 1]], + "binary": True, + }, + ) + finally: + read_only_parent.chmod(0o700) + + assert list((artifacts_path / "wandb").rglob("*.wandb")) + + def test_abort_marks_failed_finish_when_sdk_supports_exit_code(tmp_path: Path) -> None: sdk = FakeSdk() tracker = WandbTracker( @@ -171,3 +277,159 @@ def test_abort_marks_failed_finish_when_sdk_supports_exit_code(tmp_path: Path) - ) tracker.abort() assert sdk.run.finish_exit_codes == [1] + + +def test_classification_metrics_and_confusion_matrix_are_logged(tmp_path: Path) -> None: + sdk = FakeSdk() + tracker = WandbTracker( + mode="offline", + project="demo", + run_name="nnm-job-classification", + artifacts_path=tmp_path, + config={"wandb": {"mode": "offline", "project": "demo"}}, + sdk=sdk, + ) + metrics = { + "accuracy": 0.8, + "precision": 0.75, + "recall": 0.6, + "f1": 0.6667, + "specificity": 1.0, + "macro_precision": 0.875, + "macro_recall": 0.8, + "macro_f1": 0.8333, + } + tracker.log_epoch( + 1, + 0.5, + 0.4, + epoch_seconds=0.75, + train_classification=metrics, + validation_classification=metrics, + ) + tracker.finish( + best_loss=0.4, + completed_epochs=1, + num_parameters=7, + training_seconds=12.5, + classification={ + "loss": 0.3, + **metrics, + "count": 4, + "labels": ["ham", "spam"], + "actual": [0, 1, 1, 0], + "predicted": [0, 1, 0, 0], + "confusion_matrix": [[2, 0], [1, 1]], + "binary": True, + }, + ) + epoch_log = sdk.run.logs[0][0] + assert epoch_log["train/accuracy"] == 0.8 + assert epoch_log["epoch/seconds"] == 0.75 + final_log = sdk.run.logs[1][0] + assert final_log["test/examples"] == 4 + assert final_log["training/seconds"] == 12.5 + confusion_image = final_log["test/confusion_matrix"] + assert confusion_image == { + "path": str(tmp_path / "wandb" / "confusion-matrix.png"), + "caption": "Confusion matrix — rows: actual, columns: predicted", + } + with Image.open(confusion_image["path"]) as image: + assert image.format == "PNG" + assert image.width >= 640 + assert image.height >= 640 + assert sdk.run.summary["test/confusion_matrix_values"] == [[2, 0], [1, 1]] + assert sdk.run.summary["confusion_matrix_labels"] == ["ham", "spam"] + + +def test_classification_rejects_non_finite_metric(tmp_path: Path) -> None: + tracker = WandbTracker( + mode="offline", + project="demo", + run_name="nnm-job-classification", + artifacts_path=tmp_path, + config={}, + sdk=FakeSdk(), + ) + with pytest.raises(ValueError, match="finite number"): + metrics = { + "accuracy": float("nan"), + "precision": 0.5, + "recall": 0.5, + "f1": 0.5, + "specificity": 0.5, + "macro_precision": 0.5, + "macro_recall": 0.5, + "macro_f1": 0.5, + } + tracker.log_epoch( + 1, + 1.0, + 1.0, + train_classification=metrics, + ) + + +def test_multiclass_logs_only_macro_metrics(tmp_path: Path) -> None: + sdk = FakeSdk() + tracker = WandbTracker( + mode="offline", + project="demo", + run_name="nnm-job-multiclass", + artifacts_path=tmp_path, + config={}, + sdk=sdk, + ) + metrics = { + "accuracy": 0.75, + "macro_precision": 0.7, + "macro_recall": 0.72, + "macro_f1": 0.71, + } + tracker.log_epoch( + 1, + 0.5, + 0.4, + train_classification=metrics, + validation_classification=metrics, + binary_classification=False, + ) + assert set(sdk.run.logs[0][0]) == { + "epoch", + "train/loss", + "validation/loss", + "train/accuracy", + "train/macro_precision", + "train/macro_recall", + "train/macro_f1", + "validation/accuracy", + "validation/macro_precision", + "validation/macro_recall", + "validation/macro_f1", + } + tracker.finish( + best_loss=0.4, + completed_epochs=1, + num_parameters=3, + classification={ + "loss": 0.3, + **metrics, + "count": 3, + "labels": ["a", "b", "c"], + "actual": [0, 1, 2], + "predicted": [0, 2, 2], + "confusion_matrix": [[1, 0, 0], [0, 0, 1], [0, 0, 1]], + "binary": False, + }, + training_seconds=1.0, + ) + assert set(sdk.run.logs[1][0]) == { + "test/loss", + "test/accuracy", + "test/macro_precision", + "test/macro_recall", + "test/macro_f1", + "test/examples", + "training/seconds", + "test/confusion_matrix", + } diff --git a/converted/src/training/wandb_tracking.py b/converted/src/training/wandb_tracking.py index de502799..270c1a13 100644 --- a/converted/src/training/wandb_tracking.py +++ b/converted/src/training/wandb_tracking.py @@ -18,6 +18,8 @@ from pathlib import Path from typing import Any, Iterator +from PIL import Image, ImageDraw, ImageFont + from backend.wandb_credentials import WandbCredentials @@ -25,6 +27,18 @@ DEFAULT_WANDB_PROJECT = "NeuralNetworks" _WANDB_FIELDS = frozenset({"mode", "project"}) +CLASSIFICATION_METRICS = ( + "accuracy", + "precision", + "recall", + "f1", + "specificity", + "macro_precision", + "macro_recall", + "macro_f1", +) +_CLASSIFICATION_REQUIRED = frozenset(CLASSIFICATION_METRICS) + def normalize_wandb_config(value: Any) -> dict[str, str]: """Validate and normalize the browser-facing W&B mode/project object.""" @@ -76,10 +90,19 @@ def _settings( @contextmanager -def _temporary_wandb_environment(credentials: WandbCredentials) -> Iterator[None]: - """Expose selected W&B connection values only for the SDK init call.""" +def _temporary_wandb_environment( + artifacts_path: Path, + *, + credentials: WandbCredentials | None = None, +) -> Iterator[None]: + """Confine writable SDK state and optionally expose init credentials.""" - values = {"WANDB_API_KEY": credentials.api_key, "WANDB_BASE_URL": credentials.base_url} + values = { + "WANDB_CACHE_DIR": str(artifacts_path / "wandb" / "cache"), + "WANDB_DATA_DIR": str(artifacts_path / "wandb" / "data"), + } + if credentials is not None: + values.update({"WANDB_API_KEY": credentials.api_key, "WANDB_BASE_URL": credentials.base_url}) previous = {name: os.environ.get(name) for name in values} try: os.environ.update(values) @@ -98,6 +121,141 @@ def _set_summary(run: Any, key: str, value: Any) -> None: summary[key] = value +def _classification_metrics(value: Mapping[str, Any], *, binary: bool) -> dict[str, float]: + """Validate the finite scalar metrics accepted by the tracker API.""" + + expected = set(CLASSIFICATION_METRICS) + if not binary: + expected = {"accuracy", "macro_precision", "macro_recall", "macro_f1"} + if set(value) != expected: + raise ValueError(f"classification metrics must contain exactly {sorted(expected)}") + result: dict[str, float] = {} + for key, raw in value.items(): + if isinstance(raw, bool) or not isinstance(raw, (int, float)) or not math.isfinite(raw): + raise ValueError(f"classification metric {key!r} must be a finite number") + result[key] = float(raw) + return result + + +def _classification_final(value: Mapping[str, Any]) -> tuple[dict[str, float], list[list[int]], list[int], list[int], list[str], int, bool]: + """Validate final test metrics and the data needed for a W&B confusion plot.""" + + required = {"loss", "count", "labels", "actual", "predicted", "confusion_matrix", "binary"} + if not required.issubset(value): + raise ValueError("classification final payload has unexpected or missing fields") + binary = value["binary"] + if not isinstance(binary, bool): + raise ValueError("classification final payload binary must be a boolean") + expected = required | (_CLASSIFICATION_REQUIRED if binary else { + "accuracy", "macro_precision", "macro_recall", "macro_f1" + }) + if set(value) != expected: + raise ValueError("classification final payload has unexpected or missing fields") + metrics = _classification_metrics( + {key: value[key] for key in CLASSIFICATION_METRICS if key in value}, binary=binary + ) + loss = value["loss"] + count = value["count"] + if isinstance(loss, bool) or not isinstance(loss, (int, float)) or not math.isfinite(loss): + raise ValueError("classification final loss must be a finite number") + if isinstance(count, bool) or not isinstance(count, int) or count < 0: + raise ValueError("classification final count must be a non-negative integer") + labels = value["labels"] + actual = value["actual"] + predicted = value["predicted"] + matrix = value["confusion_matrix"] + if ( + not isinstance(labels, list) + or not labels + or any(not isinstance(label, str) or not label for label in labels) + or not isinstance(actual, list) + or not isinstance(predicted, list) + or len(actual) != len(predicted) + or len(actual) != count + or any(isinstance(item, bool) or not isinstance(item, int) for item in actual + predicted) + or not isinstance(matrix, list) + or len(matrix) != len(labels) + or any( + not isinstance(row, list) + or len(row) != len(labels) + or any(isinstance(item, bool) or not isinstance(item, int) or item < 0 for item in row) + for row in matrix + ) + ): + raise ValueError("classification final payload has invalid labels, examples, or matrix") + return metrics, matrix, actual, predicted, labels, count, binary + + +def _write_confusion_matrix_image( + path: Path, + matrix: list[list[int]], + labels: list[str], +) -> None: + """Render a portable heatmap instead of relying on W&B's custom chart.""" + + classes = len(labels) + cell_size = max(1, min(160, 960 // classes)) + left = 180 + top = 120 + grid_size = classes * cell_size + width = max(640, left + grid_size + 40) + height = max(640, top + grid_size + 40) + image = Image.new("RGB", (width, height), "white") + draw = ImageDraw.Draw(image) + axis_font = ImageFont.load_default(size=18) + label_font = ImageFont.load_default(size=max(8, min(16, cell_size // 4))) + value_font = ImageFont.load_default(size=max(8, min(18, cell_size // 4))) + maximum = max((value for row in matrix for value in row), default=0) + + draw.text((left + grid_size / 2, 24), "Confusion matrix", fill="#111827", font=axis_font, anchor="ma") + draw.text((left + grid_size / 2, 64), "Predicted", fill="#374151", font=axis_font, anchor="ma") + draw.text((60, top + grid_size / 2), "Actual", fill="#374151", font=axis_font, anchor="mm") + show_text = cell_size >= 18 + for index, label in enumerate(labels): + if show_text: + display_label = label if len(label) <= 18 else f"{label[:15]}..." + center = index * cell_size + cell_size / 2 + draw.text( + (left + center, top - 12), + display_label, + fill="#374151", + font=label_font, + anchor="ms", + ) + draw.text( + (left - 12, top + center), + display_label, + fill="#374151", + font=label_font, + anchor="rm", + ) + for column, value in enumerate(matrix[index]): + intensity = value / maximum if maximum else 0.0 + color = ( + round(239 - 202 * intensity), + round(246 - 147 * intensity), + round(255 - 20 * intensity), + ) + x0 = left + column * cell_size + y0 = top + index * cell_size + draw.rectangle( + (x0, y0, x0 + cell_size, y0 + cell_size), + fill=color, + outline="#d1d5db" if show_text else None, + ) + if show_text: + draw.text( + (x0 + cell_size / 2, y0 + cell_size / 2), + str(value), + fill="white" if intensity > 0.55 else "#111827", + font=value_font, + anchor="mm", + ) + + path.parent.mkdir(parents=True, exist_ok=True) + image.save(path, format="PNG") + + class WandbTracker: """Track one package-worker training run or act as a disabled no-op.""" @@ -166,9 +324,10 @@ def __init__( if self.mode == "online": init_kwargs["force"] = True assert self._credentials is not None - with _temporary_wandb_environment(self._credentials): - self._run = sdk_module.init(**init_kwargs) - else: + with _temporary_wandb_environment( + self.artifacts_path, + credentials=self._credentials if self.mode == "online" else None, + ): self._run = sdk_module.init(**init_kwargs) if self._run is None: raise RuntimeError("W&B SDK did not return a run") @@ -190,15 +349,75 @@ def run(self) -> Any | None: return self._run - def log_epoch(self, epoch: int, train_loss: float, validation_loss: float) -> None: - """Record the two package training metrics for one epoch.""" + def log_epoch( + self, + epoch: int, + train_loss: float, + validation_loss: float, + *, + epoch_seconds: float | None = None, + train_classification: Mapping[str, Any] | None = None, + validation_classification: Mapping[str, Any] | None = None, + binary_classification: bool = True, + ) -> None: + """Record losses and, when supplied, classification metrics for one epoch.""" + + if isinstance(train_loss, bool) or not isinstance(train_loss, (int, float)) or not math.isfinite(train_loss): + raise ValueError("train_loss must be a finite number") + if isinstance(validation_loss, bool) or not isinstance(validation_loss, (int, float)) or not math.isfinite(validation_loss): + raise ValueError("validation_loss must be a finite number") + if epoch_seconds is not None and ( + isinstance(epoch_seconds, bool) + or not isinstance(epoch_seconds, (int, float)) + or not math.isfinite(epoch_seconds) + or epoch_seconds < 0 + ): + raise ValueError("epoch_seconds must be a non-negative finite number") + train_values = ( + _classification_metrics(train_classification, binary=binary_classification) + if train_classification is not None + else None + ) + validation_values = ( + _classification_metrics(validation_classification, binary=binary_classification) + if validation_classification is not None + else None + ) if self._run is None: return - self._run.log( - {"train/loss": float(train_loss), "validation/loss": float(validation_loss)}, - step=int(epoch), - ) + values: dict[str, float] = { + "train/loss": float(train_loss), + "validation/loss": float(validation_loss), + } + if epoch_seconds is not None: + values["epoch/seconds"] = float(epoch_seconds) + values["epoch"] = int(epoch) + if train_values is not None: + values.update({f"train/{key}": metric for key, metric in train_values.items()}) + if validation_values is not None: + values.update({f"validation/{key}": metric for key, metric in validation_values.items()}) + with _temporary_wandb_environment(self.artifacts_path): + self._run.log(values) + + def log_step_loss(self, split: str, step: int, loss: float) -> None: + """Record one finite batch loss on a split-specific step axis.""" + + if split not in {"train", "validation"}: + raise ValueError("split must be train or validation") + if isinstance(step, bool) or not isinstance(step, int) or step < 1: + raise ValueError("step must be a positive integer") + if isinstance(loss, bool) or not isinstance(loss, (int, float)) or not math.isfinite(loss): + raise ValueError("loss must be a finite number") + if self._run is None: + return + with _temporary_wandb_environment(self.artifacts_path): + self._run.log( + { + f"{split}/step": step, + f"{split}/step_loss": float(loss), + } + ) def finish( self, @@ -206,6 +425,8 @@ def finish( best_loss: float | None, completed_epochs: int, num_parameters: int, + classification: Mapping[str, Any] | None = None, + training_seconds: float | None = None, ) -> None: """Publish final metrics, explicitly finish the SDK run, and write its manifest.""" @@ -214,14 +435,55 @@ def finish( if self._run is None: self._finished = True return + final_values = None + if classification is not None: + final_values = _classification_final(classification) + if training_seconds is not None and ( + isinstance(training_seconds, bool) + or not isinstance(training_seconds, (int, float)) + or not math.isfinite(training_seconds) + or training_seconds < 0 + ): + raise ValueError("training_seconds must be a non-negative finite number") normalized_best = best_loss if best_loss is not None and math.isfinite(best_loss) else None try: _set_summary(self._run, "best_loss", normalized_best) _set_summary(self._run, "completed_epochs", int(completed_epochs)) _set_summary(self._run, "num_parameters", int(num_parameters)) + if final_values is not None: + metrics, matrix, _actual, _predicted, labels, count, _binary = final_values + final_log = {f"test/{key}": value for key, value in metrics.items()} + final_log["test/loss"] = float(classification["loss"]) + final_log["test/examples"] = count + if training_seconds is not None: + final_log["training/seconds"] = float(training_seconds) + image_class = getattr(self._sdk, "Image", None) + if image_class is None: + raise RuntimeError("W&B SDK does not provide image logging") + image_path = self.artifacts_path / "wandb" / "confusion-matrix.png" + _write_confusion_matrix_image(image_path, matrix, labels) + final_log["test/confusion_matrix"] = image_class( + str(image_path), + caption="Confusion matrix — rows: actual, columns: predicted", + ) + for key, value in final_log.items(): + if key != "test/confusion_matrix": + _set_summary(self._run, key, value) + _set_summary(self._run, "test/confusion_matrix_values", matrix) + _set_summary( + self._run, + "confusion_matrix_convention", + f"rows=actual, columns=predicted; labels={labels}", + ) + _set_summary(self._run, "confusion_matrix_labels", labels) + with _temporary_wandb_environment(self.artifacts_path): + self._run.log(final_log) finally: - self._run.finish() - self._finished = True + try: + with _temporary_wandb_environment(self.artifacts_path): + self._run.finish() + finally: + self._finished = True self._write_manifest() def abort(self) -> None: @@ -229,7 +491,8 @@ def abort(self) -> None: if self._run is not None and not self._finished: try: - self._run.finish(exit_code=1) + with _temporary_wandb_environment(self.artifacts_path): + self._run.finish(exit_code=1) finally: self._finished = True diff --git a/converted/uv.lock b/converted/uv.lock index af30416f..4c55f0ab 100644 --- a/converted/uv.lock +++ b/converted/uv.lock @@ -410,6 +410,7 @@ dependencies = [ { name = "fastapi" }, { name = "httpx" }, { name = "numpy" }, + { name = "pillow" }, { name = "pytest" }, { name = "safetensors" }, { name = "torch" }, @@ -423,6 +424,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.115.0" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "numpy", specifier = ">=1.24.0" }, + { name = "pillow", specifier = ">=11.0.0" }, { name = "pytest", specifier = ">=9.0.0" }, { name = "safetensors", specifier = ">=0.7.0" }, { name = "torch", specifier = ">=2.1.0", index = "https://download.pytorch.org/whl/cu124" }, @@ -661,6 +663,77 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, +] + [[package]] name = "platformdirs" version = "4.11.7" diff --git a/docs/knowledge/architecture/remote-training.md b/docs/knowledge/architecture/remote-training.md index 1df74df2..5db76043 100644 --- a/docs/knowledge/architecture/remote-training.md +++ b/docs/knowledge/architecture/remote-training.md @@ -1,7 +1,7 @@ --- kind: knowledge status: current -updated: 2026-09-07 +updated: 2026-09-08 --- # Remote-training architecture @@ -116,6 +116,19 @@ from the engine command, RPC request, job document, mounts, logs, artifacts, events and HTTP responses. An online SDK failure fails the job; it never falls back to offline or disabled. +The proxy allowlist is destination-based and is not limited to the configured +W&B API host. In the currently verified W&B Cloud flow, API traffic uses +`api.wandb.ai` while run-file upload may use `storage.googleapis.com`. +Operators must derive any additional destination from sanitized proxy or SDK +logs, add only the required host, and retain default-deny behavior for all +other egress. These observed Cloud endpoints are operational evidence, not a +permanent exhaustive vendor contract. + +A fresh worker heartbeat plus repeated upload messages means finalization may +still be active. Optional SDK telemetry timeouts do not by themselves make a +run unsuccessful; the terminal backend state and the structured W&B and model +package manifests remain the acceptance boundary. + The worker records `train/loss` and `validation/loss` per epoch and final best loss, completed epochs and parameter count. It derives a new run name from the immutable job ID and atomically publishes `wandb-run.json`. Backend status and diff --git a/docs/knowledge/decisions/project-owned-datasets.md b/docs/knowledge/decisions/project-owned-datasets.md index 241581d8..682636e5 100644 --- a/docs/knowledge/decisions/project-owned-datasets.md +++ b/docs/knowledge/decisions/project-owned-datasets.md @@ -1,7 +1,7 @@ --- kind: decision status: accepted -updated: 2026-09-07 +updated: 2026-09-08 --- # Project-owned datasets and named training batches @@ -159,6 +159,12 @@ registry or Python import target to discover. - The backend stores the immutable archive by digest under the authenticated connection and returns an opaque dataset reference. Jobs persist the exact dataset ID, version, digest and normalized parameters. +- Authorization of an opaque dataset reference is connection-scoped. Session + renewal preserves that identity, but revocation or replacing the pairing does + not transfer its dataset ACL. The browser must republish the same project + dataset under the new connection before submitting a job; the backend may + deduplicate identical bytes while granting the new owner access, but neither + side may fabricate or rewrite the content digest. - The controller mounts the resolved dataset archive read-only inside the same least-privilege worker used for browser-supplied package Python. The worker's fixed loader imports the declared dataset entrypoint only inside that diff --git a/docs/knowledge/operations/local-stack.md b/docs/knowledge/operations/local-stack.md index ed5d6c9b..855977cc 100644 --- a/docs/knowledge/operations/local-stack.md +++ b/docs/knowledge/operations/local-stack.md @@ -1,7 +1,7 @@ --- kind: knowledge status: current -updated: 2026-08-12 +updated: 2026-09-09 --- # Local development stack @@ -26,8 +26,62 @@ helper. Direct browser work follows `.agents/skills/chrome-direct/SKILL.md`. From `converted/`, run persistent Valkey and FastAPI with `PYTHONPATH=src` and `NNM_VALKEY_URL` pointing to the selected local Valkey database. Deployment -configuration is under `converted/backend/`; Docker Compose is the supported -container path. +configuration is under `converted/backend/`; the persistent container path is +`converted/backend/docker-compose.yml`. Rootless Podman is the standard local +engine, so use `podman compose` on this host. Docker Compose remains a +compatible alternative only when the selected engine and socket configuration +are explicitly adjusted. + +### Persistent backend Compose + +The standard repository-root entrypoint is: + +```bash +just --justfile converted/backend/justfile compose +``` + +It builds the worker first, derives an immutable digest reference, starts the +rootless Podman socket, and starts the `valkey`, `controller`, and `backend` +services in the `nnm-backend` Compose project. The backend service uses +`--unsafe-unlimited-dataset-size`; file-count and archive-safety checks still +apply. The controller is the only service mounted with the host Podman socket; +FastAPI reaches it through the authenticated `controller-socket` volume. + +The Compose file deliberately binds the host job, dataset, data, Valkey and +secret paths because worker containers are created by host Podman and must see +the same paths. Tokens and W&B credentials live under the owner-only +`converted/backend-secrets/` directory, separate from `valkey-data/`; the +Valkey image may change ownership of its data directory during startup. +`NNM_CONTAINER_IMAGE` is required and must be a digest-pinned worker image. The +standard `just compose` recipe supplies the required +`NNM_HOST_VALKEY_DATA`, `NNM_HOST_JOBS`, `NNM_HOST_DATA`, +`NNM_HOST_ADMIN_TOKEN`, and `NNM_HOST_CONTROLLER_TOKEN` values. + +The rootless Podman deployment uses `userns_mode: keep-id` so the non-root +controller process retains access to the invoking user's socket and owner-only +secret files. SELinux labeling is disabled only for the trusted controller +container that holds the host-administrator socket capability. Its healthcheck +requires both the authenticated controller socket and a successful Podman API +request; a present controller socket alone is not considered healthy. + +For non-standard paths, dataset limits, Origins, engine settings, or W&B +settings, the backend administrator should invoke `podman compose` directly +with explicit `NNM_*` variables rather than using the standard `just compose` +wrapper. Validate expansion before a start: + +```bash +podman compose --project-name nnm-backend \ + -f converted/backend/docker-compose.yml config +podman compose --project-name nnm-backend \ + -f converted/backend/docker-compose.yml up --build -d +``` + +The W&B network and allowlisting proxy are operator-managed external +dependencies; Compose only passes their configured name and URL to the +controller. The W&B credential file is mounted read-only into the controller +and must remain owner-only. Never put tokens or API keys in the Compose file, +command line, or logs. Stop the deployment with `podman compose ... down` +without `--volumes`, because persistent Valkey and job state are host-mounted. Important configuration boundaries include: diff --git a/docs/plans/active/wandb-integration/plan.md b/docs/plans/active/wandb-integration/plan.md index 36f39394..ab64b240 100644 --- a/docs/plans/active/wandb-integration/plan.md +++ b/docs/plans/active/wandb-integration/plan.md @@ -2,7 +2,7 @@ id: wandb-integration kind: plan status: in_progress -updated: 2026-09-07 +updated: 2026-09-08 areas: - architecture - backend @@ -47,6 +47,9 @@ historical evidence rather than an executable implementation. offline-run downloads; - removal of the worker rejection, log-scraping URL discovery, and any reachable W&B/Hydra/Lightning compatibility branch found during implementation. +- classification-only W&B observability that matches the standalone + `examples/pytorch/transformer.spam.py` metrics, test report, confusion matrix, + and elapsed training time while retaining loss-only logging for every dataset. ## Non-goals @@ -57,6 +60,7 @@ historical evidence rather than an executable implementation. the model wheel as a W&B Artifact; - retaining a second Lightning, Hydra, or NNTree logging implementation; - deleting archived documentation merely because it describes historical code. +- classification telemetry for a dataset that does not declare `classes`. ## Decisions and invariants @@ -82,6 +86,23 @@ historical evidence rather than an executable implementation. configuration and per-epoch `train/loss` and `validation/loss`, plus final best loss, completed epochs, and parameter count. W&B failures propagate and fail an explicitly online job. +- A dataset with `classes` is a classification dataset. The worker derives one + classification target only when the declared batch has exactly one target + slot and it is either integral `[B]` class indices or numeric `[B, C]` + class scores, where `C == classes.count`; the latter is reduced with + `argmax`. Other class metadata/target combinations fail before the epoch + loop rather than guessing a target. Prediction logits must be `[B, C]` and + are reduced with `argmax`; this is validation of the existing package + prediction program, not loss or package-ID inference. +- Every training mode keeps the current epoch loss curves. For a classification + dataset, W&B additionally receives per-epoch train/validation accuracy, + precision, recall, F1, specificity (binary only), macro precision, macro + recall and macro F1. It receives final test values for the same applicable + metrics, test example count, `training/seconds`, and a labelled W&B confusion + matrix. The raw integer matrix and its rows=actual/columns=predicted + convention are retained in the run summary. Binary precision/recall/F1 use + class index 1, matching the transformer example; multiclass runs expose the + macro metrics and matrix without inventing a positive class. - The worker atomically writes `wandb-run.json`. Backend status and SSE consume that structured manifest; stdout/stderr URL scraping is removed. - Offline W&B files live below the existing per-job artifact directory. Their @@ -143,11 +164,13 @@ and log-regex discovery are removed rather than retained as a second variant. | [T01](tasks/T01-worker-and-credentials.md) | backend | — | T02, T03 | worker, credential module, dependency and focused tests | One worker tracker and safe administrator credential lifecycle | | [T02](tasks/T02-controller-and-api.md) | operations | — | T01, T03 | controller, executor, backend API/manager and focused tests | Controlled online capability and structured backend lifecycle | | [T03](tasks/T03-frontend.md) | frontend | — | T01, T02 | training frontend and focused tests | Typed connection-aware W&B UI and downloads | -| [T04](tasks/T04-integration-and-verification.md) | integration | T01, T02, T03 | — | knowledge, operations docs, plan state | Integrated real-path proof and durable documentation | +| [T05](tasks/T05-classification-tracker.md) | backend | T01 | — | W&B tracker and focused tests | Classification-aware tracker API with no effect on loss-only runs | +| [T06](tasks/T06-classification-worker.md) | backend | T05 | — | package worker and focused tests | Data-driven classification metrics, test evaluation, and timing | +| [T04](tasks/T04-integration-and-verification.md) | integration | T01, T02, T03, T05, T06 | — | knowledge, operations docs, plan state | Integrated real-path proof and durable documentation | -The three implementation tasks may run concurrently because this plan freezes -their shared JSON, CLI, worker-stdin, status, event, and download contracts and -their write scopes do not overlap. +T05 and T06 are intentionally sequential: the worker consumes the narrow +classification-tracking API established by T05. They do not alter the already +completed controller or frontend write scopes. ## Integration and review gates @@ -161,6 +184,11 @@ their write scopes do not overlap. digest-verified by the browser. - Dependency and content searches must find no reachable W&B logger other than the package-native tracker and no remaining `wandb_url` log-scraping path. +- A dataset without `classes` must retain exactly its present loss logging and + must neither execute a prediction pass nor emit classification keys. +- Classification derives only from dataset metadata, target tensor contracts and + the explicit prediction program; it must not inspect objective Python, + output-shape heuristics for loss selection, or package identifiers. ## Acceptance criteria @@ -179,6 +207,9 @@ their write scopes do not overlap. - [ ] No wheel or dataset is uploaded as a W&B Artifact. - [ ] The regex URL detector and every reachable deprecated W&B implementation are removed. +- [ ] A classified run shows transformer-parity epoch metrics, a final test + report, labelled confusion matrix and elapsed seconds; a non-classified + run still shows its loss curves only. - [ ] Existing backend fast tests, frontend checks, package-only guard, and real user-facing offline path pass. @@ -193,11 +224,14 @@ pnpm --dir front-end guard:package-only git diff --check ``` -Exercise one offline training job through the live editor/backend/controller, -download the resulting archive through the UI, verify its response digest, and -confirm it contains a W&B `.wandb` run file. Exercise online capability rejection -without credentials. A real Cloud/self-hosted online smoke test is conditional -on an administrator-provided test credential and configured egress; never add a +Exercise one offline classification training job through the live +editor/backend/controller, download the resulting archive through the UI, +verify its response digest, and confirm it contains a W&B `.wandb` run file, +the loss curves, classification metrics, test report, confusion matrix and +elapsed seconds. Exercise an offline non-classification job to prove that it +emits loss only. Exercise online capability rejection without credentials. A +real Cloud/self-hosted online smoke test is conditional on an +administrator-provided test credential and configured egress; never add a credential to repository fixtures. ## Knowledge and archive impact diff --git a/docs/plans/active/wandb-integration/tasks/T04-integration-and-verification.md b/docs/plans/active/wandb-integration/tasks/T04-integration-and-verification.md index c6229374..18895bae 100644 --- a/docs/plans/active/wandb-integration/tasks/T04-integration-and-verification.md +++ b/docs/plans/active/wandb-integration/tasks/T04-integration-and-verification.md @@ -4,7 +4,7 @@ kind: task status: in_progress plan: ../plan.md role: integration -depends_on: [T01, T02, T03] +depends_on: [T01, T02, T03, T05, T06] parallel_with: [] write_scope: - docs/knowledge/architecture/remote-training.md @@ -41,7 +41,7 @@ must remain within the owning task's declared scope. ## Out of scope -- New logging features, W&B Artifacts, sweeps, resume, per-user accounts, and +- Logging features outside the approved classification telemetry, W&B Artifacts, sweeps, resume, per-user accounts, and deployment of an egress proxy. ## Work diff --git a/docs/plans/active/wandb-integration/tasks/T05-classification-tracker.md b/docs/plans/active/wandb-integration/tasks/T05-classification-tracker.md new file mode 100644 index 00000000..7c49dada --- /dev/null +++ b/docs/plans/active/wandb-integration/tasks/T05-classification-tracker.md @@ -0,0 +1,63 @@ +--- +id: T05 +kind: task +status: done +plan: ../plan.md +role: backend +depends_on: [T01] +parallel_with: [] +write_scope: + - converted/src/training/wandb_tracking.py + - converted/src/tests/test_wandb_tracking.py +--- + +# Add a classification-aware W&B tracker API + +## Objective + +Extend the package-worker-only tracker so it can record the established +classification metric set and final W&B confusion-matrix panel, while retaining +the exact loss-only path for non-classification and disabled runs. + +## Context required + +- Read the [initiative](../plan.md), the [remote-training architecture](../../../../knowledge/architecture/remote-training.md), and `wandb_tracking.py`. +- Treat the package worker as the source of metric values; this task must not + inspect batches, models, datasets, package IDs, or objectives. + +## Invariants + +- Disabled tracking must not import or initialize the W&B SDK. +- Offline and online credential, network, manifest and secret-redaction + contracts remain unchanged. +- Metric keys, class labels and confusion-matrix orientation are frozen by the + initiative; tracker inputs must be finite JSON/W&B-safe values. + +## Work + +1. Introduce narrow typed/value-validated tracker methods for epoch metrics and + final classification metrics, including a W&B native confusion-matrix plot. +2. Preserve `log_epoch` loss semantics for callers without classification data. +3. Store the final metrics, raw matrix, convention, labels and parameter count + in the W&B summary, without putting these into the backend run manifest. +4. Add fake-SDK regression tests for binary and multiclass logs, matrix labels, + disabled behavior and malformed metric rejection. + +## Acceptance criteria + +- [ ] The tracker emits the loss keys on every epoch and classification keys + only when explicitly supplied by the worker. +- [ ] The final classification payload produces one labelled W&B confusion + matrix and the documented summary values. +- [ ] Existing online/offline manifest and credential tests still pass. + +## Validation + +```bash +cd converted && uv run pytest src/tests/test_wandb_tracking.py -q +``` + +## Required handoff + +Return files changed, commands/results, any assumption about W&B SDK plotting, +and any knowledge document made inaccurate. diff --git a/docs/plans/active/wandb-integration/tasks/T06-classification-worker.md b/docs/plans/active/wandb-integration/tasks/T06-classification-worker.md new file mode 100644 index 00000000..6f40ecac --- /dev/null +++ b/docs/plans/active/wandb-integration/tasks/T06-classification-worker.md @@ -0,0 +1,73 @@ +--- +id: T06 +kind: task +status: done +plan: ../plan.md +role: backend +depends_on: [T05] +parallel_with: [] +write_scope: + - converted/src/package_worker.py + - converted/src/tests/test_package_worker.py +--- + +# Compute classification metrics in the package worker + +## Objective + +Use the selected dataset's existing `classes` metadata and explicit +`CompiledPrograms.prediction()` view to publish transformer-parity W&B +classification telemetry and elapsed time, while preserving loss-only training +for every other dataset. + +## Context required + +- Read the [initiative](../plan.md), [project-owned datasets](../../../../knowledge/decisions/project-owned-datasets.md), [prediction/objective programs](../../../../knowledge/decisions/prediction-objective-programs.md), `package_worker.py`, and `T05`. +- Inspect the standalone transformer's metric definitions as the product + reference, not as code to import or duplicate wholesale. + +## Invariants + +- `model.objective(inputs, targets)` remains the sole training loss path. +- `model.prediction(inputs)` is used only after classification metadata and + declared target/logit contracts have passed deterministic preflight checks. +- No metric detection may select a loss, package, output or target by ID, + display name, Python type or objective signature. +- The worker keeps its isolation, artifact, early-stopping and seed behavior. + +## Work + +1. Add a small internal classification specification/accumulator derived from + `DatasetDefinition.classes`, its sole target slot and the frozen shape/dtype + rules in the initiative. +2. During train and validation, calculate loss as now and collect predictions + from the prediction program; compute accuracy, binary and macro metrics plus + the integer confusion matrix without third-party metric dependencies. +3. Evaluate the test loader after the epoch loop only for classification, + measure whole-training elapsed seconds with a monotonic clock, and pass the + result to `T05`'s tracker API. +4. Keep `training-summary.json` useful by adding classification/test/timing + data only for classified jobs. +5. Cover binary one-hot and class-index targets, multiclass macro behavior, + invalid class contracts/logits, and the non-classification loss-only path. + +## Acceptance criteria + +- [ ] The transformer dataset's existing `classes` metadata yields its complete + epoch and final W&B metric set, including labels `ham` and `spam`. +- [ ] A dataset without `classes` never invokes `prediction()` for telemetry + and retains its current summary/history shape. +- [ ] Ambiguous or incompatible classification declarations fail before an + epoch is trained with a clear validation error. + +## Validation + +```bash +cd converted && uv run pytest src/tests/test_package_worker.py src/tests/test_wandb_tracking.py -q +cd converted && uv run pytest src/tests/ -m fast -q +``` + +## Required handoff + +Return files changed, commands/results, contract failures covered, and any +knowledge document made inaccurate. diff --git a/dump.rdb b/dump.rdb index f4bcd83a..27c50490 100644 Binary files a/dump.rdb and b/dump.rdb differ diff --git a/examples/diagrams/package/models/resnet/README.md b/examples/diagrams/package/models/resnet/README.md new file mode 100644 index 00000000..da7c77aa --- /dev/null +++ b/examples/diagrams/package/models/resnet/README.md @@ -0,0 +1,33 @@ +# ResNet MNIST project + +This project uses the MNIST handwritten-digit dataset for classification. The +local dataset files are intentionally ignored because they are generated data: + +- `datasets/resnet-mnist/data/train.jsonl`: 60,000 examples; +- `datasets/resnet-mnist/data/test.jsonl`: 10,000 examples. + +Each JSONL record contains an `image` array of 784 raw `uint8` pixels in row +major order (`28 x 28`) and an integer `label` from 0 to 9. + +## Obtaining the data + +MNIST is available from the original database page maintained by Yann LeCun: + + + +Download the four official IDX archives: + +```text +https://yann.lecun.com/exdb/mnist/train-images-idx3-ubyte.gz +https://yann.lecun.com/exdb/mnist/train-labels-idx1-ubyte.gz +https://yann.lecun.com/exdb/mnist/t10k-images-idx3-ubyte.gz +https://yann.lecun.com/exdb/mnist/t10k-labels-idx1-ubyte.gz +``` + +Convert the IDX archives to the two JSONL files above, keeping one image and +its label on each line. Do not commit either the downloaded archives or the +generated JSONL files. + +The checked-in `dataset.py` still refers to the former IDX `.gz` filenames. Its +JSONL reader must be updated before this project can train with the converted +files. diff --git a/examples/diagrams/package/models/resnet/datasets/resnet-mnist/.gitignore b/examples/diagrams/package/models/resnet/datasets/resnet-mnist/.gitignore new file mode 100644 index 00000000..6fc16a05 --- /dev/null +++ b/examples/diagrams/package/models/resnet/datasets/resnet-mnist/.gitignore @@ -0,0 +1,5 @@ +# MNIST source archives and generated JSONL remain local to the project. +data/*.gz +data/*.jsonl +__pycache__/ +*.pyc diff --git a/examples/diagrams/package/models/resnet/datasets/resnet-mnist/data/t10k-images-idx3-ubyte.gz b/examples/diagrams/package/models/resnet/datasets/resnet-mnist/data/t10k-images-idx3-ubyte.gz deleted file mode 100644 index 5ace8ea9..00000000 Binary files a/examples/diagrams/package/models/resnet/datasets/resnet-mnist/data/t10k-images-idx3-ubyte.gz and /dev/null differ diff --git a/examples/diagrams/package/models/resnet/datasets/resnet-mnist/data/t10k-labels-idx1-ubyte.gz b/examples/diagrams/package/models/resnet/datasets/resnet-mnist/data/t10k-labels-idx1-ubyte.gz deleted file mode 100644 index a7e14154..00000000 Binary files a/examples/diagrams/package/models/resnet/datasets/resnet-mnist/data/t10k-labels-idx1-ubyte.gz and /dev/null differ diff --git a/examples/diagrams/package/models/resnet/datasets/resnet-mnist/data/train-images-idx3-ubyte.gz b/examples/diagrams/package/models/resnet/datasets/resnet-mnist/data/train-images-idx3-ubyte.gz deleted file mode 100644 index b50e4b6b..00000000 Binary files a/examples/diagrams/package/models/resnet/datasets/resnet-mnist/data/train-images-idx3-ubyte.gz and /dev/null differ diff --git a/examples/diagrams/package/models/resnet/datasets/resnet-mnist/data/train-labels-idx1-ubyte.gz b/examples/diagrams/package/models/resnet/datasets/resnet-mnist/data/train-labels-idx1-ubyte.gz deleted file mode 100644 index 707a576b..00000000 Binary files a/examples/diagrams/package/models/resnet/datasets/resnet-mnist/data/train-labels-idx1-ubyte.gz and /dev/null differ diff --git a/examples/diagrams/package/models/resnet/datasets/resnet-mnist/dataset.py b/examples/diagrams/package/models/resnet/datasets/resnet-mnist/dataset.py index e4e9bee2..9eb22737 100644 --- a/examples/diagrams/package/models/resnet/datasets/resnet-mnist/dataset.py +++ b/examples/diagrams/package/models/resnet/datasets/resnet-mnist/dataset.py @@ -3,9 +3,8 @@ from __future__ import annotations from collections.abc import Mapping -import gzip +import json from pathlib import Path -import struct from typing import Any import torch @@ -34,33 +33,38 @@ def _validate_parameters(parameters: Mapping[str, object]) -> dict[str, object]: return values -def _read_idx_images(path: Path) -> torch.Tensor: - with gzip.open(path, "rb") as archive: - payload = archive.read() - if len(payload) < 16: - raise ValueError(f"invalid IDX image archive: {path.name}") - magic, count, rows, columns = struct.unpack(">IIII", payload[:16]) - if magic != 2051 or rows != 28 or columns != 28: - raise ValueError(f"unsupported IDX image archive: {path.name}") - expected = count * rows * columns - if len(payload) != 16 + expected: - raise ValueError(f"truncated IDX image archive: {path.name}") - raw = bytearray(payload[16:]) - images = torch.frombuffer(raw, dtype=torch.uint8).clone().reshape(count, rows, columns) - return images.to(dtype=torch.float32).div_(255.0).sub_(MEAN).div_(STD).unsqueeze(1) - - -def _read_idx_labels(path: Path) -> torch.Tensor: - with gzip.open(path, "rb") as archive: - payload = archive.read() - if len(payload) < 8: - raise ValueError(f"invalid IDX label archive: {path.name}") - magic, count = struct.unpack(">II", payload[:8]) - if magic != 2049: - raise ValueError(f"unsupported IDX label archive: {path.name}") - if len(payload) != 8 + count: - raise ValueError(f"truncated IDX label archive: {path.name}") - return torch.frombuffer(bytearray(payload[8:]), dtype=torch.uint8).clone().to(dtype=torch.int64) +def _read_jsonl(path: Path) -> tuple[torch.Tensor, torch.Tensor]: + image_bytes = bytearray() + labels: list[int] = [] + with path.open(encoding="utf-8") as stream: + for line_number, line in enumerate(stream, start=1): + if not line.strip(): + continue + try: + record = json.loads(line) + except json.JSONDecodeError as exc: + raise ValueError(f"invalid JSON in {path.name} at line {line_number}") from exc + if not isinstance(record, Mapping): + raise TypeError(f"record in {path.name} at line {line_number} must be an object") + + image = record.get("image") + if ( + not isinstance(image, list) + or len(image) != 28 * 28 + or any(isinstance(pixel, bool) or not isinstance(pixel, int) or not 0 <= pixel <= 255 for pixel in image) + ): + raise ValueError(f"record in {path.name} at line {line_number} has an invalid 28x28 image") + label = record.get("label") + if isinstance(label, bool) or not isinstance(label, int) or not 0 <= label <= 9: + raise ValueError(f"record in {path.name} at line {line_number} has an invalid label") + image_bytes.extend(image) + labels.append(label) + + if not image_bytes: + raise ValueError(f"MNIST JSONL is empty: {path.name}") + image_tensor = torch.frombuffer(image_bytes, dtype=torch.uint8).clone().reshape(-1, 28, 28) + normalized = image_tensor.to(dtype=torch.float32).div_(255.0).sub_(MEAN).div_(STD).unsqueeze(1) + return normalized, torch.tensor(labels, dtype=torch.int64) class _ClassificationDataset(Dataset[dict[str, dict[str, torch.Tensor]]]): @@ -85,10 +89,8 @@ class ResNetMNIST: def __init__(self, resource_root: Path, batch_size: int, num_workers: int, train_size: float) -> None: data_root = Path(resource_root) / "data" - train_images = _read_idx_images(data_root / "train-images-idx3-ubyte.gz") - train_labels = _read_idx_labels(data_root / "train-labels-idx1-ubyte.gz") - test_images = _read_idx_images(data_root / "t10k-images-idx3-ubyte.gz") - test_labels = _read_idx_labels(data_root / "t10k-labels-idx1-ubyte.gz") + train_images, train_labels = _read_jsonl(data_root / "train.jsonl") + test_images, test_labels = _read_jsonl(data_root / "test.jsonl") self.train_dataset = _ClassificationDataset(train_images, train_labels) self.test_dataset = _ClassificationDataset(test_images, test_labels) self.batch_size = batch_size diff --git a/examples/diagrams/package/models/transformer.spam/README.md b/examples/diagrams/package/models/transformer.spam/README.md new file mode 100644 index 00000000..501f1104 --- /dev/null +++ b/examples/diagrams/package/models/transformer.spam/README.md @@ -0,0 +1,27 @@ +# Transformer Spam project + +This project uses the prepared Spam-Identification dataset in JSONL form. The +local files are intentionally ignored because they contain the downloaded +dataset: + +- `datasets/spamham/data/train.jsonl`: 31,716 examples; +- `datasets/spamham/data/test.jsonl`: 2,000 examples. + +Each line contains the message text and its binary label (`0` = `ham`, `1` = +`spam`), together with the source record metadata used by the adapter. + +## Obtaining the data + +The prepared source dataset is: + + + +Export its `train` and `test` splits as UTF-8 JSONL while preserving the fields +used by `datasets/spamham/dataset.py`: `text`, `label` or `label_text`, and +optionally `subject`, `message`, `message_id` and `date`. Place the resulting +files in `datasets/spamham/data/` with the names shown above. + +The records are an Enron spam/ham classification corpus. The underlying public +Enron Email Dataset is documented by Carnegie Mellon University at +. Check both sources for their applicable data +and privacy terms before redistributing the messages. diff --git a/examples/diagrams/package/models/variational-autoencoder/README.md b/examples/diagrams/package/models/variational-autoencoder/README.md new file mode 100644 index 00000000..936b2c5c --- /dev/null +++ b/examples/diagrams/package/models/variational-autoencoder/README.md @@ -0,0 +1,31 @@ +# Variational autoencoder MNIST project + +This project uses MNIST handwritten digits for image reconstruction. The local +dataset files are intentionally ignored because they are generated data: + +- `datasets/autoencoder-mnist/data/train.jsonl`: 60,000 examples; +- `datasets/autoencoder-mnist/data/test.jsonl`: 10,000 examples. + +Each JSONL record contains an `image` array of 784 raw `uint8` pixels in row +major order (`28 x 28`). The dataset uses the same image as input and target. + +## Obtaining the data + +MNIST is available from the original database page maintained by Yann LeCun: + + + +Download the official image archives: + +```text +https://yann.lecun.com/exdb/mnist/train-images-idx3-ubyte.gz +https://yann.lecun.com/exdb/mnist/t10k-images-idx3-ubyte.gz +``` + +Convert the IDX archives to `train.jsonl` and `test.jsonl`, writing one image +per line. Do not commit either the downloaded archives or the generated JSONL +files. + +The checked-in `dataset.py` still refers to the former IDX `.gz` filenames. Its +JSONL reader must be updated before this project can train with the converted +files. diff --git a/examples/diagrams/package/models/variational-autoencoder/datasets/autoencoder-mnist/.gitignore b/examples/diagrams/package/models/variational-autoencoder/datasets/autoencoder-mnist/.gitignore new file mode 100644 index 00000000..6fc16a05 --- /dev/null +++ b/examples/diagrams/package/models/variational-autoencoder/datasets/autoencoder-mnist/.gitignore @@ -0,0 +1,5 @@ +# MNIST source archives and generated JSONL remain local to the project. +data/*.gz +data/*.jsonl +__pycache__/ +*.pyc diff --git a/examples/diagrams/package/models/variational-autoencoder/datasets/autoencoder-mnist/data/t10k-images-idx3-ubyte.gz b/examples/diagrams/package/models/variational-autoencoder/datasets/autoencoder-mnist/data/t10k-images-idx3-ubyte.gz deleted file mode 100644 index 5ace8ea9..00000000 Binary files a/examples/diagrams/package/models/variational-autoencoder/datasets/autoencoder-mnist/data/t10k-images-idx3-ubyte.gz and /dev/null differ diff --git a/examples/diagrams/package/models/variational-autoencoder/datasets/autoencoder-mnist/data/train-images-idx3-ubyte.gz b/examples/diagrams/package/models/variational-autoencoder/datasets/autoencoder-mnist/data/train-images-idx3-ubyte.gz deleted file mode 100644 index b50e4b6b..00000000 Binary files a/examples/diagrams/package/models/variational-autoencoder/datasets/autoencoder-mnist/data/train-images-idx3-ubyte.gz and /dev/null differ diff --git a/examples/diagrams/package/models/variational-autoencoder/datasets/autoencoder-mnist/dataset.py b/examples/diagrams/package/models/variational-autoencoder/datasets/autoencoder-mnist/dataset.py index 2b867177..93e1db92 100644 --- a/examples/diagrams/package/models/variational-autoencoder/datasets/autoencoder-mnist/dataset.py +++ b/examples/diagrams/package/models/variational-autoencoder/datasets/autoencoder-mnist/dataset.py @@ -3,9 +3,8 @@ from __future__ import annotations from collections.abc import Mapping -import gzip +import json from pathlib import Path -import struct from typing import Any import torch @@ -34,21 +33,31 @@ def _validate_parameters(parameters: Mapping[str, object]) -> dict[str, object]: return values -def _read_idx_images(path: Path) -> torch.Tensor: - with gzip.open(path, "rb") as archive: - payload = archive.read() - if len(payload) < 16: - raise ValueError(f"invalid IDX image archive: {path.name}") - magic, count, rows, columns = struct.unpack(">IIII", payload[:16]) - if magic != 2051 or rows != 28 or columns != 28: - raise ValueError(f"unsupported IDX image archive: {path.name}") - expected = count * rows * columns - if len(payload) != 16 + expected: - raise ValueError(f"truncated IDX image archive: {path.name}") - - raw = bytearray(payload[16:]) - images = torch.frombuffer(raw, dtype=torch.uint8).clone().reshape(count, rows, columns) - return images.to(dtype=torch.float32).div_(255.0).sub_(MEAN).div_(STD).unsqueeze(1) +def _read_jsonl_images(path: Path) -> torch.Tensor: + image_bytes = bytearray() + with path.open(encoding="utf-8") as stream: + for line_number, line in enumerate(stream, start=1): + if not line.strip(): + continue + try: + record = json.loads(line) + except json.JSONDecodeError as exc: + raise ValueError(f"invalid JSON in {path.name} at line {line_number}") from exc + if not isinstance(record, Mapping): + raise TypeError(f"record in {path.name} at line {line_number} must be an object") + image = record.get("image") + if ( + not isinstance(image, list) + or len(image) != 28 * 28 + or any(isinstance(pixel, bool) or not isinstance(pixel, int) or not 0 <= pixel <= 255 for pixel in image) + ): + raise ValueError(f"record in {path.name} at line {line_number} has an invalid 28x28 image") + image_bytes.extend(image) + + if not image_bytes: + raise ValueError(f"MNIST JSONL is empty: {path.name}") + image_tensor = torch.frombuffer(image_bytes, dtype=torch.uint8).clone().reshape(-1, 28, 28) + return image_tensor.to(dtype=torch.float32).div_(255.0).sub_(MEAN).div_(STD).unsqueeze(1) class _ImageDataset(Dataset[dict[str, dict[str, torch.Tensor]]]): @@ -68,8 +77,8 @@ class AutoencoderMNIST(Dataset[dict[str, dict[str, torch.Tensor]]]): def __init__(self, resource_root: Path, batch_size: int, num_workers: int, train_size: float) -> None: data_root = Path(resource_root) / "data" - self.train_dataset = _ImageDataset(_read_idx_images(data_root / "train-images-idx3-ubyte.gz")) - self.test_dataset = _ImageDataset(_read_idx_images(data_root / "t10k-images-idx3-ubyte.gz")) + self.train_dataset = _ImageDataset(_read_jsonl_images(data_root / "train.jsonl")) + self.test_dataset = _ImageDataset(_read_jsonl_images(data_root / "test.jsonl")) self.batch_size = batch_size self.num_workers = num_workers self.train_size = train_size diff --git a/examples/pytorch/transformer.spam.py/.gitignore b/examples/pytorch/transformer.spam.py/.gitignore new file mode 100644 index 00000000..786e8cf6 --- /dev/null +++ b/examples/pytorch/transformer.spam.py/.gitignore @@ -0,0 +1,10 @@ +.venv/ +__pycache__/ +*.py[cod] +wandb/ + +# The Enron files are intentionally local and are not committed. +data/*.jsonl +data/*.csv +data/*.parquet +data/*.pt diff --git a/examples/pytorch/transformer.spam.py/.python-version b/examples/pytorch/transformer.spam.py/.python-version new file mode 100644 index 00000000..e4fba218 --- /dev/null +++ b/examples/pytorch/transformer.spam.py/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/examples/pytorch/transformer.spam.py/README.md b/examples/pytorch/transformer.spam.py/README.md new file mode 100644 index 00000000..31f83e83 --- /dev/null +++ b/examples/pytorch/transformer.spam.py/README.md @@ -0,0 +1,37 @@ +# Standalone PyTorch `transformer.spam` + +This directory is independent from NNModelling at runtime. It reimplements the +`transformer.spam` graph with native PyTorch modules: + +- vocabulary-limited embedding `128 -> 128` and sinusoidal positional encoding; +- two Transformer encoder blocks, four attention heads, feed-forward `128 -> + 512 -> 128`; +- mean token pooling and a `128 -> 2` classifier trained with cross entropy. + +The dataset adapter is a standalone copy of the SpamHam adapter. It expects the +prepared local files `data/train.jsonl` and `data/test.jsonl` below the +NNModelling dataset directory. The default path points there automatically; +use `--dataset-root` for another copy. + +Install/lock the project with uv: + +```bash +uv sync +``` + +For W&B cloud logging, authenticate first with `wandb login`, then run: + +```bash +uv run python main.py --epochs 5 --wandb-project transformer-spam +``` + +Each epoch logs train and validation loss, accuracy, precision, recall, F1, +specificity and macro metrics. The final test pass logs the same metrics, test +loss and a W&B confusion-matrix panel. The test split is evaluated only after +training finishes. + +For a local run without cloud access: + +```bash +WANDB_MODE=offline uv run python main.py --epochs 1 --wandb-mode offline +``` diff --git a/examples/pytorch/transformer.spam.py/dataset.py b/examples/pytorch/transformer.spam.py/dataset.py new file mode 100644 index 00000000..016d594d --- /dev/null +++ b/examples/pytorch/transformer.spam.py/dataset.py @@ -0,0 +1,174 @@ +"""Self-contained SpamHam dataset adapter for the standalone PyTorch example.""" + +from __future__ import annotations + +from collections import Counter +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +import json +from pathlib import Path +import random +import re +from typing import Any + +import torch +from torch.utils.data import DataLoader, Dataset + + +SEQUENCE_LENGTH = 128 +VOCABULARY_SIZE = 128 +PAD_TOKEN = "" +UNKNOWN_TOKEN = "" +TOKEN_PATTERN = re.compile(r"\w+|[^\w\s]", re.UNICODE) + + +@dataclass(frozen=True) +class DatasetContext: + """Filesystem location containing ``data/train.jsonl`` and ``test.jsonl``.""" + + resource_root: Path + reference: Any | None = None + + +@dataclass(frozen=True) +class TextExample: + text: str + label: int + + +class EncodedTextDataset(Dataset[tuple[torch.Tensor, int]]): + def __init__(self, examples: Sequence[TextExample], vocabulary: Mapping[str, int]) -> None: + self._examples = list(examples) + self._vocabulary = vocabulary + + def __len__(self) -> int: + return len(self._examples) + + def __getitem__(self, index: int) -> tuple[torch.Tensor, int]: + example = self._examples[index] + token_ids = [ + self._vocabulary.get(token, self._vocabulary[UNKNOWN_TOKEN]) + for token in TOKEN_PATTERN.findall(example.text.casefold()) + ][:SEQUENCE_LENGTH] + token_ids.extend([self._vocabulary[PAD_TOKEN]] * (SEQUENCE_LENGTH - len(token_ids))) + return torch.tensor(token_ids, dtype=torch.int32), example.label + + +class ProjectDataset: + def __init__(self, splits: Mapping[str, DataLoader[Any]]) -> None: + self._splits = dict(splits) + + def division(self) -> Mapping[str, DataLoader[Any]]: + return self._splits + + +def _read_jsonl(path: Path) -> list[TextExample]: + if not path.is_file(): + raise FileNotFoundError( + f"missing {path.name}; prepare the local Enron files under {path.parent}" + ) + + examples: list[TextExample] = [] + with path.open(encoding="utf-8") as stream: + for line_number, line in enumerate(stream, start=1): + if not line.strip(): + continue + try: + record = json.loads(line) + except json.JSONDecodeError as exc: + raise ValueError(f"invalid JSON in {path.name} at line {line_number}") from exc + if not isinstance(record, Mapping): + raise TypeError(f"record in {path.name} at line {line_number} must be an object") + + text = record.get("text") + if not isinstance(text, str) or not text.strip(): + subject = record.get("subject", "") + message = record.get("message", "") + if not isinstance(subject, str) or not isinstance(message, str): + raise TypeError(f"record in {path.name} at line {line_number} has invalid text") + text = f"{subject} {message}".strip() + if not text: + continue + + label = record.get("label") + if isinstance(label, bool) or not isinstance(label, int): + label = {"ham": 0, "spam": 1}.get(record.get("label_text")) + if label not in (0, 1): + raise ValueError(f"record in {path.name} at line {line_number} has an invalid label") + examples.append(TextExample(text=text, label=label)) + + if not examples: + raise ValueError(f"Enron split is empty: {path.name}") + return examples + + +def _build_vocabulary(examples: Sequence[TextExample]) -> dict[str, int]: + counts = Counter( + token + for example in examples + for token in TOKEN_PATTERN.findall(example.text.casefold()) + ) + vocabulary = {PAD_TOKEN: 0, UNKNOWN_TOKEN: 1} + for token, _count in counts.most_common(VOCABULARY_SIZE - len(vocabulary)): + vocabulary[token] = len(vocabulary) + return vocabulary + + +def _split_validation(examples: Sequence[TextExample]) -> tuple[list[TextExample], list[TextExample]]: + randomizer = random.Random(42) + validation_indices: set[int] = set() + for label in (0, 1): + indices = [index for index, example in enumerate(examples) if example.label == label] + randomizer.shuffle(indices) + validation_indices.update(indices[: max(1, len(indices) // 10)]) + + train = [example for index, example in enumerate(examples) if index not in validation_indices] + validation = [example for index, example in enumerate(examples) if index in validation_indices] + if not train or not validation: + raise ValueError("Enron train split is too small to create train and validation divisions") + return train, validation + + +def _collate(examples: list[tuple[torch.Tensor, int]]) -> dict[str, dict[str, torch.Tensor]]: + features = torch.stack([item[0] for item in examples]).to(dtype=torch.int32) + labels = torch.tensor([item[1] for item in examples], dtype=torch.int64) + targets = torch.zeros((len(examples), 2), dtype=torch.float32) + targets.scatter_(1, labels.unsqueeze(1), 1.0) + return {"inputs": {"features": features}, "targets": {"target": targets}} + + +def _loader( + examples: Sequence[TextExample], + vocabulary: Mapping[str, int], + batch_size: int, + *, + shuffle: bool, +) -> DataLoader[Any]: + return DataLoader( + EncodedTextDataset(examples, vocabulary), + batch_size=batch_size, + shuffle=shuffle, + generator=torch.Generator().manual_seed(42) if shuffle else None, + collate_fn=_collate, + drop_last=shuffle, + num_workers=0, + ) + + +def build(parameters: Mapping[str, object], context: DatasetContext) -> ProjectDataset: + """Build deterministic train, validation and test loaders from local JSONL.""" + + batch_size = parameters.get("B") + if isinstance(batch_size, bool) or not isinstance(batch_size, int) or batch_size <= 0: + raise ValueError("dataset parameter B must be a positive integer") + + data_root = context.resource_root / "data" + train_examples = _read_jsonl(data_root / "train.jsonl") + test_examples = _read_jsonl(data_root / "test.jsonl") + train_examples, validation_examples = _split_validation(train_examples) + vocabulary = _build_vocabulary(train_examples) + return ProjectDataset({ + "train": _loader(train_examples, vocabulary, batch_size, shuffle=True), + "validation": _loader(validation_examples, vocabulary, batch_size, shuffle=False), + "test": _loader(test_examples, vocabulary, batch_size, shuffle=False), + }) diff --git a/examples/pytorch/transformer.spam.py/main.py b/examples/pytorch/transformer.spam.py/main.py new file mode 100644 index 00000000..d41a8086 --- /dev/null +++ b/examples/pytorch/transformer.spam.py/main.py @@ -0,0 +1,372 @@ +"""Train the standalone PyTorch reproduction of ``transformer.spam``. + +The model is independent from NNModelling at runtime. It mirrors the diagram +with native PyTorch modules and logs epoch metrics plus the final test report +to Weights & Biases. +""" + +from __future__ import annotations + +import argparse +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +import json +import os +from pathlib import Path +import random +import time +from typing import Any + +import torch +from torch import nn +from torch.utils.data import DataLoader +import wandb + +from dataset import DatasetContext, build + + +SEQUENCE_LENGTH = 128 +MODEL_DIMENSION = 128 +NUM_HEADS = 4 +NUM_LAYERS = 2 +FEED_FORWARD_DIMENSION = 512 +NUM_CLASSES = 2 +LABELS = ("ham", "spam") +CONFUSION_MATRIX_CONVENTION = "rows=actual, columns=predicted; labels=[ham, spam]" + + +class TransformerSpamClassifier(nn.Module): + """Embedding + 2 Transformer blocks + mean token pool + binary classifier.""" + + def __init__(self) -> None: + super().__init__() + self.embedding = nn.Embedding(128, MODEL_DIMENSION, dtype=torch.float32) + position = torch.arange(SEQUENCE_LENGTH, dtype=torch.float32).unsqueeze(1) + divisor = torch.exp( + torch.arange(0, MODEL_DIMENSION, 2, dtype=torch.float32) + * (-torch.log(torch.tensor(10000.0)) / MODEL_DIMENSION) + ) + positional_encoding = torch.zeros(SEQUENCE_LENGTH, MODEL_DIMENSION) + positional_encoding[:, 0::2] = torch.sin(position * divisor) + positional_encoding[:, 1::2] = torch.cos(position * divisor) + self.register_buffer("position", positional_encoding) + self.layers = nn.ModuleList([ + nn.TransformerEncoderLayer( + d_model=MODEL_DIMENSION, + nhead=NUM_HEADS, + dim_feedforward=FEED_FORWARD_DIMENSION, + dropout=0.0, + activation="relu", + batch_first=True, + norm_first=False, + ) + for _ in range(NUM_LAYERS) + ]) + self.classifier = nn.Linear(MODEL_DIMENSION, NUM_CLASSES) + + def forward(self, features: torch.Tensor) -> torch.Tensor: + if features.ndim != 2 or features.shape[1] != SEQUENCE_LENGTH: + raise ValueError(f"expected [batch, {SEQUENCE_LENGTH}], got {tuple(features.shape)}") + hidden = self.embedding(features.long()) + self.position.unsqueeze(0) + for layer in self.layers: + hidden = layer(hidden) + return self.classifier(hidden.mean(dim=1)) + + +@dataclass(frozen=True) +class Metrics: + loss: float + accuracy: float + precision: float + recall: float + f1: float + specificity: float + macro_precision: float + macro_recall: float + macro_f1: float + confusion_matrix: list[list[int]] + actual: list[int] + predicted: list[int] + count: int + + +def set_seed(seed: int) -> None: + random.seed(seed) + torch.manual_seed(seed) + if torch.cuda.is_available(): + torch.cuda.manual_seed_all(seed) + torch.backends.cudnn.deterministic = True + torch.backends.cudnn.benchmark = False + + +def _batches( + loader: Iterable[Mapping[str, Mapping[str, torch.Tensor]]], +) -> Iterable[tuple[torch.Tensor, torch.Tensor]]: + for batch in loader: + features = batch["inputs"]["features"] + labels = batch["targets"]["target"].argmax(dim=1) + yield features, labels + + +def _safe_divide(numerator: float, denominator: float) -> float: + return numerator / denominator if denominator else 0.0 + + +def _metrics( + loss_total: float, + count: int, + matrix: list[list[int]], + actual: list[int], + predicted: list[int], +) -> Metrics: + tn, fp = matrix[0] + fn, tp = matrix[1] + class_precision = [ + _safe_divide(tn, tn + fn), + _safe_divide(tp, tp + fp), + ] + class_recall = [ + _safe_divide(tn, tn + fp), + _safe_divide(tp, tp + fn), + ] + class_f1 = [ + _safe_divide(2 * class_precision[0] * class_recall[0], class_precision[0] + class_recall[0]), + _safe_divide(2 * class_precision[1] * class_recall[1], class_precision[1] + class_recall[1]), + ] + return Metrics( + loss=_safe_divide(loss_total, count), + accuracy=_safe_divide(tn + tp, count), + precision=class_precision[1], + recall=class_recall[1], + f1=class_f1[1], + specificity=_safe_divide(tn, tn + fp), + macro_precision=sum(class_precision) / 2, + macro_recall=sum(class_recall) / 2, + macro_f1=sum(class_f1) / 2, + confusion_matrix=matrix, + actual=actual, + predicted=predicted, + count=count, + ) + + +def _run_epoch( + model: nn.Module, + loader: DataLoader[Any], + criterion: nn.Module, + device: torch.device, + optimizer: torch.optim.Optimizer | None = None, +) -> Metrics: + training = optimizer is not None + model.train(training) + loss_total = 0.0 + count = 0 + matrix = [[0, 0], [0, 0]] + actual: list[int] = [] + predicted: list[int] = [] + context = torch.enable_grad() if training else torch.no_grad() + with context: + for features, labels in _batches(loader): + features, labels = features.to(device), labels.to(device) + if training: + optimizer.zero_grad(set_to_none=True) + logits = model(features) + loss = criterion(logits, labels) + if training: + loss.backward() + optimizer.step() + predictions = logits.argmax(dim=1) + batch_count = labels.numel() + loss_total += loss.item() * batch_count + count += batch_count + batch_actual = labels.detach().cpu().tolist() + batch_predicted = predictions.detach().cpu().tolist() + actual.extend(batch_actual) + predicted.extend(batch_predicted) + for expected, guess in zip(batch_actual, batch_predicted): + matrix[expected][guess] += 1 + if not count: + raise ValueError("the data split produced no complete batches; lower --batch-size") + return _metrics(loss_total, count, matrix, actual, predicted) + + +def _metric_log(prefix: str, metrics: Metrics) -> dict[str, float]: + return { + f"{prefix}/loss": metrics.loss, + f"{prefix}/accuracy": metrics.accuracy, + f"{prefix}/precision": metrics.precision, + f"{prefix}/recall": metrics.recall, + f"{prefix}/f1": metrics.f1, + f"{prefix}/specificity": metrics.specificity, + f"{prefix}/macro_precision": metrics.macro_precision, + f"{prefix}/macro_recall": metrics.macro_recall, + f"{prefix}/macro_f1": metrics.macro_f1, + } + + +def _default_dataset_root() -> Path: + return Path(__file__).resolve().parents[2] / "diagrams/package/models/transformer.spam/datasets/spamham" + + +def _positive_int(value: str) -> int: + parsed = int(value) + if parsed <= 0: + raise argparse.ArgumentTypeError("must be positive") + return parsed + + +def _positive_float(value: str) -> float: + parsed = float(value) + if parsed <= 0: + raise argparse.ArgumentTypeError("must be positive") + return parsed + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dataset-root", type=Path, default=_default_dataset_root()) + parser.add_argument("--epochs", type=_positive_int, default=5) + parser.add_argument("--batch-size", type=_positive_int, default=32) + parser.add_argument("--learning-rate", type=_positive_float, default=0.001) + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--device", choices=("auto", "cpu", "cuda"), default="auto") + parser.add_argument("--wandb-project", default=os.getenv("WANDB_PROJECT", "transformer-spam")) + parser.add_argument("--wandb-entity", default=os.getenv("WANDB_ENTITY")) + parser.add_argument("--wandb-run-name", default=None) + parser.add_argument( + "--wandb-mode", + choices=("online", "offline", "disabled"), + default=os.getenv("WANDB_MODE", "online"), + ) + parser.add_argument("--json", action="store_true", help="print the final report as JSON") + return parser.parse_args() + + +def _serializable_metrics(metrics: Metrics) -> dict[str, object]: + return { + "loss": metrics.loss, + "accuracy": metrics.accuracy, + "precision": metrics.precision, + "recall": metrics.recall, + "f1": metrics.f1, + "specificity": metrics.specificity, + "macro_precision": metrics.macro_precision, + "macro_recall": metrics.macro_recall, + "macro_f1": metrics.macro_f1, + "confusion_matrix": metrics.confusion_matrix, + "count": metrics.count, + } + + +def train(args: argparse.Namespace) -> dict[str, object]: + set_seed(args.seed) + if args.device == "cuda" and not torch.cuda.is_available(): + raise RuntimeError("--device cuda was requested but CUDA is unavailable") + device = torch.device( + "cuda" + if args.device == "cuda" or (args.device == "auto" and torch.cuda.is_available()) + else "cpu" + ) + project = build({"B": args.batch_size}, DatasetContext(args.dataset_root)) + divisions = project.division() + model = TransformerSpamClassifier().to(device) + criterion = nn.CrossEntropyLoss() + optimizer = torch.optim.Adam(model.parameters(), lr=args.learning_rate) + run = None + config = { + "architecture": "transformer.spam", + "sequence_length": SEQUENCE_LENGTH, + "model_dimension": MODEL_DIMENSION, + "num_heads": NUM_HEADS, + "num_layers": NUM_LAYERS, + "feed_forward_dimension": FEED_FORWARD_DIMENSION, + "epochs": args.epochs, + "batch_size": args.batch_size, + "learning_rate": args.learning_rate, + "seed": args.seed, + "device": str(device), + "labels": list(LABELS), + } + if args.wandb_mode != "disabled": + run = wandb.init( + project=args.wandb_project, + entity=args.wandb_entity, + name=args.wandb_run_name, + mode=args.wandb_mode, + config=config, + ) + + started = time.perf_counter() + last_train: Metrics | None = None + last_validation: Metrics | None = None + try: + for epoch in range(1, args.epochs + 1): + last_train = _run_epoch(model, divisions["train"], criterion, device, optimizer) + last_validation = _run_epoch(model, divisions["validation"], criterion, device) + epoch_log = { + "epoch": epoch, + **_metric_log("train", last_train), + **_metric_log("validation", last_validation), + } + if run is not None: + wandb.log(epoch_log) + print( + f"epoch {epoch:03d} | train loss {last_train.loss:.4f} | " + f"val loss {last_validation.loss:.4f} | val accuracy {last_validation.accuracy:.4f}" + ) + + assert last_train is not None and last_validation is not None + test = _run_epoch(model, divisions["test"], criterion, device) + elapsed = time.perf_counter() - started + final_log = { + "test/loss": test.loss, + "test/accuracy": test.accuracy, + "test/precision": test.precision, + "test/recall": test.recall, + "test/f1": test.f1, + "test/specificity": test.specificity, + "test/macro_precision": test.macro_precision, + "test/macro_recall": test.macro_recall, + "test/macro_f1": test.macro_f1, + "test/examples": test.count, + "training/seconds": elapsed, + } + if run is not None: + wandb.log({ + **final_log, + "test/confusion_matrix": wandb.plot.confusion_matrix( + probs=None, + y_true=test.actual, + preds=test.predicted, + class_names=list(LABELS), + ), + }) + run.summary.update({ + **final_log, + "test/confusion_matrix_values": test.confusion_matrix, + "confusion_matrix_convention": CONFUSION_MATRIX_CONVENTION, + "trainable_parameters": sum(p.numel() for p in model.parameters() if p.requires_grad), + }) + result = { + **config, + "train": _serializable_metrics(last_train), + "validation": _serializable_metrics(last_validation), + "test": _serializable_metrics(test), + "training_seconds": elapsed, + "trainable_parameters": sum(p.numel() for p in model.parameters() if p.requires_grad), + "confusion_matrix_convention": CONFUSION_MATRIX_CONVENTION, + } + if args.json: + print(json.dumps(result, sort_keys=True)) + return result + finally: + if run is not None: + wandb.finish() + + +def main() -> None: + train(parse_args()) + + +if __name__ == "__main__": + main() diff --git a/examples/pytorch/transformer.spam.py/pyproject.toml b/examples/pytorch/transformer.spam.py/pyproject.toml new file mode 100644 index 00000000..c189301d --- /dev/null +++ b/examples/pytorch/transformer.spam.py/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "transformer-spam-pytorch" +version = "0.1.0" +description = "Standalone PyTorch reproduction of the transformer.spam classifier" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "numpy>=2.0", + "torch>=2.5", + "wandb>=0.19", +] + +[tool.uv] +package = false diff --git a/examples/pytorch/transformer.spam.py/uv.lock b/examples/pytorch/transformer.spam.py/uv.lock new file mode 100644 index 00000000..e30872a1 --- /dev/null +++ b/examples/pytorch/transformer.spam.py/uv.lock @@ -0,0 +1,1087 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" }, + { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" }, + { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" }, + { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" }, + { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" }, + { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, + { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, + { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, + { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, + { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, + { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, + { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, + { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, + { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, + { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, + { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, + { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, + { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, + { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, + { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, + { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, + { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, + { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, + { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, + { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, + { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, + { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, + { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, + { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "cuda-bindings" +version = "13.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-pathfinder" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/67/5e7dba1ba576dd73da5dee894ca076ca5e959450dfff66d6d510a255d1f7/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7855c4868aabc0cfae28abbe83d56734bdfbd08f08fc234ac1912a12858bf49", size = 6025351, upload-time = "2026-05-29T23:11:49.685Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/6d2e9047d1fb243dbaa364b01e0297534b9ed7fd27dba1c9f361519cf69b/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e32d08f71ebcdf00f0f41eab2eb37e8da94c8ed411cc9f7f7a019ce6b34abe3a", size = 6657965, upload-time = "2026-05-29T23:11:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6e/2394f8163360f8391f8f1b7e72d300a82724edb81a7b7084c799fbd4c91f/cuda_bindings-13.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9efb21c1ee64981e184b9e0ba5eb3179e5ba3d4b51665a6cb52b8ef3d01a7cbf", size = 5920504, upload-time = "2026-05-29T23:11:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/34/c2/ef9b6a63f7dc432712a462c816662e662e00d38caa9b861c8c2588195d03/cuda_bindings-13.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2732904099e0a4d4db774a5fc6d91ee95fae065b4d2ecabb4968c5fe2406c9d7", size = 6476660, upload-time = "2026-05-29T23:11:59.188Z" }, + { url = "https://files.pythonhosted.org/packages/b1/81/bff68ce829999c1e4209c761bbf903b1c06ec570416ddb25020864ad5907/cuda_bindings-13.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ab2f74ed65bfef4163ba07a8db16f1085e0729291db12a2423aff84ee8278b8", size = 6013639, upload-time = "2026-05-29T23:12:03.509Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e0/c8a1f0c8f9ffdea4f5fe6dbab89b326cef4d85caf489dad39e209da89416/cuda_bindings-13.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd4c814d311ec08c981f6dded1dbe7d4b371067ee4f6c14cccec4bde9590f80", size = 6534419, upload-time = "2026-05-29T23:12:05.633Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/83b1f563925b290f2d11a01a77a84013ba56052fe3653a5bef3ccfbb43d6/cuda_bindings-13.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3c772dfff49681541d59630c90f858e173ac926b9c593a2b7123f2a1043cc76", size = 5809771, upload-time = "2026-05-29T23:12:10.422Z" }, + { url = "https://files.pythonhosted.org/packages/12/20/e79b4bfe98f075195afb6343d41c498f9dbd2d161d7021d4d28bceb83581/cuda_bindings-13.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36febb7c1079d68a981dbbd8d5a67235b399802b82075c9388624719607e52b9", size = 6358584, upload-time = "2026-05-29T23:12:12.767Z" }, +] + +[[package]] +name = "cuda-pathfinder" +version = "1.8.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/e6/22df83f82f9bc26cb1c42265cf14d34d4908dba2a0f261bd7b28244acb00/cuda_pathfinder-1.8.1-py3-none-any.whl", hash = "sha256:ae0137ff9e56ea97499bcbf54f5f2778ec25f3266715ac86da192a795af982a8", size = 62552, upload-time = "2026-09-02T16:55:28.64Z" }, +] + +[[package]] +name = "cuda-toolkit" +version = "13.0.3.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/c7/a79086a62c98befcdb8349656c6f114e2db3b8b2422f6e25c97a7f2a9a3c/cuda_toolkit-13.0.3.0-py2.py3-none-any.whl", hash = "sha256:d693caaa261214ddd7dbb60d68e71cbed884e68c2be7509778f3051da0b91c3f", size = 2512, upload-time = "2026-04-14T00:50:08.173Z" }, +] + +[package.optional-dependencies] +cublas = [ + { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, +] +cudart = [ + { name = "nvidia-cuda-runtime", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, +] +cufft = [ + { name = "nvidia-cufft", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, +] +cufile = [ + { name = "nvidia-cufile", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, +] +cupti = [ + { name = "nvidia-cuda-cupti", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, +] +curand = [ + { name = "nvidia-curand", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, +] +cusolver = [ + { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cusolver", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, +] +cusparse = [ + { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, +] +nvjitlink = [ + { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, +] +nvrtc = [ + { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, +] +nvtx = [ + { name = "nvidia-nvtx", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, +] + +[[package]] +name = "filelock" +version = "3.32.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/a0/50c2c0ce5e74d7721bbb1b19a26ebd339aac5878553a6e35308c2f31f935/filelock-3.32.5.tar.gz", hash = "sha256:f6a6a28f743f9b95ce19db5abe0f376f75eb56517dff21e1a4751e2657d3e83d", size = 222838, upload-time = "2026-08-31T18:56:34.729Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/d2/b70a31e13d04456d28493f31d2aa087e99eeb2767ef0293b2625727ccb8c/filelock-3.32.5-py3-none-any.whl", hash = "sha256:142cd9fa77a872c5e78c62329a0d15278fadc686eb89e760017968961a4fd6b2", size = 100003, upload-time = "2026-08-31T18:56:33.078Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/78/f34251dadb8f3921264a1d9b8946f5e542014ee2614b285261b4e40e6775/fsspec-2026.7.0.tar.gz", hash = "sha256:c803c40f4cf860b49dea58ee3e1c33cb9c790520e233537e1340049f89b82a88", size = 317040, upload-time = "2026-07-28T16:34:51.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/3c/6a2bf344106328fd04963664a60b9bb6496fc25df8e962fcdc1367285fb9/fsspec-2026.7.0-py3-none-any.whl", hash = "sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279", size = 206583, upload-time = "2026-07-28T16:34:49.538Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/50/8fdbb16af64895706a45f06a4068e29db732ec180f3c1375f14123359138/numpy-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9", size = 16994982, upload-time = "2026-09-06T16:24:29.244Z" }, + { url = "https://files.pythonhosted.org/packages/60/39/789131c1188c078dcb3a1692e72e1e050c68b88ffe72c9ccaac9bcd7a9cd/numpy-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c", size = 12009327, upload-time = "2026-09-06T16:24:32.491Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/a312e95696e5f601914dd8b6dd844692ba61670807417e24b68e337b5c70/numpy-2.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a72f874bc9e10e4b8f80426fb49716d5141f64442a0c8418065093ec8017fbb0", size = 5445405, upload-time = "2026-09-06T16:24:35.071Z" }, + { url = "https://files.pythonhosted.org/packages/30/d0/5623a1707ed4fe16e3909fe3cf5ee3da004ae677ad23d83bbf3adf1a6faf/numpy-2.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:fc36dc566135b5eceec4cf89758fcb719266a019ef07dae1754ae7c9f617ef3e", size = 6783213, upload-time = "2026-09-06T16:24:37.253Z" }, + { url = "https://files.pythonhosted.org/packages/f1/32/84146fc020ad3c25f805f70ab60da46fe3c540a21369754a7e4369754b6f/numpy-2.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58", size = 15687872, upload-time = "2026-09-06T16:24:39.751Z" }, + { url = "https://files.pythonhosted.org/packages/65/af/aa78d1a88805456e212b65461354cd943197fb9acecc4c90fd12295123a3/numpy-2.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3", size = 16717410, upload-time = "2026-09-06T16:24:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/3b/24/faa79d865e69a97ba17473b23a1b74094b2259c03e820c70297293b9ea49/numpy-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f8929ee6c96bfbd7b4ed2032e0c03af86fe1826740ab61ddabf9072d06e57ff", size = 17040975, upload-time = "2026-09-06T16:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/8877e629445a7176297dffcaf9c485faa96a95d81728a62521ad55bd4c0f/numpy-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b5d93cf48f687479941d12b69c873ad2cc76bbd487f0091c2200636497f34034", size = 18476479, upload-time = "2026-09-06T16:24:49.35Z" }, + { url = "https://files.pythonhosted.org/packages/c8/db/35e1c2d38b04cbd5b731f9d71495e055e813197669d22b612f11748d2ff9/numpy-2.5.3-cp312-cp312-win32.whl", hash = "sha256:bf63afbe037eb5d2fe87fbcc7778e61da53ebaf21d938a4515aa73b62532a5d4", size = 6133378, upload-time = "2026-09-06T16:24:51.915Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/accf6d4f0c80c5d9ba9735d6b1550e444180599f34dec69ca01360f717ad/numpy-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def", size = 12567828, upload-time = "2026-09-06T16:24:54.255Z" }, + { url = "https://files.pythonhosted.org/packages/22/43/1764aff32e4652526ae2f71fa8b3efd8d25c8a3d6926914454e47138ed1e/numpy-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:ccb32e0525d29e8b0572eb84c9a57af0e7a4e615726927506f55063c62414034", size = 10485432, upload-time = "2026-09-06T16:24:57.278Z" }, + { url = "https://files.pythonhosted.org/packages/79/e5/8fb89cd46d14e35699d13bf943a5f5f441ecee8667120a1f6105ab89e349/numpy-2.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:66a78fe4556c60aceda5916f9eacd638b18e9e681016ec302dcb4682d6d4d034", size = 16991061, upload-time = "2026-09-06T16:25:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/2f/06/9dc9e48b5e5e941c8b10350c5ff2d721da42a20517d911d15544246775ff/numpy-2.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92f30e89b8ee0ecf363033576c422b2f58fed6a80bed0aa48dff6d14c654663e", size = 12003676, upload-time = "2026-09-06T16:25:03.475Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2a/98282aa5b8f58b1157d440bb6282eed47e3632a5de53a714fbab17e659fe/numpy-2.5.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f9a2353b37a1a9e78fd82b27ad7e2a32a2d036604d18f02b05e3136c62ca3b09", size = 5439695, upload-time = "2026-09-06T16:25:05.978Z" }, + { url = "https://files.pythonhosted.org/packages/a1/f9/b6533d777be9d6ffd29dc1be0867e563e6e8cc9a220ff1b716adc317f060/numpy-2.5.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:ccbc4665079665c3cf3bab4db9f6b095370cd6437d66be549b6c2a1fd19e1958", size = 6779395, upload-time = "2026-09-06T16:25:08.599Z" }, + { url = "https://files.pythonhosted.org/packages/73/85/735720d04ec197c5dcfacdfc9922667c7f1f5f496a279b7ba4d7c74c4cc7/numpy-2.5.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c76d5dde9f445058f83d0c02af00557a4db91de9a9a57c0df87d1535001d654b", size = 15681750, upload-time = "2026-09-06T16:25:11.173Z" }, + { url = "https://files.pythonhosted.org/packages/3a/1b/3b16a9bc514a440a7a0883684111dcb1ef1aee960af2ca95da8fc775f124/numpy-2.5.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5fa86b80fd24bcd1aff83ad23be44ea323de3f787be8f8b15d4a65621e25321", size = 16708577, upload-time = "2026-09-06T16:25:14.171Z" }, + { url = "https://files.pythonhosted.org/packages/69/c4/386f397831b07328b639c96c5b62719346cf4baf07c68d927239752b1534/numpy-2.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd4cb9ad3c7889b9b3fe0a9a9fb5d2ed26f9879bff2608d9f01aed147a20d231", size = 17042047, upload-time = "2026-09-06T16:25:17.582Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3e/a700ecbf36e85ae8328fd3b0e12eeddc22ed6358a64cb2bd913e0d195d65/numpy-2.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1302b90c0e52281681b2975adfe8a860cb7b12216a27b4b0b4207c44bf7bccf0", size = 18465724, upload-time = "2026-09-06T16:25:20.949Z" }, + { url = "https://files.pythonhosted.org/packages/41/ee/38e785e88a4045f6ad1d1f2808dcdfafdca48c760260c0587bf171e29fc9/numpy-2.5.3-cp313-cp313-win32.whl", hash = "sha256:1c80eabb4035ecf4ca9cd49cde8a9fdd69a729e63e6474887d1523ade7aa277f", size = 6129003, upload-time = "2026-09-06T16:25:23.664Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ec/100f2b1794ede74a9b3d7ec6b9736927f56713414c1dfe19ab6c383494bf/numpy-2.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:71cad2b2a7451ab79d8f5e71b453485b6775963d5cf794179144a7463fe6e8ec", size = 12560965, upload-time = "2026-09-06T16:25:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/80/b1/7dc825ca94c12acebbce4c37caa5e198695eb31424bc579679f32b1bb49d/numpy-2.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:8e4dd766076855b5ff7ea52fa5f07ce26286726e0f8bff446b7739d02e6ea204", size = 10482343, upload-time = "2026-09-06T16:25:29.772Z" }, + { url = "https://files.pythonhosted.org/packages/70/78/cf416f15dc29375a229d9dfebf8db6e313f291580b39fa1a568b6052bb07/numpy-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a", size = 16998686, upload-time = "2026-09-06T16:25:33.171Z" }, + { url = "https://files.pythonhosted.org/packages/9e/59/abcc2d8def4fd60eec7d87f92d27c13448ffd9ab14339bcc63a0d7a2fdea/numpy-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c", size = 12013862, upload-time = "2026-09-06T16:25:36.748Z" }, + { url = "https://files.pythonhosted.org/packages/94/75/4640d2d6e4b64a049e48425a82728a41ef4adb61332d2cba68055774878b/numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07", size = 5449793, upload-time = "2026-09-06T16:25:39.476Z" }, + { url = "https://files.pythonhosted.org/packages/96/cd/625b57ae33d4ca560f32cc0b47b4a5922146d9beb998ddf773900d440a73/numpy-2.5.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0", size = 6785176, upload-time = "2026-09-06T16:25:42.069Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/12918652e7912ef9751e8694c88820fcd1908e0618cb23f5f3caa6004b7b/numpy-2.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc", size = 15703377, upload-time = "2026-09-06T16:25:45.135Z" }, + { url = "https://files.pythonhosted.org/packages/45/8f/9beacf79ca7c650688ad0baa80931adb988fe6e6e5d5903c23cc3dbd70eb/numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b", size = 16711928, upload-time = "2026-09-06T16:25:48.461Z" }, + { url = "https://files.pythonhosted.org/packages/09/8d/41d0a56e1ac4c87495c897a211b1368691b7237aadabec8b3b8f3a74d48f/numpy-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104", size = 17059507, upload-time = "2026-09-06T16:25:51.873Z" }, + { url = "https://files.pythonhosted.org/packages/08/1e/0dfbc5cc251d54e2af790f254d24ec38637fa97ec7d5d11de7ffed787098/numpy-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694", size = 18471002, upload-time = "2026-09-06T16:25:55.233Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/dfa40f6991f8185c8c30ffd023dfcbb11888e823cfab9557b920f3bb7bed/numpy-2.5.3-cp314-cp314-win32.whl", hash = "sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e", size = 6180485, upload-time = "2026-09-06T16:25:58.157Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/d2c08231e4fde7e415501fd02c715d96e98599b2d8384445933944152984/numpy-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10", size = 12698179, upload-time = "2026-09-06T16:26:00.789Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e9/dcdcc9b95cf5f49815055573aee1b11cfbf5299f38a180e437ded050810f/numpy-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6", size = 10769383, upload-time = "2026-09-06T16:26:04.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/c4/af8bc08a7ef4e1529a7c0cf24969accce316b783999802089a581ec99272/numpy-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297", size = 12132668, upload-time = "2026-09-06T16:26:07.138Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ae/0f15eb56d4ec5e13c1f7ff04ff407f997d1acbadb45d3e1f2e2645a8f43c/numpy-2.5.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841", size = 5568580, upload-time = "2026-09-06T16:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/23/fb/c72a8f25d4b6e96c354e7ab45ace3b27dc11e5d6a13b6c7d0cd6b08bf112/numpy-2.5.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891", size = 6882634, upload-time = "2026-09-06T16:26:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/968c90ed2ab15060c338e8137f1215b5a60756ae07328e0a60d1c6734df4/numpy-2.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6", size = 15748923, upload-time = "2026-09-06T16:26:15.092Z" }, + { url = "https://files.pythonhosted.org/packages/59/08/9df04103947b95e3b6b1f2ed1a70521f325647a31b82da6a2aae3a485508/numpy-2.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211", size = 16746748, upload-time = "2026-09-06T16:26:18.43Z" }, + { url = "https://files.pythonhosted.org/packages/41/a0/14c8d5fe5b53a334aabb653deb391c0fef49558f491880ea300ed6785224/numpy-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653", size = 17111561, upload-time = "2026-09-06T16:26:22.113Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a6/d7e96e42f01522e154c32489640f16dfc4f6181d165d05fc3bec8c2c4999/numpy-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33", size = 18513945, upload-time = "2026-09-06T16:26:25.401Z" }, + { url = "https://files.pythonhosted.org/packages/25/39/3453afb7119d0449ef11c886874120ff180e2c337760e0e2d88f70f1a945/numpy-2.5.3-cp314-cp314t-win32.whl", hash = "sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c", size = 6335421, upload-time = "2026-09-06T16:26:28.175Z" }, + { url = "https://files.pythonhosted.org/packages/99/01/22815d2b19a1a746b1d45205cffebb3fe511a18acb75fba6c88491fc9894/numpy-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435", size = 12896420, upload-time = "2026-09-06T16:26:31.265Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ee/a7cbba67eeaff038dc29ca8b98a88396c8b0cc9c89d4924f4a27a5c9150b/numpy-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a", size = 10857177, upload-time = "2026-09-06T16:26:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/45/56/78194492883ff5eec90423fe56a3a44b154da047d88a6307f629713c584f/numpy-2.5.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a6391fafaba97500887132cd582abc6e19452b1ac775a47caa7b24490e152058", size = 16996531, upload-time = "2026-09-06T16:26:37.287Z" }, + { url = "https://files.pythonhosted.org/packages/11/39/dd55c0af90bbab564b09ae3b0aa60ec5c02b900fa4f1ba23440525c8b32d/numpy-2.5.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:09d5a423c71ad5feb5625844ad58050e35df43871004b52ac9c0ad44a56775be", size = 12012569, upload-time = "2026-09-06T16:26:40.707Z" }, + { url = "https://files.pythonhosted.org/packages/b6/51/04f67d32e4862b281b1cb84ceeaed3421189a84fb6fb51a391cd6d5009f7/numpy-2.5.3-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:f9579f383d1bf9df80081e72760e84960a7fd4f88cf0c9e535a8597c9bb646f5", size = 5448498, upload-time = "2026-09-06T16:26:43.435Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c9/25b4dc0dd1344ec26c7319e84fd4e9809d2b5628f4e12decd618036e5178/numpy-2.5.3-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:86bff898a431c0fb71f7610b75726e75a54d47b37edc9d537f48de63bb3c0b90", size = 6783026, upload-time = "2026-09-06T16:26:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c7/29285be1e5232a6e7ee3268a33c85843f5a8ee93350c6465cddd66ebbf76/numpy-2.5.3-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f3ed25271581281f2fccb1adcedfcde4c07362eec69189b50baf6f90e3ae159", size = 15697322, upload-time = "2026-09-06T16:26:49.415Z" }, + { url = "https://files.pythonhosted.org/packages/55/49/bbad5335fb4996a16881f853ff3e0ba582f01720e55c89b1c06b8fc42a90/numpy-2.5.3-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffdc76bfcae6b255dff75202c5e7feaf95b40246bc0a17944facc1fecf9f79ab", size = 16708995, upload-time = "2026-09-06T16:26:53.127Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e9/1df35483760b04a65ea44669f89dc64f30e5aca098b48ceb8b1310b0e0fe/numpy-2.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:116f96cadd935c6122e9228d676fe7ede19e741f5c8bb1c3cddbe0c51ccebea2", size = 17052508, upload-time = "2026-09-06T16:26:56.464Z" }, + { url = "https://files.pythonhosted.org/packages/b8/99/66e54da8265cc8be8a7382bf96edce17aaa2837d6f484432025932a3caa5/numpy-2.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:09ffa5d903faeaa5c4dd05009cf81c8bab9f2cb37c548b8d39b65b4cfa7c97f7", size = 18468224, upload-time = "2026-09-06T16:26:59.966Z" }, + { url = "https://files.pythonhosted.org/packages/01/bc/b5e90a91c115168d793dfd2ad9c69c438c2fe7a13a437e770bc5b078e732/numpy-2.5.3-cp315-cp315-win32.whl", hash = "sha256:e01c918ac3d48e18a927cf7b14a26a3e29ff2bdf2eacb976da0aecd6a43ed034", size = 6179919, upload-time = "2026-09-06T16:27:03.166Z" }, + { url = "https://files.pythonhosted.org/packages/37/ea/780748fd3985109075514ef8fc64cd25f943e40dde13a6d59141eb268fc8/numpy-2.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:e931e4f499e0dc7ef29d269a8e5b35dd722e5d14be07df6240166ea7c6532fae", size = 12697656, upload-time = "2026-09-06T16:27:06.153Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/407be69a2a87c8cab64d95975a8977a426a29e138f07e276ec258f0fe4e5/numpy-2.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:26e15e4aecd8617dfbaecb37d223e365d7b39411fba20454be2670a96aa74cb5", size = 10767601, upload-time = "2026-09-06T16:27:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/44/bf/a97ffb01e41d50a32a9177aef942a4d0e389a3daf451d04e5f38ef6afb87/numpy-2.5.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:6cef4bb1706dfec49243c05d921eefb4e190d41e2528b30d8035ea1f36b4c24a", size = 17090092, upload-time = "2026-09-06T16:27:12.907Z" }, + { url = "https://files.pythonhosted.org/packages/d1/24/136c02f2c2af9a067a84d0c3aa10c99012c0476fa5066732fa4a4202557d/numpy-2.5.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d1c89973648c85069c5046ad460f7b8a00218b29a2e42359ac8cc63e9ab94832", size = 12129429, upload-time = "2026-09-06T16:27:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6c/b47582d6597789bf946d5efbeb6b9e56fd8bcbd5efc6fbf51dbe1ea31eb3/numpy-2.5.3-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:214045a5bf00113a146ab9ee9730c44501af6723cdf1f6830932f7b5ef2e7af0", size = 5565452, upload-time = "2026-09-06T16:27:19.868Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/ef3cc6da73774202d4deae16bb321fd8298a4e0561e3539f8c4be237d916/numpy-2.5.3-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:8617bbfae4486cf99c9f899966699428d19da931d06ca94ad3da986c76e15997", size = 6876736, upload-time = "2026-09-06T16:27:22.232Z" }, + { url = "https://files.pythonhosted.org/packages/9e/24/e3813329498596cb842703dcacac1741612ed9fb9c4e6a3e0c7e2ebbc597/numpy-2.5.3-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:595d020938c84e320bcf40ad71089e108eac0d377cd018e14a8c094f39e98d85", size = 15745777, upload-time = "2026-09-06T16:27:25.181Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9e/4e7a07fd0776dc2210cdacf2010be8665194d094defc10c419d7dea794cc/numpy-2.5.3-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f24021b9f22bc6301c37b196974a92c1c18dccedb6fef3dd252e95f2d6adbe4", size = 16746949, upload-time = "2026-09-06T16:27:28.576Z" }, + { url = "https://files.pythonhosted.org/packages/91/db/01674c0e20335057813a00c2ebd546ed25bff9ed7914f9bced00f8c55d94/numpy-2.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:71b39d9f935b6ec0f8753e3e2afb51e3efba6f2e05b68b32a40754d24bcd4a3c", size = 17108994, upload-time = "2026-09-06T16:27:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/45/7a/584c5e71f8d378e57cac0b033891ed65c683ef90573ba4854e8c28203db0/numpy-2.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6b05c171afb3aa07adbd20abc00aea86fe375beb0fdb9ef780ec5b7f63bab1c0", size = 18512266, upload-time = "2026-09-06T16:27:35.196Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d2/4e1014173aa3c55e6a756e0e567290743a6ab33a288460374d7ef6bcd239/numpy-2.5.3-cp315-cp315t-win32.whl", hash = "sha256:f54660b0eb6b0b9f36e7fe1cdfdff472028dd0d14acd9b9b65098efbad059469", size = 6330292, upload-time = "2026-09-06T16:27:38.149Z" }, + { url = "https://files.pythonhosted.org/packages/6c/b0/ff5658a58199b7bcaad87bf260eef6713d9d42cca4e028f935b4fc5fbac6/numpy-2.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:1aad64d99730d013cfc6debafed22783b4fc5a7f4b8bc744d2d8cf7dcc880551", size = 12884918, upload-time = "2026-09-06T16:27:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0b/b12a2df5d1b774bd9007a6fdff9381145b6223d37f11afc9c37ab0efd9a1/numpy-2.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:befa1ae5bd6030b3f512b43ff3fa5290bbed6b84411a44244b14adf835f5b89d", size = 10850807, upload-time = "2026-09-06T16:27:43.868Z" }, +] + +[[package]] +name = "nvidia-cublas" +version = "13.1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cuda-nvrtc" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cd/154ca20c38269e05eff77c1464e6c1da89f50a6390b565e9d82e06bc11e1/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:37936a16db8fe4ac1f065c2139360608a543a09275cb1a1af612e08cfa065436", size = 423138758, upload-time = "2026-04-08T18:46:58.655Z" }, +] + +[[package]] +name = "nvidia-cuda-cupti" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/2a/80353b103fc20ce05ef51e928daed4b6015db4aaa9162ed0997090fe2250/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_aarch64.whl", hash = "sha256:796bd679890ee55fb14a94629b698b6db54bcfd833d391d5e94017dd9d7d3151", size = 10310827, upload-time = "2025-09-04T08:26:42.012Z" }, + { url = "https://files.pythonhosted.org/packages/33/6d/737d164b4837a9bbd202f5ae3078975f0525a55730fe871d8ed4e3b952b0/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_x86_64.whl", hash = "sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8", size = 10715597, upload-time = "2025-09-04T08:26:51.312Z" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc" +version = "13.0.88" +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" }, +] + +[[package]] +name = "nvidia-cuda-runtime" +version = "13.0.96" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/4f/17d7b9b8e285199c58ce28e31b5c5bbaa4d8271af06a89b6405258245de2/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef9bcbe90493a2b9d810e43d249adb3d02e98dd30200d86607d8d02687c43f55", size = 2261060, upload-time = "2025-10-09T08:55:15.78Z" }, + { url = "https://files.pythonhosted.org/packages/2e/24/d1558f3b68b1d26e706813b1d10aa1d785e4698c425af8db8edc3dced472/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548", size = 2243632, upload-time = "2025-10-09T08:55:36.117Z" }, +] + +[[package]] +name = "nvidia-cudnn-cu13" +version = "9.24.0.43" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/30/7c257e3d5cb4fecb147b93895c66e29c93f8e76d74b45bb418ff0587c4ec/nvidia_cudnn_cu13-9.24.0.43-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:a6812a554a1ff0413e9c52b84c26c050380649ab9615f9c16bded368ce9f421f", size = 650976863, upload-time = "2026-07-02T16:23:39.248Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ba/791cffd048fe5b044e620df55267e3e95c0e6e07d50b41e377c03dfc910f/nvidia_cudnn_cu13-9.24.0.43-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:71f181cd810e90f9b6023b01186fe82d13d65f0ec098581ee201d39fad769e4b", size = 553099438, upload-time = "2026-07-02T16:27:42.58Z" }, +] + +[[package]] +name = "nvidia-cufft" +version = "12.0.0.61" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2f/7b57e29836ea8714f81e9898409196f47d772d5ddedddf1592eadb8ab743/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3", size = 214085489, upload-time = "2025-09-04T08:31:56.044Z" }, +] + +[[package]] +name = "nvidia-cufile" +version = "1.15.1.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/70/4f193de89a48b71714e74602ee14d04e4019ad36a5a9f20c425776e72cd6/nvidia_cufile-1.15.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44", size = 1223672, upload-time = "2025-09-04T08:32:22.779Z" }, + { url = "https://files.pythonhosted.org/packages/ab/73/cc4a14c9813a8a0d509417cf5f4bdaba76e924d58beb9864f5a7baceefbf/nvidia_cufile-1.15.1.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:bdc0deedc61f548bddf7733bdc216456c2fdb101d020e1ab4b88d232d5e2f6d1", size = 1136992, upload-time = "2025-09-04T08:32:14.119Z" }, +] + +[[package]] +name = "nvidia-curand" +version = "10.4.0.35" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/72/7c2ae24fb6b63a32e6ae5d241cc65263ea18d08802aaae087d9f013335a2/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:133df5a7509c3e292aaa2b477afd0194f06ce4ea24d714d616ff36439cee349a", size = 61962106, upload-time = "2025-08-04T10:21:41.128Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9f/be0a41ca4a4917abf5cb9ae0daff1a6060cc5de950aec0396de9f3b52bc5/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc", size = 59544258, upload-time = "2025-08-04T10:22:03.992Z" }, +] + +[[package]] +name = "nvidia-cusolver" +version = "12.0.4.66" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas" }, + { name = "nvidia-cusparse" }, + { name = "nvidia-nvjitlink" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, + { url = "https://files.pythonhosted.org/packages/5f/67/cba3777620cdacb99102da4042883709c41c709f4b6323c10781a9c3aa34/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112", size = 200941980, upload-time = "2025-09-04T08:33:22.767Z" }, +] + +[[package]] +name = "nvidia-cusparse" +version = "12.6.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, + { url = "https://files.pythonhosted.org/packages/fa/18/623c77619c31d62efd55302939756966f3ecc8d724a14dab2b75f1508850/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b", size = 145942937, upload-time = "2025-09-04T08:33:58.029Z" }, +] + +[[package]] +name = "nvidia-cusparselt-cu13" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/e1/cdc1797eadf82d3a9a575a19b33fdc871a97edbec42c00b5b5e914f4aff4/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:4dca476c50bf4780d46cd0bfbd82e2bc10a08e4fef7950917ce8d7578d22a23f", size = 221051344, upload-time = "2025-09-05T18:49:51.289Z" }, + { url = "https://files.pythonhosted.org/packages/34/7d/2661f2fb3ac4302f3a246f5fc030213ac60c1fe0bce84f9783dbd831dbb7/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:786ce87568c303fadb5afcc7102d454cd3040d75f6f8626f5db460d1871f4dd0", size = 170148586, upload-time = "2025-09-05T18:50:50.248Z" }, +] + +[[package]] +name = "nvidia-nccl-cu13" +version = "2.30.7" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/21/a73174c6157101bdf1ffc22b517f76ff0082613989dd9bc8f43e8034caac/nvidia_nccl_cu13-2.30.7-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:ca786ffa5a647c75d4d1f5cc72a6c4f537947e2ba8823d7c8aaf768e7a7b9f77", size = 215983881, upload-time = "2026-06-09T03:23:15.633Z" }, + { url = "https://files.pythonhosted.org/packages/3f/34/c500f90c7ae641b8e0f98965b36b8a7ac79cc8b296e8d251fe3eb592ee54/nvidia_nccl_cu13-2.30.7-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:cefa7fdb9710efd0f39c5f1be1d61ff6fc9a996c451265bd7fbdcf9455ed4b50", size = 215965170, upload-time = "2026-06-09T03:23:39.73Z" }, +] + +[[package]] +name = "nvidia-nvjitlink" +version = "13.3.33" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/ee/580ca6f29dcab0221db8706badca1bbbb084f1975c4d4e83329c3a7e31f0/nvidia_nvjitlink-13.3.33-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:26a6de7fb4c8fdaa7703d3dad720d6d427ddfea5c48a528fd97c11733ad830e5", size = 40742423, upload-time = "2026-05-26T16:54:51.613Z" }, + { url = "https://files.pythonhosted.org/packages/69/30/45414e35ff2eee7db3da037e5707037ccf9d2b5218ffbdb055ea4d5aa98a/nvidia_nvjitlink-13.3.33-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ce48b37dfeb3cb1eae4cf85adacb47d7a6539ea2272870c9a3628ce275c2037e", size = 39168635, upload-time = "2026-05-26T16:54:13.906Z" }, +] + +[[package]] +name = "nvidia-nvshmem-cu13" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/0f/05cc9c720236dcd2db9c1ab97fff629e96821be2e63103569da0c9b72f19/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dc2a197f38e5d0376ad52cd1a2a3617d3cdc150fd5966f4aee9bcebb1d68fe9", size = 60215947, upload-time = "2025-09-06T00:32:20.022Z" }, + { url = "https://files.pythonhosted.org/packages/3c/35/a9bf80a609e74e3b000fef598933235c908fcefcef9026042b8e6dfde2a9/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80", size = 60412546, upload-time = "2025-09-06T00:32:41.564Z" }, +] + +[[package]] +name = "nvidia-nvtx" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/f3/d86c845465a2723ad7e1e5c36dcd75ddb82898b3f53be47ebd429fb2fa5d/nvidia_nvtx-13.0.85-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4", size = 148047, upload-time = "2025-09-04T08:29:01.761Z" }, + { url = "https://files.pythonhosted.org/packages/a8/64/3708a90d1ebe202ffdeb7185f878a3c84d15c2b2c31858da2ce0583e2def/nvidia_nvtx-13.0.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6", size = 148878, upload-time = "2025-09-04T08:28:53.627Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/b7/802a56eca9f2fac455b8bab5375a2647b0f0e14a2cd63ef077de3c4a7658/platformdirs-4.11.7.tar.gz", hash = "sha256:4f41487eeeeeb07f3a6625e61d9bc0ae6809f92d3386dbd74392fbb76108104d", size = 35127, upload-time = "2026-09-01T13:35:10.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/6e/80993e10a0482f630cef528635789233224f36b1ffd11592aa15d13ff9ce/platformdirs-4.11.7-py3-none-any.whl", hash = "sha256:8a02cb259042c79d1cd0450facc2fe6dc9d303ae7901afbe33bf8ea0b188cef6", size = 23938, upload-time = "2026-09-01T13:35:09.02Z" }, +] + +[[package]] +name = "protobuf" +version = "7.36.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/73/f66c748df06e7fe24e658eddd600d19c4b40bad836c97ce2d0ad9851fb6b/protobuf-7.36.1.tar.gz", hash = "sha256:d0f6470f0ce2b84e3feaea2d4b816378b37ba4d4aa08a274305373de93e2d524", size = 512499, upload-time = "2026-08-31T22:40:04.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/6c/3a54a58f2948b0f485df9ecdd06590f15d0a7abf46a89d50c3de709ff4ff/protobuf-7.36.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:3cf2ee25d006cee57294a1196ea43b37feb78e0dcd1e8af5c1aeddb777655aca", size = 456046, upload-time = "2026-08-31T22:39:56.865Z" }, + { url = "https://files.pythonhosted.org/packages/6e/08/9f9548793c771095245c0eeaf0c84b76b16a5ef26158043d456f551344a0/protobuf-7.36.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:43d3d37b1eb24c113b9b7d02008cac44e423f00b611b7781ae998d7623972969", size = 344226, upload-time = "2026-08-31T22:39:58.263Z" }, + { url = "https://files.pythonhosted.org/packages/fe/51/1bdbd612fa3c51e42ea6f45d05e84dc748ddcd2663e1aa1e89a00a33facd/protobuf-7.36.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:39c518c05586c016d7874ff6079ee115bcec1ea5fbb1d177fbf7867ef4c67e44", size = 357229, upload-time = "2026-08-31T22:39:59.198Z" }, + { url = "https://files.pythonhosted.org/packages/22/df/c799fe7a05ef16ba853a59db01f3a2c5f7d0676469589ccc4874f76a2a88/protobuf-7.36.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:97198b77e369a0abd8e262b8f6c7266c55ddb796a3a12c76d7b8881188ed83aa", size = 343228, upload-time = "2026-08-31T22:40:00.179Z" }, + { url = "https://files.pythonhosted.org/packages/3d/33/d4724ec5d86d496fe4108e220618aa0837ad3423a7af7ccdd9684ccc77c8/protobuf-7.36.1-cp310-abi3-win32.whl", hash = "sha256:0b53ce95272aad50ad25d7ff03373743209822e8ba42ea7fad27d2bee1547d00", size = 443002, upload-time = "2026-08-31T22:40:01.32Z" }, + { url = "https://files.pythonhosted.org/packages/db/37/155788a0d8daded960375af604202805308169f9b859419ea0aa370946e2/protobuf-7.36.1-cp310-abi3-win_amd64.whl", hash = "sha256:51139351435d9b43d88a55eaa49fb6f737fbb478fb0cbf2cf694d1a04a9d3363", size = 456518, upload-time = "2026-08-31T22:40:02.494Z" }, + { url = "https://files.pythonhosted.org/packages/39/ca/c47f91d3cab175b01fd8c4f0d80fdf8613be876cc616e66ad281a59c5ddf/protobuf-7.36.1-py3-none-any.whl", hash = "sha256:7d951e46b3f963d6c264c367c437921de9d5aedd9c3f9612b9077736b4e3ad5c", size = 179813, upload-time = "2026-08-31T22:40:03.54Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "sentry-sdk" +version = "2.69.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3e/d4/911132f0ad673372159b4c47d3074444651b68c4c834fd5427f91285c66a/sentry_sdk-2.69.0.tar.gz", hash = "sha256:0cf7d29419145ab0250ea51363ad79fb7509996aaa35c5d5d2c3ed85b3b80a7d", size = 1042224, upload-time = "2026-09-08T08:16:29.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/c4/11acfc646282156d8bec336db28143d33196531bd982fe20c2280f03abf6/sentry_sdk-2.69.0-py3-none-any.whl", hash = "sha256:3b92738027322061fbab34199102fcc0459ff9a5bf373ccca7091af9a5f07530", size = 528018, upload-time = "2026-09-08T08:16:27.203Z" }, +] + +[[package]] +name = "setuptools" +version = "84.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "torch" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-bindings", marker = "python_full_version < '3.15' and sys_platform == 'linux'" }, + { name = "cuda-toolkit", extra = ["cublas", "cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" }, + { name = "setuptools" }, + { name = "sympy" }, + { name = "triton", marker = "python_full_version < '3.15' and sys_platform == 'linux'" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/76/bb4770f56cf6d8971671dbcbb7493e5a6a15ad2825f4e359b02c27c38297/torch-2.14.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:c1f844f1c750e87df4b68bc3afbc0e2b0c7ef19d7b8f666e48bdcf6a0c4f0056", size = 127303200, upload-time = "2026-09-02T13:43:20.311Z" }, + { url = "https://files.pythonhosted.org/packages/fe/7b/ec44bacf2c8886b85ba4ca2285e8b09f2dff5d9c99e6a031326954082cb5/torch-2.14.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:ada340e62591d06a2bcc2d68170f20f45f0b0665d372dc510a8ed7eb3b1d609a", size = 454010251, upload-time = "2026-09-02T13:44:24.927Z" }, + { url = "https://files.pythonhosted.org/packages/15/71/49399acd41f750a906c686bd23c08a2001ccb8dd25f2971003c2ed89c1dd/torch-2.14.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:fecffb58f51fd643d213acd68da21cc3fc19bea05a3bc64b4ee55128f47a4963", size = 554620488, upload-time = "2026-09-02T13:44:49.442Z" }, + { url = "https://files.pythonhosted.org/packages/be/16/9489b137112040f9911d7527e452854f21cc4e499ce0da79864e6a7451a7/torch-2.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:cad84f41bbdf3dcf333ce394aeeaf25237c4d94fd6b659ba5eb813c829978823", size = 124114011, upload-time = "2026-09-02T13:43:55.034Z" }, + { url = "https://files.pythonhosted.org/packages/ca/40/0db773452c2a62b37761d3f418acf933d381f9e87077036fb57c2a386c37/torch-2.14.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:9d4b1022a5d9b71282ec67ad0d9e7235870096b8a246dc1c32d6ea1fc83dc998", size = 127311393, upload-time = "2026-09-02T13:43:59.607Z" }, + { url = "https://files.pythonhosted.org/packages/13/36/537fd9da2adad49e7b2bb20741398625bee548493158274e87369a8eed56/torch-2.14.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:731b9ebdea402b8b1996d4c2ae613b16660e559b19e47bdc45d970568bc91c53", size = 454010525, upload-time = "2026-09-02T13:45:03.719Z" }, + { url = "https://files.pythonhosted.org/packages/21/f1/39bd13b21f57d1982b7f3ddf663f01c7266e2957714880744eba9e8c8d11/torch-2.14.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:84bf384779a10c02fc3c6bdbab71a9cb66b0dd93c652d1ed5d6dfc0cb37e5962", size = 554619993, upload-time = "2026-09-02T13:45:28.209Z" }, + { url = "https://files.pythonhosted.org/packages/89/a8/683d9c44737554b67ca76dd2db4f42258a0f014246cb511293e51e0154bd/torch-2.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0e7cf18cb0d8bd666b6120932e29c7aef3502b61a08b44da4839580c539a7cdb", size = 124113865, upload-time = "2026-09-02T13:44:33.705Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/1241e7db5ccc2455f8735bd6b1becfad39916206ad18001c4c0014d139e2/torch-2.14.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:860423e970f2ce02c4476e8e2d1350131b1c5c5a5e4912180e78b50b53241efa", size = 127321431, upload-time = "2026-09-02T13:44:38.236Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/bbeba65e3fbb4b8f61ea19cf4ebea9f4268cc6fe64e6f7d38bfb6cc152eb/torch-2.14.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:b985c7defeb8d28691b7513ebaecc64946c5f68ec770e9f4ddba39688864f46a", size = 454027631, upload-time = "2026-09-02T13:45:43.73Z" }, + { url = "https://files.pythonhosted.org/packages/50/75/8d2b9a7e724759470c209489b79260cac537f091cc9aac8001c8d2bc845c/torch-2.14.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:b2cd92bce63d40bf6fc2e5d840fc2f0063bd180a242daa761cb6088cc2f46e27", size = 554623549, upload-time = "2026-09-02T13:46:04.252Z" }, + { url = "https://files.pythonhosted.org/packages/9a/1e/a5475c00b0555e686333e6b4036f2213e7cbea021a772ce6f9ced4dcbd2f/torch-2.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:44b044b9f6f633d982839422a57433d6a1da520037fd88e0c8a47efde589b3b8", size = 124110863, upload-time = "2026-09-02T13:45:12.764Z" }, + { url = "https://files.pythonhosted.org/packages/a5/49/bbaee76337742a42d2c5b0296ea62252567050e1d821bc2283b2e72ba6fb/torch-2.14.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:553aec938d37d77b783bcf801e638cad068870e7643c47eadac21eb180f551ed", size = 127653463, upload-time = "2026-09-02T13:45:17.1Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/6cc7a511bab384fbe8f4b8ddeecdd22e724b2162d6f15076f07a0a7ef15b/torch-2.14.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:cd8cd8f714d511ccdca907282d1da3d9be8322d4e1520b9c3bce39a5c1318a4b", size = 454009927, upload-time = "2026-09-02T13:46:19.637Z" }, + { url = "https://files.pythonhosted.org/packages/b6/da/0f04fe15fd05bf3f613a359b14acb44c35ae06fe69da0aefa8b5cdb4f9f9/torch-2.14.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d2526f71e6638133b97b3cd2881ece3df521460a4727030e8ae2a72a7d3ae31d", size = 554580530, upload-time = "2026-09-02T13:46:34.818Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c3/72ae1f02747b1f012e1975743e48cd608f83095d7f9ce58de78b79248b35/torch-2.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:731784e3914843c6bcc7aba3987ff7610ac57dbbc816a5d6b9b62e04c240a641", size = 124400194, upload-time = "2026-09-02T13:45:53.555Z" }, +] + +[[package]] +name = "transformer-spam-pytorch" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "numpy" }, + { name = "torch" }, + { name = "wandb" }, +] + +[package.metadata] +requires-dist = [ + { name = "numpy", specifier = ">=2.0" }, + { name = "torch", specifier = ">=2.5" }, + { name = "wandb", specifier = ">=0.19" }, +] + +[[package]] +name = "triton" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/07/0f8cd8e8db0472334253efdaaab3d0819fea27aa99bf0e7f1aeea4ceb5ae/triton-3.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9c404c69ed4a39e8ec632eaf6b9fe058a060bf98979c177f6ef666f06bb8d50", size = 226474486, upload-time = "2026-08-28T16:08:18.29Z" }, + { url = "https://files.pythonhosted.org/packages/c1/09/b7012e5bfae67640f268aa584caa80fe1674f6b0da949046b679972c33e3/triton-3.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e91ffa46d095b252248297292dd22bcbacd53a125a0c2eefbbbf74925a320bc3", size = 247972921, upload-time = "2026-08-28T15:55:53.157Z" }, + { url = "https://files.pythonhosted.org/packages/87/4d/4c564374bcdadb166fccbf3e45aee0d4a473f88d341761bd2fefe3b8e8c1/triton-3.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7004666652f500ed854a86988e4b3d69d247188b5d2092b5df1e44f4a954099", size = 226476793, upload-time = "2026-08-28T16:08:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/3394d5548404c1cabd1dadadd28d0b3f9478db1dff8180da53bb3f0a1e19/triton-3.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f0497218e26b7d79773ad9c2a3fa3b539ee69f587a13fac2e552b1d322a8015", size = 247975122, upload-time = "2026-08-28T15:56:04.112Z" }, + { url = "https://files.pythonhosted.org/packages/b8/59/bf0e9493118bb353ab59a5d6a65db3618d9b314417cc1459f0121e0ec5c9/triton-3.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f6b48d0591929a3867973acac3dccd4e058585f91bfb41022de496c9ffab304", size = 226488654, upload-time = "2026-08-28T16:08:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/93/d9/08c75f3459f19ad00425b564058e40efa4bcd79b816064cf27499303ea42/triton-3.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:387dae4cb0089a7b6ba1a428ae0782b65c4c58f57d94617cb22ca8593d8ccbca", size = 247972313, upload-time = "2026-08-28T15:56:14.007Z" }, + { url = "https://files.pythonhosted.org/packages/7c/34/429c5592181cfb7361a0a8e0bff218e7224b726709d75da2472b3e819f70/triton-3.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b84e7d512490ba529111260fa6f7cad8b254a6bb5fbdf41d5ef9a5e57f52d0a", size = 226591133, upload-time = "2026-08-28T16:09:02.271Z" }, + { url = "https://files.pythonhosted.org/packages/fe/d1/aa8a3e935c37efee7945984fdb64d7e0851bf6d920afd97b2d21f9d23360/triton-3.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74217bb56ed8692759227758e4c4b3bd2d608a209c1a7a081bf361fb4c2c1bf9", size = 248077577, upload-time = "2026-08-28T15:56:24.94Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "wandb" +version = "0.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "opentelemetry-api" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "protobuf" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "sentry-sdk" }, + { name = "typing-extensions" }, + { name = "xxhash" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c8/f2/ae1d136972c93c20b87f0caa8cfcacf922d2c650272d0f5af412847955a9/wandb-0.29.0.tar.gz", hash = "sha256:44981b504427ba312e0823fa62c02ff96fb9740a83e4657e693e3e2097cde603", size = 41230058, upload-time = "2026-08-26T23:43:12.669Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/ff/b1ca84260ff441652389188510b8ad8fab0e978b936aa8b7fb7557808d4a/wandb-0.29.0-py3-none-macosx_13_0_arm64.whl", hash = "sha256:c9efa966c3bf7b7a80bf461e4856546f0592cbb6a47e6f292db4d8e7270dab73", size = 26899829, upload-time = "2026-08-26T23:42:52.271Z" }, + { url = "https://files.pythonhosted.org/packages/e4/9c/fb0799f91830441e65616902c3c633f5f72166732e57c137dc6c2625179e/wandb-0.29.0-py3-none-macosx_13_0_x86_64.whl", hash = "sha256:2adae9c36a6d992c767c2248c175a1963db4771e650acdf3da471314c33d3d41", size = 28892707, upload-time = "2026-08-26T23:42:55.37Z" }, + { url = "https://files.pythonhosted.org/packages/5b/15/7270dd7ae17dd914f25794088b6d9583bc989626be472153770ca6272cf0/wandb-0.29.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:095b17df32ead3b459069b460665cea1d1781c63ad649be042894bc8e4c17b0b", size = 27547745, upload-time = "2026-08-26T23:42:57.795Z" }, + { url = "https://files.pythonhosted.org/packages/32/fc/d9a9978acffb34ae2a2e5065f421a6e62f3ad6e27ad41229e86025b56fe3/wandb-0.29.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:fdfd8845539302d2e3a880bc3d2586c2fd2da9515939cbc69e173d88f9e8c024", size = 29847397, upload-time = "2026-08-26T23:43:00.218Z" }, + { url = "https://files.pythonhosted.org/packages/dc/21/a4034ec06ed5a72cb85e3f1db013b1a8366b91ffd629bc807eb900527c73/wandb-0.29.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:217236c7347cd9c9c9e0332d5cf6d7342ca47d9d81faf2bcd1674d72988870c3", size = 27583732, upload-time = "2026-08-26T23:43:02.643Z" }, + { url = "https://files.pythonhosted.org/packages/c0/9a/6da88bb45ea28c83eeb889473f84d0d505ed5f15be40726f125621fa7467/wandb-0.29.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2bdb72ba809192a1a170830bea1071804d762769448f3ceb822b41160b9f6098", size = 30074151, upload-time = "2026-08-26T23:43:05.026Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f9/96746f484690cd23fbb84c6fd0364e8c462e804dede9efe50a98b4f27ddd/wandb-0.29.0-py3-none-win_amd64.whl", hash = "sha256:92dca672417404d2cb882fe34f38d602ddcb54ece38716f7c76630e06fc62360", size = 27040796, upload-time = "2026-08-26T23:43:07.444Z" }, + { url = "https://files.pythonhosted.org/packages/94/74/e11e2ad886ac35bb5d568308f279357868de233e0dfaec11b9ddeed53164/wandb-0.29.0-py3-none-win_arm64.whl", hash = "sha256:ac523a1cd863f1a883852146f451ec9b950b0d2278d6e3bbedd099c713d14047", size = 24663195, upload-time = "2026-08-26T23:43:09.896Z" }, +] + +[[package]] +name = "xxhash" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/a5/1386f35da1475fcaeef42581deae73417c6d2a6a0b2d2e8914de18844dcd/xxhash-4.0.1.tar.gz", hash = "sha256:d55bf4ef10eb09b8b6866790e083d26d087d84caa3cc0946ba87c3ca7ecaf7b7", size = 101513, upload-time = "2026-08-17T08:24:08.557Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/6c/dc7cffeadd06336cd934947187cd38abb263103bbc552ca0f55fe4ff595a/xxhash-4.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1ee523f51718e41753f04f7102bb4dc55a18d2ea5cbaceef8ec7ca08571bd428", size = 38444, upload-time = "2026-08-17T08:21:54.332Z" }, + { url = "https://files.pythonhosted.org/packages/75/c9/cf736f6db8c3273af18925061572db0d4357818a9ce425f4b5fb0021918e/xxhash-4.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:515a822c73abbf6a0b7c70976d9662be342835c9d78b8dc7c023411f39c35dbc", size = 36195, upload-time = "2026-08-17T08:35:13.004Z" }, + { url = "https://files.pythonhosted.org/packages/da/a2/ca1929354b6851529d0148f7f335b5e2b0281f83bab3e19f0896dc579796/xxhash-4.0.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f5d031f35962e5483a613214e61f09fe24ab523062c3646d592dc16c4a217451", size = 253113, upload-time = "2026-08-17T08:20:52.152Z" }, + { url = "https://files.pythonhosted.org/packages/de/bb/542005206af59518bc8d78a210f1e0172217bc53beb32f64a5b632e72b6b/xxhash-4.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da0264844a09b538c894e5eff25313d941deb4dedec2131b98418a71a3c9944e", size = 276525, upload-time = "2026-08-17T08:21:01.886Z" }, + { url = "https://files.pythonhosted.org/packages/1b/df/607cff25dcb0f1d35c3b04493f6ad8471edb03fd4eacbdcc5ceddef1f3e9/xxhash-4.0.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1642907941ee4b75aacc3db688af52ea02ca2305ab22af7ee686ed726b332684", size = 297703, upload-time = "2026-08-17T08:21:57.958Z" }, + { url = "https://files.pythonhosted.org/packages/15/ba/9d2275eea0b9d9c6b02921be23f7588356c60df95c763b25f0e045894d43/xxhash-4.0.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4af350bc3f329970c0e3a59af84a8a30998bf8a9167eb50cd48e59baaa1d7bec", size = 280252, upload-time = "2026-08-17T08:20:47.299Z" }, + { url = "https://files.pythonhosted.org/packages/1d/aa/2299d9f6369e550aef2abb64945e39daa34412725aa46a20d99b74d76f67/xxhash-4.0.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8ba782ca3bf1e81492611152b9a0d5264971339e95e34d69de0ac2c926be496d", size = 511041, upload-time = "2026-08-17T08:20:36.771Z" }, + { url = "https://files.pythonhosted.org/packages/83/97/31bd8b8279e6935a0719f6910ced15e9d5a2cd554b253f6027ce1b5a1c2c/xxhash-4.0.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:237b8f63a2a0fcfb1ffc06e21dad23add44e6d354b2b014364a1d41e419a4dee", size = 261812, upload-time = "2026-08-17T08:22:00.469Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c1/d180a2da23c105d8e0b02d54f9f5841013fc81c233010ec781e31f1aee4c/xxhash-4.0.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:81507a68ba84c55241fb61cce1469f473a5da4205fc8ef6f698e5948eea8dd88", size = 339878, upload-time = "2026-08-17T08:35:17.626Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3d/f584cd3172fe934f0f5a0a3917d0d7ce781f74d794fd43bb72be71c3ef6f/xxhash-4.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5f1ea31d61bcd2cd2f3ec4ca80a64187bbd7948f490b63cf0dcbc6e717b4c1e9", size = 272871, upload-time = "2026-08-17T08:20:56.067Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/2c7956b2b551682e00b9aebce9ceb0a991a131d65f9850c09f5f9760be2e/xxhash-4.0.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:06713a5aaf1d0905c5579416c020c02e42b3ceb931e86c7d3b7fb85403dee3f3", size = 301440, upload-time = "2026-08-17T08:21:35.911Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a2/0739f6482184a8026f4b022718f5f815d352059312e80696825433f0a8e7/xxhash-4.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e8cda075b10bb3917b002c74a04f9e02b7d13b5bf732571404d51c52b11c7329", size = 260157, upload-time = "2026-08-17T08:22:01.416Z" }, + { url = "https://files.pythonhosted.org/packages/a1/25/b31a7bcf1d7d116842812e54f9b944843b4236ea4fa85634e8259f342212/xxhash-4.0.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c10b9206753b64aa791b35b201485477525b26fdec5bf86e8364c388a03e2592", size = 278233, upload-time = "2026-08-17T08:21:15.674Z" }, + { url = "https://files.pythonhosted.org/packages/db/e8/5293bae090fc6119dbc5fcf5c4cc0e1536394b52d73b7904d033836c73db/xxhash-4.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f3e1a44af01b6692de0ec6caba5f0bf93ceb36896e02b7fc00952c6ea7ef39e1", size = 330270, upload-time = "2026-08-17T08:20:51.128Z" }, + { url = "https://files.pythonhosted.org/packages/72/9e/e2ab12d40921f3f34c9317637d65e011aeababf8288356ea8d527de2c1d0/xxhash-4.0.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c6fc415b5568bd9accc7187f1729a99707330c0a67a8b9f93c1149ed573ed75d", size = 478555, upload-time = "2026-08-17T08:22:04.183Z" }, + { url = "https://files.pythonhosted.org/packages/6d/32/c6148d39a49efa95f39b4cf0d41ef35a487f3b30f6fb1fc8fe8d8eab577e/xxhash-4.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:96d8de55029d42251945531f6aa7590c32b48163c66a43bf29d8657d7446a377", size = 258174, upload-time = "2026-08-17T08:35:21.18Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fb/0b04b68d6c5bc71c7a2c344f1287327b67e607f28fbcfd937697caca64b6/xxhash-4.0.1-cp312-cp312-pyemscripten_2024_0_wasm32.whl", hash = "sha256:0163b5d259de23ae9e07b7eabf435ce4704f6f205589a2b154e6af4be985ce1b", size = 20767, upload-time = "2026-08-17T08:21:00.806Z" }, + { url = "https://files.pythonhosted.org/packages/a6/be/476092aba34d1fcd313e1613a3bb3bc692f253d167b54bc90049043b5034/xxhash-4.0.1-cp312-cp312-win32.whl", hash = "sha256:1216f7ba5683f17a89eb7dcb4bc50a0b743dfe1902278d7b3d0786f538118433", size = 34669, upload-time = "2026-08-17T08:21:49.486Z" }, + { url = "https://files.pythonhosted.org/packages/aa/02/f9413d94fae43cec6d1a74c4f12156c6f4a7f5fd50e1d34defebdee3dec9/xxhash-4.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c2d525a3afabcd8e3549d85fc7e111fde6bc302d06a1893fe73adb79823415e", size = 37073, upload-time = "2026-08-17T08:22:04.886Z" }, + { url = "https://files.pythonhosted.org/packages/c1/83/6fe93c1b95acf962bc61a246df09dc2dcce895ccfc1080c9f48d0b652b92/xxhash-4.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:86b2b12bec60c678ed8f5cca0258ad93a8928ebddb6ca7732f0875afe1451d1a", size = 33299, upload-time = "2026-08-17T08:35:12.708Z" }, + { url = "https://files.pythonhosted.org/packages/f3/dd/c707286b527722f776e1fb81dd202c45623355ba1a2972337a2a26075b2b/xxhash-4.0.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:8c9fe122444e129881afd1d4d1c7ac0d3ce2d91b68c2b40173b6025ff1c31f9a", size = 43639, upload-time = "2026-08-17T08:20:54.945Z" }, + { url = "https://files.pythonhosted.org/packages/1b/3b/bb71639a0f95635f61936a6f2653599c4261b645ddddd8d00f9dfe3613e2/xxhash-4.0.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:1f3346c5c287ac3c7f38b20380f55e8768230e7252af59fabcf3b87ab21e4256", size = 40657, upload-time = "2026-08-17T08:22:12.616Z" }, + { url = "https://files.pythonhosted.org/packages/3c/91/76f3f5385faa9886a36f21fcc603f40b4c0c40ce622382f133160c48b4d9/xxhash-4.0.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:4e5141543c7f7fe3087500bbb4ac2845cb528a980aa91f8f1e661e2292ff4a5d", size = 34708, upload-time = "2026-08-17T08:35:24.614Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4a/f48f0e3e1b1ab072979fff2a5be899234e28090883e8b519d0b10215d708/xxhash-4.0.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f09ee747e2a5f876cc5ad56947734811828335e13b403dd8ea1e06d77a9dd48d", size = 35650, upload-time = "2026-08-17T08:21:09.337Z" }, + { url = "https://files.pythonhosted.org/packages/c4/53/b73d7472b196101ad1f57ed0674af3af803ac3e9ec2feadd650a7b262562/xxhash-4.0.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:acf52474b2494ef66dc7e0fb6d5e2b50c18313039ad4d275fbf9f9907c804bc5", size = 37958, upload-time = "2026-08-17T08:22:10.616Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f2/024946ad8fa532074af4e4380179da54b7ec9facc8bd0b279ec0fac4e63a/xxhash-4.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1b3cccf75eeb5b01639b2feadb042a8e07889293b7ca72fa2985e7dcb64763cf", size = 38032, upload-time = "2026-08-17T08:22:09.535Z" }, + { url = "https://files.pythonhosted.org/packages/da/e0/934af8d99bb5885711006bec30a691f728edd513d2c40f053f887d8e7577/xxhash-4.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd878d32f5c6cbce9783f8d6897561fb772211edba9dde49d85672b88ed45276", size = 35895, upload-time = "2026-08-17T08:35:16.53Z" }, + { url = "https://files.pythonhosted.org/packages/20/5f/a8011f6a1558f7ca66d9077bb4f192b1871afcea62fbd5733605d2015755/xxhash-4.0.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:41e579025a6e13a99e6d71e39c9cfc621a0dcdbbf19106325e145fa858f2d794", size = 259464, upload-time = "2026-08-17T08:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/ff/89/9665a44397547e7a3d58c0942425a976d58dcfd4b538f33220a312bf6912/xxhash-4.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74379a577a9f3b6afbdedf1b90e5c7764467051977f18a326d7d607336d743bd", size = 283949, upload-time = "2026-08-17T08:22:17.003Z" }, + { url = "https://files.pythonhosted.org/packages/34/2d/78774141266457468f29f3f5803092df4db87d8148ba74e4debd041649db/xxhash-4.0.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:acb31ecdd1a97fab5cd39a84ee9f515e727d319f796fec48703b8339b9998360", size = 303898, upload-time = "2026-08-17T08:35:27.951Z" }, + { url = "https://files.pythonhosted.org/packages/59/48/d78d22de576b42528bff87c14207de50de4f0b888221a50ff7c9d675d670/xxhash-4.0.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5b7875ac1a2edcb691f27642b8b94b904baa6bcecb7d79c72df2228ba8cb5c51", size = 287241, upload-time = "2026-08-17T08:21:13.042Z" }, + { url = "https://files.pythonhosted.org/packages/4c/de/7a1755a59c59fd46176f293bbdd99e399a6537ba9537fc723aa4d1bf6e27/xxhash-4.0.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4751f1d7eecae6b2d2a773630f1a7248f125c9a92a456694d03c15bceffc9d68", size = 519856, upload-time = "2026-08-17T08:22:15.35Z" }, + { url = "https://files.pythonhosted.org/packages/6f/fb/76580c08e916507859b0f335393cb5fdc59452c4402edbc6bcca6e47e7df/xxhash-4.0.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a51b061d54cda8b83e62c44458bfbf0dabbef9b975dd9649952ba5076b9f349", size = 268572, upload-time = "2026-08-17T08:22:14.533Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2b/1abde3e07b8f2077a38b4fbfaf764115008bfe0ff03bc7756a52c9fd0607/xxhash-4.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:74a164e8b63f1e9cf35c9a7809d082b033d1a00e7375d5d814415436e7867e57", size = 344967, upload-time = "2026-08-17T08:35:23.569Z" }, + { url = "https://files.pythonhosted.org/packages/5c/15/80b6ddf0732eef48a8b5fe717398274794392bd6dbe82af38d189d214772/xxhash-4.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f5e5c6df4b703afcbe9352d238a51efd97c3b91fdc3a2052e40fdacb1e7505f", size = 279956, upload-time = "2026-08-17T08:21:24.97Z" }, + { url = "https://files.pythonhosted.org/packages/77/e0/11cbc43c205bf81fad50d69c7319cd1b1ccc01a66cd4fb8766357126c43d/xxhash-4.0.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d54b8ae068af532c8cdf56abb9e09a60fbe7b10792444c9c27987bb6d3b450fa", size = 307583, upload-time = "2026-08-17T08:22:22.541Z" }, + { url = "https://files.pythonhosted.org/packages/1c/11/cf0bc07feb2791045b6ac075d4bf64f1a5beedef2f46ae70d7104d63a19f/xxhash-4.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1749f0688020209fe0d357ce1e1cd9ec9c6161ed0405ea949d24581c4c43fa91", size = 265848, upload-time = "2026-08-17T08:35:31.298Z" }, + { url = "https://files.pythonhosted.org/packages/d4/c4/7ada4bea2a2795073dfc42d96842930efbe7a0c1857ef4b522e4e90e5d83/xxhash-4.0.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:94ac8a6b8c47951173f0b67bf862bcb971bf24e493b9fbbdb0e010cbbc7d9f54", size = 284409, upload-time = "2026-08-17T08:21:23.156Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f4/d8ce83dd6b99ccfbdadaf2db968ae40334d2e5f73a0297e593b9ddb3df39/xxhash-4.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a33de7633c948ab2dc144af370a66e7e7af29b425dcd0f7e4f59689fb9391b53", size = 335921, upload-time = "2026-08-17T08:22:21.802Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9f/f47d8724bd8bc45b395b06b7cacea2dae0d00031af1b707184a091161df6/xxhash-4.0.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:247ece770647c0aef080561fa996f9774b4dadce2d0c42eeb98229db7dcf820d", size = 487023, upload-time = "2026-08-17T08:22:19.729Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/2d87098f3371cc1e42dd04d2285ad56bca4c56667bc501bff02d2b9fd6b5/xxhash-4.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a4553d36cc0b7fce1f35ba8a94dfd775aa3ed12f5eab2dc3b46ac75a0706b0bb", size = 264333, upload-time = "2026-08-17T08:35:27.001Z" }, + { url = "https://files.pythonhosted.org/packages/27/b8/93795ca5898ec7d7d0455283ad261c0fc76b4f0c0a69e86233bd7badb0bd/xxhash-4.0.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:87aa309a93bd5ec13f14309a305ff4e9bf74c5363fc46c264c0a22edfd5b0670", size = 20581, upload-time = "2026-08-17T08:21:39.207Z" }, + { url = "https://files.pythonhosted.org/packages/b6/96/926f7335a0a1647952c00421e8da877f658094f61336306c7cadc335c94d/xxhash-4.0.1-cp313-cp313-win32.whl", hash = "sha256:cba763d84b06bda2c38d5185dee76f1b9dfdc0789e96e476d9e10005526d0788", size = 34449, upload-time = "2026-08-17T08:22:29.362Z" }, + { url = "https://files.pythonhosted.org/packages/ea/61/8a5aeb811de093bab3434e77eff0e9461624a1a56a6a93d315d080aab2aa/xxhash-4.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:97b94fb29abf21f5f0bde15f7dbdd3a4aa2dc59f37026adc7b4bee8563b84375", size = 36520, upload-time = "2026-08-17T08:35:34.852Z" }, + { url = "https://files.pythonhosted.org/packages/04/14/97f3c74000ca36955e9cb86f6d270dcd5848b5c65afa623453f5cf2d83d6/xxhash-4.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:08ed8da18cd4fd0a6a5d6a444852d8fbd0e565388a74a4937085451b5f1a312a", size = 33428, upload-time = "2026-08-17T08:21:31.713Z" }, + { url = "https://files.pythonhosted.org/packages/81/0e/ea406a02b561d3275232ccfdb3e29df80f7a65414940e3a15721c7bea40f/xxhash-4.0.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:af05a3f650220a6c59fa0ad2410249f2d2470a05225807c378fb67458693f8df", size = 43747, upload-time = "2026-08-17T08:22:31.37Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f0/b0c94d61ccf6b5d1f8847b58ef8f923125ac4919ed5bd0eb082750ca7cbd/xxhash-4.0.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:a6e3653df1a70b8ac4191216324242e4be2bca18c9a7c10934e1bd56dc7ca15e", size = 40749, upload-time = "2026-08-17T08:22:29.431Z" }, + { url = "https://files.pythonhosted.org/packages/2f/c5/8085881a538983be0fd1c865d5df236242fea496044e2c8ca32b9f2ba39c/xxhash-4.0.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:4528cf80ebbbf57d40edfb31521ae265daa6dd636d615b1cf0ac86209579e59d", size = 34734, upload-time = "2026-08-17T08:35:33.68Z" }, + { url = "https://files.pythonhosted.org/packages/d3/94/8803d13c968fc75ca434eea991d29ac5fd8a36b4afc9a6a9803c53933db4/xxhash-4.0.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:90cb2a1c9cc503a054a19612b48ff6e8e47805f618bdb3224a07568aad03a37e", size = 35671, upload-time = "2026-08-17T08:21:48.322Z" }, + { url = "https://files.pythonhosted.org/packages/85/d5/ad91d7f0fd294190d37c08236fe661f5c4e3f83dcd1a121877a2e64681ce/xxhash-4.0.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a949b072ea59c6eca0811ccd9e95133cc50d2afda8d464b5b077c78f78efa269", size = 38094, upload-time = "2026-08-17T08:22:39.763Z" }, + { url = "https://files.pythonhosted.org/packages/89/f4/2b7ebdc1869caca5f02c4cba8379b631050d3c3d4adb9187e4dc1a6b8d3c/xxhash-4.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:79a3203aadf39637869dfea1185227d8452844d78b837e54fb1117b4d34ba5c3", size = 38244, upload-time = "2026-08-17T08:35:38.081Z" }, + { url = "https://files.pythonhosted.org/packages/90/9d/f66cf6935f528e575f1ae4d6560d376e7587569747186f4fae8777cadc1b/xxhash-4.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d9f3848ffaf010bdbabdbf4c25641fa258b6227ff27bc74a4d06edef521a4873", size = 35904, upload-time = "2026-08-17T08:21:37.358Z" }, + { url = "https://files.pythonhosted.org/packages/07/29/34569d7b482f0dc060074faafd163c588f915cbc3e3e218f1ffd8a3ad340/xxhash-4.0.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9283d9dd6b44acad35118e2976fc763a065509e4118debdb61916ec322ed17b9", size = 259595, upload-time = "2026-08-17T08:22:38.153Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/a2370acfcd48732cf5c2b87f06cfbf7fa51c0ce0dd736bde42939eb9ebf7/xxhash-4.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c7c642a0f79c3e3cf2965475507574d3d1a50ec71060039d60cb87358667cb2", size = 284279, upload-time = "2026-08-17T08:22:36.396Z" }, + { url = "https://files.pythonhosted.org/packages/08/15/17d33c24e6c4a1c0b9ddc5584f0c25d51d48b34bacde1416a2235a19db4b/xxhash-4.0.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:96dedccfb09a73a25751053a183159b88f4ee75f388df8166040c152ac0531c6", size = 303973, upload-time = "2026-08-17T08:35:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e0/4ec0d69ad5738729098a61e631b7ed2df22a922b0e03014b597c72bd863d/xxhash-4.0.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81664268dba92e037b740ecf37fa02f1cab4a391f93f28e35792b3341c60648f", size = 287535, upload-time = "2026-08-17T08:21:52.158Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8b/4f9b17e7a9eb71c65548ecddd9c18b84e3c18ca41c4d436ad2a3000d3f7b/xxhash-4.0.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:839f58c5bd9989875be0fd28446dbf32cace2c2cd8bf2f6762acdc38a95cd1aa", size = 519257, upload-time = "2026-08-17T08:22:43.272Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/3276b3e743b8ddbed9c3f71c76d9dd6a75d72aa4e678b1447b635cfd92e0/xxhash-4.0.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffa44b4c7c5d0ffa31356b4428659516c0e47647825c74079a296b3857b6d99d", size = 268190, upload-time = "2026-08-17T08:35:44.985Z" }, + { url = "https://files.pythonhosted.org/packages/08/d4/f1555de3c96721320930dbb7988c8482d82b85970076aba1a8d40e83ad43/xxhash-4.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e681a6fc7e4f715252b9b5acfb30536ec7dd1f75033a32dc617e6fa95af1a3fd", size = 345553, upload-time = "2026-08-17T08:21:41.025Z" }, + { url = "https://files.pythonhosted.org/packages/ac/98/c28908f27007087b61139d290f908dd827ffd40b88af0c43f9e1a1a7ffd5/xxhash-4.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6301d92545c591ad31c3e050aa40a5f8a4c16413f1f9e6f9322c6f0f9d2b736", size = 280499, upload-time = "2026-08-17T08:22:52.236Z" }, + { url = "https://files.pythonhosted.org/packages/a9/76/3ef57622c65816348f8196273485baab4752aae064959901e85cd867e067/xxhash-4.0.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6efb8f21cc136c79b3e5bb747c8682d37916fb202cdbbc32182de5c4e47f821f", size = 307211, upload-time = "2026-08-17T08:22:40.815Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4c/5804504bbc808968e57d6a50286dd8f8cc06e0ddd6e4ab4b1dc89ae42f35/xxhash-4.0.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:760de77279e9cf9c81d012ce0705cba13afccee9b09c480f17d778c8c5cefae8", size = 265865, upload-time = "2026-08-17T08:35:42.727Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ee/8572fdfd70e7aaaf150af899871c2cc0bb88c3295ca82172a31e04ca5168/xxhash-4.0.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a16a3fa6936e36bb1414d16a6bd012c9033e5161b68b426805b61d895392437d", size = 284545, upload-time = "2026-08-17T08:21:56.965Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f8/6eadcca0904660c848b466524e82a233d16c9d2d5258433aaf3546142d86/xxhash-4.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9c3c4b9aa9a27196b921197f7daf9e6c1412739df06a99cfa6e923879362eff6", size = 336022, upload-time = "2026-08-17T08:22:46.346Z" }, + { url = "https://files.pythonhosted.org/packages/27/df/4aa107b81602d6d6d09ab5a607c530d2d3a6b28e2e9a59b01875bd877c54/xxhash-4.0.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:863f3d3b44110f7243e86cf994aa5c5d88f2348b6e84ab4402fadadfbf9f7da7", size = 486671, upload-time = "2026-08-17T08:35:49.016Z" }, + { url = "https://files.pythonhosted.org/packages/45/b7/b2bf9b5301e9cd5f2e335fea8da0f5cf209a6594cb1fe77754774ad4a6fd/xxhash-4.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63aa52659bc32bb9bd7cb5caf523b4d14429a477762cfac886132d687c1f80fc", size = 263744, upload-time = "2026-08-17T08:21:56.165Z" }, + { url = "https://files.pythonhosted.org/packages/0b/96/35b1c02177ae26234892c2310fb4822ba62411acccbf425ab8f9fd99354a/xxhash-4.0.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:67e57b834e07ed973cee7b6da1548ff28a56458d77696fd2a5f397f340694848", size = 20563, upload-time = "2026-08-17T08:35:11.924Z" }, + { url = "https://files.pythonhosted.org/packages/51/c2/a06300b165fbd6b0cb4a9742987f2e997a9f447ce3bf7c6ac97b862ce62a/xxhash-4.0.1-cp314-cp314-win32.whl", hash = "sha256:b6c1f9c59bbe593f88a0aad30be4150f15bd57bd64efb95feeabcb8e563f1ecd", size = 35151, upload-time = "2026-08-17T08:22:44.283Z" }, + { url = "https://files.pythonhosted.org/packages/06/96/c5b37296b78f80fc97124c0fee0c7bbd1bdb6f3b18bcd8748bb113b2d8fc/xxhash-4.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:da544672efd9ad76077928a3e6c5d894e52ce82d3bf14002db4a1bf17d1a36a2", size = 37156, upload-time = "2026-08-17T08:35:46.551Z" }, + { url = "https://files.pythonhosted.org/packages/ce/5e/248f9cd169c2fb62236bedfba246d213bce728f74901e99047e3f3c55875/xxhash-4.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:d0d24a4f3fb63852cd09af46ae4b7a4d00cc8b8615a046dca543786e728d1056", size = 34379, upload-time = "2026-08-17T08:21:59.446Z" }, + { url = "https://files.pythonhosted.org/packages/58/c8/db1d37c0da0324d0298f6abd931ca1d4736e049d9f2081230a8421da74d2/xxhash-4.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:349775ac30372b344d2338b2a168c0a1312a644194da25b8bec476d55761a128", size = 38656, upload-time = "2026-08-17T08:22:49.119Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8e/e18998ec465fb977bc74272e5bf3c2e886c13b014cbef916cd607802c709/xxhash-4.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:43e5f9169e73d0f0db33b5f6b8554bcce69ac278c966daf83d5eb4eb2f13829f", size = 36306, upload-time = "2026-08-17T08:35:52.853Z" }, + { url = "https://files.pythonhosted.org/packages/ef/1a/b83f86f8a987a3cbcb7e005a6824ff64aecae35abc1395a0d44ee16c3319/xxhash-4.0.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4a252fb862b0ae2590587e625f47a0e03da05cf0205e8830b67b6596c06038b1", size = 273729, upload-time = "2026-08-17T08:21:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/02/4e/2db15aa8508e0cd5b632927a53b98234f24039ea65377e6cf996c06d2d4f/xxhash-4.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2df3ca8757dc381e75e90a4d7995a6324f58a923c7145220a7b2c0231f66fddc", size = 301083, upload-time = "2026-08-17T08:35:14.113Z" }, + { url = "https://files.pythonhosted.org/packages/26/94/ed759787ffe802bd8e31cfcdad3755cbeca2dcdafd2f790cd6f25d195199/xxhash-4.0.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bfed61996d618eb90d6eaae0178002e3466a28b06bfc557a7a3a7266378d8c5a", size = 312745, upload-time = "2026-08-17T08:22:52.232Z" }, + { url = "https://files.pythonhosted.org/packages/45/7a/f64b4a4cc8b51e950709207f55f7f56ae9c5af6631dd31d7fb443312418c/xxhash-4.0.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9761ff4a0ffa583fe850731ad24fe82c88cccb7a2294727db0955f3279a4cb3f", size = 301419, upload-time = "2026-08-17T08:35:50.143Z" }, + { url = "https://files.pythonhosted.org/packages/a0/71/bac313b8de073569b8db3152044a7cfcce87a3fa9698c18fe9f914dee6b1/xxhash-4.0.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edccc2ec58435a580f96a48a3ccae8cd0a480824119165dd90108718ad81ae6e", size = 534485, upload-time = "2026-08-17T08:22:11.515Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0c/16b5e419f24e59507ee05626d2bb0deafdb03f9f27783bc0785a9849602e/xxhash-4.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4741d42d59e4e5fa1a86c17ab9c27dc8ea459c700d91b6742fdb9138d9a516cb", size = 279605, upload-time = "2026-08-17T08:22:52.934Z" }, + { url = "https://files.pythonhosted.org/packages/5f/55/5787dd6e2d8d5b61256a5039f6b18c2193c7c1de4a2fd2413288d0d9c604/xxhash-4.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:440c401e146ce64bdb3beb8ff0c84677b6f21307c28a34779071cecee5d4d70c", size = 358924, upload-time = "2026-08-17T08:35:58.164Z" }, + { url = "https://files.pythonhosted.org/packages/f3/68/89be41991f3b0a2e91f940bdf3128852c3ed571cf560d98ad0f67024afe4/xxhash-4.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5b7979f71d06ae45a769de0699900a246d8cb632db1e8bfdc79ec019063a503c", size = 295305, upload-time = "2026-08-17T08:22:13.683Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5a/52ff0a0cc361aad393ff9a46ffe3aabbcf9c03d6c8f2612da7d553048276/xxhash-4.0.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:62198213fc3e0c56e567894b318ba45834e007d065f84ba6dc9165d21546fc56", size = 320228, upload-time = "2026-08-17T08:35:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/0f/b5/91c60ff22c7f6cd5f6d7a5bad5a2cdcb4c33987dfa50bf13f0d856279b2e/xxhash-4.0.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b3bece52127ac20044311ee73567f9f0893b5de64f9028aecc90cc740cfd525a", size = 279414, upload-time = "2026-08-17T08:23:03.212Z" }, + { url = "https://files.pythonhosted.org/packages/b9/94/9685954804d47d0390871a64bec606a0d536406382d71a784df3a5883fb4/xxhash-4.0.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a865d2d470220e659220fdb59d5b6c4422802d8d6098e1324bc4d12444798914", size = 297594, upload-time = "2026-08-17T08:35:57.881Z" }, + { url = "https://files.pythonhosted.org/packages/89/62/b67ac9412907b7a07a2a0c08c3440b9e4480231a7b3de0767e87011e4564/xxhash-4.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8580aab306888224074c7edeec734de0c3c5ccde65b2da4e6c9a5e28f7c0a1bd", size = 348526, upload-time = "2026-08-17T08:22:18.571Z" }, + { url = "https://files.pythonhosted.org/packages/37/ed/6723cc49a9f567d52d01fd7c1741b0f2e3a13e71d15f7ac49d753a20c115/xxhash-4.0.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2d52dc7c33c1b83082b707f6b7814dc76d2faaa2ea62bd9c5fab4b36f83c087f", size = 499307, upload-time = "2026-08-17T08:22:56.52Z" }, + { url = "https://files.pythonhosted.org/packages/fd/2e/7b10e101ab988d93b791023be7191d7661271d6ab31ac082276b9091042a/xxhash-4.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6a9f98af872355e0c02439e48583958eee00e60b928bb20476460d9d40cb7b4e", size = 274989, upload-time = "2026-08-17T08:36:01.834Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8d/7eabcc8d29cce40621443cff24c07d7306ef574b8956c47ac59f21098005/xxhash-4.0.1-cp314-cp314t-win32.whl", hash = "sha256:a14578102a6081465aec9cf73c76c3cd3f79f0709bdb3b8ae7ab0b54c9d8b089", size = 35482, upload-time = "2026-08-17T08:22:32.336Z" }, + { url = "https://files.pythonhosted.org/packages/ca/89/2a4268e1971f63038b79fb75e3b9c8de942cd77acabbb0c5625352a31940/xxhash-4.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c57963970d359a72262f7fe6be88f945e2334d4bc41462b7f08c37b0abf35ca6", size = 37490, upload-time = "2026-08-17T08:35:22.475Z" }, + { url = "https://files.pythonhosted.org/packages/90/7b/950ecab1fe4cf421d0a6211ddd9a0ac82e39e55c45a111ceb90953dc6c9a/xxhash-4.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:b659fad79c99b0238c7ad7e9d7dbf4eebfea9097c2dba65fa0a4d18a25b29a2f", size = 34596, upload-time = "2026-08-17T08:23:10.001Z" }, + { url = "https://files.pythonhosted.org/packages/c4/03/7dc3b85fac10751613bfedb0e120734e0e8710054abad3f931e9d3843a14/xxhash-4.0.1-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:5adf927dca8c47fde7e683fe69efdd81bc865c4db1fb6bb00b391e2b6185207b", size = 43749, upload-time = "2026-08-17T08:36:00.47Z" }, + { url = "https://files.pythonhosted.org/packages/a5/55/bfac071c5b1c6d6a3d48ab1ab96a15e958a1d7061f4afc97804292d87264/xxhash-4.0.1-cp315-cp315-android_24_x86_64.whl", hash = "sha256:c30dd1af66a820820398b26e0d74e7a9aa43cae705924f23ed828cd8e5c26c3d", size = 40758, upload-time = "2026-08-17T08:22:30.209Z" }, + { url = "https://files.pythonhosted.org/packages/79/87/49a260e685d1a74c56a69432a8ee0527ddcbd684a3c51f87edc3b75639c5/xxhash-4.0.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1bc591533fc975614f7e13594daee76af96b8e1fbcf8de76c8773858fa9e7cea", size = 34788, upload-time = "2026-08-17T08:23:09.014Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/50d72ed2170dae872e1c0fe333d0908e0a2afbffe74c5c9037d5406a4b89/xxhash-4.0.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:567cbc630302a46a8ecfd943b309ccf5372bb3718f1f3762d452df30f033bcf0", size = 35746, upload-time = "2026-08-17T08:36:05.557Z" }, + { url = "https://files.pythonhosted.org/packages/66/f0/969deaa2bab3bfd5ad5b023442124d2255b9961eef6f797ec74eb8683bdf/xxhash-4.0.1-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e998cb3685b92101ec5de0fb4d9485cf01e50bc418211955c55d98064664cf4c", size = 38098, upload-time = "2026-08-17T08:22:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/86/aa/45ed7d7b8d7b66202a47bf8ff3b77cea28d2ea54dfcdd202b4cfe043e3dc/xxhash-4.0.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:c3074db513c81f764053e3da079312ecf85a50d8350c71f4cc0105d9662a9e6c", size = 38251, upload-time = "2026-08-17T08:35:25.774Z" }, + { url = "https://files.pythonhosted.org/packages/f1/9d/45e7520a7856e13800a5dc8cd038d34c6372429465b163af0c5722f16918/xxhash-4.0.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3088dadbffa33c29e0518578430a7dff2e901a212e487aefa5faaa0dc06dad34", size = 35986, upload-time = "2026-08-17T08:23:25.854Z" }, + { url = "https://files.pythonhosted.org/packages/9e/0e/5ad466e5fea18c9f9bdc5828c0506f62190061b4a1b0e688aa54969d0a9e/xxhash-4.0.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1b50223d92df94d54e1a31469335a2c74b16692e6c1cb726f1e6949514458706", size = 264609, upload-time = "2026-08-17T08:36:04.229Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cf/8f269f85217e3dbd45e31e25e46cc26f3aff0e159ef05d228b4b982c778c/xxhash-4.0.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:427b62d62d4f967fbb10b82a3813e4875c2a6e7e7634739f17265b650c7f65a6", size = 285910, upload-time = "2026-08-17T08:22:38.589Z" }, + { url = "https://files.pythonhosted.org/packages/ca/30/2fc1a16ee0f9501d074b798ebfae52e24fa602c7117f5c4b81de71eada72/xxhash-4.0.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c6370189e8e66b7e608f533b939a9de092ddca6cce084ca0d3d414d2ed5b5d59", size = 306566, upload-time = "2026-08-17T08:23:16.895Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a7/08375cf2b997e1903663fe7525c5973b1987a4f8ad2b8d47463e9143f2ee/xxhash-4.0.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec1a470c6db94ac4589c203921e89ac1bc13e796a8b1784d8135e1893559cd3b", size = 287978, upload-time = "2026-08-17T08:36:09.296Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/90a7b404c11add9e53a497d06236152852490c3b2f21e468d97a58f26afe/xxhash-4.0.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37f667dee0f867c42894b34e2a6fe26bf195c0ea4683d9d2b713db023f242c3a", size = 520098, upload-time = "2026-08-17T08:22:41.565Z" }, + { url = "https://files.pythonhosted.org/packages/11/02/7fba10b1b17eb46308f09cc0a4ed513d74dff16b1e22a1c439f011c77129/xxhash-4.0.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f18732adcc271741bd651c3e56fa519d8a237d2cccda01fe3afb226bf87f783b", size = 268273, upload-time = "2026-08-17T08:35:29.043Z" }, + { url = "https://files.pythonhosted.org/packages/54/49/c21b228877357a3be43eeeaa22182ad1685796f415390ada475922c084e4/xxhash-4.0.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b42a5a26607e4b2409fea174773a66f2dff9dfdbf2c1a851bb7b804e2c97535", size = 350164, upload-time = "2026-08-17T08:23:29.494Z" }, + { url = "https://files.pythonhosted.org/packages/00/3c/c15bb4aa33d94b78a5553b52e7fa1070565f0199925aeadec3871de20ce9/xxhash-4.0.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:99166cc98637e8bf550cda2aab07f4f1d5f899c45fbd721801aeabcc9d404824", size = 281975, upload-time = "2026-08-17T08:36:08.139Z" }, + { url = "https://files.pythonhosted.org/packages/18/7a/b1d0388315fe7752b7725b68a912667526a1dd48ed492fcc031ac03f4b52/xxhash-4.0.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:6cf633df84d80a1668fcf61e330791dae46825e395549e7d34f376411e75088a", size = 307872, upload-time = "2026-08-17T08:22:42.206Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a1/037cb2dd8cf725c9565dfe3712b2915c0e0276a9154913dbfcbcecbeb672/xxhash-4.0.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:e259bb7e1e2d8de6b35f430f5c7220b1c0ebf3962d1ba7ec7545980d5931edb8", size = 268241, upload-time = "2026-08-17T08:23:23.997Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a9/67c44422d0ee082169b238ce24bd2796b82d7c21ed953471365df8c508d8/xxhash-4.0.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:704381264b36a18b9c62ecbabe2e71d0fc58c77c129c15355c989b10bf05b6b0", size = 284970, upload-time = "2026-08-17T08:36:13.476Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d0/254a5f51c4014cacc77a26f321372338b924f54e89efb730164ee336d850/xxhash-4.0.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:e90b4bcf1d9eb1010fdaee7c9209fb667e74c0684f3ba17f9032bd7319da90c9", size = 338074, upload-time = "2026-08-17T08:22:51.166Z" }, + { url = "https://files.pythonhosted.org/packages/64/03/f21c4830118d72ef3a958ce8bf2152f49e0d4cf200907616c9be6caf372a/xxhash-4.0.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:a65785e653573fcd1e33062760ab4c3c3440e8e910765018e4b6ed4ad07b54a0", size = 487626, upload-time = "2026-08-17T08:35:32.768Z" }, + { url = "https://files.pythonhosted.org/packages/45/1f/268a689d741d7da649317eb4ce41760140beb4179aaf43a7216fdbe8100c/xxhash-4.0.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e3996ff9b6f99180357024336bf5749a8ad6476a9a2523e535c5212b995b12a2", size = 263852, upload-time = "2026-08-17T08:23:41.871Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/adaf8101cd7f143191a0b390600294d83924b32cb13770fde8803dce27a2/xxhash-4.0.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:99054b838b74d8d3995ea0d410976ae967c46207ae22d6ddfc535e809197dab9", size = 20569, upload-time = "2026-08-17T08:36:11.952Z" }, + { url = "https://files.pythonhosted.org/packages/ee/2c/56a5eb8c993420fc07114c08f447a2b66ee996510b4764cb368b9b44c9f0/xxhash-4.0.1-cp315-cp315-win32.whl", hash = "sha256:6c45258a37fc22721395c09927cb982d3e7a83607cab15be7e2416501bd3a330", size = 35145, upload-time = "2026-08-17T08:22:50.038Z" }, + { url = "https://files.pythonhosted.org/packages/67/c7/65f210db43e62157d0fef3b4d4d7b394821e7733c8bb4ece49f91410a725/xxhash-4.0.1-cp315-cp315-win_amd64.whl", hash = "sha256:0ab851b45c70d4992be7cdeeee16f97a0b677408c758c4b1efb1cfe8030bfd37", size = 37161, upload-time = "2026-08-17T08:23:32.438Z" }, + { url = "https://files.pythonhosted.org/packages/33/ae/1a641d1d60ba219756d9ebe907ff0ecf4445adcf4fa96f6e3da57b91d439/xxhash-4.0.1-cp315-cp315-win_arm64.whl", hash = "sha256:a5b21b42a01a343096a1c018d35e9b7aec9c7065dda53ae8da071e37478b2cea", size = 34378, upload-time = "2026-08-17T08:36:15.912Z" }, + { url = "https://files.pythonhosted.org/packages/48/7f/7698b320b251806d1249e513922a626f19027e104c829a611272250350eb/xxhash-4.0.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:44ab12e8cd17d4f001769f00ad465208b4bcb897ed29e65f058f74466b57a98f", size = 38610, upload-time = "2026-08-17T08:22:55.203Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3d/436497e775b647b3b3e9a4ffe8c76c59fa4aa7a9fab6447cb59acf1b50ea/xxhash-4.0.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:45e88111ebe331de478ef8d4293efbe88f3cf8b863386c9a2357136b838e1af0", size = 36378, upload-time = "2026-08-17T08:35:36.18Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d8/17a4f8182b9257898aa2a77c2a45f70233eb8e50681a280e8e09d2ee76e9/xxhash-4.0.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bf430c587f447a554c53768ad76b9846fe7c5632180ef6f69c4fce8b0552fbd0", size = 273559, upload-time = "2026-08-17T08:23:51.075Z" }, + { url = "https://files.pythonhosted.org/packages/83/28/121bd5a5c5adb88e0da772c7bef61964cf9da92956a7a237c7d24c4351b8/xxhash-4.0.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adbd48b30e3f82c89fb2b3e6a87cdd28d113b190a5ed0ee2dee286323ee9a621", size = 299133, upload-time = "2026-08-17T08:36:14.731Z" }, + { url = "https://files.pythonhosted.org/packages/11/8f/57c7b6e04642ed738a0d08a31bed7fc63fdacb661d665f98739cc9751b62/xxhash-4.0.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e71b34978e77868cbf2d18c5206a4603f9c644dd7181bec5643bd40141d3b8c5", size = 314527, upload-time = "2026-08-17T08:22:54.224Z" }, + { url = "https://files.pythonhosted.org/packages/8e/18/42793917dbab0ea1ff71458aea4875e17a7263f2797b798af048dc81e867/xxhash-4.0.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:488ca5c5e28ef56ec4bbb12f835b3f1cbecc5f3510062e70117bc6594851932a", size = 299199, upload-time = "2026-08-17T08:23:36.864Z" }, + { url = "https://files.pythonhosted.org/packages/37/60/51dc92443923d8e908d5614f1145d8d696450f9d6c8f1abe243c6f2a0222/xxhash-4.0.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:421b94f3ba7067958d02e38960d987756347aa150df06df11aa68ae1af78c619", size = 531967, upload-time = "2026-08-17T08:36:18.66Z" }, + { url = "https://files.pythonhosted.org/packages/88/c5/d0de77de09661fac71742c4155b1cd65e274f7cc277819d702b6c8ff2db5/xxhash-4.0.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f33cf0baa91eccd2cb7b62bf00f10c2264ef578b71dd33a12962e71a36eb4d32", size = 278764, upload-time = "2026-08-17T08:22:58.15Z" }, + { url = "https://files.pythonhosted.org/packages/08/9a/589929c655aba1bfb2c41ee03e50eec1547c39c3042a66bda9c173a9614b/xxhash-4.0.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:23a4376b4a3183cb50d4d2a3179f887a7773cc695eb2c908e551bec3221b8c60", size = 359876, upload-time = "2026-08-17T08:35:40.35Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a8/c1d8c94d54d91db2215565f4b4151c1593af3e6d27ac4c00fd1e8d714a02/xxhash-4.0.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:38c3d22129a6958846a3098d68bc8e661704461c0be4793ae28836e4690c8478", size = 293548, upload-time = "2026-08-17T08:23:54.951Z" }, + { url = "https://files.pythonhosted.org/packages/8b/67/85d8abca94508a4dd10561d9dea3e6e68843c6986dd6d9c1b3729c8622e4/xxhash-4.0.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:87cbdec1a7dd930079671a60b249f3ca4e773e6fbd0676e21e36fdc9dd0f3b00", size = 318780, upload-time = "2026-08-17T08:36:17.623Z" }, + { url = "https://files.pythonhosted.org/packages/1b/16/2b920ed456b9cdcfc99ddc20c3afe42f9f807ee5850773c12fd891f3c08d/xxhash-4.0.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:6cbf4e21ef0890804b5bb9ad25c48f9c127758d7f6c66bef374efcacc63c738a", size = 277582, upload-time = "2026-08-17T08:22:57.156Z" }, + { url = "https://files.pythonhosted.org/packages/fa/cc/5811b5997aebb8452047f5800d32fc50eaa29d0ba08d4e426f84450b9c2f/xxhash-4.0.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:c101180495cb4ba3617b279a944345c53a5e73b0c150053d1fa8d8af32de9579", size = 295116, upload-time = "2026-08-17T08:23:40.868Z" }, + { url = "https://files.pythonhosted.org/packages/2d/dc/c2f3f9c2f4d6aadb79f17a9f1c9a7ee82638cc873680da044cf29537d2ee/xxhash-4.0.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:c0e6ccc2b19ec8a726b2e26062ac71ea63e15500d6bf85910e42481844fdffc1", size = 347065, upload-time = "2026-08-17T08:36:21.618Z" }, + { url = "https://files.pythonhosted.org/packages/a2/4c/750cc642c92252e10772ec09e1a1d995581ba4c3ceb24f6e2d57c7ce47ca/xxhash-4.0.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:8bcba9456242ebf180a04d9443812fd85ffe6bd12bda464dd116fcece8886ff3", size = 497208, upload-time = "2026-08-17T08:23:17.88Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2d/58693cb13d6395f39b6b9bb40c5e0db53a5df7c9fce805aa7e792f64a1a5/xxhash-4.0.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:83b8c2013edb5dc1f9e7268b6496130705bc48d79c86bb8817b3d210b81a5513", size = 274338, upload-time = "2026-08-17T08:35:44.062Z" }, + { url = "https://files.pythonhosted.org/packages/4a/08/9aa9787586d9b3e92d63343ce7dc24f0f445fd9e74ff5d6e85dd82233df5/xxhash-4.0.1-cp315-cp315t-win32.whl", hash = "sha256:aa6ccc7f31018484d652cf52db020003433f3c9fa83189c028bd807d2adde503", size = 35471, upload-time = "2026-08-17T08:24:05.795Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ab/4615789c333bee331ac417885c50105715eeb8244bfc68d2bc37dcfd63ca/xxhash-4.0.1-cp315-cp315t-win_amd64.whl", hash = "sha256:daade8936c4deaaf7b01561324ce438ba4f885d717e9adc62b4d67212ad7d7bd", size = 37488, upload-time = "2026-08-17T08:36:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/fb/81/49f718beb0c55d0411bc4bd90b50a3fbe5863a0e97a2f4d11682ba13d298/xxhash-4.0.1-cp315-cp315t-win_arm64.whl", hash = "sha256:f00330ac7e24769e2032203f2b01794d670916b0c1799fd261340f1af9499875", size = 34590, upload-time = "2026-08-17T08:23:19.597Z" }, + { url = "https://files.pythonhosted.org/packages/86/79/9127ff42a887a348dc4ce3211cf1a962836887adee6f57078132bfba78b4/xxhash-4.0.1-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:ff48915bf1871a1f19f74c11834c6329443d306cedc0c05fe7fe617810422a80", size = 31836, upload-time = "2026-08-17T08:36:28.261Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e6/f238693bfdd642adb59c99683964d46d9947fe721ff44d3bd850ae675407/xxhash-4.0.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:4a76345f5aceb4ec404918edf9c7f2b5507db864dc0d7455982009ac0890b57b", size = 34453, upload-time = "2026-08-17T08:23:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/40/4b/796ace33cdfb75c91ba6d11615c3bd436355b9f3103e05865bbee9abce57/xxhash-4.0.1-graalpy312-graalpy250_312_native-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:31d86f9e81f3e84e00131ac7c54caf5119ae4ddd82c09c31cff597c813ce1ee2", size = 38488, upload-time = "2026-08-17T08:23:59.901Z" }, + { url = "https://files.pythonhosted.org/packages/ad/23/2d549e5d5d7759eaf9ac2d2d2ab81ff60f1bb2b52cdaae8e5ec5c6524354/xxhash-4.0.1-graalpy312-graalpy250_312_native-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:deca2a30d983d240b8375ec2ee0a4288e72042827fc61df2f7671f8467e4cb2f", size = 38206, upload-time = "2026-08-17T08:36:32.193Z" }, + { url = "https://files.pythonhosted.org/packages/79/98/1ee576b27f78e6107ee4ea8ac03e8a52888dff256e57d560f8282c195563/xxhash-4.0.1-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:7c343ee174d417a44d0c3355602c0cbbfa52a04d1bbbf1723378c7d2c8f60626", size = 37127, upload-time = "2026-08-17T08:23:42.705Z" }, +] diff --git a/examples/resnet_mnist/README.md b/examples/resnet_mnist/README.md index a78b780d..9b735538 100644 --- a/examples/resnet_mnist/README.md +++ b/examples/resnet_mnist/README.md @@ -1,8 +1,8 @@ # ResNet MNIST example -This example uses a trained NNModelling wheel to classify one image. The wheel -owns the declared image preprocessing, and the script invokes its public -`predict` method. +This example uses a trained NNModelling wheel to classify one image. The +script applies the dataset's MNIST preprocessing and passes the resulting +tensor to the wheel's declared tensor contract. After training `resnet.json` in NNModelling and downloading the wheel into this directory, create the standalone environment and run the example: @@ -14,6 +14,6 @@ uv run python classify.py /absolute/path/to/digit.png ``` The installed wheel exposes `nnm_resnet_mnist.Model`; the example loads it -with `Model()` and calls its public `predict` method. Images should contain one -grayscale digit; ordinary PNG or JPEG files are accepted and resized and -normalized by the wheel's input adapter. +with `Model()` and calls `predict_tensor`. Images should contain one grayscale +digit; ordinary PNG or JPEG files are accepted and resized and normalized for +the `[1, 1, 28, 28]` model input. diff --git a/examples/resnet_mnist/classify.py b/examples/resnet_mnist/classify.py index 2fdac255..65db8872 100644 --- a/examples/resnet_mnist/classify.py +++ b/examples/resnet_mnist/classify.py @@ -7,14 +7,26 @@ from pathlib import Path import torch +from PIL import Image +from torchvision.transforms import functional as transform from nnm_resnet_mnist import Model +def _image_tensor(path: Path) -> torch.Tensor: + """Prepare one image for the wheel's declared tensor input contract.""" + + with Image.open(path) as opened: + image = opened.convert("L") + tensor = transform.to_tensor(transform.resize(image, [28, 28])) + tensor = transform.normalize(tensor, mean=[0.1307], std=[0.3081]) + return tensor.unsqueeze(0) + + def classify(model: Model, image: Path) -> dict[str, object]: """Return the top three classes for one image path.""" - scores = model.predict(image) + scores = model.predict_tensor(_image_tensor(image)) if not isinstance(scores, torch.Tensor): raise TypeError("Model.predict must return a torch.Tensor") if scores.ndim == 2 and scores.shape[0] == 1: diff --git a/examples/resnet_mnist/nnm_resnet_mnist-0.1.0-py3-none-any.whl b/examples/resnet_mnist/nnm_resnet_mnist-0.1.0-py3-none-any.whl index 6246a43b..5900dc2a 100644 Binary files a/examples/resnet_mnist/nnm_resnet_mnist-0.1.0-py3-none-any.whl and b/examples/resnet_mnist/nnm_resnet_mnist-0.1.0-py3-none-any.whl differ diff --git a/examples/resnet_mnist/uv.lock b/examples/resnet_mnist/uv.lock index 4238cdfa..35ec66c7 100644 --- a/examples/resnet_mnist/uv.lock +++ b/examples/resnet_mnist/uv.lock @@ -197,7 +197,7 @@ dependencies = [ { name = "torch" }, ] wheels = [ - { filename = "nnm_resnet_mnist-0.1.0-py3-none-any.whl", hash = "sha256:cd4e911aae5d5cebf37371b4a0a4107f9611895277c5b1eecf107ac4cf4d8eaf" }, + { filename = "nnm_resnet_mnist-0.1.0-py3-none-any.whl", hash = "sha256:9858cabf7b3024b7854a086c6b475427579ad965cc1f70a52414f3009f697a77" }, ] [package.metadata] diff --git a/examples/spamham/README.md b/examples/spamham/README.md new file mode 100644 index 00000000..35660198 --- /dev/null +++ b/examples/spamham/README.md @@ -0,0 +1,26 @@ +# SpamHam classifier wheel consumer + +Questo è un progetto `uv` standalone che usa la wheel esportata da NNModelling +per classificare un messaggio come `ham` o `spam`. La wheel deve rimanere nella +stessa cartella e deve avere il nome `nnm_spamham-0.1.0-py3-none-any.whl`. + +```bash +cd examples/spamham +uv sync +just classify "hello" +``` + +In alternativa, si può invocare direttamente lo script installato: + +```bash +uv run spamham-classify "hello" +``` + +Per eseguire i test: + +```bash +uv run pytest -q +``` + +La wheel espone `nnm_spamham.Model`; il classificatore prepara il testo con lo +stesso vocabolario e la stessa lunghezza di sequenza usati dal dataset SpamHam. diff --git a/examples/spamham/nnm_spamham-0.1.0-py3-none-any.whl b/examples/spamham/nnm_spamham-0.1.0-py3-none-any.whl index 19f7731a..abf3e685 100644 Binary files a/examples/spamham/nnm_spamham-0.1.0-py3-none-any.whl and b/examples/spamham/nnm_spamham-0.1.0-py3-none-any.whl differ diff --git a/examples/spamham/uv.lock b/examples/spamham/uv.lock index 8a3641e3..e0315929 100644 --- a/examples/spamham/uv.lock +++ b/examples/spamham/uv.lock @@ -145,7 +145,7 @@ dependencies = [ { name = "torch", version = "2.14.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform != 'darwin'" }, ] wheels = [ - { filename = "nnm_spamham-0.1.0-py3-none-any.whl", hash = "sha256:1ad0656eea60fbd17aa5cfbc8d786fed7f7d93c711cece7873dd6964a8727214" }, + { filename = "nnm_spamham-0.1.0-py3-none-any.whl", hash = "sha256:81907bd622e5101e2bfba828e1373f798bf53f30efc1b357fe7413888b771dca" }, ] [package.metadata] diff --git a/examples/vae_mnist/generated/interpolation-1-to-7.png b/examples/vae_mnist/generated/interpolation-1-to-7.png index a36b7076..bf7f8b58 100644 Binary files a/examples/vae_mnist/generated/interpolation-1-to-7.png and b/examples/vae_mnist/generated/interpolation-1-to-7.png differ diff --git a/examples/vae_mnist/nnm_vae_export-0.1.0-py3-none-any.whl b/examples/vae_mnist/nnm_vae_export-0.1.0-py3-none-any.whl index c612fe7c..0f40b458 100644 Binary files a/examples/vae_mnist/nnm_vae_export-0.1.0-py3-none-any.whl and b/examples/vae_mnist/nnm_vae_export-0.1.0-py3-none-any.whl differ diff --git a/examples/vae_mnist/uv.lock b/examples/vae_mnist/uv.lock index 2bd272bd..045ee55e 100644 --- a/examples/vae_mnist/uv.lock +++ b/examples/vae_mnist/uv.lock @@ -215,7 +215,7 @@ dependencies = [ { name = "torch" }, ] wheels = [ -{ filename = "nnm_vae_export-0.1.0-py3-none-any.whl", hash = "sha256:2050550cca61c64cd3bf150652e56bcb1b6c8333e1fe9a286c69f9bf48a3ec5b" }, + { filename = "nnm_vae_export-0.1.0-py3-none-any.whl", hash = "sha256:168bc8e99ae59026d63f77305c3da78fe4f7f237678465d22c616ab46ca400d8" }, ] [package.metadata] diff --git a/front-end/src/App.svelte b/front-end/src/App.svelte index bfdf39e4..f388490f 100644 --- a/front-end/src/App.svelte +++ b/front-end/src/App.svelte @@ -54,7 +54,7 @@ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. async function activatePathProject(payload: ProjectPathPayload): Promise> { const previous = workspaceSession; - const session = createPathProjectSession(payload, async (operation) => { + const session = await createPathProjectSession(payload, async (operation) => { // The MCP owner validates the selected project path and acknowledges the // filesystem operation only after it has completed on disk. await rpcHandler.request("project_resource", { projectPath: payload.projectPath, operation }); diff --git a/front-end/src/__tests__/projectPathWorkflow.test.ts b/front-end/src/__tests__/projectPathWorkflow.test.ts index a6f1b6aa..29d36c3f 100644 --- a/front-end/src/__tests__/projectPathWorkflow.test.ts +++ b/front-end/src/__tests__/projectPathWorkflow.test.ts @@ -22,7 +22,7 @@ describe("MCP-selected project workspace", () => { modelJson: MODEL, resources: { "model.json": { encoding: "utf8", data: MODEL } }, } - const session = createPathProjectSession(payload, async (operation) => { saved.push(operation) }) + const session = await createPathProjectSession(payload, async (operation) => { saved.push(operation) }) const changed = MODEL.replace('"name":"Demo"', '"name":"Changed"') await session.save(changed) expect(saved).toEqual([{ kind: "write", path: "model.json", encoding: "utf8", data: changed }]) @@ -32,7 +32,7 @@ describe("MCP-selected project workspace", () => { }) test("creates missing directories only when requested", async () => { - const session = createPathProjectSession({ + const session = await createPathProjectSession({ projectPath: "/projects/demo", modelJson: MODEL, resources: { "model.json": { encoding: "utf8", data: MODEL } }, @@ -52,7 +52,7 @@ describe("MCP-selected project workspace", () => { test("persists binary writes and removes directories only after remote acknowledgement", async () => { const operations: unknown[] = [] - const session = createPathProjectSession({ + const session = await createPathProjectSession({ projectPath: "/projects/demo", modelJson: MODEL, resources: { "model.json": { encoding: "utf8", data: MODEL } }, @@ -74,7 +74,7 @@ describe("MCP-selected project workspace", () => { }) test("does not expose an unacknowledged write in the local directory", async () => { - const session = createPathProjectSession({ + const session = await createPathProjectSession({ projectPath: "/projects/demo", modelJson: MODEL, resources: { "model.json": { encoding: "utf8", data: MODEL } }, @@ -90,7 +90,7 @@ describe("MCP-selected project workspace", () => { test("keeps the dataset directory and manifest when remote removal is rejected", async () => { let rejectRemoval = false - const session = createPathProjectSession({ + const session = await createPathProjectSession({ projectPath: "/projects/demo", modelJson: MODEL, resources: { "model.json": { encoding: "utf8", data: MODEL } }, diff --git a/front-end/src/__tests__/trainingController.test.ts b/front-end/src/__tests__/trainingController.test.ts index de49e02e..af382268 100644 --- a/front-end/src/__tests__/trainingController.test.ts +++ b/front-end/src/__tests__/trainingController.test.ts @@ -35,6 +35,10 @@ describe("TrainingController", () => { expect(() => controller.updateConfig({ datasetParams: { batch_size: "64" } })).toThrow(TrainingConfigurationError); expect(controller.getConfig().datasetParams.batch_size).toBe(64); expect(() => controller.updateConfig({ accelerator: "tpu" as never })).toThrow(TrainingConfigurationError); + expect(controller.getConfig().logEveryNSteps).toBe(10); + controller.updateConfig({ logEveryNSteps: 7 }); + expect(controller.getConfig().logEveryNSteps).toBe(7); + expect(() => controller.updateConfig({ logEveryNSteps: 0 })).toThrow(TrainingConfigurationError); }); it("publishes pairing status without exposing the token", async () => { diff --git a/front-end/src/components/TrainingSidebar.svelte b/front-end/src/components/TrainingSidebar.svelte index b99c0322..5a82b06a 100644 --- a/front-end/src/components/TrainingSidebar.svelte +++ b/front-end/src/components/TrainingSidebar.svelte @@ -54,6 +54,7 @@ let accelerator = $state<"auto" | "cpu" | "cuda">("auto"); let patience = $state("3"); let minDelta = $state("0"); + let logEveryNSteps = $state("10"); let seed = $state("42"); let wandbProject = $state("NeuralNetworks"); let wandbMode = $state("disabled"); @@ -117,6 +118,7 @@ datasetParams = Object.fromEntries(Object.entries(config.datasetParams).map(([key, value]) => [key, String(value ?? "")])); seed = String(config.seed); optimizerTarget = config.optimizerTarget; learningRate = String(config.learningRate); maxEpochs = String(config.maxEpochs); accelerator = config.accelerator; patience = String(config.patience); minDelta = String(config.minDelta); + logEveryNSteps = String(config.logEveryNSteps); wandbProject = config.wandbProject; wandbMode = config.wandbMode; cpu = String(config.cpu); memoryGb = String(config.memoryGb); gpu = String(config.gpu); gpuMemoryGb = config.gpuMemoryGb === undefined ? "" : String(config.gpuMemoryGb); gpuType = config.gpuType ?? ""; node = config.node ?? ""; priority = String(config.priority); @@ -214,6 +216,7 @@ optimizerTarget, learningRate: coerceTrainingValue(learningRate, "number") as number, maxEpochs: coerceTrainingValue(maxEpochs, "integer") as number, accelerator, patience: coerceTrainingValue(patience, "integer") as number, minDelta: coerceTrainingValue(minDelta, "number") as number, + logEveryNSteps: coerceTrainingValue(logEveryNSteps, "integer") as number, wandbProject, wandbMode, cpu: coerceTrainingValue(cpu, "integer") as number, memoryGb: coerceTrainingValue(memoryGb, "number") as number, gpu: coerceTrainingValue(gpu, "integer") as number, gpuMemoryGb: gpuMemoryGb ? coerceTrainingValue(gpuMemoryGb, "number") as number : undefined, @@ -511,6 +514,9 @@ + {#if wandbMode === "online" && !wandbCapabilities?.available_modes.includes("online")} La modalità online resta selezionata ma non può essere inviata finché il backend non la abilita. diff --git a/front-end/src/project-workspace/path.ts b/front-end/src/project-workspace/path.ts index dd6cde00..80c186c0 100644 --- a/front-end/src/project-workspace/path.ts +++ b/front-end/src/project-workspace/path.ts @@ -12,7 +12,7 @@ import { export type ProjectPathPayload = { readonly projectPath: string readonly modelJson: string - readonly resources: Record + readonly resources: Record } type RemoteResource = ProjectPathPayload["resources"][string] @@ -23,12 +23,12 @@ export type ProjectPathOperation = type PersistProjectPathOperation = (operation: ProjectPathOperation) => Promise /** Browser-local session for an MCP-selected path; persistence is notified to the MCP owner. */ -export function createPathProjectSession( +export async function createPathProjectSession( payload: ProjectPathPayload, persistRemote: PersistProjectPathOperation, -): ProjectWorkspaceSession { +): Promise { const files = new Map() - for (const [name, resource] of Object.entries(payload.resources)) files.set(name, decode(resource)) + await Promise.all(Object.entries(payload.resources).map(async ([name, resource]) => files.set(name, await decode(resource)))) const directories = new Set([""]) for (const name of files.keys()) { const parts = name.split("/") @@ -50,9 +50,13 @@ export function createPathProjectSession( } } -function decode(resource: RemoteResource): string | Uint8Array { +async function decode(resource: RemoteResource): Promise { if (resource.encoding === "utf8") return resource.data const bytes = Uint8Array.from(atob(resource.data), (value) => value.charCodeAt(0)) + if (resource.encoding === "base64+gzip") { + const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip")) + return new Uint8Array(await new Response(stream).arrayBuffer()) + } return bytes } diff --git a/front-end/src/sync/BrowserRPCHandler.ts b/front-end/src/sync/BrowserRPCHandler.ts index 9145d73a..33b646d1 100644 --- a/front-end/src/sync/BrowserRPCHandler.ts +++ b/front-end/src/sync/BrowserRPCHandler.ts @@ -129,8 +129,10 @@ export class BrowserRPCHandler { /** * @param diagram The Diagram instance to mutate (single source of truth). - * @param url WebSocket URL. Defaults to /ws in dev (proxied via Vite), - * or ws://localhost:9339 in production. + * @param url WebSocket URL. Defaults to the MCP server directly in dev, + * or ws://localhost:9339 in production. Direct dev access + * avoids the Vite proxy's smaller frame limit for projects + * carrying large dataset resources. * @param viewport Optional viewport controller (fitView/setCenter). * Passed from FlowCanvas.svelte via useSvelteFlow(). */ @@ -140,7 +142,7 @@ export class BrowserRPCHandler { this.url = url ?? (import.meta.env.DEV - ? `ws://${window.location.host}/ws` + ? `ws://localhost:9339` : `ws://localhost:9339`); this.viewport = viewport; this.training = training; diff --git a/front-end/src/training/api.ts b/front-end/src/training/api.ts index 589d00bf..fc85e2a4 100644 --- a/front-end/src/training/api.ts +++ b/front-end/src/training/api.ts @@ -152,7 +152,7 @@ export interface TrainingRequest { dataset: OpaqueDatasetRequest; seed: number; optimizer: { target: string; learning_rate: number }; - trainer: { max_epochs: number; accelerator: "auto" | "cpu" | "cuda"; patience: number; min_delta: number }; + trainer: { max_epochs: number; accelerator: "auto" | "cpu" | "cuda"; patience: number; min_delta: number; log_every_n_steps: number }; wandb: { project: string; mode: WandbMode }; } export interface ResourceRequest { diff --git a/front-end/src/training/controller.ts b/front-end/src/training/controller.ts index 50ddfe34..d7f57751 100644 --- a/front-end/src/training/controller.ts +++ b/front-end/src/training/controller.ts @@ -58,6 +58,7 @@ export interface TrainingConfig { accelerator: "auto" | "cpu" | "cuda"; patience: number; minDelta: number; + logEveryNSteps: number; wandbProject: string; wandbMode: WandbMode; cpu: number; @@ -149,6 +150,7 @@ const DEFAULT_CONFIG: TrainingConfig = { accelerator: "auto", patience: 3, minDelta: 0, + logEveryNSteps: 10, wandbProject: "NeuralNetworks", wandbMode: "disabled", cpu: 4, @@ -494,7 +496,13 @@ export class TrainingController { dataset: { reference: datasetReference, parameters: datasetParameters(dataset, config.datasetParams) }, seed: config.seed, optimizer: { target: config.optimizerTarget, learning_rate: config.learningRate }, - trainer: { max_epochs: config.maxEpochs, accelerator: config.accelerator, patience: config.patience, min_delta: config.minDelta }, + trainer: { + max_epochs: config.maxEpochs, + accelerator: config.accelerator, + patience: config.patience, + min_delta: config.minDelta, + log_every_n_steps: config.logEveryNSteps, + }, wandb: { project: config.wandbProject, mode: config.wandbMode }, }, resources: { @@ -701,6 +709,7 @@ function validateConfig( if (!["auto", "cpu", "cuda"].includes(config.accelerator)) throw invalid("accelerator", "valore non supportato"); integerAtLeast(config.patience, "patience", 0); nonNegative(config.minDelta, "minDelta"); + integerAtLeast(config.logEveryNSteps, "logEveryNSteps", 1); if (!["disabled", "offline", "online"].includes(config.wandbMode)) throw invalid("wandbMode", "valore non supportato"); integerAtLeast(config.cpu, "cpu", 0); positive(config.memoryGb, "memoryGb"); diff --git a/mcp-server/__tests__/project-path.test.ts b/mcp-server/__tests__/project-path.test.ts index d487f6d6..a44cc2e0 100644 --- a/mcp-server/__tests__/project-path.test.ts +++ b/mcp-server/__tests__/project-path.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "vitest" import fs from "node:fs/promises" import os from "node:os" import path from "node:path" +import { gunzipSync } from "node:zlib" import { fileURLToPath } from "node:url" import { applyProjectResource, openProjectAtPath, validateProjectPath, validateProjectResourcePath } from "../src/project-path" @@ -87,7 +88,9 @@ describe("project path boundary", () => { await fs.writeFile(path.join(projectPath, "dataset.bin"), data) const opened = await openProjectAtPath(projectPath, parent) - const reopened = Buffer.from(opened.resources["dataset.bin"].data, "base64") + const resource = opened.resources["dataset.bin"] + const encoded = Buffer.from(resource.data, "base64") + const reopened = resource.encoding === "base64+gzip" ? gunzipSync(encoded) : encoded expect(reopened.byteLength).toBe(data.byteLength) expect(reopened[0]).toBe(1) expect(reopened.at(-1)).toBe(1) diff --git a/mcp-server/src/browser-client.ts b/mcp-server/src/browser-client.ts index 22f9502c..aec8af12 100644 --- a/mcp-server/src/browser-client.ts +++ b/mcp-server/src/browser-client.ts @@ -47,10 +47,17 @@ export interface BrowserRPCClientConfig { host?: string; port?: number; requestTimeout?: number; + /** Maximum WebSocket message size for project resources. */ + maxPayload?: number; } export type BrowserNotification = (tabId: string, method: string, params: Record) => void | Promise; export type BrowserRequest = (tabId: string, method: string, params: Record) => unknown | Promise; +const DEFAULT_MAX_PAYLOAD = 256 * 1024 * 1024; +const PAYLOAD_CHUNK_BYTES = 8 * 1024 * 1024; +const PAYLOAD_CHUNK_TIMEOUT = 10_000; +const MAX_REQUEST_TIMEOUT = 180_000; + /** * BrowserRPCClient — WebSocket server that accepts multiple browser * connections (tabs) and provides a promise-based RPC interface. @@ -77,6 +84,7 @@ export class BrowserRPCClient { private host: string; private port: number; private requestTimeout: number; + private maxPayload: number; private readonly notificationListeners = new Set(); private readonly requestListeners = new Set(); @@ -84,6 +92,7 @@ export class BrowserRPCClient { this.host = config?.host ?? "localhost"; this.port = config?.port ?? 9339; this.requestTimeout = config?.requestTimeout ?? 30000; + this.maxPayload = config?.maxPayload ?? DEFAULT_MAX_PAYLOAD; } /** @@ -96,7 +105,14 @@ export class BrowserRPCClient { return Promise.resolve(); // Already started } - this.wss = new WebSocketServer({ host: this.host, port: this.port }); + this.wss = new WebSocketServer({ + host: this.host, + port: this.port, + // A project open carries model resources in one RPC request. Keep the + // limit finite, but above the largest checked-in dataset bundle after + // base64 and JSON framing overhead. + maxPayload: this.maxPayload, + }); return new Promise((resolve, reject) => { this.wss!.on("listening", () => { @@ -168,12 +184,14 @@ export class BrowserRPCClient { } const id = String(++entry.nextId); + const wireMessage = JSON.stringify({ id, method, params: params ?? {} }); + const timeout = this.timeoutForPayload(wireMessage); return new Promise((resolve, reject) => { const timer = setTimeout(() => { entry.pending.delete(id); reject(new Error(`RPC timeout: ${method}`)); - }, this.requestTimeout); + }, timeout); entry.pending.set(id, { resolve: resolve as (v: unknown) => void, @@ -181,7 +199,7 @@ export class BrowserRPCClient { timer, }); - entry.ws.send(JSON.stringify({ id, method, params: params ?? {} })); + entry.ws.send(wireMessage); }); } @@ -367,4 +385,17 @@ export class BrowserRPCClient { } entry.pending.clear(); } + + /** + * Large project payloads can spend longer in browser JSON parsing and + * resource decoding than ordinary graph calls. Scale only their watchdog; + * a disconnected browser still fails immediately through the close event. + */ + private timeoutForPayload(wireMessage: string): number { + const chunks = Math.ceil(Buffer.byteLength(wireMessage, "utf8") / PAYLOAD_CHUNK_BYTES); + return Math.min( + MAX_REQUEST_TIMEOUT, + Math.max(this.requestTimeout, this.requestTimeout + Math.max(0, chunks - 1) * PAYLOAD_CHUNK_TIMEOUT), + ); + } } diff --git a/mcp-server/src/project-path.ts b/mcp-server/src/project-path.ts index 53319ede..4cab6fe3 100644 --- a/mcp-server/src/project-path.ts +++ b/mcp-server/src/project-path.ts @@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto" import { MCPServerError } from "./errors.js" import fs from "node:fs/promises" +import { gzipSync } from "node:zlib" const PROJECT_ID = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/ const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/ @@ -41,7 +42,7 @@ export function validateProjectPath(projectPath: string, projectRoot?: string): export type ProjectPathPayload = { readonly projectPath: string readonly modelJson: string - readonly resources: Record + readonly resources: Record } export type ProjectResourceOperation = @@ -162,7 +163,7 @@ export async function rollbackCreatedProject(projectPath: string, projectRoot: s async function readFiles( directory: string, prefix: string, - resources: Record, + resources: Record, ): Promise { if (Object.keys(resources).length >= 512) throw new MCPServerError("PROJECT_TOO_LARGE", "project contains too many files") for (const entry of await fs.readdir(directory, { withFileTypes: true })) { @@ -171,9 +172,16 @@ async function readFiles( else if (entry.isFile()) { const bytes = await fs.readFile(path.join(directory, entry.name)) const resourcePath = relative.split(path.sep).join("/") - resources[resourcePath] = resourcePath === "model.json" - ? { encoding: "utf8", data: bytes.toString("utf8") } - : { encoding: "base64", data: bytes.toString("base64") } + if (resourcePath === "model.json") { + resources[resourcePath] = { encoding: "utf8", data: bytes.toString("utf8") } + } else { + const candidate = bytes.length >= 1024 * 1024 ? gzipSync(bytes) : bytes + const compressed = candidate.length < bytes.length ? candidate : bytes + resources[resourcePath] = { + encoding: compressed === bytes ? "base64" : "base64+gzip", + data: compressed.toString("base64"), + } + } } } }