metrale_grammar/compiler/decompose.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2//
3// WGRAMMAR static/dynamic decomposition (Tier 3c).
4//
5// Implements the compile-time half of the "WGRAMMAR" technique
6// (arXiv:2507.16768): a tool-call JSON-schema grammar is mostly FIXED
7// SCAFFOLDING — the literal property keys, the structural punctuation
8// (`{`, `"`, `:`, `,`, `}`), the skeleton — interleaved with a few
9// CONTEXT-DEPENDENT value slots (the string / number / enum values the
10// model fills in). The scaffolding is byte-for-byte identical for every
11// request against a given schema, so the work of resolving it can be
12// done ONCE, at compile time, instead of per-token at decode.
13//
14// INTRA-RULE DECOMPOSITION — WHY WHOLE-RULE IS NOT ENOUGH
15// -------------------------------------------------------
16// After grammar optimization a JSON-schema `root` rule is a SINGLE
17// `Sequence` that interleaves both kinds of element, e.g.
18//
19// root ::= "{" [ \n\t]* "\"x\"" [ \n\t]* ":" [ \n\t]* basic_integer …
20//
21// The scaffolding (`"{"`, `"\"x\""`, `":"`, …) and the value slots
22// (the `[ \n\t]*` whitespace classes, the `basic_integer` rule ref)
23// are NOT separate rules — they are siblings inside one body. So a
24// whole-rule static/dynamic verdict would mark this rule "dynamic" and
25// miss ~99% of it. WGRAMMAR's real granularity is the SEGMENT: a run
26// of consecutive static `ByteString` elements inside a rule body is a
27// static segment with a precomputed literal; everything between is a
28// dynamic segment. This module decomposes each rule body that way.
29//
30// WHAT THIS ADDS OVER TIERS 2 / 3b — BE PRECISE
31// ---------------------------------------------
32// Tier 3b (`coalesce.rs` + `matcher/coalesce.rs`) already detects, at
33// DECODE time, that a position is forced (`analyze_bitmask`) and walks
34// the forced chain. Tier 2 (the JIT `mask_cache` + cross-grammar
35// `RuleLevelCache`) already memoizes a per-state `AdaptiveTokenMask`
36// the first time the matcher touches that state. So masks and chains
37// are NOT recomputed per request once warm.
38//
39// What is still done LAZILY, and only when the matcher happens to reach
40// a state, is the *discovery* of which spans are pure static
41// scaffolding. Tier 3b answers "is THIS state forced?" one state at a
42// time, on the hot path. WGRAMMAR's genuine delta is to answer, at
43// COMPILE time and for the whole grammar at once, "which spans of which
44// rule bodies are nothing but a fixed literal?" — and to precompute
45// their literal byte sequence then. This module is that pass:
46// `decompose_static_regions`.
47//
48// It is deliberately NOT a second masking mechanism. It computes byte
49// sequences, not token masks; the matcher's actual forced-token
50// decisions still flow through Tier 3b's `analyze_bitmask` over the
51// authoritative `AdaptiveTokenMask`. The precomputed sequence is a
52// compile-time INDEX of the scaffolding — it lets a scheduler know,
53// before the first decode step, exactly which byte spans of the output
54// are fixed, rather than rediscovering them token by token.
55//
56// CORRECTNESS
57// -----------
58// A segment is `Static` ONLY when it is a run of `ByteString` /
59// `EmptyStr` elements — each admits exactly one byte string, so the
60// precomputed bytes are read straight off the `ByteString` payloads,
61// the same bytes the FSM scan would consume. Any element that is a
62// `CharacterClass`, `CharacterClassStar`, `Choices`, `RuleRef`,
63// `Repeat` or `TagDispatch` opens a `Dynamic` segment. The pass is
64// pure and side-effect-free; it reads the already-optimized grammar
65// and never mutates it.
66
67use crate::grammar::{GrammarData, GrammarExprType};
68
69/// One decomposed span of a rule body under WGRAMMAR.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum Segment {
72 /// A run of consecutive fixed-literal elements — pure scaffolding.
73 /// The bytes are precomputed: this span emits exactly these bytes,
74 /// in this order, every request. It is a forced-token chain known
75 /// at compile time (Tier 3b would rediscover it lazily at decode).
76 Static {
77 /// The literal bytes this span emits, start to finish.
78 bytes: Vec<u8>,
79 },
80 /// A genuine value slot — a character class, a number/string
81 /// sub-grammar, a repeat, or a tag dispatch. Its continuation
82 /// depends on the model's output and is not precomputed.
83 Dynamic,
84 /// A choice point: the model picks one branch. The branches are
85 /// each decomposed in turn — a branch is itself mostly scaffolding
86 /// (the non-empty-object branch of a JSON object opens with the
87 /// fixed `{"key":` literal). The choice ITSELF is dynamic, but the
88 /// per-branch scaffolding is still precomputed inside each
89 /// `branches[i]`. This is WGRAMMAR's recursive decomposition: the
90 /// static structure is exposed even when it sits behind a choice.
91 Choice {
92 /// One decomposition per branch, in grammar order.
93 branches: Vec<RuleDecomposition>,
94 },
95}
96
97impl Segment {
98 /// True for a fixed-literal (scaffolding) segment.
99 #[must_use]
100 pub fn is_static(&self) -> bool {
101 matches!(self, Segment::Static { .. })
102 }
103
104 /// The precomputed literal bytes, if this segment is a flat static
105 /// run. A [`Segment::Choice`] returns `None` even though its
106 /// branches may carry static spans — descend into `branches` for
107 /// those.
108 #[must_use]
109 pub fn static_bytes(&self) -> Option<&[u8]> {
110 match self {
111 Segment::Static { bytes } => Some(bytes),
112 Segment::Dynamic | Segment::Choice { .. } => None,
113 }
114 }
115}
116
117/// The decomposition of one rule body into alternating static / dynamic
118/// segments, in body order.
119#[derive(Debug, Clone, PartialEq, Eq, Default)]
120pub struct RuleDecomposition {
121 segments: Vec<Segment>,
122}
123
124impl RuleDecomposition {
125 /// The body's segments, in order. Adjacent segments always differ
126 /// in kind (consecutive static elements are coalesced into one
127 /// `Static` segment).
128 #[must_use]
129 pub fn segments(&self) -> &[Segment] {
130 &self.segments
131 }
132
133 /// True when the whole rule body is a single fixed literal — the
134 /// rule is pure scaffolding with no value slot or choice.
135 #[must_use]
136 pub fn is_fully_static(&self) -> bool {
137 matches!(self.segments.as_slice(), [Segment::Static { .. }]) || self.segments.is_empty()
138 }
139
140 /// True when the rule body contains at least one static segment,
141 /// recursing into [`Segment::Choice`] branches.
142 #[must_use]
143 pub fn has_static(&self) -> bool {
144 self.segments.iter().any(|s| match s {
145 Segment::Static { .. } => true,
146 Segment::Dynamic => false,
147 Segment::Choice { branches } => branches.iter().any(RuleDecomposition::has_static),
148 })
149 }
150
151 /// Total precomputed static scaffolding bytes in this rule body,
152 /// recursing into [`Segment::Choice`] branches.
153 #[must_use]
154 pub fn static_bytes_len(&self) -> usize {
155 self.segments
156 .iter()
157 .map(|s| match s {
158 Segment::Static { bytes } => bytes.len(),
159 Segment::Dynamic => 0,
160 Segment::Choice { branches } => branches
161 .iter()
162 .map(RuleDecomposition::static_bytes_len)
163 .sum(),
164 })
165 .sum()
166 }
167
168 /// Count of static / dynamic segments in this body, recursing into
169 /// [`Segment::Choice`] branches. A choice contributes its branches'
170 /// segments (the choice node itself is not counted as either).
171 fn segment_counts(&self) -> (usize, usize) {
172 let mut stat = 0;
173 let mut dyn_ = 0;
174 for s in &self.segments {
175 match s {
176 Segment::Static { .. } => stat += 1,
177 Segment::Dynamic => dyn_ += 1,
178 Segment::Choice { branches } => {
179 for b in branches {
180 let (bs, bd) = b.segment_counts();
181 stat += bs;
182 dyn_ += bd;
183 }
184 }
185 }
186 }
187 (stat, dyn_)
188 }
189}
190
191/// The result of the WGRAMMAR static/dynamic decomposition pass over a
192/// whole grammar — one [`RuleDecomposition`] per rule, indexed by
193/// `rule_id`.
194///
195/// This is the compile-time "index of the scaffolding": it records,
196/// before any decode step, which byte spans of which rule bodies are
197/// fixed literals and what those literals are. It composes with Tier 3b
198/// — every `Static` segment is exactly a forced-token chain — and with
199/// Tier 2 — the JIT cache still owns the actual `AdaptiveTokenMask`s.
200/// It adds no parallel masking path; it is a classification + byte
201/// precompute.
202#[derive(Debug, Clone, PartialEq, Eq)]
203pub struct GrammarDecomposition {
204 rules: Vec<RuleDecomposition>,
205}
206
207impl GrammarDecomposition {
208 /// The decomposition of rule `rule_id`'s body.
209 #[must_use]
210 pub fn rule_region(&self, rule_id: i32) -> &RuleDecomposition {
211 &self.rules[rule_id as usize]
212 }
213
214 /// Every rule's decomposition, in `rule_id` order.
215 #[must_use]
216 pub fn regions(&self) -> &[RuleDecomposition] {
217 &self.rules
218 }
219
220 /// Number of rules whose body is entirely fixed scaffolding.
221 #[must_use]
222 pub fn fully_static_rule_count(&self) -> usize {
223 self.rules.iter().filter(|r| r.is_fully_static()).count()
224 }
225
226 /// Number of static segments across every rule body — the count of
227 /// distinct scaffolding spans WGRAMMAR precomputes. Recurses into
228 /// [`Segment::Choice`] branches.
229 #[must_use]
230 pub fn static_segment_count(&self) -> usize {
231 self.rules.iter().map(|r| r.segment_counts().0).sum()
232 }
233
234 /// Number of dynamic segments across every rule body — the count of
235 /// genuine value slots. Recurses into [`Segment::Choice`] branches.
236 #[must_use]
237 pub fn dynamic_segment_count(&self) -> usize {
238 self.rules.iter().map(|r| r.segment_counts().1).sum()
239 }
240
241 /// Total precomputed static scaffolding bytes across the grammar —
242 /// the byte count the matcher never has to discover at decode.
243 #[must_use]
244 pub fn total_static_bytes(&self) -> usize {
245 self.rules
246 .iter()
247 .map(RuleDecomposition::static_bytes_len)
248 .sum()
249 }
250
251 /// The fraction of segments that are static scaffolding, in
252 /// `[0.0, 1.0]`. WGRAMMAR's premise is that the *bytes* are
253 /// overwhelmingly scaffolding; the segment count is a coarser proxy.
254 /// Returns `0.0` for a grammar with no segments.
255 #[must_use]
256 pub fn static_segment_fraction(&self) -> f64 {
257 let total = self.static_segment_count() + self.dynamic_segment_count();
258 if total == 0 {
259 return 0.0;
260 }
261 self.static_segment_count() as f64 / total as f64
262 }
263}
264
265/// Run the WGRAMMAR static/dynamic decomposition over an optimized
266/// grammar.
267///
268/// For every rule, decompose its body into alternating
269/// [`Segment::Static`] spans (fixed literals — the scaffolding, with
270/// their bytes precomputed here, once) and [`Segment::Dynamic`] spans
271/// (value slots). The grammar is only read; it is not mutated. The
272/// result is the compile-time decomposition index.
273///
274/// This pass runs once per `compile_*` call, after grammar
275/// optimization. It is cheap — a single linear walk of the AST — and
276/// its output is stored on the [`super::CompiledGrammar`] so a
277/// scheduler / matcher can consult the static/dynamic split without
278/// any per-token work.
279#[must_use]
280pub fn decompose_static_regions(grammar: &GrammarData) -> GrammarDecomposition {
281 let num_rules = grammar.num_rules();
282 let mut rules = Vec::with_capacity(num_rules as usize);
283 for rule_id in 0..num_rules {
284 let body_id = grammar.rule(rule_id).body_expr_id;
285 rules.push(decompose_rule_body(grammar, body_id));
286 }
287 GrammarDecomposition { rules }
288}
289
290/// Decompose one rule body into segments.
291///
292/// The body is one of: a `ByteString` / `EmptyStr` (one static
293/// segment), a `Sequence` (decomposed element-by-element), a `Choices`
294/// with a single branch (decompose that branch — degenerate but
295/// possible post-optimization), or anything else (one dynamic segment).
296fn decompose_rule_body(grammar: &GrammarData, body_id: i32) -> RuleDecomposition {
297 let mut builder = SegmentBuilder::default();
298 builder.push_expr(grammar, body_id, 0);
299 RuleDecomposition {
300 segments: builder.finish(),
301 }
302}
303
304/// Maximum expression-tree depth the decomposition will descend. A
305/// well-formed optimized grammar nests only a handful deep; this bound
306/// is a defensive guard against a pathological / cyclic `Sequence`
307/// nesting and never trips for real schemas. A span that would exceed
308/// it is conservatively classified `Dynamic`.
309const MAX_DECOMPOSE_DEPTH: u32 = 64;
310
311/// Accumulates segments while walking a rule body, coalescing a run of
312/// consecutive static elements into one [`Segment::Static`].
313#[derive(Default)]
314struct SegmentBuilder {
315 segments: Vec<Segment>,
316 /// Bytes of the static run currently being accumulated, if any.
317 pending_static: Option<Vec<u8>>,
318}
319
320impl SegmentBuilder {
321 /// Append a literal byte run, extending the pending static segment.
322 fn push_static(&mut self, bytes: &[u8]) {
323 self.pending_static
324 .get_or_insert_with(Vec::new)
325 .extend_from_slice(bytes);
326 }
327
328 /// Close any pending static run, then append one dynamic segment.
329 fn push_dynamic(&mut self) {
330 self.flush_static();
331 self.segments.push(Segment::Dynamic);
332 }
333
334 /// Emit the pending static run as a `Static` segment, if non-empty.
335 fn flush_static(&mut self) {
336 if let Some(bytes) = self.pending_static.take() {
337 self.segments.push(Segment::Static { bytes });
338 }
339 }
340
341 /// Walk one expression, classifying it (and, for a `Sequence`, each
342 /// of its children) into the running segment list.
343 fn push_expr(&mut self, grammar: &GrammarData, expr_id: i32, depth: u32) {
344 if depth >= MAX_DECOMPOSE_DEPTH {
345 self.push_dynamic();
346 return;
347 }
348 let expr = grammar.expr(expr_id);
349 match expr.kind {
350 // `EmptyStr` contributes nothing — it leaves the pending
351 // static run open so neighbours still coalesce.
352 GrammarExprType::EmptyStr => {}
353 GrammarExprType::ByteString => {
354 let bytes: Vec<u8> = expr.data.iter().map(|&b| b as u8).collect();
355 self.push_static(&bytes);
356 }
357 GrammarExprType::Sequence => {
358 for &child in expr.data {
359 self.push_expr(grammar, child, depth + 1);
360 }
361 }
362 // A `Choices` is a divergence point. A single branch is
363 // degenerate — descend into it. With several branches the
364 // choice itself is dynamic, but each branch is decomposed
365 // recursively so its own internal scaffolding (the fixed
366 // `{"key":` prefix of a JSON object's non-empty branch) is
367 // still exposed as static spans inside the branch. This is
368 // WGRAMMAR's recursive decomposition.
369 GrammarExprType::Choices => {
370 if expr.data.len() == 1 {
371 self.push_expr(grammar, expr.data[0], depth + 1);
372 } else {
373 self.flush_static();
374 let branches = expr
375 .data
376 .iter()
377 .map(|&branch| {
378 let mut sub = SegmentBuilder::default();
379 sub.push_expr(grammar, branch, depth + 1);
380 RuleDecomposition {
381 segments: sub.finish(),
382 }
383 })
384 .collect();
385 self.segments.push(Segment::Choice { branches });
386 }
387 }
388 // Value slots: a character class, a sub-grammar reference,
389 // a repeat, or a tag dispatch — each one dynamic segment.
390 GrammarExprType::CharacterClass
391 | GrammarExprType::CharacterClassStar
392 | GrammarExprType::RuleRef
393 | GrammarExprType::Repeat
394 | GrammarExprType::TagDispatch => self.push_dynamic(),
395 }
396 }
397
398 /// Finalize: close any trailing static run and return the segments.
399 fn finish(mut self) -> Vec<Segment> {
400 self.flush_static();
401 self.segments
402 }
403}
404
405#[cfg(test)]
406#[path = "decompose_unit_tests.rs"]
407mod tests;