Skip to content

Commit 1b795d1

Browse files
committed
feat(ci): weekly TechAPI refresh pipeline
Add .github/workflows/weekly-refresh.yml: a Monday cron (and manual dispatch) that live-scrapes every CPU/GPU benchmark source into a TechAPI checkout, gates the full dataset on app.validate plus a strict integrity_check, regenerates the static v1 dump and openapi.json into site/public, and opens a dated refresh/<date> PR via peter-evans/create-pull-request. The cross-repo PR step is guarded by secrets.TECHAPI_TOKEN; without it the job still collects, validates, dumps, and uploads artifacts. Add a --strict mode to integrity_check.py that exits non-zero on hard anomalies (duplicate slugs, slug/file mismatch, single>multi) while keeping statistical outliers advisory.
1 parent 9f8456c commit 1b795d1

2 files changed

Lines changed: 304 additions & 0 deletions

File tree

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
name: weekly-refresh
2+
3+
# Weekly automated data refresh:
4+
# 1. live-scrape benchmark sources into a TechAPI checkout
5+
# 2. gate on FULL-dataset integrity (schema + cross-source anomalies)
6+
# 3. regenerate the static v1 dump + openapi.json
7+
# 4. open a dated refresh PR against the public TechAPI repo
8+
#
9+
# TechEngine owns collection/validation/dump; TechAPI owns data/site/deploy.
10+
#
11+
# Token model: TechAPI is public, so the checkout uses the default GITHUB_TOKEN
12+
# (read-only) as a fallback — that lets the collect→validate→dump path run on
13+
# every push even when no PAT is configured. Only the cross-repo PR needs write
14+
# access, so just that step is guarded by `secrets.TECHAPI_TOKEN`. Add the PAT
15+
# (TechAPI Contents:write + Pull requests:write) as TECHAPI_TOKEN to enable PRs.
16+
on:
17+
schedule:
18+
- cron: "0 6 * * 1" # Mondays 06:00 UTC
19+
workflow_dispatch:
20+
inputs:
21+
sleep:
22+
description: "Seconds between scrape requests (politeness)"
23+
type: string
24+
default: "1.0"
25+
26+
permissions:
27+
contents: read
28+
29+
concurrency:
30+
group: weekly-refresh
31+
cancel-in-progress: false
32+
33+
jobs:
34+
refresh:
35+
runs-on: ubuntu-latest
36+
env:
37+
SLEEP: ${{ inputs.sleep || '1.0' }}
38+
TECHAPI_TOKEN: ${{ secrets.TECHAPI_TOKEN }}
39+
# Validate/seed/dump all read the data tree from this env var.
40+
TECHAPI_DATA_DIR: ${{ github.workspace }}/techapi/data
41+
steps:
42+
- name: Checkout TechEngine
43+
uses: actions/checkout@v4
44+
45+
# Read-only with the default token when no PAT is set; the PAT (when
46+
# present) lets peter-evans push the refresh branch back later.
47+
- name: Checkout TechAPI
48+
uses: actions/checkout@v4
49+
with:
50+
repository: Seungpyo1007/TechAPI
51+
path: techapi
52+
token: ${{ secrets.TECHAPI_TOKEN || secrets.GITHUB_TOKEN }}
53+
54+
- uses: actions/setup-python@v5
55+
with:
56+
python-version: "3.12"
57+
cache: pip
58+
59+
- name: Install TechEngine
60+
run: pip install -e .
61+
62+
- name: Compute refresh date
63+
id: meta
64+
run: echo "date=$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT"
65+
66+
# --- 1. Live collection (per-source; a flaky scrape must not sink the run) ---
67+
- name: Enrich benchmarks (all sources)
68+
run: |
69+
set -uo pipefail
70+
run_enrich() {
71+
comp="$1"; src="$2"
72+
echo "::group::enrich ${comp}/${src}"
73+
if python -m app.ingest.enrich \
74+
--source "$src" --component "$comp" \
75+
--data-root ./techapi/data --sleep "$SLEEP" \
76+
--summary "enrich-${comp}-${src}.md"; then
77+
:
78+
else
79+
echo "::warning::enrich source '${src}' (${comp}) failed; skipping"
80+
fi
81+
echo "::endgroup::"
82+
}
83+
for s in passmark cinebench-legacy cinebench-r23 cinebench-2024 \
84+
cinebench-nbc geekbench-nbc spec-cpu2006 topcpu-cpu; do
85+
run_enrich cpu "$s"
86+
done
87+
for s in blender timespy passmark-gpu topcpu-gpu; do
88+
run_enrich gpu "$s"
89+
done
90+
91+
# --- 2. Integrity gate over the WHOLE dataset (new + existing) ---
92+
# Either failure stops the job before the dump/PR, so contaminated data
93+
# can never reach a refresh PR.
94+
- name: Validate (schema / range / slug / FK)
95+
run: python -m app.validate
96+
97+
- name: Integrity check (cross-source anomalies, strict gate)
98+
run: python integrity_check.py ./techapi/data --strict
99+
100+
# --- 3. Static dump → site/public (what the Astro site fetches at runtime) ---
101+
- name: Generate static dump
102+
run: python -m app.dump --output ./techapi/site/public
103+
104+
# --- PR body: per-source enrich summaries + gate result ---
105+
- name: Build PR body
106+
run: |
107+
{
108+
echo "# Weekly data refresh — ${{ steps.meta.outputs.date }}"
109+
echo
110+
echo "Automated live re-scrape + full-dataset integrity gate + static dump."
111+
echo
112+
echo "## Validation"
113+
echo "- \`app.validate\` (schema/range/slug/FK): **passed**"
114+
echo "- \`integrity_check.py --strict\` (cross-source anomaly gate): **passed**"
115+
echo
116+
echo "## Enrichment summaries"
117+
for f in enrich-*.md; do
118+
[ -f "$f" ] || continue
119+
echo
120+
echo "<details><summary>$f</summary>"
121+
echo
122+
cat "$f"
123+
echo
124+
echo "</details>"
125+
done
126+
} > pr-body.md
127+
128+
- name: Upload run artifacts
129+
if: always()
130+
uses: actions/upload-artifact@v4
131+
with:
132+
name: refresh-${{ steps.meta.outputs.date }}
133+
path: |
134+
enrich-*.md
135+
pr-body.md
136+
if-no-files-found: ignore
137+
138+
# Fallback when no PAT: keep the regenerated dump so the work isn't lost.
139+
- name: Upload dump artifact (no-token fallback)
140+
if: env.TECHAPI_TOKEN == ''
141+
uses: actions/upload-artifact@v4
142+
with:
143+
name: dump-${{ steps.meta.outputs.date }}
144+
path: |
145+
techapi/site/public/v1
146+
techapi/site/public/openapi.json
147+
if-no-files-found: ignore
148+
149+
# --- 4. Dated branch + auto PR against TechAPI (only with a PAT) ---
150+
- name: Create refresh PR
151+
if: env.TECHAPI_TOKEN != ''
152+
uses: peter-evans/create-pull-request@v6
153+
with:
154+
path: ./techapi
155+
token: ${{ secrets.TECHAPI_TOKEN }}
156+
branch: refresh/${{ steps.meta.outputs.date }}
157+
base: main
158+
add-paths: |
159+
data
160+
site/public/v1
161+
site/public/openapi.json
162+
commit-message: "chore(data): weekly refresh ${{ steps.meta.outputs.date }}"
163+
title: "chore(data): weekly refresh ${{ steps.meta.outputs.date }}"
164+
body-file: pr-body.md
165+
committer: techengine-bot <techengine-bot@users.noreply.github.com>
166+
author: techengine-bot <techengine-bot@users.noreply.github.com>
167+
delete-branch: true

