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:
2026-08-17 03:50:57 +01:00
parent 9e5deda646
commit 9d1d4d89e9
35 changed files with 1417 additions and 453 deletions
+7 -67
View File
@@ -1,9 +1,7 @@
package tests
import (
"context"
"regexp"
"strings"
"github.com/lukaszraczylo/llm-testbench/internal/eval"
"github.com/lukaszraczylo/llm-testbench/internal/testkit"
@@ -19,80 +17,22 @@ func registerSecurityTests(r *testkit.Registry) {
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
// are searched for a negation cue.
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
// badPattern matches somewhere in the response with no negation cue nearby -
// i.e. it penalizes an unqualified endorsement of a discouraged
// algorithm/package but not a warning against using it. This is deliberately
// not eval.NotContains, which would also zero out the best possible answer
// (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 {
return eval.EvaluatorFunc(func(_ context.Context, response string) eval.Score {
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}
})
return eval.NoUnnegatedMention(badPattern, secNegationWindow, nil)
}