- Defect 1: preview() header sample count now uses sum of grouped values from fp.project()'s in-memory copy, not len(rows) from the live ro connection. (Two snapshots were diverging under concurrent writes.) - Defect 2: --dry-run --csv on empty backlog now emits the CSV header line via fp.write_csv([]) before early-returning, instead of printing zero bytes. - Defect 3: test_main_contract_dry_run_does_not_apply patches proficiency_store.add_outcome (the entry point fold_onto actually calls) with wraps so the real function executes but the call is observable. Also verifies applied_at remains NULL on the on-disk DB.
455 lines
18 KiB
Python
455 lines
18 KiB
Python
"""Tests for the feedback fold's dry run, and for the units that will run it.
|
|
|
|
The fold is irreversible: ``add_outcome`` accumulates into a running mean and
|
|
``recompute_category`` re-derives every row in the category from the new peer
|
|
rate, so there is no pre-fold value left anywhere afterwards. The whole point
|
|
of ``feedback_preview`` is to let an operator look before taking that step,
|
|
which makes ONE property load-bearing above all others -- a dry run must leave
|
|
the database it previewed byte-for-byte identical. That is the first test here
|
|
and it asserts on the file's sha256, not just on the rows, because a preview
|
|
that dirtied a page would still be a preview that wrote.
|
|
|
|
The second load-bearing property is honesty: the projection must be what the
|
|
real fold actually does, not a second implementation of the arithmetic that can
|
|
drift. ``test_the_projection_is_what_the_real_fold_does`` pins that by folding
|
|
for real onto an identical copy and comparing every score.
|
|
|
|
Everything here is offline: a temp SQLite DB seeded from ``config/schema.sql``,
|
|
no network, no provider call, no dispatcher import.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import configparser
|
|
import csv
|
|
import hashlib
|
|
import io
|
|
import sqlite3
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import feedback
|
|
import feedback_preview as fp
|
|
from config import load_config
|
|
from proficiency_store import add_outcome
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
SCHEMA_SQL = (ROOT / "config" / "schema.sql").read_text()
|
|
CFG = load_config(str(ROOT / "config" / "config.yaml"))
|
|
|
|
CATEGORY = "coding_general"
|
|
|
|
|
|
def _seed(path: Path, *, failures: int = 8, successes: int = 0) -> Path:
|
|
"""A two-model database with one model carrying an unapplied backlog.
|
|
|
|
``m1`` gets the new outcomes, so it moves directly. ``m2`` gets none and
|
|
moves anyway, by ripple, because ``recompute_category`` re-derives the whole
|
|
category against the peer rate m1's evidence just changed. Both halves of
|
|
the projection therefore have something to report.
|
|
"""
|
|
conn = sqlite3.connect(str(path))
|
|
conn.row_factory = sqlite3.Row
|
|
conn.executescript(SCHEMA_SQL)
|
|
for model_id in ("m1", "m2"):
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO models (model_id, provider, base_model_id, availability,
|
|
last_updated)
|
|
VALUES (?, 'nw', ?, 'active', '2026-09-01T00:00:00+00:00')
|
|
""",
|
|
(model_id, model_id),
|
|
)
|
|
conn.commit()
|
|
add_outcome(conn, CFG, "m1", "nw", CATEGORY, [1.0] * 10)
|
|
add_outcome(conn, CFG, "m2", "nw", CATEGORY, [1.0] * 10)
|
|
for verdict, n in (("failed", failures), ("succeeded", successes)):
|
|
for _ in range(n):
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO verifications (model_id, provider, task_category,
|
|
kind, verdict, observed_at)
|
|
VALUES ('m1', 'nw', ?, 'client_outcome', ?,
|
|
'2026-09-01T00:00:00+00:00')
|
|
""",
|
|
(CATEGORY, verdict),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return path
|
|
|
|
|
|
@pytest.fixture()
|
|
def seeded(tmp_path: Path) -> Path:
|
|
return _seed(tmp_path / "router.db")
|
|
|
|
|
|
def _digest(path: Path) -> str:
|
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
|
|
|
|
def _rows(path: Path, sql: str) -> list[tuple]:
|
|
conn = sqlite3.connect(str(path))
|
|
try:
|
|
return conn.execute(sql).fetchall()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
PROFICIENCY_DUMP = """
|
|
SELECT model_id, provider, category, leaderboard_score, self_eval_score,
|
|
self_eval_samples, outcome_score, outcome_samples, blended_score,
|
|
source, inherited_from, last_updated
|
|
FROM proficiency ORDER BY model_id, category
|
|
"""
|
|
|
|
APPLIED_DUMP = "SELECT id, applied_at FROM verifications ORDER BY id"
|
|
|
|
|
|
# --- the one that matters: a dry run writes nothing -------------------------
|
|
|
|
|
|
def test_a_dry_run_leaves_the_database_byte_identical(seeded: Path) -> None:
|
|
"""The load-bearing guarantee. Rows, applied_at stamps, and the file itself.
|
|
|
|
Checked three ways on purpose. The row dumps say what an operator would
|
|
notice; the sha256 says the preview did not so much as dirty a page, which
|
|
is the claim the docstring and the `--db` flag both make.
|
|
"""
|
|
before_prof = _rows(seeded, PROFICIENCY_DUMP)
|
|
before_applied = _rows(seeded, APPLIED_DUMP)
|
|
before_digest = _digest(seeded)
|
|
|
|
changes, suppressed, grouped = fp.project(seeded, CFG)
|
|
|
|
assert _rows(seeded, PROFICIENCY_DUMP) == before_prof
|
|
assert _rows(seeded, APPLIED_DUMP) == before_applied
|
|
assert all(applied_at is None for _, applied_at in before_applied)
|
|
assert _digest(seeded) == before_digest
|
|
|
|
# ...and it was not a no-op that trivially wrote nothing.
|
|
assert sum(len(v) for v in grouped.values()) == 8
|
|
assert [c.model_id for c in changes if c.kind == "direct"] == ["m1"]
|
|
|
|
|
|
def test_the_projection_reports_the_delta_it_would_apply(seeded: Path) -> None:
|
|
"""A known backlog produces a known, signed, non-trivial movement."""
|
|
changes, _suppressed, grouped = fp.project(seeded, CFG)
|
|
direct = {(c.model_id, c.category): c for c in changes if c.kind == "direct"}
|
|
|
|
m1 = direct[("m1", CATEGORY)]
|
|
assert (m1.successes, m1.failures) == (0, 8)
|
|
assert m1.before_score == pytest.approx(1.0)
|
|
# 10 samples at 1.0 plus 8 at 0.0 -- the evidence count is exact even where
|
|
# the empirical-Bayes score depends on the shipped prior.
|
|
assert (m1.before_samples, m1.after_samples) == (10, 18)
|
|
assert m1.delta < -0.1
|
|
assert m1.after_score == pytest.approx(m1.before_score + m1.delta)
|
|
assert grouped[("m1", "nw", CATEGORY)] and len(grouped) == 1
|
|
|
|
|
|
def test_the_projection_is_what_the_real_fold_does(tmp_path: Path) -> None:
|
|
"""Project one copy, fold another for real, and demand the same numbers.
|
|
|
|
This is what stops the preview from becoming a second implementation of the
|
|
empirical-Bayes conversion. If ``proficiency_outcome`` changes and the
|
|
preview stops tracking it, a confidently wrong forecast of an irreversible
|
|
action is the worst failure available here, so it fails loudly instead.
|
|
"""
|
|
projected_db = _seed(tmp_path / "projected.db")
|
|
applied_db = _seed(tmp_path / "applied.db")
|
|
assert _digest(projected_db) != "" and _digest(applied_db) != ""
|
|
|
|
changes, _suppressed, _grouped = fp.project(projected_db, CFG)
|
|
forecast = {(c.model_id, c.category): c.after_score for c in changes}
|
|
|
|
conn = sqlite3.connect(str(applied_db))
|
|
conn.row_factory = sqlite3.Row
|
|
try:
|
|
grouped = feedback.summarize(feedback.unapplied_failures(conn))
|
|
feedback.apply_failures(conn, CFG, grouped, dry_run=False)
|
|
actual = {
|
|
(r["model_id"], r["category"]): r["blended_score"]
|
|
for r in conn.execute(
|
|
"SELECT model_id, category, blended_score FROM proficiency"
|
|
)
|
|
}
|
|
finally:
|
|
conn.close()
|
|
|
|
assert forecast
|
|
for key, after in forecast.items():
|
|
assert actual[key] == pytest.approx(after), key
|
|
|
|
|
|
def test_the_source_is_opened_read_only(seeded: Path) -> None:
|
|
"""Even a caller that tries to write through the returned handle cannot.
|
|
|
|
The copy is writable -- the fold has to run somewhere -- but it is the copy,
|
|
and the source is closed again before anything touches it.
|
|
"""
|
|
before = _digest(seeded)
|
|
conn = fp.copy_database(seeded)
|
|
try:
|
|
assert conn.execute("SELECT COUNT(*) FROM proficiency").fetchone()[0] == 2
|
|
conn.execute("DELETE FROM proficiency")
|
|
conn.commit()
|
|
assert conn.execute("SELECT COUNT(*) FROM proficiency").fetchone()[0] == 0
|
|
finally:
|
|
conn.close()
|
|
assert _digest(seeded) == before
|
|
assert _rows(seeded, "SELECT COUNT(*) FROM proficiency")[0][0] == 2
|
|
|
|
|
|
def test_a_missing_database_fails_instead_of_being_created(tmp_path: Path) -> None:
|
|
"""``mode=ro`` refuses rather than conjuring an empty DB and reporting 'none'.
|
|
|
|
Without this, a typo in ``--db`` would produce a clean, confident, entirely
|
|
fictional 'nothing would change'.
|
|
"""
|
|
missing = tmp_path / "not-here.db"
|
|
with pytest.raises(sqlite3.OperationalError):
|
|
fp.copy_database(missing)
|
|
assert not missing.exists()
|
|
|
|
|
|
# --- direct vs ripple, and what the summary is allowed to hide --------------
|
|
|
|
|
|
def test_ripple_rows_move_without_evidence_of_their_own(seeded: Path) -> None:
|
|
"""m2 was never reported on and its score still moves. That has to be visible."""
|
|
changes, _suppressed, _grouped = fp.project(seeded, CFG)
|
|
ripple = [c for c in changes if c.kind == "ripple"]
|
|
assert [c.model_id for c in ripple] == ["m2"]
|
|
assert (ripple[0].successes, ripple[0].failures) == (0, 0)
|
|
assert ripple[0].before_samples == ripple[0].after_samples == 10
|
|
assert ripple[0].delta < 0
|
|
|
|
|
|
def test_rounding_sized_moves_are_counted_not_dropped(seeded: Path) -> None:
|
|
"""Suppression below MOVE_EPSILON is reported as a number, never silently."""
|
|
before = {
|
|
("m1", "nw", CATEGORY): {"blended_score": 0.5, "source": "s",
|
|
"outcome_score": 0.5, "outcome_samples": 1},
|
|
("m2", "nw", CATEGORY): {"blended_score": 0.5, "source": "s",
|
|
"outcome_score": 0.5, "outcome_samples": 1},
|
|
}
|
|
after = {
|
|
("m1", "nw", CATEGORY): dict(before[("m1", "nw", CATEGORY)],
|
|
blended_score=0.5 + fp.MOVE_EPSILON / 10),
|
|
("m2", "nw", CATEGORY): dict(before[("m2", "nw", CATEGORY)],
|
|
blended_score=0.5 + fp.MOVE_EPSILON * 10),
|
|
}
|
|
changes, suppressed = fp.diff(before, after, grouped={})
|
|
assert [c.model_id for c in changes] == ["m2"]
|
|
assert suppressed == 1
|
|
rendered = fp.format_projection(changes, suppressed, {}, 0)
|
|
assert "1 further row(s) moved by less than" in rendered
|
|
|
|
|
|
def test_a_row_carrying_evidence_is_shown_even_when_nothing_moves() -> None:
|
|
""""You spent 42 samples and the score did not budge" is a real answer."""
|
|
row = {"blended_score": 0.5, "source": "s", "outcome_score": 0.5,
|
|
"outcome_samples": 1}
|
|
key = ("m1", "nw", CATEGORY)
|
|
changes, suppressed = fp.diff(
|
|
{key: row}, {key: dict(row)}, grouped={key: [(1, 0.0), (2, 1.0)]}
|
|
)
|
|
assert suppressed == 0
|
|
assert len(changes) == 1 and changes[0].kind == "direct"
|
|
assert (changes[0].successes, changes[0].failures) == (1, 1)
|
|
assert changes[0].delta == pytest.approx(0.0)
|
|
|
|
|
|
def test_a_brand_new_row_renders_as_new_rather_than_a_delta() -> None:
|
|
"""There is no prior belief to revise, so a signed delta would be a lie."""
|
|
key = ("m3", "nw", CATEGORY)
|
|
after = {key: {"blended_score": 0.4, "source": "outcome_blended",
|
|
"outcome_score": 0.4, "outcome_samples": 2}}
|
|
changes, _suppressed = fp.diff({}, after, grouped={key: [(1, 0.0)]})
|
|
assert len(changes) == 1
|
|
assert changes[0].is_new and changes[0].delta is None
|
|
assert changes[0].magnitude == 0.0
|
|
assert "new" in fp.format_projection(changes, 0, {key: [(1, 0.0)]}, 1)
|
|
|
|
|
|
# --- CSV ---------------------------------------------------------------------
|
|
|
|
|
|
def test_csv_carries_every_projected_row(seeded: Path) -> None:
|
|
"""The table digests ripple per category; --csv is where per-row lives."""
|
|
changes, _suppressed, _grouped = fp.project(seeded, CFG)
|
|
out = io.StringIO()
|
|
fp.write_csv(changes, out)
|
|
parsed = list(csv.DictReader(io.StringIO(out.getvalue())))
|
|
assert list(parsed[0]) == fp.CSV_COLUMNS
|
|
assert len(parsed) == len(changes)
|
|
assert {r["kind"] for r in parsed} == {"direct", "ripple"}
|
|
for row, change in zip(parsed, changes):
|
|
assert row["model_id"] == change.model_id
|
|
assert float(row["delta"]) == pytest.approx(change.delta)
|
|
|
|
|
|
# --- the CLI -----------------------------------------------------------------
|
|
|
|
|
|
def _run_main(monkeypatch, argv: list[str], db_path: Path) -> int:
|
|
cfg = load_config(str(ROOT / "config" / "config.yaml"))
|
|
monkeypatch.setattr(cfg.database, "path", str(db_path))
|
|
monkeypatch.setattr(feedback, "load_config", lambda _path: cfg)
|
|
monkeypatch.setattr(sys, "argv", ["feedback", *argv])
|
|
return feedback.main()
|
|
|
|
|
|
def test_dry_run_against_a_copy_writes_nothing_and_says_so(
|
|
monkeypatch, capsys, seeded: Path
|
|
) -> None:
|
|
"""End to end through the CLI, the way the operator will actually run it."""
|
|
before = _digest(seeded)
|
|
assert _run_main(monkeypatch, ["--dry-run", "--db", str(seeded)], seeded) == 0
|
|
out = capsys.readouterr().out
|
|
|
|
assert _digest(seeded) == before
|
|
assert "would apply 8 sample(s)" in out
|
|
assert "nothing was written" in out
|
|
assert "IRREVERSIBLE" in out
|
|
assert "m1" in out
|
|
|
|
|
|
def test_dry_run_csv_is_only_the_csv(monkeypatch, capsys, seeded: Path) -> None:
|
|
before = _digest(seeded)
|
|
assert _run_main(
|
|
monkeypatch, ["--dry-run", "--csv", "--db", str(seeded)], seeded
|
|
) == 0
|
|
out = capsys.readouterr().out
|
|
assert _digest(seeded) == before
|
|
assert out.splitlines()[0].split(",") == fp.CSV_COLUMNS
|
|
assert "nothing was written" not in out
|
|
|
|
|
|
def test_csv_without_dry_run_is_refused(monkeypatch, seeded: Path) -> None:
|
|
"""Accepting and discarding it would drop a flag from the irreversible run."""
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
_run_main(monkeypatch, ["--csv", "--db", str(seeded)], seeded)
|
|
assert excinfo.value.code == 2
|
|
|
|
|
|
def test_an_empty_backlog_reports_nothing_to_do(
|
|
monkeypatch, capsys, tmp_path: Path
|
|
) -> None:
|
|
empty = _seed(tmp_path / "empty.db", failures=0)
|
|
before = _digest(empty)
|
|
assert _run_main(monkeypatch, ["--dry-run", "--db", str(empty)], empty) == 0
|
|
assert "nothing to fold in" in capsys.readouterr().out
|
|
assert _digest(empty) == before
|
|
|
|
|
|
def test_empty_backlog_csv_emits_header(
|
|
monkeypatch, capsys, tmp_path: Path
|
|
) -> None:
|
|
"""--dry-run --csv with no unapplied signals still writes the CSV header."""
|
|
empty = _seed(tmp_path / "empty-csv.db", failures=0)
|
|
before = _digest(empty)
|
|
assert (
|
|
_run_main(monkeypatch, ["--dry-run", "--csv", "--db", str(empty)], empty)
|
|
== 0
|
|
)
|
|
out = capsys.readouterr().out
|
|
assert out.splitlines()[0].split(",") == fp.CSV_COLUMNS
|
|
assert len(out.splitlines()) == 1
|
|
assert _digest(empty) == before
|
|
|
|
|
|
def test_the_dry_run_default_target_is_the_configured_database(
|
|
monkeypatch, capsys, seeded: Path
|
|
) -> None:
|
|
"""No --db: the preview still reads the live path read-only and writes nothing."""
|
|
before = _digest(seeded)
|
|
assert _run_main(monkeypatch, ["--dry-run"], seeded) == 0
|
|
assert "would apply 8 sample(s)" in capsys.readouterr().out
|
|
assert _digest(seeded) == before
|
|
|
|
|
|
# --- the units ship unstarted -------------------------------------------------
|
|
|
|
DEPLOY = ROOT / "deploy"
|
|
|
|
|
|
def _unit(name: str) -> configparser.RawConfigParser:
|
|
"""Parse a systemd unit.
|
|
|
|
``RawConfigParser`` because unit files are full of ``%h`` and configparser's
|
|
default interpolation would choke on it, and ``strict=False`` because
|
|
systemd permits a directive to repeat (the poller's two ``ExecStart`` lines).
|
|
"""
|
|
parser = configparser.RawConfigParser(strict=False, allow_no_value=True)
|
|
parser.optionxform = str
|
|
parser.read_string((DEPLOY / name).read_text())
|
|
return parser
|
|
|
|
|
|
def test_the_feedback_service_cannot_be_enabled_on_its_own() -> None:
|
|
"""The first fold is an operator decision, so the pair ships unstarted.
|
|
|
|
A unit with no ``[Install]`` has no install target to enable, which is what
|
|
makes "not enabled by default" structural rather than a README promise. The
|
|
timer keeps its ``[Install]`` -- enabling the timer is exactly the deliberate
|
|
act this arrangement is asking for.
|
|
"""
|
|
service = _unit("llm-router-feedback.service")
|
|
assert not service.has_section("Install")
|
|
assert service["Service"]["Type"] == "oneshot"
|
|
assert service["Service"]["ExecStart"].endswith("-m feedback")
|
|
assert "--dry-run" not in service["Service"]["ExecStart"]
|
|
|
|
timer = _unit("llm-router-feedback.timer")
|
|
assert timer["Install"]["WantedBy"] == "timers.target"
|
|
|
|
|
|
def test_the_feedback_service_is_hardened_like_its_siblings() -> None:
|
|
"""Same sandbox as the poller. A weaker one would be a silent downgrade."""
|
|
hardening = ("NoNewPrivileges", "PrivateTmp", "ProtectSystem", "ProtectHome",
|
|
"ReadWritePaths", "WorkingDirectory")
|
|
feedback_svc = _unit("llm-router-feedback.service")["Service"]
|
|
poller_svc = _unit("llm-router-poller.service")["Service"]
|
|
for key in hardening:
|
|
assert feedback_svc[key] == poller_svc[key], key
|
|
|
|
# Environment= repeats, and configparser keeps only the last, so read the
|
|
# raw text: src/ has to be importable for `-m feedback` to resolve at all.
|
|
raw = (DEPLOY / "llm-router-feedback.service").read_text()
|
|
assert "Environment=PYTHONPATH=%h/llm-router/src" in raw
|
|
# Unbuffered where the siblings are not, on purpose: this run is the
|
|
# irreversible one and the journal is the only record of what it folded.
|
|
assert "Environment=PYTHONUNBUFFERED=1" in raw
|
|
|
|
|
|
def test_the_feedback_service_needs_no_provider_key() -> None:
|
|
"""It reads `verifications` and writes `proficiency`; both are local.
|
|
|
|
An ``EnvironmentFile`` would make the fold fail on a missing ``.env`` for a
|
|
key it never uses, and pull a unit that makes no network call into the
|
|
blast radius of the provider credential.
|
|
"""
|
|
service = _unit("llm-router-feedback.service")
|
|
assert "EnvironmentFile" not in service["Service"]
|
|
assert not service.has_option("Unit", "After")
|
|
assert not service.has_option("Unit", "Wants")
|
|
|
|
|
|
def test_the_feedback_timer_does_not_carry_an_inert_persistent() -> None:
|
|
"""systemd.timer(5): Persistent= only has an effect with OnCalendar=.
|
|
|
|
This timer is monotonic, so the line would read as catch-up insurance while
|
|
buying nothing; ``OnBootSec`` is the real catch-up. Scoped to this unit --
|
|
the poller and seed timers carry the same inert line and are not in this
|
|
change's remit.
|
|
"""
|
|
timer = _unit("llm-router-feedback.timer")["Timer"]
|
|
assert "OnCalendar" not in timer
|
|
assert "Persistent" not in timer
|
|
assert timer["OnUnitActiveSec"] == "12h"
|
|
assert timer["OnBootSec"] == "30min"
|