diff --git a/.gitignore b/.gitignore
index f3e58ba936..630e9d96c7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -52,3 +52,4 @@ node_modules/
# Local env files
.env
+/http_service/docker-compose.override.yml
diff --git a/bugbug/models/__init__.py b/bugbug/models/__init__.py
index 5e08694d08..6b4b369166 100644
--- a/bugbug/models/__init__.py
+++ b/bugbug/models/__init__.py
@@ -23,6 +23,7 @@
"invalidcompatibilityreport": "bugbug.models.invalid_compatibility_report.InvalidCompatibilityReportModel",
"needsdiagnosis": "bugbug.models.needsdiagnosis.NeedsDiagnosisModel",
"performancebug": "bugbug.models.performancebug.PerformanceBugModel",
+ "perfregressionpredictor": "bugbug.models.perf_regression_predictor.PerfRegressionPredictorModel",
"qaneeded": "bugbug.models.qaneeded.QANeededModel",
"rcatype": "bugbug.models.rcatype.RCATypeModel",
"regression": "bugbug.models.regression.RegressionModel",
diff --git a/bugbug/models/perf_regression_predictor.py b/bugbug/models/perf_regression_predictor.py
new file mode 100644
index 0000000000..b7d14cb73d
--- /dev/null
+++ b/bugbug/models/perf_regression_predictor.py
@@ -0,0 +1,366 @@
+# -*- coding: utf-8 -*-
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this file,
+# You can obtain one at http://mozilla.org/MPL/2.0/.
+
+"""Inference-only perf regression predictor."""
+
+from __future__ import annotations
+
+import json
+import re
+from email import policy
+from email.parser import Parser
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+
+from bugbug.model import Model
+
+MODEL_NAME = "Perf Regression Predictor"
+MODEL_IDENTIFIER = "perfregressionpredictor"
+DEFAULT_MODEL_DIRECTORY = f"{MODEL_IDENTIFIER}model"
+POSITIVE_CLASS_ID = 1
+
+
+class CommitMessageCleaner:
+ """Remove common noisy prefixes from a commit message.
+
+ This intentionally mirrors the preprocessing used to prepare the model's
+ training data.
+ """
+
+ def __init__(self, clean_subject_only: bool = True) -> None:
+ self.clean_subject_only = clean_subject_only
+ prefix = r"(?:\[[^\]]+\]|\([^)]+\)|bug\s*#?\s*\d+\b)"
+ self.prefix_pattern = re.compile(
+ rf"^\s*(?:{prefix}\s*(?:[-–—:.,]\s*)?)+",
+ re.IGNORECASE,
+ )
+
+ def _clean_subject(self, subject: str) -> str:
+ return self.prefix_pattern.sub("", subject, count=1).strip()
+
+ def __call__(self, commit_message: str | None) -> str:
+ if commit_message is None:
+ return ""
+
+ lines = str(commit_message).splitlines()
+ if not lines:
+ return ""
+
+ if self.clean_subject_only:
+ for index, line in enumerate(lines):
+ if line.strip():
+ lines[index] = self._clean_subject(line)
+ break
+ return "\n".join(lines).strip("\n")
+
+ cleaned_lines = [
+ self._clean_subject(line) if index == 0 else line
+ for index, line in enumerate(lines)
+ ]
+ return "\n".join(cleaned_lines).strip("\n")
+
+
+class PatchCommitMessageExtractor:
+ """Extract a message from Git format-patch or Mercurial export content.
+
+ A Mercurial ``hg export`` (or Git ``format-patch``) bundles the commit
+ message together with the diff, so we need to peel the message off before
+ feeding the diff to the structuring logic.
+ """
+
+ def __init__(self) -> None:
+ self.subject_pattern = re.compile(r"^Subject:", re.MULTILINE)
+ self.body_end_pattern = re.compile(r"^---\s*$|^diff --git ", re.MULTILINE)
+
+ def __call__(self, patch: str) -> str | None:
+ if patch.startswith("# HG changeset patch"):
+ message_lines: list[str] = []
+ metadata_finished = False
+ for line in patch.splitlines()[1:]:
+ if not metadata_finished and (line.startswith("#") or not line.strip()):
+ continue
+ metadata_finished = True
+ if line.startswith(("diff -r ", "diff --git ")):
+ break
+ message_lines.append(line)
+ message = "\n".join(message_lines).strip()
+ return message or None
+
+ if self.subject_pattern.search(patch):
+ email_message = Parser(policy=policy.default).parsestr(patch)
+ subject = str(email_message.get("Subject", "")).strip()
+ body = email_message.get_payload()
+ if not isinstance(body, str):
+ body = ""
+ body = self.body_end_pattern.split(body, maxsplit=1)[0].strip()
+ message = "\n\n".join(part for part in (subject, body) if part)
+ return message or None
+
+ return None
+
+
+class DiffStructurer:
+ """Convert a Git or Mercurial diff to the model's structured format.
+
+ The input may be a bare diff or a full ``hg export`` / ``git format-patch``
+ payload that still carries the commit-message header; any preamble before
+ the first ``diff`` header is ignored.
+ """
+
+ def __init__(self) -> None:
+ self.git_header_pattern = re.compile(r"diff --git a/(.+?) b/(.+)")
+ self._reset()
+
+ def _reset(self) -> None:
+ self.output: list[str] = []
+ self.current_file: str | None = None
+ self.current_block_type: str | None = None
+ self.current_block_lines: list[str] = []
+ self.pending_binary_status: str | None = None
+ self.rename_from: str | None = None
+ self.rename_to: str | None = None
+ self.pending_rename = False
+
+ def _start_file(self, file_name: str) -> None:
+ self.current_file = file_name
+ self.output.extend(("", f" {file_name}"))
+
+ def _flush_block(self) -> None:
+ if self.current_block_type and self.current_block_lines:
+ self.output.append(f" <{self.current_block_type.upper()}>")
+ self.output.extend(f" {line}" for line in self.current_block_lines)
+ self.output.append(f" {self.current_block_type.upper()}>")
+ self.current_block_type = None
+ self.current_block_lines = []
+
+ def _flush_file(self) -> None:
+ if self.current_file:
+ self._flush_block()
+ if self.pending_rename and self.rename_from and self.rename_to:
+ self.output.append(f" File renamed from {self.rename_from}.")
+ elif self.pending_binary_status:
+ self.output.append(f" Binary file {self.pending_binary_status}.")
+ self.output.append("")
+
+ self.current_file = None
+ self.pending_binary_status = None
+ self.rename_from = None
+ self.rename_to = None
+ self.pending_rename = False
+
+ def __call__(self, diff_string: str) -> str:
+ self._reset()
+ started = False
+
+ for line in diff_string.strip().splitlines():
+ if not started:
+ if line.startswith(("diff -r", "diff --git")):
+ started = True
+ else:
+ continue
+
+ if line.startswith("diff -r"):
+ self._flush_file()
+ parts = line.split()
+ if len(parts) >= 4:
+ self._start_file(parts[-1])
+ continue
+
+ if line.startswith("diff --git"):
+ self._flush_file()
+ match = self.git_header_pattern.match(line)
+ if match:
+ self._start_file(match.group(2))
+ elif line.startswith("rename from "):
+ self.rename_from = line[len("rename from ") :].strip()
+ self.pending_rename = True
+ elif line.startswith("rename to "):
+ self.rename_to = line[len("rename to ") :].strip()
+ if not self.current_file:
+ self._start_file(self.rename_to)
+ elif line.startswith("--- "):
+ pass
+ elif line.startswith("+++ "):
+ pass
+ elif line.startswith("Binary files "):
+ self._flush_block()
+ self.pending_binary_status = "changed"
+ self._flush_file()
+ elif line.startswith("@@"):
+ self._flush_block()
+ elif line.startswith("-"):
+ if self.current_block_type != "REMOVED":
+ self._flush_block()
+ self.current_block_type = "REMOVED"
+ self.current_block_lines.append(line[1:].rstrip())
+ elif line.startswith("+"):
+ if self.current_block_type != "ADDED":
+ self._flush_block()
+ self.current_block_type = "ADDED"
+ self.current_block_lines.append(line[1:].rstrip())
+ else:
+ self._flush_block()
+
+ self._flush_file()
+ return "\n".join(self.output)
+
+
+class PerfRegressionPredictorModel(Model):
+ """Hugging Face sequence classifier used only for inference."""
+
+ training_supported = False
+
+ # Trained outside bugbug, so it is not in the Taskcluster index.
+ # When retraining, upload to a new versioned path instead of overwriting.
+ artifact_url = (
+ "https://storage.googleapis.com/models-dump-public/"
+ "perf-regression-predictor-v1.tar.zst"
+ )
+
+ def __init__(self, tokenizer: Any = None, transformer_model: Any = None) -> None:
+ super().__init__()
+ self.tokenizer = tokenizer
+ self.transformer_model = transformer_model
+ self.calculate_importance = False
+ self.model_directory: str | None = None
+ self.model_metadata: dict[str, Any] = {}
+ self.commit_message_cleaner = CommitMessageCleaner()
+ self.diff_structurer = DiffStructurer()
+
+ def build_model_input(self, commit_message: str | None, raw_diff: str) -> str:
+ """Build the exact text representation consumed during training."""
+ return "\n".join(
+ (
+ "",
+ self.commit_message_cleaner(commit_message),
+ "",
+ self.diff_structurer(raw_diff),
+ )
+ )
+
+ @classmethod
+ def load(cls, model_directory: str) -> "PerfRegressionPredictorModel":
+ """Load a local Hugging Face checkpoint directory."""
+ from transformers import AutoModelForSequenceClassification, AutoTokenizer
+
+ tokenizer = AutoTokenizer.from_pretrained(
+ model_directory,
+ local_files_only=True,
+ )
+ transformer_model = AutoModelForSequenceClassification.from_pretrained(
+ model_directory,
+ local_files_only=True,
+ )
+ # The service runs inference on CPU. Converting here also makes
+ # checkpoints saved in bfloat16 usable on CPUs without bfloat16
+ # acceleration.
+ transformer_model.float().to("cpu")
+ transformer_model.eval()
+
+ model = cls(tokenizer=tokenizer, transformer_model=transformer_model)
+ model.model_directory = model_directory
+
+ metadata_path = Path(model_directory) / "bugbug_model.json"
+ if metadata_path.exists():
+ with metadata_path.open(encoding="utf-8") as metadata_file:
+ model.model_metadata = json.load(metadata_file)
+
+ model._validate_checkpoint()
+ return model
+
+ def _validate_checkpoint(self) -> None:
+ if self.tokenizer is None or self.transformer_model is None:
+ raise ValueError("The tokenizer and transformer model must both be loaded")
+
+ config = self.transformer_model.config
+ if int(config.num_labels) != 2:
+ raise ValueError("Perf Regression Predictor requires exactly two labels")
+
+ id2label = {
+ int(label_id): label
+ for label_id, label in getattr(config, "id2label", {}).items()
+ }
+ if id2label and id2label.get(POSITIVE_CLASS_ID) not in (
+ "POSITIVE",
+ "1",
+ 1,
+ ):
+ raise ValueError(
+ "Checkpoint label 1 must be the positive performance-regression class"
+ )
+
+ required_tokens = {
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ }
+ tokenizer_tokens = set(self.tokenizer.get_added_vocab())
+ missing_tokens = required_tokens - tokenizer_tokens
+ if missing_tokens:
+ raise ValueError(
+ "Checkpoint tokenizer is missing structural tokens: "
+ f"{sorted(missing_tokens)}"
+ )
+
+ @property
+ def max_length(self) -> int:
+ tokenizer_limit = int(self.tokenizer.model_max_length)
+ model_limit = int(self.transformer_model.config.max_position_embeddings)
+ return min(tokenizer_limit, model_limit)
+
+ def classify(
+ self,
+ items,
+ probabilities=False,
+ importances=False,
+ importance_cutoff=0.15,
+ background_dataset=None,
+ ):
+ """Classify commit-message/diff dictionaries."""
+ del importance_cutoff, background_dataset
+ if importances:
+ raise ValueError("Transformer feature importances are not supported")
+
+ if not isinstance(items, list):
+ items = [items]
+ if not items:
+ return np.empty((0, 2)) if probabilities else np.empty((0,), dtype=int)
+
+ prompts = [
+ self.build_model_input(item.get("commit_message"), item["diff"])
+ for item in items
+ ]
+ encoded = self.tokenizer(
+ prompts,
+ truncation=True,
+ max_length=self.max_length,
+ padding=True,
+ return_tensors="pt",
+ )
+
+ import torch
+
+ with torch.inference_mode():
+ logits = self.transformer_model(**encoded).logits.float()
+ class_probabilities = torch.softmax(logits, dim=-1).cpu().numpy()
+
+ if probabilities:
+ return class_probabilities
+ return class_probabilities.argmax(axis=-1)
+
+ def get_extra_data(self) -> dict[str, Any]:
+ return {
+ "model_name": MODEL_NAME,
+ "model_version": self.model_metadata.get("model_version"),
+ "max_length": self.max_length,
+ "calibrated": False,
+ }
diff --git a/bugbug/repository.py b/bugbug/repository.py
index 6110f04aa3..c94abf1848 100644
--- a/bugbug/repository.py
+++ b/bugbug/repository.py
@@ -1597,6 +1597,16 @@ def trigger_pull() -> None:
trigger_pull()
+def get_commit_patches(repo_dir: str, revs: list[bytes]) -> list[bytes]:
+ """Export each revision as its own git-formatted patch.
+
+ Each patch is a full ``hg export`` payload, i.e. it carries the commit
+ message header followed by the diff.
+ """
+ with hglib.open(repo_dir) as hg:
+ return [hg.export(revs=[rev], git=True) for rev in revs]
+
+
def import_commits(repo_dir: str, base_rev: str, patch: bytes) -> list[bytes]:
"""Import commits from a git format-patch style patches into a Mercurial repository."""
with hglib.open(repo_dir) as hg:
diff --git a/bugbug/utils.py b/bugbug/utils.py
index 3e3cbc8f4c..35a7eacd3f 100644
--- a/bugbug/utils.py
+++ b/bugbug/utils.py
@@ -284,21 +284,46 @@ def get_last_modified(url: str) -> datetime | None:
return dateutil.parser.parse(r.headers["Last-Modified"])
+def _model_artifact_url(model_name: str) -> str | None:
+ """Return the model's `artifact_url` if published outside the Taskcluster index."""
+ # Imported here to avoid a circular import (model modules import utils).
+ from bugbug.models import get_model_class
+
+ try:
+ model_class = get_model_class(model_name)
+ except ValueError:
+ # Models missing from the registry may still exist in the Taskcluster
+ # index, so let download_model() try the index.
+ return None
+
+ return getattr(model_class, "artifact_url", None)
+
+
def download_model(model_name: str) -> str:
- version = os.getenv("TAG")
- if not version:
- try:
- version = f"v{get_bugbug_version()}"
- except PackageNotFoundError:
- version = "latest"
+ """Download a model and unpack it to a `{model_name}model` directory.
+ Models are fetched from the Taskcluster index, unless the registered model
+ class declares an `artifact_url`, in which case that URL is used instead.
+ """
path = f"{model_name}model"
- url = f"https://community-tc.services.mozilla.com/api/index/v1/task/project.bugbug.train_{model_name}.{version}/artifacts/public/{path}.tar.zst"
+ archive = f"{path}.tar.zst"
+
+ url = _model_artifact_url(model_name)
+ if url is None:
+ version = os.getenv("TAG")
+ if not version:
+ try:
+ version = f"v{get_bugbug_version()}"
+ except PackageNotFoundError:
+ version = "latest"
+ url = f"https://community-tc.services.mozilla.com/api/index/v1/task/project.bugbug.train_{model_name}.{version}/artifacts/public/{archive}"
+
logger.info("Downloading %s...", url)
- updated = download_check_etag(url)
+ # Save it under our own name; the URL can end in anything.
+ updated = download_check_etag(url, archive)
if updated:
- extract_tar_zst(f"{path}.tar.zst")
- os.remove(f"{path}.tar.zst")
+ extract_tar_zst(archive)
+ os.remove(archive)
assert os.path.exists(path), "Decompressed directory exists"
return path
diff --git a/docs/README.md b/docs/README.md
index 80931d0f47..91e923487f 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -3,3 +3,4 @@
Detailed documentation per model
- [Regressor model for predicting risky commits](models/regressor.md)
+- [Perf Regression Predictor](models/perf-regression-predictor.md)
diff --git a/docs/models/perf-regression-predictor.md b/docs/models/perf-regression-predictor.md
new file mode 100644
index 0000000000..6435524ede
--- /dev/null
+++ b/docs/models/perf-regression-predictor.md
@@ -0,0 +1,139 @@
+# Perf Regression Predictor
+
+The Perf Regression Predictor is an inference-only binary transformer
+model. It predicts whether a commit is likely to introduce a performance
+regression.
+
+The HTTP service resolves a push `(branch, rev)` server-side against its own
+local Mercurial clone (the same clone `schedule_tests` uses), loads the full
+stack of commits with `automationrelevance`, and scores every commit in the
+push separately. The per-commit input is that commit's message plus its diff,
+both taken from an `hg export` of the commit. Before inference, leading
+bracketed tags, parenthesized tags, and prefixes such as `Bug 123456` or
+`Bug #123456` are removed from the first non-empty line of the commit message.
+The diff is converted to the structured representation used to train the
+checkpoint. The combined text is truncated to the checkpoint's context window
+(512 tokens for the current CodeBERT checkpoint).
+
+Each commit gets its own `risk_score`; the push-level `risk_score` is the
+maximum across the commits in the stack.
+
+The `risk_score` is the uncalibrated softmax probability for positive class
+`1`. It must not be interpreted as a calibrated probability for operational
+decision-making.
+
+## Local inference with the CLI
+
+The CLI runs preprocessing and model inference directly. It does not start the
+HTTP service, Redis, an RQ worker, or resolve pushes from a Mercurial clone.
+
+From the Bugbug repository root, run the included sample patch against a local
+Hugging Face checkpoint:
+
+```sh
+cd /path/to/bugbug
+
+uv run --extra perf-regression-predictor \
+ bugbug-predict-perf-regression \
+ --model-dir /absolute/path/to/predictor_model \
+ --patch-file examples/perf_regression_predictor.patch
+```
+
+The sample is a Git `format-patch`, so the command extracts its commit message
+automatically. For a raw diff, provide the message directly:
+
+```sh
+uv run --extra perf-regression-predictor \
+ bugbug-predict-perf-regression \
+ --model-dir /absolute/path/to/predictor_model \
+ --patch-file /absolute/path/to/change.patch \
+ --commit-message "Bug 123456 - Improve rendering performance"
+```
+
+Alternatively, use `--commit-message-file /path/to/commit-message.txt`. If no
+message argument is provided, the CLI tries Git `format-patch` and Mercurial
+export formats. A raw diff without a detectable message is still accepted,
+with a warning.
+
+The command prints the predicted binary `class`, both class probabilities in
+`prob`, and the uncalibrated positive-class `risk_score`.
+
+## HTTP service
+
+The generic push prediction endpoint (`/{model_name}/predict/push/{branch}/{rev}`)
+uses the service's existing Redis/RQ worker and API-key presence check:
+
+```text
+GET /perfregressionpredictor/predict/push/{branch}/{rev}
+X-Api-Key: ...
+```
+
+`branch` is an hg.mozilla.org repository path such as `integration/autoland` or
+`try` (the alias `autoland` is accepted for `integration/autoland`), and `rev`
+is a changeset in that push. The first request normally returns
+`202 {"ready": false}`. Poll the same URL until it returns `200`. The worker
+resolves the push from its own local hg clone, so no Phabricator credentials are
+needed; if the push cannot be found the result is `{"available": false}`.
+
+See [HTTP service local development](../../http_service/README.perf-regression-predictor.md)
+for the complete Docker Compose setup, including the local model mount.
+
+Example result:
+
+```json
+{
+ "branch": "integration/autoland",
+ "rev": "76383a875678",
+ "risk_score": 0.75,
+ "commits": [
+ {
+ "node": "76383a875678",
+ "prob": [0.25, 0.75],
+ "class": 1,
+ "risk_score": 0.75
+ }
+ ],
+ "extra_data": {
+ "model_name": "Perf Regression Predictor",
+ "model_version": null,
+ "max_length": 512,
+ "calibrated": false,
+ "commit_count": 1
+ }
+}
+```
+
+The push-level `risk_score` is the maximum of the per-commit `risk_score`
+values in `commits`.
+
+## Model artifact
+
+This checkpoint is trained outside Bugbug, so no `bugbug-train` workflow is
+registered for it and no train task publishes an artifact for it to the
+Taskcluster index. Instead the model class declares where its archive is
+published:
+
+```python
+class PerfRegressionPredictorModel(Model):
+ training_supported = False
+ artifact_url = "https://storage.googleapis.com/.../perf-regression-predictor-v1.tar.zst"
+```
+
+`utils.download_model()` fetches that URL for any model declaring an
+`artifact_url` and falls back to the Taskcluster index for the rest, so every
+caller (including the http service's `download_models()`) downloads this model
+alongside the others with no special casing.
+
+The archive must be a `.tar.zst` holding a single directory named
+`perfregressionpredictormodel`, which is what the rest of the service expects
+on disk. When the model is retrained, publish it under a new versioned path
+and update `artifact_url`, rather than overwriting the existing object: that
+keeps a change of the deployed model a reviewable diff.
+
+Once training moves into Bugbug, the model can implement `train()`, set
+`training_supported = True` and drop `artifact_url`, at which point it is
+published by a normal `bugbug-train` task like every other model.
+
+For local Docker development before the artifact is published, mount the local
+checkpoint at `/code/perfregressionpredictormodel` in the background
+worker. This is the same fixed-directory convention used by the other models.
diff --git a/http_service/README.perf-regression-predictor.md b/http_service/README.perf-regression-predictor.md
new file mode 100644
index 0000000000..794aad6a59
--- /dev/null
+++ b/http_service/README.perf-regression-predictor.md
@@ -0,0 +1,201 @@
+# Perf Regression Predictor: HTTP Service Local Development
+
+This describes the local Docker Compose setup used to exercise the Perf
+Regression Predictor endpoint through the HTTP service and its background
+worker.
+
+Run Docker Compose commands from this directory, not the repository root. The
+root may have a different Compose application with unrelated credentials.
+
+```sh
+cd /path/to/bugbug/http_service
+```
+
+## Services
+
+The local Compose file defines:
+
+- `redis`: local Redis used by the HTTP service and RQ worker.
+- `bugbug-http-service`: Flask HTTP API on `http://localhost:8000`.
+- `bugbug-http-service-bg-worker`: background worker that downloads/loads models
+ and processes queued prediction jobs.
+- `bugbug-http-service-rq-dasboard`: optional local RQ dashboard on
+ `http://localhost:9181`.
+
+## Environment
+
+Most environment variables are optional for local startup, but individual
+endpoints may need service-specific credentials:
+
+- `BUGBUG_BUGZILLA_TOKEN`: needed by Bugzilla bug classification endpoints.
+- `BUGBUG_GITHUB_TOKEN`: needed by GitHub issue classification endpoints.
+- `BUGBUG_REPO_DIR`: local Mercurial clone used by push-based endpoints (test
+ selection and the perf regression predictor); defaults to a temporary
+ `bugbug-hg` directory.
+- `BUGBUG_ALLOW_MISSING_MODELS=1`: useful for local development when you only
+ need one model and do not have every model artifact locally.
+
+The API checks for the presence of an `X-Api-Key` header. For local testing, the
+value can be any non-empty string unless you are testing deployment-specific
+authentication behavior.
+
+If you need local secrets, create `.env` in this directory
+(`http_service/.env` from the repository root):
+
+```dotenv
+BUGBUG_BUGZILLA_TOKEN=
+BUGBUG_GITHUB_TOKEN=
+BUGBUG_ALLOW_MISSING_MODELS=1
+```
+
+Protect the file:
+
+```sh
+chmod 600 .env
+```
+
+`.env` is ignored by Git. Do not put real tokens in tracked Compose files.
+
+## Start The Service
+
+Confirm that you are using the HTTP service Compose application:
+
+```sh
+docker compose config --services
+```
+
+Start the core services:
+
+```sh
+docker compose up --build \
+ redis \
+ bugbug-http-service \
+ bugbug-http-service-bg-worker
+```
+
+The background worker downloads and validates model artifacts during startup
+unless the image was built with `CHECK_MODELS=0`.
+
+In another terminal, follow the worker logs:
+
+```sh
+docker compose logs -f bugbug-http-service-bg-worker
+```
+
+## Test A Generic Model Endpoint
+
+Use an endpoint for a model that the HTTP service exposes through the generic
+Bugzilla classifier route:
+
+```sh
+curl --compressed -sS \
+ -w '\nHTTP status: %{http_code}\n' \
+ -H "X-Api-Key: local-test" \
+ http://localhost:8000/component/predict/123456
+```
+
+The first request normally queues the job and returns:
+
+```text
+{"ready":false}
+HTTP status: 202
+```
+
+Repeat the same request after the worker finishes. A completed prediction
+returns HTTP 200.
+
+## Optional RQ Dashboard
+
+Start the dashboard when you want to inspect queued, running, or failed jobs:
+
+```sh
+docker compose up bugbug-http-service-rq-dasboard
+```
+
+Open:
+
+```text
+http://localhost:9181
+```
+
+This is a local debugging interface and should not be exposed publicly without
+authentication.
+
+## Stop The Service
+
+```sh
+docker compose down
+```
+
+## Perf Regression Predictor Setup
+
+The Perf Regression Predictor uses the same HTTP service and background
+worker. While the model artifact is unpublished, the only extra
+local-development piece it needs is a local Hugging Face checkpoint mounted at
+the standard model directory.
+
+The worker resolves each push `(branch, rev)` against its own local Mercurial
+clone (`BUGBUG_REPO_DIR`, defaulting to a temporary `bugbug-hg` directory),
+pulling the revision from `https://hg.mozilla.org/{branch}/` — the same
+mechanism the `/push/.../schedules` test-selection endpoint uses. No Phabricator
+credentials are required.
+
+### Create The Compose Override
+
+Create `http_service/docker-compose.override.yml` and replace the source side
+of the volume with the absolute path to your local Hugging Face checkpoint:
+
+```yaml
+services:
+ bugbug-http-service-bg-worker:
+ build:
+ args:
+ CHECK_MODELS: "0"
+ environment:
+ BUGBUG_ALLOW_MISSING_MODELS: "1"
+ volumes:
+ - /absolute/path/to/predictor_model:/code/perfregressionpredictormodel:ro
+```
+
+The host directory can be anywhere. Inside the container it must be mounted at:
+
+```text
+/code/perfregressionpredictormodel
+```
+
+That is the same fixed model-directory convention used by the other HTTP worker
+models. `CHECK_MODELS=0` skips startup artifact downloads while this model is
+unpublished, and `BUGBUG_ALLOW_MISSING_MODELS=1` lets the worker start without
+unrelated model artifacts.
+
+Start the core services as usual:
+
+```sh
+docker compose up --build \
+ redis \
+ bugbug-http-service \
+ bugbug-http-service-bg-worker
+```
+
+Verify that the checkpoint is mounted:
+
+```sh
+docker compose exec bugbug-http-service-bg-worker \
+ test -f /code/perfregressionpredictormodel/config.json \
+ && echo "Model is mounted"
+```
+
+Request a prediction for a push, identified by its branch and revision:
+
+```sh
+curl --compressed -sS \
+ -w '\nHTTP status: %{http_code}\n' \
+ -H "X-Api-Key: local-test" \
+ http://localhost:8000/perfregressionpredictor/predict/push/autoland/REV
+```
+
+The first request normally returns HTTP 202. Repeat the same request until it
+returns HTTP 200.
+
+For direct inference without Docker, Redis, or the HTTP API, see
+the [Perf Regression Predictor CLI documentation](../docs/models/perf-regression-predictor.md#local-inference-with-the-cli).
diff --git a/http_service/bugbug_http/app.py b/http_service/bugbug_http/app.py
index 60c4800bee..9557a1f4dd 100644
--- a/http_service/bugbug_http/app.py
+++ b/http_service/bugbug_http/app.py
@@ -32,6 +32,7 @@
from bugbug import bugzilla, get_bugbug_version, utils
from bugbug_http.models import (
MODELS_NAMES,
+ PUSH_CLASSIFIERS,
classify_broken_site_report,
classify_bug,
classify_issue,
@@ -108,6 +109,21 @@ class BugPrediction(Schema):
extra_data = fields.Dict()
+class PerformanceRegressionCommitPrediction(Schema):
+ node = fields.String()
+ prob = fields.List(fields.Float())
+ predicted_class = fields.Integer(data_key="class")
+ risk_score = fields.Float()
+
+
+class PerformanceRegressionPrediction(Schema):
+ branch = fields.String()
+ rev = fields.String()
+ risk_score = fields.Float()
+ commits = fields.List(fields.Nested(PerformanceRegressionCommitPrediction))
+ extra_data = fields.Dict()
+
+
class NotAvailableYet(Schema):
ready = fields.Boolean(metadata={"enum": [False]})
@@ -116,6 +132,15 @@ class ModelName(Schema):
model_name = fields.String(metadata={"enum": MODELS_NAMES, "example": "component"})
+class PushModelName(Schema):
+ model_name = fields.String(
+ metadata={
+ "enum": sorted(PUSH_CLASSIFIERS),
+ "example": "perfregressionpredictor",
+ }
+ )
+
+
class UnauthorizedError(Schema):
message = fields.String(dump_default="Error, missing X-API-KEY")
@@ -130,8 +155,17 @@ class Schedules(Schema):
spec.components.schema(BugPrediction.__name__, schema=BugPrediction)
+spec.components.schema(
+ PerformanceRegressionCommitPrediction.__name__,
+ schema=PerformanceRegressionCommitPrediction,
+)
+spec.components.schema(
+ PerformanceRegressionPrediction.__name__,
+ schema=PerformanceRegressionPrediction,
+)
spec.components.schema(NotAvailableYet.__name__, schema=NotAvailableYet)
spec.components.schema(ModelName.__name__, schema=ModelName)
+spec.components.schema(PushModelName.__name__, schema=PushModelName)
spec.components.schema(UnauthorizedError.__name__, schema=UnauthorizedError)
spec.components.schema(BranchName.__name__, schema=BranchName)
spec.components.schema(Schedules.__name__, schema=Schedules)
@@ -522,6 +556,76 @@ def model_prediction(model_name, bug_id):
return compress_response(data, status_code)
+@application.route("//predict/push//")
+@cross_origin()
+def model_prediction_push(model_name: str, branch: str, rev: str):
+ """
+ ---
+ get:
+ description: Classify a push using the given model, answer either 200 if the push is processed or 202 if the push is being processed
+ summary: Classify a single push
+ parameters:
+ - name: model_name
+ in: path
+ schema: PushModelName
+ - name: branch
+ in: path
+ required: true
+ schema:
+ BranchName
+ - name: rev
+ in: path
+ required: true
+ schema:
+ type: str
+ example: 76383a875678
+ responses:
+ 200:
+ description: A single push prediction
+ content:
+ application/json:
+ schema: PerformanceRegressionPrediction
+ 202:
+ description: A temporary answer for the push being processed
+ content:
+ application/json:
+ schema: NotAvailableYet
+ 401:
+ description: API key is missing
+ content:
+ application/json:
+ schema: UnauthorizedError
+ """
+ if not request.headers.get(API_TOKEN):
+ return jsonify(UnauthorizedError().dump({})), 401
+
+ if model_name not in PUSH_CLASSIFIERS:
+ return (
+ jsonify({"error": f"Model {model_name} doesn't support push predictions"}),
+ 404,
+ )
+
+ # Support the string 'autoland' for convenience.
+ if branch == "autoland":
+ branch = "integration/autoland"
+
+ LOGGER.info(
+ "Received %s push prediction request for %s @ %s", model_name, branch, rev
+ )
+
+ job = JobInfo(PUSH_CLASSIFIERS[model_name], branch, rev)
+ data = get_result(job)
+ status_code = 200
+
+ if not data:
+ if not is_pending(job):
+ schedule_job(job)
+ status_code = 202
+ data = {"ready": False}
+
+ return compress_response(data, status_code)
+
+
@application.route(
"//predict/github///"
)
diff --git a/http_service/bugbug_http/download_models.py b/http_service/bugbug_http/download_models.py
index 5e2d421807..b3a61a19a7 100644
--- a/http_service/bugbug_http/download_models.py
+++ b/http_service/bugbug_http/download_models.py
@@ -7,13 +7,13 @@
from bugbug import utils
from bugbug_http import ALLOW_MISSING_MODELS
-from bugbug_http.models import MODEL_CACHE, MODELS_NAMES
+from bugbug_http.models import MODEL_CACHE, MODELS_TO_DOWNLOAD
LOGGER = logging.getLogger()
def download_models():
- for model_name in MODELS_NAMES:
+ for model_name in MODELS_TO_DOWNLOAD:
utils.download_model(model_name)
# Try loading the model
try:
diff --git a/http_service/bugbug_http/models.py b/http_service/bugbug_http/models.py
index 5fd17b9c4f..32d2ceccd9 100644
--- a/http_service/bugbug_http/models.py
+++ b/http_service/bugbug_http/models.py
@@ -18,7 +18,11 @@
from bugbug import bugzilla, repository, test_scheduling, utils
from bugbug.github import Github
from bugbug.model import Model
-from bugbug.models import testselect
+from bugbug.models import get_model_class, testselect
+from bugbug.models.perf_regression_predictor import (
+ MODEL_IDENTIFIER as PERF_REGRESSION_PREDICTOR,
+)
+from bugbug.models.perf_regression_predictor import PatchCommitMessageExtractor
from bugbug.utils import get_hgmo_stack
from bugbug_http.readthrough_cache import ReadthroughTTLCache
@@ -41,6 +45,7 @@
"worksforme",
"fenixcomponent",
]
+PERF_REGRESSION_LOG_PREFIX = "[perf-regression-predictor]"
DEFAULT_EXPIRATION_TTL = 7 * 24 * 3600 # A week
url = urlparse(os.environ.get("REDIS_URL", "redis://localhost/0"))
@@ -53,8 +58,14 @@
ssl_cert_reqs=None,
)
+
+def load_model(model_name: str) -> Model:
+ """Load a model using the implementation registered for its name."""
+ return get_model_class(model_name).load(f"{model_name}model")
+
+
MODEL_CACHE: ReadthroughTTLCache[str, Model] = ReadthroughTTLCache(
- timedelta(hours=1), lambda m: Model.load(f"{m}model")
+ timedelta(hours=1), load_model
)
MODEL_CACHE.start_ttl_thread()
@@ -225,6 +236,122 @@ def classify_broken_site_report(model_name: str, reports_data: list[dict]) -> st
return "OK"
+def classify_perf_regression(branch: str, rev: str) -> str:
+ """Predict performance-regression risk for a push.
+
+ Mirrors :func:`schedule_tests`: the push is resolved server-side against
+ the service's own local hg clone. Every commit in the push is scored
+ separately and the top-level ``risk_score`` is the maximum across commits.
+ """
+ from bugbug_http import REPO_DIR
+ from bugbug_http.app import JobInfo
+
+ job = JobInfo(classify_perf_regression, branch, rev)
+ LOGGER.info(
+ "%s Processing prediction for %s @ %s",
+ PERF_REGRESSION_LOG_PREFIX,
+ branch,
+ rev,
+ )
+
+ # Pull the revision to the local repository.
+ LOGGER.info(
+ "%s Pulling commits from the remote repository...",
+ PERF_REGRESSION_LOG_PREFIX,
+ )
+ repository.pull(REPO_DIR, branch, rev, update=False)
+
+ # Load the full stack of patches leading to that revision.
+ LOGGER.info(
+ "%s Loading commits to analyze using automationrelevance...",
+ PERF_REGRESSION_LOG_PREFIX,
+ )
+ try:
+ revs = get_hgmo_stack(branch, rev)
+ except requests.exceptions.RequestException:
+ LOGGER.warning(
+ "%s Push not found for %s @ %s!",
+ PERF_REGRESSION_LOG_PREFIX,
+ branch,
+ rev,
+ )
+ setkey(job.result_key, orjson.dumps({"available": False}))
+ return "OK"
+
+ if not revs:
+ LOGGER.warning(
+ "%s No commits to analyze for %s @ %s",
+ PERF_REGRESSION_LOG_PREFIX,
+ branch,
+ rev,
+ )
+ setkey(job.result_key, orjson.dumps({"available": False}))
+ return "OK"
+
+ # Export each commit as its own patch (commit message + diff) from the
+ # local clone.
+ patches = repository.get_commit_patches(REPO_DIR, revs)
+
+ model = MODEL_CACHE.get(PERF_REGRESSION_PREDICTOR)
+
+ # Score each commit separately. The service runs inference on CPU,
+ # so we classify one at a time to keep memory flat.
+ extract_commit_message = PatchCommitMessageExtractor()
+ commits = []
+ for rev_node, patch in zip(revs, patches):
+ patch_text = patch.decode("utf-8", "replace")
+ commit_probabilities = model.classify(
+ [
+ {
+ "commit_message": extract_commit_message(patch_text) or "",
+ "diff": patch_text,
+ }
+ ],
+ probabilities=True,
+ )[0]
+ commits.append(
+ {
+ "node": rev_node.decode("ascii"),
+ "prob": commit_probabilities.tolist(),
+ "class": int(commit_probabilities.argmax()),
+ "risk_score": float(commit_probabilities[1]),
+ }
+ )
+
+ risk_score = max(commit["risk_score"] for commit in commits)
+
+ data = {
+ "branch": branch,
+ "rev": rev,
+ "risk_score": risk_score,
+ "commits": commits,
+ "extra_data": {
+ **model.get_extra_data(),
+ "commit_count": len(commits),
+ },
+ }
+ setkey(job.result_key, orjson.dumps(data), compress=True)
+ LOGGER.info(
+ "%s Finished prediction for %s @ %s, commit_count=%d, risk_score=%f",
+ PERF_REGRESSION_LOG_PREFIX,
+ branch,
+ rev,
+ len(commits),
+ risk_score,
+ )
+ return "OK"
+
+
+# Models that classify a push (branch + revision) rather than a bug or an
+# issue, keyed by the model name accepted by the push prediction endpoint.
+PUSH_CLASSIFIERS = {
+ PERF_REGRESSION_PREDICTOR: classify_perf_regression,
+}
+
+# Every model served by an endpoint must be downloaded at startup.
+MODELS_TO_DOWNLOAD = [*MODELS_NAMES, *PUSH_CLASSIFIERS]
+
+
@lru_cache(maxsize=None)
def get_known_tasks() -> tuple[str, ...]:
with open("known_tasks", "r") as f:
diff --git a/http_service/docker-compose.yml b/http_service/docker-compose.yml
index 31e0e1d671..90944a07e7 100644
--- a/http_service/docker-compose.yml
+++ b/http_service/docker-compose.yml
@@ -39,6 +39,8 @@ services:
- REDIS_URL=redis://redis:6379/0
- BUGBUG_ALLOW_MISSING_MODELS
- BUGBUG_REPO_DIR
+ - PHABRICATOR_API_KEY
+ - PHABRICATOR_URL
- SENTRY_DSN
depends_on:
- redis
@@ -61,7 +63,7 @@ services:
- redis
redis:
- image: redis:4
+ image: redis:7
ports:
- target: 6379
published: 6379
diff --git a/http_service/pyproject.toml b/http_service/pyproject.toml
index d14fdc9fc2..039361b8f6 100644
--- a/http_service/pyproject.toml
+++ b/http_service/pyproject.toml
@@ -17,7 +17,7 @@ classifiers = [
dependencies = [
"apispec-webframeworks~=1.2.0",
"apispec[yaml]~=6.10.0",
- "bugbug",
+ "bugbug[perf-regression-predictor]",
"cerberus~=1.3.8",
"Flask~=3.1.3",
"flask-apispec~=0.11.4",
diff --git a/http_service/tests/test_perf_regression_predictor.py b/http_service/tests/test_perf_regression_predictor.py
new file mode 100644
index 0000000000..8e35115231
--- /dev/null
+++ b/http_service/tests/test_perf_regression_predictor.py
@@ -0,0 +1,240 @@
+# -*- coding: utf-8 -*-
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this file,
+# You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import gzip
+
+import numpy as np
+import orjson
+import pytest
+import requests
+import zstandard
+
+from bugbug_http import models
+from bugbug_http.app import API_TOKEN, JobInfo
+
+PATCH_ONE = b"""\
+# HG changeset patch
+# User Developer
+# Date 1600000000 0
+# Node ID node1hash
+# Parent parent1hash
+Bug 123456 - Make rendering faster
+
+diff --git a/widget.py b/widget.py
+--- a/widget.py
++++ b/widget.py
+@@ -1 +1 @@
+-old_value = 1
++new_value = 2
+"""
+
+PATCH_TWO = b"""\
+# HG changeset patch
+# User Developer
+# Date 1600000001 0
+# Node ID node2hash
+# Parent node1hash
+Bug 123456 - Avoid repeated work
+
+diff --git a/loop.py b/loop.py
+--- a/loop.py
++++ b/loop.py
+@@ -1 +1 @@
+-slow()
++fast()
+"""
+
+
+def _response_json(response):
+ if response.headers.get("Content-Encoding") == "gzip":
+ return orjson.loads(gzip.decompress(response.data))
+ return response.json
+
+
+def _mock_repo(monkeypatch, revs, patches):
+ monkeypatch.setattr(models.repository, "pull", lambda *args, **kwargs: None)
+ monkeypatch.setattr(models, "get_hgmo_stack", lambda branch, rev: revs)
+ monkeypatch.setattr(
+ models.repository, "get_commit_patches", lambda repo_dir, r: patches
+ )
+
+
+def test_endpoint_queues_and_returns_prediction(client, jobs, add_result) -> None:
+ endpoint = "/perfregressionpredictor/predict/push/autoland/abc123def456"
+
+ unauthorized = client.get(endpoint)
+ assert unauthorized.status_code == 401
+
+ wrong_input_kind = client.get(
+ "/perfregressionpredictor/predict/123456",
+ headers={API_TOKEN: "test"},
+ )
+ assert wrong_input_kind.status_code == 404
+
+ # Only models registered as push classifiers can be used with the push
+ # prediction endpoint.
+ non_push_model = client.get(
+ "/component/predict/push/autoland/abc123def456",
+ headers={API_TOKEN: "test"},
+ )
+ assert non_push_model.status_code == 404
+
+ response = client.get(endpoint, headers={API_TOKEN: "test"})
+ assert response.status_code == 202
+ assert _response_json(response) == {"ready": False}
+
+ prediction = {
+ "branch": "integration/autoland",
+ "rev": "abc123def456",
+ "risk_score": 0.8,
+ "commits": [
+ {
+ "node": "node1hash",
+ "prob": [0.2, 0.8],
+ "class": 1,
+ "risk_score": 0.8,
+ }
+ ],
+ "extra_data": {"calibrated": False, "commit_count": 1},
+ }
+ keys = next(iter(jobs.values()))
+ add_result(keys[0], prediction)
+
+ response = client.get(endpoint, headers={API_TOKEN: "test"})
+ assert response.status_code == 200
+ assert _response_json(response) == prediction
+
+
+def test_worker_scores_each_commit(monkeypatch) -> None:
+ probabilities_by_message = {
+ "Bug 123456 - Make rendering faster": np.array([[0.2, 0.8]]),
+ "Bug 123456 - Avoid repeated work": np.array([[0.9, 0.1]]),
+ }
+
+ class FakeModel:
+ def __init__(self):
+ self.calls = []
+
+ def classify(self, items, probabilities=False):
+ assert probabilities
+ # Each commit is scored on its own, one item at a time, to keep
+ # peak memory independent of the number of commits in the push.
+ assert len(items) == 1
+ self.calls.append(items[0])
+ return probabilities_by_message[items[0]["commit_message"]]
+
+ def get_extra_data(self):
+ return {"calibrated": False}
+
+ fake_model = FakeModel()
+ _mock_repo(monkeypatch, [b"node1hash", b"node2hash"], [PATCH_ONE, PATCH_TWO])
+ monkeypatch.setattr(models.MODEL_CACHE, "get", lambda model_name: fake_model)
+
+ assert (
+ models.classify_perf_regression("integration/autoland", "abc123def456") == "OK"
+ )
+
+ # Each commit is scored separately, with its own message and full patch.
+ assert fake_model.calls == [
+ {
+ "commit_message": "Bug 123456 - Make rendering faster",
+ "diff": PATCH_ONE.decode("utf-8"),
+ },
+ {
+ "commit_message": "Bug 123456 - Avoid repeated work",
+ "diff": PATCH_TWO.decode("utf-8"),
+ },
+ ]
+
+ job = JobInfo(
+ models.classify_perf_regression, "integration/autoland", "abc123def456"
+ )
+ stored = models.redis.get(job.result_key)
+ assert stored is not None
+ result = orjson.loads(zstandard.ZstdDecompressor().decompress(stored))
+ assert result["branch"] == "integration/autoland"
+ assert result["rev"] == "abc123def456"
+ # Top-level risk score is the max across commits.
+ assert result["risk_score"] == 0.8
+ assert result["extra_data"]["commit_count"] == 2
+ assert result["commits"] == [
+ {"node": "node1hash", "prob": [0.2, 0.8], "class": 1, "risk_score": 0.8},
+ {"node": "node2hash", "prob": [0.9, 0.1], "class": 0, "risk_score": 0.1},
+ ]
+
+
+def test_worker_marks_missing_push_unavailable(monkeypatch) -> None:
+ monkeypatch.setattr(models.repository, "pull", lambda *args, **kwargs: None)
+
+ def raise_not_found(branch, rev):
+ raise requests.exceptions.HTTPError("not found")
+
+ monkeypatch.setattr(models, "get_hgmo_stack", raise_not_found)
+
+ def unexpected_model(model_name):
+ raise AssertionError("model should not be loaded for a missing push")
+
+ monkeypatch.setattr(models.MODEL_CACHE, "get", unexpected_model)
+
+ assert models.classify_perf_regression("try", "deadbeef") == "OK"
+ job = JobInfo(models.classify_perf_regression, "try", "deadbeef")
+ stored = models.redis.get(job.result_key)
+ assert stored is not None
+ assert orjson.loads(stored) == {"available": False}
+
+
+def test_worker_marks_empty_stack_unavailable(monkeypatch) -> None:
+ _mock_repo(monkeypatch, [], [])
+
+ def unexpected_model(model_name):
+ raise AssertionError("model should not be loaded for an empty stack")
+
+ monkeypatch.setattr(models.MODEL_CACHE, "get", unexpected_model)
+
+ assert models.classify_perf_regression("try", "deadbeef") == "OK"
+ job = JobInfo(models.classify_perf_regression, "try", "deadbeef")
+ stored = models.redis.get(job.result_key)
+ assert stored is not None
+ assert orjson.loads(stored) == {"available": False}
+
+
+def test_worker_propagates_model_loading_failure(monkeypatch) -> None:
+ _mock_repo(monkeypatch, [b"node1hash"], [PATCH_ONE])
+
+ def raise_missing_model(model_name):
+ raise FileNotFoundError("missing checkpoint")
+
+ monkeypatch.setattr(models.MODEL_CACHE, "get", raise_missing_model)
+
+ with pytest.raises(FileNotFoundError, match="missing checkpoint"):
+ models.classify_perf_regression("integration/autoland", "abc123def456")
+
+ job = JobInfo(
+ models.classify_perf_regression, "integration/autoland", "abc123def456"
+ )
+ assert models.redis.get(job.result_key) is None
+
+
+def test_load_model_uses_registered_model_class_and_standard_directory(
+ monkeypatch,
+) -> None:
+ loaded_directories: list[str] = []
+ sentinel = object()
+
+ class FakeModel:
+ @staticmethod
+ def load(model_directory):
+ loaded_directories.append(model_directory)
+ return sentinel
+
+ monkeypatch.setattr(
+ models,
+ "get_model_class",
+ lambda model_name: FakeModel,
+ )
+
+ loaded_model = models.load_model("somecustommodel")
+ assert loaded_model is sentinel
+ assert loaded_directories == ["somecustommodelmodel"]
diff --git a/pyproject.toml b/pyproject.toml
index cb4637b091..a471199976 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -71,6 +71,10 @@ nlp = [
"spacy==3.8.16",
]
nn = []
+perf-regression-predictor = [
+ "torch==2.13.0",
+ "transformers==5.15.0",
+]
[dependency-groups]
test = [
@@ -116,6 +120,7 @@ bugbug-fixed-comments = "scripts.inline_comments_data_collection:main"
bugbug-ci-failures-retriever = "scripts.retrieve_ci_failures:main"
bugbug-try-pushes-retriever = "scripts.retrieve_try_pushes:main"
bugbug-validate-review-context = "bugbug.tools.code_review.review_context_schema:main"
+bugbug-predict-perf-regression = "scripts.perf_regression_predictor:main"
[tool.hatch.version]
path = "VERSION"
@@ -139,6 +144,14 @@ exclude = [
[tool.uv.sources]
hackbot-runtime = { workspace = true }
agent-tools = { workspace = true }
+torch = [
+ { index = "pytorch-cpu", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
+]
+
+[[tool.uv.index]]
+name = "pytorch-cpu"
+url = "https://download.pytorch.org/whl/cpu"
+explicit = true
[tool.ruff]
extend-exclude = ["data"]
diff --git a/scripts/perf_regression_predictor.py b/scripts/perf_regression_predictor.py
new file mode 100644
index 0000000000..32b64f5bde
--- /dev/null
+++ b/scripts/perf_regression_predictor.py
@@ -0,0 +1,76 @@
+# -*- coding: utf-8 -*-
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this file,
+# You can obtain one at http://mozilla.org/MPL/2.0/.
+
+"""Run the Perf Regression Predictor against a local patch."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from pathlib import Path
+
+from bugbug.models.perf_regression_predictor import (
+ PatchCommitMessageExtractor,
+ PerfRegressionPredictorModel,
+)
+
+
+def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="Predict performance-regression risk from a local patch",
+ )
+ parser.add_argument(
+ "--model-dir",
+ required=True,
+ help="Hugging Face checkpoint directory",
+ )
+ parser.add_argument("--patch-file", required=True, type=Path)
+ commit_message = parser.add_mutually_exclusive_group()
+ commit_message.add_argument("--commit-message")
+ commit_message.add_argument("--commit-message-file", type=Path)
+ return parser.parse_args(argv)
+
+
+def main(argv: list[str] | None = None) -> int:
+ args = parse_args(argv)
+ raw_diff = args.patch_file.read_text(encoding="utf-8")
+
+ if args.commit_message is not None:
+ commit_message = args.commit_message
+ commit_message_source = "argument"
+ elif args.commit_message_file is not None:
+ commit_message = args.commit_message_file.read_text(encoding="utf-8")
+ commit_message_source = "file"
+ else:
+ commit_message = PatchCommitMessageExtractor()(raw_diff) or ""
+ commit_message_source = "patch" if commit_message else "none"
+ if not commit_message:
+ print(
+ "Warning: no commit message was found; predicting from the diff only",
+ file=sys.stderr,
+ )
+
+ model = PerfRegressionPredictorModel.load(args.model_dir)
+ probabilities = model.classify(
+ [{"commit_message": commit_message, "diff": raw_diff}],
+ probabilities=True,
+ )[0]
+ predicted_class = int(probabilities.argmax())
+ result = {
+ "prob": probabilities.tolist(),
+ "class": predicted_class,
+ "risk_score": float(probabilities[1]),
+ "extra_data": {
+ **model.get_extra_data(),
+ "commit_message_source": commit_message_source,
+ },
+ }
+ print(json.dumps(result, indent=2, sort_keys=True))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/trainer.py b/scripts/trainer.py
index 29df276581..da0828a5a5 100644
--- a/scripts/trainer.py
+++ b/scripts/trainer.py
@@ -91,6 +91,9 @@ def parse_args(args):
subparsers = main_parser.add_subparsers(title="model", dest="model", required=True)
for model_name in MODELS:
+ if not getattr(get_model_class(model_name), "training_supported", True):
+ continue
+
subparser = subparsers.add_parser(
model_name, parents=[parser], help=f"Train {model_name} model"
)
diff --git a/tests/test_perf_regression_predictor.py b/tests/test_perf_regression_predictor.py
new file mode 100644
index 0000000000..2cbe6dde1e
--- /dev/null
+++ b/tests/test_perf_regression_predictor.py
@@ -0,0 +1,220 @@
+# -*- coding: utf-8 -*-
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this file,
+# You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from bugbug.models.perf_regression_predictor import (
+ CommitMessageCleaner,
+ DiffStructurer,
+ PatchCommitMessageExtractor,
+ PerfRegressionPredictorModel,
+)
+
+clean_commit_message = CommitMessageCleaner()
+diff_to_structured_text = DiffStructurer()
+extract_commit_message_from_patch = PatchCommitMessageExtractor()
+
+RAW_DIFF = """\
+diff --git a/widget.py b/widget.py
+index 1111111..2222222 100644
+--- a/widget.py
++++ b/widget.py
+@@ -1 +1 @@
+-old_value = 1
++new_value = 2
+ context
+"""
+
+HG_DIFF = """\
+diff -r abcdef123456 widget.py
+--- a/widget.py
++++ b/widget.py
+@@ -1 +1 @@
+-old_value = 1
++new_value = 2
+ context
+"""
+
+BINARY_DIFF = """\
+diff --git a/image.png b/image.png
+index 1111111..2222222 100644
+Binary files a/image.png and b/image.png differ
+"""
+
+
+def test_clean_commit_message_preserves_body() -> None:
+ message = "[wpt PR 55677] - Rename tests\n\nKeep this body [verbatim]."
+ assert clean_commit_message(message) == (
+ "Rename tests\n\nKeep this body [verbatim]."
+ )
+
+
+def test_clean_commit_message_removes_bug_number_prefixes() -> None:
+ messages = {
+ "Bug 123456 - Improve rendering": "Improve rendering",
+ "bug #123456: Improve rendering": "Improve rendering",
+ "[PATCH] Bug 123456. Improve rendering": "Improve rendering",
+ "Bug 123456 - [performance] Improve rendering": "Improve rendering",
+ }
+ for message, expected in messages.items():
+ assert clean_commit_message(message) == expected
+
+
+def test_diff_to_structured_text() -> None:
+ assert (
+ diff_to_structured_text(RAW_DIFF)
+ == """\
+
+ widget.py
+
+ old_value = 1
+
+
+ new_value = 2
+
+"""
+ )
+
+
+def test_diff_to_structured_text_mercurial_diff() -> None:
+ assert (
+ diff_to_structured_text(HG_DIFF)
+ == """\
+
+ widget.py
+
+ old_value = 1
+
+
+ new_value = 2
+
+"""
+ )
+
+
+def test_diff_to_structured_text_full_hg_export() -> None:
+ # A full `hg export --git` payload carries the changeset header and commit
+ # message before the diff; the preamble must be ignored, including message
+ # lines that happen to start with "+" or "-".
+ export = """\
+# HG changeset patch
+# User Developer
+# Date 1600000000 0
+# Node ID abcdef123456
+# Parent 123456abcdef
+Bug 123456 - Improve rendering
+
+Body mentions -old_value and +new_value.
+
+diff --git a/widget.py b/widget.py
+--- a/widget.py
++++ b/widget.py
+@@ -1 +1 @@
+-old_value = 1
++new_value = 2
+ context
+"""
+ assert (
+ diff_to_structured_text(export)
+ == """\
+
+ widget.py
+
+ old_value = 1
+
+
+ new_value = 2
+
+"""
+ )
+
+
+def test_diff_to_structured_text_renamed_file() -> None:
+ diff = """\
+diff --git a/old_widget.py b/new_widget.py
+similarity index 91%
+rename from old_widget.py
+rename to new_widget.py
+--- a/old_widget.py
++++ b/new_widget.py
+@@ -1 +1 @@
+-old_value = 1
++new_value = 2
+"""
+ assert (
+ diff_to_structured_text(diff)
+ == """\
+
+ new_widget.py
+
+ old_value = 1
+
+
+ new_value = 2
+
+ File renamed from old_widget.py.
+"""
+ )
+
+
+def test_diff_to_structured_text_binary_file() -> None:
+ assert (
+ diff_to_structured_text(BINARY_DIFF)
+ == """\
+
+ image.png
+ Binary file changed.
+"""
+ )
+
+
+def test_build_model_input_cleans_commit_message() -> None:
+ model = PerfRegressionPredictorModel()
+ prompt = model.build_model_input("[PATCH] Bug 123456 - Make it faster", RAW_DIFF)
+ assert prompt.startswith(
+ "\nMake it faster\n\n"
+ )
+ assert "[PATCH]" not in prompt
+ assert "Bug 123456" not in prompt
+
+
+def test_build_model_input_allows_missing_commit_message() -> None:
+ model = PerfRegressionPredictorModel()
+ prompt = model.build_model_input(None, RAW_DIFF)
+ assert prompt.startswith("\n\n\n")
+ assert "widget.py" in prompt
+
+
+def test_extract_commit_message_from_git_format_patch() -> None:
+ patch = """\
+From abcdef Mon Sep 17 00:00:00 2001
+From: Developer
+Subject: [PATCH] Speed up rendering
+
+Avoid unnecessary work in the hot path.
+
+---
+ widget.py | 2 +-
+diff --git a/widget.py b/widget.py
+"""
+ assert extract_commit_message_from_patch(patch) == (
+ "[PATCH] Speed up rendering\n\nAvoid unnecessary work in the hot path."
+ )
+
+
+def test_extract_commit_message_from_mercurial_export() -> None:
+ patch = """\
+# HG changeset patch
+# User Developer
+# Date 123456 0
+# Node ID abc
+# Parent def
+Speed up rendering
+
+Avoid unnecessary work in the hot path.
+
+diff -r def -r abc widget.py
+"""
+ assert extract_commit_message_from_patch(patch) == (
+ "Speed up rendering\n\nAvoid unnecessary work in the hot path."
+ )
diff --git a/uv.lock b/uv.lock
index 9ca7e5dcb6..2746dcde7a 100644
--- a/uv.lock
+++ b/uv.lock
@@ -5,15 +5,18 @@ resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.14' and sys_platform == 'emscripten'",
"python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin'",
- "(python_full_version >= '3.14' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')",
+ "python_full_version >= '3.14' and sys_platform == 'linux'",
+ "(python_full_version >= '3.14' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')",
"python_full_version == '3.13.*' and sys_platform == 'win32'",
"python_full_version == '3.13.*' and sys_platform == 'emscripten'",
"python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'darwin'",
- "(python_full_version == '3.13.*' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version == '3.13.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')",
+ "python_full_version == '3.13.*' and sys_platform == 'linux'",
+ "(python_full_version == '3.13.*' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version == '3.13.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')",
"python_full_version < '3.13' and sys_platform == 'win32'",
"python_full_version < '3.13' and sys_platform == 'emscripten'",
"python_full_version < '3.13' and platform_machine == 'x86_64' and sys_platform == 'darwin'",
- "(python_full_version < '3.13' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')",
+ "python_full_version < '3.13' and sys_platform == 'linux'",
+ "(python_full_version < '3.13' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')",
]
[manifest]
@@ -665,6 +668,11 @@ dependencies = [
nlp = [
{ name = "spacy" },
]
+perf-regression-predictor = [
+ { name = "torch", version = "2.13.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux' and sys_platform != 'win32'" },
+ { name = "torch", version = "2.13.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
+ { name = "transformers" },
+]
[package.dev-dependencies]
spawn-pipeline = [
@@ -733,13 +741,16 @@ requires-dist = [
{ name = "tabulate", specifier = "~=0.10.0" },
{ name = "taskcluster", specifier = ">=97.1,<103.1" },
{ name = "tenacity", specifier = "~=9.1.4" },
+ { name = "torch", marker = "(sys_platform == 'linux' and extra == 'perf-regression-predictor') or (sys_platform == 'win32' and extra == 'perf-regression-predictor')", specifier = "==2.13.0", index = "https://download.pytorch.org/whl/cpu" },
+ { name = "torch", marker = "sys_platform != 'linux' and sys_platform != 'win32' and extra == 'perf-regression-predictor'", specifier = "==2.13.0" },
{ name = "tqdm", specifier = ">=4.67.3,<4.71.0" },
+ { name = "transformers", marker = "extra == 'perf-regression-predictor'", specifier = "==5.15.0" },
{ name = "unidiff", specifier = "~=0.7.5" },
{ name = "weave", specifier = ">=0.53.4" },
{ name = "xgboost", specifier = ">=3.2,<3.5" },
{ name = "zstandard", specifier = "~=0.25.0" },
]
-provides-extras = ["nlp", "nn"]
+provides-extras = ["nlp", "nn", "perf-regression-predictor"]
[package.metadata.requires-dev]
spawn-pipeline = [
@@ -767,7 +778,7 @@ source = { editable = "http_service" }
dependencies = [
{ name = "apispec", extra = ["yaml"] },
{ name = "apispec-webframeworks" },
- { name = "bugbug" },
+ { name = "bugbug", extra = ["perf-regression-predictor"] },
{ name = "cerberus" },
{ name = "flask" },
{ name = "flask-apispec" },
@@ -784,7 +795,7 @@ dependencies = [
requires-dist = [
{ name = "apispec", extras = ["yaml"], specifier = "~=6.10.0" },
{ name = "apispec-webframeworks", specifier = "~=1.2.0" },
- { name = "bugbug", editable = "." },
+ { name = "bugbug", extras = ["perf-regression-predictor"], editable = "." },
{ name = "cerberus", specifier = "~=1.3.8" },
{ name = "flask", specifier = "~=3.1.3" },
{ name = "flask-apispec", specifier = "~=0.11.4" },
@@ -2984,7 +2995,7 @@ wheels = [
[[package]]
name = "huggingface-hub"
-version = "1.26.1"
+version = "1.27.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
@@ -2997,9 +3008,9 @@ dependencies = [
{ name = "tqdm" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/89/64/bfe23dab749cb2342e1e13b61c9e684ce46b4d189c7d433cd26f76f52baf/huggingface_hub-1.26.1.tar.gz", hash = "sha256:7c28860777594ac679233f571552d0e46df34ed4e4239e844190fad3ca05e4cd", size = 936700, upload-time = "2026-08-06T09:42:24.859Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/3e/9b/ddf3d02a8681f1b9ce52fda03d755dad6b74c4f8172304c4c8d2975450f9/huggingface_hub-1.27.0.tar.gz", hash = "sha256:c1fed40ea82a6b41b477f5243546549b792ae0a93abcea608cff66089bf8f8df", size = 942668, upload-time = "2026-08-07T12:48:05.161Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b4/a7/b519cd01e57b685c5f3e9db02cb1bd7aaade35fb6554e0d36bee6d6aae28/huggingface_hub-1.26.1-py3-none-any.whl", hash = "sha256:d8676e4ec96c1e481a22a93232bd86d73bbac643fb767399aefaeaa503890fb0", size = 780754, upload-time = "2026-08-06T09:42:22.497Z" },
+ { url = "https://files.pythonhosted.org/packages/de/d8/95b735e183957c1f26d94c52977f09d466d55119cbbc1558ea4975e4c216/huggingface_hub-1.27.0-py3-none-any.whl", hash = "sha256:7df6827c2f956c60fbaa64646e979e566db76f619dd0a9729dfb8c5a3eb4f68d", size = 784926, upload-time = "2026-08-07T12:48:02.905Z" },
]
[[package]]
@@ -3905,13 +3916,16 @@ source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.14' and sys_platform == 'emscripten'",
- "(python_full_version >= '3.14' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')",
+ "python_full_version >= '3.14' and sys_platform == 'linux'",
+ "(python_full_version >= '3.14' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')",
"python_full_version == '3.13.*' and sys_platform == 'win32'",
"python_full_version == '3.13.*' and sys_platform == 'emscripten'",
- "(python_full_version == '3.13.*' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version == '3.13.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')",
+ "python_full_version == '3.13.*' and sys_platform == 'linux'",
+ "(python_full_version == '3.13.*' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version == '3.13.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')",
"python_full_version < '3.13' and sys_platform == 'win32'",
"python_full_version < '3.13' and sys_platform == 'emscripten'",
- "(python_full_version < '3.13' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')",
+ "python_full_version < '3.13' and sys_platform == 'linux'",
+ "(python_full_version < '3.13' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')",
]
sdist = { url = "https://files.pythonhosted.org/packages/dc/a0/acc8ffcd5bdc63df0097e22c719bfcd61b604358343089313a8aebbb24ab/llvmlite-0.48.0.tar.gz", hash = "sha256:543b19f9ef8f3c7c60d1468191e4ee1b1537bf9f8a3d56f64c0ddd98de92edd2", size = 184016, upload-time = "2026-07-02T20:20:05.308Z" }
wheels = [
@@ -4512,6 +4526,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/00/32/512ae30c4ef7fa2c606a24fc1f9a331a215988f4819328233d427f3269de/mozphab-2.15.3-py3-none-any.whl", hash = "sha256:3265eb9ddd03e08c44c8ea784912790f60a8ec0a1f104fdc64eea410f4dff23e", size = 124401, upload-time = "2026-06-25T16:31:55.998Z" },
]
+[[package]]
+name = "mpmath"
+version = "1.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" },
+]
+
[[package]]
name = "multidict"
version = "6.7.1"
@@ -4700,13 +4723,16 @@ source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.14' and sys_platform == 'emscripten'",
- "(python_full_version >= '3.14' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')",
+ "python_full_version >= '3.14' and sys_platform == 'linux'",
+ "(python_full_version >= '3.14' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')",
"python_full_version == '3.13.*' and sys_platform == 'win32'",
"python_full_version == '3.13.*' and sys_platform == 'emscripten'",
- "(python_full_version == '3.13.*' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version == '3.13.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')",
+ "python_full_version == '3.13.*' and sys_platform == 'linux'",
+ "(python_full_version == '3.13.*' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version == '3.13.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')",
"python_full_version < '3.13' and sys_platform == 'win32'",
"python_full_version < '3.13' and sys_platform == 'emscripten'",
- "(python_full_version < '3.13' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')",
+ "python_full_version < '3.13' and sys_platform == 'linux'",
+ "(python_full_version < '3.13' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')",
]
dependencies = [
{ name = "llvmlite", version = "0.48.0", source = { registry = "https://pypi.org/simple" } },
@@ -6626,6 +6652,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl", hash = "sha256:d8168eccca828cbb2cd573675333f3bddd254313a9c42494b84c76b539e8ba25", size = 90216, upload-time = "2026-07-22T19:30:43.251Z" },
]
+[[package]]
+name = "safetensors"
+version = "0.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/45/06/f955dbbb1859e3bd23c8ac6141af5106e7ad5fedec4a3a6e3d60f94b7001/safetensors-0.8.0.tar.gz", hash = "sha256:fabaf3e0f18a6618d9b36560682562157f77c2b71fcffc7b432be2baed9d753d", size = 325846, upload-time = "2026-06-09T07:52:25.563Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/39/a0/f718cda65b05407d228f97602cf60dca269c979867aa5beb25410de26cd3/safetensors-0.8.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c554f85858e05226d3c2828e32395e677434685d6d94594a41643361c5e837f0", size = 473568, upload-time = "2026-06-09T07:52:18.829Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/b1/fa7c600e7dceae12e9606c7578cbc9ff1e1ed55844883ee5c92205e86226/safetensors-0.8.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c80201d22cbf405b80647a60ada77bba06c8fba2da2743ba1e89cdcc39a81f25", size = 484562, upload-time = "2026-06-09T07:52:17.518Z" },
+ { url = "https://files.pythonhosted.org/packages/09/7d/65a7de0af421317bb36a067241e4235fff194eed60b961ed6d3f59a3fc60/safetensors-0.8.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a46e5ff292c356d6991e60942ba7f79817682d3a2cef0702136448cb9c4d235", size = 502844, upload-time = "2026-06-09T07:52:07.624Z" },
+ { url = "https://files.pythonhosted.org/packages/91/4f/3175c9d75634e0e0dda0082794193521035edd7c70a6f212bf33ca06ddf4/safetensors-0.8.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4124502b78f03534117c848f87a39b8f31e577b15eff423bf8bfb95f2a8c30d0", size = 511823, upload-time = "2026-06-09T07:52:09.565Z" },
+ { url = "https://files.pythonhosted.org/packages/20/87/846c289e7aa2299eff406335717cf43ce8777194ece8aad75772e0411615/safetensors-0.8.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7bc0a787ba8a35be368ee3574edfa2b1ad389eebd0a72e482ae275490e3f6c98", size = 633461, upload-time = "2026-06-09T07:52:11.128Z" },
+ { url = "https://files.pythonhosted.org/packages/76/22/8d64d9df2c45d5ded401df889d0ad90882804ca172d79ec4f0df8f727fe0/safetensors-0.8.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:040070828e36dc8e122178bbbd5830ff9e97920affb84cbe0f46442497bed358", size = 545148, upload-time = "2026-06-09T07:52:13.603Z" },
+ { url = "https://files.pythonhosted.org/packages/28/50/f203ff3a3ddfe19308efc83c5a3a29ed02bf786732ec35e68bf9162f3365/safetensors-0.8.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd6f3f93c9a0a7cc2788ee63fb763353d4bd2e89b0751bc78fcf7dda00bea774", size = 516040, upload-time = "2026-06-09T07:52:16.29Z" },
+ { url = "https://files.pythonhosted.org/packages/46/fb/cdaed17ceb2948784fd9c36b6fd3e951b608547cea81a48e8ee6f8cfdfcb/safetensors-0.8.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:fcdd41ec4628fee5799f807c73c353629130fbd942aa23d83c623dd6c9d52d78", size = 513832, upload-time = "2026-06-09T07:52:12.37Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/49/1e15de264dcc3b77943d2d0c56a95809956883b1c2d6d585c792523f180b/safetensors-0.8.0-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8e9f537aa183a38ace122d27303dcd986b26bd2a7591f9181d7f0c396f4677ca", size = 559930, upload-time = "2026-06-09T07:52:14.743Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/43/bf38443278eab4b1be1fce2931e2b012ad9cb7df52ada751d0aab8f7659a/safetensors-0.8.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:87eec7ffed2b809f05a398a8becb7d013f19f7837cd15d9748580d6cf30dbaf4", size = 678670, upload-time = "2026-06-09T07:52:20.032Z" },
+ { url = "https://files.pythonhosted.org/packages/72/e3/68cd3fa5b48488e84add63e04cb12f3bc28ae4638c06d4508c6e88823d0e/safetensors-0.8.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:4a95ae2b05d7726d751da4ebf626a2ca782b706e101bd894c95bc2450b1cffcc", size = 786679, upload-time = "2026-06-09T07:52:21.322Z" },
+ { url = "https://files.pythonhosted.org/packages/29/4b/1c19c509d56e01f4fbb3d0a2e597450f6cc04d1d56cf52defb0a62dfd715/safetensors-0.8.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae091f16662658bdc019a4ff6cb4c085bb7d725eb5978b183ffd265863b6d2d", size = 765683, upload-time = "2026-06-09T07:52:22.594Z" },
+ { url = "https://files.pythonhosted.org/packages/27/43/41c1621732edd934d868a00d1b891584c892a7b62a9aab82ea5a0a5623ee/safetensors-0.8.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8e080062fcde23be189565e1c3305d16751a218ecf9412c8601e64204eb6f846", size = 722361, upload-time = "2026-06-09T07:52:23.924Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/3f/73ccf82579412b4a71c4ca673f10b5f1f888d7cf5af7fe24f27d30307be4/safetensors-0.8.0-cp310-abi3-win32.whl", hash = "sha256:2ddf52eac562eda224f99acfa7889d02968c1fd59a5b011ae7d8137c37e9c02d", size = 342401, upload-time = "2026-06-09T07:52:28.895Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/6d/3fba214c1e5e0f69991677ec3bc17023f0421776975e1de0c682dca475e2/safetensors-0.8.0-cp310-abi3-win_amd64.whl", hash = "sha256:096ec1a98435df7beb08853bb5aa9081a84f23d0adc67ed1a0a10550f608373f", size = 355540, upload-time = "2026-06-09T07:52:27.832Z" },
+ { 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 = "scikit-learn"
version = "1.7.2"
@@ -7157,6 +7207,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/75/0a/67e95f21498de41433babf7b1db0eeab449eb58872dfb831b27747a70fd0/starlette-1.4.1-py3-none-any.whl", hash = "sha256:7d078e0fbefae0d2cecfb80a799d6fb84b1c0c6acd4f14ac79d17d0e7ec27f19", size = 74019, upload-time = "2026-08-05T15:17:24.357Z" },
]
+[[package]]
+name = "sympy"
+version = "1.14.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mpmath" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" },
+]
+
[[package]]
name = "tabulate"
version = "0.10.0"
@@ -7339,29 +7401,28 @@ wheels = [
[[package]]
name = "tokenizers"
-version = "0.23.1"
+version = "0.22.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huggingface-hub" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/c1/60/21f715d9faba5f5407ff759472ade058ec4a507ad62bcea47cb847239a73/tokenizers-0.23.1.tar.gz", hash = "sha256:1feeeadf865a7915adc25445dea30e9933e593c31bb96c277cee36de227c8bfa", size = 365748, upload-time = "2026-04-27T14:43:25.606Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/87/39/b87a87d5bb9470610b80a2d31df42fcffeaf35118b8b97952b2aff598cc7/tokenizers-0.23.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e03d6ffcbe0d56ee9c1ccd070e70a13fa750727c0277e138152acbc0252c2224", size = 3146732, upload-time = "2026-04-27T14:43:15.427Z" },
- { url = "https://files.pythonhosted.org/packages/e2/6a/068ed9f6e444c9d7e9d55ce134181325700f3d7f30410721bdc8f848d727/tokenizers-0.23.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e0948bbb1ac1d7cdfc9fb6d62c596e3b7550036ad60ecd654a66ad273326324e", size = 3054954, upload-time = "2026-04-27T14:43:13.745Z" },
- { url = "https://files.pythonhosted.org/packages/6c/36/e006edf031154cba92b8416057d92c3abe3635e4c4b0aa0b5b9bb39dde70/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bf13402aff9bc533c89cb849ec3b412dc3fbeacc9744840e423d7bf3f7dc0e3", size = 3374081, upload-time = "2026-04-27T14:43:01.241Z" },
- { url = "https://files.pythonhosted.org/packages/a2/ef/7735d226f9c7f874a6bee5e3f27fb25ecabdf207d37b8cf45286d0795893/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f836ca703b89ae07919a309f9651f7a88fd5a33d5f718ba5ad0870ec0256bad6", size = 3247641, upload-time = "2026-04-27T14:43:03.856Z" },
- { url = "https://files.pythonhosted.org/packages/b9/d9/24827036f6e21297bfffda0768e58eb6096a4f411e932964a01707857931/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae848657742035523fdf261773630cb819a26995fcd3d9ecae0c1daf6e5a4959", size = 3585624, upload-time = "2026-04-27T14:43:10.664Z" },
- { url = "https://files.pythonhosted.org/packages/0c/9a/22f3582b3a4f49358293a5206e25317621ee4526bfe9cdaa0f07a12e770e/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53b09e85775d5187941e7bab30e941b4134ab4a7dd8c68e783d231fb7ca27c51", size = 3844062, upload-time = "2026-04-27T14:43:05.643Z" },
- { url = "https://files.pythonhosted.org/packages/7e/65/b8f8814eef95800f20721384136d9a1d22241d50b2874357cb70542c392f/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea5a0ce170074329faaa8ea3f6400ecde604b6678192688533af80980daae71a", size = 3460098, upload-time = "2026-04-27T14:43:08.854Z" },
- { url = "https://files.pythonhosted.org/packages/0d/d5/1353e5f677ec27c2494fb6a6725e82d56c985f53e90ec511369e7e4f02c6/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5075b405006415ea148a992d093699c66eb01952bf59f4d5727089a98bda45a4", size = 3346235, upload-time = "2026-04-27T14:43:12.377Z" },
- { url = "https://files.pythonhosted.org/packages/71/89/39b6b8fc073fb6d413d0147aa333dc7eff7be65639ac9d19930a0b21bf33/tokenizers-0.23.1-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:56f3a77de629917652f876294dc9fe6bad4a0c43bc229dc72e59bb23a0f4729a", size = 3426398, upload-time = "2026-04-27T14:43:07.264Z" },
- { url = "https://files.pythonhosted.org/packages/0f/80/127c854da64827e5b79264ce524993a90dddcb320e5cd42412c5c02f9e8a/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9d10a6d957ef01896dc274e890eee27d41bd0e74ef31e60616f0fc311345184e", size = 9823279, upload-time = "2026-04-27T14:43:17.222Z" },
- { url = "https://files.pythonhosted.org/packages/fe/ba/44c2502feb1a058f096ddfb4e0996ef3225a01a388e1a9b094e91689fe93/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:1974288a609c343774f1b897c8b482c791ab17b75ab5c8c2b1737565c1d82288", size = 9644986, upload-time = "2026-04-27T14:43:19.45Z" },
- { url = "https://files.pythonhosted.org/packages/9e/c1/464019a9fb059870bfe4eebb4ba12208f3042035e258bf5e782906bd3847/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:120468fb4c24faf0543c835a4fabafa4deb3f20a035c9b6e83d0b553a97615d4", size = 9976181, upload-time = "2026-04-27T14:43:21.463Z" },
- { url = "https://files.pythonhosted.org/packages/79/94/3ac1432bda31626071e9b6a12709b97ae05131c804b94c8f3ac622c5da32/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e3d8f40ea6268047de7046906326abed5134f27d4e8447b23763afe5808c8a96", size = 10113853, upload-time = "2026-04-27T14:43:23.617Z" },
- { url = "https://files.pythonhosted.org/packages/6a/dd/631b21433c771b1382535326f0eca80b9c9cee2e64961dd993bc9ac4669e/tokenizers-0.23.1-cp310-abi3-win32.whl", hash = "sha256:93120a930b919416da7cd10a2f606ac9919cc69cacae7980fa2140e277660948", size = 2536263, upload-time = "2026-04-27T14:43:29.888Z" },
- { url = "https://files.pythonhosted.org/packages/97/c9/2553f72aaf65a2797d4229e37fa7fbe38ffbf3e32912d31bdd78b3323e59/tokenizers-0.23.1-cp310-abi3-win_amd64.whl", hash = "sha256:e7bfaf995c1bdbbd21d13539decb6650967013759318627d85daeb7881af16b7", size = 2798223, upload-time = "2026-04-27T14:43:28.51Z" },
- { url = "https://files.pythonhosted.org/packages/cd/2b/2be299bab55fc595e3d38567edb1a87f86e594842968fa9515a07bdcf422/tokenizers-0.23.1-cp310-abi3-win_arm64.whl", hash = "sha256:a26197957d8e4425dfba746315f3c425ea00cfa8367c5fbc4ec73447893dcea9", size = 2664127, upload-time = "2026-04-27T14:43:26.949Z" },
+ { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" },
+ { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" },
+ { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" },
+ { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" },
+ { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" },
+ { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" },
+ { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" },
+ { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" },
]
[[package]]
@@ -7373,6 +7434,83 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304", size = 49449, upload-time = "2026-07-17T01:48:05.728Z" },
]
+[[package]]
+name = "torch"
+version = "2.13.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.14' and sys_platform == 'emscripten'",
+ "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin'",
+ "(python_full_version >= '3.14' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')",
+ "python_full_version == '3.13.*' and sys_platform == 'emscripten'",
+ "python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'darwin'",
+ "(python_full_version == '3.13.*' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version == '3.13.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')",
+ "python_full_version < '3.13' and sys_platform == 'emscripten'",
+ "python_full_version < '3.13' and platform_machine == 'x86_64' and sys_platform == 'darwin'",
+ "(python_full_version < '3.13' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')",
+]
+dependencies = [
+ { name = "filelock" },
+ { name = "fsspec" },
+ { name = "jinja2" },
+ { name = "networkx" },
+ { name = "setuptools" },
+ { name = "sympy" },
+ { name = "typing-extensions" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c4/3a/ed0f4d4d1dcde03bced7aac9a28e800abcdc0cbd06b6775044c9fbd877b7/torch-2.13.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:2fe228aba290d14b9f31b049be550dbd469c3fd3013d7a19705b30454da97027", size = 111213045, upload-time = "2026-07-08T16:05:22.997Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/fa/c1c10b7aff4a9a3e8956d4f0a5f468fa6db7abc3208805719076772b4833/torch-2.13.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:33449899ce5496c1b84b4853179d94fd102028ae1407314d9fb956bb79e70d09", size = 111213743, upload-time = "2026-07-08T16:03:28.579Z" },
+ { url = "https://files.pythonhosted.org/packages/36/76/6dcc7f0c07052102dd36f83cbc5800842a909c8c3fbf1a7f8a5844954de9/torch-2.13.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d849b390e07d8d333ce8ecaf91b273c656c598379a19c9acf1318a883f6b391c", size = 111227066, upload-time = "2026-07-08T16:03:33.6Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/ce/aa8b7f9949d32e0f2f624f342bc3b48112c1b8a130288465938bc83bcbf9/torch-2.13.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:c28def70706c2f9ecc752574766e8ae4da9b810ab6676b611166761a78a9f1e1", size = 111537025, upload-time = "2026-07-08T16:02:44.28Z" },
+]
+
+[[package]]
+name = "torch"
+version = "2.13.0+cpu"
+source = { registry = "https://download.pytorch.org/whl/cpu" }
+resolution-markers = [
+ "python_full_version >= '3.14' and sys_platform == 'win32'",
+ "python_full_version >= '3.14' and sys_platform == 'linux'",
+ "python_full_version == '3.13.*' and sys_platform == 'win32'",
+ "python_full_version == '3.13.*' and sys_platform == 'linux'",
+ "python_full_version < '3.13' and sys_platform == 'win32'",
+ "python_full_version < '3.13' and sys_platform == 'linux'",
+]
+dependencies = [
+ { name = "filelock" },
+ { name = "fsspec" },
+ { name = "jinja2" },
+ { name = "networkx" },
+ { name = "setuptools" },
+ { name = "sympy" },
+ { name = "typing-extensions" },
+]
+wheels = [
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp312-cp312-linux_s390x.whl", hash = "sha256:ffadde149901c8afa138daa38d898264003cfcf1a3336ca5cd964b5af227d867", upload-time = "2026-07-08T19:28:41Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:6f307c2c32d764ffc6ff6893b801fad6d4752f3e67966cb8abf1843427c02604", upload-time = "2026-07-08T19:28:51Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:4ca4a9394b0c771238a4f73590fdbbc4debad85ed0fa63d026ae1b085da7d6e2", upload-time = "2026-07-08T19:29:03Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp312-cp312-win_amd64.whl", hash = "sha256:a8b450c1e58e5800e5b4691dac412f8d2d65a1dc3298166f91596603a3531e6f", upload-time = "2026-07-08T19:29:15Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp312-cp312-win_arm64.whl", hash = "sha256:fa0762705b933624d59f6823db9ce7ec2e35b3e1e9c319c9db51fbeecfc3e319", upload-time = "2026-07-08T19:29:21Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp313-cp313-linux_s390x.whl", hash = "sha256:966d020354f465672dc7dd10d3a5c6cd17d7eb48620aa1d265b48a1f78f06898", upload-time = "2026-07-08T19:29:30Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:0b8f7d0423027ae8b90c7977c627f3379f325363a08224dffad9b4b2d684a83d", upload-time = "2026-07-08T19:29:40Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:3fbf9c9d1f3c10c2d59d04aca426dee9ccc6ceb32d255c61e93acc3b4f75fae6", upload-time = "2026-07-08T19:29:54Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp313-cp313-win_amd64.whl", hash = "sha256:a17ff48608634db245e17e8bb00a9558554a49aeb1e4f5fe6cd039af2a10515b", upload-time = "2026-07-08T19:30:05Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp313-cp313-win_arm64.whl", hash = "sha256:ac7aaf322be4777765a53bed7264a214dd81b3a1d276b93150515a3c5f75e4b0", upload-time = "2026-07-08T19:30:12Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314-linux_s390x.whl", hash = "sha256:dec241fef3984c0d1edadd1f58708e218d4eae881ceef7bc10cf9964d41b68b9", upload-time = "2026-07-08T19:30:20Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:ca021f9eb2f8345c83fa03e3a04587308afb8df71bd472670b3ece00df58621c", upload-time = "2026-07-08T19:30:32Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d20fa53ee744502fa4c69818a720b05ca0d37abd055d4f6e66cae155114bc691", upload-time = "2026-07-08T19:30:45Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314-win_amd64.whl", hash = "sha256:e2e5134decf00e218da62318f3dc5df156231d367871918e91eba95ab0ad43ab", upload-time = "2026-07-08T19:30:58Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314t-linux_s390x.whl", hash = "sha256:991cc14b39e751122c01f017be6448533989868731cb5eecd1006893d26787c2", upload-time = "2026-07-08T19:31:09Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:7b8d26e29bceafbdaa8d63bfe7612f23875b5af2cc07e13f809c3ed890bbe1d8", upload-time = "2026-07-08T19:31:21Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:b222c15a0fc2ce207d1c1a59700b46c8fa6748df1f447ad11e5c870dde0933d9", upload-time = "2026-07-08T19:31:35Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314t-win_amd64.whl", hash = "sha256:a43376bd094124ef626bfdd3d4c2c62eacb0b5ddc99776f4a32d4fd16f1f3420", upload-time = "2026-07-08T19:31:48Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5002ca81af00ae69b57540f615b58b8ae922b6d4848176b366a52bd2196e6", upload-time = "2026-07-08T19:32:00Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:1a3a35229fdc13446b4eab50e7fcf9399ff941e89a3b761497786297a5d8dde5", upload-time = "2026-07-08T19:32:16Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp315-cp315t-manylinux_2_28_aarch64.whl", hash = "sha256:8e109528e6bab044815daebaf71770fbaace3a66ef1c816cb55c875350f78a60", upload-time = "2026-07-08T19:32:30Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp315-cp315t-manylinux_2_28_x86_64.whl", hash = "sha256:222a6681467cc7f6f05cd3068dfbc603def3a1e46d1d4620c1c8cdf6178bd563", upload-time = "2026-07-08T19:32:44Z" },
+]
+
[[package]]
name = "tqdm"
version = "4.70.0"
@@ -7394,6 +7532,26 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ad/66/0d785f0bc5e4315a96c989bb476d0fc07ea4f85132550c7b156ca2035d52/traitlets-5.16.1-py3-none-any.whl", hash = "sha256:f775618166caa0396c8e337099240f2bd3e5e917d203b2e6fbe21a58d3cb1f6b", size = 86211, upload-time = "2026-08-03T08:32:34.48Z" },
]
+[[package]]
+name = "transformers"
+version = "5.15.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "huggingface-hub" },
+ { name = "numpy" },
+ { name = "packaging" },
+ { name = "pyyaml" },
+ { name = "regex" },
+ { name = "safetensors" },
+ { name = "tokenizers" },
+ { name = "tqdm" },
+ { name = "typer" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a6/3f/d89353267d511e18f137dfd7769d07837350c11b88408ce1dfe2e93e56c7/transformers-5.15.0.tar.gz", hash = "sha256:bbf98f57b2ddd7c4ecbccfa2c0069017aa6fd01cc204bd50cbc0eeadcf2a13b8", size = 9377983, upload-time = "2026-08-10T10:27:23.261Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d8/43/81355710a4c84e9420e11a86d41a5364deb561f2ef36dfdf254a07371bbb/transformers-5.15.0-py3-none-any.whl", hash = "sha256:d7f007736f67749ae9490c4f8cb5d30b452ae2d68c8675e50ba8d63ea7feb107", size = 11749280, upload-time = "2026-08-10T10:27:20.416Z" },
+]
+
[[package]]
name = "treeherder-client"
version = "5.0.0"