Files
6krrt/tests/test_classifier_cascade.py
adlee-was-taken 7777002981 fix: replace raw datetime('now') comparisons with julianday() for ISO-T column 24h windows
Two sites in src/metrics.py (classifier_degradation_warning and
_declined_for) compared observed_at against datetime('now', ...) using
string comparison. Since observed_at is stored as ISO-8601 ('T'
separator) and datetime('now') returns a space separator, the 'T' > ' '
sorting made the time-of-day component irrelevant — a 24h window
silently admitted all rows on the cutoff's calendar date.

Replace both with julianday() on both sides, matching the pattern
already used in dispatcher.py and poller.py.

Add three tests in test_classifier_cascade.py using a just_outside
timestamp (midnight of the cutoff's calendar date — the exact shape
the old comparison wrongly admitted) and an inside timestamp, proving
the fix works.

Add tests/test_sql_time_windows.py as a tripwire: scans src/ for
any ISO-T column compared against datetime('now') with an operator,
with positive controls that verify the regexes match the bad pattern
and reject the safe julianday pattern.
2026-10-05 21:21:14 -04:00

395 lines
14 KiB
Python

"""The classifier fallback cascade: local -> stale -> history -> cloud -> guess.
Each step is asserted by making the ones before it fail, so a test that
passes proves that step actually ran rather than that some earlier step
happened to produce the same answer.
"""
from __future__ import annotations
import sqlite3
from datetime import datetime, time, timedelta, timezone
from pathlib import Path
from types import SimpleNamespace
import pytest
import dispatcher
import session_cache
ROOT = Path(__file__).resolve().parent.parent
SCHEMA_SQL = (ROOT / "config" / "schema.sql").read_text()
def _now() -> datetime:
return datetime.now(timezone.utc)
@pytest.fixture(autouse=True)
def _clean_state(monkeypatch):
"""Each test starts with an empty cache and a closed circuit."""
session_cache.clear()
monkeypatch.setattr(dispatcher, "_last_classifier_failure", 0.0)
dispatcher._provider_refusal_since.clear()
yield
dispatcher._provider_refusal_since.clear()
session_cache.clear()
@pytest.fixture
def db(tmp_path, monkeypatch):
path = tmp_path / "cascade.db"
conn = sqlite3.connect(path)
conn.row_factory = sqlite3.Row
conn.executescript(SCHEMA_SQL)
conn.commit()
def _open():
c = sqlite3.connect(path)
c.row_factory = sqlite3.Row
return c
monkeypatch.setattr(dispatcher, "_db", _open)
yield conn
conn.close()
def test_step1_stale_session_beats_a_generic_guess():
"""A stale REAL classification is used before falling back to a guess."""
session_cache.put("sess-1", task_category="coding_refactor", task_tier=3)
got = dispatcher._classify_cascade("sess-1", "sys", "user")
assert got is not None
assert got.source == "session_stale"
assert got.task_category == "coding_refactor"
assert got.task_tier == 3
def test_step1_ignores_staleness_but_never_renews_it():
"""stale_read must not refresh cached_at — a degraded read earns nothing."""
session_cache.put("sess-2", task_category="debugging", task_tier=2)
before = session_cache.stale_read("sess-2").cached_at
dispatcher._classify_cascade("sess-2", "sys", "user")
assert session_cache.stale_read("sess-2").cached_at == before
def test_step2_session_history_survives_a_restart(db):
"""With an empty cache, the session's own history is read from the DB.
This is the step that matters after a router restart, when the in-memory
cache is gone but route_decisions still remembers the session.
"""
db.execute(
"""
INSERT INTO route_decisions
(kind, task_category, task_tier, required_context_tokens,
selected_model, selected_provider, classification_source,
session_key, observed_at)
VALUES ('chat','translation',1,50,'m','neuralwatt','classifier',?,?)
""",
("sess-3", _now().isoformat()),
)
db.commit()
got = dispatcher._classify_cascade("sess-3", "sys", "user")
assert got is not None
assert got.source == "session_history"
assert got.task_category == "translation"
def test_step2_refuses_to_reuse_a_previous_degraded_answer(db):
"""A prior fallback must not be read back as if it were real.
Otherwise one outage's guess propagates through every later turn of the
session and looks like a genuine classification forever.
"""
for src in ("fallback", "session_stale", "session_history"):
db.execute(
"""
INSERT INTO route_decisions
(kind, task_category, task_tier, required_context_tokens,
selected_model, selected_provider, classification_source,
session_key, observed_at)
VALUES ('chat','general_chat',2,0,'m','neuralwatt',?,?,?)
""",
(src, "sess-4", _now().isoformat()),
)
db.commit()
# No cloud configured -> None means "use the static fallback".
assert dispatcher._classify_cascade("sess-4", "sys", "user") is None
def test_cloud_step_is_skipped_when_unconfigured(db):
"""Zero cost unless someone explicitly configures cloud_fallback."""
assert getattr(dispatcher.cfg.classifier, "cloud_fallback", None) is None
assert dispatcher._classify_cascade("sess-none", "sys", "user") is None
def test_cloud_step_used_when_configured(db, monkeypatch):
"""A configured cloud classifier answers when everything free has failed."""
monkeypatch.setattr(
dispatcher.cfg.classifier,
"cloud_fallback",
SimpleNamespace(
base_url="https://cloud.example/v1",
model="cloud-classifier",
timeout_seconds=2,
api_key_env=None,
max_output_tokens=1024,
),
)
monkeypatch.setattr(dispatcher, "_cloud_classifier_client", lambda cf: object())
monkeypatch.setattr(
session_cache,
"classify_one",
lambda *a, **k: {
"task_category": "reasoning_math",
"task_tier": 3,
"required_context_tokens": 1234,
"confidence": 0.8,
},
)
got = dispatcher._classify_cascade("sess-5", "sys", "user")
assert got is not None
assert got.source == "classifier_cloud"
assert got.task_category == "reasoning_math"
assert got.required_context_tokens == 1234
def test_provider_refusal_skips_the_cloud_step(db, monkeypatch):
"""Out of credit -> a cloud classification is a guaranteed wasted request."""
monkeypatch.setattr(
dispatcher.cfg.classifier,
"cloud_fallback",
SimpleNamespace(
base_url="https://cloud.example/v1",
model="cloud-classifier",
timeout_seconds=2,
api_key_env=None,
max_output_tokens=1024,
),
)
called = []
monkeypatch.setattr(
dispatcher,
"_cloud_classifier_client",
lambda cf: called.append(1) or object(),
)
dispatcher._record_provider_refusal("neuralwatt")
assert dispatcher._classify_cascade("sess-6", "sys", "user") is None
assert called == [], "cloud client built despite a provider-level refusal"
def test_cloud_failure_degrades_to_the_static_guess(db, monkeypatch):
"""An unreachable cloud classifier must not raise — it returns None."""
monkeypatch.setattr(
dispatcher.cfg.classifier,
"cloud_fallback",
SimpleNamespace(
base_url="https://cloud.example/v1",
model="cloud-classifier",
timeout_seconds=2,
api_key_env=None,
max_output_tokens=1024,
),
)
monkeypatch.setattr(dispatcher, "_cloud_classifier_client", lambda cf: object())
monkeypatch.setattr(session_cache, "classify_one", lambda *a, **k: None)
assert dispatcher._classify_cascade("sess-7", "sys", "user") is None
def test_missing_api_key_degrades_rather_than_raising():
"""Unlike the primary classifier, a keyless cloud fallback must not 503.
The primary raising is right — a misconfigured primary should fail loudly.
This one is reached only when things are already broken, so failing the
request would make the outage worse than the guess it replaces.
"""
cf = SimpleNamespace(
base_url="https://cloud.example/v1",
model="cloud-classifier",
timeout_seconds=2,
api_key_env="DEFINITELY_NOT_SET_ANYWHERE",
max_output_tokens=1024,
)
assert dispatcher._cloud_classifier_client(cf) is None
def test_cascade_with_no_session_key_falls_straight_through(db):
"""No session -> steps 1 and 2 are impossible, and that is not an error."""
assert dispatcher._classify_cascade(None, "sys", "user") is None
# ---------------------------------------------------------------------------
# The degradation warning: a survivable failure is the kind that goes unnoticed.
# ---------------------------------------------------------------------------
def _seed_sources(conn, pairs, at=None):
ts = at.isoformat() if at is not None else _now().isoformat()
for source, count in pairs:
for _ in range(count):
conn.execute(
"""
INSERT INTO route_decisions
(kind, task_category, task_tier, required_context_tokens,
selected_model, selected_provider, classification_source,
observed_at)
VALUES ('chat','general_chat',2,0,'m','neuralwatt',?,?)
""",
(source, ts),
)
conn.commit()
def test_degradation_warning_fires_above_threshold(db):
from metrics import classifier_degradation_warning
cfg = SimpleNamespace(
classifier=SimpleNamespace(degraded_warn_min=20, degraded_warn_threshold=0.5)
)
_seed_sources(db, [("fallback", 15), ("classifier", 10)])
out = classifier_degradation_warning(db, cfg)
assert len(out) == 1
assert "60%" in out[0]
assert "degraded source" in out[0]
def test_degradation_warning_silent_on_thin_traffic(db):
"""A share over three requests is noise; a flapping warning goes unread."""
from metrics import classifier_degradation_warning
cfg = SimpleNamespace(
classifier=SimpleNamespace(degraded_warn_min=20, degraded_warn_threshold=0.5)
)
_seed_sources(db, [("fallback", 3)])
assert classifier_degradation_warning(db, cfg) == []
def test_degradation_warning_silent_when_healthy(db):
from metrics import classifier_degradation_warning
cfg = SimpleNamespace(
classifier=SimpleNamespace(degraded_warn_min=20, degraded_warn_threshold=0.5)
)
_seed_sources(db, [("classifier", 24), ("fallback", 1)])
assert classifier_degradation_warning(db, cfg) == []
def _seed_declined(conn, source, reason, count, at=None):
ts = at.isoformat() if at is not None else _now().isoformat()
for _ in range(count):
conn.execute(
"""
INSERT INTO route_decisions
(kind, task_category, task_tier, required_context_tokens,
selected_model, selected_provider, classification_source,
classifier_reject, observed_at)
VALUES ('chat','general_chat',2,0,'m','neuralwatt',?,?,?)
""",
(source, reason, ts),
)
conn.commit()
def test_degradation_warning_says_why_the_classifier_was_not_used(db):
""""The local classifier has been failing" was wrong for local_decision, where
nothing fails and the answer is declined for being unsure."""
from metrics import classifier_degradation_warning
cfg = SimpleNamespace(
classifier=SimpleNamespace(degraded_warn_min=20, degraded_warn_threshold=0.2)
)
_seed_sources(db, [("classifier", 60)])
_seed_declined(db, "session_history", "below_confidence_min", 18)
_seed_declined(db, "fallback", "below_coverage_min", 5)
_seed_declined(db, "session_history", "below_confidence_min", 2)
(warning,) = classifier_degradation_warning(db, cfg)
assert "degraded source" in warning # the TUI's classifier-degraded matcher
assert "Declined for: 20 below_confidence_min, 5 below_coverage_min." in warning
assert "failing" not in warning
def test_degradation_warning_without_recorded_reasons_makes_no_claim_about_failure(db):
from metrics import classifier_degradation_warning
cfg = SimpleNamespace(
classifier=SimpleNamespace(degraded_warn_min=20, degraded_warn_threshold=0.5)
)
_seed_sources(db, [("fallback", 15), ("classifier", 10)])
(warning,) = classifier_degradation_warning(db, cfg)
assert "Declined for" not in warning
assert "failing" not in warning
assert "degraded source (15 of 25)" in warning
def test_degradation_warning_survives_a_database_without_the_column(db, monkeypatch):
import metrics
cfg = SimpleNamespace(
classifier=SimpleNamespace(degraded_warn_min=20, degraded_warn_threshold=0.5)
)
_seed_sources(db, [("fallback", 15), ("classifier", 10)])
monkeypatch.setattr(metrics, "_has_column", lambda *a, **k: False)
(warning,) = metrics.classifier_degradation_warning(db, cfg)
assert "60%" in warning and "Declined for" not in warning
def test_degradation_window_excludes_rows_older_than_24h_on_the_cutoff_date(db):
from metrics import classifier_degradation_warning
now = _now()
cutoff = now - timedelta(hours=24)
just_outside = datetime.combine(cutoff.date(), time(0, 0, 0), tzinfo=timezone.utc)
inside = now - timedelta(hours=1)
cfg = SimpleNamespace(
classifier=SimpleNamespace(degraded_warn_min=20, degraded_warn_threshold=0.5)
)
_seed_sources(db, [("fallback", 25)], at=just_outside)
_seed_sources(db, [("classifier", 25)], at=inside)
out = classifier_degradation_warning(db, cfg)
assert out == []
def test_degradation_window_counts_rows_inside_24h(db):
from metrics import classifier_degradation_warning
now = _now()
inside = now - timedelta(hours=1)
cfg = SimpleNamespace(
classifier=SimpleNamespace(degraded_warn_min=20, degraded_warn_threshold=0.5)
)
_seed_sources(db, [("fallback", 15), ("classifier", 10)], at=inside)
out = classifier_degradation_warning(db, cfg)
assert len(out) == 1
assert "60%" in out[0]
def test_declined_for_ignores_reasons_older_than_24h(db):
from metrics import classifier_degradation_warning
now = _now()
cutoff = now - timedelta(hours=24)
just_outside = datetime.combine(cutoff.date(), time(0, 0, 0), tzinfo=timezone.utc)
inside = now - timedelta(hours=1)
cfg = SimpleNamespace(
classifier=SimpleNamespace(degraded_warn_min=20, degraded_warn_threshold=0.2)
)
_seed_sources(db, [("classifier", 60)], at=inside)
_seed_declined(db, "session_history", "below_confidence_min", 20, at=inside)
_seed_declined(db, "fallback", "below_coverage_min", 7, at=just_outside)
(warning,) = classifier_degradation_warning(db, cfg)
assert "Declined for: 20 below_confidence_min." in warning
assert "below_coverage_min" not in warning