From 55641dd1776d738fbf17b22d2565a9513a657a60 Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Sun, 5 Jul 2026 12:12:31 +0200 Subject: [PATCH 1/2] Implement recursive grading result artifacts --- apps/cli/src/commands/pipeline/bench.ts | 65 ++- apps/cli/src/commands/results/manifest.ts | 82 ++- apps/cli/src/commands/results/summary.ts | 17 +- apps/cli/src/commands/results/validate.ts | 83 ++- apps/cli/test/commands/eval/aggregate.test.ts | 6 +- .../commands/eval/artifact-writer.test.ts | 527 +++++++++++------- .../test/commands/eval/pipeline/bench.test.ts | 7 +- .../eval/pipeline/pipeline-e2e.test.ts | 4 +- .../results/export-e2e-providers.test.ts | 50 +- apps/cli/test/commands/results/export.test.ts | 41 +- apps/cli/test/commands/results/shared.test.ts | 62 +-- .../cli/test/commands/results/summary.test.ts | 17 +- .../test/commands/results/validate.test.ts | 32 +- ...mptfoo-superset-eval-authoring-contract.md | 31 +- ...rtifact-and-workspace-resolver-contract.md | 76 ++- .../promptfoo-aligned-eval-restructure.md | 37 +- ...tfoo-grading-reference-output-alignment.md | 17 +- packages/core/src/evaluation/run-artifacts.ts | 398 ++++++++----- 18 files changed, 975 insertions(+), 577 deletions(-) diff --git a/apps/cli/src/commands/pipeline/bench.ts b/apps/cli/src/commands/pipeline/bench.ts index 855833b57..a7ebafbe6 100644 --- a/apps/cli/src/commands/pipeline/bench.ts +++ b/apps/cli/src/commands/pipeline/bench.ts @@ -26,13 +26,35 @@ interface EvaluatorScore { readonly assertions: readonly { text: string; passed: boolean; evidence?: string }[]; } -function toAssertionResult(assertion: { text: string; passed: boolean; evidence?: string }) { +function toComponentResult( + assertion: { text: string; passed: boolean; evidence?: string }, + evaluator?: Pick, +) { return { - text: assertion.text, - passed: assertion.passed, - evidence: assertion.evidence ?? '', + pass: assertion.passed, score: assertion.passed ? 1 : 0, - verdict: assertion.passed ? 'pass' : 'fail', + reason: assertion.evidence ?? assertion.text, + assertion: { + ...(evaluator ? { name: evaluator.name, type: evaluator.type } : {}), + value: assertion.text, + }, + }; +} + +function evaluatorComponent(evaluator: EvaluatorScore) { + const pass = evaluator.score >= DEFAULT_THRESHOLD; + return { + pass, + score: evaluator.score, + reason: pass ? 'Grader passed.' : 'Grader failed.', + assertion: { + name: evaluator.name, + type: evaluator.type, + weight: evaluator.weight, + }, + component_results: evaluator.assertions.map((assertion) => + toComponentResult(assertion, evaluator), + ), }; } @@ -139,20 +161,21 @@ export const evalBenchCommand = command({ allPassRates.push(passRate); // Write grading.json + const pass = weightedScore >= DEFAULT_THRESHOLD; const grading = { + pass, score: Math.round(weightedScore * 1000) / 1000, - verdict: weightedScore >= DEFAULT_THRESHOLD ? 'pass' : 'fail', - assertion_results: allAssertions.map(toAssertionResult), - summary: { passed, failed, total: allAssertions.length, pass_rate: passRate }, - graders: evaluators.map((e) => ({ - name: e.name, - type: e.type, - score: e.score, - verdict: e.score >= DEFAULT_THRESHOLD ? 'pass' : 'fail', - reasoning: '', - weight: e.weight, - assertion_results: e.assertions.map(toAssertionResult), - })), + reason: pass ? 'All grading components passed.' : 'One or more grading components failed.', + component_results: + evaluators.length > 0 + ? evaluators.map(evaluatorComponent) + : allAssertions.map((assertion) => toComponentResult(assertion)), + metadata: { + pass_count: passed, + fail_count: failed, + sample_count: allAssertions.length, + pass_rate: passRate, + }, }; await writeFile( join(testDir, 'grading.json'), @@ -164,14 +187,10 @@ export const evalBenchCommand = command({ const scores = evaluators.map((e) => ({ name: e.name, type: e.type, + pass: e.score >= DEFAULT_THRESHOLD, score: e.score, weight: e.weight, - verdict: e.score >= 0.5 ? 'pass' : 'fail', - assertions: e.assertions.map((a) => ({ - text: a.text, - passed: a.passed, - evidence: a.evidence ?? '', - })), + reason: e.score >= DEFAULT_THRESHOLD ? 'Grader passed.' : 'Grader failed.', })); // Read execution_status from metrics.json (written by pipeline run) diff --git a/apps/cli/src/commands/results/manifest.ts b/apps/cli/src/commands/results/manifest.ts index c473d9927..5b4f8c2b5 100644 --- a/apps/cli/src/commands/results/manifest.ts +++ b/apps/cli/src/commands/results/manifest.ts @@ -152,6 +152,9 @@ function readGradingAssertionResults( } function readNestedGradingScores(record: Record): unknown { + if (Array.isArray(record.component_results)) { + return record.component_results; + } if (Array.isArray(record.scores)) { return record.scores; } @@ -164,21 +167,74 @@ function readNestedGradingScores(record: Record): unknown { return undefined; } +function componentLabel(component: Record): string { + const assertion = component.assertion; + if (assertion && typeof assertion === 'object' && !Array.isArray(assertion)) { + const record = assertion as Record; + for (const key of ['value', 'name', 'id', 'type']) { + const value = record[key]; + if (typeof value === 'string' && value.trim().length > 0) { + return value; + } + } + } + return typeof component.reason === 'string' ? component.reason : 'grading component'; +} + +function mapComponentAssertion( + component: Record, +): EvaluationResult['assertions'][number] { + return { + text: componentLabel(component), + passed: component.pass === true, + evidence: typeof component.reason === 'string' ? component.reason : undefined, + }; +} + +function collectComponentAssertions(value: unknown): NonNullable { + if (!Array.isArray(value)) { + return []; + } + return value.flatMap((component) => { + if (!component || typeof component !== 'object' || Array.isArray(component)) { + return []; + } + const record = component as Record; + const nested = collectComponentAssertions(record.component_results); + return nested.length > 0 ? nested : [mapComponentAssertion(record)]; + }); +} + function mapGradingEvaluator(evaluator: Record): HydratedScore { - const verdict = - evaluator.verdict === 'pass' || evaluator.verdict === 'fail' || evaluator.verdict === 'skip' - ? evaluator.verdict - : undefined; + const pass = + typeof evaluator.pass === 'boolean' + ? evaluator.pass + : evaluator.verdict === 'pass' || + (typeof evaluator.score === 'number' && evaluator.score >= 0.8); + const verdict = pass ? ('pass' as const) : ('fail' as const); const details = evaluator.details && typeof evaluator.details === 'object' && !Array.isArray(evaluator.details) ? (evaluator.details as HydratedScore['details']) : undefined; + const assertion = + evaluator.assertion && + typeof evaluator.assertion === 'object' && + !Array.isArray(evaluator.assertion) + ? (evaluator.assertion as Record) + : undefined; return { - name: String(evaluator.name ?? ''), - type: String(evaluator.type ?? '') as HydratedScore['type'], + name: String(assertion?.name ?? assertion?.id ?? evaluator.name ?? componentLabel(evaluator)), + type: String(assertion?.type ?? evaluator.type ?? 'llm-grader') as HydratedScore['type'], score: typeof evaluator.score === 'number' ? evaluator.score : 0, - assertions: readGradingAssertionResults(evaluator) ?? [], + reason: typeof evaluator.reason === 'string' ? evaluator.reason : undefined, + assertions: (() => { + const nestedAssertions = collectComponentAssertions(evaluator.component_results); + if (nestedAssertions.length > 0) { + return nestedAssertions; + } + return readGradingAssertionResults(evaluator) ?? [mapComponentAssertion(evaluator)]; + })(), scores: mapGradingEvaluators(readNestedGradingScores(evaluator)), weight: typeof evaluator.weight === 'number' ? evaluator.weight : undefined, verdict, @@ -338,12 +394,16 @@ function hydrateManifestRecord( const timing = metrics ?? readOptionalJson(baseDir, record.timing_path); const testId = record.test_id ?? 'unknown'; const gradingAssertions = grading - ? readGradingAssertionResults(grading as unknown as Record) + ? collectComponentAssertions((grading as unknown as Record).component_results) : undefined; + const gradingRecord = grading as + | (GradingArtifact & { + graders?: readonly Record[]; + evaluators?: readonly Record[]; + }) + | undefined; const gradingScores = mapGradingEvaluators( - grading?.graders ?? - (grading as (GradingArtifact & { evaluators?: GradingArtifact['graders'] }) | undefined) - ?.evaluators, + gradingRecord?.component_results ?? gradingRecord?.graders ?? gradingRecord?.evaluators, ); return { diff --git a/apps/cli/src/commands/results/summary.ts b/apps/cli/src/commands/results/summary.ts index 3aef86c2b..699709d33 100644 --- a/apps/cli/src/commands/results/summary.ts +++ b/apps/cli/src/commands/results/summary.ts @@ -39,10 +39,19 @@ export function formatSummary( let passRate: number; if (grading) { - // Use pre-computed assertion-level counts from grading artifact - passed = grading.summary.passed; - failed = grading.summary.failed; - passRate = grading.summary.pass_rate; + const metadata = grading.metadata ?? {}; + passed = + typeof metadata.pass_count === 'number' ? metadata.pass_count : grading.pass ? total : 0; + failed = + typeof metadata.sample_count === 'number' && typeof metadata.pass_count === 'number' + ? metadata.sample_count - metadata.pass_count + : total - passed; + passRate = + typeof metadata.pass_rate === 'number' + ? metadata.pass_rate + : total > 0 + ? Math.round((passed / total) * 1000) / 1000 + : 0; } else { // Fall back to computing from per-test scores passed = results.filter((r) => r.score >= 1.0).length; diff --git a/apps/cli/src/commands/results/validate.ts b/apps/cli/src/commands/results/validate.ts index 77e0ee46e..5b7db4e2b 100644 --- a/apps/cli/src/commands/results/validate.ts +++ b/apps/cli/src/commands/results/validate.ts @@ -54,6 +54,66 @@ interface IndexEntry { readonly [key: string]: unknown; } +const LEGACY_GRADING_FIELDS = [ + 'assertion_results', + 'assertions', + 'passed', + 'evidence', + 'verdict', + 'graders', + 'checks', +] as const; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function validateGradingNode(value: unknown, pathLabel: string): string[] { + if (!isRecord(value)) { + return [`${pathLabel} must be an object`]; + } + + const errors: string[] = []; + if ( + typeof value.pass !== 'boolean' || + typeof value.score !== 'number' || + typeof value.reason !== 'string' + ) { + errors.push(`${pathLabel} must include pass, score, and reason`); + } + if (value.component_results !== undefined) { + if (!Array.isArray(value.component_results)) { + errors.push(`${pathLabel}.component_results must be an array when present`); + } else { + value.component_results.forEach((component, index) => { + errors.push(...validateGradingNode(component, `${pathLabel}.component_results[${index}]`)); + }); + } + } + return errors; +} + +function validateNoLegacyGradingFields(value: unknown, pathLabel: string): string[] { + if (Array.isArray(value)) { + return value.flatMap((entry, index) => + validateNoLegacyGradingFields(entry, `${pathLabel}[${index}]`), + ); + } + if (!isRecord(value)) { + return []; + } + + const errors: string[] = []; + const legacyFields = LEGACY_GRADING_FIELDS.filter((field) => Object.hasOwn(value, field)); + if (legacyFields.length > 0) { + errors.push(`${pathLabel} uses legacy field(s): ${legacyFields.join(', ')}`); + } + for (const [key, entry] of Object.entries(value)) { + errors.push(...validateNoLegacyGradingFields(entry, `${pathLabel}.${key}`)); + } + return errors; +} + // ── Checks ─────────────────────────────────────────────────────────────── function checkDirectoryNaming(runDir: string): Diagnostic[] { @@ -204,7 +264,8 @@ function checkIndexJsonl(runDir: string): { diagnostics: Diagnostic[]; entries: if (typeof s.name !== 'string') missing.push('name'); if (typeof s.type !== 'string') missing.push('type'); if (typeof s.score !== 'number') missing.push('score'); - if (typeof s.verdict !== 'string') missing.push('verdict'); + if (typeof s.pass !== 'boolean') missing.push('pass'); + if (typeof s.reason !== 'string') missing.push('reason'); if (missing.length > 0) { diagnostics.push({ severity: 'warning', @@ -285,23 +346,13 @@ function checkArtifactFiles(runDir: string, entries: IndexEntry[]): Diagnostic[] } else { try { const grading = JSON.parse(readFileSync(gradingPath, 'utf8')); - if (Array.isArray(grading.assertion_results)) { - // Current grading sidecar contract. - } else if (Array.isArray(grading.assertions)) { - diagnostics.push({ - severity: 'warning', - message: `${testId}: grading.json uses legacy 'assertions' array; rewrite the run to emit 'assertion_results'`, - }); - } else { + for (const error of [ + ...validateGradingNode(grading, 'grading.json'), + ...validateNoLegacyGradingFields(grading, 'grading.json'), + ]) { diagnostics.push({ severity: 'error', - message: `${testId}: grading.json missing 'assertion_results' array`, - }); - } - if (!grading.summary) { - diagnostics.push({ - severity: 'warning', - message: `${testId}: grading.json missing 'summary' object`, + message: `${testId}: ${error}`, }); } } catch { diff --git a/apps/cli/test/commands/eval/aggregate.test.ts b/apps/cli/test/commands/eval/aggregate.test.ts index 772ecff20..e0543c781 100644 --- a/apps/cli/test/commands/eval/aggregate.test.ts +++ b/apps/cli/test/commands/eval/aggregate.test.ts @@ -295,7 +295,8 @@ describe('writePerTestArtifacts', () => { const grading1 = JSON.parse( readFileSync(rowRunPath(tmpDir, 'test-1', 'sample-1', 'grading.json'), 'utf8'), ); - expect(grading1.assertion_results).toHaveLength(1); + expect(grading1.component_results).toHaveLength(1); + expect(grading1).not.toHaveProperty('assertion_results'); const metrics1 = JSON.parse( readFileSync(rowRunPath(tmpDir, 'test-1', 'sample-1', 'metrics.json'), 'utf8'), @@ -305,7 +306,8 @@ describe('writePerTestArtifacts', () => { const grading2 = JSON.parse( readFileSync(rowRunPath(tmpDir, 'test-2', 'sample-1', 'grading.json'), 'utf8'), ); - expect(grading2.assertion_results).toHaveLength(1); + expect(grading2.component_results).toHaveLength(1); + expect(grading2).not.toHaveProperty('assertion_results'); }); it('writes outputs/answer.md for results with output', async () => { diff --git a/apps/cli/test/commands/eval/artifact-writer.test.ts b/apps/cli/test/commands/eval/artifact-writer.test.ts index 8665119d0..17dc1c014 100644 --- a/apps/cli/test/commands/eval/artifact-writer.test.ts +++ b/apps/cli/test/commands/eval/artifact-writer.test.ts @@ -109,12 +109,42 @@ function runArtifactPath( return path.join(rootDir, entry?.result_dir ?? '', ...segments); } +const legacyPublicGradingFields = [ + 'assertion_results', + 'assertions', + 'passed', + 'evidence', + 'verdict', + 'graders', + 'checks', +]; + +function expectNoLegacyFields(value: unknown) { + expect(value).toBeDefined(); + if (Array.isArray(value)) { + for (const entry of value) { + expectNoLegacyFields(entry); + } + return; + } + if (typeof value !== 'object' || value === null) { + return; + } + const record = value as Record; + for (const field of legacyPublicGradingFields) { + expect(record).not.toHaveProperty(field); + } + for (const entry of Object.values(record)) { + expectNoLegacyFields(entry); + } +} + // --------------------------------------------------------------------------- // Grading artifact // --------------------------------------------------------------------------- describe('buildGradingArtifact', () => { - it('maps evaluator assertions to grading assertion_results', () => { + it('maps top-level assertions to recursive component_results', () => { const result = makeResult({ assertions: [ { text: 'correct format', passed: true }, @@ -125,34 +155,33 @@ describe('buildGradingArtifact', () => { const grading = buildGradingArtifact(result); - expect(grading).not.toHaveProperty('assertions'); - expect(grading.assertion_results).toHaveLength(3); - expect(grading.assertion_results[0]).toEqual({ - text: 'correct format', - passed: true, - evidence: '', - score: 1, - verdict: 'pass', - }); - expect(grading.assertion_results[1]).toEqual({ - text: 'has code', - passed: true, - evidence: '', - score: 1, - verdict: 'pass', - }); - expect(grading.assertion_results[2]).toEqual({ - text: 'missing tests', - passed: false, - evidence: '', - score: 0, - verdict: 'fail', - }); + expectNoLegacyFields(grading); + expect(grading.pass).toBe(true); expect(grading.score).toBe(0.9); - expect(grading.verdict).toBe('pass'); + expect(grading.reason).toBe('All grading components passed.'); + expect(grading.component_results).toEqual([ + { + pass: true, + score: 1, + reason: 'correct format', + assertion: { value: 'correct format' }, + }, + { + pass: true, + score: 1, + reason: 'has code', + assertion: { value: 'has code' }, + }, + { + pass: false, + score: 0, + reason: 'missing tests', + assertion: { value: 'missing tests' }, + }, + ]); }); - it('uses execution status for threshold-sensitive top-level verdicts', () => { + it('uses execution status for threshold-sensitive top-level pass', () => { const passedBelowDefault = buildGradingArtifact( makeResult({ score: 0.7, @@ -167,31 +196,67 @@ describe('buildGradingArtifact', () => { ); expect(passedBelowDefault.score).toBe(0.7); - expect(passedBelowDefault.verdict).toBe('pass'); + expect(passedBelowDefault.pass).toBe(true); expect(failedAboveDefault.score).toBe(0.85); - expect(failedAboveDefault.verdict).toBe('fail'); + expect(failedAboveDefault.pass).toBe(false); + expect(failedAboveDefault.reason).toBe('One or more grading components failed.'); }); - it('computes correct summary', () => { + it('normalizes script/SDK checks into component_results', () => { const result = makeResult({ - assertions: [ - { text: 'a', passed: true }, - { text: 'b', passed: true }, - { text: 'c', passed: false }, + assertions: [], + scores: [ + makeEvaluatorResult({ + name: 'attachment-check', + type: 'script', + score: 0.5, + reason: 'One attachment check failed.', + checks: [ + { text: 'Answer matches', pass: true, reason: 'Exact text matched.' }, + { + id: 'mentions-file', + text: 'Mentions attachment', + pass: false, + score: 0, + reason: 'The answer omitted example.txt.', + }, + ], + assertions: [], + }), ], }); const grading = buildGradingArtifact(result); - expect(grading.summary).toEqual({ - passed: 2, - failed: 1, - total: 3, - pass_rate: 0.667, + expectNoLegacyFields(grading); + expect(grading.component_results?.[0]).toMatchObject({ + pass: false, + score: 0.5, + reason: 'One attachment check failed.', + assertion: { name: 'attachment-check', type: 'script' }, + component_results: [ + { + pass: true, + score: 1, + reason: 'Exact text matched.', + assertion: { name: 'attachment-check', type: 'script', value: 'Answer matches' }, + }, + { + pass: false, + score: 0, + reason: 'The answer omitted example.txt.', + assertion: { + id: 'mentions-file', + name: 'attachment-check', + type: 'script', + value: 'Mentions attachment', + }, + }, + ], }); }); - it('preserves repeat trial metadata', () => { + it('preserves repeat trial metadata inside metadata only', () => { const result = makeResult({ trials: [ { @@ -215,14 +280,18 @@ describe('buildGradingArtifact', () => { totalAttempts: 2, }, }); - const grading = buildGradingArtifact(result); - expect(grading.attempts).toEqual([ + expectNoLegacyFields(grading); + expect(grading.metadata?.attempts).toBeUndefined(); + expect(grading.metadata?.aggregation).toBeUndefined(); + + const gradingWithTrials = buildGradingArtifact(result, { includeTrials: true }); + expectNoLegacyFields(gradingWithTrials); + expect(gradingWithTrials.metadata?.attempts).toEqual([ { attempt: 0, score: 0.4, - verdict: 'fail', execution_status: 'quality_failure', failure_stage: 'evaluator', failure_reason_code: 'threshold_not_met', @@ -230,11 +299,10 @@ describe('buildGradingArtifact', () => { { attempt: 1, score: 1, - verdict: 'pass', cost_usd: 0.03, }, ]); - expect(grading.aggregation).toEqual({ + expect(gradingWithTrials.metadata?.aggregation).toEqual({ strategy: 'pass_any', passed_attempts: 1, total_attempts: 2, @@ -249,8 +317,9 @@ describe('buildGradingArtifact', () => { min: 0.4, }, }), + { includeTrials: true }, ); - expect(passAll.aggregation).toEqual({ + expect(passAll.metadata?.aggregation).toEqual({ strategy: 'pass_all', passed_attempts: 1, total_attempts: 2, @@ -258,25 +327,7 @@ describe('buildGradingArtifact', () => { }); }); - it('uses top-level assertions when no grader scores', () => { - const result = makeResult({ - assertions: [ - { text: 'ok-1', passed: true }, - { text: 'ok-2', passed: true }, - { text: 'miss-1', passed: false }, - ], - }); - - const grading = buildGradingArtifact(result); - - expect(grading.assertion_results).toHaveLength(3); - expect(grading.assertion_results[0].text).toBe('ok-1'); - expect(grading.assertion_results[0].passed).toBe(true); - expect(grading.assertion_results[2].text).toBe('miss-1'); - expect(grading.assertion_results[2].passed).toBe(false); - }); - - it('includes evaluators list with AgentV extensions', () => { + it('includes multiple graders as top-level component results with named_scores', () => { const result = makeResult({ scores: [ makeEvaluatorResult({ name: 'format-check', type: 'script', score: 1.0 }), @@ -286,13 +337,21 @@ describe('buildGradingArtifact', () => { const grading = buildGradingArtifact(result); - expect(grading.graders).toHaveLength(2); - expect(grading.graders?.[0].name).toBe('format-check'); - expect(grading.graders?.[0].type).toBe('script'); - expect(grading.graders?.[1].score).toBe(0.7); + expectNoLegacyFields(grading); + expect(grading.component_results).toHaveLength(2); + expect(grading.component_results?.[0].assertion).toMatchObject({ + name: 'format-check', + type: 'script', + }); + expect(grading.component_results?.[1]).toMatchObject({ + pass: false, + score: 0.7, + assertion: { name: 'quality', type: 'llm-grader' }, + }); + expect(grading.named_scores).toEqual({ 'format-check': 1, quality: 0.7 }); }); - it('preserves multi-aspect grader assertions at top level and under the grader', () => { + it('preserves llm-rubric sub-results as nested component_results', () => { const rubricAssertions = [ { text: '[accuracy] Answer matches the reference - Score: 8/10 (strong)', @@ -310,8 +369,9 @@ describe('buildGradingArtifact', () => { scores: [ makeEvaluatorResult({ name: 'rubric-review', - type: 'llm-grader', + type: 'llm-rubric', score: 0.6, + reason: 'The answer is partially correct.', assertions: rubricAssertions, }), ], @@ -319,23 +379,114 @@ describe('buildGradingArtifact', () => { const grading = buildGradingArtifact(result); - expect(grading.assertion_results).toEqual([ - { ...rubricAssertions[0], score: 1, verdict: 'pass' }, - { ...rubricAssertions[1], score: 0, verdict: 'fail' }, - ]); - expect(grading.summary).toEqual({ - passed: 1, - failed: 1, - total: 2, - pass_rate: 0.5, - }); - expect(grading.graders?.[0]).toMatchObject({ - name: 'rubric-review', - type: 'llm-grader', + expectNoLegacyFields(grading); + expect(grading.component_results?.[0]).toMatchObject({ + pass: false, score: 0.6, - assertion_results: [ - { ...rubricAssertions[0], score: 1, verdict: 'pass' }, - { ...rubricAssertions[1], score: 0, verdict: 'fail' }, + reason: 'The answer is partially correct.', + assertion: { name: 'rubric-review', type: 'llm-rubric' }, + component_results: [ + { + pass: true, + score: 1, + reason: 'The answer includes the expected facts.', + assertion: { + name: 'rubric-review', + type: 'llm-rubric', + value: rubricAssertions[0].text, + }, + }, + { + pass: false, + score: 0, + reason: 'The answer does not cite a source.', + assertion: { + name: 'rubric-review', + type: 'llm-rubric', + value: rubricAssertions[1].text, + }, + }, + ], + }); + }); + + it('sanitizes legacy check and evidence keys from public grading metadata', () => { + const result = makeResult({ + scores: [ + makeEvaluatorResult({ + name: 'answer_quality', + type: 'llm-rubric', + score: 1, + details: { + pass: true, + score: 1, + reason: 'The answer passed.', + checks: [ + { + text: 'States the answer', + pass: true, + score: 1, + evidence: 'The output says 4.', + }, + ], + }, + }), + ], + }); + + const grading = buildGradingArtifact(result); + + expectNoLegacyFields(grading); + expect(grading.component_results?.[0].metadata).toEqual({ + details: { + pass: true, + score: 1, + reason: 'The answer passed.', + }, + }); + }); + + it('preserves nested child grader scores recursively', () => { + const result = makeResult({ + scores: [ + makeEvaluatorResult({ + name: 'assert-set', + type: 'assert-set', + score: 0.75, + assertions: [], + scores: [ + makeEvaluatorResult({ + name: 'json', + type: 'is-json', + score: 1, + assertions: [{ text: 'Valid JSON', passed: true }], + }), + makeEvaluatorResult({ + name: 'meaning', + type: 'llm-rubric', + score: 0.5, + assertions: [{ text: 'Semantically correct', passed: false, evidence: 'Too vague.' }], + }), + ], + }), + ], + }); + + const grading = buildGradingArtifact(result); + + expectNoLegacyFields(grading); + expect(grading.component_results?.[0].component_results).toHaveLength(2); + expect(grading.component_results?.[0].component_results?.[1]).toMatchObject({ + pass: false, + score: 0.5, + assertion: { name: 'meaning', type: 'llm-rubric' }, + component_results: [ + { + pass: false, + score: 0, + reason: 'Too vague.', + assertion: { name: 'meaning', type: 'llm-rubric', value: 'Semantically correct' }, + }, ], }); }); @@ -344,23 +495,19 @@ describe('buildGradingArtifact', () => { const result = makeResult({ error: 'Timeout exceeded' }); const grading = buildGradingArtifact(result); expect(grading).not.toHaveProperty('execution_metrics'); + expect(grading.reason).toBe('Timeout exceeded'); }); it('handles result with no assertions or scores', () => { const result = makeResult({ assertions: [], scores: undefined }); const grading = buildGradingArtifact(result); - expect(grading.assertion_results).toHaveLength(0); - expect(grading.summary).toEqual({ - passed: 0, - failed: 0, - total: 0, - pass_rate: 0, - }); - expect(grading.graders).toBeUndefined(); + expectNoLegacyFields(grading); + expect(grading.component_results).toBeUndefined(); + expect(grading.pass).toBe(true); }); - it('includes workspace_changes when fileChanges present', () => { + it('includes workspace_changes in metadata when fileChanges present', () => { const diff = [ '--- /dev/null', '+++ b/new-file.ts', @@ -380,20 +527,20 @@ describe('buildGradingArtifact', () => { const result = makeResult({ fileChanges: diff }); const grading = buildGradingArtifact(result); - expect(grading.workspace_changes).toBeDefined(); - expect(grading.workspace_changes?.files_created).toBe(1); - expect(grading.workspace_changes?.files_modified).toBe(1); - expect(grading.workspace_changes?.files_deleted).toBe(1); - expect(grading.workspace_changes?.deleted_file_paths).toEqual(['deleted.ts']); - expect(grading.workspace_changes).not.toHaveProperty('diff_summary'); + const workspaceChanges = grading.metadata?.workspace_changes as Record; + expect(workspaceChanges).toBeDefined(); + expect(workspaceChanges.files_created).toBe(1); + expect(workspaceChanges.files_modified).toBe(1); + expect(workspaceChanges.files_deleted).toBe(1); + expect(workspaceChanges.deleted_file_paths).toEqual(['deleted.ts']); + expect(workspaceChanges).not.toHaveProperty('diff_summary'); }); - it('includes conversation when conversationId present', () => { + it('includes conversation in metadata when conversationId present', () => { const result = makeResult({ conversationId: 'conv-abc-123' }); const grading = buildGradingArtifact(result); - expect(grading.conversation).toBeDefined(); - expect(grading.conversation?.conversation_id).toBe('conv-abc-123'); + expect(grading.metadata?.conversation).toMatchObject({ conversation_id: 'conv-abc-123' }); }); }); @@ -579,7 +726,7 @@ describe('buildRunSummaryArtifact', () => { // --------------------------------------------------------------------------- describe('buildAggregateGradingArtifact', () => { - it('combines assertion_results from multiple results with test_id', () => { + it('combines multiple results as recursive components with test metadata', () => { const results = [ makeResult({ testId: 'test-alpha', @@ -597,35 +744,28 @@ describe('buildAggregateGradingArtifact', () => { const aggregate = buildAggregateGradingArtifact(results); expect(aggregate.score).toBe(0.9); - expect(aggregate.verdict).toBe('pass'); - expect(aggregate.assertion_results).toHaveLength(3); - expect(aggregate.assertion_results[0]).toEqual({ - test_id: 'test-alpha', - text: 'criterion-1', - passed: true, - evidence: 'looks good', - score: 1, - verdict: 'pass', - }); - expect(aggregate.assertion_results[1]).toEqual({ - test_id: 'test-alpha', - text: 'criterion-2', - passed: false, - evidence: '', - score: 0, - verdict: 'fail', - }); - expect(aggregate.assertion_results[2]).toEqual({ - test_id: 'test-beta', - text: 'criterion-3', - passed: true, - evidence: '', - score: 1, - verdict: 'pass', + expect(aggregate.pass).toBe(true); + expect(aggregate.component_results).toHaveLength(2); + expect(aggregate.component_results?.[0]).toMatchObject({ + assertion: { id: 'test-alpha', name: 'test-alpha', type: 'eval-case' }, + component_results: [ + { + pass: true, + score: 1, + reason: 'looks good', + assertion: { value: 'criterion-1' }, + }, + { + pass: false, + score: 0, + reason: 'criterion-2', + assertion: { value: 'criterion-2' }, + }, + ], }); }); - it('computes correct summary counts', () => { + it('computes metadata counts', () => { const results = [ makeResult({ testId: 'test-1', @@ -645,15 +785,14 @@ describe('buildAggregateGradingArtifact', () => { const aggregate = buildAggregateGradingArtifact(results); - expect(aggregate.summary).toEqual({ - passed: 3, - failed: 1, - total: 4, - pass_rate: 0.75, + expect(aggregate.metadata).toEqual({ + pass_count: 2, + sample_count: 2, + pass_rate: 1, }); }); - it('computes top-level score and verdict from quality result status', () => { + it('computes top-level score and pass from quality result status', () => { const aggregate = buildAggregateGradingArtifact([ makeResult({ testId: 'low-threshold-pass', @@ -676,11 +815,10 @@ describe('buildAggregateGradingArtifact', () => { ]); expect(aggregate.score).toBe(0.775); - expect(aggregate.verdict).toBe('fail'); - expect(aggregate.summary).toEqual({ - passed: 1, - failed: 1, - total: 2, + expect(aggregate.pass).toBe(false); + expect(aggregate.metadata).toEqual({ + pass_count: 1, + sample_count: 2, pass_rate: 0.5, }); }); @@ -696,13 +834,11 @@ describe('buildAggregateGradingArtifact', () => { const aggregate = buildAggregateGradingArtifact(results); - expect(aggregate.assertion_results).toHaveLength(1); - expect(aggregate.assertion_results[0].test_id).toBe('test-1'); + expect(aggregate.component_results).toHaveLength(2); + expect(aggregate.component_results?.[0].assertion?.id).toBe('test-1'); expect(aggregate.score).toBe(0.9); - expect(aggregate.verdict).toBe('pass'); - expect(aggregate.summary.total).toBe(1); - expect(aggregate.summary.passed).toBe(1); - expect(aggregate.summary.failed).toBe(0); + expect(aggregate.pass).toBe(true); + expect(aggregate.metadata?.sample_count).toBe(2); }); it('excludes execution-error assertions from aggregate quality summary', () => { @@ -720,22 +856,13 @@ describe('buildAggregateGradingArtifact', () => { const aggregate = buildAggregateGradingArtifact(results); - expect(aggregate.assertion_results).toEqual([ - { - test_id: 'quality-pass', - text: 'quality criterion', - passed: true, - evidence: '', - score: 1, - verdict: 'pass', - }, - ]); + expect(aggregate.component_results).toHaveLength(1); + expect(aggregate.component_results?.[0].assertion?.id).toBe('quality-pass'); expect(aggregate.score).toBe(0.9); - expect(aggregate.verdict).toBe('pass'); - expect(aggregate.summary).toEqual({ - passed: 1, - failed: 0, - total: 1, + expect(aggregate.pass).toBe(true); + expect(aggregate.metadata).toEqual({ + pass_count: 1, + sample_count: 1, pass_rate: 1, }); }); @@ -744,12 +871,11 @@ describe('buildAggregateGradingArtifact', () => { const aggregate = buildAggregateGradingArtifact([]); expect(aggregate.score).toBe(0); - expect(aggregate.verdict).toBe('skip'); - expect(aggregate.assertion_results).toHaveLength(0); - expect(aggregate.summary).toEqual({ - passed: 0, - failed: 0, - total: 0, + expect(aggregate.pass).toBe(false); + expect(aggregate.component_results).toBeUndefined(); + expect(aggregate.metadata).toEqual({ + pass_count: 0, + sample_count: 0, pass_rate: 0, }); }); @@ -795,11 +921,9 @@ describe('buildIndexArtifactEntry', () => { { name: 'quality', type: 'llm-grader', + pass: false, score: 0.7, - assertions: [ - { text: 'criterion-a', passed: true }, - { text: 'criterion-b', passed: false }, - ], + reason: 'criterion-b', }, ], named_scores: { quality: 0.7 }, @@ -820,11 +944,9 @@ describe('buildIndexArtifactEntry', () => { { name: 'quality', type: 'llm-grader', + pass: false, score: 0.7, - assertions: [ - { text: 'criterion-a', passed: true }, - { text: 'criterion-b', passed: false }, - ], + reason: 'criterion-b', }, ], error: 'model drift', @@ -1023,7 +1145,7 @@ describe('parseJsonlResults', () => { // --------------------------------------------------------------------------- describe('schema compatibility', () => { - it('grading assertions have text/passed/evidence fields', () => { + it('grading artifacts use recursive pass/score/reason component_results fields', () => { const result = makeResult({ assertions: [ { text: 'x', passed: true }, @@ -1032,32 +1154,30 @@ describe('schema compatibility', () => { }); const grading = buildGradingArtifact(result); - expect(grading).not.toHaveProperty('assertions'); - for (const exp of grading.assertion_results) { - expect(exp).toHaveProperty('text'); - expect(exp).toHaveProperty('passed'); - expect(exp).toHaveProperty('evidence'); - expect(exp).toHaveProperty('score'); - expect(exp).toHaveProperty('verdict'); - expect(typeof exp.text).toBe('string'); - expect(typeof exp.passed).toBe('boolean'); - expect(typeof exp.evidence).toBe('string'); - expect(typeof exp.score).toBe('number'); - expect(['pass', 'fail']).toContain(exp.verdict); + expectNoLegacyFields(grading); + expect(typeof grading.pass).toBe('boolean'); + expect(typeof grading.score).toBe('number'); + expect(typeof grading.reason).toBe('string'); + for (const component of grading.component_results ?? []) { + expect(component).toHaveProperty('pass'); + expect(component).toHaveProperty('score'); + expect(component).toHaveProperty('reason'); + expect(component).toHaveProperty('assertion'); + expect(typeof component.pass).toBe('boolean'); + expect(typeof component.score).toBe('number'); + expect(typeof component.reason).toBe('string'); } }); - it('grading summary has passed/failed/total/pass_rate', () => { + it('grading named_scores and metadata are optional objects', () => { const result = makeResult({ - assertions: [{ text: 'a', passed: true }], + metadata: { safe_note: 'kept' }, + scores: [makeEvaluatorResult({ name: 'quality', score: 0.9 })], }); const grading = buildGradingArtifact(result); - expect(grading.summary).toHaveProperty('passed'); - expect(grading.summary).toHaveProperty('failed'); - expect(grading.summary).toHaveProperty('total'); - expect(grading.summary).toHaveProperty('pass_rate'); - expect(typeof grading.summary.pass_rate).toBe('number'); + expect(grading.named_scores).toEqual({ quality: 0.9 }); + expect(grading.metadata).toMatchObject({ safe_note: 'kept' }); }); it('metrics usage has duration, tokens, cost, execution, and trajectory sections', () => { @@ -1151,7 +1271,8 @@ describe('writeArtifactsFromResults', () => { 'utf8', ), ); - expect(alphaGrading.summary).toBeDefined(); + expect(alphaGrading.pass).toBe(true); + expect(alphaGrading.reason).toBe('All grading components passed.'); expect(alphaGrading).not.toHaveProperty('execution_metrics'); const alphaMetrics: TimingArtifact = JSON.parse( @@ -1492,10 +1613,10 @@ describe('writeArtifactsFromResults', () => { await readFile(runArtifactPath(testDir, testOne, 'sample-1', 'metrics.json'), 'utf8'), ); - expect(gradingOne.summary.total).toBe(1); - expect(gradingOne.summary.passed).toBe(1); - expect(gradingTwo.summary.total).toBe(2); - expect(gradingTwo.summary.failed).toBe(1); + expect(gradingOne.component_results).toHaveLength(1); + expect(gradingOne.component_results?.[0].pass).toBe(true); + expect(gradingTwo.component_results).toHaveLength(2); + expect(gradingTwo.component_results?.filter((component) => !component.pass)).toHaveLength(1); expect(metricsOne.duration.total_ms).toBe(0); }); @@ -2124,7 +2245,7 @@ describe('writeArtifactsFromResults', () => { await readFile(runArtifactPath(testDir, indexLine, 'sample-1', 'grading.json'), 'utf8'), ); - expect(grading.assertion_results[0].text).toBe('baseline-check'); + expect(grading.component_results?.[0].assertion?.value).toBe('baseline-check'); }); it('uses distinct row ids for the same test id across targets', async () => { diff --git a/apps/cli/test/commands/eval/pipeline/bench.test.ts b/apps/cli/test/commands/eval/pipeline/bench.test.ts index 3a2a823ca..9c3c2043e 100644 --- a/apps/cli/test/commands/eval/pipeline/bench.test.ts +++ b/apps/cli/test/commands/eval/pipeline/bench.test.ts @@ -72,9 +72,10 @@ describe('pipeline bench', () => { await execa('bun', [CLI_ENTRY, 'pipeline', 'bench', OUT_DIR]); const grading = JSON.parse(await readFile(join(OUT_DIR, 'test-01', 'grading.json'), 'utf8')); - expect(grading.summary.pass_rate).toBeGreaterThan(0); - expect(grading.assertion_results.length).toBeGreaterThan(0); - expect(grading.graders).toHaveLength(2); + expect(grading.metadata.pass_rate).toBeGreaterThan(0); + expect(grading.component_results).toHaveLength(2); + expect(grading).not.toHaveProperty('assertion_results'); + expect(grading).not.toHaveProperty('graders'); const indexContent = await readFile(join(OUT_DIR, '.internal', 'index.jsonl'), 'utf8'); const lines = indexContent diff --git a/apps/cli/test/commands/eval/pipeline/pipeline-e2e.test.ts b/apps/cli/test/commands/eval/pipeline/pipeline-e2e.test.ts index fc755cc41..c57c41553 100644 --- a/apps/cli/test/commands/eval/pipeline/pipeline-e2e.test.ts +++ b/apps/cli/test/commands/eval/pipeline/pipeline-e2e.test.ts @@ -58,8 +58,8 @@ describe('eval pipeline e2e', () => { const grading = JSON.parse( await readFile(join(outDir, 'input-test', 'test-01', 'grading.json'), 'utf8'), ); - expect(grading.graders).toHaveLength(2); - expect(grading.summary.pass_rate).toBeGreaterThan(0); + expect(grading.component_results).toHaveLength(2); + expect(grading.metadata.pass_rate).toBeGreaterThan(0); const indexContent = await readFile(join(outDir, '.internal', 'index.jsonl'), 'utf8'); const indexLines = indexContent diff --git a/apps/cli/test/commands/results/export-e2e-providers.test.ts b/apps/cli/test/commands/results/export-e2e-providers.test.ts index 89ba73a04..0edf155e4 100644 --- a/apps/cli/test/commands/results/export-e2e-providers.test.ts +++ b/apps/cli/test/commands/results/export-e2e-providers.test.ts @@ -502,15 +502,29 @@ describe('export e2e — multi-provider metrics verification', () => { expect(grading).not.toHaveProperty('assertions'); expect(grading.score).toBe(1); - expect(grading.verdict).toBe('pass'); - expect(grading.assertion_results).toHaveLength(2); - expect(grading.assertion_results[0].text).toBe('Correct answer'); - expect(grading.assertion_results[0].evidence).toBe('Matched expected output'); - expect(grading.assertion_results[0].score).toBe(1); - expect(grading.assertion_results[0].verdict).toBe('pass'); - expect(grading.summary.passed).toBe(2); - expect(grading.summary.failed).toBe(0); - expect(grading.summary.pass_rate).toBe(1.0); + expect(grading.pass).toBe(true); + expect(grading.component_results).toHaveLength(1); + expect(grading.component_results?.[0]).toMatchObject({ + pass: true, + score: 1, + reason: 'Grader passed.', + assertion: { + name: 'accuracy', + type: 'contains', + }, + component_results: [ + { + pass: true, + score: 1, + reason: 'Contains 42', + assertion: { + name: 'accuracy', + type: 'contains', + value: 'Contains 42', + }, + }, + ], + }); const metrics = JSON.parse( readFileSync( @@ -522,9 +536,9 @@ describe('export e2e — multi-provider metrics verification', () => { expect(metrics.metrics.tool_call_counts.Read).toBe(2); expect(metrics.metrics.tool_call_counts.Write).toBe(1); - // Graders - expect(grading.graders).toHaveLength(1); - expect(grading.graders?.[0].name).toBe('accuracy'); + expect(grading.component_results?.[0].component_results?.[0].assertion?.value).toBe( + 'Contains 42', + ); }); it('should produce correct grading for Copilot CLI result with mixed assertions', async () => { @@ -537,9 +551,10 @@ describe('export e2e — multi-provider metrics verification', () => { readFileSync(path.join(runArtifactDir(outputDir, COPILOT_RESULT), 'grading.json'), 'utf8'), ); - expect(grading.summary.passed).toBe(1); - expect(grading.summary.failed).toBe(1); - expect(grading.summary.pass_rate).toBe(0.5); + expect(grading.component_results?.[0].component_results).toHaveLength(2); + expect( + grading.component_results?.[0].component_results?.filter((component) => component.pass), + ).toHaveLength(1); const metrics = JSON.parse( readFileSync(path.join(runArtifactDir(outputDir, COPILOT_RESULT), 'metrics.json'), 'utf8'), @@ -557,9 +572,8 @@ describe('export e2e — multi-provider metrics verification', () => { readFileSync(path.join(runArtifactDir(outputDir, ERROR_RESULT), 'grading.json'), 'utf8'), ); - // Error result has empty assertions - expect(grading.summary.total).toBe(0); - expect(grading.summary.pass_rate).toBe(0); + expect(grading.component_results).toBeUndefined(); + expect(grading.pass).toBe(false); const metrics = JSON.parse( readFileSync(path.join(runArtifactDir(outputDir, ERROR_RESULT), 'metrics.json'), 'utf8'), ); diff --git a/apps/cli/test/commands/results/export.test.ts b/apps/cli/test/commands/results/export.test.ts index 6a8014d46..38ef78da0 100644 --- a/apps/cli/test/commands/results/export.test.ts +++ b/apps/cli/test/commands/results/export.test.ts @@ -723,33 +723,28 @@ describe('results export', () => { const grading: GradingArtifact = JSON.parse(readFileSync(gradingPath, 'utf8')); - // Uses artifact-writer's assertion_results field expect(grading).not.toHaveProperty('assertions'); expect(grading.score).toBe(1); - expect(grading.verdict).toBe('pass'); - expect(grading.assertion_results).toBeDefined(); - expect(grading.assertion_results.length).toBeGreaterThan(0); - expect(grading.assertion_results[0]).toHaveProperty('text'); - expect(grading.assertion_results[0]).toHaveProperty('passed'); - expect(grading.assertion_results[0]).toHaveProperty('evidence'); - expect(grading.assertion_results[0]).toHaveProperty('score'); - expect(grading.assertion_results[0]).toHaveProperty('verdict'); - - // Has summary - expect(grading.summary).toBeDefined(); - expect(grading.summary).toHaveProperty('passed'); - expect(grading.summary).toHaveProperty('failed'); - expect(grading.summary).toHaveProperty('total'); - expect(grading.summary).toHaveProperty('pass_rate'); + expect(grading.pass).toBe(true); + expect(grading.component_results).toBeDefined(); + expect(grading.component_results?.length).toBeGreaterThan(0); + expect(grading.component_results?.[0]).toHaveProperty('pass'); + expect(grading.component_results?.[0]).toHaveProperty('score'); + expect(grading.component_results?.[0]).toHaveProperty('reason'); + + expect(grading.metadata).toMatchObject({ + execution_status: 'ok', + }); // Grading artifacts stay focused on assertion evidence; execution data lives in metrics.json. expect(grading).not.toHaveProperty('execution_metrics'); - // Has evaluators - expect(grading.graders).toBeDefined(); - expect(grading.graders).toHaveLength(1); - expect(grading.graders?.[0].name).toBe('greeting_quality'); - expect(grading.graders?.[0].type).toBe('llm-grader'); + expect(grading).not.toHaveProperty('summary'); + expect(grading).not.toHaveProperty('graders'); + expect(grading.component_results?.[0].assertion).toMatchObject({ + name: 'greeting_quality', + type: 'llm-grader', + }); const perTestTimingPath = path.join(runArtifactDir(outputDir, RESULT_FULL), 'metrics.json'); expect(existsSync(perTestTimingPath)).toBe(true); @@ -871,8 +866,8 @@ describe('results export', () => { const grading: GradingArtifact = JSON.parse(readFileSync(gradingPath, 'utf8')); expect(grading).not.toHaveProperty('assertions'); - expect(grading.assertion_results).toEqual([]); - expect(grading.summary.total).toBe(0); + expect(grading.component_results).toBeUndefined(); + expect(grading.pass).toBe(true); }); it('should not write string input to a generated prompt sidecar', async () => { diff --git a/apps/cli/test/commands/results/shared.test.ts b/apps/cli/test/commands/results/shared.test.ts index 70add7065..ee65dce0f 100644 --- a/apps/cli/test/commands/results/shared.test.ts +++ b/apps/cli/test/commands/results/shared.test.ts @@ -290,40 +290,33 @@ describe('results shared source resolution', () => { writeFileSync( path.join(runDir, 'nested-graders/grading.json'), `${JSON.stringify({ + pass: false, score: 1, - verdict: 'pass', - assertion_results: [{ text: 'top-level', passed: true, evidence: 'top evidence' }], - summary: { passed: 1, failed: 0, total: 1, pass_rate: 1 }, - graders: [ + reason: 'One nested component failed.', + component_results: [ { - name: 'parent', - type: 'assert-set', + pass: true, score: 1, - assertion_results: [ - { text: 'parent assertion', passed: true, evidence: 'parent evidence' }, - ], - scores: [ + reason: 'top evidence', + assertion: { value: 'top-level' }, + }, + { + pass: false, + score: 0.5, + reason: 'parent evidence', + assertion: { name: 'parent', type: 'assert-set' }, + component_results: [ { - name: 'child', - type: 'contains', + pass: true, score: 1, - assertion_results: [ - { text: 'child assertion', passed: true, evidence: 'child evidence' }, - ], - scores: [ - { - name: 'legacy-grandchild', - type: 'regex', - score: 0, - assertions: [ - { - text: 'legacy grandchild assertion', - passed: false, - evidence: 'legacy child evidence', - }, - ], - }, - ], + reason: 'child evidence', + assertion: { name: 'child', type: 'contains', value: 'child assertion' }, + }, + { + pass: false, + score: 0, + reason: 'grandchild evidence', + assertion: { name: 'grandchild', type: 'regex', value: 'grandchild assertion' }, }, ], }, @@ -346,15 +339,18 @@ describe('results shared source resolution', () => { expect(results[0].assertions).toEqual([ { text: 'top-level', passed: true, evidence: 'top evidence' }, + { text: 'child assertion', passed: true, evidence: 'child evidence' }, + { text: 'grandchild assertion', passed: false, evidence: 'grandchild evidence' }, ]); expect(results[0].scores?.[0]?.assertions).toEqual([ - { text: 'parent assertion', passed: true, evidence: 'parent evidence' }, + { text: 'top-level', passed: true, evidence: 'top evidence' }, ]); - expect(results[0].scores?.[0]?.scores?.[0]?.assertions).toEqual([ + expect(results[0].scores?.[1]?.assertions).toEqual([ { text: 'child assertion', passed: true, evidence: 'child evidence' }, + { text: 'grandchild assertion', passed: false, evidence: 'grandchild evidence' }, ]); - expect(results[0].scores?.[0]?.scores?.[0]?.scores?.[0]?.assertions).toEqual([ - { text: 'legacy grandchild assertion', passed: false, evidence: 'legacy child evidence' }, + expect(results[0].scores?.[1]?.scores?.[1]?.assertions).toEqual([ + { text: 'grandchild assertion', passed: false, evidence: 'grandchild evidence' }, ]); }); diff --git a/apps/cli/test/commands/results/summary.test.ts b/apps/cli/test/commands/results/summary.test.ts index 1a4ff0183..4a67d80a1 100644 --- a/apps/cli/test/commands/results/summary.test.ts +++ b/apps/cli/test/commands/results/summary.test.ts @@ -76,21 +76,10 @@ describe('formatSummary', () => { describe('formatSummary with grading artifact', () => { it('uses assertion counts from grading artifact when provided', () => { const grading: AggregateGradingArtifact = { + pass: false, score: 0.75, - verdict: 'fail', - assertion_results: [ - { test_id: 'test-1', text: 'a', passed: true, evidence: '', score: 1, verdict: 'pass' }, - { - test_id: 'test-1', - text: 'b', - passed: false, - evidence: 'missing', - score: 0, - verdict: 'fail', - }, - { test_id: 'test-2', text: 'c', passed: true, evidence: '', score: 1, verdict: 'pass' }, - ], - summary: { passed: 2, failed: 1, total: 3, pass_rate: 0.667 }, + reason: 'One or more quality results failed.', + metadata: { pass_count: 2, sample_count: 3, pass_rate: 0.667 }, }; const results = [ diff --git a/apps/cli/test/commands/results/validate.test.ts b/apps/cli/test/commands/results/validate.test.ts index 9bdf30406..1e1a314c6 100644 --- a/apps/cli/test/commands/results/validate.test.ts +++ b/apps/cli/test/commands/results/validate.test.ts @@ -19,7 +19,7 @@ describe('results validate', () => { test_id: 'test-greeting', score: 1, target: 'gpt-4o', - scores: [{ name: 'quality', type: 'llm', score: 1, verdict: 'pass' }], + scores: [{ name: 'quality', type: 'llm', score: 1, pass: true, reason: 'passed' }], execution_status: 'ok', summary_path: 'test-greeting/summary.json', })}\n`, @@ -73,7 +73,7 @@ describe('results validate', () => { test_id: 'test-greeting', score: 1, target: 'gpt-4o', - scores: [{ name: 'quality', type: 'llm', score: 1, verdict: 'pass' }], + scores: [{ name: 'quality', type: 'llm', score: 1, pass: true, reason: 'passed' }], execution_status: 'ok', summary_path: 'test-greeting/summary.json', trace_path: 'test-greeting/sample-1/trace.json', @@ -107,7 +107,7 @@ describe('results validate', () => { } }); - it('accepts legacy grading assertions with a compatibility warning', () => { + it('rejects legacy public grading fields', () => { const tempDir = mkdtempSync(path.join(tmpdir(), 'agentv-validate-test-')); try { @@ -134,16 +134,32 @@ describe('results validate', () => { verdict: 'pass', assertions: [{ text: 'legacy assertion', passed: true }], summary: { passed: 1, failed: 0, total: 1, pass_rate: 1 }, + metadata: { + details: { + checks: [{ text: 'legacy check', evidence: 'legacy evidence' }], + }, + }, })}\n`, ); const { diagnostics } = validateRunDirectory(runDir); - expect(diagnostics.filter((d) => d.severity === 'error')).toEqual([]); expect(diagnostics).toContainEqual({ - severity: 'warning', + severity: 'error', + message: 'test-greeting: grading.json uses legacy field(s): assertions, verdict', + }); + expect(diagnostics).toContainEqual({ + severity: 'error', + message: 'test-greeting: grading.json must include pass, score, and reason', + }); + expect(diagnostics).toContainEqual({ + severity: 'error', + message: 'test-greeting: grading.json.metadata.details uses legacy field(s): checks', + }); + expect(diagnostics).toContainEqual({ + severity: 'error', message: - "test-greeting: grading.json uses legacy 'assertions' array; rewrite the run to emit 'assertion_results'", + 'test-greeting: grading.json.metadata.details.checks[0] uses legacy field(s): evidence', }); } finally { rmSync(tempDir, { recursive: true, force: true }); @@ -170,7 +186,7 @@ describe('results validate', () => { test_id: 'test-new', score: 1, target: 'gpt-4o', - scores: [{ name: 'quality', type: 'llm', score: 1, verdict: 'pass' }], + scores: [{ name: 'quality', type: 'llm', score: 1, pass: true, reason: 'passed' }], execution_status: 'ok', summary_path: 'test-new/summary.json', test_dir: 'test-new/test', @@ -182,7 +198,7 @@ describe('results validate', () => { test_id: 'test-legacy', score: 1, target: 'gpt-4o', - scores: [{ name: 'quality', type: 'llm', score: 1, verdict: 'pass' }], + scores: [{ name: 'quality', type: 'llm', score: 1, pass: true, reason: 'passed' }], execution_status: 'ok', summary_path: 'test-legacy/summary.json', task_dir: 'test-legacy/task', diff --git a/docs/adr/0016-promptfoo-superset-eval-authoring-contract.md b/docs/adr/0016-promptfoo-superset-eval-authoring-contract.md index c02f84017..91c196765 100644 --- a/docs/adr/0016-promptfoo-superset-eval-authoring-contract.md +++ b/docs/adr/0016-promptfoo-superset-eval-authoring-contract.md @@ -9,18 +9,19 @@ Accepted (2026-07-02). Anchor decision for the eval-authoring restructure — se eval-authoring portions of [ADR 0013 (stabilize eval authoring)](0013-stabilize-eval-authoring-contract.md) and [ADR 0013 (experiment as tags.experiment)](0013-experiment-is-metadata-expressed-as-tags-experiment.md)**; multi-turn is carved out to [ADR 0015](0015-multi-turn-conversation-execution-vs-evaluation.md); -the output/artifact contract to [ADR 0017](0017-output-artifact-and-workspace-resolver-contract.md). + the output/artifact contract to [ADR 0017](0017-output-artifact-and-workspace-resolver-contract.md). Status note (2026-07-04): implementation settled the grader vocabulary after this ADR was accepted. Current authored executable graders use `type: script`. `llm-rubric` is the promptfoo-compatible free-form rubric judge. Structured and multi-criteria rubric judging uses `g-eval` where itemized rubric semantics are -needed. The current output contract is owned by ADR 0017 and the active Beads: -authored YAML uses `assert`, `assert-set`, and `llm-rubric`, while `grading.json` -describes evaluated `graders[]` and nested `checks[]` with aggregate `pass`, -`score`, and `reason`. Do not teach `assertion_results`, `assertions`, -`passed`-only aliases, top-level `checks`, or dynamic one-grader artifact shapes -as the public contract. +needed. The current output contract is owned by ADR 0017 and the active Beads. +As of the 2026-07-05 `av-kfik.28.6` amendment, authored YAML uses `assert`, +`assert-set`, and `llm-rubric`, while native `grading.json` describes evaluated +recursive `component_results` with aggregate `pass`, `score`, and `reason`. Do +not teach `assertion_results`, `assertions`, `passed`-only aliases, `evidence`, +`verdict`, `graders`, `checks`, top-level `checks`, or dynamic one-grader artifact +shapes as the public contract. Status note (2026-07-05): Bead `av-noh3.2.1` supersedes this ADR's earlier `workspace` authoring language for coding-agent testbeds. AgentV's canonical @@ -57,14 +58,14 @@ keep AgentV's only where its semantics are genuinely better.** AgentV extension rather than being forced into `llm-rubric`. Structured AgentV rubric criteria are preserved, not flattened into a single text blob: criteria objects keep `weight`, `operator`, `required`, - `score_ranges`, and `min_score`. Result artifacts use the ADR 0017 grader - contract: `grading.json.graders[]` records each evaluated grader, and - `graders[].checks[]` records criterion- or component-level results when the - grader produces them. Deterministic graders usually emit no checks or one - check, while multi-aspect graders emit one check per authored criterion or - result unit. Structured rubric criteria therefore populate checks so the - Dashboard can show criterion-level evidence, using the same mechanism as - script graders, field accuracy, execution metrics, and tool trajectory. + `score_ranges`, and `min_score`. Result artifacts use the ADR 0017 grading + contract: `grading.json.component_results[]` records each evaluated grader, + criterion, or component recursively. Deterministic graders usually emit one + component, while multi-aspect graders emit one nested component per authored + criterion or result unit. Structured rubric criteria therefore populate + recursive components so the Dashboard can show criterion-level rationale, using + the same mechanism as script graders, field accuracy, execution metrics, and + tool trajectory. 3. **Grader execution**: `javascript` in-process (Bun `import`), `python` subprocess, `script` = the subprocess power tool (`environment.workdir` cwd, arbitrary language). `javascript` is NOT desugared to `script`. diff --git a/docs/adr/0017-output-artifact-and-workspace-resolver-contract.md b/docs/adr/0017-output-artifact-and-workspace-resolver-contract.md index 8ace98032..ba4fc969f 100644 --- a/docs/adr/0017-output-artifact-and-workspace-resolver-contract.md +++ b/docs/adr/0017-output-artifact-and-workspace-resolver-contract.md @@ -17,6 +17,12 @@ authoring shape are superseded for coding-agent testbeds. AgentV now uses `environment` as the public suite/test/case testbed recipe. `workspace` is no longer a locked canonical public testbed term. +Amended (2026-07-05) by Bead `av-kfik.28.6`: the `grading.json` +contract below supersedes both the earlier agentskills-shaped +`assertion_results`/`evidence`/`verdict` wording and the intermediate +`graders[]`/`checks[]` wording. Native AgentV grading artifacts now use a +recursive Promptfoo-style grading result in `snake_case`. + ## Context We reviewed the output formats of promptfoo, margin-lab, vercel-agent-eval, and @@ -74,18 +80,22 @@ protocol payloads. AgentV wrappers around those payloads still use `snake_case`. transcript referenced **by path**. 4. **Per-attempt grading sidecar**: `grading.json` is an AgentV-native public contract that keeps Promptfoo's aggregate grading vocabulary while preserving AgentV's richer - nested breakdown. It exposes top-level `pass`, `score`, `reason`, optional - `threshold`/`details`, and an always-present `graders[]` array. Each grader exposes - `name`, `type`, `pass`, `score`, `reason`, optional `threshold`/`details`, and - optional `checks[]`. Each check exposes `id?`, `text`, `pass`, optional `score`, - `reason`, and optional `evidence` only when the evidence is distinct from `reason`. - There are no public top-level `checks`, no dynamic single-grader shortcut, and no - public `assertion_results`, `assertions`, `passed`-only aliases, or - evidence-as-reason aliases. Authored YAML uses `assert`, `assert-set`, and - `llm-rubric`; result artifacts describe evaluated graders and checks. Default judge = - skeptical evidence-by-path (opt-out via explicit `prompt`); grader target selection - flows through the config graph (`defaults.grader`) or assertion-level target - selection, not a system-under-test target field. Evidence stays in `grading.json`. + nested breakdown. It is a recursive grading result with top-level `pass`, `score`, + and `reason`, plus optional `component_results`, `assertion`, `named_scores`, and + `metadata`. Each `component_results[]` entry uses the same shape recursively. + `assertion` carries the authored assertion/config metadata that produced that + component. SDK/script grader conveniences that still return `checks` are normalized + into `component_results` at the artifact boundary. Native AgentV artifacts never emit + public `assertion_results`, assertions-as-results, `passed`, `evidence`, `verdict`, + `graders`, or `checks` for this contract. Promptfoo import/export adapters may use + Promptfoo's `componentResults`/`namedScores` only when the output is explicitly + Promptfoo-formatted; AgentV-owned persisted artifacts stay `snake_case`. Authored YAML + uses `assert`, `assert-set`, and `llm-rubric`; result artifacts describe evaluated + recursive grading components. Default judge = skeptical evidence-by-path (opt-out via + explicit `prompt`); grader target selection flows through the config graph + (`defaults.grader`) or assertion-level target selection, not a system-under-test + target field. Grading rationale stays in `reason`; larger detached evidence belongs + in explicit artifact paths, not ad-hoc grading fields. 5. **Bundle layout / naming**: machine files move under per-run **`.internal/`** (`index.jsonl`, `progress.json`, `events.jsonl`, `bundle.json`); run root stays clean (`summary.json` + per-case dirs). Rename the reference field `manifest_path` → @@ -105,7 +115,7 @@ Confirms ADR-0009 + ADR-0012 (not a new decision): ### Artifact filenames (locked — accuracy over cosmetic consistency) - **`summary.json`** (run-root AND per-case) — the aggregate. Kept over margin's `results.json`: it's a *summary*, not the full results (those are the per-case dirs + `index.jsonl`); avoids the `results//results.json` stutter; symmetric at both levels (run aggregates cases, case aggregates samples); vercel-aligned. We match margin on the aggregate *concept/shape*, not the filename. -- Per-sample triad (distinct, all kept): **`result.json`** (what happened), **`grading.json`** (aggregate `pass`/`score`/`reason` plus `graders[]`/`checks[]`), **`metrics.json`** (duration+tokens+cost+execution/trajectory; the `timing.json` merge). +- Per-sample triad (distinct, all kept): **`result.json`** (what happened), **`grading.json`** (recursive aggregate `pass`/`score`/`reason` plus `component_results[]`), **`metrics.json`** (duration+tokens+cost+execution/trajectory; the `timing.json` merge). - **`grading.json`** kept (not `grades.json`) — source-consistent with agentskills (whose file is `grading.json`), and "grading" names the grading *result*. ### `grading.json` wire-format example @@ -114,33 +124,45 @@ Confirms ADR-0009 + ADR-0012 (not a new decision): "pass": false, "score": 0.62, "reason": "The answer names the right API but misses the rollback condition.", - "threshold": 0.8, - "details": { - "aggregation": "weighted_mean" + "named_scores": { + "api": 1, + "rollback": 0 + }, + "metadata": { + "aggregation": "weighted_mean", + "threshold": 0.8 }, - "graders": [ + "component_results": [ { - "name": "rubric", - "type": "llm-rubric", "pass": false, "score": 0.62, "reason": "Two of three rubric checks passed.", - "threshold": 0.8, - "checks": [ + "assertion": { + "name": "rubric", + "type": "llm-rubric" + }, + "component_results": [ { - "id": "api", - "text": "Identifies the API used to publish result bundles.", "pass": true, "score": 1, - "reason": "Correctly identifies the publish command." + "reason": "Correctly identifies the publish command.", + "assertion": { + "value": "Identifies the API used to publish result bundles." + }, + "metadata": { + "id": "api" + } }, { - "id": "rollback", - "text": "Explains when to roll back a failed publish.", "pass": false, "score": 0, "reason": "Mentions retrying but not rollback criteria.", - "evidence": "The response says to rerun the command after any failure." + "assertion": { + "value": "Explains when to roll back a failed publish." + }, + "metadata": { + "id": "rollback" + } } ] } diff --git a/docs/plans/promptfoo-aligned-eval-restructure.md b/docs/plans/promptfoo-aligned-eval-restructure.md index cc5d50d86..2e0f58905 100644 --- a/docs/plans/promptfoo-aligned-eval-restructure.md +++ b/docs/plans/promptfoo-aligned-eval-restructure.md @@ -7,14 +7,16 @@ workspaces and `workspace_mode` are removed from the user-facing contract, and `--workspace-path` / `execution.workspace_path` are the only static local workspace override. -Supersession note (2026-07-04): this plan predates the `av-kfik.28.1` grading -artifact decision. The current public `grading.json` contract is ADR-0017 plus -the active Beads: aggregate `pass`/`score`/`reason`, optional -`threshold`/`details`, always-present `graders[]`, nested `checks[]`, and no -public `assertion_results`, `assertions`, `passed`-only aliases, top-level -`checks`, or dynamic one-grader shortcut. Its broad pass@k language is also -stale unless it refers to an explicit sampling metric with a real `k`; use -`pass_rate`, `pass_count`, `sample_count`, and `passed`/`pass_any` otherwise. +Supersession note (2026-07-05): this plan predates the `av-kfik.28.6` grading +artifact contract. The current public `grading.json` contract is ADR-0017 as +amended by `av-kfik.28.6` plus the active Beads: a recursive Promptfoo-style +grading result in AgentV `snake_case` with `pass`, `score`, `reason`, optional +`component_results`, `assertion`, `named_scores`, and `metadata`. Public native +artifacts must not emit the earlier `assertion_results`/`summary`/`verdict` +shape or the intermediate `graders[]`/`checks[]` shape. This plan's broad pass@k +language is also stale unless it refers to an explicit sampling metric with a +real `k`; use `pass_rate`, `pass_count`, `sample_count`, and `passed`/`pass_any` +otherwise. Sources analyzed (all cloned locally, read-only): - promptfoo v0.121.17 — `/home/christso/projects/promptfoo-clone` (authoring format — the thing we clone) @@ -188,7 +190,11 @@ Applying that principle, the decisions are below (D = decided, ▸ = still a jud value: string | rubric_item[] # promptfoo-compatible field; AgentV structured extension when array items have outcome/score_ranges ``` `llm-rubric` remains the promptfoo-compatible free-form rubric-text judge. AgentV's agentic/evidence-gathering judge behavior (judge target, workspace/transcript evidence, max steps, preprocessors) remains an AgentV extension instead of being forced into promptfoo's non-agentic `llm-rubric` shape. - Artifact rows are generic AgentV grader output, not a `llm-rubric`-only feature: every grader returns `EvaluationScore.assertions[]`, the orchestrator flattens those rows into the result and `grading.json.assertions[]`, and nested `graders[].assertions[]` preserves the per-grader breakdown. Deterministic graders usually emit one row; multi-aspect graders emit one row per authored check or result unit. `llm-rubric`'s structured criteria should use one row per criterion because those criteria are distinct scoring aspects, the same pattern used by field-accuracy fields, execution metrics, and tool-trajectory requirements. + Historical grading-artifact note: this plan's old `EvaluationScore.assertions[]` + flattening, `grading.json.assertions[]`, and nested `graders[].assertions[]` + guidance is superseded by ADR-0017 as amended by `av-kfik.28.6`. Public native + `grading.json` now uses recursive `component_results` entries with `pass`, + `score`, `reason`, and optional `assertion`, `named_scores`, and `metadata`. - **Name — RESOLVED (owner Q): short-form criteria and structured rubrics use `llm-rubric`; `g-eval` is deferred until AgentV implements promptfoo's two-call semantics.** Promptfoo's `llm-rubric` accepts arbitrary object/array values, so AgentV can preserve structured `score_ranges` and per-criterion fields inside `value` without inventing another assertion type. - **Default grading behavior change — approved by owner; here's what changes** when the default judge adopts vercel's judge (§5.2): (1) **skeptical stance by default** ("strict judge; when in doubt, fail") → borderline outputs fail more often, so existing suites may show *lower* pass rates; (2) **evidence-by-path** → for agent/workspace tasks the judge reads the transcript/environment from files instead of a stuffed prompt, enabling tool-using investigation and better judgments on large outputs; (3) fixed `{pass, score, reason}` verdict contract. **Opt-out:** authoring an explicit `prompt` overrides the default skeptical prompt, preserving prior behavior. Implementation must diff the exact current `llm-grader-prompt.ts` wording to quantify the shift before flipping the default. @@ -333,9 +339,11 @@ Borrow vercel's judge model, which is stronger than a prompt-stuffed rubric: ### 5.3 `grading.json` contract — stale historical design -Stale after `av-kfik.28.1` and ADR-0017's 2026-07-04 update. Keep this section -as historical context only; implementation workers must use the current -`graders[]`/`checks[]` contract documented in ADR-0017 and in the Bead notes. +Stale after `av-kfik.28.6` and ADR-0017's 2026-07-05 grading amendment. Keep +this section as historical context only; implementation workers must use the +current recursive `component_results` contract documented in ADR-0017 and in the +Bead notes. The `assertion_results`/`summary`/`verdict` and `graders[]`/`checks[]` +contracts below are superseded. The output contract originates from agentskills' [evaluating-skills](https://github.com/agentskills/agentskills/blob/main/docs/skill-creation/evaluating-skills.mdx). Its `grading.json` is: ```json @@ -379,7 +387,10 @@ The subagents reviewed every reference's output format. Verdict: **split artifac - **Aggregate + queryability ← margin-lab (owner's pick).** The top-level **`summary.json` is a rich, self-contained, `jq`-queryable `Summary`** in margin-lab's shape — `run_id`, `status` breakdown, per-case **pass@k** (`pass_count`/`pass_rate`), per-instance summaries, `usage`, infra-failure taxonomy. You can query the whole run from one file with no database (margin-lab's key strength). Plus **`index.jsonl`** (one row per case) for streaming/line-wise queries (`jq`/`grep` per line) that scale better than a single fat file. AgentV's current top-level `summary.json` must be *widened* to margin's `Summary` richness so it's genuinely queryable, not just a manifest. - **Transcript + tool calls + metrics ← vercel (owner's pick).** Two-layer transcript (raw + normalized), canonical **`tool_name` enum**, and a precomputed **`transcript_summary`** (tool_calls, files_read/modified, shell_commands, web_fetches, thinking) — **inlined into each result row** so `tool-trajectory`/`execution-metrics`/pass@k read metrics cheaply without parsing the transcript. Transcript itself referenced **by path** (§5.1). This is where AgentV's trajectory/metrics graders get their signal. -- **Per-assertion grading ← agentskills.** `grading.json` = `assertion_results[{text, passed, evidence}]` + `summary` counts, plus AgentV's `verdict`/`score` superset (§5.3). +- **Historical only, superseded grading note.** This plan originally proposed + agentskills-style `assertion_results[{text, passed, evidence}]` + `summary` + counts, plus AgentV's `verdict`/`score` superset (§5.3). ADR-0017 as amended + by `av-kfik.28.6` supersedes that with recursive `component_results`. - **No maintained consolidated single-file export (owner: YAGNI).** Since the split bundle is the source of truth, we do **not** ship or maintain a promptfoo `EvaluateSummaryV3` file. If some external tool ever needs it, it can be **generated on demand** from the bundle — but it's not a first-class artifact. (We still adopt promptfoo-shaped `named_scores`/`derived_metrics` *inside* the split rows, because those feed the Dashboard — that's not a consolidated file.) So: **split detail** (per-case/per-attempt dirs, transcript by path) + a **margin-style queryable aggregate** (`summary.json`) + **row-per-case `index.jsonl`** + **vercel transcript/metrics** + **agentskills grading**. No DB, no maintained consolidated file; the filesystem is the query surface. diff --git a/docs/plans/promptfoo-grading-reference-output-alignment.md b/docs/plans/promptfoo-grading-reference-output-alignment.md index 5ab11c1f5..431238c7a 100644 --- a/docs/plans/promptfoo-grading-reference-output-alignment.md +++ b/docs/plans/promptfoo-grading-reference-output-alignment.md @@ -1,6 +1,6 @@ # Promptfoo Grading Reference Output Alignment Plan -Status: draft review summary. Beads are the implementation source of truth for scope, owner locks, acceptance, sequencing, and closure. This document and the PRs that update it are human-reviewable summaries of the Beads; they must not replace child Bead descriptions, acceptance criteria, or notes. +Status: historical review summary. Beads are the implementation source of truth for scope, owner locks, acceptance, sequencing, and closure. This document and the PRs that update it are human-reviewable summaries of the Beads; they must not replace child Bead descriptions, acceptance criteria, or notes. The grading artifact bullets below are superseded by ADR-0017 as amended by Bead `av-kfik.28.6`. ## Summary @@ -13,10 +13,9 @@ Finalized contract: - Explicit `assert` entries own pass/fail. - The Promptfoo-compatible low-friction pattern in AgentV wire format is a suite-level `default_test` `assert` entry with `type: llm-rubric` and `value` containing `{{ expected_output }}`. - `llm-rubric` should parse Promptfoo-style judge output `{reason, pass, score}`. -- Public grading artifacts use aggregate `{pass, score, reason, threshold?, details?, graders[]}` with `graders[]` always present. -- Each grader uses `{name, type, pass, score, reason, threshold?, details?, checks?}`. -- Checks use `{id?, text, pass, score?, reason, evidence?}`, with `evidence` present only when distinct from `reason`. -- Do not emit top-level `checks`, public `assertion_results`, a public `passed` alias, or a dynamic one-grader shortcut. +- Public native grading artifacts use a recursive Promptfoo-style grading result in AgentV `snake_case`: `pass`, `score`, `reason`, optional `component_results`, `assertion`, `named_scores`, and `metadata`. +- Each `component_results[]` entry recursively uses the same shape; SDK/script `checks` conveniences normalize into `component_results` at the artifact boundary. +- Do not emit public `assertion_results`, assertions-as-results, `passed`, `evidence`, `verdict`, `graders`, or `checks` for native AgentV grading artifacts. - Summary and index vocabulary should prefer `pass_rate`, `pass_count`, `sample_count`, and `passed`/`pass_any`; reserve `pass_at_k`/pass@k for explicit sampling metrics with a real `k`. Wire formats remain `snake_case`; internal TypeScript remains `camelCase` with boundary translation. @@ -28,12 +27,12 @@ Promptfoo evidence checked locally at clone commit `6bfc5a0c7f16f9c4717ac731d276 | Bead | Scope | Dependencies | Acceptance gates | Planned PR sequencing | | --- | --- | --- | --- | --- | | `av-kfik.28` | Parent epic for Promptfoo-compatible reference answers and public grading result contract. | Parent under `av-kfik`; coordinates `av-kfik.28.1` through `av-kfik.28.7`. | This plan PR records branch/PR/commit only; do not close the epic or child Beads from this PR. | Draft plan PR first. Implementation PRs follow child Bead order and keep Beads canonical. | -| `av-kfik.28.1` | Specify the final public grading result contract. | None inside this sub-epic. | Contract says aggregate `pass`, `score`, `reason`, always-present `graders[]`, nested `checks[]`, and no public legacy aliases. | First implementation/spec PR; blocks all artifact, SDK, parser, and dashboard work. | +| `av-kfik.28.1` | Specify the public grading result contract. Superseded by `av-kfik.28.6` final amendment for artifact shape. | None inside this sub-epic. | Historical contract said aggregate `pass`, `score`, `reason`, always-present `graders[]`, nested `checks[]`, and no public legacy aliases. | Historical sequencing note. | | `av-kfik.28.2` | Migrate authored `expected_output` to `vars.expected_output` and reject normal authored top-level/test `expected_output`. | `av-kfik.28.1`; must avoid colliding with `av-kfik.27` input hard-deprecation and `av-kfik.15` broad codemod. | Parser/codemod/errors prove `vars.expected_output` is passive and explicit `assert` owns grading; examples use AgentV wire-format `default_test` `llm-rubric` with `{{ expected_output }}` where semantic grading is intended. | Stack after `av-kfik.15`, or proceed only on isolated expected-output parser/codemod paths that do not rewrite input fixtures/docs/examples already owned by `av-kfik.15`/`av-kfik.16`. | | `av-kfik.28.3` | Parse Promptfoo-compatible `llm-rubric` judge output and normalize it into the new contract. | `av-kfik.28.1`. | Tests cover `{reason, pass, score}`, coercion/failure cases, optional `checks[]`, rubric arrays, and no public legacy fields. | Can proceed after `av-kfik.28.1` in non-overlapping grader/parser areas, using prompt/vars fixtures that do not touch input hard-deprecation migration. | | `av-kfik.28.4` | Update SDK and script grader result APIs for `pass`/`reason`/`checks`. | `av-kfik.28.1`. | SDK schemas/helpers, script grader docs/examples, and tests use aggregate plus checks; internal APIs keep camelCase and translate at boundaries. | Can proceed after `av-kfik.28.1`; coordinate with `av-kfik.28.6` before artifact fixtures are regenerated. | -| `av-kfik.28.6` | Rewrite run artifacts, JSONL/result exports, validators, and samples to stable `graders[]`/`checks[]`. | `av-kfik.28.1`, `av-kfik.28.3`, `av-kfik.28.4`. | Artifact contract tests cover single grader, multiple graders, no checks, scored checks, and failed grader parse errors; public artifacts reject legacy `assertion_results`/`passed`-only shape. | Artifact PR after parser and SDK PRs. Keep sample regeneration separate from input/example migration unless stacked after `av-kfik.15`. | -| `av-kfik.28.5` | Update Dashboard artifact readers and grading UI. | `av-kfik.28.6`, `av-kfik.28.1`. | Dashboard reads aggregate and `graders[]`, renders nested `checks[]` only when present, has fixtures for single/multi/failed grader states, and publishes screenshot evidence to `agentv-private`. | Dashboard PR after `av-kfik.28.6`; do not ship UI fixture updates before artifact shape is stable. | +| `av-kfik.28.6` | Rewrite run artifacts, JSONL/result exports, validators, and samples to stable recursive `component_results`. | `av-kfik.28.1`, `av-kfik.28.3`, `av-kfik.28.4`. | Artifact contract tests cover single assertions, multiple assertions, nested component results, script/SDK normalization, named scores, metadata, parse/error results, and rejection of legacy public fields. | Artifact PR after parser and SDK PRs. Keep sample regeneration separate from input/example migration unless stacked after `av-kfik.15`. | +| `av-kfik.28.5` | Update Dashboard artifact readers and grading UI. | `av-kfik.28.6`, `av-kfik.28.1`. | Dashboard reads aggregate and recursive `component_results`, renders nested results, has fixtures for single/multi/failed grader states, and publishes screenshot evidence to `agentv-private`. | Dashboard PR after `av-kfik.28.6`; do not ship UI fixture updates before artifact shape is stable. | | `av-kfik.28.7` | Update docs, examples, result artifact reference, script grader docs, and Promptfoo parity matrix. | `av-kfik.28.2`, `av-kfik.28.3`, `av-kfik.28.4`, `av-kfik.28.5`. | Public docs state the current contract directly; examples validate; parity matrix says AgentV is compatible with Promptfoo `llm-rubric` value templating and extends results with checks; live provider plus real LLM grader dogfood is recorded. | Final docs/examples PR after implementation and dashboard PRs, and after `av-kfik.15`/`av-kfik.16` clear broad input/codemod/docs migration. | ## Sequencing Constraints @@ -53,7 +52,7 @@ README stays out of scope for this plan except to keep Promptfoo comparison in p Implementation workers should choose the smallest checks that prove their Bead acceptance criteria, but the full sub-epic needs: - Unit, schema, parser, loader, and runtime tests for authored `vars.expected_output`, explicit `assert` ownership, rejection/migration of authored `expected_output`, and AgentV wire-format `default_test` inheritance. -- Artifact contract tests for aggregate `{pass, score, reason, graders[]}`, nested `checks[]`, and rejection of public `assertion_results`, `passed`, top-level `checks`, or one-grader dynamic shapes. +- Artifact contract tests for recursive `{pass, score, reason, component_results?}`, assertion metadata, named scores, metadata, and rejection of public `assertion_results`, `passed`, `evidence`, `verdict`, `graders`, or `checks`. - SDK and script grader tests for aggregate-only results, checks with scores, checks without scores, and boundary translation between TypeScript internals and public wire format. - Docs and examples validation after docs/examples migrate to prompts plus vars and current grading artifact vocabulary. - Live provider plus real LLM grader dogfood for eval, grader, and artifact changes, using canonical `.agentv/results//` output and private evidence. diff --git a/packages/core/src/evaluation/run-artifacts.ts b/packages/core/src/evaluation/run-artifacts.ts index dc3c5d907..fe6044d5f 100644 --- a/packages/core/src/evaluation/run-artifacts.ts +++ b/packages/core/src/evaluation/run-artifacts.ts @@ -62,6 +62,8 @@ import { type TranscriptSummaryWire, buildTranscriptSummary } from './transcript import type { EvalTest, EvaluationResult, + EvaluationVerdict, + GraderCheckResult, GraderResult, TrialAggregation, TrialResult, @@ -339,42 +341,23 @@ function isRunRuntimeSourceMetadata(value: unknown): value is RunRuntimeSourceMe ); } +export interface GradingAssertionMetadata { + readonly id?: string; + readonly name?: string; + readonly type?: string; + readonly value?: unknown; + readonly weight?: number; + readonly target?: string; +} + export interface GradingArtifact { + readonly pass: boolean; readonly score: number; - readonly verdict: 'pass' | 'fail' | 'skip'; - readonly assertion_results: readonly { - readonly text: string; - readonly passed: boolean; - readonly evidence: string; - readonly score: number; - readonly verdict: 'pass' | 'fail'; - }[]; - readonly summary: { - readonly passed: number; - readonly failed: number; - readonly total: number; - readonly pass_rate: number; - }; - readonly graders?: readonly { - readonly name: string; - readonly type: string; - readonly score: number; - readonly reasoning: string; - readonly assertion_results: readonly GradingAssertionResult[]; - readonly [key: string]: unknown; - }[]; - readonly workspace_changes?: { - readonly files_modified: number; - readonly files_created: number; - readonly files_deleted: number; - readonly deleted_file_paths?: readonly string[]; - }; - readonly conversation?: { - readonly turns: number; - readonly conversation_id: string; - }; - readonly attempts?: readonly TrialResultArtifact[]; - readonly aggregation?: TrialAggregationArtifact; + readonly reason: string; + readonly component_results?: readonly GradingArtifact[]; + readonly assertion?: GradingAssertionMetadata; + readonly named_scores?: Record; + readonly metadata?: Record; } export type TrialResultArtifact = { @@ -533,26 +516,14 @@ export interface RunConfigArtifact { } export interface AggregateGradingArtifact { + readonly pass: boolean; readonly score: number; - readonly verdict: 'pass' | 'fail' | 'skip'; - readonly assertion_results: readonly { - readonly test_id: string; - readonly text: string; - readonly passed: boolean; - readonly evidence: string; - readonly score: number; - readonly verdict: 'pass' | 'fail'; - }[]; - readonly summary: { - readonly passed: number; - readonly failed: number; - readonly total: number; - readonly pass_rate: number; - }; + readonly reason: string; + readonly component_results?: readonly GradingArtifact[]; + readonly named_scores?: Record; + readonly metadata?: Record; } -type GradingAssertionResult = GradingArtifact['assertion_results'][number]; - export interface IndexArtifactEntry { readonly timestamp: string; readonly test_id: string; @@ -728,9 +699,16 @@ function countToolCalls(result: EvaluationResult): { return { toolCalls, total }; } +interface WorkspaceChangesMetadata { + readonly files_modified: number; + readonly files_created: number; + readonly files_deleted: number; + readonly deleted_file_paths?: readonly string[]; +} + function parseWorkspaceChanges( fileChanges: string | undefined, -): GradingArtifact['workspace_changes'] | undefined { +): WorkspaceChangesMetadata | undefined { if (!fileChanges) { return undefined; } @@ -763,22 +741,7 @@ function parseWorkspaceChanges( }; } -function assertionResultFromAssertion(assertion: EvaluationResult['assertions'][number]) { - const passed = assertion.passed; - return { - text: assertion.text, - passed, - evidence: assertion.evidence ?? '', - score: passed ? 1 : 0, - verdict: passed ? ('pass' as const) : ('fail' as const), - }; -} - -function buildAssertionResults(result: EvaluationResult): GradingArtifact['assertion_results'] { - return (result.assertions ?? []).map(assertionResultFromAssertion); -} - -function resultVerdict(result: EvaluationResult): GradingArtifact['verdict'] { +function resultVerdict(result: EvaluationResult): EvaluationVerdict { const scores = result.scores ?? []; if (scores.length > 0 && scores.every((score) => score.verdict === 'skip')) { return 'skip'; @@ -789,52 +752,176 @@ function resultVerdict(result: EvaluationResult): GradingArtifact['verdict'] { return 'fail'; } -function buildEvaluators(scores: readonly GraderResult[] | undefined): GradingArtifact['graders'] { - if (!scores || scores.length === 0) { - return undefined; - } +function passFromVerdict(verdict: EvaluationVerdict | undefined, score: number): boolean { + return verdict ? verdict === 'pass' : score >= DEFAULT_THRESHOLD; +} - return scores.map((s) => ({ - name: s.name, - type: s.type, - score: s.score, - reasoning: '', - weight: s.weight, - verdict: s.verdict, - assertion_results: (s.assertions ?? []).map(assertionResultFromAssertion), - details: s.details, - scores: buildEvaluators(s.scores), - })); +function assertionMetadata( + metadata: GradingAssertionMetadata, +): GradingAssertionMetadata | undefined { + const compact = dropUndefined(metadata as Record) as GradingAssertionMetadata; + return Object.keys(compact).length > 0 ? compact : undefined; } -function toIndexAssertion( +function resultReason(result: EvaluationResult, pass: boolean): string { + const reason = result.reason?.trim(); + if (reason) { + return reason; + } + if (result.error?.trim()) { + return result.error; + } + if (result.executionStatus === 'execution_error') { + return 'Execution failed before grading completed.'; + } + if (result.executionStatus === 'quality_failure') { + return 'One or more grading components failed.'; + } + return pass ? 'All grading components passed.' : 'One or more grading components failed.'; +} + +function assertionToComponent( assertion: EvaluationResult['assertions'][number], -): Record { + parent?: GradingAssertionMetadata, +): GradingArtifact { + const pass = assertion.passed; return { - text: assertion.text, - passed: assertion.passed, - evidence: assertion.evidence, + pass, + score: pass ? 1 : 0, + reason: assertion.evidence ?? assertion.text, + assertion: assertionMetadata({ + ...parent, + value: assertion.text, + }), }; } -function toIndexScore(score: GraderResult): Record { +function checkToComponent( + check: GraderCheckResult, + parent?: GradingAssertionMetadata, +): GradingArtifact { + const score = clampScore(check.score ?? (check.pass ? 1 : 0)); return { + pass: check.pass, + score, + reason: check.reason || check.evidence || check.text, + assertion: assertionMetadata({ + ...parent, + id: check.id ?? parent?.id, + value: check.text, + }), + }; +} + +const PUBLIC_GRADING_FORBIDDEN_METADATA_KEYS = new Set([ + 'assertion_results', + 'assertions', + 'passed', + 'evidence', + 'verdict', + 'graders', + 'checks', +]); + +function sanitizePublicGradingMetadata(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(sanitizePublicGradingMetadata); + } + if (!isRecord(value)) { + return value; + } + + const entries = Object.entries(value) + .filter(([key]) => !PUBLIC_GRADING_FORBIDDEN_METADATA_KEYS.has(key)) + .map(([key, entry]) => [key, sanitizePublicGradingMetadata(entry)] as const) + .filter(([, entry]) => entry !== undefined); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} + +function scoreMetadata(score: GraderResult): Record | undefined { + const metadata = dropUndefined({ + details: sanitizePublicGradingMetadata(score.details), + token_usage: score.tokenUsage, + duration_ms: score.durationMs, + started_at: score.startedAt, + ended_at: score.endedAt, + }); + return Object.keys(metadata).length > 0 ? metadata : undefined; +} + +function scoreReason(score: GraderResult, pass: boolean): string { + const reason = score.reason?.trim(); + if (reason) { + return reason; + } + const failed = (score.checks ?? []).find((check) => !check.pass); + if (failed?.reason) { + return failed.reason; + } + const failedAssertion = (score.assertions ?? []).find((assertion) => !assertion.passed); + if (failedAssertion?.evidence || failedAssertion?.text) { + return failedAssertion.evidence ?? failedAssertion.text; + } + return pass ? 'Grader passed.' : 'Grader failed.'; +} + +function scoreToComponent(score: GraderResult): GradingArtifact { + const pass = passFromVerdict(score.verdict, score.score); + const assertion = assertionMetadata({ name: score.name, type: score.type, + weight: score.weight, + target: score.target, + }); + const componentResults = [ + ...(score.checks ?? []).map((check) => checkToComponent(check, assertion)), + ...(score.scores ?? []).map(scoreToComponent), + ...(score.checks && score.checks.length > 0 + ? [] + : (score.assertions ?? []).map((entry) => assertionToComponent(entry, assertion))), + ]; + const namedScores = collectNamedScores(score.scores); + return dropUndefined({ + pass, + score: clampScore(score.score), + reason: scoreReason(score, pass), + component_results: componentResults.length > 0 ? componentResults : undefined, + assertion, + named_scores: namedScores, + metadata: scoreMetadata(score), + }) as unknown as GradingArtifact; +} + +function buildComponentResults(result: EvaluationResult): readonly GradingArtifact[] | undefined { + if (result.scores && result.scores.length > 0) { + return result.scores.map(scoreToComponent); + } + if (result.checks && result.checks.length > 0) { + return result.checks.map((check) => checkToComponent(check)); + } + if (result.assertions && result.assertions.length > 0) { + return result.assertions.map((assertion) => assertionToComponent(assertion)); + } + return undefined; +} + +function toIndexScore(score: GraderResult): Record { + const pass = passFromVerdict(score.verdict, score.score); + return dropUndefined({ + name: score.name, + type: score.type, + pass, score: score.score, + reason: scoreReason(score, pass), weight: score.weight, - verdict: score.verdict, - assertions: (score.assertions ?? []).map(toIndexAssertion), - raw_request: score.rawRequest, - input: score.input, target: score.target, scores: score.scores?.map(toIndexScore), - details: score.details, + named_scores: collectNamedScores(score.scores), token_usage: score.tokenUsage, duration_ms: score.durationMs, started_at: score.startedAt, ended_at: score.endedAt, - }; + }); } function toIndexScores(scores: readonly GraderResult[] | undefined): IndexArtifactEntry['scores'] { @@ -1424,34 +1511,42 @@ export function buildGradingArtifact( result: EvaluationResult, options?: { includeTrials?: boolean }, ): GradingArtifact { - const assertionResults = buildAssertionResults(result); - const passed = assertionResults.filter((e) => e.passed).length; - const failed = assertionResults.filter((e) => !e.passed).length; - const total = assertionResults.length; - const includeTrials = options?.includeTrials ?? true; - - return { - score: clampScore(result.score), - verdict: resultVerdict(result), - assertion_results: assertionResults, - summary: { - passed, - failed, - total, - pass_rate: total > 0 ? Math.round((passed / total) * 1000) / 1000 : 0, - }, - graders: buildEvaluators(result.scores), - workspace_changes: parseWorkspaceChanges(result.fileChanges), - conversation: result.conversationId + const pass = resultVerdict(result) === 'pass'; + const componentResults = buildComponentResults(result); + const metadata = dropUndefined({ + ...toIndexMetadata(result.metadata), + ...(result.conversationId ? { - turns: - result.trace?.messages.filter((message) => message.role === 'assistant').length ?? 0, - conversation_id: result.conversationId, + conversation: { + turns: + result.trace?.messages.filter((message) => message.role === 'assistant').length ?? 0, + conversation_id: result.conversationId, + }, } - : undefined, - attempts: includeTrials ? toIndexTrialArtifacts(result) : undefined, - aggregation: includeTrials ? toTrialAggregationArtifact(result.aggregation) : undefined, - }; + : {}), + ...(result.fileChanges ? { workspace_changes: parseWorkspaceChanges(result.fileChanges) } : {}), + ...(options?.includeTrials + ? { + attempts: toIndexTrialArtifacts(result), + aggregation: toTrialAggregationArtifact(result.aggregation), + } + : {}), + execution_status: result.executionStatus, + failure_stage: result.failureStage, + failure_reason_code: result.failureReasonCode, + }); + + return dropUndefined({ + pass, + score: clampScore(result.score), + reason: resultReason(result, pass), + component_results: componentResults, + named_scores: collectNamedScores(result.scores), + metadata: + Object.keys(metadata).length > 0 + ? (sanitizePublicGradingMetadata(metadata) as Record | undefined) + : undefined, + }) as unknown as GradingArtifact; } function timingMetadataSource( @@ -1784,7 +1879,7 @@ export function buildRunSummaryArtifact( const caseSummaries = [...casesByKey.values()].map((entry) => ({ ...entry, pass_rate: percentage(entry.pass_count, entry.sample_count), - pass_at_1: entry.pass_count > 0, + pass_any: entry.pass_count > 0, })); const passedCases = caseSummaries.filter((entry) => entry.pass_count > 0).length; const erroredInstances = instances.filter( @@ -1902,22 +1997,7 @@ export async function readRunConfigArtifact( export function buildAggregateGradingArtifact( results: readonly EvaluationResult[], ): AggregateGradingArtifact { - const assertionResults: AggregateGradingArtifact['assertion_results'][number][] = []; const qualityResults = results.filter((r) => !isExecutionError(r)); - - for (const result of qualityResults) { - const testId = result.testId ?? 'unknown'; - for (const assertion of result.assertions ?? []) { - assertionResults.push({ - test_id: testId, - ...assertionResultFromAssertion(assertion), - }); - } - } - - const passed = assertionResults.filter((a) => a.passed).length; - const failed = assertionResults.filter((a) => !a.passed).length; - const total = assertionResults.length; const score = qualityResults.length > 0 ? Math.round( @@ -1926,25 +2006,37 @@ export function buildAggregateGradingArtifact( 1000, ) / 1000 : 0; - const verdict = - results.length === 0 - ? 'skip' - : qualityResults.length > 0 && - qualityResults.every((result) => resultVerdict(result) === 'pass') - ? 'pass' - : 'fail'; + const pass = + qualityResults.length > 0 && qualityResults.every((result) => resultVerdict(result) === 'pass'); + const componentResults = qualityResults.map((result) => ({ + ...buildGradingArtifact(result, { includeTrials: false }), + assertion: assertionMetadata({ + id: result.testId ?? 'unknown', + name: result.testId ?? 'unknown', + type: 'eval-case', + }), + })); - return { + return dropUndefined({ + pass, score, - verdict, - assertion_results: assertionResults, - summary: { - passed, - failed, - total, - pass_rate: total > 0 ? Math.round((passed / total) * 1000) / 1000 : 0, + reason: + results.length === 0 + ? 'No results to summarize.' + : pass + ? 'All quality results passed.' + : 'One or more quality results failed.', + component_results: componentResults.length > 0 ? componentResults : undefined, + named_scores: collectNamedScores(qualityResults.flatMap((result) => result.scores ?? [])), + metadata: { + pass_count: qualityResults.filter((result) => resultVerdict(result) === 'pass').length, + sample_count: qualityResults.length, + pass_rate: percentage( + qualityResults.filter((result) => resultVerdict(result) === 'pass').length, + qualityResults.length, + ), }, - }; + }) as unknown as AggregateGradingArtifact; } function safeArtifactPathSegment(value: string | undefined, fallback: string): string { From 48e23c860095bf1055fa5e9cd641147bfbab75ab Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Sun, 5 Jul 2026 12:21:32 +0200 Subject: [PATCH 2/2] Update prepared grading artifact expectations --- apps/cli/test/commands/grade/grade-prepared.test.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/cli/test/commands/grade/grade-prepared.test.ts b/apps/cli/test/commands/grade/grade-prepared.test.ts index 8959a8821..2c0537f0e 100644 --- a/apps/cli/test/commands/grade/grade-prepared.test.ts +++ b/apps/cli/test/commands/grade/grade-prepared.test.ts @@ -209,8 +209,8 @@ describe('agentv grade prepared attempts', () => { ); const grading = JSON.parse(await readFile(path.join(runDir, row.grading_path), 'utf8')); - expect(grading.workspace_changes).not.toHaveProperty('diff_summary'); - expect(grading.workspace_changes.files_modified).toBeGreaterThanOrEqual(1); + expect(grading.metadata.workspace_changes).not.toHaveProperty('diff_summary'); + expect(grading.metadata.workspace_changes.files_modified).toBeGreaterThanOrEqual(1); }, 20_000); it('fails clearly when the prepared manifest is missing', async () => { @@ -289,10 +289,8 @@ describe('agentv grade prepared attempts', () => { name: 'expected-tool-sequence', type: 'tool-trajectory', score: 0, - }); - expect(row.scores[0].assertions[0]).toMatchObject({ - text: 'No trace available for evaluation', - passed: false, + pass: false, + reason: 'No trace available for evaluation', }); }); @@ -401,7 +399,8 @@ describe('agentv grade prepared attempts', () => { name: 'expected-tool-sequence', type: 'tool-trajectory', score: 1, - assertions: [{ text: 'Found Read at position 0', passed: true }], + pass: true, + reason: 'Grader passed.', }); expect(row.metadata.prepared_attempt.trace_path).toBe(tracePath); });