mirror of
https://github.com/lukaszraczylo/llm-testbench.git
synced 2026-10-12 00:45:19 +00:00
Replay of the 2026-08-30 3-model artifact showed the same correct
answers zeroed for every model. Four mechanical causes, each with a
regression fixture:
- A dot glued to a following non-space character ("e.g.,",
"release/2.4") cut the clause window between a match and its cue.
A dot now ends a clause only before whitespace, and common
abbreviations (e.g., i.e., etc., vs.) never do.
- Markdown emphasis and code fences inflated cue distance past the
window cap, and fence-adjacent blank lines created fake paragraph
gaps. The scanner now strips backticks and double asterisks and
collapses layout gaps around fences before matching.
- A single cue did not distribute over a comma enumeration ("not ...
kubectl edit, kubectl patch"). An unnegated match now inherits the
negation of an earlier negated match in the same clause.
- Consequence descriptions ("deterministic", "silently", "will
replace", "a bare") were not recognized as cues.
Replayed against the saved artifact: 7 of 8 false positives now score
full credit; the eighth stays partial on purpose (that response does
endorse a temporary live edit).
249 lines
11 KiB
Go
249 lines
11 KiB
Go
package eval
|
|
|
|
import (
|
|
"context"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// 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|` +
|
|
// Consequence-description cues: a good answer often names the discouraged
|
|
// term while describing the harm it causes, with no imperative or
|
|
// adjectival cue at all - "a bare --force will silently overwrite their
|
|
// work", "math/rand is a deterministic PRNG that relies on a seed"
|
|
// (observed as all-model false positives in the 2026-08-30 3-model run).
|
|
`overwrit\w*|clobber\w*|silently|discard\w*|deterministic|seeded|guessable|reproducible|` +
|
|
`lose|loses|losing|lost|` +
|
|
// Modal harm phrases: "a bare --force will replace the remote branch",
|
|
// "would replace ... your rewritten history". The modal keeps these
|
|
// narrow - a bare "replaces"/"rewritten" also appears in genuine
|
|
// endorsements ("make sure your rewritten history replaces theirs") and
|
|
// must not count as a cue.
|
|
`will replace|would replace|a bare`
|
|
|
|
// 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`)
|
|
|
|
// isSentenceDot reports whether response[i] is a sentence-ending '.': a dot
|
|
// followed by whitespace or end-of-string that does not terminate a common
|
|
// abbreviation ("e.g.", "i.e.", "etc.", "vs."). A dot glued to a following
|
|
// non-space character ("e.g.,", "release/2.4") is part of a token, never a
|
|
// clause boundary.
|
|
func isSentenceDot(response string, i int) bool {
|
|
if response[i] != '.' {
|
|
return false
|
|
}
|
|
if i+1 < len(response) && response[i+1] != ' ' && response[i+1] != '\n' && response[i+1] != '\t' {
|
|
return false
|
|
}
|
|
for _, abbr := range []string{"e.g", "i.e", "etc", "vs"} {
|
|
if i >= len(abbr) && strings.EqualFold(response[i-len(abbr):i], abbr) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// 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 sentence-ending '.', 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).
|
|
//
|
|
// A '.' counts as a clause boundary only when followed by whitespace or
|
|
// end-of-string. A dot glued to a following non-space character is part of
|
|
// a token, not a sentence end: the dots inside "e.g.," and a version
|
|
// number like "release/2.4" were observed cutting the window between a
|
|
// match and its genuine negation cue ("Do not edit ... (e.g., `kubectl
|
|
// edit`)" and "git push --force origin release/2.4\n``` should never be
|
|
// used") in the 2026-08-30 3-model run, zeroing correct answers from all
|
|
// three models.
|
|
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 isSentenceDot(response, i) || 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 isSentenceDot(response, i) || c == ';' {
|
|
hi = i
|
|
break
|
|
}
|
|
if c == '\n' && i+1 < len(response) && response[i+1] == '\n' {
|
|
hi = i
|
|
break
|
|
}
|
|
}
|
|
return lo, hi
|
|
}
|
|
|
|
// sameClause reports whether response[from:to] contains no clause boundary
|
|
// (sentence-ending dot, ';', or blank-line paragraph gap), i.e. the two
|
|
// offsets sit inside one clause. Used for enumeration inheritance in
|
|
// NoUnnegatedMention; deliberately has no maxChars cap - a clause is a
|
|
// clause however long its comma list runs.
|
|
func sameClause(response string, from, to int) bool {
|
|
for i := from; i < to; i++ {
|
|
c := response[i]
|
|
if isSentenceDot(response, i) || c == ';' {
|
|
return false
|
|
}
|
|
if c == '\n' && i+1 < to && response[i+1] == '\n' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// 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).
|
|
// stripMarkdownEmphasis removes backticks and double-asterisk bold markers
|
|
// before negation scanning. Both are semantically empty but inflate the
|
|
// character distance between a cue and its match past the window cap:
|
|
// "you should **not** fix this ... with `kubectl edit`, `kubectl patch`"
|
|
// zeroed correct answers from all three models in the 2026-08-30 run.
|
|
// Single asterisks are kept - they can be literal command text ("KEYS *").
|
|
func stripMarkdownEmphasis(s string) string {
|
|
// Blank lines hugging a code fence are display layout, not a paragraph
|
|
// break: "A bare:\n\n```bash\ngit push --force ...\n```\n\nshould never
|
|
// be used" is one sentence split around a display block, and the gap
|
|
// must not cut the clause window between the fenced example and the
|
|
// prose negating it (observed false positive, 2026-08-30 run).
|
|
s = fenceLeadingGapPattern.ReplaceAllString(s, "\n```")
|
|
s = fenceTrailingGapPattern.ReplaceAllString(s, "```$1\n")
|
|
// A code-fence line ("```" or "```bash") must not become an empty line
|
|
// either - same fake-paragraph-gap failure mode.
|
|
s = strings.ReplaceAll(s, "```", " ")
|
|
s = strings.ReplaceAll(s, "**", "")
|
|
return strings.ReplaceAll(s, "`", "")
|
|
}
|
|
|
|
// fenceLeadingGapPattern and fenceTrailingGapPattern match the blank lines
|
|
// immediately before an opening code fence and after a closing one.
|
|
var (
|
|
fenceLeadingGapPattern = regexp.MustCompile(`\n[ \t]*\n+(?:[ \t]*\n)*` + "```")
|
|
fenceTrailingGapPattern = regexp.MustCompile("```" + `([a-zA-Z0-9]*)\n[ \t]*\n+(?:[ \t]*\n)*`)
|
|
)
|
|
|
|
func NoUnnegatedMention(badPattern *regexp.Regexp, maxChars int, exempt *regexp.Regexp) Evaluator {
|
|
return EvaluatorFunc(func(_ context.Context, response string) Score {
|
|
response = stripMarkdownEmphasis(response)
|
|
matches := badPattern.FindAllStringIndex(response, -1)
|
|
if len(matches) == 0 {
|
|
return Score{Value: 1, Detail: "no mention of a discouraged term"}
|
|
}
|
|
// prevNegatedEnd is the end offset of the most recent match that was
|
|
// found negated; -1 when there is none. A later match with no cue of
|
|
// its own inherits that negation when no sentence boundary separates
|
|
// the two - a single cue distributes over a comma enumeration of the
|
|
// same discouraged family ("not ... with kubectl edit, kubectl
|
|
// patch, or kubectl scale"), where each successive item drifts
|
|
// further from the shared cue than maxChars allows (observed as
|
|
// all-model false positives in the 2026-08-30 3-model run).
|
|
prevNegatedEnd := -1
|
|
for _, loc := range matches {
|
|
start, end := loc[0], loc[1]
|
|
if exempt != nil && exempt.MatchString(response[end:]) {
|
|
continue
|
|
}
|
|
if negationCueNearby(response, start, end, maxChars) {
|
|
prevNegatedEnd = end
|
|
continue
|
|
}
|
|
if prevNegatedEnd >= 0 && sameClause(response, prevNegatedEnd, start) {
|
|
prevNegatedEnd = end
|
|
continue
|
|
}
|
|
return Score{Value: 0, Detail: "unnegated mention of a discouraged term"}
|
|
}
|
|
return Score{Value: 1, Detail: "every mention of a discouraged term is negated"}
|
|
})
|
|
}
|