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>
185 lines
6.0 KiB
Go
185 lines
6.0 KiB
Go
package modelserve
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/bench/eval"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
// fakeEval scores whatever it is asked what it is told to.
|
|
type fakeEval struct {
|
|
mu sync.Mutex
|
|
score float64
|
|
asked []string
|
|
}
|
|
|
|
func (f *fakeEval) Check(_ string, r eval.Request) error {
|
|
if r.Suite == "missing" {
|
|
return errors.New("no suite called missing")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeEval) Run(_ context.Context, model string, r eval.Request, by, job string) (store.EvalRun, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.asked = append(f.asked, model+" "+r.Named())
|
|
threshold := r.MinScore
|
|
if threshold == 0 {
|
|
threshold = 0.8
|
|
}
|
|
below := 0
|
|
if f.score < 1 {
|
|
below = 1
|
|
}
|
|
return store.EvalRun{ID: "eval-1", Model: "qwen", Target: model, Suite: r.Named(), Score: f.score,
|
|
Threshold: threshold, Passed: f.score >= threshold, Cases: 4, Below: below, By: by, Job: job}, nil
|
|
}
|
|
|
|
func (f *fakeEval) said() []string {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return append([]string{}, f.asked...)
|
|
}
|
|
|
|
func stepOf(j Job, name string) Step {
|
|
for _, s := range j.Steps {
|
|
if s.Name == name {
|
|
return s
|
|
}
|
|
}
|
|
return Step{}
|
|
}
|
|
|
|
func TestAPromoteBelowTheSuitesScoreIsRefusedAndTheCanaryStays(t *testing.T) {
|
|
s, models, _ := canarySetup(t)
|
|
ev := &fakeEval{score: 0.5}
|
|
s.deps.Eval = ev
|
|
job, _ := s.StartCanary("qwen", catalog.Canary{Percent: 10}, "tester")
|
|
if j := wait(t, s, job.ID); j.State != Done {
|
|
t.Fatalf("%s: %+v", j.Error, j.Steps)
|
|
}
|
|
oldSource := models.models["qwen"].Source
|
|
gate := Change{Eval: &eval.Request{Suite: "arithmetic", MinScore: 0.8}}
|
|
|
|
job, err := s.StartPromote("qwen", "tester", gate)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
j := wait(t, s, job.ID)
|
|
if j.State != Failed || stepOf(j, "eval").State != Failed ||
|
|
!strings.Contains(j.Error, "qwen-canary scored 0.50 on arithmetic") ||
|
|
!strings.Contains(j.Error, "stays a canary") || stepOf(j, "catalog").Name != "" {
|
|
t.Fatalf("a canary scoring 0.5 of 0.8 was promoted, or not said so: %s %+v", j.Error, j.Steps)
|
|
}
|
|
if j.Eval == nil || j.Eval.Score != 0.5 || j.Eval.Job != j.ID {
|
|
t.Errorf("the job does not carry the score: %+v", j.Eval)
|
|
}
|
|
m := models.models["qwen"]
|
|
if m.Source != oldSource || m.Canary == nil || m.Canary.Percent != 10 {
|
|
t.Fatalf("the model moved, or lost its canary: %s %+v", m.Source, m.Canary)
|
|
}
|
|
if _, ok := models.models["qwen-canary"]; !ok {
|
|
t.Fatal("the canary was taken out; a refused promotion leaves that to its owner")
|
|
}
|
|
if got := ev.said(); len(got) != 1 || got[0] != "qwen-canary arithmetic" {
|
|
t.Errorf("asked %v", got)
|
|
}
|
|
|
|
ev.score = 0.9
|
|
job, _ = s.StartPromote("qwen", "tester", gate)
|
|
if j := wait(t, s, job.ID); j.State != Done || !strings.Contains(stepOf(j, "eval").Detail, "scored 0.90") {
|
|
t.Fatalf("%s: %+v", j.Error, j.Steps)
|
|
}
|
|
if m := models.models["qwen"]; m.Source == oldSource || m.Canary != nil {
|
|
t.Fatalf("a canary that passed was not promoted: %s %+v", m.Source, m.Canary)
|
|
}
|
|
}
|
|
|
|
func TestAnUpdateHeldToTheModelsSuiteTriesTheNewWeightsFirstAndKeepsTheOldBelowIt(t *testing.T) {
|
|
s, models, fleet := canarySetup(t)
|
|
ev := &fakeEval{score: 0.4}
|
|
s.deps.Eval = ev
|
|
m := models.models["qwen"]
|
|
m.Eval = &catalog.EvalGate{Suite: "arithmetic", MinScore: 0.75}
|
|
models.models["qwen"] = m
|
|
oldSource := m.Source
|
|
|
|
job, err := s.StartUpdate("qwen", "tester", Change{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
j := wait(t, s, job.ID)
|
|
if j.State != Failed || !strings.Contains(j.Error, "scored 0.40") || !strings.Contains(j.Error, "taken out again") {
|
|
t.Fatalf("%s: %+v", j.Error, j.Steps)
|
|
}
|
|
if got := ev.said(); len(got) != 1 || got[0] != "qwen-canary arithmetic" {
|
|
t.Fatalf("the new weights were not what was asked: %v", got)
|
|
}
|
|
m = models.models["qwen"]
|
|
if m.Source != oldSource || m.Canary != nil {
|
|
t.Fatalf("the model moved to weights that failed, or kept their canary: %s %+v", m.Source, m.Canary)
|
|
}
|
|
if _, ok := models.models["qwen-canary"]; ok {
|
|
t.Fatal("the failed weights were left in the catalog")
|
|
}
|
|
for _, r := range fleet.Replicas() {
|
|
if r.Model == "qwen" && strings.Contains(r.ID, "new") {
|
|
t.Fatalf("%s was replaced onto weights that failed", r.ID)
|
|
}
|
|
}
|
|
|
|
// Scoring enough, they replace the old.
|
|
ev.score = 1
|
|
job, _ = s.StartUpdate("qwen", "tester", Change{})
|
|
j = wait(t, s, job.ID)
|
|
if j.State != Done || stepOf(j, "eval").State != Done || stepOf(j, "split").Detail != "0% of qwen's requests go to qwen-canary" {
|
|
t.Fatalf("%s: %+v", j.Error, j.Steps)
|
|
}
|
|
if m := models.models["qwen"]; m.Source == oldSource || !strings.HasSuffix(m.Source, "/def98765/q4.gguf") ||
|
|
m.Canary != nil || m.Origin.Previous == nil || m.Origin.Previous.Source != oldSource || m.Eval == nil {
|
|
t.Fatalf("not moved to the new weights, or the gate was lost: %s %+v %+v", m.Source, m.Canary, m.Origin)
|
|
}
|
|
if _, ok := models.models["qwen-canary"]; ok {
|
|
t.Error("the canary was left in the catalog")
|
|
}
|
|
}
|
|
|
|
func TestAModelsSuiteCanBeSkippedOnceAndAMissingOneIsRefusedAtOnce(t *testing.T) {
|
|
s, models, _ := canarySetup(t)
|
|
ev := &fakeEval{score: 0}
|
|
s.deps.Eval = ev
|
|
m := models.models["qwen"]
|
|
m.Eval = &catalog.EvalGate{Suite: "arithmetic"}
|
|
models.models["qwen"] = m
|
|
|
|
if _, err := s.StartUpdate("qwen", "tester", Change{Eval: &eval.Request{Suite: "missing"}}); err == nil ||
|
|
!strings.Contains(err.Error(), "no suite called missing") {
|
|
t.Fatalf("an update held to a suite that is not there was started: %v", err)
|
|
}
|
|
|
|
job, err := s.StartUpdate("qwen", "tester", Change{SkipEval: true})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
j := wait(t, s, job.ID)
|
|
if j.State != Done || stepOf(j, "eval").State != Skipped || !strings.Contains(stepOf(j, "eval").Detail, "tester") {
|
|
t.Fatalf("%s: %+v", j.Error, j.Steps)
|
|
}
|
|
if len(ev.said()) != 0 {
|
|
t.Errorf("asked %v while skipped", ev.said())
|
|
}
|
|
|
|
// Without anything to run suites, a gate is refused rather than ignored.
|
|
s.deps.Eval = nil
|
|
if _, err := s.StartUpdate("qwen", "tester", Change{}); err == nil {
|
|
t.Error("an update held to the model's suite started with nothing to run it")
|
|
}
|
|
}
|