| |
| |
| |
| |
| |
| |
|
|
| import { describe, it, expect } from 'vitest'; |
| import { runGenerationLoop, type LoopModel } from '../../src/lib/model/generation-engine'; |
| import { applyGreenBias, KIRCHENBAUER_DEFAULTS } from '../../src/lib/watermark/kirchenbauer'; |
| import { deriveKeys, gumbelMaxChoose, TEXTSEAL_DEFAULTS } from '../../src/lib/watermark/textseal'; |
| import { detectKirchenbauer, type TokenizedText } from '../../src/lib/detectors/kirchenbauer-detector'; |
| import { detectTextseal } from '../../src/lib/detectors/textseal-detector'; |
| import { detectKsemstamp } from '../../src/lib/detectors/ksemstamp-detector'; |
| import { |
| acceptSentence, |
| KSEMSTAMP_DEFAULTS, |
| type Centroids, |
| } from '../../src/lib/watermark/ksemstamp'; |
| import { RandomStream, mix64 } from '../../src/lib/utils/rng'; |
| import { keyFromString } from '../../src/lib/utils/hashing'; |
| import { splitSentences } from '../../src/lib/utils/sentences'; |
| import { mean } from '../../src/lib/utils/stats'; |
|
|
| const VOCAB = 256; |
| const PERIOD = 0; |
|
|
| |
| |
| |
| |
| |
| |
| function makeMockModel(): LoopModel { |
| return { |
| vocabSize: VOCAB, |
| eosTokenIds: [VOCAB - 1], |
| decode(ids: number[]): string { |
| let out = ''; |
| for (const id of ids) { |
| if (id === PERIOD) out = out.trimEnd() + '. '; |
| else out += `W${id} `; |
| } |
| return out.trim(); |
| }, |
| async forward(inputIds, fullSeqLen, past) { |
| const lastToken = inputIds[inputIds.length - 1]; |
| const stream = new RandomStream(mix64((BigInt(lastToken) << 20n) ^ BigInt(fullSeqLen))); |
| const logits = new Float32Array(VOCAB); |
| for (let v = 0; v < VOCAB; v++) logits[v] = stream.next() * 4; |
| if (fullSeqLen % 7 === 0) logits[PERIOD] += 6; |
| else logits[PERIOD] -= 4; |
| logits[VOCAB - 1] = -20; |
| return { logits, past: (past as number ?? 0) + 1 }; |
| }, |
| disposePast() { |
| |
| }, |
| }; |
| } |
|
|
| function asTokenized(_model: LoopModel, ids: number[]): TokenizedText { |
| |
| return { |
| tokenIds: ids, |
| offsets: ids.map((_, i) => [i, i + 1] as [number, number]), |
| labels: ids.map((t) => (t === PERIOD ? '.' : `W${t}`)), |
| }; |
| } |
|
|
| const PROMPT = [3, 14, 15, 92, 65]; |
| const CFG = { temperature: 1.0, topP: 0.95, maxNewTokens: 80, topKTrace: 4 }; |
| const SEEDS = [1, 2, 3, 4, 5]; |
|
|
| describe('generation loop + Kirchenbauer end to end', () => { |
| it('same seed reproduces the baseline run exactly', async () => { |
| const model = makeMockModel(); |
| const a = await runGenerationLoop(model, PROMPT, { ...CFG, baseSeed: 11 }); |
| const b = await runGenerationLoop(model, PROMPT, { ...CFG, baseSeed: 11 }); |
| expect(a.tokenIds).toEqual(b.tokenIds); |
| }); |
|
|
| it('watermarked mean z clearly exceeds baseline mean z across seeds', async () => { |
| const model = makeMockModel(); |
| const params = KIRCHENBAUER_DEFAULTS; |
| const zBase: number[] = []; |
| const zWm: number[] = []; |
| for (const seed of SEEDS) { |
| const base = await runGenerationLoop(model, PROMPT, { ...CFG, baseSeed: seed }); |
| zBase.push(detectKirchenbauer(asTokenized(model, base.tokenIds), params).statistic); |
|
|
| const wm = await runGenerationLoop(model, PROMPT, { ...CFG, baseSeed: seed }, { |
| transformLogits: (_i, ctx, logits) => applyGreenBias(logits, ctx[ctx.length - 1], params), |
| }); |
| zWm.push(detectKirchenbauer(asTokenized(model, wm.tokenIds), params).statistic); |
| } |
| expect(mean(zWm)).toBeGreaterThan(4); |
| expect(mean(zBase)).toBeLessThan(2); |
| expect(mean(zWm)).toBeGreaterThan(mean(zBase) + 3); |
| }); |
| }); |
|
|
| describe('generation loop + TextSeal end to end', () => { |
| it('watermarked p-values are tiny; baseline is not detected (across seeds)', async () => { |
| const model = makeMockModel(); |
| const params = TEXTSEAL_DEFAULTS; |
| const keys = deriveKeys('integration-secret'); |
| let detectedWm = 0; |
| let detectedBase = 0; |
| for (const seed of SEEDS) { |
| const router = new RandomStream(BigInt(seed) + 7777n); |
| const wm = await runGenerationLoop(model, PROMPT, { ...CFG, baseSeed: seed }, { |
| sampleOverride: (_i, ctx, probs) => gumbelMaxChoose(probs, ctx, keys, params, router), |
| }); |
| const detW = detectTextseal(asTokenized(model, wm.tokenIds), keys, params, { windowL0: 20 }); |
| if (detW.watermarked) detectedWm++; |
|
|
| const base = await runGenerationLoop(model, PROMPT, { ...CFG, baseSeed: seed }); |
| const detB = detectTextseal(asTokenized(model, base.tokenIds), keys, params, { windowL0: 20 }); |
| if (detB.watermarked) detectedBase++; |
| } |
| expect(detectedWm).toBe(SEEDS.length); |
| expect(detectedBase).toBe(0); |
| }); |
|
|
| it('detection fails with the wrong key', async () => { |
| const model = makeMockModel(); |
| const params = TEXTSEAL_DEFAULTS; |
| const keys = deriveKeys('right-key'); |
| const wrong = deriveKeys('wrong-key'); |
| const router = new RandomStream(1n); |
| const wm = await runGenerationLoop(model, PROMPT, { ...CFG, baseSeed: 9 }, { |
| sampleOverride: (_i, ctx, probs) => gumbelMaxChoose(probs, ctx, keys, params, router), |
| }); |
| const det = detectTextseal(asTokenized(model, wm.tokenIds), wrong, params, { windowL0: 20 }); |
| expect(det.watermarked).toBe(false); |
| }); |
| }); |
|
|
| describe('generation loop + k-SemStamp end to end', () => { |
| |
| |
| |
| const K = 8; |
| const DIM = 16; |
| const centroids: Centroids = { |
| vectors: Array.from({ length: K }, (_, c) => { |
| const v = new Array(DIM).fill(0); |
| v[c] = 1; |
| v[(c + K) % DIM] = c % 2 === 0 ? 0.1 : -0.1; |
| const norm = Math.hypot(...v); |
| return v.map((x) => x / norm); |
| }), |
| dim: DIM, |
| encoder: 'mock', |
| corpus: 'mock', |
| }; |
| const params = { ...KSEMSTAMP_DEFAULTS, k: K, gamma: 0.25, margin: 0.0, maxTrials: 25 }; |
|
|
| function mockEmbed(text: string): number[] { |
| let h = keyFromString(text); |
| const c = Number(h % BigInt(K)); |
| const base = centroids.vectors[c]; |
| const jitterStream = new RandomStream(h); |
| const v = base.map((x) => x + (jitterStream.next() - 0.5) * 0.05); |
| const norm = Math.hypot(...v); |
| return v.map((x) => x / norm); |
| } |
|
|
| it('rejection sampling produces a chain the detector accepts; baseline does not', async () => { |
| const model = makeMockModel(); |
| let totalRetries = 0; |
| const zWm: number[] = []; |
| const zBase: number[] = []; |
|
|
| for (const seed of SEEDS) { |
| let prevCluster = 0; |
| const wm = await runGenerationLoop( |
| model, |
| PROMPT, |
| { ...CFG, maxNewTokens: 120, baseSeed: seed }, |
| { |
| maxSentenceTrials: params.maxTrials, |
| onSentenceEnd: async (text, _idx, attempt) => { |
| const emb = mockEmbed(text); |
| const res = acceptSentence(emb, prevCluster, centroids, params); |
| if (res.accepted || attempt >= params.maxTrials) { |
| prevCluster = res.assignment.clusterId; |
| return { accept: true }; |
| } |
| return { accept: false }; |
| }, |
| }, |
| ); |
| totalRetries += wm.retries; |
| const sentsW = splitSentences(wm.text); |
| const embsW = sentsW.map((s) => mockEmbed(s.text)); |
| if (sentsW.length >= 3) { |
| zWm.push(detectKsemstamp(sentsW, embsW, centroids, params).statistic); |
| } |
|
|
| const base = await runGenerationLoop(model, PROMPT, { |
| ...CFG, |
| maxNewTokens: 120, |
| baseSeed: seed, |
| }); |
| const sentsB = splitSentences(base.text); |
| const embsB = sentsB.map((s) => mockEmbed(s.text)); |
| if (sentsB.length >= 3) { |
| zBase.push(detectKsemstamp(sentsB, embsB, centroids, params).statistic); |
| } |
| } |
|
|
| expect(totalRetries).toBeGreaterThan(0); |
| expect(zWm.length).toBeGreaterThan(2); |
| expect(mean(zWm)).toBeGreaterThan(2); |
| expect(mean(zWm)).toBeGreaterThan(mean(zBase) + 1.5); |
| }); |
|
|
| it('rollback restores the exact accepted prefix (no stray tokens)', async () => { |
| const model = makeMockModel(); |
| let calls = 0; |
| const result = await runGenerationLoop( |
| model, |
| PROMPT, |
| { ...CFG, maxNewTokens: 40, baseSeed: 3 }, |
| { |
| maxSentenceTrials: 3, |
| onSentenceEnd: async () => { |
| calls++; |
| |
| return { accept: calls % 2 === 0 }; |
| }, |
| }, |
| ); |
| expect(result.retries).toBeGreaterThan(0); |
| |
| expect(result.text.trim()).toBe(model.decode(result.tokenIds)); |
| |
| expect(result.steps.length).toBe(result.tokenIds.length); |
| expect(result.steps.map((s) => s.chosenTokenId)).toEqual(result.tokenIds); |
| }); |
| }); |
|
|
| describe('streaming callback', () => { |
| it('reports every accepted token once, in order', async () => { |
| const model = makeMockModel(); |
| const seen: Array<{ index: number; id: number }> = []; |
| const result = await runGenerationLoop(model, PROMPT, { |
| ...CFG, |
| maxNewTokens: 30, |
| baseSeed: 5, |
| onToken: (step) => seen.push({ index: step.index, id: step.chosenTokenId }), |
| }); |
| expect(seen.map((s) => s.id)).toEqual(result.tokenIds); |
| expect(seen.map((s) => s.index)).toEqual(result.tokenIds.map((_, i) => i)); |
| }); |
|
|
| it('rewinds the index when a sentence is rejected, so a listener can undo it', async () => { |
| const model = makeMockModel(); |
| const indices: number[] = []; |
| let calls = 0; |
| const result = await runGenerationLoop( |
| model, |
| PROMPT, |
| { ...CFG, maxNewTokens: 40, baseSeed: 3, onToken: (step) => indices.push(step.index) }, |
| { |
| maxSentenceTrials: 3, |
| onSentenceEnd: async () => { |
| calls++; |
| return { accept: calls % 2 === 0 }; |
| }, |
| }, |
| ); |
|
|
| |
| expect(result.retries).toBeGreaterThan(0); |
| const wentBackwards = indices.some((v, i) => i > 0 && v <= indices[i - 1]); |
| expect(wentBackwards).toBe(true); |
|
|
| |
| |
| const rebuilt: number[] = []; |
| let cursor = 0; |
| for (const step of indices) { |
| rebuilt.length = step; |
| rebuilt.push(cursor++); |
| } |
| expect(rebuilt.length).toBe(result.tokenIds.length); |
| }); |
| }); |
|
|
| describe('token text is decoded in context', () => { |
| |
| |
| |
| |
| const TOKEN_BYTES: number[][] = [ |
| [0xed, 0x95], |
| [0x98], |
| [0xeb, 0x82], |
| [0x98], |
| [0x2e, 0x20], |
| [0x61], |
| ]; |
| const EOS = TOKEN_BYTES.length; |
| const decoder = new TextDecoder(); |
|
|
| function byteModel(): LoopModel { |
| return { |
| vocabSize: EOS + 1, |
| eosTokenIds: [EOS], |
| decode(ids: number[]): string { |
| return decoder.decode(Uint8Array.from(ids.flatMap((id) => TOKEN_BYTES[id] ?? []))); |
| }, |
| async forward(_inputIds, fullSeqLen, past) { |
| |
| const logits = new Float32Array(EOS + 1).fill(-20); |
| const cycle = [0, 1, 2, 3, 5, 4]; |
| logits[cycle[(fullSeqLen - 1) % cycle.length]] = 10; |
| return { logits, past: ((past as number) ?? 0) + 1 }; |
| }, |
| disposePast() {}, |
| }; |
| } |
|
|
| it('emits whole characters, never half of one', async () => { |
| const model = byteModel(); |
| const pieces: string[] = []; |
| const result = await runGenerationLoop(model, [5], { |
| ...CFG, |
| maxNewTokens: 24, |
| baseSeed: 2, |
| onToken: (s) => { |
| pieces.length = s.index; |
| pieces.push(s.chosenTokenText); |
| }, |
| }); |
|
|
| expect(result.text).toContain('ν'); |
| expect(result.text).toContain('λ'); |
| |
| |
| for (const piece of pieces) expect(piece).not.toContain('\ufffd'); |
| expect(pieces.some((p) => p === '')).toBe(true); |
| }); |
|
|
| it('per-token texts concatenate to exactly the final text', async () => { |
| const model = byteModel(); |
| const pieces: string[] = []; |
| const result = await runGenerationLoop(model, [5], { |
| ...CFG, |
| maxNewTokens: 24, |
| baseSeed: 7, |
| onToken: (s) => { |
| pieces.length = s.index; |
| pieces.push(s.chosenTokenText); |
| }, |
| }); |
|
|
| |
| |
| const settled = result.text.replace(/\ufffd+$/, ''); |
| expect(pieces.join('')).toBe(settled); |
| expect(result.steps.map((s) => s.chosenTokenText).join('')).toBe(settled); |
| }); |
| }); |
|
|
| describe('retry temperature is bounded', () => { |
| |
| async function hottest(step: number, max: number): Promise<number> { |
| const model = makeMockModel(); |
| let peak = 0; |
| await runGenerationLoop( |
| model, |
| PROMPT, |
| { |
| ...CFG, |
| maxNewTokens: 60, |
| baseSeed: 4, |
| retryTemperatureStep: step, |
| retryTemperatureMax: max, |
| onToken: (s) => { |
| peak = Math.max(peak, s.entropy ?? 0); |
| }, |
| }, |
| { |
| maxSentenceTrials: 20, |
| |
| onSentenceEnd: async (_t, _i, attempt) => ({ accept: attempt >= 12 }), |
| }, |
| ); |
| return peak; |
| } |
|
|
| it('a runaway step is clamped to the ceiling', async () => { |
| const noEscalation = await hottest(0, 1); |
| const clamped = await hottest(1.0, 1.5); |
| const uncapped = await hottest(1.0, 100); |
|
|
| |
| |
| expect(clamped).toBeGreaterThan(noEscalation); |
| expect(uncapped).toBeGreaterThan(clamped); |
| |
| |
| expect(clamped - noEscalation).toBeLessThan(uncapped - noEscalation); |
| }); |
| }); |
|
|