Skip to content

Commit 29ed2ab

Browse files
authored
feat(evals): preserve rubric criterion operators
Bead: av-r0s.1
1 parent 34d19e9 commit 29ed2ab

16 files changed

Lines changed: 523 additions & 4 deletions

File tree

apps/web/src/content/docs/docs/evaluation/rubrics.mdx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ assertions:
6767
|-------|---------|-------------|
6868
| `id` | Auto-generated | Unique identifier for the criterion |
6969
| `outcome` | — | Description of what to check |
70+
| `operator` | — | Optional intent hint: `correctness` or `contradiction` |
7071
| `weight` | `1.0` | Relative importance for scoring |
7172
| `required` | `false` | If true, failing this criterion fails the entire eval |
7273
| `min_score` | — | Minimum score (0–1) for this criterion to pass |
@@ -76,6 +77,26 @@ assertions:
7677
`required_min_score` (0–10 integer scale) is deprecated. Use `min_score` (0–1 scale) instead. For example, `required_min_score: 8` becomes `min_score: 0.8`.
7778
:::
7879

80+
### Criterion Operators
81+
82+
Use `operator` when the criterion outcome should be interpreted with a specific grading intent instead of relying on the wording in `outcome`.
83+
84+
```yaml
85+
assertions:
86+
- type: rubrics
87+
criteria:
88+
- id: supported-revenue
89+
operator: correctness
90+
outcome: States revenue increased to $10M
91+
required: true
92+
- id: no-revenue-conflict
93+
operator: contradiction
94+
outcome: Revenue increased to $10M
95+
required: true
96+
```
97+
98+
`correctness` requires the answer to positively satisfy the outcome. `contradiction` is a guard: the answer passes when it does not make an incompatible claim, even if it omits the outcome entirely.
99+
79100
## Score-Range Mode (Analytic)
80101

81102
For quality gradients instead of binary pass/fail, use score ranges:

examples/features/rubric/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Demonstrates rubric-based evaluation with weights, required flags, and auto-gene
77
- Inline rubrics as strings
88
- Rubric objects with weights
99
- Required vs optional criteria
10+
- Criterion operators for correctness and contradiction guards
1011
- Auto-generating rubrics from criteria
1112
- Rubric file references
1213

@@ -20,4 +21,5 @@ bun agentv eval examples/features/rubric/evals/dataset.eval.yaml --target defaul
2021
## Key Files
2122

2223
- `evals/dataset.eval.yaml` - Test cases with various rubric patterns
24+
- `evals/operators.eval.yaml` - Focused example of correctness and contradiction operators
2325
- `evals/rubrics/` - External rubric files
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# AgentV Rubric Operator Example
2+
# Demonstrates optional criterion operators for preserving rubric intent.
3+
4+
name: rubric-operators
5+
description: "Focused example showing correctness and contradiction rubric operators"
6+
7+
execution:
8+
target: llm
9+
10+
tests:
11+
- id: finance-summary-operator-guards
12+
criteria: |-
13+
Summarize the source faithfully. Include supported material facts and avoid
14+
claims that contradict the source.
15+
16+
input:
17+
- role: user
18+
content: |-
19+
Source note:
20+
- Q4 revenue increased to $10M from $8M.
21+
- Gross margin declined to 42% from 45%.
22+
23+
Write a two-sentence finance summary.
24+
25+
expected_output:
26+
- role: assistant
27+
content: |-
28+
Q4 revenue increased to $10M from $8M. Gross margin declined to 42%
29+
from 45%.
30+
31+
assertions:
32+
- type: rubrics
33+
criteria:
34+
- id: supported-revenue
35+
operator: correctness
36+
outcome: States Q4 revenue increased to $10M from $8M.
37+
required: true
38+
39+
- id: no-margin-contradiction
40+
operator: contradiction
41+
outcome: Gross margin declined to 42% from 45%.
42+
required: true