integrity_check.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""One-off data-integrity scan for TechAPI CPU+GPU (structural + benchmark anomaly).
2+
3+
Complements app/validate.py (schema) with: duplicate detection, slug/file match,
4+
verified-without-source, name/tier vs core-count consistency, single>multi sanity,
5+
era-vs-score outliers, and CROSS-SOURCE correlation outliers (the key wrong-variant
6+
contamination detector). Read-only; prints flagged items for human review.
7+
8+
Usage::
9+
10+
python integrity_check.py [DATA_ROOT] [--strict]
11+
12+
By default it prints every flagged item and exits 0 (human-review mode). With
13+
``--strict`` it additionally exits non-zero when any *hard* anomaly is found —
14+
unambiguous corruption that must block the weekly refresh PR: duplicate slugs,
15+
slug/filename mismatches, and physically-impossible single>multi benchmarks.
16+
The statistical cross-source/era outliers stay advisory (a heterogeneous catalog
17+
of server + desktop + mobile parts legitimately produces many ratio outliers), so
18+
they are printed for review but never fail the gate.
19+
"""
20+
from __future__ import annotations
21+
import os, json, math, re, statistics, sys
22+
23+
# Em-dash etc. in section headers must not crash on legacy consoles (e.g. cp949).
24+
try:
25+
sys.stdout.reconfigure(encoding="utf-8") # type: ignore[union-attr]
26+
except Exception:
27+
pass
28+
29+
_argv = sys.argv[1:]
30+
STRICT = "--strict" in _argv
31+
_positional = [a for a in _argv if not a.startswith("-")]
32+
ROOT = _positional[0] if _positional else r"C:\Users\29\Desktop\TechAPI\data"
33+
34+
# Hard anomalies block the weekly gate under --strict; soft ones are review-only.
35+
HARD: list[str] = []
36+
def hard(msg: str) -> None:
37+
HARD.append(msg)
38+
print(msg)
39+
40+
def load(comp):
41+
recs = []
42+
for dp, _, fs in os.walk(os.path.join(ROOT, comp)):
43+
for fn in fs:
44+
if fn.endswith(".json") and not fn.startswith("_"):
45+
p = os.path.join(dp, fn)
46+
recs.append((p, fn[:-5], json.load(open(p, encoding="utf-8"))))
47+
return recs
48+
49+
def mad_outliers(pairs, lo=0.34, hi=3.0):
50+
"""pairs: list of (label, a, b); flag log(a/b) outliers via median±3*MAD."""
51+
rs = [(l, math.log(a / b)) for l, a, b in pairs if a and b]
52+
if len(rs) < 8:
53+
return []
54+
med = statistics.median(r for _, r in rs)
55+
mad = statistics.median(abs(r - med) for _, r in rs) or 1e-9
56+
return [(l, round(math.exp(r), 2)) for l, r in rs if abs(r - med) > 4 * mad]
57+
58+
def section(t): print(f"\n### {t}")
59+
60+
cpus = load("cpu"); gpus = load("gpu")
61+
print(f"loaded CPU={len(cpus)} GPU={len(gpus)}")
62+
63+
# --- 1. duplicates + slug/file + verified-no-source ---
64+
section("structural")
65+
for comp, recs in (("cpu", cpus), ("gpu", gpus)):
66+
slugs, names = {}, {}
67+
for p, fn, d in recs:
68+
slugs.setdefault(d.get("slug"), []).append(fn)
69+
names.setdefault(d.get("name"), []).append(fn)
70+
if d.get("slug") != fn:
71+
hard(f" [{comp}] slug!=file: {fn} slug={d.get('slug')}")
72+
for s, fl in slugs.items():
73+
if len(fl) > 1: hard(f" [{comp}] DUP slug {s}: {fl}")
74+
for n, fl in names.items():
75+
if len(fl) > 1: hard(f" [{comp}] DUP name {n!r}: {fl}")
76+
77+
# --- 2. AMD Ryzen line vs DESKTOP model tier-digit (2nd digit); APU/mobile excepted ---
78+
section("CPU name/tier consistency (desktop mainstream only)")
79+
TIERMAP = {"6": "5", "7": "7", "8": "7", "9": "9"} # 2nd model digit -> expected line
80+
for p, fn, d in cpus:
81+
n = d.get("name", "")
82+
# mainstream desktop: 4-digit model, no G/U/H/HS/HX (APU/mobile) suffix
83+
m = re.match(r"AMD Ryzen (\d) (\d)(\d)\d\d(X3D|X|XT)?$", n)
84+
if m:
85+
line, _gen, tier = m.group(1), m.group(2), m.group(3)
86+
exp = TIERMAP.get(tier)
87+
if exp and exp != line:
88+
print(f" [tier] {n!r}: line Ryzen {line} but tier-digit {tier} → expect Ryzen {exp}")
89+
90+
# --- 3. benchmark sanity: single>multi (consistent-scale benches) ---
91+
section("CPU single>multi (cinebench/geekbench — should be multi>=single)")
92+
for p, fn, d in cpus:
93+
for s, mu in [("cinebench_r23_single","cinebench_r23_multi"),
94+
("geekbench_single","geekbench_multi"),
95+
("cinebench_2024_single","cinebench_2024_multi")]:
96+
a, b = d.get(s), d.get(mu)
97+
if a and b and a > b and (d.get("threads") or 1) > 1:
98+
hard(f" {d['name']!r}: {s}={a} > {mu}={b}")
99+
100+
# --- 4. era vs score (catch wrong-variant: old chip w/ modern score) ---
101+
section("CPU era-vs-score outliers")
102+
for p, fn, d in cpus:
103+
y = (d.get("release_date") or "0")[:4]
104+
pm = d.get("passmark_cpu_mark"); r23 = d.get("cinebench_r23_multi")
105+
if y < "2006" and pm and pm > 1500:
106+
print(f" {d['name']!r} ({y}): passmark {pm} too high for era")
107+
if y < "2011" and r23 and r23 > 3000:
108+
print(f" {d['name']!r} ({y}): r23 {r23} too high for era")
109+
110+
# --- 5. cross-source correlation outliers (KEY contamination detector) ---
111+
section("CPU cross-source ratio outliers (possible wrong-variant)")
112+
def collect(recs, fa, fb):
113+
return [(d["name"], d[fa], d[fb]) for p, fn, d in recs if d.get(fa) and d.get(fb)]
114+
for fa, fb in [("passmark_cpu_mark","cinebench_r23_multi"),
115+
("passmark_cpu_mark","geekbench_multi"),
116+
("cinebench_r23_multi","geekbench_multi"),
117+
("cinebench_2024_multi","cinebench_r23_multi")]:
118+
out = mad_outliers(collect(cpus, fa, fb))
119+
for label, ratio in out:
120+
print(f" [{fa}/{fb}] {label!r}: ratio={ratio}")
121+
122+
# --- 6. GPU cross-source + sanity ---
123+
section("GPU cross-source ratio outliers + sanity")
124+
for fa, fb in [("passmark_g3d_mark","timespy_score"),
125+
("timespy_score","blender_score"),
126+
("fp32_tflops","timespy_score"),
127+
("passmark_g3d_mark","fp32_tflops")]:
128+
for label, ratio in mad_outliers(collect(gpus, fa, fb)):
129+
print(f" [{fa}/{fb}] {label!r}: ratio={ratio}")
130+
131+
print("\n(no lines under a section = clean)")
132+
133+
if STRICT and HARD:
134+
print(f"\n❌ integrity gate: {len(HARD)} hard anomaly(ies) — blocking refresh.")
135+
sys.exit(1)
136+
if STRICT:
137+
print("\n✅ integrity gate: no hard anomalies.")

0 commit comments

Comments
 (0)