Skip to content

context-faithfulness / context-recall: test.vars.context clobbers contextTransform output in the grader prompt (...vars spread last) #9977

Description

@AsemHub

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions