Files
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

333 lines
9.8 KiB
Go

// Package eval asks a model a suite of questions and scores its answers, so
// new weights can be held to the quality of the old before they replace them.
//
// internal/bench measures how fast a replica answers; this measures whether
// the answers are any good. A repository that republishes its weights — a
// new quantisation, a fixed chat template — can make a model faster and
// worse at once, and nothing in the latency numbers says so. A suite is a
// handful of cases with known answers, scored by rules where a rule can
// decide (an exact string, a number, JSON of a shape) and by another model
// with a rubric where only judgement can.
package eval
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"path"
"regexp"
"strings"
"go.yaml.in/yaml/v3"
)
// Suite is a set of cases a model is asked, and the score it must reach.
type Suite struct {
// Name is what runs are filed under; the file's name without its
// extension unless the suite gives one.
Name string `json:"name,omitempty" yaml:"name"`
Description string `json:"description,omitempty" yaml:"description"`
// Threshold is the score, 0 to 1, the suite is passed at. Zero is 0.8.
Threshold float64 `json:"threshold,omitempty" yaml:"threshold"`
// Judge is the model asked to grade the cases scored by a rubric, unless
// a case names its own.
Judge string `json:"judge,omitempty" yaml:"judge"`
// System and MaxTokens are every case's unless it gives its own.
System string `json:"system,omitempty" yaml:"system"`
MaxTokens int `json:"max_tokens,omitempty" yaml:"max_tokens"`
Cases []Case `json:"cases" yaml:"cases"`
// Hash is the sha256 of the text the suite was read from: two runs
// under one name compare only when they asked the same questions.
Hash string `json:"-" yaml:"-"`
}
// Message is one turn of a conversation a case sends.
type Message struct {
Role string `json:"role" yaml:"role"`
Content string `json:"content" yaml:"content"`
}
// Case is one question and how its answer is scored: by exactly one of
// Exact, Contains, Regex, Number, JSONSchema or Judge.
type Case struct {
// ID names the case in results; case-N, by its place, unless given.
ID string `json:"id,omitempty" yaml:"id"`
// Prompt is the user's message; Messages a whole conversation instead.
Prompt string `json:"prompt,omitempty" yaml:"prompt"`
Messages []Message `json:"messages,omitempty" yaml:"messages"`
System string `json:"system,omitempty" yaml:"system"`
// MaxTokens bounds the answer; the suite's, else 512.
MaxTokens int `json:"max_tokens,omitempty" yaml:"max_tokens"`
// Temperature is 0 unless given: a suite is a measurement, and sampling
// would make two runs of the same weights disagree.
Temperature *float64 `json:"temperature,omitempty" yaml:"temperature"`
// Weight is how much the case counts against the others; 1 unless given.
Weight float64 `json:"weight,omitempty" yaml:"weight"`
// Exact is the whole answer, surrounding white space aside.
Exact *string `json:"exact,omitempty" yaml:"exact"`
// Contains are strings that must all be in the answer.
Contains StringList `json:"contains,omitempty" yaml:"contains"`
// Regex must match somewhere in the answer.
Regex string `json:"regex,omitempty" yaml:"regex"`
// IgnoreCase makes Exact, Contains and Regex blind to case.
IgnoreCase bool `json:"ignore_case,omitempty" yaml:"ignore_case"`
// Number is the value the last number in the answer must be, within
// Tolerance either side.
Number *float64 `json:"number,omitempty" yaml:"number"`
Tolerance float64 `json:"tolerance,omitempty" yaml:"tolerance"`
// JSONSchema is a schema the answer, read as JSON, must satisfy.
JSONSchema any `json:"json_schema,omitempty" yaml:"json_schema"`
// Judge is a rubric another model grades the answer by, 0 to 1;
// JudgeModel names that model, else the suite's judge.
Judge string `json:"judge,omitempty" yaml:"judge"`
JudgeModel string `json:"judge_model,omitempty" yaml:"judge_model"`
re *regexp.Regexp
schema *schema
}
// StringList is one string or a list of them.
type StringList []string
// UnmarshalYAML takes a string or a list.
func (l *StringList) UnmarshalYAML(n *yaml.Node) error {
if n.Kind == yaml.ScalarNode {
*l = StringList{n.Value}
return nil
}
var v []string
if err := n.Decode(&v); err != nil {
return err
}
*l = v
return nil
}
// UnmarshalJSON takes a string or a list.
func (l *StringList) UnmarshalJSON(b []byte) error {
var s string
if json.Unmarshal(b, &s) == nil {
*l = StringList{s}
return nil
}
var v []string
if err := json.Unmarshal(b, &v); err != nil {
return errors.New("contains is a string or a list of strings")
}
*l = v
return nil
}
// Kind is how a case is scored: exact, contains, regex, number,
// json_schema or judge.
func (c Case) Kind() string {
var kinds []string
if c.Exact != nil {
kinds = append(kinds, "exact")
}
if len(c.Contains) > 0 {
kinds = append(kinds, "contains")
}
if c.Regex != "" {
kinds = append(kinds, "regex")
}
if c.Number != nil {
kinds = append(kinds, "number")
}
if c.JSONSchema != nil {
kinds = append(kinds, "json_schema")
}
if c.Judge != "" {
kinds = append(kinds, "judge")
}
return strings.Join(kinds, "+")
}
// DefaultThreshold is the score a suite that names none is passed at.
const DefaultThreshold = 0.8
// defaultMaxTokens bounds an answer when neither the case nor the suite do.
const defaultMaxTokens = 512
// Parse reads a suite: YAML (a mapping with its cases under cases:), or
// JSON lines (one case on each, and optionally a line without a prompt or
// messages that holds the suite's own settings). The name is the file's,
// used when the suite gives none.
func Parse(file string, data []byte) (Suite, error) {
var s Suite
var err error
if isJSONLines(data) {
s, err = parseJSONLines(data)
} else {
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
err = dec.Decode(&s)
if errors.Is(err, io.EOF) {
err = errors.New("the file is empty")
}
}
if err != nil {
return Suite{}, fmt.Errorf("%s: %w", file, err)
}
if s.Name == "" {
base := path.Base(strings.ReplaceAll(file, "\\", "/"))
s.Name = strings.TrimSuffix(base, path.Ext(base))
}
sum := sha256.Sum256(data)
s.Hash = hex.EncodeToString(sum[:])
if err := s.check(); err != nil {
return Suite{}, fmt.Errorf("%s: %w", file, err)
}
return s, nil
}
// isJSONLines is whether every line that is not blank is a JSON object, and
// there is more than one, or the one there is is a case rather than a whole
// suite.
func isJSONLines(data []byte) bool {
n := 0
for _, line := range bytes.Split(data, []byte("\n")) {
line = bytes.TrimSpace(line)
if len(line) == 0 {
continue
}
if line[0] != '{' || !json.Valid(line) {
return false
}
n++
}
if n == 1 {
var probe struct {
Cases json.RawMessage `json:"cases"`
}
_ = json.Unmarshal(bytes.TrimSpace(data), &probe)
return probe.Cases == nil
}
return n > 0
}
func parseJSONLines(data []byte) (Suite, error) {
var s Suite
for i, line := range bytes.Split(data, []byte("\n")) {
line = bytes.TrimSpace(line)
if len(line) == 0 {
continue
}
var probe struct {
Prompt *string `json:"prompt"`
Messages json.RawMessage `json:"messages"`
}
_ = json.Unmarshal(line, &probe)
dec := json.NewDecoder(bytes.NewReader(line))
dec.DisallowUnknownFields()
if probe.Prompt == nil && probe.Messages == nil {
cases := s.Cases
if err := dec.Decode(&s); err != nil {
return Suite{}, fmt.Errorf("line %d: %w", i+1, err)
}
s.Cases = append(cases, s.Cases...)
continue
}
var c Case
if err := dec.Decode(&c); err != nil {
return Suite{}, fmt.Errorf("line %d: %w", i+1, err)
}
s.Cases = append(s.Cases, c)
}
return s, nil
}
// check fills in what a suite left out and refuses what cannot be run.
func (s *Suite) check() error {
if len(s.Cases) == 0 {
return errors.New("no cases: a suite has them under cases:, or one on each line of JSON")
}
if s.Threshold < 0 || s.Threshold > 1 {
return fmt.Errorf("threshold %v is not 0 to 1", s.Threshold)
}
if s.Threshold == 0 {
s.Threshold = DefaultThreshold
}
seen := map[string]bool{}
for i := range s.Cases {
c := &s.Cases[i]
if c.ID == "" {
c.ID = fmt.Sprintf("case-%d", i+1)
}
if seen[c.ID] {
return fmt.Errorf("two cases are called %s", c.ID)
}
seen[c.ID] = true
if err := c.check(); err != nil {
return fmt.Errorf("case %s: %w", c.ID, err)
}
}
return nil
}
func (c *Case) check() error {
if strings.TrimSpace(c.Prompt) == "" && len(c.Messages) == 0 {
return errors.New("no prompt and no messages: nothing to ask")
}
if c.Prompt != "" && len(c.Messages) > 0 {
return errors.New("a prompt and messages both: give one")
}
for _, m := range c.Messages {
switch m.Role {
case "system", "user", "assistant":
default:
return fmt.Errorf("a message's role is system, user or assistant, not %q", m.Role)
}
}
switch k := c.Kind(); {
case k == "":
return errors.New("no way to score it: give one of exact, contains, regex, number, json_schema or judge")
case strings.Contains(k, "+"):
return fmt.Errorf("scored by %s: give one", strings.ReplaceAll(k, "+", " and "))
}
if c.Weight < 0 {
return fmt.Errorf("weight %v is negative", c.Weight)
}
if c.Weight == 0 {
c.Weight = 1
}
if c.Tolerance < 0 {
return fmt.Errorf("tolerance %v is negative", c.Tolerance)
}
if c.Regex != "" {
expr := c.Regex
if c.IgnoreCase {
expr = "(?i)" + expr
}
re, err := regexp.Compile(expr)
if err != nil {
return fmt.Errorf("the regex: %w", err)
}
c.re = re
}
if c.JSONSchema != nil {
sc, err := compileSchema(c.JSONSchema)
if err != nil {
return fmt.Errorf("the JSON schema: %w", err)
}
c.schema = sc
}
return nil
}
// NeedsJudge is whether any case is graded by a model.
func (s Suite) NeedsJudge() bool {
for _, c := range s.Cases {
if c.Judge != "" {
return true
}
}
return false
}