- 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.
480 lines
16 KiB
Python
480 lines
16 KiB
Python
"""Tests for feedback.py — folding observed outcomes back into proficiency.
|
|
|
|
Structural and local_llm verdicts no longer feed proficiency (they bias
|
|
outcome_score downward by checker frequency). Client outcomes (kind='client_outcome')
|
|
are the only two-way evidence: 'succeeded' as positive, 'failed' as negative,
|
|
both accumulating through add_outcome.
|
|
"""
|
|
|
|
import sqlite3
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
import feedback
|
|
from config import load_config
|
|
from feedback import (
|
|
FAILURE_VERDICTS,
|
|
apply_failures,
|
|
coverage,
|
|
summarize,
|
|
unapplied_failures,
|
|
)
|
|
import proficiency_store
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
SCHEMA_SQL = (ROOT / "config" / "schema.sql").read_text()
|
|
CFG = load_config(ROOT / "config" / "config.yaml")
|
|
|
|
|
|
@pytest.fixture
|
|
def db(tmp_path):
|
|
conn = sqlite3.connect(tmp_path / "t.db")
|
|
conn.row_factory = sqlite3.Row
|
|
conn.executescript(SCHEMA_SQL)
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO models (model_id, provider, base_model_id, availability, last_updated)
|
|
VALUES ('m', 'nw', 'm', 'active', '2026-08-17T00:00:00+00:00')
|
|
"""
|
|
)
|
|
conn.commit()
|
|
yield conn
|
|
conn.close()
|
|
|
|
|
|
def _verify(conn, verdict, category="coding_general", kind="structural"):
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO verifications (model_id, provider, task_category, kind, verdict, observed_at)
|
|
VALUES ('m', 'nw', ?, ?, ?, '2026-08-17T00:00:00+00:00')
|
|
""",
|
|
(category, kind, verdict),
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
def _prof(conn):
|
|
return conn.execute(
|
|
"SELECT outcome_score s, outcome_samples n FROM proficiency WHERE model_id='m'"
|
|
).fetchone()
|
|
|
|
|
|
# --- only failures are folded in ------------------------------------------
|
|
|
|
def test_passes_are_not_recorded_as_samples(db):
|
|
# A structural 'ok' means "it parsed", not "it was correct". Recording it
|
|
# as a 1.0 would inflate every score toward the ceiling.
|
|
for _ in range(5):
|
|
_verify(db, "ok")
|
|
assert unapplied_failures(db) == []
|
|
|
|
|
|
def test_unverifiable_is_not_evidence(db):
|
|
# The checker had nothing to say; that says nothing about the model
|
|
for _ in range(5):
|
|
_verify(db, "unverifiable")
|
|
assert unapplied_failures(db) == []
|
|
|
|
|
|
@pytest.mark.parametrize("verdict", FAILURE_VERDICTS)
|
|
def test_failures_are_collected(db, verdict):
|
|
_outcome(db, verdict)
|
|
assert len(unapplied_failures(db)) == 1
|
|
|
|
|
|
def test_a_failure_drags_the_score_down_proportionally(db):
|
|
from proficiency_store import add_outcome
|
|
|
|
# Given: model has 3 success samples at 1.0
|
|
add_outcome(db, CFG, "m", "nw", "coding_general", [1.0] * 3)
|
|
_outcome(db, "failed")
|
|
|
|
apply_failures(db, CFG, summarize(unapplied_failures(db)), dry_run=False)
|
|
row = _prof(db)
|
|
# One 0.0 folded into the running mean: (1.0*3 + 0.0)/4
|
|
assert row["n"] == 4
|
|
assert row["s"] == pytest.approx(0.75)
|
|
|
|
|
|
def test_a_model_that_never_fails_keeps_its_score(db):
|
|
from proficiency_store import add_outcome
|
|
|
|
add_outcome(db, CFG, "m", "nw", "coding_general", [1.0] * 3)
|
|
for _ in range(20):
|
|
_verify(db, "ok")
|
|
apply_failures(db, CFG, summarize(unapplied_failures(db)), dry_run=False)
|
|
row = _prof(db)
|
|
assert (row["n"], row["s"]) == (3, pytest.approx(1.0))
|
|
|
|
|
|
# --- idempotence ----------------------------------------------------------
|
|
|
|
def test_a_failure_is_applied_only_once(db):
|
|
_outcome(db, "failed")
|
|
apply_failures(db, CFG, summarize(unapplied_failures(db)), dry_run=False)
|
|
first_n = _prof(db)["n"]
|
|
# Re-running must not penalize the model again for the same bad response
|
|
assert unapplied_failures(db) == []
|
|
apply_failures(db, CFG, summarize(unapplied_failures(db)), dry_run=False)
|
|
assert _prof(db)["n"] == first_n
|
|
|
|
|
|
def test_dry_run_changes_nothing(db):
|
|
_outcome(db, "failed")
|
|
apply_failures(db, CFG, summarize(unapplied_failures(db)), dry_run=True)
|
|
assert _prof(db) is None
|
|
assert len(unapplied_failures(db)) == 1
|
|
|
|
|
|
# --- grouping -------------------------------------------------------------
|
|
|
|
def test_failures_group_by_model_and_category(db):
|
|
_outcome(db, "failed", category="coding_general")
|
|
_outcome(db, "failed", category="coding_general")
|
|
_outcome(db, "failed", category="debugging")
|
|
grouped = summarize(unapplied_failures(db))
|
|
assert {k[2]: len(v) for k, v in grouped.items()} == {
|
|
"coding_general": 2,
|
|
"debugging": 1,
|
|
}
|
|
|
|
|
|
def test_failures_without_a_category_are_skipped(db):
|
|
# Nothing to attribute them to — proficiency is per-category
|
|
db.execute(
|
|
"""
|
|
INSERT INTO verifications (model_id, provider, task_category, kind, verdict, observed_at)
|
|
VALUES ('m', 'nw', NULL, 'structural', 'failed', '2026-08-17T00:00:00+00:00')
|
|
"""
|
|
)
|
|
db.commit()
|
|
assert unapplied_failures(db) == []
|
|
|
|
|
|
def test_both_check_kinds_count(db):
|
|
_outcome(db, "failed", kind="structural")
|
|
_outcome(db, "failed", kind="local_llm")
|
|
assert len(unapplied_failures(db)) == 2
|
|
|
|
|
|
# --- attribution: not every failure is the model's fault -------------------
|
|
|
|
def _verify_capped(conn, verdict="failed", category="coding_general"):
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO verifications (model_id, provider, task_category, kind, verdict,
|
|
observed_at, model_attributable)
|
|
VALUES ('m', 'nw', ?, 'structural', ?, '2026-08-17T00:00:00+00:00', 0)
|
|
""",
|
|
(category, verdict),
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
def test_client_capped_failure_is_not_the_models_fault(db):
|
|
# Found by forcing it: a request with max_tokens=40 fails, and without
|
|
# this any agent using a tight cap would systematically drag down whatever
|
|
# model it routed to.
|
|
_verify_capped(db)
|
|
assert unapplied_failures(db) == []
|
|
|
|
|
|
def test_capped_failures_are_still_recorded_for_visibility(db):
|
|
# The response really was unusable — it just says nothing about the model
|
|
_verify_capped(db)
|
|
n = db.execute("SELECT COUNT(*) c FROM verifications WHERE verdict='failed'").fetchone()["c"]
|
|
assert n == 1
|
|
|
|
|
|
def test_attributable_and_capped_failures_are_separated(db):
|
|
_outcome(db, "failed") # model's fault
|
|
_verify_capped(db) # client's cap
|
|
rows = unapplied_failures(db)
|
|
assert len(rows) == 1
|
|
assert rows[0]["verdict"] == "failed"
|
|
|
|
|
|
# --- client outcomes: the only two-way evidence ----------------------------
|
|
|
|
def _outcome(conn, verdict, category="coding_general", kind="client_outcome"):
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO verifications (model_id, provider, task_category, kind, verdict, observed_at)
|
|
VALUES ('m', 'nw', ?, ?, ?, '2026-08-17T00:00:00+00:00')
|
|
""",
|
|
(category, kind, verdict),
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
def test_a_client_reported_failure_counts_against(db):
|
|
from proficiency_store import add_outcome
|
|
|
|
add_outcome(db, CFG, "m", "nw", "coding_general", [1.0] * 3)
|
|
_outcome(db, "failed")
|
|
apply_failures(db, CFG, summarize(unapplied_failures(db)), dry_run=False)
|
|
assert _prof(db)["s"] == pytest.approx(0.75)
|
|
|
|
|
|
def test_a_structural_pass_contributes_nothing(db):
|
|
# Structural ok only means code parsed — weak evidence against recording.
|
|
# outcome_score should not budge when only structural passes exist.
|
|
for _ in range(10):
|
|
_verify(db, "ok")
|
|
assert unapplied_failures(db) == []
|
|
|
|
|
|
def test_successes_and_failures_mix_into_one_rate(db):
|
|
_outcome(db, "succeeded")
|
|
_outcome(db, "succeeded")
|
|
_outcome(db, "succeeded")
|
|
_outcome(db, "failed")
|
|
apply_failures(db, CFG, summarize(unapplied_failures(db)), dry_run=False)
|
|
row = _prof(db)
|
|
assert (row["n"], row["s"]) == (4, pytest.approx(0.75))
|
|
|
|
|
|
# --- structural/local_llm verdicts do NOT move proficiency ------------------
|
|
|
|
@pytest.mark.parametrize("verdict", ("truncated", "malformed"))
|
|
def test_structural_verdicts_not_collected(db, verdict):
|
|
# Structural check verdicts are diagnostics only — "it was truncated" or
|
|
# "it was malformed" cannot be attributed to model quality without the
|
|
# client confirming the job failed.
|
|
_verify(db, verdict)
|
|
assert unapplied_failures(db) == []
|
|
|
|
|
|
@pytest.mark.parametrize("verdict", ("truncated", "malformed"))
|
|
def test_local_llm_verdicts_not_collected(db, verdict):
|
|
# local_llm checks use the same diagnostic-verdicts; they should not move
|
|
# outcome_score even though coverage() still reports them.
|
|
_verify(db, verdict, kind="local_llm")
|
|
assert unapplied_failures(db) == []
|
|
|
|
|
|
def test_structural_verdict_does_not_budge_score(db):
|
|
# Given: model with outcome_score from client outcomes
|
|
from proficiency_store import add_outcome
|
|
|
|
add_outcome(db, CFG, "m", "nw", "coding_general", [1.0] * 3)
|
|
# structural checks produce "truncated"/"malformed" — these should NOT be
|
|
# folded into proficiency at all.
|
|
for _ in range(10):
|
|
_verify(db, "malformed")
|
|
apply_failures(db, CFG, summarize(unapplied_failures(db)), dry_run=False)
|
|
row = _prof(db)
|
|
assert row["n"] == 3
|
|
assert row["s"] == pytest.approx(1.0)
|
|
|
|
|
|
# --- client outcomes route to add_outcome (not add_self_eval) ---------------
|
|
|
|
@pytest.mark.parametrize("verdict", ("succeeded", "failed"))
|
|
def test_client_outcomes_route_to_add_outcome(db, verdict):
|
|
"""apply_failures must call add_outcome for client outcome rows."""
|
|
with patch("feedback.add_outcome") as mock_add_outcome:
|
|
_outcome(db, verdict)
|
|
apply_failures(db, CFG, summarize(unapplied_failures(db)), dry_run=False)
|
|
mock_add_outcome.assert_called_once()
|
|
call_args = mock_add_outcome.call_args
|
|
assert call_args[0][0] is db # conn
|
|
assert call_args[0][1] is CFG # cfg
|
|
assert call_args[0][2:5] == ("m", "nw", "coding_general") # model, provider, cat
|
|
expected_score = 1.0 if verdict == "succeeded" else 0.0
|
|
assert call_args[0][5] == [expected_score] # scores
|
|
|
|
|
|
def test_client_outcome_applied_at_idempotent(db):
|
|
"""Running feedback.py twice applies the outcome only once."""
|
|
_outcome(db, "failed")
|
|
assert len(unapplied_failures(db)) == 1
|
|
row_before = _prof(db)
|
|
assert row_before is None
|
|
|
|
apply_failures(db, CFG, summarize(unapplied_failures(db)), dry_run=False)
|
|
row_after = _prof(db)
|
|
assert row_after["n"] == 1
|
|
assert row_after["s"] == pytest.approx(0.0)
|
|
|
|
# Second run: applied_at is set, unapplied_failures returns [].
|
|
assert unapplied_failures(db) == []
|
|
apply_failures(db, CFG, summarize(unapplied_failures(db)), dry_run=False)
|
|
assert _prof(db)["n"] == row_after["n"]
|
|
|
|
|
|
# --- --dry-run applies nothing ---------------------------------------------
|
|
|
|
def test_dry_run_does_not_call_add_outcome(db):
|
|
"""A dry-run invocation must not call add_outcome at all."""
|
|
with patch("feedback.add_outcome") as mock_add_outcome:
|
|
_outcome(db, "failed")
|
|
apply_failures(db, CFG, summarize(unapplied_failures(db)), dry_run=True)
|
|
mock_add_outcome.assert_not_called()
|
|
|
|
|
|
def test_dry_run_preserves_verifications_unapplied(db):
|
|
"""After dry_run, the verifications row still has applied_at IS NULL."""
|
|
_outcome(db, "failed")
|
|
apply_failures(db, CFG, summarize(unapplied_failures(db)), dry_run=True)
|
|
# The prof table was never touched and the verifications row is still
|
|
# flagged as unapplied.
|
|
assert _prof(db) is None
|
|
has_unapplied = db.execute(
|
|
"SELECT COUNT(*) n FROM verifications WHERE verdict='failed' AND applied_at IS NULL"
|
|
).fetchone()["n"]
|
|
assert has_unapplied == 1
|
|
|
|
|
|
# --- coverage() reports all verdicts including diagnostics ------------------
|
|
|
|
def test_coverage_reports_all_verdicts(db, capfd):
|
|
"""coverage() must still show structural/local_llm so diagnostics are visible."""
|
|
_verify(db, "ok")
|
|
_verify(db, "malformed")
|
|
_verify(db, "truncated", kind="local_llm")
|
|
_outcome(db, "succeeded")
|
|
_outcome(db, "failed")
|
|
coverage(db)
|
|
captured = capfd.readouterr()
|
|
assert "ok" in captured.out
|
|
assert "malformed" in captured.out
|
|
assert "truncated" in captured.out
|
|
assert "succeeded" in captured.out
|
|
assert "failed" in captured.out
|
|
assert "unverifiable" not in captured.out
|
|
|
|
|
|
# --- feedback.py CLI contract (used by /admin/api/apply-feedback) ------------
|
|
|
|
|
|
def _make_main_db(tmp_path):
|
|
"""Build a DB suitable for exercising ``feedback.main()`` offline."""
|
|
path = tmp_path / "feedback.db"
|
|
conn = sqlite3.connect(str(path))
|
|
conn.row_factory = sqlite3.Row
|
|
conn.executescript(SCHEMA_SQL)
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO models (model_id, provider, base_model_id, availability, last_updated)
|
|
VALUES ('m', 'nw', 'm', 'active', '2026-08-17T00:00:00+00:00')
|
|
"""
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return path
|
|
|
|
|
|
def test_main_contract_dry_run_does_not_apply(monkeypatch, tmp_path, capsys):
|
|
"""--dry-run prints the summary and leaves verifications unapplied."""
|
|
db_path = _make_main_db(tmp_path)
|
|
cfg = load_config(str(ROOT / "config" / "config.yaml"))
|
|
monkeypatch.setattr(cfg.database, "path", str(db_path))
|
|
monkeypatch.setattr(feedback, "load_config", lambda _path: cfg)
|
|
|
|
# Seed one unapplied client failure.
|
|
conn = sqlite3.connect(str(db_path))
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO verifications (model_id, provider, task_category, kind, verdict, observed_at)
|
|
VALUES ('m', 'nw', 'coding_general', 'client_outcome', 'failed', '2026-08-17T00:00:00+00:00')
|
|
"""
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
with patch(
|
|
"proficiency_store.add_outcome",
|
|
wraps=proficiency_store.add_outcome,
|
|
) as mock_add_outcome:
|
|
monkeypatch.setattr(sys, "argv", ["feedback", "--dry-run"])
|
|
assert feedback.main() == 0
|
|
# The dry-run path exercises add_outcome through fold_onto on an
|
|
# in-memory copy — the mock confirms the real path is hit.
|
|
mock_add_outcome.assert_called()
|
|
|
|
# Verify the on-disk database was not modified.
|
|
conn = sqlite3.connect(str(db_path))
|
|
conn.row_factory = sqlite3.Row
|
|
row = conn.execute(
|
|
"SELECT applied_at FROM verifications WHERE verdict = 'failed'"
|
|
).fetchone()
|
|
assert row is not None
|
|
assert row["applied_at"] is None
|
|
conn.close()
|
|
|
|
captured = capsys.readouterr().out
|
|
assert "coding_general" in captured
|
|
assert "would apply" in captured
|
|
|
|
|
|
def test_main_contract_applies_client_outcomes_via_add_outcome(monkeypatch, tmp_path, capsys):
|
|
"""The CLI route applies client outcomes through add_outcome."""
|
|
db_path = _make_main_db(tmp_path)
|
|
cfg = load_config(str(ROOT / "config" / "config.yaml"))
|
|
monkeypatch.setattr(cfg.database, "path", str(db_path))
|
|
monkeypatch.setattr(feedback, "load_config", lambda _path: cfg)
|
|
|
|
# Seed one unapplied client success.
|
|
conn = sqlite3.connect(str(db_path))
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO verifications (model_id, provider, task_category, kind, verdict, observed_at)
|
|
VALUES ('m', 'nw', 'coding_general', 'client_outcome', 'succeeded', '2026-08-17T00:00:00+00:00')
|
|
"""
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
monkeypatch.setattr(sys, "argv", ["feedback"])
|
|
assert feedback.main() == 0
|
|
|
|
captured = capsys.readouterr().out
|
|
assert "applying" in captured
|
|
assert "applied" in captured.split("applying")[-1]
|
|
|
|
conn = sqlite3.connect(str(db_path))
|
|
conn.row_factory = sqlite3.Row
|
|
row = conn.execute(
|
|
"SELECT outcome_score s, outcome_samples n FROM proficiency WHERE model_id='m'"
|
|
).fetchone()
|
|
conn.close()
|
|
assert row is not None
|
|
assert row["n"] == 1
|
|
assert row["s"] == pytest.approx(1.0)
|
|
|
|
|
|
def test_main_contract_idempotent(monkeypatch, tmp_path):
|
|
"""A second non-dry run sees no unapplied signals and is a no-op."""
|
|
db_path = _make_main_db(tmp_path)
|
|
cfg = load_config(str(ROOT / "config" / "config.yaml"))
|
|
monkeypatch.setattr(cfg.database, "path", str(db_path))
|
|
monkeypatch.setattr(feedback, "load_config", lambda _path: cfg)
|
|
|
|
conn = sqlite3.connect(str(db_path))
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO verifications (model_id, provider, task_category, kind, verdict, observed_at)
|
|
VALUES ('m', 'nw', 'coding_general', 'client_outcome', 'failed', '2026-08-17T00:00:00+00:00')
|
|
"""
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
monkeypatch.setattr(sys, "argv", ["feedback"])
|
|
assert feedback.main() == 0
|
|
conn = sqlite3.connect(str(db_path))
|
|
conn.row_factory = sqlite3.Row
|
|
applied = conn.execute(
|
|
"SELECT COUNT(*) n FROM verifications WHERE applied_at IS NOT NULL"
|
|
).fetchone()["n"]
|
|
conn.close()
|
|
assert applied == 1
|
|
|
|
# Second run: nothing left to fold, exits cleanly without touching add_outcome.
|
|
monkeypatch.setattr(sys, "argv", ["feedback"])
|
|
assert feedback.main() == 0
|