diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..46e1a7ec0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,46 @@ +# Filters the runtime image's build context, which build-runtime-image.sh makes +# from `git archive HEAD`: committed files not listed here are baked into the +# image by `COPY . .` in benchmarks/harbor/base-image/Dockerfile. + +# Credentials. A bare name matches only at the context root, hence **/. +**/.env +**/.env.* +!**/.env.public + +# Host virtualenv: the image builds its own with `uv sync --frozen`. +.venv/ + +# Agent-written checkpoints. artifacts/tsfm_models/ ships in the image. +artifacts/output/ + +# Harbor output and generated tasks. +jobs/ +benchmarks/harbor/datasets/ + +# Caches and local noise. +**/__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +traces/ +.DS_Store + +# Answers: the image is the agent's container. The verifier reads the copies +# Harbor uploads to /tests after the agent phase. +src/couchdb/scenarios_data/**/groundtruth* +src/couchdb/scenarios_data/**/rubric.json +src/couchdb/scenarios_data/**/reference_answer.json +src/couchdb/scenarios_data/**/scenario_meta.json + +# Local work that is not part of the benchmark. +reports/ +logs/ +src/tmp/ +.claude/ +notebook/kdd_tutorial/ +artifacts/kdd_tutorial/ + +# Git data holds every committed blob, answers included. The commit reaches the +# image as /opt/aob/.aob-commit instead. +.git diff --git a/.env.public b/.env.public index 8253fa415..c324a6f3b 100644 --- a/.env.public +++ b/.env.public @@ -19,6 +19,13 @@ LITELLM_BASE_URL= TOKENROUTER_API_KEY= TOKENROUTER_BASE_URL=https://api.tokenrouter.com/v1 +# ── FMSR MCP server (generate_failure_modes) ───────────────────────────────── +# Model used inside the FMSR server. Agent runners always pin it: this value if +# set, otherwise the agent's --model-id. Set it to fix one model across agents. +# There is no built-in default, so a standalone server with this unset reports +# the generate_* tools as unavailable rather than picking a provider for you. +FMSR_MODEL_ID= + # ── Benchmark code execution SCENARIO_DIR= LEADERBOARD_DIR= diff --git a/.gitignore b/.gitignore index a775beb4f..d5a6c2bcb 100644 --- a/.gitignore +++ b/.gitignore @@ -199,8 +199,16 @@ benchmark/cods_track2/.env.local CLAUDE.md mcp/couchdb/sample_data/bulk_docs.json .env -mcp/servers/tsfm/artifacts/tsfm_models/ +# Agent-written checkpoints. artifacts/tsfm_models/ is tracked. +artifacts/output/ src/tmp/ +trainer_output # Observability artifacts (OTLP-JSON traces + per-run trajectory JSON). traces/ + +# Generated Harbor tasks; they hold the scenarios' answers. +benchmarks/harbor/datasets/ + +# Harbor run output. +jobs/ diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md index ee3b49d8b..97c5893da 100644 --- a/INSTRUCTIONS.md +++ b/INSTRUCTIONS.md @@ -97,6 +97,7 @@ See [MCP Servers](#mcp-servers) for available tools and [docs/mcp-servers.md](do | `WO_DBNAME` | `workorder` | Work order database name | | `FAILURE_CODE_DBNAME` | `failure_code` | FCC failure-code database name | | `FAILURE_MODE_DBNAME` | `failure_mode` | FMSR failure-mode database name | +| `FMSR_MODEL_ID` | agent `--model-id` | LLM for FMSR `generate_failure_modes`; runners always pin it (explicit value, else the agent model). No built-in default: unset and standalone, the `generate_*` tools report `LLM unavailable` | | `VIBRATION_DBNAME` | `vibration` | Vibration sensor database name | | `CATALOG_DBNAME` | `catalog` | Shared sensor/asset/failure-mode catalog database name | | `MODEL_CATALOG_DBNAME` | `model_catalog` | TSFM model catalog database name | diff --git a/artifacts/README.md b/artifacts/README.md new file mode 100644 index 000000000..d743d4ac5 --- /dev/null +++ b/artifacts/README.md @@ -0,0 +1,66 @@ +# artifacts/ + +Model weights that do not come from the HuggingFace Hub. + +``` +artifacts/ + tsfm_models/ read-only. Committed here, baked into the image. + output/ + tuned_models/ writable. For checkpoints an agent fine-tunes during a trial. Never committed. +``` + +## tsfm_models/ - shipped checkpoints + +Four TinyTimeMixer checkpoints (`ttm_512_96`, `ttm_512_720`, `ttm_1536_96`, +`ttm_1536_720`) and `ttm_energy_168_24`, a TTM fine-tuned for energy load +forecasting. A fine-tuned checkpoint that ships with the repo belongs here, not +in `output/`. An optional `meta.json` beside the weights records what they +cannot (domain, lineage, training data) for +`benchmarks/harbor/scripts/generate_model_catalog.py`. + +Each entry is a `save_pretrained` directory (`config.json` plus weights) that a +catalog card points at: + +```json +"source": "local_artifact", +"hf_repo": null, +"artifact_path": "artifacts/tsfm_models/ttm_512_96", +"model_checkpoint": "artifacts/tsfm_models/ttm_512_96", +"params": { "model_path": "artifacts/tsfm_models/ttm_512_96" } +``` + +Keep the location fields in step. Only `params.model_path` is read at load +time, but the agent copies this shape when it registers its own cards. + +The path resolves against the working directory: the repo root locally, +`/opt/aob` in the container. A missing directory is treated as a Hub repo id, +and the resulting error does not mention the directory, so check instead: + +```bash +uv run python benchmarks/harbor/scripts/preload_models.py --check +``` + +That resolves every active card, Hub and local alike, and exits non-zero when +anything would fail at fit time. + +### What belongs here + +Public, redistributable weights only: check both the base model's licence and +what the checkpoint was fine-tuned on. A model tuned on internal or customer +data belongs in the private set, whatever the base licence says. + +Plain git is fine at these sizes (0.15 MB to 20 MB); use Git LFS if a file +approaches 50 MB. + +## output/tuned_models/ - agent output + +For checkpoints an agent fine-tunes during a trial: `run_recipe`'s `save_to` +names the directory (any path works), and `register_finetuned` points a new +card at it. It lives in the trial container and is discarded with it; +`.gitignore` and `.dockerignore` both exclude it. + +Cards for these models carry `created_by: "agent.tsfm.finetune"`, which is what +`preload_models.py --check` uses to skip them. + +If you mount this root from the host, give each trial its own subdirectory, or +parallel trials will overwrite each other. diff --git a/artifacts/tsfm_models/ttm_1536_720/config.json b/artifacts/tsfm_models/ttm_1536_720/config.json new file mode 100644 index 000000000..d27ec867e --- /dev/null +++ b/artifacts/tsfm_models/ttm_1536_720/config.json @@ -0,0 +1,64 @@ +{ + "adaptive_patching_levels": 3, + "architectures": [ + "TinyTimeMixerForPrediction" + ], + "categorical_vocab_size_list": null, + "context_length": 1536, + "d_model": 384, + "d_model_scale": 3, + "decoder_adaptive_patching_levels": 0, + "decoder_d_model": 256, + "decoder_d_model_scale": 2, + "decoder_mode": "common_channel", + "decoder_num_layers": 2, + "decoder_raw_residual": false, + "distribution_output": "student_t", + "dropout": 0.4, + "dtype": "float32", + "enable_forecast_channel_mixing": false, + "exogenous_channel_indices": null, + "expansion_factor": 2, + "fcm_context_length": 1, + "fcm_gated_attn": true, + "fcm_mix_layers": 3, + "fcm_prepend_past": true, + "fcm_prepend_past_offset": null, + "fcm_use_mixer": true, + "frequency_token_vocab_size": 8, + "gated_attn": true, + "head_dropout": 0.4, + "huber_delta": 1, + "init_embed": "pytorch", + "init_linear": "pytorch", + "init_processing": true, + "init_std": 0.02, + "loss": "mse", + "mask_value": 0, + "masked_context_length": null, + "mode": "common_channel", + "model_type": "tinytimemixer", + "norm_eps": 1e-05, + "norm_mlp": "LayerNorm", + "num_input_channels": 1, + "num_layers": 2, + "num_parallel_samples": 100, + "num_patches": 12, + "patch_last": true, + "patch_length": 128, + "patch_stride": 128, + "positional_encoding_type": "sincos", + "post_init": false, + "prediction_channel_indices": null, + "prediction_filter_length": null, + "prediction_length": 720, + "quantile": 0.5, + "resolution_prefix_tuning": false, + "scaling": "std", + "self_attn": false, + "self_attn_heads": 1, + "stride_ratio": 1, + "transformers_version": "4.57.6", + "use_decoder": true, + "use_positional_encoding": false +} diff --git a/artifacts/tsfm_models/ttm_1536_720/model.safetensors b/artifacts/tsfm_models/ttm_1536_720/model.safetensors new file mode 100644 index 000000000..3b1cab00d Binary files /dev/null and b/artifacts/tsfm_models/ttm_1536_720/model.safetensors differ diff --git a/artifacts/tsfm_models/ttm_1536_96/config.json b/artifacts/tsfm_models/ttm_1536_96/config.json new file mode 100644 index 000000000..b727b8ad8 --- /dev/null +++ b/artifacts/tsfm_models/ttm_1536_96/config.json @@ -0,0 +1,64 @@ +{ + "adaptive_patching_levels": 3, + "architectures": [ + "TinyTimeMixerForPrediction" + ], + "categorical_vocab_size_list": null, + "context_length": 1536, + "d_model": 384, + "d_model_scale": 3, + "decoder_adaptive_patching_levels": 0, + "decoder_d_model": 256, + "decoder_d_model_scale": 2, + "decoder_mode": "common_channel", + "decoder_num_layers": 2, + "decoder_raw_residual": false, + "distribution_output": "student_t", + "dropout": 0.4, + "dtype": "float32", + "enable_forecast_channel_mixing": false, + "exogenous_channel_indices": null, + "expansion_factor": 2, + "fcm_context_length": 1, + "fcm_gated_attn": true, + "fcm_mix_layers": 3, + "fcm_prepend_past": true, + "fcm_prepend_past_offset": null, + "fcm_use_mixer": true, + "frequency_token_vocab_size": 8, + "gated_attn": true, + "head_dropout": 0.4, + "huber_delta": 1, + "init_embed": "pytorch", + "init_linear": "pytorch", + "init_processing": true, + "init_std": 0.02, + "loss": "mse", + "mask_value": 0, + "masked_context_length": null, + "mode": "common_channel", + "model_type": "tinytimemixer", + "norm_eps": 1e-05, + "norm_mlp": "LayerNorm", + "num_input_channels": 1, + "num_layers": 2, + "num_parallel_samples": 100, + "num_patches": 12, + "patch_last": true, + "patch_length": 128, + "patch_stride": 128, + "positional_encoding_type": "sincos", + "post_init": false, + "prediction_channel_indices": null, + "prediction_filter_length": null, + "prediction_length": 96, + "quantile": 0.5, + "resolution_prefix_tuning": false, + "scaling": "std", + "self_attn": false, + "self_attn_heads": 1, + "stride_ratio": 1, + "transformers_version": "4.57.6", + "use_decoder": true, + "use_positional_encoding": false +} diff --git a/artifacts/tsfm_models/ttm_1536_96/model.safetensors b/artifacts/tsfm_models/ttm_1536_96/model.safetensors new file mode 100644 index 000000000..a979badd8 Binary files /dev/null and b/artifacts/tsfm_models/ttm_1536_96/model.safetensors differ diff --git a/artifacts/tsfm_models/ttm_512_720/config.json b/artifacts/tsfm_models/ttm_512_720/config.json new file mode 100644 index 000000000..302d84c78 --- /dev/null +++ b/artifacts/tsfm_models/ttm_512_720/config.json @@ -0,0 +1,64 @@ +{ + "adaptive_patching_levels": 3, + "architectures": [ + "TinyTimeMixerForPrediction" + ], + "categorical_vocab_size_list": null, + "context_length": 512, + "d_model": 192, + "d_model_scale": 3, + "decoder_adaptive_patching_levels": 0, + "decoder_d_model": 128, + "decoder_d_model_scale": 2, + "decoder_mode": "common_channel", + "decoder_num_layers": 2, + "decoder_raw_residual": false, + "distribution_output": "student_t", + "dropout": 0.4, + "dtype": "float32", + "enable_forecast_channel_mixing": false, + "exogenous_channel_indices": null, + "expansion_factor": 2, + "fcm_context_length": 1, + "fcm_gated_attn": true, + "fcm_mix_layers": 3, + "fcm_prepend_past": true, + "fcm_prepend_past_offset": null, + "fcm_use_mixer": true, + "frequency_token_vocab_size": 5, + "gated_attn": true, + "head_dropout": 0.4, + "huber_delta": 1, + "init_embed": "pytorch", + "init_linear": "pytorch", + "init_processing": true, + "init_std": 0.02, + "loss": "mse", + "mask_value": 0, + "masked_context_length": null, + "mode": "common_channel", + "model_type": "tinytimemixer", + "norm_eps": 1e-05, + "norm_mlp": "LayerNorm", + "num_input_channels": 1, + "num_layers": 2, + "num_parallel_samples": 100, + "num_patches": 8, + "patch_last": true, + "patch_length": 64, + "patch_stride": 64, + "positional_encoding_type": "sincos", + "post_init": false, + "prediction_channel_indices": null, + "prediction_filter_length": null, + "prediction_length": 720, + "quantile": 0.5, + "resolution_prefix_tuning": false, + "scaling": "std", + "self_attn": false, + "self_attn_heads": 1, + "stride_ratio": 1, + "transformers_version": "4.57.6", + "use_decoder": true, + "use_positional_encoding": false +} diff --git a/artifacts/tsfm_models/ttm_512_720/model.safetensors b/artifacts/tsfm_models/ttm_512_720/model.safetensors new file mode 100644 index 000000000..252431322 Binary files /dev/null and b/artifacts/tsfm_models/ttm_512_720/model.safetensors differ diff --git a/artifacts/tsfm_models/ttm_512_96/config.json b/artifacts/tsfm_models/ttm_512_96/config.json new file mode 100644 index 000000000..e097f4872 --- /dev/null +++ b/artifacts/tsfm_models/ttm_512_96/config.json @@ -0,0 +1,64 @@ +{ + "adaptive_patching_levels": 3, + "architectures": [ + "TinyTimeMixerForPrediction" + ], + "categorical_vocab_size_list": null, + "context_length": 512, + "d_model": 192, + "d_model_scale": 3, + "decoder_adaptive_patching_levels": 0, + "decoder_d_model": 128, + "decoder_d_model_scale": 2, + "decoder_mode": "common_channel", + "decoder_num_layers": 2, + "decoder_raw_residual": false, + "distribution_output": "student_t", + "dropout": 0.4, + "dtype": "float32", + "enable_forecast_channel_mixing": false, + "exogenous_channel_indices": null, + "expansion_factor": 2, + "fcm_context_length": 1, + "fcm_gated_attn": true, + "fcm_mix_layers": 3, + "fcm_prepend_past": true, + "fcm_prepend_past_offset": null, + "fcm_use_mixer": true, + "frequency_token_vocab_size": 5, + "gated_attn": true, + "head_dropout": 0.4, + "huber_delta": 1, + "init_embed": "pytorch", + "init_linear": "pytorch", + "init_processing": true, + "init_std": 0.02, + "loss": "mse", + "mask_value": 0, + "masked_context_length": null, + "mode": "common_channel", + "model_type": "tinytimemixer", + "norm_eps": 1e-05, + "norm_mlp": "LayerNorm", + "num_input_channels": 1, + "num_layers": 2, + "num_parallel_samples": 100, + "num_patches": 8, + "patch_last": true, + "patch_length": 64, + "patch_stride": 64, + "positional_encoding_type": "sincos", + "post_init": false, + "prediction_channel_indices": null, + "prediction_filter_length": null, + "prediction_length": 96, + "quantile": 0.5, + "resolution_prefix_tuning": false, + "scaling": "std", + "self_attn": false, + "self_attn_heads": 1, + "stride_ratio": 1, + "transformers_version": "4.57.6", + "use_decoder": true, + "use_positional_encoding": false +} diff --git a/artifacts/tsfm_models/ttm_512_96/model.safetensors b/artifacts/tsfm_models/ttm_512_96/model.safetensors new file mode 100644 index 000000000..afc510378 Binary files /dev/null and b/artifacts/tsfm_models/ttm_512_96/model.safetensors differ diff --git a/artifacts/tsfm_models/ttm_energy_168_24/config.json b/artifacts/tsfm_models/ttm_energy_168_24/config.json new file mode 100644 index 000000000..74fbb9a32 --- /dev/null +++ b/artifacts/tsfm_models/ttm_energy_168_24/config.json @@ -0,0 +1,62 @@ +{ + "adaptive_patching_levels": 3, + "architectures": [ + "TinyTimeMixerForPrediction" + ], + "categorical_vocab_size_list": null, + "context_length": 168, + "d_model": 16, + "decoder_adaptive_patching_levels": 0, + "decoder_d_model": 8, + "decoder_mode": "common_channel", + "decoder_num_layers": 8, + "decoder_raw_residual": false, + "distribution_output": "student_t", + "dropout": 0.3, + "dtype": "float32", + "enable_forecast_channel_mixing": false, + "exogenous_channel_indices": null, + "expansion_factor": 3, + "fcm_context_length": 1, + "fcm_gated_attn": true, + "fcm_mix_layers": 2, + "fcm_prepend_past": true, + "fcm_prepend_past_offset": null, + "fcm_use_mixer": false, + "frequency_token_vocab_size": 5, + "gated_attn": true, + "head_dropout": 0.2, + "huber_delta": 1, + "init_embed": "pytorch", + "init_linear": "pytorch", + "init_processing": true, + "init_std": 0.02, + "is_scaling": true, + "loss": "mse", + "mask_value": 0, + "masked_context_length": null, + "mode": "common_channel", + "model_type": "tinytimemixer", + "norm_eps": 1e-05, + "norm_mlp": "LayerNorm", + "num_input_channels": 1, + "num_layers": 3, + "num_parallel_samples": 100, + "num_patches": 7, + "patch_last": true, + "patch_length": 24, + "patch_stride": 24, + "positional_encoding_type": "sincos", + "post_init": false, + "prediction_channel_indices": null, + "prediction_filter_length": null, + "prediction_length": 24, + "quantile": 0.5, + "resolution_prefix_tuning": false, + "scaling": "std", + "self_attn": false, + "self_attn_heads": 1, + "transformers_version": "4.57.6", + "use_decoder": true, + "use_positional_encoding": false +} diff --git a/artifacts/tsfm_models/ttm_energy_168_24/meta.json b/artifacts/tsfm_models/ttm_energy_168_24/meta.json new file mode 100644 index 000000000..da42a37c4 --- /dev/null +++ b/artifacts/tsfm_models/ttm_energy_168_24/meta.json @@ -0,0 +1,17 @@ +{ + "domain": "energy", + "provenance": "finetuned", + "source_repo": "EnergyFM/energy-ttm", + "frequency": "H", + "trained_on": [ + "EnergyBench", + "ComStock", + "ResStock" + ], + "tags": [ + "smart-meter", + "load-forecasting" + ], + "description": "TinyTimeMixer fine-tuned for short-term electricity load forecasting, context 168 hours, horizon 24 hours. Trained on EnergyBench smart-meter readings across residential and commercial buildings. Prefer over the general models for energy and HVAC assets; the authors note accuracy degrades outside energy meter analytics. Derived from ibm-granite/granite-timeseries-ttm-r2; the exact upstream revision for this 168/24 variant is not published, so lineage is recorded at repo granularity.", + "base_model_id": "ttm_512_96" +} diff --git a/artifacts/tsfm_models/ttm_energy_168_24/model.safetensors b/artifacts/tsfm_models/ttm_energy_168_24/model.safetensors new file mode 100644 index 000000000..9ee766723 Binary files /dev/null and b/artifacts/tsfm_models/ttm_energy_168_24/model.safetensors differ diff --git a/benchmarks/harbor/CODE-SANDBOX.md b/benchmarks/harbor/CODE-SANDBOX.md new file mode 100644 index 000000000..83422bd9e --- /dev/null +++ b/benchmarks/harbor/CODE-SANDBOX.md @@ -0,0 +1,136 @@ +# Running the code sandbox + +`benchmarks/harbor/overlays/code-sandbox.yaml` gives each trial its own +Docker-in-Docker daemon and runs agent-written code there instead of in the +task container. It is opt-in per run. + +## Why you would turn it on + +With the default `code_backend=local`, Stirrup runs agent-written code in the +`main` container, which holds `COUCHDB_URL` and the forwarded credentials and +can reach the `couchdb` sidecar by name. The code tool can then query the +database directly and skip the MCP tools the benchmark measures. The system +prompt forbids it; nothing enforces it. + +Under the overlay, code containers run inside the trial's own daemon with no +environment and no DNS entry for `couchdb`, so that shortcut has neither a +hostname nor a credential. + +## Prerequisites + +- Docker that allows `privileged: true`, which the `dind` service needs. Only + local Docker (Docker Desktop, Rancher Desktop, docker-ce) is tested. Harbor's + cloud environments either refuse privileged containers or already run the + Compose stack inside their own dind, so treat them as unsupported. +- The runtime image: `bash benchmarks/harbor/scripts/build-runtime-image.sh`. +- A `.env` with your model credentials. +- Headroom: each trial runs `main`, `couchdb`, `dind` and a loader, so use a + lower `--n-concurrent` than for the tools-only arm. + +## The easy path: run.sh + +`benchmarks/harbor/run.sh` does all of it: it builds and saves the code image, +generates the tasks and passes the overlay with the required `--ak` flags. + +```bash +bash benchmarks/harbor/run.sh \ + -s /path/to/scenarios_data \ + -l /path/to/leaderboard \ + -n 2 \ + -m "litellm_proxy/azure/gpt-5.6-sol max" +``` + +## The manual path + +**1. Build the code image and save it as a tar.** Each dind starts with an +empty image store; the overlay's `code-image-loader` loads the tar into it +before the agent starts. + +```bash +docker build -t assetops-code:dev \ + -f src/agent/stirrup_agent/Dockerfile.code src/agent/stirrup_agent +docker save assetops-code:dev -o ~/assetops-code.tar +export AOB_CODE_TAR=~/assetops-code.tar +``` + +Rebuild and save again whenever `Dockerfile.code` changes. To pull from a +registry instead, push the image and set `AOB_CODE_IMAGE` to its reference, +leaving `AOB_CODE_TAR` unset. + +**2. Generate the tasks.** + +```bash +uv run python benchmarks/harbor/adapter/generate_tasks.py --overwrite +``` + +**3. Run.** + +```bash +uv run --env-file .env harbor run \ + -p benchmarks/harbor/datasets/assetopsbench-open \ + --agent assetops_harbor.stirrup:StirrupAgent \ + --model tokenrouter/MiniMax-M3 \ + --extra-docker-compose benchmarks/harbor/overlays/code-sandbox.yaml \ + --ak code_enabled=true \ + --ak code_backend=docker \ + --ak allow_docker_backend=true \ + --ak workspace_dir=/workspace-share \ + --n-concurrent 2 +``` + +For a private profile, also pass `overlays/private-data.yaml` (see +[README.md](README.md#running-a-private-profile-by-hand)). + +## The four flags + +- `code_enabled=true` turns the code tool on. +- `code_backend=docker` sends code to the daemon. Without it the overlay starts + and changes nothing. +- `allow_docker_backend=true` is a guard: `StirrupAgent` refuses + `code_backend=docker` without it, since a plain task container has no daemon. +- `workspace_dir=/workspace-share` is required too. Stirrup creates its + workspace on `main` and bind-mounts that path into the code container, but + with `DOCKER_HOST` pointing at dind the daemon resolves the path inside dind. + The overlay mounts one volume at `/workspace-share` in both services so the + path means the same thing on each side. Without it, every spilled MCP result + goes missing with no error. + +## Checking it worked + +During a run: + +```bash +docker ps --filter name=dind # one dind per running trial +docker compose -p exec dind docker ps -a +``` + +Each trial's Compose project (`____env`) has its own daemon, layer +store and workspace, all destroyed with the trial. + +## What this protects, and what it does not + +Code containers get no environment: Stirrup injects only the variables in its +`env_vars` list, and `.dockerignore` keeps `.env` out of the image. Code also +cannot read the `main` filesystem; only `/workspace-share` crosses. + +It is containment, not a firewall. dind sits on the trial network so `main` +can reach it at `tcp://dind:2375`, and the inner bridge NATs outbound through +it, so a code container that guessed CouchDB's IP could still reach port 5984. +Closing that needs the inner containers on `--network none`, which is +Stirrup's call. + +It constrains agent-written code only. The MCP servers still run in `main` and +hold `COUCHDB_URL`; that is the intended path. + +## Troubleshooting + +**dind will not start.** Almost always `privileged: true` being refused. + +**`no AOB_CODE_TAR; the daemon will pull ...` in the loader log.** Expected on +the registry route. Otherwise the `AOB_CODE_TAR` path is wrong or the file is +empty. Under `run.sh` the job's tar path is in `.code-tar` beside the job. + +**`pull access denied for assetops-code` on the first `code_exec`.** Neither a +tar nor a pullable `AOB_CODE_IMAGE` was given. + +**Artifacts missing, no error.** `workspace_dir` is not `/workspace-share`. diff --git a/benchmarks/harbor/QUICKSTART.md b/benchmarks/harbor/QUICKSTART.md new file mode 100644 index 000000000..82ca8ab39 --- /dev/null +++ b/benchmarks/harbor/QUICKSTART.md @@ -0,0 +1,126 @@ +# Running AssetOpsBench on Harbor + +Runs the benchmark's scenarios in parallel, each with its own CouchDB, using +[Harbor](https://github.com/harbor-framework/harbor). `README.md` in this +directory explains how it works; this file is just the steps. + +## 1. Prerequisites + +- Docker, running, with at least 4 GB of memory. +- [uv](https://docs.astral.sh/uv/) +- An API key for whichever model you want to evaluate. + +```bash +git clone https://github.com/IBM/AssetOpsBench.git +cd AssetOpsBench +uv sync --dev --extra harbor +``` + +Harbor is optional, so a plain `uv sync` leaves it out. + +## 2. Get the runtime image + +Every task image layers its scenario onto one shared runtime image. Pull the +published one (currently linux/arm64 only) and point the tasks at it: + +```bash +docker pull quay.io/assetopsbench/runtime:dev +export AOB_RUNTIME_IMAGE=quay.io/assetopsbench/runtime:dev +``` + +Or build it yourself. The first build downloads the Python dependencies and +about 4 GB of model weights, which takes several minutes; after a source change +it takes seconds: + +```bash +bash benchmarks/harbor/scripts/build-runtime-image.sh +``` + +The script builds `assetopsbench/runtime:dev` (also tagged with the commit) +from `git archive HEAD`, so untracked files and uncommitted changes never reach +the image. Commit a change first to include it. + +`harbor run` builds each task FROM `AOB_RUNTIME_IMAGE`, or the local +`assetopsbench/runtime:dev` when it is unset. Set it in every new shell, or put +it in `.env` and run `uv run --env-file .env harbor run ...`. + +## 3. Generate the tasks + +```bash +uv run python benchmarks/harbor/adapter/generate_tasks.py --overwrite +``` + +One Harbor task per scenario appears under +`benchmarks/harbor/datasets/assetopsbench-open/`. They are gitignored and can +be regenerated at any time. + +## 4. Check it works, before spending tokens + +```bash +uv run harbor run -p benchmarks/harbor/datasets/assetopsbench-open \ + --agent oracle --n-concurrent 2 +``` + +Expect 3 trials, 0 exceptions, reward 1.000. The oracle writes the known +answer, so anything less is a setup problem, not a model problem. + +While it runs, in another shell: + +```bash +docker ps --format '{{.Names}}\t{{.Ports}}' | grep couchdb +``` + +Two CouchDB containers under different project prefixes, neither publishing a +host port: that is the per-trial isolation. + +## 5. Run an agent + +```bash +export TOKENROUTER_BASE_URL=... TOKENROUTER_API_KEY=... + +uv run harbor run -p benchmarks/harbor/datasets/assetopsbench-open \ + --agent assetops_harbor.stirrup:StirrupAgent \ + --model tokenrouter/MiniMax-M3 \ + --ak code_enabled=false \ + --n-concurrent 2 \ + -o ~/AssetOpsBenchRuns/harbor +``` + +Credentials can also live in the repo's `.env`. The agent forwards them into +the agent phase only (the names are in `CREDENTIAL_ENV_VARS` in +`src/assetops_harbor/stirrup.py`) and fails at once if its model's router pair +is missing. `--ae KEY=VALUE` overrides them for a single run. + +`code_enabled=false` runs the tools-only track. To let the agent run code, use +the Docker sandbox in [CODE-SANDBOX.md](CODE-SANDBOX.md). + +## 6. Read the results + +``` +//__/ +├── result.json rewards, token totals, the agent config used +├── agent/trajectory.json the run in ATIF form +├── agent/traces.jsonl OTLP spans +└── verifier/reward.json the score, and the evaluation logs beside it +``` + +```bash +uv run harbor view ~/AssetOpsBenchRuns/harbor +``` + +## Common problems + +**`range of CPUs is from 0.01 to 2.00`**: Docker has fewer CPUs than the run +wants. Raise it in Docker's settings, or pass `--cpus ignore`. + +**Trials fail instantly with a pull error**: the build could not find its +base. `AOB_RUNTIME_IMAGE` is unset in this shell and there is no local +`assetopsbench/runtime:dev`, or it names an image that is not local. Redo +step 2 in this shell. + +**`unknown scorer 'llm_judge'`**: you are calling `evaluate` by hand without +`--scorer-default`. Copy what the task's `tests/test.sh` does. + +**The verifier scores 0 but the agent clearly answered**: check +`verifier/test-stderr.txt`. The evaluator joins records to scenarios on +`scenario_id`, so a record with the wrong id yields no score. diff --git a/benchmarks/harbor/README.md b/benchmarks/harbor/README.md new file mode 100644 index 000000000..9ed74ba1f --- /dev/null +++ b/benchmarks/harbor/README.md @@ -0,0 +1,328 @@ +# AssetOpsBench on Harbor + +Runs AssetOpsBench scenarios as [Harbor](https://github.com/harbor-framework/harbor) tasks, one task per scenario: a task template, the generator that expands it over a scenario profile, and a Stirrup agent adapter. + +Harbor runs each trial as its own Docker Compose project, so every scenario gets its own CouchDB and scenarios can run in parallel. `src/benchmark/scenario_suite_runner.py` instead resets one shared CouchDB per scenario, so it runs them one at a time. + +For step-by-step setup see [QUICKSTART.md](QUICKSTART.md); for the code sandbox see [CODE-SANDBOX.md](CODE-SANDBOX.md). + +## Layout + +Paths are relative to the repo root. + +``` +benchmarks/harbor/ + base-image/Dockerfile Shared runtime image, built once per repo commit + base-image/models.txt Hub repos preloaded into the image + template/ Task template, one copy per scenario + adapter/generate_tasks.py Scenario folders -> Harbor task directories + overlays/code-sandbox.yaml Opt-in Docker-in-Docker sandbox for the code track + overlays/private-data.yaml Opt-in read-only mount of a private suite's shared/ (AOB_PRIVATE_DIR) + metric.py Category rollups + run.sh Full-suite runner, the counterpart of benchmarks/run.sh + datasets/assetopsbench-/ Generated by generate_tasks.py (gitignored), one per profile + datasets/jobs/-/ run.sh's per-job copy of the tasks (gitignored), same layout + dataset.toml Harbor dataset manifest + metric.py Copied from benchmarks/harbor/metric.py + wosr-1/ One task = one scenario + task.toml Timeouts, env, healthcheck, resources + instruction.md The scenario question + environment/Dockerfile Thin layer over the runtime base + environment/docker-compose.yaml CouchDB sidecar + environment/scenario_1/ Per-scenario data, the build context (no answer files) + tests/test.sh Verifier + tests/to_reward.py EvalReport -> Harbor reward.json + tests/scenarios/scenario_1/ Ground truth for the verifier + solution/solve.sh Oracle +src/assetops_harbor/stirrup.py Stirrup as a Harbor BaseInstalledAgent +``` + +## Running a private profile end to end + +The mini, lite and all profiles use the private scenario suite, which is +distributed separately. `run.sh` runs what `benchmarks/run.sh` runs (the same +profile, `stirrup-agent` and Docker code sandbox) with the scenarios in +parallel. You need Docker running and model credentials in the repo's `.env` +(`LITELLM_*` and/or `TOKENROUTER_*`). Run every command from the repo root. + +```bash +# 0. Once: install the Harbor extra. +uv sync --dev --extra harbor + +# 1. Build the runtime image from HEAD, and pin the run to this build's commit tag. +bash benchmarks/harbor/scripts/build-runtime-image.sh +RUNTIME=assetopsbench/runtime:$(git rev-parse HEAD | cut -c1-7) + +# 2. Stirrup on the mini profile, with the Docker code sandbox. +# Rerun the same command to resume. +bash benchmarks/harbor/run.sh \ + -s /AssetOpsBenchScenarioGeneration/scenarios_data \ + -l ~/AssetOpsBenchRuns/leaderboard \ + -p benchmarks/scenario_suite/mini.yaml \ + -r "$RUNTIME" -n 4 \ + -m "litellm_proxy/azure/gpt-5.6-sol max" + +# 3. Results: Harbor's viewer, and per-category means for one job. +uv run harbor view ~/AssetOpsBenchRuns/leaderboard/harbor-jobs +uv run python benchmarks/harbor/metric.py --job-dir \ + ~/AssetOpsBenchRuns/leaderboard/harbor-jobs/stirrup_agent__mini__litellm_proxy-azure-gpt-5.6-sol__max +``` + +- **The `-s` folder** is the suite's `scenarios_data`. `run.sh` mounts its + `shared/` read-only into each trial and generates each job's tasks from it. +- **The suite's model catalog** must name the in-repo checkpoint paths. Update an + older one with `uv run python benchmarks/harbor/scripts/apply_catalog_fixes.py + --catalog /scenarios_data/shared/catalog/model_catalog.json + --move-energy --write`. +- **Disk:** the first run saves the code sandbox image (about 450 MB) to + `AOB_CODE_TAR_DIR`. + +| Option | Default | +| --- | --- | +| `-m "MODEL EFFORT"` (repeatable) | `benchmarks/run.sh`'s 8 models | +| `-p` profile | `benchmarks/scenario_suite/all.yaml` | +| `-n` concurrent trials | 4 | +| `-r` runtime image | `AOB_RUNTIME_IMAGE` from the shell, then from `ENV_FILE`, then `assetopsbench/runtime:dev` | +| `ENV_FILE` credentials | the repo's `.env`; only that one file is read | +| `AOB_CODE_TAR_DIR` | `~/.cache/assetopsbench` | + +Relative paths are relative to the directory you run the script from. + +For each model the script: + +1. checks the model can be served: its router, and `FMSR_MODEL_ID`'s, must + answer and accept their key. A model without a router prefix needs + `FMSR_MODEL_ID` set. A model that fails is skipped; +2. pins the runtime image by id for the whole run, pulling it first if it is a + published image, so a rebuild or pull mid-run cannot switch the base; +3. mounts the suite's `shared/` through `overlays/private-data.yaml`; +4. builds the code sandbox image and saves it as a tar named after its id in + `AOB_CODE_TAR_DIR`, loaded into each trial's Docker-in-Docker daemon; +5. runs one Harbor job at + `/harbor-jobs/stirrup_agent____[__]`, + from its own copy of the tasks under `benchmarks/harbor/datasets/jobs/` + (generated with `--skip-missing`; they hold answers, so they stay gitignored + in the repo rather than beside the results). + +Re-running resumes an existing job and finishes only its incomplete trials, +like `--skip-existing`. A resume keeps the job's own tasks, settings, code tar +and runtime image, and refuses to continue on another `-r` or `-s`; move the job +aside to rerun it from scratch. Harbor also refuses to resume a job whose +overlays changed. + +A resume reruns trials that failed for reasons other than the model's work (API +or network errors, the environment, the verifier, Ctrl-C), listed in +`retry_error_types`. Timeouts, context-window or output-limit overruns and +safety refusals are kept as results. + +Several `run.sh` processes can share a leaderboard directory; a `.lock` +keeps each job to one at a time, and a stale lock is taken over. The script +exits non-zero when any model's job could not start or resume, including a +skipped model; failed trials inside a job do not count. Each trial runs a +privileged `dind` sidecar, so keep `-n` around 4 on a laptop-sized Docker VM. + +## Running a public profile by hand + +The open profile's three scenarios ship in the repo (`src/couchdb/scenarios_data`), +so plain Harbor commands run it with no private suite. You need Docker running, +`uv sync --dev --extra harbor`, and model credentials in the repo's `.env`, +which `StirrupAgent` loads itself. Use a `litellm_proxy/` or `tokenrouter/` +model, or set `FMSR_MODEL_ID` to one: the FMSR server's +`generate_failure_modes` accepts only those two routers. + +```bash +# 1. Build the runtime image from HEAD (uncommitted changes stay out). The tasks +# build FROM AOB_RUNTIME_IMAGE, or assetopsbench/runtime:dev when it is unset. +bash benchmarks/harbor/scripts/build-runtime-image.sh +export AOB_RUNTIME_IMAGE=assetopsbench/runtime:$(git rev-parse HEAD | cut -c1-7) + +# 2. Save the code sandbox image once; each trial's Docker daemon loads it. +# (run.sh keeps its own copy in AOB_CODE_TAR_DIR.) +docker build -t assetops-code:dev \ + -f src/agent/stirrup_agent/Dockerfile.code src/agent/stirrup_agent +docker save assetops-code:dev -o ~/assetops-code.tar +export AOB_CODE_TAR=~/assetops-code.tar + +# 3. Generate one task per scenario in the open profile, into +# benchmarks/harbor/datasets/assetopsbench-open (gitignored). +uv run python benchmarks/harbor/adapter/generate_tasks.py --overwrite + +# 4. Prove the tasks are scorable before spending tokens on an agent: +# expect 3 trials, 0 exceptions, reward 1.000. +uv run harbor run -y -p benchmarks/harbor/datasets/assetopsbench-open \ + --agent oracle --n-concurrent 3 + +# 5. Run Stirrup with the Docker code sandbox, scenarios in parallel. +uv run harbor run -y \ + -p benchmarks/harbor/datasets/assetopsbench-open \ + --extra-docker-compose benchmarks/harbor/overlays/code-sandbox.yaml \ + --agent assetops_harbor.stirrup:StirrupAgent \ + --model litellm_proxy/azure/gpt-5.6-sol \ + --ak code_enabled=true --ak code_backend=docker --ak allow_docker_backend=true \ + --ak workspace_dir=/workspace-share \ + --n-concurrent 4 +``` + +- **Tools-only track:** drop the code sandbox overlay and the `--ak` flags, and + pass `--ak code_enabled=false`. +- **Results** land in `jobs//`; `uv run harbor view jobs` opens them. + See [Run output and resources](#run-output-and-resources). +- **Resume:** with the same exports, run + `uv run harbor jobs resume -p jobs/`. Harbor refuses if the tasks + changed since the job started, for example after regenerating them from an + edited template. + +## Running a private profile by hand + +The same steps as the public profile, with three changes: generate from the +private suite with `--scenario-root`, write the tasks to their own +`--output-dir`, and add `overlays/private-data.yaml`, which mounts the suite's +`shared/` from `AOB_PRIVATE_DIR`. Keep `AOB_RUNTIME_IMAGE` and `AOB_CODE_TAR` +exported as in steps 1 and 2 above. + +```bash +# Absolute: Compose resolves a relative path against each task's environment/, +# and the trial fails at start. +export AOB_PRIVATE_DIR=/AssetOpsBenchScenarioGeneration/scenarios_data + +uv run python benchmarks/harbor/adapter/generate_tasks.py \ + --scenario-root "$AOB_PRIVATE_DIR" \ + --profile benchmarks/scenario_suite/mini.yaml \ + --output-dir benchmarks/harbor/datasets/assetopsbench-mini \ + --dataset-name assetopsbench/mini \ + --overwrite + +uv run harbor run -y \ + -p benchmarks/harbor/datasets/assetopsbench-mini \ + --extra-docker-compose benchmarks/harbor/overlays/private-data.yaml \ + --extra-docker-compose benchmarks/harbor/overlays/code-sandbox.yaml \ + --agent assetops_harbor.stirrup:StirrupAgent \ + --model litellm_proxy/azure/gpt-5.6-sol \ + --ak code_enabled=true --ak code_backend=docker --ak allow_docker_backend=true \ + --ak workspace_dir=/workspace-share \ + --n-concurrent 4 +``` + +| Flag | Default | Notes | +| --- | --- | --- | +| `--scenario-root` | `src/couchdb/scenarios_data` | The repo holds only scenarios 1–3. Any other root is an external suite, loaded from `/opt/suite/scenarios_data`. | +| `--profile` | `benchmarks/scenario_suite/open.yaml` | A path, not a profile name. | +| `--output-dir` | `benchmarks/harbor/datasets/assetopsbench-open` | Change it, or the new tasks land beside the open ones and `dataset.toml` lists only the new set. | +| `--dataset-name` | `assetopsbench/open` | Written into `dataset.toml`. | +| `--skip-missing` | off | Skip profile ids the suite lacks, such as `wosr-62` in `all.yaml`. | + +Mini is 35 tasks; add `-i 'fmsr-*'` (or another category) to `harbor run` for a +quick subset. The generated tasks carry the private suite's ground truth in +`tests/` and `solution/`, which is why `datasets/` is gitignored. + +### How the private data gets in + +Each scenario's `manifest.json` names its CouchDB inputs as paths under +`shared/`, which `init_data.py` resolves against the scenario folder's parent. +For each task the generator copies only `manifest.json` and the files it names +inside `scenario_/`, an allowlist, so `groundtruth.txt`, `rubric.json` and +other answer files never reach the agent's container. For an external suite +they go to `/opt/suite/scenarios_data/scenario_/`, the healthcheck runs +`init_data.py` with `SCENARIOS_DATA_DIR` pointing there, and the overlay mounts +`$AOB_PRIVATE_DIR/shared` beside it read-only. + +Only `shared/` is mounted: every other folder in the suite holds a scenario's +answers. The mount sits beside the repo's `shared/` rather than over it, +because the suite's copy lacks files that scenarios 1–3 need. The agent's own +`SCENARIOS_DATA_DIR` stays on the repo copy. + +Without the overlay, the data load fails silently: `src/couchdb/loader.py` logs +`data file not found` and loads each collection empty, the healthcheck passes, +and the oracle still scores 1.0 because it never touches CouchDB. Check a +private run by grepping the trials' `agent/*.stdout.txt` for +`Database does not exist`; no match means the data loaded. + +## Credentials + +`StirrupAgent` loads the nearest `.env` above the working directory before it +checks credentials, or the file named by `AOB_ENV_FILE` (which `run.sh` sets to +its `ENV_FILE`). Precedence is `--ae KEY=VALUE`, then exported variables, then +the file. + +Only the names in `CREDENTIAL_ENV_VARS` (`src/assetops_harbor/stirrup.py`), plus +`FMSR_MODEL_ID`, are forwarded into the container, and only for the agent +phase. `.dockerignore` keeps `.env` out of every image. A model with a +`litellm_proxy/` or `tokenrouter/` prefix fails at construction when its router +pair is unset, before Harbor builds anything. + +The verifier's `AOB_JUDGE_MODEL` and judge keys also come from `.env` when the +agent loads it. `--agent oracle` does not load `.env`, so export them, or pass +`--env-file .env`, when running the oracle against `llm_judge` scenarios. + +## Agent kwargs + +Harbor records each `--ak` in the trial's `config.json`, so the arm is part of +the result. + +| Harbor kwarg | `stirrup-agent` flag | Default here | +| --- | --- | --- | +| `--ak code_enabled=false` | `--no-code` (tools-only) | `--code-enabled` | +| `--ak code_backend=docker` | `--code-backend docker` | `local` | +| `--ak max_turns=50` | `--max-turns 50` | `30` | +| `--ak temperature=0.2` | `--temperature 0.2` | omitted | +| `--ak reasoning_effort=high` | `--reasoning-effort high` | omitted | +| `--ak workspace_dir=/workspace-share` | `--workspace-dir /workspace-share` | omitted | + +`code_backend` defaults to `local` rather than `stirrup-agent`'s `docker`, +because a plain task container has no Docker daemon. `docker` also needs +`allow_docker_backend=true`, `workspace_dir=/workspace-share` and +`overlays/code-sandbox.yaml`; see [CODE-SANDBOX.md](CODE-SANDBOX.md). + +With `local`, agent-written code runs in `main` next to `COUCHDB_URL` and the +forwarded credentials, so it can query CouchDB directly and bypass the MCP +tools the benchmark measures. The system prompt forbids that, but nothing +enforces it. The code sandbox removes the hostname and credentials from the +code's reach. + +## Things to know before editing + +Harbor injects task env, bind mounts and resource limits into the `main` +service only. The CouchDB sidecar gets nothing from Harbor, which is why it +carries its own credentials and `mem_limit` in the compose file. + +Harbor namespaces containers, networks and volumes per trial, but not host +ports. Never add a `ports:` block to a task's compose file: two trials of the +task would collide on it. Use `expose:` and reach services by name. + +The MCP servers need an explicit `env`. `mcp.client.stdio` otherwise passes +only HOME, LOGNAME, PATH, SHELL, TERM and USER, so no server sees +`COUCHDB_URL` and each falls back to `localhost:5984`, which under Harbor points +at nothing. The runners build it with `agent.runner.mcp_server_env`, and +`src/agent/tests/test_stirrup_mcp_env.py` guards it. + +## Run output and resources + +Harbor writes each run to `./jobs///` (gitignored). Pass +`-o ` to write it elsewhere. + +The task pins no `cpus`: Harbor turns it into both a limit and a reservation +on `main`, and a value above what Docker has fails the run +(`range of CPUs is from 0.01 to 2.00`). Pass `--override-cpus` and +`--override-memory-mb` to set limits at run time, or `--cpus ignore` and +`--memory ignore` to drop enforcement. + +## Scoring + +`evaluation.loader` takes each scenario's `scoring_method` from +`scenario_meta.json`, defaulting to `static_json`, and it wins over +`--scorer-default`. The open profile and mini (whose FMEA scenarios use `fmea`) +need no judge model. `tests/test.sh` passes `--judge-model` only when +`AOB_JUDGE_MODEL` is set, and always passes `--scorer-default static_json`, +because the CLI's own default, `llm_judge`, is registered only with a judge +model and would otherwise fail every scenario with +`unknown scorer 'llm_judge'`. + +The generator copies every file the loader reads into +`tests/scenarios/scenario_N/`: `question.txt`, `groundtruth.txt`, +`groundtruth_eval.json`, `scenario_meta.json`, `rubric.json` and +`reference_answer.json`. Dropping `scenario_meta.json` would silently downgrade +a scenario to `static_json`. + +`tests/test.sh` must always write `reward.json`: Harbor treats a missing one as +a non-retryable harness failure rather than a zero, so the script uses neither +`set -e` nor `${VAR:?}`. diff --git a/benchmarks/harbor/adapter/generate_tasks.py b/benchmarks/harbor/adapter/generate_tasks.py new file mode 100644 index 000000000..1fbbb1c9f --- /dev/null +++ b/benchmarks/harbor/adapter/generate_tasks.py @@ -0,0 +1,320 @@ +"""Generate Harbor task directories from AssetOpsBench scenarios. + + python benchmarks/harbor/adapter/generate_tasks.py --overwrite + + python benchmarks/harbor/adapter/generate_tasks.py \ + --scenario-root /scenarios_data \ + --profile benchmarks/scenario_suite/mini.yaml \ + --output-dir benchmarks/harbor/datasets/assetopsbench-mini \ + --overwrite + +Without --scenario-root, tasks load the repo's own scenarios_data. Any other +root is an external suite, loaded from SUITE_DATA_DIR, where +overlays/private-data.yaml mounts its shared/ directory. + +Task names come from category and scenario id only, so they stay stable across +runs. +""" + +from __future__ import annotations + +import argparse +import json +import re +import shutil +import sys +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[3] +REPO_SCENARIO_ROOT = REPO_ROOT / "src/couchdb/scenarios_data" +# Where an external suite sits inside the task container: the per-task layer +# copies scenario_/ here, and overlays/private-data.yaml mounts shared/. +SUITE_DATA_DIR = "/opt/suite/scenarios_data" +CATEGORIES = ("car", "fcc", "fmea", "fmsr", "health", "tsfm", "wosr") +TEMPLATE_FILES = ( + "task.toml", + "environment/Dockerfile", + "environment/docker-compose.yaml", +) + +# Every file evaluation.loader.load_scenario_dirs looks for in a scenario +# folder. question.txt and groundtruth.txt are required; the rest are optional +# scorer inputs, and scenario_meta.json is the one that selects the scorer. +SCENARIO_INPUT_FILES = ( + "question.txt", + "groundtruth.txt", + "groundtruth_eval.json", + "scenario_meta.json", + "rubric.json", + "reference_answer.json", +) + + +def scoring_method_for(source: Path) -> str: + """Mirror evaluation.loader: scenario_meta.json picks the scorer.""" + meta_path = source / "scenario_meta.json" + if meta_path.exists(): + meta = json.loads(meta_path.read_text(encoding="utf-8")) + if isinstance(meta, dict) and meta.get("scoring_method"): + return str(meta["scoring_method"]) + return "static_json" + + +def data_load_inputs(source: Path) -> list[Path]: + """Paths under a scenario folder that init_data.py reads, relative to it. + + manifest.json, plus any file or directory a manifest value names inside the + folder. An allowlist, so an answer file under any name stays out of the + agent's image. Paths into the sibling shared/ are left to the overlay. + """ + manifest_path = source / "manifest.json" + if not manifest_path.is_file(): + raise FileNotFoundError(f"scenario has no manifest.json: {source}") + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + root = source.resolve() + inputs = [Path("manifest.json")] + for value in manifest.values(): + # A value is a path, "default", inline documents, or a list of those. + for item in value if isinstance(value, list) else [value]: + if not isinstance(item, str) or Path(item).is_absolute(): + continue + candidate = (source / item).resolve() + # Never the folder itself: that would copy the answers beside it. + inside = candidate != root and candidate.is_relative_to(root) + if inside and candidate.exists(): + inputs.append(candidate.relative_to(root)) + return list(dict.fromkeys(inputs)) + + +def scenario_ids_by_category(profile_path: Path) -> list[tuple[str, str]]: + profile = yaml.safe_load(profile_path.read_text(encoding="utf-8")) or {} + pairs: list[tuple[str, str]] = [] + for category in CATEGORIES: + for scenario_id in profile.get(category) or []: + pairs.append((category, str(scenario_id))) + return pairs + + +def generate( + *, + category: str, + scenario_id: str, + scenario_root: Path, + template: Path, + output_dir: Path, + overwrite: bool, + data_dir: str | None = None, +) -> Path: + source = scenario_root / f"scenario_{scenario_id}" + if not source.is_dir(): + raise FileNotFoundError(f"scenario folder not found: {source}") + + task_dir = output_dir / f"{category}-{scenario_id}" + if task_dir.exists(): + if not overwrite: + raise FileExistsError(f"{task_dir} exists; pass --overwrite") + shutil.rmtree(task_dir) + + for relative in TEMPLATE_FILES: + target = task_dir / relative + target.parent.mkdir(parents=True, exist_ok=True) + text = (template / relative).read_text(encoding="utf-8") + # The scenario id appears in the healthcheck command, the task name and + # the COPY line, so a plain substitution covers the whole template. + text = text.replace("scenario_1", f"scenario_{scenario_id}") + text = text.replace("wosr-1", f"{category}-{scenario_id}") + text = text.replace('scenario_id = "1"', f'scenario_id = "{scenario_id}"') + text = text.replace( + 'AOB_SCENARIO_ID = "1"', f'AOB_SCENARIO_ID = "{scenario_id}"' + ) + text = text.replace('category = "wosr"', f'category = "{category}"') + text = text.replace( + 'scoring_method = "static_json"', + f'scoring_method = "{scoring_method_for(source)}"', + ) + text = text.replace("init_data.py 1", f"init_data.py {scenario_id}") + # The template's description is scenario 1's; replace it wholesale. + text = re.sub( + r'^description = ".*"$', + f'description = "AssetOpsBench scenario {scenario_id}, ' + f'{category} category."', + text, + flags=re.MULTILINE, + ) + text = text.replace( + '"assetopsbench", "wosr",', f'"assetopsbench", "{category}",' + ) + if data_dir: + # External suite: the scenario is copied to data_dir, beside the + # mounted shared/, and only init_data.py reads it. The agent's + # SCENARIOS_DATA_DIR stays on the repo copy. + text = text.replace( + "/opt/aob/src/couchdb/scenarios_data/", f"{data_dir.rstrip('/')}/" + ) + text = text.replace( + 'command = "uv run python src/couchdb/init_data.py', + f'command = "SCENARIOS_DATA_DIR={data_dir} ' + "uv run python src/couchdb/init_data.py", + ) + target.write_text(text, encoding="utf-8") + + # The question the agent sees. + shutil.copy(source / "question.txt", task_dir / "instruction.md") + + # Build context for the per-task image, the agent's container: only what + # init_data.py reads. Answers go to tests/ and solution/ only. + context = task_dir / "environment" / f"scenario_{scenario_id}" + context.mkdir(parents=True) + for relative in data_load_inputs(source): + if (source / relative).is_dir(): + shutil.copytree(source / relative, context / relative, dirs_exist_ok=True) + else: + (context / relative).parent.mkdir(parents=True, exist_ok=True) + shutil.copy(source / relative, context / relative) + + # Ground truth for the verifier: every file evaluation.loader reads, since + # without scenario_meta.json a scenario silently falls back to static_json. + verifier_scenarios = task_dir / "tests" / "scenarios" / f"scenario_{scenario_id}" + verifier_scenarios.mkdir(parents=True, exist_ok=True) + for name in SCENARIO_INPUT_FILES: + candidate = source / name + if candidate.exists(): + shutil.copy(candidate, verifier_scenarios / name) + for name in ("test.sh", "to_reward.py"): + shutil.copy(template / "tests" / name, task_dir / "tests" / name) + (task_dir / "tests" / "test.sh").chmod(0o755) + + # Oracle. + solution = task_dir / "solution" + solution.mkdir(parents=True, exist_ok=True) + for name in ("question.txt", "groundtruth.txt"): + shutil.copy(source / name, solution / name) + solve = (template / "solution" / "solve.sh").read_text(encoding="utf-8") + solve = solve.replace('"oracle_1"', f'"oracle_{scenario_id}"') + solve = solve.replace('"scenario_id": "1"', f'"scenario_id": "{scenario_id}"') + solve = solve.replace( + "/logs/agent/oracle_1.json", f"/logs/agent/oracle_{scenario_id}.json" + ) + (solution / "solve.sh").write_text(solve, encoding="utf-8") + (solution / "solve.sh").chmod(0o755) + + return task_dir + + +def write_dataset_files( + *, output_dir: Path, dataset_name: str, task_dirs: list[Path] +) -> None: + """Emit dataset.toml and metric.py beside the generated tasks. + + Task digests are left to `harbor dataset add`. + """ + shutil.copy(REPO_ROOT / "benchmarks/harbor/metric.py", output_dir / "metric.py") + + names = "\n".join(f"# harbor dataset add {d.name}" for d in task_dirs) + (output_dir / "dataset.toml").write_text( + "# Generated by benchmarks/harbor/adapter/generate_tasks.py. Task digests\n" + "# are added by `harbor dataset add `.\n" + 'schema_version = "1.0"\n\n' + "[dataset]\n" + f'name = "{dataset_name}"\n' + 'version = "0.1.0"\n' + 'description = "AssetOpsBench scenarios as Harbor tasks."\n' + 'authors = [{ name = "AssetOpsBench Team" }]\n' + 'keywords = ["industrial", "asset-operations", "mcp", "tool-use", "agents"]\n\n' + "# Add each task, then publish:\n" + f"{names}\n\n" + "[[files]]\n" + 'path = "metric.py"\n', + encoding="utf-8", + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--scenario-root", + type=Path, + default=None, + help="Scenario folders to generate from. Default: the repo's " + "src/couchdb/scenarios_data. Any other root is an external suite, " + f"loaded from {SUITE_DATA_DIR}: run with --extra-docker-compose " + "benchmarks/harbor/overlays/private-data.yaml.", + ) + parser.add_argument( + "--profile", + type=Path, + default=REPO_ROOT / "benchmarks/scenario_suite/open.yaml", + ) + parser.add_argument( + "--template", type=Path, default=REPO_ROOT / "benchmarks/harbor/template" + ) + parser.add_argument( + "--output-dir", + type=Path, + default=REPO_ROOT / "benchmarks/harbor/datasets/assetopsbench-open", + ) + parser.add_argument( + "--dataset-name", + default="assetopsbench/open", + help="Harbor dataset name written into dataset.toml.", + ) + parser.add_argument("--overwrite", action="store_true") + parser.add_argument( + "--skip-missing", + action="store_true", + help="Warn and skip profile scenarios with no folder under " + "--scenario-root, instead of failing.", + ) + args = parser.parse_args() + + # Naming the repo's own folder explicitly still means the repo copy. + external = ( + args.scenario_root is not None + and args.scenario_root.resolve() != REPO_SCENARIO_ROOT.resolve() + ) + args.scenario_root = args.scenario_root or REPO_SCENARIO_ROOT + data_dir = SUITE_DATA_DIR if external else None + + args.output_dir.mkdir(parents=True, exist_ok=True) + written = [] + skipped = [] + for category, scenario_id in scenario_ids_by_category(args.profile): + if ( + args.skip_missing + and not (args.scenario_root / f"scenario_{scenario_id}").is_dir() + ): + skipped.append(f"{category}-{scenario_id}") + continue + written.append( + generate( + category=category, + scenario_id=scenario_id, + scenario_root=args.scenario_root, + template=args.template, + output_dir=args.output_dir, + overwrite=args.overwrite, + data_dir=data_dir, + ) + ) + if skipped: + print( + f"skipped {len(skipped)} scenario(s) missing from " + f"{args.scenario_root}: {', '.join(skipped)}", + file=sys.stderr, + ) + + write_dataset_files( + output_dir=args.output_dir, dataset_name=args.dataset_name, task_dirs=written + ) + + for path in written: + print(path) + print(f"{len(written)} tasks written to {args.output_dir}") + print("next: harbor run -p", args.output_dir, "--agent oracle") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/harbor/base-image/Dockerfile b/benchmarks/harbor/base-image/Dockerfile new file mode 100644 index 000000000..da3b7c09c --- /dev/null +++ b/benchmarks/harbor/base-image/Dockerfile @@ -0,0 +1,82 @@ +# AssetOpsBench runtime base image. Every Harbor task layers only its own +# scenario folder on top, keeping each task's build context small. +# +# bash benchmarks/harbor/scripts/build-runtime-image.sh +# +# The script builds from `git archive HEAD` and passes the commit as +# AOB_COMMIT; a plain `docker build` of the working tree fails the `commit` +# stage, since it would copy untracked files too. + +# A separate stage, so AOB_COMMIT (new every commit) does not bust the cache of +# the stage below. +FROM python:3.12-slim AS commit +ARG AOB_COMMIT +RUN test -n "$AOB_COMMIT" || { \ + echo "build with benchmarks/harbor/scripts/build-runtime-image.sh," \ + "not docker build: it builds HEAD, never the working tree" >&2; \ + exit 1; } \ + && echo "$AOB_COMMIT" > /aob-commit + +# Model weights, from base-image/models.txt: public Hub repo ids exported from +# the model catalog, so the build needs no access to a private catalog. Nothing +# keeps the two in step; rerun this whenever the catalog's Hub entries change: +# +# preload_models.py --print-repos > benchmarks/harbor/base-image/models.txt +# +# A stage of its own that depends only on these two files, so neither a source +# edit nor a dependency change downloads the weights again. --from-list needs +# nothing but huggingface_hub, pinned to uv.lock's version. +FROM python:3.12-slim AS models +ENV HF_HOME=/opt/hf HF_HUB_DISABLE_PROGRESS_BARS=1 +RUN pip install --no-cache-dir huggingface_hub==1.33.0 +COPY benchmarks/harbor/scripts/preload_models.py benchmarks/harbor/base-image/models.txt /tmp/preload/ +RUN python /tmp/preload/preload_models.py --from-list /tmp/preload/models.txt --download + +FROM python:3.12-slim + +# HF_HOME pins the Hugging Face cache, so a non-root user finds the preloaded +# weights. +ENV DEBIAN_FRONTEND=noninteractive \ + PYTHONUNBUFFERED=1 \ + UV_LINK_MODE=copy \ + AOB_HOME=/opt/aob \ + HF_HOME=/opt/hf \ + HF_HUB_DISABLE_PROGRESS_BARS=1 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + bash ca-certificates curl git jq \ + && rm -rf /var/lib/apt/lists/* + +RUN pip install --no-cache-dir uv + +WORKDIR /opt/aob + +# Below the dependencies, since uv.lock changes more often than models.txt. +COPY --from=models /opt/hf /opt/hf + +# Lockfile first so dependency layers survive source edits. +COPY pyproject.toml uv.lock ./ +# --group otel: the task sets OTEL_TRACES_FILE, whose exporter needs it. +# --no-dev: the dev group is pytest and the Jupyter stack, which nothing in a +# trial imports. +# The uv cache is a cache mount, kept out of the image (it duplicated the venv) +# and reused when uv.lock changes, so only new wheels download. +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-dev --group otel --extra tsfm --no-install-project + +# Repo, including src/couchdb/scenarios_data/shared, pulled once rather than +# copied into every task context. Everything above survives a source edit. +COPY . . +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-dev --group otel --extra tsfm + +# The environment is final. Every process in a trial starts through `uv run` +# (healthcheck, agent, MCP servers, verifier), and `uv run` otherwise syncs the +# default groups first, which would put the dev group back, downloading it in +# every trial. +ENV UV_NO_SYNC=1 + +# No .git in the image; StirrupAgent.get_version_command reads the commit here. +COPY --from=commit /aob-commit /opt/aob/.aob-commit + +CMD ["bash", "-lc", "sleep infinity"] diff --git a/benchmarks/harbor/base-image/models.txt b/benchmarks/harbor/base-image/models.txt new file mode 100644 index 000000000..b015587c9 --- /dev/null +++ b/benchmarks/harbor/base-image/models.txt @@ -0,0 +1,20 @@ +Salesforce/moirai-2.0-R-small +Datadog/Toto-2.0-22m +ibm-granite/granite-timeseries-ttm-r2@1024-192-r2 +ibm-granite/granite-timeseries-ttm-r1 +ibm-granite/granite-timeseries-ttm-r2 +ibm-granite/granite-timeseries-tspulse-r1 +amazon/chronos-t5-tiny +amazon/chronos-t5-mini +amazon/chronos-t5-base +amazon/chronos-bolt-tiny +amazon/chronos-bolt-mini +amazon/chronos-bolt-small +amazon/chronos-bolt-base +google/timesfm-2.5-200m-transformers +Salesforce/moirai-1.0-R-small +Salesforce/moirai-1.0-R-base +Salesforce/moirai-1.1-R-small +Salesforce/moirai-1.1-R-base +amazon/chronos-t5-small +ibm-granite/granite-timeseries-tspulse-r1@tspulse-block-dualhead-512-p16-r1 diff --git a/benchmarks/harbor/metric.py b/benchmarks/harbor/metric.py new file mode 100644 index 000000000..ef0a8780d --- /dev/null +++ b/benchmarks/harbor/metric.py @@ -0,0 +1,96 @@ +"""Dataset-level metric: AssetOpsBench category rollups. + +Harbor runs this when metric.py is listed in the dataset's [[files]]. + + -i/-o Harbor's own path. Harbor passes rewards only, with no task + identity, so this reports overall figures only. + --job-dir Reads a finished job's /result.json files, whose + task_name carries the category (wosr-1, fmsr-12), and reports + per-category means. + +Do not put the category into the reward dict instead: Harbor averages each +reward key over all trials, counting it as zero where it is absent. +""" + +from __future__ import annotations + +import argparse +import json +from collections import defaultdict +from pathlib import Path + +CATEGORIES = ("car", "fcc", "fmea", "fmsr", "health", "tsfm", "wosr") + + +def from_rewards(input_path: Path) -> dict[str, float | int]: + scores: list[float] = [] + passes: list[int] = [] + + for line in input_path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + reward = json.loads(line) + if reward is None: + # A trial that produced no reward counts as zero, matching Harbor's + # own aggregate_reward_dicts behavior. + scores.append(0.0) + passes.append(0) + continue + scores.append(float(reward.get("reward") or 0.0)) + passes.append(int(reward.get("passed") or 0)) + + return { + "mean": sum(scores) / len(scores) if scores else 0.0, + "pass_rate": sum(passes) / len(passes) if passes else 0.0, + "n_trials": len(scores), + } + + +def from_job_dir(job_dir: Path) -> dict[str, float | int]: + by_category: dict[str, list[float]] = defaultdict(list) + overall: list[float] = [] + + for result_path in sorted(job_dir.glob("*/result.json")): + result = json.loads(result_path.read_text(encoding="utf-8")) + rewards = (result.get("verifier_result") or {}).get("rewards") or {} + score = float(rewards.get("reward") or 0.0) + overall.append(score) + + task_name = (result.get("task_name") or "").split("/")[-1] + category = task_name.split("-", 1)[0] + if category in CATEGORIES: + by_category[category].append(score) + + out: dict[str, float | int] = { + "mean": sum(overall) / len(overall) if overall else 0.0, + "n_trials": len(overall), + } + for category, values in sorted(by_category.items()): + out[f"{category}_mean"] = sum(values) / len(values) + out[f"{category}_n"] = len(values) + return out + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("-i", "--input-path", type=Path) + parser.add_argument("-o", "--output-path", type=Path) + parser.add_argument("--job-dir", type=Path) + args = parser.parse_args() + + if args.job_dir is not None: + print(json.dumps(from_job_dir(args.job_dir), indent=2)) + return 0 + + if args.input_path is None or args.output_path is None: + parser.error("pass -i and -o, or --job-dir") + + args.output_path.write_text( + json.dumps(from_rewards(args.input_path), indent=2), encoding="utf-8" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/harbor/overlays/code-sandbox.yaml b/benchmarks/harbor/overlays/code-sandbox.yaml new file mode 100644 index 000000000..9efa00053 --- /dev/null +++ b/benchmarks/harbor/overlays/code-sandbox.yaml @@ -0,0 +1,76 @@ +# Opt-in overlay for the code track: runs agent-written code in a per-trial +# Docker-in-Docker daemon, away from CouchDB. See benchmarks/harbor/CODE-SANDBOX.md. +# +# harbor run -p benchmarks/harbor/datasets/assetopsbench-open \ +# --agent assetops_harbor.stirrup:StirrupAgent \ +# --model tokenrouter/MiniMax-M3 \ +# --extra-docker-compose benchmarks/harbor/overlays/code-sandbox.yaml \ +# --ak code_enabled=true --ak code_backend=docker --ak allow_docker_backend=true \ +# --ak workspace_dir=/workspace-share +# +# Stirrup's Docker backend honours DOCKER_HOST, so code containers are created +# inside `dind`, on its own bridge, with no DNS entry for `couchdb` and no +# environment. The shared /workspace-share volume makes the workspace path +# Stirrup bind-mounts resolve to the same files in `main` and in dind. +# +# The code image reaches each (empty) daemon from AOB_CODE_TAR, loaded by +# code-image-loader, or is pulled as AOB_CODE_IMAGE when no tar is given. +services: + main: + environment: + DOCKER_HOST: tcp://dind:2375 + STIRRUP_CODE_IMAGE: ${AOB_CODE_IMAGE:-assetops-code:dev} + volumes: + - code-workspace:/workspace-share + depends_on: + dind: + condition: service_healthy + code-image-loader: + condition: service_completed_successfully + + # Loads AOB_CODE_TAR into this trial's daemon. Exits 0 without a tar, and + # Stirrup then pulls AOB_CODE_IMAGE. + code-image-loader: + image: docker:28-cli + environment: + DOCKER_HOST: tcp://dind:2375 + STIRRUP_CODE_IMAGE: ${AOB_CODE_IMAGE:-assetops-code:dev} + volumes: + - ${AOB_CODE_TAR:-/dev/null}:/image.tar:ro + command: + - sh + - -c + - | + if [ -s /image.tar ]; then + docker load -i /image.tar + else + echo "no AOB_CODE_TAR; the daemon will pull $$STIRRUP_CODE_IMAGE instead" + fi + depends_on: + dind: + condition: service_healthy + + dind: + image: docker:28-dind + # Required by dockerd, and refused by most Harbor cloud providers. + privileged: true + environment: + # Plain tcp on 2375, exposed only inside the trial's network. + DOCKER_TLS_CERTDIR: "" + expose: + - "2375" + volumes: + # Both volumes are per trial: Compose namespaces them per project. + - dind-storage:/var/lib/docker + # Same volume and mount point as in `main`. + - code-workspace:/workspace-share + healthcheck: + test: ["CMD", "docker", "-H", "tcp://localhost:2375", "info"] + interval: 3s + timeout: 5s + retries: 40 + start_period: 5s + +volumes: + dind-storage: + code-workspace: diff --git a/benchmarks/harbor/overlays/private-data.yaml b/benchmarks/harbor/overlays/private-data.yaml new file mode 100644 index 000000000..659f73bf1 --- /dev/null +++ b/benchmarks/harbor/overlays/private-data.yaml @@ -0,0 +1,27 @@ +# Opt-in overlay that mounts a private suite's shared/ data into `main`, read-only. +# +# export AOB_PRIVATE_DIR=/AssetOpsBenchScenarioGeneration/scenarios_data +# harbor run -p benchmarks/harbor/datasets/assetopsbench-mini \ +# --extra-docker-compose benchmarks/harbor/overlays/private-data.yaml ... +# +# Generate the tasks with --scenario-root "$AOB_PRIVATE_DIR": the healthcheck +# then loads each scenario from /opt/suite/scenarios_data, where its manifest's +# shared/... paths resolve against this mount. The agent keeps the repo copy. +# +# Only shared/ is mounted: every scenario_/ beside it holds that scenario's +# answers, and `main` is the agent's container. Changes to shared/ apply on the +# next run; a changed manifest.json needs the tasks regenerated. +# +# `:?` names the variable when AOB_PRIVATE_DIR is unset, and +# create_host_path: false fails the trial on a wrong path instead of mounting an +# empty directory that loads every collection empty. Use an absolute path: +# Compose resolves a relative one against the task's environment/ directory. +services: + main: + volumes: + - type: bind + source: ${AOB_PRIVATE_DIR:?set AOB_PRIVATE_DIR to the private suite's scenarios_data directory}/shared + target: /opt/suite/scenarios_data/shared + read_only: true + bind: + create_host_path: false diff --git a/benchmarks/harbor/run.sh b/benchmarks/harbor/run.sh new file mode 100755 index 000000000..88975d7b3 --- /dev/null +++ b/benchmarks/harbor/run.sh @@ -0,0 +1,437 @@ +#!/usr/bin/env bash +# Harbor counterpart of benchmarks/run.sh: the same scenarios, stirrup-agent and +# code sandbox, but each scenario is a Harbor trial with its own CouchDB, so +# scenarios run concurrently. See benchmarks/harbor/README.md. +# +# bash benchmarks/harbor/run.sh -s SCENARIO_DIR -l LEADERBOARD_DIR \ +# [-n N_CONCURRENT] [-p PROFILE] [-r RUNTIME_IMAGE] \ +# [-m "MODEL_ID REASONING_EFFORT"]... +# +# Needs Docker, `uv sync --extra harbor` and the runtime image, built with +# scripts/build-runtime-image.sh or published and passed as -r (or +# AOB_RUNTIME_IMAGE). Credentials come from ENV_FILE (default: the repo's .env), +# the only file read. Relative paths are relative to the caller's directory. +# +# One Harbor job per profile, model and effort, at +# LEADERBOARD_DIR/harbor-jobs/stirrup_agent____[__]. +# Re-running resumes it with its original settings. Exits non-zero when a +# model's job could not start or resume, or the model was skipped. + +set -euo pipefail + +usage() { + printf 'Usage: %s -s SCENARIO_DIR -l LEADERBOARD_DIR [-n N_CONCURRENT] [-p PROFILE] [-r RUNTIME_IMAGE] [-m "MODEL_ID EFFORT"]...\n' "$0" >&2 +} + +scenario_dir="${SCENARIO_DIR:-}" +leaderboard_dir="${LEADERBOARD_DIR:-}" +n_concurrent="${N_CONCURRENT:-4}" +profile="${PROFILE:-}" +env_file="${ENV_FILE:-}" +runtime_image="${AOB_RUNTIME_IMAGE:-}" +model_configs=() + +while getopts ':s:l:n:p:r:m:' option; do + case "$option" in + s) scenario_dir="$OPTARG" ;; + l) leaderboard_dir="$OPTARG" ;; + n) n_concurrent="$OPTARG" ;; + p) profile="$OPTARG" ;; + r) runtime_image="$OPTARG" ;; + m) model_configs+=("$OPTARG") ;; + :) printf 'Option -%s requires an argument.\n' "$OPTARG" >&2; usage; exit 2 ;; + \?) printf 'Unknown option: -%s\n' "$OPTARG" >&2; usage; exit 2 ;; + esac +done + +if [[ -z "$scenario_dir" || -z "$leaderboard_dir" ]]; then + usage + exit 2 +fi + +if [[ -z "${model_configs[*]+set}" ]]; then + model_configs=( + "litellm_proxy/gcp/gemini-3.6-flash high" + "litellm_proxy/azure/gpt-5.6-sol max" + "litellm_proxy/aws/claude-opus-5 high" + "litellm_proxy/aws/claude-sonnet-5 max" + "tokenrouter/MiniMax-M3 high" + "tokenrouter/moonshotai/kimi-k3 max" + "tokenrouter/z-ai/glm-5.3 max" + "tokenrouter/deepseek/deepseek-v4-flash max" + ) +fi + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +# Resolve caller paths before the cd below. +caller_dir="$PWD" +absolute() { + case "$1" in + /*) printf '%s' "$1" ;; + *) printf '%s/%s' "$caller_dir" "$1" ;; + esac +} +scenario_dir="$(absolute "$scenario_dir")" +leaderboard_dir="$(absolute "$leaderboard_dir")" +if [[ -n "$profile" ]]; then + profile="$(absolute "$profile")" +else + profile="$repo_root/benchmarks/scenario_suite/all.yaml" +fi +if [[ -n "$env_file" ]]; then + env_file="$(absolute "$env_file")" +else + env_file="$repo_root/.env" +fi +code_tar_dir="$(absolute "${AOB_CODE_TAR_DIR:-$HOME/.cache/assetopsbench}")" + +cd "$repo_root" + +if [[ ! -d "$scenario_dir" ]]; then + printf 'Scenario directory not found: %s\n' "$scenario_dir" >&2 + exit 2 +fi +scenario_dir="$(cd "$scenario_dir" && pwd)" +if [[ ! -f "$profile" ]]; then + printf 'Profile not found: %s\n' "$profile" >&2 + exit 2 +fi +mkdir -p "$leaderboard_dir" +leaderboard_dir="$(cd "$leaderboard_dir" && pwd)" +jobs_dir="$leaderboard_dir/harbor-jobs" +mkdir -p "$jobs_dir" + +if [[ ! -f "$env_file" ]]; then + printf 'Credentials file not found: %s (set ENV_FILE)\n' "$env_file" >&2 + exit 2 +fi +# StirrupAgent reads this file instead of the nearest .env. +export AOB_ENV_FILE="$env_file" + +# Each job gets its own copy of the tasks, generated once when it starts: +# Harbor refuses to resume a job whose tasks changed, and a shared folder could +# be rewritten under a running job. The copies hold answers, so they stay in the +# gitignored datasets/ rather than beside the results. +tasks_root="$repo_root/benchmarks/harbor/datasets/jobs" + +# Removed on exit: the runtime image pin, a partial code tar, the job lock. +runtime_pin="" +code_tar_partial="" +job_lock="" +cleanup() { + if [[ -n "$runtime_pin" ]]; then docker rmi "$runtime_pin" >/dev/null 2>&1 || true; fi + if [[ -n "$code_tar_partial" ]]; then rm -f "$code_tar_partial"; fi + if [[ -n "$job_lock" ]]; then rm -rf "$job_lock"; fi +} +trap cleanup EXIT + +# overlays/private-data.yaml mounts $AOB_PRIVATE_DIR/shared into each trial. +if [[ ! -d "$scenario_dir/shared" ]]; then + printf "No shared/ directory in %s; is -s the suite's scenarios_data?\n" "$scenario_dir" >&2 + exit 2 +fi +export AOB_PRIVATE_DIR="$scenario_dir" + +# -r wins, then the shell's AOB_RUNTIME_IMAGE, then ENV_FILE's, then the local +# default. +if [[ -z "$runtime_image" ]]; then + runtime_image="$(uv run --env-file "$env_file" python -c \ + 'import os; print(os.environ.get("AOB_RUNTIME_IMAGE", ""))')" +fi +runtime_image="${runtime_image:-assetopsbench/runtime:dev}" + +# The reference without its tag or digest, spelled as .RepoDigests spells it. +image_repo() { + local ref="${1%@*}" + if [[ "${ref##*/}" == *:* ]]; then ref="${ref%:*}"; fi + ref="${ref#docker.io/}" + printf '%s' "${ref#library/}" +} + +# True when the local copy of $1 came from (or went to) that same repository, +# i.e. it is a published image rather than a local build. +from_registry() { + local repo digest + repo="$(image_repo "$1")" + while read -r digest; do + [[ "${digest%@*}" == "$repo" ]] && return 0 + done < <(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$1") + return 1 +} + +# A build never refreshes a published image it already has, so pull it here. A +# local build (e.g. the default assetopsbench/runtime:dev) is used as is. +if ! docker image inspect "$runtime_image" >/dev/null 2>&1; then + if ! docker pull "$runtime_image"; then + printf 'Runtime image %s is not local and could not be pulled. Build it with\n' "$runtime_image" >&2 + printf ' bash benchmarks/harbor/scripts/build-runtime-image.sh\n' >&2 + exit 1 + fi +elif from_registry "$runtime_image" && ! docker pull "$runtime_image"; then + printf 'warning: could not pull %s; using the local copy, which may be stale\n' \ + "$runtime_image" >&2 +fi + +# Pin the base for the whole run: a tag such as :dev can move mid-run, and each +# trial resolves FROM when it builds. FROM cannot name an image id, so tag it +# under a name private to this process. +runtime_id="$(docker image inspect --format '{{.Id}}' "$runtime_image")" +runtime_id="${runtime_id#sha256:}" +runtime_pin="aob-runtime-pin:${runtime_id:0:12}-$$" +docker tag "$runtime_image" "$runtime_pin" +export AOB_RUNTIME_IMAGE="$runtime_pin" +printf 'Runtime image: %s (%s)\n' "$runtime_image" "${runtime_id:0:12}" + +# The code sandbox image, as a tar each trial's dind loads. Rebuilt every run +# (cheap when cached) and saved once per image id, so a running job's tar is +# never rewritten. Old tars stay in AOB_CODE_TAR_DIR until removed. +code_image=assetops-code:dev +docker build -q -t "$code_image" \ + -f src/agent/stirrup_agent/Dockerfile.code src/agent/stirrup_agent >/dev/null +code_id="$(docker image inspect --format '{{.Id}}' "$code_image")" +code_id="${code_id#sha256:}" +code_tar="$code_tar_dir/assetops-code-${code_id:0:12}.tar" +if [[ ! -s "$code_tar" ]]; then + mkdir -p "$code_tar_dir" + echo "Saving $code_image to $code_tar" + code_tar_partial="$code_tar.partial.$$" + docker save "$code_image" -o "$code_tar_partial" + chmod 644 "$code_tar_partial" + mv "$code_tar_partial" "$code_tar" + code_tar_partial="" +fi +export AOB_CODE_IMAGE="$code_image" +printf 'Code image: %s (%s)\n' "$code_image" "${code_id:0:12}" + +# A name safe for a directory: anything but letters, digits and ._- becomes a +# single dash, and a trailing dash is dropped. +slug() { + local name + name="$(printf '%s' "$1" | tr -c 'A-Za-z0-9._-' '-' | tr -s '-')" + printf '%s' "${name%-}" +} + +profile_name="$(basename "$profile")" +profile_slug="$(slug "${profile_name%.*}")" + +# Fail fast when a model cannot be served, rather than a job of failed trials. +# The model's and FMSR_MODEL_ID's routers must answer GET /models without a 401 +# or 403. A model with no router prefix needs FMSR_MODEL_ID, since the FMSR +# server accepts only router models. +check_model() { + uv run --env-file "$env_file" python - "$1" <<'PY' +import os +import sys +import urllib.error +import urllib.request + +# src/llm/routers.py PROXY_ROUTERS; src/assetops_harbor/tests checks they match. +ROUTERS = { + "litellm_proxy/": ("LITELLM_BASE_URL", "LITELLM_API_KEY"), + "tokenrouter/": ("TOKENROUTER_BASE_URL", "TOKENROUTER_API_KEY"), +} + + +def router(model): + return next((prefix for prefix in ROUTERS if model.startswith(prefix)), None) + + +model = sys.argv[1] +fmsr_model = os.environ.get("FMSR_MODEL_ID", "").strip() +if fmsr_model and not router(fmsr_model): + sys.exit(f"FMSR_MODEL_ID={fmsr_model} needs a {' or '.join(ROUTERS)} prefix") +if not fmsr_model and not router(model): + sys.exit( + f"{model} has no {' or '.join(ROUTERS)} prefix, so the FMSR server would " + "reject it; set FMSR_MODEL_ID to a router model" + ) + +for prefix in dict.fromkeys(p for p in (router(model), router(fmsr_model)) if p): + base_var, key_var = ROUTERS[prefix] + base, key = os.environ.get(base_var, ""), os.environ.get(key_var, "") + missing = [name for name, value in ((base_var, base), (key_var, key)) if not value] + if missing: + sys.exit(f"{' and '.join(missing)} not set for {prefix} models") + request = urllib.request.Request( + base.rstrip("/") + "/models", headers={"Authorization": f"Bearer {key}"} + ) + try: + urllib.request.urlopen(request, timeout=15) + except urllib.error.HTTPError as exc: + if exc.code in (401, 403): + sys.exit(f"{base_var} rejected {key_var} (HTTP {exc.code})") + except Exception as exc: + sys.exit(f"cannot reach {base_var} ({exc}); check the VPN or network") +PY +} + +# One run.sh per job at a time. mkdir is atomic; the lock holds its owner's +# PID, so a lock left by a dead run.sh is taken over. +lock_job() { + local lock="$1" owner + if mkdir "$lock" 2>/dev/null; then + printf '%s\n' "$$" >"$lock/pid" + return 0 + fi + owner="$(cat "$lock/pid" 2>/dev/null || true)" + if [[ -z "$owner" ]] || kill -0 "$owner" 2>/dev/null; then + return 1 + fi + rm -rf "$lock" + mkdir "$lock" 2>/dev/null || return 1 + printf '%s\n' "$$" >"$lock/pid" +} + +# Trials a resume reruns: failures not caused by the model's own work (API, +# network, environment, verifier, Ctrl-C). Harbor matches exact class names; +# src/assetops_harbor/tests/test_run_sh.py checks them against Harbor. Timeouts, +# context/output overruns and safety refusals are kept as results. +retry_error_types=( + CancelledError + NonZeroAgentExitCodeError + ApiError + ApiRateLimitError + ApiUsageLimitError + ApiInternalServerError + ApiOverloadedError + ApiConnectionClosedError + ApiResponseStalledError + UnknownApiError + ApiProviderResourceNotFoundError + AgentAuthenticationError + ModelNotFoundError + NetworkConnectionError + AgentSetupTimeoutError + EnvironmentStartTimeoutError + HealthcheckError + VerifierTimeoutError + RewardFileNotFoundError + RewardFileEmptyError + VerifierOutputParseError + AddTestsDirError + DownloadVerifierDirError +) +retry_filters=() +for error_type in "${retry_error_types[@]}"; do + retry_filters+=(--filter-error-type "$error_type") +done + +# Non-zero when a model's job could not start or resume, or was skipped. +status=0 + +for model_config in "${model_configs[@]}"; do + read -r model_id reasoning_effort <<< "$model_config" + [[ -z "${model_id:-}" ]] && continue + + # Profile and effort are in the name so each gets its own job. + job_name="stirrup_agent__${profile_slug}__$(slug "$model_id")" + if [[ -n "${reasoning_effort:-}" ]]; then + job_name+="__$(slug "$reasoning_effort")" + fi + job_path="$jobs_dir/$job_name" + # Keyed by the job's full path, so another LEADERBOARD_DIR gets its own copy. + # No "__": Harbor names the dataset after this folder and splits its + # agent__model__dataset keys on "__", failing the run once trials finish. + tasks_dir="$tasks_root/${job_name//__/--}-$(printf '%s' "$job_path" | cksum | cut -d' ' -f1)" + # What the job started on, which Harbor's own resume check does not cover: the + # runtime image, the code tar, and the suite whose shared/ the mount supplies. + image_record="$jobs_dir/$job_name.runtime-image" + code_record="$jobs_dir/$job_name.code-tar" + suite_record="$jobs_dir/$job_name.suite" + + if ! check_model "$model_id"; then + echo "Skipping $model_id" >&2 + status=1 + continue + fi + + if ! lock_job "$job_path.lock"; then + printf 'Skipping %s: another run.sh (PID %s) is working on it.\n' \ + "$job_path" "$(cat "$job_path.lock/pid" 2>/dev/null || echo unknown)" >&2 + printf 'If none is, remove %s.\n' "$job_path.lock" >&2 + status=1 + continue + fi + job_lock="$job_path.lock" + + echo "Running $model_id with reasoning effort ${reasoning_effort:-default} -> $job_path" + + if [[ -f "$job_path/config.json" ]]; then + job_code_tar="$code_tar" + [[ -f "$code_record" ]] && job_code_tar="$(cat "$code_record")" + if [[ -f "$image_record" ]] && [[ "$(cut -f1 "$image_record")" != "$runtime_id" ]]; then + printf '%s started on runtime image %s, not %s (%s).\n' \ + "$job_path" "$(cut -f2 "$image_record")" "$runtime_image" "${runtime_id:0:12}" >&2 + printf 'Pass that image as -r to finish it, or move the job aside to rerun %s.\n' \ + "$model_id" >&2 + status=1 + elif [[ -f "$suite_record" ]] && [[ "$(cat "$suite_record")" != "$scenario_dir" ]]; then + printf '%s started on the suite in %s, not %s.\n' \ + "$job_path" "$(cat "$suite_record")" "$scenario_dir" >&2 + printf 'Pass that directory as -s to finish it, or move the job aside to rerun %s.\n' \ + "$model_id" >&2 + status=1 + elif [[ ! -d "$tasks_dir" ]]; then + printf 'The tasks %s started with are gone (%s).\n' "$job_path" "$tasks_dir" >&2 + printf 'Move the job aside to rerun %s from scratch.\n' "$model_id" >&2 + status=1 + elif [[ ! -s "$job_code_tar" ]]; then + printf 'The code image %s started with is gone (%s).\n' "$job_path" "$job_code_tar" >&2 + printf 'Move the job aside to rerun %s from scratch.\n' "$model_id" >&2 + status=1 + # Rerun the trials in retry_error_types; scored trials are kept. + elif ! AOB_CODE_TAR="$job_code_tar" uv run --env-file "$env_file" \ + harbor jobs resume -p "$job_path" "${retry_filters[@]}"; then + printf 'Could not resume %s. If its overlays changed since it started,\n' "$job_path" >&2 + printf 'move it aside to rerun %s from scratch.\n' "$model_id" >&2 + status=1 + fi + rm -rf "$job_lock" + job_lock="" + continue + fi + + # The generator never removes tasks, so clear any left by a failed start. + rm -rf "$tasks_dir" + uv run python benchmarks/harbor/adapter/generate_tasks.py \ + --scenario-root "$scenario_dir" \ + --profile "$profile" \ + --output-dir "$tasks_dir" \ + --dataset-name assetopsbench/suite \ + --skip-missing \ + --overwrite >/dev/null + + printf '%s\t%s\n' "$runtime_id" "$runtime_image" >"$image_record" + printf '%s\n' "$code_tar" >"$code_record" + printf '%s\n' "$scenario_dir" >"$suite_record" + + effort_args=() + if [[ -n "${reasoning_effort:-}" ]]; then + effort_args=(--ak "reasoning_effort=$reasoning_effort") + fi + + # harbor run exits 0 when trials fail; non-zero means the job itself could not + # run, e.g. a rejected config or missing credentials. + if ! AOB_CODE_TAR="$code_tar" uv run --env-file "$env_file" harbor run -y \ + -p "$tasks_dir" \ + --agent assetops_harbor.stirrup:StirrupAgent \ + --model "$model_id" \ + --ak code_enabled=true \ + --ak code_backend=docker \ + --ak allow_docker_backend=true \ + --ak workspace_dir=/workspace-share \ + ${effort_args[@]+"${effort_args[@]}"} \ + --extra-docker-compose benchmarks/harbor/overlays/private-data.yaml \ + --extra-docker-compose benchmarks/harbor/overlays/code-sandbox.yaml \ + --n-concurrent "$n_concurrent" \ + --job-name "$job_name" \ + -o "$jobs_dir"; then + printf 'Harbor could not run %s; see the error above.\n' "$job_path" >&2 + status=1 + fi + rm -rf "$job_lock" + job_lock="" +done + +exit "$status" diff --git a/benchmarks/harbor/scripts/apply_catalog_fixes.py b/benchmarks/harbor/scripts/apply_catalog_fixes.py new file mode 100755 index 000000000..60eae49b9 --- /dev/null +++ b/benchmarks/harbor/scripts/apply_catalog_fixes.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python +"""Apply the audited fixes to a model catalog, in place. + +Every edit is reported; one already present is skipped. The fixes: + + ttm, tspulse_ad, tspulse_clf + "params": {} hands the wrapper its own defaults, which name a checkpoint: + TinyTimeMixerForecaster falls back to "ibm/TTM", both TSPulse estimators + to "ibm-granite/granite-timeseries-tspulse-r1", and the classifier to a + non-main revision. Nothing in the card says so, so preload_models.py + reports them as classical models and caches nothing. They then download + at first use: fine on a laptop, a network error inside the image. + + ttm (again) + TinyTimeMixerForecaster's fit_strategy defaults to "minimal", which + fine-tunes. Pinning params.fit_strategy and training_regime makes it serve. + + chronos + No hf_repo, so classify() guesses the target from the path shape. + + google__timesfm-2.5-200m + created_by/source claim a migration that did not happen. + + uv run python benchmarks/harbor/scripts/apply_catalog_fixes.py + uv run python benchmarks/harbor/scripts/apply_catalog_fixes.py --write + +Dry run by default. --move-energy also repoints ttm_energy_168_24 from +artifacts/output/tuned_models/ to artifacts/tsfm_models/, where the repo now +keeps it. The repo's own catalog already points there; use the flag on a +catalog that does not yet, such as a private suite's (--catalog PATH). +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path + +DEFAULT_CATALOG = Path("src/couchdb/scenarios_data/shared/tsfm/model_catalog.json") +TTM_REPO = "ibm-granite/granite-timeseries-ttm-r2" +TSPULSE_REPO = "ibm-granite/granite-timeseries-tspulse-r1" +TSPULSE_CLF_REV = "tspulse-block-dualhead-512-p16-r1" +ENERGY_OLD = "artifacts/output/tuned_models/ttm_energy_168_24" +ENERGY_NEW = "artifacts/tsfm_models/ttm_energy_168_24" + + +def catalog_path(explicit: Path | None) -> Path: + if explicit: + return explicit + if os.environ.get("AOB_MODEL_CATALOG"): + return Path(os.environ["AOB_MODEL_CATALOG"]) + if os.environ.get("SCENARIOS_DATA_DIR"): + return Path(os.environ["SCENARIOS_DATA_DIR"]) / "shared/tsfm/model_catalog.json" + return DEFAULT_CATALOG + + +def setval(target: dict, key: str, value, changes: list[str], label: str) -> None: + if target.get(key) == value: + return + target[key] = value + changes.append(f"{label}={value!r}") + + +def fix(card: dict, move_energy: bool) -> list[str]: + mid = card.get("model_id") + params = card.setdefault("params", {}) + ch: list[str] = [] + + if mid == "ttm": + setval(params, "model_path", TTM_REPO, ch, "params.model_path") + setval(params, "fit_strategy", "zero-shot", ch, "params.fit_strategy") + setval(card, "hf_repo", TTM_REPO, ch, "hf_repo") + setval(card, "training_regime", "zero_shot", ch, "training_regime") + elif mid == "tspulse_ad": + setval(params, "model_path", TSPULSE_REPO, ch, "params.model_path") + setval(card, "hf_repo", TSPULSE_REPO, ch, "hf_repo") + elif mid == "tspulse_clf": + setval(params, "model_path", TSPULSE_REPO, ch, "params.model_path") + setval(params, "revision", TSPULSE_CLF_REV, ch, "params.revision") + setval(card, "hf_repo", TSPULSE_REPO, ch, "hf_repo") + elif mid == "chronos": + mp = params.get("model_path") + if mp: + setval(card, "hf_repo", mp, ch, "hf_repo") + elif mid == "google__timesfm-2.5-200m": + setval(card, "created_by", "seed", ch, "created_by") + setval(card, "source", "sktime-foundation-survey", ch, "source") + elif mid == "ttm_energy_168_24" and move_energy: + for key in ("artifact_path", "model_checkpoint"): + if card.get(key) == ENERGY_OLD: + setval(card, key, ENERGY_NEW, ch, key) + if params.get("model_path") == ENERGY_OLD: + setval(params, "model_path", ENERGY_NEW, ch, "params.model_path") + return ch + + +def main() -> int: + p = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--catalog", type=Path, default=None) + p.add_argument("--write", action="store_true", help="apply (default: print)") + p.add_argument("--move-energy", action="store_true", + help="repoint ttm_energy_168_24 at artifacts/tsfm_models/ " + "(move the directory on disk first)") + args = p.parse_args() + + cat = catalog_path(args.catalog) + if not cat.is_file(): + print(f"no catalog at {cat}", file=sys.stderr) + return 1 + raw = json.loads(cat.read_text(encoding="utf-8")) + cards = raw if isinstance(raw, list) else raw.get("docs", [raw]) + + print(f"catalog: {cat}\n") + touched = 0 + for card in cards: + changes = fix(card, args.move_energy) + if changes: + touched += 1 + print(f" {card.get('model_id', '?'):26}") + for c in changes: + print(f" + {c}") + + # Report, do not resolve: which of two cards on one repo should survive is + # a judgement about what the scenarios need, not something to guess. + active = [c for c in cards if (c.get("status") or "active") == "active"] + seen: dict[str, list[str]] = {} + for c in active: + ref = str(c.get("hf_repo") or (c.get("params") or {}).get("model_path") or "") + if ref: + seen.setdefault(ref, []).append(c.get("model_id", "?")) + dupes = {r: ids for r, ids in seen.items() if len(ids) > 1} + + print() + if args.write and touched: + cat.write_text(json.dumps(cards if isinstance(raw, list) else raw, indent=2) + "\n", + encoding="utf-8") + print(f"wrote {touched} card(s) to {cat}") + elif touched: + print(f"{touched} card(s) would change; pass --write") + else: + print("nothing to change.") + + if dupes: + print("\nactive cards now sharing weights, decide which to deprecate:", + file=sys.stderr) + for ref, ids in dupes.items(): + print(f" {ref} <- {', '.join(ids)}", file=sys.stderr) + stale = [ + c for c in cards + if c.get("model_id") == "ttm_energy_168_24" + and (c.get("params") or {}).get("model_path") == ENERGY_OLD + ] + if stale and not args.move_energy: + print(f"\nttm_energy_168_24 still points at {ENERGY_OLD}, which no longer " + f"exists.\nRerun with --move-energy to repoint it at {ENERGY_NEW}.", + file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/harbor/scripts/audit_catalog.py b/benchmarks/harbor/scripts/audit_catalog.py new file mode 100755 index 000000000..f2d1dae0e --- /dev/null +++ b/benchmarks/harbor/scripts/audit_catalog.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python +"""Flag catalog cards whose defaults do something the card does not say. + +An empty `params` is not neutral. It hands the wrapper its own defaults, and +those defaults both name a checkpoint and pick a fit strategy: + + TinyTimeMixerForecaster model_path="ibm/TTM" fit_strategy="minimal" + TSPulseAnomalyDetector model_path="ibm-granite/granite-timeseries-tspulse-r1" + TSPulseClassifier model_path=... revision="tspulse-block-dualhead-512-p16-r1" + +So a card with `"params": {}` downloads weights at run time that +`preload_models.py` never sees, because there is nothing in the card to +collect. It passes every check on a connected machine and fails inside the +image. The same emptiness leaves TTM on "minimal", which fine-tunes. + + uv run python benchmarks/harbor/scripts/audit_catalog.py + AOB_MODEL_CATALOG=... uv run python benchmarks/harbor/scripts/audit_catalog.py + +Reports only; it never edits. Exit 1 when any card is flagged. +""" + +from __future__ import annotations + +import argparse +import importlib +import inspect +import json +import os +import sys +import warnings +from pathlib import Path + +DEFAULT_CATALOG = Path("src/couchdb/scenarios_data/shared/tsfm/model_catalog.json") +PATH_PARAMS = ("model_path", "checkpoint_path", "repo_id", + "pretrained_model_name_or_path", "tokenizer_path") +SWITCHES = ("fit_strategy", "train_model") + + +def catalog_path(explicit: Path | None) -> Path: + if explicit: + return explicit + for env in ("AOB_MODEL_CATALOG",): + if os.environ.get(env): + return Path(os.environ[env]) + if os.environ.get("SCENARIOS_DATA_DIR"): + return Path(os.environ["SCENARIOS_DATA_DIR"]) / "shared/tsfm/model_catalog.json" + return DEFAULT_CATALOG + + +def signature(card: dict): + dotted = card.get("sktime_class") + if not dotted or "." not in dotted: + return None + mod, _, name = dotted.rpartition(".") + try: + return inspect.signature(getattr(importlib.import_module(mod), name).__init__).parameters + except Exception: # noqa: BLE001 - an uninstallable wrapper is not this tool's problem + return None + + +def main() -> int: + warnings.filterwarnings("ignore") + p = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--catalog", type=Path, default=None) + args = p.parse_args() + + cat = catalog_path(args.catalog) + if not cat.is_file(): + print(f"no catalog at {cat}", file=sys.stderr) + return 1 + raw = json.loads(cat.read_text(encoding="utf-8")) + cards = raw if isinstance(raw, list) else raw.get("docs", [raw]) + active = [c for c in cards if (c.get("status") or "active") == "active"] + + sys.path.insert(0, "src") + print(f"{cat}\n{len(cards)} card(s), {len(active)} active\n") + findings = 0 + + print("A. active cards that fetch weights at run time, invisible to preload") + for c in active: + params = c.get("params") or {} + if c.get("hf_repo") or any(params.get(k) for k in PATH_PARAMS): + continue + sig = signature(c) + if not sig: + continue + for key in ("model_path", "checkpoint_path", "pretrained_model_name_or_path"): + if key in sig and sig[key].default not in (inspect._empty, None): + print(f" {c.get('model_id', '?'):22} pulls {sig[key].default!r} " + f"via the wrapper default; set params.{key} and hf_repo") + findings += 1 + break + + print("\nB. active forecasting cards that train on fit") + for c in active: + if not any("forecast" in str(t) for t in (c.get("task_ids") or [])): + continue + if c.get("training_regime") == "fine_tune": + continue # declared, and reported as TUNED by the smoke test + sig, params = signature(c), (c.get("params") or {}) + if not sig: + continue + for key in SWITCHES: + if key in sig and key not in params: + print(f" {c.get('model_id', '?'):22} no params.{key}; wrapper default " + f"is {sig[key].default!r}") + findings += 1 + + print("\nC. active cards resolving to the same weights") + seen: dict[str, list[str]] = {} + for c in active: + params = c.get("params") or {} + ref = str(c.get("hf_repo") or params.get("model_path") + or params.get("checkpoint_path") or "") + if ref: + seen.setdefault(ref, []).append(c.get("model_id", "?")) + for ref, ids in seen.items(): + if len(ids) > 1: + print(f" {ref} <- {', '.join(ids)}") + findings += 1 + + print("\nD. declared geometry that cannot be right") + for c in active: + ctx = c.get("context_length") + if isinstance(ctx, int) and ctx < 8: + print(f" {c.get('model_id', '?'):22} context_length={ctx}") + findings += 1 + + print() + if findings: + print(f"{findings} finding(s).", file=sys.stderr) + return 1 + print("nothing flagged.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/harbor/scripts/build-runtime-image.sh b/benchmarks/harbor/scripts/build-runtime-image.sh new file mode 100755 index 000000000..fc2f8d4ae --- /dev/null +++ b/benchmarks/harbor/scripts/build-runtime-image.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Build the runtime image from `git archive HEAD`, never from the working tree. +# +# bash benchmarks/harbor/scripts/build-runtime-image.sh +# bash benchmarks/harbor/scripts/build-runtime-image.sh --no-cache +# bash benchmarks/harbor/scripts/build-runtime-image.sh -t myorg/runtime:test +# +# Arguments go to `docker buildx build` unchanged. Without a -t the image is +# tagged assetopsbench/runtime:dev (the task template's default) and +# assetopsbench/runtime:; without --push/--output/--load it is loaded +# locally. Commit tags share every layer but the repo's own (about 60 MB) until +# uv.lock or models.txt changes, so a source edit rebuilds in seconds. Rebuilds +# also reuse the uv download cache, which `docker builder prune` clears. +# +# Why an archive: `COPY . .` in base-image/Dockerfile would otherwise take +# untracked and git-ignored files too, which is how a local results table with +# private ground truth once reached the image. The archive holds only what HEAD +# commits, and no .git; the commit travels as the AOB_COMMIT build arg. +# Uncommitted changes are not built; the script warns about them. +set -euo pipefail + +repo_root="$(git -C "$(dirname "${BASH_SOURCE[0]}")" rev-parse --show-toplevel)" +commit="$(git -C "$repo_root" rev-parse HEAD)" + +if ! docker buildx version >/dev/null 2>&1; then + printf 'docker buildx is required (Docker Desktop and docker-ce ship it)\n' >&2 + exit 1 +fi + +if ! git -C "$repo_root" diff --quiet HEAD --; then + printf 'warning: tracked files differ from HEAD; building %s without those changes\n' \ + "${commit:0:7}" >&2 +fi +untracked="$(git -C "$repo_root" ls-files --others --exclude-standard | wc -l | tr -d ' ')" +if (( untracked > 0 )); then + printf 'warning: %s untracked file(s) are not in the image; `git add` and commit any it needs\n' \ + "$untracked" >&2 +fi + +context="$(mktemp -d "${TMPDIR:-/tmp}/aob-runtime-context.XXXXXX")" +trap 'rm -rf "$context"' EXIT +git -C "$repo_root" archive --format=tar HEAD | tar -x -C "$context" + +has_tag=false +has_output=false +for arg in "$@"; do + case "$arg" in + -t* | --tag | --tag=*) has_tag=true ;; + --push | --load | --output | --output=* | -o) has_output=true ;; + esac +done +defaults=() +$has_tag || defaults+=(-t assetopsbench/runtime:dev -t "assetopsbench/runtime:${commit:0:7}") +$has_output || defaults+=(--load) + +printf 'Building the runtime image from %s (git archive)\n' "${commit:0:7}" >&2 +docker buildx build \ + -f "$context/benchmarks/harbor/base-image/Dockerfile" \ + --build-arg "AOB_COMMIT=$commit" \ + ${defaults[@]+"${defaults[@]}"} "$@" \ + "$context" diff --git a/benchmarks/harbor/scripts/fetch_ttm_checkpoints.py b/benchmarks/harbor/scripts/fetch_ttm_checkpoints.py new file mode 100755 index 000000000..23b08c962 --- /dev/null +++ b/benchmarks/harbor/scripts/fetch_ttm_checkpoints.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python +"""Pull TTM checkpoints from the granite series into artifacts/tsfm_models/. + +sktime's TTM wrapper requires + + context_length / num_patches == patch_length == patch_stride + +and otherwise rewrites the patch geometry, so a zero-shot load fails with +"the model weights in the configuration are mismatched". Only revisions that +satisfy it are fetched. + + # what is published, and the geometry of each + uv run python benchmarks/harbor/scripts/fetch_ttm_checkpoints.py --list + + # pull one, save it locally, and prove it loads zero-shot + uv run python benchmarks/harbor/scripts/fetch_ttm_checkpoints.py \\ + --fetch 512-96-r2 --name ttm_512_96 + +Every fetch is validated before it is written: the invariant is checked, the +checkpoint is reloaded through the same path sktime uses, and a forecast is +produced. A checkpoint that cannot do all three is not left on disk. +""" + +from __future__ import annotations + +import argparse +import json +import shutil +import sys +import tempfile +from pathlib import Path + +DEFAULT_REPO = "ibm-granite/granite-timeseries-ttm-r2" +DEST_ROOT = Path("artifacts/tsfm_models") + + +def _ttm_classes(): + """The same import ladder sktime's wrapper walks.""" + try: + from tsfm_public.models.tinytimemixer import ( + TinyTimeMixerConfig, + TinyTimeMixerForPrediction, + ) + return TinyTimeMixerConfig, TinyTimeMixerForPrediction + except ImportError: + from sktime.libs.granite_ttm import ( + TinyTimeMixerConfig, + TinyTimeMixerForPrediction, + ) + return TinyTimeMixerConfig, TinyTimeMixerForPrediction + + +def geometry(cfg: dict) -> tuple[bool, str]: + """Does this config satisfy the invariant sktime enforces?""" + ctx = cfg.get("context_length") + npatch = cfg.get("num_patches") + plen = cfg.get("patch_length") + pstr = cfg.get("patch_stride") + if not all(isinstance(v, int) and v > 0 for v in (ctx, npatch, plen, pstr)): + return False, f"incomplete geometry ctx={ctx} num_patches={npatch} patch_length={plen}" + size = ctx / npatch + ok = size == plen == pstr + detail = (f"ctx={ctx} num_patches={npatch} patch_length={plen} stride={pstr} " + f"ctx/num_patches={size:g}") + if not ok: + detail += f" -> sktime would rewrite patch_length to {max(1, int(size))}" + return ok, detail + + +def cmd_list(repo: str) -> int: + from huggingface_hub import HfApi, hf_hub_download + + api = HfApi() + refs = api.list_repo_refs(repo) + names = [b.name for b in refs.branches] + print(f"{repo}: {len(names)} revision(s)\n") + print(f" {'revision':28} {'ctx':>5} {'horizon':>8} geometry") + print(" " + "-" * 76) + for rev in sorted(names): + try: + p = hf_hub_download(repo, "config.json", revision=rev) + cfg = json.loads(Path(p).read_text()) + except Exception as exc: # noqa: BLE001 - a listing must not die on one ref + print(f" {rev:28} {'-':>5} {'-':>8} unreadable: {str(exc)[:40]}") + continue + ok, detail = geometry(cfg) + print(f" {rev:28} {cfg.get('context_length', '-'):>5} " + f"{cfg.get('prediction_length', '-'):>8} {'OK ' if ok else 'SKEW'} {detail}") + print("\nPick a revision whose geometry says OK; those load zero-shot.") + return 0 + + +def cmd_fetch(repo: str, revision: str, name: str | None, dest_root: Path) -> int: + import numpy as np + import pandas as pd + from sktime.forecasting.ttm import TinyTimeMixerForecaster + + Config, Model = _ttm_classes() + + print(f"==> {repo}@{revision}") + cfg_obj = Config.from_pretrained(repo, revision=revision) + cfg = cfg_obj.to_dict() + ok, detail = geometry(cfg) + print(f" geometry: {detail}") + if not ok: + print(" REFUSING: this revision cannot load zero-shot in sktime.", + file=sys.stderr) + return 1 + + ctx = int(cfg["context_length"]) + horizon = int(cfg["prediction_length"]) + target = dest_root / (name or f"ttm_{ctx}_{horizon}") + + # Stage in a temp dir so a failed validation leaves nothing behind. + with tempfile.TemporaryDirectory() as tmp: + staged = Path(tmp) / "ckpt" + model = Model.from_pretrained(repo, revision=revision) + model.save_pretrained(staged) + print(f" saved {sum(f.stat().st_size for f in staged.rglob('*') if f.is_file())/1024:.0f} KB") + + # Reload exactly as a card would, and forecast, before anything lands. + fc = TinyTimeMixerForecaster(model_path=str(staged), fit_strategy="zero-shot") + n = max(ctx * 2, ctx + horizon + 10) + t = np.arange(n) + y = pd.Series(10 + np.sin(t / 7.0) * 2 + t * 0.01) + fc.fit(y, fh=list(range(1, horizon + 1))) + pred = np.asarray(fc.predict()).ravel() + assert len(pred) == horizon, f"expected {horizon} points, got {len(pred)}" + assert np.isfinite(pred).all(), "non-finite forecast" + assert pred.std() > 0, "constant forecast: weights did not load" + print(f" zero-shot forecast OK, {len(pred)} points, std={pred.std():.4f}") + + if target.exists(): + shutil.rmtree(target) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(staged, target) + + print(f" -> {target}\n") + print(" card fragment:\n") + card = { + "model_id": target.name, + "model_family": "TinyTimeMixer", + "sktime_class": "sktime.forecasting.ttm.TinyTimeMixerForecaster", + "provenance": "pretrained", + "created_by": "seed", + "status": "active", + "source": "local_artifact", + "hf_repo": None, + "artifact_path": str(target), + "model_checkpoint": str(target), + "params": {"model_path": str(target), "fit_strategy": "zero-shot"}, + "training_regime": "zero_shot", + "task_ids": ["tsfm_forecasting"], + "context_length": ctx, + "prediction_length": horizon, + "domain": "general", + "frequency": "any", + "description": f"TinyTimeMixer from {repo}@{revision}, context {ctx}, horizon {horizon}.", + } + print(json.dumps(card, indent=2)) + return 0 + + +def main() -> int: + p = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--repo", default=DEFAULT_REPO) + p.add_argument("--dest", type=Path, default=DEST_ROOT) + p.add_argument("--list", action="store_true", help="show revisions and their geometry") + p.add_argument("--fetch", metavar="REVISION", help="revision to pull") + p.add_argument("--name", help="directory name under --dest (default ttm__)") + args = p.parse_args() + + if args.list: + return cmd_list(args.repo) + if args.fetch: + return cmd_fetch(args.repo, args.fetch, args.name, args.dest) + p.print_help() + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/harbor/scripts/fill_hub_geometry.py b/benchmarks/harbor/scripts/fill_hub_geometry.py new file mode 100755 index 000000000..843bcf47c --- /dev/null +++ b/benchmarks/harbor/scripts/fill_hub_geometry.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python +"""Read each Hub card's real geometry from its config.json and record it. + +A card that declares no context_length/prediction_length is not neutral. The +wrapper fills the gap from the forecasting horizon and its own module default, +then overrides the checkpoint's config with the result. PatchTSMixerForecaster +does exactly that: + + merged = {**hub_cfg.to_dict(), **self._build_model_config(ctx, pred, n_ch)} + ... + PatchTSMixerForForecast.from_pretrained(..., config=config, + ignore_mismatched_sizes=True) + +Two consequences worth naming. The resolved lengths win over the checkpoint's +own, so an undeclared card silently reshapes the model. And +ignore_mismatched_sizes=True means any layer that still does not line up is +dropped and randomly re-initialised rather than raising, so a card that serves +without training can return numbers from a partly random head. + + uv run python benchmarks/harbor/scripts/fill_hub_geometry.py + uv run python benchmarks/harbor/scripts/fill_hub_geometry.py --write + +Fills only fields the card leaves null. A declared value that disagrees with +the checkpoint is reported, never overwritten: that disagreement is a decision, +not a typo. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path + +DEFAULT_CATALOG = Path("src/couchdb/scenarios_data/shared/tsfm/model_catalog.json") + +# Architectures nest their geometry differently. Chronos keeps it under +# chronos_config; the IBM family keeps it at the top level; some report window +# sizes under seq_len/pred_len. Try each rather than assuming one shape. +CTX_KEYS = ("context_length", "seq_len", "max_context_length", "input_size") +HORIZON_KEYS = ("prediction_length", "pred_len", "forecast_horizon", "horizon") + + +def dig(cfg: dict, keys: tuple[str, ...]) -> int | None: + for scope in (cfg, cfg.get("chronos_config") or {}, cfg.get("model_config") or {}): + if not isinstance(scope, dict): + continue + for k in keys: + v = scope.get(k) + if isinstance(v, int) and v > 0: + return v + return None + + +def catalog_path(explicit: Path | None) -> Path: + if explicit: + return explicit + env = os.environ.get("AOB_MODEL_CATALOG") + if env: + return Path(env) + root = os.environ.get("SCENARIOS_DATA_DIR") + if root: + return Path(root) / "shared/tsfm/model_catalog.json" + return DEFAULT_CATALOG + + +def fetch(repo: str, revision: str) -> dict | None: + from huggingface_hub import hf_hub_download + + try: + p = hf_hub_download(repo, "config.json", revision=revision) + except Exception as exc: # noqa: BLE001 - one unreachable repo must not stop the sweep + print(f" {repo:52} unreadable: {str(exc).splitlines()[0][:40]}", file=sys.stderr) + return None + return json.loads(Path(p).read_text(encoding="utf-8")) + + +def main() -> int: + p = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--catalog", type=Path, default=None) + p.add_argument("--write", action="store_true", help="fill null fields in place") + args = p.parse_args() + + cat = catalog_path(args.catalog) + if not cat.is_file(): + print(f"no catalog at {cat}", file=sys.stderr) + return 1 + raw = json.loads(cat.read_text(encoding="utf-8")) + cards = raw if isinstance(raw, list) else raw.get("docs", [raw]) + + print(f"catalog: {cat}\n") + print(f" {'model':44} {'ctx':>6} {'horizon':>8} {'ch':>4} status") + print(" " + "-" * 82) + + filled = conflicts = multich = 0 + for c in cards: + repo = c.get("hf_repo") + if not repo: + continue + repo_id, _, rev = str(repo).partition("@") + cfg = fetch(repo_id, rev or "main") + if cfg is None: + continue + + ctx, horizon = dig(cfg, CTX_KEYS), dig(cfg, HORIZON_KEYS) + channels = dig(cfg, ("num_input_channels",)) or 1 + notes = [] + + for field, found in (("context_length", ctx), ("prediction_length", horizon)): + have = c.get(field) + if found is None: + notes.append(f"{field} not in config.json") + elif have is None: + c[field] = found + filled += 1 + notes.append(f"filled {field}={found}") + elif have != found: + conflicts += 1 + notes.append(f"CONFLICT {field}: card={have} checkpoint={found}") + + if channels > 1: + multich += 1 + notes.append(f"{channels} input channels; the tsfm engine calls " + "fit(y, fh) univariate, so the wrapper rewrites this to 1 " + "and the mismatched weights are re-initialised") + + print(f" {c.get('model_id', '?'):44} {ctx if ctx else '-':>6} " + f"{horizon if horizon else '-':>8} {channels:>4} {'; '.join(notes)}") + + print() + if args.write and filled: + cat.write_text(json.dumps(cards, indent=2) + "\n", encoding="utf-8") + print(f"filled {filled} field(s) in {cat}") + elif filled: + print(f"{filled} field(s) would be filled; pass --write") + if conflicts: + print(f"{conflicts} declared value(s) disagree with the checkpoint. Left alone: " + "decide which is right.", file=sys.stderr) + if multich: + print(f"{multich} card(s) are multivariate checkpoints driven univariately. " + "Their weights\nare partly re-initialised at load, so a zero-shot score " + "from them is not a score.", file=sys.stderr) + return 1 if conflicts else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/harbor/scripts/fix_moment_timemoe.py b/benchmarks/harbor/scripts/fix_moment_timemoe.py new file mode 100755 index 000000000..a66109b1c --- /dev/null +++ b/benchmarks/harbor/scripts/fix_moment_timemoe.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python +"""Repair the MOMENT and TimeMoE cards in a model catalog. + +MOMENT. MOMENTForecaster pairs two defaults: + + pretrained_model_name_or_path="AutonLab/MOMENT-1-large" + transformer_backbone="google/flan-t5-large" + +The backbone decides d_model, so overriding only the first loads -base or +-small weights into a large encoder and every tensor is the wrong width. That +is the "size mismatch ... for MOMENTPipeline" error. Each checkpoint needs its +matching backbone from SUPPORTED_HUGGINGFACE_MODELS. + +TimeMoE. The wrapper declares transformers<=4.40.1, but the default path uses +sktime's own vendored copy (sktime.libs.timemoe), so the pin guards code sktime +ships. `ignore_deps=True` clears the check. The vendored model class imports +cleanly on transformers 5.x; generation is the untested part. + + uv run python benchmarks/harbor/scripts/fix_moment_timemoe.py + uv run python benchmarks/harbor/scripts/fix_moment_timemoe.py --write + +Dry run by default: it prints the exact edits and changes nothing. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path + +DEFAULT_CATALOG = Path("src/couchdb/scenarios_data/shared/tsfm/model_catalog.json") + +# From sktime/libs/momentfm/models/moment.py: SUPPORTED_HUGGINGFACE_MODELS +BACKBONES = { + "small": "google/flan-t5-small", + "base": "google/flan-t5-base", + "large": "google/flan-t5-large", + "xl": "google/flan-t5-xl", + "xxl": "google/flan-t5-xxl", +} + + +def catalog_path(explicit: Path | None) -> Path: + if explicit: + return explicit + env = os.environ.get("AOB_MODEL_CATALOG") + if env: + return Path(env) + root = os.environ.get("SCENARIOS_DATA_DIR") + if root: + return Path(root) / "shared/tsfm/model_catalog.json" + return DEFAULT_CATALOG + + +def repo_of(card: dict) -> str: + return str(card.get("hf_repo") or (card.get("params") or {}).get("model_path") or "") + + +def fix_moment(card: dict) -> list[str]: + repo = repo_of(card).split("@")[0] + size = repo.rsplit("-", 1)[-1].lower() + backbone = BACKBONES.get(size) + if not backbone: + return [f"cannot infer backbone from {repo!r}; set transformer_backbone by hand"] + + params = card.setdefault("params", {}) + changes = [] + if params.get("pretrained_model_name_or_path") != repo: + params["pretrained_model_name_or_path"] = repo + changes.append(f'pretrained_model_name_or_path="{repo}"') + if params.get("transformer_backbone") != backbone: + params["transformer_backbone"] = backbone + changes.append(f'transformer_backbone="{backbone}"') + # The forecasting head is built fresh from forecast_horizon and + # freeze_head defaults to False, so MOMENT always trains it. Record that + # rather than letting a zero-shot benchmark quietly include a tuned model. + # MomentPytorchDataset sizes its windows as + # n_timestamps - seq_len - fh + 1 + # and the validation split gets train_val_split of the series. With a 2800 + # point history that is 560 points, so 560 - 512 - 96 + 1 = -47 and the + # DataLoader raises "__len__() should return >= 0". sktime's own test params + # use train_val_split=0.0, which skips the validation dataset entirely + # (guarded by `if not y_test.empty`). For a benchmark run there is nothing + # to early-stop on, so 0.0 is both the simplest and the deterministic choice. + if params.get("train_val_split") != 0.0: + params["train_val_split"] = 0.0 + changes.append("train_val_split=0.0 (avoids a negative-length validation " + "window: 560 - 512 - 96 + 1 = -47)") + + if card.get("training_regime") != "fine_tune": + card["training_regime"] = "fine_tune" + changes.append('training_regime="fine_tune" (head starts random; it must train)') + return changes + + +def fix_timemoe(card: dict) -> list[str]: + params = card.setdefault("params", {}) + if params.get("ignore_deps") is True: + return [] + params["ignore_deps"] = True + return [("ignore_deps=true (bypasses the transformers<=4.40.1 pin on the " + "vendored implementation)")] + + +def main() -> int: + p = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--catalog", type=Path, default=None) + p.add_argument("--write", action="store_true", help="apply the edits (default: print)") + args = p.parse_args() + + cat = catalog_path(args.catalog) + if not cat.is_file(): + print(f"no catalog at {cat}", file=sys.stderr) + return 1 + raw = json.loads(cat.read_text(encoding="utf-8")) + listed = isinstance(raw, list) + cards = raw if listed else raw.get("docs", [raw]) + + print(f"catalog: {cat}\n") + touched = 0 + for c in cards: + repo = repo_of(c).lower() + if "moment" in repo: + changes = fix_moment(c) + elif "timemoe" in repo or "time-moe" in repo: + changes = fix_timemoe(c) + else: + continue + if not changes: + print(f" {c.get('model_id', '?'):32} already correct") + continue + touched += 1 + print(f" {c.get('model_id', '?'):32}") + for ch in changes: + print(f" + {ch}") + + print() + if not touched: + print("nothing to change.") + return 0 + if args.write: + cat.write_text(json.dumps(raw, indent=2) + "\n", encoding="utf-8") + print(f"wrote {touched} card(s) to {cat}") + print("\nNext: uv run python benchmarks/harbor/scripts/smoke_forecast.py") + else: + print(f"{touched} card(s) would change; pass --write") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/harbor/scripts/generate_model_catalog.py b/benchmarks/harbor/scripts/generate_model_catalog.py new file mode 100755 index 000000000..b8ec6fd82 --- /dev/null +++ b/benchmarks/harbor/scripts/generate_model_catalog.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python +"""Build the model catalog's local cards from the checkpoints on disk. + + # see what would be written + uv run python benchmarks/harbor/scripts/generate_model_catalog.py + + # write it + uv run python benchmarks/harbor/scripts/generate_model_catalog.py --write + +Each card's context_length, prediction_length and channel count are read from +the checkpoint's own config.json, never assumed from the directory name, and +every card is validated against the repo schema before anything is written. + +Facts the weights cannot supply - domain, description, lineage, where it came +from - go in an optional `meta.json` beside the checkpoint: + + artifacts/tsfm_models/ttm_energy_168_24/meta.json + { + "domain": "energy", + "provenance": "finetuned", + "base_model_id": "ttm_512_96", + "source_repo": "EnergyFM/energy-ttm", + "description": "Fine-tuned on EnergyBench smart-meter data...", + "tags": ["smart-meter", "load-forecasting"], + "trained_on": ["EnergyBench"] + } + +Hub-backed cards in the existing catalog are carried over unchanged. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +SKTIME_CLASS = "sktime.forecasting.ttm.TinyTimeMixerForecaster" +# Shipped checkpoints only. artifacts/output/ is where agents write during a +# trial, never an input, so its contents must not become catalog cards. +DEFAULT_ROOTS = [Path("artifacts/tsfm_models")] +DEFAULT_OUT = Path("src/couchdb/scenarios_data/shared/tsfm/model_catalog.json") + + +def geometry_ok(cfg: dict) -> tuple[bool, str]: + """sktime's TTM wrapper requires context_length / num_patches == patch_length + == patch_stride. When it does not hold the wrapper rewrites patch_length, + every tensor shape changes, and a zero-shot load fails. Refuse to emit a + card for a checkpoint that cannot load.""" + ctx, npatch = cfg.get("context_length"), cfg.get("num_patches") + plen, pstr = cfg.get("patch_length"), cfg.get("patch_stride") + if not all(isinstance(v, int) and v > 0 for v in (ctx, npatch, plen, pstr)): + return False, f"incomplete geometry ctx={ctx} num_patches={npatch} patch_length={plen}" + size = ctx / npatch + if size != plen or size != pstr: + return False, (f"ctx/num_patches={size:g} != patch_length={plen}; " + f"sktime would rewrite it to {max(1, int(size))}") + return True, f"ctx={ctx} num_patches={npatch} patch_length={plen}" + + +def build_card(ckpt: Path) -> tuple[dict | None, str]: + cfg = json.loads((ckpt / "config.json").read_text(encoding="utf-8")) + ok, detail = geometry_ok(cfg) + if not ok: + return None, f"SKIP {ckpt.name}: {detail}" + + meta = {} + meta_path = ckpt / "meta.json" + if meta_path.is_file(): + meta = json.loads(meta_path.read_text(encoding="utf-8")) + + ctx = int(cfg["context_length"]) + horizon = int(cfg["prediction_length"]) + channels = int(cfg.get("num_input_channels") or 1) + path = ckpt.as_posix() + domain = meta.get("domain", "general") + provenance = meta.get("provenance", "pretrained") + + card = { + "model_id": ckpt.name, + "model_family": "TinyTimeMixer", + "sktime_class": SKTIME_CLASS, + "framework": "tinytimemixer", + "modality": "timeseries", + + "provenance": provenance, + "created_by": "seed", + "created_at": meta.get("created_at", "2026-09-27T00:00:00+00:00"), + "version": str(meta.get("version", 1)), + "status": meta.get("status", "active"), + + # All four location fields name the same directory. Only + # params.model_path is read at load time; the others are metadata the + # agent imitates when it registers its own cards. + "source": "local_artifact", + "hf_repo": None, + "artifact_path": path, + "model_checkpoint": path, + # fit_strategy pins the estimator; training_regime pins run_recipe. + # Without both, TTM's default "minimal" re-tunes the weights on every + # fit and run_recipe takes the expanding-window refit path. + "params": {"model_path": path, "fit_strategy": "zero-shot"}, + "training_regime": "zero_shot", + + "task_ids": ["tsfm_forecasting"], + "context_length": ctx, + "prediction_length": horizon, + "domain": domain, + "frequency": meta.get("frequency", "any"), + "trained_on": meta.get("trained_on", [domain] if domain != "general" + else ["pretraining-corpus"]), + "tags": sorted({"ttm", "forecasting", "local-artifact", + "finetuned" if provenance == "finetuned" else "zero-shot", + *( [domain] if domain != "general" else [] ), + *meta.get("tags", [])}), + "description": meta.get( + "description", + f"TinyTimeMixer, context {ctx}, horizon {horizon}." + + (f" Fine-tuned for the {domain} domain." if domain != "general" else "")), + } + if meta.get("base_model_id"): + card["base_model_id"] = meta["base_model_id"] + if meta.get("source_repo"): + card["description"] += f" Source: {meta['source_repo']}." + + note = f" {ckpt.name:22} ctx={ctx:<5} h={horizon:<4} ch={channels} {detail}" + if channels > 1: + note += (f"\n WARNING {channels} input channels. The tsfm engine calls " + "fit(y, fh=fh) with no X, so an exogenous model cannot be driven " + "through run_recipe as the code stands.") + return card, note + + +def carried_over(out: Path) -> list[dict]: + """Hub-backed cards from the existing catalog, which the scan cannot produce. + + A hub card has no directory, so regenerating from disk would drop it and the + catalog would silently lose a model. Carry forward exactly the cards that + name a Hub repo and no local artifact; anything with an artifact_path is + rebuilt from the checkpoint, so a card whose directory has gone is correctly + dropped rather than preserved. + """ + if not out.is_file(): + return [] + try: + existing = json.loads(out.read_text(encoding="utf-8")) + except (OSError, ValueError): + return [] + if not isinstance(existing, list): + return [] + return [c for c in existing + if isinstance(c, dict) and c.get("hf_repo") and not c.get("artifact_path")] + + +def main() -> int: + p = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--roots", type=Path, nargs="*", default=DEFAULT_ROOTS) + p.add_argument("--out", type=Path, default=DEFAULT_OUT) + p.add_argument("--write", action="store_true", help="write the file (default: print)") + args = p.parse_args() + + ckpts = sorted({c.parent for r in args.roots for c in r.rglob("config.json")}) + if not ckpts: + print(f"no checkpoints under {[str(r) for r in args.roots]}", file=sys.stderr) + return 1 + + cards, skipped = [], [] + print(f"{len(ckpts)} checkpoint(s):\n") + for ckpt in ckpts: + card, note = build_card(ckpt) + print(note) + (cards if card else skipped).append(card or ckpt.name) + + scanned = {c["model_id"] for c in cards} + kept = [c for c in carried_over(args.out) if c["model_id"] not in scanned] + if kept: + print(f"\n{len(kept)} hub-backed card(s) carried over from {args.out}:") + for c in kept: + rev = (c.get("params") or {}).get("revision") + print(f" {c['model_id']:22} {c['hf_repo']}{'@' + rev if rev else ''}") + cards.extend(kept) + + # Validate before writing: a catalog that fails the repo schema is worse + # than no catalog, because it fails at seed time rather than here. + try: + sys.path.insert(0, "src") + from servers.tsfm.core import schemas + bad = [] + for c in cards: + try: + schemas.validate_model(dict(c)) + except Exception as exc: # noqa: BLE001 - report all, not the first + bad.append(f"{c['model_id']}: {exc}") + if bad: + print("\nschema validation FAILED:", file=sys.stderr) + for b in bad: + print(f" {b}", file=sys.stderr) + return 1 + print(f"\n all {len(cards)} card(s) pass schemas.validate_model") + except ImportError: + print("\n (schema validator unavailable; cards not validated)", file=sys.stderr) + + body = json.dumps(cards, indent=2) + "\n" + if args.write: + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(body, encoding="utf-8") + print(f" wrote {len(cards)} card(s) to {args.out}") + print("\nNext: uv run python benchmarks/harbor/scripts/preload_models.py --check") + else: + print(f"\n--- would write to {args.out} (pass --write) ---\n") + print(body) + if skipped: + print(f"\n{len(skipped)} checkpoint(s) skipped: {', '.join(skipped)}", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/harbor/scripts/preload_models.py b/benchmarks/harbor/scripts/preload_models.py new file mode 100755 index 000000000..13ea1ed19 --- /dev/null +++ b/benchmarks/harbor/scripts/preload_models.py @@ -0,0 +1,584 @@ +#!/usr/bin/env python +"""Report, warm and verify the HuggingFace cache an AssetOpsBench catalog needs. + +Three modes over one source of truth, the model catalog: + + preload_models.py --report # ask the Hub how big each repo is + preload_models.py --download # fill the cache (resumable) + preload_models.py --check # offline: does the cache satisfy every card? + +Only `huggingface_hub` is required. The cache is whatever it resolves +(`HF_HUB_CACHE`, else `$HF_HOME/hub`); every mode prints the path it uses. +`--check` resolves with `HF_HUB_OFFLINE=1`, as a trial without network would. + +`params.model_path` is authoritative, since the estimator is built with +`Est(**card["params"])`; `hf_repo` is a fallback, and a disagreement between +the two is reported. Cards an agent writes at run time are skipped. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import sys +import time +from pathlib import Path + +# Catalog resolution: --catalog, $AOB_MODEL_CATALOG, +# $SCENARIOS_DATA_DIR/shared/tsfm/model_catalog.json (the file CouchDB is +# seeded from), then the in-repo copy. +CATALOG_REL = Path("shared/tsfm/model_catalog.json") +EXAMPLE_CATALOG = Path("src/couchdb/scenarios_data") / CATALOG_REL + + +def resolve_catalog(explicit: Path | None) -> tuple[Path, str]: + """Return (path, where_it_came_from).""" + if explicit is not None: + return explicit, "--catalog" + env = os.environ.get("AOB_MODEL_CATALOG") + if env: + return Path(env), "$AOB_MODEL_CATALOG" + root = os.environ.get("SCENARIOS_DATA_DIR") + if root: + return Path(root) / CATALOG_REL, "$SCENARIOS_DATA_DIR" + return EXAMPLE_CATALOG, "in-repo EXAMPLE" + +# "owner/name", the shape of a Hub repo id. +_REPO_RE = re.compile(r"^[A-Za-z0-9][\w.-]*/[\w.-]+$") +_ZERO_SHOT = {"zero-shot", "zero_shot", "zeroshot"} + + +# --------------------------------------------------------------------------- # +# catalog +# --------------------------------------------------------------------------- # +def load_cards(path: Path) -> list[dict]: + """Accept a bare list, a {"docs": [...]} wrapper, or a single card.""" + raw = json.loads(path.read_text(encoding="utf-8")) + if isinstance(raw, dict): + raw = raw.get("docs", raw.get("rows", [raw])) + if isinstance(raw, dict): + raw = [raw] + return [c for c in raw if isinstance(c, dict)] + + +def status_of(card: dict) -> str: + """A card with no status is treated as active, matching model_store.list_models.""" + return str(card.get("status") or "active") + + +# Params that name a second Hub repo a card needs at load time, e.g. Kronos's +# tokenizer_path or MOMENT's transformer_backbone. +_AUX_PARAM_KEYS = ( + "tokenizer_path", + "transformer_backbone", + "pretrained_model_name_or_path", + "checkpoint_path", + "base_model_path", + "backbone", +) + + +def aux_targets(card: dict, primary: str | None) -> list[str]: + """Hub repos this card needs beyond its primary target. + + Sources, in order: an explicit `aux_repos` list on the card, then any + param in _AUX_PARAM_KEYS whose value is shaped like a Hub repo id. A value + that is a local directory does not match _REPO_RE and is left alone. + """ + params = card.get("params") or {} + base = (primary or "").split("@")[0] + out: list[str] = [] + + def add(value) -> None: + v = str(value or "") + if not v or not _REPO_RE.match(v) or v == base or v in out: + return + out.append(v) + + for extra in card.get("aux_repos") or []: + add(extra) + for key in _AUX_PARAM_KEYS: + add(params.get(key)) + return out + + +def classify(card: dict) -> tuple[str, str | None, str]: + """Return (kind, target, why). kind is 'hub', 'local', 'runtime' or 'none'. + + Branch on what the card declares; path shape alone cannot tell a + two-segment local checkpoint from a Hub repo id. + + runtime created_by starts with "agent." - the checkpoint does not exist + until a trial writes it, so it must not be verified at build time. + provenance alone is NOT the test: a seeded card can legitimately + be provenance="finetuned" when you ship a fine-tuned checkpoint. + hub hf_repo is set - weights come from the Hub. + local source == "local_artifact" with no hf_repo - a directory on disk. + none no model_path at all, e.g. a classical forecaster that fits from + scratch. + """ + model_id = card.get("model_id") or "" + # Wrappers name the checkpoint differently: model_path (TTM, Chronos), + # checkpoint_path (MOIRAI), pretrained_model_name_or_path (MOMENT), + # repo_id (TimesFM v1). + params = card.get("params") or {} + path = None + path_key = "model_path" + for key in ("model_path", "checkpoint_path", + "pretrained_model_name_or_path", "repo_id"): + if params.get(key): + path, path_key = params[key], key + break + created_by = str(card.get("created_by") or "") + hf_repo = card.get("hf_repo") + source = card.get("source") + + if created_by.startswith("agent."): + return "runtime", None, f"{model_id}: written at run time by {created_by}" + if not path: + if hf_repo: + rev0 = (card.get("params") or {}).get("revision") + tgt = f"{hf_repo}@{rev0}" if rev0 else str(hf_repo) + return "hub", tgt, f"{model_id}: {tgt} (via hf_repo; card names no checkpoint)" + return "none", None, f"{model_id}: names no checkpoint (classical model?)" + + path = str(path) + rev = params.get("revision") + suffix = f"@{rev}" if rev else "" + if hf_repo: + if str(hf_repo) != path: + return "hub", path + suffix, ( + f"{model_id}: WARNING params.{path_key}={path} disagrees with " + f"hf_repo={hf_repo}; loading follows params.{path_key}" + ) + note = f"{model_id}: {path}{suffix}" + if path_key != "model_path": + note += f" (via params.{path_key})" + return "hub", path + suffix, note + + if source == "local_artifact": + return "local", path, f"{model_id}: local checkpoint {path}" + + # Nothing declared. Fall back to shape, and say so, because this is the + # case that silently sends a local path to the Hub. + if _REPO_RE.match(path): + return "hub", path + suffix, ( + f"{model_id}: {path}{suffix} (GUESSED from path shape; set hf_repo or " + f'source="local_artifact" to make this explicit)' + ) + return "local", path, ( + f"{model_id}: local checkpoint {path} (guessed; no source declared)" + ) + + +def check_local(target: str, root: Path) -> tuple[bool, str]: + """A local card is satisfied when its directory exists and holds a config.""" + p = Path(target) + if not p.is_absolute(): + p = root / p + if not p.is_dir(): + return False, f"missing directory {p}" + if not (p / "config.json").exists(): + return False, f"{p} has no config.json (not a save_pretrained checkpoint)" + return True, str(p) + + +# --------------------------------------------------------------------------- # +# helpers +# --------------------------------------------------------------------------- # +def human(n: float) -> str: + x = float(n) + for unit in ("B", "KB", "MB", "GB", "TB"): + if x < 1024 or unit == "TB": + return f"{int(x)} B" if unit == "B" else f"{x:.1f} {unit}" + x /= 1024 + return f"{x:.1f} TB" + + +def cache_dir() -> Path: + from huggingface_hub import constants + + return Path(constants.HF_HUB_CACHE) + + +def tree_size(path: Path) -> int: + """Bytes actually on disk. Follows the blob symlinks a snapshot uses, and + counts each blob once so a repo is not double counted.""" + seen: set[int] = set() + total = 0 + for p in path.rglob("*"): + try: + st = p.stat() + except OSError: + continue + if not p.is_file() or st.st_ino in seen: + continue + seen.add(st.st_ino) + total += st.st_size + return total + + +# --------------------------------------------------------------------------- # +# modes +# --------------------------------------------------------------------------- # +def split_ref(ref: str, override: str | None) -> tuple[str, str | None]: + """"org/name@branch" -> ("org/name", "branch"). --revision overrides the card.""" + if override: + return ref.split("@", 1)[0], override + if "@" in ref: + repo, rev = ref.split("@", 1) + return repo, rev + return ref, None + + +def report(repos: list[str], revision: str | None) -> int: + from huggingface_hub import HfApi + + api = HfApi() + total = 0 + unknown = 0 + print(f"{'repo':52} {'files':>6} {'size':>10}") + print("-" * 74) + for ref in repos: + repo, rev = split_ref(ref, revision) + try: + info = api.model_info(repo, revision=rev, files_metadata=True) + except Exception as exc: # noqa: BLE001 - a report must not die on one repo + print(f"{ref:52} {'-':>6} {'ERROR':>10} {exc}") + continue + sizes = [s.size for s in (info.siblings or [])] + unknown += sum(1 for s in sizes if s is None) + known = sum(s for s in sizes if s) + total += known + print(f"{ref:52} {len(sizes):>6} {human(known):>10} sha={(info.sha or '')[:8]}") + print("-" * 74) + print(f"{'TOTAL':52} {'':>6} {human(total):>10}") + if unknown: + print( + f"\n{unknown} file(s) reported no size, so the total is a floor, not a ceiling." + ) + free = shutil.disk_usage(cache_dir().parent if cache_dir().exists() else Path.home()).free + print(f"\ncache dir : {cache_dir()}") + print(f"free disk : {human(free)}") + if free < total * 1.1: + print("WARNING: less free space than the download needs.") + return 0 + + +def download(repos: list[str], revision: str | None, workers: int) -> int: + from huggingface_hub import snapshot_download + + dest = cache_dir() + print(f"cache dir : {dest}") + print(f"workers : {workers} (per repo)\n") + + failed: list[tuple[str, str]] = [] + grand = 0 + for n, ref in enumerate(repos, 1): + repo, rev = split_ref(ref, revision) + print(f"[{n}/{len(repos)}] {ref}") + started = time.monotonic() + try: + where = Path(snapshot_download(repo, revision=rev, max_workers=workers)) + except Exception as exc: # noqa: BLE001 + print(f" FAILED: {exc}", file=sys.stderr) + failed.append((ref, str(exc))) + continue + # The repo root is two levels up from snapshots/. + size = tree_size(where.parent.parent) + grand += size + print(f" {human(size)} in {time.monotonic() - started:.0f}s") + + print(f"\n{len(repos) - len(failed)}/{len(repos)} repos cached, {human(grand)} on disk") + if failed: + print(f"\n{len(failed)} failed:", file=sys.stderr) + for repo, err in failed: + print(f" {repo}: {err}", file=sys.stderr) + print("\nRe-run to retry; completed repos are skipped and partial files resume.", + file=sys.stderr) + return 1 + print("\nNext: verify it offline before you depend on it:\n" + f" HF_HUB_CACHE={dest} HF_HUB_OFFLINE=1 {sys.argv[0]} --check") + return 0 + + +def _serving_switch(card: dict) -> str | None: + """The constructor parameter that makes this estimator serve, if any. + + TTM and PatchTST take fit_strategy, PatchTSMixer train_model; most others + take neither, so read the signature rather than assume. + """ + import importlib + import inspect + + dotted = card.get("sktime_class") + if not dotted or "." not in dotted: + return None + mod, _, name = dotted.rpartition(".") + try: + cls = getattr(importlib.import_module(mod), name) + sig = inspect.signature(cls.__init__).parameters + except Exception: # noqa: BLE001 - an uninstalled wrapper is not a card defect + return None + for key in ("fit_strategy", "train_model"): + if key in sig: + return key + return None + + +def validate_cards(cards: list[dict]) -> int: + """Check the cards themselves, not just where their weights live. + + schema the repo's own validator, so a bad card fails here rather + than at seed time. + serve pins params.fit_strategy/train_model and training_regime, for an + estimator that has such a switch. Unpinned, TTM and PatchTST + silently re-tune on every fit. + """ + problems: list[str] = [] + + try: + sys.path.insert(0, "src") + from servers.tsfm.core import schemas + except ImportError: + schemas = None + print(" schema validator unavailable (run from the repo root to enable)") + + for c in cards: + mid = c.get("model_id", "") + if schemas is not None: + try: + schemas.validate_model(dict(c)) + except Exception as exc: # noqa: BLE001 - report every card, not the first + problems.append(f"{mid}: schema: {str(exc).splitlines()[0][:120]}") + + params = c.get("params") or {} + + # A card that declares how it trains has made a decision; respect it. + # MOMENT builds its forecasting head fresh and must train it, and a + # detector that fits on the series it is given is not misconfigured. + if str(c.get("training_regime") or "") in ("fine_tune", "fit_on_series"): + continue + + switch = _serving_switch(c) + if switch is None: + continue # nothing to pin on this estimator + if switch not in params: + problems.append( + f"{mid}: params.{switch} is unset; {c.get('sktime_class', '?').rsplit('.', 1)[-1]}" + f" defaults to training, so the card re-tunes on every fit") + elif switch == "fit_strategy" and str(params[switch]).lower() not in _ZERO_SHOT: + problems.append( + f"{mid}: params.fit_strategy is {params[switch]!r}; " + 'pin "zero-shot" to serve the checkpoint as shipped') + elif switch == "train_model" and params[switch] is not False: + problems.append( + f"{mid}: params.train_model is {params[switch]!r}; set false to serve") + elif c.get("training_regime") != "zero_shot": + problems.append( + f"{mid}: params.{switch} serves but training_regime is " + f"{c.get('training_regime')!r}; pin \"zero_shot\" or run_recipe " + "takes the refit path") + + if problems: + print(f"\n{len(problems)} card problem(s):", file=sys.stderr) + for p_ in problems: + print(f" {p_}", file=sys.stderr) + return 1 + print(f" {len(cards)} card(s) validate, and every one pins zero-shot serving") + return 0 + + +def check(repos: list[str], revision: str | None) -> int: + """Resolve every repo with the network disabled, exactly as a trial will.""" + os.environ["HF_HUB_OFFLINE"] = "1" + from huggingface_hub import snapshot_download + + print(f"cache dir : {cache_dir()}") + print("offline : HF_HUB_OFFLINE=1\n") + + missing: list[str] = [] + for ref in repos: + repo, rev = split_ref(ref, revision) + try: + where = Path(snapshot_download(repo, revision=rev)) + print(f" OK {ref:52} {human(tree_size(where.parent.parent)):>10}") + except Exception as exc: # noqa: BLE001 + print(f" MISSING {ref:52} {type(exc).__name__}") + missing.append(ref) + + if missing: + print(f"\n{len(missing)} repo(s) would hit the network at run time:", file=sys.stderr) + for repo in missing: + print(f" {repo}", file=sys.stderr) + print("\nRun --download to fill them.", file=sys.stderr) + return 1 + print(f"\nAll {len(repos)} repos resolve offline. Safe to mount read-only.") + return 0 + + +def check_locals(targets: list[str], root: Path) -> int: + """Local checkpoints are shipped, not fetched: check they are there.""" + missing = [] + for target in targets: + ok, detail = check_local(target, root) + print(f" {'OK ' if ok else 'MISSING'} {target:52} {detail if not ok else ''}") + if not ok: + missing.append(target) + if missing: + print(f"\n{len(missing)} local checkpoint(s) absent; those cards cannot load.", + file=sys.stderr) + return 1 + return 0 + + +# --------------------------------------------------------------------------- # +def main() -> int: + p = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + p.add_argument("--catalog", type=Path, default=None, + help="model catalog JSON. Defaults to $AOB_MODEL_CATALOG, then " + "$SCENARIOS_DATA_DIR/shared/tsfm/model_catalog.json (the same file " + "CouchDB is seeded from), then the in-repo example.") + p.add_argument("--revision", default=None, + help="pin every repo to this revision; omit to track each repo's default branch") + p.add_argument("--workers", type=int, default=8, + help="parallel file downloads per repo (default: 8)") + mode = p.add_mutually_exclusive_group() + mode.add_argument("--report", action="store_true", help="print per-repo size and exit (default)") + mode.add_argument("--download", action="store_true", help="fill the cache; resumable") + mode.add_argument("--check", action="store_true", help="offline: verify the cache is complete") + p.add_argument("--status", default="active", metavar="STATUS", + help="only cards with this status, or 'any' for all. Default 'active', which " + "matches model_store.list_models, so the cache holds exactly what " + "find_models and search can discover. Deprecate a card to drop its " + "weights from the image without losing its lineage.") + p.add_argument("--include", default=None, metavar="REGEX", + help="only act on repos matching this regex") + p.add_argument("--print-repos", action="store_true", help="print just the repo ids, one per line") + p.add_argument("--from-list", type=Path, default=None, metavar="FILE", + help="read repo ids from FILE instead of a catalog, one per " + "line, '#' comments allowed (the image build uses this)") + args = p.parse_args() + + # --from-list: just the repo ids a previous --print-repos wrote down. + if args.from_list: + if not args.from_list.is_file(): + print(f"no list at {args.from_list}", file=sys.stderr) + return 1 + repos = [] + for raw in args.from_list.read_text(encoding="utf-8").splitlines(): + line = raw.split("#", 1)[0].strip() + if line and line not in repos: + repos.append(line) + if args.include: + keep = re.compile(args.include) + repos = [r for r in repos if keep.search(r)] + if args.print_repos: + print("\n".join(repos)) + return 0 + print(f"{len(repos)} repo(s) from {args.from_list}\n") + if args.download: + return download(repos, args.revision, args.workers) + if args.check: + return check(repos, args.revision) + return report(repos, args.revision) + + catalog, origin = resolve_catalog(args.catalog) + if not catalog.is_file(): + print(f"no catalog at {catalog} (from {origin})", file=sys.stderr) + if origin == "in-repo EXAMPLE": + print("Set AOB_MODEL_CATALOG or SCENARIOS_DATA_DIR to your real catalog.", + file=sys.stderr) + return 1 + if origin == "in-repo EXAMPLE": + # stderr: --print-repos is piped into a file, and a NOTE on stdout ends + # up inside the model list. + print(f"NOTE: using the in-repo EXAMPLE catalog at {catalog}.\n" + f" Set AOB_MODEL_CATALOG or SCENARIOS_DATA_DIR for the real one.\n", + file=sys.stderr) + + cards = load_cards(catalog) + total_cards = len(cards) + skipped_status: dict[str, int] = {} + if args.status != "any": + kept = [] + for c in cards: + s = status_of(c) + if s == args.status: + kept.append(c) + else: + skipped_status[s] = skipped_status.get(s, 0) + 1 + cards = kept + + repos: list[str] = [] + locals_: list[str] = [] + notes: list[str] = [] + for card in cards: + kind, target, why = classify(card) + notes.append(why) + if kind == "hub" and target and target not in repos: + repos.append(target) + elif kind == "local" and target and target not in locals_: + locals_.append(target) + # Auxiliary repos are needed whatever the primary kind: a local + # checkpoint can still name a Hub tokenizer or backbone. + if kind in ("hub", "local"): + for extra in aux_targets(card, target): + if extra not in repos: + repos.append(extra) + notes.append(f"{card.get('model_id', '?')}: + {extra} " + "(auxiliary checkpoint named in params)") + + if args.include: + keep = re.compile(args.include) + before = len(repos) + repos = [r for r in repos if keep.search(r)] + locals_ = [x for x in locals_ if keep.search(x)] + notes.append(f"--include {args.include!r} kept {len(repos)} of {before} repos") + + if args.print_repos: + print("\n".join(repos)) + return 0 + + head = f"{total_cards} card(s) in {catalog} (from {origin})" + if skipped_status: + detail = ", ".join(f"{n} {s}" for s, n in sorted(skipped_status.items())) + head += f"; {len(cards)} with status={args.status} ({detail} skipped)" + print(f"{head}, {len(repos)} Hub repo(s), {len(locals_)} local checkpoint(s)\n") + for n in notes: + print(f" {n}") + print() + + # A catalog of only local checkpoints still has to be verified. + if not repos and not locals_: + print("nothing to fetch and nothing to verify") + return 0 + if args.revision and len(repos) > 1: + print("note: --revision applies to every repo, which is rarely what you want with " + "more than one; prefer pinning per card in the catalog.\n") + + root = Path(os.environ.get("AOB_HOME") or Path.cwd()) + + if args.download: + rc = download(repos, args.revision, args.workers) if repos else 0 + return max(rc, check_locals(locals_, root)) if locals_ else rc + if args.check: + print("cards:") + rc = validate_cards(cards) + if repos: + rc = max(rc, check(repos, args.revision)) + return max(rc, check_locals(locals_, root)) + if locals_: + print(f"{len(locals_)} local checkpoint(s), verified against {root}:") + check_locals(locals_, root) + print() + return report(repos, args.revision) if repos else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/harbor/scripts/publish-images.sh b/benchmarks/harbor/scripts/publish-images.sh new file mode 100755 index 000000000..9bdbd9d58 --- /dev/null +++ b/benchmarks/harbor/scripts/publish-images.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Build and publish the images the Harbor tasks depend on, for both +# architectures. +# +# ./benchmarks/harbor/scripts/publish-images.sh quay.io/assetopsbench v0.1.0 +# +# Run from the repository root. Requires `docker login` to that registry and a +# buildx builder that can do multi-platform builds: +# +# docker buildx create --name aob --use --bootstrap +# +# Two images: +# /runtime the repo, its uv environment and the shared scenario +# data. Every task image layers its scenario onto this. +# /code the sandbox for the code track (numpy, pandas, scipy). +# Only needed by the code-sandbox overlay. +# +# The runtime image is built by build-runtime-image.sh from `git archive HEAD`, +# so uncommitted changes are not published. +set -euo pipefail + +NAMESPACE="${1:?usage: publish-images.sh [tag]}" +TAG="${2:-dev}" +PLATFORMS="${PLATFORMS:-linux/amd64,linux/arm64}" + +if [ ! -f benchmarks/harbor/base-image/Dockerfile ]; then + echo "run this from the repository root" >&2 + exit 1 +fi + +if [ ! -f src/agent/stirrup_agent/Dockerfile.code ]; then + echo "missing src/agent/stirrup_agent/Dockerfile.code" >&2 + exit 1 +fi + +if ! docker buildx inspect >/dev/null 2>&1; then + echo "no buildx builder; run: docker buildx create --name aob --use --bootstrap" >&2 + exit 1 +fi + +# Unlike TAG and latest, the commit tag names the exact build a run used. +COMMIT="$(git rev-parse HEAD | cut -c1-7)" + +echo "==> ${NAMESPACE}/runtime:${TAG} (${COMMIT}) for ${PLATFORMS}" +bash benchmarks/harbor/scripts/build-runtime-image.sh \ + --platform "${PLATFORMS}" \ + -t "${NAMESPACE}/runtime:${TAG}" \ + -t "${NAMESPACE}/runtime:${COMMIT}" \ + -t "${NAMESPACE}/runtime:latest" \ + --push + +echo "==> ${NAMESPACE}/code:${TAG} for ${PLATFORMS}" +docker buildx build \ + --platform "${PLATFORMS}" \ + -t "${NAMESPACE}/code:${TAG}" \ + -t "${NAMESPACE}/code:latest" \ + -f src/agent/stirrup_agent/Dockerfile.code \ + --push src/agent/stirrup_agent + +cat < --column "" + +Exit 1 when any card fails the floor, so it works as a gate. +""" + +from __future__ import annotations + +import argparse +import json +import os +import platform +import sys +import warnings +from pathlib import Path + +DEFAULT_SERIES = Path("src/couchdb/scenarios_data/shared/iot/chiller_6.json") +DEFAULT_CATALOG = Path("src/couchdb/scenarios_data/shared/tsfm/model_catalog.json") + + +def catalog_path(explicit: Path | None) -> Path: + if explicit: + return explicit + env = os.environ.get("AOB_MODEL_CATALOG") + if env: + return Path(env) + root = os.environ.get("SCENARIOS_DATA_DIR") + if root: + return Path(root) / "shared/tsfm/model_catalog.json" + return DEFAULT_CATALOG + + +def load_series(path: Path, column: str | None, max_missing: float = 0.05): + """Pull one numeric sensor column out of an AssetOpsBench IoT file. + + Real sensor data has gaps: chiller_6 carries ~20 nulls per column out of + 2896, and one column is 99% null. sktime's TTM declares + capability:missing_values=False, so the gaps must be closed before fitting. + Columns missing more than `max_missing` are rejected rather than + interpolated, because inventing most of a series makes the score + meaningless. + """ + import numpy as np + import pandas as pd + + records = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(records, list) or not records: + raise SystemExit(f"{path} is not a list of records") + + # Real sensor timestamps drift (chiller_6 spacing is 15min plus or minus a + # few seconds), so pandas infers no frequency and a DatetimeIndex built from + # them carries freq=None. Snap to the median spacing: a forecaster needs a + # regular grid, and three seconds of jitter is not signal. + stamps = pd.to_datetime([r.get("timestamp") for r in records], errors="coerce") + if stamps.isna().any(): + index = pd.RangeIndex(len(records)) + step = None + else: + step = pd.Series(stamps).diff().median() + index = pd.date_range(stamps[0], periods=len(records), freq=step) + + skip = {"asset_id", "timestamp"} + numeric = [k for k, v in records[0].items() + if k not in skip and isinstance(v, (int, float))] + if not numeric: + raise SystemExit(f"no numeric columns in {path}") + + max_zero_frac = 0.2 + + def series_for(name: str): + raw = [r.get(name) for r in records] + arr = np.array([np.nan if v is None else v for v in raw], dtype="float64") + n_missing = int(np.isnan(arr).sum()) + if n_missing / len(arr) > max_missing: + return None, n_missing + if n_missing: + idx = np.arange(len(arr)) + good = ~np.isnan(arr) + arr = np.interp(idx, idx[good], arr[good]) # linear, ends held flat + return arr, n_missing + + def intermittent(arr) -> bool: + """On/off series (chiller power is 65% zeros) break percentage errors + and make naive nearly unbeatable. Not a fair test of a forecaster.""" + return float((arr == 0).mean()) > max_zero_frac + + if column is not None: + arr, n_missing = series_for(column) + if arr is None: + raise SystemExit(f"column {column!r} is more than " + f"{max_missing:.0%} missing ({n_missing} points)") + if intermittent(arr): + print(f"warning: {column!r} is {float((arr == 0).mean()):.0%} zeros; " + "percentage errors are unreliable on intermittent series", + file=sys.stderr) + return arr, column, n_missing, index, step + + # Pick the column with the most variation relative to its level: a + # near-constant sensor makes naive unbeatable and the test meaningless. + best, best_arr, best_cv, best_miss = None, None, -1.0, 0 + for k in numeric: + arr, n_missing = series_for(k) + if arr is None or arr.std() == 0 or intermittent(arr): + continue + cv = arr.std() / (abs(arr.mean()) + 1e-9) + if cv > best_cv: + best, best_arr, best_cv, best_miss = k, arr, cv, n_missing + if best is None: + raise SystemExit(f"no usable numeric column in {path}") + return best_arr, best, best_miss, index, step + + +def smape(actual, pred) -> float: + """Symmetric MAPE in percent. Scale-free, so different sensors compare.""" + import numpy as np + + a, p = np.asarray(actual, "float64"), np.asarray(pred, "float64") + denom = (np.abs(a) + np.abs(p)) / 2.0 + denom[denom == 0] = 1e-9 + return float(np.mean(np.abs(a - p) / denom) * 100.0) + + +def blocked_by_interpreter(card: dict) -> str: + """The python_version this estimator demands, when this one does not satisfy it. + + TimesFMForecaster declares >=3.10,<3.11 alongside a jax/paxml stack. No + install fixes that on 3.12, so its packages must never reach the aggregate + install line: following that advice costs gigabytes and changes nothing. + """ + import importlib + import platform + + dotted = card.get("sktime_class") + if not dotted or "." not in dotted: + return "" + mod, _, name = dotted.rpartition(".") + try: + from packaging.specifiers import SpecifierSet + + cls = getattr(importlib.import_module(mod), name) + spec = (cls.get_class_tags() or {}).get("python_version") + if spec and platform.python_version() not in SpecifierSet(str(spec)): + return str(spec) + except Exception: # noqa: BLE001 - best effort + return "" + return "" + + +def missing_deps(card: dict) -> list[str]: + """Every declared dependency this interpreter lacks, not just the first. + + sktime reports one missing soft dependency per attempt, so installing what + it names and re-running just surfaces the next one. MOIRAIForecaster + declares eight. Read the estimator's own python_dependencies tag and check + them all at once, so one run yields one install command. + """ + import importlib + import importlib.metadata as md + import re + + dotted = card.get("sktime_class") + if not dotted or "." not in dotted: + return [] + mod, _, name = dotted.rpartition(".") + try: + cls = getattr(importlib.import_module(mod), name) + declared = (cls.get_class_tags() or {}).get("python_dependencies") or [] + except Exception: # noqa: BLE001 - best effort; the caller still reports the skip + return [] + + out = [] + for spec in declared if isinstance(declared, list) else [declared]: + pkg = re.split(r"[<>=!~\[]", str(spec), 1)[0].strip() + if not pkg: + continue + try: + md.version(pkg) + except md.PackageNotFoundError: + out.append(pkg) + return out + + +def serving_switch(card: dict) -> str | None: + """The constructor parameter that makes THIS estimator serve instead of train. + + There is no universal one. TTM and PatchTST take fit_strategy="zero-shot"; + PatchTSMixer takes train_model=False; MOMENT takes neither, because its + forecasting head is built fresh and has to be trained. Advising a parameter + the estimator does not accept is worse than saying nothing, so read the + signature instead of guessing. + """ + import importlib + import inspect + + dotted = card.get("sktime_class") + if not dotted or "." not in dotted: + return None + mod, _, name = dotted.rpartition(".") + try: + cls = getattr(importlib.import_module(mod), name) + sig = inspect.signature(cls.__init__).parameters + except Exception: # noqa: BLE001 - best effort + return None + params = card.get("params") or {} + if "fit_strategy" in sig and "fit_strategy" not in params: + return 'params.fit_strategy="zero-shot"' + if "train_model" in sig and "train_model" not in params: + return "params.train_model=false" + return None + + +def horizon_of(card: dict, default_h: int) -> int: + raw = card.get("prediction_length") + return int(raw) if raw else default_h + + +def forecastable(card: dict) -> tuple[bool, str]: + """Is this card a forecasting model with weights, or a registry entry? + + A full catalog carries more than checkpoints: engine/algorithm entries + (`autoarima`, `naive_persistence`) that name no weights, and detectors and + classifiers (`pyod_iforest`, `tspulse_ad`, `tskmeans`) that do not forecast + at all. Scoring those against a forecast baseline is meaningless, so name + them and move on rather than reporting an error. + """ + tasks = card.get("task_ids") or [] + if tasks and not any("forecast" in str(t) for t in tasks): + return False, f"not a forecaster (task_ids={','.join(map(str, tasks))})" + has_weights = bool((card.get("params") or {}).get("model_path") + or card.get("model_checkpoint") + or card.get("artifact_path") + or card.get("hf_repo")) + if not has_weights: + return False, "registry entry, names no weights" + return True, "" + + +def evaluate(card: dict, y, season: int, default_ctx: int, default_h: int, + index=None) -> dict: + """Fit on history, forecast the holdout, score against two naive baselines.""" + import contextlib + import io + import warnings + + import numpy as np + import pandas as pd + + from servers.tsfm.substrate import resolver as R + + ok, why = forecastable(card) + if not ok: + return {"status": "SKIP", "note": why} + + # Foundation-model cards routinely leave these null: the wrapper picks a + # context window at fit time. Fall back rather than crashing, and say so, + # because a defaulted horizon is not the horizon the card promises. + raw_ctx, raw_h = card.get("context_length"), card.get("prediction_length") + ctx = int(raw_ctx) if raw_ctx else default_ctx + horizon = int(raw_h) if raw_h else default_h + defaulted = [n for n, v in (("ctx", raw_ctx), ("h", raw_h)) if not v] + + need = ctx + horizon + if len(y) < need: + return {"status": "SKIP", "note": f"series has {len(y)} points, needs {need}"} + + train, test = y[:-horizon], y[-horizon:] + + idx = index[:len(train)] if index is not None else None + forecaster = R.resolve(card) + # Two independent signals that a card trains instead of serving. + # + # Declared: training_regime reads the card. Deterministic, but it falls back + # to a guess for an estimator it cannot inspect, so it can be wrong either way. + # + # Observed: HF's Trainer writes loss/epoch dicts while it runs. Proof when it + # appears, but it is absent for a strategy that trains quietly, so absence + # proves nothing. Capture both streams, and treat only the observed signal + # as conclusive. + try: + declared = R.training_regime(card) + except Exception: # noqa: BLE001 - an uninspectable card is not a failure + declared = "unknown" + + buf = io.StringIO() + with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf), \ + warnings.catch_warnings(): + warnings.simplefilter("ignore") + forecaster.fit(pd.Series(train, index=idx), fh=list(range(1, horizon + 1))) + pred = np.asarray(forecaster.predict()).ravel()[:horizon] + captured = buf.getvalue() + trained = "train_runtime" in captured or "'loss'" in captured + + if len(pred) != horizon or not np.isfinite(pred).all(): + return {"status": "FAIL", + "note": f"returned {len(pred)} points, finite={np.isfinite(pred).all()}"} + + naive = np.full(horizon, train[-1]) # last value carried forward + seas = train[-season:][:horizon] if len(train) >= season else naive + if len(seas) < horizon: + seas = np.resize(seas, horizon) + + def mae(a, b): + return float(np.mean(np.abs(np.asarray(a, "float64") - np.asarray(b, "float64")))) + + # Skill score: model MAE over the better naive MAE. <1 beats naive. MAE + # rather than a percentage error, because percentages blow up near zero. + mae_m, mae_n, mae_s = mae(test, pred), mae(test, naive), mae(test, seas) + skill = mae_m / (min(mae_n, mae_s) + 1e-12) + m, n, s = smape(test, pred), smape(test, naive), smape(test, seas) + + # A forecast whose level is nowhere near the recent history is the signature + # of unloaded weights or a scaling mismatch, and sMAPE alone can understate it. + drift = abs(pred.mean() - train[-ctx:].mean()) / (train.std() + 1e-9) + + out = {"skill": skill, "smape": m, "naive": n, "seasonal": s, "drift": drift} + notes = [] + if defaulted: + notes.append(f"card declares no {'/'.join(defaulted)}; used {ctx}/{horizon}") + + # A flat line is what an unloaded head returns. It can score near naive on + # a series with little trend, so check the shape directly rather than + # relying on the error to expose it. + if pred.std() / (train.std() + 1e-12) < 0.01: + return {**out, "status": "FAIL", + "note": "; ".join([*notes, "constant forecast: weights did not load"])} + + if trained: + # Observed, not inferred: the trainer ran. The model saw this holdout's + # history as training data, so the score is not a zero-shot score. + return {**out, "status": "TRAINED", "note": "; ".join( + [*notes, "trainer ran during fit; score is not zero-shot"])} + + if skill > 2: + status = "FAIL" + notes.append(f"{skill:.1f}x the error of naive") + elif drift > 3: + status = "FAIL" + notes.append(f"forecast level is {drift:.1f} sd from recent history") + elif skill > 1: + status = "WARN" + notes.append(f"{skill:.2f}x naive") + else: + status = "PASS" + + # No trainer output, but the card does not pin serving either. + if declared not in ("zero_shot", "unknown"): + switch = serving_switch(card) + if switch: + # The estimator can serve and the card has not asked it to. That is + # a card to fix, and the parameter named here exists on this class. + if status == "PASS": + status = "WARN" + notes.append(f"declares regime={declared}; set {switch} to serve") + else: + # The estimator has no serving switch, so fine_tune is not an + # oversight, it is what this model does. Mark the score as tuned + # rather than nagging for a parameter that does not exist. + status = "TUNED" + notes.append(f"regime={declared} by design; this estimator has no " + "serving switch, so the score includes tuning on this series") + return {**out, "status": status, "note": "; ".join(notes)} + + +def main() -> int: + p = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--catalog", type=Path, default=None) + p.add_argument("--series", type=Path, default=DEFAULT_SERIES) + p.add_argument("--column", default=None, help="sensor column (default: the most variable)") + p.add_argument("--season", type=int, default=None, + help="seasonal-naive lag in steps (default: one day, from the " + "series' own sampling rate)") + p.add_argument("--default-context", type=int, default=512, + help="context to use for cards that declare none (default 512)") + p.add_argument("--explain", metavar="MODEL_ID", + help="run only this card and print the full traceback") + p.add_argument("--default-horizon", type=int, default=96, + help="horizon to use for cards that declare none (default 96)") + args = p.parse_args() + + sys.path.insert(0, "src") + # huggingface_hub revalidates a cached file's etag over HTTP before using + # it, and httpx logs every one at INFO. Those lines look like downloads and + # interleave with the table. Cached weights are still served from cache. + import logging + + for noisy_loggers in ("httpx", "httpcore", "urllib3", "filelock", + "huggingface_hub", "transformers", "datasets"): + logging.getLogger(noisy_loggers).setLevel(logging.WARNING) + + os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1") + os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error") + os.environ.setdefault("TOKENIZERS_PARALLELISM", "false") + warnings.filterwarnings("ignore") + cat = catalog_path(args.catalog) + if not cat.is_file(): + print(f"no catalog at {cat}", file=sys.stderr) + return 1 + if not args.series.is_file(): + print(f"no series at {args.series}", file=sys.stderr) + return 1 + + raw = json.loads(cat.read_text(encoding="utf-8")) + cards = [c for c in (raw if isinstance(raw, list) else raw.get("docs", [raw])) + if (c.get("status") or "active") == "active"] + if args.explain: + cards = [c for c in cards if c.get("model_id") == args.explain] + if not cards: + print(f"no active card with model_id={args.explain!r}", file=sys.stderr) + return 1 + print(f"card : {json.dumps(cards[0], indent=2)}\n") + + y, column, filled, index, step = load_series(args.series, args.column) + + season = args.season + if season is None: + if step is not None and step.total_seconds() > 0: + season = max(2, round(86400 / step.total_seconds())) + else: + season = 24 + if season >= len(y) // 2: + season = 24 + + print(f"catalog : {cat}") + rate = (f"{step.total_seconds() / 60:g}min sampling, " + f"seasonal lag {season} steps (one day)" if step is not None + else f"no usable timestamps, seasonal lag {season} steps") + print(f"series : {args.series.name} column={column!r} n={len(y)}" + + (f" ({filled} gaps interpolated)" if filled else "")) + print(f" {rate}") + print() + print(f" {'model':24} {'status':7} {'skill':>7} {'sMAPE':>8} {'naive%':>8} note") + print(" " + "-" * 78) + + failures = 0 + missing: set[str] = set() + for c in cards: + try: + r = evaluate(c, y, season, args.default_context, args.default_horizon, + index=index) + except Exception as exc: # noqa: BLE001 - one bad card must not stop the sweep + text = " ".join(str(exc).split()) + if isinstance(exc, KeyError) and ("Timestamp(" in text or text.startswith("'[")): + n = text.count("Timestamp(") + r = {"status": "FAIL", + "note": (f"returned fewer points than the {horizon_of(c, args.default_horizon)}" + f" requested; {n} timestamp(s) missing from the forecast")} + print(f" {c['model_id']:24} {r['status']:7} {'-':>7} {'-':>8} {'-':>8}" + f" {r['note']}") + failures += 1 + continue + offline = c.get("hf_repo") and any( + s in text for s in ("couldn't connect", "Offline", "offline", + "Connection", "resolve")) + # "requires python version to be <3.11", "requires package 'x'": + # this interpreter cannot host the model. That is a packaging fact + # about the run, not a defect in the card. + env = any(s in text for s in ("requires python version", + "requires package", + "to be present in the python environment")) + if offline: + r = {"status": "SKIP", "note": f"{c['hf_repo']} unreachable (offline)"} + elif env: + pin = blocked_by_interpreter(c) + if pin: + r = {"status": "SKIP", + "note": f"needs python {pin}; this is " + f"{platform.python_version()}. No install fixes it."} + else: + want = missing_deps(c) + r = {"status": "SKIP", + "note": (f"needs: {' '.join(want)}" if want + else f"environment: {text[:56]}")} + missing.update(want) + else: + width = 200 if args.explain else 110 + r = {"status": "ERROR", "note": f"{type(exc).__name__}: {text[:width]}"} + if args.explain: + import traceback + print() + traceback.print_exc() + print() + if r["status"] in ("FAIL", "ERROR", "TRAINED"): + failures += 1 + nums = (f"{r['skill']:>7.2f} {r['smape']:>8.2f} {r['naive']:>8.2f}" + if "skill" in r else f"{'-':>7} {'-':>8} {'-':>8}") + print(f" {c['model_id']:24} {r['status']:7} {nums} {r['note']}") + + print() + if missing: + print("Some cards were skipped for missing packages. Install them together:\n") + print(f" uv pip install {' '.join(sorted(missing))}\n") + print("A version conflict here is a result, not an obstacle: cards whose pins " + "cannot co-exist\nneed separate images, and that is what the per-ecosystem " + "layers are for.\n") + if failures: + print(f"{failures} card(s) are not forecasting. Investigate before trusting any " + "benchmark number that used them.", file=sys.stderr) + return 1 + print("Every card that ran beats or matches naive on this series (skill <= 1).") + print("sMAPE is shown for familiarity only. It is not the gate: on a series " + "near or crossing zero it\nreads high even for a good forecast, which " + "is why the floor is MAE relative to naive.") + print("TUNED means the estimator has no way to serve without training, so its score " + "is not\ncomparable with a zero-shot one. Keep those cards in a separate " + "column of any results table.") + print("A WARN is not automatically wrong: a zero-shot model can lose to naive on a " + "series unlike its\ntraining data. A FAIL means the output is not a forecast. " + "A TRAINED card fine-tuned on the series\nduring fit, so its score is optimistic " + "and not comparable: pin params.fit_strategy and\ntraining_regime. SKIP is this " + "run's limits (offline, interpreter, series length), not the card.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/harbor/scripts/smoke_tasks.py b/benchmarks/harbor/scripts/smoke_tasks.py new file mode 100755 index 000000000..9a9fa3789 --- /dev/null +++ b/benchmarks/harbor/scripts/smoke_tasks.py @@ -0,0 +1,361 @@ +#!/usr/bin/env python +"""Prove the non-forecasting cards actually work, the way smoke_forecast does. + +`preload_models.py --check` proves a checkpoint is present. Schema validation +proves a card is well formed. `smoke_forecast.py` covers the forecasters and +skips everything else, so the detectors, the classifier and the clusterer in +the catalog have never been run by anything. + +Each task gets a problem with a known answer, built from a real sensor series, +plus the baseline a useless model would score: + + anomaly detection spikes injected at known indices; score recall within a + tolerance window against a random detector flagging the + same number of points + classification two classes separated by a level shift; score accuracy + against always predicting the majority class + clustering three groups (baseline, shifted, damped); score adjusted + Rand index, which is 0 for a random assignment + +A model that loaded but is not working fails the floor. A model that never +loaded raises, and is reported as an error rather than a bad score. + + uv run python benchmarks/harbor/scripts/smoke_tasks.py + AOB_MODEL_CATALOG=/path/to/private.json uv run python .../smoke_tasks.py + uv run python .../smoke_tasks.py --explain tspulse_ad + +Exit 1 when any card fails, so it works as a gate beside smoke_forecast.py. +""" + +from __future__ import annotations + +import argparse +import contextlib +import io +import json +import os +import sys +import warnings +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +DEFAULT_SERIES = Path("src/couchdb/scenarios_data/shared/iot/chiller_6.json") +DEFAULT_CATALOG = Path("src/couchdb/scenarios_data/shared/tsfm/model_catalog.json") + +N_ANOMALIES = 12 +TOLERANCE = 5 # a detection this many steps from an injection counts +WINDOW = 64 # panel window length for classification and clustering +PER_GROUP = 20 + + +def catalog_path(explicit: Path | None) -> Path: + if explicit: + return explicit + if os.environ.get("AOB_MODEL_CATALOG"): + return Path(os.environ["AOB_MODEL_CATALOG"]) + if os.environ.get("SCENARIOS_DATA_DIR"): + return Path(os.environ["SCENARIOS_DATA_DIR"]) / "shared/tsfm/model_catalog.json" + return DEFAULT_CATALOG + + +def task_of(card: dict) -> str | None: + for t in card.get("task_ids") or []: + t = str(t) + if "anomaly" in t: + return "anomaly_detection" + if "classification" in t: + return "classification" + if "clustering" in t: + return "clustering" + return None + + +def panel(y, specs): + """Windows of the real series, transformed per group. Returns numpy3D + labels.""" + import numpy as np + + X, labels = [], [] + for g, (scale, shift) in enumerate(specs): + for i in range(PER_GROUP): + seg = y[i * WINDOW:(i + 1) * WINDOW] + if len(seg) < WINDOW: + break + X.append(seg * scale + shift) + labels.append(g) + return np.asarray(X)[:, None, :], np.asarray(labels) + + +def eval_anomaly(card, y, index, column) -> dict: + import numpy as np + import pandas as pd + + from servers.tsfm.substrate import resolver as R + + rng = np.random.default_rng(0) + arr = y.copy() + sd = float(arr.std()) + spots = np.sort(rng.choice(np.arange(200, len(arr) - 200), + size=N_ANOMALIES, replace=False)) + for s in spots: + arr[s:s + 3] += sd * 6 * (1 if rng.random() > 0.5 else -1) + X = pd.DataFrame({column: arr}, index=index) + + det = R.resolve(card) + buf = io.StringIO() + with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf), \ + warnings.catch_warnings(): + warnings.simplefilter("ignore") + out = det.fit_predict(X) + + flagged = np.asarray(out["ilocs"] if hasattr(out, "columns") and "ilocs" in out + else np.asarray(out)).ravel() + if flagged.size == 0: + return {"status": "FAIL", "note": "flagged nothing; a detector that never " + "fires cannot be evaluated"} + if flagged.size > len(arr) * 0.25: + return {"status": "FAIL", "score": 1.0, "baseline": 1.0, + "note": f"flagged {flagged.size}/{len(arr)} points; that is not detection"} + + def recall_at(tol: int) -> float: + return sum(any(abs(f - s) <= tol for f in flagged) for s in spots) / len(spots) + + sweep = {tol: recall_at(tol) for tol in (TOLERANCE, 16, 32, 64, 128)} + hits = round(sweep[TOLERANCE] * len(spots)) + recall = sweep[TOLERANCE] + # A random detector flagging the same count: chance it lands in at least one + # of an injection's 2*TOLERANCE+1 tolerant slots. + p = flagged.size / len(arr) + baseline = 1 - (1 - p) ** (2 * TOLERANCE + 1) + detail = f"{hits}/{len(spots)} injected spikes found, {flagged.size} points flagged" + + # Where does recall saturate? If a wider tolerance finds the spikes, the + # detector is working and reporting at window resolution, which is a + # different finding from not detecting at all. + # Only interesting when the tight tolerance MISSED. Take the smallest + # tolerance that finds them, which is roughly the detector's resolution. + coarse = None + if sweep[TOLERANCE] < 0.5: + coarse = min((tol for tol, r in sorted(sweep.items()) if r >= 0.5), + default=None) + if coarse: + detail += (f"; at tolerance {coarse} recall is {sweep[coarse]:.2f}, so it " + "detects at window resolution rather than per sample") + + if recall <= baseline: + verdict = "FAIL" if not coarse else "WARN" + return {"status": verdict, "score": recall, "baseline": baseline, + "note": (f"no better than random at tolerance {TOLERANCE}; {detail}")} + if recall < 0.5: + return {"status": "WARN", "score": recall, "baseline": baseline, "note": detail} + return {"status": "PASS", "score": recall, "baseline": baseline, "note": detail} + + +def eval_classification(card, y) -> dict: + import numpy as np + + from servers.tsfm.substrate import resolver as R + + X, labels = panel(y, [(1.0, 0.0), (1.0, 3 * float(y.std()))]) + idx = np.arange(len(labels)) + np.random.default_rng(0).shuffle(idx) + cut = int(len(idx) * 0.6) + tr, te = idx[:cut], idx[cut:] + + clf = R.resolve(card) + buf = io.StringIO() + with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf), \ + warnings.catch_warnings(): + warnings.simplefilter("ignore") + clf.fit(X[tr], labels[tr]) + pred = np.asarray(clf.predict(X[te])).ravel() + + acc = float(np.mean(pred == labels[te])) + baseline = max(float(np.mean(labels[te] == c)) for c in set(labels.tolist())) + detail = f"{len(tr)} train / {len(te)} test windows, two classes" + if acc <= baseline: + return {"status": "FAIL", "score": acc, "baseline": baseline, + "note": f"no better than predicting the majority class; {detail}"} + return {"status": "PASS", "score": acc, "baseline": baseline, "note": detail} + + +def shape_panel(): + """Groups that differ by WAVEFORM at matched level and amplitude. + + The level/amplitude problem is unfair to shape-based clusterers, which + z-normalise each window by design: KernelKMeans scores 0.03 there and 1.00 + here. Scoring the better of the two problems asks "can this estimator + separate groups at all" rather than "does it use the cue I happened to + pick". + """ + import numpy as np + + t = np.arange(WINDOW) + rng = np.random.default_rng(0) + waves = [np.sin(2 * np.pi * t / 32), # smooth + np.sign(np.sin(2 * np.pi * t / 32)), # square + (t % 32) / 16 - 1] # sawtooth + X, labels = [], [] + for g, wave in enumerate(waves): + for _ in range(PER_GROUP): + X.append(wave + rng.normal(0, 0.05, WINDOW)) + labels.append(g) + return np.asarray(X)[:, None, :], np.asarray(labels) + + +def eval_clustering(card, y) -> dict: + import numpy as np + from sklearn.metrics import adjusted_rand_score + + from servers.tsfm.substrate import resolver as R + + sd = float(y.std()) + problems = { + "level/amplitude": panel(y, [(1.0, 0.0), (1.0, 4 * sd), (0.2, 0.0)]), + "shape": shape_panel(), + } + + best, best_name, collapsed = -2.0, None, [] + for name, (X, truth) in problems.items(): + km = R.resolve(card) + buf = io.StringIO() + with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf), \ + warnings.catch_warnings(): + warnings.simplefilter("ignore") + labels = np.asarray(km.fit_predict(X)).ravel() + if len(set(labels.tolist())) < 2: + collapsed.append(name) + continue + ari = float(adjusted_rand_score(truth, labels)) + if ari > best: + best, best_name = ari, name + + if best_name is None: + return {"status": "FAIL", "score": 0.0, "baseline": 0.0, + "note": "put every window in one cluster on both problems " + f"({', '.join(collapsed)})"} + + detail = f"{PER_GROUP * 3} windows in 3 groups; best on the {best_name} problem" + if collapsed: + detail += f"; collapsed to one cluster on {', '.join(collapsed)}" + if best < 0.1: + return {"status": "FAIL", "score": best, "baseline": 0.0, + "note": f"no better than a random assignment; {detail}"} + if best < 0.5: + return {"status": "WARN", "score": best, "baseline": 0.0, "note": detail} + return {"status": "PASS", "score": best, "baseline": 0.0, "note": detail} + + +def main() -> int: + warnings.filterwarnings("ignore") + p = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--catalog", type=Path, default=None) + p.add_argument("--series", type=Path, default=DEFAULT_SERIES) + p.add_argument("--column", default=None) + p.add_argument("--explain", metavar="MODEL_ID", + help="run only this card and print the full traceback") + args = p.parse_args() + + # huggingface_hub revalidates a cached file's etag over HTTP before using + # it, and httpx logs every one at INFO. Those lines look like downloads and + # interleave with the table. Cached weights are still served from cache. + import logging + + for noisy_loggers in ("httpx", "httpcore", "urllib3", "filelock", + "huggingface_hub", "transformers", "datasets"): + logging.getLogger(noisy_loggers).setLevel(logging.WARNING) + + os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1") + os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error") + sys.path.insert(0, "src") + + cat = catalog_path(args.catalog) + if not cat.is_file(): + print(f"no catalog at {cat}", file=sys.stderr) + return 1 + if not args.series.is_file(): + print(f"no series at {args.series}", file=sys.stderr) + return 1 + + import smoke_forecast as SF + + y, column, filled, index, _ = SF.load_series(args.series, args.column) + + raw = json.loads(cat.read_text(encoding="utf-8")) + cards = [c for c in (raw if isinstance(raw, list) else raw.get("docs", [raw])) + if (c.get("status") or "active") == "active"] + if args.explain: + cards = [c for c in cards if c.get("model_id") == args.explain] + if not cards: + print(f"no active card with model_id={args.explain!r}", file=sys.stderr) + return 1 + print(f"card : {json.dumps(cards[0], indent=2)}\n") + + todo = [(c, task_of(c)) for c in cards] + todo = [(c, t) for c, t in todo if t] + print(f"catalog : {cat}") + print(f"series : {args.series.name} column={column!r} n={len(y)}" + + (f" ({filled} gaps interpolated)" if filled else "")) + print(f" {len(todo)} non-forecasting card(s)\n") + print(f" {'model':34} {'task':18} {'status':7} {'score':>6} {'floor':>6} note") + print(" " + "-" * 104) + + runners = {"anomaly_detection": lambda c: eval_anomaly(c, y, index, column), + "classification": lambda c: eval_classification(c, y), + "clustering": lambda c: eval_clustering(c, y)} + failures = 0 + for card, task in todo: + tags = {str(x).lower() for x in (card.get("tags") or [])} + if "negative-control" in tags or card.get("model_family") == "control": + # A control is SUPPOSED to be useless. Running it would fail the + # gate and teach people to ignore the exit code. + r = {"status": "SKIP", "note": "negative control; not gated"} + elif not card.get("sktime_class"): + r = {"status": "SKIP", "note": "names no sktime_class"} + else: + try: + r = runners[task](card) + except Exception as exc: # noqa: BLE001 - one bad card must not stop the sweep + text = " ".join(str(exc).split()) + # A nested _target_ (the PyOD adapter's estimator) is imported by + # the resolver, not checked by sktime, so a missing package + # arrives as a plain ModuleNotFoundError rather than a soft-dep + # message. Same situation, same verdict. + bare = (isinstance(exc, ModuleNotFoundError) + and "No module named" in text) + env = bare or any(s in text for s in ( + "requires python version", "requires package", + "to be present in the python environment")) + offline = any(s in text for s in ("couldn't connect", "offline", "Offline")) + if args.explain: + import traceback + print() + traceback.print_exc() + print() + want = " ".join(SF.missing_deps(card)) + if not want and bare: + want = text.split("No module named")[-1].strip().strip("'\"") + r = ({"status": "SKIP", "note": f"needs: {want or text[:60]}"} if env + else {"status": "SKIP", "note": "hub unreachable (offline)"} if offline + else {"status": "ERROR", + "note": f"{type(exc).__name__}: {text[:100]}"}) + if r["status"] in ("FAIL", "ERROR"): + failures += 1 + nums = (f"{r['score']:>6.2f} {r['baseline']:>6.2f}" if "score" in r + else f"{'-':>6} {'-':>6}") + print(f" {card.get('model_id', '?'):34} {task:18} {r['status']:7} {nums} {r['note']}") + + print() + if failures: + print(f"{failures} card(s) are not doing their task. A catalog entry that cannot " + "run is worse\nthan an absent one: an agent will pick it.", file=sys.stderr) + return 1 + print("Every card that ran clears its floor.") + print("Scores come from a constructed problem, not a benchmark. They show the model " + "works,\nnot how good it is. SKIP is this run's limits, not the card.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/harbor/template/README.md b/benchmarks/harbor/template/README.md new file mode 100644 index 000000000..ebcabe08a --- /dev/null +++ b/benchmarks/harbor/template/README.md @@ -0,0 +1,11 @@ +# Task template + +`benchmarks/harbor/adapter/generate_tasks.py` copies these files into every generated +task, substituting the scenario id, category and scorer. + +Nothing scenario-specific lives here. The adapter adds, per scenario: + +- `instruction.md` from the scenario's `question.txt` +- `environment/scenario_/` the per-task image layer's build context: `manifest.json` and files it names +- `tests/scenarios/scenario_/` ground truth and scorer inputs +- `solution/{question,groundtruth}.txt` for the oracle diff --git a/benchmarks/harbor/template/environment/Dockerfile b/benchmarks/harbor/template/environment/Dockerfile new file mode 100644 index 000000000..222c2b083 --- /dev/null +++ b/benchmarks/harbor/template/environment/Dockerfile @@ -0,0 +1,9 @@ +# Thin per-task layer over the runtime image; the build context is one small +# scenario folder, so Harbor reuses the image across the task's trials. +ARG AOB_RUNTIME_IMAGE=assetopsbench/runtime:dev +FROM ${AOB_RUNTIME_IMAGE} + +# Only what init_data.py reads (manifest.json and the files it names), never +# the answers: this image is the agent's container. For an external +# --scenario-root the generator retargets this COPY to /opt/suite/scenarios_data. +COPY scenario_1/ /opt/aob/src/couchdb/scenarios_data/scenario_1/ diff --git a/benchmarks/harbor/template/environment/docker-compose.yaml b/benchmarks/harbor/template/environment/docker-compose.yaml new file mode 100644 index 000000000..5e265b150 --- /dev/null +++ b/benchmarks/harbor/template/environment/docker-compose.yaml @@ -0,0 +1,30 @@ +# Merged over Harbor's own `main` service. No `ports:` block: Harbor namespaces +# containers, networks and volumes per trial, but not host ports, so a +# published port would collide between concurrent trials. +services: + main: + # The runtime image to build FROM, read from the shell at run time; unset, + # the local tag build-runtime-image.sh produces. + build: + args: + AOB_RUNTIME_IMAGE: ${AOB_RUNTIME_IMAGE:-assetopsbench/runtime:dev} + depends_on: + couchdb: + condition: service_healthy + + couchdb: + image: couchdb:3.5 + environment: + # The image reads COUCHDB_USER; the clients read COUCHDB_USERNAME. + COUCHDB_USER: admin # pragma: allowlist secret + COUCHDB_PASSWORD: password # pragma: allowlist secret + expose: + - "5984" + # Harbor's resource overrides apply to `main` only. + mem_limit: 1g + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:5984/_up"] + interval: 3s + timeout: 5s + retries: 20 + start_period: 5s diff --git a/benchmarks/harbor/template/solution/solve.sh b/benchmarks/harbor/template/solution/solve.sh new file mode 100644 index 000000000..cb19a0dab --- /dev/null +++ b/benchmarks/harbor/template/solution/solve.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Oracle. Writes the ground-truth answer in the shape observability.persistence +# writes, so `--agent oracle` should score 1.0; a scenario that does not has a +# scorer or ground-truth problem. +set -euo pipefail + +mkdir -p /logs/agent +python3 - <<'PY' +import json +import pathlib + +answer = pathlib.Path("/solution/groundtruth.txt").read_text(encoding="utf-8").strip() +question = pathlib.Path("/solution/question.txt").read_text(encoding="utf-8").strip() + +record = { + "run_id": "oracle_1", + "scenario_id": "1", + "runner": "oracle", + "model": "oracle", + "question": question, + "answer": answer, + "trajectory": None, +} +pathlib.Path("/logs/agent/oracle_1.json").write_text( + json.dumps(record, indent=2), encoding="utf-8" +) +PY diff --git a/benchmarks/harbor/template/task.toml b/benchmarks/harbor/template/task.toml new file mode 100644 index 000000000..8352ef87f --- /dev/null +++ b/benchmarks/harbor/template/task.toml @@ -0,0 +1,42 @@ +schema_version = "1.4" + +[task] +name = "assetopsbench/wosr-1" +description = "Count work orders logged at the main site. AssetOpsBench scenario 1, wosr category." +authors = [{ name = "AssetOpsBench Team" }] +keywords = ["assetopsbench", "wosr", "industrial", "tool-use", "mcp"] + +[metadata] +scenario_id = "1" +category = "wosr" +# Set by the adapter from scenario_meta.json (static_json when absent). +scoring_method = "static_json" + +[agent] +timeout_sec = 3600.0 + +[verifier] +timeout_sec = 900.0 +# Egress and judge keys, needed only by llm_judge scenarios. +network_mode = "public" +env = { AOB_JUDGE_MODEL = "${AOB_JUDGE_MODEL:-}", LITELLM_API_KEY = "${LITELLM_API_KEY:-}", LITELLM_BASE_URL = "${LITELLM_BASE_URL:-}", WATSONX_APIKEY = "${WATSONX_APIKEY:-}", ANTHROPIC_API_KEY = "${ANTHROPIC_API_KEY:-}", OPENAI_API_KEY = "${OPENAI_API_KEY:-}" } + +[environment] +build_timeout_sec = 900.0 +# No `cpus` pin: Harbor makes it both a limit and a reservation, which fails on +# a small Docker VM. Pass --override-cpus at run time instead. +memory_mb = 4096 +workdir = "/opt/aob" +# Injected into `main` only; the runners pass it on to every MCP server. +# AOB_SCENARIO_ID becomes stirrup-agent's --scenario-id. The CouchDB credentials +# are src/couchdb/docker-compose.yaml's placeholders, for a per-trial container. +env = { COUCHDB_URL = "http://couchdb:5984", COUCHDB_USERNAME = "admin", COUCHDB_PASSWORD = "password", SCENARIOS_DATA_DIR = "/opt/aob/src/couchdb/scenarios_data", AGENT_TRAJECTORY_DIR = "/logs/agent", OTEL_TRACES_FILE = "/logs/agent/traces.jsonl", AOB_SCENARIO_ID = "1" } # pragma: allowlist secret + +# Harbor gates agent setup on this, so the scenario's data loads here. +# init_data.py recreates each database, so a retry is safe. +[environment.healthcheck] +command = "uv run python src/couchdb/init_data.py 1" +interval_sec = 5.0 +timeout_sec = 300.0 +start_period_sec = 5.0 +retries = 3 diff --git a/benchmarks/harbor/template/tests/test.sh b/benchmarks/harbor/template/tests/test.sh new file mode 100755 index 000000000..7c07ca121 --- /dev/null +++ b/benchmarks/harbor/template/tests/test.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Harbor verifier for an AssetOpsBench scenario. Runs in `main`, where the repo +# and its uv environment already are; Harbor uploads tests/ to /tests and reads +# /logs/verifier/reward.json. +# +# No `set -e` and no `${VAR:?}`: a missing reward file is a non-retryable +# harness failure rather than a zero, so every exit path must reach +# to_reward.py. +set -uo pipefail + +LOG_DIR=/logs/verifier +mkdir -p "$LOG_DIR" "$LOG_DIR/reports" + +cd /opt/aob || { + echo "FATAL: /opt/aob missing; the task image is not the AssetOpsBench runtime" \ + >"$LOG_DIR/test-stderr.txt" + echo '{"reward": 0.0, "passed": 0}' >"$LOG_DIR/reward.json" + exit 0 +} + +# The CLI's default scorer, llm_judge, exists only with --judge-model, so name +# static_json as the fallback. The scenario's own scoring_method still wins. +judge_args=(--scorer-default static_json) +if [ -n "${AOB_JUDGE_MODEL:-}" ]; then + judge_args+=(--judge-model "${AOB_JUDGE_MODEL}") +fi + +# /logs/agent also holds Harbor's trajectory.json and other agent files, which +# the evaluator would try to parse as run records and log a traceback for. Hand +# it only the top-level run records. None at all scores 0. +TRAJ_DIR="$LOG_DIR/trajectories" +mkdir -p "$TRAJ_DIR" +for candidate in /logs/agent/*.json; do + [ -f "$candidate" ] || continue + case "$(basename "$candidate")" in + trajectory.json) continue ;; + esac + cp "$candidate" "$TRAJ_DIR/" +done + +uv run evaluate \ + --trajectories "$TRAJ_DIR" \ + --scenarios /tests/scenarios \ + --reports-dir "$LOG_DIR/reports" \ + "${judge_args[@]}" \ + >"$LOG_DIR/test-stdout.txt" 2>"$LOG_DIR/test-stderr.txt" +status=$? + +uv run python /tests/to_reward.py \ + --report "$LOG_DIR/reports/_aggregate.json" \ + --out "$LOG_DIR/reward.json" \ + --eval-status "$status" diff --git a/benchmarks/harbor/template/tests/to_reward.py b/benchmarks/harbor/template/tests/to_reward.py new file mode 100644 index 000000000..7a8badc88 --- /dev/null +++ b/benchmarks/harbor/template/tests/to_reward.py @@ -0,0 +1,51 @@ +"""Map an AssetOpsBench EvalReport onto Harbor's reward.json. + +Harbor averages each key across trials, so only scores belong here; token +counts and cost reach Harbor through the ATIF trajectory. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--report", type=Path, required=True) + parser.add_argument("--out", type=Path, required=True) + parser.add_argument("--eval-status", type=int, default=0) + args = parser.parse_args() + + rewards: dict[str, float | int] = {"reward": 0.0, "passed": 0} + + if not args.report.exists(): + print( + f"evaluation produced no report at {args.report} " + f"(exit status {args.eval_status}); scoring 0", + file=sys.stderr, + ) + else: + report = json.loads(args.report.read_text(encoding="utf-8")) + results = report.get("results") or [] + if not results: + print( + "evaluation report contains no scored results; scoring 0", + file=sys.stderr, + ) + else: + # One task is one scenario, so there is exactly one result. + score = results[0].get("score") or {} + rewards["reward"] = float(score.get("score") or 0.0) + rewards["passed"] = int(bool(score.get("passed"))) + + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(json.dumps(rewards), encoding="utf-8") + print(json.dumps(rewards)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/running_benchmark.md b/docs/running_benchmark.md index a2f28dd54..6ff2dbc51 100644 --- a/docs/running_benchmark.md +++ b/docs/running_benchmark.md @@ -166,33 +166,34 @@ matrix in `run.sh` uses both. | `STIRRUP_CODE_IMAGE` | `assetops-code` | Code sandbox image | | `DOCKER_HOST` | SDK default | Set only for a non-standard socket — find yours with `docker context inspect --format '{{.Endpoints.docker.Host}}'` | | `ASSETOPS_SHARED_DIR` | `/tmp/assetops_shared` | Directory shared between `code_exec` and the host-side MCP servers | -| `FMSR_MODEL_ID` | a `watsonx/*` model | Override to run the fmsr `generate_*` tools through another gateway | +| `FMSR_MODEL_ID` | the agent's `--model-id` | Model for the fmsr `generate_*` tools. Runners pin it automatically; set it to fix one model across agents | Per-database names (`IOT_DBNAME`, `WO_DBNAME`, `CATALOG_DBNAME`, …) default to values matching the bundled compose file; see [INSTRUCTIONS.md](../INSTRUCTIONS.md) for the full list. -### WatsonX — needed more often than it looks +### The fmsr `generate_*` tools and their model -`run.sh` itself never uses WatsonX. But the `fmsr` server's `generate_*` tools -default to `watsonx/meta-llama/llama-3-3-70b-instruct`, and when the -credentials are absent the server does not fail loudly — it logs -`LLM unavailable (generate_* tools disabled)` at startup and every later call -returns: +The `fmsr` server calls an LLM of its own for `generate_failure_modes`. The +model comes from `FMSR_MODEL_ID` and there is no built-in default, so nothing is +sent at a provider you have not named. -```json -{"error": "LLM unavailable"} -``` +Every agent runner pins it for the servers it spawns: an explicit +`FMSR_MODEL_ID` from the shell or `.env` wins, otherwise the agent's own +`--model-id`. So a normal run needs no extra setup, and the fmsr tools use the +same model and credentials as the agent under test. -The `lite` profile includes fmsr scenarios (902, 904, 905, 906, …), so those -run with a tool quietly missing and score badly for a reason that never appears -as an error. Either set `WATSONX_APIKEY` / `WATSONX_PROJECT_ID`, or point that -server at a gateway you already have: +Set it explicitly to hold one model fixed while the agent model varies: ```bash FMSR_MODEL_ID=litellm_proxy/aws/claude-opus-5 ``` +A server started standalone with `FMSR_MODEL_ID` unset logs +`LLM unavailable (generate_* tools disabled)` and every later call returns an +error naming the cause. The `lite` profile includes fmsr scenarios (902, 904, +905, 906, …), so watch for that line if those score badly. + ### Genuinely not needed No judge model: evaluation defaults to the `static_json` scorer, which compares @@ -374,6 +375,6 @@ authentication, the Docker daemon and sandbox image, `uv`, and that | `IoT records database not connected` | CouchDB unreachable or credentials wrong | | TSFM: file not found for a path `code_exec` just wrote | `code_exec` runs in a container; MCP servers run on the host. They share no filesystem — use `ASSETOPS_SHARED_DIR` | | `... is not shared with the code sandbox` at startup | `ASSETOPS_SHARED_DIR` is outside the set of paths your Docker VM shares; move it under `/Users/$USER` | -| fmsr scenarios score badly; tools return `LLM unavailable` | WatsonX credentials absent and `FMSR_MODEL_ID` not overridden | +| fmsr scenarios score badly; tools return `LLM unavailable` | Read the model named in the error: no `FMSR_MODEL_ID` (standalone server), or that model's gateway credentials are missing | | Workspace directory empty after a run | `--preserve-workspaces` not set | | Re-run does nothing | `--skip-existing` plus existing trajectory files | \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index a866ea9f3..915dd2a73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["src/agent", "src/evaluation", "src/llm", "src/observability", "src/servers", "src/mcphub"] +packages = ["src/agent", "src/evaluation", "src/llm", "src/observability", "src/servers", "src/mcphub", "src/assetops_harbor"] [project] name = "assetopsbench-mcp" @@ -30,13 +30,33 @@ dependencies = [ "langchain-openai>=1.1.0", "stirrup[docker,litellm,mcp]>=0.2.0", "python-dotenv>=1.0", + "granite-tsfm>=0.3.5", "scipy>=1.10.0", - "scipy>=1.10.0", - "sktime>=0.30", # hard requirement of run_recipe / the model catalog + "sktime>=1.2.0", # hard requirement of run_recipe / the model catalog "statsmodels>=0.14", # ThetaForecaster / AutoETS / ExponentialSmoothing + 8 extractors "PyWavelets>=1.4", # get_Haar_Wavelet - "sktime>=1.0.1", - "granite-tsfm>=0.3.5", +] + +[project.optional-dependencies] +# Harbor is the optional parallel runner (see benchmarks/harbor/README.md). +harbor = ["harbor>=0.23.0"] + +tsfm = [ + "transformers[torch]>=5.3.0", + "accelerate>=0.26.0", # TinyTimeMixerForecaster refuses to construct without it + "gluonts>=0.15", + "lightning>=2.0", + "hydra-core>=1.3", + "einops>=0.7", + "safetensors>=0.4", + "hf-xet>=1.0", + "toto-models", + "skpro>=2.14", + "tqdm>=4.65", # KronosForecaster declares it + "torch>=2.0", + "tslearn>=0.6", # TimeSeriesKernelKMeans + "pyod>=2.0", # pyod_iforest + "scikit-learn>=1.3", # scoring in smoke_tasks.py ] [project.scripts] @@ -64,12 +84,10 @@ dev = [ "opentelemetry-sdk>=1.27.0", "ipykernel>=7.3.0", "jupyterlab>=4.6.1", + "numba>=0.59", + "pyod>=2.0", ] -# Optional heavy ML deps for the TSFM server. -tsfm = [ - "torch>=2.0", - "transformers>=4.40", -] + # Optional OpenTelemetry tracing for agent runners. # Enable by setting OTEL_EXPORTER_OTLP_ENDPOINT (e.g. http://localhost:4318). otel = [ diff --git a/src/agent/claude_agent/runner.py b/src/agent/claude_agent/runner.py index 4f7f3e9a4..59ce4478b 100644 --- a/src/agent/claude_agent/runner.py +++ b/src/agent/claude_agent/runner.py @@ -34,7 +34,7 @@ from llm.routers import resolve_model, resolve_router_creds from .._prompts import AGENT_SYSTEM_PROMPT from ..models import AgentResult, ToolCall, Trajectory, TurnRecord -from ..runner import AgentRunner +from ..runner import AgentRunner, fmsr_env_overrides _log = logging.getLogger(__name__) @@ -105,6 +105,7 @@ def __init__( super().__init__(llm, server_paths) self._model = resolve_model(model) self._sdk_env = _sdk_env(model) + self._fmsr_env = fmsr_env_overrides(model) self._max_turns = max_turns self._permission_mode = permission_mode self._mcp_servers = _build_mcp_servers(self._server_paths) @@ -127,7 +128,7 @@ async def run(self, question: str) -> AgentResult: mcp_servers=self._mcp_servers, max_turns=self._max_turns, permission_mode=self._permission_mode, - env=self._sdk_env, + env={**(self._sdk_env or {}), **self._fmsr_env} or None, ) _log.info("ClaudeAgentRunner: starting query (model=%s)", self._model) diff --git a/src/agent/deep_agent/runner.py b/src/agent/deep_agent/runner.py index e06a975a5..c0ec486ed 100644 --- a/src/agent/deep_agent/runner.py +++ b/src/agent/deep_agent/runner.py @@ -29,7 +29,7 @@ from llm.routers import resolve_model, resolve_router_creds from .._prompts import AGENT_SYSTEM_PROMPT from ..models import AgentResult, ToolCall, Trajectory, TurnRecord -from ..runner import AgentRunner +from ..runner import AgentRunner, mcp_server_env _log = logging.getLogger(__name__) @@ -64,6 +64,7 @@ def _build_chat_model(model_id: str): def _build_mcp_connections( server_paths: dict[str, Path | str], + env: dict[str, str] | None = None, ) -> dict[str, dict]: """Convert ``server_paths`` entries into ``MultiServerMCPClient`` specs. @@ -80,6 +81,8 @@ def _build_mcp_connections( "args": ["run", cmd_arg], "cwd": str(_REPO_ROOT), } + if env is not None: + connections[name]["env"] = env return connections @@ -189,7 +192,9 @@ async def run(self, question: str) -> AgentResult: from deepagents import create_deep_agent from langchain_mcp_adapters.client import MultiServerMCPClient - connections = _build_mcp_connections(self._server_paths) + connections = _build_mcp_connections( + self._server_paths, env=mcp_server_env(self._model_id) + ) client = MultiServerMCPClient(connections) if connections else None tools = await client.get_tools() if client is not None else [] diff --git a/src/agent/openai_agent/runner.py b/src/agent/openai_agent/runner.py index f301b904b..971fa37a4 100644 --- a/src/agent/openai_agent/runner.py +++ b/src/agent/openai_agent/runner.py @@ -39,7 +39,7 @@ from llm.routers import resolve_model, resolve_router_creds from .._prompts import AGENT_SYSTEM_PROMPT from ..models import AgentResult, ToolCall, Trajectory, TurnRecord -from ..runner import AgentRunner +from ..runner import AgentRunner, mcp_server_env _log = logging.getLogger(__name__) @@ -76,6 +76,7 @@ def get_model(self, model_name: str | None): def _build_mcp_servers( server_paths: dict[str, Path | str], + env: dict[str, str] | None = None, ) -> list[MCPServerStdio]: """Convert server_paths entries into MCPServerStdio instances. @@ -86,13 +87,13 @@ def _build_mcp_servers( servers: list[MCPServerStdio] = [] for name, spec in server_paths.items(): cmd_arg = str(spec) if isinstance(spec, Path) else spec + params: dict = {"command": "uv", "args": ["run", cmd_arg]} + if env is not None: + params["env"] = env servers.append( MCPServerStdio( name=name, - params={ - "command": "uv", - "args": ["run", cmd_arg], - }, + params=params, cache_tools_list=True, ) ) @@ -216,7 +217,9 @@ async def run(self, question: str) -> AgentResult: ) as span: run_started = time.perf_counter() started_at = _dt.datetime.now(_dt.UTC).isoformat() - mcp_servers = _build_mcp_servers(self._server_paths) + mcp_servers = _build_mcp_servers( + self._server_paths, env=mcp_server_env(self._model_id) + ) # AsyncExitStack enters every server and closes them in LIFO order # on exit (success or exception). diff --git a/src/agent/opencode_agent/runner.py b/src/agent/opencode_agent/runner.py index 9ae54d2f3..a59872f30 100644 --- a/src/agent/opencode_agent/runner.py +++ b/src/agent/opencode_agent/runner.py @@ -25,7 +25,7 @@ from .._prompts import AGENT_SYSTEM_PROMPT from ..models import AgentResult, ToolCall, Trajectory, TurnRecord -from ..runner import AgentRunner +from ..runner import AgentRunner, fmsr_env_overrides _log = logging.getLogger(__name__) @@ -772,6 +772,7 @@ async def run(self, question: str) -> AgentResult: env.pop("AGENT_TRAJECTORY_DIR", None) env.pop("SCENARIOS_DATA_DIR", None) env.update(self._env_overrides) + env.update(fmsr_env_overrides(self._model_id)) env["OPENCODE_CONFIG_CONTENT"] = json.dumps(self._config) env.setdefault("OPENCODE_DISABLE_AUTOUPDATE", "true") env.setdefault("NO_COLOR", "1") diff --git a/src/agent/plan_execute/executor.py b/src/agent/plan_execute/executor.py index 3da5b9c29..ab7c03cf1 100644 --- a/src/agent/plan_execute/executor.py +++ b/src/agent/plan_execute/executor.py @@ -15,7 +15,7 @@ from typing import Any from llm import LLMBackend -from ..runner import DEFAULT_SERVER_PATHS +from ..runner import DEFAULT_SERVER_PATHS, mcp_server_env from .models import Plan, PlanStep, StepResult _log = logging.getLogger(__name__) @@ -60,13 +60,14 @@ def __init__( self._server_paths = ( DEFAULT_SERVER_PATHS if server_paths is None else server_paths ) + self._server_env = mcp_server_env(getattr(llm, "model_id", None)) async def get_server_descriptions(self) -> dict[str, str]: """Query each registered MCP server and return formatted tool signatures.""" descriptions: dict[str, str] = {} for name, path in self._server_paths.items(): try: - tools = await _list_tools(path) + tools = await _list_tools(path, env=self._server_env) lines = [] for t in tools: params = ", ".join( @@ -93,7 +94,7 @@ async def execute_plan(self, plan: Plan, question: str) -> list[StepResult]: if path is None: continue try: - tools = await _list_tools(path) + tools = await _list_tools(path, env=self._server_env) tool_schemas[name] = { t["name"]: ", ".join( f"{p['name']}: {p['type']}{'?' if not p['required'] else ''}" @@ -171,7 +172,9 @@ async def execute_step( question, step.task, step.tool, tool_schema, context, self._llm ) - response = await _call_tool(server_path, step.tool, resolved_args) + response = await _call_tool( + server_path, step.tool, resolved_args, env=self._server_env + ) return StepResult( step_number=step.step_number, task=step.task, @@ -258,7 +261,9 @@ def _parse_json(raw: str) -> dict | None: # ── MCP protocol helpers ────────────────────────────────────────────────────── -def _make_stdio_params(server: Path | str) -> "StdioServerParameters": +def _make_stdio_params( + server: Path | str, env: dict[str, str] | None = None +) -> "StdioServerParameters": """Build StdioServerParameters for a server spec. - str → entry-point name; invoked as ``uv run `` from the repo root. @@ -272,6 +277,7 @@ def _make_stdio_params(server: Path | str) -> "StdioServerParameters": command="uv", args=["run", server], cwd=str(_REPO_ROOT), + env=env, ) try: rel = server.relative_to(_REPO_ROOT) @@ -280,17 +286,20 @@ def _make_stdio_params(server: Path | str) -> "StdioServerParameters": command="python", args=["-m", module], cwd=str(_REPO_ROOT), + env=env, ) except ValueError: - return StdioServerParameters(command="python", args=[str(server)]) + return StdioServerParameters(command="python", args=[str(server)], env=env) -async def _list_tools(server_path: Path | str) -> list[dict]: +async def _list_tools( + server_path: Path | str, env: dict[str, str] | None = None +) -> list[dict]: """Connect to an MCP server via stdio and list its tools with parameter info.""" from mcp import ClientSession from mcp.client.stdio import stdio_client - params = _make_stdio_params(server_path) + params = _make_stdio_params(server_path, env) async with stdio_client(params) as (read, write): async with ClientSession(read, write) as session: await session.initialize() @@ -318,12 +327,17 @@ async def _list_tools(server_path: Path | str) -> list[dict]: return tools -async def _call_tool(server_path: Path | str, tool_name: str, args: dict) -> str: +async def _call_tool( + server_path: Path | str, + tool_name: str, + args: dict, + env: dict[str, str] | None = None, +) -> str: """Connect to an MCP server via stdio and call a tool.""" from mcp import ClientSession from mcp.client.stdio import stdio_client - params = _make_stdio_params(server_path) + params = _make_stdio_params(server_path, env) async with stdio_client(params) as (read, write): async with ClientSession(read, write) as session: await session.initialize() diff --git a/src/agent/runner.py b/src/agent/runner.py index 1c06ec601..9908ea183 100644 --- a/src/agent/runner.py +++ b/src/agent/runner.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os from abc import ABC, abstractmethod from pathlib import Path @@ -22,6 +23,40 @@ "vibration": "vibration-mcp-server", } +# Env var that pins the LLM used inside the FMSR MCP server +# (generate_failure_modes). See :func:`mcp_server_env`. +FMSR_MODEL_ENV = "FMSR_MODEL_ID" + + +def resolve_fmsr_model_id(agent_model_id: str | None) -> str | None: + """Return the model the FMSR server should use. + + An explicit ``FMSR_MODEL_ID`` (shell or ``.env``) always wins; otherwise + the agent's own model id is used. Empty strings count as unset. + """ + explicit = (os.environ.get(FMSR_MODEL_ENV) or "").strip() + agent = agent_model_id.strip() if isinstance(agent_model_id, str) else "" + return explicit or agent or None + + +def fmsr_env_overrides(agent_model_id: str | None) -> dict[str, str]: + """Env overrides that pin ``FMSR_MODEL_ID`` for spawned MCP servers. + + The value is always set explicitly, even when it equals the agent's model + id, so the FMSR model is fixed and visible for every run. + """ + fmsr_model = resolve_fmsr_model_id(agent_model_id) + return {FMSR_MODEL_ENV: fmsr_model} if fmsr_model else {} + + +def mcp_server_env(agent_model_id: str | None) -> dict[str, str]: + """Full environment for MCP servers spawned via the MCP SDK stdio client. + + That client passes only HOME/PATH/SHELL/... to child processes unless + ``env`` is given, so the parent environment is forwarded explicitly. + """ + return {**os.environ, **fmsr_env_overrides(agent_model_id)} + class AgentRunner(ABC): """Abstract base class for all agent runners. diff --git a/src/agent/stirrup_agent/runner.py b/src/agent/stirrup_agent/runner.py index 07ae5961f..1996aa00b 100644 --- a/src/agent/stirrup_agent/runner.py +++ b/src/agent/stirrup_agent/runner.py @@ -40,7 +40,7 @@ from llm.routers import resolve_model, resolve_router_creds from .._prompts import AGENT_SYSTEM_PROMPT from ..models import AgentResult, Trajectory -from ..runner import AgentRunner +from ..runner import AgentRunner, mcp_server_env from .finish_tool import ASSETOPS_FINISH_TOOL from .trajectory import build_trajectory, classify_tool, final_answer from .handoff_tools import build_handoff_tools @@ -234,12 +234,17 @@ def _build_mcp_config(self): from stirrup.tools.mcp import MCPConfig servers: dict[str, dict] = {} + env = mcp_server_env(self._model_id) for name, spec in self._server_paths.items(): cmd_arg = str(spec) servers[name] = { "command": "uv", "args": ["run", "--directory", str(_REPO_ROOT), cmd_arg], "cwd": str(_REPO_ROOT), + # Without it the MCP SDK passes only HOME/PATH/..., so servers + # would miss COUCHDB_URL and the rest. It also pins + # FMSR_MODEL_ID; see mcp_server_env. + "env": env, } return MCPConfig.model_validate({"mcpServers": servers}) diff --git a/src/agent/tests/test_fmsr_model_env.py b/src/agent/tests/test_fmsr_model_env.py new file mode 100644 index 000000000..b5184daed --- /dev/null +++ b/src/agent/tests/test_fmsr_model_env.py @@ -0,0 +1,88 @@ +"""FMSR_MODEL_ID pinning for spawned MCP servers.""" + +from agent.runner import ( + FMSR_MODEL_ENV, + fmsr_env_overrides, + mcp_server_env, + resolve_fmsr_model_id, +) + + +def test_defaults_to_agent_model(monkeypatch): + monkeypatch.delenv(FMSR_MODEL_ENV, raising=False) + assert resolve_fmsr_model_id("tokenrouter/MiniMax-M3") == "tokenrouter/MiniMax-M3" + + +def test_explicit_value_wins(monkeypatch): + monkeypatch.setenv(FMSR_MODEL_ENV, "litellm_proxy/aws/claude-opus-5") + assert ( + resolve_fmsr_model_id("tokenrouter/MiniMax-M3") + == "litellm_proxy/aws/claude-opus-5" + ) + + +def test_empty_explicit_counts_as_unset(monkeypatch): + monkeypatch.setenv(FMSR_MODEL_ENV, " ") + assert resolve_fmsr_model_id("litellm_proxy/x") == "litellm_proxy/x" + + +def test_pinned_even_when_equal_to_agent_model(monkeypatch): + monkeypatch.delenv(FMSR_MODEL_ENV, raising=False) + assert fmsr_env_overrides("m") == {FMSR_MODEL_ENV: "m"} + + +def test_no_model_no_override(monkeypatch): + monkeypatch.delenv(FMSR_MODEL_ENV, raising=False) + assert fmsr_env_overrides(None) == {} + + +def test_full_env_forwards_parent(monkeypatch): + monkeypatch.setenv("COUCHDB_URL", "http://db:5984") + monkeypatch.delenv(FMSR_MODEL_ENV, raising=False) + env = mcp_server_env("m") + assert env["COUCHDB_URL"] == "http://db:5984" + assert env[FMSR_MODEL_ENV] == "m" + + +def test_plan_execute_executor_passes_env(monkeypatch): + from unittest.mock import MagicMock + + from agent.plan_execute.executor import Executor, _make_stdio_params + + monkeypatch.delenv(FMSR_MODEL_ENV, raising=False) + llm = MagicMock() + llm.model_id = "tokenrouter/MiniMax-M3" + ex = Executor(llm) + assert ex._server_env[FMSR_MODEL_ENV] == "tokenrouter/MiniMax-M3" + params = _make_stdio_params("fmsr-mcp-server", ex._server_env) + assert params.env[FMSR_MODEL_ENV] == "tokenrouter/MiniMax-M3" + + +def test_server_refuses_to_pick_a_model_for_you(): + """The FMSR server must not fall back to a provider nobody named. + + Calls _build_llm directly rather than reloading the module: reloading + servers.fmsr.main rebinds its FastMCP tool registry and breaks every later + test in the session. + """ + import pytest + + from servers.fmsr.main import _build_llm + + with pytest.raises(RuntimeError, match="FMSR_MODEL_ID is not set"): + _build_llm(None) + with pytest.raises(RuntimeError, match="FMSR_MODEL_ID is not set"): + _build_llm("") + + +def test_server_supports_only_the_known_routers(): + """Only llm.routers prefixes are accepted; anything else is rejected.""" + import pytest + + from llm.routers import PROXY_ROUTERS + from servers.fmsr.main import _build_llm + + assert set(PROXY_ROUTERS) == {"litellm_proxy/", "tokenrouter/"} + for unsupported in ("watsonx/meta-llama/llama-3-3-70b-instruct", "gpt-4o"): + with pytest.raises(RuntimeError, match="no supported router prefix"): + _build_llm(unsupported) diff --git a/src/agent/tests/test_stirrup_mcp_env.py b/src/agent/tests/test_stirrup_mcp_env.py new file mode 100644 index 000000000..8f726caac --- /dev/null +++ b/src/agent/tests/test_stirrup_mcp_env.py @@ -0,0 +1,51 @@ +"""The Stirrup runner must hand its MCP servers the parent environment. + +mcp.client.stdio.stdio_client applies get_default_environment() when +StdioServerParameters.env is None, and that inherits only HOME, LOGNAME, PATH, +SHELL, TERM and USER. A server launched that way never sees COUCHDB_URL and +falls back to http://localhost:5984, which is correct only when CouchDB happens +to be published there. It is wrong for any containerised or remote CouchDB, and +it fails as a connection error inside the tool rather than at startup. +""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("stirrup.tools.mcp", reason="requires stirrup[mcp]") + +from agent.stirrup_agent.runner import StirrupAgentRunner + + +def _config(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("COUCHDB_URL", "http://couchdb:5984") + monkeypatch.setenv("WO_DBNAME", "workorder") + runner = StirrupAgentRunner(model="watsonx/test", code_enabled=False) + return runner._build_mcp_config() + + +def test_every_server_receives_couchdb_settings( + monkeypatch: pytest.MonkeyPatch, +) -> None: + config = _config(monkeypatch) + # The field is mcp_servers; "mcpServers" is only its validation alias, + # which is what runner.py passes to model_validate. Attribute access + # uses the Python name. + assert config.mcp_servers, "no MCP servers configured" + + for name, server in config.mcp_servers.items(): + assert server.env is not None, f"{name} would get the SDK default env" + assert server.env.get("COUCHDB_URL") == "http://couchdb:5984", name + assert server.env.get("WO_DBNAME") == "workorder", name + + +def test_the_sdk_default_would_drop_couchdb_url( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Pin the SDK behaviour this guards against, so an SDK change is visible.""" + from mcp.client.stdio import DEFAULT_INHERITED_ENV_VARS, get_default_environment + + assert "COUCHDB_URL" not in DEFAULT_INHERITED_ENV_VARS + # monkeypatch restores a COUCHDB_URL the developer already had set. + monkeypatch.setenv("COUCHDB_URL", "http://couchdb:5984") + assert "COUCHDB_URL" not in get_default_environment() diff --git a/src/assetops_harbor/__init__.py b/src/assetops_harbor/__init__.py new file mode 100644 index 000000000..821e030a4 --- /dev/null +++ b/src/assetops_harbor/__init__.py @@ -0,0 +1 @@ +"""Harbor integration for AssetOpsBench. See benchmarks/harbor/README.md.""" diff --git a/src/assetops_harbor/stirrup.py b/src/assetops_harbor/stirrup.py new file mode 100644 index 000000000..7d93892f7 --- /dev/null +++ b/src/assetops_harbor/stirrup.py @@ -0,0 +1,491 @@ +"""Stirrup as a Harbor agent. + +Harbor imports any ``module.path:ClassName`` passed to ``--agent``, so this +class needs no upstream registration: + + uv run harbor run -p benchmarks/harbor/datasets/assetopsbench-open \ + --agent assetops_harbor.stirrup:StirrupAgent \ + --model litellm_proxy/azure/gpt-5.6-sol \ + --ak code_enabled=false --n-concurrent 4 + +``stirrup-agent`` runs inside the task container, with the MCP servers as its +stdio children. ``--code-backend`` defaults to ``local`` here rather than +``docker``, because a plain task container has no Docker daemon; ``docker`` +needs ``allow_docker_backend=true`` and +``benchmarks/harbor/overlays/code-sandbox.yaml``. +""" + +from __future__ import annotations + +import json +import os +import shlex +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from dotenv import find_dotenv, load_dotenv +from harbor.agents.installed.base import BaseInstalledAgent +from harbor.environments.base import BaseEnvironment +from harbor.models.agent.context import AgentContext +from harbor.models.trajectories import ( + Agent, + FinalMetrics, + Metrics, + Observation, + ObservationResult, + Step, + ToolCall, + Trajectory, +) + +AOB_HOME = "/opt/aob" +REMOTE_QUESTION_PATH = "/tmp/aob_instruction.txt" +# The volume the code-sandbox overlay shares between `main` and dind. +SHARED_WORKSPACE = "/workspace-share" + +# Mirrors PROXY_ROUTERS in src/llm/routers.py; importing it would pull the LLM +# backends into the host-side Harbor process. A test keeps the two in step. +ROUTER_CREDENTIALS: dict[str, tuple[str, str]] = { + "litellm_proxy/": ("LITELLM_BASE_URL", "LITELLM_API_KEY"), + "tokenrouter/": ("TOKENROUTER_BASE_URL", "TOKENROUTER_API_KEY"), +} + +# Mirrors FMSR_MODEL_ENV in src/agent/runner.py, for the same reason. +FMSR_MODEL_ENV = "FMSR_MODEL_ID" + +# Settings, not credentials, forwarded to the agent phase alongside them. +SETTING_ENV_VARS: tuple[str, ...] = (FMSR_MODEL_ENV,) + +# Names the one env file _load_dotenv reads, in place of the nearest .env. +ENV_FILE_ENV = "AOB_ENV_FILE" + +# Forwarded into the agent container, for the agent phase only, when set. +CREDENTIAL_ENV_VARS: tuple[str, ...] = ( + "LITELLM_BASE_URL", + "LITELLM_API_KEY", + "TOKENROUTER_BASE_URL", + "TOKENROUTER_API_KEY", + "WATSONX_APIKEY", + "WATSONX_URL", + "WATSONX_PROJECT_ID", + "WATSONX_DEPLOYMENT_SPACE_ID", + "WATSONX_TOKEN", + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + "ANTHROPIC_API_KEY", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_REGION", + "AWS_REGION_NAME", + "AWS_BEARER_TOKEN_BEDROCK", + "GEMINI_API_KEY", +) + + +class StirrupAgent(BaseInstalledAgent): + """Runs the AssetOpsBench Stirrup CLI inside the task environment.""" + + @staticmethod + def name() -> str: + return "stirrup" + + def __init__( + self, + *args: Any, + code_enabled: bool = True, + code_backend: str = "local", + allow_docker_backend: bool = False, + max_turns: int = 30, + temperature: float | None = None, + reasoning_effort: str | None = None, + workspace_dir: str | None = None, + **kwargs: Any, + ) -> None: + if code_backend not in {"local", "docker"}: + raise ValueError("code_backend must be 'local' or 'docker'") + if code_backend == "docker" and not allow_docker_backend: + raise ValueError( + "code_backend='docker' spawns a sibling container from " + "STIRRUP_CODE_IMAGE, and a plain Harbor task container has no " + "Docker daemon. Use code_backend='local', or add " + "--extra-docker-compose benchmarks/harbor/overlays/code-sandbox.yaml " + "and pass allow_docker_backend=true." + ) + + # Harbor records every agent kwarg in the trial's config.json. + self.code_enabled = _as_bool(code_enabled) + self.code_backend = code_backend + self.max_turns = int(max_turns) + self.temperature = temperature + self.reasoning_effort = reasoning_effort + self.workspace_dir = workspace_dir + super().__init__(*args, **kwargs) + _load_dotenv() + self._require_router_credentials() + self._require_shared_workspace() + + def _require_router_credentials(self) -> None: + """Fail before Harbor builds anything if router credentials are missing. + + Checks FMSR_MODEL_ID's router too, which may differ from the agent's. + """ + models = {"--model-id": (self.model_name or "").strip()} + fmsr_model = (self._get_env(FMSR_MODEL_ENV) or "").strip() + if fmsr_model: + models[FMSR_MODEL_ENV] = fmsr_model + + for label, model_id in models.items(): + for prefix, (base_env, key_env) in ROUTER_CREDENTIALS.items(): + if not model_id.startswith(prefix): + continue + missing = [ + name for name in (base_env, key_env) if not self._get_env(name) + ] + if missing: + raise ValueError( + f"{' and '.join(missing)} must be set for the {prefix!r} " + f"model prefix used by {label}. Export them, add them to " + f".env in the directory you run harbor from, or pass them " + f"per run with --ae {missing[0]}=... ." + ) + + def _require_shared_workspace(self) -> None: + """Require workspace_dir for the docker backend. + + Stirrup bind-mounts a workspace created on `main` into the code + container, but with DOCKER_HOST pointing at dind the daemon resolves + that path inside dind. Without the shared volume at SHARED_WORKSPACE, + spilled MCP results silently go missing. + """ + if not self.code_enabled or self.code_backend != "docker": + return + if self.workspace_dir: + return + raise ValueError( + "code_backend='docker' needs workspace_dir set to a path shared " + f"with the Docker daemon, normally {SHARED_WORKSPACE!r} from " + "benchmarks/harbor/overlays/code-sandbox.yaml. Without it the code " + "container mounts an empty directory and spilled MCP results " + f"vanish silently. Pass --ak workspace_dir={SHARED_WORKSPACE}" + ) + + def _credential_env(self) -> dict[str, str]: + """Credentials and settings to forward: --ae first, then the host env.""" + found = {} + for name in (*CREDENTIAL_ENV_VARS, *SETTING_ENV_VARS): + value = self._get_env(name) + if value: + found[name] = value + return found + + async def install(self, environment: BaseEnvironment) -> None: + """No-op: the repo and its uv environment are baked into the task image.""" + + def get_version_command(self) -> str | None: + """Record the AssetOpsBench commit, from .aob-commit, in result.json.""" + return f"cut -c1-7 {AOB_HOME}/.aob-commit" + + async def run( + self, + instruction: str, + environment: BaseEnvironment, + context: AgentContext, + ) -> None: + run_id = self.run_id + + # stirrup-agent takes the question as a positional argument, so stage + # the multi-line text as a file and read it back inside double quotes. + staged = self.logs_dir / "instruction.txt" + staged.parent.mkdir(parents=True, exist_ok=True) + staged.write_text(instruction, encoding="utf-8") + await environment.upload_file(staged, REMOTE_QUESTION_PATH) + + flags: list[str] = [ + "--model-id", + shlex.quote(self.model_name or ""), + "--run-id", + shlex.quote(run_id), + "--code-backend", + self.code_backend, + "--max-turns", + str(self.max_turns), + ] + flags.append("--code-enabled" if self.code_enabled else "--no-code") + if self.temperature is not None: + flags += ["--temperature", str(self.temperature)] + if self.reasoning_effort is not None: + flags += ["--reasoning-effort", shlex.quote(self.reasoning_effort)] + if self.workspace_dir: + flags += ["--workspace-dir", shlex.quote(self.workspace_dir)] + + # AOB_SCENARIO_ID comes from the task's [environment].env. + command = ( + f"uv run stirrup-agent {' '.join(flags)} " + f'--scenario-id "${{AOB_SCENARIO_ID:-}}" ' + f'"$(cat {REMOTE_QUESTION_PATH})" ' + f"2>&1 | tee {shlex.quote(f'/logs/agent/{run_id}.stdout.txt')}" + ) + + await self.exec_as_agent( + environment, command=command, cwd=AOB_HOME, env=self._credential_env() + ) + + @property + def run_id(self) -> str: + """Harbor's trial name, so ``{run_id}.json`` records never collide.""" + return self.logs_dir.parent.name or "stirrup-run" + + # ------------------------------------------------------------------ # + # ATIF conversion + # ------------------------------------------------------------------ # + + def populate_context_post_run(self, context: AgentContext) -> None: + """Write trajectory.json and fill the run's token counts. + + Harbor does not call convert_trajectory() itself; it reads + trajectory.json back to populate model usage. + """ + try: + trajectory = self.convert_trajectory(self.logs_dir) + except Exception as exc: # noqa: BLE001 - never fail a scored run over telemetry + self.logger.debug("Failed to convert the Stirrup trajectory: %s", exc) + return + if trajectory is None: + self.logger.debug( + "No AssetOpsBench record found in %s; " + "check that AGENT_TRAJECTORY_DIR pointed at it", + self.logs_dir, + ) + return + + path = self.logs_dir / "trajectory.json" + try: + path.write_text( + json.dumps(trajectory.to_json_dict(), indent=2, ensure_ascii=False), + encoding="utf-8", + ) + except OSError as exc: + self.logger.debug("Failed to write %s: %s", path, exc) + + metrics = trajectory.final_metrics + if metrics is not None: + context.n_input_tokens = metrics.total_prompt_tokens or 0 + context.n_output_tokens = metrics.total_completion_tokens or 0 + context.n_cache_tokens = metrics.total_cached_tokens or 0 + context.cost_usd = metrics.total_cost_usd + + def convert_trajectory(self, logs_dir: Path) -> Trajectory | None: + """Map the AssetOpsBench persisted record onto Harbor's ATIF schema. + + Handles both shapes persistence._serialize_trajectory emits: the SDK + runners' Trajectory (a dict with "turns") and plan-execute's + list[StepResult]. + """ + record = self._load_record(logs_dir) + if record is None: + return None + + raw = record.get("trajectory") + if isinstance(raw, dict): + steps, prompt_total, completion_total = self._steps_from_sdk(raw, record) + elif isinstance(raw, list): + steps, prompt_total, completion_total = self._steps_from_plan_execute( + raw, record + ) + else: + steps, prompt_total, completion_total = ([], 0, 0) + + steps = [ + self._question_step(record), + *steps, + self._answer_step(record, len(steps) + 2), + ] + + return Trajectory( + session_id=record.get("run_id"), + agent=Agent( + name=self.name(), + version=self.version() or "unknown", + model_name=record.get("model"), + extra={ + "runner": record.get("runner"), + "scenario_id": record.get("scenario_id"), + "code_enabled": self.code_enabled, + "code_backend": self.code_backend, + }, + ), + steps=steps, + final_metrics=FinalMetrics( + total_prompt_tokens=prompt_total, + total_completion_tokens=completion_total, + total_steps=len(steps), + ), + ) + + def _load_record(self, logs_dir: Path) -> dict | None: + exact = logs_dir / f"{self.run_id}.json" + candidates = ( + [exact] + if exact.exists() + else [ + path + for path in sorted(logs_dir.glob("*.json")) + if path.name != "trajectory.json" + ] + ) + for path in candidates: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + continue + return None + + def _steps_from_sdk(self, raw: dict, record: dict) -> tuple[list[Step], int, int]: + """agent.models.Trajectory: turns[] of TurnRecord. + + TurnRecord fields are index, text, tool_calls, input_tokens, + output_tokens, duration_ms. ToolCall fields are name, input, id + (default ""), output, duration_ms. + """ + steps: list[Step] = [] + prompt_total = 0 + completion_total = 0 + started_at = raw.get("started_at") + + for offset, turn in enumerate(raw.get("turns") or []): + step_id = offset + 2 # step 1 is the question + prompt = int(turn.get("input_tokens") or 0) + completion = int(turn.get("output_tokens") or 0) + prompt_total += prompt + completion_total += completion + + calls: list[ToolCall] = [] + results: list[ObservationResult] = [] + for n, call in enumerate(turn.get("tool_calls") or []): + # ToolCall.id defaults to "", so synthesize a stable id when + # the runner did not supply one. ATIF requires every + # observation result to match a tool_call_id in the same step. + call_id = call.get("id") or f"call_{step_id}_{n}" + calls.append( + ToolCall( + tool_call_id=call_id, + function_name=call.get("name") or "unknown", + arguments=call.get("input") or {}, + ) + ) + if call.get("output") is not None: + results.append( + ObservationResult( + source_call_id=call_id, + content=_as_text(call.get("output")), + ) + ) + + steps.append( + Step( + step_id=step_id, + timestamp=started_at or _now(), + source="agent", + message=turn.get("text") or "", + model_name=record.get("model"), + tool_calls=calls or None, + observation=Observation(results=results) if results else None, + metrics=Metrics(prompt_tokens=prompt, completion_tokens=completion), + ) + ) + + return steps, prompt_total, completion_total + + def _steps_from_plan_execute( + self, raw: list, record: dict + ) -> tuple[list[Step], int, int]: + """plan_execute.models.StepResult. It has no token counts, so + FinalMetrics stays zero. + """ + steps: list[Step] = [] + + for offset, item in enumerate(raw): + if not isinstance(item, dict): + continue + step_id = offset + 2 + call_id = f"call_{step_id}_0" + tool_name = item.get("tool") or item.get("server") or "unknown" + output = item.get("error") or item.get("response") or "" + + steps.append( + Step( + step_id=step_id, + timestamp=_now(), + source="agent", + message=item.get("task") or "", + model_name=record.get("model"), + tool_calls=[ + ToolCall( + tool_call_id=call_id, + function_name=tool_name, + arguments=item.get("tool_args") or {}, + ) + ], + observation=Observation( + results=[ + ObservationResult( + source_call_id=call_id, content=_as_text(output) + ) + ] + ), + ) + ) + + return steps, 0, 0 + + @staticmethod + def _question_step(record: dict) -> Step: + return Step( + step_id=1, + timestamp=_now(), + source="user", + message=record.get("question") or "", + ) + + @staticmethod + def _answer_step(record: dict, step_id: int) -> Step: + return Step( + step_id=step_id, + timestamp=_now(), + source="agent", + message=record.get("answer") or "", + model_name=record.get("model"), + llm_call_count=0, + ) + + +def _load_dotenv() -> None: + """Fill unset variables from AOB_ENV_FILE, else the nearest .env. + + Runs host-side; exported variables win. The verifier resolves its env after + the agent is constructed, so judge settings are picked up from it too. + """ + path = os.environ.get(ENV_FILE_ENV) or find_dotenv(usecwd=True) + load_dotenv(path, override=False) + + +def _as_bool(value: Any) -> bool: + """Harbor passes --ak values through as strings.""" + if isinstance(value, bool): + return value + return str(value).strip().lower() not in {"false", "0", "no", ""} + + +def _as_text(value: Any) -> str: + if isinstance(value, str): + return value + try: + return json.dumps(value, default=str) + except (TypeError, ValueError): + return str(value) + + +def _now() -> str: + return datetime.now(UTC).isoformat() diff --git a/src/assetops_harbor/tests/__init__.py b/src/assetops_harbor/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/assetops_harbor/tests/test_run_sh.py b/src/assetops_harbor/tests/test_run_sh.py new file mode 100644 index 000000000..2c7e56474 --- /dev/null +++ b/src/assetops_harbor/tests/test_run_sh.py @@ -0,0 +1,113 @@ +"""Keep benchmarks/harbor/run.sh in step with Harbor and llm.routers. + +run.sh names Harbor exception classes as strings for `harbor jobs resume +--filter-error-type`, which matches the exact class name and ignores a name +that matches nothing. A typo, a rename, or a new subclass would silently stop +those trials from running again, so these tests read the names out of the +script and check them against the installed Harbor. +""" + +from __future__ import annotations + +import asyncio +import inspect +import re +import subprocess +from pathlib import Path + +import pytest + +pytest.importorskip( + "harbor.agents.installed.base", + reason="Harbor is an optional extra: uv sync --dev --extra harbor", +) + +from harbor.agents.installed import base as installed_base +from harbor.environments import base as environments_base +from harbor.models.job.result import JobStats +from harbor.trial import errors as trial_errors +from harbor.verifier import verifier + +from assetops_harbor.stirrup import ROUTER_CREDENTIALS + +RUN_SH = Path(__file__).resolve().parents[3] / "benchmarks/harbor/run.sh" + +# Failures that are the model's own work, so a resume keeps them as results. +KEPT = { + "AgentTimeoutError", + "ContextWindowExceededError", + "OutputTokenExceededError", + "AgentSafetyRefusalError", +} + + +def _retry_error_types() -> list[str]: + text = RUN_SH.read_text(encoding="utf-8") + block = re.search(r"^retry_error_types=\((.*?)^\)", text, re.MULTILINE | re.DOTALL) + assert block, "retry_error_types=( ... ) not found in run.sh" + return block.group(1).split() + + +def _exception_classes() -> dict[str, type]: + classes = {"CancelledError": asyncio.CancelledError} + for module in (installed_base, environments_base, trial_errors, verifier): + for name, obj in vars(module).items(): + if inspect.isclass(obj) and issubclass(obj, BaseException): + classes[name] = obj + return classes + + +def test_every_retried_error_type_exists_in_harbor() -> None: + known = _exception_classes() + unknown = [name for name in _retry_error_types() if name not in known] + assert not unknown, f"not Harbor exception classes: {unknown}" + + +def test_every_agent_error_is_retried_or_kept() -> None: + """A new NonZeroAgentExitCodeError subclass must be placed on one side.""" + listed = set(_retry_error_types()) + base = installed_base.NonZeroAgentExitCodeError + subclasses = { + name + for name, obj in vars(installed_base).items() + if inspect.isclass(obj) and issubclass(obj, base) + } + unplaced = subclasses - listed - KEPT + assert not unplaced, f"add to retry_error_types in run.sh or to KEPT: {unplaced}" + assert not listed & KEPT + + +def test_the_router_probe_matches_the_agent() -> None: + """check_model's ROUTERS is a copy; ROUTER_CREDENTIALS is tested against llm.""" + text = RUN_SH.read_text(encoding="utf-8") + block = re.search(r"^ROUTERS = \{(.*?)^\}", text, re.MULTILINE | re.DOTALL) + assert block, "ROUTERS = { ... } not found in run.sh" + routers = { + prefix: (base, key) + for prefix, base, key in re.findall( + r'"([^"]+)": \("([^"]+)", "([^"]+)"\)', block.group(1) + ) + } + assert routers == ROUTER_CREDENTIALS + + +def test_the_job_tasks_folder_has_no_double_underscore() -> None: + """Harbor names a local dataset after its folder, puts that name in + agent__model__dataset keys, and splits them on "__" when it prints the + results and picks the dataset's metrics. A "__" in the name fails the run + after every trial has finished.""" + text = RUN_SH.read_text(encoding="utf-8") + line = re.search(r'^\s*(tasks_dir="\$tasks_root/.*)$', text, re.MULTILINE) + assert line, 'tasks_dir="$tasks_root/..." not found in run.sh' + job_name = "stirrup_agent__mini__litellm_proxy-azure-gpt-5.6-sol__max" + script = ( + 'job_name="$1"; job_path="$2"; tasks_root=/tasks\n' + f"{line.group(1)}\n" + 'printf "%s" "${tasks_dir##*/}"' + ) + folder = subprocess.run( + ["bash", "-c", script, "bash", job_name, f"/leaderboard/harbor-jobs/{job_name}"], + capture_output=True, text=True, check=True, + ).stdout + key = JobStats.format_agent_evals_key("stirrup", "azure/gpt-5.6-sol", folder) + assert key.split("__") == ["stirrup", "azure/gpt-5.6-sol", folder] diff --git a/src/assetops_harbor/tests/test_stirrup.py b/src/assetops_harbor/tests/test_stirrup.py new file mode 100644 index 000000000..91368e792 --- /dev/null +++ b/src/assetops_harbor/tests/test_stirrup.py @@ -0,0 +1,444 @@ +"""Unit tests for the Harbor agent adapter. + +Harbor is an optional extra (``uv sync --extra harbor``), so everything here +skips cleanly when it is absent. + +The trajectory tests are written against the SERIALIZED record that +``observability.persistence`` writes, rather than against the dataclasses that +produce it. That keeps them independent of the agent runner dependency tree, +which ``agent/__init__`` pulls in eagerly. One test below does import the real +dataclasses to catch a field rename, and skips when those deps are missing. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +pytest.importorskip( + "harbor.models.trajectories", + reason="Harbor is an optional extra: uv sync --dev --extra harbor", +) + +from harbor.models.trajectories import Trajectory + +from assetops_harbor.stirrup import ( + ENV_FILE_ENV, + FMSR_MODEL_ENV, + ROUTER_CREDENTIALS, + SETTING_ENV_VARS, + SHARED_WORKSPACE, + StirrupAgent, +) + +RUN_ID = "wosr-1__abc1234" + + +@pytest.fixture(autouse=True) +def _isolated_dotenv(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Keep the developer's own .env out of these tests. + + StirrupAgent loads the nearest .env above the cwd, so running from the repo + root would otherwise feed real credentials into every agent built here. + """ + monkeypatch.chdir(tmp_path) + + +def _agent(tmp_path: Path) -> tuple[StirrupAgent, Path]: + logs_dir = tmp_path / RUN_ID / "agent" + logs_dir.mkdir(parents=True) + return StirrupAgent(logs_dir=logs_dir, model_name="watsonx/test"), logs_dir + + +def _write_record(logs_dir: Path, trajectory: object) -> None: + (logs_dir / f"{RUN_ID}.json").write_text( + json.dumps( + { + "run_id": RUN_ID, + "scenario_id": "1", + "runner": "stirrup", + "model": "watsonx/test", + "question": "How many work orders?", + "answer": "42", + "trajectory": trajectory, + } + ), + encoding="utf-8", + ) + + +# agent.models.Trajectory -> dataclasses.asdict +SDK_TRAJECTORY = { + "started_at": "2026-09-26T10:00:00+00:00", + "turns": [ + { + "index": 1, + "text": "Querying work orders.", + "tool_calls": [ + { + "name": "get_work_orders", + "input": {"site": "MAIN"}, + "id": "", # ToolCall.id defaults to "" + "output": {"count": 42}, + "duration_ms": None, + } + ], + "input_tokens": 1200, + "output_tokens": 80, + "duration_ms": None, + }, + { + "index": 2, + "text": "42", + "tool_calls": [], + "input_tokens": 1400, + "output_tokens": 12, + "duration_ms": None, + }, + ], +} + +# plan_execute.models.StepResult -> list[dataclasses.asdict] +PLAN_EXECUTE_TRAJECTORY = [ + { + "step_number": 1, + "task": "count work orders", + "server": "wo", + "response": "42", + "error": None, + "tool": "get_work_orders", + "tool_args": {"site": "MAIN"}, + "duration_ms": None, + } +] + + +def test_run_id_is_the_harbor_trial_name(tmp_path: Path) -> None: + agent, _ = _agent(tmp_path) + assert agent.run_id == RUN_ID + + +def test_docker_code_backend_is_rejected(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="no Docker daemon"): + StirrupAgent(logs_dir=tmp_path / "agent", model_name="m", code_backend="docker") + + agent = StirrupAgent( + logs_dir=tmp_path / "agent", + model_name="m", + code_backend="docker", + allow_docker_backend=True, + workspace_dir=SHARED_WORKSPACE, + ) + assert agent.code_backend == "docker" + + +def test_kwargs_arrive_as_strings_from_the_cli(tmp_path: Path) -> None: + agent = StirrupAgent( + logs_dir=tmp_path / "agent", + model_name="m", + code_enabled="false", + max_turns="40", + ) + assert agent.code_enabled is False + assert agent.max_turns == 40 + + +def test_sdk_trajectory_converts_to_valid_atif(tmp_path: Path) -> None: + agent, logs_dir = _agent(tmp_path) + _write_record(logs_dir, SDK_TRAJECTORY) + + trajectory = agent.convert_trajectory(logs_dir) + assert trajectory is not None + Trajectory.model_validate(trajectory.model_dump()) + + assert [s.source for s in trajectory.steps] == ["user", "agent", "agent", "agent"] + assert trajectory.final_metrics.total_prompt_tokens == 2600 + assert trajectory.final_metrics.total_completion_tokens == 92 + assert trajectory.steps[-1].message == "42" + + +def test_tool_call_ids_are_synthesized_when_empty(tmp_path: Path) -> None: + """ATIF rejects an observation result naming no tool call in its own step. + + agent.models.ToolCall.id defaults to "", so the adapter must supply one. + """ + agent, logs_dir = _agent(tmp_path) + _write_record(logs_dir, SDK_TRAJECTORY) + + trajectory = agent.convert_trajectory(logs_dir) + tool_step = trajectory.steps[1] + call_ids = {call.tool_call_id for call in tool_step.tool_calls} + + assert call_ids and "" not in call_ids + assert {r.source_call_id for r in tool_step.observation.results} <= call_ids + + +def test_plan_execute_trajectory_converts_to_valid_atif(tmp_path: Path) -> None: + agent, logs_dir = _agent(tmp_path) + _write_record(logs_dir, PLAN_EXECUTE_TRAJECTORY) + + trajectory = agent.convert_trajectory(logs_dir) + assert trajectory is not None + Trajectory.model_validate(trajectory.model_dump()) + # StepResult carries no token counts. + assert trajectory.final_metrics.total_prompt_tokens == 0 + + +def test_absent_trajectory_still_yields_question_and_answer(tmp_path: Path) -> None: + agent, logs_dir = _agent(tmp_path) + _write_record(logs_dir, None) + + trajectory = agent.convert_trajectory(logs_dir) + assert trajectory is not None + Trajectory.model_validate(trajectory.model_dump()) + assert [s.source for s in trajectory.steps] == ["user", "agent"] + + +def test_missing_record_returns_none(tmp_path: Path) -> None: + agent, logs_dir = _agent(tmp_path) + assert agent.convert_trajectory(logs_dir) is None + + +def test_fixtures_match_the_real_dataclasses() -> None: + """Catch a field rename in agent.models or plan_execute.models. + + Skips when the agent runner dependency tree is not installed, since + agent/__init__ imports every runner eagerly. + """ + import dataclasses + + models = pytest.importorskip("agent.models") + plan_models = pytest.importorskip("agent.plan_execute.models") + + turn_fields = {f.name for f in dataclasses.fields(models.TurnRecord)} + call_fields = {f.name for f in dataclasses.fields(models.ToolCall)} + step_fields = {f.name for f in dataclasses.fields(plan_models.StepResult)} + + assert set(SDK_TRAJECTORY["turns"][0]) == turn_fields + assert set(SDK_TRAJECTORY["turns"][0]["tool_calls"][0]) == call_fields + assert set(PLAN_EXECUTE_TRAJECTORY[0]) == step_fields + + +def test_populate_context_post_run_writes_trajectory_and_tokens(tmp_path: Path) -> None: + """Harbor never calls convert_trajectory itself. + + Trial._sync_agent_output calls populate_context_post_run and then reads + logs_dir/trajectory.json back for model usage, so the hook must both write + the file and fill the context. + """ + from harbor.models.agent.context import AgentContext + + agent, logs_dir = _agent(tmp_path) + _write_record(logs_dir, SDK_TRAJECTORY) + + context = AgentContext() + agent.populate_context_post_run(context) + + written = logs_dir / "trajectory.json" + assert written.exists(), "trajectory.json was not written" + Trajectory.model_validate(json.loads(written.read_text())) + + assert context.n_input_tokens == 2600 + assert context.n_output_tokens == 92 + + +def test_populate_context_post_run_is_quiet_without_a_record(tmp_path: Path) -> None: + from harbor.models.agent.context import AgentContext + + agent, logs_dir = _agent(tmp_path) + context = AgentContext() + agent.populate_context_post_run(context) + + assert not (logs_dir / "trajectory.json").exists() + assert context.n_input_tokens is None + + +@pytest.fixture +def no_router_credentials(monkeypatch: pytest.MonkeyPatch) -> None: + """Strip router credentials from the process environment. + + _get_env falls back to os.environ by design, so a developer who exports + these for real runs would otherwise see this test pass or fail depending on + their shell. + """ + for pair in ROUTER_CREDENTIALS.values(): + for name in pair: + # setenv first so teardown restores the original state even when + # the test itself writes the variable, which load_dotenv does. + monkeypatch.setenv(name, "") + monkeypatch.delenv(name) + + +def test_router_credentials_are_required_up_front( + tmp_path: Path, no_router_credentials: None +) -> None: + """Missing creds must fail before Harbor builds an image per trial.""" + with pytest.raises(ValueError, match="TOKENROUTER_BASE_URL"): + StirrupAgent(logs_dir=tmp_path / "agent", model_name="tokenrouter/MiniMax-M3") + + +def test_router_credentials_from_agent_env_satisfy_the_check( + tmp_path: Path, no_router_credentials: None +) -> None: + agent = StirrupAgent( + logs_dir=tmp_path / "agent", + model_name="tokenrouter/MiniMax-M3", + extra_env={ + "TOKENROUTER_BASE_URL": "https://example.invalid/v1", + "TOKENROUTER_API_KEY": "k", + }, + ) + forwarded = agent._credential_env() + assert forwarded["TOKENROUTER_BASE_URL"] == "https://example.invalid/v1" + assert forwarded["TOKENROUTER_API_KEY"] == "k" + # Only what is set, never empty placeholders. + assert all(forwarded.values()) + assert "OPENAI_API_KEY" not in forwarded or forwarded["OPENAI_API_KEY"] + + +def test_router_credentials_from_dotenv_satisfy_the_check( + tmp_path: Path, no_router_credentials: None +) -> None: + (tmp_path / ".env").write_text( + "TOKENROUTER_BASE_URL=https://example.invalid/v1\nTOKENROUTER_API_KEY=k\n", + encoding="utf-8", + ) + agent = StirrupAgent(logs_dir=tmp_path / "agent", model_name="tokenrouter/MiniMax-M3") + forwarded = agent._credential_env() + assert forwarded["TOKENROUTER_BASE_URL"] == "https://example.invalid/v1" + assert forwarded["TOKENROUTER_API_KEY"] == "k" + + +def test_exported_variables_win_over_dotenv( + tmp_path: Path, no_router_credentials: None, monkeypatch: pytest.MonkeyPatch +) -> None: + (tmp_path / ".env").write_text( + "TOKENROUTER_BASE_URL=https://example.invalid/v1\n" + "TOKENROUTER_API_KEY=from-file\n", + encoding="utf-8", + ) + monkeypatch.setenv("TOKENROUTER_API_KEY", "from-shell") + agent = StirrupAgent(logs_dir=tmp_path / "agent", model_name="tokenrouter/MiniMax-M3") + forwarded = agent._credential_env() + assert forwarded["TOKENROUTER_API_KEY"] == "from-shell" + assert forwarded["TOKENROUTER_BASE_URL"] == "https://example.invalid/v1" + + +def test_aob_env_file_replaces_the_dotenv_search( + tmp_path: Path, no_router_credentials: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """run.sh's ENV_FILE is the only file read; the cwd's .env fills no gaps.""" + (tmp_path / ".env").write_text( + "TOKENROUTER_BASE_URL=https://repo.invalid/v1\nTOKENROUTER_API_KEY=repo\n", + encoding="utf-8", + ) + chosen = tmp_path / "chosen.env" + chosen.write_text( + "LITELLM_BASE_URL=https://chosen.invalid\nLITELLM_API_KEY=chosen\n", + encoding="utf-8", + ) + monkeypatch.setenv(ENV_FILE_ENV, str(chosen)) + agent = StirrupAgent( + logs_dir=tmp_path / "agent", model_name="litellm_proxy/azure/gpt-5.6-sol" + ) + forwarded = agent._credential_env() + assert forwarded["LITELLM_API_KEY"] == "chosen" + assert "TOKENROUTER_API_KEY" not in forwarded + + +def test_unprefixed_models_need_no_router_creds(tmp_path: Path) -> None: + StirrupAgent(logs_dir=tmp_path / "agent", model_name="watsonx/llama-4") + + +def test_router_map_matches_llm_routers() -> None: + """Catch drift from src/llm/routers.py, the source of truth. + + Skips when the agent dependency tree is absent, since llm/__init__ imports + the LiteLLM and OpenAI backends. + """ + routers = pytest.importorskip("llm.routers") + assert ROUTER_CREDENTIALS == routers.PROXY_ROUTERS + + +def test_docker_backend_requires_a_shared_workspace(tmp_path: Path) -> None: + """The dind bind-mount trap must fail loudly, not silently lose files.""" + with pytest.raises(ValueError, match="workspace_dir"): + StirrupAgent( + logs_dir=tmp_path / "agent", + model_name="m", + code_enabled=True, + code_backend="docker", + allow_docker_backend=True, + ) + + agent = StirrupAgent( + logs_dir=tmp_path / "agent", + model_name="m", + code_enabled=True, + code_backend="docker", + allow_docker_backend=True, + workspace_dir=SHARED_WORKSPACE, + ) + assert agent.workspace_dir == SHARED_WORKSPACE + + +def test_local_backend_needs_no_workspace(tmp_path: Path) -> None: + agent = StirrupAgent(logs_dir=tmp_path / "agent", model_name="m", code_enabled=True) + assert agent.code_backend == "local" + assert agent.workspace_dir is None + + +def test_fmsr_model_env_matches_the_agent_package(): + """The literal here must track agent.runner.FMSR_MODEL_ENV. + + stirrup.py cannot import agent.runner: that pulls the agent SDKs into the + host-side Harbor process. Same reason ROUTER_CREDENTIALS is copied. + """ + from agent.runner import FMSR_MODEL_ENV as canonical + + assert FMSR_MODEL_ENV == canonical + assert FMSR_MODEL_ENV in SETTING_ENV_VARS + + +def test_explicit_fmsr_model_reaches_the_container( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + """An operator's FMSR_MODEL_ID must be forwarded, not dropped. + + Without it the in-container runner falls back to the agent's --model-id, so + the documented "explicit value wins" would hold for the CLI only. + """ + for name in ("LITELLM_BASE_URL", "LITELLM_API_KEY"): + monkeypatch.setenv(name, "x") + for name in ("TOKENROUTER_BASE_URL", "TOKENROUTER_API_KEY"): + monkeypatch.setenv(name, "y") + monkeypatch.setenv(FMSR_MODEL_ENV, "litellm_proxy/aws/claude-opus-5") + + agent = StirrupAgent( + model_name="tokenrouter/MiniMax-M3", + logs_dir=tmp_path, + code_enabled=False, + ) + env = agent._credential_env() + assert env[FMSR_MODEL_ENV] == "litellm_proxy/aws/claude-opus-5" + + +def test_fmsr_router_credentials_are_required_up_front( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + """A differently-routed FMSR model must fail before the image build.""" + for name in ("TOKENROUTER_BASE_URL", "TOKENROUTER_API_KEY"): + monkeypatch.setenv(name, "y") + for name in ("LITELLM_BASE_URL", "LITELLM_API_KEY"): + monkeypatch.setenv(name, "") + monkeypatch.delenv(name) + monkeypatch.setenv(FMSR_MODEL_ENV, "litellm_proxy/aws/claude-opus-5") + + with pytest.raises(ValueError, match=f"used by {FMSR_MODEL_ENV}"): + StirrupAgent( + model_name="tokenrouter/MiniMax-M3", + logs_dir=tmp_path, + code_enabled=False, + ) diff --git a/src/couchdb/scenarios_data/shared/tsfm/model_catalog.json b/src/couchdb/scenarios_data/shared/tsfm/model_catalog.json index b6c7b63b8..129277189 100644 --- a/src/couchdb/scenarios_data/shared/tsfm/model_catalog.json +++ b/src/couchdb/scenarios_data/shared/tsfm/model_catalog.json @@ -1,37 +1,238 @@ [ { - "model_id": "ttm_96_28", - "model_checkpoint": "ttm_96_28", + "model_id": "ttm_energy_168_24", "model_family": "TinyTimeMixer", + "sktime_class": "sktime.forecasting.ttm.TinyTimeMixerForecaster", + "framework": "tinytimemixer", + "modality": "timeseries", + "provenance": "finetuned", + "created_by": "seed", + "created_at": "2026-09-27T00:00:00+00:00", + "version": "1", + "status": "active", + "source": "local_artifact", + "hf_repo": null, + "artifact_path": "artifacts/tsfm_models/ttm_energy_168_24", + "model_checkpoint": "artifacts/tsfm_models/ttm_energy_168_24", + "params": { + "model_path": "artifacts/tsfm_models/ttm_energy_168_24", + "fit_strategy": "zero-shot" + }, + "training_regime": "zero_shot", + "task_ids": [ + "tsfm_forecasting" + ], + "context_length": 168, + "prediction_length": 24, + "domain": "energy", + "frequency": "H", + "trained_on": [ + "EnergyBench", + "ComStock", + "ResStock" + ], + "tags": [ + "energy", + "finetuned", + "forecasting", + "load-forecasting", + "local-artifact", + "smart-meter", + "ttm" + ], + "description": "TinyTimeMixer fine-tuned for short-term electricity load forecasting, context 168 hours, horizon 24 hours. Trained on EnergyBench smart-meter readings across residential and commercial buildings. Prefer over the general models for energy and HVAC assets; the authors note accuracy degrades outside energy meter analytics. Derived from ibm-granite/granite-timeseries-ttm-r2; the exact upstream revision for this 168/24 variant is not published, so lineage is recorded at repo granularity. Source: EnergyFM/energy-ttm.", + "base_model_id": "ttm_512_96" + }, + { + "model_id": "ttm_1536_720", + "model_family": "TinyTimeMixer", + "sktime_class": "sktime.forecasting.ttm.TinyTimeMixerForecaster", + "framework": "tinytimemixer", + "modality": "timeseries", "provenance": "pretrained", - "base_model_id": null, + "created_by": "seed", + "created_at": "2026-09-27T00:00:00+00:00", + "version": "1", + "status": "active", + "source": "local_artifact", + "hf_repo": null, + "artifact_path": "artifacts/tsfm_models/ttm_1536_720", + "model_checkpoint": "artifacts/tsfm_models/ttm_1536_720", + "params": { + "model_path": "artifacts/tsfm_models/ttm_1536_720", + "fit_strategy": "zero-shot" + }, + "training_regime": "zero_shot", "task_ids": [ "tsfm_forecasting" ], - "context_length": 96, - "prediction_length": 28, + "context_length": 1536, + "prediction_length": 720, "domain": "general", "frequency": "any", - "source": "local_artifact", - "artifact_path": "artifacts/tsfm_models/ttm_96_28", - "hf_repo": "ibm-granite/granite-timeseries-ttm-r2", - "description": "Pretrained TinyTimeMixer forecasting model, context length 96, prediction horizon 28. General-purpose zero-shot multivariate forecaster; good for short-context, short-horizon tasks.", "trained_on": [ "pretraining-corpus" ], "tags": [ + "forecasting", + "local-artifact", "ttm", + "zero-shot" + ], + "description": "TinyTimeMixer, context 1536, horizon 720." + }, + { + "model_id": "ttm_1536_96", + "model_family": "TinyTimeMixer", + "sktime_class": "sktime.forecasting.ttm.TinyTimeMixerForecaster", + "framework": "tinytimemixer", + "modality": "timeseries", + "provenance": "pretrained", + "created_by": "seed", + "created_at": "2026-09-27T00:00:00+00:00", + "version": "1", + "status": "active", + "source": "local_artifact", + "hf_repo": null, + "artifact_path": "artifacts/tsfm_models/ttm_1536_96", + "model_checkpoint": "artifacts/tsfm_models/ttm_1536_96", + "params": { + "model_path": "artifacts/tsfm_models/ttm_1536_96", + "fit_strategy": "zero-shot" + }, + "training_regime": "zero_shot", + "task_ids": [ + "tsfm_forecasting" + ], + "context_length": 1536, + "prediction_length": 96, + "domain": "general", + "frequency": "any", + "trained_on": [ + "pretraining-corpus" + ], + "tags": [ "forecasting", - "zero-shot", - "short-context" + "local-artifact", + "ttm", + "zero-shot" ], + "description": "TinyTimeMixer, context 1536, horizon 96." + }, + { + "model_id": "ttm_512_720", + "model_family": "TinyTimeMixer", + "sktime_class": "sktime.forecasting.ttm.TinyTimeMixerForecaster", + "framework": "tinytimemixer", + "modality": "timeseries", + "provenance": "pretrained", + "created_by": "seed", + "created_at": "2026-09-27T00:00:00+00:00", + "version": "1", "status": "active", - "version": "r2", + "source": "local_artifact", + "hf_repo": null, + "artifact_path": "artifacts/tsfm_models/ttm_512_720", + "model_checkpoint": "artifacts/tsfm_models/ttm_512_720", + "params": { + "model_path": "artifacts/tsfm_models/ttm_512_720", + "fit_strategy": "zero-shot" + }, + "training_regime": "zero_shot", + "task_ids": [ + "tsfm_forecasting" + ], + "context_length": 512, + "prediction_length": 720, + "domain": "general", + "frequency": "any", + "trained_on": [ + "pretraining-corpus" + ], + "tags": [ + "forecasting", + "local-artifact", + "ttm", + "zero-shot" + ], + "description": "TinyTimeMixer, context 512, horizon 720." + }, + { + "model_id": "ttm_512_96", + "model_family": "TinyTimeMixer", + "sktime_class": "sktime.forecasting.ttm.TinyTimeMixerForecaster", + "framework": "tinytimemixer", + "modality": "timeseries", + "provenance": "pretrained", "created_by": "seed", - "created_at": "2026-06-20T00:00:00+00:00", + "created_at": "2026-09-27T00:00:00+00:00", + "version": "1", + "status": "active", + "source": "local_artifact", + "hf_repo": null, + "artifact_path": "artifacts/tsfm_models/ttm_512_96", + "model_checkpoint": "artifacts/tsfm_models/ttm_512_96", + "params": { + "model_path": "artifacts/tsfm_models/ttm_512_96", + "fit_strategy": "zero-shot" + }, + "training_regime": "zero_shot", + "task_ids": [ + "tsfm_forecasting" + ], + "context_length": 512, + "prediction_length": 96, + "domain": "general", + "frequency": "any", + "trained_on": [ + "pretraining-corpus" + ], + "tags": [ + "forecasting", + "local-artifact", + "ttm", + "zero-shot" + ], + "description": "TinyTimeMixer, context 512, horizon 96." + }, + { + "model_id": "ttm_1024_192_hub", + "model_family": "TinyTimeMixer", "sktime_class": "sktime.forecasting.ttm.TinyTimeMixerForecaster", + "framework": "tinytimemixer", + "modality": "timeseries", + "provenance": "pretrained", + "created_by": "seed", + "created_at": "2026-09-27T00:00:00+00:00", + "version": "r2", + "status": "active", + "source": "hf_hub", + "hf_repo": "ibm-granite/granite-timeseries-ttm-r2", + "artifact_path": null, + "model_checkpoint": "ibm-granite/granite-timeseries-ttm-r2@1024-192-r2", "params": { - "model_path": "ibm-granite/granite-timeseries-ttm-r2" - } + "model_path": "ibm-granite/granite-timeseries-ttm-r2", + "revision": "1024-192-r2", + "fit_strategy": "zero-shot" + }, + "training_regime": "zero_shot", + "task_ids": [ + "tsfm_forecasting" + ], + "context_length": 1024, + "prediction_length": 192, + "domain": "general", + "frequency": "any", + "trained_on": [ + "pretraining-corpus" + ], + "tags": [ + "ttm", + "forecasting", + "zero-shot", + "hub", + "long-context" + ], + "description": "Pretrained TinyTimeMixer served from the HuggingFace Hub, context 1024, horizon 192. Pinned to revision 1024-192-r2 so the weights are reproducible. Demonstrates the hub-backed card shape; the other cards resolve from disk." } -] \ No newline at end of file +] diff --git a/src/servers/fmsr/main.py b/src/servers/fmsr/main.py index f0a1244cf..ca8cea0d7 100644 --- a/src/servers/fmsr/main.py +++ b/src/servers/fmsr/main.py @@ -85,7 +85,19 @@ def _missing_asset_class_error(original: str, normalized: str) -> ErrorResult: return ErrorResult(error=message) +def _is_missing_database(exc: Exception) -> bool: + return "Database does not exist" in str(exc) + + +_MISSING_DATABASE_ERROR = ( + "the data source does not exist in this environment; the " + "data is unavailable, do not retry with other arguments" +) + + def _is_not_found_error(exc: Exception) -> bool: + if _is_missing_database(exc): + return False if isinstance(exc, (KeyError, NotFoundError)): return True response = getattr(exc, "response", None) @@ -110,58 +122,85 @@ def _is_not_found_error(exc: Exception) -> bool: ) +def _strip_emphasis(item: str) -> str: + """Drop one wrapping run of markdown emphasis, e.g. **Cavitation** -> Cavitation. + + Models that format their answer as a list leave the markers in place, and + they would otherwise be stored as part of the failure-mode name. + """ + return re.sub(r"^(\*{1,3}|_{1,3})(.+?)\1$", r"\2", item.strip()).strip() + + def _parse_failure_mode_list(text: str) -> List[str]: items: List[str] = [] for line in text.splitlines(): item = line.strip() if not item: continue + # Emphasis first: "*Cavitation*" is italic, not a bullet plus a stray + # asterisk, and stripping bullets first would leave "Cavitation*". + item = _strip_emphasis(item) item = re.sub(r"^\s*(?:[-*•]|\d+[\.\)])\s*", "", item).strip() + # Again, for "- **Bearing wear**" where the bullet hid the emphasis. + item = _strip_emphasis(item) if item: items.append(item) return items # ── LLM backend (lazy init; graceful degradation if creds are absent) ───────── +# FMSR_MODEL_ID is the only source for the model generate_failure_modes uses. +# There is deliberately no built-in default: a hardcoded one silently sent every +# run at a single provider and demanded that provider's credentials whatever +# model the agent was being benchmarked on. Agent runners always set this for +# the servers they spawn (the user's explicit value if given, otherwise the +# agent's own --model-id), so an unset value means the server was started +# standalone without being told which model to use. Empty values count as unset. +# The accepted prefixes are llm.routers.PROXY_ROUTERS (tokenrouter/ and +# litellm_proxy/); anything else is rejected rather than guessed at. -_DEFAULT_MODEL_ID = "watsonx/meta-llama/llama-3-3-70b-instruct" _MAX_RETRIES = 3 -_MODEL_ID = os.environ.get("FMSR_MODEL_ID", _DEFAULT_MODEL_ID) +_MODEL_ID = (os.environ.get("FMSR_MODEL_ID") or "").strip() or None -def _build_llm(): +def _build_llm(model_id: str | None = _MODEL_ID): + """Build the generate_* backend for *model_id*. + + llm.routers owns the prefix -> credential mapping for every supported + router, so the accepted set and the variables each one needs come from + PROXY_ROUTERS rather than being restated here. A model id with no known + prefix is rejected instead of being pushed at a provider on a guess. + """ from llm import make_backend + from llm.routers import PROXY_ROUTERS, router_prefix - if _MODEL_ID.startswith("watsonx/"): - missing = [ - v for v in ("WATSONX_APIKEY", "WATSONX_PROJECT_ID") if not os.environ.get(v) - ] - if missing: - raise RuntimeError(f"Missing env vars for WatsonX: {missing}") - elif _MODEL_ID.startswith("tokenrouter/"): - missing = [ - v - for v in ("TOKENROUTER_API_KEY", "TOKENROUTER_BASE_URL") - if not os.environ.get(v) - ] - if missing: - raise RuntimeError(f"Missing env vars for TokenRouter: {missing}") - else: - missing = [ - v for v in ("LITELLM_API_KEY", "LITELLM_BASE_URL") if not os.environ.get(v) - ] - if missing: - raise RuntimeError(f"Missing env vars for LiteLLM: {missing}") - return make_backend(_MODEL_ID) + supported = ", ".join(f"{prefix}" for prefix in PROXY_ROUTERS) + if not model_id: + raise RuntimeError( + f"FMSR_MODEL_ID is not set; the generate_* tools need a model id ({supported})" + ) + prefix = router_prefix(model_id) + if prefix is None: + raise RuntimeError( + f"FMSR_MODEL_ID={model_id!r} has no supported router prefix ({supported})" + ) + base_env, key_env = PROXY_ROUTERS[prefix] + missing = [name for name in (key_env, base_env) if not os.environ.get(name)] + if missing: + raise RuntimeError(f"Missing env vars for {prefix.rstrip('/')}: {missing}") + return make_backend(model_id) try: - _llm = _build_llm() + _llm = _build_llm(_MODEL_ID) _llm_available = True + _llm_error: str | None = None + logger.info("FMSR LLM: %s", _MODEL_ID) except Exception as _e: # noqa: BLE001 - logger.warning("LLM unavailable (generate_* tools disabled): %s", _e) + logger.warning("LLM %r unavailable (generate_* tools disabled): %s", _MODEL_ID, _e) _llm = None _llm_available = False + _llm_error = f"{_MODEL_ID}: {_e}" if _MODEL_ID else str(_e) def _call_failure_mode_generation( @@ -267,6 +306,8 @@ def _find_failure_mode_doc(asset_class: str) -> Optional[dict]: try: d = fm_db.get(f"fm:{key}", check=True) except Exception as exc: # noqa: BLE001 + if _is_missing_database(exc): + raise RuntimeError(_MISSING_DATABASE_ERROR) from exc if _is_not_found_error(exc): d = None else: @@ -281,6 +322,8 @@ def _find_failure_mode_doc(asset_class: str) -> Optional[dict]: d = docs[0] return d except Exception as exc: # noqa: BLE001 + if _is_missing_database(exc): + raise RuntimeError(_MISSING_DATABASE_ERROR) from exc raise RuntimeError( f"database lookup failed for asset_class '{key}': {exc}" ) from exc @@ -298,6 +341,54 @@ def _known_failure_modes(asset_class: str) -> List[str]: ] +# The exact messages _find_failure_mode_doc raises when there is no catalog to +# read, as opposed to a catalog that failed to read. Compared by value because +# this module raises them itself a few lines above. +_CATALOG_ABSENT = ("database not connected", _MISSING_DATABASE_ERROR) + + +def _optional_known_failure_modes(asset_class: str) -> tuple[List[str], str]: + """Stored modes as optional context for the generate path, plus a note. + + generate_failure_modes documents "a new or extended list": extended when + modes are stored, new when they are not. An absent or uninitialised catalog + is that from-scratch case, so it yields ([], note) rather than failing. + + A read that fails for any other reason propagates. Stored modes may exist + and be temporarily unreadable, and generating a list that quietly ignores + them would hide a real fault behind plausible output. + """ + try: + modes = [ + mode.strip() for mode in _known_failure_modes(asset_class) if mode and mode.strip() + ] + except RuntimeError as exc: + # Raised by _find_failure_mode_doc: either there is no catalog to read, + # or a read against an existing one failed. Only the former is the + # from-scratch case; the latter means stored modes may exist. + if str(exc) not in _CATALOG_ABSENT: + raise + return [], _no_context(asset_class, exc, "not initialised") + except Exception as exc: # noqa: BLE001 + # couchdb3's truthiness check performs a request, so an unreachable + # server surfaces here as a transport error before any RuntimeError + # wrapping. We cannot establish that a catalog exists, which is the + # from-scratch case. A transport error raised mid-read still arrives as + # the RuntimeError above and stays an error. + return [], _no_context(asset_class, exc, "unreachable") + return modes, f"{len(modes)} stored mode(s) as context" + + +def _no_context(asset_class: str, exc: Exception, why: str) -> str: + logger.info( + "generate_failure_modes: no stored context for '%s', catalog %s (%s)", + asset_class, + why, + exc, + ) + return f"no stored context (failure_mode catalog {why})" + + @mcp.tool(title="Generate Failure Modes") def generate_failure_modes( asset_class: str, @@ -322,11 +413,15 @@ def generate_failure_modes( if max_modes <= 0: return ErrorResult(error="max_modes must be greater than 0") if not _llm_available: - return ErrorResult(error="LLM unavailable") + return ErrorResult(error=f"LLM unavailable ({_llm_error})") + + try: + base, context_note = _optional_known_failure_modes(key) + except Exception as exc: # noqa: BLE001 + logger.error("generate_failure_modes failed: %s", exc) + return ErrorResult(error=str(exc)) try: - base = _known_failure_modes(key) - base = [mode.strip() for mode in base if mode and mode.strip()] raw = _call_failure_mode_generation(key, base, max_modes) seen = {mode.lower() for mode in base} generated: List[str] = [] @@ -345,8 +440,8 @@ def generate_failure_modes( failure_modes=base + generated, source=f"LLM:{_MODEL_ID}", message=( - f"generated {len(generated)} new failure mode(s) for asset_class '{key}' " - f"using {len(base)} stored mode(s) as context; nothing was persisted." + f"generated {len(generated)} new failure mode(s) for asset_class " + f"'{key}' using {context_note}; nothing was persisted." ), ) except Exception as exc: # noqa: BLE001 diff --git a/src/servers/fmsr/tests/conftest.py b/src/servers/fmsr/tests/conftest.py index f44a488a5..ecafee2c4 100644 --- a/src/servers/fmsr/tests/conftest.py +++ b/src/servers/fmsr/tests/conftest.py @@ -1,12 +1,51 @@ import json -import os import pytest from unittest.mock import MagicMock, patch -requires_watsonx = pytest.mark.skipif( - os.environ.get("WATSONX_APIKEY") is None, - reason="WatsonX not available (set WATSONX_APIKEY)", + +def _fmsr_llm_available() -> bool: + """True when FMSR_MODEL_ID names a router whose credentials are present.""" + try: + from servers.fmsr.main import _llm_available + except Exception: # noqa: BLE001 - collection must not fail on import + return False + return bool(_llm_available) + + +def _failure_mode_db_reachable() -> bool: + """True when the failure_mode database answers a query. + + Mirrors _couchdb_reachable in the iot server's conftest: probe for real + rather than trusting COUCHDB_URL to be set, so a configured-but-down + CouchDB skips instead of failing. + """ + try: + from servers.fmsr.main import fm_db + + if not fm_db: + return False + fm_db.find({}, fields=["asset_class"], limit=1) + return True + except Exception: # noqa: BLE001 - collection must not fail on import + return False + + +requires_fmsr_llm = pytest.mark.skipif( + not _fmsr_llm_available(), + reason=( + "FMSR LLM not configured: set FMSR_MODEL_ID to a tokenrouter/ or " + "litellm_proxy/ model and that router's credentials" + ), +) + + +requires_failure_mode_db = pytest.mark.skipif( + not _failure_mode_db_reachable(), + reason=( + "failure_mode database not available " + "(set COUCHDB_URL and load the failure-mode catalog)" + ), ) @@ -65,7 +104,7 @@ def save(self, doc): @pytest.fixture def no_llm(): - """Simulate missing WatsonX credentials.""" + """Simulate an unconfigured or unreachable FMSR LLM.""" with patch("servers.fmsr.main._llm_available", False): yield diff --git a/src/servers/fmsr/tests/test_tools.py b/src/servers/fmsr/tests/test_tools.py index 9fd672e41..685c54be7 100644 --- a/src/servers/fmsr/tests/test_tools.py +++ b/src/servers/fmsr/tests/test_tools.py @@ -4,7 +4,7 @@ from servers.fmsr.main import mcp -from .conftest import call_tool, requires_watsonx +from .conftest import call_tool, requires_failure_mode_db, requires_fmsr_llm class TestGetFailureModes: @@ -151,9 +151,53 @@ async def test_llm_unavailable_returns_error(self, no_llm): {"asset_class": "pump", "max_modes": 3}, ) - assert data == {"error": "LLM unavailable"} + assert data["error"].startswith("LLM unavailable") - @requires_watsonx + @pytest.mark.anyio + async def test_generates_without_the_catalog(self, monkeypatch): + """A missing failure_mode database must not stop generation. + + The tool's contract is "a new or extended list": extended when modes are + stored, new when they are not. An uninitialised catalog is the from-scratch + case, not an error. + """ + from servers.fmsr import main as fmsr + + def _no_db(_asset_class): + raise RuntimeError("database not connected") + + monkeypatch.setattr(fmsr, "_known_failure_modes", _no_db) + monkeypatch.setattr(fmsr, "_llm_available", True) + monkeypatch.setattr( + fmsr, + "_call_failure_mode_generation", + lambda key, known, n: ["Seal leakage", "Bearing wear"], + ) + + data = await call_tool( + fmsr.mcp, "generate_failure_modes", {"asset_class": "pump", "max_modes": 5} + ) + assert "error" not in data, data + assert data["known"] == [] + assert data["generated"] == ["Seal leakage", "Bearing wear"] + assert "catalog not initialised" in data["message"] + + @pytest.mark.anyio + async def test_generates_when_db_handle_is_absent( + self, monkeypatch, mock_failure_mode_generation + ): + """fm_db unset is the other 'never initialised' signal.""" + monkeypatch.setattr("servers.fmsr.main.fm_db", None) + + data = await call_tool( + mcp, "generate_failure_modes", {"asset_class": "pump", "max_modes": 3} + ) + assert "error" not in data, data + assert data["known"] == [] + assert data["generated"] + + @requires_fmsr_llm + @requires_failure_mode_db @pytest.mark.anyio async def test_integration(self): data = await call_tool( @@ -293,3 +337,25 @@ async def test_mapping_tool_is_not_registered(self): assert "generate_failure_mode_sensor_mapping" not in { tool.name for tool in tools } + + +class TestMissingDatabaseMessage: + @pytest.mark.anyio + async def test_missing_database_reports_unavailable(self, monkeypatch): + from couchdb3.exceptions import NotFoundError + + class MissingDatabase: + def get(self, *args, **kwargs): + raise NotFoundError( + '{"error":"not_found","reason":"Database does not exist."}' + ) + + find = get + + monkeypatch.setattr("servers.fmsr.main.fm_db", MissingDatabase()) + + data = await call_tool(mcp, "get_failure_modes", {"asset_class": "pump"}) + + assert "does not exist in this environment" in data["error"] + assert "failure_mode" not in data["error"] + assert "no failure_mode record" not in data["error"] diff --git a/src/servers/iot/main.py b/src/servers/iot/main.py index 9e62f578e..8c2b62926 100644 --- a/src/servers/iot/main.py +++ b/src/servers/iot/main.py @@ -145,6 +145,19 @@ def known_sites() -> List[str]: return get_registry_sites() or DEFAULT_SITES +def _missing_db_error(db: Any) -> Optional[ErrorResult]: + """Return an error when the database itself is absent or unreachable, so a + missing database is not reported as an unknown key.""" + if db is not None and db.check(): + return None + return ErrorResult( + error=( + "the data source does not exist or is unreachable in this " + "environment; the data is unavailable, do not retry with other arguments" + ) + ) + + def _is_known_site(site_name: str) -> bool: return site_name in known_sites() @@ -224,7 +237,7 @@ def asset_ids(site_name: str) -> Union[AssetsResult, ErrorResult]: ) except Exception as e: logger.error(f"asset_ids failed: {e}") - return ErrorResult(error=str(e)) + return _missing_db_error(asset_db) or ErrorResult(error=str(e)) @mcp.tool(title="Get Asset Detail") @@ -265,7 +278,9 @@ def asset_detail(site_name: str, asset_id: str) -> Union[AssetDetail, ErrorResul ) docs = res.get("docs", []) if not docs: - return ErrorResult(error=f"unknown asset_id {asset_id} at site {site_name}") + return _missing_db_error(asset_db) or ErrorResult( + error=f"unknown asset_id {asset_id} at site {site_name}" + ) doc = docs[0] sensors = list(doc.get("sensors") or []) @@ -295,7 +310,7 @@ def asset_detail(site_name: str, asset_id: str) -> Union[AssetDetail, ErrorResul ) except Exception as e: logger.error(f"asset_detail failed: {e}") - return ErrorResult(error=str(e)) + return _missing_db_error(asset_db) or ErrorResult(error=str(e)) @mcp.tool(title="List Measured Sensors") @@ -322,7 +337,9 @@ def measured_sensors( sensor_list = get_sensor_list(asset_id) if not sensor_list: - return ErrorResult(error=f"unknown asset_id {asset_id} or no sensors found") + return _missing_db_error(iot_db) or ErrorResult( + error=f"unknown asset_id {asset_id} or no sensors found" + ) return SensorsResult( site_name=site_name, @@ -366,7 +383,9 @@ def installed_sensors( ) docs = res.get("docs", []) if not docs: - return ErrorResult(error=f"unknown asset_id {asset_id} at site {site_name}") + return _missing_db_error(asset_db) or ErrorResult( + error=f"unknown asset_id {asset_id} at site {site_name}" + ) names = list(docs[0].get("sensors") or []) return SensorsResult( site_name=site_name, @@ -377,7 +396,7 @@ def installed_sensors( ) except Exception as e: logger.error(f"installed_sensors failed: {e}") - return ErrorResult(error=str(e)) + return _missing_db_error(asset_db) or ErrorResult(error=str(e)) @mcp.tool(title="List Assets") @@ -435,7 +454,7 @@ def assets( ) except Exception as e: logger.error(f"assets failed: {e}") - return ErrorResult(error=str(e)) + return _missing_db_error(asset_db) or ErrorResult(error=str(e)) @mcp.tool(title="Find Assets By Sensors") @@ -478,9 +497,15 @@ def find_assets_by_sensors( if source == "measured" and not iot_db: return ErrorResult(error="IoT records database not connected") + site_asset_ids = _site_asset_ids(site_name) + if not site_asset_ids: + missing = _missing_db_error(asset_db) + if missing: + return missing + query_sensors = list(dict.fromkeys(sensors)) matches: List[AssetSensorMatch] = [] - for asset_id in _site_asset_ids(site_name): + for asset_id in site_asset_ids: available = ( get_sensor_list(asset_id) if source == "measured" @@ -522,6 +547,11 @@ def _hits(sensor_name: str) -> List[str]: AssetSensorMatch(asset_id=asset_id, matched_sensors=matched) ) + if not matches and source == "measured": + missing = _missing_db_error(iot_db) + if missing: + return missing + return FindAssetsResult( site_name=site_name, query_sensors=query_sensors, @@ -606,7 +636,7 @@ def stream_extent( total_records += 1 if total_records == 0: - return ErrorResult( + return _missing_db_error(iot_db) or ErrorResult( error=f"no records for asset_id {asset_id}" + (f", sensor {sensor}" if sensor else "") ) @@ -638,7 +668,9 @@ def stream_extent( return ErrorResult(error=str(e)) except Exception as e: logger.error(f"stream_extent failed: {e}") - return ErrorResult(error="unable to inspect telemetry stream extent") + return _missing_db_error(iot_db) or ErrorResult( + error="unable to inspect telemetry stream extent" + ) @mcp.tool(title="Get Sensor History") @@ -704,7 +736,9 @@ def history( ) available_sensors = get_sensor_list(asset_id) if not available_sensors: - return ErrorResult(error=f"unknown asset_id {asset_id} or no sensors found") + return _missing_db_error(iot_db) or ErrorResult( + error=f"unknown asset_id {asset_id} or no sensors found" + ) unknown = [ sensor for sensor in selected_sensors if sensor not in available_sensors ] @@ -763,7 +797,9 @@ def history( return ErrorResult(error=str(e)) except Exception as e: logger.error(f"history failed: {e}") - return ErrorResult(error="unable to retrieve telemetry history") + return _missing_db_error(iot_db) or ErrorResult( + error="unable to retrieve telemetry history" + ) next_cursor = None if has_more: @@ -828,7 +864,9 @@ def latest_reading( if sensor is not None: available_sensors = get_sensor_list(asset_id) if not available_sensors: - return ErrorResult(error=f"unknown asset_id {asset_id} or no sensors found") + return _missing_db_error(iot_db) or ErrorResult( + error=f"unknown asset_id {asset_id} or no sensors found" + ) if sensor not in available_sensors: return ErrorResult(error=f"unknown sensor {sensor} for asset_id {asset_id}") @@ -856,10 +894,12 @@ def latest_reading( return ErrorResult(error=str(e)) except Exception as e: logger.error(f"latest_reading failed: {e}") - return ErrorResult(error="unable to retrieve latest telemetry reading") + return _missing_db_error(iot_db) or ErrorResult( + error="unable to retrieve latest telemetry reading" + ) if latest_doc is None or latest_timestamp is None or latest_datetime is None: - return ErrorResult( + return _missing_db_error(iot_db) or ErrorResult( error=f"no records for asset_id {asset_id}" + (f", sensor {sensor}" if sensor else "") ) @@ -930,10 +970,14 @@ def sensor_coverage( return ErrorResult(error=str(e)) except Exception as e: logger.error(f"sensor_coverage failed: {e}") - return ErrorResult(error="unable to calculate sensor coverage") + return _missing_db_error(iot_db) or ErrorResult( + error="unable to calculate sensor coverage" + ) if docs_scanned == 0: - return ErrorResult(error=f"unknown asset_id {asset_id} or no records found") + return _missing_db_error(iot_db) or ErrorResult( + error=f"unknown asset_id {asset_id} or no records found" + ) sensors = [coverage[field].result(field) for field in sorted(coverage)] message = ( @@ -996,7 +1040,9 @@ def sensor_stats( available_sensors = get_sensor_list(asset_id) if not available_sensors: - return ErrorResult(error=f"unknown asset_id {asset_id} or no sensors found") + return _missing_db_error(iot_db) or ErrorResult( + error=f"unknown asset_id {asset_id} or no sensors found" + ) if sensor is not None and sensor not in available_sensors: return ErrorResult(error=f"unknown sensor {sensor} for asset_id {asset_id}") @@ -1035,10 +1081,12 @@ def sensor_stats( return ErrorResult(error=str(e)) except Exception as e: logger.error(f"sensor_stats failed: {e}") - return ErrorResult(error="unable to calculate sensor statistics") + return _missing_db_error(iot_db) or ErrorResult( + error="unable to calculate sensor statistics" + ) if records_in_window == 0: - return ErrorResult( + return _missing_db_error(iot_db) or ErrorResult( error=f"no records for asset_id {asset_id}" + (f", sensor {sensor}" if sensor else "") ) diff --git a/src/servers/iot/tests/test_tools.py b/src/servers/iot/tests/test_tools.py index 8896275e7..d71c21f92 100644 --- a/src/servers/iot/tests/test_tools.py +++ b/src/servers/iot/tests/test_tools.py @@ -1795,3 +1795,49 @@ async def test_discovery_integration(self): assert "assets" in data assert any(asset["asset_id"] == "Chiller 6" for asset in data["assets"]) assert data["total_assets"] > 0 + + +class TestMissingDatabaseMessage: + @pytest.mark.anyio + async def test_wrong_asset_id_reports_unknown_key(self, mock_asset_db, mock_iot_db): + mock_asset_db.find.return_value = {"docs": [{"siteid": "MAIN"}]} + mock_iot_db.find.return_value = {"docs": []} + mock_iot_db.check.return_value = True + + data = await call_tool( + mcp, "measured_sensors", {"site_name": "MAIN", "asset_id": "Pump-X"} + ) + + assert data["error"] == "unknown asset_id Pump-X or no sensors found" + + @pytest.mark.anyio + async def test_missing_database_reports_unavailable( + self, mock_asset_db, mock_iot_db + ): + mock_asset_db.find.return_value = {"docs": [{"siteid": "MAIN"}]} + mock_iot_db.find.side_effect = RuntimeError("Database does not exist.") + mock_iot_db.check.return_value = False + + data = await call_tool( + mcp, "measured_sensors", {"site_name": "MAIN", "asset_id": "Chiller 6"} + ) + + assert "does not exist or is unreachable" in data["error"] + assert "do not retry" in data["error"] + assert "iot" not in data["error"] + + @pytest.mark.anyio + async def test_find_assets_with_missing_database_reports_unavailable( + self, mock_asset_db, mock_iot_db + ): + mock_asset_db.find.return_value = {"docs": [{"siteid": "MAIN"}]} + mock_iot_db.find.side_effect = RuntimeError("Database does not exist.") + mock_iot_db.check.return_value = False + + data = await call_tool( + mcp, + "find_assets_by_sensors", + {"site_name": "MAIN", "sensors": ["Temp"]}, + ) + + assert "does not exist or is unreachable" in data["error"] diff --git a/src/servers/tsfm/tests/test_catalog_checkpoints.py b/src/servers/tsfm/tests/test_catalog_checkpoints.py new file mode 100644 index 000000000..e659e5a4e --- /dev/null +++ b/src/servers/tsfm/tests/test_catalog_checkpoints.py @@ -0,0 +1,157 @@ +"""Every local checkpoint in the catalog resolves, fits and forecasts. + +This is the test that the rest of the plumbing cannot substitute for. +`preload_models.py --check` proves a directory exists; schema validation proves +a card is well formed. Neither proves the weights load into the estimator the +card names, or that a fit returns the horizon the card advertises. A card can be +valid, its checkpoint present, and the pair still unusable. + +Run against the repo's own catalog, or point it elsewhere: + + uv run pytest src/servers/tsfm/tests/test_catalog_checkpoints.py -v + AOB_MODEL_CATALOG=/path/to/private_catalog.json uv run pytest ... -v + +Skips rather than fails when sktime or the TTM extra is missing, so it does not +break a checkout that never installed the tsfm group. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + +# Before numpy/pandas: they arrive with the tsfm group, so importing them first +# turns a missing group into a collection error rather than a skip. +pytest.importorskip("sktime", reason="requires the tsfm dependency group") + +import numpy as np +import pandas as pd + +from servers.tsfm.substrate import resolver as R + +DEFAULT_CATALOG = Path("src/couchdb/scenarios_data/shared/tsfm/model_catalog.json") + + +def _catalog_path() -> Path: + env = os.environ.get("AOB_MODEL_CATALOG") + if env: + return Path(env) + root = os.environ.get("SCENARIOS_DATA_DIR") + if root: + return Path(root) / "shared/tsfm/model_catalog.json" + return DEFAULT_CATALOG + + +def _local_cards() -> list[dict]: + """Active cards whose weights are a directory on disk, not a Hub repo.""" + path = _catalog_path() + if not path.is_file(): + return [] + raw = json.loads(path.read_text(encoding="utf-8")) + cards = raw if isinstance(raw, list) else raw.get("docs", [raw]) + out = [] + for c in cards: + if (c.get("status") or "active") != "active": + continue + if c.get("hf_repo"): + continue + mp = (c.get("params") or {}).get("model_path") + if mp and Path(mp).is_dir(): + out.append(c) + return out + + +def _ids(cards): + return [c["model_id"] for c in cards] + + +CARDS = _local_cards() + + +@pytest.mark.parametrize("card", CARDS, ids=_ids(CARDS) if CARDS else []) +def test_checkpoint_forecasts(card: dict) -> None: + """resolve -> fit -> predict, and the horizon matches what the card claims.""" + ctx = int(card["context_length"]) + horizon = int(card["prediction_length"]) + + # The estimator needs at least context_length of history. Give it a series + # with actual structure rather than noise, so a silently broken checkpoint + # producing constants is visible in the variance assertion below. + n = max(ctx * 3, ctx + horizon + 10) + t = np.arange(n) + y = pd.Series(10 + np.sin(t / 7.0) * 2 + t * 0.01) + + forecaster = R.resolve(card) + forecaster.fit(y, fh=list(range(1, horizon + 1))) + pred = np.asarray(forecaster.predict()).ravel() + + assert len(pred) == horizon, ( + f"{card['model_id']} claims prediction_length={horizon} " + f"but returned {len(pred)} points" + ) + assert np.isfinite(pred).all(), f"{card['model_id']} returned non-finite values" + assert pred.std() > 0, ( + f"{card['model_id']} returned a constant forecast, which usually means " + "the weights did not load and the head is at its initialisation" + ) + + +@pytest.mark.parametrize("card", CARDS, ids=_ids(CARDS) if CARDS else []) +def test_checkpoint_serves_rather_than_trains(card: dict) -> None: + """A checkpoint card must not fine-tune on fit. + + sktime's TTM defaults to fit_strategy="minimal", which trains. A card that + pins neither params.fit_strategy nor training_regime inherits that default, + re-tunes the already-tuned weights on every fit, and sends run_recipe down + the expanding-window refit loop instead of a single holdout. + """ + assert R.training_regime(card) == "zero_shot", ( + f"{card['model_id']} resolves to '{R.training_regime(card)}'. Pin both " + 'params.fit_strategy="zero-shot" and training_regime="zero_shot".' + ) + + +@pytest.mark.skipif(not CARDS, reason="no active local-artifact cards in the catalog") +def test_card_location_fields_agree() -> None: + """artifact_path, model_checkpoint and params.model_path name one place. + + Only params.model_path is read at load time. The others are metadata that + drifts silently, and register_finetuned sets all three identically, so a + seeded card that disagrees teaches the agent a shape the code does not use. + """ + bad = [] + for c in CARDS: + mp = (c.get("params") or {}).get("model_path") + for field in ("artifact_path", "model_checkpoint"): + val = c.get(field) + if val is not None and val != mp: + bad.append(f"{c['model_id']}: {field}={val!r} != params.model_path={mp!r}") + assert not bad, "location fields disagree:\n " + "\n ".join(bad) + + +@pytest.mark.skipif(not CARDS, reason="no active local-artifact cards in the catalog") +def test_distinct_cards_have_distinct_weights() -> None: + """Report cards that share a checkpoint's bytes. + + Two cards pointing at byte-identical weights cannot be told apart by any + scenario that asks an agent to choose between them, so a preference test + over such a pair measures nothing. Informational: xfail rather than fail, + because shipping a placeholder copy is a legitimate interim state. + """ + import hashlib + + by_digest: dict[str, list[str]] = {} + for c in CARDS: + w = Path((c.get("params") or {})["model_path"]) / "model.safetensors" + if not w.exists(): + continue + digest = hashlib.sha256(w.read_bytes()).hexdigest() + by_digest.setdefault(digest, []).append(c["model_id"]) + + dupes = {d: ids for d, ids in by_digest.items() if len(ids) > 1} + if dupes: + pytest.xfail("cards sharing identical weights: " + "; ".join( + ", ".join(ids) for ids in dupes.values())) diff --git a/src/servers/vibration/couchdb_client.py b/src/servers/vibration/couchdb_client.py index ad9c013c7..327bfb8bb 100644 --- a/src/servers/vibration/couchdb_client.py +++ b/src/servers/vibration/couchdb_client.py @@ -47,6 +47,12 @@ def _get_db() -> Optional[couchdb3.Database]: return None +def database_available() -> bool: + """True when the vibration database exists and CouchDB is reachable.""" + db = _get_db() + return bool(db and db.check()) + + def fetch_vibration_timeseries( asset_id: str, sensor_name: str, diff --git a/src/servers/vibration/main.py b/src/servers/vibration/main.py index bcc8985b0..41118e300 100644 --- a/src/servers/vibration/main.py +++ b/src/servers/vibration/main.py @@ -19,7 +19,11 @@ from mcp.server.fastmcp import FastMCP from pydantic import BaseModel -from .couchdb_client import fetch_vibration_timeseries, list_sensor_fields +from .couchdb_client import ( + database_available, + fetch_vibration_timeseries, + list_sensor_fields, +) from .data_store import store from .dsp.bearing_freqs import ( COMMON_BEARINGS, @@ -58,6 +62,19 @@ class ErrorResult(BaseModel): error: str +def _missing_db_error() -> Optional[ErrorResult]: + """Return an error when the database itself is absent or unreachable, so a + missing database is not reported as missing asset/sensor data.""" + if database_available(): + return None + return ErrorResult( + error=( + "the data source does not exist or is unreachable in this " + "environment; the data is unavailable, do not retry with other arguments" + ) + ) + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -155,7 +172,7 @@ def get_vibration_data( """ result = fetch_vibration_timeseries(asset_id, sensor_name, start, final) if result is None: - return ErrorResult( + return _missing_db_error() or ErrorResult( error=f"No vibration data found for asset '{asset_id}', " f"sensor '{sensor_name}' in time range starting {start}." ) @@ -190,7 +207,7 @@ def list_vibration_sensors( """ sensors = list_sensor_fields(asset_id) if not sensors: - return ErrorResult( + return _missing_db_error() or ErrorResult( error=f"No sensors found for asset '{asset_id}' at site '{site_name}'." ) return { diff --git a/src/servers/vibration/tests/test_missing_database.py b/src/servers/vibration/tests/test_missing_database.py new file mode 100644 index 000000000..b50431278 --- /dev/null +++ b/src/servers/vibration/tests/test_missing_database.py @@ -0,0 +1,30 @@ +"""A wrong asset and a missing database produce different error messages.""" + +import pytest + +from servers.vibration.main import mcp + +from .conftest import call_tool + +_ARGS = {"site_name": "MAIN", "asset_id": "Motor-X"} + + +@pytest.mark.anyio +async def test_wrong_asset_reports_no_sensors(mock_db): + mock_db.return_value.find.return_value = {"docs": []} + mock_db.return_value.check.return_value = True + + data = await call_tool(mcp, "list_vibration_sensors", _ARGS) + + assert data["error"] == "No sensors found for asset 'Motor-X' at site 'MAIN'." + + +@pytest.mark.anyio +async def test_missing_database_reports_unavailable(mock_db): + mock_db.return_value.find.side_effect = RuntimeError("Database does not exist.") + mock_db.return_value.check.return_value = False + + data = await call_tool(mcp, "list_vibration_sensors", _ARGS) + + assert "does not exist or is unreachable" in data["error"] + assert "vibration" not in data["error"] diff --git a/src/servers/wo/couch.py b/src/servers/wo/couch.py index cd694cdff..eac857b7b 100644 --- a/src/servers/wo/couch.py +++ b/src/servers/wo/couch.py @@ -45,9 +45,25 @@ def __init__( async def aclose(self) -> None: await self._c.aclose() + def _raise_if_missing_db(self, r: "httpx.Response") -> None: + """CouchDB answers 404 both for a missing doc and a missing database; + only the body's reason tells them apart.""" + if r.status_code != 404: + return + try: + reason = r.json().get("reason") + except ValueError: + return + if reason == "Database does not exist.": + raise CouchError( + "the data source does not exist in this environment; the " + "data is unavailable, do not retry with other arguments" + ) + # ---- document CRUD ---- async def get(self, doc_id: str) -> Optional[Dict[str, Any]]: r = await self._c.get(f"/{self.db}/{doc_id}") + self._raise_if_missing_db(r) if r.status_code == 404: return None r.raise_for_status() @@ -57,6 +73,7 @@ async def put(self, doc: Dict[str, Any]) -> Dict[str, Any]: if "_id" not in doc: raise CouchError("document must have _id") r = await self._c.put(f"/{self.db}/{doc['_id']}", json=doc) + self._raise_if_missing_db(r) if r.status_code == 409: raise CouchError(f"conflict updating {doc['_id']} (stale _rev)") r.raise_for_status() @@ -64,6 +81,7 @@ async def put(self, doc: Dict[str, Any]) -> Dict[str, Any]: async def delete(self, doc_id: str, rev: str) -> Dict[str, Any]: r = await self._c.delete(f"/{self.db}/{doc_id}", params={"rev": rev}) + self._raise_if_missing_db(r) r.raise_for_status() return r.json() @@ -83,6 +101,7 @@ async def find( if sort: body["sort"] = sort r = await self._c.post(f"/{self.db}/_find", json=body) + self._raise_if_missing_db(r) r.raise_for_status() return r.json().get("docs", []) @@ -95,6 +114,7 @@ async def view(self, ddoc: str, view: str, **params: Any) -> Dict[str, Any]: for k, v in params.items() } r = await self._c.get(f"/{self.db}/_design/{ddoc}/_view/{view}", params=q) + self._raise_if_missing_db(r) r.raise_for_status() return r.json() diff --git a/src/servers/wo/tests/test_missing_database.py b/src/servers/wo/tests/test_missing_database.py new file mode 100644 index 000000000..cfc9d1108 --- /dev/null +++ b/src/servers/wo/tests/test_missing_database.py @@ -0,0 +1,42 @@ +"""A missing document and a missing database produce different outcomes.""" + +import httpx +import pytest + +from servers.wo.couch import CouchClient, CouchError + + +def _client(reason: str) -> CouchClient: + client = CouchClient("http://couch.test", "workorder") + client._c = httpx.AsyncClient( + base_url="http://couch.test", + transport=httpx.MockTransport( + lambda request: httpx.Response( + 404, json={"error": "not_found", "reason": reason} + ) + ), + ) + return client + + +@pytest.mark.anyio +async def test_missing_document_returns_none() -> None: + assert await _client("missing").get("wo:MAIN:1") is None + + +@pytest.mark.anyio +async def test_missing_database_raises_distinct_message() -> None: + client = _client("Database does not exist.") + + with pytest.raises(CouchError, match="data source does not exist"): + await client.get("wo:MAIN:1") + with pytest.raises(CouchError, match="data source does not exist"): + await client.find({"type": "workorder"}) + + +@pytest.mark.anyio +async def test_missing_database_message_hides_database_name() -> None: + with pytest.raises(CouchError) as exc_info: + await _client("Database does not exist.").get("wo:MAIN:1") + + assert "workorder" not in str(exc_info.value) diff --git a/uv.lock b/uv.lock index 585aedfaf..aafec6166 100644 --- a/uv.lock +++ b/uv.lock @@ -172,6 +172,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/5a/72f33204064b6e87601a71a6baf8d855769f8a0c1eaae8d06a1094872371/anthropic-0.96.0-py3-none-any.whl", hash = "sha256:9a6e335a354602a521cd9e777e92bfd46ba6e115bf9bbfe6135311e8fb2015b2", size = 635930, upload-time = "2026-04-16T14:28:01.436Z" }, ] +[[package]] +name = "antlr4-python3-runtime" +version = "4.9.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/38/7859ff46355f76f8d19459005ca000b6e7012f2f1ca597746cbcd1fbfe5e/antlr4-python3-runtime-4.9.3.tar.gz", hash = "sha256:f224469b4168294902bb1efa80a8bf7855f24c99aef99cbefc1bcd3cce77881b", size = 117034, upload-time = "2021-11-06T17:52:23.524Z" } + [[package]] name = "anyio" version = "4.12.1" @@ -280,13 +286,37 @@ dependencies = [ { name = "stirrup", extra = ["docker", "litellm", "mcp"] }, ] +[package.optional-dependencies] +harbor = [ + { name = "harbor" }, +] +tsfm = [ + { name = "accelerate" }, + { name = "einops" }, + { name = "gluonts" }, + { name = "hf-xet" }, + { name = "hydra-core" }, + { name = "lightning" }, + { name = "pyod" }, + { name = "safetensors" }, + { name = "scikit-learn" }, + { name = "skpro" }, + { name = "torch" }, + { name = "toto-models" }, + { name = "tqdm" }, + { name = "transformers", extra = ["torch"] }, + { name = "tslearn" }, +] + [package.dev-dependencies] dev = [ { name = "anyio" }, { name = "ipykernel" }, { name = "jupyterlab" }, + { name = "numba" }, { name = "opentelemetry-api" }, { name = "opentelemetry-sdk" }, + { name = "pyod" }, { name = "pytest" }, { name = "pytest-anyio" }, ] @@ -296,20 +326,23 @@ otel = [ { name = "opentelemetry-instrumentation-httpx" }, { name = "opentelemetry-sdk" }, ] -tsfm = [ - { name = "torch" }, - { name = "transformers" }, -] [package.metadata] requires-dist = [ + { name = "accelerate", marker = "extra == 'tsfm'", specifier = ">=0.26.0" }, { name = "claude-agent-sdk", specifier = ">=0.0.14" }, { name = "couchdb3", specifier = ">=2.0.2" }, { name = "deepagents", specifier = ">=0.5.3" }, + { name = "einops", marker = "extra == 'tsfm'", specifier = ">=0.7" }, { name = "fastmcp", specifier = ">=2.14.5" }, + { name = "gluonts", marker = "extra == 'tsfm'", specifier = ">=0.15" }, { name = "granite-tsfm", specifier = ">=0.3.5" }, + { name = "harbor", marker = "extra == 'harbor'", specifier = ">=0.23.0" }, + { name = "hf-xet", marker = "extra == 'tsfm'", specifier = ">=1.0" }, + { name = "hydra-core", marker = "extra == 'tsfm'", specifier = ">=1.3" }, { name = "langchain-mcp-adapters", specifier = ">=0.2.2" }, { name = "langchain-openai", specifier = ">=1.1.0" }, + { name = "lightning", marker = "extra == 'tsfm'", specifier = ">=2.0" }, { name = "litellm", specifier = "==1.94.0" }, { name = "mcp", extras = ["cli"], specifier = ">=1.26.0" }, { name = "numpy", specifier = ">=1.24" }, @@ -318,24 +351,35 @@ requires-dist = [ { name = "pandas", specifier = ">=2.0" }, { name = "pendulum", specifier = ">=3.2.0" }, { name = "pydantic", specifier = ">=2.12.5" }, + { name = "pyod", marker = "extra == 'tsfm'", specifier = ">=2.0" }, { name = "python-dotenv", specifier = ">=1.0" }, { name = "pywavelets", specifier = ">=1.4" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "requests", specifier = ">=2.32.5" }, + { name = "safetensors", marker = "extra == 'tsfm'", specifier = ">=0.4" }, + { name = "scikit-learn", marker = "extra == 'tsfm'", specifier = ">=1.3" }, { name = "scipy", specifier = ">=1.10.0" }, - { name = "sktime", specifier = ">=0.30" }, - { name = "sktime", specifier = ">=1.0.1" }, + { name = "skpro", marker = "extra == 'tsfm'", specifier = ">=2.14" }, + { name = "sktime", specifier = ">=1.2.0" }, { name = "statsmodels", specifier = ">=0.14" }, { name = "stirrup", extras = ["docker", "litellm", "mcp"], specifier = ">=0.2.0" }, + { name = "torch", marker = "extra == 'tsfm'", specifier = ">=2.0" }, + { name = "toto-models", marker = "extra == 'tsfm'" }, + { name = "tqdm", marker = "extra == 'tsfm'", specifier = ">=4.65" }, + { name = "transformers", extras = ["torch"], marker = "extra == 'tsfm'", specifier = ">=5.3.0" }, + { name = "tslearn", marker = "extra == 'tsfm'", specifier = ">=0.6" }, ] +provides-extras = ["harbor", "tsfm"] [package.metadata.requires-dev] dev = [ { name = "anyio", specifier = ">=4.9.0" }, { name = "ipykernel", specifier = ">=7.3.0" }, { name = "jupyterlab", specifier = ">=4.6.1" }, + { name = "numba", specifier = ">=0.59" }, { name = "opentelemetry-api", specifier = ">=1.27.0" }, { name = "opentelemetry-sdk", specifier = ">=1.27.0" }, + { name = "pyod", specifier = ">=2.0" }, { name = "pytest", specifier = ">=9.0.2" }, { name = "pytest-anyio", specifier = ">=0.0.0" }, ] @@ -345,10 +389,6 @@ otel = [ { name = "opentelemetry-instrumentation-httpx", specifier = ">=0.48b0" }, { name = "opentelemetry-sdk", specifier = ">=1.27.0" }, ] -tsfm = [ - { name = "torch", specifier = ">=2.0" }, - { name = "transformers", specifier = ">=4.40" }, -] [[package]] name = "asttokens" @@ -597,14 +637,11 @@ wheels = [ [[package]] name = "click" -version = "8.3.1" +version = "8.5.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +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/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, + { 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]] @@ -634,6 +671,86 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, ] +[[package]] +name = "contourpy" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/5a/a55177dd22553a277388e8a1b3220e92de91bacb28356cdc73caa240121d/contourpy-1.4.0.tar.gz", hash = "sha256:20156f5a1ac4f8ce02656e39a61e82164a3d359796dc8026f75b062783d500e1", size = 13323726, upload-time = "2026-09-11T19:05:05.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/ba/01bde9753bdbba04a6da9d2bff3881f021bc708f654655e95fae26d3b3a3/contourpy-1.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:186ba929df36d61b6127da2e89cd1357e4cb79aca647381ab1a6feb0b152877b", size = 297302, upload-time = "2026-09-11T19:02:45.103Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f5/c5bf67522d49a2222f3154fe46879451d26f0e35ece41770758a64ea78cb/contourpy-1.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c76f3a5318164db7d9401132fc1b5364b784c613c93fa506e3b5e6d1bf353ec8", size = 284921, upload-time = "2026-09-11T19:02:48.049Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cc/cb989599eec12fda312e127eb8e04a8b21a7a6c94bf7ff1bc68273334a42/contourpy-1.4.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:43c3ccbb32c6294b183dcc8e8c46dacf5ecef809497e3d48a5be298eef185ad0", size = 356332, upload-time = "2026-09-11T19:02:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/47/fb/6f620d7602507817b0b4c21dd790ed68688f1f4ae9c29aacf21f08db5cf1/contourpy-1.4.0-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86d05cec773c9507a3950122e0e40ce77c23c75ceeb2fc189514e71893cbb34b", size = 405878, upload-time = "2026-09-11T19:02:54.093Z" }, + { url = "https://files.pythonhosted.org/packages/32/4e/693c6d6bdece679f0953775eef3a1b66d56be55478d936e7deb1f3004422/contourpy-1.4.0-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2deb580178ca19437a84bd77e4bc2cd91a8ccad212413a83c273900868b5978c", size = 408296, upload-time = "2026-09-11T19:02:56.528Z" }, + { url = "https://files.pythonhosted.org/packages/78/f9/b6831508960d559581c448532ba4df312217072975486dbf2b24fcc5b76f/contourpy-1.4.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:875f42444c9cf48d56f724f2637e60d0f73b3b12c9041e1484580a233edf9591", size = 383243, upload-time = "2026-09-11T19:02:58.638Z" }, + { url = "https://files.pythonhosted.org/packages/f2/21/52903825a0ae7bb625e8bd30a09816d4c3c5d6174a469c10a616166cf780/contourpy-1.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f863c6100bf926cf47d13f3cd75f9bb8ebd98aaab230eeb4468b91f98f39a6b3", size = 1357306, upload-time = "2026-09-11T19:03:01.036Z" }, + { url = "https://files.pythonhosted.org/packages/d8/79/d68b1ce8539e4071518fed9523f558398f34dcd078b8927b109c72dad2ef/contourpy-1.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3863ef2e2b13fe93f8c0ebb08ee400cb07153b8b0e91c5acb26e4537f283634f", size = 1427243, upload-time = "2026-09-11T19:03:03.866Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f0/b75a10e9d0616b97a30de277b6dfe83f1879880854c7330337309530eb48/contourpy-1.4.0-cp312-cp312-win32.whl", hash = "sha256:5450f091ac1be0be3ad3a2a3b3f23b5e443e78c670ced4fd347d626f92a28fd2", size = 347345, upload-time = "2026-09-11T19:03:06.192Z" }, + { url = "https://files.pythonhosted.org/packages/50/a9/dab08786bb4d77ef9a046b3c1be923be4e73c5d07d91f5a54e8ea9b09e41/contourpy-1.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:6e697d94e69f499ff6bebb899cae97a58d5d14f0e1fe9568b43a0248d2f9af8c", size = 234083, upload-time = "2026-09-11T19:03:08.122Z" }, + { url = "https://files.pythonhosted.org/packages/47/b9/3ba509755a970dbde5948e7142ac21f266be72f38017cc4087a05fdceee1/contourpy-1.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:0c7a4c2716a4e98342221954416a836cca77c996c14ddbf22b5f01d5d93ca09c", size = 559521, upload-time = "2026-09-11T19:03:10.047Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8b/62a6eacea08ea0b6ab0159a7783df7ed421ff9c1ff58fef7c31f58fa5e52/contourpy-1.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e21d1d4a9db5b3a793649c7ab0ddb4697f2818929eec871a7b9c45fce7b2a1ea", size = 297330, upload-time = "2026-09-11T19:03:11.809Z" }, + { url = "https://files.pythonhosted.org/packages/3f/2f/103cdc5b7077cd569989d3ddb99ee3d4e96ee31bde8df2a6e426b2dc6195/contourpy-1.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:196759a3e4db60e1546167e361090ebb6d199aa3aafc9e34ea17a5ac3b23e814", size = 285037, upload-time = "2026-09-11T19:03:13.781Z" }, + { url = "https://files.pythonhosted.org/packages/bf/25/97001d1fdf940e6f5adfc592d4f5899fff95daf7dceead3988fe09987d2d/contourpy-1.4.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c0e07c691f3b3321913ed9b8161c50ea5f77fadd006f52f5e755359b0dcbfc5", size = 356675, upload-time = "2026-09-11T19:03:15.374Z" }, + { url = "https://files.pythonhosted.org/packages/a6/5e/9a7241a89e2f74402aefa2569e1349ad2750ff77823c04c788eaaedb7fdd/contourpy-1.4.0-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7f861af508fca2384eef392bc1d8377cfa349b2b854cb11b45d89c75780e2561", size = 406990, upload-time = "2026-09-11T19:03:17.44Z" }, + { url = "https://files.pythonhosted.org/packages/36/81/3a4fbbf6a806eada5453a6d16f6c7d96b9f4b3e57a3127368fb545986b8a/contourpy-1.4.0-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a03b32c2e7eda8b17757c022162c13c86f5c3d6f937dddf9ba3c3ff7b513953b", size = 407271, upload-time = "2026-09-11T19:03:19.523Z" }, + { url = "https://files.pythonhosted.org/packages/68/77/8f93ecbde1a4d11dfd460cd19a0c159f5487352e751fa87f5d78cc9db08e/contourpy-1.4.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1219a8898523cba821085f2da8a1b962cc696325763f09d7f93538ba43b2d70", size = 384707, upload-time = "2026-09-11T19:03:21.208Z" }, + { url = "https://files.pythonhosted.org/packages/05/07/e5cdf94d68961ee464de6b4fedd3d2f666584f59ccd124bb1290597122ab/contourpy-1.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:20da2f86bfaed60dbba729fe738c8241b1dd22b6cf7e8cbbf30df290bd04ba28", size = 1356741, upload-time = "2026-09-11T19:03:23.621Z" }, + { url = "https://files.pythonhosted.org/packages/fd/8a/2301d07accb257ac08daeb631875fada06fedd019c20ff7b00ee07e9d211/contourpy-1.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6ced1670fafee703b8277ab1645072a226e74f167166eae146fb743916119b67", size = 1426793, upload-time = "2026-09-11T19:03:25.955Z" }, + { url = "https://files.pythonhosted.org/packages/7d/5d/a62648f06259d15c139b14eb8cf3c58fe53cb970a55f2bb77d1070e80a41/contourpy-1.4.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:abd0f7e51ecc52bf8c95713bd80b1c17c516a71fa391c54475308a4377903c2b", size = 117961, upload-time = "2026-09-11T19:03:27.713Z" }, + { url = "https://files.pythonhosted.org/packages/71/31/c5b7771ba6982f319ec90c0cf5c425b36891adfeee2d9f667f90b2d0c166/contourpy-1.4.0-cp313-cp313-win32.whl", hash = "sha256:510f7d93d94cf6ebf4e2cd0640bea16ab8affac7547230175b6745b596ce0599", size = 347438, upload-time = "2026-09-11T19:03:29.829Z" }, + { url = "https://files.pythonhosted.org/packages/68/c1/a59df0754bf83a6f3e153cc19d550a5b01f607e389b1c4d57f436f9139e0/contourpy-1.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:6507a016976a75a15aee47809a34605de6244ccc056e6164bf9fb14b27eecad4", size = 234084, upload-time = "2026-09-11T19:03:31.657Z" }, + { url = "https://files.pythonhosted.org/packages/41/48/97224f5decb1efa5a610f912195deb45321f73ec9b9dac485cd6fe2d5a6a/contourpy-1.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:423bd5f4b3f11d54a8c597234e513382e3454bb106b8ec7ae32ba4ed63f8d99b", size = 559559, upload-time = "2026-09-11T19:03:33.528Z" }, + { url = "https://files.pythonhosted.org/packages/75/b0/b548628c8dddf6e5e0a981b7fa8e4c008024df09c7d17c6a9e271edfa0b3/contourpy-1.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a3e67bc1a6a4618a1dac7c3053a9ffece5ddfa2046b2670b342cf430bb0b87d1", size = 297354, upload-time = "2026-09-11T19:03:35.017Z" }, + { url = "https://files.pythonhosted.org/packages/04/5a/513484208742f65af2648c519f0b7ab034616e183c4403538024c136a1ab/contourpy-1.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:072a702e2e178f4fcf96f04775d0c059e4c917925abf0d3208ebb6865df3c23d", size = 283729, upload-time = "2026-09-11T19:03:36.818Z" }, + { url = "https://files.pythonhosted.org/packages/35/da/5a6562febc994b2c4bf9d01e57a50458ef8a2051bb3e7b35a00333e0fc26/contourpy-1.4.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:404dbcd9233513dfd1323b66ea593fef90e899f658f6b8a9ed8e93bd0ca669db", size = 357819, upload-time = "2026-09-11T19:03:39.058Z" }, + { url = "https://files.pythonhosted.org/packages/6d/99/7b358ce888aaa426f755daa096c99c59d17c352037344e836f20e5d3b957/contourpy-1.4.0-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:61624f6722480aa7e168fd746164e7bfdf48f8ccb6542f1613741200dc37e2b1", size = 407795, upload-time = "2026-09-11T19:03:41.293Z" }, + { url = "https://files.pythonhosted.org/packages/be/02/34d7548adf08c60967435d79d7a90882eacc84f5093435287974c7e99943/contourpy-1.4.0-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e741a39dfc96babe1722561e81351aeec104526c57dbe91c167c1db606c2d55", size = 408552, upload-time = "2026-09-11T19:03:42.728Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8c/9677dad226e5aaac5b823d5b09d881ebc32ff1648af417597649841e516c/contourpy-1.4.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e439ab450c93455feb0218532ded3e946ec7a9b5f459069f122cfa44b89229f6", size = 384850, upload-time = "2026-09-11T19:03:44.402Z" }, + { url = "https://files.pythonhosted.org/packages/08/f5/e3fbeaa489c223629fbb434737cb3de4ecaca9cae4825e77b92490f640f8/contourpy-1.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:417be048f7e122cefbe2a34c51a1f2b0410f8eb35796f794d45475ddb7872d9d", size = 1357601, upload-time = "2026-09-11T19:03:46.746Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55fec293d342c222ed58a5d4056df4362e192af2e59527a3d5d822de8828/contourpy-1.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:43d80072a32299bf945de0a6dd4e034ea0162e832261de2e07a274a5adbba9c9", size = 1427340, upload-time = "2026-09-11T19:03:48.745Z" }, + { url = "https://files.pythonhosted.org/packages/86/40/0faabf453edf59b0e3633381d7ca72c3414fb9b72b5cbb08b06ed792d3e5/contourpy-1.4.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:fa1b787362a3856e63dd2b89449f6c384d55ee1beb8f94f4bcc871b40f02462c", size = 118355, upload-time = "2026-09-11T19:03:50.047Z" }, + { url = "https://files.pythonhosted.org/packages/38/0c/7ea75aa3559ae8a6805015e15a19c92b6480752ecc92526361e5776e88c5/contourpy-1.4.0-cp314-cp314-win32.whl", hash = "sha256:738c44fa71735a617f36da58e32810512407d283d5d1bb5b1cecb00d7eeba7cf", size = 356977, upload-time = "2026-09-11T19:04:11.401Z" }, + { url = "https://files.pythonhosted.org/packages/15/41/3df8cc14bb572c8b447f9dfbc876703f0f2f2896058285b5c87a1d4a638d/contourpy-1.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:181bea01bc742734ae672fa00c717d855dd35e1f029536406538c52e7b0cd73d", size = 240101, upload-time = "2026-09-11T19:04:12.772Z" }, + { url = "https://files.pythonhosted.org/packages/66/01/ee6830a5aa4565662a345172f6f35eeeed625d20f1c9d4ecd35318fe4ade/contourpy-1.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:64039341e2d8804f1a13bda8c69083eab935167bbd7e856cf096241f17d5bd45", size = 576359, upload-time = "2026-09-11T19:04:14.983Z" }, + { url = "https://files.pythonhosted.org/packages/51/af/49ee9c9cf012699e505c1b63c531cc99f19df50f205173092c4d541e3ec7/contourpy-1.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ef47fef9a912c77e3d84b014f701e5a9340f25703e9ed3bcebe37f91fb69dc49", size = 313601, upload-time = "2026-09-11T19:03:51.452Z" }, + { url = "https://files.pythonhosted.org/packages/ad/59/ce411ab2be0f805038626d8f4ca9ce563487ff51ff6c363e7512271564ed/contourpy-1.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:96019f059bcb3774acc0104be3360a57b36d33eaaa2e1d7f77c800ca8e07618b", size = 302812, upload-time = "2026-09-11T19:03:53.022Z" }, + { url = "https://files.pythonhosted.org/packages/95/b0/390915f9af14c1e2d3b2a9ad99d85b98df4352346a51e368cbb825f813c3/contourpy-1.4.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ddf5a2596d716fd3793434844caf31abc7a40b1e8718431420c89858268fb909", size = 348996, upload-time = "2026-09-11T19:03:54.594Z" }, + { url = "https://files.pythonhosted.org/packages/c7/85/e0952576d54322f3e9983b1e049a7a31f42ce82c5c29d01821e6dd3780d7/contourpy-1.4.0-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dd59df9e2fb0aff8bd7fd1adb9f349b7c835245a9d0f18c2f2deeb537190f2b1", size = 400272, upload-time = "2026-09-11T19:03:56.822Z" }, + { url = "https://files.pythonhosted.org/packages/d6/7d/f13543a5e1598e4a4ad01d9d5e2d65f9c09c37660616429913d606c5977d/contourpy-1.4.0-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b7794ea07cab575633daad8d6e963b662053391022294d3aacc1d9ba9ef54114", size = 412895, upload-time = "2026-09-11T19:03:58.487Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a8/6e5eaff53507dfd2dfc428240eecda32453067277d4b3e7ca71210b2d611/contourpy-1.4.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1c8a74744fa746eeaa12f0f9ed7f8b4add9d8f1b14d95e3b5e133675eac888f", size = 377060, upload-time = "2026-09-11T19:04:00.122Z" }, + { url = "https://files.pythonhosted.org/packages/16/a3/d00e44d35511b3cead5bd794ee672d4eecf30d9de4ceac57a49d6204064f/contourpy-1.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:107ec46f7aa1664266d69b1181aadc953d58d39f7d8d648fa5b795d4a060b0de", size = 1345688, upload-time = "2026-09-11T19:04:02.705Z" }, + { url = "https://files.pythonhosted.org/packages/1e/01/1e533b0cae32c8edd63fe40435ec3b29345dab87321afcd28722d7f957d1/contourpy-1.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5a4c89c7f38a0d7a94356d74e3391073c4325c4d47ad2fd065c3fe7dbae16c0c", size = 1416140, upload-time = "2026-09-11T19:04:04.77Z" }, + { url = "https://files.pythonhosted.org/packages/6d/0c/4ee66759f00d6a5d6909782f14f441d639fa18a4a43142e334351efc47f8/contourpy-1.4.0-cp314-cp314t-win32.whl", hash = "sha256:912c6afaa106e2f74ba22b30416b77ef7eb94e3bdc5a4df51ab147845dba9b6d", size = 371982, upload-time = "2026-09-11T19:04:06.233Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ac/b3e5324138c3e3741f526663f4f265f4f69dfdf26415a39428940864a5f6/contourpy-1.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ef9440f6f8506246269a82734f5ff9e2e4c5e775b3996fc883cc491c5257eca6", size = 262907, upload-time = "2026-09-11T19:04:07.882Z" }, + { url = "https://files.pythonhosted.org/packages/19/37/c9aa45e47819dc15a38fc5c81a2fb987fde55e9d3b991fbde514e3b6b5f5/contourpy-1.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:fc9feef8f1f001c5b87decadc67c4a5d1eebb62ca39c4763d1237ff62cf2b707", size = 587071, upload-time = "2026-09-11T19:04:09.898Z" }, + { url = "https://files.pythonhosted.org/packages/cc/74/66d4e6fbea3bcb3fd4e5ae21c039ba31b8fc408b35884b4bc09b88016dce/contourpy-1.4.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:79b06c60d5e569ce12c5da8f0c51217cdc484386e2349fa717c30fafb56d2871", size = 297358, upload-time = "2026-09-11T19:04:16.718Z" }, + { url = "https://files.pythonhosted.org/packages/2e/8d/8f7f25e71d73f15d7859943189900d2d348e740388c9ef52a0180d284762/contourpy-1.4.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f963ea9f0d68d2b6a02d861e3c0aae09ae25ba21f243fcf281e9fed468b078d", size = 283732, upload-time = "2026-09-11T19:04:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/f0/90/957549e3b03c407c670e3e191db63f4aef60dab6605bc967af3a806b543e/contourpy-1.4.0-cp315-cp315-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5545ff38229c15d11d822fc6f7415b932da9f4975f53e305b7b96ebfb01f889c", size = 357820, upload-time = "2026-09-11T19:04:20.077Z" }, + { url = "https://files.pythonhosted.org/packages/56/4c/fa8de87d907b41b05d03f4945a51675cd67319650d01b4c927d209ffa6ee/contourpy-1.4.0-cp315-cp315-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:630d32f06cedf37b7f1dd2b6a12e4492826bd477a4c9c7b2e2fee6ce3a2cea76", size = 407804, upload-time = "2026-09-11T19:04:21.781Z" }, + { url = "https://files.pythonhosted.org/packages/04/e7/96bd4f4a4ae285727befbdb4b332d53a2654cc5da4585d2525b47a063e1f/contourpy-1.4.0-cp315-cp315-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:be85d160e7c795113c2a93254dde2fc7e86b375322570eee78b420969426973c", size = 408559, upload-time = "2026-09-11T19:04:23.432Z" }, + { url = "https://files.pythonhosted.org/packages/ae/94/39660e8dc309f96bf7971eb4ec5659f272f84e42b34c9990a96ecce8442c/contourpy-1.4.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:018227e134b73090b06f911e773a526c77c9af426be29065500ce096a5accd4c", size = 384861, upload-time = "2026-09-11T19:04:25.198Z" }, + { url = "https://files.pythonhosted.org/packages/ab/7b/411dccbf73d5d19da49bf7735786b3baf4ddb1456412d48ae4d4ab1fc761/contourpy-1.4.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:4bffcb2e8cc5c0e837631acab59dd04265fdac376550e7c3e310523877c68cb7", size = 1357612, upload-time = "2026-09-11T19:04:27.614Z" }, + { url = "https://files.pythonhosted.org/packages/a0/39/bf2442130e7d5aae75a4de4504f0c8aca3ef3da3feac3940ee27ca985d83/contourpy-1.4.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc87f2ffa84a49a77d76cb792f28a48ff3b05a222aa0b2bddbd839ba78e1d5ca", size = 1427345, upload-time = "2026-09-11T19:04:29.646Z" }, + { url = "https://files.pythonhosted.org/packages/96/60/e5d9196dd83ccd7dd103762982b10ef19a4f72e8ef67ae7157699b9be0bb/contourpy-1.4.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:c8be3392b84cf43373a488874dc1ebf46f60784a70977f92586e18019d71c7ed", size = 118157, upload-time = "2026-09-11T19:04:31.091Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b2/ae3d90f08ba2ed82189d93b55ffbd41e559760e89af4f9ef54d51869f41d/contourpy-1.4.0-cp315-cp315-win32.whl", hash = "sha256:618137ec5778fb76d494c9ec854b104a377fddba0d128bef7136cce2c3bf0df8", size = 356982, upload-time = "2026-09-11T19:04:52.913Z" }, + { url = "https://files.pythonhosted.org/packages/02/a0/36648fff407dcf8b8482985d33367ffc0fe90be72383869312cd0bd00a1b/contourpy-1.4.0-cp315-cp315-win_amd64.whl", hash = "sha256:ea09dc704029930cbd097f75cb0cc1db9852adbcf1a151fac7e9559f7bcbfd19", size = 240101, upload-time = "2026-09-11T19:04:54.587Z" }, + { url = "https://files.pythonhosted.org/packages/07/09/49e987a96706b8f877596cee00d558d7880793a833addc6b33f80805e5dc/contourpy-1.4.0-cp315-cp315-win_arm64.whl", hash = "sha256:a5e2bb871b90cd7a52bdee63bf80ef7acb46398d27efc66d7679240016efc5aa", size = 576375, upload-time = "2026-09-11T19:04:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/e3/18/820ba24a070fe2a834af73e52245c501960746f5c1777ebc64ec0c0dec12/contourpy-1.4.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:432e89f835cf01f8d2123a130a3126b17ee6e0348e4d62c0973872b30aa873bf", size = 313543, upload-time = "2026-09-11T19:04:32.582Z" }, + { url = "https://files.pythonhosted.org/packages/d3/4c/f61a63d8107e0c1c3fa47d30ce45abad90b2ad48a1a9a4510332db7e0176/contourpy-1.4.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:3e0a2392533e5e2abdf277c951047516acc8d2f3c42b2dd9dc34c907c9ebabf2", size = 302702, upload-time = "2026-09-11T19:04:34.257Z" }, + { url = "https://files.pythonhosted.org/packages/9d/93/5af95cb5113ecc6e780ca0c6c79756a2cea8833f971bbae112dd4ad73ded/contourpy-1.4.0-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:758e7496cec3195fc28a28945eab3ceef348c47fb95c7afc2eca2f3901209c8c", size = 354370, upload-time = "2026-09-11T19:04:36.158Z" }, + { url = "https://files.pythonhosted.org/packages/47/75/5a9942446647202beb7b4d150891bfcb78fb112c73862484bbe4cc031092/contourpy-1.4.0-cp315-cp315t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bee6ff96653e0807cd0f8eaef00174a031e3a2effa5c20f49a5a8d574b05af23", size = 402340, upload-time = "2026-09-11T19:04:37.914Z" }, + { url = "https://files.pythonhosted.org/packages/c3/4f/bfdb0c67775a584f957211936661289789c4d0ec3e988bca2c8c02f8090e/contourpy-1.4.0-cp315-cp315t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a58b5f130afec61a093d743cdda435cc4047748b6791627e34a68f41ec3a9b07", size = 410721, upload-time = "2026-09-11T19:04:39.925Z" }, + { url = "https://files.pythonhosted.org/packages/65/d3/dddf13d6bb26e145c99debd1f2695018b5f3e24452dd9bfc9904c9d2ec89/contourpy-1.4.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9dc493e7924e5aa32d89d0b8e50986280f3d4b284968a792787bd7bcffd3cad", size = 382604, upload-time = "2026-09-11T19:04:41.793Z" }, + { url = "https://files.pythonhosted.org/packages/37/b4/89f0a5f6c6ccd322eae2704c7e933c82a4e594a61773933b4b0a18646866/contourpy-1.4.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:c927ec747633e68c9920bcaad48b0bcefe648812b822f18c34e2d0e2270a3d2a", size = 1354996, upload-time = "2026-09-11T19:04:43.856Z" }, + { url = "https://files.pythonhosted.org/packages/5d/29/fc91cc1ac6f3b591a269f4357b656d6ed0c39eb17c148f41f6da2f9729b9/contourpy-1.4.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:80c7fc8e7ac217777ce7ba2db3cf8478652b5a4b335283111012ff9eaac9d842", size = 1425030, upload-time = "2026-09-11T19:04:45.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/b8/bb129f524210c5d3eb8616876e02af280fba8b5554e0ac1afad80b51e06b/contourpy-1.4.0-cp315-cp315t-win32.whl", hash = "sha256:90feb8da006803a95ba573fddc9536357d0c3faddc3a4c9e22816ca49af52878", size = 371854, upload-time = "2026-09-11T19:04:48.106Z" }, + { url = "https://files.pythonhosted.org/packages/d8/b1/bc774d9d200cfc4f073bba78b1bb1cb3b55bf1c8c345eae41fc2db510459/contourpy-1.4.0-cp315-cp315t-win_amd64.whl", hash = "sha256:de503609fdb71f597634ca64fb4bd2f13d27c97f140e912b1e8ed93544840df0", size = 262450, upload-time = "2026-09-11T19:04:49.502Z" }, + { url = "https://files.pythonhosted.org/packages/54/bf/eb4ff492e1c3d027617b5a20e4e6d63a035b41b8cf77a95950159556a3a5/contourpy-1.4.0-cp315-cp315t-win_arm64.whl", hash = "sha256:78ed5c5f962b3e156109f0531b174a65ba80d13cc681a70227b12524c162e184", size = 587064, upload-time = "2026-09-11T19:04:51.148Z" }, +] + [[package]] name = "couchdb3" version = "2.0.2" @@ -749,6 +866,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/53/8fc9b0cdc5b7f62746e6a01b85b6461e5ae27f871010a5fcf8fa6950766d/cuda_pathfinder-1.5.6-py3-none-any.whl", hash = "sha256:7e4c07c117b78ba1fb35dac4c444d21f3677b1b1ff56175c53a8e3025c5b43c0", size = 52972, upload-time = "2026-06-30T00:58:04.34Z" }, ] +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + [[package]] name = "cyclopts" version = "4.5.3" @@ -804,6 +930,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b4/0b/3c3bb7cbe757279e693a0be6049048012f794d01f81099609ecd53b899f0/dateparser-1.4.0-py3-none-any.whl", hash = "sha256:7902b8e85d603494bf70a5a0b1decdddb2270b9c6e6b2bc8a57b93476c0df378", size = 300379, upload-time = "2026-03-26T09:56:08.409Z" }, ] +[[package]] +name = "dd-unit-scaling" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "torch" }, + { name = "unit-scaling" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/8c/b269c756abf79a37458a87aba450b2d8779ac8c3d00495481e3a812a392f/dd_unit_scaling-0.1.0.tar.gz", hash = "sha256:126b6f740f10636aaabb4d237b2d868ac3a131f62d3ddecb589347d2fa6b1970", size = 15064, upload-time = "2026-06-04T08:54:21.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/f2/0f19c1ffcf9fc2e192ed0b2186a652956705fb60b026dfab6ea23f5b9ea2/dd_unit_scaling-0.1.0-py3-none-any.whl", hash = "sha256:701b1fa8c5602e51e3668fc45d4e27b50b34aebf8189a772eede463d3cc1bc3a", size = 14876, upload-time = "2026-06-04T08:54:20.157Z" }, +] + [[package]] name = "debugpy" version = "1.8.21" @@ -872,6 +1011,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, ] +[[package]] +name = "deprecation" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/d3/8ae2869247df154b64c1884d7346d412fed0c49df84db635aab2d1c40e62/deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff", size = 173788, upload-time = "2020-04-20T14:23:38.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178, upload-time = "2020-04-20T14:23:36.581Z" }, +] + [[package]] name = "dill" version = "0.4.1" @@ -881,6 +1032,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, ] +[[package]] +name = "dirhash" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "scantree" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/70/49f93897f3a4f7ab5f20a854ebc91aad47854e9fb2cd169e3a4452fa3f5e/dirhash-0.5.0.tar.gz", hash = "sha256:e60760f0ab2e935d8cb088923ea2c6492398dca42cec785df778985fd4cd5386", size = 21377, upload-time = "2024-08-03T22:14:13.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/1f/c8bf92552b7f0a13b9f12b85e3de8df6d9814240e0f8ce8f37433df028b3/dirhash-0.5.0-py3-none-any.whl", hash = "sha256:523dfd6b058c64f45b31604376926c6e2bd2ea301d0df23095d4055674e38b09", size = 13119, upload-time = "2024-08-03T22:14:11.688Z" }, +] + [[package]] name = "diskcache" version = "5.6.3" @@ -1001,6 +1164,22 @@ lua = [ { name = "lupa" }, ] +[[package]] +name = "fastapi" +version = "0.141.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, +] + [[package]] name = "fastjsonschema" version = "2.21.2" @@ -1082,11 +1261,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.24.3" +version = "4.0.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/73/92/a8e2479937ff39185d20dd6a851c1a63e55849e447a55e798cc2e1f49c65/filelock-3.24.3.tar.gz", hash = "sha256:011a5644dc937c22699943ebbfc46e969cdde3e171470a6e40b9533e5a72affa", size = 37935, upload-time = "2026-02-19T00:48:20.543Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/d7/37691dc5063438a448b646f6f2442b4beebf16cc0e18d8cdfa7aeec60b8c/filelock-4.0.4.tar.gz", hash = "sha256:90999ed63a26ccf86b93b959ab10cf1017f422d816be454ed54cbed263e71ab5", size = 244639, upload-time = "2026-09-26T15:19:12.105Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/0f/5d0c71a1aefeb08efff26272149e07ab922b64f46c63363756224bd6872e/filelock-3.24.3-py3-none-any.whl", hash = "sha256:426e9a4660391f7f8a810d71b0555bce9008b0a1cc342ab1f6947d37639e002d", size = 24331, upload-time = "2026-02-19T00:48:18.465Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ac/8b9c6dc2aa7e9cf5582e20a337006c3428c3d752c2255dbaca2c78a8be45/filelock-4.0.4-py3-none-any.whl", hash = "sha256:0df72be195ca7892216d16f2edce8d9b93a571f02402972020a8cff84c594c7b", size = 108629, upload-time = "2026-09-26T15:19:10.603Z" }, ] [[package]] @@ -1098,6 +1277,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/79/1b8fa1bb3568781e84c9200f951c735f3f157429f44be0495da55894d620/filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25", size = 19970, upload-time = "2022-11-02T17:34:01.425Z" }, ] +[[package]] +name = "fonttools" +version = "4.66.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/24/86f9930b930b97fc82266083320f3c34643ef67261c32651e893db525aca/fonttools-4.66.0.tar.gz", hash = "sha256:ef0610dfe7bb5bf574d9bdad6f597403ebc9807d124ac6f148d7604b2609be98", size = 3692007, upload-time = "2026-09-23T17:59:14.345Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c1/25821e3bddd0644df229cfdc2e8b4101d28e4da15ffbe6e6b563ea6f3acd/fonttools-4.66.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:67ea5af3ca60e1e5c9b2841b1f6dba5ef16aab581bf463abb68515447154ab61", size = 3096788, upload-time = "2026-09-23T17:57:08.302Z" }, + { url = "https://files.pythonhosted.org/packages/0c/3d/d16b4cb63710abd91f1aaa482f2848d8b0c402502de5b8a72ba9d2613cb0/fonttools-4.66.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dbee639a23c4e067aedfbcf84d2e466a71a1a1156ad04d6d09023791ed167495", size = 2586926, upload-time = "2026-09-23T17:57:10.678Z" }, + { url = "https://files.pythonhosted.org/packages/e2/f0/dffa4fa83ec6780464e8484fa2f30ccc252d480137c8e4d3fc6ac510b3eb/fonttools-4.66.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35684b562df7154d7a0ebfa512db9199c7fb0a93b5abfb59dac9d022dbee7aaa", size = 5415821, upload-time = "2026-09-23T17:57:13.174Z" }, + { url = "https://files.pythonhosted.org/packages/6d/56/3c862bdd297a272a37e2ba1149da01446fd3f387ee80855699bb939bf5b2/fonttools-4.66.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aea0cc5609a5f2a2500f91bd6e1989fdd22da0f225be00dc1c4cec775838a7d0", size = 5396689, upload-time = "2026-09-23T17:57:15.987Z" }, + { url = "https://files.pythonhosted.org/packages/93/d7/f4b7c81a7a6b316b7226d11f8e8659f023babf1e0957f8e7ddcc4e155a82/fonttools-4.66.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8af85dfc2ad564f95b3e650024578bb2268bc4d827d418aab9650bbde4b6926a", size = 5353218, upload-time = "2026-09-23T17:57:18.803Z" }, + { url = "https://files.pythonhosted.org/packages/70/1e/f12bc535f3da359a4d29d5e7442ab2d7d67579d1516b9c7e1a38890168bd/fonttools-4.66.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2b278e2abe596872b3c9fe611f956ded85588537974e48e2adbe2d6ee159e099", size = 5516965, upload-time = "2026-09-23T17:57:21.077Z" }, + { url = "https://files.pythonhosted.org/packages/14/25/a1df66888c8b5f63c14e089400f2353da0c1ff9d09683cc0bf85afea348e/fonttools-4.66.0-cp312-cp312-win32.whl", hash = "sha256:ee7b9f6c835ea1a9beb5a35c8eb0c62b6a3290f8105d3d0c29a0a18c90f9b8ba", size = 2434425, upload-time = "2026-09-23T17:57:23.463Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f2/fd490f1ddf2e430289c13059982771e6b02a2bf37504401d2a5c53307892/fonttools-4.66.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d120ea0f5260b04e9b5ac0d9239efde4c23d990347d5d5889cdd37221726fec", size = 2486335, upload-time = "2026-09-23T17:57:25.561Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c8/1cf0a4f54c8cf351a4d9f8fcad5201dc0c5fb8676efdb1ba7cc3136ca523/fonttools-4.66.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c144e68572af1dfcb05fe065827d0920d4118b265504928e1d2262e3701a25a1", size = 3094615, upload-time = "2026-09-23T17:57:27.942Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ed/89baf1795841eaad6fcf0ee6f319c236f4379dd98653b4bff294b83d4172/fonttools-4.66.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ada87643b20a8fa5763e6dd4fa0f5e2901cbdbe0833deb6d5c5a9c3186782b94", size = 2586645, upload-time = "2026-09-23T17:57:30.248Z" }, + { url = "https://files.pythonhosted.org/packages/56/f9/982609322f92532bdd769a225c9b3e2bbac0a077c37f7780710db9a8e018/fonttools-4.66.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700c448f190cc7bbd9f059bd96131f4770e90938c9692550b142153bea73b14c", size = 5389194, upload-time = "2026-09-23T17:57:33.07Z" }, + { url = "https://files.pythonhosted.org/packages/7d/22/12177031dd8be493d5afda574323da19f6ea5488c85202f3a5708c94757c/fonttools-4.66.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c519433e8632284dbe64ee4c81b609a17e205bc1afff285553723964880d1c89", size = 5371422, upload-time = "2026-09-23T17:57:35.915Z" }, + { url = "https://files.pythonhosted.org/packages/19/e1/e8ed0950eb846f4a404ff84aea89e4c93393c2475cdfdf1c03a1b3f4e2ef/fonttools-4.66.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8739d77810aeb6d2ecd55cc2e4864d98a00c317fb6131a6d22ca58b3f50cf22a", size = 5332183, upload-time = "2026-09-23T17:57:39.041Z" }, + { url = "https://files.pythonhosted.org/packages/3e/fb/c0310cf9838859d74130530ef351a903735c6ecf47418d7d0dc4cf459a9b/fonttools-4.66.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cffa168522a0e057d3a5628c8ebd43ddf66a34613f42e116bffb85b0b32ce5a0", size = 5490019, upload-time = "2026-09-23T17:57:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/a9/7b/928f334aea18c77f6892895bb1e28388c9e2466e04bc924f93043024c780/fonttools-4.66.0-cp313-cp313-win32.whl", hash = "sha256:379b94fe10651d26caf398eb646b3ecde886a9c504d3cce5e46164d58a78e32d", size = 2432785, upload-time = "2026-09-23T17:57:44.484Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/576072a97256f06fe6d0112c522c118fe72390482baf52ad9bfa3b0d1db6/fonttools-4.66.0-cp313-cp313-win_amd64.whl", hash = "sha256:ee4f85d1341af630d787514eef1a0e1ad883f9d6c11a4c1465faeb9cd0bb5ced", size = 2484475, upload-time = "2026-09-23T17:57:46.625Z" }, + { url = "https://files.pythonhosted.org/packages/38/e6/2ea45d294ce967a8f76b5dec041abdd71c7abb652c9a81b24592edc00579/fonttools-4.66.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ce818581070527883e3678b92b1ac7ed3569e48c80f51b89b264f97d84be2608", size = 3098619, upload-time = "2026-09-23T17:57:48.843Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d5/225d95df6b88df277d86c3d86fdaa73833cd628dc2918fcf7b73133d6f67/fonttools-4.66.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:cf0033f343cb1592fb24ae3eaf8b58156f208884ac16991094293ee94c585654", size = 2588128, upload-time = "2026-09-23T17:57:52.041Z" }, + { url = "https://files.pythonhosted.org/packages/15/e6/5892522733dc50b0577693bb164032b47d3504f33ee44d92a1a740d2e89b/fonttools-4.66.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3889fdbe63e85dbf2a0d9d7c9d90746827ce60af2cdd24f0bb4738380a9540eb", size = 5373639, upload-time = "2026-09-23T17:57:54.555Z" }, + { url = "https://files.pythonhosted.org/packages/9a/72/72ec66154babfd53a1c705a9f973c49d12fa81084aa72d939e062242ada1/fonttools-4.66.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:850d50b347f31667d3a277a6e66a4219236db9d98afadb2993b8addf9bd4f593", size = 5317190, upload-time = "2026-09-23T17:57:57.31Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c6/a8ca0334c73d1fa4fae1d423ff92e0b0be0885ed98e866e3478a00ad1a47/fonttools-4.66.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:221defad3b1949c1fdde2558d1fe780d42f926b683a71a46a4495a0e6a6614bd", size = 5315603, upload-time = "2026-09-23T17:57:59.842Z" }, + { url = "https://files.pythonhosted.org/packages/d2/34/d3d4cd925d1a934e841d39a0540539b21e952c40dfac27874bac1392f3d2/fonttools-4.66.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b02686b2e47115b2378ab6156abd984c776c6faa4f56898fbcc1c30ee387937d", size = 5448784, upload-time = "2026-09-23T17:58:02.669Z" }, + { url = "https://files.pythonhosted.org/packages/61/b9/269bebce24f590d28f2e59fd8808f3282b5f5511a4a95c5f6413f0507993/fonttools-4.66.0-cp314-cp314-win32.whl", hash = "sha256:b46500e4d6f5708a9127ea12902ba54cb0ec594fc6bec649afc53154a1125b2b", size = 2437006, upload-time = "2026-09-23T17:58:05.39Z" }, + { url = "https://files.pythonhosted.org/packages/ca/bd/9c10c9a62a274451f10295c17a01286c7777f44da87cd9f77d08c5a21d4e/fonttools-4.66.0-cp314-cp314-win_amd64.whl", hash = "sha256:0cf33d1041364ebb7a9958db757e213c6d2a238eb41755fbf935db028c2db20f", size = 2489086, upload-time = "2026-09-23T17:58:07.516Z" }, + { url = "https://files.pythonhosted.org/packages/1f/45/c9bc900dca5ed7760c1692a6b1161c380515071da9d855eddedd4bb21fb5/fonttools-4.66.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d2d10f89c4bf7cd42b84695da617f9130fa594647ee74673bf8c8d001fa04ef9", size = 3172290, upload-time = "2026-09-23T17:58:09.975Z" }, + { url = "https://files.pythonhosted.org/packages/bb/82/1017a312eeb373f30d99d032d04bf55a8ea80858f1670b2e12ed90732574/fonttools-4.66.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:135ce8738a6341fbefeedc061e20c2889350e84405abea7e61b1c9d87e1a1f2f", size = 2621293, upload-time = "2026-09-23T17:58:12.309Z" }, + { url = "https://files.pythonhosted.org/packages/af/c3/b54678b08a6637d5a222b68e1f133c58096c664403b9ead1cce4eab53fd1/fonttools-4.66.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:107aae7c38f05561fef5103baa98a44400b5f4c69d87839c9a5f8125405e7d9a", size = 5546296, upload-time = "2026-09-23T17:58:15.041Z" }, + { url = "https://files.pythonhosted.org/packages/98/2c/42e1c39e4025323bf65e042ea7ed709912aebfa22d422a2937da12268331/fonttools-4.66.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e4677025ee40b3b1420387ca85f55545b8095f7596c3d5d59552dbee19c91f07", size = 5352908, upload-time = "2026-09-23T17:58:17.77Z" }, + { url = "https://files.pythonhosted.org/packages/f1/66/07a6b9e634d7e0edb3e8f23ebb483479599842093624f13bde44e3c7a4bc/fonttools-4.66.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:03922992966a1830b94a750961d1ccfe1a7375d792ca61077a5afb719811788a", size = 5413677, upload-time = "2026-09-23T17:58:20.511Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c6/d649d4d09ebbfdb0eb13cdeea731bf9685d184fef716e0c18f7779eb44fb/fonttools-4.66.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5dda4087a7f8f0b985995489b3c0115bd117f18c0fc236013a0e78e837bb9ac2", size = 5449024, upload-time = "2026-09-23T17:58:23.302Z" }, + { url = "https://files.pythonhosted.org/packages/04/4c/60c6c9a2bc4cc0ae1012df9084bfe4ba69a1a60df06f30830787a4e6c061/fonttools-4.66.0-cp314-cp314t-win32.whl", hash = "sha256:85e2960a29e6882567b981ba408b5a5ca2d8865b8f67240e1b420348997a13be", size = 2470760, upload-time = "2026-09-23T17:58:25.739Z" }, + { url = "https://files.pythonhosted.org/packages/d1/5e/fd8593bbe3da06d2cbf9c096e17becbb48ace6e7a33e0e5e21f82a55c32c/fonttools-4.66.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8ea6265535b25f8254868f5a212a1c9dbd88c2c4050f3df18d9174949df0d6d9", size = 2520614, upload-time = "2026-09-23T17:58:28.078Z" }, + { url = "https://files.pythonhosted.org/packages/a0/23/f9f265526c6a4096bbe67f45d6ed853519d49ad7e07667bd0c8edb33f7fd/fonttools-4.66.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9b0198962bbba64a88bf15dc5d95e4bab8c5208dede22a10fb06280406ed9874", size = 3092996, upload-time = "2026-09-23T17:58:30.828Z" }, + { url = "https://files.pythonhosted.org/packages/a9/25/44d594d7fd703b2c4914b0df728012b8f2e69578de2d9238742c1754243a/fonttools-4.66.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:15315302c620e6ce8582934e07932dcf442fc3f59ab208d30564200b9a2f7ea4", size = 2586696, upload-time = "2026-09-23T17:58:33.159Z" }, + { url = "https://files.pythonhosted.org/packages/b5/73/b33888692f06471222a66a79bd7f3e7b9aa9e3d87be6adb794a2ba3dc467/fonttools-4.66.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:056641fcedeaad24b92e343e35e9fd1337200c102450678894b17928482d010d", size = 5370868, upload-time = "2026-09-23T17:58:35.93Z" }, + { url = "https://files.pythonhosted.org/packages/34/22/b7fa275e29642ab706bdffc5d02564bf6ade4168bd4c8b98b6ac2a78a653/fonttools-4.66.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa995d96ced196388d8ed7a67ee001b181459d230e24bb68b193ae859f5609da", size = 5334214, upload-time = "2026-09-23T17:58:38.966Z" }, + { url = "https://files.pythonhosted.org/packages/b2/69/417f550986631f9b443666b6152c9396830c0bb7a241b2aa235d1faa2500/fonttools-4.66.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:f014bbf05f30731bf8b9f5c7d2b4c0e4f28f375926f95b187f85e0bbceb16029", size = 5311067, upload-time = "2026-09-23T17:58:41.686Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f5/80b8b3b55306975a69995b188d640664b53891509a47e6e19e5496adc529/fonttools-4.66.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:886f3029ea10362c592d2e1a5326a44645b85a2a0be6fdb7c9edbdb60cedb0ab", size = 5458838, upload-time = "2026-09-23T17:58:44.145Z" }, + { url = "https://files.pythonhosted.org/packages/f4/1a/7f5e8b4e0e876a94424faf039e99e09833c3ffb105978e3617a6cba58e92/fonttools-4.66.0-cp315-cp315-win32.whl", hash = "sha256:0640e69b00e089d6b82e0a54e3a03af84b775f1f0b2cbecc1b0d7fe3980a194a", size = 2435840, upload-time = "2026-09-23T17:58:46.482Z" }, + { url = "https://files.pythonhosted.org/packages/e0/8a/91ecd6ed7140e0589538298f941934eaccaf7ab067a60585de3a60ddcc32/fonttools-4.66.0-cp315-cp315-win_amd64.whl", hash = "sha256:a5a0624aaefb0a29e4ed7500a98732a1b0ec2d3040fe86cc8837f82e3fe4f446", size = 2488078, upload-time = "2026-09-23T17:58:49.027Z" }, + { url = "https://files.pythonhosted.org/packages/a5/16/3a82202c98d55aeb53dd1fe5cd295c53b1057a45e6be943a347dba5bc92b/fonttools-4.66.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:4bdaf80590fcccb1b9d8e3b8ca72225defb14d03b79788f59781ce1bb66049b9", size = 3163820, upload-time = "2026-09-23T17:58:51.661Z" }, + { url = "https://files.pythonhosted.org/packages/74/06/597c587ec929f18622d0f6ad4aac15a698e5d4e42d291cf051e63fe0d1b0/fonttools-4.66.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:398bfc5ce9055e0b91a6189f8cf462d4230b38f522b2ceaecb4f34cbf4fb5480", size = 2617537, upload-time = "2026-09-23T17:58:54.421Z" }, + { url = "https://files.pythonhosted.org/packages/cc/08/6477bf19e923deb834c4c5866c66f47a679cc0a31028e2240ea58f9c1244/fonttools-4.66.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd9297a5f670f3e59ae697289ddad5902d3c4a86188e83e7823bde06334a560c", size = 5524223, upload-time = "2026-09-23T17:58:57.078Z" }, + { url = "https://files.pythonhosted.org/packages/69/2a/3e893827e4d6f8858784f71f0484046626a43e21492d3683b7ea5c7262bb/fonttools-4.66.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2032ac005e14c56e774fd19f2bc1c22c796c0c3b39d91741c0e3f9575944d38", size = 5345920, upload-time = "2026-09-23T17:58:59.848Z" }, + { url = "https://files.pythonhosted.org/packages/22/7a/574cfd9f567231006152694a8904e1cfa2e7ddd2b7476e8e9d1b079278b4/fonttools-4.66.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:02117d05dddb51e39b5c5a6eed0702ed8e0e1c341cb138101b2446eb479858f8", size = 5392188, upload-time = "2026-09-23T17:59:02.461Z" }, + { url = "https://files.pythonhosted.org/packages/17/15/656c907ef5063eeb9a0f6e36a253c14ffe5d5605009d6c86140234cb0968/fonttools-4.66.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6b11180c4166a4fc353bb02b147322b7c454927bba4f5040ccdef109a73f9051", size = 5438594, upload-time = "2026-09-23T17:59:04.967Z" }, + { url = "https://files.pythonhosted.org/packages/5d/82/5df8cfb6c16035a9f0baef4c7b37ae1691e5d9280195459f1eb6f0633014/fonttools-4.66.0-cp315-cp315t-win32.whl", hash = "sha256:6627f48130cdbe04c7e1189bff4192886ecda757f575626ee8468f6580eaecf3", size = 2467910, upload-time = "2026-09-23T17:59:07.332Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cd/3359c485efaed245663bf6f840e1bb45fffa1230b1e9af1880d7d9003e56/fonttools-4.66.0-cp315-cp315t-win_amd64.whl", hash = "sha256:a9a45a23b020e667499fb2dbce0f1373b1b1b8cf88e47f3031e69ef3ea21f3ee", size = 2516298, upload-time = "2026-09-23T17:59:09.934Z" }, + { url = "https://files.pythonhosted.org/packages/83/65/826290863c9df6041f2e36a5ae5d604cd8247fffc8ec7b45581fd2473e4d/fonttools-4.66.0-py3-none-any.whl", hash = "sha256:bc7b7ddc1a1f46c363354304e9a8dd93722e4a6a24f785015650898dacf40df9", size = 1201583, upload-time = "2026-09-23T17:59:12.104Z" }, +] + [[package]] name = "fqdn" version = "1.5.1" @@ -1210,6 +1446,31 @@ http = [ { name = "aiohttp" }, ] +[[package]] +name = "gluonts" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pandas" }, + { name = "pydantic" }, + { name = "toolz" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/56/a7d3613467cd2ca6d9b09d25d1987385ab32c13c5ef121fe72060d0ab214/gluonts-0.17.0.tar.gz", hash = "sha256:f815d4a069be974a2a62c0eb7409a0b6c5c58fd960b562ba2a530fc2d5763c7e", size = 1262229, upload-time = "2026-07-31T10:57:18.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/bf/1b513c900dca580fca540a47f41873dfd7f6d47f57341aa0fbf926443db4/gluonts-0.17.0-py3-none-any.whl", hash = "sha256:811baf619b1002daa82afabc4911ded217b369a4dcbbbbf0e5d3f7876650bcd3", size = 864016, upload-time = "2026-07-31T10:57:16.435Z" }, +] + +[package.optional-dependencies] +torch = [ + { name = "lightning" }, + { name = "pytorch-lightning" }, + { name = "scipy" }, + { name = "torch" }, +] + [[package]] name = "google-auth" version = "2.49.2" @@ -1263,7 +1524,7 @@ wheels = [ [[package]] name = "granite-tsfm" -version = "0.3.6" +version = "0.3.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "datasets" }, @@ -1277,9 +1538,9 @@ dependencies = [ { name = "transformers", extra = ["torch"] }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/68/d2/174787ea23a0ffb92f1ff0ad060d03d7ef816e2837875d205d666da2b111/granite_tsfm-0.3.6.tar.gz", hash = "sha256:2ac363bf6fa3a278dbf40b58bd5c4f99e0f94f656afc345a7e38bdbefc096d5c", size = 22855565, upload-time = "2026-05-01T21:51:30.112Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/5a/bff28879b80438e5c994ef1aa28bcbc87a726e443ea23d4c444758ebea60/granite_tsfm-0.3.9.tar.gz", hash = "sha256:05761eaa37112cd8694803061b5435ec4c0621516a57bf26f64d282850415a3d", size = 26244702, upload-time = "2026-08-28T21:34:49.832Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/3c/cd27cb83e355447ee3b9f9feb3437bb8e0a9861082613e194429b843516c/granite_tsfm-0.3.6-py3-none-any.whl", hash = "sha256:89f018400142791a73cfd2e108332daf2780e88c1bd745b0a1e11392860b72fd", size = 3838715, upload-time = "2026-05-01T21:51:27.667Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a4/39124755d2f32b2793b66a1e23bd2b5cb6a30fabb0ccac3f1d99f580a10b/granite_tsfm-0.3.9-py3-none-any.whl", hash = "sha256:07ca9c503cfa9f7e34d6ee5616d1a37ae301cabc6daed0546b249ddbc2c8f272", size = 3869902, upload-time = "2026-08-28T21:34:47.776Z" }, ] [[package]] @@ -1300,33 +1561,83 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "h2" +version = "4.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/85/7c366e69d84c17bb778fe41419e1fbcce3033d5b7ce29bbffff0a98b859f/h2-4.4.1.tar.gz", hash = "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516", size = 2157281, upload-time = "2026-08-03T11:45:09.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/22/e85faf23bd72a92d1921e37d674ca56eb298a3c8be31fdecef0ff2b3aaac/h2-4.4.1-py3-none-any.whl", hash = "sha256:0e25f1462b23c9cb82d9eb02e28bc706dac2a68cb457c6a0d74d63c8a2a5d0e6", size = 62636, upload-time = "2026-08-03T11:44:59.164Z" }, +] + +[[package]] +name = "harbor" +version = "0.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dirhash" }, + { name = "fastapi" }, + { name = "filelock" }, + { name = "httpx" }, + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "litellm" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pydantic" }, + { name = "pyjwt" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "rich" }, + { name = "shortuuid" }, + { name = "supabase" }, + { name = "tenacity" }, + { name = "toml" }, + { name = "typer" }, + { name = "uvicorn" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/3a/b05d0e8752d5d531e56fbb2955f0e656c0a9b5fbcd202dbad7313b29d4df/harbor-0.23.0.tar.gz", hash = "sha256:a8b87e98db59877228bb36d2c24757cc422a3144ffaf31a1f88955c65c911e4d", size = 1907464, upload-time = "2026-09-12T04:55:11.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/c7/607ff037dff1f40d1f941b9854742d66fd43630274fd4e7b8de8480dad34/harbor-0.23.0-py3-none-any.whl", hash = "sha256:8747400dbb2a5e2298e1338e17e88eba38433c0433fd700f34d1a9021bba5c37", size = 2142100, upload-time = "2026-09-12T04:55:10.014Z" }, +] + [[package]] name = "hf-xet" -version = "1.2.0" +version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/6e/0f11bacf08a67f7fb5ee09740f2ca54163863b07b70d579356e9222ce5d8/hf_xet-1.2.0.tar.gz", hash = "sha256:a8c27070ca547293b6890c4bf389f713f80e8c478631432962bb7f4bc0bd7d7f", size = 506020, upload-time = "2025-10-24T19:04:32.129Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/a5/85ef910a0aa034a2abcfadc360ab5ac6f6bc4e9112349bd40ca97551cff0/hf_xet-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:ceeefcd1b7aed4956ae8499e2199607765fbd1c60510752003b6cc0b8413b649", size = 2861870, upload-time = "2025-10-24T19:04:11.422Z" }, - { url = "https://files.pythonhosted.org/packages/ea/40/e2e0a7eb9a51fe8828ba2d47fe22a7e74914ea8a0db68a18c3aa7449c767/hf_xet-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b70218dd548e9840224df5638fdc94bd033552963cfa97f9170829381179c813", size = 2717584, upload-time = "2025-10-24T19:04:09.586Z" }, - { url = "https://files.pythonhosted.org/packages/a5/7d/daf7f8bc4594fdd59a8a596f9e3886133fdc68e675292218a5e4c1b7e834/hf_xet-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d40b18769bb9a8bc82a9ede575ce1a44c75eb80e7375a01d76259089529b5dc", size = 3315004, upload-time = "2025-10-24T19:04:00.314Z" }, - { url = "https://files.pythonhosted.org/packages/b1/ba/45ea2f605fbf6d81c8b21e4d970b168b18a53515923010c312c06cd83164/hf_xet-1.2.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd3a6027d59cfb60177c12d6424e31f4b5ff13d8e3a1247b3a584bf8977e6df5", size = 3222636, upload-time = "2025-10-24T19:03:58.111Z" }, - { url = "https://files.pythonhosted.org/packages/4a/1d/04513e3cab8f29ab8c109d309ddd21a2705afab9d52f2ba1151e0c14f086/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6de1fc44f58f6dd937956c8d304d8c2dea264c80680bcfa61ca4a15e7b76780f", size = 3408448, upload-time = "2025-10-24T19:04:20.951Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7c/60a2756d7feec7387db3a1176c632357632fbe7849fce576c5559d4520c7/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f182f264ed2acd566c514e45da9f2119110e48a87a327ca271027904c70c5832", size = 3503401, upload-time = "2025-10-24T19:04:22.549Z" }, - { url = "https://files.pythonhosted.org/packages/4e/64/48fffbd67fb418ab07451e4ce641a70de1c40c10a13e25325e24858ebe5a/hf_xet-1.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:293a7a3787e5c95d7be1857358a9130694a9c6021de3f27fa233f37267174382", size = 2900866, upload-time = "2025-10-24T19:04:33.461Z" }, - { url = "https://files.pythonhosted.org/packages/e2/51/f7e2caae42f80af886db414d4e9885fac959330509089f97cccb339c6b87/hf_xet-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:10bfab528b968c70e062607f663e21e34e2bba349e8038db546646875495179e", size = 2861861, upload-time = "2025-10-24T19:04:19.01Z" }, - { url = "https://files.pythonhosted.org/packages/6e/1d/a641a88b69994f9371bd347f1dd35e5d1e2e2460a2e350c8d5165fc62005/hf_xet-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a212e842647b02eb6a911187dc878e79c4aa0aa397e88dd3b26761676e8c1f8", size = 2717699, upload-time = "2025-10-24T19:04:17.306Z" }, - { url = "https://files.pythonhosted.org/packages/df/e0/e5e9bba7d15f0318955f7ec3f4af13f92e773fbb368c0b8008a5acbcb12f/hf_xet-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30e06daccb3a7d4c065f34fc26c14c74f4653069bb2b194e7f18f17cbe9939c0", size = 3314885, upload-time = "2025-10-24T19:04:07.642Z" }, - { url = "https://files.pythonhosted.org/packages/21/90/b7fe5ff6f2b7b8cbdf1bd56145f863c90a5807d9758a549bf3d916aa4dec/hf_xet-1.2.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:29c8fc913a529ec0a91867ce3d119ac1aac966e098cf49501800c870328cc090", size = 3221550, upload-time = "2025-10-24T19:04:05.55Z" }, - { url = "https://files.pythonhosted.org/packages/6f/cb/73f276f0a7ce46cc6a6ec7d6c7d61cbfe5f2e107123d9bbd0193c355f106/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e159cbfcfbb29f920db2c09ed8b660eb894640d284f102ada929b6e3dc410a", size = 3408010, upload-time = "2025-10-24T19:04:28.598Z" }, - { url = "https://files.pythonhosted.org/packages/b8/1e/d642a12caa78171f4be64f7cd9c40e3ca5279d055d0873188a58c0f5fbb9/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c91d5ae931510107f148874e9e2de8a16052b6f1b3ca3c1b12f15ccb491390f", size = 3503264, upload-time = "2025-10-24T19:04:30.397Z" }, - { url = "https://files.pythonhosted.org/packages/17/b5/33764714923fa1ff922770f7ed18c2daae034d21ae6e10dbf4347c854154/hf_xet-1.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:210d577732b519ac6ede149d2f2f34049d44e8622bf14eb3d63bbcd2d4b332dc", size = 2901071, upload-time = "2025-10-24T19:04:37.463Z" }, - { url = "https://files.pythonhosted.org/packages/96/2d/22338486473df5923a9ab7107d375dbef9173c338ebef5098ef593d2b560/hf_xet-1.2.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:46740d4ac024a7ca9b22bebf77460ff43332868b661186a8e46c227fdae01848", size = 2866099, upload-time = "2025-10-24T19:04:15.366Z" }, - { url = "https://files.pythonhosted.org/packages/7f/8c/c5becfa53234299bc2210ba314eaaae36c2875e0045809b82e40a9544f0c/hf_xet-1.2.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:27df617a076420d8845bea087f59303da8be17ed7ec0cd7ee3b9b9f579dff0e4", size = 2722178, upload-time = "2025-10-24T19:04:13.695Z" }, - { url = "https://files.pythonhosted.org/packages/9a/92/cf3ab0b652b082e66876d08da57fcc6fa2f0e6c70dfbbafbd470bb73eb47/hf_xet-1.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3651fd5bfe0281951b988c0facbe726aa5e347b103a675f49a3fa8144c7968fd", size = 3320214, upload-time = "2025-10-24T19:04:03.596Z" }, - { url = "https://files.pythonhosted.org/packages/46/92/3f7ec4a1b6a65bf45b059b6d4a5d38988f63e193056de2f420137e3c3244/hf_xet-1.2.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d06fa97c8562fb3ee7a378dd9b51e343bc5bc8190254202c9771029152f5e08c", size = 3229054, upload-time = "2025-10-24T19:04:01.949Z" }, - { url = "https://files.pythonhosted.org/packages/0b/dd/7ac658d54b9fb7999a0ccb07ad863b413cbaf5cf172f48ebcd9497ec7263/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4c1428c9ae73ec0939410ec73023c4f842927f39db09b063b9482dac5a3bb737", size = 3413812, upload-time = "2025-10-24T19:04:24.585Z" }, - { url = "https://files.pythonhosted.org/packages/92/68/89ac4e5b12a9ff6286a12174c8538a5930e2ed662091dd2572bbe0a18c8a/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a55558084c16b09b5ed32ab9ed38421e2d87cf3f1f89815764d1177081b99865", size = 3508920, upload-time = "2025-10-24T19:04:26.927Z" }, - { url = "https://files.pythonhosted.org/packages/cb/44/870d44b30e1dcfb6a65932e3e1506c103a8a5aea9103c337e7a53180322c/hf_xet-1.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:e6584a52253f72c9f52f9e549d5895ca7a471608495c4ecaa6cc73dba2b24d69", size = 2905735, upload-time = "2025-10-24T19:04:35.928Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/1b/ab/522a2ab67f27971a9d48ca666d4fca85ef7d5282d142e31fd087e27b1bbe/hf_xet-1.6.0.tar.gz", hash = "sha256:2e58454a340b3556dfa4972d5451aff4fba8dd42a236600ba1a1d2b1514f0fef", size = 920527, upload-time = "2026-08-03T22:33:13.243Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/62/3c062f593bd92ef4e77a0ef39541e3d82a0a1d3947c8a777a02a13a27828/hf_xet-1.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:70cbb9c896901600128cb9b6f06e132954fbede1db30f31f7c6c63f84cb7c31d", size = 4074584, upload-time = "2026-08-03T22:32:47.364Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1e/c0ad437dd267a8e435bef594acf781bbc3874ff0b6435b4962d03ecf7cc4/hf_xet-1.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:23379c2f9ec8696d952b16414a2bae72cad86a52df869b050698ba60f538c675", size = 3867381, upload-time = "2026-08-03T22:32:49.049Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ee/7c0d7b6ab336167531b1c30af2af003f054af4c749becbd7209ae33a77c3/hf_xet-1.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f7278c05c22fd60cb436cda1269649b3e81db65ecdc8496e5e164aa4143e7b", size = 4453982, upload-time = "2026-08-03T22:32:50.568Z" }, + { url = "https://files.pythonhosted.org/packages/63/06/ad8eab1c9525246650cbaa821caa3cdbaca734ab1a5b8c91bea09cbd8d69/hf_xet-1.6.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:948f15d3a9545cfe5932f6bd8b440f6ae630aee108f14b7bd6c561f7c2dcc522", size = 4249445, upload-time = "2026-08-03T22:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/d8/26/1eee8aedb0dafc1ab9717dc9ac602cde33361b232dc06803f1f6ed18b58c/hf_xet-1.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5153e6bb103ad49d6ea9f1b2e230db5a2ea32551ad09a706d2f61d7c7c80d80e", size = 4451099, upload-time = "2026-08-03T22:32:54.114Z" }, + { url = "https://files.pythonhosted.org/packages/67/57/0b88af1f194ab6c9c650547d9cc06bfeaab836ae4dcdb331676bfb8be95a/hf_xet-1.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:35cec30d75c6f9eb9c16a77cef68e85a103b72e24d4b473714ec9ff06428bab9", size = 4664712, upload-time = "2026-08-03T22:32:55.547Z" }, + { url = "https://files.pythonhosted.org/packages/53/a0/26b717a9d1840e8abf48dcec64b5ed8fbe472671d38ad28d30e147132b33/hf_xet-1.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5789835d7c6bc9436962853192082374297fb72d7eff7e7762ec25ceb7e25338", size = 4025906, upload-time = "2026-08-03T22:32:57.391Z" }, + { url = "https://files.pythonhosted.org/packages/49/f6/4a9966633c6fef83af997e2cff68ec1963676d412bdfd096df2a93b8e185/hf_xet-1.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:75765820ce4700db3750c94acc8fe27c5fae4c9ec000a0dbac3ca082acf97765", size = 3849221, upload-time = "2026-08-03T22:32:59.123Z" }, + { url = "https://files.pythonhosted.org/packages/a2/50/7afa2c9c787405864fc47a0d1bbc02c62e9101947ed43c1f43899fc7d91d/hf_xet-1.6.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:633dc0cd71d32da58ab8c03ad38e2fac452c15c2b0a2866ebf6ededfe0a5061d", size = 4071729, upload-time = "2026-08-03T22:33:00.721Z" }, + { url = "https://files.pythonhosted.org/packages/4b/69/55b8dcf636142ae660fec1869fcac14c4da2e8412e14d6eee1523be77e9f/hf_xet-1.6.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:f0906082d9932ae0c0057fa194041c22b4e2cdb46b2592ef3b91f020d62a081a", size = 3876287, upload-time = "2026-08-03T22:33:02.251Z" }, + { url = "https://files.pythonhosted.org/packages/67/4e/a28359bf1c1ecf11eba22123168c138698f7cb576ac678f5a2e16cd5da08/hf_xet-1.6.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d62671bb130879cef0ee4c9ebe47a14af6c66ec53e6d84dc15936e5ffdfac82f", size = 4464663, upload-time = "2026-08-03T22:33:03.802Z" }, + { url = "https://files.pythonhosted.org/packages/9a/69/1f0cbc2fb22ae6082d094f743d1b8945a3f36f6089cb95f42b7ee348cda7/hf_xet-1.6.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0e6e21fa3cdfcdcd76748564bf593870a5e013f47d97cf10aed63aa222cff5b7", size = 4262538, upload-time = "2026-08-03T22:33:05.287Z" }, + { url = "https://files.pythonhosted.org/packages/d1/3a/4f4f2301ade26e404462d3336fa11f7958d914cabbabdd6e03c3c5d5658c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4fc74352a17015bd0ee90038bc9efe38db894cde45f268b6712b04fce8cd0acb", size = 4460520, upload-time = "2026-08-03T22:33:06.81Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/311725e2a905534dfee2dcb5b08414f249147f1f12252bfc2bd24caa075c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8fb4f71cba6129110c3374a33f919001ff130488fc23553698e34cc1c2a1198c", size = 4675937, upload-time = "2026-08-03T22:33:08.616Z" }, + { url = "https://files.pythonhosted.org/packages/98/b7/8c59a66d15205024662f1d66968136f13893f96df1ddc5087e2e281fc95f/hf_xet-1.6.0-cp38-abi3-win_amd64.whl", hash = "sha256:fb4fadde1b2b70bf4c0c14a6dccbe7194b1c28947fefd5bbe3fed9d940676c3b", size = 4033128, upload-time = "2026-08-03T22:33:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/73/63/ca511b6f802f28cf3489b280fe77475bcca8de85e81a6299d7916b5b5555/hf_xet-1.6.0-cp38-abi3-win_arm64.whl", hash = "sha256:3dc3e35441ba395006af5aaacc40ef2e603c51ef46c3530b9156185f00935ea3", size = 3859359, upload-time = "2026-08-03T22:33:11.725Z" }, +] + +[[package]] +name = "hpack" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" }, ] [[package]] @@ -1373,6 +1684,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + [[package]] name = "httpx-sse" version = "0.4.3" @@ -1384,21 +1700,45 @@ wheels = [ [[package]] name = "huggingface-hub" -version = "0.36.2" +version = "1.33.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "click" }, { name = "filelock" }, { name = "fsspec" }, - { name = "hf-xet", marker = "platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, { name = "packaging" }, { name = "pyyaml" }, - { name = "requests" }, { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7c/b7/8cb61d2eece5fb05a83271da168186721c450eb74e3c31f7ef3169fa475b/huggingface_hub-0.36.2.tar.gz", hash = "sha256:1934304d2fb224f8afa3b87007d58501acfda9215b334eed53072dd5e815ff7a", size = 649782, upload-time = "2026-02-06T09:24:13.098Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/2a/484d112c0d8fc5f665d7b65137ac9cdb2953c982391598c3597968a12ee7/huggingface_hub-1.33.0.tar.gz", hash = "sha256:367be21a201db9523eddf8aeac7048f2602c1b308691c97640d5e72ed188007e", size = 1043548, upload-time = "2026-09-24T09:49:29.971Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/16/963096d224b80909432dc16561a615fd33d2d13beef3ce4c63fa25e40867/huggingface_hub-1.33.0-py3-none-any.whl", hash = "sha256:04e434b06e100eddbce9a6e817d72693a7884b10a79bd67ab48080d5c07eb899", size = 846435, upload-time = "2026-09-24T09:49:28.059Z" }, +] + +[[package]] +name = "hydra-core" +version = "1.3.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "antlr4-python3-runtime" }, + { name = "omegaconf" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/a9/fe2c936f36881e3197a8800f5bfba078d7846d54eb5d009b445eda1ee281/hydra_core-1.3.7.tar.gz", hash = "sha256:d4ff629c7e23b64a9db5fe5b027cf7c55b633b737f03be51ef41b77ed016f75e", size = 3278662, upload-time = "2026-09-14T18:02:55.528Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl", hash = "sha256:48f0c8eac16145dfce371e9d2d7772854a4f591bcb56c9cf548accf531d54270", size = 566395, upload-time = "2026-02-06T09:24:11.133Z" }, + { url = "https://files.pythonhosted.org/packages/25/0c/21890c10cc94da917c8e47efc1c55cd3e56a1d25ab5947bdd35abaf9fdc1/hydra_core-1.3.7-py3-none-any.whl", hash = "sha256:83606c4a05c770f1e026e0489875f28989829d151799eabd8cfa8cbe82164515", size = 168790, upload-time = "2026-09-14T18:02:53.661Z" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, ] [[package]] @@ -1561,6 +1901,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" }, ] +[[package]] +name = "jaxtyping" +version = "0.3.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wadler-lindig" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/c1/091b8852bd7cbf50bd655543c8506033cf4029300c67f8c176c1286879a9/jaxtyping-0.3.11.tar.gz", hash = "sha256:b09c14acf6686feb9e0df5b0d8c6e7c5b6f8d36bf059ee54cd522a186c2ef050", size = 46489, upload-time = "2026-06-13T18:35:23.167Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/38/c66bbdc5047f4776c2bd3e47e5295a350e3fa44d5b8942105e71c2a876a0/jaxtyping-0.3.11-py3-none-any.whl", hash = "sha256:8a4bedc4e3f963fa82df41bd13c7ebc2bad925601eb48614c65798f21329d4e3", size = 56593, upload-time = "2026-06-13T18:35:22.01Z" }, +] + [[package]] name = "jedi" version = "0.20.0" @@ -1973,6 +2325,116 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, ] +[[package]] +name = "kiwisolver" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/07/bd78e6a8fae171ea041ef5bba3ed21a003522fa088834b069b1909981f30/kiwisolver-1.5.1.tar.gz", hash = "sha256:f1303ef2eec81262a4b708c3e858afe58d7c75ad91c1c05266eda7673369859a", size = 104395, upload-time = "2026-08-28T10:28:27.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/9b/65b302742389c6f96f2956bef5decf26011309feb2fc5d79613af18adea4/kiwisolver-1.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:63fb7294b768f444eb4b068965f2662f28c2fd4161e23bd60fcf3ff27b74c046", size = 123876, upload-time = "2026-08-28T10:25:27.44Z" }, + { url = "https://files.pythonhosted.org/packages/71/74/c21f339956f6f691b2ed7e31d5f3ae767304df6c460192739fc830853051/kiwisolver-1.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0ebdef3eae5336568147c39a55be6a2036ffde53faa9ca2d978989ae7c2da12c", size = 66487, upload-time = "2026-08-28T10:25:28.728Z" }, + { url = "https://files.pythonhosted.org/packages/84/e5/bdb34e21523e01dceda064d63713f3bdec91388af24fba1eca7ea5e85864/kiwisolver-1.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1798e83840c3f627246104c4d8a9639c60fa068adf9ce92b61791781fa8a68c1", size = 64660, upload-time = "2026-08-28T10:25:30.071Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f4/dadfec469313c7f428efa7e84b4aba9732f813c13ea7131a24b7b008ef57/kiwisolver-1.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34633ecf50d16187ab8e5528b7a2530f2feb4e23f300db4672538b51cfc5cd38", size = 1477929, upload-time = "2026-08-28T10:25:31.495Z" }, + { url = "https://files.pythonhosted.org/packages/6f/35/09c58daac34e6f6ea5c6dee0094b422118e5a7c265586008a95fd135ac5f/kiwisolver-1.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d27c2123977cb9269c30a49ba45f03a4323017ef693e19db4ec9dbe1299a3002", size = 1278499, upload-time = "2026-08-28T10:25:33.375Z" }, + { url = "https://files.pythonhosted.org/packages/19/32/739765e24fbad29d13f83e546ea4abc215a78cea9d677ca09025b027724d/kiwisolver-1.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6a797a1cefc8b9c93170db580337e1fe3d011ad18b1299943231279406342048", size = 1296677, upload-time = "2026-08-28T10:25:35.059Z" }, + { url = "https://files.pythonhosted.org/packages/df/32/03304d1010e2cc45e5b3b52cef7e43fed3a2a5cd6c87a89b4a88e1d85b5d/kiwisolver-1.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2551cf9917af48ee7c4b29cc82320489508cf96fd26a51f6fc124de661cd44c7", size = 1346037, upload-time = "2026-08-28T10:25:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/3e/57/4c49377bfd274450dd72ecaa13eaac32ea804a03363e4d1db0c5aa999ceb/kiwisolver-1.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:38f6e0deb4d0a4615efe0c4efc5990b06ae450ab50a0b321c0b078b6d238c083", size = 988248, upload-time = "2026-08-28T10:25:38.299Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c3/38df144a08b6c5d75ca4504e5cc3141bb3bfef64c04f4ef48204f42711b6/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bfd1de989b3330420e29de39352f5c049905c9e3ee67233a50d550e3d652c148", size = 2228722, upload-time = "2026-08-28T10:25:40.038Z" }, + { url = "https://files.pythonhosted.org/packages/e7/11/3221838a89cd64d9b386353e000cd8a296069a20fbe3584507fdfd5bebae/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1209042a623ddfda5497e4066c7b77651dde8e1d3a9dd97599dc7e97f3b9b78c", size = 2325216, upload-time = "2026-08-28T10:25:41.699Z" }, + { url = "https://files.pythonhosted.org/packages/83/d4/075c219230697bb5db910d37262b9bacf880f92b4811a02ab81ed073a253/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:26e8268480be5061d509e29669d59103c067a26377a56491630ece11762e3858", size = 1977689, upload-time = "2026-08-28T10:25:43.559Z" }, + { url = "https://files.pythonhosted.org/packages/bb/08/1d219c3c2dd960983d0d4da623d916e9de6385df2b0bab3d1af0e9b8fccc/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d79308fa689fac89cbcfbd4dbfc80b5f95c54c5a7fd4d194be221f9d33d026e6", size = 2491443, upload-time = "2026-08-28T10:25:45.242Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d3/024208ec1079d273f1047468d1bdffbf38bb75b7b268090fd3a0301b9d9a/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b03af77d77e50edba2030fd5f7c352ff209314b09030a3cba7c14edf9a09a444", size = 2295200, upload-time = "2026-08-28T10:25:46.984Z" }, + { url = "https://files.pythonhosted.org/packages/6e/7c/7b210498f9f92e1cd7855f260fa69ef056881087b199ee20c208f0e4189a/kiwisolver-1.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:06a6917674de9e0fe3f66f5430787f59a9f2ddb64af9b714eaec547e29ef5c19", size = 70748, upload-time = "2026-08-28T10:25:48.444Z" }, + { url = "https://files.pythonhosted.org/packages/94/61/ef0daa157c8bb23672f7423e0d14c39db1dc6ef8ed47e6bc54c9c1bef3bf/kiwisolver-1.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:ad8b9671348d7c8716715652ae11f85ed0eb99e265a2df2ca490577d69860b2c", size = 68324, upload-time = "2026-08-28T10:25:49.81Z" }, + { url = "https://files.pythonhosted.org/packages/08/c1/88018321d976f53c421e379c43bc6993e70ce0c8a3ec5edc4bfe102257f6/kiwisolver-1.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b6ae6a0328f0bc035741820fdeecdcd67bf4694eee03972e843663107122f450", size = 62272, upload-time = "2026-08-28T10:25:51.02Z" }, + { url = "https://files.pythonhosted.org/packages/85/d2/712bc17ea4f1d216034928069d612defbc6c95a471c55a7203a39faecb1a/kiwisolver-1.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:886fc26012f0e8b5f69d1cfe6d711f6b11f194621539bf8e6bb1c25c5dc82724", size = 64481, upload-time = "2026-08-28T10:25:52.22Z" }, + { url = "https://files.pythonhosted.org/packages/1b/e6/6c5380d676f43b6d918033962ea5e72360ca69e5a404154bc496b598ffdb/kiwisolver-1.5.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:aefe930d113798330e9462f7874542977869c0613cba3262e2de3a8d5dee8f3a", size = 66260, upload-time = "2026-08-28T10:25:53.387Z" }, + { url = "https://files.pythonhosted.org/packages/92/6a/7087f5822cc8bb272679641404b1966a42504dac9ee74e2b33840475a0aa/kiwisolver-1.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5ca5aebae78a0bc13c1943af4af615d4966c5b650b05d5aa83b50e427196fee", size = 123876, upload-time = "2026-08-28T10:25:54.644Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4b/9f385087ca09ee5ab9c09c6832561a7d2f7c78d3e5661d511e669f70e439/kiwisolver-1.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1ed0f5e49d0ceff8b72190824d9e59c062fbbc02c231b853112c78474b3f5ec2", size = 66487, upload-time = "2026-08-28T10:25:55.899Z" }, + { url = "https://files.pythonhosted.org/packages/47/8b/40d33ffd2f378094ed462e9a9a0907e59d4de9845e65a59561272da350d4/kiwisolver-1.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:77a4c8187a5948d7f8795adb765a3c7b553d07d86d88e43038fc32fc1fb9a3f3", size = 64673, upload-time = "2026-08-28T10:25:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/ab/43/86aacc027959108b4c66eeae8b73cedb057dfa6eb3a335d05ad65197081c/kiwisolver-1.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:74ad5c3dad54a4641b4c28cd15ded70899d04459c6c7aeacafea716be97cce6d", size = 1477992, upload-time = "2026-08-28T10:25:58.479Z" }, + { url = "https://files.pythonhosted.org/packages/ed/40/b1d0369048c79733a32c8abb0f2718532e6630641368e33a81384246e844/kiwisolver-1.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e46b23a2da695c364124817bc01d970effd5483147f8d66a6a7167e3f6b851", size = 1278821, upload-time = "2026-08-28T10:26:00.121Z" }, + { url = "https://files.pythonhosted.org/packages/61/a1/fa71c1792272ff9461432715ae60ffb7e11a4d7ac3bf68961b9cab6c60cf/kiwisolver-1.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75d9b1cf8258462dbdc1eeda718c96ea7f079324c09067f6daabfcf37712b7fe", size = 1296805, upload-time = "2026-08-28T10:26:01.868Z" }, + { url = "https://files.pythonhosted.org/packages/a3/49/3f0bd94af8e06ecc47eb834b195a06f05d48711ceb2352c56d6835160f0e/kiwisolver-1.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fca690b00c4c48f6c2a547b0160ed511357093a4e4c9b47e0fadf3128066d89", size = 1346109, upload-time = "2026-08-28T10:26:03.59Z" }, + { url = "https://files.pythonhosted.org/packages/a4/44/3afe6ef9cf06d61220953a8963e94eca978491be1d9547cb01d82a1efa08/kiwisolver-1.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:876bbfd276473d3daffe30e8c975df4ed9429967b41a6cb362dbb5155b6f13ad", size = 988252, upload-time = "2026-08-28T10:26:05.306Z" }, + { url = "https://files.pythonhosted.org/packages/e4/30/a12bd7a7285a211e1747c3eec77b8c614dbfcc1dad942f7611a1a6921ae5/kiwisolver-1.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f942903fde7363d1d879057ec5de01310efda2597161784d752fa9953a01a71a", size = 2228846, upload-time = "2026-08-28T10:26:07.312Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6b/233e2958abf0dab7b18d07e52f286e02e519d7651bfbbe97af9347564109/kiwisolver-1.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c90d3022d8a94778939cda8638c6c8da8fa757b8958dad7ec868ce29c87681b8", size = 2325583, upload-time = "2026-08-28T10:26:09.093Z" }, + { url = "https://files.pythonhosted.org/packages/42/8e/7673060a27b01405b580058510adef34687069d229800239f5e44682d4d0/kiwisolver-1.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8a34616dc2521cc8dc1d7d081734da63539f021ac0450ce950908340c6e7aa2f", size = 1978221, upload-time = "2026-08-28T10:26:11.127Z" }, + { url = "https://files.pythonhosted.org/packages/3f/7b/1b882fc1a8b4a0bb8084e7d1d85004116d08c92c0705d31f2928dec607f2/kiwisolver-1.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:8bf4df63592c2a66b4f8edc5df2544998c288aa02f96ce0acd880cd1de8c8127", size = 2491819, upload-time = "2026-08-28T10:26:13.348Z" }, + { url = "https://files.pythonhosted.org/packages/39/9c/426deb49e62c5f69464b64bbeca064d3b758a7506b8913d986ef34f4619c/kiwisolver-1.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d09037ca068d784ebc4aec290ef952ca27ac15dd9c0b5801a88c6e1096b83e6b", size = 2295520, upload-time = "2026-08-28T10:26:15.042Z" }, + { url = "https://files.pythonhosted.org/packages/f5/22/deabbb3ad6d918d74b7831b2d8ae7151b09d21c87974e5ee8a456f58c94c/kiwisolver-1.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:dc23390afe9f4ef9ac3bcc72a03a56eebbde03f4c571a32cb38f859cff9a6524", size = 70758, upload-time = "2026-08-28T10:26:16.504Z" }, + { url = "https://files.pythonhosted.org/packages/b9/71/02fa5c2fd92068bb8952847e70ee6c5cb280e7febe11653d17812acc53dd/kiwisolver-1.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:186884a58486651e3c217b6acea0a53eaa9498fdd472057c46f2f0fb5c25aad5", size = 68329, upload-time = "2026-08-28T10:26:17.658Z" }, + { url = "https://files.pythonhosted.org/packages/dd/87/2d5dfad0daf17dcc18d98c48ed2332fc3f051cf599e60be6182a30dd4cf1/kiwisolver-1.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0324cd2567259b7a095f6cf18a52b0ffc6f3de9e69528ff1bc0e7a37bd43ff1a", size = 62337, upload-time = "2026-08-28T10:26:18.778Z" }, + { url = "https://files.pythonhosted.org/packages/08/c8/83e1624f15d6262b470dbcc80b09979fd4d5b2ea3ddfc6b6e3327e235726/kiwisolver-1.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:74ea337e0ec3f6f342a36a4f1b5cd94dd9affddcd28ba9aae2905af932ee8c6b", size = 64513, upload-time = "2026-08-28T10:26:19.909Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2d/827ec30eb07f528c08d8459ffb318ae91a56d793ee8acbea8b491f0ff906/kiwisolver-1.5.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ee9df1f0d77b9c6e94f4ac0fec533fbddd5ea3a327807f18d7b069ae019ded80", size = 66287, upload-time = "2026-08-28T10:26:21.078Z" }, + { url = "https://files.pythonhosted.org/packages/53/11/5c43a562529dad8def4b81e5e1877c612a7e0298105a5939b3b409d2079c/kiwisolver-1.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fc271a6f0a2126958f4090e5507b9da5848927dae331f8f763bd4aa642b3d2cd", size = 123940, upload-time = "2026-08-28T10:26:22.475Z" }, + { url = "https://files.pythonhosted.org/packages/64/db/9bd6c505c95128c258a55236bfbb3a7a3fb6023f863316b6d7d9f3c69052/kiwisolver-1.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9b3092d8992a1d69b7a59c3e39f35e1b9be327a17f68a7c35fc17329e337d6f2", size = 66493, upload-time = "2026-08-28T10:26:23.743Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f4/b3007a3ed5c9be73f81161140684cf7d9bdb9c4b632f5f484d2a1c713fb9/kiwisolver-1.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c2306e8bb53601979fcb3fa09cc65e031876d9ae01eff2fcbcd7a84ef94d5bc1", size = 64720, upload-time = "2026-08-28T10:26:24.95Z" }, + { url = "https://files.pythonhosted.org/packages/a1/13/08188f0cafa3a800403e4ff62b9aad4e7a17f9c4c7e080dc8f18c64794cf/kiwisolver-1.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:18a0cfb124546a4c2e6087c5f3029c7f44b37c85b142e0ced71f73a7599ac208", size = 1475867, upload-time = "2026-08-28T10:26:26.393Z" }, + { url = "https://files.pythonhosted.org/packages/8a/3e/053bdc3c9abdb8f2606225eda398adca25c0c91ab90add8222a69db65ee0/kiwisolver-1.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34ec467940442c9943016fb2d4c81d1ba84351eeca2f1a78f8bc87f1ba0d414c", size = 1282865, upload-time = "2026-08-28T10:26:28.118Z" }, + { url = "https://files.pythonhosted.org/packages/5e/64/a44c341b36b610588cc2f1e89b3cae072a3119aa8be578e90987cd640751/kiwisolver-1.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a83ee7107df13abe42a54a6654670eef9bb39425cf2e27f65e0007465e1286ab", size = 1300865, upload-time = "2026-08-28T10:26:30.125Z" }, + { url = "https://files.pythonhosted.org/packages/60/5e/7e7d716dca38c714478b741257a5b4a321d9932b8d851551a136dcaf3984/kiwisolver-1.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bebb89489b279b2f5661bbbb2abcc87bcd4a46607bb4a5c966f04f1db6b8df9a", size = 1348071, upload-time = "2026-08-28T10:26:31.829Z" }, + { url = "https://files.pythonhosted.org/packages/10/1a/2b98fdda8bf45b7be317e48ed12393d44334394d315c74b81f4a14c0e31b/kiwisolver-1.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:509735237ae0d849e8a843551d423d2500d2e0a9ac1611a145658b29c0fb9f85", size = 992191, upload-time = "2026-08-28T10:26:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/d893f5ede0e50f9e3fcf01f6015f42ec7d9cd221e26772701fe4a98745f9/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:824c3d763a05ea9e9003610145186b0e9848c7584a5575c79bac5a8e7cd80bad", size = 2233854, upload-time = "2026-08-28T10:26:35.282Z" }, + { url = "https://files.pythonhosted.org/packages/b1/82/f85f6279555a6ee1639fef7bfe83adb037a03e11a6fc9eaa54b8d0380339/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1fff05e239575b1481b6ed1a782f6fad616efbf1f0b1f44e6e85c4dfe426e483", size = 2330621, upload-time = "2026-08-28T10:26:36.9Z" }, + { url = "https://files.pythonhosted.org/packages/56/31/e11aea078f66fc2fffcc179d38ca90d9da97652a241b64519169742ba46a/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0627b9bceb9c3cdcf12b8a18655eedfed2692b038df27423383c120d0b7dc2d6", size = 1982848, upload-time = "2026-08-28T10:26:39.01Z" }, + { url = "https://files.pythonhosted.org/packages/af/ea/2956b63bf5140ca46aa2c2818e6aa03e2d5754dd2fa41db1c6b28922940c/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8a708a47ade1fe19e8371d5da076bac0dd4b0a5a7985ad6c637f7f7e361b6baa", size = 2494850, upload-time = "2026-08-28T10:26:40.837Z" }, + { url = "https://files.pythonhosted.org/packages/11/d1/3829542258d8b3fc0898d221e7ef0e2c83eca0d348709bb8dbe54f3d4005/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:007a5553dfc4f4e8d184f588a0200e2cd4b63a59cc8796df3c39909e679dc7a0", size = 2298067, upload-time = "2026-08-28T10:26:42.803Z" }, + { url = "https://files.pythonhosted.org/packages/4e/0e/49522e1ab5788cbaf63a26fbd3b851f9028616828c961b8a31b35cb96df8/kiwisolver-1.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:f4167e87b397f273dc2356fcf1eaf50a6bac51e6105f45103ef7129c8efb0255", size = 72282, upload-time = "2026-08-28T10:26:44.268Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b6/22e7ca5315d363e6f81c9f37c9472e12e7b298731e77c0428e6a911a2c39/kiwisolver-1.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5c490db2168a508088f59140dd392556a54b8bd1048fc6383c8baff13c359673", size = 69855, upload-time = "2026-08-28T10:26:45.725Z" }, + { url = "https://files.pythonhosted.org/packages/30/8c/03a9cfbe871964c8758a816eb03ac96c806da2795a9a7cd9bf9648bfb594/kiwisolver-1.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4d4ca09bf13cff792b1884f64b98ee6c2467930d632233be25c56b442d99f10e", size = 126289, upload-time = "2026-08-28T10:26:47.023Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e3/14ce3041ca79dff9c9d884ca00c7bf32374e76028a865a9ecd99b4f5a517/kiwisolver-1.5.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:44b8faef94f1857e77fa0238f3390ff1ac51d2ea20a487e2e452a59fd2b5f5ca", size = 67709, upload-time = "2026-08-28T10:26:48.268Z" }, + { url = "https://files.pythonhosted.org/packages/af/c4/45030471a66ec8ef042e9f96ffe1d522c9ab12da180186a0898966fc1385/kiwisolver-1.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2ae70bc59790d2af72a3f76f24b272403e135070340281108b447cb77ea70819", size = 65909, upload-time = "2026-08-28T10:26:49.523Z" }, + { url = "https://files.pythonhosted.org/packages/42/73/17dce073a6ae259bb32cf9d686c4079d2e538868bc45967462bf33df914a/kiwisolver-1.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:43844c1a7ad6d723d5b5b4c4fc7f5bd399c40e288120d16257c7c9e8765c6e85", size = 1584907, upload-time = "2026-08-28T10:26:50.933Z" }, + { url = "https://files.pythonhosted.org/packages/8c/84/ae3c75909f507283cbfcc7e916c7e822579ef962020b97e6882b27b4478f/kiwisolver-1.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22d5e5aaad6be121f2515765e3b1c444352cb8eb4c86510801db8f2e50757316", size = 1392474, upload-time = "2026-08-28T10:26:52.638Z" }, + { url = "https://files.pythonhosted.org/packages/34/31/8bcc83caad5bce8fa4577152389848bf6bc110e51e573a2b4e7c2aa34c89/kiwisolver-1.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3fa5855898f6d3d01b72ccd48a2d65cbdee301251603fefe34e2025bddba219c", size = 1405246, upload-time = "2026-08-28T10:26:54.248Z" }, + { url = "https://files.pythonhosted.org/packages/55/72/220345537d790cf4ae54f8acfff4b5cc2468e0702a384d651cf7a771c63e/kiwisolver-1.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d66a64dd5dec136040ec2ae94aa026a912ee60fdd45bc28d3db30037fd809e88", size = 1456099, upload-time = "2026-08-28T10:26:56.042Z" }, + { url = "https://files.pythonhosted.org/packages/cd/10/3725fd2398f66d18c34b4e0f81a8d03764cd4f4f089f58a527f0b4428086/kiwisolver-1.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:9e51c119992ea8820706871c30a4642ec76de20ae82f9b50b9a45517d8e9f810", size = 1073695, upload-time = "2026-08-28T10:26:57.658Z" }, + { url = "https://files.pythonhosted.org/packages/86/cb/28d6e09e66b93e4588b2e6b7d84d020ccefea09e2f4de788510a07efeab7/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:70ed9a45c7484d2b30cdacf60d220f494a1763b9fec1ad03285c6553fa0889f2", size = 2335355, upload-time = "2026-08-28T10:26:59.202Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b3/a0f31d5e4e40af7dc97c36b8a74fdd3a36cf3c8bbd098da9a23466ff6a94/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:98b208a7cc42c803445ef551d6753cc42a5ea13e9cab1ee66cd8b9cb70195330", size = 2426524, upload-time = "2026-08-28T10:27:01.181Z" }, + { url = "https://files.pythonhosted.org/packages/2f/c9/728f63bd58c72cafdc79fc306abeeac7391bec03b757a48dadeb30906521/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c6834b92dd2428e2dd85ef3d85f723d3c12f20aaf43a2ddd4f944ca25d833408", size = 2063430, upload-time = "2026-08-28T10:27:03.06Z" }, + { url = "https://files.pythonhosted.org/packages/84/df/ce188b96f92f9a2c958231da140768918cba53c9713dc887b82f85462118/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:5d142e352eb13facc7dd047489aebdff6ba78576c239f1ea04931979caaf0567", size = 2597513, upload-time = "2026-08-28T10:27:05.072Z" }, + { url = "https://files.pythonhosted.org/packages/69/d6/76947c8203768968382e5bd74d9cc95654746703a61ea53015f2c74a2e06/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9b1c4900736e489a812c529100de4b8fb617d4db075e931e213c57424b83d9b", size = 2394488, upload-time = "2026-08-28T10:27:07.423Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d4/14b21e4eb203c4d15425e8b6a2c625a320b4a1f2f7557eead63ffc30ffb7/kiwisolver-1.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5978c3340f16a35c30f8ab2fa7bcf559973c55f1a5ef6970e1f621acf3c4db13", size = 75404, upload-time = "2026-08-28T10:27:08.892Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f5/53157899fc7f45f76421b77b99eb1639dd0f83f26ff9d76300c96bb4a3b0/kiwisolver-1.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ca307d6c259e5c98d3cb9ade55342b47a6839762caf2536f3d7b46ee660cc82e", size = 72946, upload-time = "2026-08-28T10:27:10.944Z" }, + { url = "https://files.pythonhosted.org/packages/75/62/f786c3a27f181fa339d851a77e266d208e776b9883cabc40a5b041a31b5a/kiwisolver-1.5.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:bb7c99f0673c03017a3ee01e54a5c2617a05468b11eabe513b0080e063ed95b1", size = 62420, upload-time = "2026-08-28T10:27:12.308Z" }, + { url = "https://files.pythonhosted.org/packages/02/cd/58a91ed25fbad0facdf503297b03768efab04bdf3141e5e3b49a34be7443/kiwisolver-1.5.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:0d8924877ce22e17326a99a418c3c82037da078df3c6a260b13eca677444e6e7", size = 64577, upload-time = "2026-08-28T10:27:13.502Z" }, + { url = "https://files.pythonhosted.org/packages/f6/5c/d501ef5a0958b226eac28306d24d5e5f114be0ace50e19cabae7b6b3b197/kiwisolver-1.5.1-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:534f02c1abb31ed6dbd3515545285c330b2f12d00fdb1fdb71658b9ca5a13a6a", size = 66284, upload-time = "2026-08-28T10:27:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a6/8fbecaf4fc18c02f31f05e47a84c010a80e3ec391ed2f0bdade1d62b5954/kiwisolver-1.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:cea20da04494e662b83c872683bf4ff2345206043d036315ed0e924b652e7294", size = 124031, upload-time = "2026-08-28T10:27:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c6/bf3090d2983b4204347cbdbe952116e7c3b2abf62b4e33e50167a13e75ee/kiwisolver-1.5.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:7fd82debf43c6acd0a94359d232f6bb516ee13f269a7993736a9ac9f988bb5d9", size = 66489, upload-time = "2026-08-28T10:27:17.506Z" }, + { url = "https://files.pythonhosted.org/packages/85/de/562dddef55fdd7c291da8626d6619e72b5fc0870e6ccca0e149a5731e7f3/kiwisolver-1.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:18170a77ddfecf40ec60d0928268dc95880c881864e015a8f34094ed18b9b9ad", size = 64806, upload-time = "2026-08-28T10:27:18.673Z" }, + { url = "https://files.pythonhosted.org/packages/89/b1/ba7b9c0164ce1cf62bf2872db63b8483289cf0f3110d6f9390eb09e409ed/kiwisolver-1.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ca7f6fe0f37ca978a1e5eb7a3a68e6413f417e78e838324947ffd420202b198b", size = 1482211, upload-time = "2026-08-28T10:27:20.039Z" }, + { url = "https://files.pythonhosted.org/packages/13/dc/34da54efb4976616d45c20aae32d70e89d6e7395ed908029154d1609ef22/kiwisolver-1.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b973887ff782cfd6b67c9904ad8ca542e0bc5e4961503408b423b5a688b4d38", size = 1283739, upload-time = "2026-08-28T10:27:21.82Z" }, + { url = "https://files.pythonhosted.org/packages/5a/3a/30ffb62bee646e266e98a1b5cd276d9c75b6116fbfcb87c1190838c1b6df/kiwisolver-1.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f76fc85bd054c806960f917ec0f329e24e436f1712267d90588e4c39890caa63", size = 1301681, upload-time = "2026-08-28T10:27:23.876Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8d/13c70be22a8506880b35fdc38dca36629613bc493405c79f4037f2cd2bb9/kiwisolver-1.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:828f75af2b0080c8a972e75f649ab46af008e92c6104a57a759157200b835b75", size = 1349159, upload-time = "2026-08-28T10:27:25.899Z" }, + { url = "https://files.pythonhosted.org/packages/82/0e/993972b8ec6767f47cd69818fb3a5ff14510557d29f7d1a839be7574fa1b/kiwisolver-1.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:431dc224a1a92a5c8f582d96e505196a3b5997a7271076678da2dfde67b77e9a", size = 997613, upload-time = "2026-08-28T10:27:27.507Z" }, + { url = "https://files.pythonhosted.org/packages/36/82/ca26eddd2eda2420dfc56693449c1f821f78b485da9cbde9904c03af3f93/kiwisolver-1.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:61e9a64c7635095a6bfe483e2ff055d437c59bd45f3617a228b37277f0185d62", size = 2235109, upload-time = "2026-08-28T10:27:30.113Z" }, + { url = "https://files.pythonhosted.org/packages/c7/84/97d920881e10840b8d7c7185620298e3e4c88820b05514e3a15a258b08a6/kiwisolver-1.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:3c24cd69455e1b00ddf770c13b6e2c33e07d6dc3f2d34add0bf9277c5c6bbd46", size = 2331207, upload-time = "2026-08-28T10:27:32.795Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ce/34d74b8f25acc58800f4c09268371e8d6159cf0f1206f1e4dc7835629b48/kiwisolver-1.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:27add358abe374ebaa3b8763ef380bc99051b5a4b18d94878366a9e4f59efef0", size = 1986696, upload-time = "2026-08-28T10:27:34.628Z" }, + { url = "https://files.pythonhosted.org/packages/0e/01/f892644014612527aef7031d3306a2ffc60b3cb044f802c1561f8e5e14f3/kiwisolver-1.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:255605693a483db7bd5c79f60437f7bf658f7f520d61aa42722e32257c941951", size = 2496400, upload-time = "2026-08-28T10:27:36.818Z" }, + { url = "https://files.pythonhosted.org/packages/32/6d/d8284e66e697026536e5f418b9cfe56567bffd3c775e3ecfbae373605854/kiwisolver-1.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:7d38b0c279c3032e8c9cc013b405c6df8e1668dbf15465779aa7f15f61201812", size = 2302967, upload-time = "2026-08-28T10:27:38.803Z" }, + { url = "https://files.pythonhosted.org/packages/46/0a/69a355e27f32ba50d5b6369949b6a1702e122f5277c89bc76d452b81c1c4/kiwisolver-1.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:958254518717542d02d0688d0d20cbf771da5e415e6f49543f92481c850a4540", size = 72283, upload-time = "2026-08-28T10:27:40.491Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d8/7a95be90c33dcdd52204d4aa6384d731443225b887283bbd8b61e7931f6c/kiwisolver-1.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:da3275833be0edbaf4830fae08bae3dc7219f40ce0c37eaa6c25825957e06612", size = 69860, upload-time = "2026-08-28T10:27:41.835Z" }, + { url = "https://files.pythonhosted.org/packages/31/f8/9bc493e7f5707788ba7f621902c68f82dc3a7ba03c78fbd337b026cef1ed/kiwisolver-1.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:470d420f98d368d6f010633a20659b544c5fdfa5329e6b70219f2ef08fd4a7ef", size = 126336, upload-time = "2026-08-28T10:27:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d1/82/3aea86b3f99712db825e9ac5631bf99571e818a8b8961ff98cebd798413e/kiwisolver-1.5.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:83f78128fa28705fa85d01c59771c72fe81c11bd0e6155edbb9f818983a7d761", size = 67698, upload-time = "2026-08-28T10:27:44.613Z" }, + { url = "https://files.pythonhosted.org/packages/84/7d/8daafc5d2e7f9c47a4f78f8865d86d2a9cf399c2a85f86c44a993594410c/kiwisolver-1.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:9506e892bcc3b409831d363c6f53e5985e1c8d1f6f6b0256d00358684ff85378", size = 65945, upload-time = "2026-08-28T10:27:45.932Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c9/51dc974d9130da70a8c47a96160123443d387ffe1b6b833d6f91d9429339/kiwisolver-1.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cea90547bfd93807e0013a004dc76552be44fad3bc1cc2b38610a9e889ed098f", size = 1588030, upload-time = "2026-08-28T10:27:47.678Z" }, + { url = "https://files.pythonhosted.org/packages/e6/86/f3e1a730e7a995149d8d3ff9e313b6d8a17b2cf1d98a8eff139dc30463fb/kiwisolver-1.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8e4d953faaded9ec7ede36824e9814082d22d4c7b1eafbfa079ecba8cd0d076", size = 1390760, upload-time = "2026-08-28T10:27:49.676Z" }, + { url = "https://files.pythonhosted.org/packages/4f/3b/8ba25a2b5a0d2375e046f1b72de5179513f0be95aba6e7b094c89303929f/kiwisolver-1.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e9c01d3dd7ceba4d1d436cc021d40d592466e40b9bc7f5d83dc4e98a5c9cd8c", size = 1403279, upload-time = "2026-08-28T10:27:51.242Z" }, + { url = "https://files.pythonhosted.org/packages/18/d0/278d5cb8be812740027d5ca0a7eda0c375488a88d6dce0fa60fcc2591ad2/kiwisolver-1.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37f801b5d7cc0e5a548921308e059fd2b057bb42972b591cfa3049f95423c4ed", size = 1454429, upload-time = "2026-08-28T10:27:53.213Z" }, + { url = "https://files.pythonhosted.org/packages/94/37/bcbab41063ec284c1d200efe5087cf087798c2f8916960aa8a20dd303290/kiwisolver-1.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:e68e151428b5384f766cd25739bf77c7e4a3dc93b5ded7a12118d9fbfdf78ab6", size = 1073225, upload-time = "2026-08-28T10:27:55.089Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c5/ab79dcdf5ae28909a51210ae0a1c579e97ff997b3466414f0d04c0994583/kiwisolver-1.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:8f8fddb8e323bd6eee4e54e69a39243beab22689070f4c66b472c4cc88bb89d8", size = 2334335, upload-time = "2026-08-28T10:27:56.78Z" }, + { url = "https://files.pythonhosted.org/packages/8d/8e/71d047468a189041d9c93f3b76844b924f9793b188c44bd149fa258912da/kiwisolver-1.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:3cc210010fd2f438a3ed430b45f1b501fd13a8618bf984dc2c5ce5b69b78752e", size = 2424982, upload-time = "2026-08-28T10:27:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/1347461bbea6d0e1f0580b94ef603b18e72c2be5f667fa1653867361a00b/kiwisolver-1.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b5664603a253efd3a75716d793d1d3a6a82723b61dc6db767b2460bbbeec4c0f", size = 2062857, upload-time = "2026-08-28T10:28:00.407Z" }, + { url = "https://files.pythonhosted.org/packages/1f/87/c7f0976c9cd0d127643351bc0c9929e0b8899d7f49d4ec238cd909e39c42/kiwisolver-1.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a7b85b2cc6ea45e5f7e8c9a30bc9fabd47cda09106cbb4b967335c3e6c43b69d", size = 2596022, upload-time = "2026-08-28T10:28:02.183Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9a/59a6f6ae6f938c15076be2c21b6cedea973d71bb1349ec84fa485fab82cf/kiwisolver-1.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ab620eb663952455271ac37f9aaad86b73c969c02f11f53cea405b38e96a4300", size = 2395634, upload-time = "2026-08-28T10:28:03.977Z" }, + { url = "https://files.pythonhosted.org/packages/e9/2d/8982c1fb7926da5bb7ed60318c3665b5c3f941447271ac982960a11b8637/kiwisolver-1.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:cb6fae641357ed2f6e533c0d3c6504a4a5703621a50c89459e46051d56b61140", size = 75379, upload-time = "2026-08-28T10:28:06.307Z" }, + { url = "https://files.pythonhosted.org/packages/07/78/ba7b6dfa1708b82b373ac056928a30c545d5c1a627df9839dcec3c6c1881/kiwisolver-1.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:b390aec180a7c054919c04898835e1c77bced23ea8383eb2c570213bf25d1a86", size = 73011, upload-time = "2026-08-28T10:28:07.578Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c4/1407df7512a5b36cc79840e01710dc575733c461b13ab866cae77eaf87f3/kiwisolver-1.5.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:482676e5bd48d70ac99d9fc78863469845421e01184fa83f1f9366dc49f7e974", size = 134002, upload-time = "2026-08-28T10:28:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/16/45/c37a21ad5c0ab581a93c55ad544721aaa1f0ae94edb29c6a678a23d013e6/kiwisolver-1.5.1-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:072bdb15a3c19a5b5dbc8f8fb1f4e1884bf4f3507eeb4cc6334401274d37a5c0", size = 194292, upload-time = "2026-08-28T10:28:11.06Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c1/69f00d627949580e43d57af0aa465df46868d7c29801c137a55374101294/kiwisolver-1.5.1-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:a5a00665d1a0e26763a7338d7e911d4598fbc1d50dd0d6b7919b7dc6c5d6569f", size = 73362, upload-time = "2026-08-28T10:28:12.449Z" }, +] + [[package]] name = "langchain" version = "1.2.15" @@ -2148,6 +2610,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, ] +[[package]] +name = "lightning" +version = "2.6.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fsspec", extra = ["http"] }, + { name = "lightning-utilities" }, + { name = "packaging" }, + { name = "pytorch-lightning" }, + { name = "pyyaml" }, + { name = "torch" }, + { name = "torchmetrics" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/b8/fbf1469d917c3ed3b22e00ab3a852fb9a5f85b9dc7f7060eedc3449098e7/lightning-2.6.6.tar.gz", hash = "sha256:1c6329e241f8a85f29fedead89c1ee138b36caa08de60c260c6a7eaee81c8295", size = 659902, upload-time = "2026-09-10T09:41:03.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/43/7da7fe42f254ce57777d1a86eece3f86d1b3329fc568d48e2bc16c8b7ebc/lightning-2.6.6-py3-none-any.whl", hash = "sha256:2bcbd6ee840071cd076c7dc9953d7ac1040df92f1c5fa76583aae644f037ab85", size = 849265, upload-time = "2026-09-10T09:41:00.882Z" }, +] + +[[package]] +name = "lightning-utilities" +version = "0.15.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/45/7fa8f56b17dc0f0a41ec70dd307ecd6787254483549843bef4c30ab5adce/lightning_utilities-0.15.3.tar.gz", hash = "sha256:792ae0204c79f6859721ac7f386c237a33b0ed06ba775009cb894e010a842033", size = 33553, upload-time = "2026-02-22T14:48:53.348Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/f4/ead6e0e37209b07c9baa3e984ccdb0348ca370b77cea3aaea8ddbb097e00/lightning_utilities-0.15.3-py3-none-any.whl", hash = "sha256:6c55f1bee70084a1cbeaa41ada96e4b3a0fea5909e844dd335bd80f5a73c5f91", size = 31906, upload-time = "2026-02-22T14:48:52.488Z" }, +] + [[package]] name = "litellm" version = "1.94.0" @@ -2179,6 +2674,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/8e/71147c4f78ef8b0e46b585e7c3baa1bb635e79ff188a60feec2e0eaea6ba/litellm-1.94.0-cp314-cp314-win_amd64.whl", hash = "sha256:430127afdb317b98e2f3e4fab1c857fd72117292b5edd7d655e1aae51f213ef6", size = 20270705, upload-time = "2026-07-28T20:21:48.675Z" }, ] +[[package]] +name = "llvmlite" +version = "0.49.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/27/72ae94ea5c8f7349ec1c229d4cd058feb799cbd0833ad6d1b47c919b37b7/llvmlite-0.49.0.tar.gz", hash = "sha256:00f16db782f4a13c78c5804aedc434e46794a77e89999a168f9401106270e50a", size = 194467, upload-time = "2026-08-11T16:26:00.489Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/ae/3f699ebe3590e15e023a6372dd147526fd8ec398aacf9ceb844e854964a8/llvmlite-0.49.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:b541c8fac3450db7574d1f53cf9dff83f285bfed9d69bf81fe71fc2a7d4f97fe", size = 40479231, upload-time = "2026-08-11T16:23:56.773Z" }, + { url = "https://files.pythonhosted.org/packages/be/3c/e97f69c62a2d972066d9a2612ce1f3de313035ac897a5b9f787cad8b55f7/llvmlite-0.49.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6acba646d88abbc87d5c113a3d62c1fbf8b8fee11c6493f516803e30f21ae870", size = 59890658, upload-time = "2026-08-11T16:24:05.451Z" }, + { url = "https://files.pythonhosted.org/packages/69/e6/e942ee08605fc0526ff3854260c384d8315a5830e16c4c2a5aebc14dc9bf/llvmlite-0.49.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ec8ad805e7515cb8440a690eb3cef4d34acb29eef80b705ec4e1c1ad3c43c68", size = 58344481, upload-time = "2026-08-11T16:24:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/84/49/2a44871cac6b5a2fd4aabd68cfdaf6de9a5c7edb36dee5d47b77bda4b50f/llvmlite-0.49.0-cp312-cp312-win_amd64.whl", hash = "sha256:3a9c9e3af4e214acfefa4f73ebe7bc3fb35854a62b654edb3953f5ae33c08ba3", size = 41865543, upload-time = "2026-08-11T16:24:20.41Z" }, + { url = "https://files.pythonhosted.org/packages/7d/85/0b536a3c59f2636d9dd51dda832b6c1d0ffec37608429dedf128664918f1/llvmlite-0.49.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:039fa4054a06f537fb39248d4472284ca96be311a142ec09e69f95630ab469cc", size = 40479230, upload-time = "2026-08-11T16:24:27.295Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1c/ca8ba47b057b793099784475499771780ec46839f2782f753a7079d23520/llvmlite-0.49.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ddc7aecd4f56397ed6e8f120ec5dcd5a1a8f0e6032ca4af413462792d4dca2e3", size = 59890659, upload-time = "2026-08-11T16:24:35.595Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/9526dfdd33a923f33e29a18b8f9801ee7ee4b7397e88d28192c1024c4a75/llvmlite-0.49.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3dee64784201b64c13a8df62c48a4f4218858faaa65889866bb29bdc243c038", size = 58344482, upload-time = "2026-08-11T16:24:45.79Z" }, + { url = "https://files.pythonhosted.org/packages/96/7f/9f5afcf6476b228d6b170408f377a0c4f91477fc1fc91f8141088b45bf46/llvmlite-0.49.0-cp313-cp313-win_amd64.whl", hash = "sha256:a1b414dc6b164738ec39dd8987cea73829057b7dd92fc6d91b52838385fc1dd2", size = 41865544, upload-time = "2026-08-11T16:24:53.962Z" }, + { url = "https://files.pythonhosted.org/packages/25/d1/16599b8c9f21802448059482eab48a9e74086dc56b901a677ba355565e64/llvmlite-0.49.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:80a84683d04516bb51da1bbeebddaf2c2f558809c93078a8f91807909ae331f8", size = 40479230, upload-time = "2026-08-11T16:25:01.513Z" }, + { url = "https://files.pythonhosted.org/packages/3a/61/0b23849141a4c4e7091fcd158ebb45195896974bebca3e58fee7cad4b4f4/llvmlite-0.49.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4281a0171d66d2098adce4ba706b8c550b1b10718650f682d64cde16e84e4de5", size = 59890659, upload-time = "2026-08-11T16:25:08.733Z" }, + { url = "https://files.pythonhosted.org/packages/c4/92/628692b74b31e27af9ba7e8ba651941ee4956959d5478123c453f59aad4a/llvmlite-0.49.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b095f15fb12c4d90495df5b1a3772b4732cc408398b204a787dbedd370e09c69", size = 58344479, upload-time = "2026-08-11T16:25:15.731Z" }, + { url = "https://files.pythonhosted.org/packages/96/8a/412fc273521b02cbfe0b5f8ad56cc696385f6eaeecdb9e9ae6a90111d98d/llvmlite-0.49.0-cp314-cp314-win_amd64.whl", hash = "sha256:294e2f0b70aef8f92d0ae7b203e2609f08beb39437eee73de59a21669331aae9", size = 42986588, upload-time = "2026-08-11T16:25:22.534Z" }, + { url = "https://files.pythonhosted.org/packages/fc/15/f47cf45c00c8b165ac3d268502dcb21d900e86f27fd338268a66ce922ab0/llvmlite-0.49.0-cp314-cp314-win_arm64.whl", hash = "sha256:95d1071023ed858b79f6971954fd7cc1f5dbcbab987718a4ccbe1411e47d0b81", size = 37441881, upload-time = "2026-08-11T16:25:28.324Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2e/eafd488766d1c02413cba24f7b22acb9b3ccdfd8407e98d30eb16bac4e2a/llvmlite-0.49.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:f3f2ff0aeb17d34fcce9f79b99baac441cfd3efa41b83e233ca4530a72381f72", size = 40479230, upload-time = "2026-08-11T16:25:35.125Z" }, + { url = "https://files.pythonhosted.org/packages/98/07/a2c4f04e2111ccc274b4d5e3331398a9dcf6d6e5e55d6444b1ad9d6381cf/llvmlite-0.49.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d5555ea1d63928481cbf7fcb1d67452b216c7e5b393a4eb7aa1401e67f2a4fc4", size = 59890658, upload-time = "2026-08-11T16:25:43.294Z" }, + { url = "https://files.pythonhosted.org/packages/80/f9/7b7b50f80b4585bcd78675ff3110c256877b11df32a8cde284f851762f57/llvmlite-0.49.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32adb84fdaae28aeb86fdb6253084ee707ee157289a2e98fe3caf48a62bee82", size = 58344482, upload-time = "2026-08-11T16:25:51.527Z" }, + { url = "https://files.pythonhosted.org/packages/d8/c6/32d68bfbf1d0c36888530ef6fd72864861af23dc546302b41033471a8c3a/llvmlite-0.49.0-cp314-cp314t-win_amd64.whl", hash = "sha256:be637e465010bc9c50f070468f7f1cf5385e92fee364d192dd5e6cea790ecba9", size = 42986602, upload-time = "2026-08-11T16:25:57.69Z" }, +] + [[package]] name = "lupa" version = "2.6" @@ -2403,6 +2923,67 @@ wheels = [ { 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 = "matplotlib" +version = "3.11.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/c8/9aa712a0afb882649424dd8de8ad9aa6235e796e84c6052e8f6dc1598d0d/matplotlib-3.11.2.tar.gz", hash = "sha256:cec596316640f2b394b8f0daa0ea61a8eae82d017b620b9f202befb972a59ea4", size = 32660610, upload-time = "2026-09-11T19:05:31.214Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/ce/1bfcc4873b121597791ad74032943b123218c0613af0b97e8dd05e916fb1/matplotlib-3.11.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef752769cd962f39ea0b6ffc82d1ea43a0012c5a6157c7a075212fa509cfcff2", size = 9476466, upload-time = "2026-09-11T19:03:19.45Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c4/7f5f3601ee69baf072c0c7d3ce60c03e0618621c5a56c34a62a460e29d11/matplotlib-3.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ef31985c4dedb5f1424e1aec6849a47dd37689cb7fa3c20b1b82187f26806261", size = 9305583, upload-time = "2026-09-11T19:03:22.39Z" }, + { url = "https://files.pythonhosted.org/packages/b8/90/2b3fd67ee273163faeda6d514be70b5596eaa0fd77b60ffc294ad0b34f5f/matplotlib-3.11.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df4f7784aca81a94f254c0a2767d592ee25f407e488f5fa7203e51093fb6ca27", size = 9860353, upload-time = "2026-09-11T19:03:25.049Z" }, + { url = "https://files.pythonhosted.org/packages/f4/84/32549e7a462dc311aed2ab62e5d2538028840b5c53a8be0195c789937c3d/matplotlib-3.11.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b9a7ad579856284135e401ecc918c5f8a017ee30539298862a109f51b971710", size = 10670348, upload-time = "2026-09-11T19:03:28.108Z" }, + { url = "https://files.pythonhosted.org/packages/84/39/02e21b74f7439bd643d717ea846006d46e309e6d29b632b57751265311d6/matplotlib-3.11.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3aa4b8516fd26659e4363abbf317c703d9116496c5db2e9d0609a2866dd39dd2", size = 10810580, upload-time = "2026-09-11T19:03:31.402Z" }, + { url = "https://files.pythonhosted.org/packages/04/93/0d239bde12b308262265c2d98909b2f0ecad2b5deeae96241642e822e9c7/matplotlib-3.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:c5c1c68ee401fc98271263410f0e5ce88285abacf7627132914e8adf3d70ff43", size = 9349409, upload-time = "2026-09-11T19:03:34.195Z" }, + { url = "https://files.pythonhosted.org/packages/49/a8/06baf901c02246c8a222b655cc4540ef9c15e1549a04e07927f9cde716c3/matplotlib-3.11.2-cp312-cp312-win_arm64.whl", hash = "sha256:643ff850d8e0f5b8319337f87ed3cb59506afb3df3cc48de777d85871233be7b", size = 9028084, upload-time = "2026-09-11T19:03:37.032Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ce/fc58855a3d17f6cb8a87b6f9ccd100f65432a3a7ffed57309cbf14c78987/matplotlib-3.11.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d43ff8cebb50840648cb6429b2228621dbd709010f3117b3740abb20abf21c0", size = 9476987, upload-time = "2026-09-11T19:03:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/a9/29/2a47b700545aca050e0ed73c89ba21e08407e11f5f42a567421b220de6c1/matplotlib-3.11.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2a8285cea8ef4d92aa041d1c33788bcae82248503300f93f1ca2136b9049452f", size = 9306050, upload-time = "2026-09-11T19:03:43.103Z" }, + { url = "https://files.pythonhosted.org/packages/a9/df/e7c10292f348cf55178a5b3c0092a38ea68891a3945f199ae8b7b8ac9dc1/matplotlib-3.11.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1944895967f87c84c9b4bad29a707b31f5b36df4a3a2339ea1f8ea3ce5105539", size = 9861060, upload-time = "2026-09-11T19:03:46.044Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b9/b978de6d43d47f1d4d8e653fac32c16d8d2e5f46bbbb822e26d7cb1adece/matplotlib-3.11.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af2661f6ac6bbd1d081996f54fdd9385715c625ace8cec0869055c0cfbf38981", size = 10670885, upload-time = "2026-09-11T19:03:48.942Z" }, + { url = "https://files.pythonhosted.org/packages/a3/80/d72f61bb2631ac15d17fada1f17e0f99fecba69d8376d34f17cf93b7eddd/matplotlib-3.11.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7e5a90f8a707ebb6004a713b1cff091a40cc5df4c7c1cb165e5a505ebc11c292", size = 10811222, upload-time = "2026-09-11T19:03:51.665Z" }, + { url = "https://files.pythonhosted.org/packages/94/2e/6d1d33f242c705b5c6c6fb3f0fc141add51cb1abb62912e59de0835d28c4/matplotlib-3.11.2-cp313-cp313-win_amd64.whl", hash = "sha256:bc067c462a86f0e57bf52fc6e058d90171a5420007dac00c46e90153050c69a7", size = 9349539, upload-time = "2026-09-11T19:03:54.534Z" }, + { url = "https://files.pythonhosted.org/packages/bb/bb/c0aa86a7f4c0e62f1c3c0711e361f6f23f87eb9018732a5e11994bd1b7ec/matplotlib-3.11.2-cp313-cp313-win_arm64.whl", hash = "sha256:3e8576f7c47e02fd4f21f44171302d2d1d58d4471d46da3d71fe8899d19539d9", size = 9028295, upload-time = "2026-09-11T19:03:57.261Z" }, + { url = "https://files.pythonhosted.org/packages/54/b4/4facdb700bc236e4c676495ca3798d8134ab8668cbfe0f6b2e9ebf962a38/matplotlib-3.11.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c9721f81275499da1feeb36a2cf8192ea086283b3bd16b7dc4c9d7aedb7396d6", size = 9478976, upload-time = "2026-09-11T19:04:00.12Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a0/bba05f0a25bfeb0475c2f2a87958b31103aa6b7a5386017afb1a96212825/matplotlib-3.11.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a3040b209f3968b4e84161df7b174f07a9fad33b0f2d7e48ea3bbd3075e2863", size = 9307932, upload-time = "2026-09-11T19:04:02.976Z" }, + { url = "https://files.pythonhosted.org/packages/d4/42/81d5cba4bb39b41b0998efc880e129f2d7cdb94256ca49678fdcd9c21be5/matplotlib-3.11.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf3fe71fbfb8ec0e310e0bc8537c3405a01f38f25f9394ed2135e6202fed542b", size = 10674370, upload-time = "2026-09-11T19:04:06.912Z" }, + { url = "https://files.pythonhosted.org/packages/29/5e/52f56f93d5b20815ee0ae352f7a38eff25580edb180b4fa7df87cfeba68f/matplotlib-3.11.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8c8255de28f986d935a64c9ca71c0ec2d2f41d355691f5ea684725dc91413f71", size = 10954597, upload-time = "2026-09-11T19:04:09.948Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ae/f8918a1b564db49c53cd603eb3c8748c5767e4ce0fb2cf1d7c38653e4c5d/matplotlib-3.11.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a8756cc73d9af9a7fe0deb54ea2e75ef73b01d9e575877e72acad5458e660943", size = 10812583, upload-time = "2026-09-11T19:04:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/ea/61/4315ef0d63937fe56f451887b4a0be3b8e8b22cf1d92a8d157caac54570d/matplotlib-3.11.2-cp314-cp314-win_amd64.whl", hash = "sha256:ecea603dd2fbf8242fd31a305a8b12a4ece2de28096870c65fdd0d1e35b8d9a6", size = 9505972, upload-time = "2026-09-11T19:04:15.788Z" }, + { url = "https://files.pythonhosted.org/packages/d5/00/02398b0a1a62ef73d9af0a957fe1773363eec9a6ca7e433c72ae2790fbce/matplotlib-3.11.2-cp314-cp314-win_arm64.whl", hash = "sha256:01dc8eaaab5a9fce9ff615eca82345728f289e4715b186ee10c6d85272fc26bb", size = 9184534, upload-time = "2026-09-11T19:04:18.691Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c5/1e6ca10baa7e04f113c37856aadb4adc3dbb1c55fc7da6dc830bba1292d7/matplotlib-3.11.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79a258f58253dfa025af80a9e9bb228d75fced007f0e93ae7423fefbde81a74d", size = 9526415, upload-time = "2026-09-11T19:04:21.39Z" }, + { url = "https://files.pythonhosted.org/packages/e0/93/7561727af07ccc84747953c6a33eee3981cc436d04f465032e707ca0e429/matplotlib-3.11.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c0b83f044ce10a98027b105b3931548719a6e8c7ef986b4362651e0b5367c8dc", size = 9361064, upload-time = "2026-09-11T19:04:24.107Z" }, + { url = "https://files.pythonhosted.org/packages/c1/40/ce270ef2a6794d94a409fe28b8dabedffc07c07f9e5dbb89e0f605bd157d/matplotlib-3.11.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e43b188f0a5b75447bcc197728258166aa64365770ed1caa36595a5e1ca4bbba", size = 10684626, upload-time = "2026-09-11T19:04:27.201Z" }, + { url = "https://files.pythonhosted.org/packages/45/46/396e0307dbf1c1f2d0a3654caa6dbc780dd5379f5f11257369cf8d21d9fe/matplotlib-3.11.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3304eb5a59442a8867f6920d484591c0fa09ffc29e9260be2feec3351e25869", size = 10960602, upload-time = "2026-09-11T19:04:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1c/97/4defb84695477340aa88b27c9482cc1ca4de11f36f66b6c8c0ddd685def1/matplotlib-3.11.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:02329432ae5c6af87cf208ee575b701d698bdf0b1a3bb28cc6d53c36e967e575", size = 10822603, upload-time = "2026-09-11T19:04:32.964Z" }, + { url = "https://files.pythonhosted.org/packages/7d/e7/eab54551c1ec6b72dcd678a078b6c6da8d4c85ec1ff0016e81fe52866304/matplotlib-3.11.2-cp314-cp314t-win_amd64.whl", hash = "sha256:f2ac30cf5eb5dff1b584627ae0b0e1186551a4f69ae3c75073911da497a29170", size = 9551206, upload-time = "2026-09-11T19:04:35.811Z" }, + { url = "https://files.pythonhosted.org/packages/4e/0e/04698032cb4d8fb1e30d8ffec5ded20f6f582b03c8c58b2385c95245f158/matplotlib-3.11.2-cp314-cp314t-win_arm64.whl", hash = "sha256:cf41ecd1b0c0b6f7177ed965a54c2afbe888715c7cf6054dc12d53bc1494002c", size = 9234977, upload-time = "2026-09-11T19:04:38.972Z" }, + { url = "https://files.pythonhosted.org/packages/44/a9/ca827c184b87364378964e36e7c0ae23651d146fdbc4000635205490cf82/matplotlib-3.11.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:894a9cbbecbe30ae6787d464df2e8fc7a8d475cfc68f87c3029f7c11152899b3", size = 9478997, upload-time = "2026-09-11T19:04:41.914Z" }, + { url = "https://files.pythonhosted.org/packages/48/60/01aaafc9c3159f3fae9a65bbcddaa4a6d4d72b0aeffdd636d7f6d00a5c8d/matplotlib-3.11.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:bbf1062991d826ed27e2144f3afa4461afbb8ff56e8f703043e191a8163b1ee9", size = 9307947, upload-time = "2026-09-11T19:04:44.834Z" }, + { url = "https://files.pythonhosted.org/packages/1e/a0/6aa72c7cd7cf4dfac652744d7a3310007cec3ce8e5ab9be607bec7096471/matplotlib-3.11.2-cp315-cp315-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:399fef672f7046ef7d6a57572b2a6f9845f3f5afcff04e7b2df7a363a9f42190", size = 10674370, upload-time = "2026-09-11T19:04:47.745Z" }, + { url = "https://files.pythonhosted.org/packages/a1/97/d7e90225ac145046787a2ee0fa4cdc7e4d5cb6772ea232130b10f8a4095a/matplotlib-3.11.2-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc82dde2a0d3e3ad472edce04897ad7146b8d8bfd1df8a32992eebb81af18fdc", size = 10954581, upload-time = "2026-09-11T19:04:50.712Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/89cf056a9d2cae01f4b8baf01c7e809d613244fcdfa2a2acf28c1e0d79fa/matplotlib-3.11.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:930efb28f59fda124e39265d177bab302625297bb147d2910de725a2fc2aef54", size = 10812605, upload-time = "2026-09-11T19:04:53.552Z" }, + { url = "https://files.pythonhosted.org/packages/9d/4d/c61b9f4c34af802d4d84e58803e009013d626b5f468bafe1416131863b5b/matplotlib-3.11.2-cp315-cp315-win_amd64.whl", hash = "sha256:3da3bc0cbf7245e7db72cc6d29d12c5abef72cb73059c73b945f14e3545f3eb2", size = 9506068, upload-time = "2026-09-11T19:04:56.519Z" }, + { url = "https://files.pythonhosted.org/packages/03/7b/047f2fa8708d47430d8e7fa45e9fffb0aee5d4e300f978e087be2ee11fbc/matplotlib-3.11.2-cp315-cp315-win_arm64.whl", hash = "sha256:f25446b2981717dca9786bac841cb3fd7efb568e3e3c755dd980481c5cb9228d", size = 9184576, upload-time = "2026-09-11T19:04:59.318Z" }, + { url = "https://files.pythonhosted.org/packages/03/6e/ad9c5989950785a98642667df265a47c6637e924cb69b5dcf68c3299ae7c/matplotlib-3.11.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:fea03cf56568cc1cba08b470be6a0559e71c3a5b688d54b7179bb35ba23d0821", size = 9525988, upload-time = "2026-09-11T19:05:02.304Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c3/25c61701fb655dd412428b15eee2591ce554cdd593e39a045a153373f428/matplotlib-3.11.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:254d4ddb2fa8df3b4c689c0c306063aee10521df82cfb438185e499c75fe37c1", size = 9360774, upload-time = "2026-09-11T19:05:05.092Z" }, + { url = "https://files.pythonhosted.org/packages/58/5a/4552878fce8f9d249a5e264c16f5c267c27eac08fb039fa3e207a06feff5/matplotlib-3.11.2-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e60cf3047a51edecdc4535a9196bbd6a732936b8e9f8184aabf4d16165884aaf", size = 10685892, upload-time = "2026-09-11T19:05:07.686Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/b3b19ec0124fc4aafd8a4b3bae859c562a7ff0fd8ae3b2099947f78bde19/matplotlib-3.11.2-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb3712dc9b464793de0a4e42a7313d50293c751f94bddaf7332a1bc71bccdda9", size = 10964946, upload-time = "2026-09-11T19:05:10.943Z" }, + { url = "https://files.pythonhosted.org/packages/7b/30/687ccce22f66a57fed85942450e88062773bcbf1a42914e70c7148ec71dc/matplotlib-3.11.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:75b6d88402770e181b5d06a67dda4129c31da7d05004d63a21c310ec78d1b83c", size = 10824960, upload-time = "2026-09-11T19:05:13.9Z" }, + { url = "https://files.pythonhosted.org/packages/d3/34/117fe35a8d05054b4b34d2d29287099927c24c3841d36e06d9fa2f20c1cf/matplotlib-3.11.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a6939df7567114b6bac7f4c5e06c84a67f197c1b2f2e4b234d4eecb3bfec9482", size = 9551204, upload-time = "2026-09-11T19:05:16.48Z" }, + { url = "https://files.pythonhosted.org/packages/63/5a/9f440e7bec8b80d0af2a65d1165b2f4e718982dad785531f62255d9ff19b/matplotlib-3.11.2-cp315-cp315t-win_arm64.whl", hash = "sha256:d480038c83691532ed52ff3147db51fa902fc78cb2d8349993a1cdb684435bff", size = 9233946, upload-time = "2026-09-11T19:05:19.063Z" }, +] + [[package]] name = "matplotlib-inline" version = "0.2.2" @@ -2701,6 +3282,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/33/bd5b9137445ea4b680023eb0469b2bb969d61303dedb2aac6560ff3d14a1/notebook_shim-0.2.4-py3-none-any.whl", hash = "sha256:411a5be4e9dc882a074ccbcae671eda64cceb068767e9a3419096986560e1cef", size = 13307, upload-time = "2024-02-14T23:35:16.286Z" }, ] +[[package]] +name = "numba" +version = "0.67.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llvmlite" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/90/2544f4e3a61e501d6c9a5418fd4b905323222693d54a02cab0106a0af865/numba-0.67.0.tar.gz", hash = "sha256:cd75aa535b33fa05d9d930b1ae8af9f97a2881e96d72dfb38ec9b78284d9f851", size = 2836515, upload-time = "2026-08-11T23:04:00.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/58/915cddba90010348ed0444451132fdde9b000bcbaff1582029b5bf115d11/numba-0.67.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6004d8d5f28d4028687fb2d972d629295b13685943bd2ed5cd8810c3b848e219", size = 2745050, upload-time = "2026-08-11T23:03:25.607Z" }, + { url = "https://files.pythonhosted.org/packages/bb/38/926757caaac18a66f057d7544a63620bf360a07d281c9f7ecadd2aa83963/numba-0.67.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f63d43db06b4756424d6d2484737c902e0ae944a0eec3e8b0b4de2c695b15caa", size = 3884596, upload-time = "2026-08-11T23:03:27.688Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6d/58291dc58da39d98b32db7f044729f6d8d4920cd9622fbab3179b54ff4c4/numba-0.67.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76d3335aaeffb9dc88309420890e73497a00be08a7530441bc2b58ffe025bfa5", size = 3585290, upload-time = "2026-08-11T23:03:29.684Z" }, + { url = "https://files.pythonhosted.org/packages/6e/63/ab21828b4056afed71f9ecb40f4de26c2c19de731cc001961aca74b79464/numba-0.67.0-cp312-cp312-win_amd64.whl", hash = "sha256:50e2b72406c18cda5dd7431b0082cb85ea94e06c64c33607248fc8bef92cfb81", size = 2815645, upload-time = "2026-08-11T23:03:31.732Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/bd9fe772f6c84597b76cac229b3f2890f01a2c64fd70e48ceaae10dd65cb/numba-0.67.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:77e1c7173fee57a0d84e006c7e70346689d6cb3e7db503489bae58646b4eff7b", size = 2744872, upload-time = "2026-08-11T23:03:33.649Z" }, + { url = "https://files.pythonhosted.org/packages/a1/1c/c05609739cc41116d36e30cb2b41fb00f126bb52e1b0bac907051ad8a35d/numba-0.67.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9c4953387c77864b596d8296e2cfbdef82b0eea4166ab4864b05d226c51143e0", size = 3892004, upload-time = "2026-08-11T23:03:35.797Z" }, + { url = "https://files.pythonhosted.org/packages/4a/77/a5276ad4178250403e0e2251f3e1f8ac18feac779b0474a8bcb08558490d/numba-0.67.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88f6e0f5cb6c545e158b6ef0496c01b6d6958a7ccc6634a1576a94bbbab29ff2", size = 3591878, upload-time = "2026-08-11T23:03:37.845Z" }, + { url = "https://files.pythonhosted.org/packages/0f/80/d48f0ba7442516ceb5a1585f0c81d3aa531bc96bfcabcd9f8f925768c426/numba-0.67.0-cp313-cp313-win_amd64.whl", hash = "sha256:b68ad5125fe245339cc8dcc036081fc1ea482c5063387b9612a76ccd83dc91cd", size = 2815504, upload-time = "2026-08-11T23:03:39.736Z" }, + { url = "https://files.pythonhosted.org/packages/d7/16/345b1e4774a08247aafcfdb93d4e8d24a3646366cbe72de33053fc0de1b5/numba-0.67.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:f99f880ff25f418a67f9a1d00d0ddfbc63430f627b523e515085a592a7567f4b", size = 2745088, upload-time = "2026-08-11T23:03:41.864Z" }, + { url = "https://files.pythonhosted.org/packages/1b/36/e614ba2bc0f005ed0f37a6413f08fe705210297ddb9a37a475a8b9fdab61/numba-0.67.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269245a675abdd3e2c35ec6bb2f250355effa9032514d8f2354f0d2d10854bd", size = 3861040, upload-time = "2026-08-11T23:03:43.842Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/30c42a1dbc4176cf355e8e8be61803732c55597b1332925fe233912a43d9/numba-0.67.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f074a8e23db78490f11a3930c940be758316c10ac5985be83d2f298dc080acf7", size = 3561811, upload-time = "2026-08-11T23:03:46.037Z" }, + { url = "https://files.pythonhosted.org/packages/18/6d/21bd16f770476e394c5e5f504935817967442a71251d6b86c244a2767980/numba-0.67.0-cp314-cp314-win_amd64.whl", hash = "sha256:4d576e62bf2c9370f61312b51573c4bb1f3fe96798bbab56730847a368a316c4", size = 2817421, upload-time = "2026-08-11T23:03:47.922Z" }, + { url = "https://files.pythonhosted.org/packages/95/06/bb41b0e59b9ff52c94a2f01db24f6437df058caebb377b5f372fc343a6a2/numba-0.67.0-cp314-cp314-win_arm64.whl", hash = "sha256:7930748ce8355d2a5a28602abab056a61fdc676d17377f27d17993905428171f", size = 2788885, upload-time = "2026-08-11T23:03:49.967Z" }, + { url = "https://files.pythonhosted.org/packages/10/7c/aa07151fbd0f4283f78de437cc196f9084789be89a2b4de3fdc2f6a4b414/numba-0.67.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:4a2ed006635bbd0fe45681ed49f3b4f4bad1abf0c233bcc5842c9e3a34cabd61", size = 2748150, upload-time = "2026-08-11T23:03:51.755Z" }, + { url = "https://files.pythonhosted.org/packages/74/62/b8174ca95a4cc1a7ba1520767734e016991545590b8fbde521b681701a9f/numba-0.67.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa5f002f665bec321b950dacaa26ee009e1d720f6ac9d9856eed5efe1caa03a6", size = 3896986, upload-time = "2026-08-11T23:03:53.752Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f9/3a7b6dbf81e01a48958b45ad2239edbc64707522ab17f11f9f18c44bf6d1/numba-0.67.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83ab968b0e0fa744eba03351282dd8000796e6ec8e4518f47bd3ed86c0a20c7b", size = 3614644, upload-time = "2026-08-11T23:03:55.794Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5b/248f5681c121ca853a9f4e39d342a3e01b8a0261b0275853eb3d0d56aa20/numba-0.67.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00c964a5b94d3ae82d83ac162cd610755875b98dadb779fdde06e6bfcdbca47e", size = 2822870, upload-time = "2026-08-11T23:03:58.097Z" }, +] + [[package]] name = "numpy" version = "2.4.2" @@ -2896,6 +3506,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f", size = 89954, upload-time = "2025-03-07T01:42:44.131Z" }, ] +[[package]] +name = "omegaconf" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "antlr4-python3-runtime" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/3d/e4b57b8d9008c6ebe0d5eff901f91d5700cf7bdb8c8863df817463a7fd5e/omegaconf-2.3.1.tar.gz", hash = "sha256:e5e7de64aeebeddaf8e6d3f7a783b32ac2a01c0fbd9c878012caecb891a1f42a", size = 3298472, upload-time = "2026-06-11T05:05:12.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/0e/152509871bf30df6fc38569f52a2db9b55dd41aae957adae50a053ac7778/omegaconf-2.3.1-py3-none-any.whl", hash = "sha256:3d701d14e9a8828f1edd28bb70b725908b34277cdd72cf7d6a83f94dadc6b6a0", size = 79502, upload-time = "2026-06-11T05:05:09.954Z" }, +] + [[package]] name = "openai" version = "2.31.0" @@ -3242,6 +3865,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7d/eb/b6260b31b1a96386c0a880edebe26f89669098acea8e0318bff6adb378fd/pathable-0.4.4-py3-none-any.whl", hash = "sha256:5ae9e94793b6ef5a4cbe0a7ce9dbbefc1eec38df253763fd0aeeacf2762dbbc2", size = 9592, upload-time = "2025-01-10T18:43:11.88Z" }, ] +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + [[package]] name = "pathvalidate" version = "3.3.1" @@ -3386,11 +4018,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.9.2" +version = "4.12.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" } +sdist = { url = "https://files.pythonhosted.org/packages/23/4d/e78afe1b449720c481884ca0a2f960f85f9ffdaa34b2d127b5427422c564/platformdirs-4.12.0.tar.gz", hash = "sha256:095be5c143382b1bee917c4f3e9987a0d8d6a582261f1d061ad0c403b7695b5b", size = 58964, upload-time = "2026-09-26T15:26:00.937Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, + { url = "https://files.pythonhosted.org/packages/60/3a/723ad221b7fef91ccd947f9fddbe84d38fa573e2723317aaa67bfde41015/platformdirs-4.12.0-py3-none-any.whl", hash = "sha256:f6fb2960f2f2eb0870f820f7e49e49e6c0f0589c637f34d33b260b07591327c9", size = 32965, upload-time = "2026-09-26T15:25:59.496Z" }, ] [[package]] @@ -3402,6 +4034,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "postgrest" +version = "2.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecation" }, + { name = "httpx", extra = ["http2"] }, + { name = "pydantic" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/22/88c470d8838d2678a44e0172d061630b8837cba3fb7fb492e28f6578c309/postgrest-2.31.0.tar.gz", hash = "sha256:2f395d84b2ee34fc57622ff2f711df603e2ede625f98e5015240741888f7bd0c", size = 14419, upload-time = "2026-06-04T13:37:20.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/3e/41909586cb148db0259e0208310067afda4cc097f4c7a779e829c1e68c46/postgrest-2.31.0-py3-none-any.whl", hash = "sha256:c2fd47c94e13ee8335111c4f03c9a24ea9766ce9d35fc3cd7330057c9e7ea0c3", size = 23098, upload-time = "2026-06-04T13:37:19.452Z" }, +] + [[package]] name = "proglog" version = "0.1.12" @@ -3825,11 +4472,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.11.0" +version = "2.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } +sdist = { url = "https://files.pythonhosted.org/packages/02/a5/5197bfd06417837ac079921c66fa6393f1dea3557272a263cebfef69e432/pyjwt-2.15.0.tar.gz", hash = "sha256:b11c5f9791d7bf51c2b39a81ed669f6b2dbbd669df2942f6c60167e9e3d1abe4", size = 120513, upload-time = "2026-09-23T16:56:00.689Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, + { url = "https://files.pythonhosted.org/packages/e8/55/40e45bf052ee8ee12a4dfd785519660f8effa7b065442b91646ec6828619/pyjwt-2.15.0-py3-none-any.whl", hash = "sha256:7a3742debf6b879e912dbb9819ceec1594be812452b78c5f2e2dfc56564954f8", size = 33680, upload-time = "2026-09-23T16:55:59.241Z" }, ] [package.optional-dependencies] @@ -3837,6 +4484,32 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pyod" +version = "3.6.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "matplotlib" }, + { name = "numba" }, + { name = "numpy" }, + { name = "scikit-learn" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/03/a21d82bfb14001a69f455a0976ccf581aef7cdef941fc9b4e66d21fdf7ed/pyod-3.6.6.tar.gz", hash = "sha256:cb2556d5e80621a927209d610f76f3dfc182152d73a88cebd196ede080ede25e", size = 353014, upload-time = "2026-09-17T02:19:24.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/3a/cf2a46fb4591ddeaf902b20cce03e7a1e26cab648a773dc48250e40df4d9/pyod-3.6.6-py3-none-any.whl", hash = "sha256:c778c00a91a630ed454e001ea1ca981141e389a20f90128b06dc712a3eaf90d7", size = 419467, upload-time = "2026-09-17T02:19:22.643Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/11/b213bebff182584360cb8d17c72c1677fec5c5c228de439e63bcf8ab1c8f/pyparsing-3.3.3.tar.gz", hash = "sha256:928ae7e20211f3b6f3915a72f06a0cfd29ab9d24279dd6346b6b1a7146397d36", size = 1050487, upload-time = "2026-09-20T20:59:05.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl", hash = "sha256:ece8c00a69cf01b45d0b1dedabb469c90d8caf996d4fda40f147627a122849a4", size = 126420, upload-time = "2026-09-20T20:59:04.025Z" }, +] + [[package]] name = "pyperclip" version = "1.11.0" @@ -3914,6 +4587,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, ] +[[package]] +name = "pytorch-lightning" +version = "2.6.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fsspec", extra = ["http"] }, + { name = "lightning-utilities" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "torch" }, + { name = "torchmetrics" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/e4/5025cb42311b4f8cc6d1b12cd81399ca433159f8e3831ab229c5a6c5177e/pytorch_lightning-2.6.6.tar.gz", hash = "sha256:52c52b20522dd3d3feb3bb3aae2dbe3f291e8e836d4111a74cfd72e9df018834", size = 662507, upload-time = "2026-09-10T09:41:16.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/cf/ba8e4cc1b982ff6dc6f9fdef0e4074605438b82640f6270888a10e7e42c3/pytorch_lightning-2.6.6-py3-none-any.whl", hash = "sha256:71f95c7b22f25c4f91cc659e1734bcdc6c69c8b66543f2ed2d78dbe29ce2f167", size = 853045, upload-time = "2026-09-10T09:41:14.923Z" }, +] + [[package]] name = "pytz" version = "2025.2" @@ -4106,6 +4798,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, ] +[[package]] +name = "realtime" +version = "2.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/34/54a1eaaefa24db5cb12596fd74792e08efa53ed30dc5bce2c0a68ded6146/realtime-2.31.0.tar.gz", hash = "sha256:9e641cb4d77ca0fe768515f8cf9f83550c79f49ce1550a95afc2dc0e252be8c9", size = 18716, upload-time = "2026-06-04T13:37:22.089Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/60/164246615e8b059f6d53d34648a0784260421ec98a07eb1e45160f063221/realtime-2.31.0-py3-none-any.whl", hash = "sha256:f6e494b53d6a6e80b6efcee6711c8dd40413a52e766271de1bce8ced6c36cc1d", size = 22374, upload-time = "2026-06-04T13:37:21.162Z" }, +] + [[package]] name = "redis" version = "7.2.0" @@ -4408,6 +5114,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8d/fc/7eedc3510d97878876e32774eebbeb61c43f148a96e915c84229a3e967aa/safetensors-0.8.0-cp310-abi3-win_arm64.whl", hash = "sha256:f7838e5135a406ad3e02efdcb8cf2e5397d368b0154537c4fec682dbc544d452", size = 340500, upload-time = "2026-06-09T07:52:26.745Z" }, ] +[[package]] +name = "scantree" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "pathspec" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/e4/40998faefc72ba1ddeb640a44fba92935353525dba110488806da8339c0b/scantree-0.0.4.tar.gz", hash = "sha256:15bd5cb24483b04db2c70653604e8ea3522e98087db7e38ab8482f053984c0ac", size = 24643, upload-time = "2024-08-03T20:08:59.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/ce/828467ddfa0d2fe473673026442d2032d552a168e42cfbf25fd0e5264e0c/scantree-0.0.4-py3-none-any.whl", hash = "sha256:7616ab65aa6b7f16fcf8e6fa1d9afaa99a27ab72bba05c61b691853b96763174", size = 20690, upload-time = "2024-08-03T20:08:58.137Z" }, +] + [[package]] name = "scikit-base" version = "1.0.2" @@ -4552,6 +5271,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "shortuuid" +version = "1.0.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/e2/bcf761f3bff95856203f9559baf3741c416071dd200c0fc19fad7f078f86/shortuuid-1.0.13.tar.gz", hash = "sha256:3bb9cf07f606260584b1df46399c0b87dd84773e7b25912b7e391e30797c5e72", size = 9662, upload-time = "2024-03-11T20:11:06.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/44/21d6bf170bf40b41396480d8d49ad640bca3f2b02139cd52aa1e272830a5/shortuuid-1.0.13-py3-none-any.whl", hash = "sha256:a482a497300b49b4953e15108a7913244e1bb0d41f9d332f5e9925dba33a3c5a", size = 10529, upload-time = "2024-03-11T20:11:04.807Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -4561,9 +5289,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "skpro" +version = "2.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "scikit-base" }, + { name = "scikit-learn" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/ac/bab63a68159302af5d4a65bcd4dd79b59a100dc3f8c1078d117bd42ddfd8/skpro-2.16.0.tar.gz", hash = "sha256:63b54be0b6e845ee2992d08b9bf50c2b840c76c25df4a55a1d626dc899e93397", size = 478108, upload-time = "2026-09-27T21:57:20.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3d/90cd108ac9f8a0f6838fce7cadafa0e25520fe62d358cc9f5daa531915ca/skpro-2.16.0-py3-none-any.whl", hash = "sha256:53ab0a0aa191815a5c53b487c28576721d7056bf23cac3665f268b92e4d3f0c8", size = 685900, upload-time = "2026-09-27T21:57:18.696Z" }, +] + [[package]] name = "sktime" -version = "1.0.1" +version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "joblib" }, @@ -4574,9 +5319,9 @@ dependencies = [ { name = "scikit-learn" }, { name = "scipy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e6/7f/7eb6441625dc3c5d66f71d3b40b6f5c24de3c758f71aa57792656b3ae166/sktime-1.0.1.tar.gz", hash = "sha256:3cc20576a03d46447e935c3d3512450ffd5b65ee0320af65db5ff71f4373d453", size = 36261136, upload-time = "2026-06-11T20:53:15.797Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/a0/6ea64121398a8d3e23deb6db2c1dc7012afc65c23a28eda6cc06f6fb81bb/sktime-1.2.0.tar.gz", hash = "sha256:641425178b3215a0a8dbcf4e6948a8b589e46def39c91989f71d9fa297bc3709", size = 36398732, upload-time = "2026-09-22T22:11:15.066Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/fa/4e2afddd9cb26bea3f0b5101641779be5f64874e98bc79cd4effc67fcd3b/sktime-1.0.1-py3-none-any.whl", hash = "sha256:7da3a38376de7da6cbcfb9979800ae19a5804a7f1e49f941a7a65ca3531050a3", size = 37454547, upload-time = "2026-06-11T20:53:12.312Z" }, + { url = "https://files.pythonhosted.org/packages/eb/67/98c89e7a17691ca473b7cc7200f74e43e47ad61680db59bfe791f341b056/sktime-1.2.0-py3-none-any.whl", hash = "sha256:dbffc6731d2d683b668dceb5e2bbc5804bebc8446b4c580803a281684d90e4ae", size = 8201107, upload-time = "2026-09-22T22:11:11.09Z" }, ] [[package]] @@ -4713,6 +5458,76 @@ mcp = [ { name = "mcp" }, ] +[[package]] +name = "storage3" +version = "2.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecation" }, + { name = "httpx", extra = ["http2"] }, + { name = "pydantic" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/30/fee43d523d3f680a833a4aae5bf8094de0b9031b0c2bddb3e0bc6e829e1b/storage3-2.31.0.tar.gz", hash = "sha256:d2161e2ea650dc115a1787c30e09b118365589ac772f4dd8643e3a503ecfc667", size = 20348, upload-time = "2026-06-04T13:37:23.703Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/b2/60d86a3a99ae743e8a00a6df912f85269b3bc882ba217b8ce1690881c669/storage3-2.31.0-py3-none-any.whl", hash = "sha256:4bf46e8bea320743179a6beafdc7531c5242495e00e0cc22af7c7a9d69d4ed84", size = 28492, upload-time = "2026-06-04T13:37:22.792Z" }, +] + +[[package]] +name = "strenum" +version = "0.4.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/ad/430fb60d90e1d112a62ff57bdd1f286ec73a2a0331272febfddd21f330e1/StrEnum-0.4.15.tar.gz", hash = "sha256:878fb5ab705442070e4dd1929bb5e2249511c0bcf2b0eeacf3bcd80875c82eff", size = 23384, upload-time = "2023-06-29T22:02:58.399Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/69/297302c5f5f59c862faa31e6cb9a4cd74721cd1e052b38e464c5b402df8b/StrEnum-0.4.15-py3-none-any.whl", hash = "sha256:a30cda4af7cc6b5bf52c8055bc4bf4b2b6b14a93b574626da33df53cf7740659", size = 8851, upload-time = "2023-06-29T22:02:56.947Z" }, +] + +[[package]] +name = "supabase" +version = "2.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "postgrest" }, + { name = "realtime" }, + { name = "storage3" }, + { name = "supabase-auth" }, + { name = "supabase-functions" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/8e/54a2f950629689b1613434a61fc3bff5f92f84ba6b20213f5b2add05c1bb/supabase-2.31.0.tar.gz", hash = "sha256:3467b09d00482b9a0138235bdbde7a350426f93cf2a1342372eaddfc669f1206", size = 9805, upload-time = "2026-06-04T13:37:25.22Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/06/5e6f4bf89dedadf81f893832115c1866da1aa093142c9989c2818780dae3/supabase-2.31.0-py3-none-any.whl", hash = "sha256:25f2a99207a75f2d9377e2332783b4389cf56b02cbebdaf0c1743112dcbb704e", size = 16728, upload-time = "2026-06-04T13:37:24.278Z" }, +] + +[[package]] +name = "supabase-auth" +version = "2.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx", extra = ["http2"] }, + { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/8a/408689cf39820f0d46d2731d6747ff94dbefc87ae977b4b5c4066da5b070/supabase_auth-2.31.0.tar.gz", hash = "sha256:0945b33fa96239c76dc8eaf96d7d2c94991950d24b4cfe4a5c2da9aa5e909663", size = 39151, upload-time = "2026-06-04T13:37:27.375Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/5e/22f3b0546bb1f0985f06fb1ebf5f6405a3b1bb7a5db01142c26cf148e988/supabase_auth-2.31.0-py3-none-any.whl", hash = "sha256:5e9c8b4ecdee6af04dbcb06455ce78cb15674806fcb6b425170455307d70b0ee", size = 48363, upload-time = "2026-06-04T13:37:26.26Z" }, +] + +[[package]] +name = "supabase-functions" +version = "2.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx", extra = ["http2"] }, + { name = "strenum" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/5d/61c2446ed26a57fa5543f9c270a731320911569202340d15341f72cdba7c/supabase_functions-2.31.0.tar.gz", hash = "sha256:4ad027b3ae3bd28b31233339f4db1da6965affd3546f655b421baf40cee2690f", size = 4683, upload-time = "2026-06-04T13:37:28.878Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/79/1a8162ce7d705381a4f2668c0da68e097b103c1aa701418a31da52905c7b/supabase_functions-2.31.0-py3-none-any.whl", hash = "sha256:3fdc4c4766152bfda63bdd0e286fc8a06f50e1280711fae4a1dfc9b7e9ebabc6", size = 8794, upload-time = "2026-06-04T13:37:28.022Z" }, +] + [[package]] name = "sympy" version = "1.14.0" @@ -4725,6 +5540,15 @@ 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 = "tabulate" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/58/8c37dea7bbf769b20d58e7ace7e5edfe65b849442b00ffcdd56be88697c6/tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d", size = 91754, upload-time = "2026-03-04T18:55:34.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" }, +] + [[package]] name = "tenacity" version = "9.1.4" @@ -4827,28 +5651,47 @@ wheels = [ [[package]] name = "tokenizers" -version = "0.22.2" +version = "0.23.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "huggingface-hub" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/1e/bc6587c5ab643b2e17776cace9070a2ae73549c86bffac9934a600bf3c31/tokenizers-0.23.2.tar.gz", hash = "sha256:7f0f085686b9de0d0079e6f874ae053600db64c5d13049e0bbc0119926d25aac", size = 385745, upload-time = "2026-09-03T08:55:42.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/ed/8a443528baa6fac8dfe8c3b75b038c63ac92bb539bcabe311e227c718173/tokenizers-0.23.2-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:85a9a357a3764aecc904ee76bdaf8cf1ad8e5a67a1b929a487c4a39b49ed0e90", size = 3148852, upload-time = "2026-09-03T08:55:30.874Z" }, + { url = "https://files.pythonhosted.org/packages/67/49/22da045a91732384d3a3771816bf188dc5a1f702c32e635afa7c679c0bef/tokenizers-0.23.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:986670e43691469dcee610ea0f846f91a8f84e91fc6f7a48d4c064414c0ec2bf", size = 3101593, upload-time = "2026-09-03T08:55:28.587Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4d/8f569ed49372a3ed8e57099bd515055fd48d7c95912c4307cda6973c2168/tokenizers-0.23.2-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a37039b5dfc4af84eb3ef0a92f4307e28936c8f9adccba2629d36f652e9bf7a2", size = 3516830, upload-time = "2026-09-03T08:55:14.741Z" }, + { url = "https://files.pythonhosted.org/packages/2a/de/e2f14c8919d5bf51874051d00d6c7b7e0e8bde6c6a2dbeddda7f642896ff/tokenizers-0.23.2-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b7e37ba198f24150f523e1242e83c4970de4a525480586be5dcc24d9add32c5", size = 3407975, upload-time = "2026-09-03T08:55:16.842Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bd/93c69152d02ef06ce47aed8b2bf4952dcf733c935a62791873932b2934d9/tokenizers-0.23.2-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43e4f2071e3cc8d5d86421c874aebc82659bb51a68bcdef5a0da75ee89511ccb", size = 3748165, upload-time = "2026-09-03T08:55:24.769Z" }, + { url = "https://files.pythonhosted.org/packages/2d/b7/56b84b80bc96942bba8eb23751a9e8a1fce4faaf4390425e7083f721c98c/tokenizers-0.23.2-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:325fee2e0418a9dc6c9ecf736a5f5f0db7875183ace9549ae339da76f7a1fbb7", size = 4024165, upload-time = "2026-09-03T08:55:18.806Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8a/0175e216f005c2fe08238292663aa41e4c802b216e71047a69a0e9fc6fa3/tokenizers-0.23.2-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:950d7c9426fa72406a0ffeacdbc0bb9985f5db20eb8b263f29c79aaf83105703", size = 3591899, upload-time = "2026-09-03T08:55:22.752Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ca/ca6b93c7820df123b2662a9469e8facc826ccc94e98fdd0d615f6431e73a/tokenizers-0.23.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41c2f84d172449b4dadb9cdc508e3e364076613c35b16e76ecfe47a60d1e3305", size = 3386843, upload-time = "2026-09-03T08:55:26.584Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a4/4f9106d317b14a80aefea9f0e3a8d07ef25f856a7607eb7f5ab894281fcb/tokenizers-0.23.2-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:12f0835dc2ee694746a76adf7b1567d4346a4a502ebe93fb1f5f80ea49799b78", size = 3577314, upload-time = "2026-09-03T08:55:20.825Z" }, + { url = "https://files.pythonhosted.org/packages/8d/6a/1552b70fb0d9ab074fd3fc961435d01364e79c9058481822c3af6e8d402c/tokenizers-0.23.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:eb2f9c8a24da020ea8c11a01a19c1c2547912d92121ae4a01cfbca46125dee40", size = 9967367, upload-time = "2026-09-03T08:55:33.188Z" }, + { url = "https://files.pythonhosted.org/packages/06/01/3ccb3a956c7528b2507b8a9714155c4baf86af593039db6ea375dd0c96c3/tokenizers-0.23.2-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f486f402f6f9abee5bb032553736813af0c710a86b2e0ca592634c55cea1f835", size = 9811886, upload-time = "2026-09-03T08:55:35.642Z" }, + { url = "https://files.pythonhosted.org/packages/fa/73/7038e612d48bda1599457f712f6bd3854eae1a9dc9c13aa47f835349db48/tokenizers-0.23.2-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:bef235815a067b2648caf6dcc7a71091b0b0fff9ee8057f6451eb9335fae52ef", size = 10146224, upload-time = "2026-09-03T08:55:38.391Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d8/8e9e4e0b287a338d8f88976729628c9d22e8a54cfaf9777018a7f7cb58a0/tokenizers-0.23.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5c56bda1511921587789163e524d196ed8284174ac23abd7685d5ea8da6c4718", size = 10256304, upload-time = "2026-09-03T08:55:40.977Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1f/c79a01f671a49728ebb0b61f7ff9ea45663b66cab40bc0858e9859b25c16/tokenizers-0.23.2-cp310-abi3-win32.whl", hash = "sha256:debf978920d93ba9c219bd67cc4bbfaf912c9039e41e7a28b91ec15e3728c95a", size = 2592809, upload-time = "2026-09-03T08:55:48.02Z" }, + { url = "https://files.pythonhosted.org/packages/db/f7/0a69ac6b82dbccf3f71add938a161c497952749294b8dd6dfe03a819dc40/tokenizers-0.23.2-cp310-abi3-win_amd64.whl", hash = "sha256:2e96f5699d5249c9c64aa8412e044f727aae3a4098cf830f9901ec1afc361cde", size = 2863236, upload-time = "2026-09-03T08:55:46.193Z" }, + { url = "https://files.pythonhosted.org/packages/d7/b0/dee84cb44175be1b4c35bd2f770727494e78f0bb38e571a623ade94dbebb/tokenizers-0.23.2-cp310-abi3-win_arm64.whl", hash = "sha256:e49c394456dd9985787fec76132438ba3fb8911f857b1bf3d40119f9292d41aa", size = 2729352, upload-time = "2026-09-03T08:55:44.345Z" }, +] + +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" }, - { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" }, - { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" }, - { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" }, - { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" }, - { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" }, - { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" }, - { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" }, - { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" }, - { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" }, - { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" }, - { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" }, - { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" }, - { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" }, - { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, +] + +[[package]] +name = "toolz" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/bf/5e12db234df984f6df3c7f12f1428aa680ba4e101f63f4b8b3f9e8d2e617/toolz-0.12.1.tar.gz", hash = "sha256:ecca342664893f177a13dac0e6b41cbd8ac25a358e5f215316d43e2100224f4d", size = 66550, upload-time = "2024-01-24T03:28:28.047Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/8a/d82202c9f89eab30f9fc05380daae87d617e2ad11571ab23d7c13a29bb54/toolz-0.12.1-py3-none-any.whl", hash = "sha256:d22731364c07d72eea0a0ad45bafb2c2937ab6fd38a3507bf55eae8744aa7d85", size = 56121, upload-time = "2024-01-24T03:28:25.97Z" }, ] [[package]] @@ -4911,6 +5754,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/66/4d/35352043ee0eaffdeff154fad67cd4a31dbed7ff8e3be1cc4549717d6d51/torch-2.10.0-cp314-cp314t-win_amd64.whl", hash = "sha256:71283a373f0ee2c89e0f0d5f446039bdabe8dbc3c9ccf35f0f784908b0acd185", size = 113995816, upload-time = "2026-01-21T16:22:05.312Z" }, ] +[[package]] +name = "torchmetrics" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lightning-utilities" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "torch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/34/39b8b749333db56c0585d7a11fa62a283c087bb1dfc897d69fb8cedbefb1/torchmetrics-1.9.0.tar.gz", hash = "sha256:a488609948600df52d3db4fcdab02e62aab2a85ef34da67037dc3e65b8512faa", size = 581765, upload-time = "2026-03-09T17:41:22.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/a2/c7f6ebf546f8f644edf0f999aa98ece106986a77a7b922316bf6414ff825/torchmetrics-1.9.0-py3-none-any.whl", hash = "sha256:bfdcbff3dd1d96b3374bb2496eb39f23c4b28b8a845b6a18c313688e0d2d9ca1", size = 983384, upload-time = "2026-03-09T17:41:19.756Z" }, +] + [[package]] name = "tornado" version = "6.5.7" @@ -4928,6 +5786,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796", size = 450506, upload-time = "2026-06-08T17:34:49.702Z" }, ] +[[package]] +name = "toto-2" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dd-unit-scaling" }, + { name = "einops" }, + { name = "gluonts", extra = ["torch"] }, + { name = "huggingface-hub" }, + { name = "jaxtyping" }, + { name = "matplotlib" }, + { name = "numpy" }, + { name = "safetensors" }, + { name = "torch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/46/e7c29d41892dac0aa8944596f128b500fc434a08fc9e11ac546c0e32ad6c/toto_2-2.0.0.tar.gz", hash = "sha256:ce23ce328c593b8baaedcd679dfc49c4296cb06c4a46654caab7ce66ef4087a7", size = 24024, upload-time = "2026-06-04T08:54:31.948Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/af/30d12ba1776ff21800bed679aaa60befcb99dec5841e9b25d0d2f0e72200/toto_2-2.0.0-py3-none-any.whl", hash = "sha256:5eb922f8162a800d6d31cffb10e3f4c079276b12c41e272129e5b4a930943f71", size = 18734, upload-time = "2026-06-04T08:54:30.75Z" }, +] + +[[package]] +name = "toto-models" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "toto-2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/6b/3c036fe538076d1d3458fbe0213d969a3c6e6243cabaafb4cdae84185489/toto_models-1.0.0.tar.gz", hash = "sha256:344fcbd79b3cc2dd216f4489c04ee17ee80ffbfcaddce31d3376e2f7dfcb1f74", size = 2345, upload-time = "2026-06-04T08:54:38.811Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/72/bc2640cf64bf31f5e366804e040b682d2b583cefd3e767a8eb7d88a4808f/toto_models-1.0.0-py3-none-any.whl", hash = "sha256:7c77cb79f18e195909a3926a0279f4e28b21b653ce8e65ee384f9f28125208d4", size = 2382, upload-time = "2026-06-04T08:54:37.86Z" }, +] + [[package]] name = "tqdm" version = "4.67.3" @@ -4969,23 +5859,22 @@ wheels = [ [[package]] name = "transformers" -version = "4.57.6" +version = "5.17.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "filelock" }, { name = "huggingface-hub" }, { name = "numpy" }, { name = "packaging" }, { name = "pyyaml" }, { name = "regex" }, - { name = "requests" }, { name = "safetensors" }, { name = "tokenizers" }, { name = "tqdm" }, + { name = "typer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/35/67252acc1b929dc88b6602e8c4a982e64f31e733b804c14bc24b47da35e6/transformers-4.57.6.tar.gz", hash = "sha256:55e44126ece9dc0a291521b7e5492b572e6ef2766338a610b9ab5afbb70689d3", size = 10134912, upload-time = "2026-01-16T10:38:39.284Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/9e/750649904a065007a838981785b2bd8d9ff26154c6c341ac67d0b7f82c68/transformers-5.17.0.tar.gz", hash = "sha256:a153be279169b55b92d8000bf4af294aed684503d091cca7804da2dd8a9de000", size = 9817878, upload-time = "2026-09-09T15:39:56.886Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/b8/e484ef633af3887baeeb4b6ad12743363af7cce68ae51e938e00aaa0529d/transformers-4.57.6-py3-none-any.whl", hash = "sha256:4c9e9de11333ddfe5114bc872c9f370509198acf0b87a832a0ab9458e2bd0550", size = 11993498, upload-time = "2026-01-16T10:38:31.289Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d0/c502b60d684adbd98a8dc7d5bb866842772b816ac4354e4608be240041ae/transformers-5.17.0-py3-none-any.whl", hash = "sha256:78ec1ce21579b38dfb83950a0658cd119f87212a2fcfdff478096ce9d6c03801", size = 12295140, upload-time = "2026-09-09T15:39:53.746Z" }, ] [package.optional-dependencies] @@ -5006,6 +5895,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f6/56/6113c23ff46c00aae423333eb58b3e60bdfe9179d542781955a5e1514cb3/triton-3.6.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46bd1c1af4b6704e554cad2eeb3b0a6513a980d470ccfa63189737340c7746a7", size = 188397994, upload-time = "2026-01-20T16:01:14.236Z" }, ] +[[package]] +name = "tslearn" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "numba" }, + { name = "numpy" }, + { name = "scikit-learn" }, + { name = "scipy" }, + { name = "statsmodels" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/70/297bf11d18bf3b32d7883353ee96941546d66be97b5ad02ce0b6a5b6c3f6/tslearn-0.9.0.tar.gz", hash = "sha256:eb7d32b6876a5606275ebecbf9e0b22075490a5fc8d1d09e4c13c884b30a45af", size = 19349384, upload-time = "2026-07-02T13:45:17.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/e6/01f9034d2d3f76161f222ee2e7c6311770bf9e84c237c6b95571f8300a73/tslearn-0.9.0-py3-none-any.whl", hash = "sha256:38f5c60d8a7d4dea4f11d021d82244dbe1644bf874b351dbf96d3fff24a01de1", size = 401678, upload-time = "2026-07-02T13:45:13.751Z" }, +] + [[package]] name = "typer" version = "0.24.0" @@ -5075,6 +5981,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, ] +[[package]] +name = "unit-scaling" +version = "0.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docstring-parser" }, + { name = "einops" }, + { name = "tabulate" }, + { name = "torch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/3a/6e88c1d682900d05f5b2564cd45520d82a6fb75e2e7f5c41760ed49db6dc/unit_scaling-0.3.5.tar.gz", hash = "sha256:9df186addff1bca39e888f00fb51cda3e7a54440eb478b203cc585442ba15016", size = 4781359, upload-time = "2025-07-11T09:46:18.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/ba/bc682ddafc853e7d9f4ad89384679b9bc5052a5c44fbc52aa0caad591d25/unit_scaling-0.3.5-py3-none-any.whl", hash = "sha256:53c270afb1e8bf804a39612b544dafedf1065b0cbf79820296d843392bcfac2c", size = 78666, upload-time = "2025-07-11T09:46:16.941Z" }, +] + [[package]] name = "uri-template" version = "1.3.0" @@ -5128,6 +6049,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" }, ] +[[package]] +name = "wadler-lindig" +version = "0.1.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/67/cbae4bf7683a64755c2c1778c418fea96d00e34395bb91743f08bd951571/wadler_lindig-0.1.7.tar.gz", hash = "sha256:81d14d3fe77d441acf3ebd7f4aefac20c74128bf460e84b512806dccf7b2cd55", size = 15842, upload-time = "2025-06-18T07:00:42.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/96/04e7b441807b26b794da5b11e59ed7f83b2cf8af202bd7eba8ad2fa6046e/wadler_lindig-0.1.7-py3-none-any.whl", hash = "sha256:e3ec83835570fd0a9509f969162aeb9c65618f998b1f42918cfc8d45122fe953", size = 20516, upload-time = "2025-06-18T07:00:41.684Z" }, +] + [[package]] name = "wcmatch" version = "10.1" @@ -5178,47 +6108,33 @@ wheels = [ [[package]] name = "websockets" -version = "16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, - { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, - { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, - { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, - { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, - { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, - { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, - { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, - { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, - { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, - { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, - { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, - { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, - { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, - { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, - { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, - { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, - { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, - { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, - { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, - { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, - { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, - { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, - { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] [[package]]