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;