A suite is a set of cases in YAML or JSON lines, each scored by exact, contains, regex, a number within a tolerance, a JSON Schema subset, or a judge model with a rubric; weighed into one score against a threshold. internal/bench/eval runs one on the control plane through Router.Replay, as the gateway's own requests, and keeps every run and its cases in the store (migration 0028: eval_runs, eval_cases). - llmgw ctl eval MODEL --suite FILE|NAME, --history, --run, --suites; exits non-zero below the score. Suites by name from control.eval_dir, or the two built in from deploy/eval (arithmetic, json-format). - model update and promote take --eval SUITE --min-score N, or a model's standing gate (ctl model gate, eval: in the catalog): a promote asks the canary first and leaves it below the score; an update tries the new weights as a canary given no share, then promotes or removes it. - The console's model menu: ask a suite with its progress, its runs, its gate. - The audit middleware no longer hands a handler a body cut off at 64 KiB. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
137 lines
4.9 KiB
Go
137 lines
4.9 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"time"
|
|
)
|
|
|
|
// EvalRun is one quality suite run against a model, as `llmgw ctl eval`
|
|
// and the gate on an update keep it.
|
|
type EvalRun struct {
|
|
ID string `json:"id"`
|
|
// Model is the model the run is about; Target the catalog model that
|
|
// answered, when that was another — its canary, or the new weights an
|
|
// update tried before moving to them.
|
|
Model string `json:"model"`
|
|
Target string `json:"target,omitempty"`
|
|
// Repo and Revision are where the weights that answered came from,
|
|
// when that is known.
|
|
Repo string `json:"repo,omitempty"`
|
|
Revision string `json:"revision,omitempty"`
|
|
// Suite is its name, SuiteHash the sha256 of its text.
|
|
Suite string `json:"suite"`
|
|
SuiteHash string `json:"suite_hash"`
|
|
At time.Time `json:"at"`
|
|
TookMs int64 `json:"took_ms"`
|
|
// Score is the cases' scores weighed, 0 to 1; Passed whether it
|
|
// reached Threshold.
|
|
Score float64 `json:"score"`
|
|
Threshold float64 `json:"threshold"`
|
|
Passed bool `json:"passed"`
|
|
// Cases were asked; Below scored under half; Errors got no answer.
|
|
Cases int `json:"cases"`
|
|
Below int `json:"below"`
|
|
Errors int `json:"errors"`
|
|
Judge string `json:"judge,omitempty"`
|
|
By string `json:"by,omitempty"`
|
|
// Job is the update or promotion the run gated, if one did.
|
|
Job string `json:"job,omitempty"`
|
|
// Error is why the run could not be scored at all.
|
|
Error string `json:"error,omitempty"`
|
|
Results []EvalCase `json:"results,omitempty"`
|
|
}
|
|
|
|
// EvalCase is one case of a run: how its answer scored, and why.
|
|
type EvalCase struct {
|
|
ID string `json:"id"`
|
|
Kind string `json:"kind"`
|
|
Weight float64 `json:"weight"`
|
|
Score float64 `json:"score"`
|
|
Passed bool `json:"passed"`
|
|
Answer string `json:"answer,omitempty"`
|
|
Detail string `json:"detail,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
TookMs int64 `json:"took_ms"`
|
|
}
|
|
|
|
// PutEvalRun keeps a finished run and its cases.
|
|
func (s *DB) PutEvalRun(ctx context.Context, r EvalRun) error {
|
|
return s.inTx(ctx, func(q conn) error {
|
|
if _, err := q.ExecContext(ctx, `INSERT INTO eval_runs (id, model, target, repo, revision, suite,
|
|
suite_hash, started_at, took_ms, score, threshold, passed, cases, below, errors, judge, actor, job, error)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
r.ID, r.Model, r.Target, r.Repo, r.Revision, r.Suite, r.SuiteHash, millis(r.At), r.TookMs,
|
|
r.Score, r.Threshold, boolInt(r.Passed), r.Cases, r.Below, r.Errors, r.Judge, r.By, r.Job, r.Error); err != nil {
|
|
return err
|
|
}
|
|
for i, c := range r.Results {
|
|
if _, err := q.ExecContext(ctx, `INSERT INTO eval_cases (run_id, idx, case_id, kind, weight, score,
|
|
passed, answer, detail, error, took_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
r.ID, i, c.ID, c.Kind, c.Weight, c.Score, boolInt(c.Passed), c.Answer, c.Detail, c.Error,
|
|
c.TookMs); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func boolInt(b bool) int {
|
|
if b {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
const evalRunColumns = `id, model, target, repo, revision, suite, suite_hash, started_at, took_ms, score,
|
|
threshold, passed, cases, below, errors, judge, actor, job, error`
|
|
|
|
func scanEvalRun(row interface{ Scan(...any) error }) (EvalRun, error) {
|
|
var r EvalRun
|
|
var at int64
|
|
var passed int
|
|
err := row.Scan(&r.ID, &r.Model, &r.Target, &r.Repo, &r.Revision, &r.Suite, &r.SuiteHash, &at, &r.TookMs,
|
|
&r.Score, &r.Threshold, &passed, &r.Cases, &r.Below, &r.Errors, &r.Judge, &r.By, &r.Job, &r.Error)
|
|
r.At, r.Passed = fromMillis(at), passed == 1
|
|
return r, err
|
|
}
|
|
|
|
// EvalRuns are a model's runs, newest first and without their cases; every
|
|
// model's for an empty name.
|
|
func (s *DB) EvalRuns(ctx context.Context, model string, limit int) ([]EvalRun, error) {
|
|
if limit <= 0 || limit > 1000 {
|
|
limit = 20
|
|
}
|
|
q := `SELECT ` + evalRunColumns + ` FROM eval_runs`
|
|
var args []any
|
|
if model != "" {
|
|
q += ` WHERE model = ? OR target = ?`
|
|
args = append(args, model, model)
|
|
}
|
|
q += ` ORDER BY started_at DESC, id DESC LIMIT ?`
|
|
args = append(args, limit)
|
|
return queryAll(ctx, s.db, q, args, func(rows *sql.Rows) (EvalRun, error) { return scanEvalRun(rows) })
|
|
}
|
|
|
|
// EvalRunByID is one run with its cases; ErrNotFound when there is none.
|
|
func (s *DB) EvalRunByID(ctx context.Context, id string) (EvalRun, error) {
|
|
r, err := scanEvalRun(s.db.QueryRowContext(ctx, `SELECT `+evalRunColumns+` FROM eval_runs WHERE id = ?`, id))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return EvalRun{}, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return EvalRun{}, err
|
|
}
|
|
r.Results, err = queryAll(ctx, s.db, `SELECT case_id, kind, weight, score, passed, answer, detail, error, took_ms
|
|
FROM eval_cases WHERE run_id = ? ORDER BY idx`, []any{id}, func(rows *sql.Rows) (EvalCase, error) {
|
|
var c EvalCase
|
|
var passed int
|
|
err := rows.Scan(&c.ID, &c.Kind, &c.Weight, &c.Score, &passed, &c.Answer, &c.Detail, &c.Error, &c.TookMs)
|
|
c.Passed = passed == 1
|
|
return c, err
|
|
})
|
|
return r, err
|
|
}
|