Adds router_wall_seconds and router_ttft_seconds to energy_observations: - router_wall_seconds: time.monotonic() from just before the accepted candidate's POST/connection-open to the complete response body (buffered) or the last byte forwarded (streaming). Re-marked per candidate so failover time is excluded — a dead model's 30s stall is not charged to the healthy one that replaced it. - router_ttft_seconds: streaming-only. First delta carrying non-empty content or a tool_calls fragment, excluding the role-only opening delta. NULL on buffered rows (not applicable) and on streams that produced no output token (a broken upstream — not the same as a literal 0). Separate from duration_seconds (provider's reported serving time) on purpose: OpenRouter reports duration_seconds on 0 of 3,850 rows, while the router can always measure its own clock. The two quantities are stored independently and never written into each other. log_observation() defaults both new params to None so seed_energy.py and eval_proficiency.py pass unchanged — a reprise of 6e729ad's bug where new keyword-only arguments killed the seed timer. Also: - docs/data-model.md: document the new columns, span definitions, and the distinction from duration_seconds - config/schema.sql: full CREATE TABLE declaration - plans/token-waste-waves.md: status update for Wave 1 - plans/ten-thousand-foot-review.md: companion diagnosis
325 lines
19 KiB
Markdown
325 lines
19 KiB
Markdown
> SQLite schema reference. Back to [README](../README.md).
|
||
|
||
## Decision Table Schema (SQLite)
|
||
|
||
Three data tables plus one observability table, `PRAGMA foreign_keys = ON`:
|
||
|
||
### `models` — one row per served model variant
|
||
|
||
| Column | Type | Notes |
|
||
|---|---|---|
|
||
| `model_id` | TEXT | Full catalog id (e.g. `glm-5.2-short-fast-flex` or `qwen2.5-coder-router:14b`) |
|
||
| `provider` | TEXT | `neuralwatt` \| `ollama-local` |
|
||
| `base_model_id` | TEXT | Model family (e.g. `glm-5.2`). Proficiency/leaderboard keys here. |
|
||
| `eligible_categories` | TEXT | Comma-joined category allowlist; `NULL` = unrestricted (cloud rows) |
|
||
| `display_name` | TEXT | Human-readable name |
|
||
| `cost_per_1m_prompt` | REAL | Listed USD per 1M input tokens |
|
||
| `cost_per_1m_completion` | REAL | Listed USD per 1M output tokens |
|
||
| `cost_per_1m_prompt_cached` | REAL | Cached prefix price (null if no cache discount) |
|
||
| `context_window` | INTEGER | Advertised max tokens |
|
||
| `effective_context_window` | INTEGER | `advertised × safety_factor − reserve` |
|
||
| `max_output_tokens` | INTEGER | |
|
||
| `tier` | INTEGER | 1–3, set by `tier.py` pass |
|
||
| `supports_tools` | INTEGER | Boolean 0/1 |
|
||
| `supports_json_mode` | INTEGER | |
|
||
| `supports_vision` | INTEGER | |
|
||
| `supports_reasoning` | INTEGER | "API accepts reasoning param" — NOT a quality signal |
|
||
| `reasoning_default_enabled` | INTEGER | The actual tier-bearing signal |
|
||
| `latency_class` | TEXT | `standard` \| `flex` (`-flex`: discounted async, held during peak) |
|
||
| `reasoning_mode` | TEXT | `default` \| `reduced` (`-fast`: reasoning capped) |
|
||
| `context_variant` | TEXT | `full` \| `short` (`-short`: 200K pool bounded budget) |
|
||
| `access_level` | TEXT | `public` \| `preview` \| `canary` |
|
||
| `pricing_tbd` | INTEGER | |
|
||
| `deprecated` | INTEGER | |
|
||
| `availability` | TEXT | `active` \| `deprecated` \| `stale` |
|
||
| `last_updated` | TEXT | ISO8601 |
|
||
|
||
**Serving class:** Neuralwatt ships ~6 base models as 19 catalog rows. The id
|
||
suffixes are three **orthogonal** dimensions (`glm-5.2-short-fast-flex`), parsed
|
||
by `poller.parse_serving_class` into columns. Rows carry identical catalog
|
||
pricing, so routing would pick between them arbitrarily without these — the
|
||
`latency_tolerance` hard filter resolves it.
|
||
|
||
**Local rows:** `provider='ollama-local'` rows come from `config.yaml`'s
|
||
`local_dispatch_models:` section and are refreshed by `poller.upsert_local_dispatch_models`
|
||
each poll. They start with the three cost columns NULL; once
|
||
`seed_local_dispatch_energy.py` has run, those columns hold measured
|
||
tariff-priced rates and re-polls never overwrite them.
|
||
|
||
**Access gating:** 6 of 19 rows are prose-gated
|
||
("Private preview (grant-gated)", "(Canary)"). `poller.parse_access_level`
|
||
parses them into `access_level` and `routing.allowed_access_levels` (default
|
||
`[public]`) excludes them, so dispatch won't earn a 403.
|
||
|
||
### `proficiency` — one row per (model, provider, category)
|
||
|
||
| Column | Type | Notes |
|
||
|---|---|---|
|
||
| `model_id` | TEXT | |
|
||
| `provider` | TEXT | |
|
||
| `category` | TEXT | See category list below |
|
||
| `leaderboard_score` | REAL | 0–1, from external benchmarks |
|
||
| `self_eval_score` | REAL | 0–1, from self-eval harness |
|
||
| `outcome_score` | REAL | Accumulated client-reported success rate on real traffic (0–1) |
|
||
| `outcome_samples` | INTEGER | Number of client-reported `succeeded`/`failed` verifications folded in |
|
||
| `self_eval_samples` | INTEGER | Evidence count for benchmark blending threshold |
|
||
| `blended_score` | REAL | Expected pass rate on real traffic after empirical-Bayes shrinkage |
|
||
| `source` | TEXT | `outcome_blended` \| `outcome_prior` \| `blended` \| `self_eval` \| `self_eval_thin` \| `leaderboard` |
|
||
| `inherited_from` | TEXT | Model this row was copied from, NULL if measured directly |
|
||
| `last_updated` | TEXT | ISO8601 |
|
||
|
||
**Category set** (9 categories, defined in `config/config.yaml`):
|
||
|
||
| Category | Example | Scoring type |
|
||
|---|---|---|
|
||
| `coding_general` | Merge intervals, parse semver, word wrap | Code (execution) |
|
||
| `coding_refactor` | Remove repetition, refactor dispatch chain | Code (execution) |
|
||
| `debugging` | Fix closure leak, fix binary search, fix regex | Code (execution) |
|
||
| `reasoning_math` | Percent trap, rate trap, counting | Exact match |
|
||
| `tool_use_agentic` | Right tool / right args / no tool when empty | Structural |
|
||
| `docs_writing` | Docstring quality, must-mention gotchas | Judge |
|
||
| `summarization` | Root-cause isolation, buried-lede identification | Judge |
|
||
| `translation` | Technical register, hedging/informal tone | Judge |
|
||
| `general_chat` | Simple explanations, measured pushback | Judge |
|
||
|
||
**Scoring kinds** (4 types, objective wherever the category admits it):
|
||
- **`code`** — runs model-generated Python in a subprocess, scores fraction of checks passing
|
||
- **`exact`** — normalizes & compares a single answer
|
||
- **`tool`** — structural: which tool was called, which args matched
|
||
- **`judge`** — a strong model scores against a rubric (prose categories only)
|
||
|
||
**Blending the benchmark signal:** Once `self_eval_samples ≥
|
||
self_eval_min_samples` (default 10): `blended = 0.3 × leaderboard + 0.7 ×
|
||
self_eval`. Before that, falls back to leaderboard alone. If neither
|
||
exists, the benchmark component is neutral.
|
||
|
||
**From benchmark to expected pass rate:** The files in
|
||
`src/proficiency.py` convert the benchmark blend into an expected client
|
||
success rate on real traffic. A category with no client outcome traffic
|
||
keeps the benchmark score verbatim. A trafficked category with no
|
||
per-model outcomes inherits a peer-rate prior. A row with its own
|
||
outcomes gets an empirical-Bayes blend of the prior and its observed
|
||
rate, with `outcome_prior_strength` pseudo-observations (default 20)
|
||
pulling thin data toward the category mean.
|
||
|
||
Source labels report provenance, not just confidence:
|
||
- `outcome_blended` — per-model has real outcome evidence
|
||
- `outcome_prior` — category is trafficked, but this row has no own outcomes
|
||
- `self_eval_thin` — benchmark measurement below the sample threshold; a
|
||
caller wanting to exclude it can
|
||
|
||
**Proficiency inheritance:** `propagate_to_variants` copies evaluated
|
||
`scores` to equivalent serving variants (same weights, same reasoning
|
||
setting, same context pool), but never over a row that was measured
|
||
directly. A `-fast` row is **not** equivalent to its `-standard` sibling.
|
||
|
||
"Measured directly" is `inherited_from IS NULL`, not `self_eval_samples > 0`
|
||
— inheritance copies the sample count too, so sample count alone cannot tell
|
||
an inherited row from a measured one, and using it meant a variant inherited
|
||
exactly once and then froze forever. `ensure_columns()` adds the column and
|
||
backfills provenance on databases that predate it.
|
||
|
||
### `energy_observations` — per-request telemetry
|
||
|
||
| Column | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | INTEGER | Autoincrement |
|
||
| `model_id` / `provider` | TEXT | |
|
||
| `task_category` | TEXT | |
|
||
| `prompt_tokens` / `completion_tokens` | INTEGER | |
|
||
| `energy_kwh` | REAL | **Attributed** billed figure (noisy, 20× within-model) |
|
||
| `energy_btu` | REAL | `kwh × 3412.14`, dashboard value |
|
||
| `avg_power_watts` / `duration_seconds` | REAL | Pre-attribution product, ~1.8× within-model. `duration_seconds` is the **provider's** reported serving time — see `router_wall_seconds` below, which is a different quantity |
|
||
| `attribution_ratio` | REAL | Request's share of shared GPU pool (stable quantized: 0.001, 0.25, 0.5, 0.75) |
|
||
| `carbon_g_co2eq` | REAL | Reported by provider |
|
||
| `grid_carbon_intensity` | REAL | gCO2/kWh at call time |
|
||
| `grid_id` | TEXT | e.g. `FI` |
|
||
| `carbon_source` | TEXT | `static_fallback` (constant, excluded from routing) or live measurement |
|
||
| `cost_usd` | REAL | **Billed** figure, not tokens × list price |
|
||
| `allowance_remaining_usd` | REAL | |
|
||
| `service_tier` | TEXT | As billed |
|
||
| `cached_prompt_tokens` | INTEGER | `usage.prompt_tokens_details.cached_tokens`. NULL when the provider reported no count — never a substituted 0 |
|
||
| `cached_tokens_source` | TEXT | Why the column above is what it is: `reported` (a number arrived, 0 included), `details_no_count` (a details block with no cached count), `no_details` (no details block at all). NULL only on rows written before the column |
|
||
| `router_wall_seconds` | REAL | **Router-observed** wall clock for the request, `time.monotonic()`. Not `duration_seconds` — see below |
|
||
| `router_ttft_seconds` | REAL | Router-observed time to the first output token. Streaming only; NULL on a buffered row means not applicable |
|
||
| `observed_at` | TEXT | ISO8601 |
|
||
|
||
**Router-observed latency is a second quantity, not a backfill of the first.**
|
||
`duration_seconds` is what the provider says it spent serving; the two
|
||
`router_*` columns are what the router measured end to end, which additionally
|
||
includes connection setup, network transit, queueing ahead of the first token,
|
||
and router overhead. They are never written into each other.
|
||
|
||
The reason for the second measurement is coverage. On the live database
|
||
`duration_seconds` is present on 31,243 of 31,309 NeuralWatt rows and on **0**
|
||
of 3,850 OpenRouter rows, and no request-body opt-in will change that:
|
||
OpenRouter serves generation timing only from its separate
|
||
`/api/v1/generation?id=` endpoint, a second HTTP call per request. OpenRouter
|
||
carries ~73% of routed decisions, so a latency term in the objective needs a
|
||
number that exists for every provider. The router can always take one.
|
||
|
||
Spans, which differ by path:
|
||
|
||
| path | `router_wall_seconds` | `router_ttft_seconds` |
|
||
|---|---|---|
|
||
| streaming `/v1/chat/completions` | connection open → last byte forwarded | connection open → first delta carrying content or a `tool_calls` fragment |
|
||
| buffered `/v1/chat/completions` | just before the POST → complete response body | NULL, not applicable |
|
||
| `POST /dispatch` | just before the SDK call → response returned | NULL, not applicable |
|
||
|
||
Three things follow from those definitions. The mark is re-taken per attempt,
|
||
so a failover records only the candidate that actually answered. A role-only
|
||
opening delta does not count as a first token — it is protocol, not answer — so
|
||
TTFT is measured against output a user could see. And if a streaming client
|
||
hangs up early the `finally` still records the span up to abandonment, which
|
||
understates that request's latency rather than inflating it.
|
||
|
||
NULL on rows written before the columns existed, and on `seed_energy.py` and
|
||
`eval_proficiency.py` rows, which do not pass the timings.
|
||
|
||
**Attribution noise:** Billed `energy_kwh = avg_power_watts × duration ×
|
||
attribution_ratio`. The attribution term looks like noise up close (8 identical
|
||
calls varied 20×), but ranks 750× between models while within-model spread is
|
||
1.8× — it's a stable per-model property reflecting serving concurrency. Routing
|
||
scores on the attributed figures with a **median** over all `seed_reference`
|
||
rows, so repeated sweeps accumulate into a median-across-time.
|
||
|
||
### `provider_balance_observations` — per-provider prepaid pool snapshots
|
||
|
||
| Column | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | INTEGER | Autoincrement |
|
||
| `provider` | TEXT | |
|
||
| `balance_usd` | REAL | Current prepaid pool balance |
|
||
| `total_credits_usd` | REAL | Lifetime credits purchased |
|
||
| `total_usage_usd` | REAL | Lifetime usage billed against the pool |
|
||
| `observed_at` | TEXT | ISO8601 |
|
||
|
||
Polled by `poller.py` from each provider's balance endpoint
|
||
(`provider.balance_url`). Used by `quota_accounts()` to compute per-provider
|
||
burn rate, projected runway, and stale-reading alerts.
|
||
|
||
### `local_energy_observations` — per-call local hardware draw
|
||
|
||
| Column | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | INTEGER | Autoincrement |
|
||
| `model_id` | TEXT | Local Ollama tag (e.g. `mistral-nemo:12b` or `qwen2.5-coder-router:14b`), not a cloud model |
|
||
| `call_type` | TEXT | Not a closed enum. Current values include `classify`, `verify`, `local_vision`, `local_dispatch`, `file_summarization`, `diff_checking`, and `seed_local_dispatch`. |
|
||
| `request_id` | TEXT | Optional; joins to `POST /outcome` reports the same way `energy_observations.request_id` does for cloud rows |
|
||
| `session_dir` | TEXT | Optional; used for source-less `/outcome` attribution |
|
||
| `avg_power_watts` | REAL | Averaged over the call (background `nvidia-smi` sampler) |
|
||
| `duration_seconds` | REAL | Wall-clock time for the local call |
|
||
| `energy_kwh` | REAL | `avg_power_watts × duration_seconds / 3_600_000` |
|
||
| `cost_usd` | REAL | `energy_kwh × tariff_usd_per_kwh`; NULL if tariff not configured |
|
||
| `carbon_g_co2eq` | REAL | `energy_kwh × 1000 × grid_intensity_g_per_kwh`; NULL unless grid intensity configured |
|
||
| `meter` | TEXT | `nvidia_smi` (room for RAPL / smart plug later) |
|
||
| `observed_at` | TEXT | ISO8601 |
|
||
|
||
**Separate table, by design.** `quota_burn()` sums `energy_kwh` over **all**
|
||
of `energy_observations` against the NeuralWatt plan allowance. A separate
|
||
table makes it structurally impossible for local electricity to leak into that
|
||
number. `local_energy_summary()` in `metrics.py` queries this table exclusively,
|
||
over the same 30-day window. `/metrics` surfaces it as a top-level
|
||
`"local_energy"` key, and the admin dashboard has a distinct "Local compute"
|
||
card. The meter is off by default (`local_energy:` in config); config load
|
||
refuses `enabled: true` without a `tariff_usd_per_kwh`. Remote-Ollama safety:
|
||
if any Ollama base URL is non-loopback, metering skips with a log warning.
|
||
`ensure_local_energy_table()` in `src/local_energy.py` also creates the table
|
||
and index idempotently for live databases predating this schema.
|
||
|
||
### `verifications` — response quality observations
|
||
|
||
| Column | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | INTEGER | Autoincrement |
|
||
| `model_id` / `provider` | TEXT | |
|
||
| `task_category` | TEXT | Optional (prose answers may lack a category) |
|
||
| `kind` | TEXT | `structural` \| `local_llm` \| `client_outcome` |
|
||
| `verdict` | TEXT | `ok` \| `truncated` \| `malformed` \| `unverifiable` \| `succeeded` \| `failed` |
|
||
| `detail` | TEXT | Human-readable reason |
|
||
| `completion_tokens` | INTEGER | Wasted answer cost, for payoff sum |
|
||
| `observed_at` | TEXT | ISO8601 |
|
||
| `applied_at` | TEXT | Set by `feedback.py` when folded into proficiency |
|
||
| `model_attributable` | INTEGER | 1 = model's fault; 0 = client caused (e.g. tight cap) |
|
||
|
||
Verifications drive the feedback loop: `feedback.py` reads unanswered failures,
|
||
applies a 0.0 sample per failure to `proficiency`, and marks them
|
||
`applied_at` for idempotency. `unverifiable` is recorded but not treated as a
|
||
failure — it means the checker had nothing to say, not that the model failed.
|
||
|
||
### `route_decisions` — routing observability
|
||
|
||
| Column | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | INTEGER | Autoincrement |
|
||
| `observed_at` | TEXT | ISO8601, UTC |
|
||
| `kind` | TEXT | `route` \| `dispatch` \| `chat` \| `passthrough` \| `local_vision` \| `local_dispatch_fallback` |
|
||
| `task_category` | TEXT | |
|
||
| `task_tier` | INTEGER | 1–3 |
|
||
| `required_context_tokens` | INTEGER | |
|
||
| `confidence` | REAL | Classifier confidence |
|
||
| `classifier_ms` | INTEGER | Classification latency |
|
||
| `classification_source` | TEXT | `classifier` \| `override` \| `fallback` \| `cached` |
|
||
| `latency_tolerance` | TEXT | `interactive` \| `batch` |
|
||
| `candidates_considered` | INTEGER | How many survived hard filters |
|
||
| `selected_model` | TEXT | Null when no model was selected |
|
||
| `selected_provider` | TEXT | `neuralwatt`, `local` (vision fallback), or `ollama-local` (local dispatch) |
|
||
| `runner_up_models` | TEXT | JSON array of up to 3 runner-up candidates |
|
||
| `est_cost_usd` | REAL | Estimated cost of the selected model |
|
||
| `est_proficiency` | REAL | Estimated proficiency for the task category |
|
||
| `rejected_reason` | TEXT | Active filters when nothing was selected |
|
||
| `session_key` | TEXT | Hashed session fingerprint ONLY |
|
||
| `tools` | INTEGER | 0/1 — request carried a `tools` array |
|
||
| `images` | INTEGER | 0/1 — request carried image parts |
|
||
| `json_mode` | INTEGER | 0/1 — request required JSON mode |
|
||
| `streamed` | INTEGER | 0/1 — response was streamed |
|
||
| `pinch_original_tokens` | INTEGER | Estimated tokens before pruning (includes `extra_fixed_tokens`) |
|
||
| `pinch_final_tokens` | INTEGER | Estimated tokens after pruning |
|
||
| `request_id` | TEXT | Provider request id; joins to `energy_observations.request_id` |
|
||
| `exploration` | INTEGER | 0/1 — selected as the least-evidenced candidate for exploration |
|
||
| `prefix_divergence_index` | INTEGER | First message position whose bytes differ from the previous turn in this session |
|
||
| `prefix_tokens_after_divergence` | INTEGER | Estimated tokens at or after that position in THIS turn |
|
||
| `prefix_prev_message_count` | INTEGER | The previous turn's message count |
|
||
|
||
The three `prefix_*` columns are the prefix-stability probe
|
||
(`pinch.prefix_probe`, on by default). The provider bills the longest
|
||
byte-identical **prefix** of a prompt at the cached rate, so a divergence
|
||
early in the payload re-bills everything after it; these say how much of the
|
||
previous turn's cache this turn kept. Read them together:
|
||
`prefix_divergence_index == prefix_prev_message_count` is a healthy append,
|
||
and anything lower is rewritten history —
|
||
`prefix_tokens_after_divergence` is then what it cost.
|
||
|
||
They are **NULL together** when there was no previous turn to compare against
|
||
(first turn of a session, or first after a restart), which is deliberately
|
||
distinct from a measured `0` — a zero would read as total cache loss at
|
||
message 0. **Hashes only, and not even those**: the per-message digests that
|
||
produce these numbers live in process memory for exactly one turn and never
|
||
reach the database, so nothing here is reversible to any message. Rows written
|
||
before the columns existed are NULL and nothing may be inferred for them; the
|
||
payloads were never stored, by design.
|
||
|
||
`pinch_original_tokens` and `pinch_final_tokens` capture context-pruning
|
||
outcomes: `tokens_saved = original - final`, and `pruned = original > final`.
|
||
Both are NULL when pinch is disabled or the request predates the columns.
|
||
`request_id` is written back after the provider returns a completion id; it is
|
||
NULL on `/route` (no upstream call) until backfilled. `exploration` is 1 when
|
||
`exploration.choose` replaced the ranked winner with a least-evidenced
|
||
candidate.
|
||
|
||
Rows with `kind='local_dispatch_fallback'` record a degraded answer after the
|
||
cloud refused or was exhausted — they are **NOT evidence that local was
|
||
competitive on merit** (do not feed them into proficiency analysis). They carry
|
||
`selected_provider='ollama-local'`.
|
||
|
||
`route_decisions` stores one row per routing decision so "how is routing
|
||
performing" is answerable: which model was picked, for what category/tier,
|
||
how long classification took, and — when nothing was selected — which hard
|
||
filter shut it out. It is an observability table: nothing in routing reads it.
|
||
|
||
It stores only a hashed session fingerprint in `session_key`; `session_dir`,
|
||
prompts, and answers are deliberately excluded. A test enforces that the write
|
||
path does not store prompt or answer text. The write is gated by
|
||
`logging.log_route_decisions` and is **best-effort**: a failed write is logged
|
||
at warning and swallowed so monitoring cannot slow or fail a request.
|