SPB Git

spb/ai-atlas

Public

SDK hardening: SSRF guard with per-hop redirect validation, run_id on every fact, same-source loophole closed, taxonomy at write time, result comparability, event semantics, hierarchy hints, quarantine

- fetch: validate_destination() on every request and redirect hop (max 5, manual loop): non-http(s), localhost/.local/.internal, RFC1918,
  loopback, link-local (metadata), CGNAT, IPv6 loopback/link-local/ULA, unspecified → FetchError("blocked destination")
- facts: facts_to_json / facts_from_json (tagged datetimes) for quarantined runs
- writer: run_id/source_key/is_first_run; a claim supersedes only when tier <= current or same source AND same extractor (LLM never
  overwrites deterministic from the same URL); licence/openness/modalities/status/kind/org_kind normalised via the ontology with value_raw
  + taxonomy_mappings, same assertion in another spelling re-encoded in place (no event); license_key claim; results get config_key,
  trust_level, variant, run_group, extractor, effort config for variant refs, one current row per (model, benchmark, metric, config_key),
  out-of-range scores → confidence low + anomaly; events get recorded_at, is_backfill (classify_backfill), group_key, deterministic
  importance; family/canonical hints materialised (family_id, canonical_id, artifact_kind, member_of_family, artifact_of); NEW_ARTIFACT 0,
  NEW_MODEL_FAMILY 1; conflicting claims recorded once; derived writers never open review items and mark their events backfill
- resolver: model/artifact compatible lookups; keep_separate decisions honoured; digit-collapsing aliases require the same variant_key;
  first_seen_hint → first_seen_at = min(now, hint); resolve_variant() folds evaluator-only effort variants onto the canonical model
- connector: run_id/source_key/first-run flag into the writer; --url reprocess rebuilds Targets from documents.meta._target (key, doc_type,
  meta, needs_llm, entity persisted at first fetch); quarantine for tier ≥ 2 connectors (rolling median baseline of the last 5 full runs:
  new entities > max(50, 1.3×), prices > 3×, entities < 0.8× / results < 0.5× on full extraction) → quarantined_runs + review item, nothing written
- services/events: classify_backfill, group_key_for, importance_for · services/anomalies: record / run_checks / list_anomalies
- merge: modes merge|alias|variant|family_member, keep_separate refusal, org-type and model/artifact pairs, dedupe/config_key recompute with
  collision handling, one current result per key, embeddings/sources/domains/review_queue/family_id/canonical_id re-pointed,
  resolution_decisions + admin_audit_log rows
- handlers: LLM extraction writer carries the snapshot's run_id

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

13 changed files +1,757 −133

