mirror of
https://github.com/lukaszraczylo/llm-testbench.git
synced 2026-10-12 00:45:19 +00:00
fix(catalog): unify negation guard and harden round-2 evaluators
A shared NoUnnegatedMention primitive replaces four per-category implementations, with a bidirectional clause-scoped window and an adjectival cue set. The docker layer fixture drops WORKDIR, which creates a real layer and made the pinned answer wrong. The replica failover test now forces a JSON verdict so a negated answer cannot score. Remaining exact-answer tests migrate to ExactToken. Ordering prompts shuffle their candidate lists so echoing scores zero.
This commit is contained in:
@@ -89,9 +89,9 @@ func jsonValueAs[T comparable](v any) (T, error) {
|
|||||||
}
|
}
|
||||||
return any(s).(T), nil
|
return any(s).(T), nil
|
||||||
case bool:
|
case bool:
|
||||||
b, ok := v.(bool)
|
b, err := asBool(v)
|
||||||
if !ok {
|
if err != nil {
|
||||||
return zero, fmt.Errorf("expected bool, got %T (%v)", v, v)
|
return zero, err
|
||||||
}
|
}
|
||||||
return any(b).(T), nil
|
return any(b).(T), nil
|
||||||
case float64:
|
case float64:
|
||||||
@@ -134,6 +134,30 @@ func asFloat64(v any) (float64, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// asBool coerces a decoded JSON value to bool. A string is accepted too
|
||||||
|
// (DC2, mirroring asFloat64's B9 numeric-string coercion), case-insensitive
|
||||||
|
// and trimmed: a model that answers {"guaranteed_visible":"false"} instead
|
||||||
|
// of {"guaranteed_visible":false} - both are the same value, and the
|
||||||
|
// prompt asked for a JSON field, not a JSON type - must not score 0 for a
|
||||||
|
// mechanical typing difference. Any other string fails.
|
||||||
|
func asBool(v any) (bool, error) {
|
||||||
|
switch b := v.(type) {
|
||||||
|
case bool:
|
||||||
|
return b, nil
|
||||||
|
case string:
|
||||||
|
switch strings.ToLower(strings.TrimSpace(b)) {
|
||||||
|
case "true":
|
||||||
|
return true, nil
|
||||||
|
case "false":
|
||||||
|
return false, nil
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("expected bool, got non-boolean string %q", b)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("expected bool, got %T (%v)", v, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// jsonFieldEval extracts one field by path from the response's JSON value
|
// jsonFieldEval extracts one field by path from the response's JSON value
|
||||||
// (see ExtractJSON: the first ```json fenced block if present, otherwise
|
// (see ExtractJSON: the first ```json fenced block if present, otherwise
|
||||||
// the last balanced JSON value in the response) and compares it to want.
|
// the last balanced JSON value in the response) and compares it to want.
|
||||||
@@ -193,7 +217,17 @@ func (j jsonFieldEval[T]) Evaluate(_ context.Context, response string) Score {
|
|||||||
func equalJSONValue[T comparable](got, want T) bool {
|
func equalJSONValue[T comparable](got, want T) bool {
|
||||||
if gs, ok := any(got).(string); ok {
|
if gs, ok := any(got).(string); ok {
|
||||||
ws := any(want).(string)
|
ws := any(want).(string)
|
||||||
return strings.EqualFold(strings.TrimSpace(gs), strings.TrimSpace(ws))
|
return strings.EqualFold(enumNormalize(gs), enumNormalize(ws))
|
||||||
}
|
}
|
||||||
return got == want
|
return got == want
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// enumNormalize folds hyphens to spaces and collapses repeated whitespace
|
||||||
|
// (DC4), so a closed-vocabulary enum answer like "non-repeatable-read" and
|
||||||
|
// "non repeatable read" compare equal: a model choosing its own spelling
|
||||||
|
// of a multi-word enum token should not be marked wrong for a formatting
|
||||||
|
// difference the prompt's closed vocabulary did not unambiguously specify.
|
||||||
|
func enumNormalize(s string) string {
|
||||||
|
s = strings.ReplaceAll(s, "-", " ")
|
||||||
|
return strings.Join(strings.Fields(s), " ")
|
||||||
|
}
|
||||||
|
|||||||
@@ -127,6 +127,58 @@ func TestJSONField_Float_NumericStringCoercion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestJSONField_Bool_StringCoercion is the DC2 regression, mirroring B9's
|
||||||
|
// numeric-string coercion: a model answering {"guaranteed_visible":"false"}
|
||||||
|
// instead of {"guaranteed_visible":false} must still score 1.
|
||||||
|
func TestJSONField_Bool_StringCoercion(t *testing.T) {
|
||||||
|
e := JSONField("guaranteed_visible", false)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
response string
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"bool string coerces (lowercase)", `{"guaranteed_visible":"false"}`, 1},
|
||||||
|
{"bool string coerces with whitespace", `{"guaranteed_visible":" false "}`, 1},
|
||||||
|
{"bool string coerces, different case", `{"guaranteed_visible":"False"}`, 1},
|
||||||
|
{"wrong bool string", `{"guaranteed_visible":"true"}`, 0},
|
||||||
|
{"non-boolean string does not coerce", `{"guaranteed_visible":"nope"}`, 0},
|
||||||
|
{"native bool still works", `{"guaranteed_visible":false}`, 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJSONField_EnumHyphenSpaceFold is the DC4 regression: a multi-word
|
||||||
|
// enum answer spelled with spaces instead of hyphens (or vice versa) must
|
||||||
|
// still score 1.
|
||||||
|
func TestJSONField_EnumHyphenSpaceFold(t *testing.T) {
|
||||||
|
e := JSONField("anomaly", "non-repeatable-read")
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
response string
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"exact hyphenated form", `{"anomaly":"non-repeatable-read"}`, 1},
|
||||||
|
{"space-separated form folds equal (DC4 bug probe)", `{"anomaly":"non repeatable read"}`, 1},
|
||||||
|
{"mixed hyphen/space form folds equal (DC4 bug probe)", `{"anomaly":"non repeatable-read"}`, 1},
|
||||||
|
{"a genuinely different enum value still fails", `{"anomaly":"dirty-read"}`, 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) {
|
func TestJSONField_NestedPath(t *testing.T) {
|
||||||
e := JSONField("task1", "search_web")
|
e := JSONField("task1", "search_web")
|
||||||
got := e.Evaluate(context.Background(), `{"task1":"search_web","task2":"read_file"}`)
|
got := e.Evaluate(context.Background(), `{"task1":"search_web","task2":"read_file"}`)
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package eval
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"regexp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// negationCueAlternation is the shared union of cue words/phrases that turn
|
||||||
|
// a mention of a discouraged term into a warning against it rather than an
|
||||||
|
// endorsement of it. It is the union of four independently-accumulated
|
||||||
|
// per-category cue lists (kubernetes.go's noLiveKubectlMutation,
|
||||||
|
// security.go's secNoUnnegatedMention, databases_redis.go's
|
||||||
|
// dbNoBareKeysInProd, delivery_git.go's delNoBareForcePush), extended with
|
||||||
|
// adjectival negation forms a good answer commonly uses instead of an
|
||||||
|
// imperative "don't/never" cue - e.g. "MD5 is unsuitable for password
|
||||||
|
// storage" or "SHA-256 is too fast to be safe here" carry no "don't"/"never"
|
||||||
|
// at all, only an adjective describing why the term is discouraged (D5, C2).
|
||||||
|
const negationCueAlternation = `don'?t|do not|never|avoid|not|cannot|can'?t|without|no need|` +
|
||||||
|
`shouldn'?t|should not|must not|isn'?t|wasn'?t|danger|dangerous|` +
|
||||||
|
`unsuitable|inappropriate|unsafe|insecure|weak|broken|deprecated|obsolete|` +
|
||||||
|
`too fast|predictable|unacceptable|blocks|wrong|problem`
|
||||||
|
|
||||||
|
// negationCuePattern matches any cue word/phrase in negationCueAlternation,
|
||||||
|
// case-insensitively, as a whole word/phrase (not a substring of an
|
||||||
|
// unrelated longer word).
|
||||||
|
var negationCuePattern = regexp.MustCompile(`(?i)\b(?:` + negationCueAlternation + `)\b`)
|
||||||
|
|
||||||
|
// negationBareNoColonPattern matches a bare "no:" cue (e.g. "no: MD5 lacks a
|
||||||
|
// work factor"), kept as its own pattern since a trailing \b after the
|
||||||
|
// punctuation mark ":" does not reliably assert what a word-boundary
|
||||||
|
// alternation needs.
|
||||||
|
var negationBareNoColonPattern = regexp.MustCompile(`(?i)\bno\s*:`)
|
||||||
|
|
||||||
|
// negationDirectionalCueAlternation lists cues that are only a genuine
|
||||||
|
// negation of a term mentioned AFTER them, never of a term mentioned
|
||||||
|
// BEFORE them: in "use bcrypt instead of MD5", "instead of" negates MD5
|
||||||
|
// (which follows it), but in "use MD5 instead of proper hashing", the same
|
||||||
|
// cue phrase follows MD5 while MD5 is still being recommended - checking
|
||||||
|
// "instead of"/"rather than" on both sides of a match would wrongly treat
|
||||||
|
// that second sentence as a safe, negated mention too (C2, D5).
|
||||||
|
const negationDirectionalCueAlternation = `instead of|rather than`
|
||||||
|
|
||||||
|
// negationDirectionalCuePattern matches negationDirectionalCueAlternation.
|
||||||
|
// Checked only in the window immediately BEFORE a match, never after.
|
||||||
|
var negationDirectionalCuePattern = regexp.MustCompile(`(?i)\b(?:` + negationDirectionalCueAlternation + `)\b`)
|
||||||
|
|
||||||
|
// clauseWindowBounds computes the [lo,hi) bidirectional search window
|
||||||
|
// around a match [start,end) in response: outward up to maxChars
|
||||||
|
// characters in each direction, but stopped early at the nearest clause
|
||||||
|
// boundary - a '.', ';', or a blank-line paragraph gap - whichever comes
|
||||||
|
// first, so a negation cue from an unrelated adjacent clause or sentence
|
||||||
|
// can never count as negating a genuinely unqualified endorsement
|
||||||
|
// elsewhere in a longer response (D5, C2). This is a known, accepted
|
||||||
|
// limitation for sentence-final abbreviations (e.g. "e.g.", "etc.") that
|
||||||
|
// happen to sit inside the window - none of this framework's fixtures
|
||||||
|
// place a negation cue immediately after one.
|
||||||
|
func clauseWindowBounds(response string, start, end, maxChars int) (lo, hi int) {
|
||||||
|
lo = start - maxChars
|
||||||
|
if lo < 0 {
|
||||||
|
lo = 0
|
||||||
|
}
|
||||||
|
for i := start - 1; i >= lo; i-- {
|
||||||
|
c := response[i]
|
||||||
|
if c == '.' || c == ';' {
|
||||||
|
lo = i + 1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if c == '\n' && i > 0 && response[i-1] == '\n' {
|
||||||
|
lo = i + 1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hi = end + maxChars
|
||||||
|
if hi > len(response) {
|
||||||
|
hi = len(response)
|
||||||
|
}
|
||||||
|
for i := end; i < hi; i++ {
|
||||||
|
c := response[i]
|
||||||
|
if c == '.' || c == ';' {
|
||||||
|
hi = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if c == '\n' && i+1 < len(response) && response[i+1] == '\n' {
|
||||||
|
hi = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lo, hi
|
||||||
|
}
|
||||||
|
|
||||||
|
// negationCueNearby reports whether a negation cue (bidirectional,
|
||||||
|
// clause-scoped) negates the match [start,end) in response. Directional
|
||||||
|
// cues (instead of/rather than) are checked only in the PRE-window.
|
||||||
|
func negationCueNearby(response string, start, end, maxChars int) bool {
|
||||||
|
lo, hi := clauseWindowBounds(response, start, end, maxChars)
|
||||||
|
pre, post := response[lo:start], response[end:hi]
|
||||||
|
|
||||||
|
if negationCuePattern.MatchString(pre) || negationCuePattern.MatchString(post) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if negationBareNoColonPattern.MatchString(pre) || negationBareNoColonPattern.MatchString(post) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return negationDirectionalCuePattern.MatchString(pre)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoUnnegatedMention returns an Evaluator scoring full credit unless
|
||||||
|
// badPattern matches somewhere in response with no negation cue nearby -
|
||||||
|
// i.e. it penalizes an unqualified endorsement of a discouraged
|
||||||
|
// term/command/phrase but not a warning against it. This is deliberately
|
||||||
|
// not NotContains, which would also zero out the best possible answer (one
|
||||||
|
// that correctly names the discouraged thing while warning against it).
|
||||||
|
//
|
||||||
|
// The search window around each match is bidirectional (checked both
|
||||||
|
// immediately before AND after the match, since a good answer negates a
|
||||||
|
// discouraged term with the cue on either side - "never use MD5" as much as
|
||||||
|
// "MD5 is unsuitable here") and clause-scoped (see clauseWindowBounds), so a
|
||||||
|
// cue from an unrelated adjacent sentence never falsely negates a
|
||||||
|
// genuinely unqualified endorsement elsewhere in a longer response.
|
||||||
|
// maxChars additionally caps the window width in each direction.
|
||||||
|
//
|
||||||
|
// exempt, if non-nil, is matched against the text immediately following
|
||||||
|
// each match (response[end:]); a match whose trailing text satisfies
|
||||||
|
// exempt is treated as inherently safe and skips the negation check
|
||||||
|
// entirely (e.g. "--force-with-lease" is the safe form of "--force" and
|
||||||
|
// never needs a negation cue to be a correct recommendation).
|
||||||
|
func NoUnnegatedMention(badPattern *regexp.Regexp, maxChars int, exempt *regexp.Regexp) Evaluator {
|
||||||
|
return EvaluatorFunc(func(_ context.Context, response string) Score {
|
||||||
|
matches := badPattern.FindAllStringIndex(response, -1)
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return Score{Value: 1, Detail: "no mention of a discouraged term"}
|
||||||
|
}
|
||||||
|
for _, loc := range matches {
|
||||||
|
start, end := loc[0], loc[1]
|
||||||
|
if exempt != nil && exempt.MatchString(response[end:]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !negationCueNearby(response, start, end, maxChars) {
|
||||||
|
return Score{Value: 0, Detail: "unnegated mention of a discouraged term"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Score{Value: 1, Detail: "every mention of a discouraged term is negated"}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package eval
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNoUnnegatedMention(t *testing.T) {
|
||||||
|
badPattern := regexp.MustCompile(`(?i)\bMD5\b`)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
response string
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"no mention at all", "Use bcrypt to hash the password.", 1},
|
||||||
|
{"unnegated bare endorsement", "Just hash the password with MD5.", 0},
|
||||||
|
{"pre-window imperative cue", "Never use MD5 for password storage.", 1},
|
||||||
|
{"post-window adjectival cue", "MD5 is unsuitable for password storage.", 1},
|
||||||
|
{"post-window 'too fast' adjectival cue", "MD5 is too fast to be safe for passwords.", 1},
|
||||||
|
{"post-window 'insecure' adjectival cue", "MD5 is insecure for this purpose.", 1},
|
||||||
|
{"post-window 'predictable' adjectival cue", "MD5 output is predictable given no work factor.", 1},
|
||||||
|
{"bare 'no:' cue", "no: MD5 lacks a tunable work factor.", 1},
|
||||||
|
{
|
||||||
|
name: "clause-scoped: cue in an unrelated PRECEDING sentence does not count",
|
||||||
|
response: "Never mind the deployment steps. Just hash the password with MD5.",
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "clause-scoped: cue in an unrelated FOLLOWING sentence does not count",
|
||||||
|
response: "Just hash the password with MD5. Never mind, that was a joke about something else.",
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "directional cue 'instead of' correctly negates the term it precedes",
|
||||||
|
response: "Use bcrypt instead of MD5 for password storage.",
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "directional cue 'instead of' AFTER the term does not negate it (recommends MD5 first)",
|
||||||
|
response: "Use MD5 instead of a slower alternative for password storage.",
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "directional cue 'rather than' correctly negates the term it precedes",
|
||||||
|
response: "Use argon2 rather than MD5.",
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple mentions: all negated scores full credit",
|
||||||
|
response: "Never use MD5. Avoid MD5 for anything security-sensitive.",
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple mentions: one unnegated mention scores zero",
|
||||||
|
response: "Never use MD5 for tokens. For passwords, just hash with MD5 directly.",
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
evaluator := NoUnnegatedMention(badPattern, 60, nil)
|
||||||
|
got := evaluator.Evaluate(context.Background(), tt.response)
|
||||||
|
if got.Value != tt.want {
|
||||||
|
t.Errorf("NoUnnegatedMention().Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoUnnegatedMention_Exempt(t *testing.T) {
|
||||||
|
badPattern := regexp.MustCompile(`(?i)--force\b`)
|
||||||
|
exempt := regexp.MustCompile(`(?i)^-with-lease`)
|
||||||
|
evaluator := NoUnnegatedMention(badPattern, 60, exempt)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
response string
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"exempted safe form scores full credit unnegated", "Use --force-with-lease to push safely.", 1},
|
||||||
|
{"bare --force unnegated scores zero", "Just run git push --force.", 0},
|
||||||
|
{"bare --force negated scores full credit", "Never run git push --force on a shared branch.", 1},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := evaluator.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 TestClauseWindowBounds(t *testing.T) {
|
||||||
|
response := "First sentence here. Second sentence has TERM in it. Third sentence follows."
|
||||||
|
start := len("First sentence here. Second sentence has ")
|
||||||
|
end := start + len("TERM")
|
||||||
|
|
||||||
|
lo, hi := clauseWindowBounds(response, start, end, 60)
|
||||||
|
pre := response[lo:start]
|
||||||
|
post := response[end:hi]
|
||||||
|
|
||||||
|
if want := " Second sentence has "; pre != want {
|
||||||
|
t.Errorf("pre = %q, want %q", pre, want)
|
||||||
|
}
|
||||||
|
if want := " in it"; post != want {
|
||||||
|
t.Errorf("post = %q, want %q", post, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,7 +97,18 @@ func classifyMatch(text string, start, end int) (numberCandidate, bool) {
|
|||||||
// ("float64x2") keep a word byte after the x and stay rejected.
|
// ("float64x2") keep a word byte after the x and stay rejected.
|
||||||
isMultiplier := (text[end] == 'x' || text[end] == 'X') &&
|
isMultiplier := (text[end] == 'x' || text[end] == 'X') &&
|
||||||
(end+1 >= len(text) || !isWordByte(text[end+1]))
|
(end+1 >= len(text) || !isWordByte(text[end+1]))
|
||||||
if !isMultiplier {
|
// Permit a glued byte-size unit suffix ("30MB" = 30, no space)
|
||||||
|
// (DC1): case-insensitive kb/mb/gb/tb, with nothing word-like
|
||||||
|
// after it - "300MBs" (plural) keeps a word byte after the unit
|
||||||
|
// and stays rejected, the same strictness as the x/X case above.
|
||||||
|
isUnitSuffix := false
|
||||||
|
if end+2 <= len(text) {
|
||||||
|
switch strings.ToLower(text[end : end+2]) {
|
||||||
|
case "kb", "mb", "gb", "tb":
|
||||||
|
isUnitSuffix = end+2 >= len(text) || !isWordByte(text[end+2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !isMultiplier && !isUnitSuffix {
|
||||||
return numberCandidate{}, false // e.g. "24bytes" with no separator
|
return numberCandidate{}, false // e.g. "24bytes" with no separator
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ func TestExtractLastNumber(t *testing.T) {
|
|||||||
{"multiplier suffix accepted", "The compression ratio is 64x.", 64, false},
|
{"multiplier suffix accepted", "The compression ratio is 64x.", 64, false},
|
||||||
{"multiplier suffix at end of text", "speedup: 8x", 8, false},
|
{"multiplier suffix at end of text", "speedup: 8x", 8, false},
|
||||||
{"hex literal never a multiplier", "the flag is 0x2A so the answer is 42", 42, false},
|
{"hex literal never a multiplier", "the flag is 0x2A so the answer is 42", 42, false},
|
||||||
|
// DC1: glued byte-size unit suffixes (kb/mb/gb/tb) extract the
|
||||||
|
// same as a multiplier suffix.
|
||||||
|
{"glued MB unit suffix", "The final image is 30MB.", 30, false},
|
||||||
|
{"glued lowercase mb unit suffix", "total: 30mb", 30, false},
|
||||||
|
{"glued GB unit suffix", "Budget: 60GB", 60, false},
|
||||||
|
{"glued KB unit suffix", "Size: 512KB", 512, false},
|
||||||
|
{"glued TB unit suffix", "Capacity: 4TB", 4, false},
|
||||||
|
{"plural unit suffix still rejected as an identifier", "roughly 300MBs of headroom, so the answer is 300", 300, false},
|
||||||
{"hyphen-compound number accepted as a last resort", "It is a 24-byte struct.", 24, false},
|
{"hyphen-compound number accepted as a last resort", "It is a 24-byte struct.", 24, false},
|
||||||
// Documented limitation, not a regression: the heuristic has no
|
// Documented limitation, not a regression: the heuristic has no
|
||||||
// notion of "the total" vs. "a breakdown component" and returns
|
// notion of "the total" vs. "a breakdown component" and returns
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package tests
|
package tests
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
||||||
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
||||||
)
|
)
|
||||||
@@ -81,6 +83,15 @@ id: {"temperature":"...","max_tokens":"...","stop":"..."}`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// llmTokenBudgetMaxTokens and llmTokenBudgetThinkTokens are the parameters
|
||||||
|
// for llmTokenBudgetReasoningTest, interpolated into the prompt (C14) so
|
||||||
|
// the displayed values can never drift from what llmTokenBudgetReasoningWant
|
||||||
|
// is actually computed from.
|
||||||
|
const (
|
||||||
|
llmTokenBudgetMaxTokens = 500
|
||||||
|
llmTokenBudgetThinkTokens = 437
|
||||||
|
)
|
||||||
|
|
||||||
// llmTokenBudgetReasoningWant is derived by plain subtraction, matching the
|
// llmTokenBudgetReasoningWant is derived by plain subtraction, matching the
|
||||||
// arithmetic ai_llmintegration_test.go independently recomputes.
|
// arithmetic ai_llmintegration_test.go independently recomputes.
|
||||||
//
|
//
|
||||||
@@ -92,21 +103,22 @@ id: {"temperature":"...","max_tokens":"...","stop":"..."}`
|
|||||||
// than a small fixed cap. Here: max_tokens = 500, and the model's <think>
|
// than a small fixed cap. Here: max_tokens = 500, and the model's <think>
|
||||||
// block consumes 437 completion tokens before the visible answer begins.
|
// block consumes 437 completion tokens before the visible answer begins.
|
||||||
// Tokens remaining for the visible answer = 500 - 437 = 63.
|
// Tokens remaining for the visible answer = 500 - 437 = 63.
|
||||||
const llmTokenBudgetReasoningWant = 500 - 437
|
const llmTokenBudgetReasoningWant = llmTokenBudgetMaxTokens - llmTokenBudgetThinkTokens
|
||||||
|
|
||||||
func llmTokenBudgetReasoningTest() testkit.Test {
|
func llmTokenBudgetReasoningTest() testkit.Test {
|
||||||
prompt := `An OpenAI-compatible chat completion request sets
|
prompt := fmt.Sprintf(`An OpenAI-compatible chat completion request sets
|
||||||
max_tokens = 500. This caps the total number of completion tokens the
|
max_tokens = %d. This caps the total number of completion tokens the
|
||||||
response may contain, counting every token the model generates: a
|
response may contain, counting every token the model generates: a
|
||||||
reasoning model's hidden <think>...</think> content counts against this
|
reasoning model's hidden <think>...</think> content counts against this
|
||||||
same budget before its visible answer, since both are completion tokens.
|
same budget before its visible answer, since both are completion tokens.
|
||||||
|
|
||||||
For this request, the model's <think>...</think> block consumes exactly
|
For this request, the model's <think>...</think> block consumes exactly
|
||||||
437 completion tokens before it begins writing its visible answer.
|
%d completion tokens before it begins writing its visible answer.
|
||||||
|
|
||||||
How many completion tokens remain in the max_tokens budget for the visible
|
How many completion tokens remain in the max_tokens budget for the visible
|
||||||
answer, once the <think> block has been generated? Respond with only the
|
answer, once the <think> block has been generated? Respond with only the
|
||||||
number.`
|
number, with no commas, units, or other text (for example: 63).`,
|
||||||
|
llmTokenBudgetMaxTokens, llmTokenBudgetThinkTokens)
|
||||||
|
|
||||||
return testkit.Test{
|
return testkit.Test{
|
||||||
ID: "llm-token-budget-reasoning",
|
ID: "llm-token-budget-reasoning",
|
||||||
@@ -251,7 +263,7 @@ with only one word.`
|
|||||||
Subcategory: "llm-integration",
|
Subcategory: "llm-integration",
|
||||||
Description: "Identify system (not user) as the role that carries a fixed, non-negotiable operating instruction.",
|
Description: "Identify system (not user) as the role that carries a fixed, non-negotiable operating instruction.",
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
Eval: eval.Equals("system"),
|
Eval: eval.ExactToken("system"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,26 +321,37 @@ final event? Respond with only that exact text, nothing else.`
|
|||||||
Subcategory: "llm-integration",
|
Subcategory: "llm-integration",
|
||||||
Description: "Recall the exact [DONE] sentinel text of the final SSE event in an OpenAI-compatible stream.",
|
Description: "Recall the exact [DONE] sentinel text of the final SSE event in an OpenAI-compatible stream.",
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
Eval: eval.Equals("[DONE]"),
|
Eval: eval.ExactToken("[DONE]"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// llmEmbeddingBatchDocCount and llmEmbeddingBatchSize are the parameters
|
||||||
|
// for llmEmbeddingBatchMathTest, interpolated into the prompt (C14) so the
|
||||||
|
// displayed values can never drift from what llmEmbeddingBatchMathWant is
|
||||||
|
// actually computed from.
|
||||||
|
const (
|
||||||
|
llmEmbeddingBatchDocCount = 10000
|
||||||
|
llmEmbeddingBatchSize = 96
|
||||||
|
)
|
||||||
|
|
||||||
// llmEmbeddingBatchMathWant is derived by calling aiCeilDiv, not
|
// llmEmbeddingBatchMathWant is derived by calling aiCeilDiv, not
|
||||||
// hardcoded.
|
// hardcoded.
|
||||||
//
|
//
|
||||||
// ground truth: ceil(10000/96) - 96*104 = 9984, leaving 16 documents that
|
// ground truth: ceil(10000/96) - 96*104 = 9984, leaving 16 documents that
|
||||||
// still need one more (partial) request, so 104 full batches plus 1
|
// still need one more (partial) request, so 104 full batches plus 1
|
||||||
// partial batch = 105 requests total.
|
// partial batch = 105 requests total.
|
||||||
var llmEmbeddingBatchMathWant = aiCeilDiv(10000, 96)
|
var llmEmbeddingBatchMathWant = aiCeilDiv(llmEmbeddingBatchDocCount, llmEmbeddingBatchSize)
|
||||||
|
|
||||||
func llmEmbeddingBatchMathTest() testkit.Test {
|
func llmEmbeddingBatchMathTest() testkit.Test {
|
||||||
prompt := `You must generate embeddings for 10,000 documents. The
|
prompt := fmt.Sprintf(`You must generate embeddings for %d documents. The
|
||||||
embeddings API accepts a maximum of 96 items per request, and every
|
embeddings API accepts a maximum of %d items per request, and every
|
||||||
request you send should be as full as possible (maximally batched) to
|
request you send should be as full as possible (maximally batched) to
|
||||||
minimize the number of requests.
|
minimize the number of requests.
|
||||||
|
|
||||||
How many API requests are needed in total to embed all 10,000 documents?
|
How many API requests are needed in total to embed all %d documents?
|
||||||
Respond with only the number.`
|
Respond with only the number, with no commas, units, or other text (for
|
||||||
|
example: 105).`,
|
||||||
|
llmEmbeddingBatchDocCount, llmEmbeddingBatchSize, llmEmbeddingBatchDocCount)
|
||||||
|
|
||||||
return testkit.Test{
|
return testkit.Test{
|
||||||
ID: "llm-embedding-batch-math",
|
ID: "llm-embedding-batch-math",
|
||||||
@@ -364,6 +387,6 @@ client? Respond with only that exact returned text, nothing else.`
|
|||||||
Subcategory: "llm-integration",
|
Subcategory: "llm-integration",
|
||||||
Description: "Trace the exact text an API returns when generation is cut by a stop=[\"\\n\\n\"] sequence, excluding the sequence itself.",
|
Description: "Trace the exact text an API returns when generation is cut by a stop=[\"\\n\\n\"] sequence, excluding the sequence itself.",
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
Eval: eval.Equals("Answer: 42"),
|
Eval: eval.ExactToken("Answer: 42"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ func TestLlmFieldSemanticsTest_Eval(t *testing.T) {
|
|||||||
response: "Here is my answer:\n```json\n{\"temperature\":\"sampling_randomness\",\"max_tokens\":\"completion_token_cap\",\"stop\":\"generation_end_strings\"}\n```",
|
response: "Here is my answer:\n```json\n{\"temperature\":\"sampling_randomness\",\"max_tokens\":\"completion_token_cap\",\"stop\":\"generation_end_strings\"}\n```",
|
||||||
want: 1,
|
want: 1,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "all correct, different spacing",
|
||||||
|
response: `{ "temperature": "sampling_randomness", "max_tokens": "completion_token_cap", "stop": "generation_end_strings" }`,
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "one wrong",
|
name: "one wrong",
|
||||||
response: `{"temperature":"output_format","max_tokens":"completion_token_cap","stop":"generation_end_strings"}`,
|
response: `{"temperature":"output_format","max_tokens":"completion_token_cap","stop":"generation_end_strings"}`,
|
||||||
@@ -65,6 +70,7 @@ func TestLlmTokenBudgetReasoningTest_Eval(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"exact", "63", 1},
|
{"exact", "63", 1},
|
||||||
{"prose wrapped", "63 completion tokens remain.", 1},
|
{"prose wrapped", "63 completion tokens remain.", 1},
|
||||||
|
{"fenced", "```\n63\n```", 1},
|
||||||
{"wrong: forgot to subtract", "500", 0},
|
{"wrong: forgot to subtract", "500", 0},
|
||||||
{"wrong: reported the reasoning tokens used, not remaining", "437", 0},
|
{"wrong: reported the reasoning tokens used, not remaining", "437", 0},
|
||||||
}
|
}
|
||||||
@@ -87,6 +93,7 @@ func TestLlm429RetryBackoffTest_Eval(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"all correct", `{"should_retry":"yes","wait_seconds":2}`, 1},
|
{"all correct", `{"should_retry":"yes","wait_seconds":2}`, 1},
|
||||||
{"all correct fenced", "```json\n{\"should_retry\":\"yes\",\"wait_seconds\":2}\n```", 1},
|
{"all correct fenced", "```json\n{\"should_retry\":\"yes\",\"wait_seconds\":2}\n```", 1},
|
||||||
|
{"all correct, different spacing", `{ "should_retry": "yes", "wait_seconds": 2 }`, 1},
|
||||||
{"wrong: should not retry", `{"should_retry":"no","wait_seconds":2}`, 0.5},
|
{"wrong: should not retry", `{"should_retry":"no","wait_seconds":2}`, 0.5},
|
||||||
{"wrong: ignored Retry-After value", `{"should_retry":"yes","wait_seconds":30}`, 0.5},
|
{"wrong: ignored Retry-After value", `{"should_retry":"yes","wait_seconds":30}`, 0.5},
|
||||||
{"both wrong", `{"should_retry":"no","wait_seconds":30}`, 0},
|
{"both wrong", `{"should_retry":"no","wait_seconds":30}`, 0},
|
||||||
@@ -110,6 +117,7 @@ func TestLlmContextOverflowStrategyTest_Eval(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"correct: drop-oldest-turns", `{"strategy":"drop-oldest-turns"}`, 1},
|
{"correct: drop-oldest-turns", `{"strategy":"drop-oldest-turns"}`, 1},
|
||||||
{"correct, fenced with prose", "Given the no-altering constraint:\n```json\n{\"strategy\":\"drop-oldest-turns\"}\n```", 1},
|
{"correct, fenced with prose", "Given the no-altering constraint:\n```json\n{\"strategy\":\"drop-oldest-turns\"}\n```", 1},
|
||||||
|
{"correct, different spacing", `{ "strategy": "drop-oldest-turns" }`, 1},
|
||||||
{"wrong: summarize-oldest-turns", `{"strategy":"summarize-oldest-turns"}`, 0},
|
{"wrong: summarize-oldest-turns", `{"strategy":"summarize-oldest-turns"}`, 0},
|
||||||
{"wrong: truncate-system-prompt", `{"strategy":"truncate-system-prompt"}`, 0},
|
{"wrong: truncate-system-prompt", `{"strategy":"truncate-system-prompt"}`, 0},
|
||||||
}
|
}
|
||||||
@@ -132,6 +140,7 @@ func TestLlmToolCallHandlingTest_Eval(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"all correct", `{"next_role":"tool","id_field":"tool_call_id"}`, 1},
|
{"all correct", `{"next_role":"tool","id_field":"tool_call_id"}`, 1},
|
||||||
{"all correct fenced", "```json\n{\"next_role\":\"tool\",\"id_field\":\"tool_call_id\"}\n```", 1},
|
{"all correct fenced", "```json\n{\"next_role\":\"tool\",\"id_field\":\"tool_call_id\"}\n```", 1},
|
||||||
|
{"all correct, different spacing", `{ "next_role": "tool", "id_field": "tool_call_id" }`, 1},
|
||||||
{"wrong role", `{"next_role":"assistant","id_field":"tool_call_id"}`, 0.5},
|
{"wrong role", `{"next_role":"assistant","id_field":"tool_call_id"}`, 0.5},
|
||||||
{"wrong field", `{"next_role":"tool","id_field":"id"}`, 0.5},
|
{"wrong field", `{"next_role":"tool","id_field":"id"}`, 0.5},
|
||||||
{"both wrong", `{"next_role":"user","id_field":"call_id"}`, 0},
|
{"both wrong", `{"next_role":"user","id_field":"call_id"}`, 0},
|
||||||
@@ -155,7 +164,9 @@ func TestLlmRolePlacementTest_Eval(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"correct: system", "system", 1},
|
{"correct: system", "system", 1},
|
||||||
{"correct, different case", "System", 1},
|
{"correct, different case", "System", 1},
|
||||||
|
{"correct, quoted", `"system"`, 1},
|
||||||
{"wrong: user", "user", 0},
|
{"wrong: user", "user", 0},
|
||||||
|
{"wrong: assistant", "assistant", 0},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -179,6 +190,16 @@ func TestLlmTemperatureZeroCaveatTest_Eval(t *testing.T) {
|
|||||||
response: "Floating-point arithmetic is not strictly associative, and provider-side batching or GPU kernel routing changes summation order, which can flip the top token.",
|
response: "Floating-point arithmetic is not strictly associative, and provider-side batching or GPU kernel routing changes summation order, which can flip the top token.",
|
||||||
want: 1,
|
want: 1,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "both reasons present, alternate phrasing",
|
||||||
|
response: "Numerical precision in floating-point math is not associative, and the hardware kernel path a batched request is routed through can differ between calls.",
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "both reasons present, terse phrasing",
|
||||||
|
response: "Floating-point precision issues plus parallel execution batching on the GPU.",
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "only the numerical reason",
|
name: "only the numerical reason",
|
||||||
response: "It is because of floating point rounding differences.",
|
response: "It is because of floating point rounding differences.",
|
||||||
@@ -194,6 +215,11 @@ func TestLlmTemperatureZeroCaveatTest_Eval(t *testing.T) {
|
|||||||
response: "The model just has some inherent randomness left even at temperature zero.",
|
response: "The model just has some inherent randomness left even at temperature zero.",
|
||||||
want: 0,
|
want: 0,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "neither reason, wrong explanation entirely",
|
||||||
|
response: "It happens because the random seed is not fixed across requests.",
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -255,6 +281,7 @@ func TestLlmEmbeddingBatchMathTest_Eval(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"exact", "105", 1},
|
{"exact", "105", 1},
|
||||||
{"prose wrapped", "You need 105 requests.", 1},
|
{"prose wrapped", "You need 105 requests.", 1},
|
||||||
|
{"fenced", "```\n105\n```", 1},
|
||||||
{"wrong: dropped the partial batch", "104", 0},
|
{"wrong: dropped the partial batch", "104", 0},
|
||||||
{"wrong: off by a lot", "96", 0},
|
{"wrong: off by a lot", "96", 0},
|
||||||
}
|
}
|
||||||
@@ -277,6 +304,7 @@ func TestLlmStopSequenceTraceTest_Eval(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"correct", "Answer: 42", 1},
|
{"correct", "Answer: 42", 1},
|
||||||
{"correct with whitespace", " Answer: 42 ", 1},
|
{"correct with whitespace", " Answer: 42 ", 1},
|
||||||
|
{"correct, quoted", `"Answer: 42"`, 1},
|
||||||
{"wrong: included text past the stop sequence", "Answer: 42\n\nExplanation: 6 times 7 is 42.", 0},
|
{"wrong: included text past the stop sequence", "Answer: 42\n\nExplanation: 6 times 7 is 42.", 0},
|
||||||
{"wrong: paraphrased instead of quoting exactly", "The answer is 42", 0},
|
{"wrong: paraphrased instead of quoting exactly", "The answer is 42", 0},
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-16
@@ -40,9 +40,9 @@ that fact.
|
|||||||
Three chunk-size options are available: 128 tokens, 512 tokens, and 2048
|
Three chunk-size options are available: 128 tokens, 512 tokens, and 2048
|
||||||
tokens.
|
tokens.
|
||||||
|
|
||||||
Which of these is the smallest option that reliably holds a full 450-token
|
Which of these is the smallest option large enough to contain a full
|
||||||
fact without ever splitting it across two chunks? Respond with only a
|
450-token fact? Respond with only a JSON object:
|
||||||
JSON object: {"chunk_size_tokens":128|512|2048}`
|
{"chunk_size_tokens":128|512|2048}`
|
||||||
|
|
||||||
return testkit.Test{
|
return testkit.Test{
|
||||||
ID: "rag-chunk-size-tradeoff",
|
ID: "rag-chunk-size-tradeoff",
|
||||||
@@ -62,14 +62,17 @@ JSON object: {"chunk_size_tokens":128|512|2048}`
|
|||||||
// candidates that retrieval already surfaced, so it must run second;
|
// candidates that retrieval already surfaced, so it must run second;
|
||||||
// generation consumes the reranker's narrowed, reordered candidates last.
|
// generation consumes the reranker's narrowed, reordered candidates last.
|
||||||
func ragRerankerPlacementTest() testkit.Test {
|
func ragRerankerPlacementTest() testkit.Test {
|
||||||
|
// C4: the stages below are hand-shuffled to a different order than the
|
||||||
|
// correct pipeline order, so an echo of the presentation order does not
|
||||||
|
// score 1.0.
|
||||||
prompt := `A RAG pipeline has these three stages, listed here in no
|
prompt := `A RAG pipeline has these three stages, listed here in no
|
||||||
particular order:
|
particular order:
|
||||||
|
- generate: have the LLM produce the final answer using the selected
|
||||||
|
chunks as context.
|
||||||
- retrieve: run a broad search (vector and/or keyword) over the index to
|
- retrieve: run a broad search (vector and/or keyword) over the index to
|
||||||
produce an initial candidate set of chunks.
|
produce an initial candidate set of chunks.
|
||||||
- rerank: use a more expensive, more accurate model (e.g. a cross-encoder)
|
- rerank: use a more expensive, more accurate model (e.g. a cross-encoder)
|
||||||
to reorder a candidate set by relevance and narrow it to the best few.
|
to reorder a candidate set by relevance and narrow it to the best few.
|
||||||
- generate: have the LLM produce the final answer using the selected
|
|
||||||
chunks as context.
|
|
||||||
|
|
||||||
Give the correct pipeline order as a JSON array of the three stage names,
|
Give the correct pipeline order as a JSON array of the three stage names,
|
||||||
e.g. ["a","b","c"]. Respond with only the JSON array.`
|
e.g. ["a","b","c"]. Respond with only the JSON array.`
|
||||||
@@ -122,7 +125,7 @@ based)? Respond with only one word: keyword or semantic.`
|
|||||||
Subcategory: "rag",
|
Subcategory: "rag",
|
||||||
Description: "Identify keyword (exact-term) search as the method that fails on a synonym-only query/document pair.",
|
Description: "Identify keyword (exact-term) search as the method that fails on a synonym-only query/document pair.",
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
Eval: eval.Equals("keyword"),
|
Eval: eval.ExactToken("keyword"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,8 +140,12 @@ the answer be traceable back to, and what must the system do instead of
|
|||||||
guessing when the retrieved context does not actually contain the
|
guessing when the retrieved context does not actually contain the
|
||||||
information needed to answer the question?`
|
information needed to answer the question?`
|
||||||
|
|
||||||
|
// C10: widen the traceability group beyond "cite"/"citation"/"source" -
|
||||||
|
// a response can correctly state the grounding requirement using
|
||||||
|
// "retrieved context"/"provided context"/"grounded"/"supported by"/
|
||||||
|
// "traceable" without ever using the word "cite" itself.
|
||||||
evaluator := eval.Mean(
|
evaluator := eval.Mean(
|
||||||
eval.ContainsAny("cite", "citation", "source", "quote", "quoted"),
|
eval.ContainsAny("cite", "citation", "source", "quote", "quoted", "retrieved context", "provided context", "grounded", "supported by", "traceable"),
|
||||||
eval.ContainsAny("insufficient", "does not contain", "doesn't contain", "cannot answer", "can't answer", "no relevant", "unable to find", "not found in the context", "not present in the context"),
|
eval.ContainsAny("insufficient", "does not contain", "doesn't contain", "cannot answer", "can't answer", "no relevant", "unable to find", "not found in the context", "not present in the context"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -185,7 +192,7 @@ unused ("selective")? Respond with only one word: stuffed or selective.`
|
|||||||
Subcategory: "rag",
|
Subcategory: "rag",
|
||||||
Description: "Choose relevance-filtered ('selective') context assembly over budget-filling ('stuffed') given low-relevance chunks degrade quality.",
|
Description: "Choose relevance-filtered ('selective') context assembly over budget-filling ('stuffed') given low-relevance chunks degrade quality.",
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
Eval: eval.Equals("selective"),
|
Eval: eval.ExactToken("selective"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,15 +291,23 @@ specific problem in each scenario? Respond with only a JSON object:
|
|||||||
// unsupported claims can be flagged/removed, since flagging depends on
|
// unsupported claims can be flagged/removed, since flagging depends on
|
||||||
// verification's result.
|
// verification's result.
|
||||||
func ragHallucinationMitigationOrderingTest() testkit.Test {
|
func ragHallucinationMitigationOrderingTest() testkit.Test {
|
||||||
|
// C4: hand-shuffled to a different order than the correct answer, so an
|
||||||
|
// echo of the presentation order does not score 1.0. C8: step_instruct
|
||||||
|
// is reworded to make explicit that it embeds the ALREADY-RETRIEVED
|
||||||
|
// context into the assembled prompt, which forces instruct to run
|
||||||
|
// after retrieve (the original "instruct the model... to answer using
|
||||||
|
// only the retrieved context" wording was ambiguous about whether this
|
||||||
|
// was a static, retrieval-independent system instruction).
|
||||||
prompt := `A RAG pipeline's hallucination-mitigation steps are listed
|
prompt := `A RAG pipeline's hallucination-mitigation steps are listed
|
||||||
here, identified by id, in no particular order:
|
here, identified by id, in no particular order:
|
||||||
- step_retrieve: retrieve grounding context relevant to the query.
|
- step_flag: flag or remove any claim that verification could not support.
|
||||||
- step_instruct: instruct the model, in the prompt, to answer using only
|
|
||||||
the retrieved context and to cite it.
|
|
||||||
- step_generate: generate the answer.
|
- step_generate: generate the answer.
|
||||||
|
- step_retrieve: retrieve grounding context relevant to the query.
|
||||||
- step_verify: verify each claim in the generated answer against the
|
- step_verify: verify each claim in the generated answer against the
|
||||||
retrieved context.
|
retrieved context.
|
||||||
- step_flag: flag or remove any claim that verification could not support.
|
- step_instruct: assemble the final prompt, embedding the retrieved
|
||||||
|
context together with the instruction to answer only from it and cite
|
||||||
|
it.
|
||||||
|
|
||||||
Give the correct order these steps must run in, as a JSON array of their
|
Give the correct order these steps must run in, as a JSON array of their
|
||||||
ids, e.g. ["step_a","step_b"]. Respond with only the JSON array.`
|
ids, e.g. ["step_a","step_b"]. Respond with only the JSON array.`
|
||||||
@@ -323,18 +338,22 @@ ids, e.g. ["step_a","step_b"]. Respond with only the JSON array.`
|
|||||||
// answer). frag_x (team headcount) answers a question never asked and
|
// answer). frag_x (team headcount) answers a question never asked and
|
||||||
// must be excluded.
|
// must be excluded.
|
||||||
func ragMultihopDecompositionTest() testkit.Test {
|
func ragMultihopDecompositionTest() testkit.Test {
|
||||||
|
// C4: hand-shuffled to a different order than the correct answer (the
|
||||||
|
// roster previously happened to already list the non-distractor
|
||||||
|
// fragments in the correct dependency order), so echoing the
|
||||||
|
// presentation order (minus frag_x) does not score 1.0.
|
||||||
prompt := `Multi-hop question: "What is the annual maintenance cost of
|
prompt := `Multi-hop question: "What is the annual maintenance cost of
|
||||||
the storage tier used by the database that backs the service which
|
the storage tier used by the database that backs the service which
|
||||||
handles user authentication?"
|
handles user authentication?"
|
||||||
|
|
||||||
Candidate sub-question fragments, identified by id, in no particular
|
Candidate sub-question fragments, identified by id, in no particular
|
||||||
order:
|
order:
|
||||||
- frag_q: "Which service handles user authentication?"
|
|
||||||
- frag_p: "Which database backs that service?"
|
|
||||||
- frag_s: "Which storage tier does that database use?"
|
- frag_s: "Which storage tier does that database use?"
|
||||||
- frag_r: "What is the annual maintenance cost of that storage tier?"
|
|
||||||
- frag_x: "What is the total headcount of the team that owns that
|
- frag_x: "What is the total headcount of the team that owns that
|
||||||
service?"
|
service?"
|
||||||
|
- frag_r: "What is the annual maintenance cost of that storage tier?"
|
||||||
|
- frag_q: "Which service handles user authentication?"
|
||||||
|
- frag_p: "Which database backs that service?"
|
||||||
|
|
||||||
Exactly one of these fragments (frag_x) does not help answer the
|
Exactly one of these fragments (frag_x) does not help answer the
|
||||||
multi-hop question and must be excluded. Give the remaining fragments, in
|
multi-hop question and must be excluded. Give the remaining fragments, in
|
||||||
@@ -363,8 +382,12 @@ the final context (rather than passing all 3 copies straight through to
|
|||||||
the LLM) matters: name what assembling all 3 copies wastes, and what
|
the LLM) matters: name what assembling all 3 copies wastes, and what
|
||||||
problem it risks introducing into the generated answer.`
|
problem it risks introducing into the generated answer.`
|
||||||
|
|
||||||
|
// CC4: widen the waste group with "tokens" (bare plural, not just
|
||||||
|
// "token budget"), "over-weight", and "dominat[e/ing/ion]" - a response
|
||||||
|
// can correctly describe the wasted-capacity consequence without using
|
||||||
|
// the word "budget"/"waste" specifically.
|
||||||
evaluator := eval.Mean(
|
evaluator := eval.Mean(
|
||||||
eval.ContainsAny("token budget", "context window", "context budget", "waste", "wastes", "wasted"),
|
eval.ContainsAny("token budget", "context window", "context budget", "waste", "wastes", "wasted", "tokens", "over-weight", "dominat"),
|
||||||
eval.ContainsAny("redundant", "redundancy", "duplicate", "duplication", "repetition", "repetitive", "diversity", "biased", "bias", "skew"),
|
eval.ContainsAny("redundant", "redundancy", "duplicate", "duplication", "repetition", "repetitive", "diversity", "biased", "bias", "skew"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ func TestRagChunkSizeTradeoffTest_Eval(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"correct: 512", `{"chunk_size_tokens":512}`, 1},
|
{"correct: 512", `{"chunk_size_tokens":512}`, 1},
|
||||||
{"correct, fenced with prose", "The smallest viable option is:\n```json\n{\"chunk_size_tokens\":512}\n```", 1},
|
{"correct, fenced with prose", "The smallest viable option is:\n```json\n{\"chunk_size_tokens\":512}\n```", 1},
|
||||||
|
{"correct, different spacing", `{ "chunk_size_tokens": 512 }`, 1},
|
||||||
{"wrong: too small, would split a fact", `{"chunk_size_tokens":128}`, 0},
|
{"wrong: too small, would split a fact", `{"chunk_size_tokens":128}`, 0},
|
||||||
{"wrong: larger than needed, more dilution", `{"chunk_size_tokens":2048}`, 0},
|
{"wrong: larger than needed, more dilution", `{"chunk_size_tokens":2048}`, 0},
|
||||||
}
|
}
|
||||||
@@ -39,6 +40,7 @@ func TestRagRerankerPlacementTest_Eval(t *testing.T) {
|
|||||||
{"correct order different case", `["Retrieve","Rerank","Generate"]`, 1},
|
{"correct order different case", `["Retrieve","Rerank","Generate"]`, 1},
|
||||||
{"reranker before retrieval", `["rerank","retrieve","generate"]`, 0},
|
{"reranker before retrieval", `["rerank","retrieve","generate"]`, 0},
|
||||||
{"generate before rerank", `["retrieve","generate","rerank"]`, 0},
|
{"generate before rerank", `["retrieve","generate","rerank"]`, 0},
|
||||||
|
{"wrong: echoes the prompt's shuffled presentation order verbatim (C4 bug probe)", `["generate","retrieve","rerank"]`, 0},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -59,7 +61,9 @@ func TestRagRetrievalFailureModeTest_Eval(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"correct: keyword", "keyword", 1},
|
{"correct: keyword", "keyword", 1},
|
||||||
{"correct, different case", "Keyword", 1},
|
{"correct, different case", "Keyword", 1},
|
||||||
|
{"correct, quoted", `"keyword"`, 1},
|
||||||
{"wrong: semantic", "semantic", 0},
|
{"wrong: semantic", "semantic", 0},
|
||||||
|
{"wrong: both", "both", 0},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -83,14 +87,28 @@ func TestRagCitationGroundingTest_Eval(t *testing.T) {
|
|||||||
response: "Every claim must be traceable to a cited source in the retrieved context; if the context does not contain the answer, the system must say it cannot answer rather than guess.",
|
response: "Every claim must be traceable to a cited source in the retrieved context; if the context does not contain the answer, the system must say it cannot answer rather than guess.",
|
||||||
want: 1,
|
want: 1,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "both requirements present, alternate phrasing",
|
||||||
|
response: "Answers must be grounded in and supported by the provided context, with every claim traceable back to it; when the context lacks the needed information, the system must say it is unable to find it instead of guessing.",
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "both requirements present, terse phrasing",
|
||||||
|
response: "Cite the source for every claim; if no relevant context exists, decline to answer.",
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "only the citation requirement",
|
name: "only the citation requirement",
|
||||||
response: "Every claim in the answer must include a citation back to the retrieved context.",
|
response: "Every claim in the answer must include a citation back to the retrieved context.",
|
||||||
want: 0.5,
|
want: 0.5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// C10 note: rephrased to avoid "retrieved context" (now one of
|
||||||
|
// the widened traceability-group cues), so this case still
|
||||||
|
// cleanly isolates the decline requirement from the citation
|
||||||
|
// requirement.
|
||||||
name: "only the decline requirement",
|
name: "only the decline requirement",
|
||||||
response: "If the retrieved context does not contain the answer, the system must say it is unable to find it.",
|
response: "If the answer isn't there, the system must say it cannot answer.",
|
||||||
want: 0.5,
|
want: 0.5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -98,6 +116,11 @@ func TestRagCitationGroundingTest_Eval(t *testing.T) {
|
|||||||
response: "The answer should just sound confident and helpful.",
|
response: "The answer should just sound confident and helpful.",
|
||||||
want: 0,
|
want: 0,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "neither requirement, wrong advice entirely",
|
||||||
|
response: "Just make the answer as detailed as possible.",
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -118,7 +141,9 @@ func TestRagContextSelectionVsStuffingTest_Eval(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"correct: selective", "selective", 1},
|
{"correct: selective", "selective", 1},
|
||||||
{"correct, different case", "Selective", 1},
|
{"correct, different case", "Selective", 1},
|
||||||
|
{"correct, quoted", `"selective"`, 1},
|
||||||
{"wrong: stuffed", "stuffed", 0},
|
{"wrong: stuffed", "stuffed", 0},
|
||||||
|
{"wrong: uses all the budget", "all of it", 0},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -147,6 +172,11 @@ func TestRagIndexStalenessTest_Eval(t *testing.T) {
|
|||||||
response: "```json\n{\"problem\":\"stale-chunks-coexist\",\"fix\":\"delete-old-chunks-before-reinsert\"}\n```",
|
response: "```json\n{\"problem\":\"stale-chunks-coexist\",\"fix\":\"delete-old-chunks-before-reinsert\"}\n```",
|
||||||
want: 1,
|
want: 1,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "all correct, different spacing",
|
||||||
|
response: `{ "problem": "stale-chunks-coexist", "fix": "delete-old-chunks-before-reinsert" }`,
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "wrong problem",
|
name: "wrong problem",
|
||||||
response: `{"problem":"embedding-model-drift","fix":"delete-old-chunks-before-reinsert"}`,
|
response: `{"problem":"embedding-model-drift","fix":"delete-old-chunks-before-reinsert"}`,
|
||||||
@@ -190,6 +220,11 @@ func TestRagEvalMetricChoiceTest_Eval(t *testing.T) {
|
|||||||
response: "```json\n{\"scenario_a\":\"context_precision\",\"scenario_b\":\"faithfulness\"}\n```",
|
response: "```json\n{\"scenario_a\":\"context_precision\",\"scenario_b\":\"faithfulness\"}\n```",
|
||||||
want: 1,
|
want: 1,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "all correct, different spacing",
|
||||||
|
response: `{ "scenario_a": "context_precision", "scenario_b": "faithfulness" }`,
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "scenario_a wrong",
|
name: "scenario_a wrong",
|
||||||
response: `{"scenario_a":"context_recall","scenario_b":"faithfulness"}`,
|
response: `{"scenario_a":"context_recall","scenario_b":"faithfulness"}`,
|
||||||
@@ -233,6 +268,11 @@ func TestRagHallucinationMitigationOrderingTest_Eval(t *testing.T) {
|
|||||||
response: "```json\n[\"step_retrieve\",\"step_instruct\",\"step_generate\",\"step_verify\",\"step_flag\"]\n```",
|
response: "```json\n[\"step_retrieve\",\"step_instruct\",\"step_generate\",\"step_verify\",\"step_flag\"]\n```",
|
||||||
want: 1,
|
want: 1,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "correct order, different case",
|
||||||
|
response: `["Step_Retrieve","Step_Instruct","Step_Generate","Step_Verify","Step_Flag"]`,
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "verify before generate",
|
name: "verify before generate",
|
||||||
response: `["step_retrieve","step_instruct","step_verify","step_generate","step_flag"]`,
|
response: `["step_retrieve","step_instruct","step_verify","step_generate","step_flag"]`,
|
||||||
@@ -243,6 +283,11 @@ func TestRagHallucinationMitigationOrderingTest_Eval(t *testing.T) {
|
|||||||
response: `["step_retrieve","step_instruct","step_generate","step_flag","step_verify"]`,
|
response: `["step_retrieve","step_instruct","step_generate","step_flag","step_verify"]`,
|
||||||
want: 0,
|
want: 0,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "wrong: echoes the prompt's shuffled presentation order verbatim (C4 bug probe)",
|
||||||
|
response: `["step_flag","step_generate","step_retrieve","step_verify","step_instruct"]`,
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -271,6 +316,11 @@ func TestRagMultihopDecompositionTest_Eval(t *testing.T) {
|
|||||||
response: "```json\n[\"frag_q\",\"frag_p\",\"frag_s\",\"frag_r\"]\n```",
|
response: "```json\n[\"frag_q\",\"frag_p\",\"frag_s\",\"frag_r\"]\n```",
|
||||||
want: 1,
|
want: 1,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "correct order, different case",
|
||||||
|
response: `["Frag_Q","Frag_P","Frag_S","Frag_R"]`,
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "distractor incorrectly included",
|
name: "distractor incorrectly included",
|
||||||
response: `["frag_q","frag_p","frag_s","frag_r","frag_x"]`,
|
response: `["frag_q","frag_p","frag_s","frag_r","frag_x"]`,
|
||||||
@@ -281,6 +331,11 @@ func TestRagMultihopDecompositionTest_Eval(t *testing.T) {
|
|||||||
response: `["frag_p","frag_q","frag_s","frag_r"]`,
|
response: `["frag_p","frag_q","frag_s","frag_r"]`,
|
||||||
want: 0,
|
want: 0,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "wrong: echoes the prompt's shuffled roster order verbatim, minus the distractor (C4 bug probe)",
|
||||||
|
response: `["frag_s","frag_r","frag_q","frag_p"]`,
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -304,6 +359,16 @@ func TestRagPreAssemblyDedupTest_Eval(t *testing.T) {
|
|||||||
response: "Passing all 3 copies wastes context window budget, and the redundant repetition risks biasing the answer toward that one duplicated point.",
|
response: "Passing all 3 copies wastes context window budget, and the redundant repetition risks biasing the answer toward that one duplicated point.",
|
||||||
want: 1,
|
want: 1,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "both reasons present, alternate phrasing",
|
||||||
|
response: "Sending all 3 duplicates dominates the token budget with the same content, and the repetition biases the model toward over-weighting that one point.",
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "both reasons present, terse phrasing",
|
||||||
|
response: "It wastes tokens and skews the answer toward the duplicated content.",
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "only the waste reason",
|
name: "only the waste reason",
|
||||||
response: "It wastes token budget that could hold other useful chunks.",
|
response: "It wastes token budget that could hold other useful chunks.",
|
||||||
@@ -319,6 +384,11 @@ func TestRagPreAssemblyDedupTest_Eval(t *testing.T) {
|
|||||||
response: "It just looks messy to a human reviewer.",
|
response: "It just looks messy to a human reviewer.",
|
||||||
want: 0,
|
want: 0,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "neither reason, wrong justification entirely",
|
||||||
|
response: "It makes the prompt harder for a human to read.",
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package tests
|
package tests
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
||||||
@@ -65,7 +66,7 @@ division needed? Respond with only one word: yes or no.`
|
|||||||
Subcategory: "vector-search",
|
Subcategory: "vector-search",
|
||||||
Description: "Confirm that for two unit-normalized vectors, the plain dot product equals the cosine similarity.",
|
Description: "Confirm that for two unit-normalized vectors, the plain dot product equals the cosine similarity.",
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
Eval: eval.Equals("yes"),
|
Eval: eval.ExactToken("yes"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,18 +92,23 @@ var vecRecallAtKWant = wpRecallAtK(vecRecallAtKRelevant, vecRecallAtKRetrieved)
|
|||||||
// ranked top-k retrieval list, distinct from the whitepapers recall@k
|
// ranked top-k retrieval list, distinct from the whitepapers recall@k
|
||||||
// fixture.
|
// fixture.
|
||||||
func vecRecallAtKTest() testkit.Test {
|
func vecRecallAtKTest() testkit.Test {
|
||||||
|
// C14: the relevant/retrieved lists are interpolated from
|
||||||
|
// vecRecallAtKRelevant/vecRecallAtKRetrieved (the vec-rrf-fusion
|
||||||
|
// pattern) rather than hand-typed, so the displayed lists can never
|
||||||
|
// drift from what vecRecallAtKWant is actually computed from.
|
||||||
prompt := `Recall@k is a retrieval-quality metric: for one query, it is
|
prompt := `Recall@k is a retrieval-quality metric: for one query, it is
|
||||||
the fraction of that query's truly relevant items that appear anywhere
|
the fraction of that query's truly relevant items that appear anywhere
|
||||||
within the system's top k retrieved results, regardless of exact rank.
|
within the system's top k retrieved results, regardless of exact rank.
|
||||||
recall@k = |relevant intersect top-k| / |relevant|.
|
recall@k = |relevant intersect top-k| / |relevant|.
|
||||||
|
|
||||||
A query's truly relevant vectors are: V3, V8, V11, V14, V20.
|
A query's truly relevant vectors are: ` + strings.Join(vecRecallAtKRelevant, ", ") + `.
|
||||||
|
|
||||||
A vector search system's ranked top-5 results for that query are, in rank
|
A vector search system's ranked top-5 results for that query are, in rank
|
||||||
order: V8, V1, V14, V6, V9.
|
order: ` + strings.Join(vecRecallAtKRetrieved, ", ") + `.
|
||||||
|
|
||||||
Compute recall@5 for this query. Respond with only the number, as a
|
Compute recall@5 for this query. Respond with only the number, rounded to
|
||||||
decimal (e.g. 0.6), rounded to 4 decimal places.`
|
4 decimal places, with no commas, units, or other text (for example:
|
||||||
|
0.4000).`
|
||||||
|
|
||||||
return testkit.Test{
|
return testkit.Test{
|
||||||
ID: "vec-recall-at-k",
|
ID: "vec-recall-at-k",
|
||||||
@@ -138,7 +144,7 @@ go up, or both go down? Respond with only one word: up or down.`
|
|||||||
Subcategory: "vector-search",
|
Subcategory: "vector-search",
|
||||||
Description: "State that increasing HNSW's efSearch moves recall and query latency in the same direction (up).",
|
Description: "State that increasing HNSW's efSearch moves recall and query latency in the same direction (up).",
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
Eval: eval.Equals("up"),
|
Eval: eval.ExactToken("up"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,26 +153,36 @@ go up, or both go down? Respond with only one word: up or down.`
|
|||||||
// (different m, nbits, dimension, and a total-bytes framing rather than a
|
// (different m, nbits, dimension, and a total-bytes framing rather than a
|
||||||
// ratio).
|
// ratio).
|
||||||
//
|
//
|
||||||
|
// vecPQMemoryMathM, vecPQMemoryMathNBits, and vecPQMemoryMathVectorCount are
|
||||||
|
// the PQ parameters for vecPQMemoryMathTest, interpolated into the prompt
|
||||||
|
// (C14) so the displayed parameters can never drift from what
|
||||||
|
// vecPQMemoryMathWant is actually computed from.
|
||||||
|
const (
|
||||||
|
vecPQMemoryMathM = 16
|
||||||
|
vecPQMemoryMathNBits = 4
|
||||||
|
vecPQMemoryMathVectorCount = 1000000
|
||||||
|
)
|
||||||
|
|
||||||
// ground truth: codeBytes = m*nbits/8 = 16*4/8 = 8 bytes per compressed
|
// ground truth: codeBytes = m*nbits/8 = 16*4/8 = 8 bytes per compressed
|
||||||
// vector (via wpQuantizedCodeBytes, already defined in
|
// vector (via wpQuantizedCodeBytes, already defined in
|
||||||
// whitepapers_compute.go). Total for 1,000,000 vectors = 8 * 1,000,000 =
|
// whitepapers_compute.go). Total for 1,000,000 vectors = 8 * 1,000,000 =
|
||||||
// 8,000,000 bytes. ai_vectorsearch_test.go independently recomputes this
|
// 8,000,000 bytes. ai_vectorsearch_test.go independently recomputes this
|
||||||
// with plain arithmetic.
|
// with plain arithmetic.
|
||||||
var vecPQMemoryMathWant = wpQuantizedCodeBytes(16, 4) * 1000000
|
var vecPQMemoryMathWant = wpQuantizedCodeBytes(vecPQMemoryMathM, vecPQMemoryMathNBits) * vecPQMemoryMathVectorCount
|
||||||
|
|
||||||
func vecPQMemoryMathTest() testkit.Test {
|
func vecPQMemoryMathTest() testkit.Test {
|
||||||
prompt := `Product quantization (PQ) compresses each vector by splitting
|
prompt := fmt.Sprintf(`Product quantization (PQ) compresses each vector by splitting
|
||||||
it into m equal-length subvectors and quantizing each one independently
|
it into m equal-length subvectors and quantizing each one independently
|
||||||
against its own codebook, storing only each subvector's nbits-bit centroid
|
against its own codebook, storing only each subvector's nbits-bit centroid
|
||||||
index. Concatenating the m per-subvector codes gives the full compressed
|
index. Concatenating the m per-subvector codes gives the full compressed
|
||||||
representation, with no wasted bits (assume m*nbits is a multiple of 8).
|
representation, with no wasted bits (assume m*nbits is a multiple of 8).
|
||||||
|
|
||||||
An index uses m = 16 subvectors and nbits = 4 bits per subvector.
|
An index uses m = %d subvectors and nbits = %d bits per subvector.
|
||||||
|
|
||||||
What is the total memory, in bytes, needed to store the compressed
|
What is the total memory, in bytes, needed to store the compressed
|
||||||
representations of 1,000,000 vectors indexed with these PQ parameters?
|
representations of %d vectors indexed with these PQ parameters?
|
||||||
Respond with only the integer number of bytes, with no commas, units, or
|
Respond with only the integer number of bytes, with no commas, units, or
|
||||||
other text (for example: 12345678).`
|
other text (for example: 12345678).`, vecPQMemoryMathM, vecPQMemoryMathNBits, vecPQMemoryMathVectorCount)
|
||||||
|
|
||||||
return testkit.Test{
|
return testkit.Test{
|
||||||
ID: "vec-pq-memory-math",
|
ID: "vec-pq-memory-math",
|
||||||
@@ -207,7 +223,7 @@ only one word: pre or post.`
|
|||||||
Subcategory: "vector-search",
|
Subcategory: "vector-search",
|
||||||
Description: "Choose pre-filtering over post-filtering ANN search when the metadata filter is highly selective (0.5%).",
|
Description: "Choose pre-filtering over post-filtering ANN search when the metadata filter is highly selective (0.5%).",
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
Eval: eval.Equals("pre"),
|
Eval: eval.ExactToken("pre"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,6 +296,12 @@ Respond with only the number, rounded to 4 decimal places.`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// vecDistanceToSimilarityD is the Euclidean distance for
|
||||||
|
// vecDistanceToSimilarityTest, interpolated into the prompt (C14) so the
|
||||||
|
// displayed value can never drift from what vecDistanceToSimilarityWant is
|
||||||
|
// actually computed from.
|
||||||
|
const vecDistanceToSimilarityD = 0.6
|
||||||
|
|
||||||
// vecDistanceToSimilarityWant is derived by calling
|
// vecDistanceToSimilarityWant is derived by calling
|
||||||
// aiUnitDistanceToCosineSimilarity, not hardcoded.
|
// aiUnitDistanceToCosineSimilarity, not hardcoded.
|
||||||
//
|
//
|
||||||
@@ -287,18 +309,19 @@ Respond with only the number, rounded to 4 decimal places.`
|
|||||||
// cos_sim = 1 - d^2/2. With d = 0.6, d^2 = 0.36, cos_sim = 1 - 0.18 = 0.82.
|
// cos_sim = 1 - d^2/2. With d = 0.6, d^2 = 0.36, cos_sim = 1 - 0.18 = 0.82.
|
||||||
// ai_vectorsearch_test.go independently recomputes this with plain
|
// ai_vectorsearch_test.go independently recomputes this with plain
|
||||||
// arithmetic.
|
// arithmetic.
|
||||||
var vecDistanceToSimilarityWant = round4dp(aiUnitDistanceToCosineSimilarity(0.6))
|
var vecDistanceToSimilarityWant = round4dp(aiUnitDistanceToCosineSimilarity(vecDistanceToSimilarityD))
|
||||||
|
|
||||||
func vecDistanceToSimilarityTest() testkit.Test {
|
func vecDistanceToSimilarityTest() testkit.Test {
|
||||||
prompt := `Two vectors are both unit-length (L2-normalized to magnitude
|
prompt := fmt.Sprintf(`Two vectors are both unit-length (L2-normalized to magnitude
|
||||||
1). For unit-length vectors, squared Euclidean distance relates to cosine
|
1). For unit-length vectors, squared Euclidean distance relates to cosine
|
||||||
similarity by: ||a-b||^2 = |a|^2 + |b|^2 - 2*cos_sim(a,b) = 2 - 2*cos_sim(a,b),
|
similarity by: ||a-b||^2 = |a|^2 + |b|^2 - 2*cos_sim(a,b) = 2 - 2*cos_sim(a,b),
|
||||||
since |a|=|b|=1. Rearranging: cos_sim(a,b) = 1 - (||a-b||^2) / 2.
|
since |a|=|b|=1. Rearranging: cos_sim(a,b) = 1 - (||a-b||^2) / 2.
|
||||||
|
|
||||||
The Euclidean distance between this pair is 0.6.
|
The Euclidean distance between this pair is %v.
|
||||||
|
|
||||||
Using the formula above, compute cos_sim(a,b). Respond with only the
|
Using the formula above, compute cos_sim(a,b). Respond with only the
|
||||||
number, rounded to 4 decimal places.`
|
number, rounded to 4 decimal places, with no commas, units, or other text
|
||||||
|
(for example: 0.8200).`, vecDistanceToSimilarityD)
|
||||||
|
|
||||||
return testkit.Test{
|
return testkit.Test{
|
||||||
ID: "vec-distance-to-similarity",
|
ID: "vec-distance-to-similarity",
|
||||||
@@ -331,16 +354,31 @@ const vecNearDuplicateThreshold = 0.97
|
|||||||
// this with plain arithmetic.
|
// this with plain arithmetic.
|
||||||
var vecNearDuplicateThresholdWant = round4dp(cosineSimilarity(vecNearDuplicateA, vecNearDuplicateB) - vecNearDuplicateThreshold)
|
var vecNearDuplicateThresholdWant = round4dp(cosineSimilarity(vecNearDuplicateA, vecNearDuplicateB) - vecNearDuplicateThreshold)
|
||||||
|
|
||||||
|
// aiFormatIntVector renders a []float64 of whole numbers as "[1, 2, 2, 1]"
|
||||||
|
// prompt text, used to interpolate a fixture vector directly from the Go
|
||||||
|
// slice it is graded against (C14) rather than a hand-typed duplicate.
|
||||||
|
func aiFormatIntVector(v []float64) string {
|
||||||
|
parts := make([]string, len(v))
|
||||||
|
for i, x := range v {
|
||||||
|
parts[i] = fmt.Sprintf("%d", int(x))
|
||||||
|
}
|
||||||
|
return "[" + strings.Join(parts, ", ") + "]"
|
||||||
|
}
|
||||||
|
|
||||||
func vecNearDuplicateThresholdTest() testkit.Test {
|
func vecNearDuplicateThresholdTest() testkit.Test {
|
||||||
prompt := `A vector index deduplicates near-identical items: a candidate
|
prompt := fmt.Sprintf(`A vector index deduplicates near-identical items: a candidate
|
||||||
pair is treated as a near-duplicate only when its cosine similarity is at
|
pair is treated as a near-duplicate only when its cosine similarity is at
|
||||||
or above 0.97.
|
or above %v.
|
||||||
|
|
||||||
Candidate pair: a = [1, 2, 2, 1], b = [2, 2, 1, 1].
|
Candidate pair: a = %s, b = %s.
|
||||||
|
|
||||||
Compute the cosine similarity of a and b, then subtract the 0.97 threshold
|
Compute the cosine similarity of a and b, then subtract the %v threshold
|
||||||
from it (similarity minus threshold). Respond with only the resulting
|
from it (similarity minus threshold). Respond with only the resulting
|
||||||
number, rounded to 4 decimal places (it may be negative).`
|
number, rounded to 4 decimal places, with no commas, units, or other text
|
||||||
|
(it may be negative; for example: -0.0700).`,
|
||||||
|
vecNearDuplicateThreshold,
|
||||||
|
aiFormatIntVector(vecNearDuplicateA), aiFormatIntVector(vecNearDuplicateB),
|
||||||
|
vecNearDuplicateThreshold)
|
||||||
|
|
||||||
return testkit.Test{
|
return testkit.Test{
|
||||||
ID: "vec-near-duplicate-threshold",
|
ID: "vec-near-duplicate-threshold",
|
||||||
@@ -414,7 +452,7 @@ Two embedding models are candidates:
|
|||||||
- option 768: produces 768-dimensional embeddings.
|
- option 768: produces 768-dimensional embeddings.
|
||||||
|
|
||||||
Which option's total uncompressed storage fits within the 60 GB budget?
|
Which option's total uncompressed storage fits within the 60 GB budget?
|
||||||
Respond with only a JSON object: {"choice":"128"|"768"}`
|
Respond with only a JSON object: {"choice":128|768}`
|
||||||
|
|
||||||
return testkit.Test{
|
return testkit.Test{
|
||||||
ID: "vec-embedding-dimension-tradeoff",
|
ID: "vec-embedding-dimension-tradeoff",
|
||||||
@@ -422,6 +460,9 @@ Respond with only a JSON object: {"choice":"128"|"768"}`
|
|||||||
Subcategory: "vector-search",
|
Subcategory: "vector-search",
|
||||||
Description: "Pick the embedding dimension (128 vs 768) whose uncompressed float32 storage fits a fixed 60 GB budget for 100M vectors.",
|
Description: "Pick the embedding dimension (128 vs 768) whose uncompressed float32 storage fits a fixed 60 GB budget for 100M vectors.",
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
Eval: eval.JSONField("choice", "128"),
|
// C7: choice is a number (mirrors rag-chunk-size-tradeoff's
|
||||||
|
// chunk_size_tokens), not a quoted string - the prompt's own
|
||||||
|
// template shows it unquoted.
|
||||||
|
Eval: eval.JSONField("choice", 128),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,16 @@ func TestVecCosineVsDotTest_Eval(t *testing.T) {
|
|||||||
want float64
|
want float64
|
||||||
}{
|
}{
|
||||||
{"correct: yes", "yes", 1},
|
{"correct: yes", "yes", 1},
|
||||||
{"correct: yes, prose", "Yes, since both magnitudes are 1.", 0},
|
{"correct: different case", "Yes", 1},
|
||||||
|
{"correct: quoted", `"yes"`, 1},
|
||||||
|
// C3: this row's own label was self-contradictory - "correct"
|
||||||
|
// paired with want:0. The prompt asks for "only one word: yes or
|
||||||
|
// no"; a full sentence is not a bare/quoted/fenced token even
|
||||||
|
// though its content is substantively correct, so want:0 was
|
||||||
|
// already the right assertion - only the misleading label needed
|
||||||
|
// fixing, matching how eval.ExactToken treats sentence-wrapping
|
||||||
|
// everywhere else in this codebase (it is not a summarizer).
|
||||||
|
{"wrong: substantively correct but sentence-wrapped, not a bare token", "Yes, since both magnitudes are 1.", 0},
|
||||||
{"wrong: no", "no", 0},
|
{"wrong: no", "no", 0},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -66,6 +75,7 @@ func TestVecRecallAtKTest_Eval(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"exact", "0.4", 1},
|
{"exact", "0.4", 1},
|
||||||
{"prose wrapped", "recall@5 is 0.4.", 1},
|
{"prose wrapped", "recall@5 is 0.4.", 1},
|
||||||
|
{"trailing period", "0.4.", 1},
|
||||||
{"wrong: counted an extra hit", "0.6", 0},
|
{"wrong: counted an extra hit", "0.6", 0},
|
||||||
{"wrong: missed a hit", "0.2", 0},
|
{"wrong: missed a hit", "0.2", 0},
|
||||||
}
|
}
|
||||||
@@ -87,7 +97,10 @@ func TestVecHNSWEfSearchTradeoffTest_Eval(t *testing.T) {
|
|||||||
want float64
|
want float64
|
||||||
}{
|
}{
|
||||||
{"correct: up", "up", 1},
|
{"correct: up", "up", 1},
|
||||||
|
{"correct: uppercase", "UP", 1},
|
||||||
|
{"correct: trailing period", "up.", 1},
|
||||||
{"wrong: down", "down", 0},
|
{"wrong: down", "down", 0},
|
||||||
|
{"wrong: unchanged", "unchanged", 0},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -123,7 +136,9 @@ func TestVecPQMemoryMathTest_Eval(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"exact", "8000000", 1},
|
{"exact", "8000000", 1},
|
||||||
{"prose wrapped", "The total is 8000000 bytes.", 1},
|
{"prose wrapped", "The total is 8000000 bytes.", 1},
|
||||||
|
{"fenced", "```\n8000000\n```", 1},
|
||||||
{"wrong", "512", 0},
|
{"wrong", "512", 0},
|
||||||
|
{"wrong: bit/byte confusion (8x too large)", "64000000", 0},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -143,7 +158,10 @@ func TestVecPreVsPostFilteringTest_Eval(t *testing.T) {
|
|||||||
want float64
|
want float64
|
||||||
}{
|
}{
|
||||||
{"correct: pre", "pre", 1},
|
{"correct: pre", "pre", 1},
|
||||||
|
{"correct: uppercase", "PRE", 1},
|
||||||
|
{"correct: trailing period", "pre.", 1},
|
||||||
{"wrong: post", "post", 0},
|
{"wrong: post", "post", 0},
|
||||||
|
{"wrong: both", "both", 0},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -180,6 +198,7 @@ func TestVecRRFFusionTest_Eval(t *testing.T) {
|
|||||||
{"within tolerance", "0.0307", 1},
|
{"within tolerance", "0.0307", 1},
|
||||||
{"prose wrapped", "The RRF score for D5 is 0.0308.", 1},
|
{"prose wrapped", "The RRF score for D5 is 0.0308.", 1},
|
||||||
{"wrong: only counted one list", "0.0159", 0},
|
{"wrong: only counted one list", "0.0159", 0},
|
||||||
|
{"wrong: outside tolerance", "0.0328", 0},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -214,7 +233,9 @@ func TestVecDistanceToSimilarityTest_Eval(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"exact", "0.82", 1},
|
{"exact", "0.82", 1},
|
||||||
{"prose wrapped", "cos_sim(a,b) = 0.82", 1},
|
{"prose wrapped", "cos_sim(a,b) = 0.82", 1},
|
||||||
|
{"fenced", "```\n0.82\n```", 1},
|
||||||
{"wrong: used d not d^2", "0.7", 0},
|
{"wrong: used d not d^2", "0.7", 0},
|
||||||
|
{"wrong: forgot to subtract from 1", "0.18", 0},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -252,6 +273,7 @@ func TestVecNearDuplicateThresholdTest_Eval(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"exact", "-0.07", 1},
|
{"exact", "-0.07", 1},
|
||||||
{"prose wrapped", "similarity minus threshold is -0.07.", 1},
|
{"prose wrapped", "similarity minus threshold is -0.07.", 1},
|
||||||
|
{"fenced", "```\n-0.07\n```", 1},
|
||||||
{"wrong: forgot the sign", "0.07", 0},
|
{"wrong: forgot the sign", "0.07", 0},
|
||||||
{"wrong: reported similarity, not the difference", "0.9", 0},
|
{"wrong: reported similarity, not the difference", "0.9", 0},
|
||||||
}
|
}
|
||||||
@@ -274,6 +296,7 @@ func TestVecIndexBuildQueryTradeoffTest_Eval(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"all correct", `{"scenario_a":"ivf-flat","scenario_b":"hnsw"}`, 1},
|
{"all correct", `{"scenario_a":"ivf-flat","scenario_b":"hnsw"}`, 1},
|
||||||
{"all correct fenced", "```json\n{\"scenario_a\":\"ivf-flat\",\"scenario_b\":\"hnsw\"}\n```", 1},
|
{"all correct fenced", "```json\n{\"scenario_a\":\"ivf-flat\",\"scenario_b\":\"hnsw\"}\n```", 1},
|
||||||
|
{"all correct, different case", `{"scenario_a":"IVF-FLAT","scenario_b":"HNSW"}`, 1},
|
||||||
{"scenario_a wrong", `{"scenario_a":"hnsw","scenario_b":"hnsw"}`, 0.5},
|
{"scenario_a wrong", `{"scenario_a":"hnsw","scenario_b":"hnsw"}`, 0.5},
|
||||||
{"scenario_b wrong", `{"scenario_a":"ivf-flat","scenario_b":"ivf-flat"}`, 0.5},
|
{"scenario_b wrong", `{"scenario_a":"ivf-flat","scenario_b":"ivf-flat"}`, 0.5},
|
||||||
{"both swapped", `{"scenario_a":"hnsw","scenario_b":"ivf-flat"}`, 0},
|
{"both swapped", `{"scenario_a":"hnsw","scenario_b":"ivf-flat"}`, 0},
|
||||||
@@ -309,6 +332,15 @@ func TestVecEmbeddingDimensionTradeoffTest_Eval(t *testing.T) {
|
|||||||
{"correct: 128", `{"choice":"128"}`, 1},
|
{"correct: 128", `{"choice":"128"}`, 1},
|
||||||
{"correct, fenced with prose", "The 128-dim option fits:\n```json\n{\"choice\":\"128\"}\n```", 1},
|
{"correct, fenced with prose", "The 128-dim option fits:\n```json\n{\"choice\":\"128\"}\n```", 1},
|
||||||
{"wrong: 768", `{"choice":"768"}`, 0},
|
{"wrong: 768", `{"choice":"768"}`, 0},
|
||||||
|
{"wrong: neither valid option", `{"choice":"512"}`, 0},
|
||||||
|
{
|
||||||
|
// C7 bug probe: the prompt's own template shows choice
|
||||||
|
// unquoted ({"choice":128|768}), so a genuinely numeric
|
||||||
|
// (unquoted) response must also score full credit.
|
||||||
|
name: "correct: unquoted numeric form matching the prompt's own template (C7 bug probe)",
|
||||||
|
response: `{"choice":128}`,
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
||||||
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
||||||
@@ -23,28 +22,6 @@ func registerDatabasesTests(r *testkit.Registry) {
|
|||||||
registerDBSQLTuningTests(r)
|
registerDBSQLTuningTests(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
// dbExactAnswer returns an Evaluator awarding full credit when the
|
|
||||||
// response, trimmed of whitespace, at most one layer of surrounding quote
|
|
||||||
// characters (' , ", or `), and a single trailing sentence-ending period,
|
|
||||||
// equals want case-insensitively. Used across the databases category for
|
|
||||||
// prompts that force a single short forced-vocabulary answer (e.g. "yes"
|
|
||||||
// or "no", "keyset" or "offset"): it accepts every materially-correct form
|
|
||||||
// of that answer (bare, quoted, differently-cased, or with trailing
|
|
||||||
// punctuation) without loosening the match to accept a wrong answer.
|
|
||||||
func dbExactAnswer(want string) eval.Evaluator {
|
|
||||||
return eval.EvaluatorFunc(func(_ context.Context, response string) eval.Score {
|
|
||||||
got := strings.TrimSpace(response)
|
|
||||||
got = strings.Trim(got, "\"'`")
|
|
||||||
got = strings.TrimSuffix(strings.TrimSpace(got), ".")
|
|
||||||
got = strings.TrimSpace(got)
|
|
||||||
wantTrimmed := strings.TrimSpace(want)
|
|
||||||
if strings.EqualFold(got, wantTrimmed) {
|
|
||||||
return eval.Score{Value: 1, Detail: fmt.Sprintf("equals %q", wantTrimmed)}
|
|
||||||
}
|
|
||||||
return eval.Score{Value: 0, Detail: fmt.Sprintf("got %q, want %q", got, wantTrimmed)}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// dbJSONArrayLength returns an Evaluator awarding full credit when the
|
// dbJSONArrayLength returns an Evaluator awarding full credit when the
|
||||||
// response's extracted JSON array (see eval.ExtractJSON) has exactly want
|
// response's extracted JSON array (see eval.ExtractJSON) has exactly want
|
||||||
// elements, zero otherwise. Paired with per-index eval.JSONField checks so
|
// elements, zero otherwise. Paired with per-index eval.JSONField checks so
|
||||||
|
|||||||
@@ -219,10 +219,19 @@ acquisition order (by id, ascending) would both transactions need to follow
|
|||||||
to prevent this deadlock? Respond with only a JSON object:
|
to prevent this deadlock? Respond with only a JSON object:
|
||||||
{"deadlock":true|false,"safe_order_ids":[<first id to lock>,<second id to lock>]}`
|
{"deadlock":true|false,"safe_order_ids":[<first id to lock>,<second id to lock>]}`
|
||||||
|
|
||||||
evaluator := eval.Mean(
|
// D9: weight the deadlock-diagnosis boolean 3x the safe-order answer,
|
||||||
eval.JSONField("deadlock", true),
|
// so getting the core yes/no diagnosis right dominates the score more
|
||||||
eval.JSONField("safe_order_ids[0]", 1),
|
// than the specific ordering of an already-correct diagnosis. The two
|
||||||
eval.JSONField("safe_order_ids[1]", 2),
|
// safe_order_ids indices are grouped into one combined "order" share
|
||||||
|
// (an equal-weighted Mean of the two), not two separate top-level
|
||||||
|
// terms, so the 3:1 ratio holds between "diagnosis" and "order" as
|
||||||
|
// conceptual units, not among three arbitrary equal terms.
|
||||||
|
evaluator := eval.All(
|
||||||
|
eval.W(eval.JSONField("deadlock", true), 3),
|
||||||
|
eval.W(eval.Mean(
|
||||||
|
eval.JSONField("safe_order_ids[0]", 1),
|
||||||
|
eval.JSONField("safe_order_ids[1]", 2),
|
||||||
|
), 1),
|
||||||
)
|
)
|
||||||
|
|
||||||
return testkit.Test{
|
return testkit.Test{
|
||||||
@@ -235,9 +244,9 @@ to prevent this deadlock? Respond with only a JSON object:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// dbPGReplicaFailoverTest: explain that an asynchronously-replicated commit
|
// dbPGReplicaFailoverTest: identify that an asynchronously-replicated
|
||||||
// can be lost when the primary crashes before the replica catches up and
|
// commit is NOT guaranteed visible when the primary crashes before the
|
||||||
// gets promoted.
|
// replica catches up and gets promoted.
|
||||||
//
|
//
|
||||||
// ground truth: with asynchronous streaming replication (no
|
// ground truth: with asynchronous streaming replication (no
|
||||||
// synchronous_commit), a transaction's COMMIT returns to the client as
|
// synchronous_commit), a transaction's COMMIT returns to the client as
|
||||||
@@ -247,6 +256,14 @@ to prevent this deadlock? Respond with only a JSON object:
|
|||||||
// without ever having applied that transaction's WAL - so the committed
|
// without ever having applied that transaction's WAL - so the committed
|
||||||
// transaction is not visible on it and is effectively lost, unless it can
|
// transaction is not visible on it and is effectively lost, unless it can
|
||||||
// be recovered from the old primary's WAL after the fact.
|
// be recovered from the old primary's WAL after the fact.
|
||||||
|
//
|
||||||
|
// D2: redesigned from a free-text ContainsAny("lost", ...) check to a
|
||||||
|
// forced-vocabulary JSON boolean + reason, since "lost"/"data loss" as a
|
||||||
|
// bare substring search matched its own exact negation - a response
|
||||||
|
// stating "nothing is lost" or "no data loss occurs" contains the literal
|
||||||
|
// substring "lost"/"data loss" and was wrongly scored 1.0 for asserting
|
||||||
|
// the opposite of the correct answer. A forced true/false field cannot be
|
||||||
|
// polarity-confused this way.
|
||||||
func dbPGReplicaFailoverTest() testkit.Test {
|
func dbPGReplicaFailoverTest() testkit.Test {
|
||||||
prompt := `A Postgres primary has one streaming replica, using
|
prompt := `A Postgres primary has one streaming replica, using
|
||||||
asynchronous replication (synchronous_commit is not enabled). A client's
|
asynchronous replication (synchronous_commit is not enabled). A client's
|
||||||
@@ -255,19 +272,19 @@ commit reaches the replica, the primary host crashes, and the replica is
|
|||||||
promoted to become the new primary.
|
promoted to become the new primary.
|
||||||
|
|
||||||
Is that committed transaction guaranteed to be visible on the newly
|
Is that committed transaction guaranteed to be visible on the newly
|
||||||
promoted primary? Explain what happens to it and why, in one or two
|
promoted primary? Respond with only a JSON object:
|
||||||
sentences.`
|
{"guaranteed_visible":true|false,"reason":"<one of: async-wal-not-replicated, sync-replication-guarantees-durability, replica-storage-corrupted>"}`
|
||||||
|
|
||||||
evaluator := eval.All(
|
evaluator := eval.All(
|
||||||
eval.W(eval.ContainsAny("lost", "not visible", "not guaranteed", "may be lost", "can be lost", "data loss"), 2),
|
eval.W(eval.JSONField("guaranteed_visible", false), 3),
|
||||||
eval.W(eval.ContainsAny("async", "asynchronous"), 1),
|
eval.W(eval.JSONField("reason", "async-wal-not-replicated"), 2),
|
||||||
)
|
)
|
||||||
|
|
||||||
return testkit.Test{
|
return testkit.Test{
|
||||||
ID: "pg-replica-failover",
|
ID: "pg-replica-failover",
|
||||||
Category: "databases",
|
Category: "databases",
|
||||||
Subcategory: "postgres",
|
Subcategory: "postgres",
|
||||||
Description: "Explain that an asynchronously-replicated commit can be lost on failover if the primary crashes before the replica catches up.",
|
Description: "Identify that an asynchronously-replicated commit is not guaranteed visible on failover if the primary crashes before the replica catches up.",
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
Eval: evaluator,
|
Eval: evaluator,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,9 +111,20 @@ func TestDBPGDeadlockLockOrderTest_Eval(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"correct", `{"deadlock":true,"safe_order_ids":[1,2]}`, 1},
|
{"correct", `{"deadlock":true,"safe_order_ids":[1,2]}`, 1},
|
||||||
{"correct fenced with prose", "Yes, they deadlock.\n```json\n{\"deadlock\":true,\"safe_order_ids\":[1,2]}\n```", 1},
|
{"correct fenced with prose", "Yes, they deadlock.\n```json\n{\"deadlock\":true,\"safe_order_ids\":[1,2]}\n```", 1},
|
||||||
{"says no deadlock", `{"deadlock":false,"safe_order_ids":[1,2]}`, 2.0 / 3.0},
|
// D9: deadlock (weight 3) now dominates the score over the safe
|
||||||
{"reversed safe order", `{"deadlock":true,"safe_order_ids":[2,1]}`, 1.0 / 3.0},
|
// order (weight 1, combined as one unit): getting the core
|
||||||
|
// diagnosis wrong costs far more than getting the order wrong.
|
||||||
|
{"says no deadlock", `{"deadlock":false,"safe_order_ids":[1,2]}`, 0.25},
|
||||||
|
{"reversed safe order", `{"deadlock":true,"safe_order_ids":[2,1]}`, 0.75},
|
||||||
{"everything wrong", `{"deadlock":false,"safe_order_ids":[2,1]}`, 0},
|
{"everything wrong", `{"deadlock":false,"safe_order_ids":[2,1]}`, 0},
|
||||||
|
{
|
||||||
|
// D9 bug probe: the diagnosis matters far more than the order -
|
||||||
|
// a correct diagnosis with a fully-wrong order still beats a
|
||||||
|
// wrong diagnosis with a fully-correct order.
|
||||||
|
name: "correct diagnosis with wrong order still outscores wrong diagnosis with correct order (D9 bug probe)",
|
||||||
|
response: `{"deadlock":true,"safe_order_ids":[2,1]}`,
|
||||||
|
want: 0.75,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -134,24 +145,35 @@ func TestDBPGReplicaFailoverTest_Eval(t *testing.T) {
|
|||||||
want float64
|
want float64
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "correct: names data loss and async cause",
|
name: "correct object",
|
||||||
response: "Because replication is asynchronous, that commit may be lost - the replica never received its WAL before promotion.",
|
response: `{"guaranteed_visible":false,"reason":"async-wal-not-replicated"}`,
|
||||||
want: 1,
|
want: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "correct: alternate phrasing",
|
name: "correct fenced with prose",
|
||||||
response: "No, it is not guaranteed to be visible; async replication means the promoted replica can be missing it.",
|
response: "```json\n{\"guaranteed_visible\":false,\"reason\":\"async-wal-not-replicated\"}\n```",
|
||||||
want: 1,
|
want: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "wrong: claims it is guaranteed durable",
|
name: "wrong: claims it is guaranteed durable",
|
||||||
response: "Yes, Postgres guarantees every committed transaction is always visible after failover.",
|
response: `{"guaranteed_visible":true,"reason":"async-wal-not-replicated"}`,
|
||||||
want: 0,
|
want: 0.4,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "partial: mentions loss but not the async cause",
|
name: "right verdict, wrong reason",
|
||||||
response: "That transaction can be lost after the failover.",
|
response: `{"guaranteed_visible":false,"reason":"replica-storage-corrupted"}`,
|
||||||
want: 2.0 / 3.0,
|
want: 0.6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// D2 bug probe: the exact negation of the correct answer -
|
||||||
|
// stating no data is lost - used to score 1.0 under the old
|
||||||
|
// ContainsAny("lost", "data loss", ...) substring search,
|
||||||
|
// since "nothing is lost" and "no data loss" both contain
|
||||||
|
// those literal substrings. The forced boolean field cannot be
|
||||||
|
// polarity-confused this way.
|
||||||
|
name: "wrong: exact negation of the correct answer scores near-zero, not 1.0 (D2 bug probe)",
|
||||||
|
response: `{"guaranteed_visible":true,"reason":"sync-replication-guarantees-durability"}`,
|
||||||
|
want: 0,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
package tests
|
package tests
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
||||||
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
||||||
@@ -12,7 +10,7 @@ import (
|
|||||||
// registerDBRedisTests registers every databases/redis test.
|
// registerDBRedisTests registers every databases/redis test.
|
||||||
func registerDBRedisTests(r *testkit.Registry) {
|
func registerDBRedisTests(r *testkit.Registry) {
|
||||||
r.Register(dbRedisStructureChoiceTest())
|
r.Register(dbRedisStructureChoiceTest())
|
||||||
r.Register(dbRedisTTLEvictionTest())
|
r.Register(dbRedisNoevictionWriteTest())
|
||||||
r.Register(dbRedisIncrAtomicityTest())
|
r.Register(dbRedisIncrAtomicityTest())
|
||||||
r.Register(dbRedisMultiVsPipelineTest())
|
r.Register(dbRedisMultiVsPipelineTest())
|
||||||
r.Register(dbRedisScanVsKeysTest())
|
r.Register(dbRedisScanVsKeysTest())
|
||||||
@@ -74,14 +72,14 @@ Respond with only a JSON object mapping each letter to one structure name:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// dbRedisTTLEvictionTest: determine what happens to a write under
|
// dbRedisNoevictionWriteTest: determine what happens to a write under
|
||||||
// maxmemory-policy=noeviction when Redis is already at maxmemory.
|
// maxmemory-policy=noeviction when Redis is already at maxmemory.
|
||||||
//
|
//
|
||||||
// ground truth: noeviction means Redis will never evict a key to make
|
// ground truth: noeviction means Redis will never evict a key to make
|
||||||
// room. A write that would grow memory usage while already at maxmemory
|
// room. A write that would grow memory usage while already at maxmemory
|
||||||
// is instead rejected outright with an OOM error - the write does not
|
// is instead rejected outright with an OOM error - the write does not
|
||||||
// silently succeed and no key is evicted to make space.
|
// silently succeed and no key is evicted to make space.
|
||||||
func dbRedisTTLEvictionTest() testkit.Test {
|
func dbRedisNoevictionWriteTest() testkit.Test {
|
||||||
prompt := `A Redis instance has maxmemory-policy set to "noeviction" and
|
prompt := `A Redis instance has maxmemory-policy set to "noeviction" and
|
||||||
is currently at maxmemory. A client sends a write command that would
|
is currently at maxmemory. A client sends a write command that would
|
||||||
increase memory usage further.
|
increase memory usage further.
|
||||||
@@ -89,12 +87,12 @@ increase memory usage further.
|
|||||||
What happens to that write? Respond with only one word: reject or evict.`
|
What happens to that write? Respond with only one word: reject or evict.`
|
||||||
|
|
||||||
return testkit.Test{
|
return testkit.Test{
|
||||||
ID: "redis-ttl-eviction",
|
ID: "redis-noeviction-write",
|
||||||
Category: "databases",
|
Category: "databases",
|
||||||
Subcategory: "redis",
|
Subcategory: "redis",
|
||||||
Description: "Determine that a write is rejected (not silently allowed, and nothing evicted) under maxmemory-policy=noeviction at maxmemory.",
|
Description: "Determine that a write is rejected (not silently allowed, and nothing evicted) under maxmemory-policy=noeviction at maxmemory.",
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
Eval: dbExactAnswer("reject"),
|
Eval: eval.ExactToken("reject"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +155,7 @@ pipelined commands? Respond with only "yes" or "no".`
|
|||||||
Subcategory: "redis",
|
Subcategory: "redis",
|
||||||
Description: "Confirm plain pipelining (unlike MULTI/EXEC) gives no guarantee against another client's commands interleaving.",
|
Description: "Confirm plain pipelining (unlike MULTI/EXEC) gives no guarantee against another client's commands interleaving.",
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
Eval: dbExactAnswer("no"),
|
Eval: eval.ExactToken("no"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,50 +165,34 @@ pipelined commands? Respond with only "yes" or "no".`
|
|||||||
// word "keys" elsewhere in prose ("the keys to fixing this...").
|
// word "keys" elsewhere in prose ("the keys to fixing this...").
|
||||||
var dbKeysCommandPattern = regexp.MustCompile("(?:`KEYS`|\\bKEYS\\s*\\*)")
|
var dbKeysCommandPattern = regexp.MustCompile("(?:`KEYS`|\\bKEYS\\s*\\*)")
|
||||||
|
|
||||||
// dbNegationCuePattern matches a word that turns a mention of the KEYS
|
// dbNegationWindow is how many characters around a KEYS-command mention
|
||||||
// command into a warning against running it in production, rather than an
|
|
||||||
// instruction to run it.
|
|
||||||
var dbNegationCuePattern = regexp.MustCompile(`(?i)\b(don'?t|do not|never|avoid|instead of|not|shouldn'?t|should not|rather than|without|no need|danger|dangerous)\b`)
|
|
||||||
|
|
||||||
// dbNegationWindow is how many characters before a KEYS-command mention
|
|
||||||
// are searched for a negation cue.
|
// are searched for a negation cue.
|
||||||
const dbNegationWindow = 60
|
const dbNegationWindow = 60
|
||||||
|
|
||||||
// dbNegationWindowStart returns the earliest byte offset in response to
|
|
||||||
// search for a negation cue before a KEYS-command mention starting at
|
|
||||||
// start: the current line, extended back at most dbNegationWindow
|
|
||||||
// characters.
|
|
||||||
func dbNegationWindowStart(response string, start int) int {
|
|
||||||
lineStart := strings.LastIndexByte(response[:start], '\n') + 1
|
|
||||||
return max(start-dbNegationWindow, lineStart)
|
|
||||||
}
|
|
||||||
|
|
||||||
// dbNoBareKeysInProd scores full credit unless the response instructs
|
// dbNoBareKeysInProd scores full credit unless the response instructs
|
||||||
// running "KEYS *" against a production instance without any negating
|
// running "KEYS *" against a production instance without any negating
|
||||||
// context (a warning not to, or an instruction to use SCAN instead is
|
// context (a warning not to, or an instruction to use SCAN instead is
|
||||||
// fine). This is deliberately not eval.NotContains, which would also zero
|
// fine). This is deliberately not eval.NotContains, which would also zero
|
||||||
// out the best possible answer: one that correctly explains why NOT to run
|
// out the best possible answer: one that correctly explains why NOT to run
|
||||||
// KEYS in production.
|
// KEYS in production.
|
||||||
|
//
|
||||||
|
// Delegates to eval.NoUnnegatedMention (D5), the primitive shared with
|
||||||
|
// kubernetes.go, security.go, and delivery_git.go's equivalent guards.
|
||||||
func dbNoBareKeysInProd() eval.Evaluator {
|
func dbNoBareKeysInProd() eval.Evaluator {
|
||||||
return eval.EvaluatorFunc(func(_ context.Context, response string) eval.Score {
|
return eval.NoUnnegatedMention(dbKeysCommandPattern, dbNegationWindow, nil)
|
||||||
matches := dbKeysCommandPattern.FindAllStringIndex(response, -1)
|
|
||||||
if len(matches) == 0 {
|
|
||||||
return eval.Score{Value: 1, Detail: "no mention of running the KEYS command"}
|
|
||||||
}
|
|
||||||
for _, loc := range matches {
|
|
||||||
start := loc[0]
|
|
||||||
windowStart := dbNegationWindowStart(response, start)
|
|
||||||
if !dbNegationCuePattern.MatchString(response[windowStart:start]) {
|
|
||||||
return eval.Score{Value: 0, Detail: "unnegated mention of running the KEYS command"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return eval.Score{Value: 1, Detail: "every mention of KEYS is negated"}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// dbRedisScanVsKeysTest: require SCAN over KEYS for a hot-path production
|
// dbRedisScanVsKeysTest: require SCAN over KEYS for a hot-path production
|
||||||
// lookup, using a negation-aware guard so an answer that correctly warns
|
// lookup, using a negation-aware guard so an answer that correctly warns
|
||||||
// against KEYS is not penalized for mentioning it.
|
// against KEYS is not penalized for mentioning it.
|
||||||
|
//
|
||||||
|
// ground truth (DC9): KEYS * is an O(n) full-keyspace scan that blocks
|
||||||
|
// Redis's single command-processing thread for its entire duration - on a
|
||||||
|
// production instance holding millions of keys, called thousands of times
|
||||||
|
// per second, this stalls every other client for the scan's whole
|
||||||
|
// duration. SCAN instead walks the keyspace incrementally via a cursor,
|
||||||
|
// returning a small batch per call and never blocking the server for more
|
||||||
|
// than that batch, making it the safe hot-path replacement.
|
||||||
func dbRedisScanVsKeysTest() testkit.Test {
|
func dbRedisScanVsKeysTest() testkit.Test {
|
||||||
prompt := `A request handler that runs thousands of times per second
|
prompt := `A request handler that runs thousands of times per second
|
||||||
against a production Redis instance holding millions of keys currently
|
against a production Redis instance holding millions of keys currently
|
||||||
@@ -254,7 +236,7 @@ specific message it missed while disconnected? Respond with only "yes" or
|
|||||||
Subcategory: "redis",
|
Subcategory: "redis",
|
||||||
Description: "Confirm Redis pub/sub gives no delivery guarantee (no backlog/replay) to a subscriber that was disconnected when a message was published.",
|
Description: "Confirm Redis pub/sub gives no delivery guarantee (no backlog/replay) to a subscriber that was disconnected when a message was published.",
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
Eval: dbExactAnswer("no"),
|
Eval: eval.ExactToken("no"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,9 +248,16 @@ specific message it missed while disconnected? Respond with only "yes" or
|
|||||||
// the server does not process any other client's command, so a script is
|
// the server does not process any other client's command, so a script is
|
||||||
// guaranteed to run start-to-finish with nothing interleaved.
|
// guaranteed to run start-to-finish with nothing interleaved.
|
||||||
func dbRedisLuaAtomicityTest() testkit.Test {
|
func dbRedisLuaAtomicityTest() testkit.Test {
|
||||||
prompt := `While one client's Lua script is executing via EVAL, can any
|
// DC6: narrowed to a data command (SET) specifically - "any other
|
||||||
other client's command run on the Redis server before that script
|
// command whatsoever" is a broader claim than Redis's single-threaded
|
||||||
finishes? Respond with only "yes" or "no".`
|
// script guarantee actually covers, since some non-data/administrative
|
||||||
|
// commands can be processed in certain configurations while a script
|
||||||
|
// runs. A data command like SET (one that reads/writes the keyspace)
|
||||||
|
// is unambiguously blocked, matching the ground truth exactly.
|
||||||
|
prompt := `While one client's Lua script is executing via EVAL, can
|
||||||
|
another client's SET command (a data command touching the keyspace) run
|
||||||
|
on the Redis server before that script finishes? Respond with only "yes"
|
||||||
|
or "no".`
|
||||||
|
|
||||||
return testkit.Test{
|
return testkit.Test{
|
||||||
ID: "redis-lua-atomicity",
|
ID: "redis-lua-atomicity",
|
||||||
@@ -276,7 +265,7 @@ finishes? Respond with only "yes" or "no".`
|
|||||||
Subcategory: "redis",
|
Subcategory: "redis",
|
||||||
Description: "Confirm a Lua script run via EVAL executes atomically, with no other client command able to interleave.",
|
Description: "Confirm a Lua script run via EVAL executes atomically, with no other client command able to interleave.",
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
Eval: dbExactAnswer("no"),
|
Eval: eval.ExactToken("no"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ func TestDBRedisStructureChoiceTest_Eval(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDBRedisTTLEvictionTest_Eval(t *testing.T) {
|
func TestDBRedisNoevictionWriteTest_Eval(t *testing.T) {
|
||||||
tc := dbRedisTTLEvictionTest()
|
tc := dbRedisNoevictionWriteTest()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ func registerDBSQLTuningTests(r *testkit.Registry) {
|
|||||||
r.Register(dbSQLNullInWhereTest())
|
r.Register(dbSQLNullInWhereTest())
|
||||||
r.Register(dbSQLGroupByHavingTest())
|
r.Register(dbSQLGroupByHavingTest())
|
||||||
r.Register(dbSQLWindowRankTest())
|
r.Register(dbSQLWindowRankTest())
|
||||||
r.Register(dbSQLCompositeIndexOrderTest())
|
r.Register(dbSQLCompositeIndexSkipColumnTest())
|
||||||
r.Register(dbSQLKeysetPaginationTest())
|
r.Register(dbSQLKeysetPaginationTest())
|
||||||
r.Register(dbSQLCoveringIndexTest())
|
r.Register(dbSQLCoveringIndexTest())
|
||||||
r.Register(dbSQLEquivalentRewriteTest())
|
r.Register(dbSQLEquivalentRewriteTest())
|
||||||
@@ -336,12 +336,18 @@ ORDER BY region;
|
|||||||
Respond with only a JSON array of objects, ordered exactly as the query's
|
Respond with only a JSON array of objects, ordered exactly as the query's
|
||||||
ORDER BY would return them: [{"region":"...","total":<number>}, ...]`
|
ORDER BY would return them: [{"region":"...","total":<number>}, ...]`
|
||||||
|
|
||||||
evaluator := eval.Mean(
|
// D9: weight the array-length check 2x each individual field check, so
|
||||||
dbJSONArrayLength(len(dbSQLGroupByHavingWant)),
|
// a response that leaks an extra group through (e.g. forgetting to
|
||||||
eval.JSONField("[0].region", dbSQLGroupByHavingWant[0].Region),
|
// apply the HAVING filter) cannot stay close to full credit just by
|
||||||
eval.JSONField("[0].total", dbSQLGroupByHavingWant[0].Total),
|
// getting the two checked elements right - the structural correctness
|
||||||
eval.JSONField("[1].region", dbSQLGroupByHavingWant[1].Region),
|
// of "exactly the right number of groups" matters more than any one
|
||||||
eval.JSONField("[1].total", dbSQLGroupByHavingWant[1].Total),
|
// field.
|
||||||
|
evaluator := eval.All(
|
||||||
|
eval.W(dbJSONArrayLength(len(dbSQLGroupByHavingWant)), 2),
|
||||||
|
eval.W(eval.JSONField("[0].region", dbSQLGroupByHavingWant[0].Region), 1),
|
||||||
|
eval.W(eval.JSONField("[0].total", dbSQLGroupByHavingWant[0].Total), 1),
|
||||||
|
eval.W(eval.JSONField("[1].region", dbSQLGroupByHavingWant[1].Region), 1),
|
||||||
|
eval.W(eval.JSONField("[1].total", dbSQLGroupByHavingWant[1].Total), 1),
|
||||||
)
|
)
|
||||||
|
|
||||||
return testkit.Test{
|
return testkit.Test{
|
||||||
@@ -440,44 +446,74 @@ in each department? Respond with only a JSON object:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// dbSQLCompositeIndexSchema is the inline schema + query for
|
// dbSQLCompositeIndexSkipSchema is the inline schema + index + query for
|
||||||
// dbSQLCompositeIndexOrderTest.
|
// dbSQLCompositeIndexSkipColumnTest.
|
||||||
const dbSQLCompositeIndexSchema = `Table: sessions(id bigserial, user_id bigint, created_at timestamptz, ip inet)
|
//
|
||||||
|
// D10: this test replaces the former sql-composite-index-order, which
|
||||||
|
// duplicated pg-index-choice (databases_postgres.go) almost exactly -
|
||||||
|
// same "pick equality-then-range column order" question shape, just a
|
||||||
|
// different table/column naming. This test instead covers a genuinely
|
||||||
|
// distinct btree composite-index rule: a query that skips the middle
|
||||||
|
// column of a 3-column index entirely.
|
||||||
|
const dbSQLCompositeIndexSkipSchema = `Table: events(id bigserial, tenant_id bigint, event_type text, created_at timestamptz)
|
||||||
|
|
||||||
|
Index: CREATE INDEX idx_events_tenant_type_created ON events(tenant_id, event_type, created_at);
|
||||||
|
|
||||||
Query:
|
Query:
|
||||||
SELECT * FROM sessions
|
SELECT * FROM events
|
||||||
WHERE user_id = $1 AND created_at > $2
|
WHERE tenant_id = 42 AND created_at > '2026-01-01';`
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT 50;`
|
|
||||||
|
|
||||||
// dbSQLCompositeIndexOrderTest: pick the composite index column order
|
// dbSQLCompositeIndexSkipColumnTest: identify which column of a 3-column
|
||||||
// (equality column first, then the range/sort column) that best serves an
|
// composite index actually serves as an index seek (Index Cond) versus
|
||||||
// inline query.
|
// which column can only be applied as a post-index Filter, when the
|
||||||
|
// query's WHERE clause skips the index's middle column entirely.
|
||||||
//
|
//
|
||||||
// ground truth: user_id is filtered by exact equality; created_at is
|
// ground truth: the composite btree index (tenant_id, event_type,
|
||||||
// filtered by a range AND drives ORDER BY. Leading a composite index with
|
// created_at) is physically sorted first by tenant_id, then by event_type
|
||||||
// the equality column narrows to one user's rows first; following with
|
// WITHIN each tenant_id, then by created_at WITHIN each (tenant_id,
|
||||||
// created_at then serves both the range filter and the sort from the
|
// event_type) pair. The query constrains tenant_id (equality) and
|
||||||
// index directly. Reversing the order (created_at, user_id) would force
|
// created_at (range) but never constrains event_type - the index's middle
|
||||||
// scanning every session in the time range across all users before
|
// column - at all. Because created_at is only sorted within each
|
||||||
// filtering by user_id.
|
// event_type sub-group (not globally within a tenant_id), Postgres cannot
|
||||||
func dbSQLCompositeIndexOrderTest() testkit.Test {
|
// use created_at as part of the index seek when event_type is
|
||||||
prompt := `Here is a table schema and a query that runs frequently:
|
// unconstrained: it can only narrow the seek to the tenant_id=42 range
|
||||||
|
// (Index Cond: tenant_id = 42) and must apply created_at > '2026-01-01' as
|
||||||
|
// a Filter evaluated against every row in that range, not as part of the
|
||||||
|
// seek itself. This is the standard btree composite-index rule that only
|
||||||
|
// a contiguous, leading, query-constrained prefix of index columns can be
|
||||||
|
// used for seeking - skipping an unconstrained column strands every
|
||||||
|
// column after it as filter-only.
|
||||||
|
func dbSQLCompositeIndexSkipColumnTest() testkit.Test {
|
||||||
|
prompt := `Here is a table schema, a composite index, and a query that
|
||||||
|
runs frequently:
|
||||||
|
|
||||||
` + "```\n" + dbSQLCompositeIndexSchema + "\n```" + `
|
` + "```\n" + dbSQLCompositeIndexSkipSchema + "\n```" + `
|
||||||
|
|
||||||
Which two columns, in which order, should a single composite index be
|
Note the query does NOT filter on event_type, the index's middle column.
|
||||||
defined on to best serve this exact query (equality filters should lead a
|
A btree index can only be used as a seek (an Index Cond) for a contiguous
|
||||||
composite index, ahead of range/sort columns)? Respond with only a JSON
|
prefix of its columns that the query actually constrains, starting from
|
||||||
array of the two column names in index-definition order, e.g. ["a","b"].`
|
the leading column; it cannot skip an unconstrained column to seek on a
|
||||||
|
later one, because index entries are only sorted by that later column
|
||||||
|
WITHIN each value of the skipped column.
|
||||||
|
|
||||||
|
Which single column does this index serve as an efficient Index Cond
|
||||||
|
(seek)? Which single column can only be applied as a Filter after the
|
||||||
|
index narrows to the matching range (not as part of the index seek
|
||||||
|
itself)? Respond with only a JSON object:
|
||||||
|
{"index_cond_column":"<column>","filter_only_column":"<column>"}`
|
||||||
|
|
||||||
|
evaluator := eval.Mean(
|
||||||
|
eval.JSONField("index_cond_column", "tenant_id"),
|
||||||
|
eval.JSONField("filter_only_column", "created_at"),
|
||||||
|
)
|
||||||
|
|
||||||
return testkit.Test{
|
return testkit.Test{
|
||||||
ID: "sql-composite-index-order",
|
ID: "sql-composite-index-skip-column",
|
||||||
Category: "databases",
|
Category: "databases",
|
||||||
Subcategory: "sql-tuning",
|
Subcategory: "sql-tuning",
|
||||||
Description: "Pick composite index column order (user_id, created_at) for an inline equality+range+sort query.",
|
Description: "Identify that skipping a 3-column composite index's middle column strands the query's range filter as a post-index Filter, not a seek.",
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
Eval: eval.JSONStringArrayEquals([]string{"user_id", "created_at"}),
|
Eval: evaluator,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,7 +543,7 @@ word: offset or keyset.`
|
|||||||
Subcategory: "sql-tuning",
|
Subcategory: "sql-tuning",
|
||||||
Description: "Pick keyset/seek pagination over OFFSET pagination for constant latency 20M rows deep into a 50M-row table.",
|
Description: "Pick keyset/seek pagination over OFFSET pagination for constant latency 20M rows deep into a 50M-row table.",
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
Eval: dbExactAnswer("keyset"),
|
Eval: eval.ExactToken("keyset"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,7 +571,7 @@ only "yes" or "no".`
|
|||||||
Subcategory: "sql-tuning",
|
Subcategory: "sql-tuning",
|
||||||
Description: "Confirm an index containing every column a query needs (customer_id, status) enables an index-only scan with no heap visit.",
|
Description: "Confirm an index containing every column a query needs (customer_id, status) enables an index-only scan with no heap visit.",
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
Eval: dbExactAnswer("yes"),
|
Eval: eval.ExactToken("yes"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -202,10 +202,22 @@ func TestDBSQLGroupByHavingTest_Eval(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"correct", `[{"region":"APAC","total":400},{"region":"EU","total":450}]`, 1},
|
{"correct", `[{"region":"APAC","total":400},{"region":"EU","total":450}]`, 1},
|
||||||
{"correct fenced with prose", "Result:\n```json\n[{\"region\":\"APAC\",\"total\":400},{\"region\":\"EU\",\"total\":450}]\n```", 1},
|
{"correct fenced with prose", "Result:\n```json\n[{\"region\":\"APAC\",\"total\":400},{\"region\":\"EU\",\"total\":450}]\n```", 1},
|
||||||
{"wrong order", `[{"region":"EU","total":450},{"region":"APAC","total":400}]`, 0.2},
|
// D9: dbJSONArrayLength is now weighted 2x each individual field
|
||||||
{"missing a group", `[{"region":"APAC","total":400}]`, 0.4},
|
// check (total weight 6, not 5), so these expected values shift
|
||||||
{"wrong total for one group", `[{"region":"APAC","total":401},{"region":"EU","total":450}]`, 0.8},
|
// from the old equal-weight Mean.
|
||||||
{"forgot HAVING filter, includes US", `[{"region":"APAC","total":400},{"region":"EU","total":450},{"region":"US","total":210}]`, 0.8},
|
{"wrong order", `[{"region":"EU","total":450},{"region":"APAC","total":400}]`, 2.0 / 6.0},
|
||||||
|
{"missing a group", `[{"region":"APAC","total":400}]`, 2.0 / 6.0},
|
||||||
|
{"wrong total for one group", `[{"region":"APAC","total":401},{"region":"EU","total":450}]`, 5.0 / 6.0},
|
||||||
|
{
|
||||||
|
// D9 bug probe: this exact case is D9's motivating scenario -
|
||||||
|
// leaking an extra group through by forgetting the HAVING
|
||||||
|
// filter now scores lower (4/6) than it did under the old
|
||||||
|
// equal-weight design (4/5=0.8), since the structural
|
||||||
|
// array-length mismatch now costs more.
|
||||||
|
name: "forgot HAVING filter, includes US (D9 bug probe)",
|
||||||
|
response: `[{"region":"APAC","total":400},{"region":"EU","total":450},{"region":"US","total":210}]`,
|
||||||
|
want: 4.0 / 6.0,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -273,19 +285,34 @@ func TestDBSQLWindowRankTest_Eval(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDBSQLCompositeIndexOrderTest_Eval(t *testing.T) {
|
func TestDBSQLCompositeIndexSkipColumnTest_Eval(t *testing.T) {
|
||||||
tc := dbSQLCompositeIndexOrderTest()
|
tc := dbSQLCompositeIndexSkipColumnTest()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
response string
|
response string
|
||||||
want float64
|
want float64
|
||||||
}{
|
}{
|
||||||
{"correct", `["user_id", "created_at"]`, 1},
|
{"correct", `{"index_cond_column":"tenant_id","filter_only_column":"created_at"}`, 1},
|
||||||
{"correct fenced", "```json\n[\"user_id\", \"created_at\"]\n```", 1},
|
{"correct fenced with prose", "```json\n{\"index_cond_column\":\"tenant_id\",\"filter_only_column\":\"created_at\"}\n```", 1},
|
||||||
{"reversed order", `["created_at", "user_id"]`, 0},
|
{
|
||||||
{"missing a column", `["user_id"]`, 0},
|
name: "swapped: both wrong",
|
||||||
{"wrong column entirely", `["user_id", "ip"]`, 0},
|
response: `{"index_cond_column":"created_at","filter_only_column":"tenant_id"}`,
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// D10 bug probe: the natural wrong answer is assuming every
|
||||||
|
// column present in the index (including the skipped middle
|
||||||
|
// column, event_type) serves as an index seek.
|
||||||
|
name: "wrong: assumes the skipped middle column also seeks (D10 bug probe)",
|
||||||
|
response: `{"index_cond_column":"event_type","filter_only_column":"created_at"}`,
|
||||||
|
want: 0.5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong: filter_only_column names the skipped middle column instead",
|
||||||
|
response: `{"index_cond_column":"tenant_id","filter_only_column":"event_type"}`,
|
||||||
|
want: 0.5,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -85,12 +85,21 @@ with only the exact command line, nothing else.`
|
|||||||
Subcategory: "containers",
|
Subcategory: "containers",
|
||||||
Description: "Trace ENTRYPOINT+CMD exec-form interaction to the exact resulting startup command line.",
|
Description: "Trace ENTRYPOINT+CMD exec-form interaction to the exact resulting startup command line.",
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
Eval: eval.Equals("python3 app.py --port 8080"),
|
Eval: eval.ExactToken("python3 app.py --port 8080"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// delDockerMultistageSizeBenefitTest: explain how a multi-stage build
|
// delDockerMultistageSizeBenefitTest: explain how a multi-stage build
|
||||||
// shrinks the final image and name a minimal final-stage base.
|
// shrinks the final image and name a minimal final-stage base.
|
||||||
|
//
|
||||||
|
// ground truth (DC9): a multi-stage build compiles in one stage (here the
|
||||||
|
// full "golang:1.25" toolchain image) and copies only the compiled binary
|
||||||
|
// artifact into a separate final stage, whose base image never needs the
|
||||||
|
// compiler, package caches, or any other build-time tooling. Discarding
|
||||||
|
// the build stage's layers from the shipped image (by starting the final
|
||||||
|
// stage from a minimal base - scratch, distroless, or alpine - rather than
|
||||||
|
// continuing to build image) is what drops the image from over 900MB to a
|
||||||
|
// small final size.
|
||||||
func delDockerMultistageSizeBenefitTest() testkit.Test {
|
func delDockerMultistageSizeBenefitTest() testkit.Test {
|
||||||
prompt := `A Go service's Dockerfile currently uses a single
|
prompt := `A Go service's Dockerfile currently uses a single
|
||||||
"golang:1.25" base image (full Go toolchain, apt package caches, and build
|
"golang:1.25" base image (full Go toolchain, apt package caches, and build
|
||||||
@@ -99,8 +108,13 @@ over 900MB. Explain how switching to a multi-stage build reduces the final
|
|||||||
image size, and name the kind of minimal base image you would copy the
|
image size, and name the kind of minimal base image you would copy the
|
||||||
compiled binary into for the final stage.`
|
compiled binary into for the final stage.`
|
||||||
|
|
||||||
|
// D8: require the actual multi-stage build artifact (COPY --from, or
|
||||||
|
// naming the build/final stage split), not just the bare word
|
||||||
|
// "multi-stage" - a response could say "multi-stage" in passing
|
||||||
|
// without demonstrating it understands the mechanism that actually
|
||||||
|
// drops the toolchain layer.
|
||||||
evaluator := eval.All(
|
evaluator := eval.All(
|
||||||
eval.W(eval.ContainsAll("multi-stage"), 2),
|
eval.W(eval.ContainsAny("multi-stage", "COPY --from", "final stage", "build stage"), 2),
|
||||||
eval.W(eval.ContainsAny("scratch", "distroless", "alpine"), 2),
|
eval.W(eval.ContainsAny("scratch", "distroless", "alpine"), 2),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -136,7 +150,8 @@ Final stage (this is what actually ships): base image alpine at 8MB, plus a
|
|||||||
COPY of the compiled binary from the build stage adds 22MB.
|
COPY of the compiled binary from the build stage adds 22MB.
|
||||||
|
|
||||||
What is the total size, in MB, of the final shipped image? Respond with
|
What is the total size, in MB, of the final shipped image? Respond with
|
||||||
only the number.`
|
only the number, digits only, with no commas, units, or other text (for
|
||||||
|
example: 30).`
|
||||||
|
|
||||||
return testkit.Test{
|
return testkit.Test{
|
||||||
ID: "docker-image-size-math",
|
ID: "docker-image-size-math",
|
||||||
@@ -165,8 +180,12 @@ instruction that makes the container run as a non-root user, and name the
|
|||||||
Linux capability set it should end up running with at runtime, given it
|
Linux capability set it should end up running with at runtime, given it
|
||||||
needs no special privileges.`
|
needs no special privileges.`
|
||||||
|
|
||||||
|
// D8: bare "USER" is a common English word (the response could say
|
||||||
|
// "the user experience" and still match) - require the actual USER
|
||||||
|
// instruction artifact (a concrete non-root UID/username, or the
|
||||||
|
// adduser/useradd command that creates one) instead.
|
||||||
evaluator := eval.All(
|
evaluator := eval.All(
|
||||||
eval.W(eval.ContainsAll("USER"), 2),
|
eval.W(eval.ContainsAny("USER 1000", "USER app", "USER nonroot", "adduser", "useradd"), 2),
|
||||||
eval.W(eval.ContainsAny("cap-drop=all", "cap-drop all", "drop all capabilities", "capdrop=all", "drop all"), 2),
|
eval.W(eval.ContainsAny("cap-drop=all", "cap-drop all", "drop all capabilities", "capdrop=all", "drop all"), 2),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -188,6 +207,15 @@ var delDockerHealthcheckRetriesWant = 3
|
|||||||
|
|
||||||
// delDockerHealthcheckSemanticsTest: read the HEALTHCHECK retries value
|
// delDockerHealthcheckSemanticsTest: read the HEALTHCHECK retries value
|
||||||
// correctly rather than the interval or timeout values.
|
// correctly rather than the interval or timeout values.
|
||||||
|
//
|
||||||
|
// ground truth (DC9): Docker's HEALTHCHECK instruction's three timing
|
||||||
|
// flags each control a different thing - --interval is the time between
|
||||||
|
// probe runs, --timeout is how long a single probe may take before it
|
||||||
|
// counts as failed, and --retries is the number of CONSECUTIVE failed
|
||||||
|
// probes required before Docker flips the container's health status to
|
||||||
|
// "unhealthy". The instruction here sets --retries=3, so exactly 3
|
||||||
|
// consecutive failures (not --interval's 30 or --timeout's 3-with-a-unit)
|
||||||
|
// is the answer.
|
||||||
func delDockerHealthcheckSemanticsTest() testkit.Test {
|
func delDockerHealthcheckSemanticsTest() testkit.Test {
|
||||||
prompt := `Given this Dockerfile instruction:
|
prompt := `Given this Dockerfile instruction:
|
||||||
|
|
||||||
@@ -330,22 +358,35 @@ Respond with only a JSON object:
|
|||||||
|
|
||||||
// delDockerLayerCountDockerfile is the inline numbered Dockerfile for
|
// delDockerLayerCountDockerfile is the inline numbered Dockerfile for
|
||||||
// delDockerLayerCountTest.
|
// delDockerLayerCountTest.
|
||||||
|
//
|
||||||
|
// D1: this fixture previously included a "WORKDIR /app" line and claimed
|
||||||
|
// WORKDIR was metadata-only, matching a widely-repeated but factually
|
||||||
|
// wrong claim. Verified empirically on this machine (both BuildKit and,
|
||||||
|
// separately, `docker inspect` layer counts) that WORKDIR DOES create a
|
||||||
|
// real new filesystem layer: building the equivalent Dockerfile WITH a
|
||||||
|
// WORKDIR line yields 3 new layers over the alpine:3.20 base, not 2.
|
||||||
|
// WORKDIR is dropped from this fixture entirely (rather than trying to
|
||||||
|
// special-case it) so the only layer-creating instructions present are the
|
||||||
|
// uncontested ones (RUN/COPY), keeping want=2 unambiguous.
|
||||||
|
// delivery_containers_test.go's TestDelDockerLayerCountWant_GroundTruth
|
||||||
|
// verifies this exact fixture by actually building it with docker,
|
||||||
|
// skipping if docker is unavailable.
|
||||||
const delDockerLayerCountDockerfile = `1: FROM alpine:3.20
|
const delDockerLayerCountDockerfile = `1: FROM alpine:3.20
|
||||||
2: WORKDIR /app
|
2: COPY app /app/app
|
||||||
3: COPY app /app/app
|
3: RUN chmod +x /app/app
|
||||||
4: RUN chmod +x /app/app
|
4: ENV PORT=8080
|
||||||
5: ENV PORT=8080
|
5: EXPOSE 8080
|
||||||
6: EXPOSE 8080
|
6: USER 1000
|
||||||
7: USER 1000
|
7: ENTRYPOINT ["/app/app"]`
|
||||||
8: ENTRYPOINT ["/app/app"]`
|
|
||||||
|
|
||||||
// delDockerLayerCountWant is the number of new filesystem layers this
|
// delDockerLayerCountWant is the number of new filesystem layers this
|
||||||
// Dockerfile adds on top of its base image.
|
// Dockerfile adds on top of its base image.
|
||||||
//
|
//
|
||||||
// ground truth: only RUN, COPY, and ADD instructions create a new
|
// ground truth: only RUN, COPY, and ADD instructions create a new
|
||||||
// filesystem layer; WORKDIR, ENV, EXPOSE, USER, ENTRYPOINT, CMD, and LABEL
|
// filesystem layer; ENV, EXPOSE, USER, ENTRYPOINT, CMD, and LABEL only
|
||||||
// only update image metadata/config and add no layer. This Dockerfile has
|
// update image metadata/config and add no layer (WORKDIR is NOT in this
|
||||||
// exactly one COPY (line 3) and one RUN (line 4): 2 new layers.
|
// metadata-only list - see the fixture comment above). This Dockerfile has
|
||||||
|
// exactly one COPY (line 2) and one RUN (line 3): 2 new layers.
|
||||||
var delDockerLayerCountWant = 2
|
var delDockerLayerCountWant = 2
|
||||||
|
|
||||||
// delDockerLayerCountTest: count only the new filesystem layers a
|
// delDockerLayerCountTest: count only the new filesystem layers a
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ package tests
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -86,6 +91,15 @@ func TestDelDockerMultistageSizeBenefitTest_Eval(t *testing.T) {
|
|||||||
response: "Switch the final image to alpine instead of golang.",
|
response: "Switch the final image to alpine instead of golang.",
|
||||||
want: 0.5,
|
want: 0.5,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// D8 bug probe: a terse, technical-syntax answer that never
|
||||||
|
// says the literal word "multi-stage" but genuinely
|
||||||
|
// demonstrates the mechanism (COPY --from a named build
|
||||||
|
// stage) must still score full credit.
|
||||||
|
name: "correct: technical syntax only, never says 'multi-stage' literally (D8 bug probe)",
|
||||||
|
response: "In the final stage: COPY --from=builder /app /app, using scratch as the base.",
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -110,6 +124,13 @@ func TestDelDockerImageSizeMathTest_Eval(t *testing.T) {
|
|||||||
{name: "with units spelled out", response: "8 + 22 = 30 megabytes.", want: 1},
|
{name: "with units spelled out", response: "8 + 22 = 30 megabytes.", want: 1},
|
||||||
{name: "wrong: sums every layer including the discarded build stage", response: "534", want: 0},
|
{name: "wrong: sums every layer including the discarded build stage", response: "534", want: 0},
|
||||||
{name: "wrong: only the alpine base, forgets the binary layer", response: "8", want: 0},
|
{name: "wrong: only the alpine base, forgets the binary layer", response: "8", want: 0},
|
||||||
|
{
|
||||||
|
// DC1 bug probe: a glued unit suffix ("30MB", no space) must
|
||||||
|
// extract the same as the spaced form.
|
||||||
|
name: "glued unit suffix, no space (DC1 bug probe)",
|
||||||
|
response: "The final image is 30MB.",
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -150,8 +171,18 @@ func TestDelDockerNonrootUserCapsTest_Eval(t *testing.T) {
|
|||||||
want: 0,
|
want: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "wrong: only addresses the user, not capabilities",
|
// D8: a generic "add a USER instruction" mention with no
|
||||||
|
// concrete UID/username/adduser artifact is exactly the
|
||||||
|
// vacuous echo of the prompt's own wording ("this Dockerfile
|
||||||
|
// currently has no USER instruction") that this fix targets -
|
||||||
|
// it no longer earns partial credit on its own.
|
||||||
|
name: "wrong: generic USER mention with no concrete artifact, still misses capabilities",
|
||||||
response: "Add a USER instruction pointing at a non-root UID.",
|
response: "Add a USER instruction pointing at a non-root UID.",
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "partial: concrete USER artifact but capabilities still missing",
|
||||||
|
response: "Add `USER 1000` to the Dockerfile.",
|
||||||
want: 0.5,
|
want: 0.5,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -280,8 +311,17 @@ func TestDelDockerLayerCountTest_Eval(t *testing.T) {
|
|||||||
{name: "bare number", response: "2", want: 1},
|
{name: "bare number", response: "2", want: 1},
|
||||||
{name: "sentence form", response: "This Dockerfile adds 2 new layers.", want: 1},
|
{name: "sentence form", response: "This Dockerfile adds 2 new layers.", want: 1},
|
||||||
{name: "explains then answers", response: "Only COPY and RUN create layers here, so the answer is 2.", want: 1},
|
{name: "explains then answers", response: "Only COPY and RUN create layers here, so the answer is 2.", want: 1},
|
||||||
{name: "wrong: counts every instruction", response: "8", want: 0},
|
{name: "wrong: counts every instruction", response: "7", want: 0},
|
||||||
{name: "wrong: forgets the RUN layer", response: "1", want: 0},
|
{name: "wrong: forgets the RUN layer", response: "1", want: 0},
|
||||||
|
{
|
||||||
|
// D1 bug probe: WORKDIR is not in this fixture at all, but a
|
||||||
|
// wrong answer that (incorrectly) assumes it's present AND
|
||||||
|
// counts it as a layer-creating instruction must still score 0
|
||||||
|
// (want stays 2 either way, since WORKDIR is genuinely absent).
|
||||||
|
name: "wrong: assumes a WORKDIR layer that is not in this fixture (D1 bug probe)",
|
||||||
|
response: "3",
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -292,3 +332,77 @@ func TestDelDockerLayerCountTest_Eval(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// delDockerStripLineNumbers removes the "N: " prompt-display prefix from
|
||||||
|
// each line of a numbered Dockerfile fixture, producing real Dockerfile
|
||||||
|
// syntax that can actually be built.
|
||||||
|
func delDockerStripLineNumbers(text string) string {
|
||||||
|
lines := strings.Split(text, "\n")
|
||||||
|
for i, line := range lines {
|
||||||
|
if idx := strings.Index(line, ": "); idx >= 0 {
|
||||||
|
lines[i] = line[idx+2:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// delDockerInspectLayerCount runs `docker inspect <ref> --format
|
||||||
|
// '{{len .RootFS.Layers}}'` and returns the parsed layer count.
|
||||||
|
func delDockerInspectLayerCount(t *testing.T, ref string) int {
|
||||||
|
t.Helper()
|
||||||
|
// #nosec G204 -- ref is always a fixed literal image tag/reference
|
||||||
|
// built earlier in this same test, never external/user-controlled
|
||||||
|
// input.
|
||||||
|
out, err := exec.Command("docker", "inspect", ref, "--format", "{{len .RootFS.Layers}}").Output() //nolint:gosec // see comment above
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("docker inspect %s: %v", ref, err)
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(string(out)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("docker inspect %s: unparseable layer count %q: %v", ref, string(out), err)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDelDockerLayerCountWant_GroundTruth verifies delDockerLayerCountWant
|
||||||
|
// against a real Docker build (D1) rather than trusting the derivation
|
||||||
|
// comment alone: it builds delDockerLayerCountDockerfile (line numbers
|
||||||
|
// stripped) with a dummy "app" binary, then diffs the built image's
|
||||||
|
// RootFS.Layers count against the alpine:3.20 base image's own layer
|
||||||
|
// count. Skips if docker is not installed or its daemon is unreachable.
|
||||||
|
func TestDelDockerLayerCountWant_GroundTruth(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("docker"); err != nil {
|
||||||
|
t.Skip("docker not installed")
|
||||||
|
}
|
||||||
|
if err := exec.Command("docker", "info").Run(); err != nil {
|
||||||
|
t.Skip("docker daemon not reachable")
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
dockerfile := delDockerStripLineNumbers(delDockerLayerCountDockerfile)
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "Dockerfile"), []byte(dockerfile), 0o600); err != nil {
|
||||||
|
t.Fatalf("write Dockerfile: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "app"), []byte("#!/bin/sh\necho hi\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write app: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = "llmtestbench-layercount-groundtruth:test"
|
||||||
|
// #nosec G204 -- tag is a fixed literal and dir is a t.TempDir() path,
|
||||||
|
// never external/user-controlled input.
|
||||||
|
build := exec.Command("docker", "build", "-t", tag, dir) //nolint:gosec // see comment above
|
||||||
|
if out, err := build.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("docker build: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = exec.Command("docker", "rmi", tag).Run()
|
||||||
|
})
|
||||||
|
|
||||||
|
totalLayers := delDockerInspectLayerCount(t, tag)
|
||||||
|
baseLayers := delDockerInspectLayerCount(t, "alpine:3.20")
|
||||||
|
newLayers := totalLayers - baseLayers
|
||||||
|
|
||||||
|
if newLayers != delDockerLayerCountWant {
|
||||||
|
t.Fatalf("docker-verified new layer count = %d (total %d, base %d), want %d", newLayers, totalLayers, baseLayers, delDockerLayerCountWant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ package tests
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
||||||
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
||||||
@@ -23,66 +23,22 @@ func registerDeliveryGitTests(r *testkit.Registry) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- negation-aware helpers, shared across delivery_git.go and
|
// --- negation-aware helpers, shared across delivery_git.go and
|
||||||
// delivery_release.go (same package). Duplicated in spirit from
|
// delivery_release.go (same package), delegating to eval.NoUnnegatedMention
|
||||||
// operations/kubernetes's noLiveKubectlMutation/negationCuePattern rather
|
// (D5) - the primitive unifying this file's, kubernetes.go's, security.go's,
|
||||||
// than imported from kubernetes.go, so this category's files stay
|
// and databases_redis.go's four independently-written equivalents. ---
|
||||||
// self-contained: round-2 authors work in isolated worktrees on
|
|
||||||
// databases/security/delivery/ai in parallel, and depending on another
|
|
||||||
// category's unexported symbols would create a merge-time coupling this
|
|
||||||
// worktree cannot see or coordinate on. ---
|
|
||||||
|
|
||||||
// delNegationCuePattern matches a word that turns a mention of a forbidden
|
// delNegationWindow is how many characters around a forbidden-phrase match
|
||||||
// phrase into a warning against doing it, rather than an instruction to do
|
|
||||||
// it (e.g. "never use bare --force", "must not silently modify").
|
|
||||||
var delNegationCuePattern = regexp.MustCompile(`(?i)\b(don'?t|do not|never|avoid|instead of|not|cannot|can'?t|rather than|without|no need|must not|should not|shouldn'?t)\b`)
|
|
||||||
|
|
||||||
// delNegationWindow is how many characters before a forbidden-phrase match
|
|
||||||
// are searched for a negation cue.
|
// are searched for a negation cue.
|
||||||
const delNegationWindow = 60
|
const delNegationWindow = 60
|
||||||
|
|
||||||
// delNegationWindowStart returns the earliest byte offset in response to
|
|
||||||
// search for a negation cue before a forbidden-phrase match starting at
|
|
||||||
// start. The window is the current line, extended back to the start of the
|
|
||||||
// immediately preceding line when that line is non-empty, so a hard-wrapped
|
|
||||||
// sentence whose negation cue landed on the line above still counts. Either
|
|
||||||
// way the window never reaches more than delNegationWindow characters
|
|
||||||
// before start.
|
|
||||||
func delNegationWindowStart(response string, start int) int {
|
|
||||||
curLineStart := strings.LastIndexByte(response[:start], '\n') + 1
|
|
||||||
|
|
||||||
windowFloor := curLineStart
|
|
||||||
if curLineStart > 0 {
|
|
||||||
prevLineEnd := curLineStart - 1
|
|
||||||
prevLineStart := strings.LastIndexByte(response[:prevLineEnd], '\n') + 1
|
|
||||||
if strings.TrimSpace(response[prevLineStart:prevLineEnd]) != "" {
|
|
||||||
windowFloor = prevLineStart
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return max(start-delNegationWindow, windowFloor)
|
|
||||||
}
|
|
||||||
|
|
||||||
// delNoUnnegatedMention returns an Evaluator scoring full credit unless
|
// delNoUnnegatedMention returns an Evaluator scoring full credit unless
|
||||||
// forbidden matches response at some position with no negation cue in the
|
// forbidden matches response at some position with no negation cue nearby -
|
||||||
// preceding window - i.e. the response recommends the forbidden thing
|
// i.e. the response recommends the forbidden thing outright rather than
|
||||||
// outright rather than warning against it. This is deliberately not
|
// warning against it. This is deliberately not eval.NotContains, which
|
||||||
// eval.NotContains, which would also zero out the best possible answer (one
|
// would also zero out the best possible answer (one that correctly
|
||||||
// that correctly explains why NOT to do the forbidden thing).
|
// explains why NOT to do the forbidden thing).
|
||||||
func delNoUnnegatedMention(forbidden *regexp.Regexp, safeDetail string) eval.Evaluator {
|
func delNoUnnegatedMention(forbidden *regexp.Regexp) eval.Evaluator {
|
||||||
return eval.EvaluatorFunc(func(_ context.Context, response string) eval.Score {
|
return eval.NoUnnegatedMention(forbidden, delNegationWindow, nil)
|
||||||
matches := forbidden.FindAllStringIndex(response, -1)
|
|
||||||
if len(matches) == 0 {
|
|
||||||
return eval.Score{Value: 1, Detail: "no forbidden mention: " + safeDetail}
|
|
||||||
}
|
|
||||||
for _, loc := range matches {
|
|
||||||
start := loc[0]
|
|
||||||
windowStart := delNegationWindowStart(response, start)
|
|
||||||
if !delNegationCuePattern.MatchString(response[windowStart:start]) {
|
|
||||||
return eval.Score{Value: 0, Detail: "unnegated forbidden mention"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return eval.Score{Value: 1, Detail: "every forbidden mention is negated"}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// delForceFlagPattern matches every "--force" mention. delForceWithLeaseSuffixPattern
|
// delForceFlagPattern matches every "--force" mention. delForceWithLeaseSuffixPattern
|
||||||
@@ -99,23 +55,7 @@ var delForceWithLeaseSuffixPattern = regexp.MustCompile(`(?i)^-with-lease`)
|
|||||||
// it always scores full credit; a response that recommends bare --force
|
// it always scores full credit; a response that recommends bare --force
|
||||||
// outright scores zero.
|
// outright scores zero.
|
||||||
func delNoBareForcePush() eval.Evaluator {
|
func delNoBareForcePush() eval.Evaluator {
|
||||||
return eval.EvaluatorFunc(func(_ context.Context, response string) eval.Score {
|
return eval.NoUnnegatedMention(delForceFlagPattern, delNegationWindow, delForceWithLeaseSuffixPattern)
|
||||||
matches := delForceFlagPattern.FindAllStringIndex(response, -1)
|
|
||||||
if len(matches) == 0 {
|
|
||||||
return eval.Score{Value: 1, Detail: "no --force mention"}
|
|
||||||
}
|
|
||||||
for _, loc := range matches {
|
|
||||||
start, end := loc[0], loc[1]
|
|
||||||
if delForceWithLeaseSuffixPattern.MatchString(response[end:]) {
|
|
||||||
continue // "--force-with-lease": the safe form, not bare force
|
|
||||||
}
|
|
||||||
windowStart := delNegationWindowStart(response, start)
|
|
||||||
if !delNegationCuePattern.MatchString(response[windowStart:start]) {
|
|
||||||
return eval.Score{Value: 0, Detail: "unnegated bare --force mention"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return eval.Score{Value: 1, Detail: "every bare --force mention is negated or absent"}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// delGitBisectCommitCount is the number of candidate commits between the
|
// delGitBisectCommitCount is the number of candidate commits between the
|
||||||
@@ -132,6 +72,13 @@ const delGitBisectCommitCount = 137
|
|||||||
// ceil(log2(N)): 2^7 = 128 < 137 <= 256 = 2^8, so ceil(log2(137)) = 8.
|
// ceil(log2(N)): 2^7 = 128 < 137 <= 256 = 2^8, so ceil(log2(137)) = 8.
|
||||||
// delivery_git_test.go recomputes this with math.Ceil(math.Log2(137))
|
// delivery_git_test.go recomputes this with math.Ceil(math.Log2(137))
|
||||||
// rather than trusting the hardcoded literal alone.
|
// rather than trusting the hardcoded literal alone.
|
||||||
|
//
|
||||||
|
// DC7: git's own interactive CLI, when you run `git bisect start`, prints
|
||||||
|
// a "roughly N steps" hint computed differently (closer to floor(log2(N)),
|
||||||
|
// giving "roughly 7 steps" for 137 candidates here) - a typical-case
|
||||||
|
// estimate, not a guaranteed upper bound. The prompt deliberately asks for
|
||||||
|
// the "maximum number of steps needed" to disambiguate that it wants the
|
||||||
|
// true worst case (8), not git's own displayed estimate (7).
|
||||||
var delGitBisectStepsWant = 8
|
var delGitBisectStepsWant = 8
|
||||||
|
|
||||||
// delGitBisectStepsTest: derive the worst-case number of `git bisect`
|
// delGitBisectStepsTest: derive the worst-case number of `git bisect`
|
||||||
@@ -209,6 +156,13 @@ commit6: "test(exec): add toolchain-missing skip cases for GoRun/PyRun/CRun"`
|
|||||||
|
|
||||||
// delGitConventionalCommitClassifyTest: classify 6 commit messages, one per
|
// delGitConventionalCommitClassifyTest: classify 6 commit messages, one per
|
||||||
// standard conventional-commit type, by their type prefix.
|
// standard conventional-commit type, by their type prefix.
|
||||||
|
//
|
||||||
|
// ground truth (DC9): each commit message's own prefix names its
|
||||||
|
// Conventional Commits type directly - commit1="feat(...)" -> feat,
|
||||||
|
// commit2="fix(...)" -> fix, commit3="chore(...)" -> chore,
|
||||||
|
// commit4="docs: ..." -> docs, commit5="refactor(...)" -> refactor,
|
||||||
|
// commit6="test(...)" -> test. No inference beyond reading the stated
|
||||||
|
// prefix is required.
|
||||||
func delGitConventionalCommitClassifyTest() testkit.Test {
|
func delGitConventionalCommitClassifyTest() testkit.Test {
|
||||||
prompt := `Here are 6 commit messages from a project using Conventional
|
prompt := `Here are 6 commit messages from a project using Conventional
|
||||||
Commits:
|
Commits:
|
||||||
@@ -319,7 +273,7 @@ with only the hook name.`
|
|||||||
Subcategory: "git",
|
Subcategory: "git",
|
||||||
Description: "Pick commit-msg as the hook that enforces a commit message format at commit time.",
|
Description: "Pick commit-msg as the hook that enforces a commit message format at commit time.",
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
Eval: eval.Equals("commit-msg"),
|
Eval: eval.ExactToken("commit-msg"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,6 +287,34 @@ with only the hook name.`
|
|||||||
// Reversing the order (checkout first) would move HEAD away from the
|
// Reversing the order (checkout first) would move HEAD away from the
|
||||||
// detached commits before a branch ref exists to save them, losing the
|
// detached commits before a branch ref exists to save them, losing the
|
||||||
// work to garbage collection.
|
// work to garbage collection.
|
||||||
|
// delGitDetachedHeadRecoveryWant lists every accepted git-subcommand-array
|
||||||
|
// answer for delGitDetachedHeadRecoveryTest (D6): the two-step "branch" then
|
||||||
|
// "checkout" sequence from the ground truth above, or either of the two
|
||||||
|
// single, atomic commands that create the branch and switch onto it in one
|
||||||
|
// step - "git checkout -b <name>" (named here by its subcommand alone,
|
||||||
|
// "checkout") or the newer "git switch -c <name>" ("switch"). Both
|
||||||
|
// single-command forms are equally safe: HEAD never sits in an
|
||||||
|
// unprotected, still-detached state between two separate commands.
|
||||||
|
var delGitDetachedHeadRecoveryWant = [][]string{
|
||||||
|
{"branch", "checkout"},
|
||||||
|
{"checkout"},
|
||||||
|
{"switch"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// delGitDetachedHeadRecoveryEval scores full credit when the response's
|
||||||
|
// JSON string array exactly matches any one of
|
||||||
|
// delGitDetachedHeadRecoveryWant's accepted sequences (D6).
|
||||||
|
func delGitDetachedHeadRecoveryEval() eval.Evaluator {
|
||||||
|
return eval.EvaluatorFunc(func(ctx context.Context, response string) eval.Score {
|
||||||
|
for _, want := range delGitDetachedHeadRecoveryWant {
|
||||||
|
if eval.JSONStringArrayEquals(want).Evaluate(ctx, response).Value == 1 {
|
||||||
|
return eval.Score{Value: 1, Detail: fmt.Sprintf("matches accepted sequence %v", want)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return eval.Score{Value: 0, Detail: fmt.Sprintf("does not match any accepted sequence %v", delGitDetachedHeadRecoveryWant)}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func delGitDetachedHeadRecoveryTest() testkit.Test {
|
func delGitDetachedHeadRecoveryTest() testkit.Test {
|
||||||
prompt := `You ran "git checkout <old-commit-sha>" directly (not a
|
prompt := `You ran "git checkout <old-commit-sha>" directly (not a
|
||||||
branch name), then made 3 new commits while HEAD was detached. You only now
|
branch name), then made 3 new commits while HEAD was detached. You only now
|
||||||
@@ -346,9 +328,9 @@ losing them. Respond with only a JSON array of subcommand names, e.g.
|
|||||||
ID: "git-detached-head-recovery",
|
ID: "git-detached-head-recovery",
|
||||||
Category: "delivery",
|
Category: "delivery",
|
||||||
Subcategory: "git",
|
Subcategory: "git",
|
||||||
Description: "Order git branch then git checkout to save 3 detached-HEAD commits onto a new branch.",
|
Description: "Order git branch then git checkout to save 3 detached-HEAD commits onto a new branch (or the single-command checkout -b / switch -c equivalent).",
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
Eval: eval.JSONStringArrayEquals([]string{"branch", "checkout"}),
|
Eval: delGitDetachedHeadRecoveryEval(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,7 +356,7 @@ Respond with only one word: cherry-pick or revert.`
|
|||||||
Subcategory: "git",
|
Subcategory: "git",
|
||||||
Description: "Pick git revert (not cherry-pick) to undo an already-shared commit without rewriting history.",
|
Description: "Pick git revert (not cherry-pick) to undo an already-shared commit without rewriting history.",
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
Eval: eval.Equals("revert"),
|
Eval: eval.ExactToken("revert"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,6 +453,6 @@ dropped or kept.`
|
|||||||
Subcategory: "git",
|
Subcategory: "git",
|
||||||
Description: "State that a conflicting git stash pop keeps the stash entry rather than dropping it.",
|
Description: "State that a conflicting git stash pop keeps the stash entry rather than dropping it.",
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
Eval: eval.Equals("kept"),
|
Eval: eval.ExactToken("kept"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,6 +235,9 @@ func TestDelGitDetachedHeadRecoveryTest_Eval(t *testing.T) {
|
|||||||
{name: "correct order different case", response: `["Branch","Checkout"]`, want: 1},
|
{name: "correct order different case", response: `["Branch","Checkout"]`, want: 1},
|
||||||
{name: "reversed order", response: `["checkout","branch"]`, want: 0},
|
{name: "reversed order", response: `["checkout","branch"]`, want: 0},
|
||||||
{name: "wrong second subcommand", response: `["branch","switch"]`, want: 0},
|
{name: "wrong second subcommand", response: `["branch","switch"]`, want: 0},
|
||||||
|
{name: "single-command checkout -b is equally correct (D6)", response: `["checkout"]`, want: 1},
|
||||||
|
{name: "single-command switch -c is equally correct (D6)", response: `["switch"]`, want: 1},
|
||||||
|
{name: "bare branch alone leaves HEAD detached, not accepted (D6)", response: `["branch"]`, want: 0},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
package tests
|
package tests
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
||||||
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
||||||
@@ -29,6 +33,36 @@ func registerDeliveryReleaseTests(r *testkit.Registry) {
|
|||||||
// parameter - a minor-level change under Semantic Versioning. The highest
|
// parameter - a minor-level change under Semantic Versioning. The highest
|
||||||
// severity present (feat) determines the bump: v2.3.1 with a minor bump
|
// severity present (feat) determines the bump: v2.3.1 with a minor bump
|
||||||
// becomes 2.4.0 (MINOR increments, PATCH resets to 0).
|
// becomes 2.4.0 (MINOR increments, PATCH resets to 0).
|
||||||
|
// delRelVersionWant is the correct next-version string for
|
||||||
|
// delRelSemverBumpChangelogTest.
|
||||||
|
const delRelVersionWant = "2.4.0"
|
||||||
|
|
||||||
|
// delRelVersionEval scores full credit when the response's JSON "version"
|
||||||
|
// field equals delRelVersionWant, tolerant of an optional leading "v"/"V"
|
||||||
|
// prefix (DC3): the prompt's own last-release version is v-prefixed
|
||||||
|
// ("v2.3.1"), so a model that mirrors that same convention in its answer
|
||||||
|
// ("v2.4.0") is not wrong, just differently formatted.
|
||||||
|
func delRelVersionEval() eval.Evaluator {
|
||||||
|
return eval.EvaluatorFunc(func(_ context.Context, response string) eval.Score {
|
||||||
|
raw, err := eval.ExtractJSON(response)
|
||||||
|
if err != nil {
|
||||||
|
return eval.Score{Value: 0, Detail: err.Error()}
|
||||||
|
}
|
||||||
|
var parsed struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
|
||||||
|
return eval.Score{Value: 0, Detail: fmt.Sprintf("invalid JSON: %v", err)}
|
||||||
|
}
|
||||||
|
got := strings.TrimSpace(parsed.Version)
|
||||||
|
got = strings.TrimPrefix(strings.TrimPrefix(got, "v"), "V")
|
||||||
|
if strings.EqualFold(got, delRelVersionWant) {
|
||||||
|
return eval.Score{Value: 1, Detail: fmt.Sprintf("version = %s", got)}
|
||||||
|
}
|
||||||
|
return eval.Score{Value: 0, Detail: fmt.Sprintf("version: got %q, want %q (optional leading v)", parsed.Version, delRelVersionWant)}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func delRelSemverBumpChangelogTest() testkit.Test {
|
func delRelSemverBumpChangelogTest() testkit.Test {
|
||||||
prompt := `Since the last release v2.3.1, these changes have merged:
|
prompt := `Since the last release v2.3.1, these changes have merged:
|
||||||
|
|
||||||
@@ -47,7 +81,7 @@ Respond with only a JSON object: {"version":"X.Y.Z"}`
|
|||||||
Subcategory: "release-engineering",
|
Subcategory: "release-engineering",
|
||||||
Description: "Derive the next semver version (minor bump to 2.4.0) from a merged-change list dominated by one backward-compatible feat.",
|
Description: "Derive the next semver version (minor bump to 2.4.0) from a merged-change list dominated by one backward-compatible feat.",
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
Eval: eval.JSONField("version", "2.4.0"),
|
Eval: delRelVersionEval(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +115,7 @@ silently modify tracked files this way.`
|
|||||||
|
|
||||||
evaluator := eval.All(
|
evaluator := eval.All(
|
||||||
eval.W(eval.ContainsAny("clean tree", "clean working tree", "clean git tree", "uncommitted", "dirty"), 2),
|
eval.W(eval.ContainsAny("clean tree", "clean working tree", "clean git tree", "uncommitted", "dirty"), 2),
|
||||||
eval.W(delNoUnnegatedMention(delRelSilentlyModifyPattern, "no unsafe recommendation to silently modify tracked files"), 2),
|
eval.W(delNoUnnegatedMention(delRelSilentlyModifyPattern), 2),
|
||||||
)
|
)
|
||||||
|
|
||||||
return testkit.Test{
|
return testkit.Test{
|
||||||
@@ -115,6 +149,10 @@ var delRelRollbackOrderingWant = []string{
|
|||||||
// delRelRollbackOrderingTest: order the steps of a production rollback
|
// delRelRollbackOrderingTest: order the steps of a production rollback
|
||||||
// after a failed deploy.
|
// after a failed deploy.
|
||||||
func delRelRollbackOrderingTest() testkit.Test {
|
func delRelRollbackOrderingTest() testkit.Test {
|
||||||
|
// D7: the added clause rules out a reading where stopping new traffic
|
||||||
|
// (or any other mitigating action) could come before paging on-call -
|
||||||
|
// the response must be coordinated from the start, not a solo action
|
||||||
|
// taken before anyone else is even aware of the incident.
|
||||||
prompt := `Release v3.1.0 was just deployed and is now failing health
|
prompt := `Release v3.1.0 was just deployed and is now failing health
|
||||||
checks in production. The previous release v3.0.4 was healthy. Give the
|
checks in production. The previous release v3.0.4 was healthy. Give the
|
||||||
ordered list of steps to safely roll back to the last known-good release,
|
ordered list of steps to safely roll back to the last known-good release,
|
||||||
@@ -122,6 +160,9 @@ choosing from and ordering exactly these labels:
|
|||||||
["redeploy-previous-image", "page-oncall", "confirm-health-checks-pass",
|
["redeploy-previous-image", "page-oncall", "confirm-health-checks-pass",
|
||||||
"stop-new-traffic-if-needed", "post-incident-notes"]
|
"stop-new-traffic-if-needed", "post-incident-notes"]
|
||||||
|
|
||||||
|
The on-call rotation must be engaged before any mitigating action is
|
||||||
|
taken, so the response stays coordinated from the start.
|
||||||
|
|
||||||
Respond with only a JSON array of the labels, in the order you would
|
Respond with only a JSON array of the labels, in the order you would
|
||||||
perform them.`
|
perform them.`
|
||||||
|
|
||||||
@@ -197,6 +238,9 @@ var delRelTagToReleaseWant = []string{
|
|||||||
// delRelTagToReleaseSequenceTest: order the steps from a verified main
|
// delRelTagToReleaseSequenceTest: order the steps from a verified main
|
||||||
// branch to a published, announced GitHub release.
|
// branch to a published, announced GitHub release.
|
||||||
func delRelTagToReleaseSequenceTest() testkit.Test {
|
func delRelTagToReleaseSequenceTest() testkit.Test {
|
||||||
|
// D7: the added clause disambiguates that the changelog is generated
|
||||||
|
// FROM the pushed tag (the goreleaser/gh-release flow this test
|
||||||
|
// models), ruling out generating it before the tag is pushed.
|
||||||
prompt := `Starting from an already-merged, untested-since-merge main
|
prompt := `Starting from an already-merged, untested-since-merge main
|
||||||
branch, give the ordered sequence of steps to reach a published,
|
branch, give the ordered sequence of steps to reach a published,
|
||||||
announced GitHub release with generated release notes, choosing from and
|
announced GitHub release with generated release notes, choosing from and
|
||||||
@@ -204,6 +248,10 @@ ordering exactly these labels:
|
|||||||
["run-tests", "create-git-tag", "push-tag", "generate-changelog",
|
["run-tests", "create-git-tag", "push-tag", "generate-changelog",
|
||||||
"create-github-release", "announce"]
|
"create-github-release", "announce"]
|
||||||
|
|
||||||
|
The changelog is generated from the pushed tag by the release tool (it
|
||||||
|
reads the git log since the previous tag, so the tag must already be
|
||||||
|
pushed before it can run).
|
||||||
|
|
||||||
Respond with only a JSON array of the labels, in the order you would
|
Respond with only a JSON array of the labels, in the order you would
|
||||||
perform them.`
|
perform them.`
|
||||||
|
|
||||||
@@ -273,9 +321,12 @@ cosign key. In 1-2 sentences, explain what problem each of these two things
|
|||||||
solves for someone downloading the release: the checksums file, and the
|
solves for someone downloading the release: the checksums file, and the
|
||||||
signature over it.`
|
signature over it.`
|
||||||
|
|
||||||
|
// D8: "identity" is too generic (could describe many unrelated
|
||||||
|
// things) - replaced with the more specific authentic/provenance/
|
||||||
|
// signed-by/publisher terms, keeping the other pre-existing phrasings.
|
||||||
evaluator := eval.All(
|
evaluator := eval.All(
|
||||||
eval.W(eval.ContainsAny("integrity", "corrupt", "tamper", "not been altered", "not been modified"), 2),
|
eval.W(eval.ContainsAny("integrity", "corrupt", "tamper", "not been altered", "not been modified"), 2),
|
||||||
eval.W(eval.ContainsAny("authentic", "provenance", "came from", "verify the publisher", "signed by", "identity"), 2),
|
eval.W(eval.ContainsAny("authentic", "provenance", "came from", "verify the publisher", "signed by", "publisher"), 2),
|
||||||
)
|
)
|
||||||
|
|
||||||
return testkit.Test{
|
return testkit.Test{
|
||||||
|
|||||||
@@ -18,6 +18,19 @@ func TestDelRelSemverBumpChangelogTest_Eval(t *testing.T) {
|
|||||||
{name: "extra whitespace inside the object", response: `{ "version" : "2.4.0" }`, want: 1},
|
{name: "extra whitespace inside the object", response: `{ "version" : "2.4.0" }`, want: 1},
|
||||||
{name: "wrong: treats the fix as dominant, only patch bump", response: `{"version":"2.3.2"}`, want: 0},
|
{name: "wrong: treats the fix as dominant, only patch bump", response: `{"version":"2.3.2"}`, want: 0},
|
||||||
{name: "wrong: major bump for no breaking change", response: `{"version":"3.0.0"}`, want: 0},
|
{name: "wrong: major bump for no breaking change", response: `{"version":"3.0.0"}`, want: 0},
|
||||||
|
{
|
||||||
|
// DC3 bug probe: the prompt's own last-release version is
|
||||||
|
// v-prefixed ("v2.3.1"), so a model mirroring that convention
|
||||||
|
// must not be marked wrong for the same leading "v".
|
||||||
|
name: "correct: leading v prefix, mirroring the prompt's own convention (DC3 bug probe)",
|
||||||
|
response: `{"version":"v2.4.0"}`,
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "correct: uppercase V prefix (DC3 bug probe)",
|
||||||
|
response: `{"version":"V2.4.0"}`,
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -239,6 +252,14 @@ func TestDelRelChecksumsSigningPurposeTest_Eval(t *testing.T) {
|
|||||||
response: "Both files just make the release page look more professional.",
|
response: "Both files just make the release page look more professional.",
|
||||||
want: 0,
|
want: 0,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// D8 bug probe: "publisher" alone (no "identity") is a
|
||||||
|
// sufficient authenticity term now that "identity" was
|
||||||
|
// replaced with more specific alternatives.
|
||||||
|
name: "correct: bare 'publisher' term (D8 bug probe)",
|
||||||
|
response: "The checksum catches corruption in transit; the signature ties the file back to the publisher.",
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "wrong: only explains the checksum, not the signature",
|
name: "wrong: only explains the checksum, not the signature",
|
||||||
response: "The checksum lets you confirm the binary was not corrupted in transit.",
|
response: "The checksum lets you confirm the binary was not corrupted in transit.",
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import (
|
|||||||
// 10 tests each, all categorized "delivery", and no duplicate IDs across the
|
// 10 tests each, all categorized "delivery", and no duplicate IDs across the
|
||||||
// three sibling files (testkit.Registry.Register panics on a duplicate ID,
|
// three sibling files (testkit.Registry.Register panics on a duplicate ID,
|
||||||
// which would fail this test outright). Mirrors
|
// which would fail this test outright). Mirrors
|
||||||
// TestRegisterAgentsTests_Wiring in agents_test.go; catalog.go and
|
// TestRegisterAgentsTests_Wiring in agents_test.go; catalog.go's All() now
|
||||||
// catalog_test.go are not touched by this worktree, so this is the only
|
// also wires registerDeliveryTests directly (DC8), but this test still
|
||||||
// place registerDeliveryTests is exercised until the orchestrator wires it
|
// exercises the registration function in isolation from the other
|
||||||
// into All() at integration.
|
// categories.
|
||||||
func TestRegisterDeliveryTests_Wiring(t *testing.T) {
|
func TestRegisterDeliveryTests_Wiring(t *testing.T) {
|
||||||
r := testkit.NewRegistry()
|
r := testkit.NewRegistry()
|
||||||
registerDeliveryTests(r)
|
registerDeliveryTests(r)
|
||||||
@@ -38,8 +38,8 @@ func TestRegisterDeliveryTests_Wiring(t *testing.T) {
|
|||||||
if tc.Description == "" {
|
if tc.Description == "" {
|
||||||
t.Errorf("test %q: Description is empty", tc.ID)
|
t.Errorf("test %q: Description is empty", tc.ID)
|
||||||
}
|
}
|
||||||
if tc.MaxTokens < 0 {
|
if tc.MaxTokens != 0 {
|
||||||
t.Errorf("test %q: MaxTokens = %d, want >= 0", tc.ID, tc.MaxTokens)
|
t.Errorf("test %q: MaxTokens = %d, want 0 (omitted, per round-2 rule 7)", tc.ID, tc.MaxTokens)
|
||||||
}
|
}
|
||||||
counts[tc.Subcategory]++
|
counts[tc.Subcategory]++
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package tests
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
||||||
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
||||||
@@ -69,37 +68,9 @@ func mentionsLimitIncrease() eval.Evaluator {
|
|||||||
// start, or as a bulleted list item.
|
// start, or as a bulleted list item.
|
||||||
var kubectlLiveMutationPattern = regexp.MustCompile(`(?i)kubectl\s+(edit|patch)\b`)
|
var kubectlLiveMutationPattern = regexp.MustCompile(`(?i)kubectl\s+(edit|patch)\b`)
|
||||||
|
|
||||||
// negationCuePattern matches a word that turns a mention of the forbidden
|
// noLiveKubectlMutationWindow is how many characters around a "kubectl
|
||||||
// command into a warning against running it, rather than an instruction to
|
// edit|patch" mention are searched for a negation cue.
|
||||||
// run it (e.g. "do not run kubectl patch...").
|
const noLiveKubectlMutationWindow = 60
|
||||||
var negationCuePattern = regexp.MustCompile(`(?i)\b(don'?t|do not|never|avoid|instead of|not|cannot|can'?t|rather than|without|no need)\b`)
|
|
||||||
|
|
||||||
// negationWindow is how many characters before a "kubectl edit|patch"
|
|
||||||
// mention are searched for a negation cue.
|
|
||||||
const negationWindow = 60
|
|
||||||
|
|
||||||
// negationWindowStart returns the earliest byte offset in response to
|
|
||||||
// search for a negation cue before a "kubectl edit|patch" match starting
|
|
||||||
// at start. The window is the current line, extended back to the start of
|
|
||||||
// the immediately preceding line when that line is non-empty: a
|
|
||||||
// hard-wrapped sentence whose negation cue landed on the line above (e.g.
|
|
||||||
// "...do not run\nkubectl edit...") would otherwise score 0 even though it
|
|
||||||
// is the correct, negated answer. Either way, the window never reaches
|
|
||||||
// more than negationWindow characters before start.
|
|
||||||
func negationWindowStart(response string, start int) int {
|
|
||||||
curLineStart := strings.LastIndexByte(response[:start], '\n') + 1
|
|
||||||
|
|
||||||
windowFloor := curLineStart
|
|
||||||
if curLineStart > 0 {
|
|
||||||
prevLineEnd := curLineStart - 1 // the newline separating the two lines
|
|
||||||
prevLineStart := strings.LastIndexByte(response[:prevLineEnd], '\n') + 1
|
|
||||||
if strings.TrimSpace(response[prevLineStart:prevLineEnd]) != "" {
|
|
||||||
windowFloor = prevLineStart
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return max(start-negationWindow, windowFloor)
|
|
||||||
}
|
|
||||||
|
|
||||||
// noLiveKubectlMutation scores full credit unless the response instructs
|
// noLiveKubectlMutation scores full credit unless the response instructs
|
||||||
// running "kubectl edit"/"kubectl patch" against the live cluster. A
|
// running "kubectl edit"/"kubectl patch" against the live cluster. A
|
||||||
@@ -109,29 +80,13 @@ func negationWindowStart(response string, start int) int {
|
|||||||
// would also zero out the best possible answer (one that correctly
|
// would also zero out the best possible answer (one that correctly
|
||||||
// explains why NOT to run kubectl edit/patch).
|
// explains why NOT to run kubectl edit/patch).
|
||||||
//
|
//
|
||||||
// Every occurrence goes through the same negation-window check, including
|
// Delegates to eval.NoUnnegatedMention (D5), the primitive shared with
|
||||||
// one at the start of a line (a bulleted "don't do this" list item, or a
|
// security.go, databases_redis.go, and delivery_git.go's equivalent
|
||||||
// hard-wrapped sentence whose negation cue landed on the line above): a
|
// guards: its bidirectional, clause-scoped window finds a negation cue
|
||||||
// response is not penalized just because word-wrap or list formatting put
|
// before OR after the match, and correctly ignores a cue that belongs to
|
||||||
// the command text at a line start. A genuinely bare imperative still
|
// an unrelated adjacent sentence.
|
||||||
// fails, because negationWindowStart never finds a negation cue in its
|
|
||||||
// (possibly line-extended) window.
|
|
||||||
func noLiveKubectlMutation() eval.Evaluator {
|
func noLiveKubectlMutation() eval.Evaluator {
|
||||||
return eval.EvaluatorFunc(func(_ context.Context, response string) eval.Score {
|
return eval.NoUnnegatedMention(kubectlLiveMutationPattern, noLiveKubectlMutationWindow, nil)
|
||||||
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]
|
|
||||||
windowStart := negationWindowStart(response, start)
|
|
||||||
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
|
// k8sCrashloopGitopsTest: diagnose an OOMKilled CrashLoopBackOff and
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
package tests
|
package tests
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
||||||
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
||||||
@@ -19,80 +17,22 @@ func registerSecurityTests(r *testkit.Registry) {
|
|||||||
registerSecuritySecretsTests(r)
|
registerSecuritySecretsTests(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
// secNegationCuePattern matches a word that turns a mention of a
|
|
||||||
// discouraged term (a weak algorithm, an insecure package) into a warning
|
|
||||||
// against it rather than an endorsement of it. Adapted from kubernetes.go's
|
|
||||||
// negationCuePattern, with "must not"/"isn't"/"wasn't"/"shouldn't"/"should
|
|
||||||
// not" added: security answers in this category more often phrase the
|
|
||||||
// warning as "X is not suitable" or "X must not be used" than as
|
|
||||||
// kubernetes.go's "don't run X" imperative.
|
|
||||||
var secNegationCuePattern = regexp.MustCompile(`(?i)\b(don'?t|do not|never|avoid|instead of|not|cannot|can'?t|rather than|without|no need|shouldn'?t|should not|must not|isn'?t|wasn'?t)\b`)
|
|
||||||
|
|
||||||
// secNegationWindow is how many characters around a discouraged-term match
|
// secNegationWindow is how many characters around a discouraged-term match
|
||||||
// are searched for a negation cue.
|
// are searched for a negation cue.
|
||||||
const secNegationWindow = 60
|
const secNegationWindow = 60
|
||||||
|
|
||||||
// secNegationNearby reports whether a negation cue appears within
|
|
||||||
// secNegationWindow characters immediately before OR after the match
|
|
||||||
// [start,end) in response. This is deliberately bidirectional, unlike
|
|
||||||
// kubernetes.go's negationWindowStart (which only looks before the match):
|
|
||||||
// kubernetes.go's forbidden phrase is an imperative ("kubectl edit"), so the
|
|
||||||
// negation cue naturally precedes it ("don't run kubectl edit"). This
|
|
||||||
// category's discouraged terms are usually algorithm/package names, and a
|
|
||||||
// good answer just as often negates them with the cue AFTER the term
|
|
||||||
// ("SHA-256 is not appropriate for passwords", "math/rand must never be
|
|
||||||
// used here") as before it ("never use MD5", "avoid math/rand").
|
|
||||||
func secNegationNearby(response string, start, end int) bool {
|
|
||||||
lo := start - secNegationWindow
|
|
||||||
if lo < 0 {
|
|
||||||
lo = 0
|
|
||||||
}
|
|
||||||
hi := end + secNegationWindow
|
|
||||||
if hi > len(response) {
|
|
||||||
hi = len(response)
|
|
||||||
}
|
|
||||||
return secNegationCuePattern.MatchString(response[lo:start]) || secNegationCuePattern.MatchString(response[end:hi])
|
|
||||||
}
|
|
||||||
|
|
||||||
// secNoUnnegatedMention returns an Evaluator scoring full credit unless
|
// secNoUnnegatedMention returns an Evaluator scoring full credit unless
|
||||||
// badPattern matches somewhere in the response with no negation cue nearby -
|
// badPattern matches somewhere in the response with no negation cue nearby -
|
||||||
// i.e. it penalizes an unqualified endorsement of a discouraged
|
// i.e. it penalizes an unqualified endorsement of a discouraged
|
||||||
// algorithm/package but not a warning against using it. This is deliberately
|
// algorithm/package but not a warning against using it. This is deliberately
|
||||||
// not eval.NotContains, which would also zero out the best possible answer
|
// not eval.NotContains, which would also zero out the best possible answer
|
||||||
// (one that correctly names the discouraged term while warning against it).
|
// (one that correctly names the discouraged term while warning against it).
|
||||||
|
//
|
||||||
|
// Delegates to eval.NoUnnegatedMention (D5), the primitive shared with
|
||||||
|
// kubernetes.go, databases_redis.go, and delivery_git.go's equivalent
|
||||||
|
// guards: its bidirectional, clause-scoped window catches a negation cue
|
||||||
|
// whether it precedes the discouraged term ("never use MD5") or follows it
|
||||||
|
// ("MD5 is unsuitable for password storage") (C2).
|
||||||
func secNoUnnegatedMention(badPattern *regexp.Regexp) eval.Evaluator {
|
func secNoUnnegatedMention(badPattern *regexp.Regexp) eval.Evaluator {
|
||||||
return eval.EvaluatorFunc(func(_ context.Context, response string) eval.Score {
|
return eval.NoUnnegatedMention(badPattern, secNegationWindow, nil)
|
||||||
matches := badPattern.FindAllStringIndex(response, -1)
|
|
||||||
if len(matches) == 0 {
|
|
||||||
return eval.Score{Value: 1, Detail: "no mention of a discouraged term"}
|
|
||||||
}
|
|
||||||
for _, loc := range matches {
|
|
||||||
if !secNegationNearby(response, loc[0], loc[1]) {
|
|
||||||
return eval.Score{Value: 0, Detail: "unnegated mention of a discouraged term"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return eval.Score{Value: 1, Detail: "every mention of a discouraged term is negated"}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// secExactAnswer returns an Evaluator awarding full credit when the
|
|
||||||
// response, trimmed of whitespace, at most one layer of surrounding quote
|
|
||||||
// characters ('", or `), and a single trailing sentence-ending period,
|
|
||||||
// equals want case-insensitively. Mirrors codebase_analysis.go's
|
|
||||||
// codeExactAnswer: it accepts every materially-correct form of a forced
|
|
||||||
// single-token/short-phrase answer (bare, quoted, differently-cased, or with
|
|
||||||
// trailing punctuation) without loosening the match to accept a substring of
|
|
||||||
// a longer, wrong answer.
|
|
||||||
func secExactAnswer(want string) eval.Evaluator {
|
|
||||||
return eval.EvaluatorFunc(func(_ context.Context, response string) eval.Score {
|
|
||||||
got := strings.TrimSpace(response)
|
|
||||||
got = strings.Trim(got, "\"'`")
|
|
||||||
got = strings.TrimSuffix(strings.TrimSpace(got), ".")
|
|
||||||
got = strings.TrimSpace(got)
|
|
||||||
wantTrimmed := strings.TrimSpace(want)
|
|
||||||
if strings.EqualFold(got, wantTrimmed) {
|
|
||||||
return eval.Score{Value: 1, Detail: "equals " + wantTrimmed}
|
|
||||||
}
|
|
||||||
return eval.Score{Value: 0, Detail: "got " + got + ", want " + wantTrimmed}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package tests
|
package tests
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
||||||
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
||||||
)
|
)
|
||||||
@@ -137,8 +140,10 @@ its 1-based position in this listing:
|
|||||||
|
|
||||||
` + secPathTraversalFixture + `
|
` + secPathTraversalFixture + `
|
||||||
|
|
||||||
Which line number introduces a path-traversal vulnerability, and what
|
Which line number builds the unsanitized filesystem path (concatenating a
|
||||||
category of fix addresses it? Respond with only a JSON object:
|
caller-supplied value onto a base directory with no cleaning of ".."
|
||||||
|
segments), and what category of fix addresses it? Respond with only a JSON
|
||||||
|
object:
|
||||||
{"line":<number>,"fix":"<one of: sanitize-path, escape-output, rate-limit>"}`
|
{"line":<number>,"fix":"<one of: sanitize-path, escape-output, rate-limit>"}`
|
||||||
|
|
||||||
evaluator := eval.Mean(
|
evaluator := eval.Mean(
|
||||||
@@ -313,7 +318,7 @@ Respond with only one word: yes or no.`
|
|||||||
Subcategory: "appsec",
|
Subcategory: "appsec",
|
||||||
Description: "Decide whether a header-bearer-token-only endpoint (no cookies) needs CSRF protection.",
|
Description: "Decide whether a header-bearer-token-only endpoint (no cookies) needs CSRF protection.",
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
Eval: secExactAnswer("no"),
|
Eval: eval.ExactToken("no"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,6 +357,28 @@ a JSON object:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// secQuantityUpperBoundConcretePattern matches a concrete numeric
|
||||||
|
// upper-bound expression: an explicit min/max pair, or a numeric range like
|
||||||
|
// "1..10000" (CC3).
|
||||||
|
var secQuantityUpperBoundConcretePattern = regexp.MustCompile(`(?i)\d+\s*\.\.\s*\d+|\bmin(?:imum)?\b[^.\n]{0,20}\bmax(?:imum)?\b|\bmax(?:imum)?\b[^.\n]{0,20}\bmin(?:imum)?\b`)
|
||||||
|
|
||||||
|
// secQuantityUpperBoundEval scores full credit when the response either
|
||||||
|
// uses one of the generic upper-bound words ("maximum", "upper bound",
|
||||||
|
// "cap", "overflow", "too large", "excessively large", "unbounded") or
|
||||||
|
// states a concrete numeric bound expression instead (CC3).
|
||||||
|
func secQuantityUpperBoundEval() eval.Evaluator {
|
||||||
|
words := eval.ContainsAny("maximum", "upper bound", "cap", "overflow", "too large", "excessively large", "unbounded")
|
||||||
|
return eval.EvaluatorFunc(func(ctx context.Context, response string) eval.Score {
|
||||||
|
if w := words.Evaluate(ctx, response); w.Value == 1 {
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
if secQuantityUpperBoundConcretePattern.MatchString(response) {
|
||||||
|
return eval.Score{Value: 1, Detail: "matches a concrete numeric upper-bound expression"}
|
||||||
|
}
|
||||||
|
return eval.Score{Value: 0, Detail: "no upper-bound cue (generic word or concrete numeric bound) found"}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// secInputValidationBoundaryTest: name the two numeric boundary checks
|
// secInputValidationBoundaryTest: name the two numeric boundary checks
|
||||||
// missing from an unvalidated order-quantity field.
|
// missing from an unvalidated order-quantity field.
|
||||||
//
|
//
|
||||||
@@ -371,9 +398,15 @@ validation runs on it.
|
|||||||
Name the two numeric boundary checks that must be added to quantity before
|
Name the two numeric boundary checks that must be added to quantity before
|
||||||
it is used, and why each one matters.`
|
it is used, and why each one matters.`
|
||||||
|
|
||||||
|
// CC3: the upper-bound group's plain words ("cap", "overflow") are
|
||||||
|
// generic enough to plausibly appear in unrelated prose. A response
|
||||||
|
// that instead states a concrete numeric bound (a min/max pair, or an
|
||||||
|
// explicit range like "1..10000") is an equally valid, even more
|
||||||
|
// specific answer, so it is accepted as an alternative alongside the
|
||||||
|
// existing word list rather than only via those words.
|
||||||
evaluator := eval.Mean(
|
evaluator := eval.Mean(
|
||||||
eval.ContainsAny("negative", "<= 0", "zero or negative", "greater than zero", "greater than 0", "positive"),
|
eval.ContainsAny("negative", "<= 0", "zero or negative", "greater than zero", "greater than 0", "positive"),
|
||||||
eval.ContainsAny("maximum", "upper bound", "cap", "overflow", "too large", "excessively large", "unbounded"),
|
secQuantityUpperBoundEval(),
|
||||||
)
|
)
|
||||||
|
|
||||||
return testkit.Test{
|
return testkit.Test{
|
||||||
|
|||||||
@@ -230,6 +230,14 @@ func TestSecInputValidationBoundaryTest_Eval(t *testing.T) {
|
|||||||
response: "Reject negative quantities.",
|
response: "Reject negative quantities.",
|
||||||
want: 0.5,
|
want: 0.5,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// CC3 bug probe: a concrete numeric range is an equally valid,
|
||||||
|
// even more specific upper-bound answer, even without any of
|
||||||
|
// the generic upper-bound words.
|
||||||
|
name: "correct: concrete numeric range instead of generic upper-bound words (CC3 bug probe)",
|
||||||
|
response: "Reject quantity <= 0, and only accept a range of 1..10000.",
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package tests
|
package tests
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
"github.com/lukaszraczylo/llm-testbench/internal/eval"
|
||||||
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
|
||||||
@@ -25,6 +28,27 @@ func registerSecurityCryptoTests(r *testkit.Registry) {
|
|||||||
// directly for password storage because they lack a tunable work factor.
|
// directly for password storage because they lack a tunable work factor.
|
||||||
var secWeakPasswordHashPattern = regexp.MustCompile(`(?i)\b(md5|sha-?1|sha-?256|sha-?512)\b`)
|
var secWeakPasswordHashPattern = regexp.MustCompile(`(?i)\b(md5|sha-?1|sha-?256|sha-?512)\b`)
|
||||||
|
|
||||||
|
// secWeakHashNamedAndNegated scores full credit only when the response BOTH
|
||||||
|
// safely negates every mention of a weak/fast general-purpose hash (or
|
||||||
|
// never mentions one) AND actually names at least one of them by name
|
||||||
|
// (md5/sha-1/sha-256/sha-512) - C11's fix folded into a single AND rather
|
||||||
|
// than an independent extra ContainsAny term, so a wrong answer that
|
||||||
|
// recommends SHA-256 outright cannot earn partial credit merely for having
|
||||||
|
// typed "SHA-256" while doing so.
|
||||||
|
func secWeakHashNamedAndNegated() eval.Evaluator {
|
||||||
|
named := eval.ContainsAny("md5", "sha-1", "sha1", "sha-256", "sha256")
|
||||||
|
negated := secNoUnnegatedMention(secWeakPasswordHashPattern)
|
||||||
|
return eval.EvaluatorFunc(func(ctx context.Context, response string) eval.Score {
|
||||||
|
if n := negated.Evaluate(ctx, response); n.Value != 1 {
|
||||||
|
return eval.Score{Value: 0, Detail: "unnegated mention of a discouraged hash: " + n.Detail}
|
||||||
|
}
|
||||||
|
if m := named.Evaluate(ctx, response); m.Value != 1 {
|
||||||
|
return eval.Score{Value: 0, Detail: "never names a weak hash by name"}
|
||||||
|
}
|
||||||
|
return eval.Score{Value: 1, Detail: "a weak hash is named and every mention is safely negated"}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// secPasswordHashChoiceTest: recommend bcrypt/argon2 for password storage
|
// secPasswordHashChoiceTest: recommend bcrypt/argon2 for password storage
|
||||||
// while not being penalized for correctly warning against SHA/MD5.
|
// while not being penalized for correctly warning against SHA/MD5.
|
||||||
//
|
//
|
||||||
@@ -40,9 +64,19 @@ func secPasswordHashChoiceTest() testkit.Test {
|
|||||||
hashing approach should be used to store user passwords at rest, and which
|
hashing approach should be used to store user passwords at rest, and which
|
||||||
common hash functions must never be used for this specific purpose?`
|
common hash functions must never be used for this specific purpose?`
|
||||||
|
|
||||||
|
// C11: secNoUnnegatedMention alone scores full credit for a response
|
||||||
|
// that never names any weak hash at all ("no mention" is vacuously
|
||||||
|
// negation-safe), which rewards skipping the prompt's second half
|
||||||
|
// entirely ("which common hash functions must never be used"). A
|
||||||
|
// combined (not independent) second term additionally requires the
|
||||||
|
// response to actually name at least one weak hash BY NAME while ALSO
|
||||||
|
// safely negating it - folded into one AND, not a bare extra
|
||||||
|
// ContainsAny term, since an independent term would give partial credit
|
||||||
|
// to a wrong answer merely for mentioning "SHA-256" while recommending
|
||||||
|
// it (as opposed to warning against it).
|
||||||
evaluator := eval.Mean(
|
evaluator := eval.Mean(
|
||||||
eval.ContainsAny("bcrypt", "argon2"),
|
eval.ContainsAny("bcrypt", "argon2"),
|
||||||
secNoUnnegatedMention(secWeakPasswordHashPattern),
|
secWeakHashNamedAndNegated(),
|
||||||
)
|
)
|
||||||
|
|
||||||
return testkit.Test{
|
return testkit.Test{
|
||||||
@@ -73,9 +107,14 @@ using Go's == operator on the two byte slices.
|
|||||||
Name the security defect this introduces, and the correct Go API to use
|
Name the security defect this introduces, and the correct Go API to use
|
||||||
instead of ==.`
|
instead of ==.`
|
||||||
|
|
||||||
|
// CC1: "constant-time"/"constant time" describes the FIX (a
|
||||||
|
// constant-time comparison), not the defect itself, so it belongs in
|
||||||
|
// the fix group rather than the defect-naming group - a response that
|
||||||
|
// only ever says "constant-time" never actually names the timing-attack
|
||||||
|
// defect the prompt asks for.
|
||||||
evaluator := eval.Mean(
|
evaluator := eval.Mean(
|
||||||
eval.ContainsAny("timing attack", "timing side channel", "time-based", "constant-time", "constant time"),
|
eval.ContainsAny("timing attack", "timing side channel", "time-based"),
|
||||||
eval.ContainsAny("hmac.equal", "subtle.constanttimecompare", "constanttimecompare"),
|
eval.ContainsAny("hmac.equal", "subtle.constanttimecompare", "constanttimecompare", "constant-time", "constant time"),
|
||||||
)
|
)
|
||||||
|
|
||||||
return testkit.Test{
|
return testkit.Test{
|
||||||
@@ -122,9 +161,35 @@ Respond with only a JSON object:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// secTLSFloorPattern requires the digits 1.2 as a standalone version
|
// secTLSFloorPrefixPattern strips an optional "TLS"/"TLSv" prefix (with or
|
||||||
// number, tolerant of a leading "TLS"/"TLSv" and surrounding punctuation.
|
// without a separating space) from the front of the normalized response
|
||||||
const secTLSFloorPattern = `\b1\.2\b`
|
// before comparing what remains to the exact literal "1.2" (C1).
|
||||||
|
//
|
||||||
|
// A plain `\b1\.2\b` substring search had two independent failure modes:
|
||||||
|
// "TLSv1.2" (no space) has no word boundary between "v" and "1" - both are
|
||||||
|
// \w characters - so it never matched an otherwise-correct compact answer;
|
||||||
|
// and a longer response like "TLS 1.3 only; 1.2 is legacy" contains "1.2"
|
||||||
|
// as a substring (inside the "legacy" clause) and was wrongly accepted even
|
||||||
|
// though 1.3, not 1.2, was the actual chosen floor. Stripping the prefix
|
||||||
|
// and then requiring the ENTIRE normalized response to equal "1.2" fixes
|
||||||
|
// both: "TLSv1.2" reduces to "1.2" and matches, while the longer sentence
|
||||||
|
// never reduces to the bare literal and correctly fails.
|
||||||
|
var secTLSFloorPrefixPattern = regexp.MustCompile(`(?i)^tlsv?\s*`)
|
||||||
|
|
||||||
|
// secTLSFloorVersionEval normalizes the response (fences/quotes/asterisks/
|
||||||
|
// trailing period stripped via eval.NormalizeExactToken), strips a leading
|
||||||
|
// TLS/TLSv prefix, and requires the remaining text to equal exactly "1.2".
|
||||||
|
func secTLSFloorVersionEval() eval.Evaluator {
|
||||||
|
return eval.EvaluatorFunc(func(_ context.Context, response string) eval.Score {
|
||||||
|
got := eval.NormalizeExactToken(response)
|
||||||
|
got = secTLSFloorPrefixPattern.ReplaceAllString(got, "")
|
||||||
|
got = strings.TrimSpace(got)
|
||||||
|
if got == "1.2" {
|
||||||
|
return eval.Score{Value: 1, Detail: "equals 1.2"}
|
||||||
|
}
|
||||||
|
return eval.Score{Value: 0, Detail: fmt.Sprintf("got %q, want \"1.2\"", got)}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// secTLSFloorVersionTest: name the minimum TLS protocol version that
|
// secTLSFloorVersionTest: name the minimum TLS protocol version that
|
||||||
// excludes SSLv3/TLS 1.0/1.1 while remaining broadly compatible.
|
// excludes SSLv3/TLS 1.0/1.1 while remaining broadly compatible.
|
||||||
@@ -148,7 +213,7 @@ to accept? Respond with only the version number, e.g. 1.2.`
|
|||||||
Subcategory: "crypto",
|
Subcategory: "crypto",
|
||||||
Description: "Name TLS 1.2 as the minimum floor that excludes SSLv3/TLS 1.0/1.1 while staying broadly compatible.",
|
Description: "Name TLS 1.2 as the minimum floor that excludes SSLv3/TLS 1.0/1.1 while staying broadly compatible.",
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
Eval: eval.Regex(secTLSFloorPattern),
|
Eval: secTLSFloorVersionEval(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,8 +235,13 @@ the API.
|
|||||||
What is the concrete security consequence of reusing a GCM nonce with the
|
What is the concrete security consequence of reusing a GCM nonce with the
|
||||||
same key, and what is the fix?`
|
same key, and what is the fix?`
|
||||||
|
|
||||||
|
// CC2: bare "authentication" is too generic - a response could mention
|
||||||
|
// authentication in some unrelated sense and get credit without
|
||||||
|
// actually describing the nonce-reuse consequence. Tightened to the
|
||||||
|
// specific consequence terms: forge/forgery, keystream, or the GCM
|
||||||
|
// "auth tag" reuse mechanism itself.
|
||||||
evaluator := eval.Mean(
|
evaluator := eval.Mean(
|
||||||
eval.ContainsAny("forge", "forgery", "authentication", "keystream", "xor", "plaintext recovery", "break confidentiality"),
|
eval.ContainsAny("forge", "forgery", "auth tag", "authentication tag", "keystream", "xor", "plaintext recovery", "break confidentiality"),
|
||||||
eval.ContainsAny("random nonce", "unique nonce", "fresh nonce", "never reuse", "crypto/rand"),
|
eval.ContainsAny("random nonce", "unique nonce", "fresh nonce", "never reuse", "crypto/rand"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -204,9 +274,8 @@ math/rand to produce 32 random bytes, then hex-encodes them for the reset
|
|||||||
link.
|
link.
|
||||||
|
|
||||||
Which Go standard-library package should be used instead of math/rand for
|
Which Go standard-library package should be used instead of math/rand for
|
||||||
this purpose, and confirm: math/rand must never be used to generate a
|
this purpose? Explain the one concrete risk of using math/rand for a
|
||||||
security-sensitive token like this one. Explain the one concrete risk of
|
security-sensitive token like this one.`
|
||||||
the current approach.`
|
|
||||||
|
|
||||||
evaluator := eval.Mean(
|
evaluator := eval.Mean(
|
||||||
eval.ContainsAny("crypto/rand"),
|
eval.ContainsAny("crypto/rand"),
|
||||||
@@ -249,8 +318,8 @@ issued under the old key and is still within its validity window, and it
|
|||||||
must never allow a window where a still-valid token fails verification.
|
must never allow a window where a still-valid token fails verification.
|
||||||
|
|
||||||
Order these 4 steps correctly:
|
Order these 4 steps correctly:
|
||||||
["add-new-key-to-verify-set", "start-signing-with-new-key",
|
["remove-old-key-from-verify-set", "add-new-key-to-verify-set",
|
||||||
"wait-for-old-tokens-to-expire", "remove-old-key-from-verify-set"]
|
"wait-for-old-tokens-to-expire", "start-signing-with-new-key"]
|
||||||
|
|
||||||
Respond with only a JSON array containing all 4 step ids in the correct
|
Respond with only a JSON array containing all 4 step ids in the correct
|
||||||
order.`
|
order.`
|
||||||
@@ -290,13 +359,18 @@ Should they use a shared-secret HMAC or an asymmetric signature scheme
|
|||||||
Subcategory: "crypto",
|
Subcategory: "crypto",
|
||||||
Description: "Choose asymmetric signing over shared-secret HMAC for cross-organization webhook verification.",
|
Description: "Choose asymmetric signing over shared-secret HMAC for cross-organization webhook verification.",
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
Eval: secExactAnswer("asymmetric"),
|
Eval: eval.ExactToken("asymmetric"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// secManInTheMiddlePattern accepts the standard term for the attack with
|
// secManInTheMiddlePattern accepts every standard name for this attack
|
||||||
// or without hyphens/spaces between the words.
|
// class (C6): the classic "man-in-the-middle" phrase (hyphenated, spaced,
|
||||||
const secManInTheMiddlePattern = `(?i)man[\s-]?in[\s-]?the[\s-]?middle`
|
// or run together), its "MITM" abbreviation, the newer gender-neutral
|
||||||
|
// "adversary-in-the-middle"/"machine-in-the-middle"/"person-in-the-middle"
|
||||||
|
// phrasings and their "AiTM" abbreviation, and "on-path" (the more recent
|
||||||
|
// vendor-neutral term some standards bodies now prefer for the same
|
||||||
|
// network position).
|
||||||
|
const secManInTheMiddlePattern = `(?i)\bmitm\b|\baitm\b|on[\s-]?path\b|(?:man|machine|adversary|person)[\s-]?in[\s-]?the[\s-]?middle`
|
||||||
|
|
||||||
// secCertChainValidationTest: name man-in-the-middle as the attack exposed
|
// secCertChainValidationTest: name man-in-the-middle as the attack exposed
|
||||||
// by skipping certificate-chain verification.
|
// by skipping certificate-chain verification.
|
||||||
|
|||||||
@@ -38,6 +38,21 @@ func TestSecPasswordHashChoiceTest_Eval(t *testing.T) {
|
|||||||
response: "Store passwords hashed with MD5 for speed.",
|
response: "Store passwords hashed with MD5 for speed.",
|
||||||
want: 0,
|
want: 0,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// C11 bug probe: naming a weak hash while recommending it
|
||||||
|
// (not warning against it) must not earn any credit for
|
||||||
|
// "naming" the discouraged term - the naming credit is folded
|
||||||
|
// into the same AND as the negation check, not an independent
|
||||||
|
// term.
|
||||||
|
name: "wrong: names SHA-256 by name but still recommends it (C11 bug probe)",
|
||||||
|
response: "SHA-256 is a fine choice for hashing user passwords.",
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong: warns against a hash never actually named by name (C11 bug probe)",
|
||||||
|
response: "Use bcrypt, and avoid weak, fast, general-purpose hashing algorithms for passwords.",
|
||||||
|
want: 0.5,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -68,8 +83,18 @@ func TestSecConstantTimeCompareTest_Eval(t *testing.T) {
|
|||||||
want: 1,
|
want: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "correct: constant-time phrasing, ConstantTimeCompare",
|
// CC1: "constant-time" describes the FIX, not the defect - a
|
||||||
|
// response that only ever says "constant-time" (never "timing
|
||||||
|
// attack"/"timing side channel"/"time-based") does not
|
||||||
|
// independently name the defect class the prompt asks for, so
|
||||||
|
// this now earns only the fix-group's credit, not both.
|
||||||
|
name: "partial: only fix-side 'constant-time' phrasing, never names the defect class (CC1)",
|
||||||
response: "Comparing with == leaks timing information (constant-time is required). Use crypto/subtle's ConstantTimeCompare function.",
|
response: "Comparing with == leaks timing information (constant-time is required). Use crypto/subtle's ConstantTimeCompare function.",
|
||||||
|
want: 0.5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "correct: names the defect AND uses 'constant-time' for the fix (CC1 bug probe)",
|
||||||
|
response: "This is a timing attack. Fix it by doing a constant-time comparison via crypto/subtle.",
|
||||||
want: 1,
|
want: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -127,7 +152,36 @@ func TestSecTLSFloorVersionTest_Eval(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{name: "correct bare", response: "1.2", want: 1},
|
{name: "correct bare", response: "1.2", want: 1},
|
||||||
{name: "correct with TLS prefix", response: "TLS 1.2", want: 1},
|
{name: "correct with TLS prefix", response: "TLS 1.2", want: 1},
|
||||||
{name: "correct in a sentence", response: "The minimum should be 1.2.", want: 1},
|
{name: "correct with trailing period", response: "1.2.", want: 1},
|
||||||
|
{
|
||||||
|
// C1: "TLSv1.2" (no space) has no word boundary between "v"
|
||||||
|
// and "1" - a plain \b1\.2\b regex rejected it. The
|
||||||
|
// normalize-then-strip-prefix design correctly accepts it.
|
||||||
|
name: "correct: TLSv prefix with no space (C1 bug probe)",
|
||||||
|
response: "TLSv1.2",
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// This response is no longer accepted (C1): the prompt asks to
|
||||||
|
// "respond with only the version number", and a full
|
||||||
|
// sentence-wrapped answer is the same category of over-permissive
|
||||||
|
// substring match that let "TLS 1.3 only; 1.2 is legacy" (below)
|
||||||
|
// wrongly score 1 under the old \b1\.2\b substring search -
|
||||||
|
// consistent with how every other eval.ExactToken-style forced
|
||||||
|
// single-token test in this codebase treats full-sentence
|
||||||
|
// wrapping.
|
||||||
|
name: "wrong: sentence-wrapped, not a bare/quoted/fenced token",
|
||||||
|
response: "The minimum should be 1.2.",
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// C1 bug probe: the actual chosen floor is 1.3, but the old
|
||||||
|
// substring search matched "1.2" anyway because it appears
|
||||||
|
// later in the same response, in an unrelated aside.
|
||||||
|
name: "wrong: chooses 1.3 but mentions 1.2 in an aside (C1 bug probe)",
|
||||||
|
response: "TLS 1.3 only; 1.2 is legacy.",
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
{name: "wrong: TLS 1.3", response: "1.3", want: 0},
|
{name: "wrong: TLS 1.3", response: "1.3", want: 0},
|
||||||
{name: "wrong: TLS 1.0", response: "1.0", want: 0},
|
{name: "wrong: TLS 1.0", response: "1.0", want: 0},
|
||||||
}
|
}
|
||||||
@@ -174,6 +228,19 @@ func TestSecAESGCMNonceReuseTest_Eval(t *testing.T) {
|
|||||||
response: "Just use a bigger nonce size.",
|
response: "Just use a bigger nonce size.",
|
||||||
want: 0,
|
want: 0,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// CC2 bug probe: bare "authentication" (with no forge/forgery/
|
||||||
|
// keystream/auth-tag consequence term) no longer triggers the
|
||||||
|
// defect-naming group on its own.
|
||||||
|
name: "wrong: bare 'authentication' mention with no actual consequence term (CC2 bug probe)",
|
||||||
|
response: "This affects authentication somehow. Use a fresh random nonce per encryption.",
|
||||||
|
want: 0.5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "correct: specific 'auth tag' consequence term (CC2 bug probe)",
|
||||||
|
response: "The auth tag can be forged if the nonce repeats. Use a fresh random nonce per encryption.",
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -262,6 +329,14 @@ func TestSecRotateOrderTest_Eval(t *testing.T) {
|
|||||||
response: `["add-new-key-to-verify-set","remove-old-key-from-verify-set","start-signing-with-new-key","wait-for-old-tokens-to-expire"]`,
|
response: `["add-new-key-to-verify-set","remove-old-key-from-verify-set","start-signing-with-new-key","wait-for-old-tokens-to-expire"]`,
|
||||||
want: 0,
|
want: 0,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// C4 bug probe: the prompt's presented list is now
|
||||||
|
// hand-shuffled to a different order than the correct answer,
|
||||||
|
// so an echo of the prompt's own list order must score 0, not 1.
|
||||||
|
name: "wrong: echoes the prompt's shuffled presentation order verbatim (C4 bug probe)",
|
||||||
|
response: `["remove-old-key-from-verify-set","add-new-key-to-verify-set","wait-for-old-tokens-to-expire","start-signing-with-new-key"]`,
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -308,6 +383,11 @@ func TestSecCertChainValidationTest_Eval(t *testing.T) {
|
|||||||
{name: "correct hyphenated", response: "man-in-the-middle", want: 1},
|
{name: "correct hyphenated", response: "man-in-the-middle", want: 1},
|
||||||
{name: "correct spaced in a sentence", response: "This exposes the client to a man in the middle attack.", want: 1},
|
{name: "correct spaced in a sentence", response: "This exposes the client to a man in the middle attack.", want: 1},
|
||||||
{name: "correct capitalized", response: "MITM stands for Man-In-The-Middle, which is the attack here.", want: 1},
|
{name: "correct capitalized", response: "MITM stands for Man-In-The-Middle, which is the attack here.", want: 1},
|
||||||
|
{name: "correct: bare MITM abbreviation (C6 bug probe)", response: "MITM", want: 1},
|
||||||
|
{name: "correct: AiTM abbreviation (C6 bug probe)", response: "AiTM (adversary-in-the-middle)", want: 1},
|
||||||
|
{name: "correct: on-path attacker (C6 bug probe)", response: "This is an on-path attacker.", want: 1},
|
||||||
|
{name: "correct: adversary-in-the-middle phrasing (C6 bug probe)", response: "adversary-in-the-middle", want: 1},
|
||||||
|
{name: "correct: machine-in-the-middle phrasing (C6 bug probe)", response: "machine-in-the-middle", want: 1},
|
||||||
{name: "wrong: replay attack", response: "replay attack", want: 0},
|
{name: "wrong: replay attack", response: "replay attack", want: 0},
|
||||||
{name: "wrong: denial of service", response: "denial of service", want: 0},
|
{name: "wrong: denial of service", response: "denial of service", want: 0},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ func secRemediationOrderTest() testkit.Test {
|
|||||||
to a shared remote that multiple collaborators have cloned.
|
to a shared remote that multiple collaborators have cloned.
|
||||||
|
|
||||||
Order these 4 remediation steps correctly:
|
Order these 4 remediation steps correctly:
|
||||||
["rotate-the-leaked-credential", "remove-secret-from-current-code",
|
["force-push-and-notify-collaborators", "remove-secret-from-current-code",
|
||||||
"rewrite-git-history-to-purge-it", "force-push-and-notify-collaborators"]
|
"rotate-the-leaked-credential", "rewrite-git-history-to-purge-it"]
|
||||||
|
|
||||||
Respond with only a JSON array containing all 4 step ids in the correct
|
Respond with only a JSON array containing all 4 step ids in the correct
|
||||||
order.`
|
order.`
|
||||||
@@ -158,7 +158,7 @@ with only one word: yes or no.`
|
|||||||
Subcategory: "secrets",
|
Subcategory: "secrets",
|
||||||
Description: "Confirm that Kubernetes Secret base64 encoding is not encryption and provides no confidentiality by itself.",
|
Description: "Confirm that Kubernetes Secret base64 encoding is not encryption and provides no confidentiality by itself.",
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
Eval: secExactAnswer("no"),
|
Eval: eval.ExactToken("no"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,9 +189,9 @@ replicas must be rotated without causing any request to fail
|
|||||||
authentication during the rollout.
|
authentication during the rollout.
|
||||||
|
|
||||||
Order these 4 steps correctly:
|
Order these 4 steps correctly:
|
||||||
["create-new-credential-alongside-old",
|
["verify-all-replicas-using-new-credential", "revoke-old-credential",
|
||||||
"deploy-config-with-new-credential-to-all-replicas",
|
"create-new-credential-alongside-old",
|
||||||
"verify-all-replicas-using-new-credential", "revoke-old-credential"]
|
"deploy-config-with-new-credential-to-all-replicas"]
|
||||||
|
|
||||||
Respond with only a JSON array containing all 4 step ids in the correct
|
Respond with only a JSON array containing all 4 step ids in the correct
|
||||||
order.`
|
order.`
|
||||||
@@ -300,7 +300,7 @@ request from a fork? Respond with only one word: yes or no.`
|
|||||||
Subcategory: "secrets",
|
Subcategory: "secrets",
|
||||||
Description: "Confirm repository secrets must not be exposed to CI workflow runs triggered by pull requests from forks.",
|
Description: "Confirm repository secrets must not be exposed to CI workflow runs triggered by pull requests from forks.",
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
Eval: secExactAnswer("no"),
|
Eval: eval.ExactToken("no"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,6 +364,6 @@ current working tree? Respond with only one word: yes or no.`
|
|||||||
Subcategory: "secrets",
|
Subcategory: "secrets",
|
||||||
Description: "Confirm a later .gitignore entry and working-tree deletion do not remove a secret already present in earlier git history.",
|
Description: "Confirm a later .gitignore entry and working-tree deletion do not remove a secret already present in earlier git history.",
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
Eval: secExactAnswer("yes"),
|
Eval: eval.ExactToken("yes"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,14 @@ func TestSecRemediationOrderTest_Eval(t *testing.T) {
|
|||||||
response: `["rewrite-git-history-to-purge-it","rotate-the-leaked-credential","remove-secret-from-current-code","force-push-and-notify-collaborators"]`,
|
response: `["rewrite-git-history-to-purge-it","rotate-the-leaked-credential","remove-secret-from-current-code","force-push-and-notify-collaborators"]`,
|
||||||
want: 0,
|
want: 0,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// C4 bug probe: the prompt's presented list is hand-shuffled to
|
||||||
|
// a different order than the correct answer, so echoing the
|
||||||
|
// prompt's own list order must score 0.
|
||||||
|
name: "wrong: echoes the prompt's shuffled presentation order verbatim (C4 bug probe)",
|
||||||
|
response: `["force-push-and-notify-collaborators","remove-secret-from-current-code","rotate-the-leaked-credential","rewrite-git-history-to-purge-it"]`,
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -154,6 +162,14 @@ func TestSecRotationNoDowntimeTest_Eval(t *testing.T) {
|
|||||||
response: `["create-new-credential-alongside-old","revoke-old-credential","deploy-config-with-new-credential-to-all-replicas","verify-all-replicas-using-new-credential"]`,
|
response: `["create-new-credential-alongside-old","revoke-old-credential","deploy-config-with-new-credential-to-all-replicas","verify-all-replicas-using-new-credential"]`,
|
||||||
want: 0,
|
want: 0,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// C4 bug probe: the prompt's presented list is hand-shuffled to
|
||||||
|
// a different order than the correct answer, so echoing the
|
||||||
|
// prompt's own list order must score 0.
|
||||||
|
name: "wrong: echoes the prompt's shuffled presentation order verbatim (C4 bug probe)",
|
||||||
|
response: `["verify-all-replicas-using-new-credential","revoke-old-credential","create-new-credential-alongside-old","deploy-config-with-new-credential-to-all-replicas"]`,
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ func TestRegisterSecurityTests_Wiring(t *testing.T) {
|
|||||||
if tc.Description == "" {
|
if tc.Description == "" {
|
||||||
t.Errorf("test %q: Description is empty", tc.ID)
|
t.Errorf("test %q: Description is empty", tc.ID)
|
||||||
}
|
}
|
||||||
if tc.MaxTokens < 0 {
|
if tc.MaxTokens != 0 {
|
||||||
t.Errorf("test %q: MaxTokens = %d, want >= 0", tc.ID, tc.MaxTokens)
|
t.Errorf("test %q: MaxTokens = %d, want 0 (omitted, per round-2 rule 7)", tc.ID, tc.MaxTokens)
|
||||||
}
|
}
|
||||||
counts[tc.Subcategory]++
|
counts[tc.Subcategory]++
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user