Skip to content

Commit 28af5e0

Browse files
authored
Merge pull request #8 from GetTechAPI/develop
chore(release): offline ES/QS/retail classifier
2 parents 402537c + 41ebbc6 commit 28af5e0

5 files changed

Lines changed: 429 additions & 0 deletions

File tree

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,18 @@ python -m pytest -q
3636

3737
The validator uses the Python standard library. QS, retail, and production `sample_class` values fail the build.
3838

39+
## Collection
40+
41+
Intake is **classify-then-write**. A candidate is `es`, `qs`, `retail`, or `unknown`. Only `es` may become a JSON file. QS and retail stay out; `unknown` is for human review.
42+
43+
```bash
44+
python -m app.ingest path/to/candidate.json
45+
```
46+
47+
No network. Identifier rules: Intel S-spec → retail, Q-spec + early stepping / low clocks / “Intel Confidential” → es, Q-spec labeled QS or mature stepping with retail clocks → qs. AMD `100-00000…` / Eng Sample → es, historical `Z…` OPN → qs.
48+
49+
Crawlers come later. This classifier is the gate.
50+
3951
## Branching (git-flow)
4052

4153
| Branch | Role |

app/ingest/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""ES intake: classify public identifiers before any JSON is written."""
2+
3+
from app.ingest.classify import Classification, classify
4+
5+
__all__ = ["Classification", "classify"]

app/ingest/__main__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""``python -m app.ingest`` classifies JSON on stdin or a file path."""
2+
3+
from __future__ import annotations
4+
5+
import sys
6+
7+
from app.ingest.classify import main
8+
9+
if __name__ == "__main__":
10+
sys.exit(main())

