How Watermarking Works Typebulb's interactive explainer demonstrates how text watermarking works in a browser, using a secret key to run a tournament between words the model drew so the winner is one it would say. The page includes a full implementation of TinyStories-1M, a 3.63M-parameter GPT-Neo decoder trained on synthetic stories, with code for the transformer stack and attention mechanisms. Typebulb requires JavaScript to run bulbs. Learn how text watermarking works in your browser: a secret key runs a tournament between words the model drew, so the winner is one it would say. --- format: typebulb/v1 name: How Watermarking Text Works --- code.tsx tsx import { Module, Linear, LayerNorm, compileForward, checkWebGPU, add, mul, matmul, sum, reshape, swapAxes, splitHeads, mergeHeads, softmaxCausal, gelu, type Tensor, } from 'tensorgrad' import { App, Component, a, div, h1, h2, h3, p, span, strong, em, button, inputRange, inputTextArea, table, thead, tbody, tr, th, td, type VElement, } from 'domeleon' // ============================================================================ // The language model // ============================================================================ // TinyStories-1M roneneldan/TinyStories-1M , a GPT-Neo decoder trained only on // synthetic three-year-old-vocabulary stories. 3.75M parameters, of which the 50257-row // embedding table — also the tied output head — is 3.2M and the entire eight-layer // transformer stack only 0.4M. The copy shipped here is 3.63M: the exporter keeps the // first 256 rows of the 2048-row position table, which is all a 256-token window reaches. const D = 64, L = 8, HEADS = 16, VOCAB = 50257 // The compiled graph is one fixed length. 256 is also GPT-Neo's local-attention window: // this checkpoint alternates global and local 256 attention layers, and below 256 tokens // the two are the same function, so one causal attention is exact. Above it they diverge. const CTX = 256 const INPUTS = { embed: 1, CTX, D , sel: 1, CTX } as const class Block extends Module { ln1 = new LayerNorm D q = new Linear D, D, { bias: false } k = new Linear D, D, { bias: false } v = new Linear D, D, { bias: false } attnOut = new Linear D, D ln2 = new LayerNorm D fc = new Linear D, 4 D proj = new Linear 4 D, D } class TinyStories extends Module { // Present as a parameter for the output head only. The input-side lookup happens on the // CPU: tensorgrad's embedding composes to oneHot @ table , and a 1,256,50257 one-hot // is 51MB of tensor per step to express what is really a gather of 256 rows. wte = this.param VOCAB, D blocks: Block lnf = new LayerNorm D constructor { super this.blocks = Array.from { length: L }, = new Block } } // GPT-Neo is PRE-norm, and its attention does NOT divide qk by sqrt headDim . That missing // scale is a Mesh-Tensorflow inheritance and the single easiest way to get this port wrong: // adding it still yields fluent stories, drawn from a distribution ~45% off the real one. // scripts/pack-tinystories.mjs checks the unscaled form against HuggingFace's own logits on // every build — though against its own CPU reference pass, so what that proves is the // weights and the arithmetic, not this graph: the two are separate implementations that // share the checkpoint and a 1e-5 layernorm epsilon. function block b: Block, h: Tensor : Tensor { const a = b.ln1.fwd h const q = splitHeads b.q.fwd a , HEADS const k = splitHeads b.k.fwd a , HEADS const v = splitHeads b.v.fwd a , HEADS const ctx = mergeHeads matmul softmaxCausal matmul q, swapAxes k, -1, -2 , -1 , v const h2 = add h, b.attnOut.fwd ctx const m = b.ln2.fwd h2 return add h2, b.proj.fwd gelu b.fc.fwd m , { approximate: 'tanh' } } // sel is one-hot over positions: it picks the last real token's row out of the padded // window. Padding needs no mask of its own — attention is causal, so a real position can // never see a later pad. function forward m: TinyStories, { embed, sel }: { embed: Tensor; sel: Tensor } : Tensor { let h = embed for const b of m.blocks h = block b, h h = m.lnf.fwd h const last = sum mul h, reshape sel, 1, CTX, 1 , 1 return matmul last, swapAxes m.wte, -1, -2 } // ============================================================================ // The watermark: tournament sampling // ============================================================================ // Dathathri et al., "Scalable watermarking for identifying large language model outputs", // Nature 634 2024 — the SynthID-Text scheme, deployed in Gemini and open-sourced in // HuggingFace Transformers as SynthIDTextWatermarkLogitsProcessor. // // The construction in one paragraph. At each step the model produces its distribution over // the vocabulary. Draw N^m candidate words from THAT distribution, independently and with // replacement, and run them through m knockout layers: in each layer the survivors are split // into matches of N, and the winner of a match is the candidate whose coin came up heads, // ties broken uniformly at random. The coins come from the key. The last word standing is // emitted. // // What that buys, and it is the whole reassurance: every candidate in the bracket was drawn // from the model's own distribution before the key saw it, so the key can never promote a // word the model would not have said. It only chooses among words the model was already // willing to say. At N = 2 the choosing is exactly fair on average over keys — proved in the // paper, derived and checked by brute force in scripts/check-tournament-closed-form.mjs. // // The value of the key is arbitrary; this one is fixed so runs are reproducible. const KEY = 0x7E1C4A93 / 32-bit integer hash murmur3 finalizer over a mixed pair , lifted from the two sibling watermarking bulbs. Called about V m times per generated token here, so its cost is the cost of the whole scheme. / function hash32 a: number, b: number : number { let x = a ^ Math.imul b, 0x9E3779B1 0 x = Math.imul x ^ x 16 , 0x85EBCA6B 0 x = Math.imul x ^ x 13 , 0xC2B2AE35 0 return x ^ x 16 0 } / H in the paper: how many preceding tokens are hashed with the key to seed a position. This is the difference that makes the whole scheme cheap to detect. The distortion-free bulb's key is one long sequence and a text may start anywhere inside it, which forces the detector to search every alignment and leaves no formula to write the null down with. Here the seed travels with the text: any reader holding the key can recompute it from the four words in front of them, at any position, with nothing to align and nothing to search. / const SEED CONTEXT = 4 / The layer count the paper runs its experiments at, and the ceiling on the slider. NOT "the deployed value". The paper says "Unless otherwise mentioned, for all SynthID-Text experiments, we use m = 30 tournament layers", and separately that the non-distortionary configuration is productionized in Gemini. It never joins the two, and never publishes Gemini's own m, H or scorer. Copy on this page kept being regenerated from this comment, so the wording here is load-bearing: say published, never deployed. See specs/watermarking.md A.5. What the paper DOES pin about the deployment is the non-distortion level, single sequence at K = 1, so "runs in Gemini" about the scheme itself is fair and stays. / const MAX LAYERS = 30 / r t: the seed for the position that follows ids t-SEED CONTEXT .. t-1 . / function contextSeed ids: readonly number , t: number : number { let s = KEY for let i = t - SEED CONTEXT; i < t; i++ s = hash32 s, ids i return s } / g l x, r : the key's coin for word x in layer l at seed r, Bernoulli 0.5 . The paper writes it as F g^-1 h x, l, r / 2^n sec with F g = Bernoulli 0.5 , which is one hashed bit per word, layer, position . That is what this is: the top bit of a hash of the word under a per-layer seed. Reading the m layers off m separate bits of ONE hash would be about twice as fast and is a common shortcut; it is not taken, because then the layers' independence rests on the hash's avalanche rather than on the construction, and the whole test assumes those coins are independent. selftest measures the per-layer heads rate on unmarked text either way. / const layerSeed = r: number, layer: number = hash32 r, layer const coinAt = seed: number, tok: number = hash32 seed, tok 31 / How many of this word's m coins came up heads at this position. The detector's entire arithmetic, and the number the story lights words by. / function headsFor r: number, tok: number, layers: number : number { let h = 0 for let l = 0; l < layers; l++ h += coinAt layerSeed r, l , tok return h } / Every coin of one word at one position, for the strip in the inspector. / function coinsFor r: number, tok: number, layers: number : Uint8Array { const out = new Uint8Array layers for let l = 0; l < layers; l++ out l = coinAt layerSeed r, l , tok return out } // ---- the closed form ------------------------------------------------------- / One tournament layer, applied to a whole distribution at once. m = 30 layers means 2^30 candidates, and nobody draws a billion words per step. They do not have to: the tournament's effect on the distribution has an exact closed form costing one pass over the vocabulary per layer. With m1 the probability mass sitting on heads, heads word x: p' x = p x 1 - 1 - m1 ^N / m1 tails word x: p' x = p x 1 - m1 ^ N - 1 and at the default N = 2 that is simply p x 2 - m1 for heads and p x 1 - m1 for tails. Each layer's survivors are i.i.d. draws from the previous layer's distribution, because the matches are disjoint groups of independent draws, so applying this m times with a fresh coin vector each time is the whole sampler. Derived rather than quoted, and checked two ways: scripts/check-tournament-closed-form.mjs enumerates every coin assignment over a five-word distribution against exact enumeration of all V^N candidate tuples agreement to 1e-15 , and the agreement probe here plays a hundred thousand literal brackets against it on the model's own distribution. Ties are broken uniformly over SLOTS rather than over distinct words, which matters because draws are with replacement and one word can hold several slots. ids names the word behind each slot of q , for the fairness check, which runs the same rule over a few thousand words rather than the whole vocabulary. Without it a slot is its own word. One implementation either way: the page's central claim is that this formula and the literal bracket agree, and a second copy of it would make that comparison worth less. competitors is 2 everywhere the page can reach. It stays a parameter because the paper's distortionary setting is the only positive control this check has: a measurement that has only ever printed "nothing here" cannot be told apart from one that always would, so selftest runs it at 3 and requires a bias to show. Nothing on the page offers the choice. / function tournamentLayer q: Float64Array, seed: number, coin: Uint8Array, ids?: Int32Array, competitors = 2, : void { const n = q.length let m1 = 0 for let v = 0; v < n; v++ { const c = coinAt seed, ids ? ids v : v coin v = c if c m1 += q v } // Every word with any mass sits on the same side. The layer is then a formality: whichever // side that is, its multiplier works out to 1 and the other side has nothing to scale. if m1 <= 0 || m1 = 1 return const heads = 1 - 1 - m1 competitors / m1 const tails = 1 - m1 competitors - 1 for let v = 0; v < n; v++ q v = q v coin v ? heads : tails } / The distribution tournament sampling actually emits from, at one position under one key. Writes into out and returns it. / function tournamentDistribution probs: Float64Array, r: number, layers: number, out: Float64Array, coin: Uint8Array, : Float64Array { out.set probs for let l = 0; l < layers; l++ tournamentLayer out, layerSeed r, l , coin return out } // ---- the literal bracket --------------------------------------------------- interface BracketSlot { id: number; text: string; heads: number } interface BracketRound { / The entrants to this layer, in match order: consecutive pairs. / slots: BracketSlot / Per match, which of the pair won, 0 or 1. / wonAt: number } interface Bracket { rounds: BracketRound / Distinct words among the drawn candidates. One means the position was never in doubt. / distinct: number } / Running totals of p into cdf , the form pickFrom searches. Returns cdf . / function cumulate p: Float64Array, cdf: Float64Array : Float64Array { let acc = 0 for let v = 0; v < p.length; v++ { acc += p v ; cdf v = acc } return cdf } / Sample one index from a cumulative distribution. / function pickFrom cdf: Float64Array, u: number : number { const target = u cdf cdf.length - 1 let lo = 0, hi = cdf.length - 1 while lo < hi { const mid = lo + hi 1 if cdf mid < target lo = mid + 1 else hi = mid } return lo } / The tournament played out for real: 2^m words drawn from the model, then m knockout layers. Only reachable for small brackets, which is the point of drawing it — a reader understands a knockout bracket before they have finished looking at it. Above sixteen slots the closed form above emits the same distribution without the draws. / function playBracket cdf: Float64Array, r: number, layers: number, rand: = number, label: id: number = string, : { id: number; bracket: Bracket } { let alive: number = for let i = 0; i < 2 layers; i++ alive.push pickFrom cdf, rand const distinct = new Set alive .size const rounds: BracketRound = for let l = 0; l < layers; l++ { const seed = layerSeed r, l const coins = alive.map id = coinAt seed, id const slots = alive.map id, i = { id, text: label id , heads: coins i } const wonAt: number = const next: number = for let i = 0; i < alive.length; i += 2 { // Two matching coins single out neither slot, so the match goes to a fair toss. Tossed over // SLOTS rather than over words, which matters because draws are with replacement and one // word can hold both: that is what makes the closed form come out the way it does. const at = coins i === coins i + 1 ? rand < 0.5 ? 0 : 1 : coins i coins i + 1 ? 0 : 1 wonAt.push at next.push alive i + at } rounds.push { slots, wonAt } alive = next } return { id: alive 0 , bracket: { rounds, distinct } } } // ---- detection ------------------------------------------------------------- / log of the gamma function: Lanczos, g = 5, six coefficients. Here only to build a log binomial coefficient. / function lgamma x: number : number { const COF = 76.18009172947146, -86.50532032941677, 24.01409824083091, -1.231739572450155, 0.1208650973866179e-2, -0.5395239384953e-5, let y = x, t = x + 5.5 t -= x + 0.5 Math.log t let s = 1.000000000190015 for let j = 0; j < 6; j++ s += COF j / ++y return -t + Math.log 2.5066282746310005 s / x } / One-sided p-value: P Binomial n, 1/2 = k , the chance a keyless writer's coins run this far above half. Summed exactly rather than through the normal approximation. At one half the binomial is symmetric and the Gaussian is a far better fit than it was on the green list bulb's skewed quarter, but n here runs to a few thousand terms and summing them is free, so there is no reason to quote an approximation as an odds. The z the meter reports is untouched: it is the paper's statistic. / function binomialTail k: number, n: number : number { const from = Math.ceil k if n <= 0 || from <= 0 return 1 if from n return 0 let term = Math.exp lgamma n + 1 - lgamma from + 1 - lgamma n - from + 1 - n Math.LN2 let s = term for let i = from; i < n; i++ { term = n - i / i + 1 s += term } return Math.min 1, s } / The chance a word lights by luck alone: P Binomial layers, 1/2 layers/2 . Half for an odd number of layers, less for an even one, since a level split is not a majority. / function litChance layers: number : number { return binomialTail Math.floor layers / 2 + 1, layers } interface Score { / Positions the test counted. / counted: number / Coins behind those positions: counted layers. / coins: number heads: number z: number p: number / Per token: heads out of layers , or -1 for a position the test could not count. / marks: Int8Array / Heads per layer, over the counted positions. Flat across layers is what independent coins look like; a layer standing out would mean the per-layer seeds are correlated, which is the one way the g-value construction here could be quietly wrong. Measured from the text rather than from the generator, so it reads unmarked text too, which is the case that would expose it. / perLayer: Int32Array / Positions skipped because their four-word context had already been seen. / repeats: number / Positions with no full context window in front of them, so at most SEED CONTEXT. / contextless: number / Words past the cap, read by nobody. / dropped: number } / Whether a word's coins came up heads often enough to light it. A strict majority is the one threshold statable in a legend without a table, and -1 is a position the test could not count. Every mark on both tabs and the lit figure in stats read from here. / const isLit = heads: number, layers: number = heads = 0 && heads 2 layers / How many of a scored document's words light. / const litCount = s: Score, layers: number = ...s.marks .filter m = isLit m, layers .length / The figures any scored text has, so the stats and read probes never report one quantity two ways. Rounded here because these go to a terminal, not into more arithmetic. / function scoreSummary s: Score { return { counted: s.counted, coins: s.coins, heads: s.heads, headRate: + s.heads / Math.max 1, s.coins .toFixed 3 , z: +s.z.toFixed 2 , p: s.p, repeats: s.repeats, } } / The longest text the detector will score. Generous, because unlike the distortion-free bulb's permutation test this is one pass of a few hashes per word. / const TEST MAX = 4000 / Detection, which needs no model, no prompt and no record of how the text was written: only the key, the text and the same tokenizer. Walk the words, recompute each position's seed from the four words before it, look up the coins of the word that is actually there, and count the heads. Under the null every coin is an independent fair coin, so the heads are Binomial coins, 1/2 and the z-score follows from a formula. Score x = 1 / mT sum t sum l g l x t, r t Repeated context masking, Algorithm 3 of the paper. A position whose four-word context has already been seen is skipped, because reusing a seed reuses the coins and breaks the independence the test assumes. The generator applies the same rule and leaves those positions unwatermarked, so the two agree exactly on which positions carry a mark. On text as loopy as a tiny model's this matters more than it would on a large one. / function scoreTokens ids: readonly number , layers: number : Score { const dropped = Math.max 0, ids.length - TEST MAX const n = ids.length - dropped const marks = new Int8Array n .fill -1 const perLayer = new Int32Array layers const seen = new Set