main
py 113 lines 3.99 KB
Raw
1 #!/usr/bin/env -S uv run --script
2 # SPDX-License-Identifier: Apache-2.0
3 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
4 # /// script
5 # requires-python = ">=3.11"
6 # dependencies = [
7 # "tiktoken>=0.6.0",
8 # # xgrammar imports torch unconditionally; only the CPU build is needed.
9 # "torch",
10 # "transformers>=5.10.4",
11 # # Keep in sync with requirements/common.txt.
12 # "xgrammar==0.2.7",
13 # ]
14 #
15 # [[tool.uv.index]]
16 # name = "pytorch-cpu"
17 # url = "https://download.pytorch.org/whl/cpu"
18 # explicit = true
19 #
20 # [tool.uv.sources]
21 # torch = { index = "pytorch-cpu" }
22 # ///
23 """Replay the grammar cases exported by the roundtrip tests through XGrammar.
24
25 Each file holds one model's cases, keyed by tool-choice variant. Every case is
26 compiled against the model tokenizer the same way as
27 `vllm/v1/structured_output/backend_xgrammar.py`. Token-zero cases are then
28 replayed from the first generated token: each token must be allowed by the
29 bitmask and accepted by the matcher, and the final stop token must terminate it.
30 """
31
32 import json
33 import sys
34 from functools import cache
35 from pathlib import Path
36
37 import xgrammar as xgr
38 from transformers import AutoTokenizer
39
40 CASES_DIR = Path(__file__).parent / "grammar_replay"
41
42
43 @cache
44 def load_tokenizer(model_id: str, vocab_size: int):
45 tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
46 tokenizer_info = xgr.TokenizerInfo.from_huggingface(
47 tokenizer, vocab_size=vocab_size
48 )
49 return tokenizer, xgr.GrammarCompiler(tokenizer_info)
50
51
52 def replay(model: dict, case: dict) -> str:
53 """Check one case of a model and return a summary of what was checked."""
54 tokenizer, compiler = load_tokenizer(model["model_id"], model["vocab_size"])
55 generation = case["generation_token_ids"]
56
57 # The case token IDs come from the Rust tokenizer; the runner must load the
58 # same vocabulary for the grammar to mean the same thing.
59 encoded = tokenizer.encode(case["completion"], add_special_tokens=False)
60 if encoded != generation[:-1]:
61 raise AssertionError(
62 "tokenizer mismatch: the completion encodes to "
63 f"{encoded}, but the case records {generation[:-1]}"
64 )
65
66 grammar = case["grammar"]
67 structural_tag = {"type": "structural_tag", "format": grammar["format"]}
68 compiled = compiler.compile_structural_tag(json.dumps(structural_tag))
69 if grammar["coverage"] != "from_token_zero":
70 return f"compiled only ({grammar['coverage']} is gated by the engine)"
71
72 matcher = xgr.GrammarMatcher(
73 compiled, override_stop_tokens=model["all_stop_token_ids"]
74 )
75 bitmask = xgr.allocate_token_bitmask(1, model["vocab_size"])
76 for index, token_id in enumerate(generation):
77 matcher.fill_next_token_bitmask(bitmask)
78 allowed = (bitmask[0, token_id // 32].item() >> (token_id % 32)) & 1
79 if not allowed or not matcher.accept_token(token_id):
80 prefix = tokenizer.decode(generation[:index])
81 raise AssertionError(
82 f"token {index} ({token_id}, {tokenizer.decode([token_id])!r}) "
83 f"rejected after {prefix!r}"
84 )
85 if not matcher.is_terminated():
86 raise AssertionError("matcher not terminated after the stop token")
87 return f"replayed {len(generation)} tokens from token zero"
88
89
90 def main() -> int:
91 paths = sorted(CASES_DIR.glob("*.json"))
92 if not paths:
93 print(f"no grammar replay cases in {CASES_DIR}", file=sys.stderr)
94 return 1
95
96 failures = 0
97 for path in paths:
98 model = json.loads(path.read_text())
99 for name, case in model["cases"].items():
100 try:
101 summary = replay(model, case)
102 except Exception as error:
103 failures += 1
104 coverage = case["grammar"]["coverage"]
105 print(f"FAIL {path.name} {name} [{coverage}]: {error}", file=sys.stderr)
106 else:
107 print(f"PASS {path.name} {name}: {summary}")
108
109 return 1 if failures else 0
110
111
112 if __name__ == "__main__":
113 sys.exit(main())