metrale_grammar/compiler/compiler.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2//
3// GrammarCompiler — port of `class GrammarCompiler` / `GrammarCompiler
4// ::Impl` from `cpp/grammar_compiler.cc`.
5//
6// A tokenizer-bound cache that produces `CompiledGrammar`s, avoiding
7// redundant preprocessing of grammars / schemas seen before.
8
9use crate::grammar::functor::{GrammarNormalizer, GrammarOptimizer};
10use crate::grammar::{GrammarData, parse_ebnf};
11use crate::schema::{SchemaConverterOptions, builtin_json_grammar_ebnf, json_schema_to_ebnf};
12use crate::structural_tag::structural_tag_to_grammar;
13use crate::tokenizer::TokenizerInfo;
14
15use super::compile::compile_optimized_grammar;
16use super::compiled_grammar::CompiledGrammar;
17use super::grammar_cache::{GrammarCache, UNLIMITED_BYTES};
18use super::mask_snapshot::{self, SnapshotError, SnapshotIdentity};
19use super::rule_cache::{RuleLevelCache, UNLIMITED_SIZE};
20
21/// An error produced while compiling a grammar, schema or tag.
22#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
23pub enum CompileError {
24 /// The EBNF source failed to parse.
25 #[error("failed to parse grammar: {0}")]
26 Grammar(String),
27 /// The JSON schema failed to convert or parse.
28 #[error("failed to compile JSON schema: {0}")]
29 Schema(String),
30 /// A structural tag failed to compile.
31 #[error("failed to compile structural tag: {0}")]
32 StructuralTag(String),
33}
34
35/// The cache key — exactly the request parameters that determine the
36/// compiled output. Port of the C++ `GrammarCompilerCacheKeys` union.
37#[derive(Debug, Clone, PartialEq, Eq, Hash)]
38enum CacheKey {
39 /// `compile_builtin_json_grammar`.
40 BuiltinJson,
41 /// `compile_grammar_from_ebnf(ebnf, root_rule)`.
42 Ebnf { ebnf: String, root_rule: String },
43 /// `compile_json_schema(schema, options...)`.
44 Schema {
45 schema: String,
46 any_whitespace: bool,
47 indent: Option<i32>,
48 separators: Option<(String, String)>,
49 strict_mode: bool,
50 max_whitespace_cnt: Option<i32>,
51 },
52 /// `compile_structural_tag(json)`.
53 StructuralTag(String),
54}
55
56/// A tokenizer-bound grammar compiler with an internal cache.
57///
58/// Each compiler instance is tied to one [`TokenizerInfo`]; use a
59/// separate compiler per vocabulary. Compilation results are cached
60/// (when `cache_enabled`) keyed by the request parameters.
61pub struct GrammarCompiler {
62 tokenizer_info: TokenizerInfo,
63 max_threads: usize,
64 cache_enabled: bool,
65 cache_limit_bytes: i64,
66 /// Tier-1 compiled-grammar cache, LRU-bounded by `cache_limit_bytes`.
67 ///
68 /// Was a bare `DashMap` with no eviction: `CacheKey::Schema` is keyed by
69 /// the full tool-schema text, so agentic traffic minted a permanent entry
70 /// per request (~100 MB/min under BFCL; issue #368).
71 cache: GrammarCache<CacheKey>,
72 /// Cross-grammar adaptive-token-mask cache (Tier 2, upstream commit
73 /// `bfb2a79`). `Some` only when `cache_enabled` — a rule
74 /// structurally identical to one compiled for a previous request
75 /// reuses its masks. Shared (cloned `Arc` inside) into every
76 /// `CompiledGrammar` this compiler produces. Port of the C++
77 /// `GrammarCompiler::Impl::rule_level_cache_`.
78 rule_cache: Option<RuleLevelCache>,
79}
80
81impl GrammarCompiler {
82 /// Construct a compiler bound to `tokenizer_info`.
83 ///
84 /// * `max_threads` — bounds explicit top-k mask prewarming. Lazy
85 /// matcher lookups still compute only the requested mask. Must be >= 1.
86 /// * `cache_enabled` — whether to cache compiled grammars.
87 /// * `cache_limit_bytes` — memory budget, ENFORCED by LRU eviction on
88 /// both tiers. `-1` means unlimited, which is now an explicit opt-in
89 /// rather than the serve's default: unbounded was the shape of the leak
90 /// in issue #368. The budget splits as upstream does — ~1/3 to the
91 /// rule-level cache, the remaining ~2/3 to this grammar-level one.
92 ///
93 /// Port of the C++ `GrammarCompiler` constructor.
94 pub fn new(
95 tokenizer_info: TokenizerInfo,
96 max_threads: usize,
97 cache_enabled: bool,
98 cache_limit_bytes: i64,
99 ) -> Self {
100 assert!(max_threads >= 1, "max_threads must be >= 1");
101 assert!(
102 cache_limit_bytes >= -1,
103 "cache_limit_bytes must be -1 (unlimited) or non-negative"
104 );
105 // The cross-grammar `RuleLevelCache` exists only when caching is
106 // enabled. Its memory budget mirrors the C++ split: with an
107 // unlimited (`-1`) overall limit it is unbounded; otherwise it
108 // gets `limit - limit/3*2` bytes (~1/3 of the budget — the other
109 // ~2/3 goes to the grammar-level cache).
110 let rule_cache = if cache_enabled {
111 let budget = if cache_limit_bytes == -1 {
112 UNLIMITED_SIZE
113 } else {
114 (cache_limit_bytes - cache_limit_bytes / 3 * 2) as usize
115 };
116 Some(RuleLevelCache::new(budget))
117 } else {
118 None
119 };
120 Self {
121 tokenizer_info,
122 max_threads,
123 cache_enabled,
124 cache_limit_bytes,
125 cache: GrammarCache::new(if cache_limit_bytes == -1 {
126 UNLIMITED_BYTES
127 } else {
128 // The ~2/3 complement of the rule cache's `limit - limit/3*2`.
129 (cache_limit_bytes / 3 * 2) as usize
130 }),
131 rule_cache,
132 }
133 }
134
135 /// Run the full W3 pipeline (normalize then optimize) on a parsed
136 /// grammar, then compile it against this compiler's tokenizer.
137 fn compile_normalized(&self, grammar: GrammarData) -> CompiledGrammar {
138 let normalized = GrammarNormalizer::apply(grammar);
139 let optimized = GrammarOptimizer::apply(normalized);
140 compile_optimized_grammar(
141 optimized,
142 &self.tokenizer_info,
143 self.max_threads,
144 self.rule_cache.clone(),
145 )
146 }
147
148 /// Fetch from cache or compute via `f`, honoring `cache_enabled`.
149 fn get_or_compute<F>(&self, key: CacheKey, f: F) -> CompiledGrammar
150 where
151 F: FnOnce() -> CompiledGrammar,
152 {
153 if !self.cache_enabled {
154 return f();
155 }
156 if let Some(hit) = self.cache.get(&key) {
157 return hit;
158 }
159 let value = f();
160 self.cache.insert(key, value.clone());
161 value
162 }
163
164 /// Compile a grammar from an EBNF string. Port of
165 /// `GrammarCompiler::CompileGrammar(ebnf, root_rule_name)`.
166 pub fn compile_grammar_from_ebnf(
167 &self,
168 ebnf: &str,
169 root_rule_name: &str,
170 ) -> Result<CompiledGrammar, CompileError> {
171 // Parse eagerly so a malformed grammar is a typed error rather
172 // than a cached panic.
173 let grammar =
174 parse_ebnf(ebnf, root_rule_name).map_err(|e| CompileError::Grammar(e.to_string()))?;
175 let key = CacheKey::Ebnf {
176 ebnf: ebnf.to_string(),
177 root_rule: root_rule_name.to_string(),
178 };
179 Ok(self.get_or_compute(key, || self.compile_normalized(grammar)))
180 }
181
182 /// Compile an already-parsed [`GrammarData`]. Port of
183 /// `GrammarCompiler::CompileGrammar(const Grammar&)` — keyed by the
184 /// grammar's printed EBNF, as the C++ does.
185 pub fn compile_grammar(&self, grammar: GrammarData) -> CompiledGrammar {
186 let ebnf = crate::grammar::print_grammar(&grammar);
187 let root_rule = grammar.root_rule().name.clone();
188 let key = CacheKey::Ebnf { ebnf, root_rule };
189 self.get_or_compute(key, || self.compile_normalized(grammar))
190 }
191
192 /// Compile the builtin "any JSON value" grammar. Port of
193 /// `GrammarCompiler::CompileBuiltinJSONGrammar`.
194 pub fn compile_builtin_json_grammar(&self) -> Result<CompiledGrammar, CompileError> {
195 let ebnf = builtin_json_grammar_ebnf();
196 let grammar = parse_ebnf(&ebnf, "root").map_err(|e| CompileError::Schema(e.to_string()))?;
197 Ok(self.get_or_compute(CacheKey::BuiltinJson, || self.compile_normalized(grammar)))
198 }
199
200 /// Compile a JSON schema. Port of
201 /// `GrammarCompiler::CompileJSONSchema`.
202 #[allow(clippy::too_many_arguments)]
203 pub fn compile_json_schema(
204 &self,
205 schema: &str,
206 any_whitespace: bool,
207 indent: Option<i32>,
208 separators: Option<(String, String)>,
209 strict_mode: bool,
210 max_whitespace_cnt: Option<i32>,
211 ) -> Result<CompiledGrammar, CompileError> {
212 let options = SchemaConverterOptions {
213 any_whitespace,
214 indent,
215 separators: separators.clone(),
216 strict_mode,
217 max_whitespace_cnt,
218 ..SchemaConverterOptions::default()
219 };
220 let ebnf = json_schema_to_ebnf(schema, &options)
221 .map_err(|e| CompileError::Schema(e.to_string()))?;
222 let grammar = parse_ebnf(&ebnf, "root")
223 .map_err(|e| CompileError::Schema(format!("generated EBNF failed to parse: {e}")))?;
224 let key = CacheKey::Schema {
225 schema: schema.to_string(),
226 any_whitespace,
227 indent,
228 separators,
229 strict_mode,
230 max_whitespace_cnt,
231 };
232 Ok(self.get_or_compute(key, || self.compile_normalized(grammar)))
233 }
234
235 /// Compile a structural tag. Port of
236 /// `GrammarCompiler::CompileStructuralTag`.
237 ///
238 /// Delegates the structural-tag-JSON -> `GrammarData` conversion to
239 /// the W5 `structural_tag` module (`structural_tag_to_grammar`).
240 pub fn compile_structural_tag(
241 &self,
242 structural_tag_json: &str,
243 ) -> Result<CompiledGrammar, CompileError> {
244 let grammar = structural_tag_to_grammar(structural_tag_json)
245 .map_err(|e| CompileError::StructuralTag(e.to_string()))?;
246 let key = CacheKey::StructuralTag(structural_tag_json.to_string());
247 Ok(self.get_or_compute(key, || self.compile_normalized(grammar)))
248 }
249
250 /// Clear the internal compiled-grammar cache *and* the cross-grammar
251 /// rule-level mask cache. Port of `GrammarCompiler::ClearCache`.
252 pub fn clear_cache(&self) {
253 self.cache.clear();
254 if let Some(rule_cache) = &self.rule_cache {
255 rule_cache.clear();
256 }
257 }
258
259 /// Approximate bytes held by the cache — the grammar-level compiled
260 /// grammars plus the cross-grammar rule-level mask cache. Port of
261 /// `GrammarCompiler::GetCacheSizeBytes`.
262 pub fn cache_size_bytes(&self) -> i64 {
263 let grammar_bytes = self.cache.resident_bytes() as i64;
264 let rule_bytes = self
265 .rule_cache
266 .as_ref()
267 .map_or(0, |c| c.memory_size() as i64);
268 grammar_bytes + rule_bytes
269 }
270
271 /// The configured cache memory limit; `-1` means unlimited. Port of
272 /// `GrammarCompiler::CacheLimitBytes`.
273 pub fn cache_limit_bytes(&self) -> i64 {
274 self.cache_limit_bytes
275 }
276
277 /// The tokenizer this compiler is bound to.
278 pub fn tokenizer_info(&self) -> &TokenizerInfo {
279 &self.tokenizer_info
280 }
281
282 /// The identity an on-disk mask snapshot must match to be usable
283 /// with this compiler (#918).
284 pub fn snapshot_identity(&self) -> SnapshotIdentity {
285 SnapshotIdentity {
286 tokenizer_fingerprint: self.tokenizer_info.fingerprint(),
287 vocab_size: self.tokenizer_info.vocab_size(),
288 }
289 }
290
291 /// Seed the cross-grammar [`RuleLevelCache`] from a snapshot written
292 /// by an earlier process (#918).
293 ///
294 /// Returns the number of masks imported. `0` means a miss — no file,
295 /// a snapshot from a different tokenizer or build, or a corrupt one —
296 /// and the compiler simply computes as before. A compiler constructed
297 /// with `cache_enabled == false` has no rule cache and always
298 /// returns `0`.
299 pub fn load_mask_snapshot(&self, path: &std::path::Path) -> Result<usize, SnapshotError> {
300 let Some(cache) = &self.rule_cache else {
301 return Ok(0);
302 };
303 mask_snapshot::load_from_file(cache, self.snapshot_identity(), path)
304 }
305
306 /// Persist the cross-grammar [`RuleLevelCache`] so the NEXT process
307 /// starts warm (#918). Returns the number of masks written; at most
308 /// `max_entries`, most-recently-used first.
309 pub fn save_mask_snapshot(
310 &self,
311 path: &std::path::Path,
312 max_entries: usize,
313 ) -> Result<usize, SnapshotError> {
314 let Some(cache) = &self.rule_cache else {
315 return Ok(0);
316 };
317 mask_snapshot::save_to_file(cache, self.snapshot_identity(), path, max_entries)
318 }
319
320 /// The cross-grammar rule-level cache itself, when caching is on.
321 /// Exposed for the #918 snapshot tests and for callers that want to
322 /// share one cache across compilers.
323 pub fn rule_cache(&self) -> Option<&RuleLevelCache> {
324 self.rule_cache.as_ref()
325 }
326
327 /// Entries currently held by the cross-grammar rule-level cache —
328 /// `0` when caching is disabled. Used by the #918 tests and by the
329 /// serve path's warm-up logging.
330 pub fn rule_cache_len(&self) -> usize {
331 self.rule_cache.as_ref().map_or(0, |c| c.len())
332 }
333}