From 5e6a93a746f5d4b0309937afba070c6c4e37faf9 Mon Sep 17 00:00:00 2001 From: Ali Sayed Salehi Date: Mon, 10 Aug 2026 15:31:36 -0400 Subject: [PATCH 1/9] add performance regresssion predictor inference feature --- .gitignore | 1 + bugbug/models/__init__.py | 1 + .../performance_regression_predictor.py | 320 ++++++++++++++++++ bugbug/tools/core/platforms/phabricator.py | 29 +- docs/README.md | 1 + .../performance-regression-predictor.md | 118 +++++++ http_service/README.md | 208 +++++++++++- http_service/bugbug_http/app.py | 86 +++++ http_service/bugbug_http/download_models.py | 4 +- http_service/bugbug_http/models.py | 102 +++++- http_service/docker-compose.yml | 4 +- http_service/pyproject.toml | 2 +- .../test_performance_regression_predictor.py | 266 +++++++++++++++ pyproject.toml | 13 + scripts/performance_regression_predictor.py | 108 ++++++ scripts/trainer.py | 3 + .../test_performance_regression_predictor.py | 202 +++++++++++ tests/test_phabricator.py | 37 ++ uv.lock | 246 +++++++++++--- 19 files changed, 1688 insertions(+), 63 deletions(-) create mode 100644 bugbug/models/performance_regression_predictor.py create mode 100644 docs/models/performance-regression-predictor.md create mode 100644 http_service/tests/test_performance_regression_predictor.py create mode 100644 scripts/performance_regression_predictor.py create mode 100644 tests/test_performance_regression_predictor.py 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..239fb4b2b5 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", + "performanceregressionpredictor": "bugbug.models.performance_regression_predictor.PerformanceRegressionPredictorModel", "qaneeded": "bugbug.models.qaneeded.QANeededModel", "rcatype": "bugbug.models.rcatype.RCATypeModel", "regression": "bugbug.models.regression.RegressionModel", diff --git a/bugbug/models/performance_regression_predictor.py b/bugbug/models/performance_regression_predictor.py new file mode 100644 index 0000000000..9d3283a385 --- /dev/null +++ b/bugbug/models/performance_regression_predictor.py @@ -0,0 +1,320 @@ +# -*- 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 performance regression predictor.""" + +from __future__ import annotations + +import json +import re +from collections.abc import Sequence +from pathlib import Path +from typing import Any + +import numpy as np + +from bugbug.model import Model + +MODEL_NAME = "Performance Regression Predictor" +MODEL_IDENTIFIER = "performanceregressionpredictor" +DEFAULT_MODEL_DIRECTORY = f"{MODEL_IDENTIFIER}model" +POSITIVE_CLASS_ID = 1 + + +def clean_commit_message( + commit_message: str | None, *, clean_subject_only: bool = True +) -> str: + """Remove common noisy prefixes from a commit message. + + This intentionally mirrors the preprocessing used to prepare the model's + training data. + """ + if commit_message is None: + return "" + + message = str(commit_message) + lines = message.splitlines() + if not lines: + return "" + + def _clean_subject(subject: str) -> str: + prefix = r"(?:\[[^\]]+\]|\([^)]+\)|bug\s*#?\s*\d+\b)" + return re.sub( + rf"^\s*(?:{prefix}\s*(?:[-–—:.,]\s*)?)+", + "", + subject, + count=1, + flags=re.IGNORECASE, + ).strip() + + if clean_subject_only: + for index, line in enumerate(lines): + if line.strip(): + lines[index] = _clean_subject(line) + break + return "\n".join(lines).strip("\n") + + cleaned_lines = [ + _clean_subject(line) if index == 0 else line for index, line in enumerate(lines) + ] + return "\n".join(cleaned_lines).strip("\n") + + +def combine_commit_messages(commit_messages: Sequence[str]) -> str: + """Clean and combine commit messages uploaded for one Phabricator diff. + + Phabricator exposes local commit metadata as a list. Most Mozilla diffs have + one entry, but cleaning each message separately also gives deterministic + preprocessing for the uncommon multi-commit case. + """ + return "\n\n".join( + cleaned_message + for commit_message in commit_messages + if (cleaned_message := clean_commit_message(commit_message).strip()) + ) + + +def diff_to_structured_text(diff_string: str) -> str: + """Convert a Git or Mercurial diff to the model's structured format.""" + lines = diff_string.strip().splitlines() + output: list[str] = [] + + current_file: str | None = None + current_block_type: str | None = None + current_block_lines: list[str] = [] + + pending_binary_status: str | None = None + rename_from: str | None = None + rename_to: str | None = None + pending_rename = False + + def flush_block() -> None: + nonlocal current_block_type, current_block_lines + if current_block_type and current_block_lines: + output.append(f" <{current_block_type.upper()}>") + output.extend(f" {line}" for line in current_block_lines) + output.append(f" ") + current_block_type = None + current_block_lines = [] + + def flush_file() -> None: + nonlocal current_file, pending_binary_status + nonlocal rename_from, rename_to, pending_rename + + if current_file: + flush_block() + if pending_rename and rename_from and rename_to: + output.append(f" File renamed from {rename_from}.") + elif pending_binary_status: + output.append(f" Binary file {pending_binary_status}.") + output.append("") + + current_file = None + pending_binary_status = None + rename_from = None + rename_to = None + pending_rename = False + + for line in lines: + if line.startswith("diff -r"): + flush_file() + parts = line.split() + if len(parts) >= 4: + current_file = parts[-1] + output.extend(("", f" {current_file}")) + continue + + if line.startswith("diff --git"): + flush_file() + match = re.match(r"diff --git a/(.+?) b/(.+)", line) + if match: + current_file = match.group(2) + output.extend(("", f" {current_file}")) + elif line.startswith("rename from "): + rename_from = line[len("rename from ") :].strip() + pending_rename = True + elif line.startswith("rename to "): + rename_to = line[len("rename to ") :].strip() + if not current_file: + current_file = rename_to + output.extend(("", f" {current_file}")) + elif line.startswith("--- "): + pass + elif line.startswith("+++ "): + pass + elif line.startswith("Binary files "): + flush_block() + pending_binary_status = "changed" + flush_file() + elif line.startswith("@@"): + flush_block() + elif line.startswith("-"): + if current_block_type != "REMOVED": + flush_block() + current_block_type = "REMOVED" + current_block_lines.append(line[1:].rstrip()) + elif line.startswith("+"): + if current_block_type != "ADDED": + flush_block() + current_block_type = "ADDED" + current_block_lines.append(line[1:].rstrip()) + else: + flush_block() + + flush_file() + return "\n".join(output) + + +def build_model_input(commit_message: str | None, raw_diff: str) -> str: + """Build the exact text representation consumed during training.""" + cleaned_message = clean_commit_message(commit_message) + structured_diff = diff_to_structured_text(raw_diff) + return "\n".join( + ( + "", + cleaned_message, + "", + structured_diff, + ) + ) + + +class PerformanceRegressionPredictorModel(Model): + """Hugging Face sequence classifier used only for inference.""" + + training_supported = False + + 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] = {} + + @classmethod + def load(cls, model_directory: str) -> "PerformanceRegressionPredictorModel": + """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( + "Performance 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 = [ + 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/tools/core/platforms/phabricator.py b/bugbug/tools/core/platforms/phabricator.py index 39baabd645..a29fecabd8 100644 --- a/bugbug/tools/core/platforms/phabricator.py +++ b/bugbug/tools/core/platforms/phabricator.py @@ -429,11 +429,38 @@ async def _commit_available(commit_hash: str) -> bool: def _diff_metadata(self) -> dict: phabricator = get_phabricator_client() diffs = phabricator.search_diffs(diff_id=self.diff_id) - assert len(diffs) == 1 + if len(diffs) != 1: + raise PhabricatorRevisionNotFoundException(f"Diff {self.diff_id} not found") diff = diffs[0] return diff + @cached_property + def diff_commits(self) -> list[dict]: + """Return local commit metadata uploaded with this immutable diff.""" + phabricator = get_phabricator_client() + diffs = phabricator.search_diffs( + diff_id=self.diff_id, + attachments={"commits": True}, + ) + if len(diffs) != 1: + raise PhabricatorRevisionNotFoundException(f"Diff {self.diff_id} not found") + return diffs[0].get("attachments", {}).get("commits", {}).get("commits", []) + + @property + def diff_revision_phid(self) -> str: + """Return the revision PHID associated with this diff.""" + return self._diff_metadata["revisionPHID"] + + @property + def commit_messages(self) -> list[str]: + """Return non-empty commit messages uploaded with this diff.""" + return [ + message + for commit in self.diff_commits + if isinstance((message := commit.get("message")), str) and message.strip() + ] + async def get_base_revision(self) -> Optional[str]: try: return await self.get_base_commit_hash() diff --git a/docs/README.md b/docs/README.md index c28dab0c05..ca060882ab 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,3 +1,4 @@ Detailed documentation per model - [Regressor model for predicting risky commits](models/regressor.md) +- [Performance Regression Predictor](models/performance-regression-predictor.md) diff --git a/docs/models/performance-regression-predictor.md b/docs/models/performance-regression-predictor.md new file mode 100644 index 0000000000..15c518f99b --- /dev/null +++ b/docs/models/performance-regression-predictor.md @@ -0,0 +1,118 @@ +# Performance Regression Predictor + +The Performance Regression Predictor is an inference-only binary transformer +model. It predicts whether a public Phabricator diff is likely to +introduce a performance regression. + +The input is the commit message from the diff's `commits` attachment plus the +raw diff. If a diff has multiple uploaded local commits, each message is cleaned +independently and the messages are separated by blank lines. If Phabricator did +not retain commit metadata, the revision title and summary are used as a +fallback. 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 each 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). + +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 fetch data from Phabricator. + +From the Bugbug repository root, run the included sample patch against a local +Hugging Face checkpoint: + +```sh +cd /path/to/bugbug + +uv run --extra performance-regression-predictor \ + bugbug-predict-performance-regression \ + --model-dir /absolute/path/to/predictor_model \ + --patch-file examples/performance_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 performance-regression-predictor \ + bugbug-predict-performance-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 endpoint uses the service's existing Redis/RQ worker and API-key presence +check: + +```text +GET /performanceregressionpredictor/predict/phabricator/{diff_id} +X-Api-Key: ... +``` + +The first request normally returns `202 {"ready": false}`. Poll the same URL +until it returns `200`. The worker requires `PHABRICATOR_API_KEY`; a custom +Phabricator host can be set with `PHABRICATOR_URL`. + +See [HTTP service local development](../../http_service/README.md) for the +complete Docker Compose setup, including the local model mount and secret +file. + +Example result: + +```json +{ + "revision_id": 123456, + "diff_id": 789012, + "prob": [0.25, 0.75], + "class": 1, + "risk_score": 0.75, + "extra_data": { + "model_name": "Performance Regression Predictor", + "model_version": null, + "max_length": 512, + "calibrated": false, + "commit_message_source": "diff_metadata", + "commit_message_count": 1 + } +} +``` + +Only public revisions are processed, and the worker verifies that the diff +belongs to a public revision. The `revision_id` in the response is derived from +the diff metadata. + +## Model artifact + +Production follows the existing Bugbug model-artifact convention. The +checkpoint directory must be named `performanceregressionpredictormodel` and +published as: + +```text +public/performanceregressionpredictormodel.tar.zst +``` + +under the indexed Taskcluster namespace +`project.bugbug.train_performanceregressionpredictor.`. For this first +iteration, the archive can be created and published by a one-off Taskcluster +task; no `bugbug-train` workflow is registered for this model. The standard +background-worker image then downloads it alongside the other model artifacts. + +For local Docker development before the artifact is published, mount the local +checkpoint at `/code/performanceregressionpredictormodel` in the background +worker. This is the same fixed-directory convention used by the other models. diff --git a/http_service/README.md b/http_service/README.md index dae03bff7b..91ad378205 100644 --- a/http_service/README.md +++ b/http_service/README.md @@ -1,23 +1,207 @@ -### Local development +# HTTP Service Local Development -**For starting the service locally run the following commands.** +Run Docker Compose commands from this directory, not the repository root. The +root may have a different Compose application with unrelated credentials. -Start Redis: +```sh +cd /path/to/bugbug/http_service +``` - docker-compose up redis +## Services -Build the http service image: +The local Compose file defines: - docker build -t mozilla/bugbug-http-service -f Dockerfile . +- `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`. -Start the http service: +## Environment - docker-compose up bugbug-http-service +Most environment variables are optional for local startup, but individual +endpoints may need service-specific credentials: -Build the background worker image: +- `BUGBUG_BUGZILLA_TOKEN`: needed by Bugzilla bug classification endpoints. +- `BUGBUG_GITHUB_TOKEN`: needed by GitHub issue classification endpoints. +- `PHABRICATOR_API_KEY`: needed by Phabricator-backed endpoints. +- `PHABRICATOR_URL`: optional; defaults to Mozilla production Phabricator. +- `BUGBUG_ALLOW_MISSING_MODELS=1`: useful for local development when you only + need one model and do not have every model artifact locally. - docker build -t mozilla/bugbug-http-service-bg-worker --build-arg TAG=latest -f Dockerfile.bg_worker . +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. -Run the background worker: +If you need local secrets, create `.env` in this directory +(`http_service/.env` from the repository root): - docker-compose up bugbug-http-service-bg-worker +```dotenv +BUGBUG_BUGZILLA_TOKEN= +BUGBUG_GITHUB_TOKEN= +PHABRICATOR_API_KEY= +PHABRICATOR_URL=https://phabricator.services.mozilla.com +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 +``` + +## Performance Regression Predictor + +The Performance Regression Predictor uses the same HTTP service and background +worker, but it needs two extra local-development pieces while the model artifact +is unpublished: + +- a local Hugging Face checkpoint mounted at the standard model directory; +- a Phabricator Conduit token so the worker can fetch diff metadata and raw + diffs. + +### Create The Secret File + +Create `.env` in this directory (`http_service/.env` from the repository root) +with your Conduit token: + +```dotenv +CONDUIT_API_TOKEN=api-replace-with-your-token +PHABRICATOR_URL=https://phabricator.services.mozilla.com +``` + +### 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" + PHABRICATOR_API_KEY: ${CONDUIT_API_TOKEN} + PHABRICATOR_URL: "${PHABRICATOR_URL:-https://phabricator.services.mozilla.com}" + volumes: + - /absolute/path/to/predictor_model:/code/performanceregressionpredictormodel:ro +``` + +The host directory can be anywhere. Inside the container it must be mounted at: + +```text +/code/performanceregressionpredictormodel +``` + +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/performanceregressionpredictormodel/config.json \ + && echo "Model is mounted" +``` + +Request a prediction with an immutable Phabricator diff ID: + +```sh +curl --compressed -sS \ + -w '\nHTTP status: %{http_code}\n' \ + -H "X-Api-Key: local-test" \ + http://localhost:8000/performanceregressionpredictor/predict/phabricator/DIFF_ID +``` + +The first request normally returns HTTP 202. Repeat the same request until it +returns HTTP 200. + +For direct inference without Docker, Redis, Phabricator, or the HTTP API, see +the [Performance Regression Predictor CLI documentation](../docs/models/performance-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..d7a640ecc9 100644 --- a/http_service/bugbug_http/app.py +++ b/http_service/bugbug_http/app.py @@ -32,9 +32,11 @@ from bugbug import bugzilla, get_bugbug_version, utils from bugbug_http.models import ( MODELS_NAMES, + PERFORMANCE_REGRESSION_LOG_PREFIX, classify_broken_site_report, classify_bug, classify_issue, + classify_performance_regression, get_config_specific_groups, schedule_tests, schedule_tests_from_patch, @@ -108,6 +110,15 @@ class BugPrediction(Schema): extra_data = fields.Dict() +class PerformanceRegressionPrediction(Schema): + revision_id = fields.Integer() + diff_id = fields.Integer() + prob = fields.List(fields.Float()) + predicted_class = fields.Integer(data_key="class") + risk_score = fields.Float() + extra_data = fields.Dict() + + class NotAvailableYet(Schema): ready = fields.Boolean(metadata={"enum": [False]}) @@ -130,6 +141,10 @@ class Schedules(Schema): spec.components.schema(BugPrediction.__name__, schema=BugPrediction) +spec.components.schema( + PerformanceRegressionPrediction.__name__, + schema=PerformanceRegressionPrediction, +) spec.components.schema(NotAvailableYet.__name__, schema=NotAvailableYet) spec.components.schema(ModelName.__name__, schema=ModelName) spec.components.schema(UnauthorizedError.__name__, schema=UnauthorizedError) @@ -522,6 +537,77 @@ def model_prediction(model_name, bug_id): return compress_response(data, status_code) +@application.route("/performanceregressionpredictor/predict/phabricator/") +@cross_origin() +def performance_regression_prediction(diff_id: int): + """ + --- + get: + description: Predict performance-regression risk for a public Phabricator diff + summary: Predict performance-regression risk + parameters: + - name: diff_id + in: path + required: true + schema: + type: integer + example: 789012 + responses: + 200: + description: A performance-regression risk prediction + content: + application/json: + schema: PerformanceRegressionPrediction + 202: + description: The prediction is 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 + + LOGGER.info( + "%s Received prediction request for diff_id=%d", + PERFORMANCE_REGRESSION_LOG_PREFIX, + diff_id, + ) + + job = JobInfo(classify_performance_regression, diff_id) + data = get_result(job) + status_code = 200 + + if not data: + if not is_pending(job): + LOGGER.info( + "%s Queueing prediction job for diff_id=%d", + PERFORMANCE_REGRESSION_LOG_PREFIX, + diff_id, + ) + schedule_job(job) + else: + LOGGER.info( + "%s Prediction job is pending for diff_id=%d", + PERFORMANCE_REGRESSION_LOG_PREFIX, + diff_id, + ) + status_code = 202 + data = {"ready": False} + else: + LOGGER.info( + "%s Returning cached prediction for diff_id=%d", + PERFORMANCE_REGRESSION_LOG_PREFIX, + diff_id, + ) + + 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..2e73a2c6d5 100644 --- a/http_service/bugbug_http/models.py +++ b/http_service/bugbug_http/models.py @@ -18,7 +18,12 @@ 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.performance_regression_predictor import ( + MODEL_IDENTIFIER as PERFORMANCE_REGRESSION_PREDICTOR, +) +from bugbug.models.performance_regression_predictor import combine_commit_messages +from bugbug.tools.core.platforms.phabricator import PhabricatorPatch from bugbug.utils import get_hgmo_stack from bugbug_http.readthrough_cache import ReadthroughTTLCache @@ -41,6 +46,8 @@ "worksforme", "fenixcomponent", ] +MODELS_TO_DOWNLOAD = [*MODELS_NAMES, PERFORMANCE_REGRESSION_PREDICTOR] +PERFORMANCE_REGRESSION_LOG_PREFIX = "[performance-regression-predictor]" DEFAULT_EXPIRATION_TTL = 7 * 24 * 3600 # A week url = urlparse(os.environ.get("REDIS_URL", "redis://localhost/0")) @@ -53,8 +60,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 +238,91 @@ def classify_broken_site_report(model_name: str, reports_data: list[dict]) -> st return "OK" +def classify_performance_regression(diff_id: int) -> str: + """Predict performance-regression risk for one immutable Phabricator diff.""" + from bugbug_http.app import JobInfo + + job = JobInfo(classify_performance_regression, diff_id) + LOGGER.info( + "%s Processing prediction for diff_id=%d", + PERFORMANCE_REGRESSION_LOG_PREFIX, + diff_id, + ) + patch = PhabricatorPatch(diff_id=diff_id) + + if not patch.is_accessible() or not patch.is_public(): + LOGGER.warning( + "%s Prediction unavailable for diff_id=%d", + PERFORMANCE_REGRESSION_LOG_PREFIX, + diff_id, + ) + setkey(job.result_key, orjson.dumps({"available": False})) + return "OK" + + commit_messages = patch.commit_messages + if commit_messages: + if len(commit_messages) > 1: + LOGGER.warning( + "%s Diff %d has %d uploaded commit messages; combining them " + "for inference", + PERFORMANCE_REGRESSION_LOG_PREFIX, + diff_id, + len(commit_messages), + ) + commit_message = combine_commit_messages(commit_messages) + commit_message_source = "diff_metadata" + else: + LOGGER.warning( + "%s Diff %d has no uploaded commit message metadata; using revision " + "title and summary fallback", + PERFORMANCE_REGRESSION_LOG_PREFIX, + diff_id, + ) + commit_message = "\n\n".join( + part for part in (patch.patch_title, patch.patch_description) if part + ) + commit_message_source = "revision_title_and_summary_fallback" + + LOGGER.info( + "%s Using commit message source %s for diff_id=%d with commit_message_count=%d", + PERFORMANCE_REGRESSION_LOG_PREFIX, + commit_message_source, + diff_id, + len(commit_messages), + ) + + model = MODEL_CACHE.get(PERFORMANCE_REGRESSION_PREDICTOR) + + probabilities = model.classify( + [{"commit_message": commit_message, "diff": patch.raw_diff}], + probabilities=True, + )[0] + predicted_class = int(probabilities.argmax()) + data = { + "revision_id": patch.revision_id, + "diff_id": diff_id, + "prob": probabilities.tolist(), + "class": predicted_class, + "risk_score": float(probabilities[1]), + "extra_data": { + **model.get_extra_data(), + "commit_message_source": commit_message_source, + "commit_message_count": len(commit_messages), + }, + } + setkey(job.result_key, orjson.dumps(data), compress=True) + LOGGER.info( + "%s Finished prediction for diff_id=%d, " + "revision_id=%d, class=%d, risk_score=%f", + PERFORMANCE_REGRESSION_LOG_PREFIX, + diff_id, + patch.revision_id, + predicted_class, + float(probabilities[1]), + ) + return "OK" + + @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 4175a8d462..c70ffa0a8c 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[performance-regression-predictor]", "cerberus~=1.3.8", "Flask~=3.1.3", "flask-apispec~=0.11.4", diff --git a/http_service/tests/test_performance_regression_predictor.py b/http_service/tests/test_performance_regression_predictor.py new file mode 100644 index 0000000000..358446c5d9 --- /dev/null +++ b/http_service/tests/test_performance_regression_predictor.py @@ -0,0 +1,266 @@ +# -*- 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 zstandard + +from bugbug_http import models +from bugbug_http.app import API_TOKEN, JobInfo + + +def _response_json(response): + if response.headers.get("Content-Encoding") == "gzip": + return orjson.loads(gzip.decompress(response.data)) + return response.json + + +def test_endpoint_queues_and_returns_prediction(client, jobs, add_result) -> None: + endpoint = "/performanceregressionpredictor/predict/phabricator/789012" + + unauthorized = client.get(endpoint) + assert unauthorized.status_code == 401 + + wrong_input_kind = client.get( + "/performanceregressionpredictor/predict/123456", + headers={API_TOKEN: "test"}, + ) + assert wrong_input_kind.status_code == 404 + + response = client.get(endpoint, headers={API_TOKEN: "test"}) + assert response.status_code == 202 + assert _response_json(response) == {"ready": False} + + prediction = { + "revision_id": 123456, + "diff_id": 789012, + "prob": [0.25, 0.75], + "class": 1, + "risk_score": 0.75, + "extra_data": {"calibrated": False}, + } + 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_uses_diff_commit_metadata(monkeypatch) -> None: + class FakePatch: + def __init__(self, diff_id): + assert diff_id == 789012 + self.revision_id = 123456 + self.commit_messages = ["[PATCH] - Make rendering faster"] + self.patch_title = "Unused title" + self.patch_description = "Unused summary" + self.raw_diff = "diff --git a/a b/a\n" + + def is_accessible(self): + return True + + def is_public(self): + return True + + class FakeModel: + def __init__(self): + self.items = None + + def classify(self, items, probabilities=False): + assert probabilities + self.items = items + return np.array([[0.2, 0.8]]) + + def get_extra_data(self): + return {"calibrated": False} + + fake_model = FakeModel() + monkeypatch.setattr(models, "PhabricatorPatch", FakePatch) + monkeypatch.setattr( + models.MODEL_CACHE, + "get", + lambda model_name: fake_model, + ) + + assert models.classify_performance_regression(789012) == "OK" + assert fake_model.items == [ + { + "commit_message": "Make rendering faster", + "diff": "diff --git a/a b/a\n", + } + ] + + job = JobInfo(models.classify_performance_regression, 789012) + stored = models.redis.get(job.result_key) + assert stored is not None + result = orjson.loads(zstandard.ZstdDecompressor().decompress(stored)) + assert result["revision_id"] == 123456 + assert result["diff_id"] == 789012 + assert result["risk_score"] == 0.8 + assert result["class"] == 1 + assert result["extra_data"]["commit_message_source"] == "diff_metadata" + assert result["extra_data"]["commit_message_count"] == 1 + + +def test_worker_marks_inaccessible_diff_unavailable(monkeypatch) -> None: + class FakePatch: + def __init__(self, diff_id): + assert diff_id == 789012 + + def is_accessible(self): + return False + + def is_public(self): + raise AssertionError("is_public should not be called") + + monkeypatch.setattr(models, "PhabricatorPatch", FakePatch) + + assert models.classify_performance_regression(789012) == "OK" + job = JobInfo(models.classify_performance_regression, 789012) + stored = models.redis.get(job.result_key) + assert stored is not None + result = orjson.loads(stored) + assert result == {"available": False} + + +def test_worker_cleans_and_combines_multiple_commit_messages(monkeypatch) -> None: + class FakePatch: + revision_id = 123456 + commit_messages = [ + "Bug 123456 - Improve rendering\n\nFirst body.", + "[PATCH] Bug 789012 - Avoid repeated work\n\nSecond body.", + ] + patch_title = "Unused title" + patch_description = "Unused summary" + raw_diff = "diff --git a/a b/a\n" + + def __init__(self, diff_id): + assert diff_id == 789012 + + def is_accessible(self): + return True + + def is_public(self): + return True + + class FakeModel: + def classify(self, items, probabilities=False): + assert items[0]["commit_message"] == ( + "Improve rendering\n\nFirst body.\n\n" + "Avoid repeated work\n\nSecond body." + ) + return np.array([[0.3, 0.7]]) + + def get_extra_data(self): + return {} + + monkeypatch.setattr(models, "PhabricatorPatch", FakePatch) + monkeypatch.setattr(models.MODEL_CACHE, "get", lambda model_name: FakeModel()) + + assert models.classify_performance_regression(789012) == "OK" + job = JobInfo(models.classify_performance_regression, 789012) + stored = models.redis.get(job.result_key) + assert stored is not None + result = orjson.loads(zstandard.ZstdDecompressor().decompress(stored)) + assert result["extra_data"]["commit_message_source"] == "diff_metadata" + assert result["extra_data"]["commit_message_count"] == 2 + + +def test_worker_falls_back_to_revision_message(monkeypatch) -> None: + class FakePatch: + revision_id = 123456 + commit_messages: list[str] = [] + patch_title = "Improve rendering" + patch_description = "Avoid repeated work." + raw_diff = "diff --git a/a b/a\n" + + def __init__(self, diff_id): + assert diff_id == 789012 + + def is_accessible(self): + return True + + def is_public(self): + return True + + class FakeModel: + def classify(self, items, probabilities=False): + assert items[0]["commit_message"] == ( + "Improve rendering\n\nAvoid repeated work." + ) + return np.array([[0.6, 0.4]]) + + def get_extra_data(self): + return {} + + monkeypatch.setattr(models, "PhabricatorPatch", FakePatch) + monkeypatch.setattr(models.MODEL_CACHE, "get", lambda model_name: FakeModel()) + + assert models.classify_performance_regression(789012) == "OK" + job = JobInfo(models.classify_performance_regression, 789012) + stored = models.redis.get(job.result_key) + assert stored is not None + result = orjson.loads(zstandard.ZstdDecompressor().decompress(stored)) + assert result["extra_data"]["commit_message_source"] == ( + "revision_title_and_summary_fallback" + ) + assert result["extra_data"]["commit_message_count"] == 0 + + +def test_worker_propagates_model_loading_failure(monkeypatch) -> None: + class FakePatch: + revision_id = 123456 + commit_messages = ["Improve rendering"] + patch_title = "Unused title" + patch_description = "Unused summary" + + def __init__(self, diff_id): + assert diff_id == 789012 + + def is_accessible(self): + return True + + def is_public(self): + return True + + monkeypatch.setattr(models, "PhabricatorPatch", FakePatch) + + 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_performance_regression(789012) + + job = JobInfo(models.classify_performance_regression, 789012) + 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 f132683dcf..77e7f110f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,6 +71,10 @@ nlp = [ "spacy==3.8.14", ] nn = [] +performance-regression-predictor = [ + "torch>=2.6,<3", + "transformers==4.56.2", +] [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-performance-regression = "scripts.performance_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/performance_regression_predictor.py b/scripts/performance_regression_predictor.py new file mode 100644 index 0000000000..7841465572 --- /dev/null +++ b/scripts/performance_regression_predictor.py @@ -0,0 +1,108 @@ +# -*- 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 Performance Regression Predictor against a local patch.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from email import policy +from email.parser import Parser +from pathlib import Path + +from bugbug.models.performance_regression_predictor import ( + PerformanceRegressionPredictorModel, +) + + +def extract_commit_message_from_patch(patch: str) -> str | None: + """Extract a message from Git format-patch or Mercurial export content.""" + 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 re.search(r"^Subject:", patch, flags=re.MULTILINE): + 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 = re.split(r"^---\s*$|^diff --git ", body, maxsplit=1, flags=re.MULTILINE)[ + 0 + ].strip() + message = "\n\n".join(part for part in (subject, body) if part) + return message or None + + return None + + +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 = extract_commit_message_from_patch(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 = PerformanceRegressionPredictorModel.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_performance_regression_predictor.py b/tests/test_performance_regression_predictor.py new file mode 100644 index 0000000000..26e783533e --- /dev/null +++ b/tests/test_performance_regression_predictor.py @@ -0,0 +1,202 @@ +# -*- 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.performance_regression_predictor import ( + build_model_input, + clean_commit_message, + combine_commit_messages, + diff_to_structured_text, +) +from scripts.performance_regression_predictor import ( + extract_commit_message_from_patch, +) + +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_combine_commit_messages_cleans_each_subject() -> None: + assert combine_commit_messages( + [ + "Bug 123456 - Improve rendering\n\nFirst body.", + "[PATCH] Bug 789012 - Avoid repeated work\n\nSecond body.", + ] + ) == ("Improve rendering\n\nFirst body.\n\nAvoid repeated work\n\nSecond body.") + + +def test_combine_commit_messages_drops_empty_messages() -> None: + assert ( + combine_commit_messages( + [ + "", + " ", + "Bug 123456 - Improve rendering", + ] + ) + == "Improve rendering" + ) + + +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_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: + prompt = 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: + prompt = 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/tests/test_phabricator.py b/tests/test_phabricator.py index 5fe1e72a62..7bbf398e68 100644 --- a/tests/test_phabricator.py +++ b/tests/test_phabricator.py @@ -324,6 +324,43 @@ def test_get_project_members_empty(monkeypatch) -> None: phab_platform.get_project_members.cache_clear() +def test_diff_commit_messages(monkeypatch) -> None: + client = MagicMock() + client.search_diffs.return_value = [ + { + "attachments": { + "commits": { + "commits": [ + {"identifier": "abc", "message": "First message"}, + {"identifier": "def", "message": ""}, + {"identifier": "ghi", "message": "Second message\n\nBody"}, + ] + } + } + } + ] + monkeypatch.setattr(phab_platform, "get_phabricator_client", lambda: client) + + patch = phab_platform.PhabricatorPatch(diff_id=123) + + assert patch.commit_messages == ["First message", "Second message\n\nBody"] + client.search_diffs.assert_called_once_with( + diff_id=123, + attachments={"commits": True}, + ) + + +def test_missing_diff_is_not_accessible(monkeypatch) -> None: + client = MagicMock() + client.search_diffs.return_value = [] + monkeypatch.setattr(phab_platform, "get_phabricator_client", lambda: client) + + patch = phab_platform.PhabricatorPatch(diff_id=123) + + assert not patch.is_accessible() + client.search_diffs.assert_called_once_with(diff_id=123) + + # --------------------------------------------------------------------------- # Rotation recovery: historical_reviewer_project_phids # --------------------------------------------------------------------------- diff --git a/uv.lock b/uv.lock index 2024e35395..56bb79a261 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] @@ -659,6 +662,11 @@ dependencies = [ nlp = [ { name = "spacy" }, ] +performance-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 = [ @@ -727,13 +735,16 @@ requires-dist = [ { name = "tabulate", specifier = "~=0.10.0" }, { name = "taskcluster", specifier = ">=97.1,<100.6" }, { name = "tenacity", specifier = "~=9.1.4" }, + { name = "torch", marker = "(sys_platform == 'linux' and extra == 'performance-regression-predictor') or (sys_platform == 'win32' and extra == 'performance-regression-predictor')", specifier = ">=2.6,<3", index = "https://download.pytorch.org/whl/cpu" }, + { name = "torch", marker = "sys_platform != 'linux' and sys_platform != 'win32' and extra == 'performance-regression-predictor'", specifier = ">=2.6,<3" }, { name = "tqdm", specifier = ">=4.67.3,<4.69.0" }, + { name = "transformers", marker = "extra == 'performance-regression-predictor'", specifier = "==4.56.2" }, { name = "unidiff", specifier = "~=0.7.5" }, { name = "weave", specifier = ">=0.50.0" }, { name = "xgboost", specifier = ">=3.2,<3.4" }, { name = "zstandard", specifier = "~=0.25.0" }, ] -provides-extras = ["nlp", "nn"] +provides-extras = ["nlp", "nn", "performance-regression-predictor"] [package.metadata.requires-dev] spawn-pipeline = [ @@ -761,7 +772,7 @@ source = { editable = "http_service" } dependencies = [ { name = "apispec", extra = ["yaml"] }, { name = "apispec-webframeworks" }, - { name = "bugbug" }, + { name = "bugbug", extra = ["performance-regression-predictor"] }, { name = "cerberus" }, { name = "flask" }, { name = "flask-apispec" }, @@ -778,7 +789,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 = ["performance-regression-predictor"], editable = "." }, { name = "cerberus", specifier = "~=1.3.8" }, { name = "flask", specifier = "~=3.1.3" }, { name = "flask-apispec", specifier = "~=0.11.4" }, @@ -2850,22 +2861,21 @@ wheels = [ [[package]] name = "huggingface-hub" -version = "1.16.1" +version = "0.36.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, { name = "fsspec" }, - { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, - { name = "httpx" }, + { name = "hf-xet", marker = "platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, { name = "packaging" }, { name = "pyyaml" }, + { name = "requests" }, { name = "tqdm" }, - { name = "typer" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/48/0f/ed994dbade67a54407c28cab96ef845e0e6d25500be56aca6394f8bfc9dd/huggingface_hub-1.16.1.tar.gz", hash = "sha256:7f1dc4c5ec21aed69be630ad0c3378616be16f3de1a47b141c0e812965d9c832", size = 792534, upload-time = "2026-05-21T18:40:00.908Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/b7/8cb61d2eece5fb05a83271da168186721c450eb74e3c31f7ef3169fa475b/huggingface_hub-0.36.2.tar.gz", hash = "sha256:1934304d2fb224f8afa3b87007d58501acfda9215b334eed53072dd5e815ff7a", size = 649782, upload-time = "2026-02-06T09:24:13.098Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/79/621a7dbb80c70974f73a597275351ebe03ce5bc65cb5f8f4acb5859252bc/huggingface_hub-1.16.1-py3-none-any.whl", hash = "sha256:64340de934b9ce37857ef85a82de72f5629e8a270f9119eabb12bf495eb53c22", size = 668176, upload-time = "2026-05-21T18:39:58.596Z" }, + { url = "https://files.pythonhosted.org/packages/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl", hash = "sha256:48f0c8eac16145dfce371e9d2d7772854a4f591bcb56c9cf548accf531d54270", size = 566395, upload-time = "2026-02-06T09:24:11.133Z" }, ] [[package]] @@ -3708,13 +3718,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/01/88/a8952b6d5c21e74cbf158515b779666f692846502623e9e3c39d8e8ba25f/llvmlite-0.47.0.tar.gz", hash = "sha256:62031ce968ec74e95092184d4b0e857e444f8fdff0b8f9213707699570c33ccc", size = 193614, upload-time = "2026-03-31T18:29:53.497Z" } wheels = [ @@ -4315,6 +4328,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" @@ -4490,9 +4512,9 @@ resolution-markers = [ "python_full_version < '3.13' and platform_machine == 'x86_64' and sys_platform == 'darwin'", ] dependencies = [ - { name = "llvmlite", version = "0.45.1", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, - { name = "numpy", marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, - { name = "setuptools", marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, + { name = "llvmlite", version = "0.45.1", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy" }, + { name = "setuptools" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b0/2a/975f49e156dae4edd3ab5afc60e2b3d65add014db2ddbbc23b9bb89882a4/numba-0.47.0.tar.gz", hash = "sha256:c0703df0a0ea2e29fbef7937d9849cc4734253066cb5820c5d6e0851876e3b0a", size = 1935290, upload-time = "2020-01-03T17:03:47.391Z" } @@ -4503,17 +4525,20 @@ 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.47.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, - { name = "numpy", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, + { name = "llvmlite", version = "0.47.0", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f6/c5/db2ac3685833d626c0dcae6bd2330cd68433e1fd248d15f70998160d3ad7/numba-0.65.1.tar.gz", hash = "sha256:19357146c32fe9ed25059ab915e8465fb13951cf6b0aace3826b76886373ab23", size = 2765600, upload-time = "2026-04-24T02:02:56.551Z" } wheels = [ @@ -4949,7 +4974,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "ptyprocess" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -6421,6 +6446,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/5f/4c174edad94f82de888ac00a5ddd8d07b35609b6c94f0bdf4d74af57703e/s3transfer-0.19.0-py3-none-any.whl", hash = "sha256:777cc2415536f1debadb5c2ef7779275d0fc0fe0e042411cdd6caebeb2685262", size = 90101, upload-time = "2026-06-16T19:44:50.439Z" }, ] +[[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" @@ -6533,8 +6582,8 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography", marker = "(platform_machine != 'x86_64' and sys_platform == 'darwin') or (sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')" }, - { name = "jeepney", marker = "(platform_machine != 'x86_64' and sys_platform == 'darwin') or (sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "cryptography" }, + { name = "jeepney" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ @@ -6931,6 +6980,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] +[[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" @@ -7089,29 +7150,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]] @@ -7123,6 +7183,83 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" }, ] +[[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.68.4" @@ -7144,6 +7281,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/98/a9937a969d018a23badfea0b381f66783649d48e0ea6c41923265c3cbeb3/traitlets-5.15.0-py3-none-any.whl", hash = "sha256:fb36a18867a6803deab09f3c5e0fa81bb7b26a5c9e82501c9933f759166eff40", size = 85877, upload-time = "2026-05-06T08:05:55.853Z" }, ] +[[package]] +name = "transformers" +version = "4.56.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "requests" }, + { name = "safetensors" }, + { name = "tokenizers" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e5/82/0bcfddd134cdf53440becb5e738257cc3cf34cf229d63b57bfd288e6579f/transformers-4.56.2.tar.gz", hash = "sha256:5e7c623e2d7494105c726dd10f6f90c2c99a55ebe86eef7233765abd0cb1c529", size = 9844296, upload-time = "2025-09-19T15:16:26.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/26/2591b48412bde75e33bfd292034103ffe41743cacd03120e3242516cd143/transformers-4.56.2-py3-none-any.whl", hash = "sha256:79c03d0e85b26cb573c109ff9eafa96f3c8d4febfd8a0774e8bba32702dd6dde", size = 11608055, upload-time = "2025-09-19T15:16:23.736Z" }, +] + [[package]] name = "treeherder-client" version = "5.0.0" From 2038a539af8630aae1f05441876aa39e15e1ee7e Mon Sep 17 00:00:00 2001 From: Ali Sayed Salehi Date: Mon, 10 Aug 2026 16:07:01 -0400 Subject: [PATCH 2/9] upgrade transformers and torch dependencies to the latest stable version --- pyproject.toml | 4 ++-- uv.lock | 26 +++++++++++++------------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b027ed2dda..6279e756a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,8 +72,8 @@ nlp = [ ] nn = [] performance-regression-predictor = [ - "torch>=2.6,<3", - "transformers==4.56.2", + "torch==2.13.0", + "transformers==5.15.0", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 9dc274940b..cb02844cf1 100644 --- a/uv.lock +++ b/uv.lock @@ -738,10 +738,10 @@ requires-dist = [ { name = "tabulate", specifier = "~=0.10.0" }, { name = "taskcluster", specifier = ">=97.1,<102.1" }, { name = "tenacity", specifier = "~=9.1.4" }, - { name = "torch", marker = "(sys_platform == 'linux' and extra == 'performance-regression-predictor') or (sys_platform == 'win32' and extra == 'performance-regression-predictor')", specifier = ">=2.6,<3", index = "https://download.pytorch.org/whl/cpu" }, - { name = "torch", marker = "sys_platform != 'linux' and sys_platform != 'win32' and extra == 'performance-regression-predictor'", specifier = ">=2.6,<3" }, + { name = "torch", marker = "(sys_platform == 'linux' and extra == 'performance-regression-predictor') or (sys_platform == 'win32' and extra == 'performance-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 == 'performance-regression-predictor'", specifier = "==2.13.0" }, { name = "tqdm", specifier = ">=4.67.3,<4.71.0" }, - { name = "transformers", marker = "extra == 'performance-regression-predictor'", specifier = "==4.56.2" }, + { name = "transformers", marker = "extra == 'performance-regression-predictor'", specifier = "==5.15.0" }, { name = "unidiff", specifier = "~=0.7.5" }, { name = "weave", specifier = ">=0.53.4" }, { name = "xgboost", specifier = ">=3.2,<3.4" }, @@ -2887,21 +2887,22 @@ wheels = [ [[package]] name = "huggingface-hub" -version = "0.36.2" +version = "1.27.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "click" }, { name = "filelock" }, { name = "fsspec" }, - { name = "hf-xet", marker = "platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, { name = "packaging" }, { name = "pyyaml" }, - { name = "requests" }, { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7c/b7/8cb61d2eece5fb05a83271da168186721c450eb74e3c31f7ef3169fa475b/huggingface_hub-0.36.2.tar.gz", hash = "sha256:1934304d2fb224f8afa3b87007d58501acfda9215b334eed53072dd5e815ff7a", size = 649782, upload-time = "2026-02-06T09:24:13.098Z" } +sdist = { url = "https://files.pythonhosted.org/packages/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/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl", hash = "sha256:48f0c8eac16145dfce371e9d2d7772854a4f591bcb56c9cf548accf531d54270", size = 566395, upload-time = "2026-02-06T09:24:11.133Z" }, + { url = "https://files.pythonhosted.org/packages/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]] @@ -7365,23 +7366,22 @@ wheels = [ [[package]] name = "transformers" -version = "4.56.2" +version = "5.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "filelock" }, { name = "huggingface-hub" }, { name = "numpy" }, { name = "packaging" }, { name = "pyyaml" }, { name = "regex" }, - { name = "requests" }, { name = "safetensors" }, { name = "tokenizers" }, { name = "tqdm" }, + { name = "typer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e5/82/0bcfddd134cdf53440becb5e738257cc3cf34cf229d63b57bfd288e6579f/transformers-4.56.2.tar.gz", hash = "sha256:5e7c623e2d7494105c726dd10f6f90c2c99a55ebe86eef7233765abd0cb1c529", size = 9844296, upload-time = "2025-09-19T15:16:26.778Z" } +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/70/26/2591b48412bde75e33bfd292034103ffe41743cacd03120e3242516cd143/transformers-4.56.2-py3-none-any.whl", hash = "sha256:79c03d0e85b26cb573c109ff9eafa96f3c8d4febfd8a0774e8bba32702dd6dde", size = 11608055, upload-time = "2025-09-19T15:16:23.736Z" }, + { 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]] From d2af31d32fdff97936bf1713f7c45420491a8549 Mon Sep 17 00:00:00 2001 From: Ali Sayed Salehi Date: Wed, 19 Aug 2026 16:33:33 -0400 Subject: [PATCH 3/9] rename model to perf regression predictor. Move readme docs to a separate file for perf predictor feature --- bugbug/models/__init__.py | 2 +- ...dictor.py => perf_regression_predictor.py} | 14 +- docs/README.md | 2 +- ...dictor.md => perf-regression-predictor.md} | 32 +-- http_service/README.md | 208 +---------------- .../README.perf-regression-predictor.md | 211 ++++++++++++++++++ http_service/bugbug_http/app.py | 18 +- http_service/bugbug_http/models.py | 28 +-- http_service/pyproject.toml | 2 +- ...r.py => test_perf_regression_predictor.py} | 24 +- pyproject.toml | 4 +- ...dictor.py => perf_regression_predictor.py} | 8 +- ...r.py => test_perf_regression_predictor.py} | 4 +- uv.lock | 14 +- 14 files changed, 298 insertions(+), 273 deletions(-) rename bugbug/models/{performance_regression_predictor.py => perf_regression_predictor.py} (96%) rename docs/models/{performance-regression-predictor.md => perf-regression-predictor.md} (78%) create mode 100644 http_service/README.perf-regression-predictor.md rename http_service/tests/{test_performance_regression_predictor.py => test_perf_regression_predictor.py} (90%) rename scripts/{performance_regression_predictor.py => perf_regression_predictor.py} (93%) rename tests/{test_performance_regression_predictor.py => test_perf_regression_predictor.py} (97%) diff --git a/bugbug/models/__init__.py b/bugbug/models/__init__.py index 239fb4b2b5..6b4b369166 100644 --- a/bugbug/models/__init__.py +++ b/bugbug/models/__init__.py @@ -23,7 +23,7 @@ "invalidcompatibilityreport": "bugbug.models.invalid_compatibility_report.InvalidCompatibilityReportModel", "needsdiagnosis": "bugbug.models.needsdiagnosis.NeedsDiagnosisModel", "performancebug": "bugbug.models.performancebug.PerformanceBugModel", - "performanceregressionpredictor": "bugbug.models.performance_regression_predictor.PerformanceRegressionPredictorModel", + "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/performance_regression_predictor.py b/bugbug/models/perf_regression_predictor.py similarity index 96% rename from bugbug/models/performance_regression_predictor.py rename to bugbug/models/perf_regression_predictor.py index 9d3283a385..48d86efa6f 100644 --- a/bugbug/models/performance_regression_predictor.py +++ b/bugbug/models/perf_regression_predictor.py @@ -3,7 +3,7 @@ # 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 performance regression predictor.""" +"""Inference-only perf regression predictor.""" from __future__ import annotations @@ -17,8 +17,8 @@ from bugbug.model import Model -MODEL_NAME = "Performance Regression Predictor" -MODEL_IDENTIFIER = "performanceregressionpredictor" +MODEL_NAME = "Perf Regression Predictor" +MODEL_IDENTIFIER = "perfregressionpredictor" DEFAULT_MODEL_DIRECTORY = f"{MODEL_IDENTIFIER}model" POSITIVE_CLASS_ID = 1 @@ -181,7 +181,7 @@ def build_model_input(commit_message: str | None, raw_diff: str) -> str: ) -class PerformanceRegressionPredictorModel(Model): +class PerfRegressionPredictorModel(Model): """Hugging Face sequence classifier used only for inference.""" training_supported = False @@ -195,7 +195,7 @@ def __init__(self, tokenizer: Any = None, transformer_model: Any = None) -> None self.model_metadata: dict[str, Any] = {} @classmethod - def load(cls, model_directory: str) -> "PerformanceRegressionPredictorModel": + def load(cls, model_directory: str) -> "PerfRegressionPredictorModel": """Load a local Hugging Face checkpoint directory.""" from transformers import AutoModelForSequenceClassification, AutoTokenizer @@ -230,9 +230,7 @@ def _validate_checkpoint(self) -> None: config = self.transformer_model.config if int(config.num_labels) != 2: - raise ValueError( - "Performance Regression Predictor requires exactly two labels" - ) + raise ValueError("Perf Regression Predictor requires exactly two labels") id2label = { int(label_id): label diff --git a/docs/README.md b/docs/README.md index ca060882ab..70cbbfd13b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,4 +1,4 @@ Detailed documentation per model - [Regressor model for predicting risky commits](models/regressor.md) -- [Performance Regression Predictor](models/performance-regression-predictor.md) +- [Perf Regression Predictor](models/perf-regression-predictor.md) diff --git a/docs/models/performance-regression-predictor.md b/docs/models/perf-regression-predictor.md similarity index 78% rename from docs/models/performance-regression-predictor.md rename to docs/models/perf-regression-predictor.md index 15c518f99b..884e618b5a 100644 --- a/docs/models/performance-regression-predictor.md +++ b/docs/models/perf-regression-predictor.md @@ -1,6 +1,6 @@ -# Performance Regression Predictor +# Perf Regression Predictor -The Performance Regression Predictor is an inference-only binary transformer +The Perf Regression Predictor is an inference-only binary transformer model. It predicts whether a public Phabricator diff is likely to introduce a performance regression. @@ -30,18 +30,18 @@ Hugging Face checkpoint: ```sh cd /path/to/bugbug -uv run --extra performance-regression-predictor \ - bugbug-predict-performance-regression \ +uv run --extra perf-regression-predictor \ + bugbug-predict-perf-regression \ --model-dir /absolute/path/to/predictor_model \ - --patch-file examples/performance_regression_predictor.patch + --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 performance-regression-predictor \ - bugbug-predict-performance-regression \ +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" @@ -61,7 +61,7 @@ The endpoint uses the service's existing Redis/RQ worker and API-key presence check: ```text -GET /performanceregressionpredictor/predict/phabricator/{diff_id} +GET /perfregressionpredictor/predict/phabricator/{diff_id} X-Api-Key: ... ``` @@ -69,9 +69,9 @@ The first request normally returns `202 {"ready": false}`. Poll the same URL until it returns `200`. The worker requires `PHABRICATOR_API_KEY`; a custom Phabricator host can be set with `PHABRICATOR_URL`. -See [HTTP service local development](../../http_service/README.md) for the -complete Docker Compose setup, including the local model mount and secret -file. +See [HTTP service local development](../../http_service/README.perf-regression-predictor.md) +for the complete Docker Compose setup, including the local model mount and +secret file. Example result: @@ -83,7 +83,7 @@ Example result: "class": 1, "risk_score": 0.75, "extra_data": { - "model_name": "Performance Regression Predictor", + "model_name": "Perf Regression Predictor", "model_version": null, "max_length": 512, "calibrated": false, @@ -100,19 +100,19 @@ the diff metadata. ## Model artifact Production follows the existing Bugbug model-artifact convention. The -checkpoint directory must be named `performanceregressionpredictormodel` and +checkpoint directory must be named `perfregressionpredictormodel` and published as: ```text -public/performanceregressionpredictormodel.tar.zst +public/perfregressionpredictormodel.tar.zst ``` under the indexed Taskcluster namespace -`project.bugbug.train_performanceregressionpredictor.`. For this first +`project.bugbug.train_perfregressionpredictor.`. For this first iteration, the archive can be created and published by a one-off Taskcluster task; no `bugbug-train` workflow is registered for this model. The standard background-worker image then downloads it alongside the other model artifacts. For local Docker development before the artifact is published, mount the local -checkpoint at `/code/performanceregressionpredictormodel` in the background +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.md b/http_service/README.md index 91ad378205..dae03bff7b 100644 --- a/http_service/README.md +++ b/http_service/README.md @@ -1,207 +1,23 @@ -# HTTP Service Local Development +### Local development -Run Docker Compose commands from this directory, not the repository root. The -root may have a different Compose application with unrelated credentials. +**For starting the service locally run the following commands.** -```sh -cd /path/to/bugbug/http_service -``` +Start Redis: -## Services + docker-compose up redis -The local Compose file defines: +Build the http service image: -- `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`. + docker build -t mozilla/bugbug-http-service -f Dockerfile . -## Environment +Start the http service: -Most environment variables are optional for local startup, but individual -endpoints may need service-specific credentials: + docker-compose up bugbug-http-service -- `BUGBUG_BUGZILLA_TOKEN`: needed by Bugzilla bug classification endpoints. -- `BUGBUG_GITHUB_TOKEN`: needed by GitHub issue classification endpoints. -- `PHABRICATOR_API_KEY`: needed by Phabricator-backed endpoints. -- `PHABRICATOR_URL`: optional; defaults to Mozilla production Phabricator. -- `BUGBUG_ALLOW_MISSING_MODELS=1`: useful for local development when you only - need one model and do not have every model artifact locally. +Build the background worker image: -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. + docker build -t mozilla/bugbug-http-service-bg-worker --build-arg TAG=latest -f Dockerfile.bg_worker . -If you need local secrets, create `.env` in this directory -(`http_service/.env` from the repository root): +Run the background worker: -```dotenv -BUGBUG_BUGZILLA_TOKEN= -BUGBUG_GITHUB_TOKEN= -PHABRICATOR_API_KEY= -PHABRICATOR_URL=https://phabricator.services.mozilla.com -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 -``` - -## Performance Regression Predictor - -The Performance Regression Predictor uses the same HTTP service and background -worker, but it needs two extra local-development pieces while the model artifact -is unpublished: - -- a local Hugging Face checkpoint mounted at the standard model directory; -- a Phabricator Conduit token so the worker can fetch diff metadata and raw - diffs. - -### Create The Secret File - -Create `.env` in this directory (`http_service/.env` from the repository root) -with your Conduit token: - -```dotenv -CONDUIT_API_TOKEN=api-replace-with-your-token -PHABRICATOR_URL=https://phabricator.services.mozilla.com -``` - -### 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" - PHABRICATOR_API_KEY: ${CONDUIT_API_TOKEN} - PHABRICATOR_URL: "${PHABRICATOR_URL:-https://phabricator.services.mozilla.com}" - volumes: - - /absolute/path/to/predictor_model:/code/performanceregressionpredictormodel:ro -``` - -The host directory can be anywhere. Inside the container it must be mounted at: - -```text -/code/performanceregressionpredictormodel -``` - -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/performanceregressionpredictormodel/config.json \ - && echo "Model is mounted" -``` - -Request a prediction with an immutable Phabricator diff ID: - -```sh -curl --compressed -sS \ - -w '\nHTTP status: %{http_code}\n' \ - -H "X-Api-Key: local-test" \ - http://localhost:8000/performanceregressionpredictor/predict/phabricator/DIFF_ID -``` - -The first request normally returns HTTP 202. Repeat the same request until it -returns HTTP 200. - -For direct inference without Docker, Redis, Phabricator, or the HTTP API, see -the [Performance Regression Predictor CLI documentation](../docs/models/performance-regression-predictor.md#local-inference-with-the-cli). + docker-compose up bugbug-http-service-bg-worker diff --git a/http_service/README.perf-regression-predictor.md b/http_service/README.perf-regression-predictor.md new file mode 100644 index 0000000000..f2b434f8ad --- /dev/null +++ b/http_service/README.perf-regression-predictor.md @@ -0,0 +1,211 @@ +# 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. +- `PHABRICATOR_API_KEY`: needed by Phabricator-backed endpoints. +- `PHABRICATOR_URL`: optional; defaults to Mozilla production Phabricator. +- `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= +PHABRICATOR_API_KEY= +PHABRICATOR_URL=https://phabricator.services.mozilla.com +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, but it needs two extra local-development pieces while the model artifact +is unpublished: + +- a local Hugging Face checkpoint mounted at the standard model directory; +- a Phabricator Conduit token so the worker can fetch diff metadata and raw + diffs. + +### Create The Secret File + +Create `.env` in this directory (`http_service/.env` from the repository root) +with your Conduit token: + +```dotenv +CONDUIT_API_TOKEN=api-replace-with-your-token +PHABRICATOR_URL=https://phabricator.services.mozilla.com +``` + +### 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" + PHABRICATOR_API_KEY: ${CONDUIT_API_TOKEN} + PHABRICATOR_URL: "${PHABRICATOR_URL:-https://phabricator.services.mozilla.com}" + 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 with an immutable Phabricator diff ID: + +```sh +curl --compressed -sS \ + -w '\nHTTP status: %{http_code}\n' \ + -H "X-Api-Key: local-test" \ + http://localhost:8000/perfregressionpredictor/predict/phabricator/DIFF_ID +``` + +The first request normally returns HTTP 202. Repeat the same request until it +returns HTTP 200. + +For direct inference without Docker, Redis, Phabricator, 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 d7a640ecc9..060d558e72 100644 --- a/http_service/bugbug_http/app.py +++ b/http_service/bugbug_http/app.py @@ -32,11 +32,11 @@ from bugbug import bugzilla, get_bugbug_version, utils from bugbug_http.models import ( MODELS_NAMES, - PERFORMANCE_REGRESSION_LOG_PREFIX, + PERF_REGRESSION_LOG_PREFIX, classify_broken_site_report, classify_bug, classify_issue, - classify_performance_regression, + classify_perf_regression, get_config_specific_groups, schedule_tests, schedule_tests_from_patch, @@ -537,9 +537,9 @@ def model_prediction(model_name, bug_id): return compress_response(data, status_code) -@application.route("/performanceregressionpredictor/predict/phabricator/") +@application.route("/perfregressionpredictor/predict/phabricator/") @cross_origin() -def performance_regression_prediction(diff_id: int): +def perf_regression_prediction(diff_id: int): """ --- get: @@ -574,11 +574,11 @@ def performance_regression_prediction(diff_id: int): LOGGER.info( "%s Received prediction request for diff_id=%d", - PERFORMANCE_REGRESSION_LOG_PREFIX, + PERF_REGRESSION_LOG_PREFIX, diff_id, ) - job = JobInfo(classify_performance_regression, diff_id) + job = JobInfo(classify_perf_regression, diff_id) data = get_result(job) status_code = 200 @@ -586,14 +586,14 @@ def performance_regression_prediction(diff_id: int): if not is_pending(job): LOGGER.info( "%s Queueing prediction job for diff_id=%d", - PERFORMANCE_REGRESSION_LOG_PREFIX, + PERF_REGRESSION_LOG_PREFIX, diff_id, ) schedule_job(job) else: LOGGER.info( "%s Prediction job is pending for diff_id=%d", - PERFORMANCE_REGRESSION_LOG_PREFIX, + PERF_REGRESSION_LOG_PREFIX, diff_id, ) status_code = 202 @@ -601,7 +601,7 @@ def performance_regression_prediction(diff_id: int): else: LOGGER.info( "%s Returning cached prediction for diff_id=%d", - PERFORMANCE_REGRESSION_LOG_PREFIX, + PERF_REGRESSION_LOG_PREFIX, diff_id, ) diff --git a/http_service/bugbug_http/models.py b/http_service/bugbug_http/models.py index 2e73a2c6d5..b42391a3b8 100644 --- a/http_service/bugbug_http/models.py +++ b/http_service/bugbug_http/models.py @@ -19,10 +19,10 @@ from bugbug.github import Github from bugbug.model import Model from bugbug.models import get_model_class, testselect -from bugbug.models.performance_regression_predictor import ( - MODEL_IDENTIFIER as PERFORMANCE_REGRESSION_PREDICTOR, +from bugbug.models.perf_regression_predictor import ( + MODEL_IDENTIFIER as PERF_REGRESSION_PREDICTOR, ) -from bugbug.models.performance_regression_predictor import combine_commit_messages +from bugbug.models.perf_regression_predictor import combine_commit_messages from bugbug.tools.core.platforms.phabricator import PhabricatorPatch from bugbug.utils import get_hgmo_stack from bugbug_http.readthrough_cache import ReadthroughTTLCache @@ -46,8 +46,8 @@ "worksforme", "fenixcomponent", ] -MODELS_TO_DOWNLOAD = [*MODELS_NAMES, PERFORMANCE_REGRESSION_PREDICTOR] -PERFORMANCE_REGRESSION_LOG_PREFIX = "[performance-regression-predictor]" +MODELS_TO_DOWNLOAD = [*MODELS_NAMES, PERF_REGRESSION_PREDICTOR] +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")) @@ -238,14 +238,14 @@ def classify_broken_site_report(model_name: str, reports_data: list[dict]) -> st return "OK" -def classify_performance_regression(diff_id: int) -> str: +def classify_perf_regression(diff_id: int) -> str: """Predict performance-regression risk for one immutable Phabricator diff.""" from bugbug_http.app import JobInfo - job = JobInfo(classify_performance_regression, diff_id) + job = JobInfo(classify_perf_regression, diff_id) LOGGER.info( "%s Processing prediction for diff_id=%d", - PERFORMANCE_REGRESSION_LOG_PREFIX, + PERF_REGRESSION_LOG_PREFIX, diff_id, ) patch = PhabricatorPatch(diff_id=diff_id) @@ -253,7 +253,7 @@ def classify_performance_regression(diff_id: int) -> str: if not patch.is_accessible() or not patch.is_public(): LOGGER.warning( "%s Prediction unavailable for diff_id=%d", - PERFORMANCE_REGRESSION_LOG_PREFIX, + PERF_REGRESSION_LOG_PREFIX, diff_id, ) setkey(job.result_key, orjson.dumps({"available": False})) @@ -265,7 +265,7 @@ def classify_performance_regression(diff_id: int) -> str: LOGGER.warning( "%s Diff %d has %d uploaded commit messages; combining them " "for inference", - PERFORMANCE_REGRESSION_LOG_PREFIX, + PERF_REGRESSION_LOG_PREFIX, diff_id, len(commit_messages), ) @@ -275,7 +275,7 @@ def classify_performance_regression(diff_id: int) -> str: LOGGER.warning( "%s Diff %d has no uploaded commit message metadata; using revision " "title and summary fallback", - PERFORMANCE_REGRESSION_LOG_PREFIX, + PERF_REGRESSION_LOG_PREFIX, diff_id, ) commit_message = "\n\n".join( @@ -285,13 +285,13 @@ def classify_performance_regression(diff_id: int) -> str: LOGGER.info( "%s Using commit message source %s for diff_id=%d with commit_message_count=%d", - PERFORMANCE_REGRESSION_LOG_PREFIX, + PERF_REGRESSION_LOG_PREFIX, commit_message_source, diff_id, len(commit_messages), ) - model = MODEL_CACHE.get(PERFORMANCE_REGRESSION_PREDICTOR) + model = MODEL_CACHE.get(PERF_REGRESSION_PREDICTOR) probabilities = model.classify( [{"commit_message": commit_message, "diff": patch.raw_diff}], @@ -314,7 +314,7 @@ def classify_performance_regression(diff_id: int) -> str: LOGGER.info( "%s Finished prediction for diff_id=%d, " "revision_id=%d, class=%d, risk_score=%f", - PERFORMANCE_REGRESSION_LOG_PREFIX, + PERF_REGRESSION_LOG_PREFIX, diff_id, patch.revision_id, predicted_class, diff --git a/http_service/pyproject.toml b/http_service/pyproject.toml index c7c88d5d04..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[performance-regression-predictor]", + "bugbug[perf-regression-predictor]", "cerberus~=1.3.8", "Flask~=3.1.3", "flask-apispec~=0.11.4", diff --git a/http_service/tests/test_performance_regression_predictor.py b/http_service/tests/test_perf_regression_predictor.py similarity index 90% rename from http_service/tests/test_performance_regression_predictor.py rename to http_service/tests/test_perf_regression_predictor.py index 358446c5d9..09caeeb031 100644 --- a/http_service/tests/test_performance_regression_predictor.py +++ b/http_service/tests/test_perf_regression_predictor.py @@ -21,13 +21,13 @@ def _response_json(response): def test_endpoint_queues_and_returns_prediction(client, jobs, add_result) -> None: - endpoint = "/performanceregressionpredictor/predict/phabricator/789012" + endpoint = "/perfregressionpredictor/predict/phabricator/789012" unauthorized = client.get(endpoint) assert unauthorized.status_code == 401 wrong_input_kind = client.get( - "/performanceregressionpredictor/predict/123456", + "/perfregressionpredictor/predict/123456", headers={API_TOKEN: "test"}, ) assert wrong_input_kind.status_code == 404 @@ -88,7 +88,7 @@ def get_extra_data(self): lambda model_name: fake_model, ) - assert models.classify_performance_regression(789012) == "OK" + assert models.classify_perf_regression(789012) == "OK" assert fake_model.items == [ { "commit_message": "Make rendering faster", @@ -96,7 +96,7 @@ def get_extra_data(self): } ] - job = JobInfo(models.classify_performance_regression, 789012) + job = JobInfo(models.classify_perf_regression, 789012) stored = models.redis.get(job.result_key) assert stored is not None result = orjson.loads(zstandard.ZstdDecompressor().decompress(stored)) @@ -121,8 +121,8 @@ def is_public(self): monkeypatch.setattr(models, "PhabricatorPatch", FakePatch) - assert models.classify_performance_regression(789012) == "OK" - job = JobInfo(models.classify_performance_regression, 789012) + assert models.classify_perf_regression(789012) == "OK" + job = JobInfo(models.classify_perf_regression, 789012) stored = models.redis.get(job.result_key) assert stored is not None result = orjson.loads(stored) @@ -163,8 +163,8 @@ def get_extra_data(self): monkeypatch.setattr(models, "PhabricatorPatch", FakePatch) monkeypatch.setattr(models.MODEL_CACHE, "get", lambda model_name: FakeModel()) - assert models.classify_performance_regression(789012) == "OK" - job = JobInfo(models.classify_performance_regression, 789012) + assert models.classify_perf_regression(789012) == "OK" + job = JobInfo(models.classify_perf_regression, 789012) stored = models.redis.get(job.result_key) assert stored is not None result = orjson.loads(zstandard.ZstdDecompressor().decompress(stored)) @@ -202,8 +202,8 @@ def get_extra_data(self): monkeypatch.setattr(models, "PhabricatorPatch", FakePatch) monkeypatch.setattr(models.MODEL_CACHE, "get", lambda model_name: FakeModel()) - assert models.classify_performance_regression(789012) == "OK" - job = JobInfo(models.classify_performance_regression, 789012) + assert models.classify_perf_regression(789012) == "OK" + job = JobInfo(models.classify_perf_regression, 789012) stored = models.redis.get(job.result_key) assert stored is not None result = orjson.loads(zstandard.ZstdDecompressor().decompress(stored)) @@ -237,9 +237,9 @@ def raise_missing_model(model_name): monkeypatch.setattr(models.MODEL_CACHE, "get", raise_missing_model) with pytest.raises(FileNotFoundError, match="missing checkpoint"): - models.classify_performance_regression(789012) + models.classify_perf_regression(789012) - job = JobInfo(models.classify_performance_regression, 789012) + job = JobInfo(models.classify_perf_regression, 789012) assert models.redis.get(job.result_key) is None diff --git a/pyproject.toml b/pyproject.toml index 6279e756a4..c5af0e4e7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,7 @@ nlp = [ "spacy==3.8.14", ] nn = [] -performance-regression-predictor = [ +perf-regression-predictor = [ "torch==2.13.0", "transformers==5.15.0", ] @@ -120,7 +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-performance-regression = "scripts.performance_regression_predictor:main" +bugbug-predict-perf-regression = "scripts.perf_regression_predictor:main" [tool.hatch.version] path = "VERSION" diff --git a/scripts/performance_regression_predictor.py b/scripts/perf_regression_predictor.py similarity index 93% rename from scripts/performance_regression_predictor.py rename to scripts/perf_regression_predictor.py index 7841465572..ebe6bc4bb9 100644 --- a/scripts/performance_regression_predictor.py +++ b/scripts/perf_regression_predictor.py @@ -3,7 +3,7 @@ # 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 Performance Regression Predictor against a local patch.""" +"""Run the Perf Regression Predictor against a local patch.""" from __future__ import annotations @@ -15,8 +15,8 @@ from email.parser import Parser from pathlib import Path -from bugbug.models.performance_regression_predictor import ( - PerformanceRegressionPredictorModel, +from bugbug.models.perf_regression_predictor import ( + PerfRegressionPredictorModel, ) @@ -85,7 +85,7 @@ def main(argv: list[str] | None = None) -> int: file=sys.stderr, ) - model = PerformanceRegressionPredictorModel.load(args.model_dir) + model = PerfRegressionPredictorModel.load(args.model_dir) probabilities = model.classify( [{"commit_message": commit_message, "diff": raw_diff}], probabilities=True, diff --git a/tests/test_performance_regression_predictor.py b/tests/test_perf_regression_predictor.py similarity index 97% rename from tests/test_performance_regression_predictor.py rename to tests/test_perf_regression_predictor.py index 26e783533e..2f06fbf807 100644 --- a/tests/test_performance_regression_predictor.py +++ b/tests/test_perf_regression_predictor.py @@ -3,13 +3,13 @@ # 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.performance_regression_predictor import ( +from bugbug.models.perf_regression_predictor import ( build_model_input, clean_commit_message, combine_commit_messages, diff_to_structured_text, ) -from scripts.performance_regression_predictor import ( +from scripts.perf_regression_predictor import ( extract_commit_message_from_patch, ) diff --git a/uv.lock b/uv.lock index cb02844cf1..d4ef9dce81 100644 --- a/uv.lock +++ b/uv.lock @@ -665,7 +665,7 @@ dependencies = [ nlp = [ { name = "spacy" }, ] -performance-regression-predictor = [ +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" }, @@ -738,16 +738,16 @@ requires-dist = [ { name = "tabulate", specifier = "~=0.10.0" }, { name = "taskcluster", specifier = ">=97.1,<102.1" }, { name = "tenacity", specifier = "~=9.1.4" }, - { name = "torch", marker = "(sys_platform == 'linux' and extra == 'performance-regression-predictor') or (sys_platform == 'win32' and extra == 'performance-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 == 'performance-regression-predictor'", specifier = "==2.13.0" }, + { 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 == 'performance-regression-predictor'", specifier = "==5.15.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.4" }, { name = "zstandard", specifier = "~=0.25.0" }, ] -provides-extras = ["nlp", "nn", "performance-regression-predictor"] +provides-extras = ["nlp", "nn", "perf-regression-predictor"] [package.metadata.requires-dev] spawn-pipeline = [ @@ -775,7 +775,7 @@ source = { editable = "http_service" } dependencies = [ { name = "apispec", extra = ["yaml"] }, { name = "apispec-webframeworks" }, - { name = "bugbug", extra = ["performance-regression-predictor"] }, + { name = "bugbug", extra = ["perf-regression-predictor"] }, { name = "cerberus" }, { name = "flask" }, { name = "flask-apispec" }, @@ -792,7 +792,7 @@ dependencies = [ requires-dist = [ { name = "apispec", extras = ["yaml"], specifier = "~=6.10.0" }, { name = "apispec-webframeworks", specifier = "~=1.2.0" }, - { name = "bugbug", extras = ["performance-regression-predictor"], 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" }, From 3f7081616401a1063d0506140d54d461969b9ed1 Mon Sep 17 00:00:00 2001 From: Ali Sayed Salehi Date: Fri, 21 Aug 2026 15:03:33 -0400 Subject: [PATCH 4/9] add model download capability from a URL --- bugbug/models/perf_regression_predictor.py | 7 +++++ bugbug/utils.py | 19 ++++++++++++ docs/models/perf-regression-predictor.md | 33 ++++++++++++++------- http_service/bugbug_http/download_models.py | 8 ++++- 4 files changed, 55 insertions(+), 12 deletions(-) diff --git a/bugbug/models/perf_regression_predictor.py b/bugbug/models/perf_regression_predictor.py index 48d86efa6f..c71ae8926a 100644 --- a/bugbug/models/perf_regression_predictor.py +++ b/bugbug/models/perf_regression_predictor.py @@ -186,6 +186,13 @@ class PerfRegressionPredictorModel(Model): 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 diff --git a/bugbug/utils.py b/bugbug/utils.py index 3e3cbc8f4c..1fdc36c017 100644 --- a/bugbug/utils.py +++ b/bugbug/utils.py @@ -303,6 +303,25 @@ def download_model(model_name: str) -> str: return path +def download_model_from_url(model_name: str, url: str) -> str: + """Download a model from a URL instead of the Taskcluster index. + + Like download_model(), this unpacks to a `{model_name}model` directory. + """ + path = f"{model_name}model" + archive = f"{path}.tar.zst" + + logger.info("Downloading %s...", url) + # Save it under our own name; the URL can end in anything. + updated = download_check_etag(url, archive) + if updated: + extract_tar_zst(archive) + os.remove(archive) + + assert os.path.exists(path), "Decompressed directory exists" + return path + + def zstd_compress(path: str) -> None: if not os.path.exists(path): raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), path) diff --git a/docs/models/perf-regression-predictor.md b/docs/models/perf-regression-predictor.md index 884e618b5a..d36af51ee9 100644 --- a/docs/models/perf-regression-predictor.md +++ b/docs/models/perf-regression-predictor.md @@ -99,19 +99,30 @@ the diff metadata. ## Model artifact -Production follows the existing Bugbug model-artifact convention. The -checkpoint directory must be named `perfregressionpredictormodel` and -published as: - -```text -public/perfregressionpredictormodel.tar.zst +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" ``` -under the indexed Taskcluster namespace -`project.bugbug.train_perfregressionpredictor.`. For this first -iteration, the archive can be created and published by a one-off Taskcluster -task; no `bugbug-train` workflow is registered for this model. The standard -background-worker image then downloads it alongside the other model artifacts. +`download_models()` fetches that URL for any model declaring an `artifact_url` +and falls back to the Taskcluster index for the rest, so the background worker +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 diff --git a/http_service/bugbug_http/download_models.py b/http_service/bugbug_http/download_models.py index b3a61a19a7..91b61a2690 100644 --- a/http_service/bugbug_http/download_models.py +++ b/http_service/bugbug_http/download_models.py @@ -6,6 +6,7 @@ import logging from bugbug import utils +from bugbug.models import get_model_class from bugbug_http import ALLOW_MISSING_MODELS from bugbug_http.models import MODEL_CACHE, MODELS_TO_DOWNLOAD @@ -14,7 +15,12 @@ def download_models(): for model_name in MODELS_TO_DOWNLOAD: - utils.download_model(model_name) + # Some models are published outside the Taskcluster index. + artifact_url = getattr(get_model_class(model_name), "artifact_url", None) + if artifact_url: + utils.download_model_from_url(model_name, artifact_url) + else: + utils.download_model(model_name) # Try loading the model try: m = MODEL_CACHE.get(model_name) From 5c0f205e6630be7b6b6963e3470c65662dae6d08 Mon Sep 17 00:00:00 2001 From: Ali Sayed Salehi Date: Wed, 26 Aug 2026 15:20:09 -0400 Subject: [PATCH 5/9] change the predictor API to work with Autoland pushes instead of Phabricator diffs --- bugbug/models/perf_regression_predictor.py | 59 +++- bugbug/repository.py | 10 + docs/models/perf-regression-predictor.md | 61 ++-- .../README.perf-regression-predictor.md | 38 +-- http_service/bugbug_http/app.py | 59 ++-- http_service/bugbug_http/models.py | 132 +++++---- .../tests/test_perf_regression_predictor.py | 262 ++++++++---------- scripts/perf_regression_predictor.py | 34 +-- tests/test_perf_regression_predictor.py | 62 +++-- 9 files changed, 375 insertions(+), 342 deletions(-) diff --git a/bugbug/models/perf_regression_predictor.py b/bugbug/models/perf_regression_predictor.py index c71ae8926a..13a78db96b 100644 --- a/bugbug/models/perf_regression_predictor.py +++ b/bugbug/models/perf_regression_predictor.py @@ -9,7 +9,8 @@ import json import re -from collections.abc import Sequence +from email import policy +from email.parser import Parser from pathlib import Path from typing import Any @@ -62,25 +63,53 @@ def _clean_subject(subject: str) -> str: return "\n".join(cleaned_lines).strip("\n") -def combine_commit_messages(commit_messages: Sequence[str]) -> str: - """Clean and combine commit messages uploaded for one Phabricator diff. +def extract_commit_message_from_patch(patch: str) -> str | None: + """Extract a message from Git format-patch or Mercurial export content. - Phabricator exposes local commit metadata as a list. Most Mozilla diffs have - one entry, but cleaning each message separately also gives deterministic - preprocessing for the uncommon multi-commit case. + 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. """ - return "\n\n".join( - cleaned_message - for commit_message in commit_messages - if (cleaned_message := clean_commit_message(commit_message).strip()) - ) + 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 re.search(r"^Subject:", patch, flags=re.MULTILINE): + 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 = re.split(r"^---\s*$|^diff --git ", body, maxsplit=1, flags=re.MULTILINE)[ + 0 + ].strip() + message = "\n\n".join(part for part in (subject, body) if part) + return message or None + + return None def diff_to_structured_text(diff_string: str) -> str: - """Convert a Git or Mercurial diff to the model's structured format.""" + """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. + """ lines = diff_string.strip().splitlines() output: list[str] = [] + started = False + current_file: str | None = None current_block_type: str | None = None current_block_lines: list[str] = [] @@ -118,6 +147,12 @@ def flush_file() -> None: pending_rename = False for line in lines: + if not started: + if line.startswith(("diff -r", "diff --git")): + started = True + else: + continue + if line.startswith("diff -r"): flush_file() parts = line.split() 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/docs/models/perf-regression-predictor.md b/docs/models/perf-regression-predictor.md index d36af51ee9..e8912e8105 100644 --- a/docs/models/perf-regression-predictor.md +++ b/docs/models/perf-regression-predictor.md @@ -1,20 +1,23 @@ # Perf Regression Predictor The Perf Regression Predictor is an inference-only binary transformer -model. It predicts whether a public Phabricator diff is likely to -introduce a performance regression. - -The input is the commit message from the diff's `commits` attachment plus the -raw diff. If a diff has multiple uploaded local commits, each message is cleaned -independently and the messages are separated by blank lines. If Phabricator did -not retain commit metadata, the revision title and summary are used as a -fallback. 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 each commit message. +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. @@ -22,7 +25,7 @@ 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 fetch data from Phabricator. +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: @@ -61,41 +64,47 @@ The endpoint uses the service's existing Redis/RQ worker and API-key presence check: ```text -GET /perfregressionpredictor/predict/phabricator/{diff_id} +GET /perfregressionpredictor/predict/push/{branch}/{rev} X-Api-Key: ... ``` -The first request normally returns `202 {"ready": false}`. Poll the same URL -until it returns `200`. The worker requires `PHABRICATOR_API_KEY`; a custom -Phabricator host can be set with `PHABRICATOR_URL`. +`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 and -secret file. +for the complete Docker Compose setup, including the local model mount. Example result: ```json { - "revision_id": 123456, - "diff_id": 789012, - "prob": [0.25, 0.75], - "class": 1, + "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_message_source": "diff_metadata", - "commit_message_count": 1 + "commit_count": 1 } } ``` -Only public revisions are processed, and the worker verifies that the diff -belongs to a public revision. The `revision_id` in the response is derived from -the diff metadata. +The push-level `risk_score` is the maximum of the per-commit `risk_score` +values in `commits`. ## Model artifact diff --git a/http_service/README.perf-regression-predictor.md b/http_service/README.perf-regression-predictor.md index f2b434f8ad..794aad6a59 100644 --- a/http_service/README.perf-regression-predictor.md +++ b/http_service/README.perf-regression-predictor.md @@ -29,8 +29,9 @@ endpoints may need service-specific credentials: - `BUGBUG_BUGZILLA_TOKEN`: needed by Bugzilla bug classification endpoints. - `BUGBUG_GITHUB_TOKEN`: needed by GitHub issue classification endpoints. -- `PHABRICATOR_API_KEY`: needed by Phabricator-backed endpoints. -- `PHABRICATOR_URL`: optional; defaults to Mozilla production Phabricator. +- `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. @@ -44,8 +45,6 @@ If you need local secrets, create `.env` in this directory ```dotenv BUGBUG_BUGZILLA_TOKEN= BUGBUG_GITHUB_TOKEN= -PHABRICATOR_API_KEY= -PHABRICATOR_URL=https://phabricator.services.mozilla.com BUGBUG_ALLOW_MISSING_MODELS=1 ``` @@ -131,22 +130,15 @@ docker compose down ## Perf Regression Predictor Setup The Perf Regression Predictor uses the same HTTP service and background -worker, but it needs two extra local-development pieces while the model artifact -is unpublished: +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. -- a local Hugging Face checkpoint mounted at the standard model directory; -- a Phabricator Conduit token so the worker can fetch diff metadata and raw - diffs. - -### Create The Secret File - -Create `.env` in this directory (`http_service/.env` from the repository root) -with your Conduit token: - -```dotenv -CONDUIT_API_TOKEN=api-replace-with-your-token -PHABRICATOR_URL=https://phabricator.services.mozilla.com -``` +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 @@ -161,8 +153,6 @@ services: CHECK_MODELS: "0" environment: BUGBUG_ALLOW_MISSING_MODELS: "1" - PHABRICATOR_API_KEY: ${CONDUIT_API_TOKEN} - PHABRICATOR_URL: "${PHABRICATOR_URL:-https://phabricator.services.mozilla.com}" volumes: - /absolute/path/to/predictor_model:/code/perfregressionpredictormodel:ro ``` @@ -195,17 +185,17 @@ docker compose exec bugbug-http-service-bg-worker \ && echo "Model is mounted" ``` -Request a prediction with an immutable Phabricator diff ID: +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/phabricator/DIFF_ID + 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, Phabricator, or the HTTP API, see +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 060d558e72..fd360d3058 100644 --- a/http_service/bugbug_http/app.py +++ b/http_service/bugbug_http/app.py @@ -110,12 +110,18 @@ class BugPrediction(Schema): extra_data = fields.Dict() -class PerformanceRegressionPrediction(Schema): - revision_id = fields.Integer() - diff_id = fields.Integer() +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() @@ -141,6 +147,10 @@ class Schedules(Schema): spec.components.schema(BugPrediction.__name__, schema=BugPrediction) +spec.components.schema( + PerformanceRegressionCommitPrediction.__name__, + schema=PerformanceRegressionCommitPrediction, +) spec.components.schema( PerformanceRegressionPrediction.__name__, schema=PerformanceRegressionPrediction, @@ -537,21 +547,26 @@ def model_prediction(model_name, bug_id): return compress_response(data, status_code) -@application.route("/perfregressionpredictor/predict/phabricator/") +@application.route("/perfregressionpredictor/predict/push//") @cross_origin() -def perf_regression_prediction(diff_id: int): +def perf_regression_prediction(branch: str, rev: str): """ --- get: - description: Predict performance-regression risk for a public Phabricator diff + description: Predict performance-regression risk for a push summary: Predict performance-regression risk parameters: - - name: diff_id + - name: branch in: path required: true schema: - type: integer - example: 789012 + BranchName + - name: rev + in: path + required: true + schema: + type: str + example: 76383a875678 responses: 200: description: A performance-regression risk prediction @@ -572,37 +587,45 @@ def perf_regression_prediction(diff_id: int): if not request.headers.get(API_TOKEN): return jsonify(UnauthorizedError().dump({})), 401 + # Support the string 'autoland' for convenience. + if branch == "autoland": + branch = "integration/autoland" + LOGGER.info( - "%s Received prediction request for diff_id=%d", + "%s Received prediction request for %s @ %s", PERF_REGRESSION_LOG_PREFIX, - diff_id, + branch, + rev, ) - job = JobInfo(classify_perf_regression, diff_id) + job = JobInfo(classify_perf_regression, branch, rev) data = get_result(job) status_code = 200 if not data: if not is_pending(job): LOGGER.info( - "%s Queueing prediction job for diff_id=%d", + "%s Queueing prediction job for %s @ %s", PERF_REGRESSION_LOG_PREFIX, - diff_id, + branch, + rev, ) schedule_job(job) else: LOGGER.info( - "%s Prediction job is pending for diff_id=%d", + "%s Prediction job is pending for %s @ %s", PERF_REGRESSION_LOG_PREFIX, - diff_id, + branch, + rev, ) status_code = 202 data = {"ready": False} else: LOGGER.info( - "%s Returning cached prediction for diff_id=%d", + "%s Returning cached prediction for %s @ %s", PERF_REGRESSION_LOG_PREFIX, - diff_id, + branch, + rev, ) return compress_response(data, status_code) diff --git a/http_service/bugbug_http/models.py b/http_service/bugbug_http/models.py index b42391a3b8..80ea15ac2a 100644 --- a/http_service/bugbug_http/models.py +++ b/http_service/bugbug_http/models.py @@ -22,8 +22,7 @@ from bugbug.models.perf_regression_predictor import ( MODEL_IDENTIFIER as PERF_REGRESSION_PREDICTOR, ) -from bugbug.models.perf_regression_predictor import combine_commit_messages -from bugbug.tools.core.platforms.phabricator import PhabricatorPatch +from bugbug.models.perf_regression_predictor import extract_commit_message_from_patch from bugbug.utils import get_hgmo_stack from bugbug_http.readthrough_cache import ReadthroughTTLCache @@ -238,87 +237,108 @@ def classify_broken_site_report(model_name: str, reports_data: list[dict]) -> st return "OK" -def classify_perf_regression(diff_id: int) -> str: - """Predict performance-regression risk for one immutable Phabricator diff.""" +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, diff_id) + 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 Processing prediction for diff_id=%d", + "%s Pulling commits from the remote repository...", PERF_REGRESSION_LOG_PREFIX, - diff_id, ) - patch = PhabricatorPatch(diff_id=diff_id) + repository.pull(REPO_DIR, branch, rev, update=False) - if not patch.is_accessible() or not patch.is_public(): + # 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 Prediction unavailable for diff_id=%d", + "%s Push not found for %s @ %s!", PERF_REGRESSION_LOG_PREFIX, - diff_id, + branch, + rev, ) setkey(job.result_key, orjson.dumps({"available": False})) return "OK" - commit_messages = patch.commit_messages - if commit_messages: - if len(commit_messages) > 1: - LOGGER.warning( - "%s Diff %d has %d uploaded commit messages; combining them " - "for inference", - PERF_REGRESSION_LOG_PREFIX, - diff_id, - len(commit_messages), - ) - commit_message = combine_commit_messages(commit_messages) - commit_message_source = "diff_metadata" - else: + if not revs: LOGGER.warning( - "%s Diff %d has no uploaded commit message metadata; using revision " - "title and summary fallback", + "%s No commits to analyze for %s @ %s", PERF_REGRESSION_LOG_PREFIX, - diff_id, - ) - commit_message = "\n\n".join( - part for part in (patch.patch_title, patch.patch_description) if part + branch, + rev, ) - commit_message_source = "revision_title_and_summary_fallback" + setkey(job.result_key, orjson.dumps({"available": False})) + return "OK" - LOGGER.info( - "%s Using commit message source %s for diff_id=%d with commit_message_count=%d", - PERF_REGRESSION_LOG_PREFIX, - commit_message_source, - diff_id, - len(commit_messages), - ) + # 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) - probabilities = model.classify( - [{"commit_message": commit_message, "diff": patch.raw_diff}], - probabilities=True, - )[0] - predicted_class = int(probabilities.argmax()) + # Score each commit separately. The service runs inference on CPU, + # so we classify one at a time to keep memory flat. + 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_from_patch(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 = { - "revision_id": patch.revision_id, - "diff_id": diff_id, - "prob": probabilities.tolist(), - "class": predicted_class, - "risk_score": float(probabilities[1]), + "branch": branch, + "rev": rev, + "risk_score": risk_score, + "commits": commits, "extra_data": { **model.get_extra_data(), - "commit_message_source": commit_message_source, - "commit_message_count": len(commit_messages), + "commit_count": len(commits), }, } setkey(job.result_key, orjson.dumps(data), compress=True) LOGGER.info( - "%s Finished prediction for diff_id=%d, " - "revision_id=%d, class=%d, risk_score=%f", + "%s Finished prediction for %s @ %s, commit_count=%d, risk_score=%f", PERF_REGRESSION_LOG_PREFIX, - diff_id, - patch.revision_id, - predicted_class, - float(probabilities[1]), + branch, + rev, + len(commits), + risk_score, ) return "OK" diff --git a/http_service/tests/test_perf_regression_predictor.py b/http_service/tests/test_perf_regression_predictor.py index 09caeeb031..2b4a6c8eaa 100644 --- a/http_service/tests/test_perf_regression_predictor.py +++ b/http_service/tests/test_perf_regression_predictor.py @@ -8,11 +8,44 @@ 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": @@ -20,8 +53,16 @@ def _response_json(response): 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/phabricator/789012" + endpoint = "/perfregressionpredictor/predict/push/autoland/abc123def456" unauthorized = client.get(endpoint) assert unauthorized.status_code == 401 @@ -37,12 +78,18 @@ def test_endpoint_queues_and_returns_prediction(client, jobs, add_result) -> Non assert _response_json(response) == {"ready": False} prediction = { - "revision_id": 123456, - "diff_id": 789012, - "prob": [0.25, 0.75], - "class": 1, - "risk_score": 0.75, - "extra_data": {"calibrated": False}, + "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) @@ -52,184 +99,101 @@ def test_endpoint_queues_and_returns_prediction(client, jobs, add_result) -> Non assert _response_json(response) == prediction -def test_worker_uses_diff_commit_metadata(monkeypatch) -> None: - class FakePatch: - def __init__(self, diff_id): - assert diff_id == 789012 - self.revision_id = 123456 - self.commit_messages = ["[PATCH] - Make rendering faster"] - self.patch_title = "Unused title" - self.patch_description = "Unused summary" - self.raw_diff = "diff --git a/a b/a\n" - - def is_accessible(self): - return True - - def is_public(self): - return True +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.items = None + self.calls = [] def classify(self, items, probabilities=False): assert probabilities - self.items = items - return np.array([[0.2, 0.8]]) + # 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() - monkeypatch.setattr(models, "PhabricatorPatch", FakePatch) - monkeypatch.setattr( - models.MODEL_CACHE, - "get", - lambda model_name: fake_model, + _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" ) - assert models.classify_perf_regression(789012) == "OK" - assert fake_model.items == [ + # 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": "Make rendering faster", - "diff": "diff --git a/a b/a\n", - } + "commit_message": "Bug 123456 - Avoid repeated work", + "diff": PATCH_TWO.decode("utf-8"), + }, ] - job = JobInfo(models.classify_perf_regression, 789012) + 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["revision_id"] == 123456 - assert result["diff_id"] == 789012 + 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["class"] == 1 - assert result["extra_data"]["commit_message_source"] == "diff_metadata" - assert result["extra_data"]["commit_message_count"] == 1 - - -def test_worker_marks_inaccessible_diff_unavailable(monkeypatch) -> None: - class FakePatch: - def __init__(self, diff_id): - assert diff_id == 789012 - - def is_accessible(self): - return False - - def is_public(self): - raise AssertionError("is_public should not be called") - - monkeypatch.setattr(models, "PhabricatorPatch", FakePatch) - - assert models.classify_perf_regression(789012) == "OK" - job = JobInfo(models.classify_perf_regression, 789012) - stored = models.redis.get(job.result_key) - assert stored is not None - result = orjson.loads(stored) - assert result == {"available": False} - + 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_cleans_and_combines_multiple_commit_messages(monkeypatch) -> None: - class FakePatch: - revision_id = 123456 - commit_messages = [ - "Bug 123456 - Improve rendering\n\nFirst body.", - "[PATCH] Bug 789012 - Avoid repeated work\n\nSecond body.", - ] - patch_title = "Unused title" - patch_description = "Unused summary" - raw_diff = "diff --git a/a b/a\n" - def __init__(self, diff_id): - assert diff_id == 789012 +def test_worker_marks_missing_push_unavailable(monkeypatch) -> None: + monkeypatch.setattr(models.repository, "pull", lambda *args, **kwargs: None) - def is_accessible(self): - return True + def raise_not_found(branch, rev): + raise requests.exceptions.HTTPError("not found") - def is_public(self): - return True + monkeypatch.setattr(models, "get_hgmo_stack", raise_not_found) - class FakeModel: - def classify(self, items, probabilities=False): - assert items[0]["commit_message"] == ( - "Improve rendering\n\nFirst body.\n\n" - "Avoid repeated work\n\nSecond body." - ) - return np.array([[0.3, 0.7]]) - - def get_extra_data(self): - return {} + def unexpected_model(model_name): + raise AssertionError("model should not be loaded for a missing push") - monkeypatch.setattr(models, "PhabricatorPatch", FakePatch) - monkeypatch.setattr(models.MODEL_CACHE, "get", lambda model_name: FakeModel()) + monkeypatch.setattr(models.MODEL_CACHE, "get", unexpected_model) - assert models.classify_perf_regression(789012) == "OK" - job = JobInfo(models.classify_perf_regression, 789012) + 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 - result = orjson.loads(zstandard.ZstdDecompressor().decompress(stored)) - assert result["extra_data"]["commit_message_source"] == "diff_metadata" - assert result["extra_data"]["commit_message_count"] == 2 - - -def test_worker_falls_back_to_revision_message(monkeypatch) -> None: - class FakePatch: - revision_id = 123456 - commit_messages: list[str] = [] - patch_title = "Improve rendering" - patch_description = "Avoid repeated work." - raw_diff = "diff --git a/a b/a\n" - - def __init__(self, diff_id): - assert diff_id == 789012 - - def is_accessible(self): - return True + assert orjson.loads(stored) == {"available": False} - def is_public(self): - return True - class FakeModel: - def classify(self, items, probabilities=False): - assert items[0]["commit_message"] == ( - "Improve rendering\n\nAvoid repeated work." - ) - return np.array([[0.6, 0.4]]) +def test_worker_marks_empty_stack_unavailable(monkeypatch) -> None: + _mock_repo(monkeypatch, [], []) - def get_extra_data(self): - return {} + def unexpected_model(model_name): + raise AssertionError("model should not be loaded for an empty stack") - monkeypatch.setattr(models, "PhabricatorPatch", FakePatch) - monkeypatch.setattr(models.MODEL_CACHE, "get", lambda model_name: FakeModel()) + monkeypatch.setattr(models.MODEL_CACHE, "get", unexpected_model) - assert models.classify_perf_regression(789012) == "OK" - job = JobInfo(models.classify_perf_regression, 789012) + 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 - result = orjson.loads(zstandard.ZstdDecompressor().decompress(stored)) - assert result["extra_data"]["commit_message_source"] == ( - "revision_title_and_summary_fallback" - ) - assert result["extra_data"]["commit_message_count"] == 0 + assert orjson.loads(stored) == {"available": False} def test_worker_propagates_model_loading_failure(monkeypatch) -> None: - class FakePatch: - revision_id = 123456 - commit_messages = ["Improve rendering"] - patch_title = "Unused title" - patch_description = "Unused summary" - - def __init__(self, diff_id): - assert diff_id == 789012 - - def is_accessible(self): - return True - - def is_public(self): - return True - - monkeypatch.setattr(models, "PhabricatorPatch", FakePatch) + _mock_repo(monkeypatch, [b"node1hash"], [PATCH_ONE]) def raise_missing_model(model_name): raise FileNotFoundError("missing checkpoint") @@ -237,9 +201,11 @@ def raise_missing_model(model_name): monkeypatch.setattr(models.MODEL_CACHE, "get", raise_missing_model) with pytest.raises(FileNotFoundError, match="missing checkpoint"): - models.classify_perf_regression(789012) + models.classify_perf_regression("integration/autoland", "abc123def456") - job = JobInfo(models.classify_perf_regression, 789012) + job = JobInfo( + models.classify_perf_regression, "integration/autoland", "abc123def456" + ) assert models.redis.get(job.result_key) is None diff --git a/scripts/perf_regression_predictor.py b/scripts/perf_regression_predictor.py index ebe6bc4bb9..a572e5f8b2 100644 --- a/scripts/perf_regression_predictor.py +++ b/scripts/perf_regression_predictor.py @@ -9,47 +9,15 @@ import argparse import json -import re import sys -from email import policy -from email.parser import Parser from pathlib import Path from bugbug.models.perf_regression_predictor import ( PerfRegressionPredictorModel, + extract_commit_message_from_patch, ) -def extract_commit_message_from_patch(patch: str) -> str | None: - """Extract a message from Git format-patch or Mercurial export content.""" - 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 re.search(r"^Subject:", patch, flags=re.MULTILINE): - 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 = re.split(r"^---\s*$|^diff --git ", body, maxsplit=1, flags=re.MULTILINE)[ - 0 - ].strip() - message = "\n\n".join(part for part in (subject, body) if part) - return message or None - - return None - - def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser( description="Predict performance-regression risk from a local patch", diff --git a/tests/test_perf_regression_predictor.py b/tests/test_perf_regression_predictor.py index 2f06fbf807..4fbd539a17 100644 --- a/tests/test_perf_regression_predictor.py +++ b/tests/test_perf_regression_predictor.py @@ -6,10 +6,7 @@ from bugbug.models.perf_regression_predictor import ( build_model_input, clean_commit_message, - combine_commit_messages, diff_to_structured_text, -) -from scripts.perf_regression_predictor import ( extract_commit_message_from_patch, ) @@ -59,28 +56,6 @@ def test_clean_commit_message_removes_bug_number_prefixes() -> None: assert clean_commit_message(message) == expected -def test_combine_commit_messages_cleans_each_subject() -> None: - assert combine_commit_messages( - [ - "Bug 123456 - Improve rendering\n\nFirst body.", - "[PATCH] Bug 789012 - Avoid repeated work\n\nSecond body.", - ] - ) == ("Improve rendering\n\nFirst body.\n\nAvoid repeated work\n\nSecond body.") - - -def test_combine_commit_messages_drops_empty_messages() -> None: - assert ( - combine_commit_messages( - [ - "", - " ", - "Bug 123456 - Improve rendering", - ] - ) - == "Improve rendering" - ) - - def test_diff_to_structured_text() -> None: assert ( diff_to_structured_text(RAW_DIFF) @@ -113,6 +88,43 @@ def test_diff_to_structured_text_mercurial_diff() -> None: ) +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 From e91a451438861365661f5fe35aae2c1dc555ba0b Mon Sep 17 00:00:00 2001 From: Ali Sayed Salehi Date: Wed, 2 Sep 2026 10:20:56 -0400 Subject: [PATCH 6/9] turn patch transformation functions into classes --- bugbug/models/perf_regression_predictor.py | 344 +++++++++++---------- http_service/bugbug_http/models.py | 6 +- scripts/perf_regression_predictor.py | 4 +- tests/test_perf_regression_predictor.py | 18 +- 4 files changed, 192 insertions(+), 180 deletions(-) diff --git a/bugbug/models/perf_regression_predictor.py b/bugbug/models/perf_regression_predictor.py index 13a78db96b..b7d14cb73d 100644 --- a/bugbug/models/perf_regression_predictor.py +++ b/bugbug/models/perf_regression_predictor.py @@ -24,196 +24,189 @@ POSITIVE_CLASS_ID = 1 -def clean_commit_message( - commit_message: str | None, *, clean_subject_only: bool = True -) -> str: +class CommitMessageCleaner: """Remove common noisy prefixes from a commit message. This intentionally mirrors the preprocessing used to prepare the model's training data. """ - if commit_message is None: - return "" - message = str(commit_message) - lines = message.splitlines() - if not lines: - return "" - - def _clean_subject(subject: str) -> str: + def __init__(self, clean_subject_only: bool = True) -> None: + self.clean_subject_only = clean_subject_only prefix = r"(?:\[[^\]]+\]|\([^)]+\)|bug\s*#?\s*\d+\b)" - return re.sub( + self.prefix_pattern = re.compile( rf"^\s*(?:{prefix}\s*(?:[-–—:.,]\s*)?)+", - "", - subject, - count=1, - flags=re.IGNORECASE, - ).strip() - - if clean_subject_only: - for index, line in enumerate(lines): - if line.strip(): - lines[index] = _clean_subject(line) - break - return "\n".join(lines).strip("\n") - - cleaned_lines = [ - _clean_subject(line) if index == 0 else line for index, line in enumerate(lines) - ] - return "\n".join(cleaned_lines).strip("\n") - - -def extract_commit_message_from_patch(patch: str) -> str | None: + 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. """ - 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 re.search(r"^Subject:", patch, flags=re.MULTILINE): - 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 = re.split(r"^---\s*$|^diff --git ", body, maxsplit=1, flags=re.MULTILINE)[ - 0 - ].strip() - message = "\n\n".join(part for part in (subject, body) if part) - return message or None - - return None - - -def diff_to_structured_text(diff_string: str) -> str: + + 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. """ - lines = diff_string.strip().splitlines() - output: list[str] = [] - - started = False - - current_file: str | None = None - current_block_type: str | None = None - current_block_lines: list[str] = [] - - pending_binary_status: str | None = None - rename_from: str | None = None - rename_to: str | None = None - pending_rename = False - - def flush_block() -> None: - nonlocal current_block_type, current_block_lines - if current_block_type and current_block_lines: - output.append(f" <{current_block_type.upper()}>") - output.extend(f" {line}" for line in current_block_lines) - output.append(f" ") - current_block_type = None - current_block_lines = [] - - def flush_file() -> None: - nonlocal current_file, pending_binary_status - nonlocal rename_from, rename_to, pending_rename - - if current_file: - flush_block() - if pending_rename and rename_from and rename_to: - output.append(f" File renamed from {rename_from}.") - elif pending_binary_status: - output.append(f" Binary file {pending_binary_status}.") - output.append("") - - current_file = None - pending_binary_status = None - rename_from = None - rename_to = None - pending_rename = False - - for line in lines: - if not started: - if line.startswith(("diff -r", "diff --git")): - started = True - else: + + 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 -r"): - flush_file() - parts = line.split() - if len(parts) >= 4: - current_file = parts[-1] - output.extend(("", f" {current_file}")) - continue - - if line.startswith("diff --git"): - flush_file() - match = re.match(r"diff --git a/(.+?) b/(.+)", line) - if match: - current_file = match.group(2) - output.extend(("", f" {current_file}")) - elif line.startswith("rename from "): - rename_from = line[len("rename from ") :].strip() - pending_rename = True - elif line.startswith("rename to "): - rename_to = line[len("rename to ") :].strip() - if not current_file: - current_file = rename_to - output.extend(("", f" {current_file}")) - elif line.startswith("--- "): - pass - elif line.startswith("+++ "): - pass - elif line.startswith("Binary files "): - flush_block() - pending_binary_status = "changed" - flush_file() - elif line.startswith("@@"): - flush_block() - elif line.startswith("-"): - if current_block_type != "REMOVED": - flush_block() - current_block_type = "REMOVED" - current_block_lines.append(line[1:].rstrip()) - elif line.startswith("+"): - if current_block_type != "ADDED": - flush_block() - current_block_type = "ADDED" - current_block_lines.append(line[1:].rstrip()) - else: - flush_block() - - flush_file() - return "\n".join(output) - - -def build_model_input(commit_message: str | None, raw_diff: str) -> str: - """Build the exact text representation consumed during training.""" - cleaned_message = clean_commit_message(commit_message) - structured_diff = diff_to_structured_text(raw_diff) - return "\n".join( - ( - "", - cleaned_message, - "", - structured_diff, - ) - ) + 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): @@ -235,6 +228,19 @@ def __init__(self, tokenizer: Any = None, transformer_model: Any = None) -> None 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": @@ -330,7 +336,7 @@ def classify( return np.empty((0, 2)) if probabilities else np.empty((0,), dtype=int) prompts = [ - build_model_input(item.get("commit_message"), item["diff"]) + self.build_model_input(item.get("commit_message"), item["diff"]) for item in items ] encoded = self.tokenizer( diff --git a/http_service/bugbug_http/models.py b/http_service/bugbug_http/models.py index 80ea15ac2a..701461e94c 100644 --- a/http_service/bugbug_http/models.py +++ b/http_service/bugbug_http/models.py @@ -22,7 +22,7 @@ from bugbug.models.perf_regression_predictor import ( MODEL_IDENTIFIER as PERF_REGRESSION_PREDICTOR, ) -from bugbug.models.perf_regression_predictor import extract_commit_message_from_patch +from bugbug.models.perf_regression_predictor import PatchCommitMessageExtractor from bugbug.utils import get_hgmo_stack from bugbug_http.readthrough_cache import ReadthroughTTLCache @@ -297,14 +297,14 @@ def classify_perf_regression(branch: str, rev: str) -> str: # 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_from_patch(patch_text) - or "", + "commit_message": extract_commit_message(patch_text) or "", "diff": patch_text, } ], diff --git a/scripts/perf_regression_predictor.py b/scripts/perf_regression_predictor.py index a572e5f8b2..32b64f5bde 100644 --- a/scripts/perf_regression_predictor.py +++ b/scripts/perf_regression_predictor.py @@ -13,8 +13,8 @@ from pathlib import Path from bugbug.models.perf_regression_predictor import ( + PatchCommitMessageExtractor, PerfRegressionPredictorModel, - extract_commit_message_from_patch, ) @@ -45,7 +45,7 @@ def main(argv: list[str] | None = None) -> int: commit_message = args.commit_message_file.read_text(encoding="utf-8") commit_message_source = "file" else: - commit_message = extract_commit_message_from_patch(raw_diff) or "" + commit_message = PatchCommitMessageExtractor()(raw_diff) or "" commit_message_source = "patch" if commit_message else "none" if not commit_message: print( diff --git a/tests/test_perf_regression_predictor.py b/tests/test_perf_regression_predictor.py index 4fbd539a17..2cbe6dde1e 100644 --- a/tests/test_perf_regression_predictor.py +++ b/tests/test_perf_regression_predictor.py @@ -4,12 +4,16 @@ # You can obtain one at http://mozilla.org/MPL/2.0/. from bugbug.models.perf_regression_predictor import ( - build_model_input, - clean_commit_message, - diff_to_structured_text, - extract_commit_message_from_patch, + 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 @@ -165,7 +169,8 @@ def test_diff_to_structured_text_binary_file() -> None: def test_build_model_input_cleans_commit_message() -> None: - prompt = build_model_input("[PATCH] Bug 123456 - Make it faster", RAW_DIFF) + model = PerfRegressionPredictorModel() + prompt = model.build_model_input("[PATCH] Bug 123456 - Make it faster", RAW_DIFF) assert prompt.startswith( "\nMake it faster\n\n" ) @@ -174,7 +179,8 @@ def test_build_model_input_cleans_commit_message() -> None: def test_build_model_input_allows_missing_commit_message() -> None: - prompt = build_model_input(None, RAW_DIFF) + model = PerfRegressionPredictorModel() + prompt = model.build_model_input(None, RAW_DIFF) assert prompt.startswith("\n\n\n") assert "widget.py" in prompt From 76c5486bb47e6a3918b97a944d732a7d62187135 Mon Sep 17 00:00:00 2001 From: Ali Sayed Salehi Date: Wed, 2 Sep 2026 11:07:00 -0400 Subject: [PATCH 7/9] make the push classification endpoint generic --- docs/models/perf-regression-predictor.md | 4 +- http_service/bugbug_http/app.py | 61 +++++++++---------- http_service/bugbug_http/models.py | 7 +++ .../tests/test_perf_regression_predictor.py | 8 +++ 4 files changed, 45 insertions(+), 35 deletions(-) diff --git a/docs/models/perf-regression-predictor.md b/docs/models/perf-regression-predictor.md index e8912e8105..6a889dd57b 100644 --- a/docs/models/perf-regression-predictor.md +++ b/docs/models/perf-regression-predictor.md @@ -60,8 +60,8 @@ The command prints the predicted binary `class`, both class probabilities in ## HTTP service -The endpoint uses the service's existing Redis/RQ worker and API-key presence -check: +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} diff --git a/http_service/bugbug_http/app.py b/http_service/bugbug_http/app.py index fd360d3058..9557a1f4dd 100644 --- a/http_service/bugbug_http/app.py +++ b/http_service/bugbug_http/app.py @@ -32,11 +32,10 @@ from bugbug import bugzilla, get_bugbug_version, utils from bugbug_http.models import ( MODELS_NAMES, - PERF_REGRESSION_LOG_PREFIX, + PUSH_CLASSIFIERS, classify_broken_site_report, classify_bug, classify_issue, - classify_perf_regression, get_config_specific_groups, schedule_tests, schedule_tests_from_patch, @@ -133,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") @@ -157,6 +165,7 @@ class Schedules(Schema): ) 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) @@ -547,15 +556,18 @@ def model_prediction(model_name, bug_id): return compress_response(data, status_code) -@application.route("/perfregressionpredictor/predict/push//") +@application.route("//predict/push//") @cross_origin() -def perf_regression_prediction(branch: str, rev: str): +def model_prediction_push(model_name: str, branch: str, rev: str): """ --- get: - description: Predict performance-regression risk for a push - summary: Predict performance-regression risk + 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 @@ -569,12 +581,12 @@ def perf_regression_prediction(branch: str, rev: str): example: 76383a875678 responses: 200: - description: A performance-regression risk prediction + description: A single push prediction content: application/json: schema: PerformanceRegressionPrediction 202: - description: The prediction is being processed + description: A temporary answer for the push being processed content: application/json: schema: NotAvailableYet @@ -587,46 +599,29 @@ def perf_regression_prediction(branch: str, rev: str): 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( - "%s Received prediction request for %s @ %s", - PERF_REGRESSION_LOG_PREFIX, - branch, - rev, + "Received %s push prediction request for %s @ %s", model_name, branch, rev ) - job = JobInfo(classify_perf_regression, 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): - LOGGER.info( - "%s Queueing prediction job for %s @ %s", - PERF_REGRESSION_LOG_PREFIX, - branch, - rev, - ) schedule_job(job) - else: - LOGGER.info( - "%s Prediction job is pending for %s @ %s", - PERF_REGRESSION_LOG_PREFIX, - branch, - rev, - ) status_code = 202 data = {"ready": False} - else: - LOGGER.info( - "%s Returning cached prediction for %s @ %s", - PERF_REGRESSION_LOG_PREFIX, - branch, - rev, - ) return compress_response(data, status_code) diff --git a/http_service/bugbug_http/models.py b/http_service/bugbug_http/models.py index 701461e94c..1fe1212a54 100644 --- a/http_service/bugbug_http/models.py +++ b/http_service/bugbug_http/models.py @@ -343,6 +343,13 @@ def classify_perf_regression(branch: str, rev: str) -> str: 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, +} + + @lru_cache(maxsize=None) def get_known_tasks() -> tuple[str, ...]: with open("known_tasks", "r") as f: diff --git a/http_service/tests/test_perf_regression_predictor.py b/http_service/tests/test_perf_regression_predictor.py index 2b4a6c8eaa..8e35115231 100644 --- a/http_service/tests/test_perf_regression_predictor.py +++ b/http_service/tests/test_perf_regression_predictor.py @@ -73,6 +73,14 @@ def test_endpoint_queues_and_returns_prediction(client, jobs, add_result) -> Non ) 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} From 60e7ce2c02828b7eb4b9029effcd57f6577331f9 Mon Sep 17 00:00:00 2001 From: Ali Sayed Salehi Date: Wed, 2 Sep 2026 11:13:25 -0400 Subject: [PATCH 8/9] revert unrelated changes to phabricator logic --- bugbug/tools/core/platforms/phabricator.py | 29 +---------------- tests/test_phabricator.py | 37 ---------------------- 2 files changed, 1 insertion(+), 65 deletions(-) diff --git a/bugbug/tools/core/platforms/phabricator.py b/bugbug/tools/core/platforms/phabricator.py index 0a809ee56f..4b99842d85 100644 --- a/bugbug/tools/core/platforms/phabricator.py +++ b/bugbug/tools/core/platforms/phabricator.py @@ -472,38 +472,11 @@ async def _commit_available(commit_hash: str) -> bool: def _diff_metadata(self) -> dict: phabricator = get_phabricator_client() diffs = phabricator.search_diffs(diff_id=self.diff_id) - if len(diffs) != 1: - raise PhabricatorRevisionNotFoundException(f"Diff {self.diff_id} not found") + assert len(diffs) == 1 diff = diffs[0] return diff - @cached_property - def diff_commits(self) -> list[dict]: - """Return local commit metadata uploaded with this immutable diff.""" - phabricator = get_phabricator_client() - diffs = phabricator.search_diffs( - diff_id=self.diff_id, - attachments={"commits": True}, - ) - if len(diffs) != 1: - raise PhabricatorRevisionNotFoundException(f"Diff {self.diff_id} not found") - return diffs[0].get("attachments", {}).get("commits", {}).get("commits", []) - - @property - def diff_revision_phid(self) -> str: - """Return the revision PHID associated with this diff.""" - return self._diff_metadata["revisionPHID"] - - @property - def commit_messages(self) -> list[str]: - """Return non-empty commit messages uploaded with this diff.""" - return [ - message - for commit in self.diff_commits - if isinstance((message := commit.get("message")), str) and message.strip() - ] - async def get_base_revision(self) -> Optional[str]: try: return await self.get_base_commit_hash() diff --git a/tests/test_phabricator.py b/tests/test_phabricator.py index ea65c4d351..57cb3ab1dd 100644 --- a/tests/test_phabricator.py +++ b/tests/test_phabricator.py @@ -326,43 +326,6 @@ def test_get_project_members_empty(monkeypatch) -> None: phab_platform.get_project_members.cache_clear() -def test_diff_commit_messages(monkeypatch) -> None: - client = MagicMock() - client.search_diffs.return_value = [ - { - "attachments": { - "commits": { - "commits": [ - {"identifier": "abc", "message": "First message"}, - {"identifier": "def", "message": ""}, - {"identifier": "ghi", "message": "Second message\n\nBody"}, - ] - } - } - } - ] - monkeypatch.setattr(phab_platform, "get_phabricator_client", lambda: client) - - patch = phab_platform.PhabricatorPatch(diff_id=123) - - assert patch.commit_messages == ["First message", "Second message\n\nBody"] - client.search_diffs.assert_called_once_with( - diff_id=123, - attachments={"commits": True}, - ) - - -def test_missing_diff_is_not_accessible(monkeypatch) -> None: - client = MagicMock() - client.search_diffs.return_value = [] - monkeypatch.setattr(phab_platform, "get_phabricator_client", lambda: client) - - patch = phab_platform.PhabricatorPatch(diff_id=123) - - assert not patch.is_accessible() - client.search_diffs.assert_called_once_with(diff_id=123) - - # --------------------------------------------------------------------------- # Rotation recovery: historical_reviewer_project_phids # --------------------------------------------------------------------------- From 1620adc8884df9a2f595cb9b476cd5345b91a50d Mon Sep 17 00:00:00 2001 From: Ali Sayed Salehi Date: Wed, 2 Sep 2026 12:05:43 -0400 Subject: [PATCH 9/9] move the URl download logic into download_model instead of a separate function --- bugbug/utils.py | 46 ++++++++++++--------- docs/models/perf-regression-predictor.md | 7 ++-- http_service/bugbug_http/download_models.py | 8 +--- http_service/bugbug_http/models.py | 4 +- 4 files changed, 34 insertions(+), 31 deletions(-) diff --git a/bugbug/utils.py b/bugbug/utils.py index 1fdc36c017..35a7eacd3f 100644 --- a/bugbug/utils.py +++ b/bugbug/utils.py @@ -284,40 +284,46 @@ def get_last_modified(url: str) -> datetime | None: return dateutil.parser.parse(r.headers["Last-Modified"]) -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" +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 - 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" - logger.info("Downloading %s...", url) - updated = download_check_etag(url) - if updated: - extract_tar_zst(f"{path}.tar.zst") - os.remove(f"{path}.tar.zst") - assert os.path.exists(path), "Decompressed directory exists" - return path + 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_from_url(model_name: str, url: str) -> str: - """Download a model from a URL instead of the Taskcluster index. +def download_model(model_name: str) -> str: + """Download a model and unpack it to a `{model_name}model` directory. - Like download_model(), this unpacks 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" 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) # Save it under our own name; the URL can end in anything. updated = download_check_etag(url, archive) if updated: extract_tar_zst(archive) os.remove(archive) - assert os.path.exists(path), "Decompressed directory exists" return path diff --git a/docs/models/perf-regression-predictor.md b/docs/models/perf-regression-predictor.md index 6a889dd57b..6435524ede 100644 --- a/docs/models/perf-regression-predictor.md +++ b/docs/models/perf-regression-predictor.md @@ -119,9 +119,10 @@ class PerfRegressionPredictorModel(Model): artifact_url = "https://storage.googleapis.com/.../perf-regression-predictor-v1.tar.zst" ``` -`download_models()` fetches that URL for any model declaring an `artifact_url` -and falls back to the Taskcluster index for the rest, so the background worker -downloads this model alongside the others with no special casing. +`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 diff --git a/http_service/bugbug_http/download_models.py b/http_service/bugbug_http/download_models.py index 91b61a2690..b3a61a19a7 100644 --- a/http_service/bugbug_http/download_models.py +++ b/http_service/bugbug_http/download_models.py @@ -6,7 +6,6 @@ import logging from bugbug import utils -from bugbug.models import get_model_class from bugbug_http import ALLOW_MISSING_MODELS from bugbug_http.models import MODEL_CACHE, MODELS_TO_DOWNLOAD @@ -15,12 +14,7 @@ def download_models(): for model_name in MODELS_TO_DOWNLOAD: - # Some models are published outside the Taskcluster index. - artifact_url = getattr(get_model_class(model_name), "artifact_url", None) - if artifact_url: - utils.download_model_from_url(model_name, artifact_url) - else: - utils.download_model(model_name) + utils.download_model(model_name) # Try loading the model try: m = MODEL_CACHE.get(model_name) diff --git a/http_service/bugbug_http/models.py b/http_service/bugbug_http/models.py index 1fe1212a54..32d2ceccd9 100644 --- a/http_service/bugbug_http/models.py +++ b/http_service/bugbug_http/models.py @@ -45,7 +45,6 @@ "worksforme", "fenixcomponent", ] -MODELS_TO_DOWNLOAD = [*MODELS_NAMES, PERF_REGRESSION_PREDICTOR] PERF_REGRESSION_LOG_PREFIX = "[perf-regression-predictor]" DEFAULT_EXPIRATION_TTL = 7 * 24 * 3600 # A week @@ -349,6 +348,9 @@ def classify_perf_regression(branch: str, rev: str) -> str: 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, ...]: