255 lines
10 KiB
TypeScript
255 lines
10 KiB
TypeScript
/**
|
|
* Deterministic scorers for generated answers.
|
|
*
|
|
* Every metric here is computable offline with no judge model, which is what
|
|
* makes the generation tier gateable. An LLM judge can be layered on later for
|
|
* the things regex genuinely cannot see (nuanced faithfulness, completeness),
|
|
* but nothing in this file needs one — and a number you can compute without a
|
|
* model is a number that cannot drift because the judge changed.
|
|
*
|
|
* These are intentionally *conservative*. Each one is a proxy, and each proxy's
|
|
* blind spot is documented where it lives. A proxy you understand the limits of
|
|
* beats a score you cannot explain.
|
|
*/
|
|
|
|
/** Answer-level scoring of one generated response against one golden. */
|
|
export type GenerationScores = {
|
|
/** Every mustInclude pattern matched and no mustNotInclude pattern did. */
|
|
correct: boolean
|
|
matchedRequired: string[]
|
|
missedRequired: string[]
|
|
/** Forbidden patterns that appeared. Non-empty means a specific wrong claim. */
|
|
hitForbidden: string[]
|
|
/** The model declined to answer. */
|
|
refused: boolean
|
|
/**
|
|
* Refusal behaviour was right: declined an out-of-corpus question, or
|
|
* answered an answerable one.
|
|
*/
|
|
refusalCorrect: boolean
|
|
/** The answer narrated its own retrieval ("according to Context 1"). */
|
|
leakage: string[]
|
|
/** Reasoning tags survived into the user-visible answer. */
|
|
thinkTagLeak: boolean
|
|
/**
|
|
* Fraction of the answer's numeric claims that also appear in the injected
|
|
* context. null when the answer makes no numeric claims.
|
|
*/
|
|
numericGroundedness: number | null
|
|
/** Numbers asserted by the answer that the context does not support. */
|
|
ungroundedNumbers: string[]
|
|
/** The answer used at least some markdown structure. */
|
|
markdownFormatted: boolean
|
|
/** Characters. Useful for spotting a model that answers by writing an essay. */
|
|
length: number
|
|
}
|
|
|
|
/**
|
|
* Phrases that mean "I am not answering this".
|
|
*
|
|
* Tuned against NOMAD's actual rag_context prompt, which instructs the model to
|
|
* answer from general knowledge rather than hedge — so a hedge here is either a
|
|
* correct refusal on an out-of-corpus question or a prompt regression on an
|
|
* answerable one. Which of those it is, is exactly what `refusalCorrect` says.
|
|
*/
|
|
const REFUSAL_PATTERNS: RegExp[] = [
|
|
/\bi (?:don'?t|do not) (?:know|have)\b/i,
|
|
/\bi(?:'m| am) (?:not able|unable) to\b/i,
|
|
/\bi (?:couldn'?t|could not|can'?t|cannot) find\b/i,
|
|
/\b(?:no|not enough) (?:information|details|data)\b/i,
|
|
/\b(?:does|do) not (?:contain|include|mention|specify|provide|cover)\b/i,
|
|
/\b(?:isn'?t|is not|aren'?t|are not) (?:mentioned|specified|covered|available|provided)\b/i,
|
|
/\bnot (?:mentioned|specified|covered|stated|documented)\b/i,
|
|
/\bunable to (?:answer|determine|find)\b/i,
|
|
/\bthere (?:is|'s) no (?:information|mention|record)\b/i,
|
|
// "I'd" has no space before the contraction, so this cannot reuse the
|
|
// `i (?:would|'d)` shape used above.
|
|
/\bi(?:'d| would) need more\b/i,
|
|
]
|
|
|
|
/**
|
|
* Phrases that narrate the retrieval machinery.
|
|
*
|
|
* The rag_context prompt explicitly forbids these ("Never narrate your
|
|
* retrieval or reasoning process"), so any hit is a measurable prompt
|
|
* regression. This is the cheapest, sharpest signal in the whole harness: pure
|
|
* regex, zero ambiguity, and it catches a bad prompt edit on the first run.
|
|
*/
|
|
const LEAKAGE_PATTERNS: RegExp[] = [
|
|
/\b(?:according to|based on|per|from) the (?:provided |retrieved |given |supplied )?context\b/i,
|
|
/\bcontext \d+\b/i,
|
|
/\bthe knowledge ?base\b/i,
|
|
/\bthe (?:provided|retrieved|supplied|given) (?:documents?|passages?|excerpts?|text)\b/i,
|
|
/\bthe context (?:does not|doesn'?t|is|was|seems|appears)\b/i,
|
|
/\b(?:in|from) the (?:documents?|passages?) (?:provided|above|below)\b/i,
|
|
// "I wasn't able to find specific context regarding X, but here's a general
|
|
// answer" is the verbatim symptom that started NOMAD's RAG work. It is both a
|
|
// refusal and a leak, and it is the single most important string this
|
|
// detector has to catch — a prompt change that brings it back must fail
|
|
// loudly on the very next run.
|
|
/\b(?:no|any|specific|relevant) context\b/i,
|
|
/\bsearch results?\b/i,
|
|
]
|
|
|
|
/** Reasoning-model tags that must never reach the user. */
|
|
const THINK_TAG = /<\/?(?:think|thought|thinking|reasoning)\b[^>]*>/i
|
|
|
|
export function detectRefusal(answer: string): boolean {
|
|
return REFUSAL_PATTERNS.some((re) => re.test(answer))
|
|
}
|
|
|
|
export function detectLeakage(answer: string): string[] {
|
|
return LEAKAGE_PATTERNS.filter((re) => re.test(answer)).map((re) => re.source)
|
|
}
|
|
|
|
export function hasThinkTagLeak(answer: string): boolean {
|
|
return THINK_TAG.test(answer)
|
|
}
|
|
|
|
/**
|
|
* Loose markdown check: a header, a list, emphasis, a table, or a code fence.
|
|
*
|
|
* Deliberately loose. SYSTEM_PROMPTS.default asks for markdown "for
|
|
* readability", not for a specific structure, so requiring headers would fail
|
|
* perfectly good one-sentence answers. This only catches a model that has
|
|
* stopped formatting entirely.
|
|
*/
|
|
export function isMarkdownFormatted(answer: string): boolean {
|
|
return /(^|\n)\s{0,3}#{1,6}\s|(^|\n)\s*[-*+]\s|(^|\n)\s*\d+\.\s|\*\*[^*]+\*\*|`[^`]+`|(^|\n)\s*\|/.test(
|
|
answer
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Numeric tokens the answer asserts, normalized for comparison.
|
|
*
|
|
* Numbers are the highest-value fabrication signal in this domain: a wrong
|
|
* boiling time, bleach dose, or canner pressure is a wrong answer with real
|
|
* consequences, and it is exactly the kind of specific the rag_context prompt
|
|
* forbids inventing.
|
|
*
|
|
* Thousands separators are stripped so "2,000" and "2000" compare equal.
|
|
*/
|
|
export function extractNumbers(text: string): string[] {
|
|
const matches = text.match(/\d[\d,]*(?:\.\d+)?/g) ?? []
|
|
const normalized = matches.map((m) => m.replace(/,/g, '').replace(/\.0+$/, ''))
|
|
return [...new Set(normalized)]
|
|
}
|
|
|
|
/**
|
|
* Fraction of the answer's numeric claims that the injected context supports.
|
|
*
|
|
* Returns null when the answer contains no numbers — a qualitative answer is
|
|
* not ungrounded, it is just not measurable this way, and folding it in as 1.0
|
|
* would quietly inflate the score.
|
|
*
|
|
* **Known limitation, stated plainly:** this only sees numbers. An answer that
|
|
* fabricates a procedure or a proper noun scores a perfect 1.0 here. It is a
|
|
* fabrication *detector*, not a faithfulness *guarantee* — the LLM-judge tier
|
|
* exists for the rest. Small integers (0-10) are excluded because they appear
|
|
* incidentally in almost any prose ("3 layers", "step 2") and would swamp the
|
|
* signal with false grounding.
|
|
*/
|
|
export function numericGroundedness(
|
|
answer: string,
|
|
context: string
|
|
): { score: number | null; ungrounded: string[] } {
|
|
const contextNumbers = new Set(extractNumbers(context))
|
|
const claimed = extractNumbers(answer).filter((n) => {
|
|
const value = Number.parseFloat(n)
|
|
return !Number.isNaN(value) && value > 10
|
|
})
|
|
if (claimed.length === 0) return { score: null, ungrounded: [] }
|
|
const ungrounded = claimed.filter((n) => !contextNumbers.has(n))
|
|
return { score: (claimed.length - ungrounded.length) / claimed.length, ungrounded }
|
|
}
|
|
|
|
/** Which of a golden's patterns matched, using case-insensitive regex semantics. */
|
|
export function matchPatterns(answer: string, patterns: string[]): { matched: string[]; missed: string[] } {
|
|
const matched: string[] = []
|
|
const missed: string[] = []
|
|
for (const pattern of patterns) {
|
|
if (new RegExp(pattern, 'i').test(answer)) matched.push(pattern)
|
|
else missed.push(pattern)
|
|
}
|
|
return { matched, missed }
|
|
}
|
|
|
|
export type ScoreAnswerInput = {
|
|
answer: string
|
|
/** The context text actually injected into the prompt; '' when none was. */
|
|
context: string
|
|
mustInclude: string[]
|
|
mustNotInclude: string[]
|
|
expectRefusal: boolean
|
|
}
|
|
|
|
export function scoreAnswer(input: ScoreAnswerInput): GenerationScores {
|
|
const { answer, context, mustInclude, mustNotInclude, expectRefusal } = input
|
|
|
|
const required = matchPatterns(answer, mustInclude)
|
|
const forbidden = matchPatterns(answer, mustNotInclude)
|
|
const refused = detectRefusal(answer)
|
|
const grounding = numericGroundedness(answer, context)
|
|
|
|
return {
|
|
// An out-of-corpus question has no mustInclude patterns, so `correct` there
|
|
// is carried entirely by refusalCorrect below rather than by assertions.
|
|
correct: required.missed.length === 0 && forbidden.matched.length === 0,
|
|
matchedRequired: required.matched,
|
|
missedRequired: required.missed,
|
|
hitForbidden: forbidden.matched,
|
|
refused,
|
|
refusalCorrect: expectRefusal ? refused : !refused,
|
|
leakage: detectLeakage(answer),
|
|
thinkTagLeak: hasThinkTagLeak(answer),
|
|
numericGroundedness: grounding.score,
|
|
ungroundedNumbers: grounding.ungrounded,
|
|
markdownFormatted: isMarkdownFormatted(answer),
|
|
length: answer.length,
|
|
}
|
|
}
|
|
|
|
// --- aggregation over repeats -------------------------------------------------
|
|
|
|
export type RepeatStats = {
|
|
/** How many of N repeats passed. */
|
|
passes: number
|
|
repeats: number
|
|
passRate: number
|
|
/**
|
|
* True when the outcome was neither always-pass nor always-fail. Unstable
|
|
* cases are excluded from gating — treating a coin flip as a regression is
|
|
* how a harness loses the team's trust in one afternoon.
|
|
*/
|
|
unstable: boolean
|
|
}
|
|
|
|
export function summarizeRepeats(outcomes: boolean[]): RepeatStats {
|
|
const repeats = outcomes.length
|
|
const passes = outcomes.filter(Boolean).length
|
|
return {
|
|
passes,
|
|
repeats,
|
|
passRate: repeats === 0 ? 0 : passes / repeats,
|
|
unstable: repeats > 1 && passes > 0 && passes < repeats,
|
|
}
|
|
}
|
|
|
|
export type NumericSummary = { mean: number; stddev: number; n: number }
|
|
|
|
/**
|
|
* Mean and population standard deviation, ignoring nulls.
|
|
*
|
|
* The stddev is not decoration: with temperature 0 it should be near zero, and
|
|
* a non-trivial value is the harness telling you the run is noisier than the
|
|
* difference you are about to interpret.
|
|
*/
|
|
export function summarizeNumeric(values: Array<number | null>): NumericSummary | null {
|
|
const defined = values.filter((v): v is number => v !== null && Number.isFinite(v))
|
|
if (defined.length === 0) return null
|
|
const mean = defined.reduce((a, b) => a + b, 0) / defined.length
|
|
const variance = defined.reduce((acc, v) => acc + (v - mean) ** 2, 0) / defined.length
|
|
return { mean, stddev: Math.sqrt(variance), n: defined.length }
|
|
}
|