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:
Executable
+169
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build data/reference-fingerprints.sample.json from the paper's public
|
||||
* Zenodo dataset (DOI 10.5281/zenodo.21278557, CC-BY-4.0).
|
||||
*
|
||||
* Input: the dataset's aggregated `distributions.json` — an object with a
|
||||
* `distributions` array of per-cell records:
|
||||
* { model, task_id, lang, temperature, n_valid, dist: { answer: probability }, ... }
|
||||
*
|
||||
* Counts are reconstructed as round(probability × n_valid) and answers are
|
||||
* re-normalized with this package's normalizer so vocabularies line up
|
||||
* (e.g. the dataset's coin answers h/t → heads/tails, 蓝色 → 蓝). Cells that
|
||||
* lose more than 20% of their probability mass in re-normalization are
|
||||
* skipped.
|
||||
*
|
||||
* Usage:
|
||||
* npm run build # the script imports the compiled normalizer from dist/
|
||||
* node scripts/build-sample-references.mjs <path-to-distributions.json> \
|
||||
* [--models comma,separated,slugs] [--out data/reference-fingerprints.sample.json]
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { PROBE_TASKS } from '../dist/battery.js'
|
||||
import { normalizeAnswer } from '../dist/normalizer.js'
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
/** Dataset task_id → this package's battery task id. */
|
||||
const TASK_MAP = {
|
||||
'num100-random': 'random-number-1-100',
|
||||
'num10-random': 'random-number-1-10',
|
||||
'letter-random': 'random-letter',
|
||||
'color-random': 'random-color',
|
||||
'coin-flip': 'coin-flip',
|
||||
'animal-random': 'random-animal',
|
||||
'city-random': 'random-city',
|
||||
'num-favorite': 'favorite-number',
|
||||
}
|
||||
|
||||
const LANGS = new Set(['en', 'zh'])
|
||||
|
||||
/** The dataset folds coin answers to single letters; pre-expand before normalizing. */
|
||||
const COIN_PREMAP = { h: 'heads', t: 'tails' }
|
||||
|
||||
const DEFAULT_MODELS = [
|
||||
'openai/gpt-4o-mini',
|
||||
'openai/gpt-4o',
|
||||
'openai/gpt-4.1-mini',
|
||||
'anthropic/claude-sonnet-4.5',
|
||||
'google/gemini-2.5-flash',
|
||||
'deepseek/deepseek-chat',
|
||||
'meta-llama/llama-3.1-8b-instruct',
|
||||
'qwen/qwen3-30b-a3b-instruct-2507',
|
||||
'mistralai/mistral-small-3.2-24b-instruct',
|
||||
'z-ai/glm-4.5',
|
||||
'moonshotai/kimi-k2',
|
||||
]
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { input: null, models: DEFAULT_MODELS, out: join(HERE, '..', 'data', 'reference-fingerprints.sample.json') }
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const token = argv[i]
|
||||
if (token === '--models') args.models = argv[++i].split(',').map((m) => m.trim())
|
||||
else if (token === '--out') args.out = argv[++i]
|
||||
else if (!token.startsWith('--') && !args.input) args.input = token
|
||||
else throw new Error(`Unknown argument: ${token}`)
|
||||
}
|
||||
if (!args.input) {
|
||||
console.error('usage: node scripts/build-sample-references.mjs <distributions.json> [--models a,b] [--out file]')
|
||||
process.exit(1)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
function convertCell(record, taskId) {
|
||||
const domain = PROBE_TASKS[taskId].domain
|
||||
const counts = {}
|
||||
let keptMass = 0
|
||||
let totalMass = 0
|
||||
for (const [rawAnswer, probability] of Object.entries(record.dist)) {
|
||||
totalMass += probability
|
||||
const premapped = taskId === 'coin-flip' ? (COIN_PREMAP[rawAnswer] ?? rawAnswer) : rawAnswer
|
||||
const { normalized, category } = normalizeAnswer(premapped, domain)
|
||||
if (category !== 'valid' || normalized === null) continue
|
||||
const count = Math.round(probability * record.n_valid)
|
||||
if (count <= 0) continue
|
||||
counts[normalized] = (counts[normalized] ?? 0) + count
|
||||
keptMass += probability
|
||||
}
|
||||
if (totalMass <= 0 || keptMass / totalMass < 0.8) return null
|
||||
const n = Object.values(counts).reduce((sum, c) => sum + c, 0)
|
||||
if (n < 10) return null
|
||||
return { n, counts }
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
const payload = JSON.parse(readFileSync(args.input, 'utf8'))
|
||||
const records = payload.distributions
|
||||
if (!Array.isArray(records)) throw new Error('Input has no "distributions" array')
|
||||
|
||||
const collectedAt = (payload.generated_utc ?? '').slice(0, 10) || 'unknown'
|
||||
const wanted = new Set(args.models)
|
||||
const models = {}
|
||||
let skippedCells = 0
|
||||
|
||||
for (const record of records) {
|
||||
if (!wanted.has(record.model)) continue
|
||||
if (!LANGS.has(record.lang)) continue
|
||||
const taskId = TASK_MAP[record.task_id]
|
||||
if (!taskId) continue
|
||||
if (record.temperature !== 1) continue
|
||||
|
||||
const cell = convertCell(record, taskId)
|
||||
if (!cell) {
|
||||
skippedCells += 1
|
||||
continue
|
||||
}
|
||||
const cellId = `${taskId}:${record.lang}`
|
||||
models[record.model] ??= {
|
||||
model: record.model,
|
||||
collectedAt,
|
||||
channel: 'openrouter',
|
||||
cells: {},
|
||||
}
|
||||
models[record.model].cells[cellId] = cell
|
||||
}
|
||||
|
||||
for (const model of args.models) {
|
||||
const entry = models[model]
|
||||
if (!entry) {
|
||||
console.warn(`warn: model not found in dataset: ${model}`)
|
||||
} else if (Object.keys(entry.cells).length < 8) {
|
||||
console.warn(`warn: ${model} only has ${Object.keys(entry.cells).length} usable cells`)
|
||||
}
|
||||
}
|
||||
|
||||
const output = {
|
||||
formatVersion: 1,
|
||||
protocol: 'bruckner-zenodo-2026',
|
||||
samplesPerCell: 30,
|
||||
source: {
|
||||
dataset:
|
||||
'Single-token output distributions as behavioral fingerprints of large language models',
|
||||
author: 'Tomáš Bruckner (Prague University of Economics and Business)',
|
||||
datasetDoi: '10.5281/zenodo.21278557',
|
||||
paper: 'arXiv:2607.10252',
|
||||
license: 'CC-BY-4.0',
|
||||
note:
|
||||
'Counts reconstructed as round(probability × n_valid) from the published per-cell distributions; ' +
|
||||
'answers re-normalized with the llm-fingerprint-detector normalizer. Collected via OpenRouter by ' +
|
||||
"the paper's harness under the paper's prompt protocol (not this package's battery), so comparisons " +
|
||||
'against these samples are indicative rather than strict.',
|
||||
},
|
||||
models,
|
||||
}
|
||||
|
||||
writeFileSync(args.out, `${JSON.stringify(output, null, 2)}\n`, 'utf8')
|
||||
const cellTotal = Object.values(models).reduce((sum, m) => sum + Object.keys(m.cells).length, 0)
|
||||
console.log(
|
||||
`wrote ${args.out}: ${Object.keys(models).length} models, ${cellTotal} cells (${skippedCells} cells skipped in re-normalization)`,
|
||||
)
|
||||
}
|
||||
|
||||
main()
|
||||
Reference in New Issue
Block a user