/** * Watermark PRFs (pseudo-random functions). * * Constants follow the reference implementations: * - HASH_KEY = 15485863 (the 1,000,000th prime) is the multiplicative salt * used by both jwkirchenbauer/lm-watermarking and bohanhou14/SemStamp. */ import { hashCombine, mix64, toUnitFloat } from './rng'; export const HASH_KEY = 15485863n; const MOD64 = (1n << 64n) - 1n; /** * Kirchenbauer "lefthash" (simple_1) seed: salt * prev_token_id, mod 2^64-1. * Matches the reference `additive_prf` with context_width=1. */ export function kirchenbauerSeed(prevTokenId: number, hashKey: bigint = HASH_KEY): bigint { return (hashKey * BigInt(prevTokenId)) % MOD64; } /** * Green-list membership test. Instead of permuting the whole vocabulary * (torch.randperm in the reference), we use a per-token PRF judgment: * token is green iff U(seed, key, tokenId) < gamma. * The green-list size is then Binomial(|V|, gamma) instead of exactly * gamma*|V|; negligible for the z-test (documented in README). * * `key` is the user-chosen secret. It is mixed in here rather than into the * seed formula so the displayed derivation stays literally * `15485863 * prev_token mod 2^64-1`, as in the reference implementation. */ export function isGreenToken( seed: bigint, tokenId: number, gamma: number, key: bigint = 0n, ): boolean { return greenUniform(seed, tokenId, key) < gamma; } /** The underlying uniform used by isGreenToken (exposed for UI/inspector). */ export function greenUniform(seed: bigint, tokenId: number, key: bigint = 0n): number { return toUnitFloat(hashCombine(seed, key, BigInt(tokenId))); } /** * TextSeal-style PRF: R_v = U(key, context window, candidate token v) in [0,1). * The watermark context window is the previous k tokens (paper default k=3). * * NOTE: the official facebookresearch/textseal repo derives R via a * cryptographic hash (appendix A.1). We use splitmix64-based mixing for * per-token speed in the browser; this is an educational deviation that * preserves the statistical construction (uniform R per (context, v, key)). */ export function textsealR( key: bigint, contextTokenIds: number[], candidateTokenId: number, ): number { const parts: bigint[] = [key]; for (const t of contextTokenIds) parts.push(BigInt(t)); parts.push(0x746578747365616cn); // "textseal" domain separator parts.push(BigInt(candidateTokenId)); let u = toUnitFloat(hashCombine(...parts)); // Guard against exact 0/1 for downstream log computations. if (u <= 0) u = Number.MIN_VALUE; if (u >= 1) u = 1 - Number.EPSILON; return u; } /** Derive a stable 64-bit key from a human-readable secret string. */ export function keyFromString(secret: string): bigint { let h = 0xcbf29ce484222325n; // FNV offset basis for (let i = 0; i < secret.length; i++) { h = mix64((h ^ BigInt(secret.charCodeAt(i))) & MOD64); } return h; } /** k-SemStamp seed: prev sentence's cluster id * prime salt, mixed with the key. */ export function ksemstampSeed( clusterId: number, key: bigint = 0n, hashKey: bigint = HASH_KEY, ): bigint { const base = (hashKey * BigInt(clusterId + 1)) % MOD64; // +1 so cluster 0 doesn't zero out return key === 0n ? base : hashCombine(base, key); } /** Derive a 64-bit key from a numeric seed entered by the user. */ export function keyFromSeed(seed: number): bigint { return mix64(BigInt(Math.trunc(seed)) & MOD64) | 1n; // never zero }