Files
wehub-resource-sync a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:34 +08:00

225 lines
8.2 KiB
Python

"""Scorer discovery + reward aggregation.
Finds every module under reward/scorers/ that exposes the scorer contract
(NAME, CATEGORY, WEIGHT, score(ctx) -> CategoryScore), runs them over a matrix
of (device x scenario) cells, and produces a single hill-climbable reward.
Per cell: weighted mean of *available* category scores (weights renormalized so
unavailable scorers never tank the reward). Overall: mean across cells, plus
the worst cell and worst category so you know exactly what to fix next.
Usage:
python3 -m reward.aggregate --matrix-json matrix.json # score cells
python3 -m reward.aggregate --shot a.png --device "iPhone 17" --scenario short
python3 -m reward.aggregate --baseline before.json --candidate after.json
`--matrix-json` consumes the output of ui_matrix.py --json (which lists shots
per device/scenario); without it, score a single screenshot.
"""
from __future__ import annotations
import argparse
import importlib
import importlib.util
import json
import pkgutil
import sys
from dataclasses import asdict
from pathlib import Path
HERE = Path(__file__).resolve().parent
IOS = HERE.parent.parent
sys.path.insert(0, str(HERE.parent)) # so `import reward...` works
from reward.context import Context # noqa: E402
from reward.types import CategoryScore # noqa: E402
DEFAULT_SOURCE_ROOT = str(IOS / "Sources" / "JCodeMobile")
def discover_scorers():
"""Import every scorer module and return [(module)] that satisfy the contract."""
scorers_pkg = HERE / "scorers"
found = []
for info in pkgutil.iter_modules([str(scorers_pkg)]):
if info.name.startswith("_"):
continue
mod = importlib.import_module(f"reward.scorers.{info.name}")
if all(hasattr(mod, a) for a in ("NAME", "CATEGORY", "WEIGHT", "score")):
found.append(mod)
else:
print(f"warning: {info.name} missing contract attrs, skipped", file=sys.stderr)
return sorted(found, key=lambda m: (m.CATEGORY, m.NAME))
def score_cell(ctx: Context, scorers) -> dict:
cats = []
for mod in scorers:
try:
cs = mod.score(ctx)
except Exception as e: # a broken scorer must not kill the run
cs = CategoryScore(mod.NAME, mod.CATEGORY, mod.WEIGHT,
value=0.0, evidence={"error": str(e)}, available=False)
cats.append(cs)
available = [c for c in cats if c.available]
wsum = sum(c.weight for c in available)
if wsum > 0:
cell_reward = sum(c.clamped() * c.weight for c in available) / wsum
else:
cell_reward = 0.0
return {
"device": ctx.device,
"scenario": ctx.scenario,
"content_size": ctx.meta.get("content_size", "large"),
"shot": ctx.screenshot,
"reward": round(cell_reward, 2),
"categories": [asdict(c) for c in cats],
}
def aggregate(cells: list[dict]) -> dict:
if not cells:
return {"reward": 0.0, "cells": [], "by_category": {}, "worst_cell": None}
overall = sum(c["reward"] for c in cells) / len(cells)
# Mean per category id across cells (available only).
by_cat: dict[str, list[float]] = {}
cat_meta: dict[str, tuple[str, float]] = {}
for cell in cells:
for c in cell["categories"]:
if c["available"]:
by_cat.setdefault(c["name"], []).append(c["value"])
cat_meta[c["name"]] = (c["category"], c["weight"])
cat_means = {
name: {
"category": cat_meta[name][0],
"weight": cat_meta[name][1],
"mean": round(sum(v) / len(v), 2),
"n": len(v),
}
for name, v in by_cat.items()
}
worst_cell = min(cells, key=lambda c: c["reward"])
worst_cat = min(cat_means.items(), key=lambda kv: kv[1]["mean"]) if cat_means else None
return {
"reward": round(overall, 2),
"cells": cells,
"by_category": cat_means,
"worst_cell": {"device": worst_cell["device"],
"scenario": worst_cell["scenario"],
"content_size": worst_cell.get("content_size", "large"),
"reward": worst_cell["reward"]},
"worst_category": ({"name": worst_cat[0], **worst_cat[1]} if worst_cat else None),
}
def render(report: dict) -> str:
out = ["UX reward", "=" * 52]
out.append(f" OVERALL {report['reward']:5.1f}/100 "
f"({len(report['cells'])} cells)")
out.append("")
out.append(" by category (mean across cells):")
for name, d in sorted(report["by_category"].items(),
key=lambda kv: (kv[1]["category"], kv[0])):
out.append(f" [{d['category']}] {name:20} {d['mean']:5.1f} (w={d['weight']:.2f})")
out.append("")
out.append(" per cell:")
out.append(f" {'device':22} {'size':14} {'scenario':9} {'reward':>6}")
for c in report["cells"]:
size = c.get("content_size", "large").replace("accessibility", "a11y")
out.append(f" {c['device'][:22]:22} {size[:14]:14} "
f"{c['scenario']:9} {c['reward']:6.1f}")
out.append("-" * 52)
if report.get("worst_cell"):
w = report["worst_cell"]
out.append(f" worst cell: {w['reward']:.1f} "
f"({w['device']} / {w.get('content_size', 'large')} / "
f"{w['scenario']})")
if report.get("worst_category"):
w = report["worst_category"]
out.append(f" worst category: {w['mean']:.1f} ({w['name']})")
return "\n".join(out)
def build_cells_from_matrix(matrix_json: str, source_root: str) -> list[Context]:
data = json.loads(Path(matrix_json).read_text())
ctxs = []
for row in data:
runtime = row.get("runtime")
if not isinstance(runtime, dict) or not runtime:
runtime = None
meta = {}
if row.get("content_size"):
meta["content_size"] = row["content_size"]
ctxs.append(Context(
screenshot=row.get("shot"),
device=row.get("device", "iPhone 17"),
scenario=row.get("scenario", "short"),
scale=int(row.get("scale", 3)),
source_root=source_root,
runtime=runtime,
meta=meta,
))
return ctxs
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--matrix-json", help="output of ui_matrix.py --json")
ap.add_argument("--shot", help="single screenshot path")
ap.add_argument("--device", default="iPhone 17")
ap.add_argument("--scenario", default="short")
ap.add_argument("--scale", type=int, default=3)
ap.add_argument("--source-root", default=DEFAULT_SOURCE_ROOT)
ap.add_argument("--json", action="store_true")
ap.add_argument("--out-json", help="write the full report JSON here")
ap.add_argument("--baseline", help="baseline report JSON for regression gate")
ap.add_argument("--candidate", help="candidate report JSON for regression gate")
ap.add_argument("--min", type=float, default=0.0, help="fail if overall < this")
args = ap.parse_args()
if args.baseline and args.candidate:
a = json.loads(Path(args.baseline).read_text())
b = json.loads(Path(args.candidate).read_text())
delta = b["reward"] - a["reward"]
print(f"baseline {a['reward']:5.1f}")
print(f"candidate {b['reward']:5.1f}")
print(f"delta {'+' if delta >= 0 else ''}{delta:.1f}")
sys.exit(0 if delta >= -0.5 else 1)
scorers = discover_scorers()
if not scorers:
print("no scorers found under reward/scorers/", file=sys.stderr)
sys.exit(2)
if args.matrix_json:
ctxs = build_cells_from_matrix(args.matrix_json, args.source_root)
elif args.shot:
ctxs = [Context(screenshot=args.shot, device=args.device,
scenario=args.scenario, scale=args.scale,
source_root=args.source_root)]
else:
ap.error("provide --matrix-json or --shot")
cells = [score_cell(ctx, scorers) for ctx in ctxs]
report = aggregate(cells)
if args.out_json:
Path(args.out_json).write_text(json.dumps(report, indent=2))
if args.json:
print(json.dumps(report, indent=2))
else:
print(render(report))
sys.exit(1 if report["reward"] < args.min else 0)
if __name__ == "__main__":
main()