Files
vLLM/tests/watermarking/golden_candidates.py
Raphaël RiallandSimon VeitnerOpenAI Codexmergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
4ccfe12398 [watermarking] golden tests for backwards compatibility (#56809)
Signed-off-by: Raphael Rialland <raphael.rialland@mistral.ai>
Signed-off-by: Simon Veitner <sveitner@redhat.com>
Co-authored-by: Simon Veitner <sveitner@redhat.com>
Co-authored-by: OpenAI Codex <codex@openai.com>
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
2026-09-25 22:01:48 +00:00

886 lines
29 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Deterministic watermarking golden candidates."""
import json
import math
from collections.abc import Mapping
from dataclasses import dataclass, replace
from pathlib import Path
from typing import get_args
import torch
from vllm.config.watermarking import (
WatermarkConfig,
WatermarkContextScope,
WatermarkingAlgorithm,
WatermarkPRFName,
derive_watermark_key,
)
from vllm.v1.watermarking import (
DualKeyGumbelWatermarkDetector,
GumbelWatermarkDetector,
WatermarkDetection,
WatermarkDetector,
create_prf,
create_watermarker,
)
from vllm.v1.worker.gpu.sample.watermark import repeated_context_mask
GOLDEN_SCHEMA_VERSION = 3
# JSON floats round-trip exactly, but libm differs by an ULP across builds.
GOLDEN_FLOAT_RTOL = 1e-9
GOLDEN_FLOAT_ATOL = 1e-300
P_VALUE_RATIO_MIN_DISTANCE = 1e-6
REPETITIVE_MIN_KEY_PERTURBATION_TOKENS = 8
REPETITIVE_MAX_P_VALUE_RATIO = 0.5
ROUTING_BOUNDARY_MIN_DISTANCE = 1e-6
WATERMARK_CONFIG_FIELDS = (
"algorithm",
"alpha",
"context_width",
"deduplicate_contexts",
"deduplicate_contexts_max_history",
"prf",
"allow_target_only_watermarking",
)
# round(((i + 1) * (5**0.5 - 1) / 2) % 1.0, 4) for i in range(64)
ROUTING_UNIFORMS: tuple[float, ...] = (
0.618, 0.2361, 0.8541, 0.4721, 0.0902, 0.7082, 0.3262, 0.9443,
0.5623, 0.1803, 0.7984, 0.4164, 0.0344, 0.6525, 0.2705, 0.8885,
0.5066, 0.1246, 0.7426, 0.3607, 0.9787, 0.5967, 0.2148, 0.8328,
0.4508, 0.0689, 0.6869, 0.305, 0.923, 0.541, 0.1591, 0.7771,
0.3951, 0.0132, 0.6312, 0.2492, 0.8673, 0.4853, 0.1033, 0.7214,
0.3394, 0.9574, 0.5755, 0.1935, 0.8115, 0.4296, 0.0476, 0.6656,
0.2837, 0.9017, 0.5197, 0.1378, 0.7558, 0.3738, 0.9919, 0.6099,
0.2279, 0.846, 0.464, 0.082, 0.7001, 0.3181, 0.9361, 0.5542,
) # fmt: skip
class GoldenFormatError(ValueError):
"""The stored goldens do not have the shape this module expects."""
class GoldenGuardError(ValueError):
"""A candidate is too weak or too unstable to be frozen as a golden."""
@dataclass(frozen=True)
class DeterministicGenerationFixture:
vocabulary_size: int = 128
num_tokens: int = 64
logit_denominator: int = 16
logit_modulus: int = 131
dominant_token: int | None = None
dominant_bias: int = 0
prompt: tuple[int, ...] = ()
routing_uniforms: tuple[float, ...] = ROUTING_UNIFORMS
def logits(self, position: int) -> torch.Tensor:
token_ids = torch.arange(self.vocabulary_size, dtype=torch.int64)
values = (token_ids * 37 + position * 17) % self.logit_modulus - 56
if self.dominant_token is not None:
values[self.dominant_token] += self.dominant_bias
return (values.to(torch.float32) / self.logit_denominator).unsqueeze(0)
@dataclass(frozen=True)
class WatermarkingSchemeConfig:
context_width: int = 4
generation_alpha: float = 0.1
detection_alpha: float = 0.1
generation_deduplicate_contexts: WatermarkContextScope = "single_turn"
generation_deduplicate_contexts_max_history: int | None = 8192
detection_deduplicate_contexts: bool = True
p_value_threshold: float = 0.01
@dataclass(frozen=True)
class GenerationTrace:
routing_draws: int
key_b_routed: int | None
key_b_effective: int | None
dedup_skips: int
partial_context_skips: int
class _DeterministicSampler:
def __init__(self, routing_uniforms: tuple[float, ...]) -> None:
self.routing_uniforms = routing_uniforms
self.routing_position = 0
self.key_b_routed = 0
def __call__(self, logits: torch.Tensor) -> torch.Tensor:
if logits.shape[-1] != 2:
return logits.argmax(dim=-1)
probability_a = logits.softmax(dim=-1)[:, 0]
uniform = self.routing_uniforms[
self.routing_position % len(self.routing_uniforms)
]
self.routing_position += 1
routed = (probability_a <= uniform).to(torch.int64)
self.key_b_routed += int(routed.sum())
return routed
def routing_boundary(alpha: float) -> float:
"""Return the realised key-A probability the deterministic sampler sees."""
logits = torch.tensor([1 - alpha, alpha], dtype=torch.float32).log()
return float(logits.softmax(dim=-1)[0])
def _create_gumbel_detector(
key: int,
config: WatermarkingSchemeConfig,
prf: WatermarkPRFName,
) -> WatermarkDetector:
return GumbelWatermarkDetector(
key=key,
context_width=config.context_width,
p_value_threshold=config.p_value_threshold,
prf=prf,
deduplicate_contexts=config.detection_deduplicate_contexts,
)
def _create_dual_key_gumbel_detector(
key: int,
config: WatermarkingSchemeConfig,
prf: WatermarkPRFName,
) -> WatermarkDetector:
return DualKeyGumbelWatermarkDetector(
key=key,
context_width=config.context_width,
p_value_threshold=config.p_value_threshold,
prf=prf,
deduplicate_contexts=config.detection_deduplicate_contexts,
alpha=config.detection_alpha,
)
DETECTOR_FACTORIES = {
"gumbel": _create_gumbel_detector,
"dual_key_gumbel": _create_dual_key_gumbel_detector,
}
@dataclass(frozen=True)
class WatermarkingCandidate:
id: str
scheme: WatermarkingAlgorithm
scheme_config: WatermarkingSchemeConfig
prf: WatermarkPRFName
key: int
detection_key: int
fixture: DeterministicGenerationFixture = DeterministicGenerationFixture()
def configuration(self) -> dict[str, object]:
prf_version = getattr(create_prf(self.prf, self.key), "version", None)
if not isinstance(prf_version, str):
raise ValueError(f"PRF {self.prf} does not define a version")
return {
"scheme": self.scheme,
"scheme_config": {
"context_width": self.scheme_config.context_width,
"generation_alpha": self.scheme_config.generation_alpha,
"detection_alpha": self.scheme_config.detection_alpha,
"generation_deduplicate_contexts": (
self.scheme_config.generation_deduplicate_contexts
),
"generation_deduplicate_contexts_max_history": (
self.scheme_config.generation_deduplicate_contexts_max_history
),
"detection_deduplicate_contexts": (
self.scheme_config.detection_deduplicate_contexts
),
"p_value_threshold": self.scheme_config.p_value_threshold,
},
"prf": self.prf,
"prf_version": prf_version,
"key": str(self.key),
"detection_key": str(self.detection_key),
"fixture": {
"vocabulary_size": self.fixture.vocabulary_size,
"num_tokens": self.fixture.num_tokens,
"logit_denominator": self.fixture.logit_denominator,
"logit_modulus": self.fixture.logit_modulus,
"dominant_token": self.fixture.dominant_token,
"dominant_bias": self.fixture.dominant_bias,
"prompt": list(self.fixture.prompt),
},
}
def resolved(self) -> dict[str, object]:
"""Return the vLLM-side state this candidate resolves to.
An allowlist rather than the whole dataclass, so a new production field
is a deliberate golden change: WATERMARK_CONFIG_FIELDS names the fields
recorded here and test_goldens.py checks it still covers
WatermarkConfig. Keys exceed 2**53 and are decimal strings.
"""
config = self._watermark_config()
detector = self._detector()
key_b_prf = getattr(detector, "key_b_prf", None)
dual_key = self.scheme == "dual_key_gumbel"
return {
"watermark_config": {
field: getattr(config, field) for field in WATERMARK_CONFIG_FIELDS
},
"derived_keys": {
"key_a": str(derive_watermark_key(self.key, b"key_a"))
if dual_key
else None,
"key_b": str(derive_watermark_key(self.key, b"key_b"))
if dual_key
else None,
},
"detector": {
"type": type(detector).__name__,
"context_width": detector.context_width,
"p_value_threshold": detector.p_value_threshold,
"deduplicate_contexts": detector.deduplicate_contexts,
"alpha": getattr(detector, "alpha", None),
"prf_key": str(detector.prf.key),
"key_b_prf_key": None if key_b_prf is None else str(key_b_prf.key),
},
}
def generate(self) -> list[int]:
return self.generate_with_trace()[0]
def generate_with_trace(self) -> tuple[list[int], GenerationTrace]:
watermarker = create_watermarker(self._watermark_config())
sampler = _DeterministicSampler(self.fixture.routing_uniforms)
generated: list[int] = []
dedup_skips = 0
partial_context_skips = 0
key_b_effective = 0
for position in range(self.fixture.num_tokens):
context = self._context(generated)
skip_mask = self._skip_mask(generated, context)
skipped = skip_mask is not None and bool(skip_mask.item())
if skipped:
dedup_skips += 1
if len(generated) < self.scheme_config.context_width:
partial_context_skips += 1
routed_before = sampler.key_b_routed
sample = watermarker.sample(
self.fixture.logits(position),
context,
sampler,
skip_mask,
)
if sampler.key_b_routed > routed_before and not skipped:
key_b_effective += 1
generated.append(int(sample.token_ids.item()))
routes = self.scheme == "dual_key_gumbel" and (
0 < self.scheme_config.generation_alpha < 1
)
trace = GenerationTrace(
routing_draws=sampler.routing_position,
key_b_routed=sampler.key_b_routed if routes else None,
key_b_effective=key_b_effective if routes else None,
dedup_skips=dedup_skips,
partial_context_skips=partial_context_skips,
)
return generated, trace
def detect(self, token_ids: list[int]) -> WatermarkDetection:
return self._detector().detect(token_ids)
def golden_entry(
self,
detection_token_ids: list[int] | None = None,
) -> dict:
"""Generate, detect and trace this candidate into one golden entry.
``detection_token_ids`` runs detection over stored tokens instead of
the freshly generated ones, so a token drift and a detection drift are
reported independently.
"""
token_ids, trace = self.generate_with_trace()
scored = token_ids if detection_token_ids is None else detection_token_ids
detection = self.detect(scored)
threshold = self.scheme_config.p_value_threshold
return {
"configuration": self.configuration(),
"resolved": self.resolved(),
"generation": token_ids,
"trace": _trace_payload(trace),
"detection": {
"score": detection.score,
"p_value": detection.p_value,
"p_value_ratio": detection.p_value / threshold,
"num_scored_tokens": detection.num_scored_tokens,
"is_watermarked": detection.is_watermarked,
},
}
def _watermark_config(self) -> WatermarkConfig:
return WatermarkConfig(
algorithm=self.scheme,
key=self.key,
alpha=self.scheme_config.generation_alpha,
context_width=self.scheme_config.context_width,
deduplicate_contexts=(self.scheme_config.generation_deduplicate_contexts),
deduplicate_contexts_max_history=(
self.scheme_config.generation_deduplicate_contexts_max_history
),
prf=self.prf,
)
def _detector(self) -> WatermarkDetector:
return DETECTOR_FACTORIES[self.scheme](
self.detection_key,
self.scheme_config,
self.prf,
)
def _context(self, generated: list[int]) -> torch.Tensor:
width = self.scheme_config.context_width
padded = [-1] * width + generated
return torch.tensor([padded[-width:]], dtype=torch.int64)
def _skip_mask(
self, generated: list[int], context: torch.Tensor
) -> torch.Tensor | None:
scope = self.scheme_config.generation_deduplicate_contexts
if scope == "none":
return None
prompt_len = len(self.fixture.prompt)
storage = torch.zeros(
(1, prompt_len + self.fixture.num_tokens),
dtype=torch.int64,
)
if prompt_len:
storage[0, :prompt_len] = torch.tensor(self.fixture.prompt)
if generated:
storage[0, prompt_len : prompt_len + len(generated)] = torch.tensor(
generated
)
return repeated_context_mask(
storage,
torch.tensor([0]),
torch.tensor([prompt_len]),
torch.tensor([prompt_len + len(generated)]),
context,
self.scheme_config.generation_deduplicate_contexts_max_history,
include_prompt=scope == "all",
skip_partial_context=scope == "all",
)
def _trace_payload(trace: GenerationTrace) -> dict:
return {
"routing_draws": trace.routing_draws,
"key_b_routed": trace.key_b_routed,
"key_b_effective": trace.key_b_effective,
"dedup_skips": trace.dedup_skips,
"partial_context_skips": trace.partial_context_skips,
}
BALANCED_FIXTURE = DeterministicGenerationFixture()
# The first five tokens of BALANCED_FIXTURE under scope "all", so position 4 repeats.
PROMPT_FIXTURE = DeterministicGenerationFixture(prompt=(46, 42, 38, 34, 122))
REPETITIVE_FIXTURE = DeterministicGenerationFixture(
vocabulary_size=16,
num_tokens=48,
dominant_token=3,
dominant_bias=52,
)
MIDDLE_FIXTURE = DeterministicGenerationFixture(
vocabulary_size=8,
num_tokens=64,
dominant_token=3,
dominant_bias=16,
)
# Few enough scored tokens that the p_value lands just under its threshold.
NEAR_THRESHOLD_FIXTURE = DeterministicGenerationFixture(
vocabulary_size=16,
num_tokens=8,
)
def _candidate(
id: str,
scheme: WatermarkingAlgorithm,
key: int,
*,
prf: WatermarkPRFName,
detection_key: int | None = None,
context_width: int = 4,
generation_alpha: float = 0.1,
detection_alpha: float | None = None,
generation_deduplicate_contexts: WatermarkContextScope = "single_turn",
generation_deduplicate_contexts_max_history: int | None = 8192,
detection_deduplicate_contexts: bool = True,
p_value_threshold: float = 0.01,
fixture: DeterministicGenerationFixture = BALANCED_FIXTURE,
) -> WatermarkingCandidate:
return WatermarkingCandidate(
id=id,
scheme=scheme,
scheme_config=WatermarkingSchemeConfig(
context_width=context_width,
generation_alpha=generation_alpha,
detection_alpha=(
generation_alpha if detection_alpha is None else detection_alpha
),
generation_deduplicate_contexts=generation_deduplicate_contexts,
generation_deduplicate_contexts_max_history=(
generation_deduplicate_contexts_max_history
),
detection_deduplicate_contexts=detection_deduplicate_contexts,
p_value_threshold=p_value_threshold,
),
prf=prf,
key=key,
detection_key=key if detection_key is None else detection_key,
fixture=fixture,
)
WATERMARKING_CANDIDATES = (
_candidate("gumbel-philox-key0-cw1", "gumbel", 0, prf="philox", context_width=1),
_candidate("gumbel-philox-key42-cw4", "gumbel", 42, prf="philox"),
_candidate(
"gumbel-philox-key42-cw4-wrong-key",
"gumbel",
42,
prf="philox",
detection_key=43,
),
_candidate(
"gumbel-philox-key2p32-cw5-no-dedup",
"gumbel",
2**32,
prf="philox",
context_width=5,
generation_deduplicate_contexts="none",
detection_deduplicate_contexts=False,
),
_candidate(
"gumbel-philox-keymax-cw16-all",
"gumbel",
2**64 - 1,
prf="philox",
context_width=16,
generation_deduplicate_contexts="all",
),
_candidate(
"gumbel-philox-key42-cw4-all",
"gumbel",
42,
prf="philox",
generation_deduplicate_contexts="all",
),
_candidate(
"gumbel-philox-key42-cw4-all-prompt",
"gumbel",
42,
prf="philox",
generation_deduplicate_contexts="all",
fixture=PROMPT_FIXTURE,
),
_candidate(
"gumbel-philox-key42-cw4-mid-history8",
"gumbel",
42,
prf="philox",
generation_deduplicate_contexts_max_history=8,
fixture=MIDDLE_FIXTURE,
),
_candidate(
"gumbel-philox-key42-cw4-mid-history-none",
"gumbel",
42,
prf="philox",
generation_deduplicate_contexts_max_history=None,
fixture=MIDDLE_FIXTURE,
),
_candidate(
"gumbel-philox-key42-cw4-mid-no-dedup",
"gumbel",
42,
prf="philox",
generation_deduplicate_contexts="none",
generation_deduplicate_contexts_max_history=None,
detection_deduplicate_contexts=False,
fixture=MIDDLE_FIXTURE,
),
_candidate(
"gumbel-philox-key42-cw4-mid-all",
"gumbel",
42,
prf="philox",
generation_deduplicate_contexts="all",
generation_deduplicate_contexts_max_history=None,
fixture=MIDDLE_FIXTURE,
),
_candidate(
"gumbel-philox-key42-cw4-near-threshold",
"gumbel",
42,
prf="philox",
p_value_threshold=0.0015,
fixture=NEAR_THRESHOLD_FIXTURE,
),
_candidate(
"gumbel-philox-repeated-cw1",
"gumbel",
42,
prf="philox",
context_width=1,
fixture=REPETITIVE_FIXTURE,
),
_candidate(
"gumbel-philox-repeated-cw4-all",
"gumbel",
42,
prf="philox",
generation_deduplicate_contexts="all",
fixture=REPETITIVE_FIXTURE,
),
_candidate(
"dual-key-gumbel-philox-key0-cw1-alpha0",
"dual_key_gumbel",
0,
prf="philox",
context_width=1,
generation_alpha=0.0,
),
_candidate(
"dual-key-gumbel-philox-key0-cw4-alpha0.1",
"dual_key_gumbel",
0,
prf="philox",
),
_candidate(
"dual-key-gumbel-philox-key42-cw4-alpha-mismatch",
"dual_key_gumbel",
42,
prf="philox",
generation_alpha=0.1,
detection_alpha=0.2,
),
_candidate(
"dual-key-gumbel-philox-key2p32-cw5-alpha0.5",
"dual_key_gumbel",
2**32,
prf="philox",
context_width=5,
generation_alpha=0.5,
),
_candidate(
"dual-key-gumbel-philox-keymax-cw16-alpha1",
"dual_key_gumbel",
2**64 - 1,
prf="philox",
context_width=16,
generation_alpha=1.0,
),
_candidate(
"dual-key-gumbel-philox-keymax-cw4-alpha0.4",
"dual_key_gumbel",
2**64 - 1,
prf="philox",
generation_alpha=0.4,
),
_candidate(
"dual-key-gumbel-philox-key7-cw4-near-threshold",
"dual_key_gumbel",
7,
prf="philox",
p_value_threshold=0.003,
fixture=NEAR_THRESHOLD_FIXTURE,
),
_candidate(
"dual-key-gumbel-philox-repeated-cw1",
"dual_key_gumbel",
42,
prf="philox",
context_width=1,
fixture=REPETITIVE_FIXTURE,
),
_candidate(
"dual-key-gumbel-philox-repeated-cw4-all",
"dual_key_gumbel",
42,
prf="philox",
generation_deduplicate_contexts="all",
fixture=REPETITIVE_FIXTURE,
),
)
DEDUPLICATION_TWINS = (
(
"gumbel-philox-key42-cw4-mid-history-none",
"gumbel-philox-key42-cw4-mid-no-dedup",
),
)
def validate_golden_guards(entries: Mapping[str, dict]) -> None:
candidates_by_id = {
candidate.id: candidate for candidate in WATERMARKING_CANDIDATES
}
combinations = {
(candidate.scheme, candidate.prf) for candidate in WATERMARKING_CANDIDATES
}
expected_combinations = configured_algorithm_prf_combinations()
if combinations != expected_combinations:
missing_combinations = sorted(expected_combinations - combinations)
unexpected_combinations = sorted(combinations - expected_combinations)
raise GoldenGuardError(
"candidate scheme/PRF coverage mismatch: "
f"missing {missing_combinations}, unexpected {unexpected_combinations}"
)
missing = sorted(set(candidates_by_id) - set(entries))
if missing:
raise GoldenGuardError(f"golden entries are missing candidates: {missing}")
for candidate in WATERMARKING_CANDIDATES:
entry = entries[candidate.id]
ratio = entry["detection"]["p_value_ratio"]
if abs(ratio - 1.0) < P_VALUE_RATIO_MIN_DISTANCE:
raise GoldenGuardError(
f"{candidate.id}: p_value is within "
f"{P_VALUE_RATIO_MIN_DISTANCE:g} of p_value_threshold "
f"(ratio {ratio!r}), so is_watermarked is not reproducible"
)
if candidate.fixture == REPETITIVE_FIXTURE:
alternate_tokens = replace(candidate, key=candidate.key ^ 1).generate()
changed_tokens = sum(
actual != alternate
for actual, alternate in zip(
entry["generation"], alternate_tokens, strict=True
)
)
if changed_tokens < REPETITIVE_MIN_KEY_PERTURBATION_TOKENS:
raise GoldenGuardError(
f"{candidate.id}: key ^ 1 changes only {changed_tokens} tokens; "
f"expected at least {REPETITIVE_MIN_KEY_PERTURBATION_TOKENS}"
)
full_context_skips = (
entry["trace"]["dedup_skips"] - entry["trace"]["partial_context_skips"]
)
if full_context_skips < 1:
raise GoldenGuardError(
f"{candidate.id}: repetitive fixture has no full-context "
"deduplication skip"
)
if ratio > REPETITIVE_MAX_P_VALUE_RATIO:
raise GoldenGuardError(
f"{candidate.id}: p_value_ratio {ratio!r} exceeds "
f"{REPETITIVE_MAX_P_VALUE_RATIO}"
)
alpha = candidate.scheme_config.generation_alpha
if candidate.scheme == "dual_key_gumbel" and 0 < alpha < 1:
boundary = routing_boundary(alpha)
distance = min(
abs(uniform - boundary)
for uniform in candidate.fixture.routing_uniforms
)
if distance < ROUTING_BOUNDARY_MIN_DISTANCE:
raise GoldenGuardError(
f"{candidate.id}: routing uniform is only {distance!r} from "
f"the realised boundary; expected at least "
f"{ROUTING_BOUNDARY_MIN_DISTANCE:g}"
)
for enabled_id, disabled_id in DEDUPLICATION_TWINS:
enabled = candidates_by_id[enabled_id]
disabled = candidates_by_id[disabled_id]
expected_disabled = replace(
enabled,
id=disabled_id,
scheme_config=replace(
enabled.scheme_config,
generation_deduplicate_contexts="none",
detection_deduplicate_contexts=False,
),
)
if disabled != expected_disabled:
raise GoldenGuardError(
f"{enabled_id} and {disabled_id} must differ only in generation "
"and detection deduplication"
)
enabled_entry = entries[enabled_id]
disabled_entry = entries[disabled_id]
if enabled_entry["trace"]["dedup_skips"] < 1:
raise GoldenGuardError(f"{enabled_id}: expected at least one dedup skip")
if disabled_entry["trace"]["dedup_skips"] != 0:
raise GoldenGuardError(f"{disabled_id}: expected zero dedup skips")
if enabled_entry["generation"] == disabled_entry["generation"]:
raise GoldenGuardError(
f"{enabled_id} and {disabled_id}: generations must differ"
)
enabled_scored = enabled_entry["detection"]["num_scored_tokens"]
disabled_scored = disabled_entry["detection"]["num_scored_tokens"]
if enabled_scored == disabled_scored:
raise GoldenGuardError(
f"{enabled_id} and {disabled_id}: detector scored-token counts "
"must differ"
)
def golden_payload() -> dict:
candidates = {
candidate.id: candidate.golden_entry() for candidate in WATERMARKING_CANDIDATES
}
return {
"schema_version": GOLDEN_SCHEMA_VERSION,
"candidates": candidates,
}
def floats_match(actual: float, expected: float) -> bool:
return math.isclose(
actual,
expected,
rel_tol=GOLDEN_FLOAT_RTOL,
abs_tol=GOLDEN_FLOAT_ATOL,
)
def _relative_difference(actual: float, expected: float) -> float:
if expected == 0.0:
return 0.0 if actual == 0.0 else math.inf
return abs(actual - expected) / abs(expected)
def _compare_float(
field: str,
actual: float,
expected: float,
differences: list[str],
) -> None:
if not floats_match(actual, expected):
differences.append(
f"{field}: expected {expected!r}, got {actual!r}, "
f"relative difference {_relative_difference(actual, expected):.3e}"
)
def _compare_exact(
field: str,
actual: object,
expected: object,
differences: list[str],
) -> None:
if actual != expected:
differences.append(f"{field}: expected {expected!r}, got {actual!r}")
def _compare_mapping(
field: str,
actual: Mapping[str, object],
expected: Mapping[str, object],
differences: list[str],
) -> None:
for key in sorted(set(actual) | set(expected)):
if actual.get(key) != expected.get(key):
differences.append(
f"{field}.{key}: expected {expected.get(key)!r}, "
f"got {actual.get(key)!r}"
)
def compare_entries(produced: dict, golden: dict) -> list[str]:
"""Return one line per field that differs, empty when the two agree.
Configuration, resolved state, tokens, trace,
num_scored_tokens and is_watermarked are compared exactly; score, p_value
and p_value_ratio are compared with GOLDEN_FLOAT_RTOL.
"""
differences: list[str] = []
for field in ("configuration", "resolved", "trace"):
_compare_mapping(field, produced[field], golden[field], differences)
if produced["generation"] != golden["generation"]:
first_difference = next(
(
index
for index, (actual, expected) in enumerate(
zip(produced["generation"], golden["generation"])
)
if actual != expected
),
min(len(produced["generation"]), len(golden["generation"])),
)
differences.append(
f"generation: first differs at index {first_difference} "
f"({len(produced['generation'])} tokens generated, "
f"{len(golden['generation'])} in the golden): "
f"expected {golden['generation']}, got {produced['generation']}"
)
detection = produced["detection"]
expected_detection = golden["detection"]
for field in ("score", "p_value", "p_value_ratio"):
_compare_float(
f"detection.{field}",
detection[field],
expected_detection[field],
differences,
)
for field in ("num_scored_tokens", "is_watermarked"):
_compare_exact(
f"detection.{field}",
detection[field],
expected_detection[field],
differences,
)
return differences
def compare_golden(candidate: WatermarkingCandidate, golden: dict) -> list[str]:
"""Return one line per field of ``candidate`` that drifted from ``golden``."""
return compare_entries(candidate.golden_entry(golden["generation"]), golden)
def read_goldens(path: Path) -> dict[str, dict]:
"""Read the goldens file and return its candidate mapping."""
payload = json.loads(path.read_text(encoding="utf-8"))
version = payload.get("schema_version")
if version != GOLDEN_SCHEMA_VERSION:
raise GoldenFormatError(
f"{path}: schema_version {version!r}, expected {GOLDEN_SCHEMA_VERSION}"
)
return payload["candidates"]
def configured_algorithms() -> set[str]:
return set(get_args(WatermarkingAlgorithm))
def configured_prfs() -> set[str]:
return set(get_args(WatermarkPRFName))
def configured_algorithm_prf_combinations() -> set[tuple[str, str]]:
return {
(algorithm, prf)
for algorithm in configured_algorithms()
for prf in configured_prfs()
}