Skip to content

Commit f0410da

Browse files
committed
fix(ingest): skip candidates that may be duplicates or carry guessed values
A dry run at bc86016 on TechAPI develop's data wrote 15 GPUs, at least 9 of them already curated under another name, and 52 CPUs with wrong values. Ingest now skips rather than guesses, and the summary says why for every skip. Dedup reads every manufacturer folder of the category under run()'s data_root instead of get_data_root() and the candidate's own folder. GPUs also match across spelling (Ti500 / Ti 500), bus, memory size, revision, Mobile/laptop, "Generation" and "(Workstation) Edition" names and the retired Quadro brand (new gpu_identity.py), and a name that extends a curated board with the same clock and TDP counts as that board. An existing path is never written over. CPU tables: hybrid parts take P + E + LP-E cores and threads, checked against the Total column; a mismatch is a problem, not a value. Arc Graphics columns are no longer read as CPU cores, and TDP/Power columns as clocks. A section heading becomes the architecture only when it names one codename or Zen generation; "Mobile", "Desktop" and generation headings leave it empty. Release dates: parse_date_period gives the end of the stated period. A part the source dates after the run date ("Q4 2026", "Q1 2027") is skipped, and so is any candidate with problems, even with --include-drafts.
1 parent bc86016 commit f0410da

13 files changed

Lines changed: 703 additions & 64 deletions

‎app/ingest/gpu_identity.py‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Conservative GPU identity checks for additions-only ingest.
2+
3+
One board is often listed under several names: ``GeForce3 Ti500`` and
4+
``GeForce3 Ti 500``, ``RTX 6000 Ada Generation`` and ``RTX 6000 Ada``, a laptop
5+
part with and without ``Mobile``, one card per memory size. A candidate that
6+
matches a curated board under any of those spellings is skipped, never added
7+
as a second record: a missed addition is cheap, a duplicate is not.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import re
13+
14+
# Words that name a variant of a board rather than a different board.
15+
_QUALIFIERS = frozenset({
16+
"agp", "pci", "pcie", # bus
17+
"generation", "edition", # "Ada Generation", "Workstation Edition"
18+
"mobile", "laptop", "notebook", # laptop parts; Max-Q is its own power tier
19+
"engineering", "sample",
20+
})
21+
_MEMORY_RE = re.compile(r"\d+(?:gb|mb)")
22+
# Turing and later workstation boards dropped the Quadro brand ("Quadro T500" is "T500").
23+
_QUADRO_RENAMED_RE = re.compile(r"(?:t|a|rtx)\d*")
24+
25+
26+
def _tokens(slug: str) -> list[str]:
27+
tokens = [token for token in slug.lower().split("-") if token]
28+
joined: list[str] = []
29+
for token in tokens:
30+
# Rejoin pairs the slugifier splits: "max-q", "e-s" (engineering sample),
31+
# "pci-e", "rev-2", "3-gb".
32+
if joined and (joined[-1], token) == ("e", "s"):
33+
joined[-1] = "engineering"
34+
continue
35+
if joined and (
36+
(joined[-1], token) in {("max", "q"), ("pci", "e")}
37+
or (joined[-1] == "rev" and token.isdigit())
38+
or (joined[-1].isdigit() and token in {"gb", "mb"})
39+
):
40+
joined[-1] += token
41+
continue
42+
joined.append(token)
43+
return joined
44+
45+
46+
def gpu_key(slug: str) -> str:
47+
"""Spelling-insensitive identity: ``geforce3-ti500`` == ``geforce3-ti-500``."""
48+
return "".join(_tokens(slug))
49+
50+
51+
def gpu_family_key(slug: str) -> str:
52+
"""Identity with variant words dropped: bus, memory size, revision, laptop
53+
markers, "Generation"/"(Workstation) Edition" and the retired Quadro brand."""
54+
tokens = _tokens(slug)
55+
if len(tokens) > 1 and tokens[0] == "quadro" and _QUADRO_RENAMED_RE.fullmatch(tokens[1]):
56+
tokens = tokens[1:]
57+
kept = [
58+
token
59+
for i, token in enumerate(tokens)
60+
if token not in _QUALIFIERS
61+
and not _MEMORY_RE.fullmatch(token)
62+
and not re.fullmatch(r"rev\d*", token)
63+
# "RTX PRO 6000 Blackwell Workstation (Edition)" is the plain board.
64+
and not (token == "workstation" and tokens[i + 1 : i + 2] in ([], ["edition"]))
65+
]
66+
return "".join(kept)
67+
68+
69+
def spec_twins(candidate: dict[str, object], existing: dict[str, object]) -> bool:
70+
"""Two names where one extends the other (``HD 2350`` / ``HD 2350 PRO``) and
71+
the boards share core clock and TDP: likely the same board, so not a new one."""
72+
left = gpu_key(str(candidate.get("slug", "")))
73+
right = gpu_key(str(existing.get("slug", "")))
74+
short, long = sorted((left, right), key=len)
75+
if len(short) < 4 or short == long or not long.startswith(short):
76+
return False
77+
if not long[len(short)].isalpha():
78+
return False # "hd235" vs "hd2350" is a different number, not a suffix
79+
clocks = candidate.get("base_clock_mhz"), existing.get("base_clock_mhz")
80+
tdps = candidate.get("tdp_w"), existing.get("tdp_w")
81+
return None not in clocks and None not in tdps and clocks[0] == clocks[1] and tdps[0] == tdps[1]

