Skip to content

Commit 9f8456c

Browse files
committed
feat(ingest): multi-source benchmark enrichment
Add a variant-safe enrichment runner (app/ingest/enrich.py) that fills null benchmark columns on existing TechAPI CPU/GPU records without ever overwriting, writing only on exact heading matches. Backed by per-source scrapers (PassMark, technical.city, cgdirector, notebookcheck, SPEC CPU2006, topcpu.net, Blender, videocardbenchmark) registered in a SOURCES table. Extend the CPU/GPU models with legacy + cross-aggregator benchmark fields, add network-free unit tests for the source parsers, and wire a cpu-only enrich step into weekly-ingest.
1 parent e17eacc commit 9f8456c

18 files changed

Lines changed: 1901 additions & 2 deletions

.github/workflows/weekly-ingest.yml

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,38 @@ jobs:
6666
--summary ingest-summary.md \
6767
$DRAFTS_FLAG
6868
69+
# Variant-safe benchmark backfill on existing CPU records (PassMark).
70+
# CPU-only; never overwrites, only fills nulls on exact heading matches.
71+
# Non-fatal: a scrape hiccup must not sink the weekly ingest PR.
72+
- name: Enrich benchmarks (PassMark, cpu only)
73+
if: env.CATEGORY == 'cpu'
74+
continue-on-error: true
75+
env:
76+
TECHAPI_DATA_DIR: ${{ github.workspace }}/TechAPI/data
77+
run: |
78+
python -m app.ingest.enrich \
79+
--data-root TechAPI/data \
80+
--limit "$LIMIT" \
81+
--min-year 2008 \
82+
--sleep 0.5 \
83+
--summary enrich-summary.md
84+
85+
- name: Combine summaries for PR body
86+
run: |
87+
cp ingest-summary.md pr-body.md
88+
if [ -f enrich-summary.md ]; then
89+
printf '\n\n---\n\n' >> pr-body.md
90+
cat enrich-summary.md >> pr-body.md
91+
fi
92+
6993
- name: Upload summary artifact
7094
uses: actions/upload-artifact@v4
7195
with:
7296
name: ingest-summary
73-
path: ingest-summary.md
97+
path: |
98+
ingest-summary.md
99+
enrich-summary.md
100+
pr-body.md
74101
75102
- name: Check whether ingest produced any additions
76103
id: changes
@@ -106,7 +133,7 @@ jobs:
106133
fi
107134
gh pr create \
108135
--title "feat(data/${CATEGORY}): weekly ingest" \
109-
--body-file ../ingest-summary.md \
136+
--body-file ../pr-body.md \
110137
--base main \
111138
--head "$BRANCH" \
112139
$DRAFT_FLAG

