Describe the bug
When a context-faithfulness or context-recall assertion composes its grading context at runtime with contextTransform, the grader silently scores against the raw test.vars.context instead of the transformed context. The transformed value is still recorded in gradingResult.metadata.context, so the run log shows the intended context while the grader actually saw a different one — the score is wrong and the log is misleading.
Root cause: in src/matchers/rag.ts, the object passed to renderLlmRubricPrompt (and to callProviderWithContext) spreads ...(vars || {}) after context: contextString:
// matchesContextFaithfulness (src/matchers/rag.ts, as of current main)
promptText = await renderLlmRubricPrompt(nliPrompt, {
context: contextString, // resolveContext()/contextTransform output
statements,
...(vars || {}), // vars = test.vars; a `context` key here overwrites the line above
});
Later keys win in an object literal, so vars.context overwrites the freshly-serialized contextString. matchesContextRecall has the identical shape with groundTruth.
Trigger condition: fires when test.vars.context is truthy and the effective grading context differs from it (e.g. a contextTransform builds a larger/composed context). A contextTransform with no context var set is unaffected (no context key in vars to clobber).
To Reproduce
Deterministic proof — the exact object literal from rag.ts, no promptfoo needed:
const contextString = "DIGEST + FETCHED_NOTES (the transformed context)";
const vars = { context: "DIGEST ONLY (raw test.vars.context)" };
const rendered = { context: contextString, statements: [], ...(vars || {}) };
console.log(rendered.context);
// -> "DIGEST ONLY (raw test.vars.context)" <- transformed context silently lost
Full in-situ repro (no API key: SUT = echo, grader = a recording custom provider). Three files:
# promptfooconfig.yaml
description: contextTransform clobber repro
prompts:
- "The system uses idempotent retries."
providers:
- id: echo
defaultTest:
options:
provider: file://grader.js
tests:
- vars:
query: "How does the system handle retries?"
context: "RAW_CONTEXT_ALPHA." # raw test.vars.context
assert:
- type: context-faithfulness
threshold: 0
contextTransform: file://transform.js # composes a DIFFERENT context
// transform.js — resolveContext() returns this; it is what metadata.context stores
module.exports = (output, context) =>
String(context.vars.context) + "\nTRANSFORM_ONLY_MARKER_BETA.";
// grader.js — records every prompt it is asked to grade, no API key
const fs = require('fs'), path = require('path');
const LOG = path.join(__dirname, 'grader-received-prompts.txt');
class RecordingGrader {
id() { return 'recording-grader'; }
async callApi(prompt) {
fs.appendFileSync(LOG, '\n===== GRADER RECEIVED =====\n' + prompt + '\n');
return { output: 'Final verdict for each statement in order: Yes.' };
}
}
module.exports = RecordingGrader;
Run npx promptfoo eval -c promptfooconfig.yaml --no-cache -o result.json, then compare:
grader-received-prompts.txt -> the NLI prompt's Context section contains only RAW_CONTEXT_ALPHA, not TRANSFORM_ONLY_MARKER_BETA (the grader graded the raw context).
result.json -> metadata.context does contain TRANSFORM_ONLY_MARKER_BETA (the log claims the transformed context was used).
That contradiction is the bug.
Expected behavior
The grader should score against the context produced by contextTransform/resolveContext — the same value stored in metadata.context. test.vars.context should not override the composed grading context.
Suggested fix
Reorder the spread so the reserved grader keys win, at both the render and provider-call sites in matchesContextFaithfulness and matchesContextRecall:
renderLlmRubricPrompt(nliPrompt, {
...(vars || {}),
context: contextString,
statements,
});
Alternatively, strip the reserved keys — context, statements, groundTruth — from vars before spreading. PR #9939 already defines these as GRADER_BUILTIN_VARS but only excludes them on the opt-in graderVars path; the default path stays exposed.
System information
- promptfoo: 0.121.17 (latest published); code unchanged on
main (src/matchers/rag.ts).
- Node.js: v22.23.1
- OS: Windows 10 (19045)
Additional context
- Exact sites on current
main: matchesContextFaithfulness (render + provider-call) and matchesContextRecall (render + provider-call) — both end their prompt-vars object with ...(vars || {}).
- Related but distinct (filing separately): the context-faithfulness NLI scoring computes
1 - (statements without "yes") / statements.length without accounting for statements that produced no parsed verdict, so a truncated/short grader response can silently inflate the score toward 1.0.
Describe the bug
When a
context-faithfulnessorcontext-recallassertion composes its grading context at runtime withcontextTransform, the grader silently scores against the rawtest.vars.contextinstead of the transformed context. The transformed value is still recorded ingradingResult.metadata.context, so the run log shows the intended context while the grader actually saw a different one — the score is wrong and the log is misleading.Root cause: in
src/matchers/rag.ts, the object passed torenderLlmRubricPrompt(and tocallProviderWithContext) spreads...(vars || {})aftercontext: contextString:Later keys win in an object literal, so
vars.contextoverwrites the freshly-serializedcontextString.matchesContextRecallhas the identical shape withgroundTruth.Trigger condition: fires when
test.vars.contextis truthy and the effective grading context differs from it (e.g. acontextTransformbuilds a larger/composed context). AcontextTransformwith nocontextvar set is unaffected (nocontextkey invarsto clobber).To Reproduce
Deterministic proof — the exact object literal from
rag.ts, no promptfoo needed:Full in-situ repro (no API key: SUT =
echo, grader = a recording custom provider). Three files:Run
npx promptfoo eval -c promptfooconfig.yaml --no-cache -o result.json, then compare:grader-received-prompts.txt-> the NLI prompt's Context section contains onlyRAW_CONTEXT_ALPHA, notTRANSFORM_ONLY_MARKER_BETA(the grader graded the raw context).result.json->metadata.contextdoes containTRANSFORM_ONLY_MARKER_BETA(the log claims the transformed context was used).That contradiction is the bug.
Expected behavior
The grader should score against the context produced by
contextTransform/resolveContext— the same value stored inmetadata.context.test.vars.contextshould not override the composed grading context.Suggested fix
Reorder the spread so the reserved grader keys win, at both the render and provider-call sites in
matchesContextFaithfulnessandmatchesContextRecall:Alternatively, strip the reserved keys —
context,statements,groundTruth— fromvarsbefore spreading. PR #9939 already defines these asGRADER_BUILTIN_VARSbut only excludes them on the opt-ingraderVarspath; the default path stays exposed.System information
main(src/matchers/rag.ts).Additional context
main:matchesContextFaithfulness(render + provider-call) andmatchesContextRecall(render + provider-call) — both end their prompt-vars object with...(vars || {}).1 - (statements without "yes") / statements.lengthwithout accounting for statements that produced no parsed verdict, so a truncated/short grader response can silently inflate the score toward 1.0.