Canonicalization — aia canonicalize
The canonical upgrade (2026-09) turns the flat "everything is a model" corpus into one hierarchy and one vocabulary:
model_family Llama 4 · Qwen3.6 · Claude · GPT 5.4
└ model Llama 4 Maverick · Qwen3.6-35B-A3B · Claude Fable 5.1 ← the unit counted as "a model"
└ artifact unsloth/Qwen3.6-35B-A3B-GGUF (quantization) · zai-org/GLM-5-FP8 (conversion) · mlx-community/… (packaging)
└ deployment OpenRouter qwen/qwen3.6-35b-a3b · Together … ← `prices` rows, never entitiesEvaluation-effort variants (gpt-5-4-mini-medium, claude-opus-5-xhigh, deepseek-v4-pro-non-reasoning) are result configurations
(reasoning_effort, reasoning, thinking_budget) of the canonical model, never entities of their own.
Two layers implement it:
- at write time —
FactWriter/Resolver(src/aiatlas/sdk) apply the ontology to every incoming fact, so new data is born canonical; - on the existing corpus —
aia canonicalize(src/aiatlas/services/canonical.py) replays the same rules over what is already stored.
Running it
.venv/bin/aia canonicalize # dry-run: plan + counts + examples, writes nothing
.venv/bin/aia canonicalize --apply # execute the plan (one transaction per step)
.venv/bin/aia canonicalize --step results --step events --apply
.venv/bin/aia canonicalize --json # machine-readable report
.venv/bin/aia anomalies [--severity critical] [--refresh]
.venv/bin/aia quarantine list | release <id> | discard <id>Steps always run in this order (later steps depend on earlier ones):
duplicates → variants → artifacts → families → licenses → taxonomy → results → events → anomalies.
Idempotence is a hard requirement: a second --apply immediately after the first must report 0 change(s). Every step computes its plan
from reads, and apply executes exactly that plan; nothing depends on "what changed last time". Re-run it after every connector wave.
What is never done
- No row is ever deleted. Merged duplicates and folded variants stay as rows with
status='merged',merged_into=<survivor>; their slugs and ids keep resolving (API followsmerged_into, web 301s). Artifacts keep their own row, slug, aliases, identifiers, claims, results, prices and events. - Raw snapshots and the archive are never touched. Canonical values live in
claims.value/entities.attributes; the source label is kept next to them (claims.value_raw,attributes.<prop>_raw) whenever it differed. - No value is guessed. Unknown licence / modality / status strings are kept as-is and recorded in
taxonomy_mappingswithcanonical = NULL(the backlog is visible; nothing is dropped). Ambiguous identities go toreview_queue, never to a merge. - Derived facts never outrank sourced facts. Everything the engine infers is written by a tier-2
derivedwriter (sourceai-atlas.registry): the ordinary writer rules apply, so a tier-1 explicit statement (opennessfrom a lab page) is kept and the derived disagreement is stored once asconflicting(without a review item — derived disagreements are expected and inspectable). - Observation truth is preserved.
first_seen_atis when we first saw an entity; a release date earlier than that is a fact about the model (attributes.release_date), not about our observation./diff,/changesand the counters stay observation-based; timelines useoccurred_at = coalesce(effective_at, observed_at).
Steps
duplicates
Exact normalised-name duplicates (ids.normalize_alias) within a type — models, providers, benchmarks, datasets, frameworks, libraries,
hardware — and across the organisation group (company | organization | lab | university, also joined by a shared hf_org / github_org).
- identifiers do not conflict (no scheme with two different values) → merge the weaker row into the survivor. Survivor = curated
org type (
company/lab/universitybeat the hub's genericorganization), then most current claims, then oldestfirst_seen_at. Recorded as amergedecision inresolution_decisionsand inadmin_audit_log. - identifiers conflict (
gemini-2-5-flash-liteAA vsgemini-2.5-flash-liteGoogle id + a different AA slug) →review_queuemerge_candidateonly. - same name but two organisations (non-org types) → review only. Pairs with a
keep_separatedecision are skipped. - single-letter organisations (
w) →junk_entityreview item;org_kinddefaults from the entity type (ORG_TYPE_DEFAULT_KIND).
variants
Models whose name carries an evaluator effort suffix (ontology.models.analyze_model_name().is_effort_variant: -xhigh/-high/-medium/-low/ -minimal, -thinking/-reasoning, -non-reasoning/-no-think, -32k-thinking, -high-effort, up to three chained) and that are known
only through evaluator identifiers (artificial_analysis, livebench_model_id…), have no hf_repo and no tier-1 claim. Anything with an
official/hub/provider identifier is a real model that happens to end in "Thinking" (Qwen3-VL-8B-Thinking has its own weights) and is left alone.
-max, -fast, -instant are model tiers (Qwen3-Max, GPT-5.1-Codex-Max, Grok 4.1 Fast), never effort suffixes.
Base resolution (Resolver.resolve_variant): same artificial_analysis identifier / alias / slug as the base name, or the same
variant_key through the precomputed index; never onto another effort variant; several bases → prefer the variant's organisation, else review.
- resolved → every
benchmark_results.configof the variant gains the effort dict +aa_variant_slug, thenmerge_entities(variant → canonical)moves results (dedupe keys recomputed, collisions close the older row), prices, relations, events, aliases and identifiers. The variant's AA identifier now points at the canonical model, so the next AA run resolves it directly; the writer adds the effort dict to the config of any result whose model ref is an effort variant (effort_config), so folded results stay distinguishable and comparable (reasoning_effortis a condition key: sameconfig_key, partially comparable). - unresolved →
identity_confidence='medium',attributes.evaluation_variant_of_hint,variant_candidatereview item.
artifacts
A model row is an artifact when its name / hf_repo carries a quantisation or precision token (GGUF, FP8, MXFP4, AWQ, BF16,
MLX, ONNX…), when its repository lives under a converter organisation (ontology.models.CONVERTER_ORGS: unsloth, bartowski,
mlx-community…), or when its hub repo attributes say is_quantized / quant_format (repo attributes only count with an hf_repo: a
provider's serving precision — OpenRouter fp8 — says nothing about identity).
- Official checkpoints stay models. A repo under the developer's own organisation (
FAMILY_ORGSpublisher or the entity's own org, never a converter org) is the model itself even in native FP8 (deepseek-ai/DeepSeek-R1,nvidia/…-Nemotron-3-Ultra-…-BF16). It only becomes an artifact when the same organisation also has the plain model entity and nobody else knows the tagged repo under its own identity; when evaluators/providers do, the pair is amerge_candidatefor review. No checkpoint entities are created. artifact_kind:quantization(bit-width / GGUF / AWQ…),conversion(BF16/FP8 repack, MLX, ONNX), elsepackaging.canonical_id: thequantized_fromobject (neverderived_from/fine_tuned_from— a fine-tune is a new model), else the unique model with the samevariant_key(same organisation preferred, then the family's official publisher); none → artifact withcanonical_id = NULL,identity_confidence='low',unresolved_artifactreview item.- Writes:
entity_type='artifact',artifact_kind,canonical_id, relationartifact_of, avariant_ofdecision. Slug, aliases, identifiers, claims, results, prices, events untouched.modelandartifactare lookup-compatible types in the resolver, so the same hub repo keeps resolving to the same row.
families
For every model with a family hint: family = versioned label when a version follows the family word (Llama 3.1, Qwen3.6, Gemini 2.5,
Claude 4, GPT 5.4 — hyphenated slug versions gpt-5-4 count; 38B/235B are sizes, not versions), else the base family (Claude,
Kimi). One level only; the family entity carries attributes.family_root (Llama) for grouping.
Slug = slugify(label); on collision with any other entity (the model gpt-5.5 itself) the organisation slug is prefixed, then family-.
Organisation = the family's official publisher when known, else the model's. Writes: entities.family_id, relation member_of_family,
attributes.family only when the model had none (a sourced family claim is never overwritten).
licenses
normalize_license(attributes.license) → canonical key (SPDX id when one exists: Apache-2.0, MIT, CC-BY-NC-4.0; AI Atlas key
otherwise: Llama-3.1-Community, Gemma-Terms). Creates one license entity per observed key (slug = key lower-cased, attributes =
LicenseInfo.as_dict(): commercial use, redistribution, derivatives, hosting restrictions, acceptable-use policy, OSI approval…), relation
uses_license, attributes.license_key. Unclassified strings stay in license (mapping recorded with canonical = NULL).
Openness 2.0 as derived claims (tier 2, extractor='derived'): weights_available (true with an hf_repo / model card / weights URL
or an open-ish openness label; false when proprietary; unknown → nothing written), commercial_use_allowed, redistribution_allowed,
derivatives_allowed from the licence, and the category openness = derive_openness(dims) (open-source | open-weights | restricted-weights | proprietary). HF "gated" is a distribution mechanism, not a licence property: a gated Apache-2.0 repo is open-weights. A tier-1
openness statement that disagrees is kept current; the derived value is stored once as conflicting.
taxonomy
Current claims (and claim-less attributes) for license, openness, modalities, modalities_input, modalities_output, status, hardware
and framework kind, org_kind are re-encoded in place: same claim row, same source, same tier — only the encoding changes
(apache-2.0 → Apache-2.0, restricted → restricted-weights, ["Text","pdf"] → ["document","text"], computer → system). The
source label goes to claims.value_raw / attributes.<prop>_raw. This is not a new assertion, so no change event is emitted. Every
raw → canonical pair (and every unknown raw value) lands in taxonomy_mappings.
The writer applies the same normalisation to incoming facts; when a source re-states the same licence in another spelling the current claim is
re-encoded and confirmed — never a LICENSE_CHANGED event.
results
Back-fills config_key (comparability hash of the task keys + metric — variant, board, harness, evaluator, shots, scaffold, system…),
trust_level (ontology.benchmarks.trust_level(source_key, config): official-benchmark / independent-evaluator / official-model-card /
community / peer-reviewed / unverified), variant, run_group (release, index version, dataset revision, run date) and extractor.
Then one current row per (model, benchmark, metric, config_key): rows from an older run group than the latest observed one get
is_current = false, valid_to = <newer observation>, so leaderboards show the latest LiveBench release / aider run / AA index version
instead of piling up every run. Run keys are not part of config_key (two releases of the same task are partially comparable); condition keys
(reasoning_effort, temperature, judge…) are not either, so folded effort variants share a key and are compared as conditions.
Scores outside the metric bounds are stored with confidence='low' and flagged (score_above_max / score_below_min).
events
Three clocks: occurred_at = coalesce(effective_at, observed_at) (when it happened), observed_at (when a connector saw it), recorded_at
(when the row was written). is_backfill = true when the event was observed before the connector's second successful run (the initial
corpus is history, not news), when effective_at is more than 3 days before observed_at, or for NEW_* events whose entity's
release_date / published_at predates observation by more than 3 days. group_key = release:<entity>:<yyyy-mm> for RELEASE,
ANNOUNCEMENT, NEW_MODEL, VERSION_RELEASED so one release seen in several documents groups. Live counters ("+N · 24 h", /changes,
pulse) count non-backfill events only; timelines use everything. The step prints live-event counts for the last 24 h before/after.
Importance is deterministic (services.events.importance_for): frontier or open-weight releases 3, price moves ≥ 50 % 3 / ≥ 20 % 2 / else 1,
context ≥ 5× 3, deprecations 3, leader changes 2, metadata corrections 0, artifact events 0, family events 1; tier > 2 sources lose one point.
anomalies
Runs ontology.anomalies over live models/artifacts, hardware, live prices and current results; upserts anomalies rows by dedupe_key
(reopened when a check fires again; operator states ignored / fixed are kept), and auto-resolves open flags whose check no longer fires.
aia anomalies lists them. Flags never change the data.
Write-time counterparts (SDK)
FactWriter(run_id=…): every claim, relation, price, result and event carries the connector run id (batch inspection/rollback).- Same-source loophole closed: a claim supersedes only when
tier <= current.tieror same source and same extractor — an LLM claim never overwrites a deterministic one from the same URL (it is stored asconflicting, once). Resolver:keep_separatedecisions block alias resolution/merges; aliases whose normalisation collapses digit separators (Qwen3-8B≡Qwen 38B→qwen38b) additionally require the samevariant_key;first_seen_hintback-datesfirst_seen_at(min(now, hint)); hierarchy hints (EntityRef.family/.canonical/.artifact_kind/.identity_confidence) are materialised.- Quarantine: tier ≥ 2 connectors (hubs, leaderboards, registries) hold their facts until the run ends and compare the run with the
connector's rolling baseline (median of the last 5 full-extraction runs): new entities > max(50, 1.3× baseline), price rows > 3×, or — on a
full re-extraction — entity references < 0.8× or results < 0.5× → the whole run goes to
quarantined_runs(status='pending', serialised Facts),connector_runs.status='quarantined', aquarantinereview item; nothing is written.aia quarantine release <id>writes the facts exactly as the connector would have;discarddrops them (snapshots stay archived). A connector's first run is never quarantined — it sets the baseline. - SSRF guard (
sdk/fetch.py): every URL and every redirect hop (max 5) is resolved and refused when it targets a non-http(s) scheme,localhost/.local/.internal, RFC1918, loopback, link-local (cloud metadata), CGNAT, IPv6 loopback/link-local/ULA or unspecified addresses.
Counting rules (for the API / UI)
- Models =
entity_type = 'model' and merged_into is null(artifacts and folded variants excluded;include=artifactsrestores the old universe). Artifacts =entity_type = 'artifact'. Families =entity_type = 'model_family'. - Current benchmark results =
valid_to is null and is_current. Live events =is_backfill = false.