app/ingest/enrich.py

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
"""Benchmark enrichment for existing TechAPI records (multi-source).
2+
3+
Unlike ``app.ingest`` (which *adds* missing SKUs), this *enriches* records that
4+
already exist: it fills null benchmark columns on CPU JSONs using a variant-safe
5+
source. It only ever fills nulls (never overwrites) and only writes a chip when
6+
the source confirms an exact heading match; everything else is reported as
7+
"unresolved" for review.
8+
9+
Sources (``--source``):
10+
* ``passmark`` → passmark_single / passmark_cpu_mark (cpubenchmark.net)
11+
* ``cinebench-legacy`` → cinebench_r15/r10/r11_5 single+multi (technical.city)
12+
* ``spec-cpu2006`` → specint2006 / specfp2006 (spec.org)
13+
14+
::
15+
16+
python -m app.ingest.enrich --source cinebench-legacy \\
17+
--data-root ../TechAPI/data --min-year 2011 --summary enrich.md
18+
19+
Run output is a PR-ready Markdown summary. Designed for the weekly-ingest
20+
workflow, but safe to run locally (respects ``--dry-run`` and ``--sleep``).
21+
22+
DOM note: each source's extractor is validated against live HTML on first run;
23+
adjust selectors if a site's markup drifts. Pure logic is covered by
24+
tests/unit/test_passmark_enrich.py and test_technical_city.py.
25+
"""
26+
27+
from __future__ import annotations
28+
29+
import argparse
30+
import json
31+
import os
32+
import sys
33+
import time
34+
from collections.abc import Callable
35+
from dataclasses import dataclass, field
36+
from pathlib import Path
37+
from typing import Any
38+
39+
import httpx
40+
41+
from .sources import (
42+
blender,
43+
cgdirector,
44+
notebookcheck,
45+
spec2006,
46+
technical_city,
47+
topcpu,
48+
videocardbenchmark,
49+
)
50+
from .sources.passmark import fetch_scores, make_client
51+
52+
# A resolver maps (client, name, id_override) -> (scores_dict, source_url) | None.
53+
Resolver = Callable[..., "tuple[dict[str, Any], str] | None"]
54+
55+
56+
def _passmark_resolver(
57+
client: httpx.Client, name: str, id_override: str | None = None
58+
) -> tuple[dict[str, Any], str] | None:
59+
r = fetch_scores(client, name, id_override=id_override)
60+
if r is None:
61+
return None
62+
return {"passmark_single": r.single_thread, "passmark_cpu_mark": r.cpu_mark}, r.source_url
63+
64+
65+
# name -> (resolver, primary_field). primary_field skips records already filled;
66+
# None means "attempt every record" (for multi-field sources — fill-only-nulls
67+
# still applies, and cached-table sources cost no network per record).
68+
SOURCES: dict[str, tuple[Resolver, str | None]] = {
69+
"passmark": (_passmark_resolver, "passmark_cpu_mark"),
70+
"cinebench-legacy": (technical_city.resolve, "cinebench_r15_multi"),
71+
"cinebench-r23": (cgdirector.resolve, "cinebench_r23_multi"),
72+
"cinebench-2024": (cgdirector.resolve_2024, "cinebench_2024_multi"),
73+
"cinebench-nbc": (notebookcheck.resolve, None),
74+
"geekbench-nbc": (notebookcheck.resolve_geekbench, "geekbench_multi"),
75+
"spec-cpu2006": (spec2006.resolve, None),
76+
"blender": (blender.resolve, "blender_score"), # GPU: --component gpu
77+
"timespy": (topcpu.resolve, "timespy_score"), # GPU: --component gpu
78+
"topcpu-cpu": (topcpu.resolve_cpu, None), # CPU: cb2024/passmark/gb6/r23 fill
79+
"passmark-gpu": (videocardbenchmark.resolve, "passmark_g3d_mark"), # GPU: legacy-incl.
80+
"topcpu-gpu": (topcpu.resolve_gpu, None), # GPU: timespy-extreme/speedway/octane/fp32
81+
}
82+
83+
84+
@dataclass
85+
class EnrichResult:
86+
filled: list[tuple[str, dict[str, Any]]] = field(default_factory=list) # (slug, scores)
87+
unresolved: list[str] = field(default_factory=list)
88+
already: int = 0
89+
90+
def markdown_summary(self, source: str = "") -> str:
91+
lines = [f"# Benchmark enrichment summary ({source})".rstrip(), ""]
92+
lines.append(f"- filled: **{len(self.filled)}**")
93+
lines.append(f"- unresolved (no exact-variant match / no data): {len(self.unresolved)}")
94+
lines.append(f"- skipped (already populated): {self.already}")
95+
lines.append("")
96+
if self.filled:
97+
lines.append("## Filled")
98+
for slug, scores in self.filled:
99+
vals = ", ".join(f"{k}={v}" for k, v in scores.items())
100+
lines.append(f"- `{slug}` — {vals}")
101+
lines.append("")
102+
if self.unresolved:
103+
lines.append("## Unresolved (no exact match or source lacks the data)")
104+
for name in self.unresolved:
105+
lines.append(f"- {name}")
106+
return "\n".join(lines).rstrip() + "\n"
107+
108+
109+
def _default_data_root() -> Path:
110+
explicit = os.environ.get("TECHAPI_DATA_DIR")
111+
if explicit:
112+
return Path(explicit)
113+
return Path(__file__).resolve().parent.parent.parent.parent / "TechAPI" / "data"
114+
115+
116+
def _candidates(cpu_root: Path, manufacturer: str | None) -> list[Path]:
117+
base = cpu_root / manufacturer if manufacturer else cpu_root
118+
return sorted(p for p in base.rglob("*.json") if not p.name.startswith("_"))
119+
120+
121+
def enrich(
122+
*,
123+
data_root: Path,
124+
resolver: Resolver = _passmark_resolver,
125+
primary_field: str | None = "passmark_cpu_mark",
126+
component: str = "cpu",
127+
manufacturer: str | None = None,
128+
limit: int | None = None,
129+
min_year: int | None = None,
130+
max_year: int | None = None,
131+
overrides: dict[str, str] | None = None,
132+
sleep: float = 1.0,
133+
dry_run: bool = False,
134+
) -> EnrichResult:
135+
overrides = overrides or {}
136+
result = EnrichResult()
137+
client = make_client()
138+
processed = 0
139+
try:
140+
for path in _candidates(data_root / component, manufacturer):
141+
rec = json.loads(path.read_text(encoding="utf-8"))
142+
if primary_field is not None and rec.get(primary_field) is not None:
143+
result.already += 1
144+
continue
145+
year = (rec.get("release_date") or "0")[:4]
146+
if min_year is not None and year < str(min_year):
147+
continue
148+
if max_year is not None and year > str(max_year):
149+
continue
150+
if limit is not None and processed >= limit:
151+
break
152+
processed += 1
153+
name = rec.get("name", "")
154+
out = resolver(client, name, overrides.get(name))
155+
if sleep:
156+
time.sleep(sleep)
157+
if out is None:
158+
result.unresolved.append(name)
159+
continue
160+
scores, source_url = out
161+
changed = {k: v for k, v in scores.items() if rec.get(k) is None}
162+
if not changed:
163+
result.already += 1
164+
continue
165+
rec.update(changed)
166+
urls = rec.setdefault("source_urls", [])
167+
if source_url not in urls:
168+
urls.append(source_url)
169+
if not dry_run:
170+
path.write_text(
171+
json.dumps(rec, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
172+
)
173+
result.filled.append((rec.get("slug", path.stem), changed))
174+
finally:
175+
if client is not None:
176+
client.close()
177+
return result
178+
179+
180+
def main(argv: list[str] | None = None) -> int:
181+
parser = argparse.ArgumentParser(prog="app.ingest.enrich")
182+
parser.add_argument("--source", choices=sorted(SOURCES), default="passmark")
183+
parser.add_argument("--data-root", type=Path, default=_default_data_root())
184+
parser.add_argument(
185+
"--component", default="cpu", help="Component dir under data-root (cpu, gpu)."
186+
)
187+
parser.add_argument(
188+
"--manufacturer", default=None, help="Limit to data/<component>/<manufacturer>/."
189+
)
190+
parser.add_argument("--limit", type=int, default=None, help="Max records to query this run.")
191+
parser.add_argument("--min-year", type=int, default=None, help="Skip records before this year.")
192+
parser.add_argument("--max-year", type=int, default=None, help="Skip records after this year.")
193+
parser.add_argument(
194+
"--overrides", type=Path, default=None, help="JSON map {name: passmark_id}."
195+
)
196+
parser.add_argument("--sleep", type=float, default=1.0, help="Seconds between requests.")
197+
parser.add_argument("--summary", type=Path, default=Path("enrich-summary.md"))
198+
parser.add_argument("--dry-run", action="store_true")
199+
args = parser.parse_args(argv)
200+
201+
overrides: dict[str, str] = {}
202+
if args.overrides and args.overrides.exists():
203+
overrides = json.loads(args.overrides.read_text(encoding="utf-8"))
204+
205+
resolver, primary_field = SOURCES[args.source]
206+
result = enrich(
207+
data_root=args.data_root,
208+
resolver=resolver,
209+
primary_field=primary_field,
210+
component=args.component,
211+
manufacturer=args.manufacturer,
212+
limit=args.limit,
213+
min_year=args.min_year,
214+
max_year=args.max_year,
215+
overrides=overrides,
216+
sleep=args.sleep,
217+
dry_run=args.dry_run,
218+
)
219+
args.summary.write_text(result.markdown_summary(args.source), encoding="utf-8")
220+
print(
221+
f"source={args.source} filled={len(result.filled)} "
222+
f"unresolved={len(result.unresolved)} already={result.already} dry_run={args.dry_run}"
223+
)
224+
return 0
225+
226+
227+
if __name__ == "__main__":
228+
sys.exit(main(sys.argv[1:]))

0 commit comments

Comments
 (0)