Files
promptfoo--promptfoo/test/assertions/similar.test.ts
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

582 lines
17 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import { handleSimilar } from '../../src/assertions/similar';
import { matchesSimilarity } from '../../src/matchers/similarity';
import { createMockProvider } from '../factories/provider';
vi.mock('../../src/matchers/similarity', () => ({
matchesSimilarity: vi.fn().mockImplementation(async (expected, output, _threshold, inverse) => {
if (inverse) {
return {
pass: expected !== output,
score: expected === output ? 0 : 1,
};
}
return {
pass: expected === output,
score: expected === output ? 1 : 0,
};
}),
}));
describe('handleSimilar', () => {
it('should handle string similarity assertion', async () => {
const result = await handleSimilar({
assertion: {
type: 'similar',
value: 'hello world',
},
baseType: 'similar' as any,
renderedValue: 'hello world',
outputString: 'hello world',
inverse: false,
test: {
description: 'test',
vars: {},
assert: [],
options: {},
},
assertionValueContext: {
prompt: 'test prompt',
vars: {},
test: {
description: 'test',
vars: {},
assert: [],
options: {},
},
logProbs: undefined,
// @ts-ignore
provider: createMockProvider({ response: {} }),
providerResponse: { output: 'hello world' },
},
output: 'hello world',
providerResponse: { output: 'hello world' },
});
expect(result.pass).toBe(true);
expect(result.score).toBe(1);
});
it('should handle array of strings similarity assertion', async () => {
const result = await handleSimilar({
assertion: {
type: 'similar',
value: ['hello world', 'hi world'],
},
baseType: 'similar' as any,
renderedValue: ['hello world', 'hi world'],
outputString: 'hello world',
inverse: false,
test: {
description: 'test',
vars: {},
assert: [],
options: {},
},
assertionValueContext: {
prompt: 'test prompt',
vars: {},
test: {
description: 'test',
vars: {},
assert: [],
options: {},
},
logProbs: undefined,
// @ts-ignore
provider: createMockProvider({ response: {} }),
providerResponse: { output: 'hello world' },
},
output: 'hello world',
providerResponse: { output: 'hello world' },
});
expect(result.pass).toBe(true);
expect(result.score).toBe(1);
});
it('should handle custom threshold', async () => {
const result = await handleSimilar({
assertion: {
type: 'similar',
value: 'hello world',
threshold: 0.5,
},
baseType: 'similar' as any,
renderedValue: 'hello world',
outputString: 'hello world',
inverse: false,
test: {
description: 'test',
vars: {},
assert: [],
options: {},
},
assertionValueContext: {
prompt: 'test prompt',
vars: {},
test: {
description: 'test',
vars: {},
assert: [],
options: {},
},
logProbs: undefined,
// @ts-ignore
provider: createMockProvider({ response: {} }),
providerResponse: { output: 'hello world' },
},
output: 'hello world',
providerResponse: { output: 'hello world' },
});
expect(result.pass).toBe(true);
});
it('should handle inverse similarity assertion', async () => {
const result = await handleSimilar({
assertion: {
type: 'similar',
value: 'hello world',
},
baseType: 'similar' as any,
renderedValue: 'hello world',
outputString: 'completely different',
inverse: true,
test: {
description: 'test',
vars: {},
assert: [],
options: {},
},
assertionValueContext: {
prompt: 'test prompt',
vars: {},
test: {
description: 'test',
vars: {},
assert: [],
options: {},
},
logProbs: undefined,
// @ts-ignore
provider: createMockProvider({ response: {} }),
providerResponse: { output: 'completely different' },
},
output: 'completely different',
providerResponse: { output: 'completely different' },
});
expect(result.pass).toBe(true);
});
it('should fail when no array values meet threshold', async () => {
const result = await handleSimilar({
assertion: {
type: 'similar',
value: ['hello world', 'hi world'],
threshold: 0.9,
},
baseType: 'similar' as any,
renderedValue: ['hello world', 'hi world'],
outputString: 'completely different',
inverse: false,
test: {
description: 'test',
vars: {},
assert: [],
options: {},
},
assertionValueContext: {
prompt: 'test prompt',
vars: {},
test: {
description: 'test',
vars: {},
assert: [],
options: {},
},
logProbs: undefined,
// @ts-ignore
provider: createMockProvider({ response: {} }),
providerResponse: { output: 'completely different' },
},
output: 'completely different',
providerResponse: { output: 'completely different' },
});
expect(result.pass).toBe(false);
expect(result.reason).toBe('None of the provided values met the similarity threshold');
expect(result.score).toBe(0);
});
it('should report the best (highest) score when no array values meet threshold', async () => {
// Neither value passes, but the output is closer to the second one.
vi.mocked(matchesSimilarity)
.mockResolvedValueOnce({ pass: false, score: 0.2 } as any)
.mockResolvedValueOnce({ pass: false, score: 0.6 } as any);
const result = await handleSimilar({
assertion: {
type: 'similar',
value: ['far value', 'closer value'],
threshold: 0.9,
},
baseType: 'similar' as any,
renderedValue: ['far value', 'closer value'],
outputString: 'some output',
inverse: false,
test: {
description: 'test',
vars: {},
assert: [],
options: {},
},
assertionValueContext: {
prompt: 'test prompt',
vars: {},
test: {
description: 'test',
vars: {},
assert: [],
options: {},
},
logProbs: undefined,
// @ts-ignore
provider: createMockProvider({ response: {} }),
providerResponse: { output: 'some output' },
},
output: 'some output',
providerResponse: { output: 'some output' },
});
expect(result.pass).toBe(false);
// Report how close the output got to its closest value (0.6), not the worst (0.2).
expect(result.score).toBe(0.6);
});
it('should FAIL not-similar with an array when the output is similar to ANY value', async () => {
// not-similar means "dissimilar to ALL". The output is too similar to the
// first value, so the inverse check fails there and the assertion returns
// immediately — it must fail rather than continue and pass on a later value.
// Only the first value is evaluated, so we queue a single result (queuing a
// second would leak into the next test, which runs in random order).
vi.mocked(matchesSimilarity).mockResolvedValueOnce({
pass: false,
score: 0.05,
reason: 'too similar',
} as any);
const result = await handleSimilar({
assertion: {
type: 'similar',
value: ['forbidden answer A', 'unrelated answer B'],
threshold: 0.75,
},
baseType: 'similar' as any,
renderedValue: ['forbidden answer A', 'unrelated answer B'],
outputString: 'forbidden answer A',
inverse: true,
test: {
description: 'test',
vars: {},
assert: [],
options: {},
},
assertionValueContext: {
prompt: 'test prompt',
vars: {},
test: { description: 'test', vars: {}, assert: [], options: {} },
logProbs: undefined,
// @ts-ignore
provider: createMockProvider({ response: {} }),
providerResponse: { output: 'forbidden answer A' },
},
output: 'forbidden answer A',
providerResponse: { output: 'forbidden answer A' },
});
// Before the fix this returned pass: true (dissimilar to the second value
// short-circuited the loop). The output is identical to value A, so it must fail.
expect(result.pass).toBe(false);
expect(result.score).toBe(0.05);
});
it('should PASS not-similar with an array only when dissimilar to ALL values', async () => {
// Dissimilar to every value → not-similar passes; report the lowest score
// (the value it came closest to / tightest margin).
vi.mocked(matchesSimilarity)
.mockResolvedValueOnce({ pass: true, score: 0.8, reason: 'dissimilar' } as any)
.mockResolvedValueOnce({ pass: true, score: 0.95, reason: 'dissimilar' } as any);
const result = await handleSimilar({
assertion: {
type: 'similar',
value: ['forbidden answer A', 'forbidden answer B'],
threshold: 0.75,
},
baseType: 'similar' as any,
renderedValue: ['forbidden answer A', 'forbidden answer B'],
outputString: 'a totally different response',
inverse: true,
test: {
description: 'test',
vars: {},
assert: [],
options: {},
},
assertionValueContext: {
prompt: 'test prompt',
vars: {},
test: { description: 'test', vars: {}, assert: [], options: {} },
logProbs: undefined,
// @ts-ignore
provider: createMockProvider({ response: {} }),
providerResponse: { output: 'a totally different response' },
},
output: 'a totally different response',
providerResponse: { output: 'a totally different response' },
});
expect(result.pass).toBe(true);
expect(result.score).toBe(0.8);
});
it('should report the closest (lowest) score across 3+ values when dissimilar to ALL (not-similar)', async () => {
// Dissimilar to all three; the lowest score is in the MIDDLE, proving the
// min-selection picks the tightest margin regardless of position (not just
// first/last). The full closest result (score + reason) is propagated.
vi.mocked(matchesSimilarity)
.mockResolvedValueOnce({ pass: true, score: 0.9, reason: 'dissimilar to A' } as any)
.mockResolvedValueOnce({ pass: true, score: 0.78, reason: 'closest to B' } as any)
.mockResolvedValueOnce({ pass: true, score: 0.95, reason: 'dissimilar to C' } as any);
const result = await handleSimilar({
assertion: { type: 'similar', value: ['A', 'B', 'C'], threshold: 0.75 },
renderedValue: ['A', 'B', 'C'],
outputString: 'a totally different response',
inverse: true,
test: { description: 'test', vars: {}, assert: [], options: {} },
} as any);
expect(result.pass).toBe(true);
expect(result.score).toBe(0.78);
expect(result.reason).toBe('closest to B');
});
it('should throw error for invalid renderedValue type', async () => {
await expect(
handleSimilar({
assertion: {
type: 'similar',
value: 'test',
},
baseType: 'similar' as any,
renderedValue: 123 as any,
outputString: 'test',
inverse: false,
test: {
description: 'test',
vars: {},
assert: [],
options: {},
},
assertionValueContext: {
prompt: 'test prompt',
vars: {},
test: {
description: 'test',
vars: {},
assert: [],
options: {},
},
logProbs: undefined,
// @ts-ignore
provider: createMockProvider({ response: {} }),
providerResponse: { output: 'test' },
},
output: 'test',
providerResponse: { output: 'test' },
}),
).rejects.toThrow('Similarity assertion type must have a string or array of strings value');
});
it('should throw error for empty array value', async () => {
await expect(
handleSimilar({
assertion: {
type: 'similar',
value: [],
},
baseType: 'similar' as any,
renderedValue: [],
outputString: 'test',
inverse: false,
test: {
description: 'test',
vars: {},
assert: [],
options: {},
},
assertionValueContext: {
prompt: 'test prompt',
vars: {},
test: {
description: 'test',
vars: {},
assert: [],
options: {},
},
logProbs: undefined,
// @ts-ignore
provider: createMockProvider({ response: {} }),
providerResponse: { output: 'test' },
},
output: 'test',
providerResponse: { output: 'test' },
}),
).rejects.toThrow('Similarity assertion must have at least one value to compare against');
});
it('should use dot_product metric when specified', async () => {
const mockMatchesSimilarity = vi.mocked(matchesSimilarity);
await handleSimilar({
assertion: {
type: 'similar:dot',
value: 'hello world',
},
baseType: 'similar' as any,
renderedValue: 'hello world',
outputString: 'hello world',
inverse: false,
test: {
description: 'test',
vars: {},
assert: [],
options: {},
},
assertionValueContext: {
prompt: 'test prompt',
vars: {},
test: {
description: 'test',
vars: {},
assert: [],
options: {},
},
logProbs: undefined,
// @ts-ignore
provider: createMockProvider({ response: {} }),
providerResponse: { output: 'hello world' },
},
output: 'hello world',
providerResponse: { output: 'hello world' },
});
// Verify that matchesSimilarity was called with dot_product metric
expect(mockMatchesSimilarity).toHaveBeenCalledWith(
'hello world',
'hello world',
expect.any(Number),
false,
expect.any(Object),
'dot_product',
);
});
it('should use euclidean metric when specified', async () => {
const mockMatchesSimilarity = vi.mocked(matchesSimilarity);
await handleSimilar({
assertion: {
type: 'similar:euclidean',
value: 'hello world',
},
baseType: 'similar' as any,
renderedValue: 'hello world',
outputString: 'hello world',
inverse: false,
test: {
description: 'test',
vars: {},
assert: [],
options: {},
},
assertionValueContext: {
prompt: 'test prompt',
vars: {},
test: {
description: 'test',
vars: {},
assert: [],
options: {},
},
logProbs: undefined,
// @ts-ignore
provider: createMockProvider({ response: {} }),
providerResponse: { output: 'hello world' },
},
output: 'hello world',
providerResponse: { output: 'hello world' },
});
// Verify that matchesSimilarity was called with euclidean metric
expect(mockMatchesSimilarity).toHaveBeenCalledWith(
'hello world',
'hello world',
expect.any(Number),
false,
expect.any(Object),
'euclidean',
);
});
it('should default to cosine metric when not specified', async () => {
const mockMatchesSimilarity = vi.mocked(matchesSimilarity);
await handleSimilar({
assertion: {
type: 'similar',
value: 'hello world',
},
baseType: 'similar' as any,
renderedValue: 'hello world',
outputString: 'hello world',
inverse: false,
test: {
description: 'test',
vars: {},
assert: [],
options: {},
},
assertionValueContext: {
prompt: 'test prompt',
vars: {},
test: {
description: 'test',
vars: {},
assert: [],
options: {},
},
logProbs: undefined,
// @ts-ignore
provider: createMockProvider({ response: {} }),
providerResponse: { output: 'hello world' },
},
output: 'hello world',
providerResponse: { output: 'hello world' },
});
// Verify that matchesSimilarity was called with cosine metric (default)
expect(mockMatchesSimilarity).toHaveBeenCalledWith(
'hello world',
'hello world',
expect.any(Number),
false,
expect.any(Object),
'cosine',
);
});
});