Embedding "JB" in a text: two watermarking schemes
Julien Béranger
+ Claude Opus 5
Since August 2026, Anthropic marks the text Claude produces. Céleste Chrétien's LinkedIn post laid out the mechanism clearly: a statistically detectable bias in token choice, invisible to a reader, recoverable by anyone holding the key. This is a companion piece that builds the thing and measures it.
Two schemes, same source paragraph:
- Scheme A — steganographic payload. "JB" is the message, hidden as 16 bits. Exact recovery, zero robustness.
- Scheme B — keyed statistical watermark. "JB" is the key. Nothing is recovered from the text, only a z-score. This is the family Anthropic and Google actually deploy.
Everything is TypeScript, self-contained, no dependencies. Every number below was produced by the code in §6.
node --experimental-strip-types run.ts0. What Anthropic announced, and why
Anthropic confirmed in an updated support page that Claude's output carries an invisible watermark, as reported by TechCrunch on 11 August. The help centre documentation is explicit that the mark lives in the text rather than in the file: it survives copy-paste, and may survive some editing. It is applied at the model level, so it is present whichever surface the text comes from — claude.ai, the API, Claude Code, Cowork. Files get C2PA provenance metadata instead, which is a different mechanism with a different failure mode: metadata is stripped by screenshots and format conversions, as C2PA Viewer's breakdown sets out. Forbes describes the text mechanism as a subtle bias in word choice, detectable across enough content — which is exactly the construction below. At least one tracker reads the disclosed method as a variant of SynthID-Text.
The regulation
The trigger is Article 50 of the EU AI Act. Paragraph 2 requires providers of generative systems — audio, image, video or text — to mark outputs in a machine-readable format and make them detectable as artificially generated, with technical solutions that are effective, interoperable, robust and reliable. The obligations took effect on 2 August 2026. The Commission published its finalised guidelines on 20 July 2026 alongside a Code of Practice on Transparency of AI-Generated Content; Paul Weiss and Herbert Smith Freehills Kramer both have readable summaries, and Orrick's guide goes furthest into the operational detail.
Two details from that guidance matter for what follows. The Code distinguishes free-form text — a raw character sequence with no container, as in a chat window — from containerised text like PDF or DOCX, which can carry metadata and therefore falls under a different marking regime. And it defines "very short text" as anything under 200 tokens. That threshold is not arbitrary, and §3.5 shows why. Meanwhile the AI Omnibus provisional agreement of May 2026 gives systems already on the market before 2 August until 2 December 2026 to comply — which is the legal shape of Anthropic's "older models will be retrofitted".
Three limits worth stating plainly
It is a processing signal, not an authorship signal. Anthropic's limitations section says a detected mark means the content may have passed through Claude — not that Claude wrote the ideas, text or data. A mark can appear after proofreading, translation, summarisation or file conversion. And the absence of a mark proves nothing either.
The detector does not exist yet. MLQ's summary notes that the statistical construction, the minimum reliable text length, and the measured detection and false-positive rates are all unpublished. Without the key, third-party detectors are still guessing from stylistic tics — the "not X, but Y" school of AI detection, which is not a detector so much as a vibe.
It is global, not European. Anthropic applies the watermark everywhere because it has no durable technical mechanism to limit it to the EU; connecting from outside Europe changes nothing about the output. Alain Goudey's critique makes the sharpest version of the objection: a regional norm becomes a worldwide product property, and that is a political choice presented as a technical constraint. French coverage from Next, KultureGeek and Blog du Modérateur covers the rollout; Medianama raises the anti-circumvention question that the announcement leaves open.
The prior art
The green/red construction in §3 is Kirchenbauer et al., A Watermark for Large Language Models (ICML 2023): partition the vocabulary into "green" and "red" lists seeded on preceding tokens, softly promote green during sampling, detect with a z-test that yields interpretable p-values. Google's production system is SynthID-Text, published in Nature in October 2024 by Dathathri et al., which replaces the soft bias with a mechanism called Tournament Sampling and was validated on roughly 20 million live Gemini responses — the first deployment of a text watermark at that scale. It is open source.
The steganographic cousin in §2 is the same trick with the keys held by different people. Norelli and Bronstein's LLMs can hide text in other text of the same length is the sharpest recent statement of it: their Calgacus protocol (code) encodes one meaningful text inside another meaningful text of identical length, using an 8B open model on a laptop. Their framing — a radical decoupling of text from authorial intent — is the right lens for the whole topic.
1. INPUT — source text, identical for both schemes
Stats. ETH is trading around $2,511, up roughly 5% on the day and extending well past yesterday's ~$2,386 level — the move has it approaching its 200-day EMA with a 50/100-day EMA convergence some traders are reading as a bullish setup. The validator set kept climbing: 905,998 active validators (up from 905,286), with 42.7 million ETH staked (35.03% of supply, up from 34.99%). The entry queue grew to 2,110,424 ETH at a 36-day, 15-hour wait. The exit queue stayed calm at 256 ETH with a 6-minute wait, confirming yesterday's brief exit-queue spike was a one-day blip. Gas ticked up to 0.078 gwei average (from 0.042 gwei) alongside the price move. DeFi TVL rose to $49.65 billion, tracking the ETH price gain. ETF flows: September 2 revised to a $48.2 million net outflow, but September 3 flipped back to a $2.9 million net inflow; cumulative net inflows since launch stand at roughly $13.02 billion.
154 whitespace tokens, 128 of them alphabetic. Every figure is immovable. That last constraint turns out to matter more than anything else here.
2. Scheme A — "JB" as a hidden payload
2.1 Construction
Payload. J = 0x4A, B = 0x42 → 01001010 01000010, 16 bits.
Carrier rule. One bit per designated word, encoded as the parity of its letter count: even → 0, odd → 1. Non-letters are stripped first, so $2,511, and 36-day, can carry nothing.
Key. The ordered list of the 16 carrier positions — whitespace-token indices into the output.
2.2 OUTPUT — Scheme A
Five words changed, one inserted, no figure touched. Changes in bold:
Stats. ETH is trading around $2,511, up roughly 5% on the day and climbing well past yesterday's ~$2,386 level — the move has it approaching its 200-day EMA with a 50/100-day EMA convergence some traders are reading as a bullish signal. The validator set kept growing: 905,998 active validators (up from 905,286), with 42.7 million ETH staked (35.03% of circulating supply, up from 34.99%). The entry queue grew to 2,110,424 ETH at a 36-day, 15-hour wait. The exit queue stayed quiet at 256 ETH with a 6-minute wait, confirming yesterday's brief exit-queue spike was a one-day blip. Gas ticked up to 0.078 gwei average (from 0.042 gwei) alongside the price move. DeFi TVL rose to $49.65 billion, tracking the ETH price gain. ETF flows: September 2 revised to a $48.2 million net outflow, but September 3 flipped back to a $2.9 million net inflow; cumulative net inflows since launch stand at roughly $13.02 billion.
Only 5 of the 16 carriers needed editing — the other 11 already had the right parity by chance. In a text this long a large fraction of arbitrary bits are already satisfied, so a short payload is cheap to embed.
2.3 How to retrieve "JB"
export const KEY_A = [4, 7, 13, 40, 45, 47, 59, 67, 75, 80, 87, 88, 98, 110, 116, 143];
const letters = (t: string) => t.replace(/[^A-Za-z]/g, "");
export function decodeA(text: string, key: number[] = KEY_A) {
const toks = text.split(/\s+/);
const bits = key.map((p) => letters(toks[p]).length % 2).join("");
const payload = (bits.match(/.{8}/g) ?? [])
.map((b) => String.fromCharCode(parseInt(b, 2)))
.join("");
return { bits, payload };
}Full trace:
| # | idx | token | letters | len | bit |
|---|---|---|---|---|---|
| 1 | 4 | around | around | 6 | 0 |
| 2 | 7 | roughly | roughly | 7 | 1 |
| 3 | 13 | climbing | climbing | 8 | 0 |
| 4 | 40 | signal. | signal | 6 | 0 |
| 5 | 45 | growing: | growing | 7 | 1 |
| 6 | 47 | active | active | 6 | 0 |
| 7 | 59 | circulating | circulating | 11 | 1 |
| 8 | 67 | grew | grew | 4 | 0 |
| 9 | 75 | wait. | wait | 4 | 0 |
| 10 | 80 | quiet | quiet | 5 | 1 |
| 11 | 87 | wait, | wait | 4 | 0 |
| 12 | 88 | confirming | confirming | 10 | 0 |
| 13 | 98 | ticked | ticked | 6 | 0 |
| 14 | 110 | move. | move | 4 | 0 |
| 15 | 116 | billion, | billion | 7 | 1 |
| 16 | 143 | inflow; | inflow | 6 | 0 |
01001010 = 74 = J, 01000010 = 66 = B.
2.4 Fragility, measured
The same key applied to the unmarked original:
watermarked -> { bits: "0100101001000010", payload: "JB" }
original -> { bits: "0111000010000000", payload: "p\x80" } // garbageNo false positive — good. But the failure mode is total. Inserting the single word circulating at index 59 shifted every later index by one, so all seven subsequent carriers land on the wrong word. One inserted word destroys the payload. Translation destroys it completely. There is no partial credit and no confidence interval: you get "JB" or you get nothing.
This is why the literature on multi-bit watermarking — carrying an actual message rather than a presence signal — works so hard on invariant features. Yoo et al. anchor payload positions to syntactic dependencies rather than raw indices, precisely so that inserting a word does not shift everything downstream. Naive positional keying, as above, is the thing you build first and then throw away.
3. Scheme B — "JB" as a key (green/red)
The Kirchenbauer / SynthID-Text construction, applied post-hoc. Here "JB" is no longer the message — it is the watermarking key. Nothing is recovered from the text; a detector holding the key computes a statistic.
3.1 Construction
- Context-seeded PRF. For each word, hash
key ‖ previous_word ‖ candidate_word. The low bit splits the vocabulary in half:1= green,0= red. γ = 0.5. - Bias. A real LLM adds a logit boost δ to green tokens at sampling time. Post-hoc, we instead choose green synonyms.
- Detection. Count green words G out of N scorable words, then
z = (G − γN) / √(Nγ(1−γ)), and a one-sided p-value.
import { createHash } from "node:crypto";
export const KEY_B = "JB";
const GAMMA = 0.5;
const norm = (t: string) => t.toLowerCase().replace(/[^a-z]/g, "");
const scorable = (t: string) => norm(t).length >= 1 && !/\d/.test(t);
/** Seed the PRF on the PREVIOUS word, then hash the candidate. Green iff LSB === 1. */
export function isGreen(prev: string, tok: string, key = KEY_B): boolean {
const h = createHash("sha256").update(`${key}|${norm(prev)}|${norm(tok)}`).digest();
return (h[0] & 1) === 1;
}
export function score(text: string, key = KEY_B) {
const toks = text.split(/\s+/);
let n = 0, g = 0;
for (let i = 1; i < toks.length; i++) {
if (!scorable(toks[i])) continue; // numbers offer no choice, so no signal
n++;
if (isGreen(toks[i - 1], toks[i], key)) g++;
}
const z = (g - GAMMA * n) / Math.sqrt(n * GAMMA * (1 - GAMMA));
return { n, green: g, frac: g / n, z, p: 0.5 * erfc(z / Math.SQRT2) };
}Node has no erfc, so:
function erfc(x: number): number {
// Numerical Recipes 6.2.2 — accurate to ~1.2e-7
const z = Math.abs(x);
const t = 1 / (1 + z / 2);
const poly =
-1.26551223 + t * (1.00002368 + t * (0.37409196 + t * (0.09678418 +
t * (-0.18628806 + t * (0.27886807 + t * (-1.13520398 + t * (1.48851587 +
t * (-0.82215223 + t * 0.17087277))))))));
const r = t * Math.exp(-z * z + poly);
return x >= 0 ? r : 2 - r;
}One-word context is the crudest possible seeding. Real schemes hash over an n-gram window, and there is a whole design space here: Kuditipudi et al. use a fixed key sequence to get distortion-free watermarks, while Liu et al. seed on semantic embeddings of the preceding text rather than surface tokens, so that paraphrase preserves the seed. That last idea is the direct answer to the attack in §3.4.
3.2 The interaction problem — why this needs a search
Because the PRF is seeded on the previous word, substituting word i re-rolls the green/red assignment of word i+1. Choices are not independent, so greedy left-to-right substitution is suboptimal. A beam search over a table of meaning-preserving candidates:
export function rewrite(text: string, syn = SYNONYMS, key = KEY_B, beam = 40): string {
const toks = text.split(/\s+/);
let states: [number, string[]][] = [[0, [...toks]]]; // [greenCount, tokens]
for (let i = 1; i < toks.length; i++) {
const cands = syn[i] ?? [toks[i]];
const next: [number, string[]][] = [];
for (const [g, tk] of states) {
for (const c of cands) {
const t2 = [...tk];
t2[i] = c;
let add = 0, prev = t2[i - 1];
for (const part of c.split(" ")) { // candidates may be multi-word
if (scorable(part) && isGreen(prev, part, key)) add++;
prev = part;
}
next.push([g + add, t2]);
}
}
next.sort((a, b) => b[0] - a[0]);
states = next.slice(0, beam);
}
return states[0][1].join(" ");
}The candidate table — 26 positions, numbers excluded by construction:
export const SYNONYMS: Record<number, string[]> = {
3: ["trading", "changing hands", "quoted"],
4: ["around", "near", "about", "close to"],
7: ["roughly", "about", "some", "nearly"],
13: ["extending", "pushing", "climbing", "rising", "stretching"],
14: ["well", "clearly", "comfortably"],
18: ["level", "close", "print", "mark"],
24: ["approaching", "nearing", "testing", "closing on"],
32: ["convergence", "crossover", "cluster"],
34: ["traders", "desks", "chartists", "analysts"],
36: ["reading", "treating"],
39: ["bullish", "constructive", "positive"],
40: ["setup.", "signal.", "pattern.", "structure."],
44: ["kept", "carried on", "continued"],
45: ["climbing:", "growing:", "rising:", "expanding:"],
66: ["grew", "expanded", "swelled", "widened"],
78: ["stayed", "remained", "held"],
79: ["calm", "quiet", "flat", "subdued"],
87: ["confirming", "showing", "suggesting", "indicating"],
89: ["brief", "short", "one-off", "momentary"],
97: ["ticked", "edged", "crept", "nudged"],
112: ["rose", "climbed", "increased", "advanced"],
116: ["tracking", "following", "mirroring", "in line with"],
125: ["revised", "restated", "adjusted"],
135: ["flipped", "swung", "turned", "reversed"],
143: ["cumulative", "total", "aggregate"],
148: ["stand", "sit", "now stand"],
};Slot 36 is worth a note. ["reading", "flagging", "calling"] scored higher, but the sentence reads "traders are ___ as a bullish setup", and calling as is ungrammatical. The optimizer maximises green count, not English, so the candidate lists have to be pre-filtered by hand. A production system does this inside the decoder, where the language model's own probabilities keep the output fluent for free — which is exactly the quality argument SynthID's authors make, backed by human side-by-side ratings showing no perceived degradation.
3.3 OUTPUT — Scheme B
Substitutions bold. These are different words from Scheme A: different scheme, different key, different choices.
Stats. ETH is changing hands around $2,511, up roughly 5% on the day and rising well past yesterday's ~$2,386 mark — the move has it testing its 200-day EMA with a 50/100-day EMA cluster some traders are treating as a constructive signal. The validator set kept climbing: 905,998 active validators (up from 905,286), with 42.7 million ETH staked (35.03% of supply, up from 34.99%). The entry queue swelled to 2,110,424 ETH at a 36-day, 15-hour wait. The exit queue remained flat at 256 ETH with a 6-minute wait, suggesting yesterday's one-off exit-queue spike was a one-day blip. Gas edged up to 0.078 gwei average (from 0.042 gwei) alongside the price move. DeFi TVL rose to $49.65 billion, tracking the ETH price gain. ETF flows: September 2 revised to a $48.2 million net outflow, but September 3 swung back to a $2.9 million net inflow; cumulative net inflows since launch now stand at roughly $13.02 billion.
Chosen: trading→changing hands, extending→rising, level→mark, approaching→testing, convergence→cluster, reading→treating, bullish→constructive, setup→signal, grew→swelled, stayed→remained, calm→flat, confirming→suggesting, brief→one-off, ticked→edged, flipped→swung, stand→now stand.
3.4 Detection results
| Text | green / N | fraction | z | p |
|---|---|---|---|---|
| Original (unmarked) | 71 / 128 | 55.5% | +1.24 | 0.11 |
| Watermarked B | 88 / 130 | 67.7% | +4.03 | 2.7e-5 |
| Watermarked B, first sentence only | 13 / 16 | 81.3% | +2.50 | 0.0062 |
| + 1 word in 6 deleted | 72 / 107 | 67.3% | +3.58 | 0.00017 |
| + full paraphrase attack | 78 / 130 | 60.0% | +2.28 | 0.011 |
The paraphrase attack re-rolls every one of the 26 swappable slots at random, key-blind — a stand-in for "rewrite it with another model", which is the canonical attack since Krishna et al. showed paraphrasing evades detectors (and that retrieval is a decent defence).
False-positive check — the watermarked text scored under wrong keys:
| key | z | p |
|---|---|---|
AA | +1.05 | 0.15 |
XY | −0.18 | 0.57 |
CC | −0.18 | 0.57 |
ZZ | −0.70 | 0.76 |
QW | +0.53 | 0.30 |
The signal exists only under the right key. Note the unmarked original already sits at 55.5% / z = +1.24 — natural text drifts off 50% by chance, which is exactly why detection needs a threshold and a p-value rather than a yes/no.
3.5 What the numbers say
-
Graceful degradation, not cliff-edge failure. The paraphrase attack took z from 4.03 to 2.28 — weakened, still above the unmarked baseline. Scheme A would already be dead. This is the whole reason production systems use statistics rather than payloads.
-
Deletion barely hurts, and the reason is instructive. Dropping one word in six only corrupts the context of the following word, so roughly 5/6 of the (prev, word) pairs survive intact and z only falls to 3.58. An attack has to change words, not remove them. Yang et al. report the same shape under systematic attack: word deletion below about 30% leaves the watermark detectable, and beyond that the text is unusable anyway.
-
Length is the binding constraint. The first sentence alone hit 81.3% green — a stronger rate — but with only 16 scorable words the p-value is 0.0062 rather than 2.7e-5. Evidence accumulates with N. This is why short samples are undetectable: not because the bias is absent, but because there was never enough room for it to show. It is also, presumably, why the EU Code of Practice draws its "very short text" line at 200 tokens.
-
Factual text is hostile terrain, and the vendors admit it. 26 of the 154 tokens here are figures, carrying no signal at all. Google's own SynthID documentation states the point directly: watermarking is less effective on factual responses, because there is less room to vary generation without hurting accuracy. The "capitale de la France est…" problem, at production scale. A market-data paragraph is close to the worst case.
-
Post-hoc editing is a weak proxy for generation-time biasing. A real model biases every token as it samples. Here only 26 positions out of 128 were reachable with a hand-written thesaurus, and each edit risks the meaning or the grammar (see §3.2). Doing it inside the decoder is both cheaper and stronger — and it is the difference between the two families of methods that the text-watermarking survey draws: post-processing schemes (Unicode homoglyphs, lexical substitution, reformatting) are easy to implement and easy to normalise away; logit-level schemes are stabler and harder to strip.
-
None of this is provably robust. Zhang et al., Watermarks in the Sand prove that under natural assumptions — an attacker with a quality oracle and a paraphrasing model — strong watermarking is impossible, and this holds even when detection is private and key-based. Sadasivan et al. reach a compatible conclusion from the detection side. The honest framing is that watermarking raises cost for the careless, not that it defeats the motivated. Anthropic's documentation says roughly this; the press coverage sometimes does not.
4. Side-by-side
| Scheme A (payload) | Scheme B (green/red) | Production SynthID-Text | |
|---|---|---|---|
| Role of "JB" | the hidden message | the secret key | the key (provider-held) |
| Where the bias lives | post-hoc word substitution | post-hoc word substitution | sampling-time logit bias |
| Detector output | exact 16-bit string | z-score + p-value | z-score + p-value |
| Capacity | ~1 bit per carrier | 0 bits — presence only | 0 bits — presence only |
| Survives 1 inserted word | ✗ total loss | ✓ | ✓ |
| Survives paraphrase | ✗ | partial (z 4.03 → 2.28) | partial |
| Survives translation | ✗ | ✗ | ✗ (Google documents this) |
| Detectable without key | ✗ | ✗ | ✗ |
| Needs a long text | no (needs 16 clean slots) | yes | yes |
The asymmetry worth holding onto: a 16-bit payload needs 16 correct slots, so any loss is fatal. A statistical watermark spends hundreds of tokens accumulating evidence, and can therefore afford to lose many of them.
5. Two consequences
Individualised watermarking. Nothing in Scheme B changes if KEY_B is per-account rather than per-provider. That is the point, and it is why the traceability concern is technically well-founded rather than speculative — the machinery is identical, only key custody differs. The wrong-key table shows the flip side, though: with many accounts you are running many hypothesis tests at once, and multiple-comparison correction gets brutal fast. This has been studied directly — Multi-use LLM Watermarking and the False Detection Problem works through what happens to false-positive rates when one deployment carries many keys. Identifying one user among 10⁶ from a z ≈ 4 signal is not the same problem as answering "did Claude touch this", and conflating the two is how a provenance tool becomes a surveillance tool by accident.
Code. Scheme B transfers to code with the same machinery — identifier names, statement order, formatting — and there is a literature on it (Lee et al., Who Wrote this Code? Watermarking for Code Generation, arXiv:2305.15060). But the constraint is harder than prose. Code has a compiler: a bad substitution is not a stylistic wobble, it is a build failure. The §3.2 grammar problem, with teeth. The upside is real though: 300,000 lines is an enormous N, so even a very weak per-token bias reaches significance, and attributing a codebase to a provider stops being far-fetched. Whether that is a licensing question, a provenance feature or a liability depends entirely on who is asking.
6. Full source
Two files. Node 22+ only, no dependencies.
wm.ts
import { createHash } from "node:crypto";
export const ORIGINAL =
"Stats. ETH is trading around $2,511, up roughly 5% on the day and extending well past yesterday's ~$2,386 level — the move has it approaching its 200-day EMA with a 50/100-day EMA convergence some traders are reading as a bullish setup. The validator set kept climbing: 905,998 active validators (up from 905,286), with 42.7 million ETH staked (35.03% of supply, up from 34.99%). The entry queue grew to 2,110,424 ETH at a 36-day, 15-hour wait. The exit queue stayed calm at 256 ETH with a 6-minute wait, confirming yesterday's brief exit-queue spike was a one-day blip. Gas ticked up to 0.078 gwei average (from 0.042 gwei) alongside the price move. DeFi TVL rose to $49.65 billion, tracking the ETH price gain. ETF flows: September 2 revised to a $48.2 million net outflow, but September 3 flipped back to a $2.9 million net inflow; cumulative net inflows since launch stand at roughly $13.02 billion.";
export const WATERMARKED_A =
"Stats. ETH is trading around $2,511, up roughly 5% on the day and climbing well past yesterday's ~$2,386 level — the move has it approaching its 200-day EMA with a 50/100-day EMA convergence some traders are reading as a bullish signal. The validator set kept growing: 905,998 active validators (up from 905,286), with 42.7 million ETH staked (35.03% of circulating supply, up from 34.99%). The entry queue grew to 2,110,424 ETH at a 36-day, 15-hour wait. The exit queue stayed quiet at 256 ETH with a 6-minute wait, confirming yesterday's brief exit-queue spike was a one-day blip. Gas ticked up to 0.078 gwei average (from 0.042 gwei) alongside the price move. DeFi TVL rose to $49.65 billion, tracking the ETH price gain. ETF flows: September 2 revised to a $48.2 million net outflow, but September 3 flipped back to a $2.9 million net inflow; cumulative net inflows since launch stand at roughly $13.02 billion.";
/* ============ Scheme A — payload ============ */
export const KEY_A = [4, 7, 13, 40, 45, 47, 59, 67, 75, 80, 87, 88, 98, 110, 116, 143];
const letters = (t: string) => t.replace(/[^A-Za-z]/g, "");
export function decodeA(text: string, key: number[] = KEY_A) {
const toks = text.split(/\s+/);
const bits = key.map((p) => letters(toks[p]).length % 2).join("");
const payload = (bits.match(/.{8}/g) ?? [])
.map((b) => String.fromCharCode(parseInt(b, 2)))
.join("");
return { bits, payload };
}
/* ============ Scheme B — green/red ============ */
export const KEY_B = "JB";
const GAMMA = 0.5;
const norm = (t: string) => t.toLowerCase().replace(/[^a-z]/g, "");
const scorable = (t: string) => norm(t).length >= 1 && !/\d/.test(t);
export function isGreen(prev: string, tok: string, key = KEY_B): boolean {
const h = createHash("sha256").update(`${key}|${norm(prev)}|${norm(tok)}`).digest();
return (h[0] & 1) === 1;
}
function erfc(x: number): number {
const z = Math.abs(x);
const t = 1 / (1 + z / 2);
const poly =
-1.26551223 + t * (1.00002368 + t * (0.37409196 + t * (0.09678418 +
t * (-0.18628806 + t * (0.27886807 + t * (-1.13520398 + t * (1.48851587 +
t * (-0.82215223 + t * 0.17087277))))))));
const r = t * Math.exp(-z * z + poly);
return x >= 0 ? r : 2 - r;
}
export function score(text: string, key = KEY_B) {
const toks = text.split(/\s+/);
let n = 0, g = 0;
for (let i = 1; i < toks.length; i++) {
if (!scorable(toks[i])) continue;
n++;
if (isGreen(toks[i - 1], toks[i], key)) g++;
}
const z = (g - GAMMA * n) / Math.sqrt(n * GAMMA * (1 - GAMMA));
return { n, green: g, frac: g / n, z, p: 0.5 * erfc(z / Math.SQRT2) };
}
export const SYNONYMS: Record<number, string[]> = {
3: ["trading", "changing hands", "quoted"],
4: ["around", "near", "about", "close to"],
7: ["roughly", "about", "some", "nearly"],
13: ["extending", "pushing", "climbing", "rising", "stretching"],
14: ["well", "clearly", "comfortably"],
18: ["level", "close", "print", "mark"],
24: ["approaching", "nearing", "testing", "closing on"],
32: ["convergence", "crossover", "cluster"],
34: ["traders", "desks", "chartists", "analysts"],
36: ["reading", "treating"],
39: ["bullish", "constructive", "positive"],
40: ["setup.", "signal.", "pattern.", "structure."],
44: ["kept", "carried on", "continued"],
45: ["climbing:", "growing:", "rising:", "expanding:"],
66: ["grew", "expanded", "swelled", "widened"],
78: ["stayed", "remained", "held"],
79: ["calm", "quiet", "flat", "subdued"],
87: ["confirming", "showing", "suggesting", "indicating"],
89: ["brief", "short", "one-off", "momentary"],
97: ["ticked", "edged", "crept", "nudged"],
112: ["rose", "climbed", "increased", "advanced"],
116: ["tracking", "following", "mirroring", "in line with"],
125: ["revised", "restated", "adjusted"],
135: ["flipped", "swung", "turned", "reversed"],
143: ["cumulative", "total", "aggregate"],
148: ["stand", "sit", "now stand"],
};
export function rewrite(text: string, syn = SYNONYMS, key = KEY_B, beam = 40): string {
const toks = text.split(/\s+/);
let states: [number, string[]][] = [[0, [...toks]]];
for (let i = 1; i < toks.length; i++) {
const cands = syn[i] ?? [toks[i]];
const next: [number, string[]][] = [];
for (const [g, tk] of states) {
for (const c of cands) {
const t2 = [...tk];
t2[i] = c;
let add = 0, prev = t2[i - 1];
for (const part of c.split(" ")) {
if (scorable(part) && isGreen(prev, part, key)) add++;
prev = part;
}
next.push([g + add, t2]);
}
}
next.sort((a, b) => b[0] - a[0]);
states = next.slice(0, beam);
}
return states[0][1].join(" ");
}run.ts
import { ORIGINAL, WATERMARKED_A, decodeA, score, rewrite, SYNONYMS } from "./wm.ts";
const f = (x: number) => (x >= 0 ? "+" : "") + x.toFixed(2);
console.log("A: watermarked ->", JSON.stringify(decodeA(WATERMARKED_A)));
console.log("A: original ->", JSON.stringify(decodeA(ORIGINAL)));
const wm = rewrite(ORIGINAL);
/** Key-blind attacker: re-roll every swappable slot at random. */
function attack(text: string, seed = 7): string {
const t = text.split(" ");
const o = ORIGINAL.split(" ");
let s = seed;
const rnd = () => (s = (s * 1103515245 + 12345) % 2147483648) / 2147483648;
for (const k of Object.keys(SYNONYMS).map(Number)) {
const cands = SYNONYMS[k];
const i = t.findIndex((x) => x === o[k]);
const j = t.indexOf(cands.find((c) => t.includes(c.split(" ")[0])) ?? "");
const idx = i >= 0 ? i : j;
if (idx >= 0) t[idx] = cands[Math.floor(rnd() * cands.length)];
}
return t.join(" ");
}
const rows: [string, string][] = [
["ORIGINAL (unmarked)", ORIGINAL],
["WATERMARKED_B", wm],
[" first sentence only", wm.split("—")[0]],
[" + 1 word in 6 deleted", wm.split(" ").filter((_, i) => i % 6 !== 0).join(" ")],
[" + full paraphrase attack", attack(wm)],
];
console.log("\n--- detection (key JB) ---");
for (const [name, t] of rows) {
const s = score(t);
console.log(
` ${name.padEnd(28)} ${String(s.green).padStart(3)}/${String(s.n).padEnd(4)} ` +
`${(s.frac * 100).toFixed(1)}% z=${f(s.z)} p=${s.p.toExponential(1)}`
);
}
console.log("\n--- wrong keys on watermarked text ---");
for (const k of ["AA", "XY", "CC", "ZZ", "QW"]) {
const s = score(wm, k);
console.log(` ${k} z=${f(s.z)} p=${s.p.toFixed(2)}`);
}
console.log("\n" + wm);Further reading
Foundational papers
- Kirchenbauer, Geiping, Wen, Katz, Miers, Goldstein — A Watermark for Large Language Models, ICML 2023. The green/red list construction and the z-test used above.
- Dathathri et al. — Scalable watermarking for identifying large language model outputs, Nature 634, 818–823 (2024). SynthID-Text and Tournament Sampling. Also on PubMed and PMC; reference implementation on GitHub.
- Kuditipudi, Thickstun, Hashimoto, Liang — Robust Distortion-Free Watermarks for Language Models.
- Liu, Pan, Hu, Meng, Wen — A Semantic Invariant Robust Watermark for Large Language Models. Seeds on semantics rather than surface tokens.
- Liu et al. — A Survey of Text Watermarking in the Era of Large Language Models, ACM Computing Surveys 57(2).
Limits and attacks
- Zhang, Edelman, Francati, Venturi, Ateniese, Barak — Watermarks in the Sand: Impossibility of Strong Watermarking for Generative Models, ICML 2024 (ACM DL).
- Sadasivan, Kumar, Balasubramanian, Wang, Feizi — Can AI-Generated Text be Reliably Detected?
- Krishna, Song, Karpinska, Wieting, Iyyer — Paraphrasing Evades Detectors of AI-Generated Text, but Retrieval is an Effective Defense, NeurIPS 2023.
- Yang et al. — Watermarking Text Generated by Black-Box Language Models. Systematic robustness results under re-translation, deletion and synonym substitution.
- Han, Li et al. — Robustness Assessment and Enhancement of Text Watermarking for Google's SynthID. Back-translation vulnerability scales with MT quality.
- Multi-use LLM Watermarking and the False Detection Problem. Directly relevant to per-user keys.
Steganography
- Norelli & Bronstein — LLMs can hide text in other text of the same length, plus the Calgacus implementation.
- Wu, Wu, Xue, Wen, Peng — Generative Text Steganography with Large Language Model (LLM-Stega), a black-box approach through public chat interfaces.
Regulation and standards
- Article 50, EU AI Act — the operative text.
- European Commission FAQ on Article 50 transparency obligations.
- A Practical Guide to Article 50, including the AI Omnibus timing changes.
- Orrick, Paul Weiss and Herbert Smith Freehills Kramer on the Guidelines and Code of Practice.
- C2PA — Content Credentials, the technical specification and the conformance programme.
The Claude announcement
- Anthropic — How Claude marks AI-generated content (primary source).
- Google — SynthID documentation (limitations section).
- TechCrunch · Forbes · MLQ · Medianama · Interesting Engineering · Cyberpress
- French coverage: Next · KultureGeek · Blog du Modérateur and its FAQ
- Critical takes: Alain Goudey on the institutional risk · C2PA Viewer on why the two mechanisms shouldn't be conflated · WasItAIGenerated on what is actually verifiable today