Files
Moshe Abramovitch 14e8556f43 fix(benchmarks): key the orphan exemption by dir, rail an empty registry
Self-review of the exemption found two ways it removed more than the
orphan it was aimed at.

Keyed by skill name, the exemption could take a live skill with it. A
rename leaves the old and new dirs carrying the same skill name in their
reports, so the name is not a key: the two entries already overwrote each
other when building the comparison, and excluding the orphan by name
excluded the registered dir too. Both dirs sit on disk together whenever
prune-orphans hits PRUNE_CAP and deletes nothing, so this is reachable,
not theoretical. Keyed by catalog_dir it is not — dir names are unique by
construction.

With no registrations parsed, every dir looked like an orphan. A single
malformed components.d file would have exempted the whole catalog and
disabled the guard everywhere. prune-orphans already refuses to delete
anything when the declared set comes back empty, for exactly this
reason; this mirrors that rail.

Both cases now have tests, and both tests fail against the previous
commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Moshe Abramovitch <moshea@nvidia.com>
2026-09-02 10:11:45 -05:00

538 lines
23 KiB
Python

#!/usr/bin/env python3
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""Aggregate per-skill BENCHMARK.md evaluation reports into benchmarks.json.
Walks skills/*/BENCHMARK.md, extracts the evaluation summary, agents, and
per-dimension results (skill-assisted score plus uplift vs. the no-skill
baseline), and writes a single machine-readable benchmarks.json at the repo
root for downstream dashboards and tooling.
Usage:
python3 .github/scripts/aggregate_benchmarks.py [--repo-root PATH]
python3 .github/scripts/aggregate_benchmarks.py --check # fail on drift
"""
import argparse
import json
import re
import sys
from pathlib import Path
# Two report layouts are in circulation and both must parse:
# v1 — "Evaluation Report" with an Evaluation Summary list, an "Agents Used"
# list, and a "## Results" table keyed on Dimension.
# v2 — SkillEvaluator 0.9.x, with an "Evaluation Metadata" list, agents on a
# single line, and a "## Results at a Glance" table keyed on Measure.
# Each field lists its patterns most-specific first; the first match wins.
SUMMARY_FIELDS = {
"skill": [re.compile(r"^- Skill: `?([^`\n]+)`?\s*$")],
"evaluation_date": [re.compile(r"^- Evaluation date: (.+)$")],
# RETIRED. v1/v2 emitted "- NVSkills-Eval profile: external". v3 dropped
# the line along with the NVSkills-Eval name, which is internal and does
# not belong in a published report. The field is retired rather than
# carried as a permanently-null column named after an internal tool.
"environment": [re.compile(r"^- Environment: `?([^`\n]+)`?\s*$")],
# v3 provenance. Absent from v1/v2, which leave these None.
"evaluator_version": [re.compile(r"^- Evaluator version: `?([^`\n]+?)`?\s*$")],
# The digest line carries a trailing snapshot name in parentheses, so this
# cannot use the trailing-anchored form the other fields use.
"dataset_digest": [re.compile(r"^- Dataset digest: `?([^`\s]+)`?")],
"validation_status": [re.compile(r"^- Validation status: `?([^`\n]+?)`?\s*$")],
"tasks": [
re.compile(r"^- Dataset: (\d+) evaluation tasks?"), # v1
re.compile(r"^- Tasks: (\d+) evaluation tasks?"), # v2
],
"attempts_per_task": [re.compile(r"^- Attempts per task: (\d+)")],
"pass_threshold_pct": [re.compile(r"^- Pass threshold: (\d+(?:\.\d+)?)%")],
"verdict": [
# v2 states the verdict in a callout above the report body.
re.compile(r"^>?\s*[^\w\s]*\s*\*\*Overall verdict: (\w+)"),
# v1 states it as a summary bullet. Anchored to end-of-line so it
# cannot match the v2 methodology bullet, which begins
# "- Overall verdict: PASS only when every configured dimension ..."
# and would otherwise report PASS for every v2 report.
re.compile(r"^- Overall verdict: (\w+)\s*$"),
],
}
AGENT_LINE = re.compile(r"^- `([^`]+)`\s*$")
# v2 lists agents inline, e.g. "- Agents: Claude Code (`model/id`), Codex (`model/id`)"
AGENTS_INLINE = re.compile(r"^- Agents: (.+)$")
# v1 cell, e.g. "100% (+70%)" or "97%" — score with optional uplift vs. baseline
CELL = re.compile(r"(\d+(?:\.\d+)?)%(?:\s*\(([+-±]?\d+(?:\.\d+)?)%\))?")
# v2 cell, e.g. "45% → 98% (+53 points)" — baseline, skill score, then uplift.
# An unchanged dimension is written "±0 points", so ± must be accepted here
# alongside + and -; see parse_uplift for why dropping it is not harmless.
CELL_V2 = re.compile(
r"(\d+(?:\.\d+)?)%\s*(?:→|->)\s*(\d+(?:\.\d+)?)%"
r"(?:\s*\(([+-±]?\d+(?:\.\d+)?)\s*points?\))?"
)
RESULTS_SECTIONS = {"results", "results at a glance"}
# v2 leads its table with an aggregate row; the per-dimension rows below it are
# what v1 reports, so skipping it keeps average_uplift comparable across both.
SUMMARY_ROWS = {"overall"}
INT_FIELDS = {"tasks", "attempts_per_task"}
FLOAT_FIELDS = {"pass_threshold_pct"}
# NO_FABRICATION: v3 mentions "50%" once, in a static glossary bullet
# ("- The 50% attempt pass threshold is a separate per-task gate; ..."). That
# sentence is byte-identical across skills with 4, 5 and 18 tasks, so it is
# template prose, not a per-skill measurement. Scraping it would stamp 50.0 on
# every v3 skill regardless of what it was evaluated against — a fabricated
# provenance claim in the file whose purpose is recording provenance. Leave
# pass_threshold_pct None on v3 until SkillEvaluator emits it as a real field.
# Fields mid-retirement: known to be losing values as cards migrate, so a
# rising null count is the expected transition rather than a parser break.
#
# pass_threshold_pct is read from the "- Pass threshold: N%" line that v1/v2
# cards carry and v3 cards dropped (see NO_FABRICATION above). Every skill that
# re-runs CI and lands a v3 card therefore adds exactly one null. Without this
# exemption the guard blocks every sync that migrates any skill — it fired on
# cuopt-server-api-python on 2026-08-28 and would fire again on each of the
# ~100 skills still carrying a v1/v2 value.
#
# The drift is still reported, just not treated as blocking: the point of the
# guard is catching a parser that broke without anyone noticing, and this is
# the opposite — a change we understand and chose. Every other field stays
# guarded.
#
# Remove this once SkillEvaluator emits the threshold as a real per-run field
# and the parser reads it again.
MIGRATING_FIELDS = {"pass_threshold_pct"}
def parse_uplift(raw):
"""Convert an uplift cell capture to a float, or None when absent.
Reports write an unchanged dimension as "±0 points". ± carries no sign,
so strip it and keep the magnitude — the only value emitted in practice
is ±0, which must land as 0.0 rather than None: average_uplift() skips
None entries, so a dropped ±0 leaves the row out of the denominator and
inflates the average for any skill that scored the same with and without
the skill applied.
"""
if raw is None:
return None
return float(raw.replace("±", ""))
def normalize_agent(name: str) -> str:
"""v1 writes `claude-code`; v2 writes "Claude Code (`model/id`)"."""
name = re.sub(r"\s*\(.*?\)\s*", "", name).strip().strip("`")
name = re.sub(r"\s*\(Baseline\s*(?:→|->)\s*Skill Uplift\)\s*", "", name).strip()
return name.lower().replace(" ", "-")
def parse_benchmark(path: Path) -> dict:
entry = {k: None for k in SUMMARY_FIELDS}
entry["agents"] = []
entry["results"] = []
section = None
agent_cols = {} # results-table column index -> agent name, from the header row
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if line.startswith("#"):
section = line.lstrip("# ").lower()
continue
for key, patterns in SUMMARY_FIELDS.items():
if entry[key] is not None:
continue
for rx in patterns:
m = rx.match(line)
if not m:
continue
val = m.group(1).strip()
if key in INT_FIELDS:
val = int(val)
elif key in FLOAT_FIELDS:
val = float(val)
entry[key] = val
break
if section == "agents used":
m = AGENT_LINE.match(line)
if m:
entry["agents"].append(normalize_agent(m.group(1)))
m = AGENTS_INLINE.match(line)
if m and not entry["agents"]:
# Each agent reads "Name (`provider/model`)"; keep the name only.
names = re.findall(r"([^,(]+?)\s*\(`[^`]*`\)", m.group(1))
if not names:
names = m.group(1).split(",")
entry["agents"] = [normalize_agent(a) for a in names if a.strip()]
if section in RESULTS_SECTIONS and line.startswith("|"):
cells = [c.strip() for c in line.strip("|").split("|")]
if not cells or set(cells[0]) <= {"-", ":", " "}:
continue
if cells[0] in ("Dimension", "Measure"):
# Key agent columns off the header row rather than assuming
# they match Agents Used order (columns may vary per report).
agent_cols = {
i: normalize_agent(c)
for i, c in enumerate(cells)
if i > 0 and c.strip("`") and c != "Num"
}
continue
dimension = cells[0]
if dimension.lower() in SUMMARY_ROWS:
continue
num = None
scores = {}
for i, cell in enumerate(cells[1:], start=1):
if i not in agent_cols:
if num is None and cell.isdigit():
num = int(cell)
continue
m2 = CELL_V2.search(cell)
if m2:
# Record the skill-assisted score, matching v1 semantics.
scores[agent_cols[i]] = {
"score_pct": float(m2.group(2)),
"uplift_pct": parse_uplift(m2.group(3)),
}
continue
m = CELL.search(cell)
if not m:
continue
scores[agent_cols[i]] = {
"score_pct": float(m.group(1)),
"uplift_pct": parse_uplift(m.group(2)),
}
if scores:
entry["results"].append({
"dimension": dimension,
"num": num,
"agents": scores,
})
return entry
def null_rate_regressions(old: dict, new: dict, exclude=frozenset()) -> dict:
"""Fields that lost values between two benchmarks.json generations.
Returns {field: (old_null_count, new_null_count)} for every field whose
null count rose, counted only over skills present in BOTH files so that
newly added skills cannot register as a regression.
This is the generic guard against silent degradation: a regeneration can
succeed, keep a valid schema, pass --check, and still quietly empty a
column when an upstream report format changes. Both the v3
pass_threshold drift and the 2026-08-03 disappearance of
cuopt-multi-objective-exploration are that shape.
``exclude`` drops catalog dirs from the comparison entirely. Callers use
it for dirs no longer registered in components.d: those are orphans
awaiting prune-orphans, and their component field going null is the
deregistration working, not a parser losing data. Counting them made a
routine catalog_dir rename unlandable — see main().
Skills are keyed by catalog_dir, not by the skill name in the report. A
rename leaves the old and new dirs carrying the *same* skill name, so a
name key collides: the two entries overwrite each other here, and an
exclusion aimed at the orphan would take the live skill with it. Dir
names are unique by construction.
"""
def by_dir(doc):
return {s.get("catalog_dir") or s.get("skill"): s
for s in doc.get("skills", [])}
old_by_skill, new_by_skill = by_dir(old), by_dir(new)
common = (old_by_skill.keys() & new_by_skill.keys()) - set(exclude)
fields = {
k
for skill in common
for k in (*old_by_skill[skill], *new_by_skill[skill])
if k not in ("skill", "catalog_dir")
}
regressions = {}
for field in sorted(fields):
was = sum(1 for s in common if old_by_skill[s].get(field) is None)
now = sum(1 for s in common if new_by_skill[s].get(field) is None)
if now > was:
regressions[field] = (was, now)
return regressions
def registered_catalog_dirs(repo_root: Path) -> set:
"""Catalog dirs the sync will keep, by prune-orphans' own rule.
A dir survives the sync if it is declared in components.d, staged in
manual-components.yml, or listed in catalog-exceptions.yml. Anything else
under skills/ is deleted in the next sync commit.
Deliberately not derived from load_component_map(): that maps dir ->
component name and so only sees entries that declare one. An exception
listed without a component would drop out of the map and be read here as
an orphan, quietly losing it the null guard's protection. Membership, not
the component value, is what decides whether a dir has a future.
"""
dirs = set()
for yml in sorted((repo_root / "components.d").glob("*.yml")):
for line in yml.read_text(encoding="utf-8").splitlines():
m = re.match(r"^-?\s*catalog_dir:\s*(.+)$", line.strip())
if m:
dirs.add(m.group(1).strip())
manual = repo_root / ".github" / "scripts" / "manual-components.yml"
if manual.exists():
for line in manual.read_text(encoding="utf-8").splitlines():
m = re.match(r"^-\s*(\S+)\s*$", line.strip())
if m:
dirs.add(m.group(1))
exceptions = repo_root / "catalog-exceptions.yml"
if exceptions.exists():
for line in exceptions.read_text(encoding="utf-8").splitlines():
m = re.match(r"^-?\s*dir:\s*(.+)$", line.strip())
if m:
dirs.add(m.group(1).strip())
return dirs
def component_only_difference(existing, payload: str) -> bool:
"""True when two benchmarks.json bodies agree except on `component`.
Used to tell a stale product assignment — which a components.d edit
creates immediately and the next sync resolves — apart from real drift in
the measurements. Everything except the component values must match
exactly, including which skills are present, so this cannot mask a skill
appearing, vanishing, or changing its numbers.
A malformed existing file is not a component-only difference; it is a
genuine mismatch and the caller should report it as one.
"""
if existing is None:
return False
try:
a, b = json.loads(existing), json.loads(payload)
except json.JSONDecodeError:
return False
def strip(doc):
doc = json.loads(json.dumps(doc)) # copy; leave the caller's alone
for entry in doc.get("skills", []):
entry.pop("component", None)
for row in doc.get("results", []):
row.pop("component", None)
return doc
return strip(a) == strip(b)
def average_uplift(results: list):
uplifts = [
s["uplift_pct"]
for r in results
for s in r["agents"].values()
if s["uplift_pct"] is not None
]
return round(sum(uplifts) / len(uplifts), 2) if uplifts else None
def load_component_map(repo_root: Path) -> dict:
"""Map catalog skill dir -> component (product) name.
Primary source is components.d/ registrations. Skills that intentionally
exist without a registration (catalog-exceptions.yml) may declare a
display component there so downstream consumers can still group them.
"""
mapping = {}
for yml in sorted((repo_root / "components.d").glob("*.yml")):
name = None
for line in yml.read_text(encoding="utf-8").splitlines():
m = re.match(r"^name:\s*(.+)$", line.strip())
if m and name is None:
name = m.group(1).strip()
m = re.match(r"^-?\s*catalog_dir:\s*(.+)$", line.strip())
if m:
mapping[m.group(1).strip()] = name
manual = repo_root / ".github" / "scripts" / "manual-components.yml"
if manual.exists():
name = None
for line in manual.read_text(encoding="utf-8").splitlines():
m = re.match(r"^-?\s*name:\s*(.+)$", line.strip())
if m:
name = m.group(1).strip()
continue
m = re.match(r"^-\s*(\S+)\s*$", line.strip())
if m and name and m.group(1) not in mapping:
mapping[m.group(1)] = name
exceptions = repo_root / "catalog-exceptions.yml"
if exceptions.exists():
current_dir = None
for line in exceptions.read_text(encoding="utf-8").splitlines():
m = re.match(r"^-?\s*dir:\s*(.+)$", line.strip())
if m:
current_dir = m.group(1).strip()
continue
m = re.match(r"^component:\s*(.+)$", line.strip())
if m and current_dir and current_dir not in mapping:
mapping[current_dir] = m.group(1).strip()
return mapping
def generate(root: Path) -> str:
components = load_component_map(root)
skills = []
rows = []
skipped = []
for bm in sorted(root.glob("skills/*/BENCHMARK.md")):
catalog_dir = bm.parent.name
entry = parse_benchmark(bm)
results = entry.pop("results")
if not results:
skipped.append(catalog_dir)
component = components.get(catalog_dir)
# Flat measurement rows: one per skill x dimension x agent, so BI
# tools can load them as a table and join on catalog_dir.
for r in results:
for agent, s in r["agents"].items():
rows.append({
"catalog_dir": catalog_dir,
"component": component,
"dimension": r["dimension"],
"num": r["num"],
"agent": agent,
"score_pct": s["score_pct"],
"uplift_pct": s["uplift_pct"],
})
entry["catalog_dir"] = catalog_dir
entry["component"] = component
entry["has_results"] = bool(results)
entry["average_uplift_pct"] = average_uplift(results)
skills.append(entry)
out = {
"schema_version": 2,
"source": "skills/*/BENCHMARK.md",
"skill_count": len(skills),
"result_row_count": len(rows),
"skills_without_results": sorted(skipped),
"skills": skills,
"results": rows,
}
return json.dumps(out, indent=2) + "\n"
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--repo-root", default=".", type=Path)
ap.add_argument(
"--check",
action="store_true",
help="Fail (exit 1) if the checked-in benchmarks.json does not match "
"what the BENCHMARK.md sources would generate.",
)
ap.add_argument(
"--allow-null-regressions",
action="store_true",
help="Write even when a field lost values against the existing "
"benchmarks.json. Use when an upstream report format change has "
"genuinely retired a field, so the new state can be landed.",
)
args = ap.parse_args()
root = args.repo_root.resolve()
target = root / "benchmarks.json"
payload = generate(root)
count = json.loads(payload)["skill_count"]
if args.check:
existing = target.read_text(encoding="utf-8") if target.exists() else None
if existing != payload:
# `component` is the one field not parsed from a BENCHMARK.md. It
# is joined in from components.d, so it moves the moment a PR
# edits a registration — while benchmarks.json cannot be brought
# up to date until the sync has renamed the dirs on disk. A
# components.d-only PR is therefore *expected* to differ here, and
# failing on it demanded a regeneration the null guard below then
# refused to perform. Validate the measurements; let the
# post-sync regeneration settle the join.
if component_only_difference(existing, payload):
print(
"benchmarks.json differs only in component assignments, "
"which components.d has changed and the next sync will "
"re-derive. Measurements are up to date."
)
return 0
print(
"benchmarks.json is out of date with skills/*/BENCHMARK.md.\n"
"Regenerate it with: python3 .github/scripts/aggregate_benchmarks.py",
file=sys.stderr,
)
return 1
print(f"benchmarks.json is up to date ({count} skills)")
return 0
if target.exists() and not args.allow_null_regressions:
previous = json.loads(target.read_text(encoding="utf-8"))
generated = json.loads(payload)
# Catalog dirs still on disk but no longer registered are orphans:
# the sync's prune-orphans step deletes them in the same commit that
# writes the renamed dirs. Their component is null because the
# deregistration worked, so counting it as lost data blocks the very
# change that deregistered them. #519 could not be landed at all
# until this exclusion existed.
registered = registered_catalog_dirs(root)
# prune-orphans refuses to delete anything when the declared set comes
# back empty, because a parse error makes every dir look unregistered.
# The same reasoning applies here: an empty set would exempt the whole
# catalog and silently disable the guard.
orphans = {
s.get("catalog_dir")
for s in generated.get("skills", [])
if registered and s.get("catalog_dir") not in registered
}
if orphans:
print(
f"note: {len(orphans)} deregistered dir(s) awaiting prune "
"excluded from the null guard.",
file=sys.stderr,
)
lost = null_rate_regressions(previous, generated, exclude=orphans)
# Report every regression; block only on the ones we did not expect.
for field, (was, now) in sorted(lost.items()):
if field in MIGRATING_FIELDS:
print(
f"note: {field} {was} -> {now} nulls — expected while cards "
"migrate; see MIGRATING_FIELDS.",
file=sys.stderr,
)
lost = {f: v for f, v in lost.items() if f not in MIGRATING_FIELDS}
if lost:
print(
"Refusing to write benchmarks.json: fields lost values.\n"
"Skills carried these before and would not after:",
file=sys.stderr,
)
for field, (was, now) in lost.items():
print(f" {field}: {was} -> {now} nulls", file=sys.stderr)
print(
"\nThis usually means an upstream BENCHMARK.md format change "
"stopped emitting a field the parser reads.\n"
"Either teach the parser the new format, or re-run with "
"--allow-null-regressions if the field is genuinely retired.",
file=sys.stderr,
)
return 1
target.write_text(payload, encoding="utf-8")
print(f"Wrote benchmarks.json: {count} skills")
return 0
if __name__ == "__main__":
sys.exit(main())