Files
nvidia__skills/.github/scripts/aggregate_benchmarks.py
Moshe Abramovitch e6526d7c9c fix(benchmarks): capture v3 provenance and block silent field loss
SkillEvaluator's report format changed in July 2026. It stopped emitting
`NVSkills-Eval profile` and `Pass threshold`, both of which the aggregator
reads, so those columns in benchmarks.json have been emptying one skill at a
time as each is re-evaluated: 0% null through June, 57% in July, 100% for
every skill evaluated in August. Nothing failed. The regeneration succeeded,
the schema stayed valid, --check passed, and the data quietly degraded for
six weeks before anyone noticed.

Neither field is recoverable from a v3 report. `profile` is gone entirely.
The only "50%" left is a glossary sentence that is byte-identical across
skills with 4, 5 and 18 tasks, so it is template prose rather than a
measurement — scraping it would stamp an unverified pass threshold on every
skill in the file whose purpose is recording provenance. Both stay null until
SkillEvaluator emits them as real fields; a NO_FABRICATION note records why.

What v3 does add is stronger provenance the parser was discarding:

  - evaluator_version   which build produced the score
  - dataset_digest      sha256 of the exact task set, unique per skill
  - validation_status   pipeline outcome for the run

Capturing these follows the existing multi-pattern idiom already used for
`tasks`, so v1/v2 reports are unaffected and simply leave them null.

The generic fix is null_rate_regressions(), which compares a regeneration
against the committed benchmarks.json and refuses to write when any field
lost values, counting only skills present in both so new additions cannot
false-fire. --allow-null-regressions lands a deliberate retirement without
requiring a code edit under pressure. Verified against the real catalog: it
does not fire today, it does fire when the July drift is replayed over 20
reports, and it flags the exact fields lost in PR #466.

Adds the first tests for .github/scripts (16 cases, two real BENCHMARK.md
fixtures, one per format). Confirmed no regression: every pre-existing field
is byte-identical across all 341 skills.

Also adds the SPDX header aggregate_benchmarks.py was missing; it was the
only .py in .github/scripts/ without one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Moshe Abramovitch <moshea@nvidia.com>
2026-08-20 11:36:31 -05:00

386 lines
16 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: (.+)$")],
# v1/v2 only. v3 dropped this line along with the NVSkills-Eval name; it
# stays None there rather than being inferred. See NO_FABRICATION below.
"profile": [re.compile(r"^- NVSkills-Eval profile: `?([^`\n]+)`?\s*$")],
"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.
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) -> 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 profile /
pass_threshold drift and the 2026-08-03 disappearance of
cuopt-multi-objective-exploration are that shape.
"""
old_by_skill = {s["skill"]: s for s in old.get("skills", [])}
new_by_skill = {s["skill"]: s for s in new.get("skills", [])}
common = old_by_skill.keys() & new_by_skill.keys()
fields = {
k
for skill in common
for k in (*old_by_skill[skill], *new_by_skill[skill])
if k != "skill"
}
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 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:
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"))
lost = null_rate_regressions(previous, json.loads(payload))
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())