SPB Git

spb/ai-atlas

Public

canonicalize pass 2: identifier aliases (snapshots, previews, effort/instruct suffixes, vendor prefixes, re-versioned ids) are not conflicts → 15 org-prefixed duplicate pairs merged keeping the plain slug, hard conflicts persisted as keep_separate; same-publisher organisations (Alibaba/Qwen, Meta/Meta AI) no longer block merges; OpenRouter routers retyped as product[kind=router]; effort variants resolve through any alias/identifier/api_model_id, dated snapshots and the exact-identifier preference, unresolved → identity low; quant/precision token in an official repo → artifact of the same-org plain model (merged when evaluators know it as the model); quantized_from only when it names the same model; official thinking releases and digit-gated tier words (devstral-medium, mistral-medium) stay models

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 12, 2026) parent 993464a Browse files

4 changed files +220 −46

modified src/aiatlas/ontology/models.py +19 −4
@@ -65,7 +65,19 @@ _DEFAULT_THINK_RE = re.compile(r"[-_ ](default[-_ ]?(thinking|think))$", re.I)
65 _MAX_EFFORT_SUFFIXES = 3 # "claude-opus-4-5-20251101-thinking-64k-high-effort" → thinking-64k + high-effort 65 _MAX_EFFORT_SUFFIXES = 3 # "claude-opus-4-5-20251101-thinking-64k-high-effort" → thinking-64k + high-effort
66 # Words that are *part of a model name*, never an effort suffix, when they precede the suffix (e.g. "Kimi K2 Thinking" is a distinct release). 66 # Words that are *part of a model name*, never an effort suffix, when they precede the suffix (e.g. "Kimi K2 Thinking" is a distinct release).
67 OFFICIAL_THINKING_RELEASES = {"kimi-k2-thinking", "qwen3-235b-a22b-thinking-2507", "qwen3-30b-a3b-thinking-2507", "qwen3-4b-thinking-2507", "glm-4.5-air-thinking", 67 OFFICIAL_THINKING_RELEASES = {"kimi-k2-thinking", "qwen3-235b-a22b-thinking-2507", "qwen3-30b-a3b-thinking-2507", "qwen3-4b-thinking-2507", "glm-4.5-air-thinking",
68− "gemini-2.5-flash-thinking", "grok-3-mini-thinking", "gemini-2-0-flash-thinking-exp-1219", "gemini-2-0-flash-thinking-exp-01-21"} 68+ "gemini-2.5-flash-thinking", "grok-3-mini-thinking", "gemini-2-0-flash-thinking-exp-1219", "gemini-2-0-flash-thinking-exp-01-21",
69+ "qwen3-next-80b-a3b-thinking", "trinity-large-thinking", "trinity-mini-thinking", "kimi-k2-thinking-turbo", "devstral-medium",
70+ "mistral-medium", "codestral-medium", "magistral-medium", "mistral-small", "mistral-large"}
71+# families whose "Thinking" checkpoints are separate weights (own hub repo), never an evaluation setting
72+_OFFICIAL_THINKING_PATTERNS = [re.compile(p) for p in (r"^qwen3-vl-.*-thinking$", r"^qwen3(\.\d+)?-.*-thinking-\d{4}$", r"^qwen3-next-.*-thinking$",
73+ r"^glm-4\.[1-9]v?-.*thinking$", r"^deepseek-v3\.1-terminus-thinking$", r"^trinity-.*-thinking$")]
74+# tier words that are only an *effort* when the stem carries a version or size digit ("claude-opus-5-medium", "o3-mini-high", "gpt-5-4-mini-low")
75+# — never on a bare product name ("devstral-medium", "mistral-medium" are model tiers)
76+_DIGIT_GATED_SUFFIXES = {"medium", "low", "high", "minimal"}
77+
78+
79+def is_official_thinking_release(low: str) -> bool:
80+ return low in OFFICIAL_THINKING_RELEASES or any(p.match(low) for p in _OFFICIAL_THINKING_PATTERNS)
69 81
70 # ---------------------------------------------------------------------------------------------- name analysis 82 # ---------------------------------------------------------------------------------------------- name analysis
71 _SIZE_RE = re.compile(r"(?<![a-z0-9])(\d+(?:\.\d+)?)\s?([bmt])(?![a-z])", re.I) # 70B 3.8B 235B 1.5T 350M 83 _SIZE_RE = re.compile(r"(?<![a-z0-9])(\d+(?:\.\d+)?)\s?([bmt])(?![a-z])", re.I) # 70B 3.8B 235B 1.5T 350M
@@ -126,7 +138,7 @@ def analyze_model_name(raw: str) -> NameAnalysis:
126 # evaluator effort suffixes (up to three, e.g. "-thinking-64k-high-effort"), only when the stem is not itself an official "Thinking" release 138 # evaluator effort suffixes (up to three, e.g. "-thinking-64k-high-effort"), only when the stem is not itself an official "Thinking" release
127 stripped: list[str] = [] 139 stripped: list[str] = []
128 for _ in range(_MAX_EFFORT_SUFFIXES): 140 for _ in range(_MAX_EFFORT_SUFFIXES):
129− if low in OFFICIAL_THINKING_RELEASES: 141+ if is_official_thinking_release(low):
130 break 142 break
131 m = _BUDGET_RE.search(low) # "-32k-thinking" before the bare "-thinking" 143 m = _BUDGET_RE.search(low) # "-32k-thinking" before the bare "-thinking"
132 if m: 144 if m:
@@ -143,6 +155,8 @@ def analyze_model_name(raw: str) -> NameAnalysis:
143 m = _EFFORT_RE.search(low) 155 m = _EFFORT_RE.search(low)
144 if m: 156 if m:
145 suffix = m.group(1).lower() 157 suffix = m.group(1).lower()
158+ if suffix in _DIGIT_GATED_SUFFIXES and not re.search(r"\d", low[: m.start()]):
159+ break
146 a.effort = {**EFFORT_SUFFIXES[suffix], **a.effort} 160 a.effort = {**EFFORT_SUFFIXES[suffix], **a.effort}
147 stripped.insert(0, suffix) 161 stripped.insert(0, suffix)
148 low = low[: m.start()] 162 low = low[: m.start()]
@@ -356,5 +370,6 @@ def base_name(name: str) -> str:
356 return out.strip().rstrip("-_ (").strip() or n 370 return out.strip().rstrip("-_ (").strip() or n
357 371
358 372
359−__all__ = ["ARTIFACT_PACKAGING", "CONVERTER_ORGS", "EFFORT_SUFFIXES", "FAMILY_ORGS", "NameAnalysis", "PRECISION_FORMATS", "QUANT_FORMATS", "analyze_model_name", 373+__all__ = ["ARTIFACT_PACKAGING", "CONVERTER_ORGS", "EFFORT_SUFFIXES", "FAMILY_ORGS", "OFFICIAL_THINKING_RELEASES", "NameAnalysis", "PRECISION_FORMATS", "QUANT_FORMATS",
360− "base_name", "effort_config", "family_hint", "family_release_hint", "is_official_org", "official_orgs", "variant_key"] 374+ "analyze_model_name", "base_name", "effort_config", "family_hint", "family_release_hint", "is_official_org", "is_official_thinking_release", "official_orgs",
375+ "variant_key"]
modified src/aiatlas/sdk/resolution.py +25 −9
@@ -36,7 +36,7 @@ _COMPATIBLE: dict[str, tuple[str, ...]] = {"model": ("model", "artifact"), "arti
36 # names whose normalised alias is ambiguous: a digit, a separator, a digit ("Qwen3-8B" / "Qwen 38B" → "qwen38b") 36 # names whose normalised alias is ambiguous: a digit, a separator, a digit ("Qwen3-8B" / "Qwen 38B" → "qwen38b")
37 _DIGIT_SEP_DIGIT = re.compile(r"\d[.\-\s_]\d") 37 _DIGIT_SEP_DIGIT = re.compile(r"\d[.\-\s_]\d")
38 # identifier schemes issued by evaluators: an entity known ONLY through these may be an evaluation configuration rather than a model 38 # identifier schemes issued by evaluators: an entity known ONLY through these may be an evaluation configuration rather than a model
39−EVALUATOR_SCHEMES = frozenset({"artificial_analysis", "livebench_model_id", "aider_model", "lmarena"}) 39+EVALUATOR_SCHEMES = frozenset({"artificial_analysis", "livebench_model_id", "aider_model", "lmarena", "openrouter"}) # OpenRouter lists `o3-mini-high` as an endpoint of o3-mini
40 40
41 41
42 def compatible_types(entity_type: str) -> tuple[str, ...]: 42 def compatible_types(entity_type: str) -> tuple[str, ...]:
@@ -198,14 +198,21 @@ class Resolver:
198 if not a.is_effort_variant: 198 if not a.is_effort_variant:
199 return None 199 return None
200 base = base_name(ref.name) 200 base = base_name(ref.name)
201− base_norms = {normalize_alias(base), normalize_alias(a.base_key)} - {""} 201+ bases = {base, base.lower(), a.base_key, slugify(base)}
202− rows = await fetch_all(self.conn, """select distinct e.id, e.canonical_name, e.organization_id, e.merged_into from entities e 202+ # a dated snapshot in the base ("gpt-5.2-2025-12-11-high") also names the undated model
203+ undated = re.sub(r"[-_ ]?(20\d{2}[-_.]?\d{2}[-_.]?\d{2}|\d{4})$", "", base).strip("-_ ")
204+ if undated and undated != base and re.search(r"[a-z]", undated, re.I):
205+ bases |= {undated, undated.lower(), slugify(undated)}
206+ bases = sorted(bases)
207+ base_norms = sorted({normalize_alias(b) for b in bases} - {""})
208+ # the base may be known by any alias, any identifier value (AA slug, api model id, provider id…), its slug or its api_model_id
209+ rows = await fetch_all(self.conn, """select distinct coalesce(e.merged_into, e.id) as id, e.canonical_name, e.organization_id from entities e
203 left join entity_identifiers ei on ei.entity_id = e.id 210 left join entity_identifiers ei on ei.entity_id = e.id
204 left join entity_aliases al on al.entity_id = e.id 211 left join entity_aliases al on al.entity_id = e.id
205− where e.entity_type = 'model' and e.merged_into is null 212+ where e.entity_type = 'model'
206− and ((ei.scheme = 'artificial_analysis' and ei.value = any(cast(:bases as text[]))) 213+ and (ei.value = any(cast(:bases as text[])) or al.alias_norm = any(cast(:norms as text[]))
207− or al.alias_norm = any(cast(:norms as text[])) or e.slug = any(cast(:bases as text[])))""", 214+ or e.slug = any(cast(:bases as text[])) or lower(e.attributes->>'api_model_id') = any(cast(:bases as text[])))""",
208− bases=sorted({base.lower(), a.base_key}), norms=sorted(base_norms)) 215+ bases=bases, norms=base_norms)
209 candidates = {r["id"]: r for r in rows if r["id"] != ref.id} 216 candidates = {r["id"]: r for r in rows if r["id"] != ref.id}
210 if self.variant_index: 217 if self.variant_index:
211 vk = variant_key(base) 218 vk = variant_key(base)
@@ -214,10 +221,19 @@ class Resolver:
214 row = await fetch_one(self.conn, "select id, canonical_name, organization_id, merged_into from entities where id = :id and merged_into is null", id=cid) 221 row = await fetch_one(self.conn, "select id, canonical_name, organization_id, merged_into from entities where id = :id and merged_into is null", id=cid)
215 if row: 222 if row:
216 candidates[cid] = row 223 candidates[cid] = row
217− # never fold onto another effort variant 224+ # candidates must be live models (a merged row resolved to its survivor above) and never another effort variant
218− candidates = {k: v for k, v in candidates.items() if not analyze_model_name(v["canonical_name"]).is_effort_variant} 225+ live = {r["id"] for r in await fetch_all(self.conn, "select id from entities where id = any(cast(:ids as text[])) and merged_into is null and entity_type = 'model'",
226+ ids=sorted(candidates))} if candidates else set()
227+ candidates = {k: v for k, v in candidates.items() if k in live and not analyze_model_name(v["canonical_name"]).is_effort_variant}
219 if not candidates: 228 if not candidates:
220 return None 229 return None
230+ if len(candidates) > 1:
231+ # prefer the candidate an evaluator/vendor identifies by the exact base name, then the variant's organisation
232+ exact = await fetch_all(self.conn, "select distinct entity_id from entity_identifiers where value = any(cast(:b as text[])) and entity_id = any(cast(:ids as text[]))",
233+ b=bases, ids=sorted(candidates))
234+ exact_ids = {r["entity_id"] for r in exact}
235+ if len(exact_ids) == 1:
236+ candidates = {k: v for k, v in candidates.items() if k in exact_ids}
221 if len(candidates) > 1 and org_id: 237 if len(candidates) > 1 and org_id:
222 same = {k: v for k, v in candidates.items() if v["organization_id"] == org_id} 238 same = {k: v for k, v in candidates.items() if v["organization_id"] == org_id}
223 if same: 239 if same:
modified src/aiatlas/services/canonical.py +150 −31
@@ -14,6 +14,7 @@ from __future__ import annotations
14 14
15 import json 15 import json
16 import logging 16 import logging
17+import re
17 from collections import defaultdict 18 from collections import defaultdict
18 from dataclasses import dataclass, field 19 from dataclasses import dataclass, field
19 from datetime import UTC, datetime, timedelta 20 from datetime import UTC, datetime, timedelta
@@ -173,8 +174,62 @@ async def _identifiers(conn: AsyncConnection, ids: list[str]) -> dict[str, dict[
173 return out 174 return out
174 175
175 176
177+_SNAPSHOT_REMAINDER = re.compile(r"^(\d{1,8}|preview\d*|exp\d*|latest|beta|alpha|v\d+)$")
178+_EFFORT_REMAINDER = {"reasoning", "nonreasoning", "thinking", "nonthinking", "high", "low", "medium", "xhigh", "minimal", "instruct", "it", "chat"}
179+_VENDOR_PREFIXES = {"meta", "metallama", "google", "openai", "alibaba", "nvidia", "microsoft", "anthropic", "qwen", "deepseek", "mistral", "mistralai", "zai", "moonshotai"}
180+_VERSION_TOKEN = re.compile(r"^v?\d+(\.\d+)*$")
181+
182+
183+def _identifier_alias(a: str, b: str) -> bool:
184+ """Two values of one scheme name the same thing when one is a dated/preview snapshot, an effort/instruct suffix, a vendor-prefixed
185+ or re-versioned spelling of the other (`gemini-2.5-flash-lite` ~ `gemini-2.5-flash-lite-preview-06-17`, `google/gemini-2.5-flash-lite` ~
186+ `google/gemini-2.5-flash-lite-preview`, `meta-llama/Meta-Llama-3.1-8B-Instruct` ~ `meta-llama/Llama-3.1-8B-Instruct`,
187+ `gemini-omni-flash` ~ `gemini-omni-1.1-flash`)."""
188+ na, nb = normalize_alias(a), normalize_alias(b)
189+ if na == nb:
190+ return True
191+ short, long_ = sorted((na, nb), key=len)
192+ if long_.startswith(short):
193+ rest = long_[len(short):]
194+ if _SNAPSHOT_REMAINDER.match(rest) or rest in _EFFORT_REMAINDER or rest.startswith("preview") or rest.startswith("exp"):
195+ return True
196+ if long_.endswith(short) and long_[: -len(short)] in _VENDOR_PREFIXES:
197+ return True
198+ # re-versioned / vendor-duplicated spellings: same word tokens once version and vendor tokens are removed, and the version tokens of
199+ # one side are a subset of the other's (`gemini-omni-flash` ⊂ `gemini-omni-1.1-flash`; `gpt-4` vs `gpt-5` stay different)
200+ ta = [t for t in re.split(r"[-/_.\s]+", a.lower()) if t]
201+ tb = [t for t in re.split(r"[-/_.\s]+", b.lower()) if t]
202+ words_a = [t for t in ta if not _VERSION_TOKEN.match(t) and t not in _VENDOR_PREFIXES]
203+ words_b = [t for t in tb if not _VERSION_TOKEN.match(t) and t not in _VENDOR_PREFIXES]
204+ va = {t for t in ta if _VERSION_TOKEN.match(t)}
205+ vb = {t for t in tb if _VERSION_TOKEN.match(t)}
206+ return bool(words_a) and words_a == words_b and (va <= vb or vb <= va)
207+
208+
176 def _identifiers_conflict(a: dict[str, set[str]], b: dict[str, set[str]]) -> bool: 209 def _identifiers_conflict(a: dict[str, set[str]], b: dict[str, set[str]]) -> bool:
177− return any(a[s] and b[s] and a[s] != b[s] for s in set(a) & set(b)) 210+ """Hard contradiction only: a shared scheme whose values are not aliases of one another."""
211+ for s in set(a) & set(b):
212+ if not a[s] or not b[s] or a[s] == b[s]:
213+ continue
214+ if not all(_identifier_alias(x, y) for x in a[s] for y in b[s]):
215+ return True
216+ return False
217+
218+
219+def _conflict_detail(a: dict[str, set[str]], b: dict[str, set[str]]) -> str:
220+ parts = []
221+ for s in sorted(set(a) & set(b)):
222+ if a[s] and b[s] and a[s] != b[s] and not all(_identifier_alias(x, y) for x in a[s] for y in b[s]):
223+ parts.append(f"{s}: {'/'.join(sorted(a[s]))} vs {'/'.join(sorted(b[s]))}")
224+ return "; ".join(parts)
225+
226+
227+def _same_publisher(model_name: str, org_a: dict[str, Any] | None, org_b: dict[str, Any] | None) -> bool:
228+ """Alibaba/Qwen, Meta/Meta AI…: both organisations publish the model's family officially."""
229+ if not org_a or not org_b:
230+ return False
231+ official = set(official_orgs(model_name))
232+ return bool(official) and bool(_org_lookup_keys(org_a) & official) and bool(_org_lookup_keys(org_b) & official)
178 233
179 234
180 def _org_lookup_keys(row: dict[str, Any]) -> set[str]: 235 def _org_lookup_keys(row: dict[str, Any]) -> set[str]:
@@ -192,12 +247,29 @@ def _in_scope(scope: set[str] | None, entity_id: str) -> bool:
192 return scope is None or entity_id in scope 247 return scope is None or entity_id in scope
193 248
194 249
250+async def _retype_routers(conn: AsyncConnection, rep: StepReport, apply: bool, scope: set[str] | None) -> None:
251+ """OpenRouter's own routers (`openrouter/auto`, `openrouter/pareto-code`…) are products, not models: no weights, no developer."""
252+ rows = await fetch_all(conn, """select distinct e.id, e.slug, e.canonical_name from entities e left join entity_identifiers ei on ei.entity_id = e.id
253+ where e.entity_type = 'model' and e.merged_into is null
254+ and ((ei.scheme = 'openrouter' and ei.value like 'openrouter/%') or e.attributes->>'openrouter_id' like 'openrouter/%')""")
255+ for r in rows:
256+ if not _in_scope(scope, r["id"]):
257+ continue
258+ rep.bump("routers_retyped_as_product")
259+ rep.example(f"product[router] {r['slug']} ({r['canonical_name']})")
260+ if apply:
261+ await execute(conn, """update entities set entity_type = 'product', attributes = attributes || '{"kind": "router"}'::jsonb, updated_at = now() where id = :id""", id=r["id"])
262+ await execute(conn, "update relations set valid_to = now() where predicate = 'develops' and valid_to is null and (subject_id = :id or object_id = :id)", id=r["id"])
263+
264+
195 async def step_duplicates(conn: AsyncConnection, rep: StepReport, apply: bool, *, scope: set[str] | None = None) -> None: 265 async def step_duplicates(conn: AsyncConnection, rep: StepReport, apply: bool, *, scope: set[str] | None = None) -> None:
196 source_id = await registry_source_id(conn) 266 source_id = await registry_source_id(conn)
267+ await _retype_routers(conn, rep, apply, scope)
197 rows = await fetch_all(conn, """select id, entity_type, canonical_name, slug, organization_id, attributes, first_seen_at from entities 268 rows = await fetch_all(conn, """select id, entity_type, canonical_name, slug, organization_id, attributes, first_seen_at from entities
198 where merged_into is null and entity_type = any(cast(:types as text[]))""", types=list(DEDUPE_TYPES) + sorted(ORG_TYPES)) 269 where merged_into is null and entity_type = any(cast(:types as text[]))""", types=list(DEDUPE_TYPES) + sorted(ORG_TYPES))
199 if scope is not None: 270 if scope is not None:
200 rows = [r for r in rows if r["id"] in scope] 271 rows = [r for r in rows if r["id"] in scope]
272+ orgs_by_id = {o["id"]: o for o in await fetch_all(conn, "select id, slug, canonical_name, attributes from entities where entity_type = any(cast(:t as text[]))", t=sorted(ORG_TYPES))}
201 # group: exact normalised name within a type; organisations across the org group (+ shared hf_org/github_org) 273 # group: exact normalised name within a type; organisations across the org group (+ shared hf_org/github_org)
202 groups: dict[str, list[dict[str, Any]]] = defaultdict(list) 274 groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
203 for r in rows: 275 for r in rows:
@@ -234,8 +306,18 @@ async def step_duplicates(conn: AsyncConnection, rep: StepReport, apply: bool, *
234 claims = await _claim_counts(conn, all_ids) 306 claims = await _claim_counts(conn, all_ids)
235 idents = await _identifiers(conn, all_ids) 307 idents = await _identifiers(conn, all_ids)
236 type_rank = {"company": 0, "lab": 0, "university": 0, "organization": 1} # curated org types beat the generic hub "organization" 308 type_rank = {"company": 0, "lab": 0, "university": 0, "organization": 1} # curated org types beat the generic hub "organization"
309+
310+ def collision_slug(m: dict[str, Any], cluster: list[dict[str, Any]]) -> int:
311+ """1 when the slug is a collision product — an organisation prefix (`google-gemma-4-31b`, `meta-ai-llama-…`) or a `-2` suffix over
312+ another member's slug (`minimax-m3-2`): the plain slug survives."""
313+ org = orgs_by_id.get(m["organization_id"] or "")
314+ if org and m["slug"].startswith(org["slug"] + "-") and not normalize_alias(m["canonical_name"]).startswith(normalize_alias(org["slug"])):
315+ return 1
316+ mm = re.match(r"^(.*)-\d+$", m["slug"])
317+ return int(bool(mm) and any(o["slug"] == mm.group(1) for o in cluster if o is not m))
318+
237 for cluster in dup_clusters: 319 for cluster in dup_clusters:
238− cluster.sort(key=lambda m: (type_rank.get(m["entity_type"], 0), -claims.get(m["id"], 0), m["first_seen_at"])) 320+ cluster.sort(key=lambda m: (type_rank.get(m["entity_type"], 0), collision_slug(m, cluster), -claims.get(m["id"], 0), m["first_seen_at"]))
239 survivor = cluster[0] 321 survivor = cluster[0]
240 for other in cluster[1:]: 322 for other in cluster[1:]:
241 pair = sorted([survivor["id"], other["id"]]) 323 pair = sorted([survivor["id"], other["id"]])
@@ -243,12 +325,17 @@ async def step_duplicates(conn: AsyncConnection, rep: StepReport, apply: bool, *
243 rep.bump("_kept_separate") 325 rep.bump("_kept_separate")
244 continue 326 continue
245 if _identifiers_conflict(idents[survivor["id"]], idents[other["id"]]): 327 if _identifiers_conflict(idents[survivor["id"]], idents[other["id"]]):
246− if await _review(conn, "merge_candidate", pair, f"'{other['canonical_name']}' duplicates '{survivor['canonical_name']}' but identifiers conflict", 328+ detail = _conflict_detail(idents[survivor["id"]], idents[other["id"]])
247− {"slugs": [other["slug"], survivor["slug"]], "step": "duplicates"}, apply=apply): 329+ rep.bump("keep_separate_recorded")
248− rep.bump("review_conflicting_identifiers") 330+ rep.example(f"keep separate: {other['slug']} vs {survivor['slug']} — {detail}")
249− rep.example(f"review: {other['canonical_name']} ({other['slug']}) vs {survivor['canonical_name']} ({survivor['slug']}) — identifiers differ") 331+ if apply:
332+ await record_decision(conn, other["id"], survivor["id"], "keep_separate", actor="canonicalize",
333+ note=f"same name, contradictory identifiers ({detail})", payload={"step": "duplicates", "slugs": [other["slug"], survivor["slug"]]})
334+ await execute(conn, "update review_queue set status = 'rejected', resolved_at = now() where status = 'pending' and kind = 'merge_candidate' and entity_ids @> :ids and entity_ids <@ :ids",
335+ ids=pair)
250 continue 336 continue
251− if survivor["organization_id"] and other["organization_id"] and survivor["organization_id"] != other["organization_id"] and other["entity_type"] not in ORG_TYPES: 337+ if (survivor["organization_id"] and other["organization_id"] and survivor["organization_id"] != other["organization_id"] and other["entity_type"] not in ORG_TYPES
338+ and not _same_publisher(survivor["canonical_name"], orgs_by_id.get(survivor["organization_id"]), orgs_by_id.get(other["organization_id"]))):
252 if await _review(conn, "merge_candidate", pair, f"'{other['canonical_name']}' and '{survivor['canonical_name']}' share a name but have different organisations", 339 if await _review(conn, "merge_candidate", pair, f"'{other['canonical_name']}' and '{survivor['canonical_name']}' share a name but have different organisations",
253 {"slugs": [other["slug"], survivor["slug"]], "step": "duplicates"}, apply=apply): 340 {"slugs": [other["slug"], survivor["slug"]], "step": "duplicates"}, apply=apply):
254 rep.bump("review_different_organizations") 341 rep.bump("review_different_organizations")
@@ -288,22 +375,45 @@ async def _models(conn: AsyncConnection, *, types: tuple[str, ...] = ("model",))
288 where e.merged_into is null and e.entity_type = any(cast(:t as text[])) order by e.first_seen_at, e.id""", t=list(types)) 375 where e.merged_into is null and e.entity_type = any(cast(:t as text[])) order by e.first_seen_at, e.id""", t=list(types))
289 376
290 377
291−def _variant_index(models: list[dict[str, Any]]) -> tuple[dict[str, str], dict[str, list[dict[str, Any]]]]: 378+def _variant_index(models: list[dict[str, Any]], aliases: dict[str, list[str]] | None = None) -> tuple[dict[str, str], dict[str, list[dict[str, Any]]]]:
292− """variant_key → canonical model id (unique keys only) and the full multi-map.""" 379+ """variant_key → canonical model id (unique keys only) and the full multi-map; with `aliases` (model id → alias strings) every alias of a
380+ canonical model contributes its own key (`gpt-3.5-turbo-0613`, `o3-mini-2025-01-31`…)."""
293 multi: dict[str, list[dict[str, Any]]] = defaultdict(list) 381 multi: dict[str, list[dict[str, Any]]] = defaultdict(list)
294 for m in models: 382 for m in models:
295 a = analyze_model_name(m["canonical_name"]) 383 a = analyze_model_name(m["canonical_name"])
296 if a.is_effort_variant or a.is_artifact: 384 if a.is_effort_variant or a.is_artifact:
297 continue 385 continue
298− multi[variant_key(m["canonical_name"])].append(m) 386+ keys = {variant_key(m["canonical_name"])}
387+ for al in (aliases or {}).get(m["id"], []):
388+ aa = analyze_model_name(al)
389+ if not aa.is_effort_variant and not aa.is_artifact:
390+ keys.add(variant_key(al))
391+ for k in keys:
392+ if k and m not in multi[k]:
393+ multi[k].append(m)
299 return {k: v[0]["id"] for k, v in multi.items() if len(v) == 1}, multi 394 return {k: v[0]["id"] for k, v in multi.items() if len(v) == 1}, multi
300 395
301 396
397+async def _model_aliases(conn: AsyncConnection, ids: list[str]) -> dict[str, list[str]]:
398+ out: dict[str, list[str]] = defaultdict(list)
399+ for r in await fetch_all(conn, "select entity_id, alias from entity_aliases where entity_id = any(cast(:ids as text[]))", ids=ids):
400+ out[r["entity_id"]].append(r["alias"])
401+ return out
402+
403+
302 async def step_variants(conn: AsyncConnection, rep: StepReport, apply: bool, *, scope: set[str] | None = None) -> None: 404 async def step_variants(conn: AsyncConnection, rep: StepReport, apply: bool, *, scope: set[str] | None = None) -> None:
303 models = await _models(conn) 405 models = await _models(conn)
304− index, _ = _variant_index(models) 406+ index, _ = _variant_index(models, await _model_aliases(conn, [m["id"] for m in models]))
305 resolver = Resolver(conn, source_tier=2, variant_index=index) 407 resolver = Resolver(conn, source_tier=2, variant_index=index)
306 candidates = [m for m in models if _in_scope(scope, m["id"]) and analyze_model_name(m["canonical_name"]).is_effort_variant] 408 candidates = [m for m in models if _in_scope(scope, m["id"]) and analyze_model_name(m["canonical_name"]).is_effort_variant]
409+ # names that used to look like variants (before the ontology learnt they are tiers/official releases) drop their hint and regain full identity
410+ for m in models:
411+ if _in_scope(scope, m["id"]) and (m["attributes"] or {}).get("evaluation_variant_of_hint") and not analyze_model_name(m["canonical_name"]).is_effort_variant:
412+ rep.bump("stale_variant_hint_cleared")
413+ if apply:
414+ await execute(conn, """update entities set attributes = attributes - 'evaluation_variant_of_hint', provenance = provenance - 'evaluation_variant_of_hint',
415+ identity_confidence = 'high', updated_at = now() where id = :id""", id=m["id"])
416+ await execute(conn, "update review_queue set status = 'rejected', resolved_at = now() where status = 'pending' and kind = 'variant_candidate' and :id = any(entity_ids)", id=m["id"])
307 idents = await _identifiers(conn, [m["id"] for m in candidates]) 417 idents = await _identifiers(conn, [m["id"] for m in candidates])
308 official = {r["entity_id"] for r in await fetch_all(conn, """select distinct entity_id from claims where tier = 1 and status = 'current' 418 official = {r["entity_id"] for r in await fetch_all(conn, """select distinct entity_id from claims where tier = 1 and status = 'current'
309 and entity_id = any(cast(:ids as text[]))""", ids=[m["id"] for m in candidates])} if candidates else set() 419 and entity_id = any(cast(:ids as text[]))""", ids=[m["id"] for m in candidates])} if candidates else set()
@@ -320,13 +430,13 @@ async def step_variants(conn: AsyncConnection, rep: StepReport, apply: bool, *,
320 if folded is None: 430 if folded is None:
321 hint = base_name(m["canonical_name"]) 431 hint = base_name(m["canonical_name"])
322 attrs = m["attributes"] or {} 432 attrs = m["attributes"] or {}
323− if attrs.get("evaluation_variant_of_hint") == hint and m["identity_confidence"] == "medium": 433+ if attrs.get("evaluation_variant_of_hint") == hint and m["identity_confidence"] == "low":
324 continue 434 continue
325 rep.bump("unresolved_flagged") 435 rep.bump("unresolved_flagged")
326− rep.example(f"unresolved variant {m['slug']} (base '{hint}' not found) → identity medium + review") 436+ rep.example(f"unresolved variant {m['slug']} (base '{hint}' not found) → identity low + review")
327 if apply: 437 if apply:
328 await _set_attributes(conn, m["id"], {"evaluation_variant_of_hint": hint}, source_id=await registry_source_id(conn)) 438 await _set_attributes(conn, m["id"], {"evaluation_variant_of_hint": hint}, source_id=await registry_source_id(conn))
329− await execute(conn, "update entities set identity_confidence = 'medium' where id = :id", id=m["id"]) 439+ await execute(conn, "update entities set identity_confidence = 'low' where id = :id", id=m["id"])
330 await _review(conn, "variant_candidate", [m["id"]], f"'{m['canonical_name']}' looks like an evaluation-effort variant of '{hint}' but no such model exists", 440 await _review(conn, "variant_candidate", [m["id"]], f"'{m['canonical_name']}' looks like an evaluation-effort variant of '{hint}' but no such model exists",
331 {"slug": m["slug"], "base": hint, "effort": a.effort}, apply=apply) 441 {"slug": m["slug"], "base": hint, "effort": a.effort}, apply=apply)
332 continue 442 continue
@@ -382,22 +492,27 @@ async def step_artifacts(conn: AsyncConnection, rep: StepReport, apply: bool, *,
382 vk_candidates = [c for c in multi.get(variant_key(probe), []) if c["id"] != m["id"]] 492 vk_candidates = [c for c in multi.get(variant_key(probe), []) if c["id"] != m["id"]]
383 cid: str | None = None 493 cid: str | None = None
384 if official: 494 if official:
385− # the developer's own repo is the model (NVIDIA-Nemotron-3-Ultra-…-BF16, DeepSeek-R1 in native FP8) — it only becomes an 495+ # A quant/precision token in the repo name makes it an artifact whatever the organisation (an official FP8/GGUF repo is a
386− # artifact when the same organisation also has the plain model entity (tencent/Hy-MT2-7B-GGUF next to Hy-MT2-7B) AND nobody 496+ # conversion of the model). Canonical = the same organisation's plain model with the same variant_key when it exists, else NULL.
387− # else (evaluators, providers) knows it under its own identity; otherwise the pair is a merge candidate for review 497+ # Two guards, because a model is never invented or erased: (1) a native-dtype attribute without a token in the name
388− same_org = [c for c in vk_candidates if c["organization_id"] == m["organization_id"] and not analyze_model_name((c["attributes"] or {}).get("hf_repo") or c["canonical_name"]).is_artifact] 498+ # (DeepSeek-R1 `quant_format=fp8`) is the model; (2) when evaluators/providers know the tagged entity under its own identity
389− if len(same_org) != 1 or not has_token: 499+ # (Nemotron 3 Ultra whose only hub repo is `…-BF16`) it IS the model → merged into the plain sibling when one exists, kept otherwise.
500+ if not has_token:
390 rep.bump("_official_checkpoint_kept_as_model") 501 rep.bump("_official_checkpoint_kept_as_model")
391 continue 502 continue
392− schemes = set(official_idents.get(m["id"], {})) - {"hf_repo"} 503+ same_org = [c for c in vk_candidates if c["organization_id"] == m["organization_id"] and not analyze_model_name((c["attributes"] or {}).get("hf_repo") or c["canonical_name"]).is_artifact]
393− if schemes: 504+ external = set(official_idents.get(m["id"], {})) - {"hf_repo"}
394− if await _review(conn, "merge_candidate", sorted([m["id"], same_org[0]["id"]]), 505+ if external:
395− f"'{m['canonical_name']}' and '{same_org[0]['canonical_name']}' look like one model published as two official checkpoints", 506+ if len(same_org) == 1 and not await kept_separate(conn, m["id"], same_org[0]["id"]):
396− {"slugs": [m["slug"], same_org[0]["slug"]], "step": "artifacts"}, apply=apply): 507+ rep.bump("official_checkpoint_merged_into_model")
397− rep.bump("review_official_checkpoint_pair") 508+ rep.example(f"merge official checkpoint {m['slug']} → {same_org[0]['slug']} (known to {sorted(external)})")
398− rep.example(f"review: {m['slug']} (official repo with dtype tag, known to {sorted(schemes)}) vs {same_org[0]['slug']}") 509+ if apply:
510+ await merge_entities(conn, m["id"], same_org[0]["id"], mode="merge", actor="canonicalize",
511+ note="official checkpoint repo of the same release (dtype tag in the repo name)", payload={"step": "artifacts"})
512+ else:
513+ rep.bump("_official_tagged_repo_is_the_model")
399 continue 514 continue
400− cid = same_org[0]["id"] 515+ cid = same_org[0]["id"] if len(same_org) == 1 else None
401 if attr_quantized or a.is_quantized or a_name.is_quantized or quant_attr in QUANT_ATTR_FORMATS: 516 if attr_quantized or a.is_quantized or a_name.is_quantized or quant_attr in QUANT_ATTR_FORMATS:
402 kind = "quantization" 517 kind = "quantization"
403 elif a.is_conversion or a_name.is_conversion or a.precision or quant_attr: 518 elif a.is_conversion or a_name.is_conversion or a.precision or quant_attr:
@@ -405,10 +520,8 @@ async def step_artifacts(conn: AsyncConnection, rep: StepReport, apply: bool, *,
405 else: 520 else:
406 kind = "packaging" 521 kind = "packaging"
407 if cid is None: 522 if cid is None:
408− cid = base_of.get(m["id"]) 523+ # 1) a model with the same variant_key (the name says what was quantised); 2) the hub's `quantized_from` object, but only when it
409− if cid and (cid not in by_id or cid == m["id"]): 524+ # names the same model — a fine-tune's GGUF (Hermes-2-Pro-Mistral-7B-GGUF) is not an artifact of the foundation model (Mistral 7B)
410− cid = None
411− if cid is None:
412 candidates = vk_candidates 525 candidates = vk_candidates
413 if len(candidates) > 1: 526 if len(candidates) > 1:
414 same_org = [c for c in candidates if c["organization_id"] == m["organization_id"]] 527 same_org = [c for c in candidates if c["organization_id"] == m["organization_id"]]
@@ -416,6 +529,12 @@ async def step_artifacts(conn: AsyncConnection, rep: StepReport, apply: bool, *,
416 candidates = same_org or official_c or candidates 529 candidates = same_org or official_c or candidates
417 if len(candidates) == 1: 530 if len(candidates) == 1:
418 cid = candidates[0]["id"] 531 cid = candidates[0]["id"]
532+ if cid is None:
533+ rel = base_of.get(m["id"])
534+ if rel and rel in by_id and rel != m["id"]:
535+ base_probe = (by_id[rel]["attributes"] or {}).get("hf_repo") or by_id[rel]["canonical_name"]
536+ if variant_key(base_probe) == variant_key(probe) or analyze_model_name(base_probe).base_key in a.base_key:
537+ cid = rel
419 if m["entity_type"] == "artifact" and m["canonical_id"] == cid: 538 if m["entity_type"] == "artifact" and m["canonical_id"] == cid:
420 continue 539 continue
421 rep.bump("artifacts_marked" if cid else "artifacts_unresolved") 540 rep.bump("artifacts_marked" if cid else "artifacts_unresolved")
modified tests/test_canonical.py +26 −2
@@ -277,13 +277,37 @@ async def test_duplicates_step(conn: AsyncConnection) -> None:
277 y = f.entity("model", f"Dup {t} ", identifiers={"openai_model_id": f"dup-{t}-b"}) # same name, conflicting identifier scheme → review only 277 y = f.entity("model", f"Dup {t} ", identifiers={"openai_model_id": f"dup-{t}-b"}) # same name, conflicting identifier scheme → review only
278 await _write(conn, f) 278 await _write(conn, f)
279 rep = await _run(canon.step_duplicates, conn, True, keep.id, dup.id, x.id, y.id) 279 rep = await _run(canon.step_duplicates, conn, True, keep.id, dup.id, x.id, y.id)
280− assert rep.counts.get("merged", 0) >= 1 and rep.counts.get("review_conflicting_identifiers", 0) >= 1 280+ assert rep.counts.get("merged", 0) >= 1 and rep.counts.get("keep_separate_recorded", 0) >= 1
281 assert (await fetch_one(conn, "select merged_into from entities where id = :id", id=dup.id))["merged_into"] == keep.id 281 assert (await fetch_one(conn, "select merged_into from entities where id = :id", id=dup.id))["merged_into"] == keep.id
282+ assert await fetch_one(conn, "select 1 from resolution_decisions where decision = 'keep_separate' and a_id in (:x, :y) and b_id in (:x, :y)", x=x.id, y=y.id)
282 assert (await fetch_one(conn, "select attributes->>'org_kind' as k from entities where id = :id", id=keep.id))["k"] == "company" 283 assert (await fetch_one(conn, "select attributes->>'org_kind' as k from entities where id = :id", id=keep.id))["k"] == "company"
283 for i in (x.id, y.id): 284 for i in (x.id, y.id):
284 assert (await fetch_one(conn, "select merged_into from entities where id = :id", id=i))["merged_into"] is None 285 assert (await fetch_one(conn, "select merged_into from entities where id = :id", id=i))["merged_into"] is None
285 again = await _run(canon.step_duplicates, conn, True, keep.id, dup.id, x.id, y.id) 286 again = await _run(canon.step_duplicates, conn, True, keep.id, dup.id, x.id, y.id)
286− assert again.counts.get("merged", 0) == 0 287+ assert again.counts.get("merged", 0) == 0 and again.counts.get("keep_separate_recorded", 0) == 0
288+
289+
290+async def test_snapshot_identifier_is_not_a_conflict_and_routers_become_products(conn: AsyncConnection) -> None:
291+ t = _tag()
292+ f = Facts()
293+ org = f.entity("company", f"Google test {t}")
294+ plain = f.entity("model", f"Gemini {t} Flash", organization=org, identifiers={"gemini_model_id": f"gemini-{t}-flash", "openrouter": f"google/gemini-{t}-flash"})
295+ prefixed = f.entity("model", f"Gemini {t} Flash ", organization=org, identifiers={"gemini_model_id": f"gemini-{t}-flash-preview-06-17"}, slug_hint=f"google-gemini-{t}-flash")
296+ router = f.entity("model", f"Auto Router {t}", identifiers={"openrouter": f"openrouter/auto-{t}"}, attributes={"context_length": 2_000_000})
297+ rel_org = f.entity("provider", f"OpenRouter {t}")
298+ f.relate(rel_org, "develops", router)
299+ await _write(conn, f)
300+ rep = await _run(canon.step_duplicates, conn, True, plain.id, prefixed.id, router.id)
301+ assert rep.counts.get("merged", 0) == 1 and rep.counts.get("routers_retyped_as_product") == 1
302+ row = await fetch_one(conn, "select merged_into, slug from entities where id = :id", id=prefixed.id)
303+ assert row["merged_into"] == plain.id # the plain-slug entity survives, both identifiers are kept
304+ ids = {r["value"] for r in await fetch_all(conn, "select value from entity_identifiers where entity_id = :id and scheme = 'gemini_model_id'", id=plain.id)}
305+ assert ids == {f"gemini-{t}-flash", f"gemini-{t}-flash-preview-06-17"}
306+ r = await fetch_one(conn, "select entity_type, attributes->>'kind' as kind from entities where id = :id", id=router.id)
307+ assert r["entity_type"] == "product" and r["kind"] == "router"
308+ assert not await fetch_one(conn, "select 1 from relations where object_id = :id and predicate = 'develops' and valid_to is null", id=router.id)
309+ again = await _run(canon.step_duplicates, conn, True, plain.id, prefixed.id, router.id)
310+ assert again.changes == 0
287 311
288 312
289 # ---------------------------------------------------------------------------------------------- result re-homing & family slugs 313 # ---------------------------------------------------------------------------------------------- result re-homing & family slugs
290 314
Menu

Theme