Files
6krrt/tests/test_metrics.py
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

539 lines
19 KiB
Python

"""Tests for metrics.py — read-only aggregation helpers.
Every test seeds a throwaway SQLite DB directly from schema.sql, never writes
to the live ``router.db``, and asserts on *actual queried aggregates* rather
than mock-call assertions (to defeat ``misleading_success_output``).
Functions tested:
- quota_burn
- scoring_coverage
- recent_decisions
- per_model
- verdict_mix
- top_proficiency
Also verifies that ``import dispatcher`` and ``/health`` still work after the
move, and that ``import metrics`` alone succeeds (no circular import).
"""
from __future__ import annotations
import sqlite3
from datetime import datetime, timedelta, timezone
from pathlib import Path
from types import SimpleNamespace
import pytest
from starlette.testclient import TestClient
import dispatcher
from config import load_config
from metrics import (
quota_burn,
scoring_coverage,
recent_decisions,
per_model,
verdict_mix,
top_proficiency,
)
ROOT = Path(__file__).resolve().parent.parent
SCHEMA_SQL = (ROOT / "schema.sql").read_text()
CFG = load_config(str(ROOT / "config.yaml"))
# =============================================================================
# Helpers
# =============================================================================
def _now() -> datetime:
return datetime.now(timezone.utc)
def _make_db(tmp_path: Path, extra_sql: str = "") -> sqlite3.Connection:
"""Create a clean DB seeded from schema.sql, returning a Row-backed conn."""
conn = sqlite3.connect(str(tmp_path / "test.db"))
conn.row_factory = sqlite3.Row
conn.executescript(SCHEMA_SQL + extra_sql)
return conn
def _seed_models(conn: sqlite3.Connection) -> None:
"""Insert routable model rows into an (empty) DB."""
for model_id, tier, context, cost, vision in (
("cheap", 2, 262128, 0.30, 1),
("dear", 2, 262128, 9.00, 0),
("tiny", 1, 131072, 0.10, 1),
):
conn.execute(
"""
INSERT INTO models (
model_id, provider, base_model_id, tier, context_window,
effective_context_window, max_output_tokens,
cost_per_1m_prompt, cost_per_1m_completion,
supports_vision, supports_json_mode,
latency_class, reasoning_mode, context_variant,
access_level, availability, last_updated
) VALUES (?, 'neuralwatt', ?, ?, ?, 192500, 16384, ?, ?,
?, 1, 'standard', 'default', 'full', 'public', 'active',
'2026-08-22T00:00:00+00:00')
""",
(model_id, model_id, tier, context, cost, cost / 3, vision),
)
conn.commit()
def _seed_proficiency(conn: sqlite3.Connection) -> None:
"""Insert proficiency rows for the seeded models."""
for model_id, score in (
("cheap", 0.90),
("dear", 0.95),
("tiny", 0.70),
):
conn.execute(
"""
INSERT INTO proficiency (
model_id, provider, category, blended_score, source, last_updated
) VALUES (?, 'neuralwatt', 'coding_general', ?, 'self_eval_thin', '2026-01-01T00:00:00+00:00')
""",
(model_id, score),
)
conn.commit()
def _seed_energy(conn: sqlite3.Connection) -> None:
now = _now()
recent_rows = [
("cheap", 5.0e-05, 100, 0.25), # 2 days ago
("cheap", 3.0e-05, 200, 0.50), # 2 days ago
("dear", 1.0e-04, 150, 0.75), # 5 days ago
("tiny", 1.0e-05, 50, 1.00), # 10 days ago
]
for (model_id, kwh, tokens, attr) in recent_rows:
conn.execute(
"""
INSERT INTO energy_observations (
model_id, provider, task_category, prompt_tokens,
completion_tokens, energy_kwh, attribution_ratio,
observed_at
) VALUES (?, 'neuralwatt', 'coding_general', 1000, ?, ?, ?, ?)
""",
(model_id, tokens, kwh, attr, (now - timedelta(days=2)).isoformat()),
)
conn.commit()
# --- Import / no-circular-import smoke tests -----------------------------------
def test_metrics_can_be_imported_alone():
"""metrics.py must not require dispatcher — it *is* the cycle-breaker."""
# If this import raises ImportError (circular), we fail.
import metrics # noqa: F401
def test_dispatcher_imports_after_metrics():
"""importing metrics first, then dispatcher, must not raise."""
# This test runs *after* metrics has already been imported above.
# The import chain is: dispatcher → metrics (one-way).
assert hasattr(dispatcher, "app")
# --- quota_burn tests ---------------------------------------------------------
def test_quota_burn_returns_none_when_no_plan(tmp_path):
"""When plan_kwh_per_period is falsy, quota_burn returns None."""
conn = _make_db(tmp_path)
no_plan_cfg = SimpleNamespace(
objective=SimpleNamespace(plan_kwh_per_period=None)
)
assert quota_burn(conn, no_plan_cfg) is None
def test_quota_burn_returns_none_when_plan_is_zero(tmp_path):
"""plan_kwh_per_period == 0 is treated the same as None."""
conn = _make_db(tmp_path)
zero_plan_cfg = SimpleNamespace(
objective=SimpleNamespace(plan_kwh_per_period=0)
)
assert quota_burn(conn, zero_plan_cfg) is None
def test_quota_burn_aggregates_last_30_days(tmp_path):
"""Two recent rows and one old row — only the recent ones count."""
cfg = SimpleNamespace(
objective=SimpleNamespace(plan_kwh_per_period=6.25)
)
conn = _make_db(tmp_path)
now = _now()
conn.execute(
"INSERT INTO energy_observations "
"(model_id, provider, energy_kwh, completion_tokens, observed_at) "
"VALUES ('m', 'neuralwatt', 0.10, 100, ?)",
((now - timedelta(days=1)).isoformat(),),
)
conn.execute(
"INSERT INTO energy_observations "
"(model_id, provider, energy_kwh, completion_tokens, observed_at) "
"VALUES ('m', 'neuralwatt', 0.15, 200, ?)",
((now - timedelta(days=5)).isoformat(),),
)
conn.execute(
"INSERT INTO energy_observations "
"(model_id, provider, energy_kwh, completion_tokens, observed_at) "
"VALUES ('m', 'neuralwatt', 0.90, 300, ?)",
((now - timedelta(days=60)).isoformat(),),
)
conn.commit()
result = quota_burn(conn, cfg)
assert result is not None
assert result["metered_kwh_30d"] == pytest.approx(0.25)
assert result["metered_calls_30d"] == 2
assert result["plan_kwh"] == 6.25
assert result["metered_fraction_of_plan"] == pytest.approx(0.25 / 6.25)
def test_quota_burn_empty_db(tmp_path):
"""Zero rows → kwh=0, calls=0, not an error."""
cfg = SimpleNamespace(
objective=SimpleNamespace(plan_kwh_per_period=1.0)
)
conn = _make_db(tmp_path)
result = quota_burn(conn, cfg)
assert result["metered_kwh_30d"] == 0.0
assert result["metered_calls_30d"] == 0
# --- scoring_coverage tests ---------------------------------------------------
def test_scoring_coverage_has_all_keys(tmp_path):
"""The return dict always has these keys, even when empty."""
conn = _make_db(tmp_path)
# Seed routable models
_seed_models(conn)
result = scoring_coverage(conn, CFG)
assert "routable_models" in result
assert "with_energy_data" in result
assert "with_proficiency_data" in result
assert "quota" in result
assert "warnings" in result
def test_scoring_coverage_warning_when_no_energy(tmp_path):
"""Models with no seed_reference observations produce a warning."""
conn = _make_db(tmp_path)
_seed_models(conn)
# No energy_observations rows at all
result = scoring_coverage(conn, CFG)
warning_texts = result["warnings"]
assert any("no reference-workload observations" in w for w in warning_texts)
assert result["with_energy_data"] == 0
def test_scoring_coverage_no_warning_when_full_coverage(tmp_path):
"""When every routable model has energy + proficiency, no warnings."""
conn = _make_db(tmp_path)
_seed_models(conn)
# Seed SEED_CATEGORY energy observations
now = _now()
for model in ["cheap", "dear"]:
conn.execute(
"INSERT INTO energy_observations "
"(model_id, provider, task_category, prompt_tokens, "
"completion_tokens, energy_kwh, attribution_ratio, observed_at) "
"VALUES (?, 'neuralwatt', 'seed_reference', 1000, 100, 0.001, 0.25, ?)",
(model, now.isoformat()),
)
conn.execute(
"INSERT INTO proficiency "
"(model_id, provider, category, blended_score, source, last_updated) "
"VALUES (?, 'neuralwatt', 'coding_general', 0.9, 'self_eval', ?)",
(model, now.isoformat()),
)
conn.commit()
result = scoring_coverage(conn, CFG)
# 'tiny' has no data so we expect warnings. Let's also add 'tiny'.
conn.execute(
"INSERT INTO energy_observations "
"(model_id, provider, task_category, prompt_tokens, "
"completion_tokens, energy_kwh, attribution_ratio, observed_at) "
"VALUES ('tiny', 'neuralwatt', 'seed_reference', 1000, 100, 0.001, 0.25, ?)",
(now.isoformat(),),
)
conn.execute(
"INSERT INTO proficiency "
"(model_id, provider, category, blended_score, source, last_updated) "
"VALUES ('tiny', 'neuralwatt', 'coding_general', 0.7, 'self_eval', ?)",
(now.isoformat(),),
)
conn.commit()
result = scoring_coverage(conn, CFG)
assert result["with_energy_data"] == 3
assert result["with_proficiency_data"] == 3
assert len(result["warnings"]) == 0
def test_scoring_coverage_empty_db(tmp_path):
"""Empty DB: 0 routable, no warnings, quota=None."""
conn = _make_db(tmp_path)
result = scoring_coverage(conn, CFG)
assert result["routable_models"] == 0
assert result["with_energy_data"] == 0
assert result["with_proficiency_data"] == 0
# quota returns None because plan_kwh_per_period may be None in default cfg
# (it is 6.25 by default, but let's just check the structure)
assert result["warnings"] == []
# --- recent_decisions tests ---------------------------------------------------
def test_recent_decisions_returns_rows_in_desc_order(tmp_path, monkeypatch):
"""Rows come back ordered by id DESC, and selected_provider is included."""
conn = _make_db(tmp_path)
# Seed two models
_seed_models(conn)
# Create fake dispatcher state to use TestClient, but we'll insert
# route_decisions rows directly and call recent_decisions(conn).
conn.execute(
"""
INSERT INTO route_decisions (
observed_at, kind, task_category, task_tier, required_context_tokens,
confidence, classifier_ms, classification_source, latency_tolerance,
candidates_considered, selected_model, selected_provider,
runner_up_models, est_cost_usd, est_proficiency,
session_key, tools, images, json_mode, streamed
) VALUES (?, 'route', 'coding_general', 2, 100, 0.95, 200,
'classifier', 'interactive', 5, 'cheap', 'neuralwatt',
'[{"model_id":"dear","provider":"neuralwatt"}]',
0.001, 0.9, 'abc123', 0, 0, 0, 0)
""",
(datetime.now(timezone.utc).isoformat(),),
)
conn.execute(
"""
INSERT INTO route_decisions (
observed_at, kind, task_category, task_tier, required_context_tokens,
confidence, classifier_ms, classification_source, latency_tolerance,
candidates_considered, selected_model, selected_provider,
runner_up_models, est_cost_usd, est_proficiency,
session_key, tools, images, json_mode, streamed
) VALUES (?, 'route', 'docs_writing', 1, 50, 0.88, 150,
'classifier', 'interactive', 3, 'dear', 'neuralwatt',
NULL, 0.005, 0.95, 'def456', 0, 0, 0, 0)
""",
(datetime.now(timezone.utc).isoformat(),),
)
conn.commit()
rows = recent_decisions(conn, limit=50)
assert len(rows) == 2
# DESC order: dear (id=2) first
assert rows[0]["selected_model"] == "dear"
assert rows[0]["kind"] == "route"
assert rows[0]["selected_provider"] == "neuralwatt"
assert rows[1]["selected_model"] == "cheap"
def test_recent_decisions_respects_limit(tmp_path):
"""limit=1 should return only one row regardless of DB content."""
conn = _make_db(tmp_path)
_seed_models(conn)
now = _now().isoformat()
for i in range(5):
conn.execute(
"INSERT INTO route_decisions "
"(observed_at, kind, selected_model, selected_provider) "
"VALUES (?, 'route', 'm', 'neuralwatt')",
(now,),
)
conn.commit()
rows = recent_decisions(conn, limit=1)
assert len(rows) == 1
def test_recent_decisions_empty_db(tmp_path):
"""No rows: returns an empty list, not an error."""
conn = _make_db(tmp_path)
assert recent_decisions(conn) == []
# --- per_model tests ----------------------------------------------------------
def test_per_model_aggregates_correctly(tmp_path):
"""Sum/cost/energy/carbon/tokens match hand-computed values."""
conn = _make_db(tmp_path)
now = _now()
rows = [
("cheap", 0.001, 5.0e-05, 2.4e-03, 100, 0.25),
("cheap", 0.002, 3.0e-05, 1.2e-03, 200, 0.50),
("dear", 0.010, 1.0e-04, 5.0e-03, 150, 0.75),
]
for (model_id, cost, kwh, carbon, tokens, attr) in rows:
conn.execute(
"INSERT INTO energy_observations "
"(model_id, provider, cost_usd, energy_kwh, carbon_g_co2eq, "
"completion_tokens, attribution_ratio, observed_at) "
"VALUES (?, 'neuralwatt', ?, ?, ?, ?, ?, ?)",
(model_id, cost, kwh, carbon, tokens, attr, now.isoformat()),
)
conn.commit()
results = per_model(conn)
by_model = {r["model_id"]: r for r in results}
cheap = by_model["cheap"]
assert cheap["calls"] == 2
assert cheap["sum_cost_usd"] == pytest.approx(0.003)
assert cheap["sum_energy_kwh"] == pytest.approx(8.0e-05)
assert cheap["sum_carbon_g_co2eq"] == pytest.approx(3.6e-03)
assert cheap["avg_completion_tokens"] == pytest.approx(150.0)
assert cheap["avg_attribution_ratio"] == pytest.approx(0.375)
dear = by_model["dear"]
assert dear["calls"] == 1
assert dear["sum_cost_usd"] == pytest.approx(0.010)
def test_per_model_empty_db(tmp_path):
"""No energy rows: returns empty list."""
conn = _make_db(tmp_path)
assert per_model(conn) == []
# --- verdict_mix tests --------------------------------------------------------
def test_verdict_mix_counts_by_verdict(tmp_path):
"""Counts match the inserted rows."""
conn = _make_db(tmp_path)
now = _now()
conn.execute(
"INSERT INTO verifications (model_id, provider, kind, verdict, observed_at) "
"VALUES ('m', 'neuralwatt', 'structural', 'ok', ?)",
(now.isoformat(),),
)
conn.execute(
"INSERT INTO verifications (model_id, provider, kind, verdict, observed_at) "
"VALUES ('m', 'neuralwatt', 'structural', 'ok', ?)",
(now.isoformat(),),
)
conn.execute(
"INSERT INTO verifications (model_id, provider, kind, verdict, observed_at) "
"VALUES ('m', 'neuralwatt', 'local_llm', 'malformed', ?)",
(now.isoformat(),),
)
conn.execute(
"INSERT INTO verifications (model_id, provider, kind, verdict, observed_at) "
"VALUES ('m', 'neuralwatt', 'structural', 'unverifiable', ?)",
(now.isoformat(),),
)
# Stale row outside the window
conn.execute(
"INSERT INTO verifications (model_id, provider, kind, verdict, observed_at) "
"VALUES ('m', 'neuralwatt', 'structural', 'truncated', ?)",
((now - timedelta(days=30)).isoformat(),),
)
conn.commit()
result = verdict_mix(conn, since_days=7)
assert result["ok"] == 2
assert result["malformed"] == 1
assert result["unverifiable"] == 1
# truncated is > 7 days ago, so excluded
assert "truncated" not in result or result["truncated"] == 0
def test_verdict_mix_empty_db(tmp_path):
"""No rows: returns empty dict."""
conn = _make_db(tmp_path)
assert verdict_mix(conn) == {}
# --- top_proficiency tests ----------------------------------------------------
def test_top_proficiency_ordered_correctly(tmp_path):
"""Models are returned ordered by blended_score DESC."""
conn = _make_db(tmp_path)
_seed_models(conn)
_seed_proficiency(conn)
conn.commit()
results = top_proficiency(conn, "coding_general")
assert len(results) == 3
assert results[0]["model_id"] == "dear" # 0.95
assert results[1]["model_id"] == "cheap" # 0.90
assert results[2]["model_id"] == "tiny" # 0.70
def test_top_proficiency_filter_by_category(tmp_path):
"""Requests for one category exclude models that have scores only for another."""
conn = _make_db(tmp_path)
_seed_models(conn)
# Only code categories
conn.execute(
"""
INSERT INTO proficiency (
model_id, provider, category, blended_score, source, last_updated
) VALUES ('cheap', 'neuralwatt', 'coding_general', 0.90, 'self_eval_thin', '2026-01-01T00:00:00+00:00')
""",
)
conn.execute(
"""
INSERT INTO proficiency (
model_id, provider, category, blended_score, source, last_updated
) VALUES ('cheap', 'neuralwatt', 'docs_writing', 0.85, 'self_eval_thin', '2026-01-01T00:00:00+00:00')
""",
)
conn.commit()
coding = top_proficiency(conn, "coding_general")
docs = top_proficiency(conn, "docs_writing")
assert len(coding) == 1
assert coding[0]["model_id"] == "cheap"
assert len(docs) == 1
assert docs[0]["model_id"] == "cheap"
def test_top_proficiency_empty_for_missing_category(tmp_path):
"""No proficiency rows for category → empty list."""
conn = _make_db(tmp_path)
_seed_models(conn)
assert top_proficiency(conn, "nonexistent_category") == []
# --- /health endpoint compatibility -------------------------------------------
def test_health_endpoint_returns_scoring_key(tmp_path, monkeypatch):
"""/health still returns the same SHAPE after moving functions to metrics."""
db_path = tmp_path / "test.db"
conn = _make_db(tmp_path)
_seed_models(conn)
conn.commit()
conn.close()
monkeypatch.setattr(dispatcher.cfg.database, "path", str(db_path))
# Disable local verification to avoid Ollama dependency
monkeypatch.setattr(dispatcher.cfg.verification, "local_llm_enabled", False)
monkeypatch.setattr(dispatcher.cfg.routing, "require_vision", False)
monkeypatch.setenv("NEURALWATT_API_KEY", "test-key")
with TestClient(dispatcher.app) as client:
resp = client.get("/health")
assert resp.status_code == 200
data = resp.json()
assert "scoring" in data
assert "routable_models" in data["scoring"]
assert "with_energy_data" in data["scoring"]
assert "with_proficiency_data" in data["scoring"]
assert "quota" in data["scoring"]
assert "warnings" in data["scoring"]