Files
6krrt/feedback.py
adlee-was-taken e28443cb36 feat: POST /outcome — the only ground truth the router can get
Everything else this router records is a proxy. Structural checks know whether
code parses. The local checker guesses whether prose looks right. Neither knows
whether the answer did the job. The client does: it ran the tests, or used the
answer, or watched it fail.

Clients report against the provider's completion id, which they already
receive in the response body and on every stream chunk. energy_observations
and verifications now store that id so a report has something to join on.

Two properties make this the highest-value signal available.

It is the only quality signal that survives streaming. A retry cannot reach a
streamed response -- the bytes are already gone -- but a report arrives
afterwards and works identically either way. Every agent client streams, so
without this the main workflow had verification and feedback but no route from
outcome back into routing.

And its successes count. feedback.py folds client outcomes in BOTH directions,
unlike checks where only failures do. That asymmetry is deliberate: a parser
reporting 'ok' means the code parsed, which is weak evidence that would
inflate every score toward the ceiling, while a client reporting 'succeeded'
means the work worked.

An unknown request_id returns 404 rather than being quietly accepted. A client
whose reports go nowhere should find out rather than train nothing.

Verified end to end on both paths, including a streamed completion reported as
failed after the fact.

Tests 227 -> 232.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018xTPER7K8fNyKiuqNvTCTa
2026-08-17 22:32:04 -04:00

173 lines
6.3 KiB
Python

#!/usr/bin/env python3
"""Fold observed verification failures back into proficiency.
The eval harness measures models on a fixed 23-task benchmark. This measures
them on YOUR traffic, which is more predictive of routing quality and
accumulates for free as you work.
python feedback.py --dry-run # show what would change
python feedback.py # apply
Two sources, treated differently, because they are not the same kind of
evidence.
**Checks (`structural`, `local_llm`) contribute failures only.** A pass there
is weak: structural 'ok' means the code parsed, not that it was correct, and a
model emitting syntactically valid nonsense would score 1.0. Recording those
passes would flood `self_eval_score` with 1.0 samples and wash out the
benchmark's discrimination — the coding categories already sit at 1.00 for
every model, and this would spread that flatness everywhere. A failure is the
opposite: truncated or malformed output is definitive.
**Client outcomes contribute BOTH ways.** A client reporting success is not
the same claim as a parser reporting success — it ran the tests, or used the
answer, and knows the work worked. That is the only ground truth available
here, so it counts in both directions. It is also the only signal that
survives streaming, where a retry cannot reach.
Each failure is applied once. `applied_at` marks consumed rows so re-running
cannot penalize a model repeatedly for the same bad response.
"""
from __future__ import annotations
import argparse
import sqlite3
import sys
from collections import defaultdict
from config import load_config
from proficiency_store import add_self_eval
# Verdicts that count as observed failures. 'unverifiable' is excluded: it
# means the checker had nothing to say, which is not evidence about the model.
FAILURE_VERDICTS = ("truncated", "malformed", "failed")
# The one verdict that counts as a positive sample. A parser's 'ok' does not
# qualify — it means the code parsed. This means the client ran it.
SUCCESS_VERDICTS = ("succeeded",)
# Failures the model did not cause are excluded. The obvious case: a client
# that sets max_tokens=40 and gets a truncated answer caused that itself, and
# counting it would let any agent with a tight cap drag down whatever model it
# happened to route to. Found by forcing exactly that during testing.
def unapplied_failures(conn: sqlite3.Connection) -> list[sqlite3.Row]:
"""Rows that should move a model's score, with the sample each contributes.
Named for what it mostly is. Successes only enter via client outcomes; a
check passing is not evidence the answer was right.
"""
conn.row_factory = sqlite3.Row
scored = FAILURE_VERDICTS + SUCCESS_VERDICTS
placeholders = ",".join("?" * len(scored))
return conn.execute(
f"""
SELECT id, model_id, provider, task_category, kind, verdict, detail
FROM verifications
WHERE verdict IN ({placeholders})
AND applied_at IS NULL
AND task_category IS NOT NULL
AND model_attributable = 1
ORDER BY id
""",
scored,
).fetchall()
def summarize(
rows: list[sqlite3.Row],
) -> dict[tuple[str, str, str], list[tuple[int, float]]]:
"""Group by (model, provider, category) as (row id, sample score) pairs."""
grouped: dict[tuple[str, str, str], list[tuple[int, float]]] = defaultdict(list)
for r in rows:
score = 1.0 if r["verdict"] in SUCCESS_VERDICTS else 0.0
grouped[(r["model_id"], r["provider"], r["task_category"])].append(
(r["id"], score)
)
return grouped
def apply_failures(conn: sqlite3.Connection, cfg, grouped, dry_run: bool) -> int:
applied = 0
for (model_id, provider, category), pairs in sorted(grouped.items()):
ids = [i for i, _ in pairs]
scores = [sc for _, sc in pairs]
wins = sum(1 for sc in scores if sc == 1.0)
print(
f" {model_id:24s} {category:18s} "
f"{len(scores) - wins} failure(s), {wins} success(es)"
)
if dry_run:
continue
# Folded into the running mean by proficiency.accumulate, so the effect
# scales with the observed rate rather than replacing the benchmark.
add_self_eval(conn, cfg, model_id, provider, category, scores)
conn.executemany(
"UPDATE verifications SET applied_at = datetime('now') WHERE id = ?",
[(i,) for i in ids],
)
applied += len(ids)
if not dry_run:
conn.commit()
return applied
def coverage(conn: sqlite3.Connection) -> None:
"""Report what verification has seen, so its usefulness stays visible."""
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT kind, verdict, COUNT(*) n FROM verifications
GROUP BY kind, verdict ORDER BY kind, verdict
"""
).fetchall()
if not rows:
print(" no verifications recorded yet")
return
total = sum(r["n"] for r in rows)
print(f" {'kind':16s}{'verdict':16s}{'n':>6}{'share':>9}")
for r in rows:
print(f" {r['kind']:16s}{r['verdict']:16s}{r['n']:>6}{r['n']/total*100:>8.1f}%")
unver = sum(r["n"] for r in rows if r["verdict"] == "unverifiable")
if total and unver / total > 0.8:
print(
f"\n NOTE: {unver/total*100:.0f}% of responses were unverifiable. "
"Structural checking is not earning much here —\n"
" most traffic is prose. The local LLM check covers that, but only "
"above the size threshold."
)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
cfg = load_config("config.yaml")
conn = sqlite3.connect(cfg.database.path)
print("verification coverage so far:")
coverage(conn)
print()
rows = unapplied_failures(conn)
if not rows:
print("no unapplied signals — nothing to fold in")
conn.close()
return 0
grouped = summarize(rows)
print(f"{'would apply' if args.dry_run else 'applying'} "
f"{len(rows)} sample(s) across {len(grouped)} (model, category) pair(s):")
applied = apply_failures(conn, cfg, grouped, args.dry_run)
conn.close()
if not args.dry_run:
print(f"\napplied {applied} sample(s)")
return 0
if __name__ == "__main__":
raise SystemExit(main())