modified src/aiatlas/sdk/connector.py +200 −17
@@ -11,25 +11,29 @@ from __future__ import annotations
11 import asyncio 11 import asyncio
12 import json 12 import json
13 import logging 13 import logging
14+import statistics
14 import time 15 import time
15 from dataclasses import dataclass, field 16 from dataclasses import dataclass, field
16 from datetime import UTC, datetime, timedelta 17 from datetime import UTC, datetime, timedelta
17 from typing import Any 18 from typing import Any
18 19
19−from aiatlas.db import execute, fetch_one, jsonb, transaction 20+from aiatlas.db import connection, execute, fetch_one, jsonb, transaction
20 from aiatlas.ids import new_id 21 from aiatlas.ids import new_id
21 from aiatlas.sdk import archive 22 from aiatlas.sdk import archive
22 from aiatlas.sdk.extract.feeds import FeedItem, parse_feed 23 from aiatlas.sdk.extract.feeds import FeedItem, parse_feed
23 from aiatlas.sdk.extract.html import HtmlDoc, parse_html 24 from aiatlas.sdk.extract.html import HtmlDoc, parse_html
24 from aiatlas.sdk.extract.markdown import MarkdownDoc, parse_markdown 25 from aiatlas.sdk.extract.markdown import MarkdownDoc, parse_markdown
25−from aiatlas.sdk.facts import Facts, Target 26+from aiatlas.sdk.facts import EntityRef, Facts, Target, facts_to_json
26 from aiatlas.sdk.fetch import BlockedError, Fetcher, FetchError, FetchResult, NotModified, canonicalize_url, file_result 27 from aiatlas.sdk.fetch import BlockedError, Fetcher, FetchError, FetchResult, NotModified, canonicalize_url, file_result
28+from aiatlas.sdk.resolution import Resolver
27 from aiatlas.sdk.writer import FactWriter 29 from aiatlas.sdk.writer import FactWriter
28 30
29 log = logging.getLogger(__name__) 31 log = logging.getLogger(__name__)
30 32
31 CIRCUIT_FAILURES = 3 33 CIRCUIT_FAILURES = 3
32 CIRCUIT_COOLDOWN_S = 1800 34 CIRCUIT_COOLDOWN_S = 1800
35+BASELINE_RUNS = 5 # rolling window (median) of full-extraction runs used by the quarantine detector
36+QUARANTINE_MIN_NEW_ENTITIES = 50 # a run may always create up to this many new entities without tripping the detector
33 37
34 38
35 class ConnectorError(Exception): 39 class ConnectorError(Exception):
@@ -77,6 +81,17 @@ class RunStats:
77 meta: dict[str, Any] = field(default_factory=dict) 81 meta: dict[str, Any] = field(default_factory=dict)
78 82
79 83
84+@dataclass
85+class Buffered:
86+ """Facts extracted from one document, held until the end of the run when the connector is quarantine-capable."""
87+ target: Target
88+ doc_id: str
89+ snapshot_id: str
90+ source_url: str
91+ fetched_at: datetime
92+ facts: Facts
93+
94+
80 @dataclass 95 @dataclass
81 class RunContext: 96 class RunContext:
82 run_id: str 97 run_id: str
@@ -89,6 +104,11 @@ class RunContext:
89 max_targets: int = 2000 104 max_targets: int = 2000
90 stats: RunStats = field(default_factory=RunStats) 105 stats: RunStats = field(default_factory=RunStats)
91 seen_urls: set[str] = field(default_factory=set) 106 seen_urls: set[str] = field(default_factory=set)
107+ is_first_run: bool = False # no earlier successful run → every event of this run is backfill
108+ source_key: str | None = None
109+ buffered: list[Buffered] = field(default_factory=list) # quarantine mode: facts held until the run-end anomaly check
110+ quarantined: bool = False
111+ quarantine_reason: str | None = None
92 log: logging.LoggerAdapter[logging.Logger] = field(init=False) 112 log: logging.LoggerAdapter[logging.Logger] = field(init=False)
93 113
94 def __post_init__(self) -> None: 114 def __post_init__(self) -> None:
@@ -114,11 +134,21 @@ class BaseConnector:
114 concurrency: int = 3 134 concurrency: int = 3
115 default_doc_type: str = "page" 135 default_doc_type: str = "page"
116 needs_llm: bool = False # queue documents for LLM extraction after deterministic extraction 136 needs_llm: bool = False # queue documents for LLM extraction after deterministic extraction
137+ # Hold every fact until the end of the run and compare the run's counts with the connector's rolling baseline before writing;
138+ # a collapse or an explosion parks the whole run in `quarantined_runs` for review. None = default (tier ≥ 2 hub/leaderboard/registry
139+ # connectors are quarantine-capable; tier-1 lab documentation is not — it is the primary source).
140+ quarantine: bool | None = None
117 141
118 def __init__(self, config: dict[str, Any] | None = None): 142 def __init__(self, config: dict[str, Any] | None = None):
119 self.config = config or {} 143 self.config = config or {}
120 self.source_id: str | None = None 144 self.source_id: str | None = None
121 145
146+ @property
147+ def quarantine_enabled(self) -> bool:
148+ if self.quarantine is not None:
149+ return self.quarantine
150+ return self.tier >= 2
151+
122 # ------------------------------------------------------------------------------------------ to implement 152 # ------------------------------------------------------------------------------------------ to implement
123 async def discover(self, ctx: RunContext) -> list[Target]: 153 async def discover(self, ctx: RunContext) -> list[Target]:
124 """Return the targets for this run (seed pages, feeds, sitemaps, org pages…).""" 154 """Return the targets for this run (seed pages, feeds, sitemaps, org pages…)."""
@@ -165,10 +195,14 @@ class BaseConnector:
165 ctx = RunContext(run_id=run_id, connector=self, fetcher=fetcher, started_at=started, force=force, reprocess=reprocess, 195 ctx = RunContext(run_id=run_id, connector=self, fetcher=fetcher, started_at=started, force=force, reprocess=reprocess,
166 file_overrides=file_overrides or {}, max_targets=max_targets or int(self.config.get("max_targets", 2000))) 196 file_overrides=file_overrides or {}, max_targets=max_targets or int(self.config.get("max_targets", 2000)))
167 async with transaction() as conn: 197 async with transaction() as conn:
168− state = await fetch_one(conn, "select source_id, enabled, circuit_open_until from connectors where name = :n", n=self.name) 198+ state = await fetch_one(conn, """select c.source_id, c.enabled, c.circuit_open_until, s.key as source_key,
199+ exists(select 1 from connector_runs r where r.connector_name = c.name and r.status in ('success','unchanged','suspect')) as has_success
200+ from connectors c left join sources s on s.id = c.source_id where c.name = :n""", n=self.name)
169 if state is None: 201 if state is None:
170 raise ConnectorError(f"connector {self.name!r} not registered (run `aia seed`)") 202 raise ConnectorError(f"connector {self.name!r} not registered (run `aia seed`)")
171 self.source_id = state["source_id"] 203 self.source_id = state["source_id"]
204+ ctx.source_key = state["source_key"] or self.source_key or None
205+ ctx.is_first_run = not state["has_success"]
172 if not state["enabled"] and not force: 206 if not state["enabled"] and not force:
173 ctx.log.info("connector disabled, skipping") 207 ctx.log.info("connector disabled, skipping")
174 await self._record(conn, ctx, "skipped", error="disabled") 208 await self._record(conn, ctx, "skipped", error="disabled")
@@ -184,21 +218,23 @@ class BaseConnector:
184 try: 218 try:
185 targets = await self.discover(ctx) 219 targets = await self.discover(ctx)
186 if only_urls: 220 if only_urls:
187− wanted = {canonicalize_url(u) for u in only_urls} 221+ targets = await self._select_targets(targets, only_urls)
188− targets = [t for t in targets if canonicalize_url(t.url) in wanted] or [Target(url=u) for u in only_urls]
189 ctx.stats.docs_discovered = len(targets) 222 ctx.stats.docs_discovered = len(targets)
190 await self._process_all(ctx, targets) 223 await self._process_all(ctx, targets)
191− status = self._final_status(ctx) 224+ if ctx.buffered:
225+ await self._flush_or_quarantine(ctx)
226+ status = "quarantined" if ctx.quarantined else self._final_status(ctx)
192 async with transaction() as conn: 227 async with transaction() as conn:
193 await self._record(conn, ctx, status) 228 await self._record(conn, ctx, status)
194 changed = ctx.stats.docs_changed > 0 229 changed = ctx.stats.docs_changed > 0
195− await execute(conn, """update connectors set consecutive_failures = 0, circuit_open_until = null, last_success_at = now(), 230+ await execute(conn, """update connectors set consecutive_failures = 0, circuit_open_until = null,
196− last_change_at = case when cast(:changed as boolean) then now() else last_change_at end, 231+ last_success_at = case when cast(:q as boolean) then last_success_at else now() end,
232+ last_change_at = case when cast(:changed as boolean) and not cast(:q as boolean) then now() else last_change_at end,
197 consecutive_unchanged = case when cast(:changed as boolean) then 0 else consecutive_unchanged + 1 end, 233 consecutive_unchanged = case when cast(:changed as boolean) then 0 else consecutive_unchanged + 1 end,
198 interval_seconds = cast(:interval as integer), next_run_at = now() + make_interval(secs => cast(:interval as double precision)), 234 interval_seconds = cast(:interval as integer), next_run_at = now() + make_interval(secs => cast(:interval as double precision)),
199 last_duration_ms = :d, health = :health, updated_at = now() where name = :n""", 235 last_duration_ms = :d, health = :health, updated_at = now() where name = :n""",
200− changed=changed, interval=await self._next_interval(conn, changed), d=int((time.perf_counter() - t0) * 1000), 236+ changed=changed, q=ctx.quarantined, interval=await self._next_interval(conn, changed), d=int((time.perf_counter() - t0) * 1000),
201− health="degraded" if status == "suspect" else "ok", n=self.name) 237+ health="degraded" if status in ("suspect", "quarantined") else "ok", n=self.name)
202 if status == "suspect": 238 if status == "suspect":
203 await execute(conn, """insert into review_queue (id, kind, entity_ids, payload, reason, dedupe_key) 239 await execute(conn, """insert into review_queue (id, kind, entity_ids, payload, reason, dedupe_key)
204 values (:id, 'parser_breakage', '{}', cast(:p as jsonb), :r, :d) on conflict (dedupe_key) do nothing""", 240 values (:id, 'parser_breakage', '{}', cast(:p as jsonb), :r, :d) on conflict (dedupe_key) do nothing""",
@@ -223,11 +259,58 @@ class BaseConnector:
223 raise 259 raise
224 return ctx 260 return ctx
225 261
262+ async def _select_targets(self, targets: list[Target], only_urls: list[str]) -> list[Target]:
263+ """`--url` / single-document reprocess: keep the discovered targets for those URLs; for URLs `discover()` no longer lists, rebuild
264+ the Target from what the first fetch persisted in `documents.meta` (key, doc_type, meta, needs_llm, entity) so extractors that
265+ branch on `target.key`/`target.meta` behave exactly as they did originally."""
266+ wanted = {canonicalize_url(u) for u in only_urls}
267+ selected = [t for t in targets if canonicalize_url(t.url) in wanted]
268+ missing = wanted - {canonicalize_url(t.url) for t in selected}
269+ if missing:
270+ async with transaction() as conn:
271+ for url in sorted(missing):
272+ doc = await fetch_one(conn, "select url, doc_type, meta, needs_llm, entity_id, priority from documents where url = :u", u=url)
273+ if not doc:
274+ selected.append(Target(url=url))
275+ continue
276+ meta = dict(doc["meta"] or {})
277+ stored = meta.pop("_target", {}) if isinstance(meta.get("_target"), dict) else {}
278+ entity = None
279+ if stored.get("entity"):
280+ try:
281+ from aiatlas.sdk.facts import _ref_from # noqa: PLC0415
282+
283+ entity = _ref_from(stored["entity"])
284+ except Exception: # noqa: BLE001
285+ entity = None
286+ elif doc["entity_id"]:
287+ row = await fetch_one(conn, "select entity_type, canonical_name from entities where id = :id", id=doc["entity_id"])
288+ if row:
289+ entity = EntityRef(entity_type=row["entity_type"], name=row["canonical_name"], id=doc["entity_id"])
290+ selected.append(Target(url=doc["url"], doc_type=stored.get("doc_type") or doc["doc_type"] or self.default_doc_type, entity=entity,
291+ meta=stored.get("meta") if isinstance(stored.get("meta"), dict) else meta, key=stored.get("key"),
292+ needs_llm=bool(stored.get("needs_llm", doc["needs_llm"])), priority=int(doc["priority"] or 2)))
293+ return selected
294+
295+ @staticmethod
296+ def _target_meta(target: Target, doc_type: str) -> dict[str, Any]:
297+ """What `documents.meta` remembers about the target that produced the document (rebuilt by `_select_targets`)."""
298+ stored: dict[str, Any] = {"key": target.key, "doc_type": doc_type, "meta": _jsonable(target.meta), "needs_llm": bool(target.needs_llm)}
299+ if target.entity is not None:
300+ from aiatlas.sdk.facts import _encode # noqa: PLC0415
301+
302+ stored["entity"] = _encode(target.entity)
303+ return {**_jsonable(target.meta), "_target": stored}
304+
305+ def _full_extraction(self, ctx: RunContext) -> bool:
306+ s = ctx.stats
307+ return s.docs_fetched > 0 and s.docs_changed >= s.docs_fetched - s.docs_failed and s.docs_unchanged == 0
308+
226 def _final_status(self, ctx: RunContext) -> str: 309 def _final_status(self, ctx: RunContext) -> str:
227 s = ctx.stats 310 s = ctx.stats
228 # Breakage detection only makes sense when every fetched document was (re)extracted: unchanged documents legitimately 311 # Breakage detection only makes sense when every fetched document was (re)extracted: unchanged documents legitimately
229 # produce zero records (304 / same hash), so an incremental run is never "suspect". 312 # produce zero records (304 / same hash), so an incremental run is never "suspect".
230− full_extraction = s.docs_fetched > 0 and s.docs_changed >= s.docs_fetched - s.docs_failed and s.docs_unchanged == 0 313+ full_extraction = self._full_extraction(ctx)
231 if self.expected_min_records and full_extraction and s.records < self.expected_min_records and not ctx.reprocess: 314 if self.expected_min_records and full_extraction and s.records < self.expected_min_records and not ctx.reprocess:
232 return "suspect" 315 return "suspect"
233 if s.docs_fetched and s.docs_failed == s.docs_fetched and s.docs_fetched > 0: 316 if s.docs_fetched and s.docs_failed == s.docs_fetched and s.docs_fetched > 0:
@@ -298,8 +381,11 @@ class BaseConnector:
298 if doc is None: 381 if doc is None:
299 doc_id = new_id("document") 382 doc_id = new_id("document")
300 await execute(conn, """insert into documents (id, source_id, connector_name, url, doc_type, priority, meta) values (:id, :s, :c, :u, :t, :p, cast(:m as jsonb)) 383 await execute(conn, """insert into documents (id, source_id, connector_name, url, doc_type, priority, meta) values (:id, :s, :c, :u, :t, :p, cast(:m as jsonb))
301− on conflict (url) do nothing""", id=doc_id, s=self.source_id, c=self.name, u=url, t=doc_type, p=target.priority, m=jsonb(target.meta)) 384+ on conflict (url) do nothing""", id=doc_id, s=self.source_id, c=self.name, u=url, t=doc_type, p=target.priority,
385+ m=jsonb(self._target_meta(target, doc_type)))
302 doc = await fetch_one(conn, "select * from documents where url = :u", u=url) 386 doc = await fetch_one(conn, "select * from documents where url = :u", u=url)
387+ elif (target.key or target.meta) and not ctx.reprocess and (doc["meta"] or {}).get("_target", {}).get("key") != target.key:
388+ await execute(conn, "update documents set meta = meta || cast(:m as jsonb) where id = :id", m=jsonb(self._target_meta(target, doc_type)), id=doc["id"])
303 assert doc 389 assert doc
304 # ---- reprocess mode: replay latest snapshot without network 390 # ---- reprocess mode: replay latest snapshot without network
305 if ctx.reprocess: 391 if ctx.reprocess:
@@ -407,15 +493,26 @@ class BaseConnector:
407 return [] 493 return []
408 if facts is None: 494 if facts is None:
409 return [] 495 return []
496+ item = Buffered(target=target, doc_id=doc["id"], snapshot_id=snapshot_id, source_url=res.final_url or res.url, fetched_at=res.fetched_at, facts=facts)
497+ ctx.stats.records += len(facts.entities) + len(facts.prices) + len(facts.results)
498+ if self.quarantine_enabled and not ctx.reprocess:
499+ ctx.buffered.append(item) # written (or quarantined) at the end of the run, see _flush_or_quarantine
500+ else:
501+ await self._write_buffered(ctx, item)
502+ return facts.targets
503+
504+ async def _write_buffered(self, ctx: RunContext, item: Buffered) -> None:
505+ facts, target, snapshot_id = item.facts, item.target, item.snapshot_id
410 async with transaction() as conn: 506 async with transaction() as conn:
411− writer = FactWriter(conn, source_id=self.source_id, snapshot_id=snapshot_id, source_url=res.final_url or res.url, tier=self.tier, 507+ writer = FactWriter(conn, source_id=self.source_id, snapshot_id=snapshot_id, source_url=item.source_url, tier=self.tier,
412− connector_name=self.name, extractor="deterministic", extractor_version=self.parser_version, observed_at=res.fetched_at) 508+ connector_name=self.name, extractor="deterministic", extractor_version=self.parser_version, observed_at=item.fetched_at,
509+ run_id=ctx.run_id, source_key=ctx.source_key, is_first_run=ctx.is_first_run)
413 ws = await writer.write(facts) 510 ws = await writer.write(facts)
414 main = facts.document_entity or target.entity 511 main = facts.document_entity or target.entity
415 if main and main.id is None: 512 if main and main.id is None:
416 await writer.resolver.resolve(main) 513 await writer.resolver.resolve(main)
417 if main and main.id: 514 if main and main.id:
418− await execute(conn, "update documents set entity_id = coalesce(entity_id, :e), title = coalesce(:t, title) where id = :id", e=main.id, t=facts.document_title, id=doc["id"]) 515+ await execute(conn, "update documents set entity_id = coalesce(entity_id, :e), title = coalesce(:t, title) where id = :id", e=main.id, t=facts.document_title, id=item.doc_id)
419 await execute(conn, "update snapshots set processing_status = :st where id = :id", 516 await execute(conn, "update snapshots set processing_status = :st where id = :id",
420 st="llm_pending" if (target.needs_llm or self.needs_llm or facts.llm_hint) else "extracted", id=snapshot_id) 517 st="llm_pending" if (target.needs_llm or self.needs_llm or facts.llm_hint) else "extracted", id=snapshot_id)
421 if target.needs_llm or self.needs_llm or facts.llm_hint: 518 if target.needs_llm or self.needs_llm or facts.llm_hint:
@@ -430,8 +527,94 @@ class BaseConnector:
430 s.claims_written += ws.claims 527 s.claims_written += ws.claims
431 s.relations_written += ws.relations 528 s.relations_written += ws.relations
432 s.events_emitted += ws.events 529 s.events_emitted += ws.events
433− s.records += len(facts.entities) + len(facts.prices) + len(facts.results) 530+
434− return facts.targets 531+ # ------------------------------------------------------------------------------------------ quarantine
532+ async def _observed_counts(self, ctx: RunContext) -> dict[str, int]:
533+ """Counts of what the buffered facts would write: entity references, prices, results and *new* entity names (refs that resolve to
534+ nothing today). Resolution runs in a transaction that is rolled back so the check leaves no trace."""
535+ refs: dict[str, EntityRef] = {}
536+ prices = results = 0
537+ for item in ctx.buffered:
538+ prices += len(item.facts.prices)
539+ results += len(item.facts.results)
540+ for ref in item.facts.entities:
541+ refs.setdefault(ref.key(), ref)
542+ new_entities = 0
543+ async with connection() as conn:
544+ trans = await conn.begin()
545+ try:
546+ resolver = Resolver(conn, source_tier=self.tier)
547+ for ref in refs.values():
548+ probe = EntityRef(entity_type=ref.entity_type, name=ref.name, identifiers=dict(ref.identifiers), aliases=list(ref.aliases), slug_hint=ref.slug_hint)
549+ if await resolver.resolve(probe, create=False) is None:
550+ new_entities += 1
551+ finally:
552+ await trans.rollback()
553+ return {"entities": len(refs), "prices": prices, "results": results, "new_entities": new_entities}
554+
555+ @staticmethod
556+ def _quarantine_reason(observed: dict[str, int], baseline: dict[str, Any] | None, *, full_extraction: bool) -> str | None:
557+ """Explosion rules always apply; collapse rules only when every document was re-extracted (an incremental run legitimately yields little)."""
558+ if not baseline or not baseline.get("history"):
559+ return None
560+ med = baseline.get("medians") or {}
561+ b_new, b_ent, b_pr, b_res = med.get("new_entities", 0), med.get("entities", 0), med.get("prices", 0), med.get("results", 0)
562+ if observed["new_entities"] > max(QUARANTINE_MIN_NEW_ENTITIES, 1.3 * b_new):
563+ return f"new entities {observed['new_entities']} > max({QUARANTINE_MIN_NEW_ENTITIES}, 1.3 × baseline {b_new:g})"
564+ if b_pr > 0 and observed["prices"] > 3 * b_pr:
565+ return f"price rows {observed['prices']} > 3 × baseline {b_pr:g}"
566+ if full_extraction:
567+ if b_ent > 0 and observed["entities"] < 0.8 * b_ent:
568+ return f"entity references {observed['entities']} < 0.8 × baseline {b_ent:g}"
569+ if b_res > 0 and observed["results"] < 0.5 * b_res:
570+ return f"benchmark results {observed['results']} < 0.5 × baseline {b_res:g}"
571+ return None
572+
573+ @staticmethod
574+ def _next_baseline(baseline: dict[str, Any] | None, observed: dict[str, int]) -> dict[str, Any]:
575+ history = list((baseline or {}).get("history") or [])
576+ history.append({k: int(observed[k]) for k in ("entities", "prices", "results", "new_entities")})
577+ history = history[-BASELINE_RUNS:]
578+ medians = {k: float(statistics.median(h[k] for h in history)) for k in ("entities", "prices", "results", "new_entities")}
579+ return {"history": history, "medians": medians, "runs": len(history), "updated_at": datetime.now(UTC).isoformat(timespec="seconds")}
580+
581+ async def _flush_or_quarantine(self, ctx: RunContext) -> None:
582+ observed = await self._observed_counts(ctx)
583+ full = self._full_extraction(ctx)
584+ async with transaction() as conn:
585+ row = await fetch_one(conn, "select baseline from connectors where name = :n", n=self.name)
586+ baseline = (row or {}).get("baseline")
587+ reason = None if ctx.force and ctx.file_overrides else self._quarantine_reason(observed, baseline, full_extraction=full)
588+ ctx.stats.meta["quarantine_check"] = {"observed": observed, "baseline": (baseline or {}).get("medians"), "full_extraction": full}
589+ if reason:
590+ ctx.quarantined = True
591+ ctx.quarantine_reason = reason
592+ ctx.log.warning("run quarantined", extra={"reason": reason, **observed})
593+ payload = [{"doc_id": b.doc_id, "snapshot_id": b.snapshot_id, "source_url": b.source_url, "fetched_at": b.fetched_at.isoformat(),
594+ "target": {"url": b.target.url, "doc_type": b.target.doc_type, "key": b.target.key, "needs_llm": b.target.needs_llm, "meta": _jsonable(b.target.meta)},
595+ "facts": facts_to_json(b.facts)} for b in ctx.buffered]
596+ qid = f"quar_{new_id('connector_run').split('_', 1)[1]}"
597+ async with transaction() as conn:
598+ await execute(conn, """insert into quarantined_runs (id, run_id, connector_name, reason, stats, facts) values (:id, :r, :c, :why, cast(:s as jsonb), cast(:f as jsonb))""",
599+ id=qid, r=ctx.run_id, c=self.name, why=reason, s=jsonb({"observed": observed, "baseline": baseline, "full_extraction": full, "documents": len(ctx.buffered)}),
600+ f=jsonb(payload))
601+ await execute(conn, "update connector_runs set quarantined = true, baseline = cast(:b as jsonb) where id = :id", b=jsonb(baseline), id=ctx.run_id)
602+ await execute(conn, "update snapshots set processing_status = 'quarantined' where id = any(cast(:ids as text[]))", ids=[b.snapshot_id for b in ctx.buffered])
603+ await execute(conn, """insert into review_queue (id, kind, entity_ids, payload, reason, dedupe_key) values (:id, 'quarantine', '{}', cast(:p as jsonb), :r, :d)
604+ on conflict (dedupe_key) do nothing""",
605+ id=new_id("review"), p=jsonb({"connector": self.name, "run_id": ctx.run_id, "quarantine_id": qid, "observed": observed,
606+ "baseline": (baseline or {}).get("medians")}),
607+ r=f"{self.name}: run held in quarantine — {reason}; nothing written, nothing deleted", d=f"quarantine:{qid}")
608+ ctx.buffered.clear()
609+ return
610+ for item in ctx.buffered:
611+ await self._write_buffered(ctx, item)
612+ ctx.buffered.clear()
613+ if full and not ctx.reprocess and not ctx.file_overrides:
614+ async with transaction() as conn:
615+ nb = self._next_baseline(baseline, observed)
616+ await execute(conn, "update connectors set baseline = cast(:b as jsonb) where name = :n", b=jsonb(nb), n=self.name)
617+ await execute(conn, "update connector_runs set baseline = cast(:b as jsonb) where id = :id", b=jsonb(nb), id=ctx.run_id)
435 618
436 async def _record(self, conn: Any, ctx: RunContext, status: str, *, error: str | None = None) -> None: 619 async def _record(self, conn: Any, ctx: RunContext, status: str, *, error: str | None = None) -> None:
437 s = ctx.stats 620 s = ctx.stats
modified src/aiatlas/sdk/facts.py +88 −0
@@ -227,6 +227,92 @@ EVENT_CATEGORY_BY_TYPE: dict[str, str] = {
227 "mcp_server": "tool", "researcher": "company", "license": "model", "product": "tool", "robot": "hardware", 227 "mcp_server": "tool", "researcher": "company", "license": "model", "product": "tool", "robot": "hardware",
228 } 228 }
229 229
230+
231+# ---------------------------------------------------------------------------------------------- JSON round-trip (quarantine)
232+# Quarantined runs hold their Facts in `quarantined_runs.facts` (jsonb) until an operator releases or discards them. Datetimes are
233+# tagged so they come back as real datetime objects (asyncpg needs them, not ISO strings). Entity references are inlined; the
234+# resolver caches by `EntityRef.key()`, so equal refs resolve to the same entity after a round-trip.
235+_DT_TAG = "__datetime__"
236+
237+
238+def _encode(value: Any) -> Any:
239+ if isinstance(value, datetime):
240+ return {_DT_TAG: value.isoformat()}
241+ if isinstance(value, EntityRef):
242+ return {"entity_type": value.entity_type, "name": value.name, "identifiers": dict(value.identifiers), "aliases": list(value.aliases),
243+ "slug_hint": value.slug_hint, "organization": _encode(value.organization), "description": value.description, "status": value.status,
244+ "attributes": _encode(value.attributes), "first_seen_hint": _encode(value.first_seen_hint), "family": _encode(value.family),
245+ "canonical": _encode(value.canonical), "artifact_kind": value.artifact_kind, "identity_confidence": value.identity_confidence, "id": value.id}
246+ if isinstance(value, dict):
247+ return {str(k): _encode(v) for k, v in value.items()}
248+ if isinstance(value, (list, tuple, set)):
249+ return [_encode(v) for v in value]
250+ if isinstance(value, (str, int, float, bool)) or value is None:
251+ return value
252+ return str(value)
253+
254+
255+def _decode(value: Any) -> Any:
256+ if isinstance(value, dict):
257+ if set(value) == {_DT_TAG}:
258+ return datetime.fromisoformat(value[_DT_TAG])
259+ return {k: _decode(v) for k, v in value.items()}
260+ if isinstance(value, list):
261+ return [_decode(v) for v in value]
262+ return value
263+
264+
265+def _ref_from(d: dict[str, Any] | None) -> EntityRef | None:
266+ if not d:
267+ return None
268+ return EntityRef(entity_type=d["entity_type"], name=d["name"], identifiers=dict(d.get("identifiers") or {}), aliases=list(d.get("aliases") or []),
269+ slug_hint=d.get("slug_hint"), organization=_ref_from(d.get("organization")), description=d.get("description"), status=d.get("status"),
270+ attributes=_decode(d.get("attributes") or {}), first_seen_hint=_decode(d.get("first_seen_hint")), family=_ref_from(d.get("family")),
271+ canonical=_ref_from(d.get("canonical")), artifact_kind=d.get("artifact_kind"), identity_confidence=d.get("identity_confidence"), id=d.get("id"))
272+
273+
274+def facts_to_json(facts: Facts) -> dict[str, Any]:
275+ """Serialise a Facts object to a JSON-compatible dict (see `facts_from_json`)."""
276+ def obj(o: Any) -> dict[str, Any]:
277+ return {k: _encode(v) for k, v in o.__dict__.items()}
278+
279+ return {
280+ "version": 1,
281+ "entities": [_encode(e) for e in facts.entities],
282+ "claims": [obj(c) for c in facts.claims],
283+ "relations": [obj(r) for r in facts.relations],
284+ "events": [obj(e) for e in facts.events],
285+ "prices": [obj(p) for p in facts.prices],
286+ "results": [obj(r) for r in facts.results],
287+ "targets": [obj(t) for t in facts.targets],
288+ "document_title": facts.document_title,
289+ "document_entity": _encode(facts.document_entity),
290+ "llm_hint": facts.llm_hint,
291+ }
292+
293+
294+def facts_from_json(data: dict[str, Any]) -> Facts:
295+ """Inverse of `facts_to_json`."""
296+ def ref_fields(d: dict[str, Any], *keys: str) -> dict[str, Any]:
297+ out = {k: _decode(v) for k, v in d.items() if k not in keys}
298+ for k in keys:
299+ out[k] = _ref_from(d.get(k))
300+ return out
301+
302+ facts = Facts()
303+ facts.entities = [r for r in (_ref_from(e) for e in data.get("entities") or []) if r]
304+ facts.claims = [Claim(**ref_fields(c, "entity")) for c in data.get("claims") or []]
305+ facts.relations = [Relation(**ref_fields(r, "subject", "object")) for r in data.get("relations") or []]
306+ facts.events = [Event(**ref_fields(e, "entity")) for e in data.get("events") or []]
307+ facts.prices = [PriceObs(**ref_fields(p, "model", "provider")) for p in data.get("prices") or []]
308+ facts.results = [ResultObs(**ref_fields(r, "model", "benchmark")) for r in data.get("results") or []]
309+ facts.targets = [Target(**ref_fields(t, "entity")) for t in data.get("targets") or []]
310+ facts.document_title = data.get("document_title")
311+ facts.document_entity = _ref_from(data.get("document_entity"))
312+ facts.llm_hint = data.get("llm_hint")
313+ return facts
314+
315+
230 __all__ = [ 316 __all__ = [
231 "CONFIDENCE", 317 "CONFIDENCE",
232 "EVENT_CATEGORY_BY_TYPE", 318 "EVENT_CATEGORY_BY_TYPE",
@@ -240,4 +326,6 @@ __all__ = [
240 "Relation", 326 "Relation",
241 "ResultObs", 327 "ResultObs",
242 "Target", 328 "Target",
329+ "facts_from_json",
330+ "facts_to_json",
243 ] 331 ]
modified src/aiatlas/sdk/fetch.py +187 −24
@@ -7,11 +7,13 @@ from __future__ import annotations
7 7
8 import asyncio 8 import asyncio
9 import hashlib 9 import hashlib
10+import ipaddress
10 import logging 11 import logging
12+import socket
11 import time 13 import time
12 from dataclasses import dataclass, field 14 from dataclasses import dataclass, field
13 from datetime import UTC, datetime 15 from datetime import UTC, datetime
14−from urllib.parse import urlparse, urlunparse 16+from urllib.parse import urljoin, urlparse, urlunparse
15 from urllib.robotparser import RobotFileParser 17 from urllib.robotparser import RobotFileParser
16 18
17 import httpx 19 import httpx
@@ -22,6 +24,100 @@ log = logging.getLogger(__name__)
22 24
23 TRANSIENT_STATUS = {408, 425, 429, 500, 502, 503, 504} 25 TRANSIENT_STATUS = {408, 425, 429, 500, 502, 503, 504}
24 BLOCK_STATUS = {401, 403, 451, 999} 26 BLOCK_STATUS = {401, 403, 451, 999}
27+REDIRECT_STATUS = {301, 302, 303, 307, 308}
28+MAX_REDIRECTS = 5
29+
30+# ---------------------------------------------------------------------------------------------- SSRF guard
31+# Follow-up targets come from crawled content (links in model cards, feeds, READMEs): every destination — and every redirect
32+# hop — is validated before a connection is opened. Nothing internal is ever fetched.
33+_BLOCKED_SUFFIXES = (".local", ".internal", ".localhost", ".localdomain", ".lan", ".home", ".corp", ".intranet")
34+_BLOCKED_HOSTS = {"localhost", "metadata.google.internal", "metadata", "instance-data", "kubernetes.default", "kubernetes.default.svc"}
35+_BLOCKED_NETWORKS = [ipaddress.ip_network(n) for n in (
36+ "0.0.0.0/8", # unspecified / "this network"
37+ "10.0.0.0/8", # RFC1918
38+ "100.64.0.0/10", # CGNAT
39+ "127.0.0.0/8", # loopback
40+ "169.254.0.0/16", # link-local (cloud metadata endpoints 169.254.169.254 …)
41+ "172.16.0.0/12", # RFC1918
42+ "192.0.0.0/24", # IETF protocol assignments
43+ "192.168.0.0/16", # RFC1918
44+ "198.18.0.0/15", # benchmarking
45+ "240.0.0.0/4", # reserved + broadcast
46+ "::/128", # unspecified
47+ "::1/128", # loopback
48+ "fc00::/7", # ULA
49+ "fe80::/10", # link-local
50+ "::ffff:0:0/96", # IPv4-mapped (checked again as IPv4 below)
51+ "64:ff9b::/96", # NAT64
52+)]
53+
54+
55+class BlockedDestination(ValueError):
56+ """The URL points at a private, local or otherwise non-public destination."""
57+
58+
59+def _ip_blocked(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
60+ if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
61+ ip = ip.ipv4_mapped
62+ if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_unspecified or ip.is_reserved or ip.is_multicast:
63+ return True
64+ return any(ip in net for net in _BLOCKED_NETWORKS)
65+
66+
67+def validate_destination(url: str, *, resolved_ips: list[str] | None = None) -> None:
68+ """Raise `BlockedDestination` for non-http(s) schemes, local/internal hostnames and private, loopback, link-local, CGNAT,
69+ IPv6 ULA/link-local or unspecified addresses. `resolved_ips` lets callers (and tests) inject the DNS answer."""
70+ p = urlparse(url.strip())
71+ if p.scheme.lower() not in ("http", "https"):
72+ raise BlockedDestination(f"blocked destination: scheme {p.scheme!r} for {url}")
73+ host = (p.hostname or "").strip().lower().rstrip(".")
74+ if not host:
75+ raise BlockedDestination(f"blocked destination: no host in {url}")
76+ if p.username or p.password:
77+ raise BlockedDestination(f"blocked destination: credentials in URL {url}")
78+ if host in _BLOCKED_HOSTS or host.endswith(_BLOCKED_SUFFIXES) or "." not in host and not _is_ip_literal(host):
79+ raise BlockedDestination(f"blocked destination: local host {host!r}")
80+ literal = _parse_ip(host)
81+ if literal is not None:
82+ if _ip_blocked(literal):
83+ raise BlockedDestination(f"blocked destination: non-public address {host}")
84+ return
85+ ips = resolved_ips if resolved_ips is not None else _resolve(host, p.port or (443 if p.scheme.lower() == "https" else 80))
86+ for raw in ips:
87+ ip = _parse_ip(raw)
88+ if ip is not None and _ip_blocked(ip):
89+ raise BlockedDestination(f"blocked destination: {host} resolves to non-public address {raw}")
90+
91+
92+def _is_ip_literal(host: str) -> bool:
93+ return _parse_ip(host) is not None
94+
95+
96+def _parse_ip(host: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
97+ try:
98+ return ipaddress.ip_address(host.strip("[]").split("%")[0])
99+ except ValueError:
100+ return None
101+
102+
103+def _resolve(host: str, port: int) -> list[str]:
104+ try:
105+ infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
106+ except socket.gaierror:
107+ return [] # unresolvable hosts fail later with a transport error, not as SSRF
108+ return sorted({info[4][0] for info in infos})
109+
110+
111+async def validate_destination_async(url: str) -> None:
112+ """Non-blocking wrapper (DNS resolution runs in a thread)."""
113+ p = urlparse(url.strip())
114+ host = (p.hostname or "").strip().lower()
115+ if not host or _parse_ip(host) is not None:
116+ validate_destination(url, resolved_ips=[])
117+ return
118+ validate_destination(url, resolved_ips=[]) # scheme / hostname rules first (no DNS)
119+ ips = await asyncio.to_thread(_resolve, host, p.port or (443 if p.scheme.lower() == "https" else 80))
120+ validate_destination(url, resolved_ips=ips)
25 121
26 122
27 class FetchError(Exception): 123 class FetchError(Exception):
@@ -138,7 +234,7 @@ class _Robots:
138 if cached is None or cached[0] < time.monotonic(): 234 if cached is None or cached[0] < time.monotonic():
139 rp: RobotFileParser | None = RobotFileParser() 235 rp: RobotFileParser | None = RobotFileParser()
140 try: 236 try:
141− r = await client.get(f"{key}/robots.txt", timeout=15, follow_redirects=True) 237+ r = await client.get(f"{key}/robots.txt", timeout=15, follow_redirects=False) # host already validated by the caller
142 if r.status_code == 200 and len(r.content) < 512 * 1024: 238 if r.status_code == 200 and len(r.content) < 512 * 1024:
143 rp.parse(r.text.splitlines()) # type: ignore[union-attr] 239 rp.parse(r.text.splitlines()) # type: ignore[union-attr]
144 else: 240 else:
@@ -170,7 +266,8 @@ class Fetcher:
170 self._http2 = http2 266 self._http2 = http2
171 267
172 async def __aenter__(self) -> Fetcher: 268 async def __aenter__(self) -> Fetcher:
173− self._client = httpx.AsyncClient(headers=self.headers, timeout=httpx.Timeout(self.timeout_s, connect=20), follow_redirects=True, 269+ # redirects are followed manually in `get()` so that every hop goes through the SSRF guard
270+ self._client = httpx.AsyncClient(headers=self.headers, timeout=httpx.Timeout(self.timeout_s, connect=20), follow_redirects=False,
174 http2=self._http2, limits=httpx.Limits(max_connections=16, max_keepalive_connections=8)) 271 http2=self._http2, limits=httpx.Limits(max_connections=16, max_keepalive_connections=8))
175 return self 272 return self
176 273
@@ -187,9 +284,13 @@ class Fetcher:
187 284
188 async def get(self, url: str, *, etag: str | None = None, last_modified: str | None = None, min_bytes: int = 1, 285 async def get(self, url: str, *, etag: str | None = None, last_modified: str | None = None, min_bytes: int = 1,
189 escalate: bool = False, retries: int = 2, accept: str | None = None, rate_per_min: int | None = None) -> FetchResult: 286 escalate: bool = False, retries: int = 2, accept: str | None = None, rate_per_min: int | None = None) -> FetchResult:
190− """Direct fetch with conditional headers. Raises NotModified (304), FetchError, BlockedError.""" 287+ """Direct fetch with conditional headers. Raises NotModified (304), FetchError, BlockedError.
288+ Every destination and every redirect hop (max 5) is validated against the SSRF guard before a connection is opened."""
191 client = self.client 289 client = self.client
192− domain = domain_of(url) 290+ try:
291+ await validate_destination_async(url)
292+ except BlockedDestination as exc:
293+ raise FetchError(f"blocked destination: {exc}", url=url) from exc
193 if self.robots and not await _robots.allowed(client, url): 294 if self.robots and not await _robots.allowed(client, url):
194 raise BlockedError(f"robots.txt disallows {url}", status=None, url=url) 295 raise BlockedError(f"robots.txt disallows {url}", status=None, url=url)
195 headers: dict[str, str] = {} 296 headers: dict[str, str] = {}
@@ -200,13 +301,34 @@ class Fetcher:
200 if accept: 301 if accept:
201 headers["Accept"] = accept 302 headers["Accept"] = accept
202 last_exc: Exception | None = None 303 last_exc: Exception | None = None
304+ current = url
305+ hops = 0
203 for attempt in range(retries + 1): 306 for attempt in range(retries + 1):
204− await _limiter.wait(domain, rate_per_min or self.rate_per_min) 307+ await _limiter.wait(domain_of(current), rate_per_min or self.rate_per_min)
205 t0 = time.perf_counter() 308 t0 = time.perf_counter()
206 try: 309 try:
207− async with client.stream("GET", url, headers=headers) as r: 310+ async with client.stream("GET", current, headers=headers) as r:
208 if r.status_code == 304: 311 if r.status_code == 304:
209 raise NotModified() 312 raise NotModified()
313+ if r.status_code in REDIRECT_STATUS:
314+ location = r.headers.get("location")
315+ if not location:
316+ raise FetchError(f"http {r.status_code} without Location for {current}", status=r.status_code, url=url)
317+ hops += 1
318+ if hops > MAX_REDIRECTS:
319+ raise FetchError(f"too many redirects (> {MAX_REDIRECTS}) for {url}", status=r.status_code, url=url)
320+ nxt = urljoin(current, location)
321+ try:
322+ await validate_destination_async(nxt)
323+ except BlockedDestination as exc:
324+ raise FetchError(f"blocked destination: redirect {current} → {nxt}: {exc}", status=r.status_code, url=url) from exc
325+ if domain_of(nxt) != domain_of(current):
326+ headers.pop("If-None-Match", None) # validators belong to the original resource only
327+ headers.pop("If-Modified-Since", None)
328+ current = nxt
329+ # a redirect is not a retry: loop again without consuming the attempt budget
330+ return await self._follow(client, current, headers, url=url, hops=hops, min_bytes=min_bytes, escalate=escalate,
331+ retries=retries, rate_per_min=rate_per_min)
210 if r.status_code in TRANSIENT_STATUS and attempt < retries: 332 if r.status_code in TRANSIENT_STATUS and attempt < retries:
211 retry_after = r.headers.get("retry-after") 333 retry_after = r.headers.get("retry-after")
212 delay = min(60.0, float(retry_after)) if retry_after and retry_after.isdigit() else 2.0 * (2 ** attempt) 334 delay = min(60.0, float(retry_after)) if retry_after and retry_after.isdigit() else 2.0 * (2 ** attempt)
@@ -219,22 +341,7 @@ class Fetcher:
219 raise BlockedError(f"http {r.status_code} for {url}", status=r.status_code, url=url) 341 raise BlockedError(f"http {r.status_code} for {url}", status=r.status_code, url=url)
220 if r.status_code >= 400: 342 if r.status_code >= 400:
221 raise FetchError(f"http {r.status_code} for {url}", status=r.status_code, url=url) 343 raise FetchError(f"http {r.status_code} for {url}", status=r.status_code, url=url)
222− chunks: list[bytes] = [] 344+ return await self._read(r, url=url, final_url=current, t0=t0, min_bytes=min_bytes, escalate=escalate)
223− size = 0
224− async for chunk in r.aiter_bytes():
225− size += len(chunk)
226− if size > settings.max_body_bytes:
227− raise FetchError(f"body exceeds {settings.max_body_bytes} bytes", status=r.status_code, url=url)
228− chunks.append(chunk)
229− content = b"".join(chunks)
230− if len(content) < min_bytes:
231− raise FetchError(f"suspiciously short body ({len(content)} bytes) for {url}", status=r.status_code, url=url)
232− res = FetchResult(url=url, final_url=str(r.url), status=r.status_code, headers={k.lower(): v for k, v in r.headers.items()},
233− content=content, content_type=r.headers.get("content-type", "").lower(), fetched_at=datetime.now(UTC),
234− duration_ms=int((time.perf_counter() - t0) * 1000), transport="direct")
235− if escalate and res.is_html and _looks_like_challenge(content):
236− return await self._escalate(url, reason="anti-bot challenge page")
237− return res
238 except NotModified: 345 except NotModified:
239 raise 346 raise
240 except (BlockedError, FetchError): 347 except (BlockedError, FetchError):
@@ -247,6 +354,61 @@ class Fetcher:
247 raise FetchError(f"{exc.__class__.__name__}: {exc}", url=url) from exc 354 raise FetchError(f"{exc.__class__.__name__}: {exc}", url=url) from exc
248 raise FetchError(f"fetch failed: {last_exc}", url=url) 355 raise FetchError(f"fetch failed: {last_exc}", url=url)
249 356
357+ async def _follow(self, client: httpx.AsyncClient, current: str, headers: dict[str, str], *, url: str, hops: int, min_bytes: int,
358+ escalate: bool, retries: int, rate_per_min: int | None) -> FetchResult:
359+ """Continue a redirect chain: each hop is validated, capped at MAX_REDIRECTS, rate-limited per domain."""
360+ while True:
361+ await _limiter.wait(domain_of(current), rate_per_min or self.rate_per_min)
362+ t0 = time.perf_counter()
363+ try:
364+ async with client.stream("GET", current, headers=headers) as r:
365+ if r.status_code in REDIRECT_STATUS:
366+ location = r.headers.get("location")
367+ if not location:
368+ raise FetchError(f"http {r.status_code} without Location for {current}", status=r.status_code, url=url)
369+ hops += 1
370+ if hops > MAX_REDIRECTS:
371+ raise FetchError(f"too many redirects (> {MAX_REDIRECTS}) for {url}", status=r.status_code, url=url)
372+ nxt = urljoin(current, location)
373+ try:
374+ await validate_destination_async(nxt)
375+ except BlockedDestination as exc:
376+ raise FetchError(f"blocked destination: redirect {current} → {nxt}: {exc}", status=r.status_code, url=url) from exc
377+ if domain_of(nxt) != domain_of(current):
378+ headers.pop("If-None-Match", None)
379+ headers.pop("If-Modified-Since", None)
380+ current = nxt
381+ continue
382+ if r.status_code == 304:
383+ raise NotModified()
384+ if r.status_code in BLOCK_STATUS:
385+ if escalate:
386+ return await self._escalate(url, reason=f"http {r.status_code}")
387+ raise BlockedError(f"http {r.status_code} for {current}", status=r.status_code, url=url)
388+ if r.status_code >= 400:
389+ raise FetchError(f"http {r.status_code} for {current}", status=r.status_code, url=url)
390+ return await self._read(r, url=url, final_url=current, t0=t0, min_bytes=min_bytes, escalate=escalate)
391+ except (httpx.TimeoutException, httpx.TransportError) as exc:
392+ raise FetchError(f"{exc.__class__.__name__}: {exc}", url=url) from exc
393+
394+ async def _read(self, r: httpx.Response, *, url: str, final_url: str, t0: float, min_bytes: int, escalate: bool) -> FetchResult:
395+ chunks: list[bytes] = []
396+ size = 0
397+ async for chunk in r.aiter_bytes():
398+ size += len(chunk)
399+ if size > settings.max_body_bytes:
400+ raise FetchError(f"body exceeds {settings.max_body_bytes} bytes", status=r.status_code, url=url)
401+ chunks.append(chunk)
402+ content = b"".join(chunks)
403+ if len(content) < min_bytes:
404+ raise FetchError(f"suspiciously short body ({len(content)} bytes) for {url}", status=r.status_code, url=url)
405+ res = FetchResult(url=url, final_url=final_url, status=r.status_code, headers={k.lower(): v for k, v in r.headers.items()},
406+ content=content, content_type=r.headers.get("content-type", "").lower(), fetched_at=datetime.now(UTC),
407+ duration_ms=int((time.perf_counter() - t0) * 1000), transport="direct")
408+ if escalate and res.is_html and _looks_like_challenge(content):
409+ return await self._escalate(url, reason="anti-bot challenge page")
410+ return res
411+
250 # ---------------------------------------------------------------------------------------------- escalation (optional) 412 # ---------------------------------------------------------------------------------------------- escalation (optional)
251 async def _escalate(self, url: str, *, reason: str) -> FetchResult: 413 async def _escalate(self, url: str, *, reason: str) -> FetchResult:
252 log.warning("escalating fetch", extra={"url": url, "reason": reason}) 414 log.warning("escalating fetch", extra={"url": url, "reason": reason})
@@ -326,4 +488,5 @@ def file_result(path: str, *, url: str, content_type: str = "application/octet-s
326 _limiter = _RateLimiter() 488 _limiter = _RateLimiter()
327 _robots = _Robots() 489 _robots = _Robots()
328 490
329−__all__ = ["BlockedError", "FetchError", "FetchResult", "Fetcher", "NotModified", "canonicalize_url", "domain_of", "file_result"] 491+__all__ = ["MAX_REDIRECTS", "BlockedDestination", "BlockedError", "FetchError", "FetchResult", "Fetcher", "NotModified", "canonicalize_url", "domain_of",
492+ "file_result", "validate_destination", "validate_destination_async"]
modified src/aiatlas/sdk/resolution.py +124 −11
@@ -3,11 +3,19 @@
3 1. identifiers (scheme, value) → exact entity 3 1. identifiers (scheme, value) → exact entity
4 2. normalized alias within the same entity type (disambiguated by organization when several match) 4 2. normalized alias within the same entity type (disambiguated by organization when several match)
5 3. slug collision within the same type 5 3. slug collision within the same type
6− 4. otherwise create, and park ambiguous cases in the review queue as merge candidates 6+ 4. evaluation-effort variants of an existing model ("gpt-5-4-mini-medium") fold onto the canonical model — they are a result
7+ configuration, never an entity of their own
8+ 5. otherwise create, and park ambiguous cases in the review queue as merge candidates
9+
10+Guards: persisted `resolution_decisions` (keep_separate) are honoured — two entities an operator kept apart are never re-fused by an
11+alias match; aliases whose normalisation collapses digits and dots ("Qwen3-8B" ≡ "Qwen 38B") additionally require the same
12+`variant_key`; `model` and `artifact` are compatible types for lookups (an artifact used to be typed model — the same hub repo must
13+keep resolving to the same row).
7 """ 14 """
8 from __future__ import annotations 15 from __future__ import annotations
9 16
10 import logging 17 import logging
18+import re
11 from datetime import UTC, datetime 19 from datetime import UTC, datetime
12 from typing import Any 20 from typing import Any
13 21
@@ -15,6 +23,7 @@ from sqlalchemy.ext.asyncio import AsyncConnection
15 23
16 from aiatlas.db import execute, fetch_all, fetch_one, jsonb 24 from aiatlas.db import execute, fetch_all, fetch_one, jsonb
17 from aiatlas.ids import ENTITY_TYPES, new_id, normalize_alias, slugify 25 from aiatlas.ids import ENTITY_TYPES, new_id, normalize_alias, slugify
26+from aiatlas.ontology.models import analyze_model_name, base_name, variant_key
18 from aiatlas.sdk.facts import EntityRef 27 from aiatlas.sdk.facts import EntityRef
19 28
20 log = logging.getLogger(__name__) 29 log = logging.getLogger(__name__)
@@ -22,16 +31,28 @@ log = logging.getLogger(__name__)
22 # Types whose slug should be prefixed by the organization slug to stay unique and readable (models: `qwen-qwen3-8b` is ugly → 31 # Types whose slug should be prefixed by the organization slug to stay unique and readable (models: `qwen-qwen3-8b` is ugly →
23 # we keep the model name and only prefix on collision). 32 # we keep the model name and only prefix on collision).
24 GENERIC_NAMES = {"model", "models", "api", "pricing", "docs", "blog", "news", "research", "overview"} 33 GENERIC_NAMES = {"model", "models", "api", "pricing", "docs", "blog", "news", "research", "overview"}
34+# lookup-compatible type groups: a ref of one type may resolve to a stored entity of another type in the same group
35+_COMPATIBLE: dict[str, tuple[str, ...]] = {"model": ("model", "artifact"), "artifact": ("artifact", "model")}
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")
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"})
40+
41+
42+def compatible_types(entity_type: str) -> tuple[str, ...]:
43+ return _COMPATIBLE.get(entity_type, (entity_type,))
25 44
26 45
27 class Resolver: 46 class Resolver:
28− def __init__(self, conn: AsyncConnection, *, snapshot_id: str | None = None, source_tier: int = 2): 47+ def __init__(self, conn: AsyncConnection, *, snapshot_id: str | None = None, source_tier: int = 2, variant_index: dict[str, str] | None = None):
29 self.conn = conn 48 self.conn = conn
30 self.snapshot_id = snapshot_id 49 self.snapshot_id = snapshot_id
31 self.tier = source_tier 50 self.tier = source_tier
51+ self.variant_index = variant_index # optional precomputed variant_key → canonical model id (canonicalization)
32 self._cache: dict[str, str] = {} 52 self._cache: dict[str, str] = {}
33 self.created: list[str] = [] 53 self.created: list[str] = []
34 self.updated: set[str] = set() 54 self.updated: set[str] = set()
55+ self.folded: dict[str, tuple[str, dict[str, str]]] = {} # ref key → (canonical id, effort config) for folded variants
35 56
36 async def resolve(self, ref: EntityRef, *, create: bool = True) -> str | None: 57 async def resolve(self, ref: EntityRef, *, create: bool = True) -> str | None:
37 if ref.id: 58 if ref.id:
@@ -51,6 +72,12 @@ class Resolver:
51 found = await self._by_alias(ref, org_id) 72 found = await self._by_alias(ref, org_id)
52 if found is None: 73 if found is None:
53 found = await self._by_slug(ref) 74 found = await self._by_slug(ref)
75+ if found is None and ref.entity_type == "model" and self.tier >= 2 and set(ref.identifiers) <= EVALUATOR_SCHEMES and not ref.attributes.get("hf_repo"):
76+ # only evaluator-only references fold: a ref carrying official/hub identifiers (or a tier-1 source) names a real model
77+ variant = await self.resolve_variant(ref, org_id=org_id)
78+ if variant:
79+ found = variant[0]
80+ self.folded[key] = variant
54 if found is None and not create: 81 if found is None and not create:
55 return None 82 return None
56 if found is None: 83 if found is None:
@@ -64,12 +91,13 @@ class Resolver:
64 91
65 # ---------------------------------------------------------------------------------------------- lookups 92 # ---------------------------------------------------------------------------------------------- lookups
66 async def _by_identifiers(self, ref: EntityRef) -> str | None: 93 async def _by_identifiers(self, ref: EntityRef) -> str | None:
94+ types = compatible_types(ref.entity_type)
67 for scheme, value in ref.identifiers.items(): 95 for scheme, value in ref.identifiers.items():
68 row = await fetch_one(self.conn, """select ei.entity_id, e.entity_type, e.merged_into from entity_identifiers ei 96 row = await fetch_one(self.conn, """select ei.entity_id, e.entity_type, e.merged_into from entity_identifiers ei
69 join entities e on e.id = ei.entity_id where ei.scheme = :s and ei.value = :v""", 97 join entities e on e.id = ei.entity_id where ei.scheme = :s and ei.value = :v""",
70 s=scheme, v=str(value)) 98 s=scheme, v=str(value))
71 if row: 99 if row:
72− if row["entity_type"] != ref.entity_type: 100+ if row["entity_type"] not in types:
73 log.warning("identifier type mismatch", extra={"scheme": scheme, "value": value, "have": row["entity_type"], "want": ref.entity_type}) 101 log.warning("identifier type mismatch", extra={"scheme": scheme, "value": value, "have": row["entity_type"], "want": ref.entity_type})
74 continue 102 continue
75 return row["merged_into"] or row["entity_id"] 103 return row["merged_into"] or row["entity_id"]
@@ -89,12 +117,27 @@ class Resolver:
89 norms = {normalize_alias(n) for n in names if n and normalize_alias(n)} 117 norms = {normalize_alias(n) for n in names if n and normalize_alias(n)}
90 if not norms: 118 if not norms:
91 return None 119 return None
92− rows = await fetch_all(self.conn, """select distinct e.id, e.organization_id, e.canonical_name, e.merged_into from entity_aliases a 120+ rows = await fetch_all(self.conn, """select distinct e.id, e.organization_id, e.canonical_name, e.merged_into, a.alias from entity_aliases a
93 join entities e on e.id = a.entity_id 121 join entities e on e.id = a.entity_id
94− where a.alias_norm = any(cast(:norms as text[])) and e.entity_type = :t""", norms=list(norms), t=ref.entity_type) 122+ where a.alias_norm = any(cast(:norms as text[])) and e.entity_type = any(cast(:types as text[]))""",
123+ norms=list(norms), types=list(compatible_types(ref.entity_type)))
95 if not rows: 124 if not rows:
96 return None 125 return None
97 rows = [{**r, "id": r["merged_into"] or r["id"]} for r in rows] 126 rows = [{**r, "id": r["merged_into"] or r["id"]} for r in rows]
127+ # collision safety: when the normalised alias erases digit separators, the candidate must share the ref's variant key
128+ # (or match one of the ref's names textually) — "Qwen 38B" must not land on "Qwen3-8B"
129+ if ref.entity_type in ("model", "artifact") and any(_DIGIT_SEP_DIGIT.search(n) for n in names if n):
130+ lower_names = {n.strip().lower() for n in names if n}
131+ ref_vk = variant_key(ref.name)
132+ safe = []
133+ for r in rows:
134+ if (r["alias"] or "").strip().lower() in lower_names or variant_key(r["canonical_name"]) == ref_vk:
135+ safe.append(r)
136+ else:
137+ log.info("alias collision refused", extra={"name": ref.name, "candidate": r["canonical_name"]})
138+ rows = safe
139+ if not rows:
140+ return None
98 kept = [] 141 kept = []
99 for r in rows: 142 for r in rows:
100 if await self._conflicting_identifier(r["id"], ref): 143 if await self._conflicting_identifier(r["id"], ref):
@@ -106,6 +149,13 @@ class Resolver:
106 if not rows: 149 if not rows:
107 return None 150 return None
108 ids = {r["id"] for r in rows} 151 ids = {r["id"] for r in rows}
152+ # persisted decisions: a candidate an operator kept separate from another entity is only accepted with a matching organisation
153+ separate = await self._kept_separate(sorted(ids))
154+ if separate:
155+ rows = [r for r in rows if r["id"] not in separate or (org_id and r["organization_id"] == org_id)]
156+ ids = {r["id"] for r in rows}
157+ if not rows:
158+ return None
109 if len(ids) == 1: 159 if len(ids) == 1:
110 return rows[0]["id"] 160 return rows[0]["id"]
111 if org_id: 161 if org_id:
@@ -117,22 +167,82 @@ class Resolver:
117 {"name": ref.name, "identifiers": ref.identifiers}) 167 {"name": ref.name, "identifiers": ref.identifiers})
118 return None 168 return None
119 169
170+ async def _kept_separate(self, ids: list[str]) -> set[str]:
171+ """Ids among `ids` that carry a `keep_separate` decision with any entity."""
172+ if not ids:
173+ return set()
174+ rows = await fetch_all(self.conn, """select a_id, b_id from resolution_decisions where decision = 'keep_separate'
175+ and (a_id = any(cast(:ids as text[])) or b_id = any(cast(:ids as text[])))""", ids=ids)
176+ out: set[str] = set()
177+ for r in rows:
178+ out.update(x for x in (r["a_id"], r["b_id"]) if x in ids)
179+ return out
180+
181+ async def kept_separate(self, a: str, b: str) -> bool:
182+ row = await fetch_one(self.conn, """select 1 from resolution_decisions where decision = 'keep_separate'
183+ and ((a_id = :a and b_id = :b) or (a_id = :b and b_id = :a)) limit 1""", a=a, b=b)
184+ return row is not None
185+
120 async def _by_slug(self, ref: EntityRef) -> str | None: 186 async def _by_slug(self, ref: EntityRef) -> str | None:
121 slug = ref.slug_hint or slugify(ref.name) 187 slug = ref.slug_hint or slugify(ref.name)
122 row = await fetch_one(self.conn, "select id, entity_type, organization_id, merged_into from entities where slug = :s", s=slug) 188 row = await fetch_one(self.conn, "select id, entity_type, organization_id, merged_into from entities where slug = :s", s=slug)
123− if row and row["entity_type"] == ref.entity_type and not await self._conflicting_identifier(row["merged_into"] or row["id"], ref): 189+ if row and row["entity_type"] in compatible_types(ref.entity_type) and not await self._conflicting_identifier(row["merged_into"] or row["id"], ref):
124 return row["merged_into"] or row["id"] 190 return row["merged_into"] or row["id"]
125 return None 191 return None
126 192
193+ async def resolve_variant(self, ref: EntityRef, *, org_id: str | None = None) -> tuple[str, dict[str, str]] | None:
194+ """Evaluation-effort variant ("claude-opus-5-medium", "qwen3-6-27b-non-reasoning") → (canonical model id, effort config) when a
195+ canonical model exists: same `artificial_analysis` identifier or alias as the base name, or the same `variant_key`
196+ (through the optional precomputed index). Returns None when the base cannot be resolved or is ambiguous."""
197+ a = analyze_model_name(ref.name)
198+ if not a.is_effort_variant:
199+ return None
200+ base = base_name(ref.name)
201+ base_norms = {normalize_alias(base), normalize_alias(a.base_key)} - {""}
202+ rows = await fetch_all(self.conn, """select distinct e.id, e.canonical_name, e.organization_id, e.merged_into from entities e
203+ left join entity_identifiers ei on ei.entity_id = e.id
204+ left join entity_aliases al on al.entity_id = e.id
205+ where e.entity_type = 'model' and e.merged_into is null
206+ and ((ei.scheme = 'artificial_analysis' and ei.value = any(cast(:bases as text[])))
207+ or al.alias_norm = any(cast(:norms as text[])) or e.slug = any(cast(:bases as text[])))""",
208+ bases=sorted({base.lower(), a.base_key}), norms=sorted(base_norms))
209+ candidates = {r["id"]: r for r in rows if r["id"] != ref.id}
210+ if self.variant_index:
211+ vk = variant_key(base)
212+ cid = self.variant_index.get(vk)
213+ if cid and cid != ref.id and cid not in candidates:
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)
215+ if row:
216+ candidates[cid] = row
217+ # never fold onto another effort variant
218+ candidates = {k: v for k, v in candidates.items() if not analyze_model_name(v["canonical_name"]).is_effort_variant}
219+ if not candidates:
220+ return None
221+ if len(candidates) > 1 and org_id:
222+ same = {k: v for k, v in candidates.items() if v["organization_id"] == org_id}
223+ if same:
224+ candidates = same
225+ if len(candidates) != 1:
226+ await self._review("merge_candidate", sorted(candidates), f"effort variant '{ref.name}' matches {len(candidates)} base models",
227+ {"name": ref.name, "base": base})
228+ return None
229+ cid = next(iter(candidates))
230+ return cid, dict(a.effort)
231+
127 # ---------------------------------------------------------------------------------------------- writes 232 # ---------------------------------------------------------------------------------------------- writes
128 async def _create(self, ref: EntityRef, org_id: str | None) -> str: 233 async def _create(self, ref: EntityRef, org_id: str | None) -> str:
129 eid = new_id(ref.entity_type) 234 eid = new_id(ref.entity_type)
130 slug = await self._unique_slug(ref, org_id) 235 slug = await self._unique_slug(ref, org_id)
131− first_seen = datetime.now(UTC) 236+ now = datetime.now(UTC)
132− await execute(self.conn, """insert into entities (id, entity_type, canonical_name, slug, description, status, organization_id, first_seen_at, last_seen_at) 237+ first_seen = now
133− values (:id, :t, :n, :slug, :d, :status, :org, :fs, now())""", 238+ if ref.first_seen_hint is not None:
239+ hint = ref.first_seen_hint if ref.first_seen_hint.tzinfo else ref.first_seen_hint.replace(tzinfo=UTC)
240+ first_seen = min(now, hint)
241+ await execute(self.conn, """insert into entities (id, entity_type, canonical_name, slug, description, status, organization_id, first_seen_at, last_seen_at,
242+ identity_confidence, artifact_kind)
243+ values (:id, :t, :n, :slug, :d, :status, :org, :fs, now(), :ic, :ak)""",
134 id=eid, t=ref.entity_type, n=ref.name.strip()[:300], slug=slug, d=(ref.description or None), status=ref.status or "active", 244 id=eid, t=ref.entity_type, n=ref.name.strip()[:300], slug=slug, d=(ref.description or None), status=ref.status or "active",
135− org=org_id, fs=first_seen) 245+ org=org_id, fs=first_seen, ic=ref.identity_confidence or "high", ak=ref.artifact_kind if ref.entity_type == "artifact" else None)
136 self.created.append(eid) 246 self.created.append(eid)
137 return eid 247 return eid
138 248
@@ -145,6 +255,9 @@ class Resolver:
145 if ref.description: 255 if ref.description:
146 sets.append("description = case when description is null or length(description) < 40 then :d else description end") 256 sets.append("description = case when description is null or length(description) < 40 then :d else description end")
147 params["d"] = ref.description 257 params["d"] = ref.description
258+ if ref.first_seen_hint is not None:
259+ sets.append("first_seen_at = least(first_seen_at, :fs)")
260+ params["fs"] = ref.first_seen_hint if ref.first_seen_hint.tzinfo else ref.first_seen_hint.replace(tzinfo=UTC)
148 await execute(self.conn, f"update entities set {', '.join(sets)} where id = :id", **params) 261 await execute(self.conn, f"update entities set {', '.join(sets)} where id = :id", **params)
149 self.updated.add(eid) 262 self.updated.add(eid)
150 263
@@ -185,4 +298,4 @@ class Resolver:
185 on conflict (dedupe_key) do nothing""", id=new_id("review"), k=kind, ids=entity_ids, p=jsonb(payload), r=reason, d=dedupe) 298 on conflict (dedupe_key) do nothing""", id=new_id("review"), k=kind, ids=entity_ids, p=jsonb(payload), r=reason, d=dedupe)
186 299
187 300
188−__all__ = ["Resolver"] 301+__all__ = ["EVALUATOR_SCHEMES", "Resolver", "compatible_types"]
modified src/aiatlas/sdk/writer.py +226 −69
@@ -3,9 +3,14 @@
3 Temporal rules (per entity × property): 3 Temporal rules (per entity × property):
4 * no current claim → insert current claim, set attribute, NEW_* event for new entities 4 * no current claim → insert current claim, set attribute, NEW_* event for new entities
5 * same value → confirm (observed_at bumped), nothing else 5 * same value → confirm (observed_at bumped), nothing else
6+ * same assertion, older encoding (taxonomy) → re-encode the current claim in place (canonical value + value_raw), never an event
6 * different, source ≥ tier → supersede (valid_to = observed), insert new current, update attribute, CHANGE event for material props 7 * different, source ≥ tier → supersede (valid_to = observed), insert new current, update attribute, CHANGE event for material props
8+ * different, same source AND same extractor → supersede (a source correcting itself); an LLM claim never supersedes a deterministic
9+ claim from the same URL — it is stored as conflicting
7 * different, worse source → store as `conflicting`, flag confidence, review-queue item — never overwrite 10 * different, worse source → store as `conflicting`, flag confidence, review-queue item — never overwrite
8−Prices and benchmark results have their own append-only tables with the same close/open semantics. 11+Prices and benchmark results have their own append-only tables with the same close/open semantics; results additionally keep ONE
12+current row per (model, benchmark, metric, config_key): a newer run group closes the older rows so leaderboards show the latest run.
13+Every fact carries `run_id` (batch rollback) and every event carries `recorded_at`, `is_backfill` and `group_key`.
9 """ 14 """
10 from __future__ import annotations 15 from __future__ import annotations
11 16
@@ -19,6 +24,10 @@ from sqlalchemy.ext.asyncio import AsyncConnection
19 24
20 from aiatlas.db import execute, fetch_one, jsonb 25 from aiatlas.db import execute, fetch_one, jsonb
21 from aiatlas.ids import new_id, normalize_alias 26 from aiatlas.ids import new_id, normalize_alias
27+from aiatlas.ontology import benchmarks as bench_ontology
28+from aiatlas.ontology.anomalies import check_price_movement, check_result
29+from aiatlas.ontology.models import effort_config
30+from aiatlas.ontology.taxonomy import TAXONOMY_PROPERTIES, normalize_property
22 from aiatlas.sdk.facts import ( 31 from aiatlas.sdk.facts import (
23 EVENT_CATEGORY_BY_TYPE, 32 EVENT_CATEGORY_BY_TYPE,
24 MATERIAL_PROPERTIES, 33 MATERIAL_PROPERTIES,
@@ -29,13 +38,18 @@ from aiatlas.sdk.facts import (
29 ResultObs, 38 ResultObs,
30 ) 39 )
31 from aiatlas.sdk.resolution import Resolver 40 from aiatlas.sdk.resolution import Resolver
41+from aiatlas.services.events import classify_backfill, group_key_for, importance_for
32 42
33 log = logging.getLogger(__name__) 43 log = logging.getLogger(__name__)
34 44
35 TIER_CONFIDENCE = {1: "high", 2: "medium", 3: "low", 4: "low"} 45 TIER_CONFIDENCE = {1: "high", 2: "medium", 3: "low", 4: "low"}
36 SOFT_PROPERTIES = {"description", "summary", "tagline", "availability_note", "abstract", "notes", "training_data_notes", "safety_notes", "hardware_requirements"} 46 SOFT_PROPERTIES = {"description", "summary", "tagline", "availability_note", "abstract", "notes", "training_data_notes", "safety_notes", "hardware_requirements"}
47+# derived / secondary encodings of another property: never their own change event
48+NO_EVENT_PROPERTIES = {"license_key"}
37 NEW_IMPORTANCE = {"model": 3, "company": 2, "provider": 2, "paper": 1, "dataset": 1, "benchmark": 2, "framework": 1, "hardware": 2, 49 NEW_IMPORTANCE = {"model": 3, "company": 2, "provider": 2, "paper": 1, "dataset": 1, "benchmark": 2, "framework": 1, "hardware": 2,
38− "tool": 1, "repository": 0, "release": 2, "regulation": 2, "incident": 2, "organization": 1, "researcher": 0} 50+ "tool": 1, "repository": 0, "release": 2, "regulation": 2, "incident": 2, "organization": 1, "researcher": 0,
51+ "artifact": 0, "model_family": 1, "license": 0}
52+SILENT_NEW_TYPES = {"researcher", "country", "license", "quantization"}
39 53
40 54
41 def _norm_value(v: Any) -> Any: 55 def _norm_value(v: Any) -> Any:
@@ -55,6 +69,14 @@ def _same(a: Any, b: Any) -> bool:
55 return json.dumps(_norm_value(a), sort_keys=True, default=str) == json.dumps(_norm_value(b), sort_keys=True, default=str) 69 return json.dumps(_norm_value(a), sort_keys=True, default=str) == json.dumps(_norm_value(b), sort_keys=True, default=str)
56 70
57 71
72+def _cfg_hash(config: dict[str, Any] | None) -> str:
73+ return hashlib.sha1(json.dumps(config or {}, sort_keys=True, default=str).encode()).hexdigest()[:12]
74+
75+
76+def result_dedupe_key(model_id: str, benchmark_id: str, config: dict[str, Any] | None, metric: str | None) -> str:
77+ return f"{model_id}:{benchmark_id}:{_cfg_hash(config)}:{metric or ''}"
78+
79+
58 class WriteStats: 80 class WriteStats:
59 def __init__(self) -> None: 81 def __init__(self) -> None:
60 self.entities_created = 0 82 self.entities_created = 0
@@ -65,6 +87,7 @@ class WriteStats:
65 self.prices = 0 87 self.prices = 0
66 self.results = 0 88 self.results = 0
67 self.conflicts = 0 89 self.conflicts = 0
90+ self.folded_variants = 0
68 91
69 def as_dict(self) -> dict[str, int]: 92 def as_dict(self) -> dict[str, int]:
70 return dict(self.__dict__) 93 return dict(self.__dict__)
@@ -73,7 +96,8 @@ class WriteStats:
73 class FactWriter: 96 class FactWriter:
74 def __init__(self, conn: AsyncConnection, *, source_id: str | None, snapshot_id: str | None, source_url: str | None, 97 def __init__(self, conn: AsyncConnection, *, source_id: str | None, snapshot_id: str | None, source_url: str | None,
75 tier: int = 2, connector_name: str | None = None, extractor: str = "deterministic", extractor_version: str = "1", 98 tier: int = 2, connector_name: str | None = None, extractor: str = "deterministic", extractor_version: str = "1",
76− observed_at: datetime | None = None): 99+ observed_at: datetime | None = None, run_id: str | None = None, source_key: str | None = None, is_first_run: bool = False,
100+ variant_index: dict[str, str] | None = None):
77 self.conn = conn 101 self.conn = conn
78 self.source_id = source_id 102 self.source_id = source_id
79 self.snapshot_id = snapshot_id 103 self.snapshot_id = snapshot_id
@@ -83,14 +107,29 @@ class FactWriter:
83 self.extractor = extractor 107 self.extractor = extractor
84 self.extractor_version = extractor_version 108 self.extractor_version = extractor_version
85 self.observed_at = observed_at or datetime.now(UTC) 109 self.observed_at = observed_at or datetime.now(UTC)
86− self.resolver = Resolver(conn, snapshot_id=snapshot_id, source_tier=tier) 110+ self.run_id = run_id
111+ self._source_key = source_key
112+ self.is_first_run = is_first_run # connector's first successful run → every event is backfill (initial corpus)
113+ self.resolver = Resolver(conn, snapshot_id=snapshot_id, source_tier=tier, variant_index=variant_index)
87 self.stats = WriteStats() 114 self.stats = WriteStats()
88 115
116+ @property
117+ def derived(self) -> bool:
118+ """Derived writers (canonicalization) store disagreements as conflicting claims but never open review items."""
119+ return self.extractor == "derived"
120+
121+ async def source_key(self) -> str | None:
122+ if self._source_key is None and self.source_id:
123+ row = await fetch_one(self.conn, "select key from sources where id = :id", id=self.source_id)
124+ self._source_key = row["key"] if row else ""
125+ return self._source_key or None
126+
89 # ---------------------------------------------------------------------------------------------- entry point 127 # ---------------------------------------------------------------------------------------------- entry point
90 async def write(self, facts: Facts) -> WriteStats: 128 async def write(self, facts: Facts) -> WriteStats:
91 new_before = len(self.resolver.created) 129 new_before = len(self.resolver.created)
92 for ref in facts.entities: 130 for ref in facts.entities:
93 await self.resolver.resolve(ref) 131 await self.resolver.resolve(ref)
132+ await self._apply_hierarchy(ref)
94 for prop, value in ref.attributes.items(): 133 for prop, value in ref.attributes.items():
95 await self.write_claim(ref, prop, value) 134 await self.write_claim(ref, prop, value)
96 for c in facts.claims: 135 for c in facts.claims:
@@ -111,8 +150,31 @@ class FactWriter:
111 await self._new_entity_event(eid) 150 await self._new_entity_event(eid)
112 self.stats.entities_created = len(self.resolver.created) 151 self.stats.entities_created = len(self.resolver.created)
113 self.stats.entities_updated = len(self.resolver.updated - set(self.resolver.created)) 152 self.stats.entities_updated = len(self.resolver.updated - set(self.resolver.created))
153+ self.stats.folded_variants = len(self.resolver.folded)
114 return self.stats 154 return self.stats
115 155
156+ # ---------------------------------------------------------------------------------------------- hierarchy hints
157+ async def _apply_hierarchy(self, ref: EntityRef) -> None:
158+ """Materialise `family` / `canonical` hints: entities.family_id + member_of_family, entities.canonical_id + artifact_of, identity_confidence."""
159+ if not ref.id:
160+ return
161+ if ref.family is not None:
162+ if ref.family.entity_type != "model_family":
163+ ref.family.entity_type = "model_family"
164+ fid = await self.resolver.resolve(ref.family)
165+ if fid and fid != ref.id:
166+ await execute(self.conn, "update entities set family_id = :f where id = :e and family_id is distinct from :f", f=fid, e=ref.id)
167+ await self.write_relation(ref, "member_of_family", ref.family)
168+ if ref.canonical is not None:
169+ cid = await self.resolver.resolve(ref.canonical)
170+ if cid and cid != ref.id:
171+ await execute(self.conn, """update entities set canonical_id = :c, artifact_kind = coalesce(cast(:k as text), artifact_kind)
172+ where id = :e and (canonical_id is distinct from :c or (cast(:k as text) is not null and artifact_kind is null))""",
173+ c=cid, k=ref.artifact_kind, e=ref.id)
174+ await self.write_relation(ref, "artifact_of", ref.canonical, {"artifact_kind": ref.artifact_kind} if ref.artifact_kind else None)
175+ if ref.identity_confidence:
176+ await execute(self.conn, "update entities set identity_confidence = :ic where id = :e and identity_confidence <> :ic", ic=ref.identity_confidence, e=ref.id)
177+
116 # ---------------------------------------------------------------------------------------------- claims 178 # ---------------------------------------------------------------------------------------------- claims
117 async def write_claim(self, ref: EntityRef, prop: str, value: Any, *, unit: str | None = None, confidence: str | None = None, 179 async def write_claim(self, ref: EntityRef, prop: str, value: Any, *, unit: str | None = None, confidence: str | None = None,
118 observed_at: datetime | None = None, effective_at: datetime | None = None, source_url: str | None = None) -> None: 180 observed_at: datetime | None = None, effective_at: datetime | None = None, source_url: str | None = None) -> None:
@@ -121,67 +183,114 @@ class FactWriter:
121 eid = await self.resolver.resolve(ref) 183 eid = await self.resolver.resolve(ref)
122 assert eid 184 assert eid
123 value = _norm_value(value) 185 value = _norm_value(value)
186+ value_raw: str | None = None
187+ if prop in TAXONOMY_PROPERTIES:
188+ value, value_raw, mappings = normalize_property(ref.entity_type, prop, value)
189+ for domain, raw, canon in mappings:
190+ await self._record_mapping(domain, raw, canon)
191+ value = _norm_value(value)
124 observed = observed_at or self.observed_at 192 observed = observed_at or self.observed_at
125 conf = confidence or TIER_CONFIDENCE.get(self.tier, "medium") 193 conf = confidence or TIER_CONFIDENCE.get(self.tier, "medium")
126 url = source_url or self.source_url 194 url = source_url or self.source_url
127 noisy = prop.startswith(NOISY_PREFIXES) 195 noisy = prop.startswith(NOISY_PREFIXES)
128− current = await fetch_one(self.conn, """select id, value, tier, source_url, observed_at from claims 196+ current = await fetch_one(self.conn, """select id, value, value_raw, tier, source_url, observed_at, extractor from claims
129 where entity_id = :e and property = :p and status = 'current' order by valid_from desc limit 1""", 197 where entity_id = :e and property = :p and status = 'current' order by valid_from desc limit 1""",
130 e=eid, p=prop) 198 e=eid, p=prop)
131 is_new_entity = eid in self.resolver.created 199 is_new_entity = eid in self.resolver.created
200+ await self._apply_claim(ref, eid, prop, value, value_raw, current, unit=unit, conf=conf, observed=observed, effective_at=effective_at, url=url,
201+ noisy=noisy, is_new_entity=is_new_entity)
202+ if prop == "license" and isinstance(value, str):
203+ from aiatlas.ontology.licenses import LICENSES
204+
205+ if value in LICENSES: # canonical licence key as its own property (filterable), never its own event
206+ await self.write_claim(ref, "license_key", value, confidence=confidence, observed_at=observed_at, effective_at=effective_at, source_url=source_url)
207+
208+ async def _apply_claim(self, ref: EntityRef, eid: str, prop: str, value: Any, value_raw: str | None, current: dict[str, Any] | None, *, unit: str | None,
209+ conf: str, observed: datetime, effective_at: datetime | None, url: str | None, noisy: bool, is_new_entity: bool) -> None:
132 if current is None: 210 if current is None:
133− await self._insert_claim(eid, prop, value, unit, conf, "current", observed, effective_at, url) 211+ await self._insert_claim(eid, prop, value, unit, conf, "current", observed, effective_at, url, value_raw)
134− await self._set_attribute(eid, prop, value, unit, conf, url, observed) 212+ await self._set_attribute(eid, prop, value, unit, conf, url, observed, value_raw)
135 return 213 return
136 if _same(current["value"], value): 214 if _same(current["value"], value):
137 await execute(self.conn, "update claims set observed_at = greatest(observed_at, :o) where id = :id", o=observed, id=current["id"]) 215 await execute(self.conn, "update claims set observed_at = greatest(observed_at, :o) where id = :id", o=observed, id=current["id"])
138 if noisy: 216 if noisy:
139− await self._set_attribute(eid, prop, value, unit, conf, url, observed) 217+ await self._set_attribute(eid, prop, value, unit, conf, url, observed, value_raw)
218+ return
219+ if prop in TAXONOMY_PROPERTIES and _same(normalize_property(ref.entity_type, prop, current["value"])[0], value):
220+ # same assertion in an older encoding ("apache-2.0" → "Apache-2.0"): re-encode in place, keep the source label, no event
221+ raw_keep = current["value_raw"] or (current["value"] if isinstance(current["value"], str) else json.dumps(current["value"], ensure_ascii=False))
222+ raw_keep = raw_keep if raw_keep != value else None
223+ await execute(self.conn, """update claims set value = cast(:v as jsonb), value_text = :vt, value_raw = :raw, observed_at = greatest(observed_at, :o) where id = :id""",
224+ v=jsonb(value), vt=value[:2000] if isinstance(value, str) else None, raw=raw_keep, o=observed, id=current["id"])
225+ await self._set_attribute(eid, prop, value, unit, conf, url, observed, raw_keep, keep_provenance=True)
140 return 226 return
141 same_source = bool(url and current["source_url"] == url) 227 same_source = bool(url and current["source_url"] == url)
228+ same_extractor = (current["extractor"] or "deterministic") == self.extractor
142 if prop in SOFT_PROPERTIES and not same_source: 229 if prop in SOFT_PROPERTIES and not same_source:
143 # soft text (descriptions, notes): first statement wins until *its own* source changes; never an event, never a conflict 230 # soft text (descriptions, notes): first statement wins until *its own* source changes; never an event, never a conflict
144 return 231 return
145− if self.tier <= (current["tier"] or 2) or same_source: 232+ if self.tier <= (current["tier"] or 2) or (same_source and same_extractor):
146 # supersede 233 # supersede
147 await execute(self.conn, "update claims set status = 'superseded', valid_to = :o where id = :id", o=observed, id=current["id"]) 234 await execute(self.conn, "update claims set status = 'superseded', valid_to = :o where id = :id", o=observed, id=current["id"])
148− await self._insert_claim(eid, prop, value, unit, conf, "current", observed, effective_at, url) 235+ await self._insert_claim(eid, prop, value, unit, conf, "current", observed, effective_at, url, value_raw)
149− await self._set_attribute(eid, prop, value, unit, conf, url, observed) 236+ await self._set_attribute(eid, prop, value, unit, conf, url, observed, value_raw)
150− if not noisy and not is_new_entity and prop not in SOFT_PROPERTIES: 237+ if not noisy and not is_new_entity and prop not in SOFT_PROPERTIES and prop not in NO_EVENT_PROPERTIES:
151 await self._property_change_event(eid, prop, current["value"], value, observed, effective_at, url) 238 await self._property_change_event(eid, prop, current["value"], value, observed, effective_at, url)
152− else: 239+ return
153− await self._insert_claim(eid, prop, value, unit, conf, "conflicting", observed, effective_at, url) 240+ # the same disagreement from the same source/extractor is recorded once (re-observed, not re-inserted)
154− await execute(self.conn, "update claims set confidence = 'conflicted' where id = :id", id=current["id"]) 241+ dup = await fetch_one(self.conn, """select id from claims where entity_id = :e and property = :p and status = 'conflicting' and value = cast(:v as jsonb)
155− await execute(self.conn, """update entities set quality = quality || jsonb_build_object('conflicts', coalesce((quality->>'conflicts')::int, 0) + 1) 242+ and extractor = :ex and coalesce(source_url, '') = :url limit 1""", e=eid, p=prop, v=jsonb(value), ex=self.extractor, url=url or "")
156− where id = :id""", id=eid) 243+ if dup:
157− self.stats.conflicts += 1 244+ await execute(self.conn, "update claims set observed_at = greatest(observed_at, :o) where id = :id", o=observed, id=dup["id"])
245+ return
246+ await self._insert_claim(eid, prop, value, unit, conf, "conflicting", observed, effective_at, url, value_raw)
247+ await execute(self.conn, "update claims set confidence = 'conflicted' where id = :id", id=current["id"])
248+ await execute(self.conn, """update entities set quality = quality || jsonb_build_object('conflicts', coalesce((quality->>'conflicts')::int, 0) + 1)
249+ where id = :id""", id=eid)
250+ self.stats.conflicts += 1
251+ if not self.derived:
158 dedupe = f"conflict:{eid}:{prop}:{hashlib.sha1(json.dumps(value, sort_keys=True, default=str).encode()).hexdigest()[:10]}" 252 dedupe = f"conflict:{eid}:{prop}:{hashlib.sha1(json.dumps(value, sort_keys=True, default=str).encode()).hexdigest()[:10]}"
159 await execute(self.conn, """insert into review_queue (id, kind, entity_ids, payload, reason, dedupe_key) 253 await execute(self.conn, """insert into review_queue (id, kind, entity_ids, payload, reason, dedupe_key)
160 values (:id, 'conflict', :ids, cast(:p as jsonb), :r, :d) on conflict (dedupe_key) do nothing""", 254 values (:id, 'conflict', :ids, cast(:p as jsonb), :r, :d) on conflict (dedupe_key) do nothing""",
161 id=new_id("review"), ids=[eid], p=jsonb({"property": prop, "current": current["value"], "current_source": current["source_url"], 255 id=new_id("review"), ids=[eid], p=jsonb({"property": prop, "current": current["value"], "current_source": current["source_url"],
162− "claimed": value, "claimed_source": url, "tier": self.tier}), 256+ "claimed": value, "claimed_source": url, "tier": self.tier, "extractor": self.extractor}),
163 r=f"source disagrees on {prop}", d=dedupe) 257 r=f"source disagrees on {prop}", d=dedupe)
164 258
259+ async def _record_mapping(self, domain: str, raw: str, canonical: str | None) -> None:
260+ if canonical is not None and raw == canonical:
261+ return # identity mappings carry no information
262+ await execute(self.conn, """insert into taxonomy_mappings (domain, raw, canonical) values (:d, :r, :c)
263+ on conflict (domain, raw) do update set count = taxonomy_mappings.count + 1, last_seen_at = now(),
264+ canonical = coalesce(excluded.canonical, taxonomy_mappings.canonical)""", d=domain, r=raw[:300], c=canonical)
265+
165 async def _insert_claim(self, eid: str, prop: str, value: Any, unit: str | None, conf: str, status: str, observed: datetime, 266 async def _insert_claim(self, eid: str, prop: str, value: Any, unit: str | None, conf: str, status: str, observed: datetime,
166− effective_at: datetime | None, url: str | None) -> None: 267+ effective_at: datetime | None, url: str | None, value_raw: str | None = None) -> None:
167 text_val = value if isinstance(value, str) else None 268 text_val = value if isinstance(value, str) else None
168 num_val = float(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else None 269 num_val = float(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else None
169 await execute(self.conn, """insert into claims (id, entity_id, property, value, value_text, value_num, unit, source_id, snapshot_id, source_url, tier, 270 await execute(self.conn, """insert into claims (id, entity_id, property, value, value_text, value_num, unit, source_id, snapshot_id, source_url, tier,
170− confidence, status, extractor, extractor_version, observed_at, effective_at, valid_from) 271+ confidence, status, extractor, extractor_version, observed_at, effective_at, valid_from, run_id, value_raw)
171− values (:id, :e, :p, cast(:v as jsonb), :vt, :vn, :u, :src, :snap, :url, :tier, :conf, :status, :ex, :exv, :o, :eff, :vf)""", 272+ values (:id, :e, :p, cast(:v as jsonb), :vt, :vn, :u, :src, :snap, :url, :tier, :conf, :status, :ex, :exv, :o, :eff, :vf, :run, :raw)""",
172 id=new_id("claim"), e=eid, p=prop, v=jsonb(value), vt=text_val[:2000] if text_val else None, vn=num_val, u=unit, src=self.source_id, 273 id=new_id("claim"), e=eid, p=prop, v=jsonb(value), vt=text_val[:2000] if text_val else None, vn=num_val, u=unit, src=self.source_id,
173 snap=self.snapshot_id, url=url, tier=self.tier, conf=conf, status=status, ex=self.extractor, exv=self.extractor_version, 274 snap=self.snapshot_id, url=url, tier=self.tier, conf=conf, status=status, ex=self.extractor, exv=self.extractor_version,
174− o=observed, eff=effective_at, vf=effective_at or observed) 275+ o=observed, eff=effective_at, vf=effective_at or observed, run=self.run_id, raw=value_raw[:500] if value_raw else None)
175 self.stats.claims += 1 276 self.stats.claims += 1
176 277
177− async def _set_attribute(self, eid: str, prop: str, value: Any, unit: str | None, conf: str, url: str | None, observed: datetime) -> None: 278+ async def _set_attribute(self, eid: str, prop: str, value: Any, unit: str | None, conf: str, url: str | None, observed: datetime,
279+ value_raw: str | None = None, *, keep_provenance: bool = False) -> None:
178 prov = {"source_id": self.source_id, "snapshot_id": self.snapshot_id, "url": url, "observed_at": observed.isoformat(timespec="seconds"), 280 prov = {"source_id": self.source_id, "snapshot_id": self.snapshot_id, "url": url, "observed_at": observed.isoformat(timespec="seconds"),
179 "tier": self.tier, "confidence": conf, "extractor": self.extractor} 281 "tier": self.tier, "confidence": conf, "extractor": self.extractor}
180 if unit: 282 if unit:
181 prov["unit"] = unit 283 prov["unit"] = unit
182− await execute(self.conn, """update entities set attributes = attributes || jsonb_build_object(cast(:p as text), cast(:v as jsonb)), 284+ attrs = {prop: value}
183− provenance = provenance || jsonb_build_object(cast(:p as text), cast(:prov as jsonb)), last_seen_at = greatest(last_seen_at, :o) 285+ if value_raw is not None:
184− where id = :id""", p=prop, v=jsonb(value), prov=jsonb(prov), o=observed, id=eid) 286+ attrs[f"{prop}_raw"] = value_raw
287+ if keep_provenance:
288+ await execute(self.conn, """update entities set attributes = (attributes - cast(:praw as text)) || cast(:a as jsonb), last_seen_at = greatest(last_seen_at, :o) where id = :id""",
289+ praw=f"{prop}_raw", a=jsonb(attrs), o=observed, id=eid)
290+ else:
291+ await execute(self.conn, """update entities set attributes = (attributes - cast(:praw as text)) || cast(:a as jsonb),
292+ provenance = provenance || jsonb_build_object(cast(:p as text), cast(:prov as jsonb)), last_seen_at = greatest(last_seen_at, :o)
293+ where id = :id""", praw=f"{prop}_raw", a=jsonb(attrs), p=prop, prov=jsonb(prov), o=observed, id=eid)
185 if prop == "status" and isinstance(value, str): 294 if prop == "status" and isinstance(value, str):
186 await execute(self.conn, "update entities set status = :s where id = :id", s=value[:40], id=eid) 295 await execute(self.conn, "update entities set status = :s where id = :id", s=value[:40], id=eid)
187 if prop == "description" and isinstance(value, str): 296 if prop == "description" and isinstance(value, str):
@@ -192,9 +301,9 @@ class FactWriter:
192 event_type, importance = MATERIAL_PROPERTIES.get(prop, ("PROPERTY_CHANGED", 0)) 301 event_type, importance = MATERIAL_PROPERTIES.get(prop, ("PROPERTY_CHANGED", 0))
193 row = await fetch_one(self.conn, "select canonical_name, entity_type from entities where id = :id", id=eid) 302 row = await fetch_one(self.conn, "select canonical_name, entity_type from entities where id = :id", id=eid)
194 name = row["canonical_name"] if row else eid 303 name = row["canonical_name"] if row else eid
195− category = EVENT_CATEGORY_BY_TYPE.get(row["entity_type"] if row else "", "update") 304+ etype = row["entity_type"] if row else ""
196− if prop == "status" and str(new).lower() in ("deprecated", "retired", "discontinued"): 305+ category = EVENT_CATEGORY_BY_TYPE.get(etype, "update")
197− importance = 3 306+ importance = importance_for(event_type, etype, old, new, tier=self.tier, default=importance)
198 summary = f"{name}: {prop.replace('_', ' ')} changed from {_short(old)} to {_short(new)}" 307 summary = f"{name}: {prop.replace('_', ' ')} changed from {_short(old)} to {_short(new)}"
199 dedupe = f"{event_type}:{eid}:{prop}:{hashlib.sha1(json.dumps([old, new], sort_keys=True, default=str).encode()).hexdigest()[:12]}" 308 dedupe = f"{event_type}:{eid}:{prop}:{hashlib.sha1(json.dumps([old, new], sort_keys=True, default=str).encode()).hexdigest()[:12]}"
200 await self.emit_event(event_type, category, summary, entity_id=eid, old_value=old, new_value=new, importance=importance, 309 await self.emit_event(event_type, category, summary, entity_id=eid, old_value=old, new_value=new, importance=importance,
@@ -217,10 +326,10 @@ class FactWriter:
217 else: 326 else:
218 await execute(self.conn, "update relations set observed_at = :o where id = :id", o=self.observed_at, id=existing["id"]) 327 await execute(self.conn, "update relations set observed_at = :o where id = :id", o=self.observed_at, id=existing["id"])
219 return 328 return
220− await execute(self.conn, """insert into relations (id, subject_id, predicate, object_id, attributes, source_id, snapshot_id, source_url, tier, confidence, observed_at, valid_from) 329+ await execute(self.conn, """insert into relations (id, subject_id, predicate, object_id, attributes, source_id, snapshot_id, source_url, tier, confidence, observed_at, valid_from, run_id)
221− values (:id, :s, :p, :o, cast(:a as jsonb), :src, :snap, :url, :tier, :conf, :obs, :obs)""", 330+ values (:id, :s, :p, :o, cast(:a as jsonb), :src, :snap, :url, :tier, :conf, :obs, :obs, :run)""",
222 id=new_id("relation"), s=sid, p=predicate, o=oid, a=jsonb(attributes or {}), src=self.source_id, snap=self.snapshot_id, 331 id=new_id("relation"), s=sid, p=predicate, o=oid, a=jsonb(attributes or {}), src=self.source_id, snap=self.snapshot_id,
223− url=source_url or self.source_url, tier=self.tier, conf=conf, obs=self.observed_at) 332+ url=source_url or self.source_url, tier=self.tier, conf=conf, obs=self.observed_at, run=self.run_id)
224 self.stats.relations += 1 333 self.stats.relations += 1
225 334
226 # ---------------------------------------------------------------------------------------------- prices 335 # ---------------------------------------------------------------------------------------------- prices
@@ -242,16 +351,17 @@ class FactWriter:
242 await execute(self.conn, "update prices set observed_at = :o where id = :id", o=self.observed_at, id=current["id"]) 351 await execute(self.conn, "update prices set observed_at = :o where id = :id", o=self.observed_at, id=current["id"])
243 return 352 return
244 await execute(self.conn, "update prices set valid_to = :o where id = :id", o=self.observed_at, id=current["id"]) 353 await execute(self.conn, "update prices set valid_to = :o where id = :id", o=self.observed_at, id=current["id"])
354+ price_id = new_id("price")
245 await execute(self.conn, """insert into prices (id, model_id, provider_id, provider_model_id, input_per_mtok, output_per_mtok, cached_input_per_mtok, cache_write_per_mtok, 355 await execute(self.conn, """insert into prices (id, model_id, provider_id, provider_model_id, input_per_mtok, output_per_mtok, cached_input_per_mtok, cache_write_per_mtok,
246 batch_input_per_mtok, batch_output_per_mtok, per_image, per_request, currency, context_length, max_output_tokens, features, 356 batch_input_per_mtok, batch_output_per_mtok, per_image, per_request, currency, context_length, max_output_tokens, features,
247− observed_at, valid_from, source_id, snapshot_id, source_url, tier, meta) 357+ observed_at, valid_from, source_id, snapshot_id, source_url, tier, meta, run_id)
248− values (:id, :m, :p, :pm, :i, :o, :ci, :cw, :bi, :bo, :img, :req, :cur, :ctx, :mo, cast(:f as jsonb), :obs, :obs, :src, :snap, :url, :tier, cast(:meta as jsonb))""", 358+ values (:id, :m, :p, :pm, :i, :o, :ci, :cw, :bi, :bo, :img, :req, :cur, :ctx, :mo, cast(:f as jsonb), :obs, :obs, :src, :snap, :url, :tier, cast(:meta as jsonb), :run)""",
249− id=new_id("price"), m=mid, p=pid, pm=p.provider_model_id, i=p.input_per_mtok, o=p.output_per_mtok, ci=p.cached_input_per_mtok, 359+ id=price_id, m=mid, p=pid, pm=p.provider_model_id, i=p.input_per_mtok, o=p.output_per_mtok, ci=p.cached_input_per_mtok,
250 cw=p.cache_write_per_mtok, bi=p.batch_input_per_mtok, bo=p.batch_output_per_mtok, img=p.per_image, req=p.per_request, cur=p.currency, 360 cw=p.cache_write_per_mtok, bi=p.batch_input_per_mtok, bo=p.batch_output_per_mtok, img=p.per_image, req=p.per_request, cur=p.currency,
251 ctx=p.context_length, mo=p.max_output_tokens, f=jsonb(p.features), obs=self.observed_at, src=self.source_id, snap=self.snapshot_id, 361 ctx=p.context_length, mo=p.max_output_tokens, f=jsonb(p.features), obs=self.observed_at, src=self.source_id, snap=self.snapshot_id,
252− url=url, tier=self.tier, meta=jsonb(p.meta)) 362+ url=url, tier=self.tier, meta=jsonb(p.meta), run=self.run_id)
253 self.stats.prices += 1 363 self.stats.prices += 1
254− model = await fetch_one(self.conn, "select canonical_name from entities where id = :id", id=mid) 364+ model = await fetch_one(self.conn, "select canonical_name, entity_type from entities where id = :id", id=mid)
255 provider = await fetch_one(self.conn, "select canonical_name from entities where id = :id", id=pid) 365 provider = await fetch_one(self.conn, "select canonical_name from entities where id = :id", id=pid)
256 mname = model["canonical_name"] if model else mid 366 mname = model["canonical_name"] if model else mid
257 pname = provider["canonical_name"] if provider else pid 367 pname = provider["canonical_name"] if provider else pid
@@ -260,8 +370,14 @@ class FactWriter:
260 new = {"input_per_mtok": p.input_per_mtok, "output_per_mtok": p.output_per_mtok} 370 new = {"input_per_mtok": p.input_per_mtok, "output_per_mtok": p.output_per_mtok}
261 summary = f"{pname} changed pricing for {mname}: {_fmt_price(old)} → {_fmt_price(new)}" 371 summary = f"{pname} changed pricing for {mname}: {_fmt_price(old)} → {_fmt_price(new)}"
262 dedupe = f"PRICE_CHANGED:{mid}:{pid}:{p.provider_model_id or ''}:{hashlib.sha1(json.dumps([old, new], sort_keys=True, default=str).encode()).hexdigest()[:12]}" 372 dedupe = f"PRICE_CHANGED:{mid}:{pid}:{p.provider_model_id or ''}:{hashlib.sha1(json.dumps([old, new], sort_keys=True, default=str).encode()).hexdigest()[:12]}"
263− await self.emit_event("PRICE_CHANGED", "price", summary, entity_id=mid, old_value=old, new_value=new, importance=2, dedupe_key=dedupe, 373+ importance = importance_for("PRICE_CHANGED", model["entity_type"] if model else None, old, new, tier=self.tier)
374+ await self.emit_event("PRICE_CHANGED", "price", summary, entity_id=mid, old_value=old, new_value=new, importance=importance, dedupe_key=dedupe,
264 source_url=url, meta={"provider_id": pid, "provider": pname}) 375 source_url=url, meta={"provider_id": pid, "provider": pname})
376+ for anomaly in check_price_movement({**old, "model_id": mid, "provider_id": pid}, {**new, "model_id": mid, "provider_id": pid}):
377+ from aiatlas.services.anomalies import record
378+
379+ anomaly.detail["price_id"] = price_id
380+ await record(self.conn, anomaly)
265 else: 381 else:
266 new = {"input_per_mtok": p.input_per_mtok, "output_per_mtok": p.output_per_mtok} 382 new = {"input_per_mtok": p.input_per_mtok, "output_per_mtok": p.output_per_mtok}
267 summary = f"{pname} lists {mname} at {_fmt_price(new)}" 383 summary = f"{pname} lists {mname} at {_fmt_price(new)}"
@@ -277,51 +393,87 @@ class FactWriter:
277 if not mid or not bid: 393 if not mid or not bid:
278 return 394 return
279 url = r.source_url or self.source_url 395 url = r.source_url or self.source_url
280− cfg_hash = hashlib.sha1(json.dumps(r.config, sort_keys=True, default=str).encode()).hexdigest()[:12] 396+ # evaluation-effort variants ("gpt-5-4-mini-medium") are a *configuration* of the canonical model: the effort dict lands in the
281− dedupe = f"{mid}:{bid}:{cfg_hash}:{r.metric or ''}" 397+ # config so folded results stay distinguishable and comparable (reasoning_effort is a condition key, not a task key)
282− existing = await fetch_one(self.conn, "select id, score from benchmark_results where dedupe_key = :d", d=dedupe) 398+ config = effort_config(r.model.name, r.config)
399+ metric = r.metric
400+ dedupe = result_dedupe_key(mid, bid, config, metric)
401+ config_key = bench_ontology.config_key(config, metric)
402+ trust = r.trust_level or bench_ontology.trust_level(await self.source_key(), config, extractor=self.extractor)
403+ variant = r.variant or bench_ontology.variant_from_config(config)
404+ run_group = r.run_group or bench_ontology.run_group_from_config(config)
283 conf = r.confidence or TIER_CONFIDENCE.get(self.tier, "medium") 405 conf = r.confidence or TIER_CONFIDENCE.get(self.tier, "medium")
406+ lo, hi = bench_ontology.metric_bounds(metric, r.unit)
407+ out_of_range = (hi is not None and r.score > hi + 1e-9) or (lo is not None and r.score < lo - 1e-9)
408+ if out_of_range:
409+ conf = "low"
410+ existing = await fetch_one(self.conn, "select id, score from benchmark_results where dedupe_key = :d", d=dedupe)
411+ names: dict[str, Any] | None = None
284 if existing: 412 if existing:
285 if abs((existing["score"] or 0) - r.score) > 1e-9: 413 if abs((existing["score"] or 0) - r.score) > 1e-9:
286− await execute(self.conn, "update benchmark_results set valid_to = :o, dedupe_key = dedupe_key || ':' || :suffix where id = :id", 414+ await execute(self.conn, "update benchmark_results set valid_to = :o, is_current = false, dedupe_key = dedupe_key || ':' || :suffix where id = :id",
287 o=self.observed_at, suffix=new_id("result")[-10:], id=existing["id"]) 415 o=self.observed_at, suffix=new_id("result")[-10:], id=existing["id"])
288− model = await fetch_one(self.conn, "select canonical_name from entities where id = :id", id=mid) 416+ names = await self._names(mid, bid)
289− bench = await fetch_one(self.conn, "select canonical_name from entities where id = :id", id=bid) 417+ await self.emit_event("BENCHMARK_UPDATED", "benchmark", f"{names['model']} on {names['bench']}: {existing['score']:g} → {r.score:g}",
290− await self.emit_event("BENCHMARK_UPDATED", "benchmark",
291− f"{model['canonical_name'] if model else mid} on {bench['canonical_name'] if bench else bid}: {existing['score']:g} → {r.score:g}",
292 entity_id=mid, old_value=existing["score"], new_value=r.score, importance=1, 418 entity_id=mid, old_value=existing["score"], new_value=r.score, importance=1,
293 dedupe_key=f"BENCHMARK_UPDATED:{dedupe}:{r.score:g}", source_url=url, meta={"benchmark_id": bid}) 419 dedupe_key=f"BENCHMARK_UPDATED:{dedupe}:{r.score:g}", source_url=url, meta={"benchmark_id": bid})
294 else: 420 else:
295 await execute(self.conn, "update benchmark_results set observed_at = :o where id = :id", o=self.observed_at, id=existing["id"]) 421 await execute(self.conn, "update benchmark_results set observed_at = :o where id = :id", o=self.observed_at, id=existing["id"])
296 return 422 return
423+ rid = new_id("result")
297 await execute(self.conn, """insert into benchmark_results (id, model_id, benchmark_id, score, metric, unit, higher_is_better, config, evaluated_at, observed_at, 424 await execute(self.conn, """insert into benchmark_results (id, model_id, benchmark_id, score, metric, unit, higher_is_better, config, evaluated_at, observed_at,
298− source_id, snapshot_id, source_url, tier, confidence, dedupe_key) 425+ source_id, snapshot_id, source_url, tier, confidence, dedupe_key, config_key, trust_level, variant, run_group, is_current, extractor, run_id)
299− values (:id, :m, :b, :s, :metric, :unit, :hib, cast(:cfg as jsonb), :ev, :obs, :src, :snap, :url, :tier, :conf, :d)""", 426+ values (:id, :m, :b, :s, :metric, :unit, :hib, cast(:cfg as jsonb), :ev, :obs, :src, :snap, :url, :tier, :conf, :d, :ck, :trust, :variant, :rg, true, :ex, :run)""",
300− id=new_id("result"), m=mid, b=bid, s=r.score, metric=r.metric, unit=r.unit, hib=r.higher_is_better, cfg=jsonb(r.config), 427+ id=rid, m=mid, b=bid, s=r.score, metric=metric, unit=r.unit, hib=r.higher_is_better, cfg=jsonb(config), ev=r.evaluated_at, obs=self.observed_at,
301− ev=r.evaluated_at, obs=self.observed_at, src=self.source_id, snap=self.snapshot_id, url=url, tier=self.tier, conf=conf, d=dedupe) 428+ src=self.source_id, snap=self.snapshot_id, url=url, tier=self.tier, conf=conf, d=dedupe, ck=config_key, trust=trust, variant=variant, rg=run_group,
429+ ex=self.extractor, run=self.run_id)
302 self.stats.results += 1 430 self.stats.results += 1
431+ # one current row per (model, benchmark, metric, config_key): a different (older) run group is closed so the leaderboard shows the latest run
432+ await execute(self.conn, """update benchmark_results set is_current = false, valid_to = coalesce(valid_to, :obs)
433+ where model_id = :m and benchmark_id = :b and coalesce(metric, '') = :metric and config_key = :ck and id <> :id and is_current
434+ and coalesce(run_group, '') <> :rg and observed_at <= :obs""",
435+ obs=self.observed_at, m=mid, b=bid, metric=metric or "", ck=config_key, id=rid, rg=run_group or "")
436+ if out_of_range:
437+ from aiatlas.services.anomalies import record
438+
439+ names = names or await self._names(mid, bid)
440+ for anomaly in check_result({"id": rid, "model_id": mid, "benchmark_id": bid, "score": r.score, "metric": metric, "unit": r.unit,
441+ "model_name": names["model"], "benchmark_name": names["bench"]}):
442+ await record(self.conn, anomaly)
303 if not existing: 443 if not existing:
304− model = await fetch_one(self.conn, "select canonical_name from entities where id = :id", id=mid) 444+ names = names or await self._names(mid, bid)
305− bench = await fetch_one(self.conn, "select canonical_name from entities where id = :id", id=bid) 445+ await self.emit_event("BENCHMARK_RESULT", "benchmark", f"{names['model']} scores {r.score:g}{r.unit or ''} on {names['bench']}",
306− await self.emit_event("BENCHMARK_RESULT", "benchmark",
307− f"{model['canonical_name'] if model else mid} scores {r.score:g}{r.unit or ''} on {bench['canonical_name'] if bench else bid}",
308 entity_id=mid, new_value=r.score, importance=1, dedupe_key=f"BENCHMARK_RESULT:{dedupe}", source_url=url, 446 entity_id=mid, new_value=r.score, importance=1, dedupe_key=f"BENCHMARK_RESULT:{dedupe}", source_url=url,
309− meta={"benchmark_id": bid, "metric": r.metric}) 447+ meta={"benchmark_id": bid, "metric": metric, "config_key": config_key})
310 await self.write_relation(r.model, "evaluated_on", r.benchmark, source_url=url) 448 await self.write_relation(r.model, "evaluated_on", r.benchmark, source_url=url)
311 449
450+ async def _names(self, mid: str, bid: str) -> dict[str, Any]:
451+ model = await fetch_one(self.conn, "select canonical_name from entities where id = :id", id=mid)
452+ bench = await fetch_one(self.conn, "select canonical_name from entities where id = :id", id=bid)
453+ return {"model": model["canonical_name"] if model else mid, "bench": bench["canonical_name"] if bench else bid}
454+
312 # ---------------------------------------------------------------------------------------------- events 455 # ---------------------------------------------------------------------------------------------- events
313 async def emit_event(self, event_type: str, category: str, summary: str, *, entity_id: str | None = None, old_value: Any = None, 456 async def emit_event(self, event_type: str, category: str, summary: str, *, entity_id: str | None = None, old_value: Any = None,
314 new_value: Any = None, importance: int = 2, effective_at: datetime | None = None, dedupe_key: str | None = None, 457 new_value: Any = None, importance: int = 2, effective_at: datetime | None = None, dedupe_key: str | None = None,
315− source_url: str | None = None, meta: dict[str, Any] | None = None, observed_at: datetime | None = None) -> None: 458+ source_url: str | None = None, meta: dict[str, Any] | None = None, observed_at: datetime | None = None,
459+ entity_first_seen: datetime | None = None, is_backfill: bool | None = None) -> None:
316 dedupe = dedupe_key or f"{event_type}:{entity_id or ''}:{normalize_alias(summary)[:120]}" 460 dedupe = dedupe_key or f"{event_type}:{entity_id or ''}:{normalize_alias(summary)[:120]}"
461+ observed = observed_at or self.observed_at
462+ if self.derived:
463+ # a derived writer re-encodes what we already know: its events are bookkeeping, never news
464+ is_backfill, importance = True, min(importance, 1)
465+ backfill = is_backfill if is_backfill is not None else classify_backfill(event_type, effective_at, observed, is_first_run=self.is_first_run,
466+ entity_first_seen=entity_first_seen)
467+ group_key = group_key_for(event_type, entity_id, effective_at, observed)
317 await execute(self.conn, """insert into change_events (id, entity_id, event_type, category, property, old_value, new_value, summary, importance, observed_at, 468 await execute(self.conn, """insert into change_events (id, entity_id, event_type, category, property, old_value, new_value, summary, importance, observed_at,
318− effective_at, source_id, snapshot_id, source_url, connector_name, dedupe_key, meta) 469+ effective_at, source_id, snapshot_id, source_url, connector_name, dedupe_key, meta, run_id, recorded_at, is_backfill, group_key)
319− values (:id, :e, :t, :c, :p, cast(:o as jsonb), cast(:n as jsonb), :s, :imp, :obs, :eff, :src, :snap, :url, :conn, :d, cast(:meta as jsonb)) 470+ values (:id, :e, :t, :c, :p, cast(:o as jsonb), cast(:n as jsonb), :s, :imp, :obs, :eff, :src, :snap, :url, :conn, :d, cast(:meta as jsonb),
471+ :run, now(), :bf, :gk)
320 on conflict (dedupe_key) do nothing""", 472 on conflict (dedupe_key) do nothing""",
321 id=new_id("change_event"), e=entity_id, t=event_type, c=category, p=(meta or {}).get("property"), o=jsonb(old_value) if old_value is not None else None, 473 id=new_id("change_event"), e=entity_id, t=event_type, c=category, p=(meta or {}).get("property"), o=jsonb(old_value) if old_value is not None else None,
322− n=jsonb(new_value) if new_value is not None else None, s=summary[:500], imp=max(0, min(3, importance)), obs=observed_at or self.observed_at, 474+ n=jsonb(new_value) if new_value is not None else None, s=summary[:500], imp=max(0, min(3, importance)), obs=observed,
323 eff=effective_at, src=self.source_id, snap=self.snapshot_id, url=source_url or self.source_url, conn=self.connector_name, d=dedupe[:400], 475 eff=effective_at, src=self.source_id, snap=self.snapshot_id, url=source_url or self.source_url, conn=self.connector_name, d=dedupe[:400],
324− meta=jsonb(meta or {})) 476+ meta=jsonb(meta or {}), run=self.run_id, bf=backfill, gk=group_key)
325 self.stats.events += 1 477 self.stats.events += 1
326 478
327 async def _new_entity_event(self, eid: str) -> None: 479 async def _new_entity_event(self, eid: str) -> None:
@@ -329,25 +481,30 @@ class FactWriter:
329 if not row: 481 if not row:
330 return 482 return
331 etype = row["entity_type"] 483 etype = row["entity_type"]
332− if etype in ("researcher", "country", "license", "quantization"): 484+ if etype in SILENT_NEW_TYPES:
333 return 485 return
334− importance = NEW_IMPORTANCE.get(etype, 1)
335− if self.tier > 2:
336− importance = max(0, importance - 1)
337 org = None 486 org = None
487+ org_models = 0
338 if row["organization_id"]: 488 if row["organization_id"]:
339− o = await fetch_one(self.conn, "select canonical_name from entities where id = :id", id=row["organization_id"]) 489+ o = await fetch_one(self.conn, """select canonical_name, (select count(*) from entities m where m.organization_id = e.id and m.entity_type = 'model'
490+ and m.merged_into is null) as models from entities e where e.id = :id""", id=row["organization_id"])
340 org = o["canonical_name"] if o else None 491 org = o["canonical_name"] if o else None
492+ org_models = int(o["models"] or 0) if o else 0
493+ attrs = row["attributes"] or {}
494+ if etype == "model":
495+ importance = importance_for("NEW_MODEL", etype, tier=self.tier, org_model_count=org_models, openness=attrs.get("openness"))
496+ else:
497+ importance = importance_for(f"NEW_{etype.upper()}", etype, tier=self.tier, default=NEW_IMPORTANCE.get(etype, 1))
341 label = etype.replace("_", " ") 498 label = etype.replace("_", " ")
342 summary = f"New {label}: {row['canonical_name']}" + (f" ({org})" if org else "") 499 summary = f"New {label}: {row['canonical_name']}" + (f" ({org})" if org else "")
343 effective = None 500 effective = None
344− rel = row["attributes"].get("release_date") or row["attributes"].get("published_at") 501+ rel = attrs.get("release_date") or attrs.get("published_at")
345 if isinstance(rel, str): 502 if isinstance(rel, str):
346 from aiatlas.sdk.extract.dates import parse_datetime 503 from aiatlas.sdk.extract.dates import parse_datetime
347 504
348 effective = parse_datetime(rel) 505 effective = parse_datetime(rel)
349 await self.emit_event(f"NEW_{etype.upper()}", EVENT_CATEGORY_BY_TYPE.get(etype, "update"), summary, entity_id=eid, importance=importance, 506 await self.emit_event(f"NEW_{etype.upper()}", EVENT_CATEGORY_BY_TYPE.get(etype, "update"), summary, entity_id=eid, importance=importance,
350− dedupe_key=f"NEW_{etype.upper()}:{eid}", effective_at=effective) 507+ dedupe_key=f"NEW_{etype.upper()}:{eid}", effective_at=effective, entity_first_seen=effective)
351 508
352 509
353 def _short(v: Any) -> str: 510 def _short(v: Any) -> str:
@@ -365,4 +522,4 @@ def _fmt_price(p: dict[str, Any]) -> str:
365 return " / ".join(parts) + " per 1M tokens" if parts else "n/a" 522 return " / ".join(parts) + " per 1M tokens" if parts else "n/a"
366 523
367 524
368−__all__ = ["FactWriter", "WriteStats"] 525+__all__ = ["FactWriter", "WriteStats", "result_dedupe_key"]
added src/aiatlas/services/anomalies.py +82 −0
@@ -0,0 +1,82 @@
1+"""Anomaly persistence: the ontology's deterministic checks (`aiatlas.ontology.anomalies`) produce flags; this module upserts them in the
2+`anomalies` table (one row per dedupe key, reopened when a check fires again, auto-resolved when it stops firing). Never deletes,
3+never edits the flagged value."""
4+from __future__ import annotations
5+
6+import logging
7+from typing import Any
8+
9+from sqlalchemy.ext.asyncio import AsyncConnection
10+from ulid import ULID
11+
12+from aiatlas.db import execute, fetch_all, fetch_one, jsonb
13+from aiatlas.ontology.anomalies import Anomaly, check_hardware, check_model, check_price, check_result
14+
15+log = logging.getLogger(__name__)
16+
17+
18+async def record(conn: AsyncConnection, anomaly: Anomaly) -> str:
19+ """Upsert one anomaly. Rows an operator marked `ignored`/`fixed` keep their status; `resolved` rows reopen."""
20+ row = await fetch_one(conn, """insert into anomalies (id, entity_id, check_name, severity, message, value, detail, status, dedupe_key)
21+ values (:id, :e, :c, :sev, :m, cast(:v as jsonb), cast(:d as jsonb), 'open', :k)
22+ on conflict (dedupe_key) do update set last_seen_at = now(), message = excluded.message, value = excluded.value,
23+ detail = excluded.detail, severity = excluded.severity,
24+ status = case when anomalies.status = 'resolved' then 'open' else anomalies.status end,
25+ resolved_at = case when anomalies.status = 'resolved' then null else anomalies.resolved_at end
26+ returning id""",
27+ id=f"anom_{ULID()}", e=anomaly.entity_id, c=anomaly.check, sev=anomaly.severity, m=anomaly.message[:1000],
28+ v=jsonb(anomaly.value) if anomaly.value is not None else None, d=jsonb(anomaly.detail or {}), k=anomaly.dedupe_key[:400])
29+ return row["id"] if row else ""
30+
31+
32+async def run_checks(conn: AsyncConnection, *, resolve_stale: bool = True) -> dict[str, Any]:
33+ """Run every ontology check over live models/artifacts, hardware, live prices and live results; upsert flags; auto-resolve open flags whose
34+ check no longer fires. Returns counts by severity and the number resolved."""
35+ found: list[Anomaly] = []
36+ rows = await fetch_all(conn, "select id, canonical_name, attributes from entities where entity_type in ('model','artifact') and merged_into is null")
37+ for r in rows:
38+ found.extend(check_model(r["id"], r["canonical_name"], r["attributes"] or {}))
39+ rows = await fetch_all(conn, "select id, canonical_name, attributes from entities where entity_type = 'hardware' and merged_into is null")
40+ for r in rows:
41+ found.extend(check_hardware(r["id"], r["canonical_name"], r["attributes"] or {}))
42+ rows = await fetch_all(conn, """select p.*, m.canonical_name as model_name, v.canonical_name as provider_name from prices p
43+ join entities m on m.id = p.model_id join entities v on v.id = p.provider_id where p.valid_to is null""")
44+ for r in rows:
45+ found.extend(check_price(r))
46+ rows = await fetch_all(conn, """select r.id, r.model_id, r.benchmark_id, r.score, r.metric, r.unit, r.evaluated_at, m.canonical_name as model_name,
47+ b.canonical_name as benchmark_name, m.attributes->>'release_date' as model_release_date
48+ from benchmark_results r join entities m on m.id = r.model_id join entities b on b.id = r.benchmark_id
49+ where r.valid_to is null and r.is_current""")
50+ for r in rows:
51+ found.extend(check_result(r))
52+ keys: set[str] = set()
53+ by_sev: dict[str, int] = {}
54+ for a in found:
55+ key = a.dedupe_key[:400]
56+ if key in keys:
57+ continue
58+ keys.add(key)
59+ await record(conn, a)
60+ by_sev[a.severity] = by_sev.get(a.severity, 0) + 1
61+ resolved = 0
62+ if resolve_stale:
63+ row = await fetch_one(conn, """with s as (update anomalies set status = 'resolved', resolved_at = now(), resolution = 'check no longer fires'
64+ where status = 'open' and not (dedupe_key = any(cast(:keys as text[]))) returning 1) select count(*) as n from s""",
65+ keys=sorted(keys))
66+ resolved = int(row["n"]) if row else 0
67+ return {"flagged": len(keys), "by_severity": by_sev, "resolved": resolved}
68+
69+
70+async def list_anomalies(conn: AsyncConnection, *, severity: str | None = None, status: str = "open", limit: int = 200) -> list[dict[str, Any]]:
71+ return await fetch_all(conn, """select a.*, e.canonical_name, e.slug, e.entity_type from anomalies a left join entities e on e.id = a.entity_id
72+ where (cast(:st as text) = '' or a.status = :st) and (cast(:sev as text) = '' or a.severity = :sev)
73+ order by case a.severity when 'critical' then 0 when 'warning' then 1 else 2 end, a.last_seen_at desc limit :n""",
74+ st=status or "", sev=severity or "", n=limit)
75+
76+
77+async def set_status(conn: AsyncConnection, anomaly_id: str, status: str, *, resolution: str | None = None) -> None:
78+ await execute(conn, "update anomalies set status = :s, resolution = coalesce(:r, resolution), resolved_at = case when :s in ('resolved','fixed','ignored') then now() else null end where id = :id",
79+ s=status, r=resolution, id=anomaly_id)
80+
81+
82+__all__ = ["list_anomalies", "record", "run_checks", "set_status"]
added src/aiatlas/services/events.py +128 −0
@@ -0,0 +1,128 @@
1+"""Event semantics — deterministic, database-free rules shared by the writer and `aia canonicalize events`.
2+
3+Three clocks on every change event:
4+ occurred_at = coalesce(effective_at, observed_at) when the thing happened (release date, price effective date)
5+ observed_at when a connector saw it
6+ recorded_at when the row was written
7+
8+`is_backfill` separates *history being loaded* from *news*: the "+N in 24 h" counters, /changes and the pulse feed only count live
9+(non-backfill) events; timelines and /asof use every event.
10+"""
11+from __future__ import annotations
12+
13+from datetime import datetime, timedelta
14+from typing import Any
15+
16+BACKFILL_LAG_DAYS = 3
17+RELEASE_LIKE = {"RELEASE", "ANNOUNCEMENT", "NEW_MODEL", "VERSION_RELEASED"}
18+
19+
20+def classify_backfill(event_type: str, effective_at: datetime | None, observed_at: datetime, *, is_first_run: bool = False,
21+ entity_first_seen: datetime | None = None) -> bool:
22+ """True when the event describes something that happened well before we observed it.
23+
24+ * `effective_at` more than BACKFILL_LAG_DAYS before `observed_at` → backfill (an old release date being loaded);
25+ * the connector run is the connector's first successful run → backfill (initial corpus, not news);
26+ * NEW_* events for an entity whose first-seen hint (release/publication date) predates observation by more than the lag → backfill.
27+ """
28+ if is_first_run:
29+ return True
30+ lag = timedelta(days=BACKFILL_LAG_DAYS)
31+ if effective_at is not None and observed_at is not None and _aware(effective_at) < _aware(observed_at) - lag:
32+ return True
33+ if event_type.startswith("NEW_") and entity_first_seen is not None and observed_at is not None and _aware(entity_first_seen) < _aware(observed_at) - lag:
34+ return True
35+ return False
36+
37+
38+def group_key_for(event_type: str, entity_id: str | None, effective_at: datetime | None, observed_at: datetime | None) -> str | None:
39+ """One release seen in several documents (blog post, docs page, hub listing, provider listing) groups under one key per entity × month."""
40+ if event_type not in RELEASE_LIKE or not entity_id:
41+ return None
42+ when = effective_at or observed_at
43+ if when is None:
44+ return None
45+ return f"release:{entity_id}:{when.strftime('%Y-%m')}"
46+
47+
48+def _aware(dt: datetime) -> datetime:
49+ from datetime import UTC
50+
51+ return dt if dt.tzinfo is not None else dt.replace(tzinfo=UTC)
52+
53+
54+# ---------------------------------------------------------------------------------------------- importance (0 minor … 3 major)
55+def _num(v: Any) -> float | None:
56+ if isinstance(v, bool):
57+ return None
58+ if isinstance(v, (int, float)):
59+ return float(v)
60+ if isinstance(v, str):
61+ try:
62+ return float(v.replace(",", ""))
63+ except ValueError:
64+ return None
65+ return None
66+
67+
68+def _price_change_ratio(old: Any, new: Any) -> float:
69+ """Largest relative move among input/output per-million prices (0 when unknown)."""
70+ if not isinstance(old, dict) or not isinstance(new, dict):
71+ a, b = _num(old), _num(new)
72+ return abs(b - a) / a if a and b is not None and a > 0 else 0.0
73+ best = 0.0
74+ for k in ("input_per_mtok", "output_per_mtok", "cached_input_per_mtok"):
75+ a, b = _num(old.get(k)), _num(new.get(k))
76+ if a is not None and b is not None and a > 0:
77+ best = max(best, abs(b - a) / a)
78+ elif a in (None, 0) and b:
79+ best = max(best, 1.0) # newly priced / free → paid counts as a full move
80+ return best
81+
82+
83+def importance_for(event_type: str, entity_type: str | None, old: Any = None, new: Any = None, *, tier: int = 2, org_model_count: int = 0,
84+ openness: str | None = None, leader_change: bool = False, default: int = 2) -> int:
85+ """Deterministic importance:
86+ artifact events 0 · model_family events 1 · metadata corrections (PROPERTY_CHANGED) 0
87+ frontier-model release (organisation with ≥ 3 models, source tier ≤ 2) 3 · open-weight release 3 · other NEW_MODEL 2
88+ price change ≥ 50 % → 3, ≥ 20 % → 2, else 1 · context ≥ 5× → 3 (shrink → 1) · deprecation / retirement 3 · benchmark leader change 2
89+ Tier > 2 sources lose one point (never below 0)."""
90+ et = event_type.upper()
91+ if entity_type == "artifact":
92+ return 0
93+ if entity_type == "model_family":
94+ return min(1, default)
95+ if et == "PROPERTY_CHANGED":
96+ return 0
97+ if et == "PRICE_CHANGED":
98+ ratio = _price_change_ratio(old, new)
99+ imp = 3 if ratio >= 0.5 else 2 if ratio >= 0.2 else 1
100+ elif et == "CONTEXT_CHANGED":
101+ a, b = _num(old), _num(new)
102+ if a and b and a > 0:
103+ imp = 3 if b / a >= 5 else 1 if b < a else 2
104+ else:
105+ imp = 2
106+ elif et in ("DEPRECATION_ANNOUNCED", "RETIREMENT_ANNOUNCED"):
107+ imp = 3
108+ elif et == "STATUS_CHANGED":
109+ imp = 3 if str(new).lower() in ("deprecated", "retired", "discontinued") else 2
110+ elif et == "OPENNESS_CHANGED":
111+ imp = 3 if str(new).startswith("open") else 2
112+ elif et == "NEW_MODEL":
113+ if openness and str(openness).startswith("open"):
114+ imp = 3
115+ elif org_model_count >= 3 and tier <= 2:
116+ imp = 3
117+ else:
118+ imp = 2
119+ elif et in ("BENCHMARK_LEADER_CHANGED",) or (et.startswith("BENCHMARK") and leader_change):
120+ imp = 2
121+ else:
122+ imp = default
123+ if tier > 2:
124+ imp -= 1
125+ return max(0, min(3, imp))
126+
127+
128+__all__ = ["BACKFILL_LAG_DAYS", "RELEASE_LIKE", "classify_backfill", "group_key_for", "importance_for"]
modified src/aiatlas/services/handlers.py +2 −1
@@ -55,7 +55,8 @@ async def llm_extract(payload: dict[str, Any], job: dict[str, Any]) -> dict[str,
55 tier = await fetch_one(conn, "select tier from sources where id = :id", id=snap["source_id"]) if snap["source_id"] else None 55 tier = await fetch_one(conn, "select tier from sources where id = :id", id=snap["source_id"]) if snap["source_id"] else None
56 # LLM output never outranks a deterministic statement from the same source: one tier lower (disagreement → conflicting + review) 56 # LLM output never outranks a deterministic statement from the same source: one tier lower (disagreement → conflicting + review)
57 writer = FactWriter(conn, source_id=snap["source_id"], snapshot_id=snapshot_id, source_url=snap["url"], tier=min(4, (tier["tier"] if tier else 2) + 1), 57 writer = FactWriter(conn, source_id=snap["source_id"], snapshot_id=snapshot_id, source_url=snap["url"], tier=min(4, (tier["tier"] if tier else 2) + 1),
58− connector_name=snap["connector_name"], extractor="llm", extractor_version=res.model, observed_at=snap["observed_at"].astimezone(UTC)) 58+ connector_name=snap["connector_name"], extractor="llm", extractor_version=res.model, observed_at=snap["observed_at"].astimezone(UTC),
59+ run_id=snap["run_id"] or job["id"])
59 ws = await writer.write(facts) 60 ws = await writer.write(facts)
60 await execute(conn, "update snapshots set processing_status = 'llm_done' where id = :id", id=snapshot_id) 61 await execute(conn, "update snapshots set processing_status = 'llm_done' where id = :id", id=snapshot_id)
61 return {"task": task, "model": res.model, **ws.as_dict()} 62 return {"task": task, "model": res.model, **ws.as_dict()}
modified src/aiatlas/services/merge.py +182 −11
@@ -1,27 +1,97 @@
1 """Entity merging (curation): fold a duplicate `source` into `target`. Nothing is deleted — the source row stays with 1 """Entity merging (curation): fold a duplicate `source` into `target`. Nothing is deleted — the source row stays with
2−`status='merged'` and `merged_into=target` so old slugs and ids keep resolving; every dependent row is re-pointed.""" 2+`status='merged'` and `merged_into=target` so old slugs and ids keep resolving; every dependent row is re-pointed.
3+
4+Modes
5+ merge (default) full merge; records a `merge` decision
6+ alias same as merge; records an `alias` decision (the source was another name of the target)
7+ variant the source is an *artifact* (quantisation / conversion / packaging) of the target: it keeps its own row, slug and
8+ facts, gets `entity_type='artifact'`, `canonical_id=target` and an `artifact_of` relation
9+ family_member the target is a `model_family`: the source gets `family_id=target` and a `member_of_family` relation
10+
11+Every call writes a `resolution_decisions` row (applied=true) and an `admin_audit_log` row; `keep_separate` decisions block merges.
12+"""
3 from __future__ import annotations 13 from __future__ import annotations
4 14
15+import json
5 from typing import Any 16 from typing import Any
6 17
7 from sqlalchemy.ext.asyncio import AsyncConnection 18 from sqlalchemy.ext.asyncio import AsyncConnection
8 19
9−from aiatlas.db import execute, fetch_one, fetch_val 20+from aiatlas.db import execute, fetch_all, fetch_one, fetch_val, jsonb
10 from aiatlas.ids import new_id, normalize_alias 21 from aiatlas.ids import new_id, normalize_alias
22+from aiatlas.ontology import benchmarks as bench_ontology
23+
24+ORG_TYPES = {"company", "organization", "lab", "university"}
25+MODES = ("merge", "alias", "variant", "family_member")
26+DERIVED_SOURCE_KEY = "ai-atlas.registry"
27+
28+
29+async def registry_source_id(conn: AsyncConnection) -> str | None:
30+ row = await fetch_one(conn, "select id from sources where key = :k", k=DERIVED_SOURCE_KEY)
31+ return row["id"] if row else None
32+
33+
34+async def record_decision(conn: AsyncConnection, a_id: str, b_id: str, decision: str, *, actor: str = "curation", note: str | None = None,
35+ payload: dict[str, Any] | None = None, applied: bool = True) -> None:
36+ await execute(conn, """insert into resolution_decisions (id, a_id, b_id, decision, actor, note, payload, applied)
37+ values (:id, :a, :b, :d, :actor, :note, cast(:p as jsonb), :applied)
38+ on conflict (a_id, b_id, decision) do update set applied = resolution_decisions.applied or excluded.applied,
39+ note = coalesce(excluded.note, resolution_decisions.note), payload = resolution_decisions.payload || excluded.payload""",
40+ id=f"rd_{new_id('review').split('_', 1)[1]}", a=a_id, b=b_id, d=decision, actor=actor, note=note, p=jsonb(payload or {}), applied=applied)
41+
42+
43+async def audit(conn: AsyncConnection, action: str, target: str | None, payload: dict[str, Any] | None = None, *, actor: str = "curation") -> None:
44+ await execute(conn, "insert into admin_audit_log (actor, action, target, payload) values (:actor, :action, :target, cast(:p as jsonb))",
45+ actor=actor, action=action, target=target, p=jsonb(payload or {}))
46+
47+
48+async def kept_separate(conn: AsyncConnection, a: str, b: str) -> bool:
49+ row = await fetch_one(conn, """select 1 from resolution_decisions where decision = 'keep_separate'
50+ and ((a_id = :a and b_id = :b) or (a_id = :b and b_id = :a)) limit 1""", a=a, b=b)
51+ return row is not None
52+
11 53
54+async def upsert_relation(conn: AsyncConnection, subject_id: str, predicate: str, object_id: str, attributes: dict[str, Any] | None = None, *,
55+ source_id: str | None = None, tier: int = 2, confidence: str = "high") -> bool:
56+ """Insert a live relation unless an identical live edge exists. Returns True when a row was inserted."""
57+ if subject_id == object_id:
58+ return False
59+ existing = await fetch_one(conn, "select id from relations where subject_id = :s and predicate = :p and object_id = :o and valid_to is null",
60+ s=subject_id, p=predicate, o=object_id)
61+ if existing:
62+ if attributes:
63+ await execute(conn, "update relations set attributes = attributes || cast(:a as jsonb) where id = :id", a=jsonb(attributes), id=existing["id"])
64+ return False
65+ await execute(conn, """insert into relations (id, subject_id, predicate, object_id, attributes, source_id, tier, confidence, observed_at, valid_from)
66+ values (:id, :s, :p, :o, cast(:a as jsonb), :src, :tier, :conf, now(), now())""",
67+ id=new_id("relation"), s=subject_id, p=predicate, o=object_id, a=jsonb(attributes or {}), src=source_id, tier=tier, conf=confidence)
68+ return True
12 69
13−async def merge_entities(conn: AsyncConnection, source_id: str, target_id: str) -> dict[str, Any]: 70+
71+async def merge_entities(conn: AsyncConnection, source_id: str, target_id: str, *, mode: str = "merge", actor: str = "curation",
72+ note: str | None = None, payload: dict[str, Any] | None = None) -> dict[str, Any]:
73+ if mode not in MODES:
74+ raise ValueError(f"unknown merge mode {mode!r}")
14 if source_id == target_id: 75 if source_id == target_id:
15 raise ValueError("source and target are the same entity") 76 raise ValueError("source and target are the same entity")
16 src = await fetch_one(conn, "select id, entity_type, canonical_name, slug, merged_into, attributes, provenance from entities where id = :id", id=source_id) 77 src = await fetch_one(conn, "select id, entity_type, canonical_name, slug, merged_into, attributes, provenance from entities where id = :id", id=source_id)
17− dst = await fetch_one(conn, "select id, entity_type, merged_into from entities where id = :id", id=target_id) 78+ dst = await fetch_one(conn, "select id, entity_type, canonical_name, merged_into from entities where id = :id", id=target_id)
18 if not src or not dst: 79 if not src or not dst:
19 raise LookupError("source or target entity not found") 80 raise LookupError("source or target entity not found")
20 if dst["merged_into"]: 81 if dst["merged_into"]:
21 raise ValueError("target is itself merged; merge into its survivor instead") 82 raise ValueError("target is itself merged; merge into its survivor instead")
22 if src["merged_into"]: 83 if src["merged_into"]:
23 raise ValueError("source is already merged") 84 raise ValueError("source is already merged")
24− if src["entity_type"] != dst["entity_type"]: 85+ if mode in ("merge", "alias") and await kept_separate(conn, source_id, target_id):
86+ raise ValueError("a keep_separate decision exists for this pair; refusing to merge")
87+ if mode == "variant":
88+ return await _mark_artifact(conn, src, dst, actor=actor, note=note, payload=payload)
89+ if mode == "family_member":
90+ return await _mark_family_member(conn, src, dst, actor=actor, note=note, payload=payload)
91+ same_type = src["entity_type"] == dst["entity_type"]
92+ org_pair = src["entity_type"] in ORG_TYPES and dst["entity_type"] in ORG_TYPES
93+ model_pair = {src["entity_type"], dst["entity_type"]} <= {"model", "artifact"}
94+ if not (same_type or org_pair or model_pair):
25 raise ValueError(f"cannot merge a {src['entity_type']} into a {dst['entity_type']}") 95 raise ValueError(f"cannot merge a {src['entity_type']} into a {dst['entity_type']}")
26 96
27 moved: dict[str, int] = {} 97 moved: dict[str, int] = {}
@@ -56,22 +126,123 @@ async def merge_entities(conn: AsyncConnection, source_id: str, target_id: str)
56 "provider_id = case when provider_id = :s then :t else provider_id end where model_id = :s or provider_id = :s") 126 "provider_id = case when provider_id = :s then :t else provider_id end where model_id = :s or provider_id = :s")
57 await count_update("results", "update benchmark_results set model_id = case when model_id = :s then :t else model_id end, " 127 await count_update("results", "update benchmark_results set model_id = case when model_id = :s then :t else model_id end, "
58 "benchmark_id = case when benchmark_id = :s then :t else benchmark_id end where model_id = :s or benchmark_id = :s") 128 "benchmark_id = case when benchmark_id = :s then :t else benchmark_id end where model_id = :s or benchmark_id = :s")
129+ moved["result_dedupe_collisions"] = await recompute_result_keys(conn, target_id, source_id=source_id)
130+ moved["results_closed"] = await enforce_current_results(conn, model_id=target_id)
59 await count_update("documents", "update documents set entity_id = :t where entity_id = :s") 131 await count_update("documents", "update documents set entity_id = :t where entity_id = :s")
60 await count_update("children", "update entities set organization_id = :t where organization_id = :s") 132 await count_update("children", "update entities set organization_id = :t where organization_id = :s")
133+ await count_update("family_members", "update entities set family_id = :t where family_id = :s")
134+ await count_update("artifacts", "update entities set canonical_id = :t where canonical_id = :s")
61 await count_update("llm_jobs", "update llm_jobs set entity_id = :t where entity_id = :s") 135 await count_update("llm_jobs", "update llm_jobs set entity_id = :t where entity_id = :s")
136+ await count_update("sources", "update sources set organization_id = :t where organization_id = :s")
137+ await count_update("domains", "update domains set organization_id = :t where organization_id = :s")
138+ await count_update("review_items", "update review_queue set entity_ids = array_replace(entity_ids, :s, :t) where :s = any(entity_ids)")
139+ # embeddings (table only exists where pgvector is installed): the target keeps its own vector; a source-only vector moves over
140+ if await fetch_val(conn, "select to_regclass('entity_embeddings') is not null"):
141+ has_target_vec = await fetch_one(conn, "select 1 from entity_embeddings where entity_id = :t", t=target_id)
142+ if has_target_vec:
143+ await count_update("embeddings", "delete from entity_embeddings where entity_id = :s")
144+ else:
145+ await count_update("embeddings", "update entity_embeddings set entity_id = :t where entity_id = :s")
62 # attributes the target lacks are inherited (with their provenance); target values always win 146 # attributes the target lacks are inherited (with their provenance); target values always win
63 await execute(conn, """update entities t set attributes = coalesce(s.attributes, '{}'::jsonb) || t.attributes, 147 await execute(conn, """update entities t set attributes = coalesce(s.attributes, '{}'::jsonb) || t.attributes,
64 provenance = coalesce(s.provenance, '{}'::jsonb) || t.provenance, last_seen_at = greatest(t.last_seen_at, s.last_seen_at), 148 provenance = coalesce(s.provenance, '{}'::jsonb) || t.provenance, last_seen_at = greatest(t.last_seen_at, s.last_seen_at),
65 first_seen_at = least(t.first_seen_at, s.first_seen_at), updated_at = now() 149 first_seen_at = least(t.first_seen_at, s.first_seen_at), updated_at = now()
66 from entities s where t.id = :t and s.id = :s""", s=source_id, t=target_id) 150 from entities s where t.id = :t and s.id = :s""", s=source_id, t=target_id)
67− await execute(conn, "update entities set merged_into = :t, status = 'merged', updated_at = now() where id = :s", s=source_id, t=target_id) 151+ await execute(conn, "update entities set merged_into = :t, status = 'merged', canonical_id = null, family_id = null, updated_at = now() where id = :s", s=source_id, t=target_id)
68 await execute(conn, "update entities set merged_into = :t where merged_into = :s", s=source_id, t=target_id) 152 await execute(conn, "update entities set merged_into = :t where merged_into = :s", s=source_id, t=target_id)
69− await execute(conn, """insert into change_events (id, entity_id, event_type, category, summary, importance, connector_name, dedupe_key, meta) 153+ await execute(conn, """insert into change_events (id, entity_id, event_type, category, summary, importance, connector_name, dedupe_key, meta, is_backfill)
70− values (:id, :t, 'ENTITY_MERGED', 'source', :sum, 1, 'curation', :dk, cast(:m as jsonb)) 154+ values (:id, :t, 'ENTITY_MERGED', 'source', :sum, 0, 'curation', :dk, cast(:m as jsonb), true)
71 on conflict (dedupe_key) do nothing""", 155 on conflict (dedupe_key) do nothing""",
72 id=new_id("change_event"), t=target_id, sum=f"Merged duplicate '{src['canonical_name']}' ({src['slug']})", dk=f"merge:{source_id}:{target_id}", 156 id=new_id("change_event"), t=target_id, sum=f"Merged duplicate '{src['canonical_name']}' ({src['slug']})", dk=f"merge:{source_id}:{target_id}",
73− m=f'{{"source_id": "{source_id}", "source_slug": "{src["slug"]}"}}') 157+ m=json.dumps({"source_id": source_id, "source_slug": src["slug"], "mode": mode}))
74− return {"source_id": source_id, "target_id": target_id, "moved": moved} 158+ decision = "alias" if mode == "alias" else "merge"
159+ await record_decision(conn, source_id, target_id, decision, actor=actor, note=note, payload={"moved": moved, **(payload or {})})
160+ await audit(conn, f"entity.{decision}", target_id, {"source_id": source_id, "source_slug": src["slug"], "target_id": target_id, "moved": moved, "note": note}, actor=actor)
161+ return {"source_id": source_id, "target_id": target_id, "mode": mode, "moved": moved}
162+
163+
164+async def _mark_artifact(conn: AsyncConnection, src: dict[str, Any], dst: dict[str, Any], *, actor: str, note: str | None, payload: dict[str, Any] | None) -> dict[str, Any]:
165+ if dst["entity_type"] not in ("model",):
166+ raise ValueError("the canonical entity of an artifact must be a model")
167+ kind = (payload or {}).get("artifact_kind") or (src["attributes"] or {}).get("artifact_kind") or "conversion"
168+ await execute(conn, """update entities set entity_type = 'artifact', canonical_id = :t, artifact_kind = coalesce(artifact_kind, :k),
169+ identity_confidence = coalesce(cast(:ic as text), identity_confidence), updated_at = now() where id = :s""",
170+ t=dst["id"], k=kind, ic=(payload or {}).get("identity_confidence"), s=src["id"])
171+ inserted = await upsert_relation(conn, src["id"], "artifact_of", dst["id"], {"artifact_kind": kind}, source_id=await registry_source_id(conn))
172+ await record_decision(conn, src["id"], dst["id"], "variant_of", actor=actor, note=note, payload={"artifact_kind": kind, **(payload or {})})
173+ await audit(conn, "entity.artifact_of", src["id"], {"canonical_id": dst["id"], "artifact_kind": kind, "note": note}, actor=actor)
174+ return {"source_id": src["id"], "target_id": dst["id"], "mode": "variant", "moved": {"relations": int(inserted)}}
175+
176+
177+async def _mark_family_member(conn: AsyncConnection, src: dict[str, Any], dst: dict[str, Any], *, actor: str, note: str | None, payload: dict[str, Any] | None) -> dict[str, Any]:
178+ if dst["entity_type"] != "model_family":
179+ raise ValueError("family_member requires a model_family target")
180+ await execute(conn, "update entities set family_id = :t, updated_at = now() where id = :s and family_id is distinct from :t", t=dst["id"], s=src["id"])
181+ inserted = await upsert_relation(conn, src["id"], "member_of_family", dst["id"], source_id=await registry_source_id(conn))
182+ await record_decision(conn, src["id"], dst["id"], "family_member", actor=actor, note=note, payload=payload)
183+ await audit(conn, "entity.family_member", src["id"], {"family_id": dst["id"], "note": note}, actor=actor)
184+ return {"source_id": src["id"], "target_id": dst["id"], "mode": "family_member", "moved": {"relations": int(inserted)}}
185+
186+
187+# ---------------------------------------------------------------------------------------------- benchmark result bookkeeping
188+def _cfg_hash(config: dict[str, Any] | None) -> str:
189+ import hashlib
190+
191+ return hashlib.sha1(json.dumps(config or {}, sort_keys=True, default=str).encode()).hexdigest()[:12]
192+
193+
194+async def recompute_result_keys(conn: AsyncConnection, model_id: str, *, source_id: str | None = None) -> int:
195+ """Recompute `dedupe_key` (= model:benchmark:cfg_hash:metric) and `config_key` for the rows now attached to `model_id`.
196+ Collisions (the target already had the same result from the same source) close the older row. Returns the number of collisions."""
197+ rows = await fetch_all(conn, """select id, benchmark_id, metric, config, dedupe_key, config_key, run_group, variant, observed_at, valid_to, is_current from benchmark_results
198+ where model_id = :m order by observed_at asc, id asc""", m=model_id)
199+ collisions = 0
200+ seen: dict[str, dict[str, Any]] = {}
201+ for r in rows:
202+ base = f"{model_id}:{r['benchmark_id']}:{_cfg_hash(r['config'])}:{r['metric'] or ''}"
203+ ck = bench_ontology.config_key(r["config"], r["metric"])
204+ rg = r["run_group"] or bench_ontology.run_group_from_config(r["config"])
205+ variant = r["variant"] or bench_ontology.variant_from_config(r["config"])
206+ if (rg, variant) != (r["run_group"], r["variant"]):
207+ await execute(conn, "update benchmark_results set run_group = :rg, variant = :v where id = :id", rg=rg, v=variant, id=r["id"])
208+ if r["valid_to"] is not None:
209+ # historical rows keep a unique suffixed key
210+ key = base if r["dedupe_key"] == base and base not in seen else f"{base}:{r['id'][-10:]}"
211+ await execute(conn, "update benchmark_results set dedupe_key = :d, config_key = :ck where id = :id and (dedupe_key <> :d or config_key is distinct from :ck)",
212+ d=key, ck=ck, id=r["id"])
213+ continue
214+ prev = seen.get(base)
215+ if prev is not None:
216+ # two live rows for the same key: keep the newer (rows are sorted by observed_at asc), close the older
217+ older, newer = prev, r
218+ await execute(conn, "update benchmark_results set valid_to = :o, is_current = false, dedupe_key = :d, config_key = :ck where id = :id",
219+ o=newer["observed_at"], d=f"{base}:{older['id'][-10:]}", ck=ck, id=older["id"])
220+ collisions += 1
221+ seen[base] = r
222+ await execute(conn, "update benchmark_results set dedupe_key = :d, config_key = :ck where id = :id and (dedupe_key <> :d or config_key is distinct from :ck)",
223+ d=base, ck=ck, id=r["id"])
224+ return collisions
225+
226+
227+async def enforce_current_results(conn: AsyncConnection, *, model_id: str | None = None, dry_run: bool = False) -> int:
228+ """One current row per (model, benchmark, metric, config_key): rows from an older run group than the latest observed one are closed
229+ (`is_current=false`, `valid_to` = the newer observation). Returns the number of rows (that would be) closed."""
230+ scope = "and r.model_id = :m" if model_id else ""
231+ sql = f"""with latest as (
232+ select distinct on (model_id, benchmark_id, coalesce(metric, ''), config_key) id, model_id, benchmark_id, coalesce(metric, '') as metric, config_key,
233+ coalesce(run_group, '') as run_group, observed_at
234+ from benchmark_results r where valid_to is null and is_current and config_key is not null {scope}
235+ order by model_id, benchmark_id, coalesce(metric, ''), config_key, observed_at desc, coalesce(run_group, '') desc, id desc)
236+ select r.id, l.observed_at as close_at from benchmark_results r join latest l
237+ on l.model_id = r.model_id and l.benchmark_id = r.benchmark_id and l.metric = coalesce(r.metric, '') and l.config_key = r.config_key
238+ where r.valid_to is null and r.is_current and r.id <> l.id and coalesce(r.run_group, '') <> l.run_group {scope}"""
239+ rows = await fetch_all(conn, sql, m=model_id) if model_id else await fetch_all(conn, sql)
240+ if dry_run or not rows:
241+ return len(rows)
242+ for r in rows:
243+ await execute(conn, "update benchmark_results set is_current = false, valid_to = :o where id = :id", o=r["close_at"], id=r["id"])
244+ return len(rows)
75 245
76 246
77−__all__ = ["merge_entities"] 247+__all__ = ["MODES", "ORG_TYPES", "audit", "enforce_current_results", "kept_separate", "merge_entities", "recompute_result_keys", "record_decision",
248+ "registry_source_id", "upsert_relation"]
added tests/test_events.py +47 −0
@@ -0,0 +1,47 @@
1+"""Event semantics: backfill classification, release grouping, deterministic importance."""
2+from __future__ import annotations
3+
4+from datetime import UTC, datetime, timedelta
5+
6+from aiatlas.services.events import classify_backfill, group_key_for, importance_for
7+
8+NOW = datetime(2026, 9, 12, 12, 0, tzinfo=UTC)
9+
10+
11+def test_backfill_rules() -> None:
12+ assert classify_backfill("NEW_MODEL", None, NOW) is False
13+ assert classify_backfill("NEW_MODEL", NOW - timedelta(days=1), NOW) is False # 1 day lag: news
14+ assert classify_backfill("NEW_MODEL", NOW - timedelta(days=4), NOW) is True # > 3 days: history being loaded
15+ assert classify_backfill("PRICE_CHANGED", None, NOW, is_first_run=True) is True # connector's first run = initial corpus
16+ assert classify_backfill("NEW_MODEL", None, NOW, entity_first_seen=NOW - timedelta(days=30)) is True
17+ assert classify_backfill("PRICE_CHANGED", None, NOW, entity_first_seen=NOW - timedelta(days=30)) is False # hint only applies to NEW_*
18+ assert classify_backfill("NEW_PAPER", datetime(2026, 9, 11), NOW) is False # naive datetimes are treated as UTC
19+
20+
21+def test_group_key() -> None:
22+ assert group_key_for("NEW_MODEL", "model_x", datetime(2026, 5, 3, tzinfo=UTC), NOW) == "release:model_x:2026-05"
23+ assert group_key_for("RELEASE", "model_x", None, NOW) == "release:model_x:2026-09"
24+ assert group_key_for("PRICE_CHANGED", "model_x", None, NOW) is None
25+ assert group_key_for("RELEASE", None, None, NOW) is None
26+
27+
28+def test_importance_matrix() -> None:
29+ assert importance_for("NEW_MODEL", "artifact") == 0
30+ assert importance_for("NEW_MODEL_FAMILY", "model_family") == 1
31+ assert importance_for("PROPERTY_CHANGED", "model") == 0
32+ assert importance_for("NEW_MODEL", "model", org_model_count=5, tier=1) == 3 # frontier lab
33+ assert importance_for("NEW_MODEL", "model", org_model_count=1, tier=1) == 2
34+ assert importance_for("NEW_MODEL", "model", org_model_count=1, openness="open-weights") == 3
35+ assert importance_for("NEW_MODEL", "model", org_model_count=5, tier=3) == 1 # not frontier at tier 3, and a community source loses a point
36+ old, new = {"input_per_mtok": 10, "output_per_mtok": 30}, {"input_per_mtok": 5, "output_per_mtok": 30}
37+ assert importance_for("PRICE_CHANGED", "model", old, new) == 3 # -50 %
38+ assert importance_for("PRICE_CHANGED", "model", old, {"input_per_mtok": 7.5, "output_per_mtok": 30}) == 2 # -25 %
39+ assert importance_for("PRICE_CHANGED", "model", old, {"input_per_mtok": 9.5, "output_per_mtok": 30}) == 1 # -5 %
40+ assert importance_for("CONTEXT_CHANGED", "model", 200_000, 1_000_000) == 3
41+ assert importance_for("CONTEXT_CHANGED", "model", 200_000, 400_000) == 2
42+ assert importance_for("CONTEXT_CHANGED", "model", 200_000, 100_000) == 1
43+ assert importance_for("DEPRECATION_ANNOUNCED", "model") == 3 and importance_for("RETIREMENT_ANNOUNCED", "model") == 3
44+ assert importance_for("STATUS_CHANGED", "model", "active", "deprecated") == 3 and importance_for("STATUS_CHANGED", "model", "preview", "active") == 2
45+ assert importance_for("OPENNESS_CHANGED", "model", "proprietary", "open-weights") == 3
46+ assert importance_for("BENCHMARK_RESULT", "model", leader_change=True) == 2 and importance_for("BENCHMARK_RESULT", "model", default=1) == 1
47+ assert importance_for("PRICE_CHANGED", "artifact", old, new) == 0
added tests/test_fetch_ssrf.py +102 −0
@@ -0,0 +1,102 @@
1+"""SSRF guard — pure validator tests (no network: DNS answers are injected)."""
2+from __future__ import annotations
3+
4+import pytest
5+
6+from aiatlas.sdk.fetch import MAX_REDIRECTS, BlockedDestination, validate_destination
7+
8+
9+@pytest.mark.parametrize("url", [
10+ "ftp://example.com/x", "file:///etc/passwd", "gopher://example.com", "javascript:alert(1)", "data:text/html,hi",
11+ "http://localhost/", "http://LOCALHOST:8080/", "https://foo.local/", "http://db.internal/", "http://intranet/", "http://metadata.google.internal/computeMetadata/v1/",
12+ "http://127.0.0.1/", "http://127.1.2.3/", "http://10.0.0.5/", "http://172.16.4.4/", "http://172.31.255.1/", "http://192.168.2.10/",
13+ "http://169.254.169.254/latest/meta-data/", "http://169.254.170.2/", "http://100.64.1.1/", "http://100.127.255.255/", "http://0.0.0.0/",
14+ "http://[::1]/", "http://[fe80::1]/", "http://[fc00::1]/", "http://[fd12:3456::1]/", "http://[::]/", "http://[::ffff:127.0.0.1]/", "http://[::ffff:10.0.0.1]/",
15+ "http://user:pass@example.com/",
16+])
17+def test_blocked_literals(url: str) -> None:
18+ with pytest.raises(BlockedDestination):
19+ validate_destination(url, resolved_ips=[])
20+
21+
22+@pytest.mark.parametrize("url", ["https://huggingface.co/Qwen/Qwen3-8B", "http://arxiv.org/abs/2505.09388", "https://8.8.8.8/", "https://[2606:4700::6810:84e5]/"])
23+def test_public_allowed(url: str) -> None:
24+ validate_destination(url, resolved_ips=["104.16.0.1"])
25+
26+
27+def test_dns_rebinding_to_private_is_blocked() -> None:
28+ with pytest.raises(BlockedDestination):
29+ validate_destination("https://evil.example.com/", resolved_ips=["10.0.0.9"])
30+ with pytest.raises(BlockedDestination):
31+ validate_destination("https://evil.example.com/", resolved_ips=["104.16.0.1", "169.254.169.254"]) # any private answer blocks
32+ with pytest.raises(BlockedDestination):
33+ validate_destination("https://evil.example.com/", resolved_ips=["fd00::1"])
34+
35+
36+def test_unresolvable_host_is_not_ssrf() -> None:
37+ # resolution failures surface later as transport errors, not as blocked destinations
38+ validate_destination("https://does-not-exist.example.com/", resolved_ips=[])
39+
40+
41+def test_redirect_cap_constant() -> None:
42+ assert MAX_REDIRECTS == 5
43+
44+
45+async def test_fetcher_get_blocks_before_connecting() -> None:
46+ from aiatlas.sdk.fetch import Fetcher, FetchError
47+
48+ async with Fetcher(robots=False) as f:
49+ with pytest.raises(FetchError) as exc:
50+ await f.get("http://169.254.169.254/latest/meta-data/")
51+ assert "blocked destination" in str(exc.value)
52+ with pytest.raises(FetchError):
53+ await f.get("file:///etc/hosts")
54+
55+
56+async def test_fetcher_blocks_redirect_to_private(monkeypatch: pytest.MonkeyPatch) -> None:
57+ """A public host redirecting to an internal address is refused on the redirect hop (uses respx-free httpx MockTransport)."""
58+ import httpx
59+
60+ from aiatlas.sdk import fetch as fetch_mod
61+ from aiatlas.sdk.fetch import Fetcher, FetchError
62+
63+ async def fake_validate(url: str) -> None:
64+ # public → ok, private literal → raise, exactly like the real validator without DNS
65+ fetch_mod.validate_destination(url, resolved_ips=["104.16.0.1"])
66+
67+ monkeypatch.setattr(fetch_mod, "validate_destination_async", fake_validate)
68+
69+ def handler(request: httpx.Request) -> httpx.Response:
70+ if request.url.host == "public.example.com":
71+ return httpx.Response(302, headers={"location": "http://169.254.169.254/latest/"})
72+ return httpx.Response(200, content=b"x" * 100)
73+
74+ async with Fetcher(robots=False) as f:
75+ f._client = httpx.AsyncClient(transport=httpx.MockTransport(handler), follow_redirects=False)
76+ with pytest.raises(FetchError) as exc:
77+ await f.get("https://public.example.com/start", min_bytes=1)
78+ assert "blocked destination" in str(exc.value)
79+
80+
81+async def test_fetcher_caps_redirects(monkeypatch: pytest.MonkeyPatch) -> None:
82+ import httpx
83+
84+ from aiatlas.sdk import fetch as fetch_mod
85+ from aiatlas.sdk.fetch import Fetcher, FetchError
86+
87+ async def fake_validate(url: str) -> None:
88+ fetch_mod.validate_destination(url, resolved_ips=["104.16.0.1"])
89+
90+ monkeypatch.setattr(fetch_mod, "validate_destination_async", fake_validate)
91+ n = {"hops": 0}
92+
93+ def handler(request: httpx.Request) -> httpx.Response:
94+ n["hops"] += 1
95+ return httpx.Response(301, headers={"location": f"https://public.example.com/{n['hops']}"})
96+
97+ async with Fetcher(robots=False) as f:
98+ f._client = httpx.AsyncClient(transport=httpx.MockTransport(handler), follow_redirects=False)
99+ with pytest.raises(FetchError) as exc:
100+ await f.get("https://public.example.com/start", min_bytes=1)
101+ assert "too many redirects" in str(exc.value)
102+ assert n["hops"] == MAX_REDIRECTS + 1
added tests/test_resolver.py +123 −0
@@ -0,0 +1,123 @@
1+"""Resolver guards against the local dev database (rolled back)."""
2+from __future__ import annotations
3+
4+import uuid
5+from collections.abc import AsyncIterator
6+
7+import pytest
8+from sqlalchemy.ext.asyncio import AsyncConnection
9+
10+from aiatlas import db
11+from aiatlas.db import execute, fetch_one
12+from aiatlas.sdk.facts import EntityRef, Facts
13+from aiatlas.sdk.resolution import Resolver, compatible_types
14+from aiatlas.sdk.writer import FactWriter
15+from aiatlas.services.merge import merge_entities, record_decision
16+
17+
18+@pytest.fixture
19+async def conn() -> AsyncIterator[AsyncConnection]:
20+ async with db.engine().connect() as c:
21+ trans = await c.begin()
22+ try:
23+ yield c
24+ finally:
25+ await trans.rollback()
26+ await db.dispose()
27+
28+
29+def _tag() -> str:
30+ return uuid.uuid4().hex[:8]
31+
32+
33+async def _write(conn: AsyncConnection, facts: Facts, *, tier: int = 1) -> FactWriter:
34+ w = FactWriter(conn, source_id=None, snapshot_id=None, source_url="https://example.com/x", tier=tier, connector_name="test", run_id="run_test", source_key="")
35+ await w.write(facts)
36+ return w
37+
38+
39+def test_compatible_types() -> None:
40+ assert set(compatible_types("model")) == {"model", "artifact"} and compatible_types("paper") == ("paper",)
41+
42+
43+async def test_alias_collision_requires_variant_key(conn: AsyncConnection) -> None:
44+ t = _tag()
45+ f = Facts()
46+ a = f.entity("model", f"Qwen{t}-8B")
47+ await _write(conn, f)
48+ r = Resolver(conn, source_tier=2)
49+ # "Qwen{t} 38B" normalises to the same alias key but is another size → must not resolve onto the 8B model
50+ other = await r.resolve(EntityRef("model", f"Qwen{t} 38B"), create=False)
51+ assert other is None
52+ same = await r.resolve(EntityRef("model", f"Qwen{t} 8B"), create=False)
53+ assert same == a.id
54+
55+
56+async def test_keep_separate_decision_blocks_alias_and_merge(conn: AsyncConnection) -> None:
57+ t = _tag()
58+ f = Facts()
59+ org1 = f.entity("company", f"Org One {t}")
60+ org2 = f.entity("company", f"Org Two {t}")
61+ a = f.entity("model", f"Nimbus {t} Pro", organization=org1, identifiers={"hf_repo": f"one/nimbus-{t}"}, aliases=[f"Nimbus {t}"])
62+ b = f.entity("model", f"Nimbus {t} b", organization=org2, aliases=[f"Nimbus {t}"])
63+ await _write(conn, f)
64+ await record_decision(conn, a.id, b.id, "keep_separate", actor="test", note="different vendors")
65+ r = Resolver(conn, source_tier=2)
66+ # ambiguous alias with both candidates kept separate → no guess; with the organisation of B → B; the pair is never merged automatically
67+ assert await r.resolve(EntityRef("model", f"Nimbus {t}"), create=False) is None
68+ assert await r.resolve(EntityRef("model", f"Nimbus {t}", organization=EntityRef("company", f"Org One {t}", id=org1.id)), create=False) == a.id
69+ assert await r.resolve(EntityRef("model", f"Nimbus {t}", organization=EntityRef("company", f"Org Two {t}", id=org2.id)), create=False) == b.id
70+ with pytest.raises(ValueError):
71+ await merge_entities(conn, a.id, b.id)
72+
73+
74+async def test_model_ref_resolves_to_artifact_row(conn: AsyncConnection) -> None:
75+ t = _tag()
76+ f = Facts()
77+ art = f.entity("artifact", f"unsloth/Zeta-{t}-GGUF", identifiers={"hf_repo": f"unsloth/Zeta-{t}-GGUF"})
78+ await _write(conn, f)
79+ r = Resolver(conn, source_tier=2)
80+ assert await r.resolve(EntityRef("model", f"Zeta-{t}-GGUF", identifiers={"hf_repo": f"unsloth/Zeta-{t}-GGUF"}), create=False) == art.id
81+
82+
83+async def test_resolve_variant_only_for_evaluator_refs(conn: AsyncConnection) -> None:
84+ t = _tag()
85+ f = Facts()
86+ org = f.entity("company", f"Zorg {t}")
87+ base = f.entity("model", f"Zeta {t}", organization=org, identifiers={"artificial_analysis": f"zeta-{t}"})
88+ await _write(conn, f, tier=2)
89+ r = Resolver(conn, source_tier=2)
90+ folded = await r.resolve_variant(EntityRef("model", f"zeta-{t}-xhigh"))
91+ assert folded == (base.id, {"reasoning_effort": "xhigh"})
92+ assert await r.resolve_variant(EntityRef("model", f"zeta-{t}")) is None # not a variant
93+ assert await r.resolve_variant(EntityRef("model", f"omega-{t}-high")) is None # base unknown
94+ # a ref with its own hub identifier is a real model even if its name ends in "-thinking": created, never folded
95+ real = await r.resolve(EntityRef("model", f"zeta-{t}-thinking", identifiers={"hf_repo": f"zorg/Zeta-{t}-Thinking"}))
96+ assert real != base.id
97+ # a tier-1 resolver never folds either
98+ r1 = Resolver(conn, source_tier=1)
99+ official = await r1.resolve(EntityRef("model", f"zeta-{t}-high"), create=False)
100+ assert official is None
101+ # an evaluator-only ref folds and records the fold
102+ r2 = Resolver(conn, source_tier=2)
103+ eid = await r2.resolve(EntityRef("model", f"zeta-{t}-high", identifiers={"artificial_analysis": f"zeta-{t}-high"}))
104+ assert eid == base.id and r2.folded[f"model:artificial_analysis=zeta-{t}-high"] == (base.id, {"reasoning_effort": "high"})
105+
106+
107+async def test_first_seen_hint_and_touch(conn: AsyncConnection) -> None:
108+ from datetime import UTC, datetime
109+
110+ t = _tag()
111+ r = Resolver(conn, source_tier=1)
112+ eid = await r.resolve(EntityRef("model", f"Zeta {t}", first_seen_hint=datetime(2023, 5, 1, tzinfo=UTC)))
113+ row = await fetch_one(conn, "select first_seen_at from entities where id = :id", id=eid)
114+ assert row["first_seen_at"] == datetime(2023, 5, 1, tzinfo=UTC)
115+ r2 = Resolver(conn, source_tier=1)
116+ await r2.resolve(EntityRef("model", f"Zeta {t}", first_seen_hint=datetime(2022, 1, 1, tzinfo=UTC)))
117+ row = await fetch_one(conn, "select first_seen_at from entities where id = :id", id=eid)
118+ assert row["first_seen_at"] == datetime(2022, 1, 1, tzinfo=UTC)
119+ # a future hint never moves first_seen forward
120+ await execute(conn, "update entities set first_seen_at = :d where id = :id", d=datetime(2022, 1, 1, tzinfo=UTC), id=eid)
121+ await Resolver(conn).resolve(EntityRef("model", f"Zeta {t}", first_seen_hint=datetime(2030, 1, 1, tzinfo=UTC)))
122+ row = await fetch_one(conn, "select first_seen_at from entities where id = :id", id=eid)
123+ assert row["first_seen_at"] == datetime(2022, 1, 1, tzinfo=UTC)
added tests/test_writer.py +266 −0
@@ -0,0 +1,266 @@
1+"""FactWriter rules against the local dev database, inside a transaction that is always rolled back (nothing persists)."""
2+from __future__ import annotations
3+
4+import uuid
5+from collections.abc import AsyncIterator
6+from datetime import UTC, datetime, timedelta
7+
8+import pytest
9+from sqlalchemy.ext.asyncio import AsyncConnection
10+
11+from aiatlas import db
12+from aiatlas.db import fetch_all, fetch_one
13+from aiatlas.sdk.facts import Claim, EntityRef, Event, Facts, PriceObs, ResultObs, Target, facts_from_json, facts_to_json
14+from aiatlas.sdk.writer import FactWriter
15+
16+pytestmark = pytest.mark.usefixtures("conn")
17+
18+
19+@pytest.fixture
20+async def conn() -> AsyncIterator[AsyncConnection]:
21+ async with db.engine().connect() as c:
22+ trans = await c.begin()
23+ try:
24+ yield c
25+ finally:
26+ await trans.rollback()
27+ await db.dispose()
28+
29+
30+def _tag() -> str:
31+ return uuid.uuid4().hex[:8]
32+
33+
34+def _writer(conn: AsyncConnection, *, tier: int = 1, extractor: str = "deterministic", url: str = "https://example.com/doc", run_id: str | None = "run_test",
35+ observed_at: datetime | None = None, source_key: str | None = None, is_first_run: bool = False) -> FactWriter:
36+ return FactWriter(conn, source_id=None, snapshot_id=None, source_url=url, tier=tier, connector_name="test", extractor=extractor,
37+ extractor_version="t", observed_at=observed_at, run_id=run_id, source_key=source_key or "", is_first_run=is_first_run)
38+
39+
40+# ---------------------------------------------------------------------------------------------- pure: Facts JSON round-trip
41+def test_facts_json_round_trip() -> None:
42+ f = Facts()
43+ org = f.entity("company", "Zorg Labs", identifiers={"domain": "zorg.example"})
44+ m = f.entity("model", "Zeta 9", identifiers={"hf_repo": "zorg/Zeta-9"}, organization=org, attributes={"license": "Apache 2.0", "released": datetime(2026, 1, 2, tzinfo=UTC)},
45+ first_seen_hint=datetime(2025, 12, 31, tzinfo=UTC), family=EntityRef("model_family", "Zeta"), artifact_kind=None)
46+ f.claim(m, "context_length", 128000, unit="tokens", observed_at=datetime(2026, 1, 3, tzinfo=UTC))
47+ f.relate(org, "develops", m, attributes={"role": "developer"})
48+ f.event("RELEASE", "model", "Zeta 9 released", entity=m, effective_at=datetime(2026, 1, 2, tzinfo=UTC), importance=3)
49+ f.price(model=m, provider=f.entity("provider", "Zorg Cloud"), input_per_mtok=1.5, output_per_mtok=6.0, provider_model_id="zeta-9")
50+ f.result(model=m, benchmark=f.entity("benchmark", "GPQA Diamond"), score=71.2, metric="accuracy", config={"variant": "Diamond"}, evaluated_at=datetime(2026, 1, 5, tzinfo=UTC))
51+ f.follow("https://zorg.example/more", doc_type="page", key="more", meta={"x": 1})
52+ f.document_entity = m
53+ f.document_title = "Zeta 9"
54+ data = facts_to_json(f)
55+ back = facts_from_json(data)
56+ assert [e.name for e in back.entities] == [e.name for e in f.entities]
57+ assert back.entities[1].first_seen_hint == datetime(2025, 12, 31, tzinfo=UTC) and back.entities[1].family and back.entities[1].family.name == "Zeta"
58+ assert back.entities[1].attributes["released"] == datetime(2026, 1, 2, tzinfo=UTC) and back.entities[1].organization.identifiers == {"domain": "zorg.example"}
59+ assert back.claims[0].observed_at == datetime(2026, 1, 3, tzinfo=UTC) and back.claims[0].entity.key() == m.key()
60+ assert back.relations[0].attributes == {"role": "developer"} and back.events[0].effective_at == datetime(2026, 1, 2, tzinfo=UTC)
61+ assert back.prices[0].input_per_mtok == 1.5 and back.results[0].evaluated_at == datetime(2026, 1, 5, tzinfo=UTC) and back.results[0].config == {"variant": "Diamond"}
62+ assert back.targets[0].key == "more" and back.document_entity.key() == m.key() and back.document_title == "Zeta 9"
63+ assert facts_to_json(back) == data
64+
65+
66+# ---------------------------------------------------------------------------------------------- taxonomy at write time
67+async def test_license_normalised_with_raw_and_mapping(conn: AsyncConnection) -> None:
68+ t = _tag()
69+ f = Facts()
70+ m = f.entity("model", f"Zeta {t}", identifiers={"hf_repo": f"zorg/zeta-{t}"}, attributes={"license": "Apache 2.0", "openness": "restricted", "modalities": ["Text", "pdf"]})
71+ w = _writer(conn)
72+ await w.write(f)
73+ claims = {r["property"]: r for r in await fetch_all(conn, "select * from claims where entity_id = :e and status = 'current'", e=m.id)}
74+ assert claims["license"]["value"] == "Apache-2.0" and claims["license"]["value_raw"] == "Apache 2.0" and claims["license"]["run_id"] == "run_test"
75+ assert claims["license_key"]["value"] == "Apache-2.0"
76+ assert claims["openness"]["value"] == "restricted-weights" and claims["openness"]["value_raw"] == "restricted"
77+ assert claims["modalities"]["value"] == ["document", "text"]
78+ ent = await fetch_one(conn, "select attributes from entities where id = :e", e=m.id)
79+ assert ent["attributes"]["license"] == "Apache-2.0" and ent["attributes"]["license_raw"] == "Apache 2.0" and ent["attributes"]["license_key"] == "Apache-2.0"
80+ assert ent["attributes"]["modalities"] == ["document", "text"] and ent["attributes"]["modalities_raw"] == "Text, pdf"
81+ mapping = await fetch_one(conn, "select canonical from taxonomy_mappings where domain = 'license' and raw = 'Apache 2.0'")
82+ assert mapping and mapping["canonical"] == "Apache-2.0"
83+ # a second source spelling the same licence differently confirms, never a LICENSE_CHANGED event
84+ f2 = Facts()
85+ m2 = f2.entity("model", f"Zeta {t}", identifiers={"hf_repo": f"zorg/zeta-{t}"}, attributes={"license": "apache-2.0"})
86+ await _writer(conn, tier=2, url="https://other.example/x").write(f2)
87+ assert m2.id == m.id
88+ n = await fetch_one(conn, "select count(*) as n from claims where entity_id = :e and property = 'license' and status = 'current'", e=m.id)
89+ assert n["n"] == 1
90+ ev = await fetch_all(conn, "select event_type from change_events where entity_id = :e", e=m.id)
91+ assert {e["event_type"] for e in ev} == {"NEW_MODEL"}
92+
93+
94+async def test_unknown_taxonomy_value_kept(conn: AsyncConnection) -> None:
95+ t = _tag()
96+ f = Facts()
97+ m = f.entity("model", f"Zeta {t}", attributes={"license": f"custom-{t}"})
98+ await _writer(conn).write(f)
99+ c = await fetch_one(conn, "select value, value_raw from claims where entity_id = :e and property = 'license'", e=m.id)
100+ assert c["value"] == f"custom-{t}" and c["value_raw"] is None
101+ mp = await fetch_one(conn, "select canonical from taxonomy_mappings where domain = 'license' and raw = :r", r=f"custom-{t}")
102+ assert mp and mp["canonical"] is None
103+
104+
105+# ---------------------------------------------------------------------------------------------- same-source loophole & supersede
106+async def test_llm_never_supersedes_deterministic_same_source(conn: AsyncConnection) -> None:
107+ t = _tag()
108+ url = f"https://docs.example/{t}"
109+ f = Facts()
110+ m = f.entity("model", f"Zeta {t}", identifiers={"hf_repo": f"zorg/zeta-{t}"}, attributes={"context_length": 100_000})
111+ await _writer(conn, tier=1, url=url).write(f)
112+ # LLM extraction from the same URL, one tier lower
113+ f2 = Facts()
114+ m2 = f2.entity("model", f"Zeta {t}", identifiers={"hf_repo": f"zorg/zeta-{t}"}, attributes={"context_length": 200_000})
115+ llm = _writer(conn, tier=2, extractor="llm", url=url)
116+ await llm.write(f2)
117+ rows = await fetch_all(conn, "select value, status, extractor from claims where entity_id = :e and property = 'context_length' order by value_num", e=m.id)
118+ assert [(r["value"], r["status"]) for r in rows] == [(100_000, "current"), (200_000, "conflicting")]
119+ assert llm.stats.conflicts == 1
120+ assert await fetch_one(conn, "select 1 from review_queue where kind = 'conflict' and :e = any(entity_ids)", e=m.id)
121+ # the deterministic extractor correcting itself from the same URL supersedes → CONTEXT_CHANGED with deterministic importance
122+ f3 = Facts()
123+ f3.entity("model", f"Zeta {t}", identifiers={"hf_repo": f"zorg/zeta-{t}"}, attributes={"context_length": 1_000_000})
124+ await _writer(conn, tier=1, url=url).write(f3)
125+ cur = await fetch_one(conn, "select value from claims where entity_id = :e and property = 'context_length' and status = 'current'", e=m.id)
126+ assert cur["value"] == 1_000_000 and m2.id == m.id
127+ ev = await fetch_one(conn, "select importance, is_backfill, run_id, recorded_at from change_events where entity_id = :e and event_type = 'CONTEXT_CHANGED'", e=m.id)
128+ assert ev and ev["importance"] == 3 and ev["is_backfill"] is False and ev["run_id"] == "run_test" and ev["recorded_at"] is not None
129+
130+
131+async def test_derived_writer_conflicts_without_review_items(conn: AsyncConnection) -> None:
132+ t = _tag()
133+ f = Facts()
134+ m = f.entity("model", f"Zeta {t}", attributes={"openness": "open-weights"})
135+ await _writer(conn, tier=1).write(f)
136+ f2 = Facts()
137+ f2.claim(EntityRef("model", f"Zeta {t}", id=m.id), "openness", "restricted-weights")
138+ w = _writer(conn, tier=2, extractor="derived", url=None)
139+ await w.write(f2)
140+ assert w.stats.conflicts == 1
141+ assert (await fetch_one(conn, "select value from claims where entity_id = :e and property = 'openness' and status = 'current'", e=m.id))["value"] == "open-weights"
142+ assert not await fetch_one(conn, "select 1 from review_queue where kind = 'conflict' and :e = any(entity_ids)", e=m.id)
143+
144+
145+# ---------------------------------------------------------------------------------------------- results: comparability, one current row per key, bounds
146+async def test_results_one_current_per_config_key(conn: AsyncConnection) -> None:
147+ t = _tag()
148+ f = Facts()
149+ m = f.entity("model", f"Zeta {t}", identifiers={"hf_repo": f"zorg/zeta-{t}"})
150+ b = f.entity("benchmark", f"LiveBench {t}", identifiers={"registry_benchmark": f"livebench-{t}"})
151+ f.result(model=m, benchmark=b, score=61.0, metric="global_average", unit="%", config={"release": "2026-05-01", "livebench_model_id": "zeta"})
152+ await _writer(conn, tier=2, source_key="livebench.ai").write(f)
153+ first = await fetch_one(conn, "select * from benchmark_results where model_id = :m", m=m.id)
154+ assert first["config_key"] and first["trust_level"] == "official-benchmark" and first["run_group"] == "2026-05-01" and first["is_current"] is True
155+ assert first["run_id"] == "run_test" and first["extractor"] == "deterministic"
156+ f2 = Facts()
157+ m2 = f2.entity("model", f"Zeta {t}", identifiers={"hf_repo": f"zorg/zeta-{t}"})
158+ b2 = f2.entity("benchmark", f"LiveBench {t}", identifiers={"registry_benchmark": f"livebench-{t}"})
159+ f2.result(model=m2, benchmark=b2, score=64.0, metric="global_average", unit="%", config={"release": "2026-06-25", "livebench_model_id": "zeta"})
160+ await _writer(conn, tier=2, source_key="livebench.ai", observed_at=datetime.now(UTC) + timedelta(seconds=1)).write(f2)
161+ rows = await fetch_all(conn, "select score, is_current, valid_to, config_key, run_group from benchmark_results where model_id = :m order by observed_at", m=m.id)
162+ assert [(r["score"], r["is_current"], r["valid_to"] is None) for r in rows] == [(61.0, False, False), (64.0, True, True)]
163+ assert rows[0]["config_key"] == rows[1]["config_key"]
164+ # same run group, different condition (reasoning effort) → both stay current, same config_key
165+ f3 = Facts()
166+ m3 = f3.entity("model", f"Zeta {t}", identifiers={"hf_repo": f"zorg/zeta-{t}"})
167+ b3 = f3.entity("benchmark", f"LiveBench {t}", identifiers={"registry_benchmark": f"livebench-{t}"})
168+ f3.result(model=m3, benchmark=b3, score=66.0, metric="global_average", unit="%", config={"release": "2026-06-25", "livebench_model_id": "zeta", "reasoning_effort": "high"})
169+ await _writer(conn, tier=2, source_key="livebench.ai", observed_at=datetime.now(UTC) + timedelta(seconds=2)).write(f3)
170+ cur = await fetch_all(conn, "select score from benchmark_results where model_id = :m and is_current order by score", m=m.id)
171+ assert [r["score"] for r in cur] == [64.0, 66.0]
172+
173+
174+async def test_result_out_of_bounds_flagged(conn: AsyncConnection) -> None:
175+ t = _tag()
176+ f = Facts()
177+ m = f.entity("model", f"Zeta {t}")
178+ b = f.entity("benchmark", f"Bench {t}")
179+ f.result(model=m, benchmark=b, score=140.0, metric="accuracy", unit="%")
180+ await _writer(conn, tier=2).write(f)
181+ r = await fetch_one(conn, "select confidence from benchmark_results where model_id = :m", m=m.id)
182+ assert r["confidence"] == "low"
183+ a = await fetch_one(conn, "select check_name, severity, status from anomalies where entity_id = :m", m=m.id)
184+ assert a and a["check_name"] == "score_above_max" and a["severity"] == "critical" and a["status"] == "open"
185+
186+
187+async def test_effort_variant_result_config_augmented(conn: AsyncConnection) -> None:
188+ """An evaluator row named '<model>-high' lands on the canonical model with reasoning_effort in the config."""
189+ t = _tag()
190+ base = Facts()
191+ m = base.entity("model", f"Zeta {t}", identifiers={"artificial_analysis": f"zeta-{t}"})
192+ await _writer(conn, tier=2, source_key="artificialanalysis.ai").write(base)
193+ f = Facts()
194+ v = f.entity("model", f"zeta-{t}-high", identifiers={"artificial_analysis": f"zeta-{t}-high"})
195+ b = f.entity("benchmark", f"GPQA {t}", identifiers={"registry_benchmark": f"gpqa-{t}"})
196+ f.result(model=v, benchmark=b, score=80.0, metric="accuracy", unit="%", config={"evaluator": "Artificial Analysis", "index_version": "4.3", "aa_slug": f"zeta-{t}-high"})
197+ w = _writer(conn, tier=2, source_key="artificialanalysis.ai")
198+ await w.write(f)
199+ assert v.id == m.id and w.stats.folded_variants == 1
200+ r = await fetch_one(conn, "select config, trust_level from benchmark_results where model_id = :m", m=m.id)
201+ assert r["config"]["reasoning_effort"] == "high" and r["config"]["aa_variant_slug"] == f"zeta-{t}-high" and r["trust_level"] == "independent-evaluator"
202+ ids = await fetch_all(conn, "select value from entity_identifiers where entity_id = :m and scheme = 'artificial_analysis' order by value", m=m.id)
203+ assert [i["value"] for i in ids] == [f"zeta-{t}", f"zeta-{t}-high"]
204+ assert not await fetch_one(conn, "select 1 from entities where canonical_name = :n", n=f"zeta-{t}-high")
205+
206+
207+# ---------------------------------------------------------------------------------------------- events: backfill, group key, NEW_* importance
208+async def test_new_entity_events_backfill_and_grouping(conn: AsyncConnection) -> None:
209+ t = _tag()
210+ f = Facts()
211+ m = f.entity("model", f"Zeta {t}", attributes={"release_date": "2024-01-15"})
212+ await _writer(conn, tier=1).write(f)
213+ ev = await fetch_one(conn, "select is_backfill, group_key, effective_at, importance from change_events where entity_id = :e and event_type = 'NEW_MODEL'", e=m.id)
214+ assert ev["is_backfill"] is True and ev["group_key"] == f"release:{m.id}:2024-01" and ev["importance"] == 2
215+ # first run of a connector → everything is backfill even without dates
216+ f2 = Facts()
217+ m2 = f2.entity("model", f"Zeta {t} b")
218+ await _writer(conn, tier=1, is_first_run=True).write(f2)
219+ ev2 = await fetch_one(conn, "select is_backfill from change_events where entity_id = :e", e=m2.id)
220+ assert ev2["is_backfill"] is True
221+ # artifacts and families never make importance-3 news
222+ f3 = Facts()
223+ art = f3.entity("artifact", f"unsloth/Zeta-{t}-GGUF", identifiers={"hf_repo": f"unsloth/Zeta-{t}-GGUF"}, canonical=EntityRef("model", f"Zeta {t}", id=m.id), artifact_kind="quantization")
224+ fam = f3.entity("model_family", f"Zeta family {t}")
225+ await _writer(conn, tier=2).write(f3)
226+ imps = {r["event_type"]: r["importance"] for r in await fetch_all(conn, "select event_type, importance from change_events where entity_id in (:a, :f)", a=art.id, f=fam.id)}
227+ assert imps == {"NEW_ARTIFACT": 0, "NEW_MODEL_FAMILY": 1}
228+ row = await fetch_one(conn, "select canonical_id, artifact_kind from entities where id = :a", a=art.id)
229+ assert row["canonical_id"] == m.id and row["artifact_kind"] == "quantization"
230+ assert await fetch_one(conn, "select 1 from relations where subject_id = :a and predicate = 'artifact_of' and object_id = :m and valid_to is null", a=art.id, m=m.id)
231+
232+
233+async def test_family_hint_and_first_seen_hint(conn: AsyncConnection) -> None:
234+ t = _tag()
235+ f = Facts()
236+ m = f.entity("model", f"Llama {t} 9B", family=EntityRef("model_family", f"Llama {t}"), first_seen_hint=datetime(2024, 3, 1, tzinfo=UTC), identity_confidence="medium")
237+ await _writer(conn, tier=1).write(f)
238+ row = await fetch_one(conn, "select family_id, first_seen_at, identity_confidence from entities where id = :m", m=m.id)
239+ assert row["family_id"] == m.family.id and row["first_seen_at"] == datetime(2024, 3, 1, tzinfo=UTC) and row["identity_confidence"] == "medium"
240+ assert await fetch_one(conn, "select 1 from relations where subject_id = :m and predicate = 'member_of_family' and object_id = :f", m=m.id, f=m.family.id)
241+ fam = await fetch_one(conn, "select entity_type, slug from entities where id = :f", f=m.family.id)
242+ assert fam["entity_type"] == "model_family"
243+
244+
245+async def test_price_change_importance_and_run_id(conn: AsyncConnection) -> None:
246+ t = _tag()
247+ f = Facts()
248+ m = f.entity("model", f"Zeta {t}", identifiers={"hf_repo": f"zorg/zeta-{t}"})
249+ p = f.entity("provider", f"Zorg Cloud {t}")
250+ f.price(model=m, provider=p, provider_model_id="zeta", input_per_mtok=10.0, output_per_mtok=30.0)
251+ await _writer(conn, tier=1).write(f)
252+ f2 = Facts()
253+ m2 = f2.entity("model", f"Zeta {t}", identifiers={"hf_repo": f"zorg/zeta-{t}"})
254+ p2 = f2.entity("provider", f"Zorg Cloud {t}")
255+ f2.price(model=m2, provider=p2, provider_model_id="zeta", input_per_mtok=4.0, output_per_mtok=30.0)
256+ await _writer(conn, tier=1, run_id="run_2", observed_at=datetime.now(UTC) + timedelta(seconds=1)).write(f2)
257+ ev = await fetch_one(conn, "select importance, run_id from change_events where entity_id = :m and event_type = 'PRICE_CHANGED'", m=m.id)
258+ assert ev["importance"] == 3 and ev["run_id"] == "run_2" # -60 %
259+ prices = await fetch_all(conn, "select input_per_mtok, run_id, valid_to from prices where model_id = :m order by valid_from", m=m.id)
260+ assert [(r["input_per_mtok"], r["run_id"], r["valid_to"] is None) for r in prices] == [(10.0, "run_test", False), (4.0, "run_2", True)]
261+
262+
263+async def test_target_and_unused_imports_keep_dataclasses_stable() -> None:
264+ assert Target(url="https://x").doc_type == "page" and Claim(EntityRef("model", "x"), "p", 1).unit is None
265+ assert Event("RELEASE", "model", "s").importance == 2 and PriceObs(EntityRef("model", "m"), EntityRef("provider", "p")).currency == "USD"
266+ assert ResultObs(EntityRef("model", "m"), EntityRef("benchmark", "b"), 1.0).trust_level is None
267
Menu

Theme