‎app/ingest/normalize.py‎

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -138,34 +138,58 @@ def parse_cores_threads(text: str) -> tuple[int | None, int | None]:
138138

139139
def parse_date(text: str) -> date | None:
140140
"""Best-effort calendar date. ``"Q3 2023"`` → ``2023-07-01``; year-only → Jan 1."""
141+
period = parse_date_period(text)
142+
return period[0] if period else None
143+
144+
145+
def parse_date_period(text: str) -> tuple[date, date] | None:
146+
"""``(first, last)`` day of the period a date cell names.
147+
148+
A full date is a one-day period. ``"Q4 2026"`` is Oct 1 – Dec 31, ``"Dec 2024"``
149+
the whole month and a bare year the whole year, so a caller can tell whether
150+
the period had ended (the part had shipped) by a given day.
151+
"""
141152
if not text:
142153
return None
143154
stripped = text.strip()
144155

145156
if (match := _ISO_DATE_RE.match(stripped)):
146157
year, month, day = (int(g) for g in match.groups())
147-
return _safe_date(year, month, day)
158+
return _day(_safe_date(year, month, day))
148159
if (match := _NUMERIC_DATE_RE.match(stripped)):
149160
year, month, day = (int(g) for g in match.groups())
150-
return _safe_date(year, month, day)
161+
return _day(_safe_date(year, month, day))
151162
if (match := _LONG_DATE_RE.search(stripped)):
152163
month_name, day_str, year_str = match.group(1), match.group(2), match.group(3)
153-
return _safe_date(int(year_str), _MONTHS[month_name.lower()[:3]], int(day_str))
164+
return _day(_safe_date(int(year_str), _MONTHS[month_name.lower()[:3]], int(day_str)))
154165
if (match := _SHORT_DATE_RE.search(stripped)):
155166
day_str, month_name, year_str = match.group(1), match.group(2), match.group(3)
156-
return _safe_date(int(year_str), _MONTHS[month_name.lower()[:3]], int(day_str))
167+
return _day(_safe_date(int(year_str), _MONTHS[month_name.lower()[:3]], int(day_str)))
157168
if (match := _QUARTER_RE.search(stripped)):
158169
quarter = int(match.group(1))
159170
year_raw = match.group(2)
160171
year = 2000 + int(year_raw) if len(year_raw) == 2 else int(year_raw)
161-
return _safe_date(year, (quarter - 1) * 3 + 1, 1)
172+
return _months(year, (quarter - 1) * 3 + 1, 3)
162173
if (match := _MONTH_YEAR_RE.search(stripped)):
163-
return _safe_date(int(match.group(2)), _MONTHS[match.group(1).lower()[:3]], 1)
174+
return _months(int(match.group(2)), _MONTHS[match.group(1).lower()[:3]], 1)
164175
if (match := _YEAR_ONLY_RE.search(stripped)):
165-
return _safe_date(int(match.group(1)), 1, 1)
176+
return _months(int(match.group(1)), 1, 12)
166177
return None
167178

168179

180+
def _day(day: date | None) -> tuple[date, date] | None:
181+
return (day, day) if day else None
182+
183+
184+
def _months(year: int, first_month: int, count: int) -> tuple[date, date] | None:
185+
first = _safe_date(year, first_month, 1)
186+
if first is None:
187+
return None
188+
last_month = first_month + count - 1
189+
after = date(year + 1, 1, 1) if last_month == 12 else date(year, last_month + 1, 1)
190+
return first, date.fromordinal(after.toordinal() - 1)
191+
192+
169193
def _safe_date(year: int, month: int, day: int) -> date | None:
170194
try:
171195
return date(year, month, day)

0 commit comments

Comments
 (0)