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
500 lines
17 KiB
TypeScript
500 lines
17 KiB
TypeScript
/**
|
|
* CRITICAL TEST: WHERE clause consistency between pagination and metrics.
|
|
*
|
|
* This test ensures that queryTestIndices() and getFilteredMetrics() use
|
|
* the EXACT SAME WHERE clause logic, preventing silent data corruption where
|
|
* metrics don't match the displayed results.
|
|
*
|
|
* If these tests fail, it means the two methods have diverged and metrics
|
|
* will be inaccurate!
|
|
*/
|
|
|
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { getDb } from '../../src/database/index';
|
|
import { runDbMigrations } from '../../src/migrate';
|
|
import EvalFactory from '../factories/evalFactory';
|
|
|
|
describe('Filtered Metrics - WHERE Clause Consistency', () => {
|
|
beforeAll(async () => {
|
|
await runDbMigrations();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
// Clear all tables before each test
|
|
const db = await getDb();
|
|
await db.run('DELETE FROM eval_results');
|
|
await db.run('DELETE FROM evals_to_datasets');
|
|
await db.run('DELETE FROM evals_to_prompts');
|
|
await db.run('DELETE FROM evals_to_tags');
|
|
await db.run('DELETE FROM evals');
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.resetAllMocks();
|
|
});
|
|
|
|
/**
|
|
* CRITICAL TEST #1: Row count consistency
|
|
*
|
|
* The number of rows in pagination MUST match the sum of test counts in metrics.
|
|
* If this fails, the WHERE clauses have diverged!
|
|
*/
|
|
describe('CRITICAL: Row count consistency', () => {
|
|
it('should return same row count for pagination and metrics with no filters', async () => {
|
|
const eval_ = await EvalFactory.create({
|
|
numResults: 20,
|
|
resultTypes: ['success', 'error', 'failure'],
|
|
});
|
|
|
|
const { testIndices } = await (eval_ as any).queryTestIndices({});
|
|
const metrics = await eval_.getFilteredMetrics({});
|
|
|
|
const metricsTotal = metrics.reduce(
|
|
(sum, m) => sum + m.testPassCount + m.testFailCount + m.testErrorCount,
|
|
0,
|
|
);
|
|
|
|
expect(testIndices.length).toBe(metricsTotal);
|
|
});
|
|
|
|
it('should return same row count for pagination and metrics with filterMode=errors', async () => {
|
|
const eval_ = await EvalFactory.create({
|
|
numResults: 20,
|
|
resultTypes: ['success', 'error', 'failure'],
|
|
});
|
|
|
|
const { testIndices } = await (eval_ as any).queryTestIndices({ filterMode: 'errors' });
|
|
const metrics = await eval_.getFilteredMetrics({ filterMode: 'errors' });
|
|
|
|
const metricsTotal = metrics.reduce(
|
|
(sum, m) => sum + m.testPassCount + m.testFailCount + m.testErrorCount,
|
|
0,
|
|
);
|
|
|
|
expect(testIndices.length).toBe(metricsTotal);
|
|
expect(testIndices.length).toBeGreaterThan(0); // Sanity check
|
|
});
|
|
|
|
it('should return same row count for pagination and metrics with filterMode=failures', async () => {
|
|
const eval_ = await EvalFactory.create({
|
|
numResults: 20,
|
|
resultTypes: ['success', 'error', 'failure'],
|
|
});
|
|
|
|
const { testIndices } = await (eval_ as any).queryTestIndices({ filterMode: 'failures' });
|
|
const metrics = await eval_.getFilteredMetrics({ filterMode: 'failures' });
|
|
|
|
const metricsTotal = metrics.reduce(
|
|
(sum, m) => sum + m.testPassCount + m.testFailCount + m.testErrorCount,
|
|
0,
|
|
);
|
|
|
|
expect(testIndices.length).toBe(metricsTotal);
|
|
expect(testIndices.length).toBeGreaterThan(0); // Sanity check
|
|
});
|
|
|
|
it('should return same row count for pagination and metrics with filterMode=passes', async () => {
|
|
const eval_ = await EvalFactory.create({
|
|
numResults: 20,
|
|
resultTypes: ['success', 'error', 'failure'],
|
|
});
|
|
|
|
const { testIndices } = await (eval_ as any).queryTestIndices({ filterMode: 'passes' });
|
|
const metrics = await eval_.getFilteredMetrics({ filterMode: 'passes' });
|
|
|
|
const metricsTotal = metrics.reduce(
|
|
(sum, m) => sum + m.testPassCount + m.testFailCount + m.testErrorCount,
|
|
0,
|
|
);
|
|
|
|
expect(testIndices.length).toBe(metricsTotal);
|
|
expect(testIndices.length).toBeGreaterThan(0); // Sanity check
|
|
});
|
|
|
|
it('should return same row count for pagination and metrics with filterMode=highlights', async () => {
|
|
const eval_ = await EvalFactory.create({
|
|
numResults: 20,
|
|
resultTypes: ['success', 'error', 'failure'],
|
|
withHighlights: true,
|
|
});
|
|
|
|
const { testIndices } = await (eval_ as any).queryTestIndices({
|
|
filterMode: 'highlights',
|
|
});
|
|
const metrics = await eval_.getFilteredMetrics({ filterMode: 'highlights' });
|
|
|
|
const metricsTotal = metrics.reduce(
|
|
(sum, m) => sum + m.testPassCount + m.testFailCount + m.testErrorCount,
|
|
0,
|
|
);
|
|
|
|
expect(testIndices.length).toBe(metricsTotal);
|
|
expect(testIndices.length).toBeGreaterThan(0); // Sanity check
|
|
});
|
|
|
|
it('should return same row count for pagination and metrics with searchQuery', async () => {
|
|
const eval_ = await EvalFactory.create({
|
|
numResults: 20,
|
|
resultTypes: ['success', 'error', 'failure'],
|
|
searchableContent: 'searchable_content',
|
|
});
|
|
|
|
const searchQuery = 'searchable_content';
|
|
const { testIndices } = await (eval_ as any).queryTestIndices({ searchQuery });
|
|
const metrics = await eval_.getFilteredMetrics({ searchQuery });
|
|
|
|
const metricsTotal = metrics.reduce(
|
|
(sum, m) => sum + m.testPassCount + m.testFailCount + m.testErrorCount,
|
|
0,
|
|
);
|
|
|
|
expect(testIndices.length).toBe(metricsTotal);
|
|
expect(testIndices.length).toBeGreaterThan(0); // Sanity check
|
|
});
|
|
|
|
it('should return same row count for pagination and metrics with metadata filter', async () => {
|
|
const eval_ = await EvalFactory.create({
|
|
numResults: 10,
|
|
resultTypes: ['success', 'failure'],
|
|
});
|
|
|
|
// Add metadata to some rows
|
|
const db = await getDb();
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"source":"unit"}') WHERE eval_id = '${eval_.id}' AND test_idx IN (1, 3, 5)`,
|
|
);
|
|
|
|
const filters = [
|
|
JSON.stringify({
|
|
logicOperator: 'and',
|
|
type: 'metadata',
|
|
operator: 'equals',
|
|
field: 'source',
|
|
value: 'unit',
|
|
}),
|
|
];
|
|
|
|
const { testIndices } = await (eval_ as any).queryTestIndices({ filters });
|
|
const metrics = await eval_.getFilteredMetrics({ filters });
|
|
|
|
const metricsTotal = metrics.reduce(
|
|
(sum, m) => sum + m.testPassCount + m.testFailCount + m.testErrorCount,
|
|
0,
|
|
);
|
|
|
|
expect(testIndices.length).toBe(metricsTotal);
|
|
expect(testIndices.length).toBe(3); // Sanity check
|
|
});
|
|
|
|
it('should return same row count for pagination and metrics with plugin filter', async () => {
|
|
const eval_ = await EvalFactory.create({
|
|
numResults: 10,
|
|
resultTypes: ['success', 'failure'],
|
|
});
|
|
|
|
const db = await getDb();
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"pluginId":"harmful:harassment"}') WHERE eval_id = '${eval_.id}' AND test_idx IN (2, 4, 6)`,
|
|
);
|
|
|
|
const filters = [
|
|
JSON.stringify({
|
|
logicOperator: 'and',
|
|
type: 'plugin',
|
|
operator: 'equals',
|
|
value: 'harmful',
|
|
}),
|
|
];
|
|
|
|
const { testIndices } = await (eval_ as any).queryTestIndices({ filters });
|
|
const metrics = await eval_.getFilteredMetrics({ filters });
|
|
|
|
const metricsTotal = metrics.reduce(
|
|
(sum, m) => sum + m.testPassCount + m.testFailCount + m.testErrorCount,
|
|
0,
|
|
);
|
|
|
|
expect(testIndices.length).toBe(metricsTotal);
|
|
expect(testIndices.length).toBe(3); // Sanity check
|
|
});
|
|
|
|
it('should return same row count for pagination and metrics with strategy filter', async () => {
|
|
const eval_ = await EvalFactory.create({
|
|
numResults: 10,
|
|
resultTypes: ['success', 'failure'],
|
|
});
|
|
|
|
const db = await getDb();
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"strategyId":"jailbreak"}') WHERE eval_id = '${eval_.id}' AND test_idx IN (1, 2, 3)`,
|
|
);
|
|
|
|
const filters = [
|
|
JSON.stringify({
|
|
logicOperator: 'and',
|
|
type: 'strategy',
|
|
operator: 'equals',
|
|
value: 'jailbreak',
|
|
}),
|
|
];
|
|
|
|
const { testIndices } = await (eval_ as any).queryTestIndices({ filters });
|
|
const metrics = await eval_.getFilteredMetrics({ filters });
|
|
|
|
const metricsTotal = metrics.reduce(
|
|
(sum, m) => sum + m.testPassCount + m.testFailCount + m.testErrorCount,
|
|
0,
|
|
);
|
|
|
|
expect(testIndices.length).toBe(metricsTotal);
|
|
expect(testIndices.length).toBe(3); // Sanity check
|
|
});
|
|
|
|
it('should return same row count for pagination and metrics with severity filter', async () => {
|
|
const eval_ = await EvalFactory.create({
|
|
numResults: 10,
|
|
resultTypes: ['success', 'failure'],
|
|
});
|
|
|
|
const db = await getDb();
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"severity":"high"}') WHERE eval_id = '${eval_.id}' AND test_idx IN (0, 5)`,
|
|
);
|
|
|
|
const filters = [
|
|
JSON.stringify({
|
|
logicOperator: 'and',
|
|
type: 'severity',
|
|
operator: 'equals',
|
|
value: 'high',
|
|
}),
|
|
];
|
|
|
|
const { testIndices } = await (eval_ as any).queryTestIndices({ filters });
|
|
const metrics = await eval_.getFilteredMetrics({ filters });
|
|
|
|
const metricsTotal = metrics.reduce(
|
|
(sum, m) => sum + m.testPassCount + m.testFailCount + m.testErrorCount,
|
|
0,
|
|
);
|
|
|
|
expect(testIndices.length).toBe(metricsTotal);
|
|
expect(testIndices.length).toBe(2); // Sanity check
|
|
});
|
|
|
|
it('should return same row count for pagination and metrics with metric filter', async () => {
|
|
const eval_ = await EvalFactory.create({
|
|
numResults: 10,
|
|
resultTypes: ['success', 'failure'],
|
|
withNamedScores: true,
|
|
});
|
|
|
|
const filters = [
|
|
JSON.stringify({
|
|
logicOperator: 'and',
|
|
type: 'metric',
|
|
operator: 'equals',
|
|
value: 'accuracy',
|
|
}),
|
|
];
|
|
|
|
const { testIndices } = await (eval_ as any).queryTestIndices({ filters });
|
|
const metrics = await eval_.getFilteredMetrics({ filters });
|
|
|
|
const metricsTotal = metrics.reduce(
|
|
(sum, m) => sum + m.testPassCount + m.testFailCount + m.testErrorCount,
|
|
0,
|
|
);
|
|
|
|
expect(testIndices.length).toBe(metricsTotal);
|
|
expect(testIndices.length).toBe(10); // All have named scores
|
|
});
|
|
|
|
it('should return same row count for pagination and metrics with combined filters', async () => {
|
|
const eval_ = await EvalFactory.create({
|
|
numResults: 20,
|
|
resultTypes: ['success', 'error', 'failure'],
|
|
withHighlights: true,
|
|
withNamedScores: true,
|
|
searchableContent: 'searchable',
|
|
});
|
|
|
|
const db = await getDb();
|
|
await db.run(
|
|
`UPDATE eval_results SET metadata = json('{"source":"unit","severity":"high"}') WHERE eval_id = '${eval_.id}' AND test_idx IN (3, 6, 9)`,
|
|
);
|
|
|
|
const filters = [
|
|
JSON.stringify({
|
|
logicOperator: 'and',
|
|
type: 'metadata',
|
|
operator: 'equals',
|
|
field: 'source',
|
|
value: 'unit',
|
|
}),
|
|
];
|
|
|
|
const { testIndices } = await (eval_ as any).queryTestIndices({
|
|
filterMode: 'failures',
|
|
searchQuery: 'searchable',
|
|
filters,
|
|
});
|
|
|
|
const metrics = await eval_.getFilteredMetrics({
|
|
filterMode: 'failures',
|
|
searchQuery: 'searchable',
|
|
filters,
|
|
});
|
|
|
|
const metricsTotal = metrics.reduce(
|
|
(sum, m) => sum + m.testPassCount + m.testFailCount + m.testErrorCount,
|
|
0,
|
|
);
|
|
|
|
expect(testIndices.length).toBe(metricsTotal);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* CRITICAL TEST #2: Empty result handling
|
|
*
|
|
* When no results match the filter, both methods must return zero counts.
|
|
*/
|
|
describe('CRITICAL: Empty result handling', () => {
|
|
it('should return zero counts when no results match filter', async () => {
|
|
const eval_ = await EvalFactory.create({
|
|
numResults: 10,
|
|
resultTypes: ['success'],
|
|
});
|
|
|
|
// Filter for errors when there are none
|
|
const { testIndices } = await (eval_ as any).queryTestIndices({ filterMode: 'errors' });
|
|
const metrics = await eval_.getFilteredMetrics({ filterMode: 'errors' });
|
|
|
|
const metricsTotal = metrics.reduce(
|
|
(sum, m) => sum + m.testPassCount + m.testFailCount + m.testErrorCount,
|
|
0,
|
|
);
|
|
|
|
expect(testIndices.length).toBe(0);
|
|
expect(metricsTotal).toBe(0);
|
|
});
|
|
|
|
it('should return zero counts when search matches nothing', async () => {
|
|
const eval_ = await EvalFactory.create({
|
|
numResults: 10,
|
|
resultTypes: ['success', 'failure'],
|
|
});
|
|
|
|
const searchQuery = 'nonexistent_search_term_xyz';
|
|
const { testIndices } = await (eval_ as any).queryTestIndices({ searchQuery });
|
|
const metrics = await eval_.getFilteredMetrics({ searchQuery });
|
|
|
|
const metricsTotal = metrics.reduce(
|
|
(sum, m) => sum + m.testPassCount + m.testFailCount + m.testErrorCount,
|
|
0,
|
|
);
|
|
|
|
expect(testIndices.length).toBe(0);
|
|
expect(metricsTotal).toBe(0);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* CRITICAL TEST #3: Metrics correctness
|
|
*
|
|
* Not only should the counts match, but the actual metrics should be correct
|
|
* based on the filtered dataset.
|
|
*/
|
|
describe('CRITICAL: Metrics correctness', () => {
|
|
it('should calculate correct metrics for filtered failures', async () => {
|
|
const eval_ = await EvalFactory.create({
|
|
numResults: 20,
|
|
resultTypes: ['success', 'error', 'failure'], // Cycles: success, error, failure, success, error, failure...
|
|
});
|
|
|
|
const metrics = await eval_.getFilteredMetrics({ filterMode: 'failures' });
|
|
|
|
// With cycling pattern, we expect roughly 1/3 failures
|
|
const totalTests = metrics.reduce(
|
|
(sum, m) => sum + m.testPassCount + m.testFailCount + m.testErrorCount,
|
|
0,
|
|
);
|
|
|
|
const failCount = metrics.reduce((sum, m) => sum + m.testFailCount, 0);
|
|
|
|
expect(totalTests).toBeGreaterThan(0);
|
|
expect(failCount).toBe(totalTests); // All filtered results should be failures
|
|
expect(metrics[0].testPassCount).toBe(0); // No passes in failure filter
|
|
expect(metrics[0].testErrorCount).toBe(0); // No errors in failure filter
|
|
});
|
|
|
|
it('should calculate correct metrics for filtered passes', async () => {
|
|
const eval_ = await EvalFactory.create({
|
|
numResults: 20,
|
|
resultTypes: ['success', 'error', 'failure'],
|
|
});
|
|
|
|
const metrics = await eval_.getFilteredMetrics({ filterMode: 'passes' });
|
|
|
|
const totalTests = metrics.reduce(
|
|
(sum, m) => sum + m.testPassCount + m.testFailCount + m.testErrorCount,
|
|
0,
|
|
);
|
|
|
|
const passCount = metrics.reduce((sum, m) => sum + m.testPassCount, 0);
|
|
|
|
expect(totalTests).toBeGreaterThan(0);
|
|
expect(passCount).toBe(totalTests); // All filtered results should be passes
|
|
expect(metrics[0].testFailCount).toBe(0); // No failures in pass filter
|
|
expect(metrics[0].testErrorCount).toBe(0); // No errors in pass filter
|
|
});
|
|
|
|
it('should calculate correct metrics for filtered errors', async () => {
|
|
const eval_ = await EvalFactory.create({
|
|
numResults: 20,
|
|
resultTypes: ['success', 'error', 'failure'],
|
|
});
|
|
|
|
const metrics = await eval_.getFilteredMetrics({ filterMode: 'errors' });
|
|
|
|
const totalTests = metrics.reduce(
|
|
(sum, m) => sum + m.testPassCount + m.testFailCount + m.testErrorCount,
|
|
0,
|
|
);
|
|
|
|
const errorCount = metrics.reduce((sum, m) => sum + m.testErrorCount, 0);
|
|
|
|
expect(totalTests).toBeGreaterThan(0);
|
|
expect(errorCount).toBe(totalTests); // All filtered results should be errors
|
|
expect(metrics[0].testPassCount).toBe(0); // No passes in error filter
|
|
expect(metrics[0].testFailCount).toBe(0); // No failures in error filter
|
|
});
|
|
|
|
it('should include named scores only from filtered results', async () => {
|
|
const eval_ = await EvalFactory.create({
|
|
numResults: 10,
|
|
resultTypes: ['success', 'failure'],
|
|
withNamedScores: true,
|
|
});
|
|
|
|
// All results have accuracy and relevance scores
|
|
const allMetrics = await eval_.getFilteredMetrics({});
|
|
const passMetrics = await eval_.getFilteredMetrics({ filterMode: 'passes' });
|
|
|
|
// Both should have named scores
|
|
expect(allMetrics[0].namedScores).toHaveProperty('accuracy');
|
|
expect(allMetrics[0].namedScores).toHaveProperty('relevance');
|
|
expect(passMetrics[0].namedScores).toHaveProperty('accuracy');
|
|
expect(passMetrics[0].namedScores).toHaveProperty('relevance');
|
|
|
|
// Pass metrics should have LOWER accuracy sum than all metrics
|
|
// (5 passes at 1.0 each = 5.0 total) < (5 passes at 1.0 + 5 failures at 0.2 = 6.0 total)
|
|
expect(passMetrics[0].namedScores.accuracy).toBeLessThan(allMetrics[0].namedScores.accuracy);
|
|
|
|
// But the pass metrics should have non-zero accuracy
|
|
expect(passMetrics[0].namedScores.accuracy).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
});
|