mirror of
https://github.com/lukaszraczylo/llm-testbench.git
synced 2026-10-12 00:45:19 +00:00
feat: LLM accuracy testing framework with 17-test catalog
Framework runs a category/subcategory test catalog against multiple LLMs through an OpenAI-compatible endpoint. Deterministic evaluators score each response. Reports render as table, markdown, or JSON.
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
// Package config loads and validates the llm-testbench YAML configuration.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// apiKeyEnvVar names the environment variable that overrides Config.APIKey.
|
||||
//
|
||||
//nolint:gosec // this is an env var *name*, not a credential value
|
||||
const apiKeyEnvVar = "LLMTB_API_KEY" // #nosec G101 -- this is an env var *name*, not a credential value
|
||||
|
||||
// Config is the top-level configuration loaded from config.yaml.
|
||||
type Config struct {
|
||||
Endpoint string `yaml:"endpoint"`
|
||||
APIKey string `yaml:"api_key"`
|
||||
Models []string `yaml:"models"`
|
||||
Concurrency int `yaml:"concurrency"`
|
||||
RequestTimeout time.Duration `yaml:"request_timeout"`
|
||||
MaxTokensDefault int `yaml:"max_tokens_default"`
|
||||
}
|
||||
|
||||
// rawConfig mirrors Config but with RequestTimeout as a string, since
|
||||
// yaml.v3 does not natively decode Go duration strings.
|
||||
type rawConfig struct {
|
||||
Endpoint string `yaml:"endpoint"`
|
||||
APIKey string `yaml:"api_key"`
|
||||
RequestTimeout string `yaml:"request_timeout"`
|
||||
Models []string `yaml:"models"`
|
||||
Concurrency int `yaml:"concurrency"`
|
||||
MaxTokensDefault int `yaml:"max_tokens_default"`
|
||||
}
|
||||
|
||||
// Load reads and parses the YAML config at path, applies the LLMTB_API_KEY
|
||||
// environment override (which always wins over the file value), and
|
||||
// validates the result.
|
||||
func Load(path string) (Config, error) {
|
||||
// #nosec G304 -- path is the operator's own --config flag value (a
|
||||
// local CLI tool reading its own config file), not externally supplied
|
||||
// or network input.
|
||||
data, err := os.ReadFile(path) //nolint:gosec // path is the operator's own --config flag value, not external input
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("config: read %s: %w", path, err)
|
||||
}
|
||||
return parse(data)
|
||||
}
|
||||
|
||||
// parse decodes YAML bytes into a validated Config. Split out from Load for
|
||||
// direct, file-free unit testing.
|
||||
func parse(data []byte) (Config, error) {
|
||||
var raw rawConfig
|
||||
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||
return Config{}, fmt.Errorf("config: parse yaml: %w", err)
|
||||
}
|
||||
|
||||
cfg := Config{
|
||||
Endpoint: raw.Endpoint,
|
||||
APIKey: raw.APIKey,
|
||||
Models: raw.Models,
|
||||
Concurrency: raw.Concurrency,
|
||||
MaxTokensDefault: raw.MaxTokensDefault,
|
||||
}
|
||||
|
||||
if raw.RequestTimeout != "" {
|
||||
d, err := time.ParseDuration(raw.RequestTimeout)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("config: parse request_timeout %q: %w", raw.RequestTimeout, err)
|
||||
}
|
||||
cfg.RequestTimeout = d
|
||||
}
|
||||
|
||||
if envKey := os.Getenv(apiKeyEnvVar); envKey != "" {
|
||||
cfg.APIKey = envKey
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Validate checks that required fields are present and sane.
|
||||
func (c Config) Validate() error {
|
||||
if c.Endpoint == "" {
|
||||
return fmt.Errorf("config: endpoint must not be empty")
|
||||
}
|
||||
if len(c.Models) == 0 {
|
||||
return fmt.Errorf("config: models must list at least one model")
|
||||
}
|
||||
for i, m := range c.Models {
|
||||
if m == "" {
|
||||
return fmt.Errorf("config: models[%d] must not be empty", i)
|
||||
}
|
||||
}
|
||||
if c.Concurrency <= 0 {
|
||||
return fmt.Errorf("config: concurrency must be > 0, got %d", c.Concurrency)
|
||||
}
|
||||
if c.RequestTimeout <= 0 {
|
||||
return fmt.Errorf("config: request_timeout must be > 0, got %s", c.RequestTimeout)
|
||||
}
|
||||
if c.MaxTokensDefault <= 0 {
|
||||
return fmt.Errorf("config: max_tokens_default must be > 0, got %d", c.MaxTokensDefault)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const validYAML = `
|
||||
endpoint: https://llm-gateway.example.com/v1
|
||||
api_key: ""
|
||||
models:
|
||||
- uni/deepseek-v4-flash-0731
|
||||
- uni/qwen3.6-27b
|
||||
concurrency: 4
|
||||
request_timeout: 120s
|
||||
max_tokens_default: 4000
|
||||
`
|
||||
|
||||
func TestParse_Valid(t *testing.T) {
|
||||
cfg, err := parse([]byte(validYAML))
|
||||
if err != nil {
|
||||
t.Fatalf("parse() error = %v", err)
|
||||
}
|
||||
if cfg.Endpoint != "https://llm-gateway.example.com/v1" {
|
||||
t.Errorf("Endpoint = %q", cfg.Endpoint)
|
||||
}
|
||||
if len(cfg.Models) != 2 {
|
||||
t.Errorf("Models = %v, want 2 entries", cfg.Models)
|
||||
}
|
||||
if cfg.Concurrency != 4 {
|
||||
t.Errorf("Concurrency = %d, want 4", cfg.Concurrency)
|
||||
}
|
||||
if cfg.RequestTimeout != 120*time.Second {
|
||||
t.Errorf("RequestTimeout = %v, want 120s", cfg.RequestTimeout)
|
||||
}
|
||||
if cfg.MaxTokensDefault != 4000 {
|
||||
t.Errorf("MaxTokensDefault = %d, want 4000", cfg.MaxTokensDefault)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_EnvOverridesAPIKey(t *testing.T) {
|
||||
t.Setenv(apiKeyEnvVar, "env-secret")
|
||||
cfg, err := parse([]byte(validYAML))
|
||||
if err != nil {
|
||||
t.Fatalf("parse() error = %v", err)
|
||||
}
|
||||
if cfg.APIKey != "env-secret" {
|
||||
t.Errorf("APIKey = %q, want env override", cfg.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_FileAPIKeyUsedWhenNoEnv(t *testing.T) {
|
||||
// S10: guarantee no env var leakage from the operator's real shell -
|
||||
// without this, a machine with LLMTB_API_KEY already set would make
|
||||
// this test assert the wrong thing regardless of parse()'s behavior.
|
||||
t.Setenv(apiKeyEnvVar, "")
|
||||
|
||||
yaml := `
|
||||
endpoint: https://llm-gateway.example.com/v1
|
||||
api_key: "file-key"
|
||||
models:
|
||||
- uni/deepseek-v4-flash-0731
|
||||
concurrency: 4
|
||||
request_timeout: 120s
|
||||
max_tokens_default: 4000
|
||||
`
|
||||
cfg, err := parse([]byte(yaml))
|
||||
if err != nil {
|
||||
t.Fatalf("parse() error = %v", err)
|
||||
}
|
||||
if cfg.APIKey != "file-key" {
|
||||
t.Errorf("APIKey = %q, want file-key", cfg.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_InvalidYAML(t *testing.T) {
|
||||
_, err := parse([]byte("not: [valid: yaml"))
|
||||
if err == nil {
|
||||
t.Fatal("parse() error = nil, want error for invalid YAML")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_InvalidDuration(t *testing.T) {
|
||||
yaml := `
|
||||
endpoint: https://x
|
||||
models: [m]
|
||||
concurrency: 1
|
||||
request_timeout: not-a-duration
|
||||
max_tokens_default: 100
|
||||
`
|
||||
_, err := parse([]byte(yaml))
|
||||
if err == nil {
|
||||
t.Fatal("parse() error = nil, want error for invalid duration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
base := Config{
|
||||
Endpoint: "https://x",
|
||||
Models: []string{"m"},
|
||||
Concurrency: 1,
|
||||
RequestTimeout: time.Second,
|
||||
MaxTokensDefault: 100,
|
||||
}
|
||||
|
||||
if err := base.Validate(); err != nil {
|
||||
t.Fatalf("Validate() on valid config error = %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
mutate func(c Config) Config
|
||||
name string
|
||||
}{
|
||||
{name: "empty endpoint", mutate: func(c Config) Config { c.Endpoint = ""; return c }},
|
||||
{name: "no models", mutate: func(c Config) Config { c.Models = nil; return c }},
|
||||
{name: "empty model entry", mutate: func(c Config) Config { c.Models = []string{""}; return c }},
|
||||
{name: "zero concurrency", mutate: func(c Config) Config { c.Concurrency = 0; return c }},
|
||||
{name: "negative concurrency", mutate: func(c Config) Config { c.Concurrency = -1; return c }},
|
||||
{name: "zero timeout", mutate: func(c Config) Config { c.RequestTimeout = 0; return c }},
|
||||
{name: "zero max tokens", mutate: func(c Config) Config { c.MaxTokensDefault = 0; return c }},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c := tt.mutate(base)
|
||||
if err := c.Validate(); err == nil {
|
||||
t.Errorf("Validate() on %s: error = nil, want error", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_ReadsFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
if err := os.WriteFile(path, []byte(validYAML), 0o600); err != nil {
|
||||
t.Fatalf("os.WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if cfg.Endpoint == "" {
|
||||
t.Error("Load() returned empty Endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_MissingFile(t *testing.T) {
|
||||
_, err := Load(filepath.Join(t.TempDir(), "does-not-exist.yaml"))
|
||||
if err == nil {
|
||||
t.Fatal("Load() error = nil, want error for missing file")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package eval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// WeightedEvaluator pairs an Evaluator with its share of the composite score.
|
||||
type WeightedEvaluator struct {
|
||||
Eval Evaluator
|
||||
Weight float64
|
||||
}
|
||||
|
||||
// W builds a WeightedEvaluator; a convenience constructor for All.
|
||||
func W(e Evaluator, weight float64) WeightedEvaluator {
|
||||
return WeightedEvaluator{Eval: e, Weight: weight}
|
||||
}
|
||||
|
||||
// compositeEval computes a weighted mean over its sub-evaluators, ignoring
|
||||
// sub-scores that are Skipped.
|
||||
type compositeEval struct {
|
||||
weighted []WeightedEvaluator
|
||||
}
|
||||
|
||||
// All returns an Evaluator that combines weighted sub-evaluators into a
|
||||
// single weighted-mean Score. Sub-scores marked Skipped are excluded from
|
||||
// both the numerator and the weight total; if every sub-evaluator is
|
||||
// skipped, the composite itself is Skipped.
|
||||
func All(weighted ...WeightedEvaluator) Evaluator {
|
||||
return compositeEval{weighted: weighted}
|
||||
}
|
||||
|
||||
// Mean wraps evaluators with equal weight 1 and combines them via All. A
|
||||
// convenience for the common case where every sub-check counts the same.
|
||||
// Named Mean, not Equal, to avoid reading as a synonym of the Equals
|
||||
// evaluator (S12) - Mean computes an unweighted average of N sub-scores,
|
||||
// while Equals is a single trimmed, case-insensitive string comparison.
|
||||
func Mean(evaluators ...Evaluator) Evaluator {
|
||||
weighted := make([]WeightedEvaluator, 0, len(evaluators))
|
||||
for _, e := range evaluators {
|
||||
weighted = append(weighted, W(e, 1))
|
||||
}
|
||||
return All(weighted...)
|
||||
}
|
||||
|
||||
func (c compositeEval) Evaluate(ctx context.Context, response string) Score {
|
||||
var weightedSum, activeWeight float64
|
||||
details := make([]string, 0, len(c.weighted))
|
||||
|
||||
for _, we := range c.weighted {
|
||||
s := we.Eval.Evaluate(ctx, response)
|
||||
if s.Skipped {
|
||||
details = append(details, fmt.Sprintf("skipped (w=%.2f): %s", we.Weight, s.Detail))
|
||||
continue
|
||||
}
|
||||
activeWeight += we.Weight
|
||||
weightedSum += s.Value * we.Weight
|
||||
details = append(details, fmt.Sprintf("%.2f (w=%.2f): %s", s.Value, we.Weight, s.Detail))
|
||||
}
|
||||
|
||||
if activeWeight == 0 {
|
||||
return Score{Skipped: true, Detail: "all sub-evaluators skipped: " + strings.Join(details, "; ")}
|
||||
}
|
||||
|
||||
return Score{
|
||||
Value: clamp01(weightedSum / activeWeight),
|
||||
Detail: strings.Join(details, "; "),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package eval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAll_WeightedMean(t *testing.T) {
|
||||
e := All(
|
||||
W(Equals("minor"), 2),
|
||||
W(ContainsAny("bump"), 1),
|
||||
)
|
||||
// "minor bump" -> Equals fails (0, weight 2), ContainsAny succeeds (1, weight 1)
|
||||
// weighted mean = (0*2 + 1*1) / 3 = 1/3
|
||||
got := e.Evaluate(context.Background(), "minor bump")
|
||||
want := 1.0 / 3.0
|
||||
if got.Value != want {
|
||||
t.Errorf("Evaluate() = %v, want %v (detail: %s)", got.Value, want, got.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMean_AllPass(t *testing.T) {
|
||||
e := Mean(
|
||||
ContainsAll("launchd"),
|
||||
ContainsAll("StartInterval"),
|
||||
)
|
||||
got := e.Evaluate(context.Background(), "use launchd with StartInterval key")
|
||||
if got.Value != 1 {
|
||||
t.Errorf("Evaluate() = %v, want 1 (detail: %s)", got.Value, got.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMean_PartialCredit(t *testing.T) {
|
||||
e := Mean(
|
||||
ContainsAll("launchd"),
|
||||
ContainsAll("StartInterval"),
|
||||
)
|
||||
// Only "launchd" present -> (1 + 0) / 2 = 0.5
|
||||
got := e.Evaluate(context.Background(), "use launchd, not cron")
|
||||
if got.Value != 0.5 {
|
||||
t.Errorf("Evaluate() = %v, want 0.5 (detail: %s)", got.Value, got.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAll_SkippedSubEvaluatorExcluded(t *testing.T) {
|
||||
skipped := EvaluatorFunc(func(_ context.Context, _ string) Score {
|
||||
return Score{Skipped: true, Detail: "toolchain missing: go"}
|
||||
})
|
||||
e := All(
|
||||
W(Equals("x"), 1),
|
||||
W(skipped, 5),
|
||||
)
|
||||
got := e.Evaluate(context.Background(), "x")
|
||||
if got.Skipped {
|
||||
t.Fatal("Evaluate() should not be Skipped when at least one sub-evaluator is active")
|
||||
}
|
||||
if got.Value != 1 {
|
||||
t.Errorf("Evaluate() = %v, want 1 (skipped sub-evaluator excluded from weight)", got.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAll_EverythingSkipped(t *testing.T) {
|
||||
skipped := EvaluatorFunc(func(_ context.Context, _ string) Score {
|
||||
return Score{Skipped: true}
|
||||
})
|
||||
e := All(W(skipped, 1))
|
||||
got := e.Evaluate(context.Background(), "anything")
|
||||
if !got.Skipped {
|
||||
t.Error("Evaluate() should be Skipped when every sub-evaluator is skipped")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package eval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// containsAll scores the fraction of substrings present in the response,
|
||||
// case-insensitive. Full credit only when every substring matches.
|
||||
type containsAll struct {
|
||||
substrings []string
|
||||
}
|
||||
|
||||
// ContainsAll returns an Evaluator that awards matched/total credit for how
|
||||
// many of substrings appear (case-insensitive) in the response.
|
||||
func ContainsAll(substrings ...string) Evaluator {
|
||||
return containsAll{substrings: substrings}
|
||||
}
|
||||
|
||||
func (c containsAll) Evaluate(_ context.Context, response string) Score {
|
||||
if len(c.substrings) == 0 {
|
||||
return Score{Value: 1, Detail: "no substrings required"}
|
||||
}
|
||||
lower := strings.ToLower(response)
|
||||
matched := 0
|
||||
var missing []string
|
||||
for _, s := range c.substrings {
|
||||
if strings.Contains(lower, strings.ToLower(s)) {
|
||||
matched++
|
||||
} else {
|
||||
missing = append(missing, s)
|
||||
}
|
||||
}
|
||||
value := float64(matched) / float64(len(c.substrings))
|
||||
detail := fmt.Sprintf("matched %d/%d", matched, len(c.substrings))
|
||||
if len(missing) > 0 {
|
||||
detail += fmt.Sprintf("; missing: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
return Score{Value: value, Detail: detail}
|
||||
}
|
||||
|
||||
// containsAny awards full credit if at least one alternative matches.
|
||||
type containsAny struct {
|
||||
substrings []string
|
||||
}
|
||||
|
||||
// ContainsAny returns an Evaluator that awards full credit if any substring
|
||||
// (case-insensitive) appears in the response, zero otherwise.
|
||||
func ContainsAny(substrings ...string) Evaluator {
|
||||
return containsAny{substrings: substrings}
|
||||
}
|
||||
|
||||
func (c containsAny) Evaluate(_ context.Context, response string) Score {
|
||||
lower := strings.ToLower(response)
|
||||
for _, s := range c.substrings {
|
||||
if strings.Contains(lower, strings.ToLower(s)) {
|
||||
return Score{Value: 1, Detail: fmt.Sprintf("matched %q", s)}
|
||||
}
|
||||
}
|
||||
return Score{Value: 0, Detail: fmt.Sprintf("none of %v matched", c.substrings)}
|
||||
}
|
||||
|
||||
// notContains is a guard: full credit unless a forbidden substring appears.
|
||||
type notContains struct {
|
||||
substrings []string
|
||||
}
|
||||
|
||||
// NotContains returns a composable guard Evaluator: full credit unless the
|
||||
// response contains any of substrings (case-insensitive), in which case it
|
||||
// scores zero. Useful to penalize unsafe/undesired suggestions (e.g.
|
||||
// "kubectl edit").
|
||||
func NotContains(substrings ...string) Evaluator {
|
||||
return notContains{substrings: substrings}
|
||||
}
|
||||
|
||||
func (c notContains) Evaluate(_ context.Context, response string) Score {
|
||||
lower := strings.ToLower(response)
|
||||
for _, s := range c.substrings {
|
||||
if strings.Contains(lower, strings.ToLower(s)) {
|
||||
return Score{Value: 0, Detail: fmt.Sprintf("forbidden %q present", s)}
|
||||
}
|
||||
}
|
||||
return Score{Value: 1, Detail: "no forbidden substrings present"}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package eval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestContainsAll(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
substrings []string
|
||||
want float64
|
||||
}{
|
||||
{"all present", "use launchd with StartInterval", []string{"launchd", "StartInterval"}, 1},
|
||||
{"case insensitive", "use Launchd here", []string{"LAUNCHD"}, 1},
|
||||
{"partial credit", "alpha and beta only", []string{"alpha", "beta", "gamma", "delta"}, 0.5},
|
||||
{"none present", "nothing matches", []string{"x", "y"}, 0},
|
||||
{"empty list", "anything", nil, 1},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ContainsAll(tt.substrings...).Evaluate(context.Background(), tt.response)
|
||||
if got.Value != tt.want {
|
||||
t.Errorf("ContainsAll(%v).Evaluate(%q) = %v, want %v (detail: %s)", tt.substrings, tt.response, got.Value, tt.want, got.Detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainsAny(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
substrings []string
|
||||
want float64
|
||||
}{
|
||||
{"one matches", "install coreutils via brew", []string{"coreutils", "gtimeout"}, 1},
|
||||
{"case insensitive match", "use gtimeout instead", []string{"GTIMEOUT"}, 1},
|
||||
{"none match", "totally unrelated text", []string{"foo", "bar"}, 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ContainsAny(tt.substrings...).Evaluate(context.Background(), tt.response)
|
||||
if got.Value != tt.want {
|
||||
t.Errorf("ContainsAny(%v).Evaluate(%q) = %v, want %v", tt.substrings, tt.response, got.Value, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotContains(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
substrings []string
|
||||
want float64
|
||||
}{
|
||||
{"forbidden absent", "git commit the manifest change", []string{"kubectl edit"}, 1},
|
||||
{"forbidden present", "just run kubectl edit deployment", []string{"kubectl edit"}, 0},
|
||||
{"case insensitive forbidden", "run kubectl patch now", []string{"KUBECTL PATCH"}, 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := NotContains(tt.substrings...).Evaluate(context.Background(), tt.response)
|
||||
if got.Value != tt.want {
|
||||
t.Errorf("NotContains(%v).Evaluate(%q) = %v, want %v", tt.substrings, tt.response, got.Value, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package eval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// equalsEval scores full credit on a trimmed, case-insensitive exact match.
|
||||
type equalsEval struct {
|
||||
want string
|
||||
}
|
||||
|
||||
// Equals returns an Evaluator awarding full credit when the response,
|
||||
// trimmed and lower-cased, equals want (also trimmed and lower-cased).
|
||||
func Equals(want string) Evaluator {
|
||||
return equalsEval{want: want}
|
||||
}
|
||||
|
||||
func (e equalsEval) Evaluate(_ context.Context, response string) Score {
|
||||
got := strings.ToLower(strings.TrimSpace(response))
|
||||
want := strings.ToLower(strings.TrimSpace(e.want))
|
||||
if got == want {
|
||||
return Score{Value: 1, Detail: fmt.Sprintf("equals %q", e.want)}
|
||||
}
|
||||
return Score{Value: 0, Detail: fmt.Sprintf("got %q, want %q", strings.TrimSpace(response), e.want)}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package eval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEquals(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
want string
|
||||
response string
|
||||
wantVal float64
|
||||
}{
|
||||
{"exact match", "patch", "patch", 1},
|
||||
{"case insensitive", "PATCH", "patch", 1},
|
||||
{"trimmed whitespace", "patch", " patch\n", 1},
|
||||
{"mismatch", "patch", "minor", 0},
|
||||
{"substring is not equal", "patch", "patch bump", 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := Equals(tt.want).Evaluate(context.Background(), tt.response)
|
||||
if got.Value != tt.wantVal {
|
||||
t.Errorf("Equals(%q).Evaluate(%q) = %v, want %v", tt.want, tt.response, got.Value, tt.wantVal)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Package eval provides deterministic evaluators that score a (normalized)
|
||||
// LLM response against an expected answer. All evaluators are pure functions
|
||||
// of the response text plus evaluator configuration; none call the network.
|
||||
package eval
|
||||
|
||||
import "context"
|
||||
|
||||
// Score is the outcome of evaluating one response. Value is in [0, 1] unless
|
||||
// Skipped is true (e.g. a required toolchain is missing), in which case
|
||||
// Value carries no meaning and the test is excluded from aggregates.
|
||||
type Score struct {
|
||||
Detail string
|
||||
Value float64
|
||||
Skipped bool
|
||||
}
|
||||
|
||||
// Evaluator scores a normalized response string.
|
||||
type Evaluator interface {
|
||||
Evaluate(ctx context.Context, response string) Score
|
||||
}
|
||||
|
||||
// EvaluatorFunc adapts a plain function to the Evaluator interface.
|
||||
type EvaluatorFunc func(ctx context.Context, response string) Score
|
||||
|
||||
// Evaluate implements Evaluator.
|
||||
func (f EvaluatorFunc) Evaluate(ctx context.Context, response string) Score {
|
||||
return f(ctx, response)
|
||||
}
|
||||
|
||||
// clamp01 constrains v to the closed interval [0, 1].
|
||||
func clamp01(v float64) float64 {
|
||||
switch {
|
||||
case v < 0:
|
||||
return 0
|
||||
case v > 1:
|
||||
return 1
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package eval
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// leadingPackageClausePattern matches a Go source line that is nothing but
|
||||
// a package clause (optionally indented), used to defensively strip a
|
||||
// model's own "package xxx" line before splicing its code into
|
||||
// solution.go. Only the first non-blank line is ever checked/stripped; a
|
||||
// package clause appearing later (inside a string, comment, or as a
|
||||
// genuine syntax error) is left alone.
|
||||
var leadingPackageClausePattern = regexp.MustCompile(`^\s*package\s+\w+\s*$`)
|
||||
|
||||
// stripLeadingPackageClause removes a leading "package xxx" line from code,
|
||||
// if the first non-blank line is one. This lets a model's response be
|
||||
// either a bare code fragment or a full file (package clause + imports +
|
||||
// declarations) and still compile as part of solution.go's package main.
|
||||
func stripLeadingPackageClause(code string) string {
|
||||
lines := strings.Split(code, "\n")
|
||||
for i, line := range lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
if leadingPackageClausePattern.MatchString(line) {
|
||||
return strings.Join(append(lines[:i:i], lines[i+1:]...), "\n")
|
||||
}
|
||||
break
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
// execTimeout bounds every compile/run step of the exec evaluators.
|
||||
const execTimeout = 30 * time.Second
|
||||
|
||||
// CodePlaceholder marks where the extracted response code is substituted
|
||||
// into a harness template.
|
||||
const CodePlaceholder = "{{CODE}}"
|
||||
|
||||
// PassthroughHarness is a harness that runs the extracted code verbatim,
|
||||
// with no surrounding driver. Use it when the response is expected to be a
|
||||
// complete, self-contained program (e.g. a full Python script) rather than
|
||||
// a fragment plugged into a test driver.
|
||||
const PassthroughHarness = CodePlaceholder
|
||||
|
||||
// buildSource substitutes code into harness at CodePlaceholder.
|
||||
func buildSource(harness, code string) string {
|
||||
return strings.ReplaceAll(harness, CodePlaceholder, code)
|
||||
}
|
||||
|
||||
// goEnvCache memoizes `go env GOCACHE`/`go env GOMODCACHE` so every
|
||||
// exec-evaluator call does not pay for an extra subprocess: they are read
|
||||
// once per process and reused.
|
||||
var (
|
||||
goEnvOnce sync.Once
|
||||
goCacheDir string
|
||||
goModCacheDir string
|
||||
)
|
||||
|
||||
// goEnvValue shells out to `go env key` and returns its trimmed output, or
|
||||
// "" if the go tool is unavailable or the call fails.
|
||||
func goEnvValue(key string) string {
|
||||
// #nosec G204 -- key is always one of the two fixed, hardcoded literals
|
||||
// ("GOCACHE"/"GOMODCACHE") passed by minimalExecEnv below, never
|
||||
// response/user-controlled input.
|
||||
out, err := exec.Command("go", "env", key).Output() //nolint:gosec // key is one of two hardcoded literals, see comment above
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
// minimalExecEnv builds a minimal environment allowlist for compiling and
|
||||
// running model-generated code, rather than inheriting the operator's full
|
||||
// environment: PATH (needed to find go/python3/cc and any tool they shell
|
||||
// out to), HOME and TMPDIR pinned to homeDir (an isolated, single-call temp
|
||||
// directory, never the operator's real home), and GOCACHE/GOMODCACHE
|
||||
// inherited from the operator's actual Go environment so `go run` reuses
|
||||
// the existing build/module cache instead of rebuilding it from scratch on
|
||||
// every call.
|
||||
func minimalExecEnv(homeDir string) []string {
|
||||
goEnvOnce.Do(func() {
|
||||
goCacheDir = goEnvValue("GOCACHE")
|
||||
goModCacheDir = goEnvValue("GOMODCACHE")
|
||||
})
|
||||
|
||||
env := []string{
|
||||
"PATH=" + os.Getenv("PATH"),
|
||||
"HOME=" + homeDir,
|
||||
"TMPDIR=" + homeDir,
|
||||
}
|
||||
if goCacheDir != "" {
|
||||
env = append(env, "GOCACHE="+goCacheDir)
|
||||
}
|
||||
if goModCacheDir != "" {
|
||||
env = append(env, "GOMODCACHE="+goModCacheDir)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// mkWorkDirs creates an isolated per-call temp root plus a "work"
|
||||
// subdirectory inside it. Callers write source files into work and run
|
||||
// commands with cmd.Dir = work, while pointing HOME/TMPDIR (via
|
||||
// minimalExecEnv) at root, never at work itself: the Go toolchain
|
||||
// specifically refuses to detect a go.mod when the module directory is
|
||||
// exactly equal to the resolved system temp root ("ignoring go.mod in
|
||||
// system temp root"), which fires precisely when TMPDIR is set to the same
|
||||
// directory `go run` is invoked from. Keeping root and work as distinct
|
||||
// (parent, child) directories avoids that while still confining
|
||||
// HOME/TMPDIR to a directory nothing else uses.
|
||||
func mkWorkDirs(prefix string) (root, work string, err error) {
|
||||
root, err = os.MkdirTemp("", prefix)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
work = filepath.Join(root, "work")
|
||||
if err := os.Mkdir(work, 0o700); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return root, work, nil
|
||||
}
|
||||
|
||||
// checkToolchain reports a Skipped score if name is not on PATH, nil
|
||||
// otherwise.
|
||||
func checkToolchain(name string) *Score {
|
||||
if _, err := exec.LookPath(name); err != nil {
|
||||
s := Score{Skipped: true, Detail: fmt.Sprintf("toolchain missing: %s", name)}
|
||||
return &s
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// compareStdout scores full credit when stdout, trimmed, equals want,
|
||||
// trimmed. cmdErr, when non-nil, always scores zero and surfaces stderr.
|
||||
func compareStdout(stdout, want string, cmdErr error, stderr string) Score {
|
||||
if cmdErr != nil {
|
||||
return Score{Value: 0, Detail: fmt.Sprintf("execution failed: %v; stderr: %s", cmdErr, strings.TrimSpace(stderr))}
|
||||
}
|
||||
got := strings.TrimSpace(stdout)
|
||||
wantTrimmed := strings.TrimSpace(want)
|
||||
if got == wantTrimmed {
|
||||
return Score{Value: 1, Detail: "stdout matches expected"}
|
||||
}
|
||||
return Score{Value: 0, Detail: fmt.Sprintf("stdout = %q, want %q", got, wantTrimmed)}
|
||||
}
|
||||
|
||||
// goRunEval compiles/runs a two-file Go module: solution.go (the model's
|
||||
// code, wrapped in package main) plus harness.go (a complete, independent
|
||||
// driver file supplied by the test author).
|
||||
type goRunEval struct {
|
||||
harness string
|
||||
want string
|
||||
}
|
||||
|
||||
// GoRun returns an Evaluator that writes the response's first fenced Go
|
||||
// code block to its own solution.go (defensively stripping a leading
|
||||
// "package xxx" line and prefixing "package main"), writes harness
|
||||
// verbatim to its own harness.go (a complete file: its own package clause,
|
||||
// imports, and func main), `go run`s both together in an isolated temp
|
||||
// module, and compares trimmed stdout to want. Splitting the model's code
|
||||
// and the harness into separate files means each may import what it needs
|
||||
// independently, instead of splicing code into a single shared import
|
||||
// block. If the `go` toolchain is unavailable, the Score is Skipped rather
|
||||
// than zero.
|
||||
func GoRun(harness, want string) Evaluator {
|
||||
return goRunEval{harness: harness, want: want}
|
||||
}
|
||||
|
||||
func (g goRunEval) Evaluate(ctx context.Context, response string) Score {
|
||||
if s := checkToolchain("go"); s != nil {
|
||||
return *s
|
||||
}
|
||||
|
||||
code := ExtractCodeBlock(response, "go")
|
||||
solution := "package main\n\n" + stripLeadingPackageClause(code)
|
||||
|
||||
root, work, err := mkWorkDirs("llmtb-go-*")
|
||||
if err != nil {
|
||||
return Score{Value: 0, Detail: fmt.Sprintf("mkdtemp: %v", err)}
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(root) }() // best-effort cleanup; failure is not actionable
|
||||
|
||||
if writeErr := os.WriteFile(filepath.Join(work, "solution.go"), []byte(solution), 0o600); writeErr != nil {
|
||||
return Score{Value: 0, Detail: fmt.Sprintf("write solution.go: %v", writeErr)}
|
||||
}
|
||||
if writeErr := os.WriteFile(filepath.Join(work, "harness.go"), []byte(g.harness), 0o600); writeErr != nil {
|
||||
return Score{Value: 0, Detail: fmt.Sprintf("write harness.go: %v", writeErr)}
|
||||
}
|
||||
if modErr := os.WriteFile(filepath.Join(work, "go.mod"), []byte("module llmtbharness\n\ngo 1.23\n"), 0o600); modErr != nil {
|
||||
return Score{Value: 0, Detail: fmt.Sprintf("write go.mod: %v", modErr)}
|
||||
}
|
||||
|
||||
runCtx, cancel := context.WithTimeout(ctx, execTimeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(runCtx, "go", "run", ".")
|
||||
cmd.Dir = work
|
||||
cmd.Env = minimalExecEnv(root)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
err = cmd.Run()
|
||||
|
||||
return compareStdout(stdout.String(), g.want, err, stderr.String())
|
||||
}
|
||||
|
||||
// pyRunEval runs a Python program built from a harness template plus the
|
||||
// extracted Python code block from the response.
|
||||
type pyRunEval struct {
|
||||
harness string
|
||||
want string
|
||||
}
|
||||
|
||||
// PyRun returns an Evaluator that substitutes the response's first fenced
|
||||
// Python code block into harness at CodePlaceholder, runs it with python3,
|
||||
// and compares trimmed stdout to want. If python3 is unavailable, the Score
|
||||
// is Skipped rather than zero.
|
||||
func PyRun(harness, want string) Evaluator {
|
||||
return pyRunEval{harness: harness, want: want}
|
||||
}
|
||||
|
||||
func (p pyRunEval) Evaluate(ctx context.Context, response string) Score {
|
||||
if s := checkToolchain("python3"); s != nil {
|
||||
return *s
|
||||
}
|
||||
|
||||
code := ExtractCodeBlock(response, "python")
|
||||
source := buildSource(p.harness, code)
|
||||
|
||||
root, work, err := mkWorkDirs("llmtb-py-*")
|
||||
if err != nil {
|
||||
return Score{Value: 0, Detail: fmt.Sprintf("mkdtemp: %v", err)}
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(root) }() // best-effort cleanup; failure is not actionable
|
||||
|
||||
scriptPath := filepath.Join(work, "script.py")
|
||||
if writeErr := os.WriteFile(scriptPath, []byte(source), 0o600); writeErr != nil {
|
||||
return Score{Value: 0, Detail: fmt.Sprintf("write script.py: %v", writeErr)}
|
||||
}
|
||||
|
||||
runCtx, cancel := context.WithTimeout(ctx, execTimeout)
|
||||
defer cancel()
|
||||
|
||||
// #nosec G204 -- this is the eval package's whole purpose: compile/run
|
||||
// model-generated code under an isolated temp dir and a minimal env
|
||||
// allowlist (minimalExecEnv). See README's "Requirements" section for
|
||||
// the corresponding operator-facing warning (S13).
|
||||
cmd := exec.CommandContext(runCtx, "python3", scriptPath) //nolint:gosec // see comment above
|
||||
cmd.Dir = work
|
||||
cmd.Env = minimalExecEnv(root)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
err = cmd.Run()
|
||||
|
||||
return compareStdout(stdout.String(), p.want, err, stderr.String())
|
||||
}
|
||||
|
||||
// cRunEval compiles/runs a C program built from a harness template plus the
|
||||
// extracted C code block from the response.
|
||||
type cRunEval struct {
|
||||
harness string
|
||||
want string
|
||||
}
|
||||
|
||||
// CRun returns an Evaluator that substitutes the response's first fenced C
|
||||
// code block into harness at CodePlaceholder, compiles it with cc, runs the
|
||||
// binary, and compares trimmed stdout to want. If cc is unavailable, the
|
||||
// Score is Skipped rather than zero.
|
||||
func CRun(harness, want string) Evaluator {
|
||||
return cRunEval{harness: harness, want: want}
|
||||
}
|
||||
|
||||
func (c cRunEval) Evaluate(ctx context.Context, response string) Score {
|
||||
if s := checkToolchain("cc"); s != nil {
|
||||
return *s
|
||||
}
|
||||
|
||||
code := ExtractCodeBlock(response, "c")
|
||||
source := buildSource(c.harness, code)
|
||||
|
||||
root, work, err := mkWorkDirs("llmtb-c-*")
|
||||
if err != nil {
|
||||
return Score{Value: 0, Detail: fmt.Sprintf("mkdtemp: %v", err)}
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(root) }() // best-effort cleanup; failure is not actionable
|
||||
|
||||
srcPath := filepath.Join(work, "main.c")
|
||||
if writeErr := os.WriteFile(srcPath, []byte(source), 0o600); writeErr != nil {
|
||||
return Score{Value: 0, Detail: fmt.Sprintf("write main.c: %v", writeErr)}
|
||||
}
|
||||
binPath := filepath.Join(work, "prog")
|
||||
|
||||
buildCtx, buildCancel := context.WithTimeout(ctx, execTimeout)
|
||||
defer buildCancel()
|
||||
// #nosec G204 -- compiling model-generated C source under an isolated
|
||||
// temp dir and a minimal env allowlist is this evaluator's job; see the
|
||||
// PyRun comment above and README's "Requirements" warning (S13).
|
||||
buildCmd := exec.CommandContext(buildCtx, "cc", "-o", binPath, srcPath) //nolint:gosec // see comment above
|
||||
buildCmd.Dir = work
|
||||
buildCmd.Env = minimalExecEnv(root)
|
||||
var buildStderr bytes.Buffer
|
||||
buildCmd.Stderr = &buildStderr
|
||||
if buildErr := buildCmd.Run(); buildErr != nil {
|
||||
return Score{Value: 0, Detail: fmt.Sprintf("cc failed: %v; stderr: %s", buildErr, strings.TrimSpace(buildStderr.String()))}
|
||||
}
|
||||
|
||||
runCtx, runCancel := context.WithTimeout(ctx, execTimeout)
|
||||
defer runCancel()
|
||||
// #nosec G204 -- binPath is the binary this same call just compiled,
|
||||
// into a fresh, isolated temp dir this evaluator created; not an
|
||||
// externally supplied executable path.
|
||||
runCmd := exec.CommandContext(runCtx, binPath) //nolint:gosec // see comment above
|
||||
runCmd.Dir = work
|
||||
runCmd.Env = minimalExecEnv(root)
|
||||
var stdout, stderr bytes.Buffer
|
||||
runCmd.Stdout = &stdout
|
||||
runCmd.Stderr = &stderr
|
||||
err = runCmd.Run()
|
||||
|
||||
return compareStdout(stdout.String(), c.want, err, stderr.String())
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package eval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func requireToolchain(t *testing.T, name string) {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath(name); err != nil {
|
||||
t.Skipf("toolchain %s not found on PATH, skipping", name)
|
||||
}
|
||||
}
|
||||
|
||||
// goHarness is a complete, standalone harness.go: its own package clause
|
||||
// and imports, calling Double (defined separately in solution.go, same
|
||||
// package) - no {{CODE}} placeholder, per GoRun's two-file contract.
|
||||
const goHarness = `package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func main() {
|
||||
fmt.Println(Double(21))
|
||||
}
|
||||
`
|
||||
|
||||
func TestGoRun_Pass(t *testing.T) {
|
||||
requireToolchain(t, "go")
|
||||
e := GoRun(goHarness, "42")
|
||||
response := "```go\nfunc Double(n int) int { return n * 2 }\n```"
|
||||
got := e.Evaluate(context.Background(), response)
|
||||
if got.Value != 1 {
|
||||
t.Errorf("Evaluate() = %v, want 1 (detail: %s)", got.Value, got.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoRun_WrongOutput(t *testing.T) {
|
||||
requireToolchain(t, "go")
|
||||
e := GoRun(goHarness, "42")
|
||||
response := "```go\nfunc Double(n int) int { return n + 2 }\n```"
|
||||
got := e.Evaluate(context.Background(), response)
|
||||
if got.Value != 0 {
|
||||
t.Errorf("Evaluate() = %v, want 0 (detail: %s)", got.Value, got.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoRun_CompileError(t *testing.T) {
|
||||
requireToolchain(t, "go")
|
||||
e := GoRun(goHarness, "42")
|
||||
response := "```go\nfunc Double(n int) int { this is not valid go\n```"
|
||||
got := e.Evaluate(context.Background(), response)
|
||||
if got.Value != 0 {
|
||||
t.Errorf("Evaluate() = %v, want 0 for compile error", got.Value)
|
||||
}
|
||||
if got.Skipped {
|
||||
t.Error("Evaluate() should not be Skipped for a compile error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripLeadingPackageClause(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
code string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "leading package clause is stripped",
|
||||
code: "package main\n\nfunc F() {}",
|
||||
want: "\nfunc F() {}",
|
||||
},
|
||||
{
|
||||
name: "blank lines before package clause are tolerated",
|
||||
code: "\n\npackage foo\nfunc F() {}",
|
||||
want: "\n\nfunc F() {}",
|
||||
},
|
||||
{
|
||||
name: "no package clause leaves code untouched",
|
||||
code: "import \"fmt\"\n\nfunc F() {}",
|
||||
want: "import \"fmt\"\n\nfunc F() {}",
|
||||
},
|
||||
{
|
||||
name: "package clause not on the first line is left alone",
|
||||
code: "func F() {}\n\npackage main",
|
||||
want: "func F() {}\n\npackage main",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := stripLeadingPackageClause(tt.code)
|
||||
if got != tt.want {
|
||||
t.Errorf("stripLeadingPackageClause(%q) = %q, want %q", tt.code, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckToolchain_MissingBinaryIsSkipped(t *testing.T) {
|
||||
s := checkToolchain("definitely-not-a-real-toolchain-xyz")
|
||||
if s == nil || !s.Skipped {
|
||||
t.Fatal("checkToolchain() for missing binary should return Skipped score")
|
||||
}
|
||||
}
|
||||
|
||||
const pyPassthroughHarness = PassthroughHarness
|
||||
|
||||
func TestPyRun_Pass(t *testing.T) {
|
||||
requireToolchain(t, "python3")
|
||||
e := PyRun(pyPassthroughHarness, "OOMKilled=2\nImagePullBackOff=1")
|
||||
response := "```python\nprint('OOMKilled=2')\nprint('ImagePullBackOff=1')\n```"
|
||||
got := e.Evaluate(context.Background(), response)
|
||||
if got.Value != 1 {
|
||||
t.Errorf("Evaluate() = %v, want 1 (detail: %s)", got.Value, got.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPyRun_WrongOutput(t *testing.T) {
|
||||
requireToolchain(t, "python3")
|
||||
e := PyRun(pyPassthroughHarness, "OOMKilled=2")
|
||||
response := "```python\nprint('OOMKilled=3')\n```"
|
||||
got := e.Evaluate(context.Background(), response)
|
||||
if got.Value != 0 {
|
||||
t.Errorf("Evaluate() = %v, want 0 (detail: %s)", got.Value, got.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPyRun_RuntimeError(t *testing.T) {
|
||||
requireToolchain(t, "python3")
|
||||
e := PyRun(pyPassthroughHarness, "anything")
|
||||
response := "```python\nraise ValueError('boom')\n```"
|
||||
got := e.Evaluate(context.Background(), response)
|
||||
if got.Value != 0 || got.Skipped {
|
||||
t.Errorf("Evaluate() = %+v, want zero non-skipped score for runtime error", got)
|
||||
}
|
||||
}
|
||||
|
||||
const cHarness = `#include <stdio.h>
|
||||
|
||||
{{CODE}}
|
||||
|
||||
int main(void) {
|
||||
printf("%d\n", triple(7));
|
||||
return 0;
|
||||
}
|
||||
`
|
||||
|
||||
func TestCRun_Pass(t *testing.T) {
|
||||
requireToolchain(t, "cc")
|
||||
e := CRun(cHarness, "21")
|
||||
response := "```c\nint triple(int n) { return n * 3; }\n```"
|
||||
got := e.Evaluate(context.Background(), response)
|
||||
if got.Value != 1 {
|
||||
t.Errorf("Evaluate() = %v, want 1 (detail: %s)", got.Value, got.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCRun_CompileError(t *testing.T) {
|
||||
requireToolchain(t, "cc")
|
||||
e := CRun(cHarness, "21")
|
||||
response := "```c\nthis is not valid c code {{{\n```"
|
||||
got := e.Evaluate(context.Background(), response)
|
||||
if got.Value != 0 {
|
||||
t.Errorf("Evaluate() = %v, want 0 for compile error", got.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecEvaluators_ToolchainMissingSkipsNotFails(t *testing.T) {
|
||||
// This test does not require any toolchain; it verifies the Skipped
|
||||
// contract directly against a bogus PATH so it runs in every environment.
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
|
||||
goScore := GoRun(goHarness, "42").Evaluate(context.Background(), "```go\nfunc Double(n int) int { return n*2 }\n```")
|
||||
if !goScore.Skipped {
|
||||
t.Errorf("GoRun with empty PATH: Skipped = false, want true (%+v)", goScore)
|
||||
}
|
||||
|
||||
pyScore := PyRun(pyPassthroughHarness, "x").Evaluate(context.Background(), "```python\nprint('x')\n```")
|
||||
if !pyScore.Skipped {
|
||||
t.Errorf("PyRun with empty PATH: Skipped = false, want true (%+v)", pyScore)
|
||||
}
|
||||
|
||||
cScore := CRun(cHarness, "21").Evaluate(context.Background(), "```c\nint triple(int n){return n*3;}\n```")
|
||||
if !cScore.Skipped {
|
||||
t.Errorf("CRun with empty PATH: Skipped = false, want true (%+v)", cScore)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package eval
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// fencePattern matches a fenced code block: ```lang\n...body...```
|
||||
var fencePattern = regexp.MustCompile("(?s)```([a-zA-Z0-9_+-]*)\\n?(.*?)```")
|
||||
|
||||
// ExtractCodeBlock returns the first fenced code block tagged with lang
|
||||
// (case-insensitive). If none is tagged, it falls back to the first
|
||||
// untagged fenced block, then to the first fenced block of any language,
|
||||
// then to the whole trimmed response if there are no fences at all.
|
||||
func ExtractCodeBlock(response, lang string) string {
|
||||
matches := fencePattern.FindAllStringSubmatch(response, -1)
|
||||
wantLang := strings.ToLower(strings.TrimSpace(lang))
|
||||
|
||||
var untagged string
|
||||
for _, m := range matches {
|
||||
blockLang := strings.ToLower(strings.TrimSpace(m[1]))
|
||||
if wantLang != "" && blockLang == wantLang {
|
||||
return strings.TrimSpace(m[2])
|
||||
}
|
||||
if untagged == "" && blockLang == "" {
|
||||
untagged = m[2]
|
||||
}
|
||||
}
|
||||
if untagged != "" {
|
||||
return strings.TrimSpace(untagged)
|
||||
}
|
||||
if len(matches) > 0 {
|
||||
return strings.TrimSpace(matches[0][2])
|
||||
}
|
||||
return strings.TrimSpace(response)
|
||||
}
|
||||
|
||||
// jsonFencePattern matches a fenced ```json ... ``` block specifically
|
||||
// (case-insensitive on the "json" language tag).
|
||||
var jsonFencePattern = regexp.MustCompile("(?is)```json\\s*\\n?(.*?)```")
|
||||
|
||||
// findAllBalancedJSON scans text for every top-level, bracket-balanced JSON
|
||||
// object/array literal, in the order they appear. Brackets inside string
|
||||
// literals are ignored. An unbalanced trailing fragment (an opening
|
||||
// bracket with no matching close) is not included.
|
||||
func findAllBalancedJSON(text string) []string {
|
||||
var out []string
|
||||
pos := 0
|
||||
for pos < len(text) {
|
||||
start := -1
|
||||
for i := pos; i < len(text); i++ {
|
||||
if text[i] == '{' || text[i] == '[' {
|
||||
start = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if start == -1 {
|
||||
break
|
||||
}
|
||||
|
||||
depth := 0
|
||||
inString := false
|
||||
escaped := false
|
||||
end := -1
|
||||
for i := start; i < len(text); i++ {
|
||||
c := text[i]
|
||||
if inString {
|
||||
switch {
|
||||
case escaped:
|
||||
escaped = false
|
||||
case c == '\\':
|
||||
escaped = true
|
||||
case c == '"':
|
||||
inString = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch c {
|
||||
case '"':
|
||||
inString = true
|
||||
case '{', '[':
|
||||
depth++
|
||||
case '}', ']':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
end = i
|
||||
}
|
||||
}
|
||||
if end != -1 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if end == -1 {
|
||||
break
|
||||
}
|
||||
out = append(out, text[start:end+1])
|
||||
pos = end + 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ExtractJSON returns response's intended JSON object/array literal,
|
||||
// tolerant of surrounding prose and markdown code fences. It prefers the
|
||||
// content of the first ```json fenced block, if any; otherwise it takes
|
||||
// the LAST balanced JSON value anywhere in response. Taking the last
|
||||
// (rather than the first) value matters for a model that thinks out loud
|
||||
// with a candidate JSON draft before committing to a final answer: the
|
||||
// final JSON, not the draft, is the one to score.
|
||||
func ExtractJSON(response string) (string, error) {
|
||||
if m := jsonFencePattern.FindStringSubmatch(response); m != nil {
|
||||
if all := findAllBalancedJSON(m[1]); len(all) > 0 {
|
||||
return all[0], nil
|
||||
}
|
||||
}
|
||||
|
||||
all := findAllBalancedJSON(response)
|
||||
if len(all) == 0 {
|
||||
return "", fmt.Errorf("no JSON object or array found in response")
|
||||
}
|
||||
return all[len(all)-1], nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package eval
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExtractCodeBlock(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
lang string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "tagged block matches lang",
|
||||
response: "Here:\n```go\nfunc F() {}\n```\nDone.",
|
||||
lang: "go",
|
||||
want: "func F() {}",
|
||||
},
|
||||
{
|
||||
name: "case insensitive lang tag",
|
||||
response: "```GO\nfunc F() {}\n```",
|
||||
lang: "go",
|
||||
want: "func F() {}",
|
||||
},
|
||||
{
|
||||
name: "prefers matching lang over other tagged blocks",
|
||||
response: "```json\n{\"x\":1}\n```\n```go\nfunc F() {}\n```",
|
||||
lang: "go",
|
||||
want: "func F() {}",
|
||||
},
|
||||
{
|
||||
name: "falls back to untagged block",
|
||||
response: "```\nfunc F() {}\n```",
|
||||
lang: "go",
|
||||
want: "func F() {}",
|
||||
},
|
||||
{
|
||||
name: "no fences falls back to whole response",
|
||||
response: " func F() {} ",
|
||||
lang: "go",
|
||||
want: "func F() {}",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ExtractCodeBlock(tt.response, tt.lang)
|
||||
if got != tt.want {
|
||||
t.Errorf("ExtractCodeBlock(%q, %q) = %q, want %q", tt.response, tt.lang, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package eval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// decodeStringArray extracts the first JSON value from response and decodes
|
||||
// it as a []string. Elements that are not strings cause an error.
|
||||
func decodeStringArray(response string) ([]string, error) {
|
||||
raw, err := ExtractJSON(response)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var arr []string
|
||||
if err := json.Unmarshal([]byte(raw), &arr); err != nil {
|
||||
return nil, fmt.Errorf("invalid JSON string array: %w", err)
|
||||
}
|
||||
return arr, nil
|
||||
}
|
||||
|
||||
// jsonStringSetEval scores a JSON array of strings as an unordered set,
|
||||
// case-insensitive and whitespace-trimmed.
|
||||
type jsonStringSetEval struct {
|
||||
want []string
|
||||
}
|
||||
|
||||
// JSONStringSet returns an Evaluator that parses the first JSON array in the
|
||||
// response as a set of strings (case-insensitive, trimmed) and compares it
|
||||
// to want, order-independent. Score is the Jaccard-style overlap
|
||||
// |intersection| / max(len(want), len(got)), so both missing and extraneous
|
||||
// entries cost credit; full credit requires an exact set match.
|
||||
func JSONStringSet(want []string) Evaluator {
|
||||
return jsonStringSetEval{want: want}
|
||||
}
|
||||
|
||||
func normalizeSet(items []string) map[string]struct{} {
|
||||
set := make(map[string]struct{}, len(items))
|
||||
for _, s := range items {
|
||||
set[strings.ToLower(strings.TrimSpace(s))] = struct{}{}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
func (j jsonStringSetEval) Evaluate(_ context.Context, response string) Score {
|
||||
got, err := decodeStringArray(response)
|
||||
if err != nil {
|
||||
return Score{Value: 0, Detail: err.Error()}
|
||||
}
|
||||
|
||||
wantSet := normalizeSet(j.want)
|
||||
gotSet := normalizeSet(got)
|
||||
|
||||
matched := 0
|
||||
for k := range gotSet {
|
||||
if _, ok := wantSet[k]; ok {
|
||||
matched++
|
||||
}
|
||||
}
|
||||
|
||||
denom := len(wantSet)
|
||||
if len(gotSet) > denom {
|
||||
denom = len(gotSet)
|
||||
}
|
||||
if denom == 0 {
|
||||
return Score{Value: 1, Detail: "both sets empty"}
|
||||
}
|
||||
|
||||
value := float64(matched) / float64(denom)
|
||||
return Score{
|
||||
Value: value,
|
||||
Detail: fmt.Sprintf("matched %d/%d (got %v, want %v)", matched, denom, got, j.want),
|
||||
}
|
||||
}
|
||||
|
||||
// jsonStringArrayEqualsEval scores full credit only on an exact, in-order
|
||||
// match of a JSON string array.
|
||||
type jsonStringArrayEqualsEval struct {
|
||||
want []string
|
||||
}
|
||||
|
||||
// JSONStringArrayEquals returns an Evaluator that parses the first JSON
|
||||
// array in the response as []string and awards full credit only when it
|
||||
// exactly equals want element-for-element, in order (case-insensitive,
|
||||
// trimmed). Any mismatch, including reordering, scores zero.
|
||||
func JSONStringArrayEquals(want []string) Evaluator {
|
||||
return jsonStringArrayEqualsEval{want: want}
|
||||
}
|
||||
|
||||
func (j jsonStringArrayEqualsEval) Evaluate(_ context.Context, response string) Score {
|
||||
got, err := decodeStringArray(response)
|
||||
if err != nil {
|
||||
return Score{Value: 0, Detail: err.Error()}
|
||||
}
|
||||
|
||||
if len(got) != len(j.want) {
|
||||
return Score{Value: 0, Detail: fmt.Sprintf("length %d, want %d (got %v, want %v)", len(got), len(j.want), got, j.want)}
|
||||
}
|
||||
for i := range got {
|
||||
if !strings.EqualFold(strings.TrimSpace(got[i]), strings.TrimSpace(j.want[i])) {
|
||||
return Score{Value: 0, Detail: fmt.Sprintf("index %d: got %q, want %q (got %v, want %v)", i, got[i], j.want[i], got, j.want)}
|
||||
}
|
||||
}
|
||||
return Score{Value: 1, Detail: fmt.Sprintf("exact match: %v", got)}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package eval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// pathSegment is one step of a dot/bracket JSON path, e.g. "a.b[0]" is
|
||||
// [{key:"a"} {key:"b"} {index:0, isIndex:true}].
|
||||
type pathSegment struct {
|
||||
key string
|
||||
index int
|
||||
isIndex bool
|
||||
}
|
||||
|
||||
// parsePath splits a path like "a.b[0].c" into segments. An empty path
|
||||
// yields no segments (navigate returns the root value unchanged).
|
||||
func parsePath(path string) ([]pathSegment, error) {
|
||||
var segments []pathSegment
|
||||
if path == "" {
|
||||
return segments, nil
|
||||
}
|
||||
for _, rawPart := range strings.Split(path, ".") {
|
||||
part := rawPart
|
||||
for part != "" {
|
||||
if idx := strings.IndexByte(part, '['); idx >= 0 {
|
||||
if idx > 0 {
|
||||
segments = append(segments, pathSegment{key: part[:idx]})
|
||||
}
|
||||
end := strings.IndexByte(part, ']')
|
||||
if end < idx {
|
||||
return nil, fmt.Errorf("malformed path %q: unmatched '['", path)
|
||||
}
|
||||
n, err := strconv.Atoi(part[idx+1 : end])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("malformed path %q: bad index: %w", path, err)
|
||||
}
|
||||
segments = append(segments, pathSegment{index: n, isIndex: true})
|
||||
part = part[end+1:]
|
||||
continue
|
||||
}
|
||||
segments = append(segments, pathSegment{key: part})
|
||||
part = ""
|
||||
}
|
||||
}
|
||||
return segments, nil
|
||||
}
|
||||
|
||||
// navigate walks v (as decoded by encoding/json into any) following segments.
|
||||
func navigate(v any, segments []pathSegment) (any, error) {
|
||||
cur := v
|
||||
for _, seg := range segments {
|
||||
if seg.isIndex {
|
||||
arr, ok := cur.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected array at index [%d], got %T", seg.index, cur)
|
||||
}
|
||||
if seg.index < 0 || seg.index >= len(arr) {
|
||||
return nil, fmt.Errorf("index [%d] out of range (len %d)", seg.index, len(arr))
|
||||
}
|
||||
cur = arr[seg.index]
|
||||
continue
|
||||
}
|
||||
m, ok := cur.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected object at field %q, got %T", seg.key, cur)
|
||||
}
|
||||
val, ok := m[seg.key]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("field %q not found", seg.key)
|
||||
}
|
||||
cur = val
|
||||
}
|
||||
return cur, nil
|
||||
}
|
||||
|
||||
// jsonValueAs converts a decoded JSON value (string/float64/bool/etc.) to T,
|
||||
// dispatching on the runtime type of the zero value of T.
|
||||
func jsonValueAs[T comparable](v any) (T, error) {
|
||||
var zero T
|
||||
switch any(zero).(type) {
|
||||
case string:
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return zero, fmt.Errorf("expected string, got %T (%v)", v, v)
|
||||
}
|
||||
return any(s).(T), nil
|
||||
case bool:
|
||||
b, ok := v.(bool)
|
||||
if !ok {
|
||||
return zero, fmt.Errorf("expected bool, got %T (%v)", v, v)
|
||||
}
|
||||
return any(b).(T), nil
|
||||
case float64:
|
||||
f, err := asFloat64(v)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
return any(f).(T), nil
|
||||
case int:
|
||||
f, err := asFloat64(v)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
return any(int(f)).(T), nil
|
||||
default:
|
||||
return zero, fmt.Errorf("unsupported JSONField target type %T", zero)
|
||||
}
|
||||
}
|
||||
|
||||
func asFloat64(v any) (float64, error) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n, nil
|
||||
case json.Number:
|
||||
return n.Float64()
|
||||
default:
|
||||
return 0, fmt.Errorf("expected number, got %T (%v)", v, v)
|
||||
}
|
||||
}
|
||||
|
||||
// jsonFieldEval extracts one field by path from the first JSON value in the
|
||||
// response and compares it to want.
|
||||
type jsonFieldEval[T comparable] struct {
|
||||
want T
|
||||
path string
|
||||
}
|
||||
|
||||
// JSONField returns an Evaluator that extracts the first JSON object/array
|
||||
// in the response (stripping surrounding prose and code fences), navigates
|
||||
// path (dot/bracket syntax, e.g. "bump" or "steps[0]"), and awards full
|
||||
// credit when the field equals want. T is inferred from want; supported
|
||||
// types are string, bool, float64, and int. String comparison is
|
||||
// case-insensitive and trims whitespace.
|
||||
func JSONField[T comparable](path string, want T) Evaluator {
|
||||
return jsonFieldEval[T]{path: path, want: want}
|
||||
}
|
||||
|
||||
func (j jsonFieldEval[T]) Evaluate(_ context.Context, response string) Score {
|
||||
raw, err := ExtractJSON(response)
|
||||
if err != nil {
|
||||
return Score{Value: 0, Detail: err.Error()}
|
||||
}
|
||||
|
||||
dec := json.NewDecoder(strings.NewReader(raw))
|
||||
dec.UseNumber()
|
||||
var parsed any
|
||||
if decodeErr := dec.Decode(&parsed); decodeErr != nil {
|
||||
return Score{Value: 0, Detail: fmt.Sprintf("invalid JSON: %v", decodeErr)}
|
||||
}
|
||||
|
||||
segments, err := parsePath(j.path)
|
||||
if err != nil {
|
||||
return Score{Value: 0, Detail: err.Error()}
|
||||
}
|
||||
|
||||
val, err := navigate(parsed, segments)
|
||||
if err != nil {
|
||||
return Score{Value: 0, Detail: fmt.Sprintf("path %q: %v", j.path, err)}
|
||||
}
|
||||
|
||||
got, err := jsonValueAs[T](val)
|
||||
if err != nil {
|
||||
return Score{Value: 0, Detail: fmt.Sprintf("path %q: %v", j.path, err)}
|
||||
}
|
||||
|
||||
if equalJSONValue(got, j.want) {
|
||||
return Score{Value: 1, Detail: fmt.Sprintf("%s = %v", j.path, got)}
|
||||
}
|
||||
return Score{Value: 0, Detail: fmt.Sprintf("%s: got %v, want %v", j.path, got, j.want)}
|
||||
}
|
||||
|
||||
// equalJSONValue compares two values of the same comparable type T, folding
|
||||
// case and whitespace when T is string.
|
||||
func equalJSONValue[T comparable](got, want T) bool {
|
||||
if gs, ok := any(got).(string); ok {
|
||||
ws := any(want).(string)
|
||||
return strings.EqualFold(strings.TrimSpace(gs), strings.TrimSpace(ws))
|
||||
}
|
||||
return got == want
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package eval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"plain object", `{"bump":"minor"}`, `{"bump":"minor"}`, false},
|
||||
{"fenced object", "Here you go:\n```json\n{\"bump\":\"minor\"}\n```", `{"bump":"minor"}`, false},
|
||||
{"object with prose around", `The answer is {"bump":"minor"} as computed.`, `{"bump":"minor"}`, false},
|
||||
{"nested braces ignored inside strings", `{"note":"a { b } c","bump":"patch"}`, `{"note":"a { b } c","bump":"patch"}`, false},
|
||||
{"array", `["ClaudeBot","Googlebot"]`, `["ClaudeBot","Googlebot"]`, false},
|
||||
{"no json", "no json here at all", "", true},
|
||||
// S7: a candidate JSON considered during reasoning, followed by the
|
||||
// real final JSON, must resolve to the final one.
|
||||
{
|
||||
name: "reasoning with a candidate JSON then a final JSON: final wins",
|
||||
response: `Let me think. One option would be {"bump":"major"} but that is not right. The actual answer is {"bump":"minor"}.`,
|
||||
want: `{"bump":"minor"}`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "fenced json block preferred over any other json-looking text before it",
|
||||
response: "Draft: {\"bump\":\"major\"}\n\nFinal answer:\n```json\n{\"bump\":\"minor\"}\n```",
|
||||
want: `{"bump":"minor"}`,
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ExtractJSON(tt.response)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("ExtractJSON(%q) error = %v, wantErr %v", tt.response, err, tt.wantErr)
|
||||
}
|
||||
if err == nil && got != tt.want {
|
||||
t.Errorf("ExtractJSON(%q) = %q, want %q", tt.response, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONField_String(t *testing.T) {
|
||||
e := JSONField("bump", "minor")
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
want float64
|
||||
}{
|
||||
{"exact match", `{"bump":"minor"}`, 1},
|
||||
{"case insensitive", `{"bump":"MINOR"}`, 1},
|
||||
{"wrong value", `{"bump":"patch"}`, 0},
|
||||
{"missing field", `{"other":"x"}`, 0},
|
||||
{"fenced json", "```json\n{\"bump\": \"minor\"}\n```", 1},
|
||||
{"invalid json", "not json at all", 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := e.Evaluate(context.Background(), tt.response)
|
||||
if got.Value != tt.want {
|
||||
t.Errorf("Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONField_Int(t *testing.T) {
|
||||
e := JSONField("M", 16)
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
want float64
|
||||
}{
|
||||
{"exact match", `{"M":16,"efConstruction":200,"efSearch":50}`, 1},
|
||||
{"wrong value", `{"M":32}`, 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := e.Evaluate(context.Background(), tt.response)
|
||||
if got.Value != tt.want {
|
||||
t.Errorf("Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONField_NestedPath(t *testing.T) {
|
||||
e := JSONField("task1", "search_web")
|
||||
got := e.Evaluate(context.Background(), `{"task1":"search_web","task2":"read_file"}`)
|
||||
if got.Value != 1 {
|
||||
t.Errorf("Evaluate() = %v, want 1 (detail: %s)", got.Value, got.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONField_IndexedPath(t *testing.T) {
|
||||
e := JSONField("steps[0]", "build")
|
||||
got := e.Evaluate(context.Background(), `{"steps":["build","test","deploy"]}`)
|
||||
if got.Value != 1 {
|
||||
t.Errorf("Evaluate() = %v, want 1 (detail: %s)", got.Value, got.Detail)
|
||||
}
|
||||
got = e.Evaluate(context.Background(), `{"steps":["test","build","deploy"]}`)
|
||||
if got.Value != 0 {
|
||||
t.Errorf("Evaluate() = %v, want 0 (detail: %s)", got.Value, got.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONStringSet(t *testing.T) {
|
||||
e := JSONStringSet([]string{"ClaudeBot", "Googlebot"})
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
want float64
|
||||
}{
|
||||
{"exact set", `["ClaudeBot","Googlebot"]`, 1},
|
||||
{"exact set different order", `["Googlebot","ClaudeBot"]`, 1},
|
||||
{"case insensitive", `["claudebot","googlebot"]`, 1},
|
||||
{"missing one", `["ClaudeBot"]`, 0.5},
|
||||
{"extra one", `["ClaudeBot","Googlebot","GPTBot"]`, float64(2) / 3},
|
||||
{"completely wrong", `["GPTBot","PerplexityBot"]`, 0},
|
||||
{"invalid json", "no array here", 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := e.Evaluate(context.Background(), tt.response)
|
||||
if got.Value != tt.want {
|
||||
t.Errorf("Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONStringArrayEquals(t *testing.T) {
|
||||
want := []string{"build", "test", "backup", "deploy", "verify"}
|
||||
e := JSONStringArrayEquals(want)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
want float64
|
||||
}{
|
||||
{"exact order", `["build","test","backup","deploy","verify"]`, 1},
|
||||
{"case insensitive exact order", `["BUILD","TEST","BACKUP","DEPLOY","VERIFY"]`, 1},
|
||||
{"wrong order", `["build","backup","test","deploy","verify"]`, 0},
|
||||
{"wrong length", `["build","test"]`, 0},
|
||||
{"invalid json", "not an array", 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := e.Evaluate(context.Background(), tt.response)
|
||||
if got.Value != tt.want {
|
||||
t.Errorf("Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package eval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Number is the set of numeric primitive kinds Numeric can extract and
|
||||
// compare. Defined locally (rather than importing golang.org/x/exp/constraints)
|
||||
// since only gopkg.in/yaml.v3 and golang.org/x/sync/errgroup are allowed deps.
|
||||
type Number interface {
|
||||
~float32 | ~float64 |
|
||||
~int | ~int8 | ~int16 | ~int32 | ~int64 |
|
||||
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64
|
||||
}
|
||||
|
||||
// numberPattern matches signed integers and decimals, including a
|
||||
// leading-dot form with no integer part (".9896"). Longer alternatives are
|
||||
// listed first so the match is not truncated to just the integer part of
|
||||
// "0.9896" or just the dot-less ".9896".
|
||||
var numberPattern = regexp.MustCompile(`-?(?:\d+\.\d+|\.\d+|\d+)`)
|
||||
|
||||
// hyphenCompoundSuffixPattern matches a hyphen directly after a number
|
||||
// joining it to a following word, e.g. the "-bit" in "64-bit" or the
|
||||
// "-byte" in "8-byte". A number immediately followed by this is part of a
|
||||
// unit/architecture descriptor, not a standalone answer value, and is
|
||||
// skipped when picking "the" number out of a line - otherwise "sizeof(...)
|
||||
// is 24 bytes on a 64-bit system" would extract the trailing 64 instead of
|
||||
// the actual answer, 24.
|
||||
var hyphenCompoundSuffixPattern = regexp.MustCompile(`^-\w`)
|
||||
|
||||
// lastRelevantNumber returns the last number in text that is not part of a
|
||||
// "<number>-word" compound, scanning from the end.
|
||||
func lastRelevantNumber(text string) (string, bool) {
|
||||
matches := numberPattern.FindAllStringIndex(text, -1)
|
||||
for i := len(matches) - 1; i >= 0; i-- {
|
||||
start, end := matches[i][0], matches[i][1]
|
||||
if hyphenCompoundSuffixPattern.MatchString(text[end:]) {
|
||||
continue
|
||||
}
|
||||
return text[start:end], true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ExtractLastNumber returns the model's answer number from s, converted to
|
||||
// T. It first looks only at the last non-empty line (the common case: a
|
||||
// final "24" or "0.9896" on its own line after any reasoning), and only
|
||||
// falls back to the last number anywhere in s if that line has no usable
|
||||
// number at all. Within whichever text it searches, it skips a number that
|
||||
// is part of a "<number>-word" compound (see hyphenCompoundSuffixPattern):
|
||||
// "sizeof(struct Config) is 24 bytes on a 64-bit system" must extract 24,
|
||||
// not the later, unrelated 64. It is the default extractor for Numeric.
|
||||
func ExtractLastNumber[T Number](s string) (T, error) {
|
||||
lines := strings.Split(s, "\n")
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
line := strings.TrimSpace(lines[i])
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if lit, ok := lastRelevantNumber(line); ok {
|
||||
return parseNumber[T](lit)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if lit, ok := lastRelevantNumber(s); ok {
|
||||
return parseNumber[T](lit)
|
||||
}
|
||||
return 0, fmt.Errorf("no number found in response")
|
||||
}
|
||||
|
||||
// parseNumber converts a numberPattern match (possibly ".9896", with no
|
||||
// leading zero) to T.
|
||||
func parseNumber[T Number](literal string) (T, error) {
|
||||
f, err := strconv.ParseFloat(literal, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse number %q: %w", literal, err)
|
||||
}
|
||||
return T(f), nil
|
||||
}
|
||||
|
||||
// numericEval scores full credit when the extracted number is within tol of
|
||||
// want, zero otherwise.
|
||||
type numericEval[T Number] struct {
|
||||
extract func(string) (T, error)
|
||||
want T
|
||||
tol T
|
||||
}
|
||||
|
||||
// Numeric returns an Evaluator that extracts a value of type T from the
|
||||
// response via extract and awards full credit when |got-want| <= tol.
|
||||
func Numeric[T Number](extract func(string) (T, error), want, tol T) Evaluator {
|
||||
return numericEval[T]{extract: extract, want: want, tol: tol}
|
||||
}
|
||||
|
||||
func (n numericEval[T]) Evaluate(_ context.Context, response string) Score {
|
||||
got, err := n.extract(response)
|
||||
if err != nil {
|
||||
return Score{Value: 0, Detail: fmt.Sprintf("extract failed: %v", err)}
|
||||
}
|
||||
|
||||
var diff T
|
||||
if got >= n.want {
|
||||
diff = got - n.want
|
||||
} else {
|
||||
diff = n.want - got
|
||||
}
|
||||
|
||||
if diff <= n.tol {
|
||||
return Score{Value: 1, Detail: fmt.Sprintf("got %v, want %v (tol %v)", got, n.want, n.tol)}
|
||||
}
|
||||
return Score{Value: 0, Detail: fmt.Sprintf("got %v, want %v (tol %v)", got, n.want, n.tol)}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package eval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractLastNumber(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
want float64
|
||||
wantErr bool
|
||||
}{
|
||||
{"plain number", "0.8785", 0.8785, false},
|
||||
{"trailing number after prose", "The cosine similarity is 0.8785", 0.8785, false},
|
||||
{"last of multiple numbers", "step 1 then step 2 result is 42", 42, false},
|
||||
{"negative number", "the delta is -3.5", -3.5, false},
|
||||
{"no number", "no digits here", 0, true},
|
||||
// S6 regressions.
|
||||
{"unit qualifier after the answer is skipped", "24 bytes on a 64-bit system", 24, false},
|
||||
{"leading-dot decimal", "= .9896", 0.9896, false},
|
||||
{"answer on last line, reasoning with numbers on earlier lines", "step 1 uses 8 items\nstep 2 uses 16 items\nfinal answer: 42", 42, false},
|
||||
{"last line has only a unit-qualified number, falls back to whole text", "the computation used 100 iterations\nresult on a 64-bit system", 100, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ExtractLastNumber[float64](tt.response)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("ExtractLastNumber(%q) error = %v, wantErr %v", tt.response, err, tt.wantErr)
|
||||
}
|
||||
if err == nil && got != tt.want {
|
||||
t.Errorf("ExtractLastNumber(%q) = %v, want %v", tt.response, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNumeric_Float(t *testing.T) {
|
||||
e := Numeric(ExtractLastNumber[float64], 0.8785, 0.0005)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
want float64
|
||||
}{
|
||||
{"exact", "0.8785", 1},
|
||||
{"within tolerance above", "0.8789", 1},
|
||||
{"within tolerance below", "0.8781", 1},
|
||||
{"outside tolerance", "0.9000", 0},
|
||||
{"unparsable", "no number here", 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := e.Evaluate(context.Background(), tt.response)
|
||||
if got.Value != tt.want {
|
||||
t.Errorf("Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNumeric_IntExact(t *testing.T) {
|
||||
e := Numeric(ExtractLastNumber[int], 32, 0)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
want float64
|
||||
}{
|
||||
{"exact match", "32", 1},
|
||||
{"off by one", "33", 0},
|
||||
{"exact match with prose", "The sizeof value is 32 bytes", 1},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := e.Evaluate(context.Background(), tt.response)
|
||||
if got.Value != tt.want {
|
||||
t.Errorf("Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNumeric_UnsignedNoUnderflow(t *testing.T) {
|
||||
// Regression: unsigned subtraction must not wrap when got < want.
|
||||
e := Numeric(ExtractLastNumber[uint], 10, 2)
|
||||
got := e.Evaluate(context.Background(), "9")
|
||||
if got.Value != 1 {
|
||||
t.Errorf("Evaluate(9) with want=10 tol=2 = %v, want 1 (detail: %s)", got.Value, got.Detail)
|
||||
}
|
||||
got = e.Evaluate(context.Background(), "5")
|
||||
if got.Value != 0 {
|
||||
t.Errorf("Evaluate(5) with want=10 tol=2 = %v, want 0 (detail: %s)", got.Value, got.Detail)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package eval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// regexEval scores full credit when pattern matches the response.
|
||||
type regexEval struct {
|
||||
pattern *regexp.Regexp
|
||||
raw string
|
||||
}
|
||||
|
||||
// Regex returns an Evaluator awarding full credit when pattern matches
|
||||
// anywhere in the response, zero otherwise. pattern must be a valid RE2
|
||||
// expression; it panics on an invalid pattern since evaluators are built
|
||||
// once at catalog registration time, not per-request.
|
||||
func Regex(pattern string) Evaluator {
|
||||
re := regexp.MustCompile(pattern)
|
||||
return regexEval{pattern: re, raw: pattern}
|
||||
}
|
||||
|
||||
func (r regexEval) Evaluate(_ context.Context, response string) Score {
|
||||
if r.pattern.MatchString(response) {
|
||||
return Score{Value: 1, Detail: fmt.Sprintf("matched /%s/", r.raw)}
|
||||
}
|
||||
return Score{Value: 0, Detail: fmt.Sprintf("no match for /%s/", r.raw)}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package eval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRegex(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
response string
|
||||
want float64
|
||||
}{
|
||||
{"matches", `ssh\s+root@10\.0\.0\.100`, "run: ssh [email protected] 'pct exec 251 -- systemctl status myservice'", 1},
|
||||
{"no match", `ssh\s+root@10\.0\.0\.100`, "ssh [email protected]", 0},
|
||||
{"anchored word boundary", `\bpct exec 251\b`, "use pct exec 251 -- cmd", 1},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := Regex(tt.pattern).Evaluate(context.Background(), tt.response)
|
||||
if got.Value != tt.want {
|
||||
t.Errorf("Regex(%q).Evaluate(%q) = %v, want %v", tt.pattern, tt.response, got.Value, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegex_PanicsOnInvalidPattern(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Fatal("Regex() with invalid pattern did not panic")
|
||||
}
|
||||
}()
|
||||
Regex(`(unclosed`)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Package llm defines the OpenAI-compatible chat client interface used to
|
||||
// exercise language models under test, plus a concrete HTTP implementation.
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Message is a single chat message in an OpenAI-compatible conversation.
|
||||
type Message struct {
|
||||
Role string
|
||||
Content string
|
||||
}
|
||||
|
||||
// Request is one chat-completion request sent to a model.
|
||||
type Request struct {
|
||||
Model string
|
||||
Messages []Message
|
||||
MaxTokens int
|
||||
Temperature float64
|
||||
}
|
||||
|
||||
// Response is the normalized result of a chat-completion call.
|
||||
type Response struct {
|
||||
Text string
|
||||
PromptTokens int
|
||||
CompletionTokens int
|
||||
Latency time.Duration
|
||||
}
|
||||
|
||||
// Client completes chat requests against a language model backend.
|
||||
// Implementations must be safe for concurrent use.
|
||||
type Client interface {
|
||||
Complete(ctx context.Context, req Request) (Response, error)
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// maxRetries is the number of retry attempts after the initial request on
|
||||
// transport errors or 5xx responses, per PLAN.md ("retry w/ backoff x2").
|
||||
const maxRetries = 2
|
||||
|
||||
// retryBaseDelay is the base backoff delay; attempt N waits N*retryBaseDelay.
|
||||
const retryBaseDelay = 250 * time.Millisecond
|
||||
|
||||
// OpenAIClient talks to an OpenAI-compatible /v1/chat/completions endpoint.
|
||||
type OpenAIClient struct {
|
||||
httpClient *http.Client
|
||||
endpoint string
|
||||
apiKey string
|
||||
}
|
||||
|
||||
// NewOpenAIClient builds a client against endpoint (e.g.
|
||||
// "https://host/v1") using apiKey (may be empty for keyless gateways) and
|
||||
// per-request timeout.
|
||||
func NewOpenAIClient(endpoint, apiKey string, timeout time.Duration) *OpenAIClient {
|
||||
return &OpenAIClient{
|
||||
endpoint: endpoint,
|
||||
apiKey: apiKey,
|
||||
httpClient: &http.Client{
|
||||
Timeout: timeout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type chatCompletionRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []chatMsg `json:"messages"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
}
|
||||
|
||||
type chatMsg struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type chatCompletionResponse struct {
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
Choices []struct {
|
||||
Message chatMsg `json:"message"`
|
||||
} `json:"choices"`
|
||||
Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
|
||||
// Complete implements Client. It retries transport failures and 5xx
|
||||
// responses up to maxRetries times with linear backoff.
|
||||
func (c *OpenAIClient) Complete(ctx context.Context, req Request) (Response, error) {
|
||||
body := chatCompletionRequest{
|
||||
Model: req.Model,
|
||||
MaxTokens: req.MaxTokens,
|
||||
Temperature: req.Temperature,
|
||||
}
|
||||
for _, m := range req.Messages {
|
||||
body.Messages = append(body.Messages, chatMsg(m))
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return Response{}, fmt.Errorf("llm: marshal request: %w", err)
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
delay := time.Duration(attempt) * retryBaseDelay
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return Response{}, ctx.Err()
|
||||
case <-time.After(delay):
|
||||
}
|
||||
}
|
||||
|
||||
// Measured per-attempt, starting after any backoff delay: Latency
|
||||
// reflects the successful attempt's own generation time, not the
|
||||
// cumulative wait across earlier failed/retried attempts (S8).
|
||||
attemptStart := time.Now()
|
||||
resp, err := c.doRequest(ctx, payload)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
if isRetryable(err) {
|
||||
continue
|
||||
}
|
||||
return Response{}, err
|
||||
}
|
||||
|
||||
latency := time.Since(attemptStart)
|
||||
text := ""
|
||||
if len(resp.Choices) > 0 {
|
||||
text = resp.Choices[0].Message.Content
|
||||
}
|
||||
if resp.Error != nil {
|
||||
lastErr = fmt.Errorf("llm: api error: %s", resp.Error.Message)
|
||||
return Response{}, lastErr
|
||||
}
|
||||
return Response{
|
||||
Text: text,
|
||||
PromptTokens: resp.Usage.PromptTokens,
|
||||
CompletionTokens: resp.Usage.CompletionTokens,
|
||||
Latency: latency,
|
||||
}, nil
|
||||
}
|
||||
return Response{}, fmt.Errorf("llm: request failed after %d attempts: %w", maxRetries+1, lastErr)
|
||||
}
|
||||
|
||||
type retryableError struct {
|
||||
err error
|
||||
status int
|
||||
}
|
||||
|
||||
func (e *retryableError) Error() string {
|
||||
if e.status != 0 {
|
||||
return fmt.Sprintf("llm: server returned status %d", e.status)
|
||||
}
|
||||
return fmt.Sprintf("llm: transport error: %v", e.err)
|
||||
}
|
||||
|
||||
func (e *retryableError) Unwrap() error { return e.err }
|
||||
|
||||
func isRetryable(err error) bool {
|
||||
var re *retryableError
|
||||
return errors.As(err, &re)
|
||||
}
|
||||
|
||||
func (c *OpenAIClient) doRequest(ctx context.Context, payload []byte) (*chatCompletionResponse, error) {
|
||||
url := c.endpoint + "/chat/completions"
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("llm: build request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
if c.apiKey != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
}
|
||||
|
||||
httpResp, err := c.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, &retryableError{err: err}
|
||||
}
|
||||
defer func() { _ = httpResp.Body.Close() }() // best-effort; body already fully read below
|
||||
|
||||
respBody, err := io.ReadAll(httpResp.Body)
|
||||
if err != nil {
|
||||
return nil, &retryableError{err: fmt.Errorf("read body: %w", err)}
|
||||
}
|
||||
|
||||
if httpResp.StatusCode >= 500 {
|
||||
return nil, &retryableError{status: httpResp.StatusCode}
|
||||
}
|
||||
if httpResp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("llm: server returned status %d: %s", httpResp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var out chatCompletionResponse
|
||||
if err := json.Unmarshal(respBody, &out); err != nil {
|
||||
return nil, fmt.Errorf("llm: decode response: %w", err)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// newTestServer spins up a local httptest server; this is not the live
|
||||
// endpoint and is safe for offline unit tests.
|
||||
func newTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(handler)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestOpenAIClient_Complete_Success(t *testing.T) {
|
||||
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/chat/completions" {
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
var body chatCompletionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
if body.Model != "test-model" {
|
||||
t.Errorf("model = %q, want test-model", body.Model)
|
||||
}
|
||||
resp := chatCompletionResponse{}
|
||||
resp.Choices = []struct {
|
||||
Message chatMsg `json:"message"`
|
||||
}{{Message: chatMsg{Role: "assistant", Content: "hello there"}}}
|
||||
resp.Usage.PromptTokens = 12
|
||||
resp.Usage.CompletionTokens = 3
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
})
|
||||
|
||||
client := NewOpenAIClient(srv.URL, "", time.Second)
|
||||
resp, err := client.Complete(context.Background(), Request{
|
||||
Model: "test-model",
|
||||
Messages: []Message{{Role: "user", Content: "hi"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Complete() error = %v", err)
|
||||
}
|
||||
if resp.Text != "hello there" {
|
||||
t.Errorf("Text = %q, want %q", resp.Text, "hello there")
|
||||
}
|
||||
if resp.PromptTokens != 12 || resp.CompletionTokens != 3 {
|
||||
t.Errorf("tokens = %d/%d, want 12/3", resp.PromptTokens, resp.CompletionTokens)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIClient_Complete_SendsTemperatureZero verifies Request.Temperature
|
||||
// (PLAN.md pins this to 0 for deterministic scoring, wired from main.go's
|
||||
// requestTemperature constant) actually lands on the wire as a JSON
|
||||
// "temperature":0 field, not silently omitted (chatCompletionRequest has no
|
||||
// "omitempty" on Temperature specifically so a real zero is distinguishable
|
||||
// from an absent field) (N7).
|
||||
func TestOpenAIClient_Complete_SendsTemperatureZero(t *testing.T) {
|
||||
var rawBody []byte
|
||||
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
rawBody, err = io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read request body: %v", err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(chatCompletionResponse{})
|
||||
})
|
||||
|
||||
client := NewOpenAIClient(srv.URL, "", time.Second)
|
||||
_, err := client.Complete(context.Background(), Request{
|
||||
Model: "m",
|
||||
Messages: []Message{{Role: "user", Content: "x"}},
|
||||
Temperature: 0,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Complete() error = %v", err)
|
||||
}
|
||||
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(rawBody, &decoded); err != nil {
|
||||
t.Fatalf("json.Unmarshal(rawBody) error = %v; body: %s", err, rawBody)
|
||||
}
|
||||
got, present := decoded["temperature"]
|
||||
if !present {
|
||||
t.Fatalf("request body has no \"temperature\" key at all: %s", rawBody)
|
||||
}
|
||||
if got != float64(0) {
|
||||
t.Errorf("temperature = %v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIClient_Complete_RetriesOn5xxThenSucceeds(t *testing.T) {
|
||||
var calls int32
|
||||
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
n := atomic.AddInt32(&calls, 1)
|
||||
if n < 3 {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
resp := chatCompletionResponse{}
|
||||
resp.Choices = []struct {
|
||||
Message chatMsg `json:"message"`
|
||||
}{{Message: chatMsg{Role: "assistant", Content: "ok"}}}
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
})
|
||||
|
||||
client := NewOpenAIClient(srv.URL, "", time.Second)
|
||||
resp, err := client.Complete(context.Background(), Request{Model: "m", Messages: []Message{{Role: "user", Content: "x"}}})
|
||||
if err != nil {
|
||||
t.Fatalf("Complete() error = %v", err)
|
||||
}
|
||||
if resp.Text != "ok" {
|
||||
t.Errorf("Text = %q, want ok", resp.Text)
|
||||
}
|
||||
// S8: Latency must reflect the successful attempt's own generation
|
||||
// time, not the cumulative wait across the two failed attempts plus
|
||||
// their backoff delays (which total 250ms+500ms=750ms for attempts 1
|
||||
// and 2 - see retryBaseDelay/maxRetries).
|
||||
if resp.Latency > 200*time.Millisecond {
|
||||
t.Errorf("Latency = %v, want well under the 750ms of retry backoff (per-attempt measurement, S8)", resp.Latency)
|
||||
}
|
||||
|
||||
if got := atomic.LoadInt32(&calls); got != 3 {
|
||||
t.Errorf("calls = %d, want 3 (1 initial + 2 retries)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIClient_Complete_ExhaustsRetriesOn5xx(t *testing.T) {
|
||||
var calls int32
|
||||
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
})
|
||||
|
||||
client := NewOpenAIClient(srv.URL, "", time.Second)
|
||||
_, err := client.Complete(context.Background(), Request{Model: "m", Messages: []Message{{Role: "user", Content: "x"}}})
|
||||
if err == nil {
|
||||
t.Fatal("Complete() error = nil, want error after exhausting retries")
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != maxRetries+1 {
|
||||
t.Errorf("calls = %d, want %d", got, maxRetries+1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIClient_Complete_NoRetryOn4xx(t *testing.T) {
|
||||
var calls int32
|
||||
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"error":{"message":"bad request"}}`))
|
||||
})
|
||||
|
||||
client := NewOpenAIClient(srv.URL, "", time.Second)
|
||||
_, err := client.Complete(context.Background(), Request{Model: "m", Messages: []Message{{Role: "user", Content: "x"}}})
|
||||
if err == nil {
|
||||
t.Fatal("Complete() error = nil, want error")
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Errorf("calls = %d, want 1 (no retry on 4xx)", got)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "400") {
|
||||
t.Errorf("error = %v, want mention of 400", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIClient_Complete_APIErrorField(t *testing.T) {
|
||||
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"error":{"message":"model overloaded"}}`))
|
||||
})
|
||||
|
||||
client := NewOpenAIClient(srv.URL, "", time.Second)
|
||||
_, err := client.Complete(context.Background(), Request{Model: "m", Messages: []Message{{Role: "user", Content: "x"}}})
|
||||
if err == nil {
|
||||
t.Fatal("Complete() error = nil, want error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "model overloaded") {
|
||||
t.Errorf("error = %v, want mention of model overloaded", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIClient_Complete_ContextCanceled(t *testing.T) {
|
||||
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
})
|
||||
|
||||
client := NewOpenAIClient(srv.URL, "", time.Second)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, err := client.Complete(ctx, Request{Model: "m", Messages: []Message{{Role: "user", Content: "x"}}})
|
||||
if err == nil {
|
||||
t.Fatal("Complete() error = nil, want error for canceled context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIClient_Complete_SendsAPIKey(t *testing.T) {
|
||||
srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer secret-key" {
|
||||
t.Errorf("Authorization = %q, want Bearer secret-key", got)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(chatCompletionResponse{})
|
||||
})
|
||||
|
||||
client := NewOpenAIClient(srv.URL, "secret-key", time.Second)
|
||||
_, err := client.Complete(context.Background(), Request{Model: "m", Messages: []Message{{Role: "user", Content: "x"}}})
|
||||
if err != nil {
|
||||
t.Fatalf("Complete() error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/runner"
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
||||
)
|
||||
|
||||
// jsonResult is the JSON-serializable projection of a runner.Result, joined
|
||||
// with its test's category/subcategory for self-contained output.
|
||||
type jsonResult struct {
|
||||
Model string `json:"model"`
|
||||
TestID string `json:"test_id"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Subcategory string `json:"subcategory,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Score float64 `json:"score"`
|
||||
LatencyMS int64 `json:"latency_ms"`
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
Tokens int `json:"tokens"`
|
||||
Skipped bool `json:"skipped"`
|
||||
}
|
||||
|
||||
// renderJSON dumps raw results as a JSON array, joined with test metadata.
|
||||
func renderJSON(w io.Writer, tests []testkit.Test, results []runner.Result) error {
|
||||
testByID := make(map[string]testkit.Test, len(tests))
|
||||
for _, t := range tests {
|
||||
testByID[t.ID] = t
|
||||
}
|
||||
|
||||
out := make([]jsonResult, 0, len(results))
|
||||
for _, r := range results {
|
||||
t := testByID[r.TestID]
|
||||
jr := jsonResult{
|
||||
Model: r.Model,
|
||||
TestID: r.TestID,
|
||||
Category: t.Category,
|
||||
Subcategory: t.Subcategory,
|
||||
Score: r.Score.Value,
|
||||
Detail: r.Score.Detail,
|
||||
Skipped: r.Score.Skipped,
|
||||
LatencyMS: r.Latency.Milliseconds(),
|
||||
PromptTokens: r.PromptTokens,
|
||||
CompletionTokens: r.CompletionTokens,
|
||||
Tokens: r.TotalTokens(),
|
||||
}
|
||||
if r.Err != nil {
|
||||
jr.Error = r.Err.Error()
|
||||
}
|
||||
out = append(out, jr)
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(out)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/runner"
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
||||
)
|
||||
|
||||
// renderMarkdown writes the same three tables as renderTable, formatted as
|
||||
// GitHub-flavored markdown tables.
|
||||
func renderMarkdown(w io.Writer, tests []testkit.Test, models []string, results []runner.Result) error {
|
||||
idx := indexResults(results)
|
||||
ew := &errWriter{w: w}
|
||||
|
||||
ew.println("## Per-test scores")
|
||||
ew.println()
|
||||
scoreHeader := append([]string{"Test", "Category", "Subcategory"}, models...)
|
||||
writeMarkdownHeader(ew, scoreHeader)
|
||||
for _, t := range sortedTests(tests) {
|
||||
row := []string{t.ID, t.Category, t.Subcategory}
|
||||
for _, model := range models {
|
||||
r, ok := idx[t.ID][model]
|
||||
row = append(row, cellText(r, ok))
|
||||
}
|
||||
writeMarkdownRow(ew, row)
|
||||
}
|
||||
|
||||
ew.println()
|
||||
ew.println("## Category rollup (mean score)")
|
||||
ew.println()
|
||||
catHeader := append([]string{"Category"}, models...)
|
||||
writeMarkdownHeader(ew, catHeader)
|
||||
for _, cm := range categoryRollup(tests, models, idx) {
|
||||
row := []string{cm.category}
|
||||
for _, model := range models {
|
||||
row = append(row, meanCellText(cm.means[model]))
|
||||
}
|
||||
writeMarkdownRow(ew, row)
|
||||
}
|
||||
|
||||
ew.println()
|
||||
ew.println("## Model summary")
|
||||
ew.println()
|
||||
writeMarkdownHeader(ew, []string{"Model", "Mean", "Passed", "Partial", "Failed", "Errors", "Mean latency", "Total tokens", "Tok/s"})
|
||||
for _, ms := range summarize(models, results) {
|
||||
writeMarkdownRow(ew, []string{
|
||||
ms.model,
|
||||
meanCellText(ms.overallMean),
|
||||
fmt.Sprintf("%d", ms.passed),
|
||||
fmt.Sprintf("%d", ms.partial),
|
||||
fmt.Sprintf("%d", ms.failed),
|
||||
fmt.Sprintf("%d", ms.errors),
|
||||
durationText(ms.meanLatency, !math.IsNaN(ms.overallMean)),
|
||||
fmt.Sprintf("%d", ms.totalTokens),
|
||||
rateText(ms.avgTokPerSec),
|
||||
})
|
||||
}
|
||||
|
||||
return ew.err
|
||||
}
|
||||
|
||||
func writeMarkdownHeader(ew *errWriter, cols []string) {
|
||||
ew.println("| " + strings.Join(cols, " | ") + " |")
|
||||
sep := make([]string, len(cols))
|
||||
for i := range sep {
|
||||
sep[i] = "---"
|
||||
}
|
||||
ew.println("| " + strings.Join(sep, " | ") + " |")
|
||||
}
|
||||
|
||||
func writeMarkdownRow(ew *errWriter, cols []string) {
|
||||
ew.println("| " + strings.Join(cols, " | ") + " |")
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
// Package report renders runner.Result sets as a comparison table, GitHub
|
||||
// markdown tables, or raw JSON.
|
||||
package report
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/runner"
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
||||
)
|
||||
|
||||
// Format selects the output renderer.
|
||||
type Format string
|
||||
|
||||
// Supported Format values.
|
||||
const (
|
||||
FormatTable Format = "table"
|
||||
FormatMarkdown Format = "markdown"
|
||||
FormatJSON Format = "json"
|
||||
)
|
||||
|
||||
// passThreshold is the minimum score counted as a full pass in the model
|
||||
// summary table.
|
||||
const passThreshold = 0.99
|
||||
|
||||
// Render writes tests/models/results to w in the requested format.
|
||||
func Render(w io.Writer, format Format, tests []testkit.Test, models []string, results []runner.Result) error {
|
||||
switch format {
|
||||
case FormatTable:
|
||||
return renderTable(w, tests, models, results)
|
||||
case FormatMarkdown:
|
||||
return renderMarkdown(w, tests, models, results)
|
||||
case FormatJSON:
|
||||
return renderJSON(w, tests, results)
|
||||
default:
|
||||
return fmt.Errorf("report: unknown format %q", format)
|
||||
}
|
||||
}
|
||||
|
||||
// resultIndex looks up a Result by testID then model.
|
||||
type resultIndex map[string]map[string]runner.Result
|
||||
|
||||
func indexResults(results []runner.Result) resultIndex {
|
||||
idx := make(resultIndex)
|
||||
for _, r := range results {
|
||||
if idx[r.TestID] == nil {
|
||||
idx[r.TestID] = make(map[string]runner.Result)
|
||||
}
|
||||
idx[r.TestID][r.Model] = r
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
// sortedTests returns tests ordered by Category, Subcategory, then ID, for
|
||||
// deterministic, human-grouped table output.
|
||||
func sortedTests(tests []testkit.Test) []testkit.Test {
|
||||
out := make([]testkit.Test, len(tests))
|
||||
copy(out, tests)
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Category != out[j].Category {
|
||||
return out[i].Category < out[j].Category
|
||||
}
|
||||
if out[i].Subcategory != out[j].Subcategory {
|
||||
return out[i].Subcategory < out[j].Subcategory
|
||||
}
|
||||
return out[i].ID < out[j].ID
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// cellText renders one (test, model) cell for the score table: "score
|
||||
// (Ntok)" for a scored result, or ERR/skip/N/A otherwise.
|
||||
func cellText(r runner.Result, found bool) string {
|
||||
switch {
|
||||
case !found:
|
||||
return "N/A"
|
||||
case r.Err != nil:
|
||||
return "ERR"
|
||||
case r.Score.Skipped:
|
||||
return "skip"
|
||||
default:
|
||||
return fmt.Sprintf("%.2f (%dtok)", r.Score.Value, r.TotalTokens())
|
||||
}
|
||||
}
|
||||
|
||||
// categoryMean holds the per-model mean score for one category, computed
|
||||
// over non-skipped, non-error results only.
|
||||
type categoryMean struct {
|
||||
means map[string]float64
|
||||
category string
|
||||
}
|
||||
|
||||
func categoryRollup(tests []testkit.Test, models []string, idx resultIndex) []categoryMean {
|
||||
order := make([]string, 0)
|
||||
byCategory := make(map[string][]testkit.Test)
|
||||
for _, t := range sortedTests(tests) {
|
||||
if _, seen := byCategory[t.Category]; !seen {
|
||||
order = append(order, t.Category)
|
||||
}
|
||||
byCategory[t.Category] = append(byCategory[t.Category], t)
|
||||
}
|
||||
|
||||
out := make([]categoryMean, 0, len(order))
|
||||
for _, cat := range order {
|
||||
cm := categoryMean{category: cat, means: make(map[string]float64, len(models))}
|
||||
for _, model := range models {
|
||||
var sum float64
|
||||
var n int
|
||||
for _, t := range byCategory[cat] {
|
||||
r, ok := idx[t.ID][model]
|
||||
if !ok || r.Err != nil || r.Score.Skipped {
|
||||
continue
|
||||
}
|
||||
sum += r.Score.Value
|
||||
n++
|
||||
}
|
||||
if n > 0 {
|
||||
cm.means[model] = sum / float64(n)
|
||||
} else {
|
||||
cm.means[model] = math.NaN()
|
||||
}
|
||||
}
|
||||
out = append(out, cm)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// meanCellText renders a categoryMean/modelSummary mean cell, showing N/A
|
||||
// when there is no scored data.
|
||||
func meanCellText(v float64) string {
|
||||
if math.IsNaN(v) {
|
||||
return "N/A"
|
||||
}
|
||||
return fmt.Sprintf("%.2f", v)
|
||||
}
|
||||
|
||||
// rateText renders a modelSummary avgTokPerSec cell, showing N/A when there
|
||||
// is no timed, non-skipped data to compute a rate from.
|
||||
func rateText(v float64) string {
|
||||
if math.IsNaN(v) {
|
||||
return "N/A"
|
||||
}
|
||||
return fmt.Sprintf("%.1f", v)
|
||||
}
|
||||
|
||||
// durationText renders a modelSummary meanLatency cell, showing N/A when
|
||||
// there is no scored data (N6): meanLatency's zero value, printed via
|
||||
// time.Duration.String(), would otherwise misleadingly read as a real "0s"
|
||||
// mean latency rather than "no data". hasData mirrors overallMean's NaN
|
||||
// check, since both are set together (or left unset together) in
|
||||
// summarize.
|
||||
func durationText(d time.Duration, hasData bool) string {
|
||||
if !hasData {
|
||||
return "N/A"
|
||||
}
|
||||
return d.String()
|
||||
}
|
||||
|
||||
// modelSummary aggregates one model's results across the whole run.
|
||||
type modelSummary struct {
|
||||
model string
|
||||
overallMean float64 // math.NaN() when no scored data
|
||||
passed int
|
||||
partial int
|
||||
failed int
|
||||
errors int
|
||||
meanLatency time.Duration
|
||||
totalTokens int
|
||||
avgTokPerSec float64 // math.NaN() when no timed, non-skipped data
|
||||
}
|
||||
|
||||
func summarize(models []string, results []runner.Result) []modelSummary {
|
||||
byModel := make(map[string][]runner.Result, len(models))
|
||||
for _, r := range results {
|
||||
byModel[r.Model] = append(byModel[r.Model], r)
|
||||
}
|
||||
|
||||
out := make([]modelSummary, 0, len(models))
|
||||
for _, model := range models {
|
||||
ms := modelSummary{model: model}
|
||||
var scoreSum float64
|
||||
var scoredCount int
|
||||
var latencySum time.Duration
|
||||
var latencyCount int
|
||||
var completionTokenSum int
|
||||
var latencySecondsSum float64
|
||||
|
||||
for _, r := range byModel[model] {
|
||||
ms.totalTokens += r.TotalTokens()
|
||||
switch {
|
||||
case r.Err != nil:
|
||||
ms.errors++
|
||||
continue
|
||||
case r.Score.Skipped:
|
||||
continue
|
||||
}
|
||||
|
||||
scoreSum += r.Score.Value
|
||||
scoredCount++
|
||||
latencySum += r.Latency
|
||||
latencyCount++
|
||||
completionTokenSum += r.CompletionTokens
|
||||
latencySecondsSum += r.Latency.Seconds()
|
||||
|
||||
switch {
|
||||
case r.Score.Value >= passThreshold:
|
||||
ms.passed++
|
||||
case r.Score.Value <= 0:
|
||||
ms.failed++
|
||||
default:
|
||||
ms.partial++
|
||||
}
|
||||
}
|
||||
|
||||
if scoredCount > 0 {
|
||||
ms.overallMean = scoreSum / float64(scoredCount)
|
||||
ms.meanLatency = latencySum / time.Duration(latencyCount)
|
||||
} else {
|
||||
ms.overallMean = math.NaN()
|
||||
}
|
||||
|
||||
// Aggregate ratio (sum of completion tokens over sum of elapsed
|
||||
// seconds), not a mean of per-test ratios: robust to a handful of
|
||||
// very short responses skewing a per-test-averaged rate.
|
||||
if latencySecondsSum > 0 {
|
||||
ms.avgTokPerSec = float64(completionTokenSum) / latencySecondsSum
|
||||
} else {
|
||||
ms.avgTokPerSec = math.NaN()
|
||||
}
|
||||
|
||||
out = append(out, ms)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/runner"
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
||||
)
|
||||
|
||||
func sampleTests() []testkit.Test {
|
||||
return []testkit.Test{
|
||||
{ID: "go-1", Category: "programming", Subcategory: "golang"},
|
||||
{ID: "py-1", Category: "programming", Subcategory: "python"},
|
||||
{ID: "k8s-1", Category: "operations", Subcategory: "kubernetes"},
|
||||
}
|
||||
}
|
||||
|
||||
func sampleResults() []runner.Result {
|
||||
return []runner.Result{
|
||||
{Model: "m1", TestID: "go-1", Score: eval.Score{Value: 1}, Latency: 100 * time.Millisecond, PromptTokens: 6, CompletionTokens: 4},
|
||||
{Model: "m1", TestID: "py-1", Score: eval.Score{Value: 0.5}, Latency: 200 * time.Millisecond, PromptTokens: 12, CompletionTokens: 8},
|
||||
{Model: "m1", TestID: "k8s-1", Err: errors.New("boom")},
|
||||
{Model: "m2", TestID: "go-1", Score: eval.Score{Value: 0}, Latency: 50 * time.Millisecond, PromptTokens: 3, CompletionTokens: 2},
|
||||
{Model: "m2", TestID: "py-1", Score: eval.Score{Skipped: true, Detail: "toolchain missing: cc"}},
|
||||
{Model: "m2", TestID: "k8s-1", Score: eval.Score{Value: 1}, Latency: 150 * time.Millisecond, PromptTokens: 9, CompletionTokens: 6},
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTable_ContainsExpectedCells(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := Render(&buf, FormatTable, sampleTests(), []string{"m1", "m2"}, sampleResults()); err != nil {
|
||||
t.Fatalf("Render() error = %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
|
||||
for _, want := range []string{
|
||||
"== Per-test scores ==",
|
||||
"== Category rollup (mean score) ==",
|
||||
"== Model summary ==",
|
||||
"1.00 (10tok)", // m1/go-1: 6 prompt + 4 completion
|
||||
"0.50 (20tok)", // m1/py-1: 12 prompt + 8 completion
|
||||
"ERR", // m1/k8s-1
|
||||
"0.00 (5tok)", // m2/go-1: 3 prompt + 2 completion
|
||||
"skip", // m2/py-1
|
||||
"TOK/S",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("table output missing %q\n---\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdown_ProducesGitHubTables(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := Render(&buf, FormatMarkdown, sampleTests(), []string{"m1", "m2"}, sampleResults()); err != nil {
|
||||
t.Fatalf("Render() error = %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
|
||||
for _, want := range []string{
|
||||
"## Per-test scores",
|
||||
"| Test | Category | Subcategory | m1 | m2 |",
|
||||
"| --- | --- | --- | --- | --- |",
|
||||
"## Category rollup (mean score)",
|
||||
"## Model summary",
|
||||
"Tok/s",
|
||||
"1.00 (10tok)",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("markdown output missing %q\n---\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderJSON_DumpsRawResults(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := Render(&buf, FormatJSON, sampleTests(), []string{"m1", "m2"}, sampleResults()); err != nil {
|
||||
t.Fatalf("Render() error = %v", err)
|
||||
}
|
||||
|
||||
var out []jsonResult
|
||||
if err := json.Unmarshal(buf.Bytes(), &out); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v; output:\n%s", err, buf.String())
|
||||
}
|
||||
if len(out) != len(sampleResults()) {
|
||||
t.Fatalf("len(out) = %d, want %d", len(out), len(sampleResults()))
|
||||
}
|
||||
|
||||
var errEntry, skipEntry, tokenEntry *jsonResult
|
||||
for i := range out {
|
||||
if out[i].Error != "" {
|
||||
errEntry = &out[i]
|
||||
}
|
||||
if out[i].Skipped {
|
||||
skipEntry = &out[i]
|
||||
}
|
||||
if out[i].Model == "m1" && out[i].TestID == "go-1" {
|
||||
tokenEntry = &out[i]
|
||||
}
|
||||
}
|
||||
if errEntry == nil || errEntry.Error != "boom" {
|
||||
t.Errorf("expected an entry with Error = \"boom\", got %+v", errEntry)
|
||||
}
|
||||
if skipEntry == nil || skipEntry.Category != "programming" {
|
||||
t.Errorf("expected skipped entry to carry Category, got %+v", skipEntry)
|
||||
}
|
||||
if tokenEntry == nil || tokenEntry.PromptTokens != 6 || tokenEntry.CompletionTokens != 4 || tokenEntry.Tokens != 10 {
|
||||
t.Errorf("expected m1/go-1 to carry prompt=6 completion=4 total=10 tokens, got %+v", tokenEntry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_UnknownFormat(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := Render(&buf, Format("bogus"), sampleTests(), []string{"m1"}, sampleResults())
|
||||
if err == nil {
|
||||
t.Fatal("Render() error = nil, want error for unknown format")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarize_Buckets(t *testing.T) {
|
||||
results := []runner.Result{
|
||||
{Model: "m1", TestID: "a", Score: eval.Score{Value: 1}},
|
||||
{Model: "m1", TestID: "b", Score: eval.Score{Value: 0.5}},
|
||||
{Model: "m1", TestID: "c", Score: eval.Score{Value: 0}},
|
||||
{Model: "m1", TestID: "d", Err: errors.New("fail")},
|
||||
{Model: "m1", TestID: "e", Score: eval.Score{Skipped: true}},
|
||||
}
|
||||
summaries := summarize([]string{"m1"}, results)
|
||||
if len(summaries) != 1 {
|
||||
t.Fatalf("summarize() len = %d, want 1", len(summaries))
|
||||
}
|
||||
ms := summaries[0]
|
||||
if ms.passed != 1 || ms.partial != 1 || ms.failed != 1 || ms.errors != 1 {
|
||||
t.Errorf("buckets = passed:%d partial:%d failed:%d errors:%d, want 1,1,1,1", ms.passed, ms.partial, ms.failed, ms.errors)
|
||||
}
|
||||
wantMean := (1.0 + 0.5 + 0.0) / 3.0
|
||||
if ms.overallMean != wantMean {
|
||||
t.Errorf("overallMean = %v, want %v", ms.overallMean, wantMean)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarize_AvgTokPerSec(t *testing.T) {
|
||||
// Aggregate ratio (sum completion tokens / sum latency seconds), not a
|
||||
// mean of per-test ratios: a fast, short response (10 tok / 0.1s = 100
|
||||
// tok/s) and a slow, long one (100 tok / 2s = 50 tok/s) must average to
|
||||
// 110 tok / 2.1s, not (100+50)/2.
|
||||
results := []runner.Result{
|
||||
{Model: "m1", TestID: "fast", Score: eval.Score{Value: 1}, Latency: 100 * time.Millisecond, CompletionTokens: 10},
|
||||
{Model: "m1", TestID: "slow", Score: eval.Score{Value: 1}, Latency: 2 * time.Second, CompletionTokens: 100},
|
||||
{Model: "m1", TestID: "err", Err: errors.New("fail"), Latency: time.Second, CompletionTokens: 999},
|
||||
{Model: "m1", TestID: "skipped", Score: eval.Score{Skipped: true}, Latency: time.Second, CompletionTokens: 999},
|
||||
}
|
||||
summaries := summarize([]string{"m1"}, results)
|
||||
ms := summaries[0]
|
||||
|
||||
want := 110.0 / 2.1
|
||||
if math.Abs(ms.avgTokPerSec-want) > 1e-9 {
|
||||
t.Errorf("avgTokPerSec = %v, want %v (errored/skipped results must be excluded)", ms.avgTokPerSec, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarize_AvgTokPerSec_NoDataIsNaN(t *testing.T) {
|
||||
results := []runner.Result{
|
||||
{Model: "m1", TestID: "err", Err: errors.New("fail")},
|
||||
{Model: "m1", TestID: "skipped", Score: eval.Score{Skipped: true}},
|
||||
}
|
||||
summaries := summarize([]string{"m1"}, results)
|
||||
if !math.IsNaN(summaries[0].avgTokPerSec) {
|
||||
t.Errorf("avgTokPerSec = %v, want NaN when there is no timed data", summaries[0].avgTokPerSec)
|
||||
}
|
||||
if rateText(summaries[0].avgTokPerSec) != "N/A" {
|
||||
t.Errorf("rateText(NaN) = %q, want N/A", rateText(summaries[0].avgTokPerSec))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurationText(t *testing.T) {
|
||||
if got := durationText(0, false); got != "N/A" {
|
||||
t.Errorf("durationText(0, false) = %q, want N/A", got)
|
||||
}
|
||||
if got := durationText(0, true); got != "0s" {
|
||||
t.Errorf("durationText(0, true) = %q, want 0s (a real zero-latency measurement, not lack of data)", got)
|
||||
}
|
||||
if got := durationText(42*time.Millisecond, true); got != "42ms" {
|
||||
t.Errorf("durationText(42ms, true) = %q, want 42ms", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarize_MeanLatency_NoDataIsNA(t *testing.T) {
|
||||
// N6 regression: a model with only errored/skipped results (no scored
|
||||
// data at all) must render MEAN_LATENCY as N/A, not the zero Duration's
|
||||
// misleading "0s".
|
||||
results := []runner.Result{
|
||||
{Model: "m1", TestID: "err", Err: errors.New("fail")},
|
||||
{Model: "m1", TestID: "skipped", Score: eval.Score{Skipped: true}},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := Render(&buf, FormatTable, []testkit.Test{{ID: "err"}, {ID: "skipped"}}, []string{"m1"}, results); err != nil {
|
||||
t.Fatalf("Render() error = %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "N/A") {
|
||||
t.Errorf("table output missing N/A for mean latency with no scored data:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "0s") {
|
||||
t.Errorf("table output should not render a misleading 0s mean latency:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCategoryRollup_ExcludesErrorsAndSkips(t *testing.T) {
|
||||
tests := []testkit.Test{
|
||||
{ID: "a", Category: "cat"},
|
||||
{ID: "b", Category: "cat"},
|
||||
{ID: "c", Category: "cat"},
|
||||
}
|
||||
results := []runner.Result{
|
||||
{Model: "m1", TestID: "a", Score: eval.Score{Value: 1}},
|
||||
{Model: "m1", TestID: "b", Err: errors.New("fail")},
|
||||
{Model: "m1", TestID: "c", Score: eval.Score{Skipped: true}},
|
||||
}
|
||||
idx := indexResults(results)
|
||||
rollup := categoryRollup(tests, []string{"m1"}, idx)
|
||||
if len(rollup) != 1 {
|
||||
t.Fatalf("categoryRollup() len = %d, want 1", len(rollup))
|
||||
}
|
||||
if rollup[0].means["m1"] != 1 {
|
||||
t.Errorf("mean = %v, want 1 (error/skip excluded)", rollup[0].means["m1"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/runner"
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
||||
)
|
||||
|
||||
// errWriter wraps an io.Writer and remembers the first write error, so a
|
||||
// sequence of Fprint* calls can be written without checking after every
|
||||
// line; the caller checks ew.err once at the end.
|
||||
type errWriter struct {
|
||||
w io.Writer
|
||||
err error
|
||||
}
|
||||
|
||||
func (ew *errWriter) println(args ...any) {
|
||||
if ew.err != nil {
|
||||
return
|
||||
}
|
||||
_, ew.err = fmt.Fprintln(ew.w, args...)
|
||||
}
|
||||
|
||||
// renderTable writes three plain-text tables: per-test scores, category
|
||||
// rollup, and model summary.
|
||||
func renderTable(w io.Writer, tests []testkit.Test, models []string, results []runner.Result) error {
|
||||
idx := indexResults(results)
|
||||
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
|
||||
ew := &errWriter{w: tw}
|
||||
|
||||
writeScoreTable(ew, tests, models, idx)
|
||||
ew.println()
|
||||
writeCategoryTable(ew, tests, models, idx)
|
||||
ew.println()
|
||||
writeSummaryTable(ew, models, results)
|
||||
if ew.err != nil {
|
||||
return ew.err
|
||||
}
|
||||
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
func writeScoreTable(ew *errWriter, tests []testkit.Test, models []string, idx resultIndex) {
|
||||
ew.println("== Per-test scores ==")
|
||||
header := []string{"TEST", "CATEGORY", "SUBCATEGORY"}
|
||||
header = append(header, models...)
|
||||
ew.println(strings.Join(header, "\t"))
|
||||
|
||||
for _, t := range sortedTests(tests) {
|
||||
row := []string{t.ID, t.Category, t.Subcategory}
|
||||
for _, model := range models {
|
||||
r, ok := idx[t.ID][model]
|
||||
row = append(row, cellText(r, ok))
|
||||
}
|
||||
ew.println(strings.Join(row, "\t"))
|
||||
}
|
||||
}
|
||||
|
||||
func writeCategoryTable(ew *errWriter, tests []testkit.Test, models []string, idx resultIndex) {
|
||||
ew.println("== Category rollup (mean score) ==")
|
||||
header := append([]string{"CATEGORY"}, models...)
|
||||
ew.println(strings.Join(header, "\t"))
|
||||
|
||||
for _, cm := range categoryRollup(tests, models, idx) {
|
||||
row := []string{cm.category}
|
||||
for _, model := range models {
|
||||
row = append(row, meanCellText(cm.means[model]))
|
||||
}
|
||||
ew.println(strings.Join(row, "\t"))
|
||||
}
|
||||
}
|
||||
|
||||
func writeSummaryTable(ew *errWriter, models []string, results []runner.Result) {
|
||||
ew.println("== Model summary ==")
|
||||
ew.println(strings.Join([]string{"MODEL", "MEAN", "PASSED", "PARTIAL", "FAILED", "ERRORS", "MEAN_LATENCY", "TOTAL_TOKENS", "TOK/S"}, "\t"))
|
||||
|
||||
for _, ms := range summarize(models, results) {
|
||||
row := []string{
|
||||
ms.model,
|
||||
meanCellText(ms.overallMean),
|
||||
fmt.Sprintf("%d", ms.passed),
|
||||
fmt.Sprintf("%d", ms.partial),
|
||||
fmt.Sprintf("%d", ms.failed),
|
||||
fmt.Sprintf("%d", ms.errors),
|
||||
durationText(ms.meanLatency, !math.IsNaN(ms.overallMean)),
|
||||
fmt.Sprintf("%d", ms.totalTokens),
|
||||
rateText(ms.avgTokPerSec),
|
||||
}
|
||||
ew.println(strings.Join(row, "\t"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Package runner fans a test catalog out across models with bounded
|
||||
// concurrency and collects per-(model,test) results.
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/llm"
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
||||
)
|
||||
|
||||
// Result is the outcome of running one test against one model.
|
||||
type Result struct {
|
||||
Err error
|
||||
Model string
|
||||
TestID string
|
||||
Score eval.Score
|
||||
Latency time.Duration
|
||||
PromptTokens int
|
||||
CompletionTokens int
|
||||
}
|
||||
|
||||
// TotalTokens returns PromptTokens + CompletionTokens, the total token
|
||||
// usage billed for this (model, test) call.
|
||||
func (r Result) TotalTokens() int {
|
||||
return r.PromptTokens + r.CompletionTokens
|
||||
}
|
||||
|
||||
// Config controls how the Runner fans work out.
|
||||
type Config struct {
|
||||
// Concurrency bounds the number of in-flight (model,test) calls.
|
||||
Concurrency int
|
||||
// Temperature is sent on every request; PLAN.md pins this to 0 for
|
||||
// determinism across runs.
|
||||
Temperature float64
|
||||
// MaxTokensDefault is used when a Test does not set its own MaxTokens.
|
||||
MaxTokensDefault int
|
||||
}
|
||||
|
||||
// Runner executes a set of tests against a set of models.
|
||||
type Runner struct {
|
||||
client llm.Client
|
||||
cfg Config
|
||||
}
|
||||
|
||||
// New builds a Runner that issues requests through client.
|
||||
func New(client llm.Client, cfg Config) *Runner {
|
||||
return &Runner{client: client, cfg: cfg}
|
||||
}
|
||||
|
||||
// Run executes every combination of models x tests, bounded by
|
||||
// cfg.Concurrency in-flight calls, and returns one Result per combination.
|
||||
// A per-call failure is captured in Result.Err rather than aborting the run.
|
||||
func (r *Runner) Run(ctx context.Context, models []string, tests []testkit.Test) []Result {
|
||||
type job struct {
|
||||
model string
|
||||
test testkit.Test
|
||||
}
|
||||
|
||||
jobs := make([]job, 0, len(models)*len(tests))
|
||||
for _, m := range models {
|
||||
for _, t := range tests {
|
||||
jobs = append(jobs, job{model: m, test: t})
|
||||
}
|
||||
}
|
||||
|
||||
results := make([]Result, len(jobs))
|
||||
|
||||
g, gctx := errgroup.WithContext(ctx)
|
||||
concurrency := r.cfg.Concurrency
|
||||
if concurrency <= 0 {
|
||||
concurrency = 1
|
||||
}
|
||||
g.SetLimit(concurrency)
|
||||
|
||||
// go.mod's go directive is 1.22+, so loop variables are already
|
||||
// per-iteration scoped; no manual i, j := i, j shadowing needed (N4).
|
||||
for i, j := range jobs {
|
||||
g.Go(func() error {
|
||||
results[i] = r.runOne(gctx, j.model, j.test)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
// Errors are captured per-result, not surfaced from Run; g.Wait() only
|
||||
// ever returns non-nil if a goroutine itself returned an error, which
|
||||
// runOne never does.
|
||||
_ = g.Wait()
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// runOne executes a single (model, test) combination.
|
||||
func (r *Runner) runOne(ctx context.Context, model string, test testkit.Test) Result {
|
||||
maxTokens := test.MaxTokens
|
||||
if maxTokens <= 0 {
|
||||
maxTokens = r.cfg.MaxTokensDefault
|
||||
}
|
||||
|
||||
messages := make([]llm.Message, 0, 2)
|
||||
if test.System != "" {
|
||||
messages = append(messages, llm.Message{Role: "system", Content: test.System})
|
||||
}
|
||||
messages = append(messages, llm.Message{Role: "user", Content: test.Prompt})
|
||||
|
||||
req := llm.Request{
|
||||
Model: model,
|
||||
Messages: messages,
|
||||
MaxTokens: maxTokens,
|
||||
Temperature: r.cfg.Temperature,
|
||||
}
|
||||
|
||||
resp, err := r.client.Complete(ctx, req)
|
||||
if err != nil {
|
||||
return Result{Model: model, TestID: test.ID, Err: err}
|
||||
}
|
||||
|
||||
normalized := testkit.Normalize(resp.Text)
|
||||
score := test.Eval.Evaluate(ctx, normalized)
|
||||
|
||||
return Result{
|
||||
Model: model,
|
||||
TestID: test.ID,
|
||||
Score: score,
|
||||
Latency: resp.Latency,
|
||||
PromptTokens: resp.PromptTokens,
|
||||
CompletionTokens: resp.CompletionTokens,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/llm"
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
||||
)
|
||||
|
||||
// mockClient is an offline llm.Client for unit tests; it never touches the
|
||||
// network.
|
||||
type mockClient struct {
|
||||
respond func(req llm.Request) (llm.Response, error)
|
||||
|
||||
inFlight int32
|
||||
maxInFlight int32
|
||||
}
|
||||
|
||||
func (m *mockClient) Complete(_ context.Context, req llm.Request) (llm.Response, error) {
|
||||
cur := atomic.AddInt32(&m.inFlight, 1)
|
||||
for {
|
||||
max := atomic.LoadInt32(&m.maxInFlight)
|
||||
if cur <= max || atomic.CompareAndSwapInt32(&m.maxInFlight, max, cur) {
|
||||
break
|
||||
}
|
||||
}
|
||||
defer atomic.AddInt32(&m.inFlight, -1)
|
||||
|
||||
if m.respond != nil {
|
||||
return m.respond(req)
|
||||
}
|
||||
return llm.Response{Text: "OK"}, nil
|
||||
}
|
||||
|
||||
func echoEval() eval.Evaluator {
|
||||
return eval.EvaluatorFunc(func(_ context.Context, response string) eval.Score {
|
||||
if response == "OK" {
|
||||
return eval.Score{Value: 1}
|
||||
}
|
||||
return eval.Score{Value: 0, Detail: "got " + response}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunner_Run_FansOutAllCombinations(t *testing.T) {
|
||||
client := &mockClient{}
|
||||
r := New(client, Config{Concurrency: 4, MaxTokensDefault: 100})
|
||||
|
||||
tests := []testkit.Test{
|
||||
{ID: "t1", Category: "c", Prompt: "p1", Eval: echoEval()},
|
||||
{ID: "t2", Category: "c", Prompt: "p2", Eval: echoEval()},
|
||||
}
|
||||
models := []string{"model-a", "model-b", "model-c"}
|
||||
|
||||
results := r.Run(context.Background(), models, tests)
|
||||
|
||||
if len(results) != len(models)*len(tests) {
|
||||
t.Fatalf("Run() returned %d results, want %d", len(results), len(models)*len(tests))
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
for _, res := range results {
|
||||
seen[res.Model+"/"+res.TestID] = true
|
||||
if res.Err != nil {
|
||||
t.Errorf("Result.Err = %v, want nil", res.Err)
|
||||
}
|
||||
if res.Score.Value != 1 {
|
||||
t.Errorf("Result.Score.Value = %v, want 1", res.Score.Value)
|
||||
}
|
||||
}
|
||||
for _, m := range models {
|
||||
for _, tc := range tests {
|
||||
key := m + "/" + tc.ID
|
||||
if !seen[key] {
|
||||
t.Errorf("Run() missing combination %s", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_Run_RespectsConcurrencyLimit(t *testing.T) {
|
||||
client := &mockClient{
|
||||
respond: func(_ llm.Request) (llm.Response, error) {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
return llm.Response{Text: "OK"}, nil
|
||||
},
|
||||
}
|
||||
const limit = 2
|
||||
r := New(client, Config{Concurrency: limit, MaxTokensDefault: 100})
|
||||
|
||||
var tests []testkit.Test
|
||||
for i := range 8 {
|
||||
tests = append(tests, testkit.Test{ID: string(rune('a' + i)), Category: "c", Prompt: "p", Eval: echoEval()})
|
||||
}
|
||||
|
||||
r.Run(context.Background(), []string{"model-a"}, tests)
|
||||
|
||||
if got := atomic.LoadInt32(&client.maxInFlight); got > limit {
|
||||
t.Errorf("max in-flight calls = %d, want <= %d", got, limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_Run_CapturesPerCallError(t *testing.T) {
|
||||
wantErr := errors.New("transport failure")
|
||||
client := &mockClient{
|
||||
respond: func(_ llm.Request) (llm.Response, error) {
|
||||
return llm.Response{}, wantErr
|
||||
},
|
||||
}
|
||||
r := New(client, Config{Concurrency: 2, MaxTokensDefault: 100})
|
||||
|
||||
results := r.Run(context.Background(), []string{"model-a"}, []testkit.Test{
|
||||
{ID: "t1", Category: "c", Prompt: "p", Eval: echoEval()},
|
||||
})
|
||||
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("Run() returned %d results, want 1", len(results))
|
||||
}
|
||||
if !errors.Is(results[0].Err, wantErr) {
|
||||
t.Errorf("Result.Err = %v, want %v", results[0].Err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_Run_NormalizesResponseBeforeEval(t *testing.T) {
|
||||
client := &mockClient{
|
||||
respond: func(_ llm.Request) (llm.Response, error) {
|
||||
return llm.Response{Text: "<think>internal reasoning</think>\n\nOK"}, nil
|
||||
},
|
||||
}
|
||||
r := New(client, Config{Concurrency: 1, MaxTokensDefault: 100})
|
||||
|
||||
results := r.Run(context.Background(), []string{"model-a"}, []testkit.Test{
|
||||
{ID: "t1", Category: "c", Prompt: "p", Eval: echoEval()},
|
||||
})
|
||||
|
||||
if results[0].Score.Value != 1 {
|
||||
t.Errorf("Score.Value = %v, want 1 (detail: %s)", results[0].Score.Value, results[0].Score.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_Run_UsesTestMaxTokensOverDefault(t *testing.T) {
|
||||
var gotMaxTokens int
|
||||
client := &mockClient{
|
||||
respond: func(req llm.Request) (llm.Response, error) {
|
||||
gotMaxTokens = req.MaxTokens
|
||||
return llm.Response{Text: "OK"}, nil
|
||||
},
|
||||
}
|
||||
r := New(client, Config{Concurrency: 1, MaxTokensDefault: 100})
|
||||
|
||||
r.Run(context.Background(), []string{"model-a"}, []testkit.Test{
|
||||
{ID: "t1", Category: "c", Prompt: "p", MaxTokens: 500, Eval: echoEval()},
|
||||
})
|
||||
|
||||
if gotMaxTokens != 500 {
|
||||
t.Errorf("MaxTokens = %d, want 500 (test override)", gotMaxTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_Run_FallsBackToDefaultMaxTokens(t *testing.T) {
|
||||
var gotMaxTokens int
|
||||
client := &mockClient{
|
||||
respond: func(req llm.Request) (llm.Response, error) {
|
||||
gotMaxTokens = req.MaxTokens
|
||||
return llm.Response{Text: "OK"}, nil
|
||||
},
|
||||
}
|
||||
r := New(client, Config{Concurrency: 1, MaxTokensDefault: 100})
|
||||
|
||||
r.Run(context.Background(), []string{"model-a"}, []testkit.Test{
|
||||
{ID: "t1", Category: "c", Prompt: "p", Eval: echoEval()},
|
||||
})
|
||||
|
||||
if gotMaxTokens != 100 {
|
||||
t.Errorf("MaxTokens = %d, want 100 (config default)", gotMaxTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_Run_IncludesSystemMessageWhenSet(t *testing.T) {
|
||||
var gotMessages []llm.Message
|
||||
client := &mockClient{
|
||||
respond: func(req llm.Request) (llm.Response, error) {
|
||||
gotMessages = req.Messages
|
||||
return llm.Response{Text: "OK"}, nil
|
||||
},
|
||||
}
|
||||
r := New(client, Config{Concurrency: 1, MaxTokensDefault: 100})
|
||||
|
||||
r.Run(context.Background(), []string{"model-a"}, []testkit.Test{
|
||||
{ID: "t1", Category: "c", System: "you are terse", Prompt: "p", Eval: echoEval()},
|
||||
})
|
||||
|
||||
if len(gotMessages) != 2 || gotMessages[0].Role != "system" || gotMessages[1].Role != "user" {
|
||||
t.Errorf("Messages = %+v, want [system, user]", gotMessages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_Run_TracksLatencyAndTokens(t *testing.T) {
|
||||
client := &mockClient{
|
||||
respond: func(_ llm.Request) (llm.Response, error) {
|
||||
return llm.Response{Text: "OK", PromptTokens: 10, CompletionTokens: 5, Latency: 42 * time.Millisecond}, nil
|
||||
},
|
||||
}
|
||||
r := New(client, Config{Concurrency: 1, MaxTokensDefault: 100})
|
||||
|
||||
results := r.Run(context.Background(), []string{"model-a"}, []testkit.Test{
|
||||
{ID: "t1", Category: "c", Prompt: "p", Eval: echoEval()},
|
||||
})
|
||||
|
||||
if results[0].PromptTokens != 10 {
|
||||
t.Errorf("PromptTokens = %d, want 10", results[0].PromptTokens)
|
||||
}
|
||||
if results[0].CompletionTokens != 5 {
|
||||
t.Errorf("CompletionTokens = %d, want 5", results[0].CompletionTokens)
|
||||
}
|
||||
if results[0].TotalTokens() != 15 {
|
||||
t.Errorf("TotalTokens() = %d, want 15", results[0].TotalTokens())
|
||||
}
|
||||
if results[0].Latency != 42*time.Millisecond {
|
||||
t.Errorf("Latency = %v, want 42ms", results[0].Latency)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Package testkit defines the Test type shared by the catalog in
|
||||
// internal/tests, a Registry to collect them, and response normalization
|
||||
// applied before evaluation.
|
||||
package testkit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
||||
)
|
||||
|
||||
// Test is one catalog entry: a fully self-contained prompt scored by a
|
||||
// deterministic Evaluator.
|
||||
type Test struct {
|
||||
Eval eval.Evaluator
|
||||
ID string
|
||||
Category string
|
||||
Subcategory string
|
||||
Description string
|
||||
System string
|
||||
Prompt string
|
||||
MaxTokens int
|
||||
}
|
||||
|
||||
// thinkBlockPattern strips a leading <think>...</think> or
|
||||
// <reasoning>...</reasoning> block, case-insensitive, before the real
|
||||
// answer. Models observed on this endpoint prepend one.
|
||||
var thinkBlockPattern = regexp.MustCompile(`(?is)^\s*<(think|reasoning)>.*?</(think|reasoning)>\s*`)
|
||||
|
||||
// thinkCloseTagPattern and thinkOpenTagPattern locate a closing/opening
|
||||
// <think>/<reasoning> tag anywhere in the text, used to detect an orphan
|
||||
// closing tag (S7): some models on this endpoint truncate the opening tag
|
||||
// (e.g. it falls outside a context/streaming window) but still emit the
|
||||
// closing tag, leaving stray reasoning text with no opener in front of it.
|
||||
var thinkCloseTagPattern = regexp.MustCompile(`(?i)</(?:think|reasoning)>`)
|
||||
var thinkOpenTagPattern = regexp.MustCompile(`(?i)<(?:think|reasoning)>`)
|
||||
|
||||
// stripOrphanThinkClose removes everything up to and including a closing
|
||||
// </think>/</reasoning> tag that has no matching opening tag earlier in s.
|
||||
// A tag pair with a real opener is left alone (thinkBlockPattern already
|
||||
// handles the well-formed, anchored-at-start case).
|
||||
func stripOrphanThinkClose(s string) string {
|
||||
closeLoc := thinkCloseTagPattern.FindStringIndex(s)
|
||||
if closeLoc == nil {
|
||||
return s
|
||||
}
|
||||
if thinkOpenTagPattern.MatchString(s[:closeLoc[0]]) {
|
||||
return s
|
||||
}
|
||||
return s[closeLoc[1]:]
|
||||
}
|
||||
|
||||
// Normalize prepares a raw model response for evaluation: it strips a
|
||||
// leading <think>/<reasoning> block (or, failing that, an orphan closing
|
||||
// tag with no opener), collapses Windows line endings, and trims
|
||||
// surrounding whitespace.
|
||||
func Normalize(response string) string {
|
||||
s := thinkBlockPattern.ReplaceAllString(response, "")
|
||||
s = stripOrphanThinkClose(s)
|
||||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// Registry collects Tests and provides lookup/filtering for the runner and
|
||||
// CLI.
|
||||
type Registry struct {
|
||||
byID map[string]Test
|
||||
tests []Test
|
||||
}
|
||||
|
||||
// NewRegistry builds an empty Registry.
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{byID: make(map[string]Test)}
|
||||
}
|
||||
|
||||
// Register adds t to the registry. It panics on a duplicate ID since the
|
||||
// catalog is assembled once at program startup via init()-time registration,
|
||||
// making a duplicate a programming error, not a runtime condition.
|
||||
func (r *Registry) Register(t Test) {
|
||||
if t.ID == "" {
|
||||
panic("testkit: Test.ID must not be empty")
|
||||
}
|
||||
if _, exists := r.byID[t.ID]; exists {
|
||||
panic(fmt.Sprintf("testkit: duplicate test ID %q", t.ID))
|
||||
}
|
||||
if t.Eval == nil {
|
||||
panic(fmt.Sprintf("testkit: test %q has a nil Evaluator", t.ID))
|
||||
}
|
||||
r.tests = append(r.tests, t)
|
||||
r.byID[t.ID] = t
|
||||
}
|
||||
|
||||
// All returns every registered test, sorted by ID for deterministic output.
|
||||
func (r *Registry) All() []Test {
|
||||
out := make([]Test, len(r.tests))
|
||||
copy(out, r.tests)
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out
|
||||
}
|
||||
|
||||
// Filter returns registered tests whose Category/Subcategory match the
|
||||
// given filters. Empty filters match everything.
|
||||
func (r *Registry) Filter(category, subcategory string) []Test {
|
||||
all := r.All()
|
||||
if category == "" && subcategory == "" {
|
||||
return all
|
||||
}
|
||||
var out []Test
|
||||
for _, t := range all {
|
||||
if category != "" && !strings.EqualFold(t.Category, category) {
|
||||
continue
|
||||
}
|
||||
if subcategory != "" && !strings.EqualFold(t.Subcategory, subcategory) {
|
||||
continue
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Get returns the test with the given ID.
|
||||
func (r *Registry) Get(id string) (Test, bool) {
|
||||
t, ok := r.byID[id]
|
||||
return t, ok
|
||||
}
|
||||
|
||||
// Len returns the number of registered tests.
|
||||
func (r *Registry) Len() int {
|
||||
return len(r.tests)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package testkit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
||||
)
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"trims whitespace", " hello ", "hello"},
|
||||
{"leading whitespace before answer", "\n\nOK", "OK"},
|
||||
{"strips think block", "<think>reasoning here</think>OK", "OK"},
|
||||
{"strips think block with whitespace after", "<think>reasoning here</think>\n\nOK", "OK"},
|
||||
{"strips think block case insensitive", "<THINK>x</THINK>OK", "OK"},
|
||||
{"strips reasoning block", "<reasoning>plan</reasoning>final answer", "final answer"},
|
||||
{"collapses windows line endings", "line1\r\nline2", "line1\nline2"},
|
||||
{"multiline think block", "<think>\nstep 1\nstep 2\n</think>\nanswer", "answer"},
|
||||
{"no think block passthrough", "just a plain answer", "just a plain answer"},
|
||||
{"think tag not at start is left alone", "prefix <think>x</think> suffix", "prefix <think>x</think> suffix"},
|
||||
// S7: an orphan closing tag (opening tag truncated by the gateway)
|
||||
// must still be stripped.
|
||||
{"orphan think close tag with no opener", "leftover reasoning residue</think>OK", "OK"},
|
||||
{"orphan reasoning close tag with no opener", "leftover plan</reasoning>final answer", "final answer"},
|
||||
{"orphan close tag case insensitive", "residue</THINK>OK", "OK"},
|
||||
{"orphan close tag with whitespace after", "residue</think>\n\nOK", "OK"},
|
||||
{"no close tag at all passthrough", "just a plain answer with no tags", "just a plain answer with no tags"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := Normalize(tt.in)
|
||||
if got != tt.want {
|
||||
t.Errorf("Normalize(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newDummyEval(value float64) eval.Evaluator {
|
||||
return eval.EvaluatorFunc(func(_ context.Context, _ string) eval.Score {
|
||||
return eval.Score{Value: value}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRegistry_RegisterAndAll(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
r.Register(Test{ID: "b-test", Category: "cat", Subcategory: "sub", Prompt: "p", Eval: newDummyEval(1)})
|
||||
r.Register(Test{ID: "a-test", Category: "cat", Subcategory: "sub", Prompt: "p", Eval: newDummyEval(1)})
|
||||
|
||||
all := r.All()
|
||||
if len(all) != 2 {
|
||||
t.Fatalf("All() len = %d, want 2", len(all))
|
||||
}
|
||||
if all[0].ID != "a-test" || all[1].ID != "b-test" {
|
||||
t.Errorf("All() not sorted by ID: got %q, %q", all[0].ID, all[1].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_RegisterDuplicateIDPanics(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
r.Register(Test{ID: "dup", Category: "c", Prompt: "p", Eval: newDummyEval(1)})
|
||||
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("Register() with duplicate ID did not panic")
|
||||
}
|
||||
}()
|
||||
r.Register(Test{ID: "dup", Category: "c", Prompt: "p", Eval: newDummyEval(1)})
|
||||
}
|
||||
|
||||
func TestRegistry_RegisterEmptyIDPanics(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("Register() with empty ID did not panic")
|
||||
}
|
||||
}()
|
||||
r.Register(Test{Category: "c", Prompt: "p", Eval: newDummyEval(1)})
|
||||
}
|
||||
|
||||
func TestRegistry_RegisterNilEvaluatorPanics(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("Register() with nil Evaluator did not panic")
|
||||
}
|
||||
}()
|
||||
r.Register(Test{ID: "x", Category: "c", Prompt: "p"})
|
||||
}
|
||||
|
||||
func TestRegistry_Filter(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
r.Register(Test{ID: "go-1", Category: "programming", Subcategory: "golang", Prompt: "p", Eval: newDummyEval(1)})
|
||||
r.Register(Test{ID: "py-1", Category: "programming", Subcategory: "python", Prompt: "p", Eval: newDummyEval(1)})
|
||||
r.Register(Test{ID: "k8s-1", Category: "operations", Subcategory: "kubernetes", Prompt: "p", Eval: newDummyEval(1)})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
category string
|
||||
subcategory string
|
||||
wantIDs []string
|
||||
}{
|
||||
{"no filter", "", "", []string{"go-1", "k8s-1", "py-1"}},
|
||||
{"by category", "programming", "", []string{"go-1", "py-1"}},
|
||||
{"by category case insensitive", "PROGRAMMING", "", []string{"go-1", "py-1"}},
|
||||
{"by subcategory", "", "golang", []string{"go-1"}},
|
||||
{"by category and subcategory", "programming", "python", []string{"py-1"}},
|
||||
{"no match", "nonexistent", "", nil},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := r.Filter(tt.category, tt.subcategory)
|
||||
if len(got) != len(tt.wantIDs) {
|
||||
t.Fatalf("Filter(%q, %q) len = %d, want %d", tt.category, tt.subcategory, len(got), len(tt.wantIDs))
|
||||
}
|
||||
for i, want := range tt.wantIDs {
|
||||
if got[i].ID != want {
|
||||
t.Errorf("Filter(%q, %q)[%d].ID = %q, want %q", tt.category, tt.subcategory, i, got[i].ID, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_Get(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
r.Register(Test{ID: "x", Category: "c", Prompt: "p", Eval: newDummyEval(1)})
|
||||
|
||||
got, ok := r.Get("x")
|
||||
if !ok || got.ID != "x" {
|
||||
t.Errorf("Get(%q) = %+v, %v, want ID x, true", "x", got, ok)
|
||||
}
|
||||
_, ok = r.Get("missing")
|
||||
if ok {
|
||||
t.Error("Get() for missing ID returned ok=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_Len(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
if r.Len() != 0 {
|
||||
t.Errorf("Len() = %d, want 0", r.Len())
|
||||
}
|
||||
r.Register(Test{ID: "x", Category: "c", Prompt: "p", Eval: newDummyEval(1)})
|
||||
if r.Len() != 1 {
|
||||
t.Errorf("Len() = %d, want 1", r.Len())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
||||
)
|
||||
|
||||
func registerAgentsTests(r *testkit.Registry) {
|
||||
r.Register(agentToolRoutingTest())
|
||||
r.Register(agentPlanOrderingTest())
|
||||
}
|
||||
|
||||
// agentToolRoutingRoster is the inline tool roster for
|
||||
// agentToolRoutingTest.
|
||||
const agentToolRoutingRoster = `- search_web: Search the public web for current information not in your
|
||||
training data.
|
||||
- read_file: Read the contents of a file from the local filesystem.
|
||||
- run_shell: Execute an arbitrary shell command on the local machine.
|
||||
- query_db: Run a read-only SQL query against the application's Postgres
|
||||
database.
|
||||
- send_email: Send an email to a specified recipient.
|
||||
- none: No tool is needed; answer directly from what you already know.`
|
||||
|
||||
// agentToolRoutingTest: map 4 mini-tasks to the correct tool from a 6-tool
|
||||
// roster.
|
||||
//
|
||||
// ground truth: task1 is arithmetic answerable without any tool (none);
|
||||
// task2 needs a row count from a database table (query_db); task3 asks for
|
||||
// current information that cannot be in training data (search_web); task4
|
||||
// asks to notify someone by email (send_email).
|
||||
func agentToolRoutingTest() testkit.Test {
|
||||
prompt := `You have access to this roster of tools:
|
||||
|
||||
` + agentToolRoutingRoster + `
|
||||
|
||||
For each of these 4 tasks, decide which single tool (or "none") is the
|
||||
correct one to use:
|
||||
|
||||
task1: "What is 15% of 240?"
|
||||
task2: "How many rows are in the 'orders' table?"
|
||||
task3: "What is today's top headline on a major news site?"
|
||||
task4: "Notify the on-call engineer at [email protected] that the deploy
|
||||
finished."
|
||||
|
||||
Respond with only a JSON object mapping each task id to the tool name:
|
||||
{"task1":"...","task2":"...","task3":"...","task4":"..."}`
|
||||
|
||||
evaluator := eval.Mean(
|
||||
eval.JSONField("task1", "none"),
|
||||
eval.JSONField("task2", "query_db"),
|
||||
eval.JSONField("task3", "search_web"),
|
||||
eval.JSONField("task4", "send_email"),
|
||||
)
|
||||
|
||||
return testkit.Test{
|
||||
ID: "agent-tool-routing",
|
||||
Category: "agents",
|
||||
Subcategory: "tool-routing",
|
||||
Description: "Map 4 mini-tasks to the correct tool from a 6-tool roster.",
|
||||
Prompt: prompt,
|
||||
MaxTokens: 300,
|
||||
Eval: evaluator,
|
||||
}
|
||||
}
|
||||
|
||||
// agentPlanOrderingWant is the unique valid step order implied by the
|
||||
// dependency constraints in agentPlanOrderingTest's prompt.
|
||||
var agentPlanOrderingWant = []string{"build", "test", "backup", "deploy", "verify", "rollback"}
|
||||
|
||||
// agentPlanOrderingTest: order deployment steps that have explicit
|
||||
// pairwise dependencies.
|
||||
//
|
||||
// ground truth: the prompt states build must precede test (need a built
|
||||
// artifact to test), backup must happen only after tests pass and only
|
||||
// before deploy, deploy must follow backup, verify must follow deploy, and
|
||||
// rollback is only meaningful after a deploy whose verify step failed. This
|
||||
// pins a single valid total order: build, test, backup, deploy, verify,
|
||||
// rollback.
|
||||
func agentPlanOrderingTest() testkit.Test {
|
||||
prompt := `You are planning a deployment with these steps: build, test,
|
||||
backup, deploy, verify, rollback.
|
||||
|
||||
Constraints:
|
||||
- You must build the artifact before you can test it.
|
||||
- You only take a backup of the current production state once tests have
|
||||
passed - there is no point backing up before you know the new build is
|
||||
viable.
|
||||
- Deploy only happens after the backup completes.
|
||||
- After deploy, you must verify the new deployment is healthy.
|
||||
- Rollback (restoring the pre-deploy backup) only makes sense after a
|
||||
deploy has happened, and only if verify fails.
|
||||
|
||||
Give the single correct step order as a JSON array of step ids, e.g.
|
||||
["build","test",...]. Include all 6 steps, listing rollback in its position
|
||||
in the sequence even though it is conditional on verify failing.`
|
||||
|
||||
return testkit.Test{
|
||||
ID: "agent-plan-ordering",
|
||||
Category: "agents",
|
||||
Subcategory: "planning",
|
||||
Description: "Order 6 deployment steps under explicit pairwise dependency constraints.",
|
||||
Prompt: prompt,
|
||||
MaxTokens: 300,
|
||||
Eval: eval.JSONStringArrayEquals(agentPlanOrderingWant),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAgentToolRoutingTest_Eval(t *testing.T) {
|
||||
tc := agentToolRoutingTest()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
want float64
|
||||
}{
|
||||
{
|
||||
name: "all correct",
|
||||
response: `{"task1":"none","task2":"query_db","task3":"search_web","task4":"send_email"}`,
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "one wrong",
|
||||
response: `{"task1":"run_shell","task2":"query_db","task3":"search_web","task4":"send_email"}`,
|
||||
want: 0.75,
|
||||
},
|
||||
{
|
||||
name: "all wrong",
|
||||
response: `{"task1":"run_shell","task2":"search_web","task3":"query_db","task4":"none"}`,
|
||||
want: 0,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||
if got.Value != tt.want {
|
||||
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentPlanOrderingTest_Eval(t *testing.T) {
|
||||
tc := agentPlanOrderingTest()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
want float64
|
||||
}{
|
||||
{
|
||||
name: "correct order",
|
||||
response: `["build","test","backup","deploy","verify","rollback"]`,
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "correct order fenced",
|
||||
response: "```json\n[\"build\",\"test\",\"backup\",\"deploy\",\"verify\",\"rollback\"]\n```",
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "backup before test violates dependency",
|
||||
response: `["build","backup","test","deploy","verify","rollback"]`,
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "rollback before verify",
|
||||
response: `["build","test","backup","deploy","rollback","verify"]`,
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "missing a step",
|
||||
response: `["build","test","backup","deploy","verify"]`,
|
||||
want: 0,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||
if got.Value != tt.want {
|
||||
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
||||
)
|
||||
|
||||
func registerCTests(r *testkit.Registry) {
|
||||
r.Register(cStructSizeTest())
|
||||
}
|
||||
|
||||
// cStructSizeWant is sizeof(struct Config) on LP64 (64-bit System V / Apple
|
||||
// ARM64 ABI: natural alignment, no packing).
|
||||
//
|
||||
// ground truth: char flag(align 1, size 1) at offset 0; double ratio
|
||||
// (align 8, size 8) needs offset divisible by 8, so it starts at offset 8
|
||||
// (7 bytes padding after flag), ending at 16; int count (align 4, size 4)
|
||||
// at offset 16 (already aligned), ending at 20; short mode (align 2, size
|
||||
// 2) at offset 20, ending at 22. The struct's own alignment is the widest
|
||||
// member alignment (8, from double), so the total size rounds up from 22
|
||||
// to the next multiple of 8: 24. c_test.go cross-checks this with cc via
|
||||
// sizeof and offsetof when the cc toolchain is available.
|
||||
const cStructSizeWant = 24
|
||||
|
||||
func cStructSizeTest() testkit.Test {
|
||||
prompt := `Here is a C struct:
|
||||
|
||||
` + "```c" + `
|
||||
struct Config {
|
||||
char flag;
|
||||
double ratio;
|
||||
int count;
|
||||
short mode;
|
||||
};
|
||||
` + "```" + `
|
||||
|
||||
Assuming the LP64 data model (64-bit, no #pragma pack, no
|
||||
non-default alignment attributes), what is sizeof(struct Config) in bytes?
|
||||
Respond with only the number, nothing else.`
|
||||
|
||||
return testkit.Test{
|
||||
ID: "c-struct-size",
|
||||
Category: "programming",
|
||||
Subcategory: "c",
|
||||
Description: "Compute sizeof() for a C struct on LP64 given its member types and default alignment.",
|
||||
Prompt: prompt,
|
||||
MaxTokens: 200,
|
||||
Eval: eval.Numeric(eval.ExtractLastNumber[int], cStructSizeWant, 0),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCStructSizeWant_GroundTruth cross-checks cStructSizeWant against the
|
||||
// real cc toolchain's sizeof(struct Config), rather than trusting the
|
||||
// hand-derived alignment arithmetic in the doc comment alone. Skips (does
|
||||
// not fail) when cc is unavailable, matching the exec-evaluator contract.
|
||||
func TestCStructSizeWant_GroundTruth(t *testing.T) {
|
||||
if _, err := exec.LookPath("cc"); err != nil {
|
||||
t.Skip("cc not found on PATH, skipping ground-truth cross-check")
|
||||
}
|
||||
|
||||
const src = `#include <stdio.h>
|
||||
struct Config {
|
||||
char flag;
|
||||
double ratio;
|
||||
int count;
|
||||
short mode;
|
||||
};
|
||||
int main(void) {
|
||||
printf("%zu", sizeof(struct Config));
|
||||
return 0;
|
||||
}
|
||||
`
|
||||
dir := t.TempDir()
|
||||
srcPath := filepath.Join(dir, "main.c")
|
||||
if err := os.WriteFile(srcPath, []byte(src), 0o600); err != nil {
|
||||
t.Fatalf("write main.c: %v", err)
|
||||
}
|
||||
binPath := filepath.Join(dir, "prog")
|
||||
|
||||
// #nosec G204 -- compiling a fixed, hardcoded C source (src above) in a
|
||||
// t.TempDir() this test just created; not external input.
|
||||
build := exec.Command("cc", "-o", binPath, srcPath) //nolint:gosec // see comment above
|
||||
if out, err := build.CombinedOutput(); err != nil {
|
||||
t.Fatalf("cc failed: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
// #nosec G204 -- binPath is the binary this test just compiled above,
|
||||
// in its own t.TempDir(); not an externally supplied executable path.
|
||||
out, err := exec.Command(binPath).Output() //nolint:gosec // see comment above
|
||||
if err != nil {
|
||||
t.Fatalf("running compiled program failed: %v", err)
|
||||
}
|
||||
|
||||
got := strings.TrimSpace(string(out))
|
||||
if got != "24" {
|
||||
t.Errorf("cc-computed sizeof(struct Config) = %s, want 24 (matches cStructSizeWant)", got)
|
||||
}
|
||||
if cStructSizeWant != 24 {
|
||||
t.Errorf("cStructSizeWant = %d, want 24", cStructSizeWant)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCStructSizeTest_Eval(t *testing.T) {
|
||||
tc := cStructSizeTest()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
want float64
|
||||
}{
|
||||
{"exact correct", "24", 1},
|
||||
{"prose wrapped", "sizeof(struct Config) is 24 bytes", 1},
|
||||
{"unpadded sum is wrong", "15", 0},
|
||||
{"off by one", "23", 0},
|
||||
// S6 regression: the trailing "64-bit" qualifier must not be
|
||||
// mistaken for the answer.
|
||||
{"answer followed by a 64-bit qualifier", "sizeof(struct Config) is 24 bytes on a 64-bit system", 1},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||
if got.Value != tt.want {
|
||||
t.Errorf("Eval.Evaluate() = %v, want %v (detail: %s)", got.Value, tt.want, got.Detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package tests
|
||||
|
||||
import "github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
||||
|
||||
// All builds a Registry containing the full 17-test catalog: 3
|
||||
// programming/golang, 2 programming/python, 1 programming/typescript, 1
|
||||
// programming/c, 2 operations/macos, 2 operations/linux, 1
|
||||
// operations/kubernetes, 1 research/web, 1 research/whitepapers, 1
|
||||
// research/codebase, and 2 agents (tool-routing, planning).
|
||||
func All() *testkit.Registry {
|
||||
r := testkit.NewRegistry()
|
||||
registerGolangTests(r)
|
||||
registerPythonTests(r)
|
||||
registerTypeScriptTests(r)
|
||||
registerCTests(r)
|
||||
registerMacOSTests(r)
|
||||
registerLinuxTests(r)
|
||||
registerKubernetesTests(r)
|
||||
registerWebTests(r)
|
||||
registerWhitepapersTests(r)
|
||||
registerCodebaseTests(r)
|
||||
registerAgentsTests(r)
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package tests
|
||||
|
||||
import "testing"
|
||||
|
||||
// wantCatalog is the full 17-test catalog per PLAN.md, id -> {category,
|
||||
// subcategory}. TestAll_MatchesCatalog checks the registry against this
|
||||
// list exactly, so an accidental removal, duplication, or miscategorized
|
||||
// test fails loudly.
|
||||
var wantCatalog = map[string][2]string{
|
||||
"go-struct-align": {"programming", "golang"},
|
||||
"go-worker-pool": {"programming", "golang"},
|
||||
"go-semver-classify": {"programming", "golang"},
|
||||
"py-log-triage": {"programming", "python"},
|
||||
"py-cosine": {"programming", "python"},
|
||||
"ts-debounce-composable": {"programming", "typescript"},
|
||||
"c-struct-size": {"programming", "c"},
|
||||
"macos-timeout-portability": {"operations", "macos"},
|
||||
"macos-launchd-cron": {"operations", "macos"},
|
||||
"linux-pct-exec": {"operations", "linux"},
|
||||
"linux-systemd-oneshot": {"operations", "linux"},
|
||||
"k8s-crashloop-gitops": {"operations", "kubernetes"},
|
||||
"web-robots-ai-crawlers": {"research", "web"},
|
||||
"paper-hnsw-params": {"research", "whitepapers"},
|
||||
"code-trace-go": {"research", "codebase"},
|
||||
"agent-tool-routing": {"agents", "tool-routing"},
|
||||
"agent-plan-ordering": {"agents", "planning"},
|
||||
}
|
||||
|
||||
func TestAll_MatchesCatalog(t *testing.T) {
|
||||
r := All()
|
||||
|
||||
if r.Len() != len(wantCatalog) {
|
||||
t.Fatalf("registry has %d tests, want %d", r.Len(), len(wantCatalog))
|
||||
}
|
||||
|
||||
for id, catSub := range wantCatalog {
|
||||
tc, ok := r.Get(id)
|
||||
if !ok {
|
||||
t.Errorf("missing test %q", id)
|
||||
continue
|
||||
}
|
||||
if tc.Category != catSub[0] {
|
||||
t.Errorf("%s: Category = %q, want %q", id, tc.Category, catSub[0])
|
||||
}
|
||||
if tc.Subcategory != catSub[1] {
|
||||
t.Errorf("%s: Subcategory = %q, want %q", id, tc.Subcategory, catSub[1])
|
||||
}
|
||||
if tc.Prompt == "" {
|
||||
t.Errorf("%s: Prompt is empty", id)
|
||||
}
|
||||
if tc.Eval == nil {
|
||||
t.Errorf("%s: Eval is nil", id)
|
||||
}
|
||||
if tc.Description == "" {
|
||||
t.Errorf("%s: Description is empty", id)
|
||||
}
|
||||
if tc.MaxTokens <= 0 {
|
||||
t.Errorf("%s: MaxTokens = %d, want > 0", id, tc.MaxTokens)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
||||
)
|
||||
|
||||
func registerCodebaseTests(r *testkit.Registry) {
|
||||
r.Register(codeTraceGoTest())
|
||||
}
|
||||
|
||||
// mix is the traced function for codeTraceGoTest: deterministic bit
|
||||
// manipulation plus recursion. It is called directly (not re-implemented)
|
||||
// to derive codeTraceGoWant, and codebase_test.go calls it again with the
|
||||
// same input to ground that value, per PLAN.md's instruction that this
|
||||
// test's ground truth be established by running the same function.
|
||||
func mix(n int, depth int) int {
|
||||
if depth == 0 {
|
||||
return n
|
||||
}
|
||||
if n%2 == 0 {
|
||||
n = n >> 1
|
||||
} else {
|
||||
n = (n << 1) ^ 0x2A
|
||||
}
|
||||
return mix(n, depth-1) + depth
|
||||
}
|
||||
|
||||
// codeTraceGoWant is mix(37, 4), computed by running mix itself.
|
||||
//
|
||||
// ground truth: mix(37,4): 37 is odd -> n=(37<<1)^0x2A=96, return
|
||||
// mix(96,3)+4. 96 is even -> n=48, return mix(48,2)+3. 48 is even -> n=24,
|
||||
// return mix(24,1)+2. 24 is even -> n=12, return mix(12,0)+1. depth=0 ->
|
||||
// return 12. Unwinding: 12+1=13, 13+2=15, 15+3=18, 18+4=22.
|
||||
var codeTraceGoWant = mix(37, 4)
|
||||
|
||||
func codeTraceGoTest() testkit.Test {
|
||||
prompt := `Here is a Go function:
|
||||
|
||||
` + "```go" + `
|
||||
func mix(n int, depth int) int {
|
||||
if depth == 0 {
|
||||
return n
|
||||
}
|
||||
if n%2 == 0 {
|
||||
n = n >> 1
|
||||
} else {
|
||||
n = (n << 1) ^ 0x2A
|
||||
}
|
||||
return mix(n, depth-1) + depth
|
||||
}
|
||||
` + "```" + `
|
||||
|
||||
What is the exact value of mix(37, 4)? Respond with only the number,
|
||||
nothing else.`
|
||||
|
||||
return testkit.Test{
|
||||
ID: "code-trace-go",
|
||||
Category: "research",
|
||||
Subcategory: "codebase",
|
||||
Description: "Trace a recursive Go function combining bit manipulation and recursion to an exact numeric result.",
|
||||
Prompt: prompt,
|
||||
MaxTokens: 200,
|
||||
Eval: eval.Numeric(eval.ExtractLastNumber[int], codeTraceGoWant, 0),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMix_GroundTruth runs mix itself (the traced function) to ground
|
||||
// codeTraceGoWant, per PLAN.md's instruction for this specific test.
|
||||
func TestMix_GroundTruth(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
n int
|
||||
depth int
|
||||
want int
|
||||
}{
|
||||
{"base case depth 0", 12, 0, 12},
|
||||
{"one level, even", 24, 1, 13}, // 24>>1=12; mix(12,0)+1=13
|
||||
{"two levels", 48, 2, 15}, // 48>>1=24; mix(24,1)+2=15
|
||||
{"three levels", 96, 3, 18}, // 96>>1=48; mix(48,2)+3=18
|
||||
{"full trace, odd start", 37, 4, 22},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := mix(tt.n, tt.depth)
|
||||
if got != tt.want {
|
||||
t.Errorf("mix(%d, %d) = %d, want %d", tt.n, tt.depth, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if codeTraceGoWant != 22 {
|
||||
t.Errorf("codeTraceGoWant = %d, want 22", codeTraceGoWant)
|
||||
}
|
||||
if got := mix(37, 4); got != codeTraceGoWant {
|
||||
t.Errorf("mix(37, 4) = %d, want codeTraceGoWant %d", got, codeTraceGoWant)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeTraceGoTest_Eval(t *testing.T) {
|
||||
tc := codeTraceGoTest()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
want float64
|
||||
}{
|
||||
{"exact correct", "22", 1},
|
||||
{"prose wrapped", "The result is 22.", 1},
|
||||
{"off by one", "21", 0},
|
||||
{"forgot to track depth in the unwind", "12", 0},
|
||||
// S6 regression: a trailing "64-bit" qualifier after the real
|
||||
// answer must not be mistaken for the answer itself.
|
||||
{"answer followed by a 64-bit qualifier", "The result is 22, computed via a 64-bit accumulator.", 1},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||
if got.Value != tt.want {
|
||||
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// Package tests is the test catalog: one file per category, each
|
||||
// registering its Tests into a shared Registry via Register in main.go's
|
||||
// wiring (see All in catalog.go).
|
||||
package tests
|
||||
|
||||
import (
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
||||
)
|
||||
|
||||
// terseCodeOnly is prepended as a system message on code-generation tests
|
||||
// to keep responses parseable by the exec/regex evaluators.
|
||||
const terseCodeOnly = "Respond with only the requested code in a single fenced code block. " +
|
||||
"No prose before or after the code block, no explanations, no comments beyond what was asked for."
|
||||
|
||||
func registerGolangTests(r *testkit.Registry) {
|
||||
r.Register(goStructAlignTest())
|
||||
r.Register(goWorkerPoolTest())
|
||||
r.Register(goSemverClassifyTest())
|
||||
}
|
||||
|
||||
// goStructAlignTest: reorder struct fields to minimize padding on a 64-bit
|
||||
// (LP64) system.
|
||||
//
|
||||
// ground truth: on LP64, alignment/size per field are bool(align 1, size
|
||||
// 1), int64(align 8, size 8), int32(align 4, size 4), string(align 8, size
|
||||
// 16 = 8-byte pointer + 8-byte length), byte(align 1, size 1). The Go
|
||||
// compiler lays out fields in declaration order, padding each field to its
|
||||
// own alignment, then rounds the struct size up to the widest field
|
||||
// alignment (8).
|
||||
//
|
||||
// Wasteful order: Active bool(0..1) pad(1..8) ID int64(8..16)
|
||||
// Priority int32(16..20) pad(20..24) Name string(24..40) Flag byte(40..41)
|
||||
// pad(41..48) -> total 48 bytes, 18 bytes of padding.
|
||||
//
|
||||
// Optimal order: group the two 8-byte-aligned fields first (Name string,
|
||||
// ID int64, in either order: 0..16, 16..24), then the 4-byte-aligned
|
||||
// int32 (24..28), then the two 1-byte fields adjacent with no padding
|
||||
// between them (28..29, 29..30), then round up to the next multiple of 8
|
||||
// -> total 32 bytes. No smaller layout exists: the payload is
|
||||
// 16+8+4+1+1 = 30 bytes and the struct's own alignment (8, from the
|
||||
// 64-bit-aligned fields) forces the size up to the next multiple of 8,
|
||||
// which is 32.
|
||||
//
|
||||
// golang_test.go verifies both sizes with unsafe.Sizeof on literal struct
|
||||
// definitions, and cross-checks computeLP64StructSize itself against
|
||||
// unsafe.Sizeof for several canonical field orders. Scoring
|
||||
// (goStructAlignEval) parses the response's actual field order and
|
||||
// computes its real layout size rather than matching one hardcoded
|
||||
// ordering, so any of the several field orders that reach 32 bytes scores
|
||||
// full credit (S3).
|
||||
func goStructAlignTest() testkit.Test {
|
||||
prompt := `Here is a Go struct definition:
|
||||
|
||||
` + "```go" + `
|
||||
type Task struct {
|
||||
Active bool
|
||||
ID int64
|
||||
Priority int32
|
||||
Name string
|
||||
Flag byte
|
||||
}
|
||||
` + "```" + `
|
||||
|
||||
On a 64-bit (LP64) system, this field order wastes memory to padding.
|
||||
Reorder the fields (keep the same field names and types, change nothing
|
||||
else) to minimize the total size of the struct due to alignment padding.
|
||||
Respond with the corrected struct definition only.`
|
||||
|
||||
return testkit.Test{
|
||||
ID: "go-struct-align",
|
||||
Category: "programming",
|
||||
Subcategory: "golang",
|
||||
Description: "Reorder a Go struct's fields to minimize alignment padding on a 64-bit system.",
|
||||
System: terseCodeOnly,
|
||||
Prompt: prompt,
|
||||
MaxTokens: 500,
|
||||
Eval: goStructAlignEval(),
|
||||
}
|
||||
}
|
||||
|
||||
// goWorkerPoolHarness is a complete, independent driver file (its own
|
||||
// package clause and imports) that eval.GoRun writes to harness.go
|
||||
// alongside the model's response in solution.go, so the model's code and
|
||||
// the harness's code never share one spliced import block. It drives the
|
||||
// response's Pool[T, R any] implementation (defined in solution.go, same
|
||||
// package) with a deliberately slow worker function and checks (a) output
|
||||
// order matches input order and (b) the pool actually parallelizes work,
|
||||
// via both an observed-concurrency counter and a wall-clock bound far
|
||||
// below the fully-sequential time.
|
||||
const goWorkerPoolHarness = `package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
const n = 100
|
||||
const workers = 8
|
||||
const perItem = 2 * time.Millisecond
|
||||
|
||||
in := make([]int, n)
|
||||
for i := range in {
|
||||
in[i] = i
|
||||
}
|
||||
|
||||
var concurrent int32
|
||||
var maxConcurrent int32
|
||||
f := func(x int) int {
|
||||
cur := atomic.AddInt32(&concurrent, 1)
|
||||
for {
|
||||
m := atomic.LoadInt32(&maxConcurrent)
|
||||
if cur <= m || atomic.CompareAndSwapInt32(&maxConcurrent, m, cur) {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(perItem)
|
||||
atomic.AddInt32(&concurrent, -1)
|
||||
return x * 2
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
out := Pool(in, workers, f)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if len(out) != n {
|
||||
fmt.Fprintf(os.Stderr, "len(out)=%d, want %d\n", len(out), n)
|
||||
os.Exit(1)
|
||||
}
|
||||
for i := range out {
|
||||
if out[i] != i*2 {
|
||||
fmt.Fprintf(os.Stderr, "out[%d]=%d, want %d: input order not preserved\n", i, out[i], i*2)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
if atomic.LoadInt32(&maxConcurrent) < 2 {
|
||||
fmt.Fprintf(os.Stderr, "maxConcurrent=%d, want >= 2: no real concurrency observed\n", maxConcurrent)
|
||||
os.Exit(1)
|
||||
}
|
||||
// Sequential execution would take n*perItem = 200ms; with workers
|
||||
// goroutines truly running in parallel it should land well under that.
|
||||
if elapsed > 120*time.Millisecond {
|
||||
fmt.Fprintf(os.Stderr, "elapsed=%s, want < 120ms: looks sequential\n", elapsed)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("PASS")
|
||||
}
|
||||
`
|
||||
|
||||
// goWorkerPoolTest: implement a generic, order-preserving, concurrent
|
||||
// worker pool.
|
||||
//
|
||||
// ground truth: the harness above is the oracle; it directly measures
|
||||
// order preservation and real concurrency rather than a hardcoded number,
|
||||
// so there is no separate constant to derive.
|
||||
func goWorkerPoolTest() testkit.Test {
|
||||
prompt := `Implement this Go generic function:
|
||||
|
||||
` + "```go" + `
|
||||
func Pool[T, R any](in []T, workers int, f func(T) R) []R
|
||||
` + "```" + `
|
||||
|
||||
Requirements:
|
||||
- Run f concurrently across exactly workers goroutines.
|
||||
- The returned slice must preserve the input order: result[i] is f(in[i]).
|
||||
- You may include whatever imports your implementation needs. Do not
|
||||
include a package clause or a main function: respond with only the
|
||||
Pool function definition (plus any imports it needs).`
|
||||
|
||||
return testkit.Test{
|
||||
ID: "go-worker-pool",
|
||||
Category: "programming",
|
||||
Subcategory: "golang",
|
||||
Description: "Implement a generic, order-preserving, concurrent worker pool.",
|
||||
System: terseCodeOnly,
|
||||
Prompt: prompt,
|
||||
MaxTokens: 800,
|
||||
Eval: eval.GoRun(goWorkerPoolHarness, "PASS"),
|
||||
}
|
||||
}
|
||||
|
||||
// goSemverClassifyTest: classify the aggregate semver bump implied by a
|
||||
// batch of conventional commits.
|
||||
//
|
||||
// ground truth: per Conventional Commits, a MAJOR bump requires either a
|
||||
// "!" after the type/scope or a "BREAKING CHANGE:" footer; a MINOR bump
|
||||
// comes from any "feat:" commit; a PATCH bump comes from any "fix:" commit
|
||||
// with no feat present; chore/docs/etc. commits do not trigger a release on
|
||||
// their own. The batch below has one "feat:" commit, two "fix:" commits, a
|
||||
// "chore:" and a "docs:" commit, and zero commits with a "!" marker or a
|
||||
// "BREAKING CHANGE:" footer (commit 4's body uses the word "breaking" in
|
||||
// the ordinary-English sense "test breaking flakiness", not as a release
|
||||
// footer) -> the highest applicable bump across the batch is minor.
|
||||
func goSemverClassifyTest() testkit.Test {
|
||||
prompt := `Here are 5 conventional commit messages from one release batch:
|
||||
|
||||
1. "feat(cache): add TTL eviction policy for LRU cache"
|
||||
2. "fix(scheduler): prevent duplicate job dispatch on retry"
|
||||
3. "chore(ci): pin golangci-lint version in the lint config"
|
||||
4. "fix(auth): correct token expiry check off by one
|
||||
|
||||
This resolves an intermittent test breaking flakiness in local dev but
|
||||
does not change any public behaviour."
|
||||
5. "docs(readme): clarify installation steps for macOS"
|
||||
|
||||
Using the Conventional Commits specification, determine the single semver
|
||||
bump ("major", "minor", or "patch") that this batch of commits, taken
|
||||
together, should produce for the next release. Respond with only a JSON
|
||||
object: {"bump":"major|minor|patch"}`
|
||||
|
||||
return testkit.Test{
|
||||
ID: "go-semver-classify",
|
||||
Category: "programming",
|
||||
Subcategory: "golang",
|
||||
Description: "Classify the aggregate semver bump for a batch of conventional commits, including a 'breaking' word trap.",
|
||||
System: terseCodeOnly,
|
||||
Prompt: prompt,
|
||||
MaxTokens: 300,
|
||||
Eval: eval.JSONField("bump", "minor"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// wastefulTask and optimalTask mirror the struct in goStructAlignTest's
|
||||
// prompt (wasteful field order) and its minimal-padding reordering. Sizes
|
||||
// are asserted with unsafe.Sizeof to ground the "ground truth:" derivation
|
||||
// comment in golang.go against the actual compiler layout, per PLAN.md's
|
||||
// requirement to recompute cheap ground truths in the unit test.
|
||||
//
|
||||
// wastefulTask deliberately keeps the prompt's wasteful field order
|
||||
// verbatim (the "before" layout), so fieldalignment's auto-fix must not be
|
||||
// allowed to reorder it - that would make it identical to optimalTask and
|
||||
// defeat this test's comparison.
|
||||
//
|
||||
//nolint:govet // fieldalignment: intentional, see comment above
|
||||
type wastefulTask struct {
|
||||
Active bool
|
||||
ID int64
|
||||
Priority int32
|
||||
Name string
|
||||
Flag byte
|
||||
}
|
||||
|
||||
type optimalTask struct {
|
||||
Name string
|
||||
ID int64
|
||||
Priority int32
|
||||
Active bool
|
||||
Flag byte
|
||||
}
|
||||
|
||||
func TestGoStructAlign_GroundTruthSizes(t *testing.T) {
|
||||
if got, want := unsafe.Sizeof(wastefulTask{}), uintptr(48); got != want {
|
||||
t.Errorf("unsafe.Sizeof(wastefulTask{}) = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := unsafe.Sizeof(optimalTask{}), uintptr(32); got != want {
|
||||
t.Errorf("unsafe.Sizeof(optimalTask{}) = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoStructAlignTest_Eval(t *testing.T) {
|
||||
tc := goStructAlignTest()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
want float64
|
||||
}{
|
||||
{
|
||||
name: "optimal order Name then ID",
|
||||
response: "```go\ntype Task struct {\n\tName string\n\tID int64\n\tPriority int32\n\tActive bool\n\tFlag byte\n}\n```",
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "optimal order ID then Name",
|
||||
response: "```go\ntype Task struct {\n\tID int64\n\tName string\n\tPriority int32\n\tFlag byte\n\tActive bool\n}\n```",
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "original wasteful order",
|
||||
response: "```go\ntype Task struct {\n\tActive bool\n\tID int64\n\tPriority int32\n\tName string\n\tFlag byte\n}\n```",
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "int32 not moved after align-8 fields",
|
||||
response: "```go\ntype Task struct {\n\tPriority int32\n\tName string\n\tID int64\n\tActive bool\n\tFlag byte\n}\n```",
|
||||
want: 0,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||
if got.Value != tt.want {
|
||||
t.Errorf("Eval.Evaluate() = %v, want %v (detail: %s)", got.Value, tt.want, got.Detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoWorkerPoolTest_Eval(t *testing.T) {
|
||||
tc := goWorkerPoolTest()
|
||||
|
||||
correctPool := "```go\n" + `func Pool[T, R any](in []T, workers int, f func(T) R) []R {
|
||||
out := make([]R, len(in))
|
||||
sem := make(chan struct{}, workers)
|
||||
done := make(chan struct{})
|
||||
for i, v := range in {
|
||||
i, v := i, v
|
||||
sem <- struct{}{}
|
||||
go func() {
|
||||
defer func() { <-sem; done <- struct{}{} }()
|
||||
out[i] = f(v)
|
||||
}()
|
||||
}
|
||||
for range in {
|
||||
<-done
|
||||
}
|
||||
return out
|
||||
}
|
||||
` + "```"
|
||||
|
||||
sequentialPool := "```go\n" + `func Pool[T, R any](in []T, workers int, f func(T) R) []R {
|
||||
out := make([]R, len(in))
|
||||
for i, v := range in {
|
||||
out[i] = f(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
` + "```"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
want float64
|
||||
}{
|
||||
{"correct concurrent pool", correctPool, 1},
|
||||
{"sequential pool fails concurrency/timing check", sequentialPool, 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||
if got.Skipped {
|
||||
t.Skip("go toolchain not available, skipping exec-evaluator assertion")
|
||||
}
|
||||
if got.Value != tt.want {
|
||||
t.Errorf("Eval.Evaluate() = %v, want %v (detail: %s)", got.Value, tt.want, got.Detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGoWorkerPoolTest_Eval_ResponseShapes covers B1: the two-file
|
||||
// (solution.go + harness.go) split must accept a response that brings its
|
||||
// own imports, whether or not it also carries a package clause, since
|
||||
// eval.GoRun strips at most a leading package clause and gives the
|
||||
// response its own import block separate from the harness's.
|
||||
func TestGoWorkerPoolTest_Eval_ResponseShapes(t *testing.T) {
|
||||
tc := goWorkerPoolTest()
|
||||
|
||||
// Shape 1: a bare function body with its own "import" block and no
|
||||
// package clause - the common case when a model obeys "respond with
|
||||
// only the function definition".
|
||||
withImportsNoPackage := "```go\n" + `import "sync"
|
||||
|
||||
func Pool[T, R any](in []T, workers int, f func(T) R) []R {
|
||||
out := make([]R, len(in))
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, workers)
|
||||
for i, v := range in {
|
||||
i, v := i, v
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
out[i] = f(v)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
return out
|
||||
}
|
||||
` + "```"
|
||||
|
||||
// Shape 2: a full, standalone file - package clause, imports, and the
|
||||
// function - as some models emit even when told not to include a
|
||||
// package clause. eval.GoRun must strip the leading "package main" line
|
||||
// and still compile it as part of solution.go.
|
||||
fullFileWithPackageClause := "```go\n" + `package main
|
||||
|
||||
import "sync"
|
||||
|
||||
func Pool[T, R any](in []T, workers int, f func(T) R) []R {
|
||||
out := make([]R, len(in))
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, workers)
|
||||
for i, v := range in {
|
||||
i, v := i, v
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
out[i] = f(v)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
return out
|
||||
}
|
||||
` + "```"
|
||||
|
||||
// Shape 3: uses sync.WaitGroup without importing "sync" - now that the
|
||||
// prompt explicitly allows imports, this is a genuine model mistake,
|
||||
// not a harness limitation. It must fail to compile (score 0), not be
|
||||
// silently patched or Skipped.
|
||||
usesSyncWithoutImport := "```go\n" + `func Pool[T, R any](in []T, workers int, f func(T) R) []R {
|
||||
out := make([]R, len(in))
|
||||
var wg sync.WaitGroup
|
||||
for i, v := range in {
|
||||
wg.Add(1)
|
||||
i, v := i, v
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
out[i] = f(v)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
return out
|
||||
}
|
||||
` + "```"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
want float64
|
||||
}{
|
||||
{"imports, no package clause: PASS", withImportsNoPackage, 1},
|
||||
{"full file with package clause: PASS after strip", fullFileWithPackageClause, 1},
|
||||
{"uses sync without importing it: fails to compile", usesSyncWithoutImport, 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||
if got.Skipped {
|
||||
t.Skip("go toolchain not available, skipping exec-evaluator assertion")
|
||||
}
|
||||
if got.Value != tt.want {
|
||||
t.Errorf("Eval.Evaluate() = %v, want %v (detail: %s)", got.Value, tt.want, got.Detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoSemverClassifyTest_Eval(t *testing.T) {
|
||||
tc := goSemverClassifyTest()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
want float64
|
||||
}{
|
||||
{"correct minor", `{"bump":"minor"}`, 1},
|
||||
{"correct minor fenced", "```json\n{\"bump\": \"minor\"}\n```", 1},
|
||||
{"trap: falls for the word breaking and answers major", `{"bump":"major"}`, 0},
|
||||
{"wrong: answers patch, missing the feat commit", `{"bump":"patch"}`, 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||
if got.Value != tt.want {
|
||||
t.Errorf("Eval.Evaluate() = %v, want %v (detail: %s)", got.Value, tt.want, got.Detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
||||
)
|
||||
|
||||
func registerKubernetesTests(r *testkit.Registry) {
|
||||
r.Register(k8sCrashloopGitopsTest())
|
||||
}
|
||||
|
||||
// k8sCrashloopGitopsDescribeExcerpt is the inline kubectl describe pod
|
||||
// excerpt for k8sCrashloopGitopsTest.
|
||||
const k8sCrashloopGitopsDescribeExcerpt = `Name: inventory-worker-6f9b7c8d9-2xk4p
|
||||
Namespace: inventory
|
||||
Status: Running
|
||||
Containers:
|
||||
worker:
|
||||
State: Waiting
|
||||
Reason: CrashLoopBackOff
|
||||
Last State: Terminated
|
||||
Reason: OOMKilled
|
||||
Exit Code: 137
|
||||
Ready: False
|
||||
Restart Count: 9
|
||||
Limits:
|
||||
cpu: 500m
|
||||
memory: 128Mi
|
||||
Requests:
|
||||
cpu: 100m
|
||||
memory: 64Mi
|
||||
Events:
|
||||
Warning BackOff kubelet Back-off restarting failed container worker in pod inventory-worker-6f9b7c8d9-2xk4p`
|
||||
|
||||
// mentionsLimitIncrease scores full credit only when the response both
|
||||
// names the resource being raised (memory / the limit / the 128Mi value)
|
||||
// and uses language for raising it. Using two independent ContainsAny
|
||||
// checks combined by weighted mean would give partial credit to a response
|
||||
// that raises the wrong resource (e.g. CPU) or that mentions memory
|
||||
// without proposing to change the limit; this requires both.
|
||||
func mentionsLimitIncrease() eval.Evaluator {
|
||||
verbs := eval.ContainsAny("increase", "raise", "bump", "higher", "raising")
|
||||
target := eval.ContainsAny("memory limit", "limit", "128Mi")
|
||||
return eval.EvaluatorFunc(func(ctx context.Context, response string) eval.Score {
|
||||
v := verbs.Evaluate(ctx, response)
|
||||
t := target.Evaluate(ctx, response)
|
||||
if v.Value == 1 && t.Value == 1 {
|
||||
return eval.Score{Value: 1, Detail: "proposes raising the memory limit"}
|
||||
}
|
||||
return eval.Score{Value: 0, Detail: "does not clearly propose raising the memory limit"}
|
||||
})
|
||||
}
|
||||
|
||||
// kubectlLiveMutationPattern matches any mention of "kubectl edit" or
|
||||
// "kubectl patch", negated or not.
|
||||
var kubectlLiveMutationPattern = regexp.MustCompile(`(?i)kubectl\s+(edit|patch)\b`)
|
||||
|
||||
// kubectlImperativeMutationPattern matches a bare/imperative "kubectl
|
||||
// edit"/"kubectl patch" at the start of a line (optionally after
|
||||
// whitespace or a bullet marker) - a command a reader would actually run,
|
||||
// never acceptable regardless of negation cues elsewhere in the response.
|
||||
var kubectlImperativeMutationPattern = regexp.MustCompile(`(?im)^\s*(?:[-*]\s*)?kubectl\s+(edit|patch)\b`)
|
||||
|
||||
// negationCuePattern matches a word that turns a mention of the forbidden
|
||||
// command into a warning against running it, rather than an instruction to
|
||||
// run it (e.g. "do not run kubectl patch...").
|
||||
var negationCuePattern = regexp.MustCompile(`(?i)\b(don'?t|do not|never|avoid|instead of|not)\b`)
|
||||
|
||||
// negationWindow is how many characters before a "kubectl edit|patch"
|
||||
// mention (never crossing a newline) are searched for a negation cue.
|
||||
const negationWindow = 60
|
||||
|
||||
// noLiveKubectlMutation scores full credit unless the response instructs
|
||||
// running "kubectl edit"/"kubectl patch" against the live cluster. A
|
||||
// contrastive mention - warning the reader not to do this, in favor of the
|
||||
// GitOps procedure - is fine and does not cost credit; an imperative or
|
||||
// otherwise unnegated mention scores zero. This is deliberately not
|
||||
// eval.NotContains, which would also zero out the best possible answer
|
||||
// (one that correctly explains why NOT to run kubectl edit/patch).
|
||||
func noLiveKubectlMutation() eval.Evaluator {
|
||||
return eval.EvaluatorFunc(func(_ context.Context, response string) eval.Score {
|
||||
if kubectlImperativeMutationPattern.MatchString(response) {
|
||||
return eval.Score{Value: 0, Detail: "imperative kubectl edit/patch command present"}
|
||||
}
|
||||
|
||||
matches := kubectlLiveMutationPattern.FindAllStringIndex(response, -1)
|
||||
if len(matches) == 0 {
|
||||
return eval.Score{Value: 1, Detail: "no mention of kubectl edit/patch"}
|
||||
}
|
||||
|
||||
for _, loc := range matches {
|
||||
start := loc[0]
|
||||
lineStart := strings.LastIndexByte(response[:start], '\n') + 1
|
||||
windowStart := start - negationWindow
|
||||
if windowStart < lineStart {
|
||||
windowStart = lineStart
|
||||
}
|
||||
if !negationCuePattern.MatchString(response[windowStart:start]) {
|
||||
return eval.Score{Value: 0, Detail: "unnegated mention of kubectl edit/patch"}
|
||||
}
|
||||
}
|
||||
return eval.Score{Value: 1, Detail: "every mention of kubectl edit/patch is negated"}
|
||||
})
|
||||
}
|
||||
|
||||
// k8sCrashloopGitopsTest: diagnose an OOMKilled CrashLoopBackOff and
|
||||
// require the correct GitOps fix procedure, not a live kubectl mutation.
|
||||
func k8sCrashloopGitopsTest() testkit.Test {
|
||||
prompt := `This cluster is entirely managed by ArgoCD: every workload's
|
||||
manifests live in a git repository, and ArgoCD continuously reconciles the
|
||||
live cluster state to match what is committed there. Here is the output of
|
||||
"kubectl describe pod" for a pod that is failing:
|
||||
|
||||
` + k8sCrashloopGitopsDescribeExcerpt + `
|
||||
|
||||
What is the root cause of this CrashLoopBackOff, and what is the correct
|
||||
procedure to fix it given that ArgoCD manages this cluster?`
|
||||
|
||||
evaluator := eval.All(
|
||||
eval.W(eval.ContainsAny("OOM", "OOMKilled", "out of memory", "memory limit"), 2),
|
||||
eval.W(mentionsLimitIncrease(), 2),
|
||||
eval.W(eval.ContainsAny("git commit", "commit the change", "ArgoCD", "GitOps"), 2),
|
||||
eval.W(noLiveKubectlMutation(), 2),
|
||||
)
|
||||
|
||||
return testkit.Test{
|
||||
ID: "k8s-crashloop-gitops",
|
||||
Category: "operations",
|
||||
Subcategory: "kubernetes",
|
||||
Description: "Diagnose an OOMKilled CrashLoopBackOff and require the GitOps fix procedure over a live kubectl mutation.",
|
||||
Prompt: prompt,
|
||||
MaxTokens: 600,
|
||||
Eval: evaluator,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMentionsLimitIncrease(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
want float64
|
||||
}{
|
||||
{"raise memory limit", "increase the memory limit", 1},
|
||||
{"bump the limit", "bump the limit to 256Mi", 1},
|
||||
{"raise the value directly", "raise 128Mi to a higher value", 1},
|
||||
{"only verb, no target", "we should increase it", 0},
|
||||
{"only target, no verb", "the memory limit is 128Mi", 0},
|
||||
{"wrong resource entirely", "increase the cpu request", 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := mentionsLimitIncrease().Evaluate(context.Background(), tt.response)
|
||||
if got.Value != tt.want {
|
||||
t.Errorf("mentionsLimitIncrease().Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoLiveKubectlMutation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
want float64
|
||||
}{
|
||||
{
|
||||
name: "no mention at all",
|
||||
response: "Raise the memory limit in the git repository and commit the change.",
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "negated mid-sentence mention",
|
||||
response: "This cluster uses ArgoCD, so do not run kubectl edit or kubectl patch against the live Deployment.",
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "imperative command at line start",
|
||||
response: "Just run this:\nkubectl patch deployment inventory-worker --patch '...'",
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "unnegated mid-sentence mention",
|
||||
response: "One option is to try kubectl patch to fix this quickly.",
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "negation cue too far away to count",
|
||||
response: "Never mind the manifest for now, but you could just casually run a quick kubectl patch to fix the limit right away.",
|
||||
want: 0,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := noLiveKubectlMutation().Evaluate(context.Background(), tt.response)
|
||||
if got.Value != tt.want {
|
||||
t.Errorf("noLiveKubectlMutation().Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestK8sCrashloopGitopsTest_Eval(t *testing.T) {
|
||||
tc := k8sCrashloopGitopsTest()
|
||||
|
||||
// The best possible answer explains WHY not to mutate the live cluster
|
||||
// by name-checking the forbidden commands - this must score 1.0, not be
|
||||
// capped by a naive substring guard (B2). Written as one unwrapped
|
||||
// paragraph (no embedded newlines), matching how the raw API response
|
||||
// text actually arrives - a hand-wrapped Go source literal would put
|
||||
// "kubectl edit" at a false line-start and trip the imperative check
|
||||
// regardless of the negation earlier in the same sentence.
|
||||
good := `The container was OOMKilled (exit code 137) because it exceeded its 128Mi memory limit. Since this cluster is managed by ArgoCD, do not run kubectl edit or kubectl patch directly against the live cluster - any direct change would be reverted on the next sync. Instead, raise the memory limit in the deployment manifest in the git repository (e.g. to 256Mi) and commit the change; ArgoCD will pick it up and reconcile the live state.`
|
||||
|
||||
badLiveMutation := `The container hit its 128Mi memory limit and was
|
||||
OOMKilled. This cluster uses ArgoCD, but the quickest fix is to run
|
||||
kubectl patch deployment inventory-worker -n inventory --patch
|
||||
'{"spec":{"template":{"spec":{"containers":[{"name":"worker","resources":{"limits":{"memory":"256Mi"}}}]}}}}'
|
||||
to raise the memory limit to 256Mi right now.`
|
||||
|
||||
badWrongRootCause := `This is a CrashLoopBackOff because the container image
|
||||
is missing. Check the image tag and re-push it.`
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
want float64
|
||||
}{
|
||||
{"good diagnosis and GitOps fix, naming the forbidden commands to warn against them", good, 1},
|
||||
{"correct diagnosis but imperative live kubectl patch loses guard weight", badLiveMutation, 0.75},
|
||||
{"wrong root cause scores low", badWrongRootCause, 0.25},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||
if got.Value != tt.want {
|
||||
t.Errorf("Eval.Evaluate() = %v, want %v (detail: %s)", got.Value, tt.want, got.Detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
||||
)
|
||||
|
||||
func registerLinuxTests(r *testkit.Registry) {
|
||||
r.Register(linuxPctExecTest())
|
||||
r.Register(linuxSystemdOneshotTest())
|
||||
}
|
||||
|
||||
// linuxPctExecTest: one-line command to check a service inside an LXC
|
||||
// container through its Proxmox host.
|
||||
func linuxPctExecTest() testkit.Test {
|
||||
prompt := `Container CT 251 is an LXC container running on Proxmox host
|
||||
10.0.0.100. Your laptop cannot reach CT 251 directly - only the Proxmox
|
||||
host is reachable over the network. Give a single, one-line shell command,
|
||||
run from your laptop, that checks the output of "systemctl status
|
||||
myservice" inside CT 251 by going through the Proxmox host.`
|
||||
|
||||
// Requires ssh to the host (10.0.0.100), pct exec into container 251,
|
||||
// and the actual systemctl status command, in that logical order, on
|
||||
// one line. No (?s): DOTALL would let ssh/host/pct/systemctl match
|
||||
// across unrelated paragraphs of a longer response instead of one
|
||||
// actual command. [^\n]*? between ssh and the host (rather than
|
||||
// requiring ssh directly followed by an optional user@) tolerates any
|
||||
// flags (e.g. "ssh -t [email protected]").
|
||||
pattern := `(?i)ssh\b[^\n]*?10\.0\.0\.100\b[^\n]*pct\s+exec\s+251\b[^\n]*systemctl\s+status\s+myservice`
|
||||
|
||||
return testkit.Test{
|
||||
ID: "linux-pct-exec",
|
||||
Category: "operations",
|
||||
Subcategory: "linux",
|
||||
Description: "One-line ssh + pct exec command to check a service status inside a Proxmox LXC container.",
|
||||
Prompt: prompt,
|
||||
MaxTokens: 200,
|
||||
Eval: eval.Regex(pattern),
|
||||
}
|
||||
}
|
||||
|
||||
// linuxSystemdOneshotTest: write a systemd unit that runs a script exactly
|
||||
// once at boot after networking is up, with no restart loop.
|
||||
func linuxSystemdOneshotTest() testkit.Test {
|
||||
prompt := `Write a systemd unit file that runs the script
|
||||
/usr/local/bin/bootstrap.sh exactly once, at boot, after network
|
||||
connectivity is actually available (not merely after the network
|
||||
interfaces are configured). The unit must not restart the script if it
|
||||
exits - one run per boot only, no restart loop. Respond with the unit file
|
||||
contents only.`
|
||||
|
||||
evaluator := eval.All(
|
||||
eval.W(eval.ContainsAll("Type=oneshot", "network-online.target", "WantedBy=multi-user.target"), 3),
|
||||
eval.W(eval.ContainsAny("RemainAfterExit"), 1),
|
||||
)
|
||||
|
||||
return testkit.Test{
|
||||
ID: "linux-systemd-oneshot",
|
||||
Category: "operations",
|
||||
Subcategory: "linux",
|
||||
Description: "Write a run-once-at-boot systemd oneshot unit gated on network-online.target.",
|
||||
System: terseCodeOnly,
|
||||
Prompt: prompt,
|
||||
MaxTokens: 400,
|
||||
Eval: evaluator,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLinuxPctExecTest_Eval(t *testing.T) {
|
||||
tc := linuxPctExecTest()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
want float64
|
||||
}{
|
||||
{
|
||||
name: "correct with user prefix and double dash",
|
||||
response: `ssh [email protected] "pct exec 251 -- systemctl status myservice"`,
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "correct without user prefix",
|
||||
response: `ssh 10.0.0.100 pct exec 251 -- systemctl status myservice`,
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "wrong ip",
|
||||
response: `ssh [email protected] "pct exec 251 -- systemctl status myservice"`,
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "wrong container id",
|
||||
response: `ssh [email protected] "pct exec 999 -- systemctl status myservice"`,
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "missing ssh hop, direct pct exec",
|
||||
response: `pct exec 251 -- systemctl status myservice`,
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "ssh with -t flag before user@host",
|
||||
response: `ssh -t [email protected] "pct exec 251 -- systemctl status myservice"`,
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "ssh with multiple flags before user@host",
|
||||
response: `ssh -t -o StrictHostKeyChecking=no [email protected] "pct exec 251 -- systemctl status myservice"`,
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "scattered mention across paragraphs is not a real one-line command",
|
||||
response: `First, ssh to the Proxmox host at 10.0.0.100.
|
||||
|
||||
Then, separately, run pct exec 251 to enter the container.
|
||||
|
||||
Finally, check systemctl status myservice inside it.`,
|
||||
want: 0,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||
if got.Value != tt.want {
|
||||
t.Errorf("Eval.Evaluate() = %v, want %v (detail: %s)", got.Value, tt.want, got.Detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinuxSystemdOneshotTest_Eval(t *testing.T) {
|
||||
tc := linuxSystemdOneshotTest()
|
||||
|
||||
fullUnit := `[Unit]
|
||||
Description=Bootstrap script
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/bootstrap.sh
|
||||
RemainAfterExit=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target`
|
||||
|
||||
missingRemainAfterExit := `[Unit]
|
||||
Description=Bootstrap script
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/bootstrap.sh
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target`
|
||||
|
||||
wrongType := `[Unit]
|
||||
Description=Bootstrap script
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Restart=always
|
||||
ExecStart=/usr/local/bin/bootstrap.sh
|
||||
|
||||
[Install]
|
||||
WantedBy=graphical.target`
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
want float64
|
||||
}{
|
||||
{"full unit with RemainAfterExit scores 1", fullUnit, 1},
|
||||
{"missing RemainAfterExit loses optional weight", missingRemainAfterExit, 0.75},
|
||||
{"wrong type/target/restart-loop scores 0", wrongType, 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||
if got.Value != tt.want {
|
||||
t.Errorf("Eval.Evaluate() = %v, want %v (detail: %s)", got.Value, tt.want, got.Detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
||||
)
|
||||
|
||||
func registerMacOSTests(r *testkit.Registry) {
|
||||
r.Register(macosTimeoutPortabilityTest())
|
||||
r.Register(macosLaunchdCronTest())
|
||||
}
|
||||
|
||||
// macosTimeoutPortabilityTest: explain why `timeout` is missing on stock
|
||||
// macOS and give two portable fixes.
|
||||
func macosTimeoutPortabilityTest() testkit.Test {
|
||||
prompt := `A deployment script runs this on a fresh, stock macOS machine (no
|
||||
Homebrew packages installed yet):
|
||||
|
||||
` + "```sh" + `
|
||||
timeout 30 ./run-migration.sh
|
||||
` + "```" + `
|
||||
|
||||
It fails immediately with:
|
||||
|
||||
` + "```" + `
|
||||
timeout: command not found
|
||||
` + "```" + `
|
||||
|
||||
Explain why this happens on stock macOS, and give two different portable
|
||||
fixes: one that installs a replacement, and one that works with zero extra
|
||||
packages installed.`
|
||||
|
||||
evaluator := eval.Mean(
|
||||
eval.ContainsAny("coreutils", "gtimeout"),
|
||||
eval.ContainsAny("perl -e 'alarm'", "perl -e \"alarm\"", "alarm(", "background & kill", "& kill", "builtin"),
|
||||
eval.ContainsAll("GNU"),
|
||||
)
|
||||
|
||||
return testkit.Test{
|
||||
ID: "macos-timeout-portability",
|
||||
Category: "operations",
|
||||
Subcategory: "macos",
|
||||
Description: "Explain why `timeout` is missing on stock macOS and give two portable fixes.",
|
||||
Prompt: prompt,
|
||||
MaxTokens: 500,
|
||||
Eval: evaluator,
|
||||
}
|
||||
}
|
||||
|
||||
// macosLaunchdCronTest: identify launchd as the persistence mechanism for a
|
||||
// recurring macOS job and the minimal plist keys it needs.
|
||||
func macosLaunchdCronTest() testkit.Test {
|
||||
prompt := `On macOS, you need a background job that:
|
||||
|
||||
1. Survives a reboot (it must still be active after the machine restarts).
|
||||
2. Runs automatically every 5 minutes, with no user needing to be logged in
|
||||
to trigger it manually.
|
||||
|
||||
Which macOS mechanism should you use for this (not cron), and what are the
|
||||
minimal keys a property list for it needs to include to run every 5
|
||||
minutes? Name the mechanism, the plist keys, and which directory the plist
|
||||
file belongs in for a per-machine (not per-user) job.`
|
||||
|
||||
// The prompt asks specifically for a per-machine (not per-user) job, so
|
||||
// LaunchDaemons is the only correct plist directory; LaunchAgents is
|
||||
// per-user and would be wrong here (a contrastive mention of
|
||||
// LaunchAgents, e.g. explaining why it's not the right choice, is fine
|
||||
// and is not penalized - see B2 for the same principle).
|
||||
evaluator := eval.Mean(
|
||||
eval.ContainsAny("launchd", "launchctl"),
|
||||
eval.ContainsAny("StartInterval", "StartCalendarInterval"),
|
||||
eval.ContainsAll("LaunchDaemons"),
|
||||
)
|
||||
|
||||
return testkit.Test{
|
||||
ID: "macos-launchd-cron",
|
||||
Category: "operations",
|
||||
Subcategory: "macos",
|
||||
Description: "Identify launchd as the mechanism for a persistent, reboot-surviving, 5-minute recurring macOS job.",
|
||||
Prompt: prompt,
|
||||
MaxTokens: 500,
|
||||
Eval: evaluator,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMacosTimeoutPortabilityTest_Eval(t *testing.T) {
|
||||
tc := macosTimeoutPortabilityTest()
|
||||
|
||||
good := `macOS ships BSD userland, not GNU coreutils, so there is no
|
||||
"timeout" binary out of the box - it is a GNU coreutils tool. Two portable
|
||||
fixes: (1) brew install coreutils, then use "gtimeout 30 ./script.sh"
|
||||
(GNU coreutils installs its tools with a "g" prefix to avoid clobbering the
|
||||
BSD originals); or (2) with zero extra packages, background the command
|
||||
and kill it after a sleep, e.g. run it, sleep 30 & kill it if still
|
||||
running, or use "perl -e 'alarm shift; exec @ARGV' 30 ./script.sh" which
|
||||
relies only on the perl builtin that ships with macOS.`
|
||||
|
||||
badMissingAll := "Just run the script differently, I'm not sure why it fails."
|
||||
badMissingGNU := "Install coreutils via brew and use gtimeout, or use perl -e 'alarm'."
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
want float64
|
||||
}{
|
||||
{"good answer full credit", good, 1},
|
||||
{"missing everything scores 0", badMissingAll, 0},
|
||||
{"missing GNU explanation loses one third", badMissingGNU, 2.0 / 3.0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||
if got.Value != tt.want {
|
||||
t.Errorf("Eval.Evaluate() = %v, want %v (detail: %s)", got.Value, tt.want, got.Detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMacosLaunchdCronTest_Eval(t *testing.T) {
|
||||
tc := macosLaunchdCronTest()
|
||||
|
||||
good := `Use launchd, managed via launchctl, not cron. Put a plist in
|
||||
/Library/LaunchDaemons for a system-wide job. Minimal keys: Label,
|
||||
ProgramArguments, and StartInterval set to 300 (seconds) to run every 5
|
||||
minutes; RunAtLoad is optional.`
|
||||
|
||||
badWrongMechanism := "Just add a crontab entry with */5 * * * *."
|
||||
badMissingKeys := "Use launchd and put the plist in LaunchDaemons."
|
||||
badWrongDirectory := `Use launchd, managed via launchctl. Put a plist in
|
||||
~/Library/LaunchAgents. Minimal keys: Label, ProgramArguments, and
|
||||
StartInterval set to 300.`
|
||||
goodContrastiveMention := `Use launchd via launchctl, not cron. This must be a
|
||||
per-machine job, so the plist goes in /Library/LaunchDaemons, not
|
||||
~/Library/LaunchAgents (LaunchAgents is per-user and would not run without
|
||||
a logged-in session). Minimal keys: Label, ProgramArguments, and
|
||||
StartInterval set to 300.`
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
want float64
|
||||
}{
|
||||
{"good answer full credit", good, 1},
|
||||
{"wrong mechanism (cron) scores 0", badWrongMechanism, 0},
|
||||
{"missing plist keys loses one third", badMissingKeys, 2.0 / 3.0},
|
||||
{"LaunchAgents (per-user) is the wrong directory for this per-machine job", badWrongDirectory, 2.0 / 3.0},
|
||||
{"contrastive LaunchAgents mention alongside correct LaunchDaemons still scores full credit", goodContrastiveMention, 1},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||
if got.Value != tt.want {
|
||||
t.Errorf("Eval.Evaluate() = %v, want %v (detail: %s)", got.Value, tt.want, got.Detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package tests
|
||||
|
||||
import "math"
|
||||
|
||||
// cosineSimilarity computes the cosine similarity of two equal-length
|
||||
// vectors: dot(a,b) / (|a| * |b|). Used to derive ground-truth expected
|
||||
// values for numeric tests at catalog-registration time rather than
|
||||
// hardcoding a pre-computed constant.
|
||||
func cosineSimilarity(a, b []float64) float64 {
|
||||
var dot, normA, normB float64
|
||||
for i := range a {
|
||||
dot += a[i] * b[i]
|
||||
normA += a[i] * a[i]
|
||||
normB += b[i] * b[i]
|
||||
}
|
||||
return dot / (math.Sqrt(normA) * math.Sqrt(normB))
|
||||
}
|
||||
|
||||
// round4dp rounds v to 4 decimal places, matching the precision the
|
||||
// py-cosine prompt asks the model to answer to.
|
||||
func round4dp(v float64) float64 {
|
||||
return math.Round(v*10000) / 10000
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
||||
)
|
||||
|
||||
func registerPythonTests(r *testkit.Registry) {
|
||||
r.Register(pyLogTriageTest())
|
||||
r.Register(pyCosineTest())
|
||||
}
|
||||
|
||||
// pyLogTriageLog is the inline kubelet log excerpt for pyLogTriageTest.
|
||||
// ground truth: counting by hand (also re-derived in python_test.go): 15
|
||||
// lines total, 6 non-failure lines ("Started container ..." x4,
|
||||
// "Readiness probe succeeded" x1, plus one more "Started container
|
||||
// postgres"), 5 "OOMKilled" lines, 4 "ImagePullBackOff" lines.
|
||||
const pyLogTriageLog = `2026-08-10T10:00:01Z pod=web-7f9c kubelet: Started container app
|
||||
2026-08-10T10:00:05Z pod=web-7f9c kubelet: OOMKilled: memory limit exceeded
|
||||
2026-08-10T10:00:07Z pod=api-2b3d kubelet: Back-off pulling image "registry.example.com/api:v2": ImagePullBackOff
|
||||
2026-08-10T10:00:09Z pod=cache-1a2b kubelet: Started container redis
|
||||
2026-08-10T10:00:11Z pod=web-9d1e kubelet: OOMKilled: memory limit exceeded
|
||||
2026-08-10T10:00:13Z pod=worker-4f5g kubelet: Back-off pulling image "registry.example.com/worker:v1": ImagePullBackOff
|
||||
2026-08-10T10:00:15Z pod=db-6h7i kubelet: Started container postgres
|
||||
2026-08-10T10:00:17Z pod=web-3j8k kubelet: OOMKilled: memory limit exceeded
|
||||
2026-08-10T10:00:19Z pod=api-9l0m kubelet: Back-off pulling image "registry.example.com/api:v3": ImagePullBackOff
|
||||
2026-08-10T10:00:21Z pod=queue-2n3o kubelet: Started container consumer
|
||||
2026-08-10T10:00:23Z pod=web-5p6q kubelet: Readiness probe succeeded
|
||||
2026-08-10T10:00:25Z pod=worker-7r8s kubelet: OOMKilled: memory limit exceeded
|
||||
2026-08-10T10:00:27Z pod=cache-9t0u kubelet: Back-off pulling image "registry.example.com/cache:v1": ImagePullBackOff
|
||||
2026-08-10T10:00:29Z pod=db-1v2w kubelet: Started container postgres
|
||||
2026-08-10T10:00:31Z pod=api-3x4y kubelet: OOMKilled: memory limit exceeded`
|
||||
|
||||
// pyLogTriageWant is the expected stdout of a correct triage script:
|
||||
// counts sorted alphabetically by failure type, "type=count" per line.
|
||||
// ground truth: 4 ImagePullBackOff lines, 5 OOMKilled lines (see
|
||||
// pyLogTriageLog comment above); "I" < "O" alphabetically.
|
||||
const pyLogTriageWant = "ImagePullBackOff=4\nOOMKilled=5"
|
||||
|
||||
func pyLogTriageTest() testkit.Test {
|
||||
prompt := `Here are 15 lines of kubelet log output for several pods in one namespace:
|
||||
|
||||
` + pyLogTriageLog + `
|
||||
|
||||
Write a self-contained python3 script (embed the log lines above as data in
|
||||
your script; do not read from stdin or a file) that counts occurrences of
|
||||
each failure type - OOMKilled and ImagePullBackOff - and prints one line
|
||||
per failure type as "type=count", sorted alphabetically by type name. Do
|
||||
not count non-failure lines (container starts, successful probes). Print
|
||||
exactly those lines and nothing else.`
|
||||
|
||||
return testkit.Test{
|
||||
ID: "py-log-triage",
|
||||
Category: "programming",
|
||||
Subcategory: "python",
|
||||
Description: "Write a python3 script that triages kubelet log lines into failure-type counts.",
|
||||
System: terseCodeOnly,
|
||||
Prompt: prompt,
|
||||
MaxTokens: 700,
|
||||
Eval: eval.PyRun(eval.PassthroughHarness, pyLogTriageWant),
|
||||
}
|
||||
}
|
||||
|
||||
// pyCosineVectorA and pyCosineVectorB are the two 8-dimensional vectors for
|
||||
// pyCosineTest.
|
||||
var (
|
||||
pyCosineVectorA = []float64{0.12, 0.45, -0.33, 0.78, 0.05, -0.61, 0.29, 0.14}
|
||||
pyCosineVectorB = []float64{0.22, 0.39, -0.28, 0.65, 0.11, -0.55, 0.31, 0.09}
|
||||
)
|
||||
|
||||
// pyCosineWant is derived, not hardcoded: it calls the same
|
||||
// cosineSimilarity helper that python_test.go independently re-verifies
|
||||
// against math.Sqrt.
|
||||
// ground truth: cosine(a,b) = dot(a,b) / (|a| * |b|), rounded to 4 decimal
|
||||
// places as the prompt requests.
|
||||
var pyCosineWant = round4dp(cosineSimilarity(pyCosineVectorA, pyCosineVectorB))
|
||||
|
||||
func pyCosineTest() testkit.Test {
|
||||
prompt := `Given two 8-dimensional vectors:
|
||||
|
||||
a = [0.12, 0.45, -0.33, 0.78, 0.05, -0.61, 0.29, 0.14]
|
||||
b = [0.22, 0.39, -0.28, 0.65, 0.11, -0.55, 0.31, 0.09]
|
||||
|
||||
Compute their cosine similarity, rounded to 4 decimal places. Respond with
|
||||
only the number, nothing else.`
|
||||
|
||||
return testkit.Test{
|
||||
ID: "py-cosine",
|
||||
Category: "programming",
|
||||
Subcategory: "python",
|
||||
Description: "Compute the cosine similarity of two inline 8-dimensional vectors to 4 decimal places.",
|
||||
Prompt: prompt,
|
||||
MaxTokens: 200,
|
||||
Eval: eval.Numeric(eval.ExtractLastNumber[float64], pyCosineWant, 0.0005),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCosineSimilarity_KnownCases(t *testing.T) {
|
||||
// Grounds the cosineSimilarity helper itself against two textbook
|
||||
// cases before trusting it to derive pyCosineWant.
|
||||
tests := []struct {
|
||||
name string
|
||||
a, b []float64
|
||||
want float64
|
||||
}{
|
||||
{"identical vectors", []float64{1, 2, 3}, []float64{1, 2, 3}, 1},
|
||||
{"orthogonal vectors", []float64{1, 0}, []float64{0, 1}, 0},
|
||||
{"opposite vectors", []float64{1, 0}, []float64{-1, 0}, -1},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := cosineSimilarity(tt.a, tt.b)
|
||||
if math.Abs(got-tt.want) > 1e-9 {
|
||||
t.Errorf("cosineSimilarity(%v, %v) = %v, want %v", tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPyCosineWant_GroundTruth(t *testing.T) {
|
||||
// Independent re-derivation via math.Sqrt, not via the cosineSimilarity
|
||||
// helper under test, per PLAN.md's rule to recompute cheap ground
|
||||
// truths in the unit test.
|
||||
a, b := pyCosineVectorA, pyCosineVectorB
|
||||
var dot, sumA, sumB float64
|
||||
for i := range a {
|
||||
dot += a[i] * b[i]
|
||||
sumA += a[i] * a[i]
|
||||
sumB += b[i] * b[i]
|
||||
}
|
||||
want := dot / (math.Sqrt(sumA) * math.Sqrt(sumB))
|
||||
wantRounded := math.Round(want*10000) / 10000
|
||||
|
||||
if pyCosineWant != wantRounded {
|
||||
t.Errorf("pyCosineWant = %v, independently recomputed = %v", pyCosineWant, wantRounded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPyLogTriageWant_GroundTruth(t *testing.T) {
|
||||
// Independent re-derivation of the expected counts by scanning the raw
|
||||
// log text directly, rather than trusting the hand count in the
|
||||
// doc comment.
|
||||
lines := strings.Split(pyLogTriageLog, "\n")
|
||||
if len(lines) != 15 {
|
||||
t.Fatalf("pyLogTriageLog has %d lines, want 15", len(lines))
|
||||
}
|
||||
|
||||
oom := strings.Count(pyLogTriageLog, "OOMKilled")
|
||||
pull := strings.Count(pyLogTriageLog, "ImagePullBackOff")
|
||||
if oom != 5 {
|
||||
t.Errorf("OOMKilled count = %d, want 5", oom)
|
||||
}
|
||||
if pull != 4 {
|
||||
t.Errorf("ImagePullBackOff count = %d, want 4", pull)
|
||||
}
|
||||
|
||||
want := "ImagePullBackOff=4\nOOMKilled=5"
|
||||
if pyLogTriageWant != want {
|
||||
t.Errorf("pyLogTriageWant = %q, want %q", pyLogTriageWant, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPyLogTriageTest_Eval(t *testing.T) {
|
||||
tc := pyLogTriageTest()
|
||||
|
||||
correctScript := "```python\n" + `counts = {"OOMKilled": 0, "ImagePullBackOff": 0}
|
||||
log = """` + pyLogTriageLog + `"""
|
||||
for line in log.splitlines():
|
||||
for key in counts:
|
||||
if key in line:
|
||||
counts[key] += 1
|
||||
for key in sorted(counts):
|
||||
print(f"{key}={counts[key]}")
|
||||
` + "```"
|
||||
|
||||
wrongScript := "```python\nprint('OOMKilled=1')\nprint('ImagePullBackOff=1')\n```"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
want float64
|
||||
}{
|
||||
{"correct triage script", correctScript, 1},
|
||||
{"wrong counts", wrongScript, 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||
if got.Skipped {
|
||||
t.Skip("python3 not available, skipping exec-evaluator assertion")
|
||||
}
|
||||
if got.Value != tt.want {
|
||||
t.Errorf("Eval.Evaluate() = %v, want %v (detail: %s)", got.Value, tt.want, got.Detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPyCosineTest_Eval(t *testing.T) {
|
||||
tc := pyCosineTest()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
want float64
|
||||
}{
|
||||
{"exact", "0.9896", 1},
|
||||
{"within tolerance", "0.9899", 1},
|
||||
{"prose wrapped", "The cosine similarity is 0.9896.", 1},
|
||||
{"outside tolerance", "0.5", 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||
if got.Value != tt.want {
|
||||
t.Errorf("Eval.Evaluate() = %v, want %v (detail: %s)", got.Value, tt.want, got.Detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
||||
)
|
||||
|
||||
// lp64TypeLayout is one Go type's size and alignment, in bytes, on a 64-bit
|
||||
// (LP64) system - the same values golang_test.go cross-checks against
|
||||
// unsafe.Sizeof/unsafe.Alignof.
|
||||
type lp64TypeLayout struct {
|
||||
size, align uintptr
|
||||
}
|
||||
|
||||
// lp64FieldLayout covers the field types used by goStructAlignTest's
|
||||
// prompt (bool, int64, int32, string, byte). string is a two-word header
|
||||
// (an 8-byte pointer plus an 8-byte length) on a 64-bit system.
|
||||
var lp64FieldLayout = map[string]lp64TypeLayout{
|
||||
"bool": {size: 1, align: 1},
|
||||
"byte": {size: 1, align: 1},
|
||||
"int32": {size: 4, align: 4},
|
||||
"int64": {size: 8, align: 8},
|
||||
"string": {size: 16, align: 8},
|
||||
}
|
||||
|
||||
// computeLP64StructSize simulates the Go compiler's struct layout
|
||||
// algorithm on a 64-bit system for a sequence of field types, in
|
||||
// declaration order: each field is padded up to its own alignment, then
|
||||
// the total is padded up to the widest field's alignment. It is general
|
||||
// over any sequence of the types in lp64FieldLayout, not just the specific
|
||||
// field set goStructAlignTest asks about.
|
||||
func computeLP64StructSize(fieldTypes []string) (uintptr, error) {
|
||||
var offset, maxAlign uintptr = 0, 1
|
||||
for _, ft := range fieldTypes {
|
||||
layout, ok := lp64FieldLayout[ft]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unknown field type %q", ft)
|
||||
}
|
||||
if layout.align > maxAlign {
|
||||
maxAlign = layout.align
|
||||
}
|
||||
if rem := offset % layout.align; rem != 0 {
|
||||
offset += layout.align - rem
|
||||
}
|
||||
offset += layout.size
|
||||
}
|
||||
if rem := offset % maxAlign; rem != 0 {
|
||||
offset += maxAlign - rem
|
||||
}
|
||||
return offset, nil
|
||||
}
|
||||
|
||||
// structAlignFieldPattern matches one "Name Type" struct field declaration
|
||||
// line for the specific 5 fields goStructAlignTest's prompt gives (Active
|
||||
// bool, ID int64, Priority int32, Name string, Flag byte), in whatever
|
||||
// order the response declares them.
|
||||
var structAlignFieldPattern = regexp.MustCompile(`(?m)^\s*(Active|ID|Priority|Name|Flag)\s+(bool|int64|int32|string|byte)\s*$`)
|
||||
|
||||
// structAlignWantFields is the required field name -> type mapping;
|
||||
// goStructAlignEval rejects a response that renames, retypes, drops, or
|
||||
// duplicates any of them.
|
||||
var structAlignWantFields = map[string]string{
|
||||
"Active": "bool",
|
||||
"ID": "int64",
|
||||
"Priority": "int32",
|
||||
"Name": "string",
|
||||
"Flag": "byte",
|
||||
}
|
||||
|
||||
// goStructAlignMinimalSize is the smallest possible layout size for these 5
|
||||
// fields on LP64 (see the ground-truth derivation in golang.go's doc
|
||||
// comment); golang_test.go recomputes this via computeLP64StructSize
|
||||
// independently of the hardcoded constant.
|
||||
const goStructAlignMinimalSize = 32
|
||||
|
||||
// goStructAlignEval extracts the field order from the response's struct
|
||||
// definition and computes its actual LP64 layout size, rather than
|
||||
// matching one hardcoded field order: any of the several field orderings
|
||||
// that reach the 32-byte minimum scores full credit (S3).
|
||||
func goStructAlignEval() eval.Evaluator {
|
||||
return eval.EvaluatorFunc(func(_ context.Context, response string) eval.Score {
|
||||
matches := structAlignFieldPattern.FindAllStringSubmatch(response, -1)
|
||||
|
||||
seen := make(map[string]bool, len(structAlignWantFields))
|
||||
types := make([]string, 0, len(matches))
|
||||
for _, m := range matches {
|
||||
name, typ := m[1], m[2]
|
||||
wantType := structAlignWantFields[name]
|
||||
if typ != wantType {
|
||||
return eval.Score{Value: 0, Detail: fmt.Sprintf("field %s has type %s, want %s", name, typ, wantType)}
|
||||
}
|
||||
if seen[name] {
|
||||
return eval.Score{Value: 0, Detail: fmt.Sprintf("field %s appears more than once", name)}
|
||||
}
|
||||
seen[name] = true
|
||||
types = append(types, typ)
|
||||
}
|
||||
|
||||
if len(seen) != len(structAlignWantFields) {
|
||||
return eval.Score{Value: 0, Detail: fmt.Sprintf("found %d/%d required fields", len(seen), len(structAlignWantFields))}
|
||||
}
|
||||
|
||||
size, err := computeLP64StructSize(types)
|
||||
if err != nil {
|
||||
return eval.Score{Value: 0, Detail: err.Error()}
|
||||
}
|
||||
if size == goStructAlignMinimalSize {
|
||||
return eval.Score{Value: 1, Detail: fmt.Sprintf("computed layout size = %d bytes (minimal)", size)}
|
||||
}
|
||||
return eval.Score{Value: 0, Detail: fmt.Sprintf("computed layout size = %d bytes, want %d (minimal)", size, goStructAlignMinimalSize)}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// The four field orders below all reach the 32-byte minimum: the two
|
||||
// 8-byte-aligned fields (Name, ID) may go in either order at the front, and
|
||||
// the two 1-byte fields (Active, Flag) may go in either order at the back,
|
||||
// independent of each other. optimalOrderNameIDPriorityActiveFlag mirrors
|
||||
// optimalTask in golang_test.go; the other three are its remaining
|
||||
// permutations.
|
||||
type optimalOrderNameIDPriorityFlagActive struct {
|
||||
Name string
|
||||
ID int64
|
||||
Priority int32
|
||||
Flag byte
|
||||
Active bool
|
||||
}
|
||||
|
||||
// The govet suppressions below are intentional: each of these types
|
||||
// encodes one specific, named field permutation under test (its own name
|
||||
// documents the order); fieldalignment's suggested reorder (grouping the
|
||||
// pointer-containing string field first, for GC-scan cost, not total size)
|
||||
// would silently make two of these permutations identical to each other
|
||||
// and defeat the point of testing them as distinct orderings.
|
||||
|
||||
//nolint:govet // fieldalignment: intentional, see comment above
|
||||
type optimalOrderIDNamePriorityActiveFlag struct {
|
||||
ID int64
|
||||
Name string
|
||||
Priority int32
|
||||
Active bool
|
||||
Flag byte
|
||||
}
|
||||
|
||||
//nolint:govet // fieldalignment: intentional, see comment above
|
||||
type optimalOrderIDNamePriorityFlagActive struct {
|
||||
ID int64
|
||||
Name string
|
||||
Priority int32
|
||||
Flag byte
|
||||
Active bool
|
||||
}
|
||||
|
||||
func TestComputeLP64StructSize_CrossCheckUnsafeSizeof(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fieldTypes []string
|
||||
want uintptr
|
||||
}{
|
||||
{"wasteful order (matches wastefulTask)", []string{"bool", "int64", "int32", "string", "byte"}, unsafe.Sizeof(wastefulTask{})},
|
||||
{"Name,ID,Priority,Active,Flag (matches optimalTask)", []string{"string", "int64", "int32", "bool", "byte"}, unsafe.Sizeof(optimalTask{})},
|
||||
{"Name,ID,Priority,Flag,Active", []string{"string", "int64", "int32", "byte", "bool"}, unsafe.Sizeof(optimalOrderNameIDPriorityFlagActive{})},
|
||||
{"ID,Name,Priority,Active,Flag", []string{"int64", "string", "int32", "bool", "byte"}, unsafe.Sizeof(optimalOrderIDNamePriorityActiveFlag{})},
|
||||
{"ID,Name,Priority,Flag,Active", []string{"int64", "string", "int32", "byte", "bool"}, unsafe.Sizeof(optimalOrderIDNamePriorityFlagActive{})},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := computeLP64StructSize(tt.fieldTypes)
|
||||
if err != nil {
|
||||
t.Fatalf("computeLP64StructSize(%v) error = %v", tt.fieldTypes, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("computeLP64StructSize(%v) = %d, want %d (unsafe.Sizeof)", tt.fieldTypes, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeLP64StructSize_UnknownType(t *testing.T) {
|
||||
_, err := computeLP64StructSize([]string{"bool", "float64"})
|
||||
if err == nil {
|
||||
t.Fatal("computeLP64StructSize() with an unknown type: error = nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoStructAlignEval_AllFourMinimalOrdersScoreFull(t *testing.T) {
|
||||
e := goStructAlignEval()
|
||||
|
||||
orders := []string{
|
||||
"Name string\n\tID int64\n\tPriority int32\n\tActive bool\n\tFlag byte",
|
||||
"Name string\n\tID int64\n\tPriority int32\n\tFlag byte\n\tActive bool",
|
||||
"ID int64\n\tName string\n\tPriority int32\n\tActive bool\n\tFlag byte",
|
||||
"ID int64\n\tName string\n\tPriority int32\n\tFlag byte\n\tActive bool",
|
||||
}
|
||||
for i, order := range orders {
|
||||
response := "```go\ntype Task struct {\n\t" + order + "\n}\n```"
|
||||
got := e.Evaluate(context.Background(), response)
|
||||
if got.Value != 1 {
|
||||
t.Errorf("order %d: Evaluate() = %v, want 1 (detail: %s)", i, got.Value, got.Detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoStructAlignEval_RejectsBadResponses(t *testing.T) {
|
||||
e := goStructAlignEval()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
}{
|
||||
{
|
||||
name: "original wasteful order",
|
||||
response: "```go\ntype Task struct {\n\tActive bool\n\tID int64\n\tPriority int32\n\tName string\n\tFlag byte\n}\n```",
|
||||
},
|
||||
{
|
||||
name: "int32 not grouped after the align-8 fields",
|
||||
response: "```go\ntype Task struct {\n\tPriority int32\n\tName string\n\tID int64\n\tActive bool\n\tFlag byte\n}\n```",
|
||||
},
|
||||
{
|
||||
name: "missing a field",
|
||||
response: "```go\ntype Task struct {\n\tName string\n\tID int64\n\tPriority int32\n\tActive bool\n}\n```",
|
||||
},
|
||||
{
|
||||
name: "wrong type for a field",
|
||||
response: "```go\ntype Task struct {\n\tName string\n\tID int32\n\tPriority int32\n\tActive bool\n\tFlag byte\n}\n```",
|
||||
},
|
||||
{
|
||||
name: "duplicated field",
|
||||
response: "```go\ntype Task struct {\n\tName string\n\tID int64\n\tPriority int32\n\tActive bool\n\tActive bool\n}\n```",
|
||||
},
|
||||
{
|
||||
name: "no recognizable fields",
|
||||
response: "I'm not sure how to reorder this.",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := e.Evaluate(context.Background(), tt.response)
|
||||
if got.Value != 0 {
|
||||
t.Errorf("Evaluate(%q) = %v, want 0 (detail: %s)", tt.response, got.Value, got.Detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
||||
)
|
||||
|
||||
func registerTypeScriptTests(r *testkit.Registry) {
|
||||
r.Register(tsDebounceComposableTest())
|
||||
}
|
||||
|
||||
// tsAnyEscapeHatchPattern matches TypeScript's `any` used as a type: a type
|
||||
// annotation (": any"), a sole generic argument ("<any>"), a non-first
|
||||
// generic argument ("Map<string, any>"), a cast ("as any"), or an array
|
||||
// type ("any[]"). A plain \bany\b substring check would false-positive on
|
||||
// prose words like "many" or "Company", so this is intentionally more
|
||||
// specific than eval.NotContains's plain substring matching.
|
||||
var tsAnyEscapeHatchPattern = regexp.MustCompile(`(?i):\s*any\b|<\s*any\s*>|,\s*any\s*>|\bas\s+any\b|\bany\s*\[\]`)
|
||||
|
||||
// noAnyEscapeHatch scores full credit unless the response uses TypeScript's
|
||||
// `any` type as an escape hatch.
|
||||
func noAnyEscapeHatch() eval.Evaluator {
|
||||
return eval.EvaluatorFunc(func(_ context.Context, response string) eval.Score {
|
||||
if tsAnyEscapeHatchPattern.MatchString(response) {
|
||||
return eval.Score{Value: 0, Detail: "uses the `any` escape hatch"}
|
||||
}
|
||||
return eval.Score{Value: 1, Detail: "properly typed, no `any`"}
|
||||
})
|
||||
}
|
||||
|
||||
// tsDebounceComposableTest: write a properly typed, generic, cleaned-up
|
||||
// Vue 3 debounce composable.
|
||||
func tsDebounceComposableTest() testkit.Test {
|
||||
prompt := `Write a Vue 3 composable with this exact signature:
|
||||
|
||||
` + "```ts" + `
|
||||
export function useDebouncedRef<T>(source: Ref<T>, ms: number): Ref<T>
|
||||
` + "```" + `
|
||||
|
||||
Requirements:
|
||||
- Returns a new ref whose value updates to match source, but only after ms
|
||||
milliseconds have passed with no further changes to source (debounced).
|
||||
- Watches source for changes (use Vue's watch API).
|
||||
- Cleans up any pending timer, both between debounce cycles and when the
|
||||
owning component scope is torn down (e.g. via onScopeDispose).
|
||||
- Fully typed: do not use the "any" type anywhere.
|
||||
- Import anything you need from "vue".`
|
||||
|
||||
evaluator := eval.All(
|
||||
eval.W(eval.ContainsAll("export function useDebouncedRef", "watch", "<T>"), 2),
|
||||
eval.W(eval.ContainsAny("clearTimeout", "onScopeDispose"), 1),
|
||||
eval.W(noAnyEscapeHatch(), 1),
|
||||
)
|
||||
|
||||
return testkit.Test{
|
||||
ID: "ts-debounce-composable",
|
||||
Category: "programming",
|
||||
Subcategory: "typescript",
|
||||
Description: "Write a properly typed, generic Vue 3 debounced-ref composable with cleanup.",
|
||||
System: terseCodeOnly,
|
||||
Prompt: prompt,
|
||||
MaxTokens: 600,
|
||||
Eval: evaluator,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const goodDebounceComposable = "```ts\n" + `import { ref, watch, onScopeDispose, type Ref } from 'vue'
|
||||
|
||||
export function useDebouncedRef<T>(source: Ref<T>, ms: number): Ref<T> {
|
||||
const debounced = ref(source.value) as Ref<T>
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const stop = watch(source, (value) => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
debounced.value = value
|
||||
}, ms)
|
||||
})
|
||||
|
||||
onScopeDispose(() => {
|
||||
if (timer) clearTimeout(timer)
|
||||
stop()
|
||||
})
|
||||
|
||||
return debounced
|
||||
}
|
||||
` + "```"
|
||||
|
||||
func TestNoAnyEscapeHatch(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
want float64
|
||||
}{
|
||||
{"clean typed code", goodDebounceComposable, 1},
|
||||
{"type annotation any", "function f(x: any) {}", 0},
|
||||
{"generic any", "const r: Ref<any> = ref(1)", 0},
|
||||
{"cast as any", "const x = value as any", 0},
|
||||
{"false positive guard: many", "there are many refs here", 1},
|
||||
{"false positive guard: Company", "Company.example()", 1},
|
||||
// N3 additions.
|
||||
{"non-first generic argument any", "const m: Map<string, any> = new Map()", 0},
|
||||
{"array of any", "const values: any[] = []", 0},
|
||||
{"false positive guard: many[] is not any[]", "const values: many[] = []", 1},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := noAnyEscapeHatch().Evaluate(context.Background(), tt.response)
|
||||
if got.Value != tt.want {
|
||||
t.Errorf("noAnyEscapeHatch().Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTsDebounceComposableTest_Eval(t *testing.T) {
|
||||
tc := tsDebounceComposableTest()
|
||||
|
||||
badMissingCleanup := "```ts\n" + `export function useDebouncedRef<T>(source: Ref<T>, ms: number): Ref<T> {
|
||||
const debounced = ref(source.value)
|
||||
watch(source, (value) => {
|
||||
setTimeout(() => { debounced.value = value }, ms)
|
||||
})
|
||||
return debounced
|
||||
}
|
||||
` + "```"
|
||||
|
||||
badUsesAny := "```ts\n" + `export function useDebouncedRef<T>(source: any, ms: number): any {
|
||||
const debounced = ref(source.value)
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
watch(source, (value: any) => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => { debounced.value = value }, ms)
|
||||
})
|
||||
return debounced
|
||||
}
|
||||
` + "```"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
want float64
|
||||
}{
|
||||
{"correct composable scores 1", goodDebounceComposable, 1},
|
||||
{"missing cleanup loses cleanup weight", badMissingCleanup, 0.75}, // (2*1 + 1*0 + 1*1)/4
|
||||
{"uses any loses guard weight", badUsesAny, 0.75}, // (2*1 + 1*1 + 1*0)/4
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||
if got.Value != tt.want {
|
||||
t.Errorf("Eval.Evaluate() = %v, want %v (detail: %s)", got.Value, tt.want, got.Detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
||||
)
|
||||
|
||||
func registerWebTests(r *testkit.Registry) {
|
||||
r.Register(webRobotsAICrawlersTest())
|
||||
}
|
||||
|
||||
// webRobotsAICrawlersRobotsTxt is the inline robots.txt for
|
||||
// webRobotsAICrawlersTest. Each listed user-agent has an explicit block, so
|
||||
// the most specific matching block applies per bot (standard robots.txt
|
||||
// precedence); the wildcard block only applies to bots with no dedicated
|
||||
// block of their own.
|
||||
const webRobotsAICrawlersRobotsTxt = `User-agent: GPTBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: ClaudeBot
|
||||
Allow: /
|
||||
|
||||
User-agent: PerplexityBot
|
||||
Disallow: /docs/
|
||||
|
||||
User-agent: Googlebot
|
||||
Allow: /
|
||||
|
||||
User-agent: *
|
||||
Disallow: /private/`
|
||||
|
||||
// webRobotsAICrawlersTest: determine which AI crawlers (not just any
|
||||
// allowed bot) can fetch /docs/.
|
||||
//
|
||||
// ground truth: for path /docs/, GPTBot's dedicated block disallows /
|
||||
// entirely (blocked), ClaudeBot's dedicated block allows / (can fetch),
|
||||
// PerplexityBot's dedicated block disallows /docs/ specifically (blocked),
|
||||
// and Googlebot's dedicated block allows / (can fetch) - but Googlebot is
|
||||
// Google's general-purpose search-indexing crawler, not an AI crawler, so
|
||||
// it does not belong in an "AI crawlers" answer even though it is
|
||||
// technically allowed. The only AI crawler that can fetch /docs/ is
|
||||
// ClaudeBot.
|
||||
func webRobotsAICrawlersTest() testkit.Test {
|
||||
prompt := `Here is a robots.txt file:
|
||||
|
||||
` + webRobotsAICrawlersRobotsTxt + `
|
||||
|
||||
GPTBot (OpenAI), ClaudeBot (Anthropic), and PerplexityBot (Perplexity) are
|
||||
AI crawlers used to fetch content for AI systems. Googlebot is Google's
|
||||
general-purpose search-indexing crawler, not an AI crawler.
|
||||
|
||||
Which of the AI crawlers listed above are permitted, by this robots.txt, to
|
||||
fetch a page at path /docs/? Respond with only a JSON array of crawler
|
||||
names, e.g. ["ClaudeBot"]. Do not include Googlebot even if it is allowed,
|
||||
since it is not an AI crawler.`
|
||||
|
||||
return testkit.Test{
|
||||
ID: "web-robots-ai-crawlers",
|
||||
Category: "research",
|
||||
Subcategory: "web",
|
||||
Description: "Determine which AI crawlers (excluding a general search-engine decoy) may fetch a path per robots.txt rules.",
|
||||
Prompt: prompt,
|
||||
MaxTokens: 300,
|
||||
Eval: eval.JSONStringSet([]string{"ClaudeBot"}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWebRobotsAICrawlersTest_Eval(t *testing.T) {
|
||||
tc := webRobotsAICrawlersTest()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
want float64
|
||||
}{
|
||||
{"correct: only ClaudeBot", `["ClaudeBot"]`, 1},
|
||||
{"correct fenced", "```json\n[\"ClaudeBot\"]\n```", 1},
|
||||
{"includes decoy Googlebot", `["ClaudeBot","Googlebot"]`, 0.5},
|
||||
{"includes disallowed GPTBot", `["ClaudeBot","GPTBot"]`, 0.5},
|
||||
{"empty answer", `[]`, 0},
|
||||
{"only wrong bots", `["GPTBot","PerplexityBot"]`, 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||
if got.Value != tt.want {
|
||||
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
||||
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
||||
)
|
||||
|
||||
func registerWhitepapersTests(r *testkit.Registry) {
|
||||
r.Register(paperHnswParamsTest())
|
||||
}
|
||||
|
||||
// paperHnswParamsExcerpt is a ~150-word, technically accurate original
|
||||
// description of HNSW's three tuning parameters, written for this test
|
||||
// (not reproduced from any paper), pinning three specific numeric values.
|
||||
//
|
||||
// S11: the sentence about M was reworded. The original phrasing ("M=16,
|
||||
// giving each vector roughly 32 edges once both directions are counted")
|
||||
// conflated M with total node degree and misattributed the factor of two
|
||||
// to link bidirectionality. HNSW actually permits the base layer (layer 0)
|
||||
// twice as many links as upper layers (Mmax0 = 2*M) to keep that most
|
||||
// heavily traversed layer well connected - the excerpt now states that
|
||||
// correctly, still pinning M=16 as the single unambiguous answer to
|
||||
// question 1.
|
||||
const paperHnswParamsExcerpt = `HNSW (Hierarchical Navigable Small World) builds a
|
||||
multi-layer proximity graph over the indexed vectors, where each upper
|
||||
layer is a sparser sub-mesh of the layer below it. Search descends from a
|
||||
coarse entry point at the top layer down to a precise result at the base
|
||||
layer. Three parameters dominate the accuracy, speed, and memory trade-off.
|
||||
M controls the maximum number of bidirectional links each node keeps per
|
||||
layer; a benchmark index described in this report used M=16 links per node
|
||||
on the upper layers, with the base layer permitted twice that (Mmax0 = 32)
|
||||
to stay well connected. efConstruction bounds the size of the dynamic
|
||||
candidate list explored while inserting a new node, and was set to 200
|
||||
during the build - higher values slow indexing but produce a
|
||||
better-connected graph. efSearch, set to 64 at query time, bounds the same
|
||||
candidate list during search; raising it improves recall at the cost of
|
||||
latency, independent of the build-time efConstruction value.`
|
||||
|
||||
// paperHnswParamsTest: extract three precise numeric parameters from a
|
||||
// technical excerpt.
|
||||
//
|
||||
// ground truth: the excerpt states M=16, efConstruction=200, efSearch=64
|
||||
// verbatim; these are the only numbers attached to those parameter names in
|
||||
// the text.
|
||||
func paperHnswParamsTest() testkit.Test {
|
||||
prompt := `Read this excerpt about HNSW indexing:
|
||||
|
||||
` + paperHnswParamsExcerpt + `
|
||||
|
||||
Answer three questions based only on this excerpt:
|
||||
1. What value of M did the benchmark index use?
|
||||
2. What value of efConstruction was used during the index build?
|
||||
3. What value of efSearch was used at query time?
|
||||
|
||||
Respond with only a JSON object: {"M":<number>,"efConstruction":<number>,"efSearch":<number>}`
|
||||
|
||||
evaluator := eval.Mean(
|
||||
eval.JSONField("M", 16),
|
||||
eval.JSONField("efConstruction", 200),
|
||||
eval.JSONField("efSearch", 64),
|
||||
)
|
||||
|
||||
return testkit.Test{
|
||||
ID: "paper-hnsw-params",
|
||||
Category: "research",
|
||||
Subcategory: "whitepapers",
|
||||
Description: "Extract three precise numeric HNSW tuning parameters from a technical excerpt.",
|
||||
Prompt: prompt,
|
||||
MaxTokens: 300,
|
||||
Eval: evaluator,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestPaperHnswParamsExcerpt_GroundTruth independently extracts the three
|
||||
// numeric parameter values from the raw excerpt text via regex, rather
|
||||
// than trusting the doc comment's transcription of them.
|
||||
func TestPaperHnswParamsExcerpt_GroundTruth(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
want int
|
||||
}{
|
||||
{"M", `M=(\d+)`, 16},
|
||||
{"efConstruction", `efConstruction[^\d]*(\d+)`, 200},
|
||||
{"efSearch", `efSearch,\s*set to (\d+)`, 64},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
re := regexp.MustCompile(tt.pattern)
|
||||
m := re.FindStringSubmatch(paperHnswParamsExcerpt)
|
||||
if m == nil {
|
||||
t.Fatalf("pattern %q found no match in excerpt", tt.pattern)
|
||||
}
|
||||
got, err := strconv.Atoi(m[1])
|
||||
if err != nil {
|
||||
t.Fatalf("parse %q: %v", m[1], err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("extracted %s = %d, want %d", tt.name, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaperHnswParamsTest_Eval(t *testing.T) {
|
||||
tc := paperHnswParamsTest()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
want float64
|
||||
}{
|
||||
{"all correct", `{"M":16,"efConstruction":200,"efSearch":64}`, 1},
|
||||
{"one field wrong", `{"M":32,"efConstruction":200,"efSearch":64}`, 2.0 / 3.0},
|
||||
{"all wrong", `{"M":8,"efConstruction":100,"efSearch":32}`, 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||
if got.Value != tt.want {
|
||||
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user