feat: initial release of llm-fingerprint-detector
TypeScript library + CLI implementing single-token behavioral fingerprinting of LLMs behind OpenAI-compatible APIs, per 'One Token Is Enough' (Bruckner, arXiv:2607.10252). - Zero runtime dependencies (Node >= 18.17 built-in fetch) - fingerprint / compare / verify API + llm-fingerprint CLI - Jensen-Shannon divergence over per-cell answer distributions - Reasoning-model aware (auto-disables hidden thinking channels) - Bundled sample references for 11 popular models (CC-BY-4.0, derived from the paper's public Zenodo dataset) - Unit + integration tests via node --test Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* End-to-end test against a local mock OpenAI-compatible server: adapter
|
||||
* detection, concurrent sampling, normalization, aggregation and verify().
|
||||
*/
|
||||
import assert from 'node:assert/strict'
|
||||
import { createServer } from 'node:http'
|
||||
import { after, before, test } from 'node:test'
|
||||
|
||||
import { compare, fingerprint, verify } from '../dist/api.js'
|
||||
|
||||
/** Two simulated "models" with different answer distributions. */
|
||||
const MODEL_BEHAVIOR = {
|
||||
'mock-alpha': {
|
||||
'random-number-1-100': () => (Math.random() < 0.8 ? '42' : '73'),
|
||||
'random-number-1-10': () => '7',
|
||||
'random-letter': () => 'k',
|
||||
'random-color': () => (Math.random() < 0.6 ? 'blue' : 'teal'),
|
||||
'coin-flip': () => (Math.random() < 0.7 ? 'Heads' : 'Tails'),
|
||||
'random-animal': () => 'octopus',
|
||||
'random-city': () => 'Tokyo',
|
||||
'favorite-number': () => '42',
|
||||
},
|
||||
'mock-beta': {
|
||||
'random-number-1-100': () => (Math.random() < 0.8 ? '57' : '3'),
|
||||
'random-number-1-10': () => '3',
|
||||
'random-letter': () => 'm',
|
||||
'random-color': () => 'crimson',
|
||||
'coin-flip': () => 'Tails',
|
||||
'random-animal': () => 'giraffe',
|
||||
'random-city': () => 'Paris',
|
||||
'favorite-number': () => '9',
|
||||
},
|
||||
}
|
||||
|
||||
/** Map a user prompt back to the battery task (rough but sufficient for the mock). */
|
||||
function taskFromPrompt(prompt) {
|
||||
const p = prompt.toLowerCase()
|
||||
if (p.includes('100') || p.includes('1 到 100') || p.includes('1 至 100') || p.includes('1 到 100'))
|
||||
return 'random-number-1-100'
|
||||
if (p.includes('10') || p.includes('1 到 10')) return 'random-number-1-10'
|
||||
if (p.includes('letter') || p.includes('字母')) return 'random-letter'
|
||||
if (p.includes('color') || p.includes('颜色')) return 'random-color'
|
||||
if (p.includes('coin') || p.includes('硬币')) return 'coin-flip'
|
||||
if (p.includes('animal') || p.includes('动物')) return 'random-animal'
|
||||
if (p.includes('city') || p.includes('城市')) return 'random-city'
|
||||
if (p.includes('favorite') || p.includes('favourite') || p.includes('喜欢') || p.includes('最爱'))
|
||||
return 'favorite-number'
|
||||
return 'random-number-1-100'
|
||||
}
|
||||
|
||||
let server
|
||||
let baseUrl
|
||||
let requestCount = 0
|
||||
let sawReasoningField = 0
|
||||
|
||||
before(async () => {
|
||||
server = createServer((req, res) => {
|
||||
let body = ''
|
||||
req.on('data', (chunk) => (body += chunk))
|
||||
req.on('end', () => {
|
||||
requestCount += 1
|
||||
const payload = JSON.parse(body)
|
||||
|
||||
if (req.url !== '/v1/chat/completions') {
|
||||
res.writeHead(404).end('not found')
|
||||
return
|
||||
}
|
||||
if (req.headers.authorization !== 'Bearer test-key') {
|
||||
res.writeHead(401, { 'content-type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: { message: 'bad key' } }))
|
||||
return
|
||||
}
|
||||
// The mock accepts the OpenRouter-style reasoning field (counts it for assertions).
|
||||
if (payload.reasoning !== undefined) sawReasoningField += 1
|
||||
|
||||
const behavior = MODEL_BEHAVIOR[payload.model]
|
||||
if (!behavior) {
|
||||
res.writeHead(404, { 'content-type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: { message: `unknown model ${payload.model}` } }))
|
||||
return
|
||||
}
|
||||
const userPrompt = payload.messages.find((m) => m.role === 'user')?.content ?? ''
|
||||
const answer = behavior[taskFromPrompt(userPrompt)]()
|
||||
res.writeHead(200, { 'content-type': 'application/json' })
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
choices: [{ message: { role: 'assistant', content: answer } }],
|
||||
usage: { prompt_tokens: 25, completion_tokens: 2 },
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
baseUrl = `http://127.0.0.1:${server.address().port}/v1`
|
||||
})
|
||||
|
||||
after(() => server.close())
|
||||
|
||||
test('fingerprint(): collects distributions from a live endpoint', async () => {
|
||||
const run = await fingerprint(
|
||||
{ baseUrl, model: 'mock-alpha', apiKey: 'test-key' },
|
||||
{ cells: 4, samplesPerCell: 12, concurrency: 6 },
|
||||
)
|
||||
assert.equal(run.errorCount, 0)
|
||||
assert.equal(run.fingerprint.model, 'mock-alpha')
|
||||
assert.equal(run.fingerprint.protocol, 'one-token/v1')
|
||||
assert.equal(Object.keys(run.fingerprint.cells).length, 4)
|
||||
|
||||
const rn100 = run.fingerprint.cells['random-number-1-100:en']
|
||||
assert.ok(rn100, 'expected the top-priority cell to be probed')
|
||||
assert.equal(rn100.validCount, 12)
|
||||
assert.ok(rn100.counts['42'] > 0, 'mock-alpha answers 42 most of the time')
|
||||
assert.ok(run.adapter.postReasoning === false)
|
||||
assert.ok(sawReasoningField > 0, 'adapter probe should have tried the reasoning field')
|
||||
})
|
||||
|
||||
test('fingerprint(): progress callback covers all requests', async () => {
|
||||
const events = []
|
||||
const run = await fingerprint(
|
||||
{ baseUrl, model: 'mock-alpha', apiKey: 'test-key' },
|
||||
{ cells: 2, samplesPerCell: 5, onProgress: (e) => events.push(e) },
|
||||
)
|
||||
const sampling = events.filter((e) => e.stage === 'sampling')
|
||||
assert.equal(sampling.length, 10)
|
||||
assert.equal(sampling.at(-1).done, 10)
|
||||
assert.equal(sampling.at(-1).total, 10)
|
||||
assert.equal(run.durationMs >= 0, true)
|
||||
})
|
||||
|
||||
test('verify(): same mock model → match, different mock model → mismatch', async () => {
|
||||
const options = { cells: 6, samplesPerCell: 20, concurrency: 8 }
|
||||
const referenceRun = await fingerprint({ baseUrl, model: 'mock-alpha', apiKey: 'test-key' }, options)
|
||||
|
||||
const same = await verify(
|
||||
{ baseUrl, model: 'mock-alpha', apiKey: 'test-key' },
|
||||
referenceRun.fingerprint,
|
||||
options,
|
||||
)
|
||||
assert.equal(same.verdict, 'match')
|
||||
assert.ok(same.meanJsd < 0.25, `same model meanJsd should be small, got ${same.meanJsd}`)
|
||||
|
||||
const different = await verify(
|
||||
{ baseUrl, model: 'mock-beta', apiKey: 'test-key' },
|
||||
referenceRun.fingerprint,
|
||||
options,
|
||||
)
|
||||
assert.equal(different.verdict, 'mismatch')
|
||||
assert.ok(different.meanJsd > 0.35, `different model meanJsd should be large, got ${different.meanJsd}`)
|
||||
})
|
||||
|
||||
test('compare(): flags protocol mismatch', async () => {
|
||||
const runA = await fingerprint(
|
||||
{ baseUrl, model: 'mock-alpha', apiKey: 'test-key' },
|
||||
{ cells: 4, samplesPerCell: 12 },
|
||||
)
|
||||
const foreign = { ...runA.fingerprint, protocol: 'someone-elses-protocol' }
|
||||
const result = compare(runA.fingerprint, foreign)
|
||||
assert.equal(result.protocolMismatch, true)
|
||||
assert.equal(result.verdict, 'match') // identical distributions still match
|
||||
})
|
||||
|
||||
test('fingerprint(): invalid API key aborts the run with an auth error', async () => {
|
||||
await assert.rejects(
|
||||
fingerprint(
|
||||
{ baseUrl, model: 'mock-alpha', apiKey: 'wrong-key' },
|
||||
{ cells: 2, samplesPerCell: 3 },
|
||||
),
|
||||
(error) => {
|
||||
assert.match(error.message, /401/)
|
||||
return true
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('fingerprint(): AbortSignal cancels the run', async () => {
|
||||
const controller = new AbortController()
|
||||
const promise = fingerprint(
|
||||
{ baseUrl, model: 'mock-alpha', apiKey: 'test-key' },
|
||||
{
|
||||
cells: 8,
|
||||
samplesPerCell: 50,
|
||||
onProgress: (e) => {
|
||||
if (e.stage === 'sampling' && e.done >= 5) controller.abort()
|
||||
},
|
||||
signal: controller.signal,
|
||||
},
|
||||
)
|
||||
await assert.rejects(promise, (error) => {
|
||||
assert.equal(error.name, 'ProbeRunError')
|
||||
assert.equal(error.reason, 'aborted')
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
test('endpoint validation: bad base URL rejects before any request', async () => {
|
||||
await assert.rejects(fingerprint({ baseUrl: '', model: 'x' }), /baseUrl is empty/)
|
||||
await assert.rejects(fingerprint({ baseUrl: 'https://ok.example/v1', model: ' ' }), /model is empty/)
|
||||
assert.ok(requestCount > 0)
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import {
|
||||
CELL_PRIORITY_ORDER,
|
||||
PROBE_TASKS,
|
||||
SYSTEM_PROMPTS,
|
||||
getCellsForPreset,
|
||||
getSystemPrompt,
|
||||
getTaskSpec,
|
||||
isCellId,
|
||||
makeCellId,
|
||||
parseCellId,
|
||||
pickParaphrase,
|
||||
} from '../dist/battery.js'
|
||||
|
||||
test('battery covers 8 tasks × 2 languages = 16 unique cells', () => {
|
||||
assert.equal(Object.keys(PROBE_TASKS).length, 8)
|
||||
assert.equal(CELL_PRIORITY_ORDER.length, 16)
|
||||
assert.equal(new Set(CELL_PRIORITY_ORDER).size, 16)
|
||||
for (const cellId of CELL_PRIORITY_ORDER) {
|
||||
assert.ok(isCellId(cellId), `${cellId} should be a valid cell id`)
|
||||
}
|
||||
})
|
||||
|
||||
test('every task has ≥3 paraphrases per language (anti-filter design)', () => {
|
||||
for (const task of Object.values(PROBE_TASKS)) {
|
||||
for (const lang of ['en', 'zh']) {
|
||||
assert.ok(
|
||||
task.paraphrases[lang].length >= 3,
|
||||
`${task.id}:${lang} has only ${task.paraphrases[lang].length} paraphrases`,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('cell id round-trip', () => {
|
||||
const cellId = makeCellId('random-number-1-100', 'zh')
|
||||
assert.equal(cellId, 'random-number-1-100:zh')
|
||||
assert.deepEqual(parseCellId(cellId), { task: 'random-number-1-100', lang: 'zh' })
|
||||
assert.equal(isCellId('not-a-task:en'), false)
|
||||
assert.equal(isCellId('random-color:fr'), false)
|
||||
})
|
||||
|
||||
test('system prompt matches the cell language', () => {
|
||||
assert.equal(getSystemPrompt('random-color:en'), SYSTEM_PROMPTS.en)
|
||||
assert.equal(getSystemPrompt('random-color:zh'), SYSTEM_PROMPTS.zh)
|
||||
})
|
||||
|
||||
test('presets slice the priority order', () => {
|
||||
assert.deepEqual(getCellsForPreset('quick'), CELL_PRIORITY_ORDER.slice(0, 4))
|
||||
assert.deepEqual(getCellsForPreset('standard'), CELL_PRIORITY_ORDER.slice(0, 8))
|
||||
assert.deepEqual(getCellsForPreset('strict'), CELL_PRIORITY_ORDER)
|
||||
})
|
||||
|
||||
test('pickParaphrase draws from the cell language pool', () => {
|
||||
const spec = getTaskSpec('coin-flip:zh')
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const paraphrase = pickParaphrase('coin-flip:zh')
|
||||
assert.ok(spec.paraphrases.zh.includes(paraphrase))
|
||||
}
|
||||
// Deterministic draw with an injected RNG.
|
||||
assert.equal(pickParaphrase('coin-flip:en', () => 0), PROBE_TASKS['coin-flip'].paraphrases.en[0])
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import { normalizeAnswer, parseAnyNumber } from '../dist/normalizer.js'
|
||||
|
||||
const INT_100 = { kind: 'int', min: 1, max: 100 }
|
||||
const INT_10 = { kind: 'int', min: 1, max: 10 }
|
||||
|
||||
test('digits pass through', () => {
|
||||
assert.deepEqual(normalizeAnswer('42', INT_100), { normalized: '42', category: 'valid' })
|
||||
assert.deepEqual(normalizeAnswer(' 7.\n', INT_100), { normalized: '7', category: 'valid' })
|
||||
})
|
||||
|
||||
test('English number words fold to digits', () => {
|
||||
assert.deepEqual(normalizeAnswer('Seven!', INT_100), { normalized: '7', category: 'valid' })
|
||||
assert.deepEqual(normalizeAnswer('forty-two', INT_100), { normalized: '42', category: 'valid' })
|
||||
assert.deepEqual(normalizeAnswer('Twelve', INT_100), { normalized: '12', category: 'valid' })
|
||||
assert.equal(parseAnyNumber('onehundred'), 100)
|
||||
})
|
||||
|
||||
test('Chinese numerals fold to digits', () => {
|
||||
assert.deepEqual(normalizeAnswer('四十二', INT_100), { normalized: '42', category: 'valid' })
|
||||
assert.deepEqual(normalizeAnswer('十五', INT_100), { normalized: '15', category: 'valid' })
|
||||
assert.deepEqual(normalizeAnswer('两', INT_10), { normalized: '2', category: 'valid' })
|
||||
assert.deepEqual(normalizeAnswer('一百', INT_100), { normalized: '100', category: 'valid' })
|
||||
})
|
||||
|
||||
test('full-width and Arabic-Indic digits fold to Latin', () => {
|
||||
assert.deepEqual(normalizeAnswer('42', INT_100), { normalized: '42', category: 'valid' })
|
||||
assert.deepEqual(normalizeAnswer('٤٢', INT_100), { normalized: '42', category: 'valid' })
|
||||
})
|
||||
|
||||
test('out-of-range numbers are invalid', () => {
|
||||
assert.equal(normalizeAnswer('101', INT_100).category, 'invalid')
|
||||
assert.equal(normalizeAnswer('0', INT_100).category, 'invalid')
|
||||
assert.equal(normalizeAnswer('42', INT_10).category, 'invalid')
|
||||
})
|
||||
|
||||
test('refusals are detected in both languages', () => {
|
||||
assert.equal(normalizeAnswer('I cannot help with that request.', INT_100).category, 'refusal')
|
||||
assert.equal(normalizeAnswer("I'm sorry, but I can't do that", INT_100).category, 'refusal')
|
||||
assert.equal(normalizeAnswer('抱歉,我不能这样做。', INT_100).category, 'refusal')
|
||||
assert.equal(normalizeAnswer('作为一个AI,我没有偏好。', INT_100).category, 'refusal')
|
||||
})
|
||||
|
||||
test('empty and whitespace-only answers are empty', () => {
|
||||
assert.equal(normalizeAnswer('', INT_100).category, 'empty')
|
||||
assert.equal(normalizeAnswer(' \n ', INT_100).category, 'empty')
|
||||
assert.equal(normalizeAnswer('"…"', INT_100).category, 'empty')
|
||||
})
|
||||
|
||||
test('colors: quotes stripped, aliases folded, Chinese 色 suffix dropped', () => {
|
||||
const COLOR = { kind: 'color' }
|
||||
assert.deepEqual(normalizeAnswer('"Blue".', COLOR), { normalized: 'blue', category: 'valid' })
|
||||
assert.deepEqual(normalizeAnswer('Grey', COLOR), { normalized: 'gray', category: 'valid' })
|
||||
assert.deepEqual(normalizeAnswer('蓝色', COLOR), { normalized: '蓝', category: 'valid' })
|
||||
assert.deepEqual(normalizeAnswer('青', COLOR), { normalized: '青', category: 'valid' })
|
||||
})
|
||||
|
||||
test('letters: single letters and letter names', () => {
|
||||
const LETTER = { kind: 'letter' }
|
||||
assert.deepEqual(normalizeAnswer('Q', LETTER), { normalized: 'q', category: 'valid' })
|
||||
assert.deepEqual(normalizeAnswer('zee', LETTER), { normalized: 'z', category: 'valid' })
|
||||
assert.deepEqual(normalizeAnswer('queue', LETTER), { normalized: 'q', category: 'valid' })
|
||||
assert.equal(normalizeAnswer('hello', LETTER).category, 'invalid')
|
||||
})
|
||||
|
||||
test('coin: heads/tails variants in both languages', () => {
|
||||
const COIN = { kind: 'coin' }
|
||||
assert.deepEqual(normalizeAnswer('Heads!', COIN), { normalized: 'heads', category: 'valid' })
|
||||
assert.deepEqual(normalizeAnswer('tail', COIN), { normalized: 'tails', category: 'valid' })
|
||||
assert.deepEqual(normalizeAnswer('正面', COIN), { normalized: 'heads', category: 'valid' })
|
||||
assert.deepEqual(normalizeAnswer('反', COIN), { normalized: 'tails', category: 'valid' })
|
||||
assert.equal(normalizeAnswer('maybe', COIN).category, 'invalid')
|
||||
})
|
||||
|
||||
test('word tasks: first word, Latin or CJK', () => {
|
||||
const WORD = { kind: 'word' }
|
||||
assert.deepEqual(normalizeAnswer('Tokyo', WORD), { normalized: 'tokyo', category: 'valid' })
|
||||
assert.deepEqual(normalizeAnswer('New York City', WORD), { normalized: 'new', category: 'valid' })
|
||||
assert.deepEqual(normalizeAnswer('大象', WORD), { normalized: '大象', category: 'valid' })
|
||||
})
|
||||
|
||||
test('emoji and punctuation are stripped before classification', () => {
|
||||
assert.deepEqual(normalizeAnswer('🎲 42 🎲', INT_100), { normalized: '42', category: 'valid' })
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import { compare } from '../dist/api.js'
|
||||
import {
|
||||
getBundledAttribution,
|
||||
listBundledReferences,
|
||||
loadBundledReference,
|
||||
parseFingerprintJson,
|
||||
} from '../dist/reference.js'
|
||||
|
||||
test('bundled references load and carry attribution', () => {
|
||||
const references = listBundledReferences()
|
||||
assert.ok(references.length >= 5, 'expected several bundled sample references')
|
||||
|
||||
const attribution = getBundledAttribution()
|
||||
assert.match(attribution.datasetDoi, /10\.5281\/zenodo\.21278557/)
|
||||
assert.match(attribution.license, /CC-BY-4\.0/)
|
||||
assert.match(attribution.author, /Bruckner/)
|
||||
})
|
||||
|
||||
test('a bundled reference converts to a well-formed fingerprint', () => {
|
||||
const fp = loadBundledReference('openai/gpt-4o-mini')
|
||||
assert.equal(fp.model, 'openai/gpt-4o-mini')
|
||||
assert.equal(fp.protocol, 'bruckner-zenodo-2026')
|
||||
const cells = Object.values(fp.cells)
|
||||
assert.ok(cells.length >= 8)
|
||||
for (const cell of cells) {
|
||||
assert.ok(cell.validCount >= 10)
|
||||
const sum = Object.values(cell.counts).reduce((s, n) => s + n, 0)
|
||||
assert.ok(Math.abs(sum - cell.validCount) <= 2, `${cell.cellId}: counts ≈ validCount`)
|
||||
assert.ok(cell.entropyBits >= 0)
|
||||
assert.ok(cell.normalizedEntropy >= 0 && cell.normalizedEntropy <= 1)
|
||||
}
|
||||
})
|
||||
|
||||
test('bundled references: self-compare → 0 distance, cross-model → clearly separated', () => {
|
||||
const gpt = loadBundledReference('openai/gpt-4o-mini')
|
||||
const self = compare(gpt, loadBundledReference('openai/gpt-4o-mini'))
|
||||
assert.equal(self.meanJsd, 0)
|
||||
assert.equal(self.verdict, 'match')
|
||||
|
||||
const claude = loadBundledReference('anthropic/claude-sonnet-4.5')
|
||||
const cross = compare(gpt, claude)
|
||||
assert.ok(
|
||||
cross.meanJsd > 0.3,
|
||||
`different models should diverge (got ${cross.meanJsd}) — paper median is 0.463`,
|
||||
)
|
||||
assert.notEqual(cross.verdict, 'match')
|
||||
})
|
||||
|
||||
test('unknown bundled id throws with the available list', () => {
|
||||
assert.throws(() => loadBundledReference('no/such-model'), /Available:/)
|
||||
})
|
||||
|
||||
test('parseFingerprintJson validates structure', () => {
|
||||
const fp = loadBundledReference('openai/gpt-4o-mini')
|
||||
const roundTripped = parseFingerprintJson(JSON.stringify(fp))
|
||||
assert.equal(roundTripped.model, fp.model)
|
||||
|
||||
assert.throws(() => parseFingerprintJson('not json'), /not valid JSON/)
|
||||
assert.throws(() => parseFingerprintJson('{}'), /formatVersion/)
|
||||
assert.throws(
|
||||
() => parseFingerprintJson(JSON.stringify({ formatVersion: 1, model: 'x' })),
|
||||
/cells/,
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import {
|
||||
buildCellDistribution,
|
||||
compareCellSets,
|
||||
jensenShannonDivergence,
|
||||
median,
|
||||
shannonEntropyBits,
|
||||
splitHalfJsd,
|
||||
} from '../dist/stats.js'
|
||||
|
||||
function almostEqual(actual, expected, epsilon = 1e-9) {
|
||||
assert.ok(
|
||||
Math.abs(actual - expected) < epsilon,
|
||||
`expected ${actual} ≈ ${expected} (±${epsilon})`,
|
||||
)
|
||||
}
|
||||
|
||||
test('entropy: uniform over 4 outcomes is 2 bits, point mass is 0', () => {
|
||||
almostEqual(shannonEntropyBits({ a: 1, b: 1, c: 1, d: 1 }), 2)
|
||||
almostEqual(shannonEntropyBits({ a: 10 }), 0)
|
||||
almostEqual(shannonEntropyBits({}), 0)
|
||||
})
|
||||
|
||||
test('JSD: identical distributions → 0', () => {
|
||||
almostEqual(jensenShannonDivergence({ a: 3, b: 1 }, { a: 6, b: 2 }), 0)
|
||||
})
|
||||
|
||||
test('JSD: disjoint distributions → 1 bit', () => {
|
||||
almostEqual(jensenShannonDivergence({ a: 5 }, { b: 7 }), 1)
|
||||
})
|
||||
|
||||
test('JSD: known hand-computed value', () => {
|
||||
// P = (1, 0), Q = (0.5, 0.5), M = (0.75, 0.25)
|
||||
// JSD = H(M) − (H(P)+H(Q))/2 = 0.8112781245 − 0.5 = 0.3112781245
|
||||
almostEqual(jensenShannonDivergence({ a: 4 }, { a: 2, b: 2 }), 0.31127812445913294, 1e-12)
|
||||
})
|
||||
|
||||
test('JSD is symmetric and count-scale invariant', () => {
|
||||
const p = { x: 3, y: 9, z: 1 }
|
||||
const q = { x: 5, y: 2 }
|
||||
almostEqual(jensenShannonDivergence(p, q), jensenShannonDivergence(q, p))
|
||||
almostEqual(
|
||||
jensenShannonDivergence(p, q),
|
||||
jensenShannonDivergence({ x: 30, y: 90, z: 10 }, q),
|
||||
)
|
||||
})
|
||||
|
||||
test('median of even/odd lists', () => {
|
||||
assert.equal(median([3, 1, 2]), 2)
|
||||
assert.equal(median([4, 1, 2, 3]), 2.5)
|
||||
assert.equal(median([]), null)
|
||||
})
|
||||
|
||||
function sample(cellId, normalized, category, arrivalIndex, latencyMs = 100) {
|
||||
return {
|
||||
cellId,
|
||||
raw: normalized ?? '',
|
||||
normalized,
|
||||
category,
|
||||
latencyMs,
|
||||
usage: { promptTokens: 20, completionTokens: 2, reasoningTokens: null },
|
||||
arrivalIndex,
|
||||
}
|
||||
}
|
||||
|
||||
test('buildCellDistribution aggregates categories and entropy', () => {
|
||||
const cellId = 'random-number-1-100:en'
|
||||
const samples = [
|
||||
sample(cellId, '42', 'valid', 0, 100),
|
||||
sample(cellId, '42', 'valid', 1, 200),
|
||||
sample(cellId, '7', 'valid', 2, 300),
|
||||
sample(cellId, 'banana', 'invalid', 3, 400),
|
||||
sample(cellId, null, 'refusal', 4, 500),
|
||||
sample(cellId, null, 'empty', 5, 600),
|
||||
sample(cellId, null, 'error', 6, 9999), // error latency is excluded
|
||||
]
|
||||
const dist = buildCellDistribution(cellId, samples, { kind: 'int', min: 1, max: 100 })
|
||||
assert.deepEqual(dist.counts, { 42: 2, 7: 1 })
|
||||
assert.equal(dist.validCount, 3)
|
||||
assert.equal(dist.invalidCount, 1)
|
||||
assert.equal(dist.refusalCount, 1)
|
||||
assert.equal(dist.emptyCount, 1)
|
||||
assert.equal(dist.errorCount, 1)
|
||||
assert.equal(dist.totalCount, 7)
|
||||
almostEqual(dist.entropyBits, shannonEntropyBits({ a: 2, b: 1 }))
|
||||
assert.ok(dist.normalizedEntropy > 0 && dist.normalizedEntropy <= 1)
|
||||
assert.equal(dist.medianLatencyMs, 350) // median of [100..600]; the error sample is excluded
|
||||
assert.equal(dist.meanCompletionTokens, 2)
|
||||
})
|
||||
|
||||
test('compareCellSets: skips thin cells, averages the rest', () => {
|
||||
const mk = (counts, validCount) => ({ counts, validCount })
|
||||
const a = {
|
||||
'random-number-1-100:en': mk({ 42: 20 }, 20),
|
||||
'random-color:en': mk({ blue: 15 }, 15),
|
||||
'coin-flip:en': mk({ heads: 3 }, 3), // below the 10-valid minimum
|
||||
}
|
||||
const b = {
|
||||
'random-number-1-100:en': mk({ 42: 20 }, 20),
|
||||
'random-color:en': mk({ red: 15 }, 15),
|
||||
'coin-flip:en': mk({ heads: 30 }, 30),
|
||||
}
|
||||
const { entries, meanJsd } = compareCellSets(a, b)
|
||||
assert.equal(entries.length, 2)
|
||||
assert.equal(entries[0].cellId, 'random-color:en') // sorted by descending JSD
|
||||
almostEqual(entries[0].jsd, 1)
|
||||
almostEqual(entries[1].jsd, 0)
|
||||
almostEqual(meanJsd, 0.5)
|
||||
})
|
||||
|
||||
test('compareCellSets: nothing comparable → meanJsd null', () => {
|
||||
const { entries, meanJsd } = compareCellSets({}, {})
|
||||
assert.equal(entries.length, 0)
|
||||
assert.equal(meanJsd, null)
|
||||
})
|
||||
|
||||
test('splitHalfJsd: stable endpoint → 0, alternating endpoint → 1', () => {
|
||||
const cellId = 'random-number-1-100:en'
|
||||
const stable = new Map([
|
||||
[cellId, Array.from({ length: 20 }, (_, i) => sample(cellId, '42', 'valid', i))],
|
||||
])
|
||||
almostEqual(splitHalfJsd(stable), 0)
|
||||
|
||||
const alternating = new Map([
|
||||
[
|
||||
cellId,
|
||||
Array.from({ length: 20 }, (_, i) => sample(cellId, i % 2 === 0 ? '1' : '2', 'valid', i)),
|
||||
],
|
||||
])
|
||||
almostEqual(splitHalfJsd(alternating), 1)
|
||||
})
|
||||
|
||||
test('splitHalfJsd: too few samples → null', () => {
|
||||
const cellId = 'random-number-1-100:en'
|
||||
const thin = new Map([
|
||||
[cellId, Array.from({ length: 6 }, (_, i) => sample(cellId, '42', 'valid', i))],
|
||||
])
|
||||
assert.equal(splitHalfJsd(thin), null)
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import {
|
||||
JSD_MATCH_THRESHOLD,
|
||||
JSD_MISMATCH_THRESHOLD,
|
||||
MIN_COMPARABLE_CELLS,
|
||||
} from '../dist/constants.js'
|
||||
import { buildComparisonResult, decideVerdict } from '../dist/verdict.js'
|
||||
|
||||
test('verdict thresholds: match / uncertain / mismatch bands', () => {
|
||||
const cells = MIN_COMPARABLE_CELLS
|
||||
assert.equal(decideVerdict(0.0, cells), 'match')
|
||||
assert.equal(decideVerdict(JSD_MATCH_THRESHOLD, cells), 'match')
|
||||
assert.equal(decideVerdict(JSD_MATCH_THRESHOLD + 1e-9, cells), 'uncertain')
|
||||
assert.equal(decideVerdict(JSD_MISMATCH_THRESHOLD, cells), 'uncertain')
|
||||
assert.equal(decideVerdict(JSD_MISMATCH_THRESHOLD + 1e-9, cells), 'mismatch')
|
||||
assert.equal(decideVerdict(0.9, cells), 'mismatch')
|
||||
})
|
||||
|
||||
test('verdict: too few comparable cells → insufficient', () => {
|
||||
assert.equal(decideVerdict(0.1, MIN_COMPARABLE_CELLS - 1), 'insufficient')
|
||||
assert.equal(decideVerdict(null, 10), 'insufficient')
|
||||
})
|
||||
|
||||
test('buildComparisonResult carries thresholds, baselines and per-cell details', () => {
|
||||
const entries = [
|
||||
{ cellId: 'random-number-1-100:en', jsd: 0.5, validA: 25, validB: 30 },
|
||||
{ cellId: 'random-color:en', jsd: 0.1, validA: 25, validB: 30 },
|
||||
{ cellId: 'coin-flip:en', jsd: 0.2, validA: 25, validB: 30 },
|
||||
{ cellId: 'random-animal:en', jsd: 0.2, validA: 25, validB: 30 },
|
||||
]
|
||||
const result = buildComparisonResult(entries, 0.25, false)
|
||||
assert.equal(result.verdict, 'match')
|
||||
assert.equal(result.comparableCellCount, 4)
|
||||
assert.equal(result.cells.length, 4)
|
||||
assert.equal(result.protocolMismatch, false)
|
||||
assert.equal(result.thresholds.match, JSD_MATCH_THRESHOLD)
|
||||
assert.equal(result.baselines.differentModel, 0.463)
|
||||
})
|
||||
Reference in New Issue
Block a user