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
|
||||
case bool:
|
||||
b, ok := v.(bool)
|
||||
if !ok {
|
||||
return zero, fmt.Errorf("expected bool, got %T (%v)", v, v)
|
||||
b, err := asBool(v)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
return any(b).(T), nil
|
||||
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
|
||||
// (see ExtractJSON: the first ```json fenced block if present, otherwise
|
||||
// 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 {
|
||||
if gs, ok := any(got).(string); ok {
|
||||
ws := any(want).(string)
|
||||
return strings.EqualFold(strings.TrimSpace(gs), strings.TrimSpace(ws))
|
||||
return strings.EqualFold(enumNormalize(gs), enumNormalize(ws))
|
||||
}
|
||||
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) {
|
||||
e := JSONField("task1", "search_web")
|
||||
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.
|
||||
isMultiplier := (text[end] == 'x' || text[end] == 'X') &&
|
||||
(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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,14 @@ func TestExtractLastNumber(t *testing.T) {
|
||||
{"multiplier suffix accepted", "The compression ratio is 64x.", 64, 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},
|
||||
// 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},
|
||||
// Documented limitation, not a regression: the heuristic has no
|
||||
// notion of "the total" vs. "a breakdown component" and returns
|
||||
|
||||
Reference in New Issue
Block a user