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 = 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"