0d3cb498a3
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Waiting to run
Test and Publish Multi-arch Docker Image / test (push) Waiting to run
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Blocked by required conditions
Validate Renovate Config / Validate Renovate Configuration (push) Waiting to run
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
565 lines
16 KiB
TypeScript
565 lines
16 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
AssertionsResult,
|
|
DEFAULT_TOKENS_USED,
|
|
GUARDRAIL_BLOCKED_REASON,
|
|
} from '../../src/assertions/assertionsResult';
|
|
import { getEnvBool } from '../../src/envars';
|
|
|
|
import type { AssertionSet, GradingResult } from '../../src/types/index';
|
|
|
|
vi.mock('../../src/envars');
|
|
|
|
describe('AssertionsResult', () => {
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
});
|
|
|
|
describe('noAssertsResult', () => {
|
|
it('should return default result for no assertions', () => {
|
|
const result = AssertionsResult.noAssertsResult();
|
|
expect(result).toEqual({
|
|
pass: true,
|
|
score: 1,
|
|
reason: 'No assertions',
|
|
tokensUsed: DEFAULT_TOKENS_USED,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('addResult', () => {
|
|
it('should add result and update totals', () => {
|
|
const assertionsResult = new AssertionsResult({});
|
|
const result: GradingResult = {
|
|
pass: true,
|
|
score: 0.8,
|
|
reason: 'Test passed',
|
|
tokensUsed: {
|
|
total: 100,
|
|
prompt: 50,
|
|
completion: 50,
|
|
cached: 0,
|
|
},
|
|
};
|
|
|
|
assertionsResult.addResult({
|
|
index: 0,
|
|
result,
|
|
metric: 'accuracy',
|
|
weight: 2,
|
|
});
|
|
|
|
expect(assertionsResult['totalScore']).toBe(1.6); // 0.8 * 2
|
|
expect(assertionsResult['totalWeight']).toBe(2);
|
|
expect(assertionsResult['tokensUsed']).toEqual({
|
|
total: 100,
|
|
prompt: 50,
|
|
completion: 50,
|
|
cached: 0,
|
|
numRequests: 0,
|
|
});
|
|
expect(assertionsResult['namedScores']).toEqual({
|
|
accuracy: 1.6,
|
|
});
|
|
});
|
|
|
|
it('should handle failed results', () => {
|
|
const assertionsResult = new AssertionsResult({});
|
|
const result: GradingResult = {
|
|
pass: false,
|
|
score: 0.3,
|
|
reason: 'Test failed',
|
|
tokensUsed: DEFAULT_TOKENS_USED,
|
|
};
|
|
|
|
assertionsResult.addResult({
|
|
index: 0,
|
|
result,
|
|
});
|
|
|
|
expect(assertionsResult['failedReason']).toBe('Test failed');
|
|
});
|
|
|
|
it('should throw error if short circuit enabled', () => {
|
|
vi.mocked(getEnvBool).mockReturnValue(true);
|
|
|
|
const assertionsResult = new AssertionsResult({});
|
|
const result: GradingResult = {
|
|
pass: false,
|
|
score: 0,
|
|
reason: 'Critical failure',
|
|
tokensUsed: DEFAULT_TOKENS_USED,
|
|
};
|
|
|
|
expect(() =>
|
|
assertionsResult.addResult({
|
|
index: 0,
|
|
result,
|
|
}),
|
|
).toThrow('Critical failure');
|
|
});
|
|
});
|
|
|
|
describe('testResult', () => {
|
|
it('should calculate final result with threshold', async () => {
|
|
const assertionsResult = new AssertionsResult({ threshold: 0.7 });
|
|
|
|
assertionsResult.addResult({
|
|
index: 0,
|
|
result: {
|
|
pass: true,
|
|
score: 0.6,
|
|
reason: 'Test 1',
|
|
tokensUsed: DEFAULT_TOKENS_USED,
|
|
},
|
|
weight: 1,
|
|
});
|
|
|
|
assertionsResult.addResult({
|
|
index: 1,
|
|
result: {
|
|
pass: true,
|
|
score: 0.8,
|
|
reason: 'Test 2',
|
|
tokensUsed: DEFAULT_TOKENS_USED,
|
|
},
|
|
weight: 1,
|
|
});
|
|
|
|
const result = await assertionsResult.testResult();
|
|
|
|
expect(result.pass).toBe(true);
|
|
expect(result.score).toBe(0.7);
|
|
expect(result.reason).toBe('Aggregate score 0.70 ≥ 0.7 threshold');
|
|
});
|
|
|
|
it('should honor a threshold of 0 as an override (never fail on individual assertion failures)', async () => {
|
|
const assertionsResult = new AssertionsResult({ threshold: 0 });
|
|
|
|
// A failing assertion — under the default all-pass logic this fails the test.
|
|
assertionsResult.addResult({
|
|
index: 0,
|
|
result: {
|
|
pass: false,
|
|
score: 0,
|
|
reason: 'Test 1 failed',
|
|
tokensUsed: DEFAULT_TOKENS_USED,
|
|
},
|
|
weight: 1,
|
|
});
|
|
|
|
// A passing assertion.
|
|
assertionsResult.addResult({
|
|
index: 1,
|
|
result: {
|
|
pass: true,
|
|
score: 1,
|
|
reason: 'Test 2 passed',
|
|
tokensUsed: DEFAULT_TOKENS_USED,
|
|
},
|
|
weight: 1,
|
|
});
|
|
|
|
const result = await assertionsResult.testResult();
|
|
|
|
// Aggregate score 0.5 ≥ 0 → the threshold override passes the test. Before the fix
|
|
// `if (this.threshold)` was falsy for 0, so the override was skipped and the failing
|
|
// assertion failed the whole test.
|
|
expect(result.pass).toBe(true);
|
|
expect(result.score).toBe(0.5);
|
|
expect(result.reason).toBe('Aggregate score 0.50 ≥ 0 threshold');
|
|
});
|
|
|
|
it('should pass at the threshold:0 boundary when every assertion fails (aggregate score 0)', async () => {
|
|
// The override the fix depends on is `0 >= 0`. With every assertion failing the
|
|
// aggregate score is exactly 0, which must still pass under threshold:0.
|
|
const assertionsResult = new AssertionsResult({ threshold: 0 });
|
|
assertionsResult.addResult({
|
|
index: 0,
|
|
result: { pass: false, score: 0, reason: 'failed', tokensUsed: DEFAULT_TOKENS_USED },
|
|
weight: 1,
|
|
});
|
|
|
|
const result = await assertionsResult.testResult();
|
|
|
|
expect(result.pass).toBe(true);
|
|
expect(result.score).toBe(0);
|
|
expect(result.reason).toBe('Aggregate score 0.00 ≥ 0 threshold');
|
|
});
|
|
|
|
it('should NOT force-pass when the threshold is null (e.g. an empty `threshold:` in YAML)', async () => {
|
|
// A null/NaN threshold is not a real score requirement. Gating the override on a
|
|
// numeric threshold keeps `score >= null` (always true) from silently passing every
|
|
// failing assertion; the default all-pass logic applies instead.
|
|
const assertionsResult = new AssertionsResult({ threshold: null as unknown as number });
|
|
assertionsResult.addResult({
|
|
index: 0,
|
|
result: { pass: false, score: 0, reason: 'Test failed', tokensUsed: DEFAULT_TOKENS_USED },
|
|
weight: 1,
|
|
});
|
|
|
|
const result = await assertionsResult.testResult();
|
|
|
|
expect(result.pass).toBe(false);
|
|
expect(result.reason).toBe('Test failed');
|
|
});
|
|
|
|
it('should honor an assert-set threshold of 0 (override + threshold survives in metadata)', async () => {
|
|
// The assert-set path (index.ts) builds an AssertionsResult with a parentAssertionSet,
|
|
// and flows through the same numeric-threshold override gate. A threshold of 0
|
|
// must still engage the override here, and `0` must round-trip into the assert-set metadata
|
|
// (buildAssertionSetMetadata uses `!== undefined`, not a truthy check).
|
|
const assertionsResult = new AssertionsResult({
|
|
threshold: 0,
|
|
parentAssertionSet: {
|
|
index: 0,
|
|
assertionSet: {
|
|
type: 'assert-set',
|
|
threshold: 0,
|
|
assert: [
|
|
{ type: 'equals', value: 'Hello world' },
|
|
{ type: 'contains', value: 'world' },
|
|
],
|
|
} as AssertionSet,
|
|
},
|
|
});
|
|
|
|
// A failing assertion — under the default all-pass logic this fails the assert-set.
|
|
assertionsResult.addResult({
|
|
index: 0,
|
|
result: {
|
|
pass: false,
|
|
score: 0,
|
|
reason: 'equals failed',
|
|
tokensUsed: DEFAULT_TOKENS_USED,
|
|
},
|
|
weight: 1,
|
|
});
|
|
assertionsResult.addResult({
|
|
index: 1,
|
|
result: {
|
|
pass: true,
|
|
score: 1,
|
|
reason: 'contains passed',
|
|
tokensUsed: DEFAULT_TOKENS_USED,
|
|
},
|
|
weight: 1,
|
|
});
|
|
|
|
const result = await assertionsResult.testResult();
|
|
|
|
expect(result.pass).toBe(true);
|
|
expect(result.score).toBe(0.5);
|
|
expect(result.reason).toBe('Aggregate score 0.50 ≥ 0 threshold');
|
|
expect(result.metadata?.assertionSet?.threshold).toBe(0);
|
|
});
|
|
|
|
it('should handle scoring function', async () => {
|
|
const assertionsResult = new AssertionsResult({});
|
|
const scoringFunction = vi.fn().mockResolvedValue({
|
|
pass: true,
|
|
score: 0.9,
|
|
reason: 'Custom scoring',
|
|
});
|
|
|
|
const result = await assertionsResult.testResult(scoringFunction);
|
|
|
|
expect(result.pass).toBe(true);
|
|
expect(result.score).toBe(0.9);
|
|
expect(result.reason).toBe('Custom scoring');
|
|
expect(scoringFunction).toHaveBeenCalledWith(
|
|
{},
|
|
{
|
|
threshold: undefined,
|
|
parentAssertionSet: undefined,
|
|
componentResults: [],
|
|
tokensUsed: DEFAULT_TOKENS_USED,
|
|
},
|
|
);
|
|
});
|
|
|
|
it('should handle scoring function errors', async () => {
|
|
const assertionsResult = new AssertionsResult({});
|
|
const scoringFunction = vi.fn().mockRejectedValue(new Error('Scoring failed'));
|
|
|
|
const result = await assertionsResult.testResult(scoringFunction);
|
|
|
|
expect(result.pass).toBe(false);
|
|
expect(result.score).toBe(0);
|
|
expect(result.reason).toBe('Scoring function error: Scoring failed');
|
|
});
|
|
|
|
it('should handle failed content safety checks', async () => {
|
|
const assertionsResult = new AssertionsResult({});
|
|
|
|
assertionsResult.addResult({
|
|
index: 0,
|
|
result: {
|
|
pass: false,
|
|
score: 0,
|
|
reason: 'Failed safety check',
|
|
assertion: {
|
|
type: 'guardrails',
|
|
config: {
|
|
purpose: 'redteam',
|
|
},
|
|
},
|
|
tokensUsed: DEFAULT_TOKENS_USED,
|
|
},
|
|
});
|
|
|
|
const result = await assertionsResult.testResult();
|
|
|
|
expect(result.pass).toBe(true);
|
|
expect(result.reason).toBe(GUARDRAIL_BLOCKED_REASON);
|
|
});
|
|
});
|
|
|
|
describe('namedScores weight normalization', () => {
|
|
it('should normalize a shared metric using assertion weights', async () => {
|
|
const assertionsResult = new AssertionsResult({});
|
|
|
|
assertionsResult.addResult({
|
|
index: 0,
|
|
result: {
|
|
pass: true,
|
|
score: 1,
|
|
reason: 'Critical signal passed',
|
|
tokensUsed: DEFAULT_TOKENS_USED,
|
|
},
|
|
metric: 'accuracy',
|
|
weight: 3,
|
|
});
|
|
|
|
assertionsResult.addResult({
|
|
index: 1,
|
|
result: {
|
|
pass: false,
|
|
score: 0,
|
|
reason: 'Optional signal failed',
|
|
tokensUsed: DEFAULT_TOKENS_USED,
|
|
},
|
|
metric: 'accuracy',
|
|
weight: 1,
|
|
});
|
|
|
|
const result = await assertionsResult.testResult();
|
|
|
|
// accuracy: (1 * 3 + 0 * 1) / (3 + 1) = 0.75
|
|
expect(result.namedScores!['accuracy']).toBeCloseTo(0.75);
|
|
expect(result.namedScoreWeights).toEqual({
|
|
accuracy: 4,
|
|
});
|
|
});
|
|
|
|
it('should apply different weights to named metrics and normalize correctly', async () => {
|
|
const assertionsResult = new AssertionsResult({});
|
|
|
|
assertionsResult.addResult({
|
|
index: 0,
|
|
result: {
|
|
pass: true,
|
|
score: 0.6,
|
|
reason: 'Test 1',
|
|
tokensUsed: DEFAULT_TOKENS_USED,
|
|
},
|
|
metric: 'relevance',
|
|
weight: 3,
|
|
});
|
|
|
|
assertionsResult.addResult({
|
|
index: 1,
|
|
result: {
|
|
pass: true,
|
|
score: 0.9,
|
|
reason: 'Test 2',
|
|
tokensUsed: DEFAULT_TOKENS_USED,
|
|
},
|
|
metric: 'clarity',
|
|
weight: 1,
|
|
});
|
|
|
|
const result = await assertionsResult.testResult();
|
|
|
|
// relevance: (0.6 * 3) / 3 = 0.6
|
|
expect(result.namedScores!['relevance']).toBeCloseTo(0.6);
|
|
// clarity: (0.9 * 1) / 1 = 0.9
|
|
expect(result.namedScores!['clarity']).toBeCloseTo(0.9);
|
|
expect(result.namedScoreWeights).toEqual({
|
|
relevance: 3,
|
|
clarity: 1,
|
|
});
|
|
});
|
|
|
|
it('should produce unchanged namedScores when weights are equal', async () => {
|
|
const assertionsResult = new AssertionsResult({});
|
|
|
|
assertionsResult.addResult({
|
|
index: 0,
|
|
result: {
|
|
pass: true,
|
|
score: 0.5,
|
|
reason: 'Test 1',
|
|
tokensUsed: DEFAULT_TOKENS_USED,
|
|
},
|
|
metric: 'accuracy',
|
|
weight: 2,
|
|
});
|
|
|
|
assertionsResult.addResult({
|
|
index: 1,
|
|
result: {
|
|
pass: true,
|
|
score: 0.7,
|
|
reason: 'Test 2',
|
|
tokensUsed: DEFAULT_TOKENS_USED,
|
|
},
|
|
metric: 'accuracy',
|
|
weight: 2,
|
|
});
|
|
|
|
const result = await assertionsResult.testResult();
|
|
|
|
// accuracy: (0.5 * 2 + 0.7 * 2) / (2 + 2) = 2.4 / 4 = 0.6
|
|
expect(result.namedScores!['accuracy']).toBeCloseTo(0.6);
|
|
expect(result.namedScoreWeights).toEqual({
|
|
accuracy: 4,
|
|
});
|
|
});
|
|
|
|
it('should compute weighted averages for the same metric with unequal weights', async () => {
|
|
const assertionsResult = new AssertionsResult({});
|
|
|
|
assertionsResult.addResult({
|
|
index: 0,
|
|
result: {
|
|
pass: true,
|
|
score: 0.4,
|
|
reason: 'Test 1',
|
|
tokensUsed: DEFAULT_TOKENS_USED,
|
|
},
|
|
metric: 'accuracy',
|
|
weight: 1,
|
|
});
|
|
|
|
assertionsResult.addResult({
|
|
index: 1,
|
|
result: {
|
|
pass: true,
|
|
score: 0.8,
|
|
reason: 'Test 2',
|
|
tokensUsed: DEFAULT_TOKENS_USED,
|
|
},
|
|
metric: 'accuracy',
|
|
weight: 3,
|
|
});
|
|
|
|
const result = await assertionsResult.testResult();
|
|
|
|
// accuracy: (0.4 * 1 + 0.8 * 3) / (1 + 3) = 0.7
|
|
expect(result.namedScores!['accuracy']).toBeCloseTo(0.7);
|
|
expect(result.namedScoreWeights).toEqual({
|
|
accuracy: 4,
|
|
});
|
|
});
|
|
|
|
it('should handle weight 0 for named metric correctly', async () => {
|
|
const assertionsResult = new AssertionsResult({});
|
|
|
|
assertionsResult.addResult({
|
|
index: 0,
|
|
result: {
|
|
pass: true,
|
|
score: 0.8,
|
|
reason: 'Test 1',
|
|
tokensUsed: DEFAULT_TOKENS_USED,
|
|
},
|
|
metric: 'safety',
|
|
weight: 0,
|
|
});
|
|
|
|
const result = await assertionsResult.testResult();
|
|
|
|
// weight 0: (0.8 * 0) / 0 → 0 (division guarded)
|
|
expect(result.namedScores!['safety']).toBe(0);
|
|
expect(result.namedScoreWeights).toEqual({
|
|
safety: 0,
|
|
});
|
|
});
|
|
|
|
it('should preserve nested namedScoreWeights when merging child named scores', async () => {
|
|
const assertionsResult = new AssertionsResult({});
|
|
|
|
assertionsResult.addResult({
|
|
index: 0,
|
|
result: {
|
|
pass: false,
|
|
score: 0.75,
|
|
reason: 'Nested assertion set partially failed',
|
|
tokensUsed: DEFAULT_TOKENS_USED,
|
|
namedScores: {
|
|
accuracy: 0.75,
|
|
},
|
|
namedScoreWeights: {
|
|
accuracy: 4,
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await assertionsResult.testResult();
|
|
|
|
expect(result.namedScores!['accuracy']).toBeCloseTo(0.75);
|
|
expect(result.namedScoreWeights).toEqual({
|
|
accuracy: 4,
|
|
});
|
|
});
|
|
|
|
it('should scale nested namedScoreWeights by parent assertion weight', async () => {
|
|
const assertionsResult = new AssertionsResult({});
|
|
|
|
assertionsResult.addResult({
|
|
index: 0,
|
|
result: {
|
|
pass: true,
|
|
score: 0.75,
|
|
reason: 'Nested assertion set passed',
|
|
tokensUsed: DEFAULT_TOKENS_USED,
|
|
namedScores: {
|
|
accuracy: 0.75,
|
|
},
|
|
namedScoreWeights: {
|
|
accuracy: 4,
|
|
},
|
|
},
|
|
weight: 2,
|
|
});
|
|
|
|
const result = await assertionsResult.testResult();
|
|
|
|
expect(result.namedScores!['accuracy']).toBeCloseTo(0.75);
|
|
expect(result.namedScoreWeights).toEqual({
|
|
accuracy: 8,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('parentAssertionSet', () => {
|
|
it('should return parent assertion set', () => {
|
|
const parentSet = {
|
|
index: 1,
|
|
assertionSet: {
|
|
type: 'assert-set',
|
|
assert: [
|
|
{
|
|
type: 'contains-any',
|
|
},
|
|
],
|
|
} as AssertionSet,
|
|
};
|
|
const assertionsResult = new AssertionsResult({ parentAssertionSet: parentSet });
|
|
|
|
expect(assertionsResult.parentAssertionSet).toBe(parentSet);
|
|
});
|
|
});
|
|
});
|