app/ingest/classify.py

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
"""Offline ES / QS / retail classifier.
2+
3+
No network. A candidate must be ``es`` before the ingest pipeline may write JSON.
4+
QS and retail belong in TechAPI or nowhere; ``unknown`` goes to a review issue.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import argparse
10+
import json
11+
import re
12+
import sys
13+
from dataclasses import asdict, dataclass
14+
from pathlib import Path
15+
from typing import Any
16+
17+
CLASS_ES = "es"
18+
CLASS_QS = "qs"
19+
CLASS_RETAIL = "retail"
20+
CLASS_UNKNOWN = "unknown"
21+
22+
# Intel production S-spec: 5 letters starting with S (e.g. SRKNY).
23+
_SSPEC_RE = re.compile(r"^S[A-Z0-9]{4}$")
24+
# Intel sample QDF / Q-spec: 4–6 letters starting with Q (e.g. QXLB, QDF4).
25+
_QSPEC_RE = re.compile(r"^Q[A-Z0-9]{3,5}$")
26+
# Zen 4+ engineering OPN, e.g. 100-000000665-21_N
27+
_AMD_MODERN_OPN_RE = re.compile(
28+
r"^100-0+\d+(?:-\d+)?(?:_[A-Z0-9]+)?$", re.IGNORECASE
29+
)
30+
31+
_ES_STEPPINGS = {"A0", "B0", "G0"}
32+
_QS_STEPPINGS = {"C0", "H0"}
33+
34+
_QS_PHRASES = (
35+
"qualification sample",
36+
"qualification-sample",
37+
" qs ",
38+
"(qs)",
39+
"[qs]",
40+
)
41+
_ES_PHRASES = (
42+
"engineering sample",
43+
"eng sample",
44+
"eng. sample",
45+
"intel confidential",
46+
)
47+
_RETAIL_PHRASES = ("retail", "production sku", "production part")
48+
49+
50+
@dataclass(frozen=True)
51+
class Classification:
52+
"""Result of classifying one candidate. ``sample_class`` is never empty."""
53+
54+
sample_class: str
55+
reasons: tuple[str, ...]
56+
sample_revision: str | None = None
57+
58+
def to_dict(self) -> dict[str, Any]:
59+
return asdict(self)
60+
61+
62+
def _norm(value: object) -> str:
63+
if not isinstance(value, str):
64+
return ""
65+
return value.strip()
66+
67+
68+
def _upper(value: object) -> str:
69+
return _norm(value).upper()
70+
71+
72+
def _blob(candidate: dict[str, Any]) -> str:
73+
parts: list[str] = []
74+
for key in ("name", "slug", "notes", "sample_class"):
75+
parts.append(_norm(candidate.get(key)))
76+
markings = candidate.get("markings")
77+
if isinstance(markings, list):
78+
parts.extend(_norm(m) for m in markings)
79+
labels = candidate.get("source_labels")
80+
if isinstance(labels, list):
81+
parts.extend(_norm(label) for label in labels)
82+
return " ".join(parts).lower()
83+
84+
85+
def _has_phrase(blob: str, phrases: tuple[str, ...]) -> str | None:
86+
padded = f" {blob} "
87+
for phrase in phrases:
88+
if phrase in padded or phrase.strip() in blob:
89+
return phrase.strip()
90+
return None
91+
92+
93+
def _intel_qspec(candidate: dict[str, Any]) -> str:
94+
qspec = _upper(candidate.get("qspec"))
95+
if _QSPEC_RE.match(qspec):
96+
return qspec
97+
return ""
98+
99+
100+
def _intel_sspec(candidate: dict[str, Any]) -> str:
101+
for key in ("sspec", "s_spec"):
102+
sspec = _upper(candidate.get(key))
103+
if _SSPEC_RE.match(sspec):
104+
return sspec
105+
return ""
106+
107+
108+
def _amd_opn(candidate: dict[str, Any]) -> str:
109+
return _norm(candidate.get("opn")).replace(" ", "")
110+
111+
112+
def _clock_far_below_retail(candidate: dict[str, Any]) -> bool:
113+
base = candidate.get("base_clock_ghz")
114+
retail = candidate.get("retail_base_clock_ghz")
115+
if not isinstance(base, (int, float)) or isinstance(base, bool):
116+
return False
117+
if isinstance(retail, (int, float)) and not isinstance(retail, bool):
118+
return base <= retail * 0.6
119+
# Early Intel ES desktop parts often ship a ~1.x GHz fuse default.
120+
return base <= 1.5
121+
122+
123+
def _clocks_match_retail(candidate: dict[str, Any]) -> bool:
124+
base = candidate.get("base_clock_ghz")
125+
retail = candidate.get("retail_base_clock_ghz")
126+
if not isinstance(base, (int, float)) or not isinstance(retail, (int, float)):
127+
return False
128+
if isinstance(base, bool) or isinstance(retail, bool):
129+
return False
130+
return abs(base - retail) <= 0.15
131+
132+
133+
def _classify_intel(candidate: dict[str, Any], blob: str) -> Classification:
134+
sspec = _intel_sspec(candidate)
135+
if sspec:
136+
return Classification(
137+
CLASS_RETAIL, (f"Intel S-spec {sspec} is a production part",)
138+
)
139+
140+
qspec = _intel_qspec(candidate)
141+
qs_phrase = _has_phrase(blob, _QS_PHRASES)
142+
es_phrase = _has_phrase(blob, _ES_PHRASES)
143+
stepping = _upper(candidate.get("stepping"))
144+
145+
if not qspec:
146+
if _has_phrase(blob, _RETAIL_PHRASES) or (
147+
"core i" in blob and "sample" not in blob
148+
):
149+
return Classification(
150+
CLASS_RETAIL, ("Intel retail model name with no Q-spec",)
151+
)
152+
return Classification(CLASS_UNKNOWN, ("Intel candidate has no Q-spec or S-spec",))
153+
154+
reasons: list[str] = [f"Intel Q-spec {qspec}"]
155+
es_hits = 0
156+
qs_hits = 0
157+
158+
if stepping in _ES_STEPPINGS:
159+
es_hits += 1
160+
reasons.append(f"early stepping {stepping}")
161+
if stepping in _QS_STEPPINGS:
162+
qs_hits += 1
163+
reasons.append(f"mature stepping {stepping}")
164+
if _clock_far_below_retail(candidate):
165+
es_hits += 1
166+
reasons.append("base clock far below retail")
167+
if _clocks_match_retail(candidate):
168+
qs_hits += 1
169+
reasons.append("clocks match retail equivalent")
170+
if es_phrase:
171+
es_hits += 1
172+
reasons.append(f"source text {es_phrase!r}")
173+
if qs_phrase:
174+
qs_hits += 1
175+
reasons.append(f"source text {qs_phrase!r}")
176+
177+
if qs_hits > es_hits:
178+
return Classification(CLASS_QS, tuple(reasons))
179+
if es_hits > 0 and es_hits >= qs_hits:
180+
return Classification(CLASS_ES, tuple(reasons), sample_revision="es1")
181+
return Classification(
182+
CLASS_UNKNOWN,
183+
tuple(reasons + ["not enough ES/QS evidence"]),
184+
)
185+
186+
187+
def _classify_amd(candidate: dict[str, Any], blob: str) -> Classification:
188+
opn = _amd_opn(candidate)
189+
qs_phrase = _has_phrase(blob, _QS_PHRASES)
190+
es_phrase = _has_phrase(blob, _ES_PHRASES)
191+
192+
if qs_phrase:
193+
return Classification(
194+
CLASS_QS, (f"source text {qs_phrase!r}", f"opn={opn or 'n/a'}")
195+
)
196+
197+
if opn:
198+
compact = opn.replace("_", "-")
199+
if _AMD_MODERN_OPN_RE.match(opn):
200+
reasons = [f"AMD modern OPN {opn}"]
201+
if es_phrase:
202+
reasons.append(f"source text {es_phrase!r}")
203+
return Classification(CLASS_ES, tuple(reasons), sample_revision="es1")
204+
prefix = compact[:1].upper()
205+
if prefix == "Z":
206+
return Classification(CLASS_QS, (f"AMD OPN prefix Z ({opn})",))
207+
if prefix == "1":
208+
return Classification(
209+
CLASS_ES, (f"AMD historical ES1 OPN {opn}",), sample_revision="es1"
210+
)
211+
if prefix == "2":
212+
return Classification(
213+
CLASS_ES, (f"AMD historical ES2 OPN {opn}",), sample_revision="es2"
214+
)
215+
216+
if es_phrase:
217+
return Classification(
218+
CLASS_ES, (f"source text {es_phrase!r}",), sample_revision="es1"
219+
)
220+
if opn:
221+
return Classification(CLASS_UNKNOWN, (f"AMD OPN {opn} has no ES/QS evidence",))
222+
return Classification(CLASS_UNKNOWN, ("AMD candidate has no OPN",))
223+
224+
225+
def classify(candidate: dict[str, Any]) -> Classification:
226+
"""Return es / qs / retail / unknown for one candidate dict."""
227+
blob = _blob(candidate)
228+
manufacturer = _norm(candidate.get("manufacturer")).lower()
229+
230+
declared = _norm(candidate.get("sample_class")).lower()
231+
if declared in {CLASS_QS, "qualification"}:
232+
return Classification(CLASS_QS, ("declared sample_class is qs",))
233+
if declared in {CLASS_RETAIL, "production"}:
234+
return Classification(CLASS_RETAIL, ("declared sample_class is retail/production",))
235+
236+
if manufacturer == "intel":
237+
return _classify_intel(candidate, blob)
238+
if manufacturer == "amd":
239+
return _classify_amd(candidate, blob)
240+
241+
if _has_phrase(blob, _QS_PHRASES):
242+
return Classification(CLASS_QS, ("source text indicates QS",))
243+
if _has_phrase(blob, _ES_PHRASES):
244+
return Classification(CLASS_ES, ("source text indicates ES",), sample_revision="es1")
245+
return Classification(
246+
CLASS_UNKNOWN, (f"unsupported manufacturer '{manufacturer or 'missing'}'",)
247+
)
248+
249+
250+
def main(argv: list[str] | None = None) -> int:
251+
parser = argparse.ArgumentParser(
252+
description="Classify a CPU candidate as es, qs, retail, or unknown."
253+
)
254+
parser.add_argument(
255+
"path",
256+
nargs="?",
257+
help="JSON file (object or list of objects). Reads stdin if omitted.",
258+
)
259+
args = parser.parse_args(argv)
260+
261+
if args.path:
262+
raw = Path(args.path).read_text(encoding="utf-8-sig")
263+
else:
264+
raw = sys.stdin.read()
265+
payload: Any = json.loads(raw)
266+
items = payload if isinstance(payload, list) else [payload]
267+
results = [classify(item).to_dict() for item in items]
268+
json.dump(results if isinstance(payload, list) else results[0], sys.stdout, indent=2)
269+
sys.stdout.write("\n")
270+
return 0
271+
272+
273+
if __name__ == "__main__":
274+
sys.exit(main())

0 commit comments

Comments
 (0)