This guide explains how to create a brand-new quality dimension with a scorer in PQF — for example, adding an entirely new axis like "Observability" or "Accessibility" to the medal rubric.
Adding a metric to an existing dimension? That is a more common and simpler task. See adding-a-metric.md instead — it covers the common case with a full example.
Add a new metric (to an existing dimension) when the signal you want to measure fits naturally within an existing quality axis and shares the same scorer infrastructure.
Add a new dimension when:
- The quality axis is conceptually distinct from all existing dimensions (Test Verification, Documentation, Security, Substrate Compatibility, Engagement)
- It requires its own scorer binary with different API calls or external integrations
- The team has agreed to track this axis across PQF-tracked products
A quality dimension is one axis of the result rubric (e.g., Test Verification, Documentation, Security). Adding a dimension requires:
- An entry in
config/dimensions.yaml— declares the dimension's outputs and result criteria - A new
scorers/<name>/directory withlogic.py,scorer.py, and tests - Registration in the
Makefile
Add a new top-level entry under dimensions::
my_dimension:
label: "My Dimension"
description: "One sentence describing what this dimension measures."
scorer: scorers/my_dimension/scorer.py
applies_to:
product_types: [charm, snap] # which product types this dimension scores
aggregation: worst_in_scope
outputs:
some_boolean:
type: boolean
label: "Human-readable label"
description: "What this metric checks and how."
# ai_assisted: true # Uncomment if scored by LLM, not GitHub API
some_number:
type: number
range: "0–100"
label: "Human-readable label"
description: "What this metric measures."
medals:
bronze:
- some_boolean == true
silver:
- some_number >= 70
gold:
- some_number >= 90If your dimension only applies to charms, set applies_to.product_types: [charm]. Root products automatically return not_applicable for this dimension and are not penalized in their result calculation.
Each criterion is a string evaluated against the product's computed metrics:
| Syntax | Example |
|---|---|
metric >= value |
coverage_pct >= 80 |
metric <= value |
avg_triage_days <= 5 |
metric == true |
has_readme == true |
metric == false |
has_violations == false |
Result tiers are cumulative — a product earning silver must also satisfy all bronze criteria.
Set ai_assisted: true on any output metric that is scored by an LLM rather than deterministic API checks. The UI renders an ✦ AI badge next to that metric in the Dimension Detail page.
mkdir -p scorers/my_dimension/__tests__
touch scorers/my_dimension/__init__.py
touch scorers/my_dimension/logic.py
touch scorers/my_dimension/scorer.py
touch scorers/my_dimension/__tests__/__init__.py
touch scorers/my_dimension/__tests__/test_logic.pylogic.py must contain a compute_metrics function that:
- Accepts
unit: EvaluationUnitand any credentials it needs (e.g.github_token: str) - Returns
dict[str, Any]with exactly the keys declared indimensions.yamloutputs - Has no side effects — no
os.environ, no file I/O, no print statements
Use unit.repo, unit.subpath, unit.allure_report_url, and unit.documentation_url to access
the leaf product's source information.
Use helpers from scorers.shared.github_signals rather than making raw requests calls — they
handle auth retry, base URL, and monorepo subpath scoping automatically. See
adding-a-metric.md — Reference: shared GitHub signal helpers
for the full list.
from __future__ import annotations
from typing import Any
from engine.models import EvaluationUnit
from scorers.shared.github_signals import repo_file_exists, workflow_files
def _my_boolean_check(unit: EvaluationUnit, github_token: str | None) -> bool:
"""Return True if the repository has the expected signal."""
return repo_file_exists(unit.repo, "SOME_FILE.md", github_token)
def _my_number_check(unit: EvaluationUnit, github_token: str | None) -> float:
"""Return a score 0–100 based on some repository signal."""
files = workflow_files(unit.repo, github_token)
# ... derive a number from the files
return 75.0 if files else 0.0
def compute_metrics(unit: EvaluationUnit, github_token: str) -> dict[str, Any]:
return {
"some_boolean": _my_boolean_check(unit, github_token),
"some_number": _my_number_check(unit, github_token),
}For a real example of a complete logic.py, read
scorers/documentation/logic.py — it shows the standard
import pattern, helper structure, and compute_metrics signature used across the project.
Use pytest-mock to patch shared helpers at their import site in logic.py. Never make real
network calls in tests.
from engine.models import EvaluationUnit, ProductType
from scorers.my_dimension.logic import compute_metrics
UNIT = EvaluationUnit(
product_id="test-charm",
product_type=ProductType.CHARM,
repo="canonical/test-repo",
)
def test_returns_defaults_when_signals_missing(mocker):
mocker.patch("scorers.my_dimension.logic.repo_file_exists", return_value=False)
mocker.patch("scorers.my_dimension.logic.workflow_files", return_value=[])
result = compute_metrics(UNIT, "test-token")
assert result == {
"some_boolean": False,
"some_number": 0.0,
}
def test_boolean_true_when_file_exists(mocker):
mocker.patch(
"scorers.my_dimension.logic.repo_file_exists",
side_effect=lambda repo, path, token: path == "SOME_FILE.md",
)
mocker.patch("scorers.my_dimension.logic.workflow_files", return_value=[])
result = compute_metrics(UNIT, "test-token")
assert result["some_boolean"] is TrueWhy
scorers.my_dimension.logic.repo_file_existsand notscorers.shared.github_signals.repo_file_exists? Python's mock patches the name as it is used in the module under test. Sincelogic.pyimportsrepo_file_existsdirectly, you patch it at its import site inlogic.
Run the tests:
python3 -m pytest scorers/my_dimension/ -vIf your scorer uses OpenRouter, mock the OpenAI client with pytest-mock:
def test_llm_scorer(mocker):
mock_client = mocker.patch("scorers.my_dimension.logic.OpenAI")
mock_instance = mock_client.return_value
mock_instance.chat.completions.create.return_value = mocker.Mock(
choices=[mocker.Mock(message=mocker.Mock(content='{"result": true}'))]
)
result = compute_metrics(UNIT, github_token="tok", openrouter_api_key="key")
assert result["result"] is Truescorer.py is thin: it reads env vars, resolves leaf units, calls compute_metrics, and prints
JSON. Copy the pattern used by existing scorers exactly. In particular, keep the CLI arguments
and the resolve_leaf_units_for(...) call unchanged unless you have a specific reason to alter
them: PQF relies on that shape to resolve ref: entries and the correct set of leaf products.
#!/usr/bin/env python3
"""my_dimension scorer — iterates leaf products and outputs per-leaf metrics."""
import argparse
import json
import os
import sys
from pathlib import Path
import yaml
REPO_ROOT = Path(__file__).resolve().parents[2]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
def _load_all_products(products_dir: Path) -> list[dict]:
return [yaml.safe_load(f.read_text()) for f in sorted(products_dir.glob("*.yaml"))]
def main() -> int:
from engine.graph import build_graph, resolve_leaf_units_for
from scorers.my_dimension.logic import compute_metrics
parser = argparse.ArgumentParser()
parser.add_argument("--product-yaml", required=True)
parser.add_argument("--products-dir", default=None)
args = parser.parse_args()
github_token = os.environ["GITHUB_TOKEN"]
product_path = Path(args.product_yaml)
product = yaml.safe_load(product_path.read_text())
product_id = product["id"]
products_dir = Path(args.products_dir) if args.products_dir else product_path.parent
all_products = _load_all_products(products_dir)
graph = build_graph(all_products)
units = resolve_leaf_units_for(graph, product_id)
results = {}
for unit in units:
results[unit.product_id] = compute_metrics(unit, github_token)
print(json.dumps(results, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())If your scorer requires additional credentials (e.g., OPENROUTER_API_KEY), read them from env
vars in main() and pass them as parameters to compute_metrics — never read them in logic.py.
Add the new scorer to the score and score-no-llm targets in Makefile. Find the existing
scorer lines (they all follow the same pattern) and add yours in alphabetical order:
$(PYTHON) scorers/my_dimension/scorer.py --product-yaml products/$(PRODUCT).yaml \
--products-dir products \
> $(SCORE_DIR)/$(PRODUCT)/my_dimension.jsonAdd it to both score and score-no-llm targets so make score-all-no-llm runs your scorer.
Run the full pipeline to see your new dimension appear in the dashboard:
make score-no-llm PRODUCT=<any-product>
make _merge PRODUCT=<any-product>
make _assemble
make dev # → http://localhost:5173See Running scorers locally for the full workflow.
-
config/dimensions.yamlhas the new dimension withlabel,description,applies_to,aggregation,outputs, andmedals -
scorers/my_dimension/logic.pyis a pure function — noos.environ, no file I/O; returns exactly the keys declared inoutputs -
scorers/my_dimension/scorer.pyreads env vars and callscompute_metrics; usesresolve_leaf_units_for -
scorers/my_dimension/__tests__/test_logic.pytests all main code paths (signals present, signals missing) -
Makefile— scorer added to bothscoreandscore-no-llmtargets -
make testpasses (all Python tests) -
make lintpasses -
make score-no-llm PRODUCT=<any-product>runs without error -
make _merge PRODUCT=<any-product> && make _assembleupdatespublic/portfolio.json - New dimension appears correctly in the dashboard (
make dev)