Files
llmgw/internal/bench/eval/schema.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

339 lines
8.9 KiB
Go

package eval
import (
"encoding/json"
"fmt"
"math"
"reflect"
"regexp"
"slices"
"sort"
"strings"
"unicode/utf8"
)
// A JSON Schema validator for what a suite asks of an answer's shape: types,
// properties, required ones, items, enums, bounds and patterns. Not the whole
// standard — no $ref, no oneOf — and a schema using what is not here is
// refused when the suite is read rather than passed by an answer it never
// checked.
type schema struct {
types []string
properties map[string]*schema
required []string
additional *schema // nil: anything; noneAllowed: nothing
items *schema
enum []any
constant any
hasConst bool
minimum, maximum *float64
exclMin, exclMax *float64
minLength, maxLength *int
minItems, maxItems *int
pattern *regexp.Regexp
anyOf, allOf []*schema
never bool
}
// noneAllowed is additionalProperties: false.
var noneAllowed = &schema{never: true}
// annotations are keywords that describe rather than constrain.
var annotations = map[string]bool{"$schema": true, "$id": true, "title": true, "description": true,
"default": true, "examples": true, "format": true, "$comment": true}
func compileSchema(v any) (*schema, error) {
// Through JSON, so a schema written in YAML and one in JSON are the same
// thing by the time they are read: numbers as float64, maps as map[string]any.
b, err := json.Marshal(v)
if err != nil {
return nil, err
}
var raw any
if err := json.Unmarshal(b, &raw); err != nil {
return nil, err
}
return compile(raw, "")
}
func compile(raw any, at string) (*schema, error) {
if b, ok := raw.(bool); ok {
if b {
return &schema{}, nil
}
return noneAllowed, nil
}
m, ok := raw.(map[string]any)
if !ok {
return nil, fmt.Errorf("%s is not a schema object", where(at))
}
s := &schema{}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
v := m[k]
var err error
switch k {
case "type":
switch t := v.(type) {
case string:
s.types = []string{t}
case []any:
for _, x := range t {
if str, ok := x.(string); ok {
s.types = append(s.types, str)
}
}
}
for _, t := range s.types {
if !slices.Contains([]string{"object", "array", "string", "number", "integer", "boolean", "null"}, t) {
return nil, fmt.Errorf("%s: %q is not a JSON type", where(at), t)
}
}
case "properties":
props, ok := v.(map[string]any)
if !ok {
return nil, fmt.Errorf("%s: properties is not an object", where(at))
}
s.properties = map[string]*schema{}
for name, p := range props {
if s.properties[name], err = compile(p, at+"."+name); err != nil {
return nil, err
}
}
case "required":
list, _ := v.([]any)
for _, x := range list {
if str, ok := x.(string); ok {
s.required = append(s.required, str)
}
}
case "additionalProperties":
if s.additional, err = compile(v, at+".*"); err != nil {
return nil, err
}
case "items":
if s.items, err = compile(v, at+"[]"); err != nil {
return nil, err
}
case "enum":
s.enum, _ = v.([]any)
case "const":
s.constant, s.hasConst = v, true
case "minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum":
f, ok := v.(float64)
if !ok {
return nil, fmt.Errorf("%s: %s is not a number", where(at), k)
}
*map[string]**float64{"minimum": &s.minimum, "maximum": &s.maximum,
"exclusiveMinimum": &s.exclMin, "exclusiveMaximum": &s.exclMax}[k] = &f
case "minLength", "maxLength", "minItems", "maxItems":
f, ok := v.(float64)
if !ok || f < 0 || f != math.Trunc(f) {
return nil, fmt.Errorf("%s: %s is not a whole number", where(at), k)
}
n := int(f)
*map[string]**int{"minLength": &s.minLength, "maxLength": &s.maxLength,
"minItems": &s.minItems, "maxItems": &s.maxItems}[k] = &n
case "pattern":
str, _ := v.(string)
if s.pattern, err = regexp.Compile(str); err != nil {
return nil, fmt.Errorf("%s: pattern: %w", where(at), err)
}
case "anyOf", "allOf":
list, ok := v.([]any)
if !ok || len(list) == 0 {
return nil, fmt.Errorf("%s: %s is not a list of schemas", where(at), k)
}
for i, x := range list {
sub, err := compile(x, fmt.Sprintf("%s(%s %d)", at, k, i+1))
if err != nil {
return nil, err
}
if k == "anyOf" {
s.anyOf = append(s.anyOf, sub)
} else {
s.allOf = append(s.allOf, sub)
}
}
default:
if !annotations[k] {
return nil, fmt.Errorf("%s: %s is not supported here (type, properties, required, "+
"additionalProperties, items, enum, const, minimum, maximum, exclusiveMinimum, exclusiveMaximum, "+
"minLength, maxLength, minItems, maxItems, pattern, anyOf and allOf are)", where(at), k)
}
}
}
return s, nil
}
func where(at string) string {
if at == "" {
return "the schema"
}
return "the schema at " + strings.TrimPrefix(at, ".")
}
// validate says what is wrong with v, or "".
func (s *schema) validate(v any, at string) string {
if s.never {
return fmt.Sprintf("%s is not allowed", place(at))
}
if len(s.types) > 0 && !slices.ContainsFunc(s.types, func(t string) bool { return isType(v, t) }) {
return fmt.Sprintf("%s is %s, not %s", place(at), typeOf(v), strings.Join(s.types, " or "))
}
if s.hasConst && !equal(v, s.constant) {
return fmt.Sprintf("%s is %s, not %s", place(at), short(v), short(s.constant))
}
if s.enum != nil && !slices.ContainsFunc(s.enum, func(e any) bool { return equal(v, e) }) {
return fmt.Sprintf("%s is %s, not one of %s", place(at), short(v), short(s.enum))
}
switch x := v.(type) {
case float64:
switch {
case s.minimum != nil && x < *s.minimum:
return fmt.Sprintf("%s is %v, below %v", place(at), x, *s.minimum)
case s.maximum != nil && x > *s.maximum:
return fmt.Sprintf("%s is %v, above %v", place(at), x, *s.maximum)
case s.exclMin != nil && x <= *s.exclMin:
return fmt.Sprintf("%s is %v, not above %v", place(at), x, *s.exclMin)
case s.exclMax != nil && x >= *s.exclMax:
return fmt.Sprintf("%s is %v, not below %v", place(at), x, *s.exclMax)
}
case string:
n := utf8.RuneCountInString(x)
switch {
case s.minLength != nil && n < *s.minLength:
return fmt.Sprintf("%s is %d characters, fewer than %d", place(at), n, *s.minLength)
case s.maxLength != nil && n > *s.maxLength:
return fmt.Sprintf("%s is %d characters, more than %d", place(at), n, *s.maxLength)
case s.pattern != nil && !s.pattern.MatchString(x):
return fmt.Sprintf("%s, %s, does not match %s", place(at), short(x), s.pattern)
}
case []any:
switch {
case s.minItems != nil && len(x) < *s.minItems:
return fmt.Sprintf("%s has %d items, fewer than %d", place(at), len(x), *s.minItems)
case s.maxItems != nil && len(x) > *s.maxItems:
return fmt.Sprintf("%s has %d items, more than %d", place(at), len(x), *s.maxItems)
}
if s.items != nil {
for i, e := range x {
if why := s.items.validate(e, fmt.Sprintf("%s[%d]", at, i)); why != "" {
return why
}
}
}
case map[string]any:
for _, r := range s.required {
if _, ok := x[r]; !ok {
return fmt.Sprintf("%s has no %s", place(at), r)
}
}
keys := make([]string, 0, len(x))
for k := range x {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if p, ok := s.properties[k]; ok {
if why := p.validate(x[k], at+"."+k); why != "" {
return why
}
} else if s.additional != nil {
if why := s.additional.validate(x[k], at+"."+k); why != "" {
return why
}
}
}
}
for _, sub := range s.allOf {
if why := sub.validate(v, at); why != "" {
return why
}
}
if len(s.anyOf) > 0 {
var first string
for _, sub := range s.anyOf {
why := sub.validate(v, at)
if why == "" {
return ""
}
if first == "" {
first = why
}
}
return first + " (nor any other choice)"
}
return ""
}
func place(at string) string {
if at == "" {
return "the answer"
}
return strings.TrimPrefix(at, ".")
}
func isType(v any, t string) bool {
switch t {
case "object":
_, ok := v.(map[string]any)
return ok
case "array":
_, ok := v.([]any)
return ok
case "string":
_, ok := v.(string)
return ok
case "number":
_, ok := v.(float64)
return ok
case "integer":
f, ok := v.(float64)
return ok && f == math.Trunc(f)
case "boolean":
_, ok := v.(bool)
return ok
case "null":
return v == nil
}
return false
}
func typeOf(v any) string {
switch x := v.(type) {
case map[string]any:
return "an object"
case []any:
return "a list"
case string:
return "a string"
case float64:
if x == math.Trunc(x) {
return "an integer"
}
return "a number"
case bool:
return "a boolean"
case nil:
return "null"
}
return fmt.Sprintf("%T", v)
}
func equal(a, b any) bool { return reflect.DeepEqual(a, b) }
func short(v any) string {
b, _ := json.Marshal(v)
s := string(b)
if len(s) > 60 {
s = s[:57] + "..."
}
return s
}