Files
6krrt/schema.sql
adlee-was-taken bfb0ff7c00 feat(metrics): persist routing decisions and expose GET /metrics
The router previously recorded only completions (energy_observations),
not the routing decisions behind them, so 'how routing is performing' was
not answerable from data. This adds:

- route_decisions table + idempotent ensure_route_decisions (guarded
  CREATE TABLE IF NOT EXISTS, never regenerates a live DB) gated by
  logging.log_route_decisions; every decision kind (route/dispatch/chat/
  passthrough/local-vision) is persisted best-effort via
  persist_route_decision (never fails a request; only session_key, never
  session_dir). The table is ensured on the write path (mirroring
  proficiency_store._write -> ensure_columns) so a live DB that predates
  the feature migrates safely.
- metrics.py aggregator moved quota_burn/scoring_coverage in from the
  dispatcher (breaking a would-be circular import) and adds
  recent_decisions/per_model/verdict_mix/top_proficiency; /health now
  imports them and GET /metrics exposes the 7-key JSON (window-bounded,
  loopback-only, no auth).
- observed_at indexes on energy_observations/verifications.
2026-08-23 20:20:09 -04:00

250 lines
15 KiB
SQL

-- Model router decision table schema
-- SQLite. Run with: sqlite3 router.db < schema.sql
PRAGMA foreign_keys = ON;
-- One row per (model_id, provider). Refreshed by the pricing poller.
CREATE TABLE IF NOT EXISTS models (
model_id TEXT NOT NULL,
provider TEXT NOT NULL, -- 'neuralwatt' (only provider today)
-- The model family under the serving suffixes: glm-5.2-short-fast-flex
-- and glm-5.2 share one. Proficiency and leaderboard priors are properties
-- of the weights, not the queue, so both key on this and every variant
-- inherits from its family.
base_model_id TEXT,
display_name TEXT,
cost_per_1m_prompt REAL, -- USD, null if pricing_tbd
cost_per_1m_completion REAL,
cost_per_1m_prompt_cached REAL, -- null if provider has no cache discount
context_window INTEGER, -- advertised max tokens
effective_context_window INTEGER, -- derived, see §3.2 of design doc
max_output_tokens INTEGER,
tier INTEGER, -- 1-3, set manually / by a tiering pass
supports_tools INTEGER DEFAULT 0, -- boolean 0/1
supports_json_mode INTEGER DEFAULT 0,
supports_vision INTEGER DEFAULT 0,
supports_reasoning INTEGER DEFAULT 0, -- capabilities.reasoning: "API accepts a
-- reasoning param", NOT a quality signal.
-- True for ~90% of the catalog; do not tier on it.
-- Whether reasoning is ON by default (metadata.reasoning.default_enabled), falling back to
-- supports_reasoning when the model exposes no reasoning block. This is the tier-bearing signal.
reasoning_default_enabled INTEGER DEFAULT 0,
-- Serving class. NeuralWatt ships one base model as several rows that differ only by suffix;
-- these three dimensions are orthogonal, hence ids like 'glm-5.2-short-fast-flex'. They carry
-- no price difference in the catalog, so without these columns such rows tie exactly and the
-- router picks between them arbitrarily.
latency_class TEXT DEFAULT 'standard', -- 'standard' | 'flex' (-flex: discounted
-- async, held server-side during peak)
reasoning_mode TEXT DEFAULT 'default', -- 'default' | 'reduced' (-fast: thinking
-- disabled or capped to a short budget)
context_variant TEXT DEFAULT 'full', -- 'full' | 'short' (-short: 200K pool with
-- a bounded reasoning budget)
-- Access gating is prose-only in the catalog ("Private preview (grant-gated)", "canary"), so
-- it is parsed from the description. Non-public rows are excluded from routing by default,
-- otherwise the dispatcher selects them and takes a 403.
access_level TEXT DEFAULT 'public', -- 'public' | 'preview' | 'canary'
pricing_tbd INTEGER DEFAULT 0,
deprecated INTEGER DEFAULT 0,
availability TEXT DEFAULT 'active', -- 'active' | 'deprecated' | 'stale'
last_updated TEXT NOT NULL, -- ISO8601
PRIMARY KEY (model_id, provider)
);
-- One row per (model_id, provider, category). Refreshed by the benchmark poller
-- and/or written to directly by your self-eval harness.
CREATE TABLE IF NOT EXISTS proficiency (
model_id TEXT NOT NULL,
provider TEXT NOT NULL,
category TEXT NOT NULL, -- coding_general, coding_refactor, debugging,
-- docs_writing, summarization, translation,
-- reasoning_math, tool_use_agentic, general_chat
leaderboard_score REAL, -- 0-1, from external benchmark sources
self_eval_score REAL, -- 0-1, from your own eval harness
self_eval_samples INTEGER DEFAULT 0,
blended_score REAL, -- computed: see blending rule in design doc
source TEXT, -- 'leaderboard' | 'self_eval' | 'blended'
-- Which model this row's scores were COPIED from, or NULL if they were
-- measured on this row directly. Provenance, not decoration: without it
-- an inherited row is indistinguishable from a measured one (both carry
-- self_eval_samples > 0), so propagate_to_variants could not tell which
-- rows it was allowed to refresh and froze every variant permanently at
-- its first inheritance.
inherited_from TEXT,
last_updated TEXT NOT NULL,
PRIMARY KEY (model_id, provider, category),
FOREIGN KEY (model_id, provider) REFERENCES models (model_id, provider)
);
-- Per-request energy/cost observations, logged by the dispatcher as real calls
-- happen (energy is only available on actual completions, not the models list).
-- This is what you aggregate into a per-model energy average over time.
CREATE TABLE IF NOT EXISTS energy_observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
model_id TEXT NOT NULL,
provider TEXT NOT NULL,
-- The provider's completion id (chatcmpl-...). The client receives this in
-- the response body and in every stream chunk, so it is the join key that
-- lets a client report back later whether the answer actually worked.
request_id TEXT,
-- Fingerprint of the conversation this completion belongs to, derived from
-- its opening message. Stable across a session's turns and distinct
-- between sessions, so the router can tell whether two clients are active
-- WITHOUT the clients cooperating. Used to refuse ambiguous outcome
-- attribution rather than guess.
session_key TEXT,
-- Working directory, when the conversation reveals one. Agent clients
-- usually put the cwd in their system prompt, which makes an outcome
-- report from that directory attributable even under concurrency.
session_dir TEXT,
task_category TEXT,
prompt_tokens INTEGER,
completion_tokens INTEGER,
-- energy_kwh is what NeuralWatt BILLS. It equals avg_power_watts *
-- duration_seconds * attribution_ratio, where attribution_ratio is this
-- request's share of a shared multi-tenant GPU pool. Up close that term
-- looks like noise: eight identical calls to one model inside one minute
-- varied 20x, correlating +0.997 with the ratio while power and duration
-- held steady.
--
-- It is not noise, and this is the ATTRIBUTED figure scoring reads. Across
-- the reference sweep the median ratio spans 750x BETWEEN models while the
-- typical spread WITHIN one is 1.8x, and the values are quantized (0.001,
-- 0.25, 0.5, 0.75) -- that is serving concurrency, a stable per-model
-- property. A model whose GPUs carry more concurrent requests genuinely
-- costs less per request. The median over repeated sweeps absorbs what
-- noise remains.
energy_kwh REAL, -- response.energy.energy_kwh (attributed)
energy_btu REAL, -- energy_kwh * 3412.14, purely for comedic dashboard value
-- The pre-attribution terms. avg_power_watts * duration_seconds is the
-- pool's energy over the request, independent of how many other tenants
-- shared it. Scoring on that product was TRIED and is wrong: it discards
-- the 750x between-model signal above to suppress a 1.8x within-model one.
-- Kept as a diagnostic (dispatcher.gross_energy_kwh) so the decomposition
-- stays inspectable, not as a scoring input.
avg_power_watts REAL, -- response.energy.avg_power_watts
duration_seconds REAL, -- response.energy.duration_seconds
attribution_ratio REAL, -- the share term, kept so the identity checks out
-- Carbon is what design doc §4 actually scores eco on, and NeuralWatt
-- reports it per-request rather than making us derive it. Grid intensity
-- and region are stored alongside because the same energy in a different
-- region is a different carbon figure -- keeping them makes the open
-- question (real-time intensity vs per-model average) answerable later
-- from logged data instead of a re-run.
carbon_g_co2eq REAL, -- response.energy.carbon_g_co2eq
grid_carbon_intensity REAL, -- gCO2/kWh at call time
grid_id TEXT, -- e.g. 'FI'
-- How the carbon figure was obtained. 'static_fallback' means NeuralWatt
-- could not resolve live grid data and substituted a constant (475.0,
-- about a global average) while still reporting the original grid_id --
-- so the number is a placeholder, not a measurement. Routing on it would
-- penalize a model against a made-up figure, so eco scoring excludes it.
carbon_source TEXT, -- 'agent_cache' | 'static_fallback' | ...
-- The provider's own billed figure (response.cost.request_cost_usd), NOT
-- a tokens x list-price estimate. These disagree: flex rows bill roughly
-- 40% under their standard sibling while the catalog advertises both at
-- the same price, so the estimate would be wrong for every flex call.
cost_usd REAL,
allowance_remaining_usd REAL, -- response.cost.allowance_remaining_usd
service_tier TEXT, -- response.service_tier, as billed
observed_at TEXT NOT NULL
);
-- One row per verified completion. Structural checks are free, so every
-- response gets one; the point is to learn which models fail on REAL work
-- rather than only on the fixed 23-task benchmark in evals/tasks.yaml.
--
-- 'unverifiable' is recorded and is NOT a failure. Most prose lands there,
-- and counting "we could not check this" as "this was wrong" would penalize
-- models for the checker's limits.
CREATE TABLE IF NOT EXISTS verifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
model_id TEXT NOT NULL,
provider TEXT NOT NULL,
request_id TEXT, -- provider completion id, for client reports
task_category TEXT,
kind TEXT NOT NULL, -- 'structural' | 'local_llm' | 'client_outcome'
-- 'succeeded'/'failed' come only from client_outcome and are ground truth:
-- the client ran the code, or used the answer, and knows. Every other
-- verdict is a proxy for that.
verdict TEXT NOT NULL, -- ok | truncated | malformed | unverifiable
-- | succeeded | failed
detail TEXT,
completion_tokens INTEGER, -- what a wasted answer cost, for the payoff sum
observed_at TEXT NOT NULL,
-- Set once feedback.py has folded this row into proficiency, so re-running
-- cannot penalize a model repeatedly for the same bad response.
applied_at TEXT,
-- Whether this failure is the MODEL's fault. A client that sets a tight
-- max_tokens and gets a truncated answer caused that itself; counting it
-- against the model would let any agent with a small cap systematically
-- drag down whatever it routed to. Still recorded -- the response really
-- was unusable -- but excluded from proficiency feedback.
model_attributable INTEGER DEFAULT 1
);
-- One row per routing decision, logged by the dispatcher on every route that
-- is made (see todo #2 of router-monitoring-tui.md for the writes; this table
-- and its inline-create helper are todo #1). It makes "how is routing
-- performing" answerable: which model was picked, for what category/tier, how
-- long classification took, and — when nothing was selected — which hard
-- filter shut it out.
--
-- This is an OBSERVABILITY table, not a scoring input: nothing in routing.py
-- reads it. It stores only the hashed session fingerprint in `session_key`;
-- never `session_dir` and never any prompt or answer text. A test enforces
-- that the write path stores only the hash.
--
-- Like `energy_observations`, `observed_at` uses
-- datetime.now(timezone.utc).isoformat().
CREATE TABLE IF NOT EXISTS route_decisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
observed_at TEXT NOT NULL, -- ISO8601, UTC
kind TEXT NOT NULL, -- 'route' | 'dispatch' | 'chat'
-- | 'passthrough' | 'local_vision'
task_category TEXT,
task_tier INTEGER, -- 1-3
required_context_tokens INTEGER,
confidence REAL,
classifier_ms INTEGER,
classification_source TEXT, -- 'classifier' | 'override' | 'fallback'
latency_tolerance TEXT, -- 'interactive' | 'batch'
candidates_considered INTEGER,
selected_model TEXT, -- null when nothing was selected
-- 'neuralwatt' for routed/dispatch/passthrough, 'local' for the local-vision
-- fallback. Kept so a decision row can join to energy_observations on the
-- (model_id, provider) key that table uses.
selected_provider TEXT,
runner_up_models TEXT, -- JSON array of {"model_id":..,
-- "provider":..}, <=3; nullable
est_cost_usd REAL,
est_proficiency REAL,
rejected_reason TEXT, -- the 422 limits when no selection
session_key TEXT, -- hashed session fingerprint ONLY,
-- never session_dir or prompt text
tools INTEGER, -- 0/1
images INTEGER, -- 0/1
json_mode INTEGER, -- 0/1
streamed INTEGER -- 0/1
);
CREATE INDEX IF NOT EXISTS idx_verifications_model ON verifications (model_id, provider);
CREATE INDEX IF NOT EXISTS idx_verifications_verdict ON verifications (verdict);
CREATE INDEX IF NOT EXISTS idx_observations_request ON energy_observations (request_id);
CREATE INDEX IF NOT EXISTS idx_models_provider ON models (provider);
CREATE INDEX IF NOT EXISTS idx_models_availability ON models (availability);
CREATE INDEX IF NOT EXISTS idx_models_routing ON models (access_level, latency_class, tier);
CREATE INDEX IF NOT EXISTS idx_proficiency_category ON proficiency (category);
CREATE INDEX IF NOT EXISTS idx_energy_model ON energy_observations (model_id, provider);
-- recent_decisions orders by id, but a time-window query benefits from this.
CREATE INDEX IF NOT EXISTS idx_route_decisions_observed ON route_decisions (observed_at);
-- Guarded — re-applying a fresh schema is a no-op.
CREATE INDEX IF NOT EXISTS idx_energy_observed ON energy_observations (observed_at);
CREATE INDEX IF NOT EXISTS idx_verifications_observed ON verifications (observed_at);