Model Registry as Graph Resources + Rejected-Alternative Routing Provenance¶
(CONCEPT:AU-KG.ontology.model-profile-graph-resource / CONCEPT:AU-ORCH.routing.rejected-candidate-provenance / CONCEPT:AU-ORCH.adapter.openai-catalog-verification)
The
ModelRegistryalready selects a model per task/role (docs/architecture/sampling_profiles.mdcovers the sibling sampling-profile axis). This closes two gaps: (1) a model's capability/cost/observability contract lived only in code/ config, never as a graph-addressable resource with provenance; (2) the router picked a model but discarded what it rejected and why, so model choice can never become an evolution target — there was no counterfactual to learn from.
Why¶
Skills, prompts, and specs already persist as content-addressed
ArtifactVersionNode subclasses (SkillVersionNode/PromptVersionNode/SpecVersionNode) —
first-class, queryable, lifecycle-tracked graph resources. Model definitions had no
equivalent: a ModelDefinition was a plain Pydantic object loaded from a registry file,
visible only through GET /models. And every routing decision
(ModelRegistry.pick_for_task/pick_for_task_adaptive) returned exactly one
ModelDefinition — the alternatives it walked past, and why each was rejected, were
never recorded anywhere.
Model profiles as graph resources¶
ModelProfileVersionNode (agent_utilities/models/knowledge_graph.py) generalizes
ArtifactVersionNode to the model-routing vector, content-addressed on
(provider, model_id, tier, cost, context_window, max_output_tokens)
(agent_utilities/models/model_profile.py::profile_version_hash).
Honesty contract: every field is nullable/absent by default. build_model_profile()
populates a field ONLY from a real source — the ModelDefinition itself, a capability tag
that is a real positive signal (absence of a tag means unknown, never false), or an
observed_* kwarg a caller computed from real telemetry. Every field with no source is
recorded in unsourced_fields with a one-line reason (see _NO_SOURCE_REASONS) — never a
fabricated or plausible-looking default. Today that covers prompt/KV-cache behaviour,
per-domain quality, prefill/decode latency distributions, availability/error/throttle
history, privacy/residency eligibility, and local-serving hardware fields (see
reports/deferred/waves1-5-gate.md D-W15-9..10 for the follow-up aggregation work each one
needs).
flowchart LR
MD["ModelDefinition\n(model_registry.py)"] --> BUILD["build_model_profile()"]
OBS["observed_* telemetry\n(optional, not yet wired)"] -.-> BUILD
BUILD --> NODE["ModelProfileVersionNode\n(content-addressed)"]
NODE --> SYNC["sync_model_profiles(engine, registry)"]
SYNC --> KG[("Knowledge Graph\nengine.add_node")]
NODE --> OWL["inference_owl_ttl()\nkg:Model individuals"]
Surfaces¶
Both reachable per Two surfaces by default, dispatching through the same
_execute_tool core:
- MCP:
ontology_model_profile(action="list"|"sync"|"get"|"owl")(agent_utilities/mcp/tools/ontology_tools.py). - REST:
GET /ontology/model-profiles,GET /ontology/model-profiles/{model_id},POST /ontology/model-profiles/sync(agent_utilities/gateway/ontology_api.py), plus the collapsed action-routed twin atPOST /ontology/model-profiles(ACTION_TOOL_ROUTESinkg_server.py).
list never writes to the KG (a live projection from the active registry); sync upserts
one ModelProfileVersionNode per configured model (bounded — one write per configured
model, never per request); get reads the persisted node when synced, else falls back to
the live projection so the tool is useful before the first sync.
Rejected-alternative routing provenance¶
ModelRegistry.explain_pick_for_task() (agent_utilities/models/model_registry.py) wraps
the existing pick_for_task/pick_for_task_adaptive — it delegates the actual choice
to them (so the explanation can never disagree with the live picker) and also scores
every candidate in the eligible pool: tag match, tier rank under the same
_TIER_PRIORITY table the picker uses, a derived score, and — for every non-chosen
candidate — a rejection_reason (missing required tag(s), or a worse tier rank).
Ranking is done against the effective tier the picker actually selects from, not the
caller's nominal complexity: confidence-gated routing (pick_for_task_adaptive) may
shift the tier up or down, and both sides now derive it from the one shared
_effective_tier helper so they cannot drift. Scoring in the unshifted frame made the
chosen model come out with the LOWEST score in its own record and left the real reason
(the confidence gate) unstated — a persisted RoutingDecisionNode an auditor will read
exactly backwards. When a shift happened the reason says so explicitly
(... for complexity 'light' (confidence-shifted from 'medium')). Surfacing the shift as
a first-class field on RoutingDecision rather than inside the reason string is
D-W15-9. The result is bounded to MAX_ROUTING_CANDIDATES (8) in either case of registry
size, so persisting one decision per routing call never writes an unbounded dump.
sequenceDiagram
participant CM as create_model(role=...)
participant RM as _resolve_role_model
participant AR as model_router.pick_adaptive_with_decision
participant Reg as ModelRegistry
participant Tr as harness.tracing (current trace)
participant Sink as KGTraceBackend
CM->>RM: role
RM->>AR: registry, role
AR->>Reg: pick_for_task_adaptive(...)
AR->>Reg: explain_pick_for_task(...) %% same confidence signal
Reg-->>AR: (chosen model, RoutingDecision)
AR-->>RM: (model, decision)
RM->>Tr: get_trace_id() / get_kg_trace_sink()
RM->>Sink: record_routing_decision(trace_id, decision)
Sink->>Sink: RoutingDecisionNode -> engine.add_node + HAS_ROUTING_DECISION edge
RM-->>CM: model
model_factory._resolve_role_model (the same function every create_model(role=...) call
already used for role-based routing) now also calls explain_pick_for_task and, when a KG
trace sink is installed AND a trace is active, persists a RoutingDecisionNode attached to
the current trace via RegistryEdgeType.HAS_ROUTING_DECISION
(KGTraceBackend.record_routing_decision, mirroring record_event's existing
Trace→Span/Generation attachment pattern). Recording is best-effort and a complete no-op
when no sink is wired (e.g. unit tests, zero overhead) — the SAME contract
wrap_model_for_tracing already uses.
OpenAI catalogue verification + secret reference¶
The openai provider path in create_model (_provider == "openai") previously trusted
config.openai_api_key (a plain literal) with no existence check on the configured
model_id. Two additive fixes, reusing existing conventions rather than new plumbing:
core/credentials.pygains a fourth, highest-precedence tier:secret_ref > env > file > none. A provider whoseAgentConfigcarries a<provider>_api_key_reffield (today:openai_api_key_ref/OPENAI_API_KEY_REF) resolves through the SAMEenv:///vault:///secret://convention already used elsewhere in this repo (e.g.ONTOLOGY_RELEASE_SIGNING_PRIVATE_KEY_REF), viasecurity.cli_secrets.resolve_runtime_secret_reference— so an OpenBao-backed key never has to be a literal in the process environment.create_model'sopenaibranch falls back toCredentialResolver().resolve("openai")only when neither an explicitapi_keynor the literalOPENAI_API_KEYis configured — the SAME resolver thecustom/proxyprovider path already called, so there is one canonical OpenAI credential source.core/openai_catalog.py(verify_openai_model) calls the liveGET /v1/models/{id}catalogue viaopenai.AsyncOpenAI(already a core dependency throughpydantic-ai-slim[openai]— no new dependency) to confirm a configured model id actually exists, rather than assuming it. Wired into a newagent-utilities-doctorcheck,openai_catalog(static by default: credential-tier + configured-model-count only;live=Truealso probes the catalogue) — reachable via both the doctor CLI andgraph_configure(action="preflight").
Credential-leak boundary: verify_openai_model returns only a class-name-only error
(never str(exc), since the openai SDK's exception messages can embed the request
URL/headers and therefore the credential); the doctor check reports only booleans/counts/
model ids, never the resolved key. tests/unit/core/test_openai_catalog.py proves this with
a fake exception whose message embeds a fake key and asserts the key never survives into the
result, independently re-checked against PersistencePrivacyGuard (the repo's sk-...
api-token pattern).
Deferred¶
Full list in reports/deferred/waves1-5-gate.md (D-W15-9..10): the telemetry-aggregation
pipeline behind observed_*, local-serving hardware fields on ModelDefinition, privacy/
residency declaration, and a pre-existing KGTraceBackend/IntelligenceGraphEngine.add_node
duck-type mismatch discovered (not introduced) while wiring record_routing_decision.