Files
llmgw/internal/bench/eval/run_test.go
T
Ilker ManapandClaude Opus 5.5 f0477f140d Quality suites, and new weights held to one: ctl eval, model update/promote --eval, model gate
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>
2026-10-05 00:04:43 +02:00

293 lines
9.5 KiB
Go

package eval
import (
"context"
"encoding/json"
"errors"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"testing/fstest"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
const arith = `
name: arith
threshold: 0.75
judge: grader
cases:
- id: add
prompt: "What is 17 + 25? Answer with the number only."
number: 42
- id: mul
prompt: "What is 6 * 7?"
number: 42
weight: 2
- id: capital
prompt: "What is the capital of France?"
contains: Paris
- id: polite
prompt: "Say hello."
judge: "A greeting of any kind passes."
`
// fakeModel answers by the prompt, and as a judge by what it was asked to
// grade; it counts what it was asked.
type fakeModel struct {
mu sync.Mutex
answers map[string]string
asked []string
inFlight, most atomic.Int32
}
func (f *fakeModel) call(_ context.Context, path, model string, body []byte) (int, []byte, time.Duration, error) {
n := f.inFlight.Add(1)
defer f.inFlight.Add(-1)
for {
m := f.most.Load()
if n <= m || f.most.CompareAndSwap(m, n) {
break
}
}
time.Sleep(5 * time.Millisecond)
var req struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
}
if err := json.Unmarshal(body, &req); err != nil || path != "/v1/chat/completions" || req.Model != model {
return 400, []byte(`{"error":{"message":"bad request"}}`), 0, nil
}
f.mu.Lock()
f.asked = append(f.asked, model)
f.mu.Unlock()
last := req.Messages[len(req.Messages)-1].Content
answer := "I do not know"
switch {
case model == "grader":
answer = `{"score": 0, "reason": "no greeting"}`
if strings.Contains(last, "Answer:\nHello") {
answer = `{"score": 1, "reason": "a greeting"}`
}
case model == "missing":
return 404, []byte(`{"error":{"message":"the model \"missing\" does not exist"}}`), 0, nil
default:
for k, v := range f.answers {
if strings.Contains(last, k) {
answer = v
}
}
}
out, _ := json.Marshal(map[string]any{"choices": []any{map[string]any{
"message": map[string]any{"content": answer}, "finish_reason": "stop"}}})
return 200, out, 0, nil
}
func TestASuiteIsAskedAndScoredByWeight(t *testing.T) {
s, err := Parse("arith.yaml", []byte(arith))
if err != nil {
t.Fatal(err)
}
f := &fakeModel{answers: map[string]string{"17 + 25": "<think>carry</think>42", "6 * 7": "41",
"France": "Paris.", "hello": "Hello there!"}}
var progress atomic.Int32
run, err := Evaluate(context.Background(), f.call, s, Options{Model: "qwen", Concurrency: 2,
Progress: func(done, total int) { progress.Store(int32(done)) }})
if err != nil {
t.Fatal(err)
}
// add 1, mul 0 at weight 2, capital 1, polite 1: 3 of 5.
if run.Score != 0.6 || run.Passed || run.Threshold != 0.75 || run.Below != 1 || run.Cases != 4 ||
run.Suite != "arith" || run.Judge != "grader" || len(run.SuiteHash) != 64 {
t.Fatalf("%+v", run)
}
byID := map[string]store.EvalCase{}
for _, r := range run.Results {
byID[r.ID] = r
}
if r := byID["add"]; !r.Passed || r.Answer != "42" || r.Kind != "number" {
t.Errorf("add: %+v", r)
}
if r := byID["mul"]; r.Passed || !strings.Contains(r.Detail, "read 41, wanted 42") || r.Weight != 2 {
t.Errorf("mul: %+v", r)
}
if r := byID["polite"]; !r.Passed || !strings.Contains(r.Detail, "the judge: a greeting") {
t.Errorf("polite: %+v", r)
}
if progress.Load() != 4 || f.most.Load() > 2 {
t.Errorf("progress %d, %d at once with a concurrency of 2", progress.Load(), f.most.Load())
}
// A lower bar passes the same answers.
run, _ = Evaluate(context.Background(), f.call, s, Options{Model: "qwen", MinScore: 0.5})
if !run.Passed || run.Threshold != 0.5 {
t.Errorf("at 0.5: %+v", run)
}
}
func TestAModelThatAnswersNothingFailsWithWhy(t *testing.T) {
s, _ := Parse("arith.yaml", []byte(arith))
run, err := Evaluate(context.Background(), (&fakeModel{}).call, s, Options{Model: "missing"})
if err != nil {
t.Fatal(err)
}
if run.Passed || run.Errors != 4 || !strings.Contains(run.Error, `"missing" does not exist`) {
t.Fatalf("%+v", run)
}
}
func TestARubricWithNoJudgeIsRefusedBeforeAnythingIsAsked(t *testing.T) {
s, _ := Parse("arith.yaml", []byte(strings.Replace(arith, "judge: grader\n", "", 1)))
f := &fakeModel{}
if _, err := Evaluate(context.Background(), f.call, s, Options{Model: "qwen"}); err == nil ||
!strings.Contains(err.Error(), "--judge") {
t.Fatalf("%v", err)
}
if len(f.asked) != 0 {
t.Errorf("asked %v first", f.asked)
}
}
func TestASuiteIsReadFromJSONLinesAsFromYAML(t *testing.T) {
jsonl := `{"name": "fmt", "threshold": 0.5}
{"id": "a", "prompt": "Give JSON", "json_schema": {"type": "object", "required": ["ok"]}}
{"prompt": "Say yes", "regex": "^yes", "ignore_case": true, "weight": 3}
`
s, err := Parse("format.jsonl", []byte(jsonl))
if err != nil {
t.Fatal(err)
}
if s.Name != "fmt" || s.Threshold != 0.5 || len(s.Cases) != 2 || s.Cases[1].ID != "case-2" ||
s.Cases[1].Weight != 3 || s.Cases[0].schema == nil || s.Cases[1].re == nil {
t.Fatalf("%+v", s)
}
// Named by its file when it names nothing, and 0.8 to pass.
s, err = Parse("dir/plain.jsonl", []byte(`{"prompt": "q", "exact": "a"}`))
if err != nil || s.Name != "plain" || s.Threshold != DefaultThreshold {
t.Fatalf("%+v %v", s, err)
}
}
func TestASuiteThatCannotBeScoredIsRefusedSayingWhy(t *testing.T) {
for _, tc := range []struct{ suite, why string }{
{"cases: []", "no cases"},
{"cases:\n - prompt: q", "no way to score it"},
{"cases:\n - prompt: q\n exact: a\n regex: b", "exact and regex"},
{"cases:\n - exact: a", "nothing to ask"},
{"cases:\n - prompt: q\n regex: '('", "the regex"},
{"cases:\n - id: x\n prompt: q\n exact: a\n - id: x\n prompt: q\n exact: a", "two cases are called x"},
{"threshold: 2\ncases:\n - prompt: q\n exact: a", "not 0 to 1"},
{"cases:\n - prompt: q\n exactly: a", "exactly"},
{"cases:\n - messages: [{role: robot, content: hi}]\n exact: a", "role"},
} {
if _, err := Parse("s.yaml", []byte(tc.suite)); err == nil || !strings.Contains(err.Error(), tc.why) {
t.Errorf("%q: %v, wanted it to say %q", tc.suite, err, tc.why)
}
}
}
type memStore struct {
mu sync.Mutex
runs []store.EvalRun
}
func (m *memStore) PutEvalRun(_ context.Context, r store.EvalRun) error {
m.mu.Lock()
defer m.mu.Unlock()
m.runs = append(m.runs, r)
return nil
}
func (m *memStore) EvalRuns(_ context.Context, model string, _ int) ([]store.EvalRun, error) {
m.mu.Lock()
defer m.mu.Unlock()
var out []store.EvalRun
for i := len(m.runs) - 1; i >= 0; i-- {
if r := m.runs[i]; model == "" || r.Model == model || r.Target == model {
r.Results = nil
out = append(out, r)
}
}
return out, nil
}
func (m *memStore) EvalRunByID(_ context.Context, id string) (store.EvalRun, error) {
m.mu.Lock()
defer m.mu.Unlock()
for _, r := range m.runs {
if r.ID == id {
return r, nil
}
}
return store.EvalRun{}, store.ErrNotFound
}
func TestTheControlPlaneRunsSuitesByNameAndFilesACanarysRunUnderItsModel(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "arith.yaml"), []byte(arith), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "broken.yaml"), []byte("cases: [}"), 0o600); err != nil {
t.Fatal(err)
}
builtin := fstest.MapFS{
"arith.yaml": {Data: []byte("cases:\n - prompt: shadowed\n exact: x\n")},
"json-format.yaml": {Data: []byte("description: JSON\ncases:\n - prompt: q\n json_schema: {type: object}\n")},
}
f := &fakeModel{answers: map[string]string{"17 + 25": "42", "6 * 7": "42", "France": "Paris", "hello": "Hello"}}
st := &memStore{}
svc := NewService(Deps{Call: f.call, Store: st, Dir: dir, Builtin: builtin, Log: slog.New(slog.DiscardHandler),
Origin: func(m string) (string, string) { return "o/r", "rev-of-" + m },
CanaryOf: func(n string) string {
if parent, ok := strings.CutSuffix(n, "-canary"); ok {
return parent
}
return ""
},
})
suites := svc.Suites()
if len(suites) != 3 || suites[0].Name != "arith" || suites[0].From != filepath.Join(dir, "arith.yaml") ||
suites[0].Cases != 4 || !suites[0].Judge || suites[1].Error == "" || suites[2].From != "built in" {
t.Fatalf("%+v", suites)
}
if _, err := svc.Load(Request{Suite: "../etc/passwd"}); err == nil {
t.Error("a path outside the eval directory was read")
}
live, err := svc.Start("qwen-canary", Request{Suite: "arith"}, "tester")
if err != nil {
t.Fatal(err)
}
if live.State != Running || live.Model != "qwen" || live.Target != "qwen-canary" || live.Cases != 4 {
t.Fatalf("%+v", live)
}
deadline := time.Now().Add(5 * time.Second)
for live.State == Running && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
live, _ = svc.Get(context.Background(), live.ID)
}
if live.State != Done || live.Score != 1 || !live.Passed || live.Revision != "rev-of-qwen-canary" || live.By != "tester" {
t.Fatalf("%+v", live)
}
if kept, err := st.EvalRunByID(context.Background(), live.ID); err != nil || len(kept.Results) != 4 {
t.Fatalf("kept %+v %v", kept, err)
}
// A gate waits for its run, and files it with the job.
run, err := svc.Run(context.Background(), "qwen", Request{Suite: "json-format"}, "tester", "update-1")
if err != nil || run.Job != "update-1" || run.Passed || run.Target != "" {
t.Fatalf("%+v %v", run, err)
}
hist, err := svc.History(context.Background(), "qwen", 10)
if err != nil || len(hist) != 2 || hist[0].ID != run.ID || hist[1].ID != live.ID {
t.Fatalf("%+v %v", hist, err)
}
if _, err := svc.Get(context.Background(), "eval-nope"); !errors.Is(err, store.ErrNotFound) {
t.Errorf("%v", err)
}
}