[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>
This commit is contained in:
Raphaël Rialland
2026-09-25 22:01:48 +00:00
committed by GitHub
co-authored by Simon Veitner OpenAI Codex mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
parent b6761e8ded
commit 4ccfe12398
7 changed files with 3866 additions and 3 deletions
+2
View File
@@ -211,6 +211,7 @@ steps:
- vllm/utils/
- vllm/v1/
- tests/v1
- tests/watermarking
device: cpu-small
commands:
# split the test to avoid interference
@@ -222,6 +223,7 @@ steps:
- pytest -v -s -m 'cpu_test' v1/kv_connector/unit
- pytest -v -s -m 'cpu_test' v1/ec_connector/unit
- pytest -v -s -m 'cpu_test' v1/metrics
- pytest -v -s watermarking
- label: ":nvidia: (H200 MIG 18GB) Extract Hidden States Integration"
key: extract-hidden-states-integration
+2
View File
@@ -0,0 +1,2 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+24
View File
@@ -0,0 +1,24 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Regenerate tests/watermarking/watermarking_goldens.json."""
import json
from pathlib import Path
from tests.watermarking.golden_candidates import golden_payload, validate_golden_guards
GOLDENS_PATH = Path(__file__).with_name("watermarking_goldens.json")
def main() -> None:
payload = golden_payload()
validate_golden_guards(payload["candidates"])
GOLDENS_PATH.write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8"
)
print(f"wrote {len(payload['candidates'])} candidates to {GOLDENS_PATH}")
if __name__ == "__main__":
main()
+885
View File
@@ -0,0 +1,885 @@
# 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()
}
+99
View File
@@ -0,0 +1,99 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Check the watermarking goldens still reproduce."""
import dataclasses
from pathlib import Path
import pytest
from tests.watermarking.golden_candidates import (
DETECTOR_FACTORIES,
GOLDEN_FLOAT_RTOL,
WATERMARK_CONFIG_FIELDS,
WATERMARKING_CANDIDATES,
compare_golden,
configured_algorithm_prf_combinations,
configured_algorithms,
read_goldens,
routing_boundary,
validate_golden_guards,
)
from vllm.config.watermarking import WatermarkConfig
GOLDENS_PATH = Path(__file__).with_name("watermarking_goldens.json")
CANDIDATES_BY_ID = {candidate.id: candidate for candidate in WATERMARKING_CANDIDATES}
NEGATIVE_CANDIDATE_ID = "gumbel-philox-key42-cw4-wrong-key"
@pytest.fixture(scope="module")
def goldens() -> dict[str, dict]:
return read_goldens(GOLDENS_PATH)
def test_goldens_cover_every_candidate(goldens: dict[str, dict]):
candidate_ids = set(CANDIDATES_BY_ID)
assert len(candidate_ids) == len(WATERMARKING_CANDIDATES)
assert set(goldens) == candidate_ids, "golden contract does not match candidates"
candidate_combinations = {
(candidate.scheme, candidate.prf) for candidate in WATERMARKING_CANDIDATES
}
assert candidate_combinations == configured_algorithm_prf_combinations()
assert set(DETECTOR_FACTORIES) == configured_algorithms()
def test_golden_fixture_guards(goldens: dict[str, dict]):
validate_golden_guards(goldens)
def test_resolved_records_every_watermark_config_field():
assert {field.name for field in dataclasses.fields(WatermarkConfig)} == (
set(WATERMARK_CONFIG_FIELDS) | {"key"}
)
@pytest.mark.parametrize(
"candidate",
WATERMARKING_CANDIDATES,
ids=lambda candidate: candidate.id,
)
def test_watermarking_candidate_golden(candidate, goldens: dict[str, dict]):
differences = compare_golden(candidate, goldens[candidate.id])
assert not differences, "\n".join(
[
f"{candidate.id}: golden drift "
f"(tolerance {GOLDEN_FLOAT_RTOL:g} on score and p_value)",
*differences,
]
)
def test_negative_row_is_not_watermarked(goldens: dict[str, dict]):
candidate = CANDIDATES_BY_ID[NEGATIVE_CANDIDATE_ID]
assert candidate.detection_key != candidate.key
assert goldens[NEGATIVE_CANDIDATE_ID]["detection"]["is_watermarked"] is False
@pytest.mark.parametrize(
"candidate",
[
candidate
for candidate in WATERMARKING_CANDIDATES
if candidate.scheme == "dual_key_gumbel"
and 0 < candidate.scheme_config.generation_alpha < 1
],
ids=lambda candidate: candidate.id,
)
def test_routing_sequence_resolves_alpha(candidate):
alpha = candidate.scheme_config.generation_alpha
uniforms = candidate.fixture.routing_uniforms
boundary = routing_boundary(alpha)
share = sum(1 for uniform in uniforms if boundary <= uniform) / len(uniforms)
assert abs(share - alpha) <= 0.03
+6 -3
View File
@@ -276,9 +276,9 @@ def test_gpu_sampler_warns_when_watermarking_is_enabled_for_greedy(monkeypatch):
"vllm.v1.watermarking.gpu_sampler.logger.warning_once", messages.append
)
sampler.add_request(0, 1, SamplingParams(temperature=0))
sampler.add_request(0, 1, SamplingParams(temperature=1))
sampler.add_request(0, 1, SamplingParams(temperature=0, watermarking=False))
sampler.add_request(0, SamplingParams(temperature=0))
sampler.add_request(0, SamplingParams(temperature=1))
sampler.add_request(0, SamplingParams(temperature=0, watermarking=False))
assert messages == [
(
@@ -945,6 +945,7 @@ def test_draft_sampler_uses_draft_key_and_advances_context(monkeypatch):
speculator = object.__new__(StubSpeculator)
speculator.model = StubModel()
speculator.use_fp64_gumbel = False
speculator.acceptance_estimator = None
draft_watermarker = object.__new__(DraftWatermarker)
draft_watermarker.watermarker = StubWatermarker()
draft_watermarker.contexts = torch.tensor([[1, 2], [3, 4]])
@@ -978,6 +979,7 @@ def test_dspark_reduced_vocab_draft_sampler_applies_watermarking(monkeypatch):
speculator.seeds = torch.zeros(2, dtype=torch.int64)
speculator._step_cols = torch.tensor([0])
speculator.use_fp64_gumbel = False
speculator.acceptance_estimator = None
watermark_logits: list[torch.Tensor] = []
def sample(logits, sampled, idx_map, temperature):
@@ -1012,6 +1014,7 @@ def test_dspark_target_only_watermarking_leaves_drafts_unwatermarked(monkeypatch
speculator.seeds = torch.zeros(2, dtype=torch.int64)
speculator._step_cols = torch.tensor([0])
speculator.use_fp64_gumbel = False
speculator.acceptance_estimator = None
speculator.draft_watermarker = None
monkeypatch.setattr(
"vllm.v1.worker.gpu.spec_decode.dspark.speculator.gumbel_sample",
File diff suppressed because it is too large Load Diff