packages/core/src/evaluation/graders/llm-grader-prompt.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
buildScoreRangeOutputSchema,
1010
substituteVariables,
1111
} from './llm-grader.js';
12+
import { formatRubricOperatorGuidance, formatRubricOperatorLabel } from './rubric-operators.js';
1213

1314
export interface LlmGraderPromptAssembly {
1415
systemPrompt: string;
@@ -144,10 +145,20 @@ function assembleChecklist(
144145

145146
parts.push('[[ ## rubrics ## ]]');
146147

148+
const operatorGuidance = formatRubricOperatorGuidance(rubrics);
149+
if (operatorGuidance.length > 0) {
150+
parts.push('', 'Operator guidance:');
151+
for (const guidance of operatorGuidance) {
152+
parts.push(`- ${guidance}`);
153+
}
154+
parts.push('');
155+
}
156+
147157
for (const rubric of rubrics) {
148158
const requiredLabel = rubric.required ? ' (REQUIRED)' : '';
149159
const weightLabel = rubric.weight !== 1.0 ? ` (weight: ${rubric.weight})` : '';
150-
parts.push(`- [${rubric.id}]${requiredLabel}${weightLabel}: ${rubric.outcome}`);
160+
const operatorLabel = formatRubricOperatorLabel(rubric.operator);
161+
parts.push(`- [${rubric.id}]${requiredLabel}${weightLabel}${operatorLabel}: ${rubric.outcome}`);
151162
}
152163

153164
parts.push('', 'For each rubric, determine if it is satisfied and provide brief reasoning.');
@@ -213,6 +224,10 @@ function assembleScoreRange(
213224

214225
parts.push('', `### Criterion: ${rubric.id}${weightLabel}${minScoreLabel}`);
215226

227+
if (rubric.operator) {
228+
parts.push(`Operator: ${rubric.operator}`);
229+
}
230+
216231
if (rubric.outcome) {
217232
parts.push(`Description: ${rubric.outcome}`);
218233
}
@@ -227,6 +242,11 @@ function assembleScoreRange(
227242
}
228243
}
229244

245+
const operatorGuidance = formatRubricOperatorGuidance(rubrics);
246+
if (operatorGuidance.length > 0) {
247+
parts.push('', ...operatorGuidance);
248+
}
249+
230250
parts.push(
231251
'',
232252
'For each criterion, provide an integer score 0-10 that matches one of its defined score ranges.',

packages/core/src/evaluation/graders/llm-grader.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { extractLastAssistantContent, isAgentProvider } from '../providers/types
1414
import { DEPRECATED_TEMPLATE_VARIABLES, TEMPLATE_VARIABLES } from '../template-variables.js';
1515
import type { TokenUsage } from '../trace.js';
1616
import type { AssertionEntry, JsonObject, RubricItem } from '../types.js';
17+
import { formatRubricOperatorGuidance, formatRubricOperatorLabel } from './rubric-operators.js';
1718
import { clampScore, isNonEmptyString, parseJsonFromText, scoreToVerdict } from './scoring.js';
1819
import type { EvaluationContext, EvaluationScore, Grader } from './types.js';
1920

@@ -952,6 +953,10 @@ export class LlmGrader implements Grader {
952953

953954
parts.push('', `### Criterion: ${rubric.id}${weightLabel}${minScoreLabel}`);
954955

956+
if (rubric.operator) {
957+
parts.push(`Operator: ${rubric.operator}`);
958+
}
959+
955960
if (rubric.outcome) {
956961
parts.push(`Description: ${rubric.outcome}`);
957962
}
@@ -966,6 +971,11 @@ export class LlmGrader implements Grader {
966971
}
967972
}
968973

974+
const operatorGuidance = formatRubricOperatorGuidance(rubrics);
975+
if (operatorGuidance.length > 0) {
976+
parts.push('', ...operatorGuidance);
977+
}
978+
969979
parts.push(
970980
'',
971981
'For each criterion, provide an integer score 0-10 that matches one of its defined score ranges.',
@@ -1007,10 +1017,22 @@ export class LlmGrader implements Grader {
10071017

10081018
parts.push('[[ ## rubrics ## ]]');
10091019

1020+
const operatorGuidance = formatRubricOperatorGuidance(rubrics);
1021+
if (operatorGuidance.length > 0) {
1022+
parts.push('', 'Operator guidance:');
1023+
for (const guidance of operatorGuidance) {
1024+
parts.push(`- ${guidance}`);
1025+
}
1026+
parts.push('');
1027+
}
1028+
10101029
for (const rubric of rubrics) {
10111030
const requiredLabel = rubric.required ? ' (REQUIRED)' : '';
10121031
const weightLabel = rubric.weight !== 1.0 ? ` (weight: ${rubric.weight})` : '';
1013-
parts.push(`- [${rubric.id}]${requiredLabel}${weightLabel}: ${rubric.outcome}`);
1032+
const operatorLabel = formatRubricOperatorLabel(rubric.operator);
1033+
parts.push(
1034+
`- [${rubric.id}]${requiredLabel}${weightLabel}${operatorLabel}: ${rubric.outcome}`,
1035+
);
10141036
}
10151037

10161038
parts.push('', 'For each rubric, determine if it is satisfied and provide brief reasoning.');
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { RubricItem, RubricOperator } from '../types.js';
2+
3+
const OPERATOR_GUIDANCE: Record<RubricOperator, string> = {
4+
correctness:
5+
'Correctness: mark satisfied only when the answer positively supports or fulfills the outcome. Omission or contradiction should not satisfy it.',
6+
contradiction:
7+
'Contradiction guard: mark satisfied when the answer does not make a claim that contradicts the outcome. Do not require the answer to mention the outcome; mark unsatisfied only for incompatible claims.',
8+
};
9+
10+
export function formatRubricOperatorLabel(operator: RubricOperator | undefined): string {
11+
return operator ? ` (operator: ${operator})` : '';
12+
}
13+
14+
export function formatRubricOperatorGuidance(rubrics: readonly RubricItem[]): readonly string[] {
15+
const operators = new Set<RubricOperator>();
16+
for (const rubric of rubrics) {
17+
if (rubric.operator) {
18+
operators.add(rubric.operator);
19+
}
20+
}
21+
22+
if (operators.size === 0) {
23+
return [];
24+
}
25+
26+
return [...operators].map((operator) => OPERATOR_GUIDANCE[operator]);
27+
}

packages/core/src/evaluation/loaders/grader-parser.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ import type {
1010
GraderKind,
1111
JsonObject,
1212
JsonValue,
13+
RubricOperator,
1314
} from '../types.js';
14-
import { isGraderKind } from '../types.js';
15+
import { RUBRIC_OPERATOR_VALUES, isGraderKind } from '../types.js';
1516
import { validateCustomPromptContent } from '../validation/prompt-validator.js';
1617
import { parseYamlValue } from '../yaml-loader.js';
1718
import { resolveFileReference } from './file-resolver.js';
@@ -1940,6 +1941,28 @@ function isValidFieldAggregationType(
19401941
return typeof value === 'string' && VALID_FIELD_AGGREGATION_TYPES.has(value);
19411942
}
19421943

1944+
const VALID_RUBRIC_OPERATORS: ReadonlySet<string> = new Set(RUBRIC_OPERATOR_VALUES);
1945+
1946+
function parseRubricOperator(
1947+
value: unknown,
1948+
rubricId: string,
1949+
evaluatorName: string,
1950+
evalId: string,
1951+
): RubricOperator | undefined {
1952+
if (value === undefined) {
1953+
return undefined;
1954+
}
1955+
1956+
if (typeof value === 'string' && VALID_RUBRIC_OPERATORS.has(value)) {
1957+
return value as RubricOperator;
1958+
}
1959+
1960+
logWarning(
1961+
`Ignoring invalid operator for rubric '${rubricId}' in evaluator '${evaluatorName}' in '${evalId}': must be one of ${RUBRIC_OPERATOR_VALUES.join(', ')}`,
1962+
);
1963+
return undefined;
1964+
}
1965+
19431966
/**
19441967
* Parse rubric items from raw YAML/JSON data.
19451968
* Supports both checklist rubrics and score-range rubrics.
@@ -1961,6 +1984,7 @@ function parseRubricItems(
19611984

19621985
const id = asString(rawRubric.id) ?? `rubric-${index + 1}`;
19631986
const expectedOutcome = asString(rawRubric.outcome) ?? '';
1987+
const operator = parseRubricOperator(rawRubric.operator, id, evaluatorName, evalId);
19641988
const weight = typeof rawRubric.weight === 'number' ? rawRubric.weight : 1.0;
19651989

19661990
// Parse min_score (0-1 scale), required_min_score (deprecated 0-10 scale), and required
@@ -2018,6 +2042,7 @@ function parseRubricItems(
20182042
id,
20192043
weight,
20202044
...(expectedOutcome.length > 0 ? { outcome: expectedOutcome } : {}),
2045+
...(operator !== undefined ? { operator } : {}),
20212046
...(required !== undefined ? { required } : {}),
20222047
...(minScore !== undefined ? { min_score: minScore } : {}),
20232048
...(requiredMinScore !== undefined ? { required_min_score: requiredMinScore } : {}),
@@ -2035,6 +2060,7 @@ function parseRubricItems(
20352060
items.push({
20362061
id,
20372062
outcome: expectedOutcome,
2063+
...(operator !== undefined ? { operator } : {}),
20382064
weight,
20392065
// Default to required: true if not specified (backward compatibility)
20402066
required: required ?? true,
@@ -2237,6 +2263,8 @@ export function parseInlineRubrics(
22372263
}
22382264

22392265
const expectedOutcome = asString(rubric.outcome) ?? '';
2266+
const id = asString(rubric.id) ?? `rubric-${index + 1}`;
2267+
const operator = parseRubricOperator(rubric.operator, id, 'rubrics', '<inline>');
22402268

22412269
// Parse score_ranges if present (supports shorthand map format)
22422270
const rawScoreRanges = rubric.score_ranges;
@@ -2256,7 +2284,8 @@ export function parseInlineRubrics(
22562284
: undefined;
22572285

22582286
const baseRubric = {
2259-
id: asString(rubric.id) ?? `rubric-${index + 1}`,
2287+
id,
2288+
...(operator !== undefined ? { operator } : {}),
22602289
weight: typeof rubric.weight === 'number' ? rubric.weight : 1.0,
22612290
};
22622291

packages/core/src/evaluation/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,10 @@ export type ScoreRange = {
467467
readonly outcome: string;
468468
};
469469

470+
export const RUBRIC_OPERATOR_VALUES = ['correctness', 'contradiction'] as const;
471+
472+
export type RubricOperator = (typeof RUBRIC_OPERATOR_VALUES)[number];
473+
470474
/**
471475
* Rubric item for LLM grader evaluation.
472476
* Supports two modes:
@@ -480,6 +484,11 @@ export type RubricItem = {
480484
* For score-range rubrics: optional overall criterion description.
481485
*/
482486
readonly outcome?: string;
487+
/**
488+
* Optional grading intent. `correctness` requires positive support for the outcome.
489+
* `contradiction` is a guard: omission is acceptable, but incompatible claims fail.
490+
*/
491+
readonly operator?: RubricOperator;
483492
readonly weight: number;
484493
/**
485494
* Legacy boolean gating (treated as min_score: 1.0 for score-range rubrics).

packages/core/src/evaluation/validation/eval-file.schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ const ScoreRangeSchema = z.object({
7272
const RubricItemSchema = z.object({
7373
id: z.string().optional(),
7474
outcome: z.string().optional(),
75+
operator: z.enum(['correctness', 'contradiction']).optional(),
7576
weight: z.number().optional(),
7677
required: z.boolean().optional(),
7778
/** Minimum score (0-1) for this criterion to pass. */

0 commit comments

Comments
 (0)