main
py 885 lines 29.2 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 """Deterministic watermarking golden candidates."""
5
6 import json
7 import math
8 from collections.abc import Mapping
9 from dataclasses import dataclass, replace
10 from pathlib import Path
11 from typing import get_args
12
13 import torch
14
15 from vllm.config.watermarking import (
16 WatermarkConfig,
17 WatermarkContextScope,
18 WatermarkingAlgorithm,
19 WatermarkPRFName,
20 derive_watermark_key,
21 )
22 from vllm.v1.watermarking import (
23 DualKeyGumbelWatermarkDetector,
24 GumbelWatermarkDetector,
25 WatermarkDetection,
26 WatermarkDetector,
27 create_prf,
28 create_watermarker,
29 )
30 from vllm.v1.worker.gpu.sample.watermark import repeated_context_mask
31
32 GOLDEN_SCHEMA_VERSION = 3
33
34 # JSON floats round-trip exactly, but libm differs by an ULP across builds.
35 GOLDEN_FLOAT_RTOL = 1e-9
36 GOLDEN_FLOAT_ATOL = 1e-300
37
38 P_VALUE_RATIO_MIN_DISTANCE = 1e-6
39 REPETITIVE_MIN_KEY_PERTURBATION_TOKENS = 8
40 REPETITIVE_MAX_P_VALUE_RATIO = 0.5
41 ROUTING_BOUNDARY_MIN_DISTANCE = 1e-6
42
43 WATERMARK_CONFIG_FIELDS = (
44 "algorithm",
45 "alpha",
46 "context_width",
47 "deduplicate_contexts",
48 "deduplicate_contexts_max_history",
49 "prf",
50 "allow_target_only_watermarking",
51 )
52
53 # round(((i + 1) * (5**0.5 - 1) / 2) % 1.0, 4) for i in range(64)
54 ROUTING_UNIFORMS: tuple[float, ...] = (
55 0.618, 0.2361, 0.8541, 0.4721, 0.0902, 0.7082, 0.3262, 0.9443,
56 0.5623, 0.1803, 0.7984, 0.4164, 0.0344, 0.6525, 0.2705, 0.8885,
57 0.5066, 0.1246, 0.7426, 0.3607, 0.9787, 0.5967, 0.2148, 0.8328,
58 0.4508, 0.0689, 0.6869, 0.305, 0.923, 0.541, 0.1591, 0.7771,
59 0.3951, 0.0132, 0.6312, 0.2492, 0.8673, 0.4853, 0.1033, 0.7214,
60 0.3394, 0.9574, 0.5755, 0.1935, 0.8115, 0.4296, 0.0476, 0.6656,
61 0.2837, 0.9017, 0.5197, 0.1378, 0.7558, 0.3738, 0.9919, 0.6099,
62 0.2279, 0.846, 0.464, 0.082, 0.7001, 0.3181, 0.9361, 0.5542,
63 ) # fmt: skip
64
65
66 class GoldenFormatError(ValueError):
67 """The stored goldens do not have the shape this module expects."""
68
69
70 class GoldenGuardError(ValueError):
71 """A candidate is too weak or too unstable to be frozen as a golden."""
72
73
74 @dataclass(frozen=True)
75 class DeterministicGenerationFixture:
76 vocabulary_size: int = 128
77 num_tokens: int = 64
78 logit_denominator: int = 16
79 logit_modulus: int = 131
80 dominant_token: int | None = None
81 dominant_bias: int = 0
82 prompt: tuple[int, ...] = ()
83 routing_uniforms: tuple[float, ...] = ROUTING_UNIFORMS
84
85 def logits(self, position: int) -> torch.Tensor:
86 token_ids = torch.arange(self.vocabulary_size, dtype=torch.int64)
87 values = (token_ids * 37 + position * 17) % self.logit_modulus - 56
88 if self.dominant_token is not None:
89 values[self.dominant_token] += self.dominant_bias
90 return (values.to(torch.float32) / self.logit_denominator).unsqueeze(0)
91
92
93 @dataclass(frozen=True)
94 class WatermarkingSchemeConfig:
95 context_width: int = 4
96 generation_alpha: float = 0.1
97 detection_alpha: float = 0.1
98 generation_deduplicate_contexts: WatermarkContextScope = "single_turn"
99 generation_deduplicate_contexts_max_history: int | None = 8192
100 detection_deduplicate_contexts: bool = True
101 p_value_threshold: float = 0.01
102
103
104 @dataclass(frozen=True)
105 class GenerationTrace:
106 routing_draws: int
107 key_b_routed: int | None
108 key_b_effective: int | None
109 dedup_skips: int
110 partial_context_skips: int
111
112
113 class _DeterministicSampler:
114 def __init__(self, routing_uniforms: tuple[float, ...]) -> None:
115 self.routing_uniforms = routing_uniforms
116 self.routing_position = 0
117 self.key_b_routed = 0
118
119 def __call__(self, logits: torch.Tensor) -> torch.Tensor:
120 if logits.shape[-1] != 2:
121 return logits.argmax(dim=-1)
122
123 probability_a = logits.softmax(dim=-1)[:, 0]
124 uniform = self.routing_uniforms[
125 self.routing_position % len(self.routing_uniforms)
126 ]
127 self.routing_position += 1
128 routed = (probability_a <= uniform).to(torch.int64)
129 self.key_b_routed += int(routed.sum())
130 return routed
131
132
133 def routing_boundary(alpha: float) -> float:
134 """Return the realised key-A probability the deterministic sampler sees."""
135 logits = torch.tensor([1 - alpha, alpha], dtype=torch.float32).log()
136 return float(logits.softmax(dim=-1)[0])
137
138
139 def _create_gumbel_detector(
140 key: int,
141 config: WatermarkingSchemeConfig,
142 prf: WatermarkPRFName,
143 ) -> WatermarkDetector:
144 return GumbelWatermarkDetector(
145 key=key,
146 context_width=config.context_width,
147 p_value_threshold=config.p_value_threshold,
148 prf=prf,
149 deduplicate_contexts=config.detection_deduplicate_contexts,
150 )
151
152
153 def _create_dual_key_gumbel_detector(
154 key: int,
155 config: WatermarkingSchemeConfig,
156 prf: WatermarkPRFName,
157 ) -> WatermarkDetector:
158 return DualKeyGumbelWatermarkDetector(
159 key=key,
160 context_width=config.context_width,
161 p_value_threshold=config.p_value_threshold,
162 prf=prf,
163 deduplicate_contexts=config.detection_deduplicate_contexts,
164 alpha=config.detection_alpha,
165 )
166
167
168 DETECTOR_FACTORIES = {
169 "gumbel": _create_gumbel_detector,
170 "dual_key_gumbel": _create_dual_key_gumbel_detector,
171 }
172
173
174 @dataclass(frozen=True)
175 class WatermarkingCandidate:
176 id: str
177 scheme: WatermarkingAlgorithm
178 scheme_config: WatermarkingSchemeConfig
179 prf: WatermarkPRFName
180 key: int
181 detection_key: int
182 fixture: DeterministicGenerationFixture = DeterministicGenerationFixture()
183
184 def configuration(self) -> dict[str, object]:
185 prf_version = getattr(create_prf(self.prf, self.key), "version", None)
186 if not isinstance(prf_version, str):
187 raise ValueError(f"PRF {self.prf} does not define a version")
188 return {
189 "scheme": self.scheme,
190 "scheme_config": {
191 "context_width": self.scheme_config.context_width,
192 "generation_alpha": self.scheme_config.generation_alpha,
193 "detection_alpha": self.scheme_config.detection_alpha,
194 "generation_deduplicate_contexts": (
195 self.scheme_config.generation_deduplicate_contexts
196 ),
197 "generation_deduplicate_contexts_max_history": (
198 self.scheme_config.generation_deduplicate_contexts_max_history
199 ),
200 "detection_deduplicate_contexts": (
201 self.scheme_config.detection_deduplicate_contexts
202 ),
203 "p_value_threshold": self.scheme_config.p_value_threshold,
204 },
205 "prf": self.prf,
206 "prf_version": prf_version,
207 "key": str(self.key),
208 "detection_key": str(self.detection_key),
209 "fixture": {
210 "vocabulary_size": self.fixture.vocabulary_size,
211 "num_tokens": self.fixture.num_tokens,
212 "logit_denominator": self.fixture.logit_denominator,
213 "logit_modulus": self.fixture.logit_modulus,
214 "dominant_token": self.fixture.dominant_token,
215 "dominant_bias": self.fixture.dominant_bias,
216 "prompt": list(self.fixture.prompt),
217 },
218 }
219
220 def resolved(self) -> dict[str, object]:
221 """Return the vLLM-side state this candidate resolves to.
222
223 An allowlist rather than the whole dataclass, so a new production field
224 is a deliberate golden change: WATERMARK_CONFIG_FIELDS names the fields
225 recorded here and test_goldens.py checks it still covers
226 WatermarkConfig. Keys exceed 2**53 and are decimal strings.
227 """
228 config = self._watermark_config()
229 detector = self._detector()
230 key_b_prf = getattr(detector, "key_b_prf", None)
231 dual_key = self.scheme == "dual_key_gumbel"
232 return {
233 "watermark_config": {
234 field: getattr(config, field) for field in WATERMARK_CONFIG_FIELDS
235 },
236 "derived_keys": {
237 "key_a": str(derive_watermark_key(self.key, b"key_a"))
238 if dual_key
239 else None,
240 "key_b": str(derive_watermark_key(self.key, b"key_b"))
241 if dual_key
242 else None,
243 },
244 "detector": {
245 "type": type(detector).__name__,
246 "context_width": detector.context_width,
247 "p_value_threshold": detector.p_value_threshold,
248 "deduplicate_contexts": detector.deduplicate_contexts,
249 "alpha": getattr(detector, "alpha", None),
250 "prf_key": str(detector.prf.key),
251 "key_b_prf_key": None if key_b_prf is None else str(key_b_prf.key),
252 },
253 }
254
255 def generate(self) -> list[int]:
256 return self.generate_with_trace()[0]
257
258 def generate_with_trace(self) -> tuple[list[int], GenerationTrace]:
259 watermarker = create_watermarker(self._watermark_config())
260 sampler = _DeterministicSampler(self.fixture.routing_uniforms)
261 generated: list[int] = []
262 dedup_skips = 0
263 partial_context_skips = 0
264 key_b_effective = 0
265
266 for position in range(self.fixture.num_tokens):
267 context = self._context(generated)
268 skip_mask = self._skip_mask(generated, context)
269 skipped = skip_mask is not None and bool(skip_mask.item())
270 if skipped:
271 dedup_skips += 1
272 if len(generated) < self.scheme_config.context_width:
273 partial_context_skips += 1
274 routed_before = sampler.key_b_routed
275 sample = watermarker.sample(
276 self.fixture.logits(position),
277 context,
278 sampler,
279 skip_mask,
280 )
281 if sampler.key_b_routed > routed_before and not skipped:
282 key_b_effective += 1
283 generated.append(int(sample.token_ids.item()))
284
285 routes = self.scheme == "dual_key_gumbel" and (
286 0 < self.scheme_config.generation_alpha < 1
287 )
288 trace = GenerationTrace(
289 routing_draws=sampler.routing_position,
290 key_b_routed=sampler.key_b_routed if routes else None,
291 key_b_effective=key_b_effective if routes else None,
292 dedup_skips=dedup_skips,
293 partial_context_skips=partial_context_skips,
294 )
295 return generated, trace
296
297 def detect(self, token_ids: list[int]) -> WatermarkDetection:
298 return self._detector().detect(token_ids)
299
300 def golden_entry(
301 self,
302 detection_token_ids: list[int] | None = None,
303 ) -> dict:
304 """Generate, detect and trace this candidate into one golden entry.
305
306 ``detection_token_ids`` runs detection over stored tokens instead of
307 the freshly generated ones, so a token drift and a detection drift are
308 reported independently.
309 """
310 token_ids, trace = self.generate_with_trace()
311 scored = token_ids if detection_token_ids is None else detection_token_ids
312 detection = self.detect(scored)
313 threshold = self.scheme_config.p_value_threshold
314 return {
315 "configuration": self.configuration(),
316 "resolved": self.resolved(),
317 "generation": token_ids,
318 "trace": _trace_payload(trace),
319 "detection": {
320 "score": detection.score,
321 "p_value": detection.p_value,
322 "p_value_ratio": detection.p_value / threshold,
323 "num_scored_tokens": detection.num_scored_tokens,
324 "is_watermarked": detection.is_watermarked,
325 },
326 }
327
328 def _watermark_config(self) -> WatermarkConfig:
329 return WatermarkConfig(
330 algorithm=self.scheme,
331 key=self.key,
332 alpha=self.scheme_config.generation_alpha,
333 context_width=self.scheme_config.context_width,
334 deduplicate_contexts=(self.scheme_config.generation_deduplicate_contexts),
335 deduplicate_contexts_max_history=(
336 self.scheme_config.generation_deduplicate_contexts_max_history
337 ),
338 prf=self.prf,
339 )
340
341 def _detector(self) -> WatermarkDetector:
342 return DETECTOR_FACTORIES[self.scheme](
343 self.detection_key,
344 self.scheme_config,
345 self.prf,
346 )
347
348 def _context(self, generated: list[int]) -> torch.Tensor:
349 width = self.scheme_config.context_width
350 padded = [-1] * width + generated
351 return torch.tensor([padded[-width:]], dtype=torch.int64)
352
353 def _skip_mask(
354 self, generated: list[int], context: torch.Tensor
355 ) -> torch.Tensor | None:
356 scope = self.scheme_config.generation_deduplicate_contexts
357 if scope == "none":
358 return None
359
360 prompt_len = len(self.fixture.prompt)
361 storage = torch.zeros(
362 (1, prompt_len + self.fixture.num_tokens),
363 dtype=torch.int64,
364 )
365 if prompt_len:
366 storage[0, :prompt_len] = torch.tensor(self.fixture.prompt)
367 if generated:
368 storage[0, prompt_len : prompt_len + len(generated)] = torch.tensor(
369 generated
370 )
371 return repeated_context_mask(
372 storage,
373 torch.tensor([0]),
374 torch.tensor([prompt_len]),
375 torch.tensor([prompt_len + len(generated)]),
376 context,
377 self.scheme_config.generation_deduplicate_contexts_max_history,
378 include_prompt=scope == "all",
379 skip_partial_context=scope == "all",
380 )
381
382
383 def _trace_payload(trace: GenerationTrace) -> dict:
384 return {
385 "routing_draws": trace.routing_draws,
386 "key_b_routed": trace.key_b_routed,
387 "key_b_effective": trace.key_b_effective,
388 "dedup_skips": trace.dedup_skips,
389 "partial_context_skips": trace.partial_context_skips,
390 }
391
392
393 BALANCED_FIXTURE = DeterministicGenerationFixture()
394
395 # The first five tokens of BALANCED_FIXTURE under scope "all", so position 4 repeats.
396 PROMPT_FIXTURE = DeterministicGenerationFixture(prompt=(46, 42, 38, 34, 122))
397
398 REPETITIVE_FIXTURE = DeterministicGenerationFixture(
399 vocabulary_size=16,
400 num_tokens=48,
401 dominant_token=3,
402 dominant_bias=52,
403 )
404
405 MIDDLE_FIXTURE = DeterministicGenerationFixture(
406 vocabulary_size=8,
407 num_tokens=64,
408 dominant_token=3,
409 dominant_bias=16,
410 )
411
412 # Few enough scored tokens that the p_value lands just under its threshold.
413 NEAR_THRESHOLD_FIXTURE = DeterministicGenerationFixture(
414 vocabulary_size=16,
415 num_tokens=8,
416 )
417
418
419 def _candidate(
420 id: str,
421 scheme: WatermarkingAlgorithm,
422 key: int,
423 *,
424 prf: WatermarkPRFName,
425 detection_key: int | None = None,
426 context_width: int = 4,
427 generation_alpha: float = 0.1,
428 detection_alpha: float | None = None,
429 generation_deduplicate_contexts: WatermarkContextScope = "single_turn",
430 generation_deduplicate_contexts_max_history: int | None = 8192,
431 detection_deduplicate_contexts: bool = True,
432 p_value_threshold: float = 0.01,
433 fixture: DeterministicGenerationFixture = BALANCED_FIXTURE,
434 ) -> WatermarkingCandidate:
435 return WatermarkingCandidate(
436 id=id,
437 scheme=scheme,
438 scheme_config=WatermarkingSchemeConfig(
439 context_width=context_width,
440 generation_alpha=generation_alpha,
441 detection_alpha=(
442 generation_alpha if detection_alpha is None else detection_alpha
443 ),
444 generation_deduplicate_contexts=generation_deduplicate_contexts,
445 generation_deduplicate_contexts_max_history=(
446 generation_deduplicate_contexts_max_history
447 ),
448 detection_deduplicate_contexts=detection_deduplicate_contexts,
449 p_value_threshold=p_value_threshold,
450 ),
451 prf=prf,
452 key=key,
453 detection_key=key if detection_key is None else detection_key,
454 fixture=fixture,
455 )
456
457
458 WATERMARKING_CANDIDATES = (
459 _candidate("gumbel-philox-key0-cw1", "gumbel", 0, prf="philox", context_width=1),
460 _candidate("gumbel-philox-key42-cw4", "gumbel", 42, prf="philox"),
461 _candidate(
462 "gumbel-philox-key42-cw4-wrong-key",
463 "gumbel",
464 42,
465 prf="philox",
466 detection_key=43,
467 ),
468 _candidate(
469 "gumbel-philox-key2p32-cw5-no-dedup",
470 "gumbel",
471 2**32,
472 prf="philox",
473 context_width=5,
474 generation_deduplicate_contexts="none",
475 detection_deduplicate_contexts=False,
476 ),
477 _candidate(
478 "gumbel-philox-keymax-cw16-all",
479 "gumbel",
480 2**64 - 1,
481 prf="philox",
482 context_width=16,
483 generation_deduplicate_contexts="all",
484 ),
485 _candidate(
486 "gumbel-philox-key42-cw4-all",
487 "gumbel",
488 42,
489 prf="philox",
490 generation_deduplicate_contexts="all",
491 ),
492 _candidate(
493 "gumbel-philox-key42-cw4-all-prompt",
494 "gumbel",
495 42,
496 prf="philox",
497 generation_deduplicate_contexts="all",
498 fixture=PROMPT_FIXTURE,
499 ),
500 _candidate(
501 "gumbel-philox-key42-cw4-mid-history8",
502 "gumbel",
503 42,
504 prf="philox",
505 generation_deduplicate_contexts_max_history=8,
506 fixture=MIDDLE_FIXTURE,
507 ),
508 _candidate(
509 "gumbel-philox-key42-cw4-mid-history-none",
510 "gumbel",
511 42,
512 prf="philox",
513 generation_deduplicate_contexts_max_history=None,
514 fixture=MIDDLE_FIXTURE,
515 ),
516 _candidate(
517 "gumbel-philox-key42-cw4-mid-no-dedup",
518 "gumbel",
519 42,
520 prf="philox",
521 generation_deduplicate_contexts="none",
522 generation_deduplicate_contexts_max_history=None,
523 detection_deduplicate_contexts=False,
524 fixture=MIDDLE_FIXTURE,
525 ),
526 _candidate(
527 "gumbel-philox-key42-cw4-mid-all",
528 "gumbel",
529 42,
530 prf="philox",
531 generation_deduplicate_contexts="all",
532 generation_deduplicate_contexts_max_history=None,
533 fixture=MIDDLE_FIXTURE,
534 ),
535 _candidate(
536 "gumbel-philox-key42-cw4-near-threshold",
537 "gumbel",
538 42,
539 prf="philox",
540 p_value_threshold=0.0015,
541 fixture=NEAR_THRESHOLD_FIXTURE,
542 ),
543 _candidate(
544 "gumbel-philox-repeated-cw1",
545 "gumbel",
546 42,
547 prf="philox",
548 context_width=1,
549 fixture=REPETITIVE_FIXTURE,
550 ),
551 _candidate(
552 "gumbel-philox-repeated-cw4-all",
553 "gumbel",
554 42,
555 prf="philox",
556 generation_deduplicate_contexts="all",
557 fixture=REPETITIVE_FIXTURE,
558 ),
559 _candidate(
560 "dual-key-gumbel-philox-key0-cw1-alpha0",
561 "dual_key_gumbel",
562 0,
563 prf="philox",
564 context_width=1,
565 generation_alpha=0.0,
566 ),
567 _candidate(
568 "dual-key-gumbel-philox-key0-cw4-alpha0.1",
569 "dual_key_gumbel",
570 0,
571 prf="philox",
572 ),
573 _candidate(
574 "dual-key-gumbel-philox-key42-cw4-alpha-mismatch",
575 "dual_key_gumbel",
576 42,
577 prf="philox",
578 generation_alpha=0.1,
579 detection_alpha=0.2,
580 ),
581 _candidate(
582 "dual-key-gumbel-philox-key2p32-cw5-alpha0.5",
583 "dual_key_gumbel",
584 2**32,
585 prf="philox",
586 context_width=5,
587 generation_alpha=0.5,
588 ),
589 _candidate(
590 "dual-key-gumbel-philox-keymax-cw16-alpha1",
591 "dual_key_gumbel",
592 2**64 - 1,
593 prf="philox",
594 context_width=16,
595 generation_alpha=1.0,
596 ),
597 _candidate(
598 "dual-key-gumbel-philox-keymax-cw4-alpha0.4",
599 "dual_key_gumbel",
600 2**64 - 1,
601 prf="philox",
602 generation_alpha=0.4,
603 ),
604 _candidate(
605 "dual-key-gumbel-philox-key7-cw4-near-threshold",
606 "dual_key_gumbel",
607 7,
608 prf="philox",
609 p_value_threshold=0.003,
610 fixture=NEAR_THRESHOLD_FIXTURE,
611 ),
612 _candidate(
613 "dual-key-gumbel-philox-repeated-cw1",
614 "dual_key_gumbel",
615 42,
616 prf="philox",
617 context_width=1,
618 fixture=REPETITIVE_FIXTURE,
619 ),
620 _candidate(
621 "dual-key-gumbel-philox-repeated-cw4-all",
622 "dual_key_gumbel",
623 42,
624 prf="philox",
625 generation_deduplicate_contexts="all",
626 fixture=REPETITIVE_FIXTURE,
627 ),
628 )
629
630 DEDUPLICATION_TWINS = (
631 (
632 "gumbel-philox-key42-cw4-mid-history-none",
633 "gumbel-philox-key42-cw4-mid-no-dedup",
634 ),
635 )
636
637
638 def validate_golden_guards(entries: Mapping[str, dict]) -> None:
639 candidates_by_id = {
640 candidate.id: candidate for candidate in WATERMARKING_CANDIDATES
641 }
642 combinations = {
643 (candidate.scheme, candidate.prf) for candidate in WATERMARKING_CANDIDATES
644 }
645 expected_combinations = configured_algorithm_prf_combinations()
646 if combinations != expected_combinations:
647 missing_combinations = sorted(expected_combinations - combinations)
648 unexpected_combinations = sorted(combinations - expected_combinations)
649 raise GoldenGuardError(
650 "candidate scheme/PRF coverage mismatch: "
651 f"missing {missing_combinations}, unexpected {unexpected_combinations}"
652 )
653
654 missing = sorted(set(candidates_by_id) - set(entries))
655 if missing:
656 raise GoldenGuardError(f"golden entries are missing candidates: {missing}")
657
658 for candidate in WATERMARKING_CANDIDATES:
659 entry = entries[candidate.id]
660 ratio = entry["detection"]["p_value_ratio"]
661 if abs(ratio - 1.0) < P_VALUE_RATIO_MIN_DISTANCE:
662 raise GoldenGuardError(
663 f"{candidate.id}: p_value is within "
664 f"{P_VALUE_RATIO_MIN_DISTANCE:g} of p_value_threshold "
665 f"(ratio {ratio!r}), so is_watermarked is not reproducible"
666 )
667
668 if candidate.fixture == REPETITIVE_FIXTURE:
669 alternate_tokens = replace(candidate, key=candidate.key ^ 1).generate()
670 changed_tokens = sum(
671 actual != alternate
672 for actual, alternate in zip(
673 entry["generation"], alternate_tokens, strict=True
674 )
675 )
676 if changed_tokens < REPETITIVE_MIN_KEY_PERTURBATION_TOKENS:
677 raise GoldenGuardError(
678 f"{candidate.id}: key ^ 1 changes only {changed_tokens} tokens; "
679 f"expected at least {REPETITIVE_MIN_KEY_PERTURBATION_TOKENS}"
680 )
681 full_context_skips = (
682 entry["trace"]["dedup_skips"] - entry["trace"]["partial_context_skips"]
683 )
684 if full_context_skips < 1:
685 raise GoldenGuardError(
686 f"{candidate.id}: repetitive fixture has no full-context "
687 "deduplication skip"
688 )
689 if ratio > REPETITIVE_MAX_P_VALUE_RATIO:
690 raise GoldenGuardError(
691 f"{candidate.id}: p_value_ratio {ratio!r} exceeds "
692 f"{REPETITIVE_MAX_P_VALUE_RATIO}"
693 )
694
695 alpha = candidate.scheme_config.generation_alpha
696 if candidate.scheme == "dual_key_gumbel" and 0 < alpha < 1:
697 boundary = routing_boundary(alpha)
698 distance = min(
699 abs(uniform - boundary)
700 for uniform in candidate.fixture.routing_uniforms
701 )
702 if distance < ROUTING_BOUNDARY_MIN_DISTANCE:
703 raise GoldenGuardError(
704 f"{candidate.id}: routing uniform is only {distance!r} from "
705 f"the realised boundary; expected at least "
706 f"{ROUTING_BOUNDARY_MIN_DISTANCE:g}"
707 )
708
709 for enabled_id, disabled_id in DEDUPLICATION_TWINS:
710 enabled = candidates_by_id[enabled_id]
711 disabled = candidates_by_id[disabled_id]
712 expected_disabled = replace(
713 enabled,
714 id=disabled_id,
715 scheme_config=replace(
716 enabled.scheme_config,
717 generation_deduplicate_contexts="none",
718 detection_deduplicate_contexts=False,
719 ),
720 )
721 if disabled != expected_disabled:
722 raise GoldenGuardError(
723 f"{enabled_id} and {disabled_id} must differ only in generation "
724 "and detection deduplication"
725 )
726
727 enabled_entry = entries[enabled_id]
728 disabled_entry = entries[disabled_id]
729 if enabled_entry["trace"]["dedup_skips"] < 1:
730 raise GoldenGuardError(f"{enabled_id}: expected at least one dedup skip")
731 if disabled_entry["trace"]["dedup_skips"] != 0:
732 raise GoldenGuardError(f"{disabled_id}: expected zero dedup skips")
733 if enabled_entry["generation"] == disabled_entry["generation"]:
734 raise GoldenGuardError(
735 f"{enabled_id} and {disabled_id}: generations must differ"
736 )
737 enabled_scored = enabled_entry["detection"]["num_scored_tokens"]
738 disabled_scored = disabled_entry["detection"]["num_scored_tokens"]
739 if enabled_scored == disabled_scored:
740 raise GoldenGuardError(
741 f"{enabled_id} and {disabled_id}: detector scored-token counts "
742 "must differ"
743 )
744
745
746 def golden_payload() -> dict:
747 candidates = {
748 candidate.id: candidate.golden_entry() for candidate in WATERMARKING_CANDIDATES
749 }
750 return {
751 "schema_version": GOLDEN_SCHEMA_VERSION,
752 "candidates": candidates,
753 }
754
755
756 def floats_match(actual: float, expected: float) -> bool:
757 return math.isclose(
758 actual,
759 expected,
760 rel_tol=GOLDEN_FLOAT_RTOL,
761 abs_tol=GOLDEN_FLOAT_ATOL,
762 )
763
764
765 def _relative_difference(actual: float, expected: float) -> float:
766 if expected == 0.0:
767 return 0.0 if actual == 0.0 else math.inf
768 return abs(actual - expected) / abs(expected)
769
770
771 def _compare_float(
772 field: str,
773 actual: float,
774 expected: float,
775 differences: list[str],
776 ) -> None:
777 if not floats_match(actual, expected):
778 differences.append(
779 f"{field}: expected {expected!r}, got {actual!r}, "
780 f"relative difference {_relative_difference(actual, expected):.3e}"
781 )
782
783
784 def _compare_exact(
785 field: str,
786 actual: object,
787 expected: object,
788 differences: list[str],
789 ) -> None:
790 if actual != expected:
791 differences.append(f"{field}: expected {expected!r}, got {actual!r}")
792
793
794 def _compare_mapping(
795 field: str,
796 actual: Mapping[str, object],
797 expected: Mapping[str, object],
798 differences: list[str],
799 ) -> None:
800 for key in sorted(set(actual) | set(expected)):
801 if actual.get(key) != expected.get(key):
802 differences.append(
803 f"{field}.{key}: expected {expected.get(key)!r}, "
804 f"got {actual.get(key)!r}"
805 )
806
807
808 def compare_entries(produced: dict, golden: dict) -> list[str]:
809 """Return one line per field that differs, empty when the two agree.
810
811 Configuration, resolved state, tokens, trace,
812 num_scored_tokens and is_watermarked are compared exactly; score, p_value
813 and p_value_ratio are compared with GOLDEN_FLOAT_RTOL.
814 """
815 differences: list[str] = []
816 for field in ("configuration", "resolved", "trace"):
817 _compare_mapping(field, produced[field], golden[field], differences)
818
819 if produced["generation"] != golden["generation"]:
820 first_difference = next(
821 (
822 index
823 for index, (actual, expected) in enumerate(
824 zip(produced["generation"], golden["generation"])
825 )
826 if actual != expected
827 ),
828 min(len(produced["generation"]), len(golden["generation"])),
829 )
830 differences.append(
831 f"generation: first differs at index {first_difference} "
832 f"({len(produced['generation'])} tokens generated, "
833 f"{len(golden['generation'])} in the golden): "
834 f"expected {golden['generation']}, got {produced['generation']}"
835 )
836
837 detection = produced["detection"]
838 expected_detection = golden["detection"]
839 for field in ("score", "p_value", "p_value_ratio"):
840 _compare_float(
841 f"detection.{field}",
842 detection[field],
843 expected_detection[field],
844 differences,
845 )
846 for field in ("num_scored_tokens", "is_watermarked"):
847 _compare_exact(
848 f"detection.{field}",
849 detection[field],
850 expected_detection[field],
851 differences,
852 )
853 return differences
854
855
856 def compare_golden(candidate: WatermarkingCandidate, golden: dict) -> list[str]:
857 """Return one line per field of ``candidate`` that drifted from ``golden``."""
858 return compare_entries(candidate.golden_entry(golden["generation"]), golden)
859
860
861 def read_goldens(path: Path) -> dict[str, dict]:
862 """Read the goldens file and return its candidate mapping."""
863 payload = json.loads(path.read_text(encoding="utf-8"))
864 version = payload.get("schema_version")
865 if version != GOLDEN_SCHEMA_VERSION:
866 raise GoldenFormatError(
867 f"{path}: schema_version {version!r}, expected {GOLDEN_SCHEMA_VERSION}"
868 )
869 return payload["candidates"]
870
871
872 def configured_algorithms() -> set[str]:
873 return set(get_args(WatermarkingAlgorithm))
874
875
876 def configured_prfs() -> set[str]:
877 return set(get_args(WatermarkPRFName))
878
879
880 def configured_algorithm_prf_combinations() -> set[tuple[str, str]]:
881 return {
882 (algorithm, prf)
883 for algorithm in configured_algorithms()
884 for prf in configured_prfs()
885 }