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.ts

0. 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:

#idxtokenletterslenbit
14aroundaround60
27roughlyroughly71
313climbingclimbing80
440signal.signal60
545growing:growing71
647activeactive60
759circulatingcirculating111
867grewgrew40
975wait.wait40
1080quietquiet51
1187wait,wait40
1288confirmingconfirming100
1398tickedticked60
14110move.move40
15116billion,billion71
16143inflow;inflow60

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" }   // garbage

No 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

  1. 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.
  2. Bias. A real LLM adds a logit boost δ to green tokens at sampling time. Post-hoc, we instead choose green synonyms.
  3. 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

Textgreen / Nfractionzp
Original (unmarked)71 / 12855.5%+1.240.11
Watermarked B88 / 13067.7%+4.032.7e-5
Watermarked B, first sentence only13 / 1681.3%+2.500.0062
+ 1 word in 6 deleted72 / 10767.3%+3.580.00017
+ full paraphrase attack78 / 13060.0%+2.280.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:

keyzp
AA+1.050.15
XY−0.180.57
CC−0.180.57
ZZ−0.700.76
QW+0.530.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 messagethe secret keythe key (provider-held)
Where the bias livespost-hoc word substitutionpost-hoc word substitutionsampling-time logit bias
Detector outputexact 16-bit stringz-score + p-valuez-score + p-value
Capacity~1 bit per carrier0 bits — presence only0 bits — presence only
Survives 1 inserted word✗ total loss
Survives paraphrasepartial (z 4.03 → 2.28)partial
Survives translation✗ (Google documents this)
Detectable without key
Needs a long textno (needs 16 clean slots)yesyes

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

Limits and attacks

Steganography

Regulation and standards

The Claude announcement

Embedding "JB" in a text: two watermarking schemes — Julien Beranger