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
430 lines
18 KiB
TypeScript
430 lines
18 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { calculateBleuScore, handleBleuScore } from '../../src/assertions/bleu';
|
|
|
|
import type { AssertionParams } from '../../src/types/index';
|
|
|
|
describe('BLEU score calculation', () => {
|
|
it('identical sentences should have BLEU score equal to one', () => {
|
|
const references = ['The cat sat on the mat.'];
|
|
const candidate = 'The cat sat on the mat.';
|
|
|
|
const score = calculateBleuScore(candidate, references);
|
|
expect(score).toBe(1);
|
|
});
|
|
|
|
it('completely different sentences should have very low but non-zero BLEU score due to smoothing', () => {
|
|
const references = ['The cat sat on the mat.'];
|
|
const candidate = 'Dogs run in the park.';
|
|
|
|
const score = calculateBleuScore(candidate, references);
|
|
expect(score).toBeGreaterThan(0.0);
|
|
expect(score).toBeLessThan(0.001);
|
|
});
|
|
|
|
it('partially matching sentences should have score between 0 and 1', () => {
|
|
const references = ['The cat sat on the mat.'];
|
|
const candidate = 'The dog sat on the mat.';
|
|
|
|
const score = calculateBleuScore(candidate, references);
|
|
expect(score).toBeGreaterThan(0.5);
|
|
expect(score).toBeLessThan(1.0);
|
|
});
|
|
|
|
it('should handle custom weights', () => {
|
|
const references = ['The cat sat on the mat.'];
|
|
const candidate = 'The cat sat on the mat.';
|
|
const weights = [0.25, 0.25, 0.25, 0.25];
|
|
|
|
const score = calculateBleuScore(candidate, references, weights);
|
|
expect(score).toBeGreaterThan(0.999);
|
|
});
|
|
|
|
it('should handle empty or single word sentences', () => {
|
|
const references = ['cat'];
|
|
const candidate = 'cat';
|
|
|
|
const score = calculateBleuScore(candidate, references);
|
|
expect(score).toBeGreaterThan(0.0);
|
|
});
|
|
|
|
it('should score a perfect short match ~1.0 regardless of token count', () => {
|
|
// A candidate shorter than the 4-gram order has no 4-grams (and a 1-2 word
|
|
// candidate has no 3-/2-grams). Those unavailable orders must be dropped and
|
|
// the weights renormalized, not treated as zero-precision. Before the fix an
|
|
// exact 1-word match scored ~0.00001 and a 3-word match ~0.018, failing the
|
|
// default 0.5 threshold; only candidates of >= 4 tokens could ever pass.
|
|
expect(calculateBleuScore('cat', ['cat'])).toBeCloseTo(1, 5);
|
|
expect(calculateBleuScore('good dog', ['good dog'])).toBeCloseTo(1, 5);
|
|
expect(calculateBleuScore('the good dog', ['the good dog'])).toBeCloseTo(1, 5);
|
|
});
|
|
|
|
it('should still penalize an imperfect short match', () => {
|
|
// 2-token candidate, one of two unigrams matches; only orders 1-2 exist.
|
|
// unigram precision = 1/2, bigram precision = 0 (smoothed to 1e-7), weights
|
|
// renormalized to 0.5 each over the two usable orders:
|
|
// exp(0.5*ln(0.5) + 0.5*ln(1e-7)) ≈ 0.00022360679. Pin the exact value so
|
|
// the renormalization math is locked in; the old smoothed-zero behavior
|
|
// returned ~4.7e-6 (every missing order dragged the score down), so the
|
|
// > 1e-4 guard below fails on origin/main.
|
|
const score = calculateBleuScore('good cat', ['good dog']);
|
|
expect(score).toBeCloseTo(Math.exp(0.5 * Math.log(0.5) + 0.5 * Math.log(1e-7)), 10);
|
|
expect(score).toBeGreaterThan(1e-4);
|
|
expect(score).toBeLessThan(1.0);
|
|
});
|
|
|
|
it('should reduce a perfect short match to the brevity penalty when shorter than the reference', () => {
|
|
// Perfect n-gram precision on every usable order (product 1), so the score
|
|
// equals the brevity penalty alone. The candidate is shorter than the
|
|
// reference, so the penalty pulls it below 1 — but it no longer collapses to
|
|
// ~0 the way it did before short orders were dropped and renormalized.
|
|
expect(calculateBleuScore('cat', ['cat sat down'])).toBeCloseTo(Math.exp(1 - 3 / 1), 10);
|
|
expect(calculateBleuScore('good dog', ['good dog runs fast'])).toBeCloseTo(
|
|
Math.exp(1 - 4 / 2),
|
|
10,
|
|
);
|
|
});
|
|
|
|
it('should pick the best matching reference for a short candidate', () => {
|
|
// The renormalized short-candidate path must still take the best match across
|
|
// references: the exact reference yields a perfect score.
|
|
expect(calculateBleuScore('good dog', ['bad dog', 'good dog', 'the dog'])).toBeCloseTo(1, 5);
|
|
});
|
|
|
|
it('should be unchanged for candidates with all four n-gram orders (renormalization is a no-op)', () => {
|
|
// Every order 1-4 is present, so weightSum === 1 and dividing by it is a
|
|
// no-op: the result must match the pre-renormalization formula exactly,
|
|
// including non-uniform weights. Precisions are 5/6, 3/5, 2/4, 1/3.
|
|
const score = calculateBleuScore(
|
|
'the dog sat on the mat',
|
|
['the cat sat on the mat'],
|
|
[0.4, 0.3, 0.2, 0.1],
|
|
);
|
|
expect(score).toBeCloseTo(
|
|
Math.exp(
|
|
0.4 * Math.log(5 / 6) +
|
|
0.3 * Math.log(3 / 5) +
|
|
0.2 * Math.log(2 / 4) +
|
|
0.1 * Math.log(1 / 3),
|
|
),
|
|
10,
|
|
);
|
|
});
|
|
|
|
it('should not return NaN for a short candidate with custom n-gram weights', () => {
|
|
// Regression guard: dropping unavailable orders and renormalizing divides by
|
|
// the sum of the *usable* weights. With weights concentrated on higher orders
|
|
// a short candidate can have no usable weighted order. That must not divide by
|
|
// zero (NaN); it scores 0 (no scorable signal under these weights). A >= 4
|
|
// token candidate still has its 4-grams, so the same weights score normally.
|
|
expect(calculateBleuScore('cat', ['cat'], [0, 0, 0, 1])).toBe(0);
|
|
expect(calculateBleuScore('the good dog', ['the good dog'], [0, 0, 0, 1])).toBe(0);
|
|
expect(
|
|
calculateBleuScore('the very good dog', ['the very good dog'], [0, 0, 0, 1]),
|
|
).toBeCloseTo(1, 5);
|
|
// A partially-weighted short candidate still scores finitely (not NaN).
|
|
expect(calculateBleuScore('good dog', ['good dog'], [0.5, 0.5, 0, 0])).not.toBeNaN();
|
|
});
|
|
|
|
it('should reject negative weights', () => {
|
|
// BLEU weights are non-negative by definition. Negatives would push the score
|
|
// far outside [0, 1] (a negative weight on a smoothed zero-precision order) and
|
|
// can make the usable-weight sum cancel to zero. Reject them outright.
|
|
expect(() => calculateBleuScore('a b', ['a b'], [1, -1, 0.5, 0.5])).toThrow(
|
|
'Weights must be non-negative',
|
|
);
|
|
expect(() => calculateBleuScore('a b c x', ['a b c d'], [0.5, 0.5, 1, -1])).toThrow(
|
|
'Weights must be non-negative',
|
|
);
|
|
});
|
|
|
|
it('should handle sentences with different lengths', () => {
|
|
const references = ['The cat sat on the mat.'];
|
|
const candidate = 'The cat sat.';
|
|
|
|
const score = calculateBleuScore(candidate, references);
|
|
expect(score).toBeGreaterThan(0.0);
|
|
expect(score).toBeLessThan(1.0);
|
|
});
|
|
|
|
it('should handle multiple references and take best matching score', () => {
|
|
const references = [
|
|
'The cat sat on the mat.',
|
|
'There is a cat on the mat.',
|
|
'A cat is sitting on the mat.',
|
|
];
|
|
const candidate = 'The cat was sitting on the mat.';
|
|
|
|
const score = calculateBleuScore(candidate, references);
|
|
expect(score).toBeGreaterThan(0.25);
|
|
});
|
|
|
|
it('should use closest reference length for brevity penalty', () => {
|
|
const references = ['The cat sat on mat.', 'Cat mat.', 'A cat is sitting on a mat.'];
|
|
const candidate = 'The cat sat on mat.';
|
|
|
|
const score = calculateBleuScore(candidate, references);
|
|
expect(score).toBeGreaterThan(0.999);
|
|
});
|
|
|
|
it('should not depend on the order of equidistant references', () => {
|
|
const candidate = 'a b c d'; // 4 tokens
|
|
const shorterRef = 'a b c'; // 3 tokens (distance 1 from candidate)
|
|
const longerRef = 'a b c d e'; // 5 tokens (distance 1 from candidate)
|
|
|
|
// When two references are equally close to the candidate length, BLEU breaks the
|
|
// tie toward the shorter reference (Papineni et al. / NLTK `closest_ref_length`),
|
|
// so the score must be independent of the order the references are provided in.
|
|
const scoreShorterFirst = calculateBleuScore(candidate, [shorterRef, longerRef]);
|
|
const scoreLongerFirst = calculateBleuScore(candidate, [longerRef, shorterRef]);
|
|
|
|
expect(scoreLongerFirst).toBeCloseTo(scoreShorterFirst, 10);
|
|
});
|
|
|
|
it('should break equidistant ties toward the shorter reference (direction, not just determinism)', () => {
|
|
// Two references equidistant from the candidate length (both distance 2): lengths 2
|
|
// and 6. Every 1- to 4-gram of the candidate is contained in the longer reference, so
|
|
// n-gram precision is perfect and the score reduces to the brevity penalty alone.
|
|
// NLTK `closest_ref_length` breaks the tie toward the SHORTER reference (length 2);
|
|
// the candidate (4 tokens) is longer than it, so the brevity penalty is 1 and the
|
|
// score is exactly 1. Preferring the longer reference (length 6) would instead give
|
|
// exp(1 - 6/4) ≈ 0.6065 — so this pins the tie-break DIRECTION. The order-independence
|
|
// test above passes for either direction; only this test fails if the tie flips.
|
|
const candidate = 'a b c d'; // 4 tokens
|
|
const refs = ['a b', 'a b c d e f']; // lengths 2 and 6, both distance 2 from candidate
|
|
|
|
expect(calculateBleuScore(candidate, refs)).toBe(1);
|
|
expect(calculateBleuScore(candidate, [...refs].reverse())).toBe(1);
|
|
// Guard: a regression preferring the longer reference would yield exp(1 - 6/4).
|
|
expect(calculateBleuScore(candidate, refs)).not.toBeCloseTo(Math.exp(1 - 6 / 4), 5);
|
|
});
|
|
|
|
it('should penalize short candidates and never exceed 1.0', () => {
|
|
// Every 1- to 4-gram of the candidate appears in the reference, so n-gram
|
|
// precision is perfect, but the candidate is shorter than the reference. BLEU is
|
|
// bounded by 1.0, so the brevity penalty must pull the score down (penalize),
|
|
// never above 1.0. Per Papineni et al., BP = exp(1 - referenceLength/candidateLength)
|
|
// for candidateLength <= referenceLength.
|
|
const candidate = 'a b c d e'; // 5 tokens, perfect n-gram precision
|
|
const reference = 'a b c d e f g'; // 7 tokens
|
|
|
|
const score = calculateBleuScore(candidate, [reference]);
|
|
|
|
expect(score).toBeLessThanOrEqual(1);
|
|
// precision product is 1, so the score equals the brevity penalty itself.
|
|
expect(score).toBeCloseTo(Math.exp(1 - 7 / 5), 10);
|
|
});
|
|
|
|
it('should not penalize a candidate at least as long as the reference (brevity penalty = 1 at c == r)', () => {
|
|
// candidateLength === referenceLength is the boundary of the brevity-penalty
|
|
// branch: exp(1 - referenceLength/candidateLength) = exp(0) = 1, so a perfect,
|
|
// equal-length match must score exactly 1.0 — never penalized, never above 1.0.
|
|
const candidate = 'a b c d e'; // 5 tokens
|
|
const reference = 'a b c d e'; // 5 tokens, identical length and content
|
|
|
|
const score = calculateBleuScore(candidate, [reference]);
|
|
|
|
expect(score).toBe(1);
|
|
});
|
|
|
|
it('should throw error for empty reference array', () => {
|
|
expect(() => {
|
|
calculateBleuScore('test', []);
|
|
}).toThrow('Invalid inputs');
|
|
});
|
|
|
|
it('should return 0 for an empty candidate instead of throwing', () => {
|
|
expect(calculateBleuScore('', ['some reference'])).toBe(0);
|
|
});
|
|
|
|
it('should return 0 for a whitespace-only candidate', () => {
|
|
expect(calculateBleuScore(' ', ['some reference'])).toBe(0);
|
|
expect(calculateBleuScore('\n\t', ['some reference'])).toBe(0);
|
|
});
|
|
|
|
it('should still throw for a null or undefined candidate', () => {
|
|
expect(() => calculateBleuScore(null as never, ['some reference'])).toThrow('Invalid inputs');
|
|
expect(() => calculateBleuScore(undefined as never, ['some reference'])).toThrow(
|
|
'Invalid inputs',
|
|
);
|
|
});
|
|
|
|
it('should throw error for invalid weights', () => {
|
|
const references = ['The cat sat on the mat.'];
|
|
const weightsNotSummingToOne = [0.5, 0.5, 0.5, 0.5];
|
|
|
|
expect(() => {
|
|
calculateBleuScore('test', references, weightsNotSummingToOne);
|
|
}).toThrow('Weights must sum to 1');
|
|
});
|
|
|
|
it('should throw error for wrong number of weights', () => {
|
|
const references = ['The cat sat on the mat.'];
|
|
const incorrectLengthWeights = [0.5, 0.5];
|
|
|
|
expect(() => {
|
|
calculateBleuScore('test', references, incorrectLengthWeights);
|
|
}).toThrow('Invalid inputs');
|
|
});
|
|
|
|
it('should handle multiple references with varying lengths', () => {
|
|
const references = ['The small cat sat.', 'A cat was sitting.', 'The cat is on the mat.'];
|
|
const candidate = 'The small cat sat.';
|
|
|
|
const score = calculateBleuScore(candidate, references);
|
|
expect(score).toBeGreaterThan(0.999);
|
|
});
|
|
});
|
|
|
|
describe('handleBleuScore', () => {
|
|
it('should handle string reference with passing score', () => {
|
|
const params = {
|
|
assertion: { type: 'bleu', value: 'The cat sat on the mat.' },
|
|
renderedValue: 'The cat sat on the mat.',
|
|
outputString: 'The cat sat on the mat.',
|
|
inverse: false,
|
|
} as AssertionParams;
|
|
expect(handleBleuScore(params)).toEqual({
|
|
pass: true,
|
|
score: expect.any(Number),
|
|
reason: 'Assertion passed',
|
|
assertion: expect.any(Object),
|
|
});
|
|
});
|
|
|
|
it('should handle array of references', () => {
|
|
const params = {
|
|
assertion: {
|
|
type: 'bleu',
|
|
value: ['The cat sat on mat.', 'The cat is sitting on mat.'],
|
|
},
|
|
renderedValue: ['The cat sat on mat.', 'The cat is sitting on mat.'],
|
|
outputString: 'The cat sat on mat.',
|
|
inverse: false,
|
|
} as AssertionParams;
|
|
expect(handleBleuScore(params)).toEqual({
|
|
pass: true,
|
|
score: expect.any(Number),
|
|
reason: 'Assertion passed',
|
|
assertion: expect.any(Object),
|
|
});
|
|
});
|
|
|
|
it('should produce an order-independent score and verdict for an array of references', () => {
|
|
// End-to-end guard through the public handler: the same equidistant references supplied
|
|
// in different orders via renderedValue must yield the same score and pass/fail verdict.
|
|
const outputString = 'a b c d'; // 4 tokens
|
|
const refs = ['a b c', 'a b c d e']; // lengths 3 and 5, both distance 1 from candidate
|
|
const base = {
|
|
assertion: { type: 'bleu' },
|
|
outputString,
|
|
inverse: false,
|
|
};
|
|
const shorterFirst = handleBleuScore({ ...base, renderedValue: refs } as AssertionParams);
|
|
const longerFirst = handleBleuScore({
|
|
...base,
|
|
renderedValue: [...refs].reverse(),
|
|
} as AssertionParams);
|
|
|
|
expect(longerFirst.score).toBeCloseTo(shorterFirst.score, 10);
|
|
expect(longerFirst.pass).toBe(shorterFirst.pass);
|
|
});
|
|
|
|
it('should handle custom threshold', () => {
|
|
const params = {
|
|
assertion: { type: 'bleu', value: 'The cat sat on the mat.', threshold: 0.8 },
|
|
renderedValue: 'The cat sat on the mat.',
|
|
outputString: 'The dog sat on the mat.',
|
|
inverse: false,
|
|
} as AssertionParams;
|
|
expect(handleBleuScore(params)).toEqual({
|
|
pass: false,
|
|
score: expect.any(Number),
|
|
reason: expect.stringMatching(/BLEU score \d+\.\d+ is less than threshold 0\.8/),
|
|
assertion: expect.any(Object),
|
|
});
|
|
});
|
|
|
|
it('should handle inverse assertion', () => {
|
|
const params = {
|
|
assertion: { type: 'bleu', value: 'The cat sat on the mat.', threshold: 0.8 },
|
|
renderedValue: 'The cat sat on the mat.',
|
|
outputString: 'The dog ran in the park.',
|
|
inverse: true,
|
|
} as AssertionParams;
|
|
expect(handleBleuScore(params)).toEqual({
|
|
pass: true,
|
|
score: expect.any(Number),
|
|
reason: 'Assertion passed',
|
|
assertion: expect.any(Object),
|
|
});
|
|
});
|
|
|
|
it('should report a bounded [0, 1] score for inverse assertions on short candidates', () => {
|
|
// Regression for the brevity-penalty bug: a short candidate with perfect n-gram
|
|
// precision previously scored above 1.0 (~1.33), so the inverse path (1 - score)
|
|
// reported a NEGATIVE score. With the score correctly bounded to [0, 1], the
|
|
// inverse score stays in [0, 1] too.
|
|
const params = {
|
|
assertion: { type: 'bleu', value: 'a b c d e f g' },
|
|
renderedValue: 'a b c d e f g', // 7 tokens
|
|
outputString: 'a b c d e', // 5 tokens, shorter with perfect n-gram precision
|
|
inverse: true,
|
|
} as AssertionParams;
|
|
|
|
const result = handleBleuScore(params);
|
|
|
|
expect(result.score).toBeGreaterThanOrEqual(0);
|
|
expect(result.score).toBeLessThanOrEqual(1);
|
|
// BLEU score is exp(1 - 7/5) ≈ 0.6703, so the inverse score is 1 - that.
|
|
expect(result.score).toBeCloseTo(1 - Math.exp(1 - 7 / 5), 10);
|
|
});
|
|
|
|
it('should use default threshold of 0.5', () => {
|
|
const params = {
|
|
assertion: { type: 'bleu', value: 'The cat sat on the mat.' },
|
|
renderedValue: 'The cat sat on the mat.',
|
|
outputString: 'The dog ran in the park.',
|
|
inverse: false,
|
|
} as AssertionParams;
|
|
expect(handleBleuScore(params)).toEqual({
|
|
pass: false,
|
|
score: expect.any(Number),
|
|
reason: expect.stringMatching(/BLEU score \d+\.\d+ is less than threshold 0\.5/),
|
|
assertion: expect.any(Object),
|
|
});
|
|
});
|
|
|
|
it('should return pass:false score:0 for empty output instead of throwing', () => {
|
|
const result = handleBleuScore({
|
|
assertion: { type: 'bleu' },
|
|
renderedValue: 'some reference',
|
|
outputString: '',
|
|
inverse: false,
|
|
} as AssertionParams);
|
|
expect(result.pass).toBe(false);
|
|
expect(result.score).toBe(0);
|
|
});
|
|
|
|
it('should treat whitespace-only output like empty output', () => {
|
|
const result = handleBleuScore({
|
|
assertion: { type: 'bleu' },
|
|
renderedValue: 'some reference',
|
|
outputString: ' \n\t',
|
|
inverse: false,
|
|
} as AssertionParams);
|
|
expect(result.pass).toBe(false);
|
|
expect(result.score).toBe(0);
|
|
});
|
|
|
|
it('should pass an inverse assertion for empty output (score inverts to 1)', () => {
|
|
const result = handleBleuScore({
|
|
assertion: { type: 'bleu' },
|
|
renderedValue: 'some reference',
|
|
outputString: '',
|
|
inverse: true,
|
|
} as AssertionParams);
|
|
expect(result.pass).toBe(true);
|
|
expect(result.score).toBe(1);
|
|
});
|
|
});
|