|
| 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