Skip to main content

ferrum_sampler/
structured_output.rs

1//! Tokenizer-aware hard constraints for structured output.
2//!
3//! A factory owns the tokenizer trie and grammar compiler and is shared by an
4//! engine. Each request gets an independent matcher with no shared mutable
5//! grammar state.
6
7use std::{
8    collections::{HashMap, HashSet},
9    str,
10    sync::Arc,
11};
12
13use ferrum_interfaces::tokenizer::Tokenizer;
14use ferrum_types::{FerrumError, ResponseFormat, Result, StructuredOutputStart, TokenId};
15use llguidance::{
16    api::TopLevelGrammar,
17    toktrie::{InferenceCapabilities, TokEnv, TokRxInfo, TokTrie, TokenizerEnv},
18    JsonCompileOptions, Matcher, ParserFactory,
19};
20use parking_lot::Mutex;
21use serde_json::json;
22
23mod harmony;
24#[cfg(test)]
25mod reasoning_envelope_tests;
26
27const MAX_CACHED_GRAMMARS: usize = 64;
28// Structured output is the requested product result; hidden reasoning may use
29// at most the other half of a normal-sized completion budget.
30const AUTO_STRUCTURED_RESERVE_DIVISOR: usize = 2;
31const MIN_AUTO_STRUCTURED_RESERVE_TOKENS: usize = 32;
32const MAX_AUTO_STRUCTURED_RESERVE_TOKENS: usize = 1024;
33const MAX_IDENTICAL_TOKEN_RUN: usize = MAX_AUTO_STRUCTURED_RESERVE_TOKENS / 2;
34
35/// Immutable per-request output budget used when a structured grammar starts
36/// after a reasoning delimiter.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct StructuredOutputBudgetPlan {
39    pub total_output_tokens: usize,
40    pub reasoning_token_limit: usize,
41    pub boundary_token_count: usize,
42    pub structured_reserve_tokens: usize,
43}
44
45impl StructuredOutputBudgetPlan {
46    fn automatic(total_output_tokens: usize, boundary_token_count: usize) -> Result<Self> {
47        if boundary_token_count == 0 || total_output_tokens <= boundary_token_count {
48            return Err(FerrumError::invalid_request(format!(
49                "structured output requires max_tokens greater than its {boundary_token_count}-token delimiter"
50            )));
51        }
52        let available_after_boundary = total_output_tokens - boundary_token_count;
53        let proportional_reserve = total_output_tokens.div_ceil(AUTO_STRUCTURED_RESERVE_DIVISOR);
54        let structured_reserve_tokens = proportional_reserve
55            .clamp(
56                MIN_AUTO_STRUCTURED_RESERVE_TOKENS,
57                MAX_AUTO_STRUCTURED_RESERVE_TOKENS,
58            )
59            .min(available_after_boundary);
60        Ok(Self {
61            total_output_tokens,
62            reasoning_token_limit: total_output_tokens
63                - boundary_token_count
64                - structured_reserve_tokens,
65            boundary_token_count,
66            structured_reserve_tokens,
67        })
68    }
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72struct StructuredOutputLivenessPolicy {
73    max_identical_token_run: usize,
74}
75
76impl StructuredOutputLivenessPolicy {
77    fn for_request(max_output_tokens: usize, budget: Option<StructuredOutputBudgetPlan>) -> Self {
78        let guaranteed_structured_tokens = budget
79            .map(|plan| plan.structured_reserve_tokens)
80            .unwrap_or(max_output_tokens);
81        Self {
82            max_identical_token_run: guaranteed_structured_tokens
83                .div_ceil(2)
84                .clamp(1, MAX_IDENTICAL_TOKEN_RUN),
85        }
86    }
87}
88
89/// Shared, immutable tokenizer and grammar compilation state.
90pub struct StructuredOutputFactory {
91    parser_factory: ParserFactory,
92    tokenizer: Arc<dyn Tokenizer + Send + Sync>,
93    vocab_size: usize,
94    defined_token_ids: Arc<[bool]>,
95    json_token_classes: Arc<[StructuredOutputTokenClass]>,
96    grammar_templates: Mutex<HashMap<String, Matcher>>,
97}
98
99impl std::fmt::Debug for StructuredOutputFactory {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        f.debug_struct("StructuredOutputFactory")
102            .field("vocab_size", &self.vocab_size)
103            .finish_non_exhaustive()
104    }
105}
106
107impl StructuredOutputFactory {
108    /// Build the tokenizer trie once for this engine.
109    pub fn new(tokenizer: Arc<dyn Tokenizer + Send + Sync>) -> Result<Self> {
110        Self::new_with_model_vocab_size(tokenizer, None)
111    }
112
113    /// Build against the executor's logits width when it is larger than the
114    /// tokenizer base vocabulary (for example added EOS/control tokens).
115    pub fn new_with_model_vocab_size(
116        tokenizer: Arc<dyn Tokenizer + Send + Sync>,
117        model_vocab_size: Option<usize>,
118    ) -> Result<Self> {
119        let eos = tokenizer.special_tokens().eos_token.ok_or_else(|| {
120            FerrumError::config("structured output requires a tokenizer EOS token")
121        })?;
122        let vocab_size = model_vocab_size
123            .unwrap_or_else(|| tokenizer.vocab_size())
124            .max(tokenizer.vocab_size());
125        if vocab_size == 0 || eos.get() as usize >= vocab_size {
126            return Err(FerrumError::config(format!(
127                "structured output tokenizer has invalid vocab/EOS: vocab_size={vocab_size}, eos={}",
128                eos.get()
129            )));
130        }
131
132        let special_ids = tokenizer_special_ids(tokenizer.as_ref());
133        let mut defined_token_ids = Vec::with_capacity(vocab_size);
134        let mut json_token_classes = Vec::with_capacity(vocab_size);
135        let token_bytes = (0..vocab_size)
136            .map(|idx| {
137                let token = TokenId::new(idx as u32);
138                if special_ids.contains(&token.get()) {
139                    defined_token_ids.push(true);
140                    json_token_classes.push(StructuredOutputTokenClass::Control);
141                    special_token_marker(token)
142                } else if let Some(bytes) = tokenizer
143                    .token_bytes(token)
144                    .filter(|bytes| !bytes.is_empty())
145                {
146                    defined_token_ids.push(true);
147                    json_token_classes.push(classify_json_token_bytes(&bytes));
148                    bytes
149                } else {
150                    // Keep vocabulary holes out of the trie. The explicit
151                    // eligibility mask below is still required because
152                    // llguidance's wildcard slice represents its root as an
153                    // all-token bitset, including IDs with no trie node.
154                    defined_token_ids.push(false);
155                    json_token_classes.push(StructuredOutputTokenClass::Undefined);
156                    Vec::new()
157                }
158            })
159            .collect::<Vec<_>>();
160
161        let mut eos_tokens = vec![eos.get()];
162        eos_tokens.extend(
163            tokenizer
164                .special_tokens()
165                .extra_eos_tokens
166                .iter()
167                .map(|token| token.get())
168                .filter(|token| *token < vocab_size as u32),
169        );
170        eos_tokens.sort_unstable();
171        eos_tokens.dedup();
172        if let Some(position) = eos_tokens.iter().position(|token| *token == eos.get()) {
173            eos_tokens.swap(0, position);
174        }
175
176        let info = TokRxInfo::new(vocab_size as u32, eos.get());
177        let trie = TokTrie::from(&info, &token_bytes).with_eos_tokens(&eos_tokens);
178        let tok_env: TokEnv = Arc::new(FerrumTokenizerEnv {
179            tokenizer: Arc::clone(&tokenizer),
180            trie,
181        });
182        let mut parser_factory = ParserFactory::new(
183            &tok_env,
184            InferenceCapabilities {
185                ff_tokens: false,
186                conditional_ff_tokens: false,
187                backtrack: false,
188                fork: false,
189            },
190            &llguidance::earley::SlicedBiasComputer::general_slices(),
191        )
192        .map_err(|error| {
193            FerrumError::config(format!("build structured-output parser factory: {error}"))
194        })?;
195        parser_factory.quiet();
196
197        Ok(Self {
198            parser_factory,
199            tokenizer,
200            vocab_size,
201            defined_token_ids: defined_token_ids.into(),
202            json_token_classes: json_token_classes.into(),
203            grammar_templates: Mutex::new(HashMap::new()),
204        })
205    }
206
207    /// Compile one request's grammar while reusing the tokenizer trie.
208    pub fn create_processor(
209        &self,
210        response_format: &ResponseFormat,
211        start: &StructuredOutputStart,
212        max_output_tokens: usize,
213        stop_token_ids: &HashSet<u32>,
214        stop_text_sequences: &[String],
215    ) -> Result<Option<StructuredOutputProcessor>> {
216        let schema = match response_format {
217            ResponseFormat::Text => return Ok(None),
218            ResponseFormat::JsonObject => json!({"type": "object"}),
219            ResponseFormat::JsonSchema(schema) => {
220                serde_json::from_str(schema).map_err(|error| {
221                    FerrumError::invalid_request(format!(
222                        "response_format.schema is not valid JSON: {error}"
223                    ))
224                })?
225            }
226        };
227        let schema = compact_json_schema(schema)?;
228        let grammar_key = serde_json::to_string(&schema).map_err(|error| {
229            FerrumError::invalid_request(format!("serialize structured-output schema: {error}"))
230        })?;
231        let matcher = {
232            let mut templates = self.grammar_templates.lock();
233            if let Some(template) = templates.get(&grammar_key) {
234                template.deep_clone()
235            } else {
236                let grammar = TopLevelGrammar::from_json_schema(schema);
237                let parser = self
238                    .parser_factory
239                    .create_parser(grammar)
240                    .map_err(|error| {
241                        FerrumError::invalid_request(format!(
242                            "unsupported structured-output grammar: {error}"
243                        ))
244                    })?;
245                let matcher = Matcher::new(Ok(parser));
246                if templates.len() >= MAX_CACHED_GRAMMARS {
247                    templates.clear();
248                }
249                templates.insert(grammar_key, matcher.deep_clone());
250                matcher
251            }
252        };
253        let (activation, budget) = match start {
254            StructuredOutputStart::Immediate => (Activation::Active, None),
255            StructuredOutputStart::HarmonyFinal => {
256                let boundary = harmony::HarmonyBoundary::compile(
257                    self.tokenizer.as_ref(),
258                    self.vocab_size,
259                    max_output_tokens,
260                    stop_token_ids,
261                    stop_text_sequences,
262                )?;
263                let budget = boundary.budget();
264                (Activation::Harmony(boundary), Some(budget))
265            }
266            StructuredOutputStart::AfterDelimiter(delimiter) => {
267                let delimiter_tokens =
268                    self.boundary_token_ids(delimiter, stop_token_ids, stop_text_sequences)?;
269                let budget = StructuredOutputBudgetPlan::automatic(
270                    max_output_tokens,
271                    delimiter_tokens.len(),
272                )?;
273                (
274                    Activation::Boundary {
275                        opening_tokens: Vec::new(),
276                        delimiter_tokens,
277                        allow_reasoning: true,
278                        forcing: false,
279                    },
280                    Some(budget),
281                )
282            }
283            StructuredOutputStart::AfterReasoningEnvelope {
284                opening,
285                closing,
286                allow_reasoning,
287            } => {
288                let opening_tokens =
289                    self.boundary_token_ids(opening, stop_token_ids, stop_text_sequences)?;
290                let delimiter_tokens =
291                    self.boundary_token_ids(closing, stop_token_ids, stop_text_sequences)?;
292                if let Some(token) = opening_tokens
293                    .iter()
294                    .chain(&delimiter_tokens)
295                    .find(|token| {
296                        !self
297                            .defined_token_ids
298                            .get(**token as usize)
299                            .copied()
300                            .unwrap_or(false)
301                    })
302                {
303                    return Err(FerrumError::invalid_request(format!(
304                        "structured-output reasoning envelope contains undefined token {token}"
305                    )));
306                }
307                // Empty reasoning is legal in either mode, so a stop can also
308                // cross directly from the opening header into the closing tag.
309                let empty_envelope = format!("{opening}{closing}");
310                if let Some(stop) = stop_text_sequences
311                    .iter()
312                    .find(|stop| !stop.is_empty() && empty_envelope.contains(stop.as_str()))
313                {
314                    return Err(FerrumError::invalid_request(format!(
315                        "structured-output reasoning envelope conflicts with stop sequence {stop:?}"
316                    )));
317                }
318                let mut budget = StructuredOutputBudgetPlan::automatic(
319                    max_output_tokens,
320                    opening_tokens.len() + delimiter_tokens.len(),
321                )?;
322                if !allow_reasoning {
323                    budget.reasoning_token_limit = 0;
324                    budget.structured_reserve_tokens =
325                        max_output_tokens - budget.boundary_token_count;
326                }
327                (
328                    Activation::Boundary {
329                        opening_tokens,
330                        delimiter_tokens,
331                        allow_reasoning: *allow_reasoning,
332                        forcing: false,
333                    },
334                    Some(budget),
335                )
336            }
337        };
338
339        let grammar_start = matches!(activation, Activation::Active).then_some(0);
340        let liveness = StructuredOutputLivenessPolicy::for_request(max_output_tokens, budget);
341        Ok(Some(StructuredOutputProcessor {
342            state: Mutex::new(ProcessorState {
343                matcher,
344                activation: activation.clone(),
345                initial_activation: activation,
346                consumed: 0,
347                boundary_forced: false,
348                boundary_start: None,
349                grammar_start,
350                trailing_grammar_token_id: None,
351                trailing_identical_token_count: 0,
352                liveness_intervention_count: 0,
353                last_liveness_intervention_at: None,
354            }),
355            vocab_size: self.vocab_size,
356            defined_token_ids: Arc::clone(&self.defined_token_ids),
357            json_token_classes: Arc::clone(&self.json_token_classes),
358            budget,
359            liveness,
360        }))
361    }
362
363    fn boundary_token_ids(
364        &self,
365        delimiter: &str,
366        stop_token_ids: &HashSet<u32>,
367        stop_text_sequences: &[String],
368    ) -> Result<Vec<u32>> {
369        if delimiter.is_empty() {
370            return Err(FerrumError::invalid_request(
371                "structured-output delimiter must not be empty",
372            ));
373        }
374        let tokens = if let Some(token) = self.tokenizer.token_id(delimiter) {
375            vec![token.get()]
376        } else {
377            self.tokenizer
378                .encode(delimiter, false)?
379                .into_iter()
380                .map(|token| token.get())
381                .collect::<Vec<_>>()
382        };
383        if tokens.is_empty() {
384            return Err(FerrumError::invalid_request(format!(
385                "structured-output delimiter {delimiter:?} did not tokenize"
386            )));
387        }
388        if let Some(token) = tokens.iter().find(|token| stop_token_ids.contains(token)) {
389            return Err(FerrumError::invalid_request(format!(
390                "structured-output delimiter token {token} conflicts with a stop token"
391            )));
392        }
393        if let Some(stop) = stop_text_sequences
394            .iter()
395            .find(|stop| !stop.is_empty() && delimiter.contains(stop.as_str()))
396        {
397            return Err(FerrumError::invalid_request(format!(
398                "structured-output delimiter {delimiter:?} conflicts with stop sequence {stop:?}"
399            )));
400        }
401        Ok(tokens)
402    }
403}
404
405fn compact_json_schema(schema: serde_json::Value) -> Result<serde_json::Value> {
406    let mut schema = match schema {
407        schema @ serde_json::Value::Object(_) => schema,
408        schema @ serde_json::Value::Bool(_) => json!({"allOf": [schema]}),
409        _ => {
410            return Err(FerrumError::invalid_request(
411                "response_format.schema must be a JSON Schema object or boolean",
412            ));
413        }
414    };
415
416    // x-guidance is an llguidance compiler extension, not a JSON Schema
417    // constraint. Keep compiler policy owned by Ferrum so a request cannot
418    // re-enable an unbounded whitespace loop or change JSON separators.
419    JsonCompileOptions {
420        whitespace_flexible: false,
421        ..JsonCompileOptions::default()
422    }
423    .apply_to(&mut schema);
424    Ok(schema)
425}
426
427/// Per-request structured-output parser state.
428pub struct StructuredOutputProcessor {
429    state: Mutex<ProcessorState>,
430    vocab_size: usize,
431    defined_token_ids: Arc<[bool]>,
432    json_token_classes: Arc<[StructuredOutputTokenClass]>,
433    budget: Option<StructuredOutputBudgetPlan>,
434    liveness: StructuredOutputLivenessPolicy,
435}
436
437/// Typed phase returned after applying a structured-output constraint.
438#[derive(Debug, Clone, Copy, PartialEq, Eq)]
439pub enum StructuredOutputPhase {
440    WaitingForDelimiter,
441    ForcingDelimiter,
442    EnforcingGrammar,
443}
444
445/// Privacy-safe lexical class for the tail of an incomplete grammar.
446///
447/// This deliberately exposes neither decoded text nor a token history. It is
448/// used only by terminal diagnostics to distinguish an unbounded whitespace,
449/// number, string, or control-token run from a parser activation failure.
450#[derive(Debug, Clone, Copy, PartialEq, Eq)]
451#[repr(u8)]
452pub enum StructuredOutputTokenClass {
453    Whitespace,
454    Number,
455    Structural,
456    StringBoundary,
457    Literal,
458    Other,
459    Control,
460    Undefined,
461}
462
463/// Allocation-free hot-path result of one structured-output mask operation.
464#[derive(Debug, Clone, Copy, PartialEq, Eq)]
465pub struct StructuredOutputMaskOutcome {
466    pub phase: StructuredOutputPhase,
467    pub accepting: bool,
468    pub liveness_intervention: bool,
469    /// Generated-token index at which the visible grammar-owned output starts.
470    /// `None` means the processor is still in the hidden pre-grammar domain.
471    /// The execution engine uses this boundary to scope request-local sampling
472    /// history without teaching the grammar about penalty policy.
473    pub grammar_start_token_index: Option<usize>,
474    /// Exact delimiter token authorized for the next sampling step while the
475    /// processor is waiting to activate. The engine uses this typed grant to
476    /// avoid rejecting an intentionally hidden special token during output
477    /// quality filtering.
478    pub required_delimiter_token_id: Option<u32>,
479}
480
481/// Terminal/debug snapshot that distinguishes activation failures from an
482/// incomplete grammar without retaining generated text.
483#[derive(Debug, Clone, Copy, PartialEq, Eq)]
484pub struct StructuredOutputProgress {
485    pub phase: StructuredOutputPhase,
486    pub generated_token_count: usize,
487    pub consumed_token_count: usize,
488    pub delimiter_token_count: Option<usize>,
489    pub delimiter_prefix_token_count: usize,
490    pub reasoning_token_count: Option<usize>,
491    pub boundary_forced: bool,
492    pub budget: Option<StructuredOutputBudgetPlan>,
493    pub grammar_token_count: usize,
494    pub trailing_token_class: Option<StructuredOutputTokenClass>,
495    pub trailing_token_class_count: usize,
496    pub trailing_token_id: Option<u32>,
497    pub trailing_identical_token_count: usize,
498    pub liveness_identical_token_limit: usize,
499    pub liveness_intervention_count: usize,
500    pub accepting: bool,
501}
502
503impl std::fmt::Debug for StructuredOutputProcessor {
504    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
505        let state = self.state.lock();
506        f.debug_struct("StructuredOutputProcessor")
507            .field("vocab_size", &self.vocab_size)
508            .field("consumed", &state.consumed)
509            .field("active", &state.grammar_start.is_some())
510            .field("budget", &self.budget)
511            .finish()
512    }
513}
514
515struct ProcessorState {
516    matcher: Matcher,
517    activation: Activation,
518    initial_activation: Activation,
519    consumed: usize,
520    boundary_forced: bool,
521    boundary_start: Option<usize>,
522    grammar_start: Option<usize>,
523    trailing_grammar_token_id: Option<u32>,
524    trailing_identical_token_count: usize,
525    liveness_intervention_count: usize,
526    last_liveness_intervention_at: Option<usize>,
527}
528
529#[derive(Clone)]
530enum Activation {
531    Active,
532    Harmony(harmony::HarmonyBoundary),
533    Boundary {
534        opening_tokens: Vec<u32>,
535        delimiter_tokens: Vec<u32>,
536        allow_reasoning: bool,
537        forcing: bool,
538    },
539}
540
541impl StructuredOutputProcessor {
542    /// Consume newly generated tokens and hard-mask every illegal next token.
543    /// Waiting-for-reasoning mode leaves normal logits untouched until the
544    /// typed delimiter has been observed.
545    pub fn mask_logits(&self, logits: &mut [f32], generated: &[TokenId]) -> Result<()> {
546        self.mask_logits_inner(logits, generated, None, None)
547            .map(|_| ())
548    }
549
550    /// Apply the grammar mask while allowing engine-resolved stop tokens once
551    /// the grammar accepts. Some model templates use an end-of-turn token that
552    /// is not the tokenizer's primary EOS, so it cannot be represented by the
553    /// grammar parser's EOS set alone.
554    pub fn mask_logits_with_terminals(
555        &self,
556        logits: &mut [f32],
557        generated: &[TokenId],
558        terminal_token_ids: &HashSet<u32>,
559        hidden_control_token_ids: &HashSet<u32>,
560    ) -> Result<StructuredOutputMaskOutcome> {
561        self.mask_logits_inner(
562            logits,
563            generated,
564            Some(terminal_token_ids),
565            Some(hidden_control_token_ids),
566        )
567    }
568
569    fn mask_logits_inner(
570        &self,
571        logits: &mut [f32],
572        generated: &[TokenId],
573        terminal_token_ids: Option<&HashSet<u32>>,
574        hidden_control_token_ids: Option<&HashSet<u32>>,
575    ) -> Result<StructuredOutputMaskOutcome> {
576        let mut state = self.state.lock();
577        advance_state(&mut state, generated, terminal_token_ids, self.budget)?;
578        activate_forcing_if_due(&mut state, generated, self.budget);
579        if let Activation::Harmony(boundary) = &state.activation {
580            if !boundary.in_payload() {
581                self.mask_undefined_token_ids(logits);
582                let required_delimiter_token_id =
583                    boundary.mask_before_payload(logits, hidden_control_token_ids)?;
584                return Ok(StructuredOutputMaskOutcome {
585                    phase: boundary.progress().0,
586                    accepting: false,
587                    liveness_intervention: false,
588                    grammar_start_token_index: None,
589                    required_delimiter_token_id,
590                });
591            }
592        }
593        if let Activation::Boundary {
594            opening_tokens,
595            delimiter_tokens,
596            forcing,
597            ..
598        } = &state.activation
599        {
600            self.mask_undefined_token_ids(logits);
601            let opening_token = opening_tokens.get(generated.len()).copied();
602            let after_opening = generated.get(opening_tokens.len()..).unwrap_or_default();
603            let delimiter_prefix_token_count =
604                delimiter_prefix_token_count(after_opening, delimiter_tokens);
605            let required_delimiter_token = opening_token
606                .or_else(|| delimiter_tokens.get(delimiter_prefix_token_count).copied())
607                .ok_or_else(|| {
608                    FerrumError::internal("structured-output delimiter state has no next token")
609                })?;
610            let force_boundary = opening_token.is_some() || *forcing;
611            if force_boundary {
612                force_exact_token(logits, required_delimiter_token)?;
613            } else if let Some(hidden_control_token_ids) = hidden_control_token_ids {
614                for token_id in hidden_control_token_ids {
615                    if required_delimiter_token == *token_id {
616                        continue;
617                    }
618                    if let Some(logit) = logits.get_mut(*token_id as usize) {
619                        *logit = f32::NEG_INFINITY;
620                    }
621                }
622            }
623            return Ok(StructuredOutputMaskOutcome {
624                phase: if force_boundary {
625                    StructuredOutputPhase::ForcingDelimiter
626                } else {
627                    StructuredOutputPhase::WaitingForDelimiter
628                },
629                accepting: false,
630                liveness_intervention: false,
631                grammar_start_token_index: None,
632                required_delimiter_token_id: Some(required_delimiter_token),
633            });
634        }
635
636        let grammar_start_token_index = state.grammar_start.ok_or_else(|| {
637            FerrumError::internal(
638                "active structured-output processor has no grammar start token index",
639            )
640        })?;
641
642        let accepting = state.matcher.is_accepting().map_err(|error| {
643            FerrumError::model(format!(
644                "structured-output acceptance check failed: {error}"
645            ))
646        })?;
647        let mask = state.matcher.compute_mask_or_eos().map_err(|error| {
648            FerrumError::model(format!("structured-output mask failed: {error}"))
649        })?;
650        let mut finite_allowed = 0usize;
651        for (idx, logit) in logits.iter_mut().enumerate() {
652            let token = idx as u32;
653            let allowed_terminal = match &state.activation {
654                Activation::Harmony(boundary) => boundary.is_terminal(token),
655                _ => terminal_token_ids.is_some_and(|terminals| terminals.contains(&token)),
656            };
657            let hidden_non_terminal_control = hidden_control_token_ids
658                .is_some_and(|controls| controls.contains(&token))
659                && !allowed_terminal;
660            let protocol_control = matches!(&state.activation,
661                Activation::Harmony(boundary) if boundary.is_control(token) && !allowed_terminal);
662            let allowed = idx < self.vocab_size
663                && self.defined_token_ids.get(idx).copied().unwrap_or(false)
664                && !hidden_non_terminal_control
665                && !protocol_control
666                && (mask.is_allowed(token) || (accepting && allowed_terminal));
667            if !allowed {
668                *logit = f32::NEG_INFINITY;
669            } else if logit.is_finite() {
670                finite_allowed += 1;
671            }
672        }
673        if finite_allowed == 0 {
674            return Err(FerrumError::model(
675                "structured-output grammar has no legal finite token",
676            ));
677        }
678        let liveness_intervention = if !accepting
679            && state.trailing_identical_token_count >= self.liveness.max_identical_token_run
680        {
681            state
682                .trailing_grammar_token_id
683                .and_then(|token| logits.get_mut(token as usize))
684                .is_some_and(|logit| {
685                    if logit.is_finite() && finite_allowed > 1 {
686                        *logit = f32::NEG_INFINITY;
687                        if state.last_liveness_intervention_at != Some(generated.len()) {
688                            state.liveness_intervention_count += 1;
689                            state.last_liveness_intervention_at = Some(generated.len());
690                        }
691                        true
692                    } else {
693                        false
694                    }
695                })
696        } else {
697            false
698        };
699        Ok(StructuredOutputMaskOutcome {
700            phase: StructuredOutputPhase::EnforcingGrammar,
701            accepting,
702            liveness_intervention,
703            grammar_start_token_index: Some(grammar_start_token_index),
704            required_delimiter_token_id: None,
705        })
706    }
707
708    fn mask_undefined_token_ids(&self, logits: &mut [f32]) {
709        for (idx, logit) in logits.iter_mut().enumerate() {
710            if !self.defined_token_ids.get(idx).copied().unwrap_or(false) {
711                *logit = f32::NEG_INFINITY;
712            }
713        }
714    }
715
716    /// True only when reasoning has closed and the grammar accepts the full
717    /// generated structured value.
718    pub fn is_accepting(&self, generated: &[TokenId]) -> Result<bool> {
719        self.is_accepting_inner(generated, None)
720    }
721
722    /// Completion check that treats an engine-resolved terminal sampled after
723    /// grammar acceptance as framing rather than part of the JSON value.
724    pub fn is_accepting_with_terminals(
725        &self,
726        generated: &[TokenId],
727        terminal_token_ids: &HashSet<u32>,
728    ) -> Result<bool> {
729        self.is_accepting_inner(generated, Some(terminal_token_ids))
730    }
731
732    fn is_accepting_inner(
733        &self,
734        generated: &[TokenId],
735        terminal_token_ids: Option<&HashSet<u32>>,
736    ) -> Result<bool> {
737        Ok(self
738            .progress_inner(generated, terminal_token_ids)?
739            .accepting)
740    }
741
742    /// Inspect the typed activation/grammar state after consuming `generated`.
743    pub fn progress_with_terminals(
744        &self,
745        generated: &[TokenId],
746        terminal_token_ids: &HashSet<u32>,
747    ) -> Result<StructuredOutputProgress> {
748        self.progress_inner(generated, Some(terminal_token_ids))
749    }
750
751    fn progress_inner(
752        &self,
753        generated: &[TokenId],
754        terminal_token_ids: Option<&HashSet<u32>>,
755    ) -> Result<StructuredOutputProgress> {
756        let mut state = self.state.lock();
757        advance_state(&mut state, generated, terminal_token_ids, self.budget)?;
758        activate_forcing_if_due(&mut state, generated, self.budget);
759        let (phase, delimiter_token_count, delimiter_prefix_token_count, accepting) =
760            match &state.activation {
761                Activation::Boundary {
762                    opening_tokens,
763                    delimiter_tokens,
764                    forcing,
765                    ..
766                } => (
767                    if generated.len() < opening_tokens.len() || *forcing {
768                        StructuredOutputPhase::ForcingDelimiter
769                    } else {
770                        StructuredOutputPhase::WaitingForDelimiter
771                    },
772                    Some(opening_tokens.len() + delimiter_tokens.len()),
773                    generated.len().min(opening_tokens.len())
774                        + delimiter_prefix_token_count(
775                            generated.get(opening_tokens.len()..).unwrap_or_default(),
776                            delimiter_tokens,
777                        ),
778                    false,
779                ),
780                Activation::Active => (
781                    StructuredOutputPhase::EnforcingGrammar,
782                    None,
783                    0,
784                    state.matcher.is_accepting().map_err(|error| {
785                        FerrumError::model(format!(
786                            "structured-output acceptance check failed: {error}"
787                        ))
788                    })?,
789                ),
790                Activation::Harmony(boundary) => {
791                    let (phase, length, prefix) = boundary.progress();
792                    (
793                        phase,
794                        Some(length),
795                        prefix,
796                        boundary.in_payload()
797                            && state.matcher.is_accepting().map_err(|error| {
798                                FerrumError::model(format!(
799                                    "structured-output acceptance check failed: {error}"
800                                ))
801                            })?,
802                    )
803                }
804            };
805        let grammar_tokens = state
806            .grammar_start
807            .and_then(|start| generated.get(start..))
808            .unwrap_or_default();
809        let trailing_token_id = grammar_tokens.last().map(|token| token.get());
810        let trailing_token_class = trailing_token_id.map(|token| {
811            self.json_token_classes
812                .get(token as usize)
813                .copied()
814                .unwrap_or(StructuredOutputTokenClass::Undefined)
815        });
816        let trailing_token_class_count = trailing_token_class.map_or(0, |class| {
817            grammar_tokens
818                .iter()
819                .rev()
820                .take_while(|token| {
821                    self.json_token_classes
822                        .get(token.get() as usize)
823                        .copied()
824                        .unwrap_or(StructuredOutputTokenClass::Undefined)
825                        == class
826                })
827                .count()
828        });
829        let trailing_identical_token_count = trailing_token_id.map_or(0, |token_id| {
830            grammar_tokens
831                .iter()
832                .rev()
833                .take_while(|token| token.get() == token_id)
834                .count()
835        });
836        let opening_token_count = match &state.initial_activation {
837            Activation::Boundary { opening_tokens, .. } => opening_tokens.len(),
838            _ => 0,
839        };
840        Ok(StructuredOutputProgress {
841            phase,
842            generated_token_count: generated.len(),
843            consumed_token_count: state.consumed,
844            delimiter_token_count: delimiter_token_count
845                .or(self.budget.map(|budget| budget.boundary_token_count)),
846            delimiter_prefix_token_count,
847            reasoning_token_count: self.budget.map(|_| {
848                state
849                    .boundary_start
850                    .unwrap_or(generated.len())
851                    .saturating_sub(opening_token_count)
852            }),
853            boundary_forced: state.boundary_forced,
854            budget: self.budget,
855            grammar_token_count: grammar_tokens.len(),
856            trailing_token_class,
857            trailing_token_class_count,
858            trailing_token_id,
859            trailing_identical_token_count,
860            liveness_identical_token_limit: self.liveness.max_identical_token_run,
861            liveness_intervention_count: state.liveness_intervention_count,
862            accepting,
863        })
864    }
865
866    pub fn reset(&self) -> Result<()> {
867        let mut state = self.state.lock();
868        state
869            .matcher
870            .reset()
871            .map_err(|error| FerrumError::internal(format!("reset structured output: {error}")))?;
872        state.activation = state.initial_activation.clone();
873        state.consumed = 0;
874        state.boundary_forced = false;
875        state.boundary_start = None;
876        state.grammar_start = matches!(state.initial_activation, Activation::Active).then_some(0);
877        state.trailing_grammar_token_id = None;
878        state.trailing_identical_token_count = 0;
879        state.liveness_intervention_count = 0;
880        state.last_liveness_intervention_at = None;
881        Ok(())
882    }
883}
884
885fn activate_forcing_if_due(
886    state: &mut ProcessorState,
887    generated: &[TokenId],
888    budget: Option<StructuredOutputBudgetPlan>,
889) {
890    if let Activation::Harmony(boundary) = &mut state.activation {
891        boundary.activate_forcing_if_due(generated.len());
892        state.boundary_forced = boundary.is_forced();
893        return;
894    }
895    let Some(budget) = budget else {
896        return;
897    };
898    let should_force = match &state.activation {
899        Activation::Boundary {
900            opening_tokens,
901            allow_reasoning,
902            forcing,
903            ..
904        } => {
905            !forcing
906                && generated.len() >= opening_tokens.len()
907                && (!allow_reasoning
908                    || generated.len() - opening_tokens.len() >= budget.reasoning_token_limit)
909        }
910        _ => false,
911    };
912    if should_force {
913        let delimiter_prefix_token_count = match &state.activation {
914            Activation::Boundary {
915                opening_tokens,
916                delimiter_tokens,
917                ..
918            } => delimiter_prefix_token_count(&generated[opening_tokens.len()..], delimiter_tokens),
919            Activation::Active | Activation::Harmony(_) => 0,
920        };
921        if let Activation::Boundary { forcing, .. } = &mut state.activation {
922            *forcing = true;
923        }
924        state.boundary_forced = true;
925        state.boundary_start = Some(generated.len() - delimiter_prefix_token_count);
926    }
927}
928
929fn force_exact_token(logits: &mut [f32], required_token: u32) -> Result<()> {
930    let required_index = required_token as usize;
931    if required_index >= logits.len() {
932        return Err(FerrumError::model(format!(
933            "structured-output delimiter token {required_token} is outside logits width {}",
934            logits.len()
935        )));
936    }
937    logits.fill(f32::NEG_INFINITY);
938    logits[required_index] = 0.0;
939    Ok(())
940}
941
942fn delimiter_prefix_token_count(generated: &[TokenId], delimiter_tokens: &[u32]) -> usize {
943    let max_prefix = generated
944        .len()
945        .min(delimiter_tokens.len().saturating_sub(1));
946    (1..=max_prefix)
947        .rev()
948        .find(|prefix_len| {
949            generated[generated.len() - prefix_len..]
950                .iter()
951                .zip(&delimiter_tokens[..*prefix_len])
952                .all(|(token, expected)| token.get() == *expected)
953        })
954        .unwrap_or(0)
955}
956
957fn advance_state(
958    state: &mut ProcessorState,
959    generated: &[TokenId],
960    terminal_token_ids: Option<&HashSet<u32>>,
961    budget: Option<StructuredOutputBudgetPlan>,
962) -> Result<()> {
963    if state.consumed > generated.len() {
964        return Err(FerrumError::internal(
965            "structured-output token history moved backwards without reset",
966        ));
967    }
968
969    if let Activation::Harmony(boundary) = &mut state.activation {
970        while state.consumed < generated.len() && !boundary.in_payload() {
971            let index = state.consumed;
972            if let Some(start) = boundary.observe(generated[index].get(), index)? {
973                state.grammar_start = Some(start);
974                state.boundary_start = Some(boundary.boundary_start());
975            }
976            state.consumed = index + 1;
977        }
978        state.boundary_forced = boundary.is_forced();
979        if !boundary.in_payload() {
980            return Ok(());
981        }
982    }
983
984    if let Activation::Boundary {
985        opening_tokens,
986        delimiter_tokens,
987        allow_reasoning,
988        ..
989    } = &state.activation
990    {
991        if opening_tokens
992            .iter()
993            .zip(generated)
994            .any(|(expected, token)| *expected != token.get())
995        {
996            return Err(FerrumError::model(
997                "structured-output reasoning envelope has an invalid opening header",
998            ));
999        }
1000        if generated.len() < opening_tokens.len() {
1001            state.consumed = generated.len();
1002            return Ok(());
1003        }
1004        if !allow_reasoning
1005            && delimiter_tokens
1006                .iter()
1007                .zip(&generated[opening_tokens.len()..])
1008                .any(|(expected, token)| *expected != token.get())
1009        {
1010            return Err(FerrumError::model(
1011                "structured-output reasoning is disabled; the envelope must close immediately",
1012            ));
1013        }
1014        let search_from = state
1015            .consumed
1016            .saturating_sub(delimiter_tokens.len())
1017            .max(opening_tokens.len());
1018        if let Some(offset) = generated[search_from..]
1019            .windows(delimiter_tokens.len())
1020            .position(|window| {
1021                window
1022                    .iter()
1023                    .zip(delimiter_tokens)
1024                    .all(|(token, expected)| token.get() == *expected)
1025            })
1026        {
1027            let grammar_start = search_from + offset + delimiter_tokens.len();
1028            if !opening_tokens.is_empty() {
1029                let reasoning_count = grammar_start - delimiter_tokens.len() - opening_tokens.len();
1030                state.boundary_forced |= !allow_reasoning
1031                    || budget.is_some_and(|plan| {
1032                        reasoning_count + delimiter_tokens.len() - 1 >= plan.reasoning_token_limit
1033                    });
1034            }
1035            state.boundary_start = Some(grammar_start - delimiter_tokens.len());
1036            state.grammar_start = Some(grammar_start);
1037            state.activation = Activation::Active;
1038            state.consumed = grammar_start;
1039            state.trailing_grammar_token_id = None;
1040            state.trailing_identical_token_count = 0;
1041        } else {
1042            state.consumed = generated.len();
1043            return Ok(());
1044        }
1045    }
1046
1047    for token in &generated[state.consumed..] {
1048        if let Activation::Harmony(boundary) = &mut state.activation {
1049            let accepting = state.matcher.is_accepting().map_err(|error| {
1050                FerrumError::model(format!(
1051                    "structured-output acceptance check failed: {error}"
1052                ))
1053            })?;
1054            if boundary.observe_payload_control(token.get(), accepting)? {
1055                continue;
1056            }
1057        } else if terminal_token_ids.is_some_and(|terminals| terminals.contains(&token.get()))
1058            && state.matcher.is_accepting().map_err(|error| {
1059                FerrumError::model(format!(
1060                    "structured-output acceptance check failed: {error}"
1061                ))
1062            })?
1063        {
1064            continue;
1065        }
1066        state.matcher.consume_token(token.get()).map_err(|error| {
1067            FerrumError::model(format!(
1068                "structured-output token {} violated the grammar: {error}",
1069                token.get()
1070            ))
1071        })?;
1072        if state.trailing_grammar_token_id == Some(token.get()) {
1073            state.trailing_identical_token_count += 1;
1074        } else {
1075            state.trailing_grammar_token_id = Some(token.get());
1076            state.trailing_identical_token_count = 1;
1077        }
1078    }
1079    state.consumed = generated.len();
1080    Ok(())
1081}
1082
1083struct FerrumTokenizerEnv {
1084    tokenizer: Arc<dyn Tokenizer + Send + Sync>,
1085    trie: TokTrie,
1086}
1087
1088impl TokenizerEnv for FerrumTokenizerEnv {
1089    fn tok_trie(&self) -> &TokTrie {
1090        &self.trie
1091    }
1092
1093    fn tokenize_bytes(&self, bytes: &[u8]) -> Vec<u32> {
1094        str::from_utf8(bytes)
1095            .ok()
1096            .and_then(|text| self.tokenizer.encode(text, false).ok())
1097            .map(|tokens| tokens.into_iter().map(|token| token.get()).collect())
1098            .unwrap_or_else(|| self.trie.greedy_tokenize(bytes))
1099    }
1100
1101    fn tokenize_is_canonical(&self) -> bool {
1102        false
1103    }
1104}
1105
1106fn tokenizer_special_ids(tokenizer: &(dyn Tokenizer + Send + Sync)) -> HashSet<u32> {
1107    let special = tokenizer.special_tokens();
1108    [
1109        special.bos_token,
1110        special.eos_token,
1111        special.unk_token,
1112        special.pad_token,
1113        special.sep_token,
1114        special.cls_token,
1115        special.mask_token,
1116    ]
1117    .into_iter()
1118    .flatten()
1119    .chain(special.extra_eos_tokens.iter().copied())
1120    .map(|token| token.get())
1121    .collect()
1122}
1123
1124fn special_token_marker(token: TokenId) -> Vec<u8> {
1125    let mut marker = vec![TokTrie::SPECIAL_TOKEN_MARKER];
1126    marker.extend_from_slice(format!("[{}]", token.get()).as_bytes());
1127    marker
1128}
1129
1130fn classify_json_token_bytes(bytes: &[u8]) -> StructuredOutputTokenClass {
1131    if bytes.is_empty() {
1132        StructuredOutputTokenClass::Undefined
1133    } else if bytes
1134        .iter()
1135        .all(|byte| matches!(byte, b' ' | b'\n' | b'\r' | b'\t'))
1136    {
1137        StructuredOutputTokenClass::Whitespace
1138    } else if bytes
1139        .iter()
1140        .all(|byte| byte.is_ascii_digit() || matches!(byte, b'-' | b'+' | b'.' | b'e' | b'E'))
1141    {
1142        StructuredOutputTokenClass::Number
1143    } else if bytes
1144        .iter()
1145        .all(|byte| matches!(byte, b'{' | b'}' | b'[' | b']' | b',' | b':'))
1146    {
1147        StructuredOutputTokenClass::Structural
1148    } else if bytes.iter().all(|byte| matches!(byte, b'"' | b'\\')) {
1149        StructuredOutputTokenClass::StringBoundary
1150    } else if bytes.iter().all(u8::is_ascii_alphabetic) {
1151        StructuredOutputTokenClass::Literal
1152    } else {
1153        StructuredOutputTokenClass::Other
1154    }
1155}
1156
1157#[cfg(test)]
1158mod tests {
1159    use super::*;
1160    use ferrum_interfaces::tokenizer::{ChatMessage, TokenizerInfo, TokenizerType};
1161    use ferrum_types::SpecialTokens;
1162
1163    const EOS: u32 = 256;
1164    const TEST_MAX_OUTPUT_TOKENS: usize = 128;
1165
1166    pub(super) struct ByteTokenizer {
1167        special: SpecialTokens,
1168        token_text: Vec<String>,
1169    }
1170
1171    impl ByteTokenizer {
1172        pub(super) fn new() -> Self {
1173            let mut token_text = (0u16..=255)
1174                .map(|byte| char::from_u32(byte as u32).unwrap().to_string())
1175                .collect::<Vec<_>>();
1176            token_text.push("<eos>".to_string());
1177            Self {
1178                special: SpecialTokens {
1179                    eos_token: Some(TokenId::new(EOS)),
1180                    ..SpecialTokens::default()
1181                },
1182                token_text,
1183            }
1184        }
1185    }
1186
1187    impl Tokenizer for ByteTokenizer {
1188        fn encode(&self, text: &str, _add_special: bool) -> Result<Vec<TokenId>> {
1189            Ok(text
1190                .as_bytes()
1191                .iter()
1192                .map(|byte| TokenId::new(*byte as u32))
1193                .collect())
1194        }
1195
1196        fn decode(&self, tokens: &[TokenId], _skip_special: bool) -> Result<String> {
1197            Ok(tokens
1198                .iter()
1199                .filter(|token| token.get() < 256)
1200                .map(|token| token.get() as u8 as char)
1201                .collect())
1202        }
1203
1204        fn decode_incremental(&self, _prev: &[TokenId], next: TokenId) -> Result<String> {
1205            self.decode(&[next], true)
1206        }
1207
1208        fn vocab_size(&self) -> usize {
1209            self.token_text.len()
1210        }
1211
1212        fn special_tokens(&self) -> &SpecialTokens {
1213            &self.special
1214        }
1215
1216        fn token_id(&self, text: &str) -> Option<TokenId> {
1217            (text.len() == 1).then(|| TokenId::new(text.as_bytes()[0] as u32))
1218        }
1219
1220        fn token_text(&self, token_id: TokenId) -> Option<&str> {
1221            self.token_text
1222                .get(token_id.get() as usize)
1223                .map(String::as_str)
1224        }
1225
1226        fn apply_chat_template(&self, messages: &[ChatMessage]) -> Result<String> {
1227            Ok(messages
1228                .iter()
1229                .map(|message| message.content.as_str())
1230                .collect::<Vec<_>>()
1231                .join("\n"))
1232        }
1233
1234        fn info(&self) -> TokenizerInfo {
1235            TokenizerInfo {
1236                tokenizer_type: TokenizerType::BPE,
1237                vocab_size: self.vocab_size(),
1238                special_tokens: self.special.clone(),
1239                supports_incremental: true,
1240                supports_chat_template: false,
1241                max_token_length: Some(1),
1242                model_name: Some("byte-test".to_string()),
1243            }
1244        }
1245    }
1246
1247    struct MergedObjectTokenizer {
1248        inner: ByteTokenizer,
1249    }
1250
1251    impl MergedObjectTokenizer {
1252        const OBJECT: u32 = 256;
1253        const EOS: u32 = 257;
1254
1255        fn new() -> Self {
1256            let mut inner = ByteTokenizer::new();
1257            inner.token_text[EOS as usize] = "{}".to_string();
1258            inner.token_text.push("<eos>".to_string());
1259            inner.special.eos_token = Some(TokenId::new(Self::EOS));
1260            Self { inner }
1261        }
1262    }
1263
1264    impl Tokenizer for MergedObjectTokenizer {
1265        fn encode(&self, text: &str, add_special: bool) -> Result<Vec<TokenId>> {
1266            self.inner.encode(text, add_special)
1267        }
1268
1269        fn decode(&self, tokens: &[TokenId], skip_special: bool) -> Result<String> {
1270            let mut decoded = String::new();
1271            for token in tokens {
1272                match token.get() {
1273                    Self::OBJECT => decoded.push_str("{}"),
1274                    Self::EOS if skip_special => {}
1275                    Self::EOS => decoded.push_str("<eos>"),
1276                    _ => decoded.push_str(&self.inner.decode(&[*token], skip_special)?),
1277                }
1278            }
1279            Ok(decoded)
1280        }
1281
1282        fn decode_incremental(&self, _prev: &[TokenId], next: TokenId) -> Result<String> {
1283            self.decode(&[next], true)
1284        }
1285
1286        fn vocab_size(&self) -> usize {
1287            self.inner.token_text.len()
1288        }
1289
1290        fn special_tokens(&self) -> &SpecialTokens {
1291            &self.inner.special
1292        }
1293
1294        fn token_id(&self, text: &str) -> Option<TokenId> {
1295            (text == "{}")
1296                .then(|| TokenId::new(Self::OBJECT))
1297                .or_else(|| self.inner.token_id(text))
1298        }
1299
1300        fn token_text(&self, token_id: TokenId) -> Option<&str> {
1301            self.inner.token_text(token_id)
1302        }
1303
1304        fn info(&self) -> TokenizerInfo {
1305            TokenizerInfo {
1306                vocab_size: self.vocab_size(),
1307                max_token_length: Some(2),
1308                model_name: Some("merged-object-test".to_string()),
1309                ..self.inner.info()
1310            }
1311        }
1312    }
1313
1314    fn factory() -> StructuredOutputFactory {
1315        StructuredOutputFactory::new(Arc::new(ByteTokenizer::new())).unwrap()
1316    }
1317
1318    fn assert_and_append(
1319        processor: &StructuredOutputProcessor,
1320        generated: &mut Vec<TokenId>,
1321        text: &str,
1322    ) {
1323        for byte in text.bytes() {
1324            let mut logits = vec![0.0; EOS as usize + 1];
1325            processor.mask_logits(&mut logits, generated).unwrap();
1326            assert!(
1327                logits[byte as usize].is_finite(),
1328                "byte {byte:?} rejected after {:?}",
1329                generated
1330            );
1331            generated.push(TokenId::new(byte as u32));
1332        }
1333    }
1334
1335    #[test]
1336    fn json_object_hard_masks_non_object_roots() {
1337        let processor = factory()
1338            .create_processor(
1339                &ResponseFormat::JsonObject,
1340                &StructuredOutputStart::Immediate,
1341                TEST_MAX_OUTPUT_TOKENS,
1342                &HashSet::new(),
1343                &[],
1344            )
1345            .unwrap()
1346            .unwrap();
1347        let mut logits = vec![0.0; EOS as usize + 1];
1348        processor.mask_logits(&mut logits, &[]).unwrap();
1349        assert!(logits[b'{' as usize].is_finite());
1350        assert!(!logits[b'[' as usize].is_finite());
1351        assert!(!logits[b'`' as usize].is_finite());
1352        assert!(!logits[EOS as usize].is_finite());
1353    }
1354
1355    #[test]
1356    fn json_object_uses_compact_separators_without_unbounded_whitespace() {
1357        let processor = factory()
1358            .create_processor(
1359                &ResponseFormat::JsonObject,
1360                &StructuredOutputStart::Immediate,
1361                TEST_MAX_OUTPUT_TOKENS,
1362                &HashSet::new(),
1363                &[],
1364            )
1365            .unwrap()
1366            .unwrap();
1367        let generated = vec![TokenId::new(b'{' as u32)];
1368        let mut logits = vec![0.0; EOS as usize + 1];
1369        processor.mask_logits(&mut logits, &generated).unwrap();
1370
1371        assert!(!logits[b' ' as usize].is_finite());
1372        assert!(logits[b'}' as usize].is_finite());
1373        assert!(logits[b'"' as usize].is_finite());
1374    }
1375
1376    #[test]
1377    fn undefined_model_vocab_ids_are_masked_inside_wildcard_strings() {
1378        let tokenizer = Arc::new(ByteTokenizer::new());
1379        let undefined_token = tokenizer.vocab_size() as u32;
1380        assert_eq!(
1381            tokenizer.token_bytes(TokenId::new(undefined_token)),
1382            Some(Vec::new()),
1383            "the test tokenizer must reproduce a decoder that returns empty text for an unknown id"
1384        );
1385        let processor = StructuredOutputFactory::new_with_model_vocab_size(
1386            tokenizer,
1387            Some(undefined_token as usize + 1),
1388        )
1389        .unwrap()
1390        .create_processor(
1391            &ResponseFormat::JsonObject,
1392            &StructuredOutputStart::Immediate,
1393            TEST_MAX_OUTPUT_TOKENS,
1394            &HashSet::new(),
1395            &[],
1396        )
1397        .unwrap()
1398        .unwrap();
1399
1400        let mut generated = Vec::new();
1401        assert_and_append(&processor, &mut generated, r#"{"value":"Ferrum "#);
1402        let mut logits = vec![0.0; undefined_token as usize + 1];
1403        processor.mask_logits(&mut logits, &generated).unwrap();
1404        assert!(logits[b'x' as usize].is_finite());
1405        assert!(!logits[undefined_token as usize].is_finite());
1406    }
1407
1408    #[test]
1409    fn json_object_accepts_nested_unicode_escape_and_eos_only_after_close() {
1410        let processor = factory()
1411            .create_processor(
1412                &ResponseFormat::JsonObject,
1413                &StructuredOutputStart::Immediate,
1414                TEST_MAX_OUTPUT_TOKENS,
1415                &HashSet::new(),
1416                &[],
1417            )
1418            .unwrap()
1419            .unwrap();
1420        let mut generated = Vec::new();
1421        assert_and_append(
1422            &processor,
1423            &mut generated,
1424            r#"{"items":[true,null,{"name":"line\u000A"}],"n":-1.2e+3}"#,
1425        );
1426        assert!(processor.is_accepting(&generated).unwrap());
1427        let mut logits = vec![0.0; EOS as usize + 1];
1428        processor.mask_logits(&mut logits, &generated).unwrap();
1429        assert!(logits[EOS as usize].is_finite());
1430        assert!(!logits[b'x' as usize].is_finite());
1431    }
1432
1433    #[test]
1434    fn json_object_accepts_a_complete_root_from_one_merged_token() {
1435        let processor = StructuredOutputFactory::new(Arc::new(MergedObjectTokenizer::new()))
1436            .unwrap()
1437            .create_processor(
1438                &ResponseFormat::JsonObject,
1439                &StructuredOutputStart::Immediate,
1440                TEST_MAX_OUTPUT_TOKENS,
1441                &HashSet::new(),
1442                &[],
1443            )
1444            .unwrap()
1445            .unwrap();
1446        let generated = vec![TokenId::new(MergedObjectTokenizer::OBJECT)];
1447        let terminals = HashSet::from([MergedObjectTokenizer::EOS]);
1448
1449        let progress = processor
1450            .progress_with_terminals(&generated, &terminals)
1451            .unwrap();
1452        assert!(progress.accepting);
1453
1454        let mut logits = vec![0.0; MergedObjectTokenizer::EOS as usize + 1];
1455        let outcome = processor
1456            .mask_logits_with_terminals(&mut logits, &generated, &terminals, &HashSet::new())
1457            .unwrap();
1458        assert!(outcome.accepting);
1459        assert_eq!(outcome.grammar_start_token_index, Some(0));
1460        assert!(!logits[MergedObjectTokenizer::OBJECT as usize].is_finite());
1461        assert!(logits[MergedObjectTokenizer::EOS as usize].is_finite());
1462    }
1463
1464    #[test]
1465    fn json_object_breaks_an_unbounded_identical_token_run_when_closure_is_legal() {
1466        let processor = StructuredOutputFactory::new(Arc::new(MergedObjectTokenizer::new()))
1467            .unwrap()
1468            .create_processor(
1469                &ResponseFormat::JsonObject,
1470                &StructuredOutputStart::Immediate,
1471                64,
1472                &HashSet::new(),
1473                &[],
1474            )
1475            .unwrap()
1476            .unwrap();
1477        let mut generated =
1478            br#"{"marker":""#.iter().map(|byte| TokenId::new(*byte as u32)).collect::<Vec<_>>();
1479        generated.extend(std::iter::repeat_n(
1480            TokenId::new(MergedObjectTokenizer::OBJECT),
1481            32,
1482        ));
1483
1484        let mut logits = vec![0.0; MergedObjectTokenizer::EOS as usize + 1];
1485        let outcome = processor
1486            .mask_logits_with_terminals(
1487                &mut logits,
1488                &generated,
1489                &HashSet::from([MergedObjectTokenizer::EOS]),
1490                &HashSet::new(),
1491            )
1492            .unwrap();
1493
1494        assert!(!outcome.accepting);
1495        assert!(outcome.liveness_intervention);
1496        assert!(!logits[MergedObjectTokenizer::OBJECT as usize].is_finite());
1497        assert!(logits[b'"' as usize].is_finite());
1498
1499        generated.extend([TokenId::new(b'"' as u32), TokenId::new(b'}' as u32)]);
1500        let progress = processor
1501            .progress_with_terminals(&generated, &HashSet::from([MergedObjectTokenizer::EOS]))
1502            .unwrap();
1503        assert!(progress.accepting);
1504        assert_eq!(progress.liveness_identical_token_limit, 32);
1505        assert_eq!(progress.liveness_intervention_count, 1);
1506    }
1507
1508    #[test]
1509    fn structured_liveness_guard_preserves_the_only_finite_grammar_candidate() {
1510        let processor = StructuredOutputFactory::new(Arc::new(MergedObjectTokenizer::new()))
1511            .unwrap()
1512            .create_processor(
1513                &ResponseFormat::JsonObject,
1514                &StructuredOutputStart::Immediate,
1515                64,
1516                &HashSet::new(),
1517                &[],
1518            )
1519            .unwrap()
1520            .unwrap();
1521        let mut generated =
1522            br#"{"marker":""#.iter().map(|byte| TokenId::new(*byte as u32)).collect::<Vec<_>>();
1523        generated.extend(std::iter::repeat_n(
1524            TokenId::new(MergedObjectTokenizer::OBJECT),
1525            32,
1526        ));
1527        let mut logits = vec![f32::NEG_INFINITY; MergedObjectTokenizer::EOS as usize + 1];
1528        logits[MergedObjectTokenizer::OBJECT as usize] = 0.0;
1529
1530        let outcome = processor
1531            .mask_logits_with_terminals(
1532                &mut logits,
1533                &generated,
1534                &HashSet::from([MergedObjectTokenizer::EOS]),
1535                &HashSet::new(),
1536            )
1537            .unwrap();
1538
1539        assert!(!outcome.liveness_intervention);
1540        assert!(logits[MergedObjectTokenizer::OBJECT as usize].is_finite());
1541    }
1542
1543    #[test]
1544    fn structured_liveness_limit_is_derived_from_the_guaranteed_result_budget() {
1545        let budget = StructuredOutputBudgetPlan::automatic(4096, 1).unwrap();
1546        assert_eq!(budget.structured_reserve_tokens, 1024);
1547        assert_eq!(
1548            StructuredOutputLivenessPolicy::for_request(4096, Some(budget)).max_identical_token_run,
1549            512
1550        );
1551        assert_eq!(
1552            StructuredOutputLivenessPolicy::for_request(64, None).max_identical_token_run,
1553            32
1554        );
1555    }
1556
1557    #[test]
1558    fn strict_schema_rejects_wrong_property_and_accepts_required_value() {
1559        let schema = r#"{
1560            "type":"object",
1561            "properties":{"answer":{"const":42}},
1562            "required":["answer"],
1563            "additionalProperties":false
1564        }"#;
1565        let processor = factory()
1566            .create_processor(
1567                &ResponseFormat::JsonSchema(schema.to_string()),
1568                &StructuredOutputStart::Immediate,
1569                TEST_MAX_OUTPUT_TOKENS,
1570                &HashSet::new(),
1571                &[],
1572            )
1573            .unwrap()
1574            .unwrap();
1575        let mut generated = Vec::new();
1576        assert_and_append(&processor, &mut generated, r#"{"answer":42}"#);
1577        assert!(processor.is_accepting(&generated).unwrap());
1578    }
1579
1580    #[test]
1581    fn request_cannot_override_compact_json_compiler_policy() {
1582        let schema = r#"{
1583            "type":"object",
1584            "properties":{"answer":{"const":42}},
1585            "required":["answer"],
1586            "additionalProperties":false,
1587            "x-guidance":{
1588                "item_separator":", ",
1589                "key_separator":": ",
1590                "whitespace_flexible":true
1591            }
1592        }"#;
1593        let processor = factory()
1594            .create_processor(
1595                &ResponseFormat::JsonSchema(schema.to_string()),
1596                &StructuredOutputStart::Immediate,
1597                TEST_MAX_OUTPUT_TOKENS,
1598                &HashSet::new(),
1599                &[],
1600            )
1601            .unwrap()
1602            .unwrap();
1603        let mut generated = Vec::new();
1604        assert_and_append(&processor, &mut generated, r#"{"answer":"#);
1605        let mut logits = vec![0.0; EOS as usize + 1];
1606        processor.mask_logits(&mut logits, &generated).unwrap();
1607
1608        assert!(!logits[b' ' as usize].is_finite());
1609        assert!(logits[b'4' as usize].is_finite());
1610        assert_and_append(&processor, &mut generated, "42}");
1611        assert!(processor.is_accepting(&generated).unwrap());
1612    }
1613
1614    #[test]
1615    fn boolean_json_schema_keeps_its_semantics_under_compact_policy() {
1616        let processor = factory()
1617            .create_processor(
1618                &ResponseFormat::JsonSchema("true".to_string()),
1619                &StructuredOutputStart::Immediate,
1620                TEST_MAX_OUTPUT_TOKENS,
1621                &HashSet::new(),
1622                &[],
1623            )
1624            .unwrap()
1625            .unwrap();
1626        let mut generated = Vec::new();
1627        assert_and_append(&processor, &mut generated, "true");
1628        assert!(processor.is_accepting(&generated).unwrap());
1629    }
1630
1631    #[test]
1632    fn terminal_progress_classifies_an_unclosed_number_without_retaining_text() {
1633        let processor = factory()
1634            .create_processor(
1635                &ResponseFormat::JsonSchema(
1636                    r#"{"type":"object","properties":{"value":{"type":"integer"}},"required":["value"],"additionalProperties":false}"#
1637                        .to_string(),
1638                ),
1639                &StructuredOutputStart::Immediate,
1640                TEST_MAX_OUTPUT_TOKENS,
1641                &HashSet::new(),
1642                &[],
1643            )
1644            .unwrap()
1645            .unwrap();
1646        let generated =
1647            r#"{"value":123777"#.bytes().map(|byte| TokenId::new(byte as u32)).collect::<Vec<_>>();
1648        let progress = processor
1649            .progress_with_terminals(&generated, &HashSet::new())
1650            .unwrap();
1651
1652        assert_eq!(progress.phase, StructuredOutputPhase::EnforcingGrammar);
1653        assert_eq!(progress.grammar_token_count, generated.len());
1654        assert_eq!(
1655            progress.trailing_token_class,
1656            Some(StructuredOutputTokenClass::Number)
1657        );
1658        assert_eq!(progress.trailing_token_class_count, 6);
1659        assert_eq!(progress.trailing_token_id, Some(b'7' as u32));
1660        assert_eq!(progress.trailing_identical_token_count, 3);
1661        assert!(!progress.accepting);
1662    }
1663
1664    #[test]
1665    fn lexical_diagnostics_classify_json_bytes_without_decoding_content() {
1666        assert_eq!(
1667            classify_json_token_bytes(b" \n\t"),
1668            StructuredOutputTokenClass::Whitespace
1669        );
1670        assert_eq!(
1671            classify_json_token_bytes(b"-12.5e+3"),
1672            StructuredOutputTokenClass::Number
1673        );
1674        assert_eq!(
1675            classify_json_token_bytes(br#"{}[],:"#),
1676            StructuredOutputTokenClass::Structural
1677        );
1678        assert_eq!(
1679            classify_json_token_bytes(br#"\""#),
1680            StructuredOutputTokenClass::StringBoundary
1681        );
1682        assert_eq!(
1683            classify_json_token_bytes(b"true"),
1684            StructuredOutputTokenClass::Literal
1685        );
1686        assert_eq!(
1687            classify_json_token_bytes(br#""value":"#),
1688            StructuredOutputTokenClass::Other
1689        );
1690    }
1691
1692    struct FragmentedUtf8Tokenizer {
1693        special: SpecialTokens,
1694        token_text: Vec<String>,
1695    }
1696
1697    impl FragmentedUtf8Tokenizer {
1698        const FIRE_HEAD: u32 = 128;
1699        const FIRE_TAIL: u32 = 129;
1700        const EOS: u32 = 130;
1701
1702        fn new() -> Self {
1703            let mut token_text = (0u8..=127)
1704                .map(|byte| (byte as char).to_string())
1705                .collect::<Vec<_>>();
1706            token_text.extend(["\u{fffd}".to_string(), "\u{fffd}".to_string()]);
1707            token_text.push("<eos>".to_string());
1708            Self {
1709                special: SpecialTokens {
1710                    eos_token: Some(TokenId::new(Self::EOS)),
1711                    ..SpecialTokens::default()
1712                },
1713                token_text,
1714            }
1715        }
1716
1717        fn raw_bytes(token: TokenId) -> Option<Vec<u8>> {
1718            match token.get() {
1719                byte @ 0..=127 => Some(vec![byte as u8]),
1720                Self::FIRE_HEAD => Some(vec![0xf0, 0x9f]),
1721                Self::FIRE_TAIL => Some(vec![0x94, 0xa5]),
1722                _ => None,
1723            }
1724        }
1725    }
1726
1727    impl Tokenizer for FragmentedUtf8Tokenizer {
1728        fn encode(&self, text: &str, _add_special: bool) -> Result<Vec<TokenId>> {
1729            let mut tokens = Vec::new();
1730            let mut bytes = text.as_bytes();
1731            while let Some((&byte, remaining)) = bytes.split_first() {
1732                if bytes.starts_with(&[0xf0, 0x9f, 0x94, 0xa5]) {
1733                    tokens.push(TokenId::new(Self::FIRE_HEAD));
1734                    tokens.push(TokenId::new(Self::FIRE_TAIL));
1735                    bytes = &bytes[4..];
1736                } else if byte <= 127 {
1737                    tokens.push(TokenId::new(byte as u32));
1738                    bytes = remaining;
1739                } else {
1740                    return Err(FerrumError::tokenizer(
1741                        "fragmented UTF-8 test tokenizer received unsupported input",
1742                    ));
1743                }
1744            }
1745            Ok(tokens)
1746        }
1747
1748        fn decode(&self, tokens: &[TokenId], _skip_special: bool) -> Result<String> {
1749            let bytes = tokens
1750                .iter()
1751                .filter_map(|token| Self::raw_bytes(*token))
1752                .flatten()
1753                .collect::<Vec<_>>();
1754            Ok(String::from_utf8_lossy(&bytes).into_owned())
1755        }
1756
1757        fn decode_incremental(&self, _prev: &[TokenId], next: TokenId) -> Result<String> {
1758            self.decode(&[next], true)
1759        }
1760
1761        fn vocab_size(&self) -> usize {
1762            self.token_text.len()
1763        }
1764
1765        fn special_tokens(&self) -> &SpecialTokens {
1766            &self.special
1767        }
1768
1769        fn token_id(&self, text: &str) -> Option<TokenId> {
1770            (text.len() == 1 && text.is_ascii()).then(|| TokenId::new(text.as_bytes()[0] as u32))
1771        }
1772
1773        fn token_text(&self, token_id: TokenId) -> Option<&str> {
1774            self.token_text
1775                .get(token_id.get() as usize)
1776                .map(String::as_str)
1777        }
1778
1779        fn token_bytes(&self, token_id: TokenId) -> Option<Vec<u8>> {
1780            Self::raw_bytes(token_id)
1781        }
1782
1783        fn info(&self) -> TokenizerInfo {
1784            TokenizerInfo {
1785                tokenizer_type: TokenizerType::BPE,
1786                vocab_size: self.vocab_size(),
1787                special_tokens: self.special.clone(),
1788                supports_incremental: true,
1789                supports_chat_template: false,
1790                max_token_length: Some(2),
1791                model_name: Some("fragmented-utf8-test".to_string()),
1792            }
1793        }
1794    }
1795
1796    #[test]
1797    fn strict_schema_accepts_utf8_split_across_byte_level_tokens() {
1798        let tokenizer = Arc::new(FragmentedUtf8Tokenizer::new());
1799        assert!(tokenizer
1800            .decode(&[TokenId::new(FragmentedUtf8Tokenizer::FIRE_HEAD)], false)
1801            .unwrap()
1802            .contains('\u{fffd}'));
1803        let processor = StructuredOutputFactory::new(tokenizer)
1804            .unwrap()
1805            .create_processor(
1806                &ResponseFormat::JsonSchema(
1807                    r#"{"type":"object","properties":{"value":{"const":"\ud83d\udd25"}},"required":["value"],"additionalProperties":false}"#
1808                        .to_string(),
1809                ),
1810                &StructuredOutputStart::Immediate,
1811                TEST_MAX_OUTPUT_TOKENS,
1812                &HashSet::new(),
1813                &[],
1814            )
1815            .unwrap()
1816            .unwrap();
1817
1818        let mut generated = Vec::new();
1819        for token in r#"{"value":""#
1820            .bytes()
1821            .map(|byte| TokenId::new(byte as u32))
1822            .chain([
1823                TokenId::new(FragmentedUtf8Tokenizer::FIRE_HEAD),
1824                TokenId::new(FragmentedUtf8Tokenizer::FIRE_TAIL),
1825            ])
1826            .chain(r#""}"#.bytes().map(|byte| TokenId::new(byte as u32)))
1827        {
1828            let mut logits = vec![0.0; FragmentedUtf8Tokenizer::EOS as usize + 1];
1829            processor.mask_logits(&mut logits, &generated).unwrap();
1830            assert!(
1831                logits[token.get() as usize].is_finite(),
1832                "token {} rejected after {:?}",
1833                token.get(),
1834                generated
1835            );
1836            generated.push(token);
1837        }
1838        assert!(processor.is_accepting(&generated).unwrap());
1839    }
1840
1841    #[test]
1842    fn reasoning_delimiter_defers_then_activates_the_grammar() {
1843        let processor = factory()
1844            .create_processor(
1845                &ResponseFormat::JsonObject,
1846                &StructuredOutputStart::AfterDelimiter("</think>".to_string()),
1847                TEST_MAX_OUTPUT_TOKENS,
1848                &HashSet::new(),
1849                &[],
1850            )
1851            .unwrap()
1852            .unwrap();
1853        let mut generated = Vec::new();
1854        let controls = HashSet::from([b'<' as u32, b'>' as u32]);
1855        let mut waiting_logits = vec![0.0; EOS as usize + 1];
1856        let waiting = processor
1857            .mask_logits_with_terminals(&mut waiting_logits, &generated, &HashSet::new(), &controls)
1858            .unwrap();
1859        assert_eq!(waiting.phase, StructuredOutputPhase::WaitingForDelimiter);
1860        assert!(!waiting.accepting);
1861        assert_eq!(waiting.grammar_start_token_index, None);
1862        assert_eq!(waiting.required_delimiter_token_id, Some(b'<' as u32));
1863        assert!(waiting_logits[b'<' as usize].is_finite());
1864        assert!(!waiting_logits[b'>' as usize].is_finite());
1865
1866        let delimiter_prefix = "</think"
1867            .bytes()
1868            .map(|byte| TokenId::new(byte as u32))
1869            .collect::<Vec<_>>();
1870        let mut partial_logits = vec![0.0; EOS as usize + 1];
1871        let partial = processor
1872            .mask_logits_with_terminals(
1873                &mut partial_logits,
1874                &delimiter_prefix,
1875                &HashSet::new(),
1876                &controls,
1877            )
1878            .unwrap();
1879        assert_eq!(partial.phase, StructuredOutputPhase::WaitingForDelimiter);
1880        assert_eq!(partial.grammar_start_token_index, None);
1881        assert_eq!(partial.required_delimiter_token_id, Some(b'>' as u32));
1882        assert!(!partial_logits[b'<' as usize].is_finite());
1883        assert!(partial_logits[b'>' as usize].is_finite());
1884        let partial_progress = processor
1885            .progress_with_terminals(&delimiter_prefix, &HashSet::new())
1886            .unwrap();
1887        assert_eq!(partial_progress.delimiter_token_count, Some(8));
1888        assert_eq!(partial_progress.delimiter_prefix_token_count, 7);
1889
1890        processor.reset().unwrap();
1891
1892        assert_and_append(&processor, &mut generated, "reasoning [is free]</think>");
1893        let mut logits = vec![0.0; EOS as usize + 1];
1894        let active = processor
1895            .mask_logits_with_terminals(&mut logits, &generated, &HashSet::new(), &HashSet::new())
1896            .unwrap();
1897        assert_eq!(active.phase, StructuredOutputPhase::EnforcingGrammar);
1898        assert_eq!(active.grammar_start_token_index, Some(27));
1899        assert!(logits[b'{' as usize].is_finite());
1900        assert!(!logits[b'[' as usize].is_finite());
1901        assert!(!logits[EOS as usize].is_finite());
1902
1903        assert_and_append(&processor, &mut generated, r#"{"ok":true}"#);
1904        assert!(processor.is_accepting(&generated).unwrap());
1905        let progress = processor
1906            .progress_with_terminals(&generated, &HashSet::new())
1907            .unwrap();
1908        assert_eq!(progress.phase, StructuredOutputPhase::EnforcingGrammar);
1909        assert!(progress.accepting);
1910        assert_eq!(progress.generated_token_count, generated.len());
1911        assert!(!progress.boundary_forced);
1912        assert_eq!(progress.reasoning_token_count, Some(19));
1913    }
1914
1915    #[test]
1916    fn reasoning_budget_forces_exact_delimiter_and_preserves_structured_reserve() {
1917        let processor = factory()
1918            .create_processor(
1919                &ResponseFormat::JsonObject,
1920                &StructuredOutputStart::AfterDelimiter("</think>".to_string()),
1921                48,
1922                &HashSet::new(),
1923                &[],
1924            )
1925            .unwrap()
1926            .unwrap();
1927        let mut generated = "reason!!"
1928            .bytes()
1929            .map(|byte| TokenId::new(byte as u32))
1930            .collect::<Vec<_>>();
1931
1932        for expected in "</think>".bytes() {
1933            let mut logits = vec![f32::NEG_INFINITY; EOS as usize + 1];
1934            let outcome = processor
1935                .mask_logits_with_terminals(
1936                    &mut logits,
1937                    &generated,
1938                    &HashSet::new(),
1939                    &HashSet::new(),
1940                )
1941                .unwrap();
1942            assert_eq!(outcome.phase, StructuredOutputPhase::ForcingDelimiter);
1943            assert_eq!(outcome.grammar_start_token_index, None);
1944            assert_eq!(outcome.required_delimiter_token_id, Some(expected as u32));
1945            assert_eq!(
1946                logits
1947                    .iter()
1948                    .enumerate()
1949                    .filter(|(_, logit)| logit.is_finite())
1950                    .map(|(token, _)| token)
1951                    .collect::<Vec<_>>(),
1952                vec![expected as usize]
1953            );
1954            generated.push(TokenId::new(expected as u32));
1955        }
1956
1957        let mut grammar_logits = vec![0.0; EOS as usize + 1];
1958        let outcome = processor
1959            .mask_logits_with_terminals(
1960                &mut grammar_logits,
1961                &generated,
1962                &HashSet::new(),
1963                &HashSet::new(),
1964            )
1965            .unwrap();
1966        assert_eq!(outcome.phase, StructuredOutputPhase::EnforcingGrammar);
1967        assert!(grammar_logits[b'{' as usize].is_finite());
1968        assert!(!grammar_logits[b'[' as usize].is_finite());
1969
1970        let progress = processor
1971            .progress_with_terminals(&generated, &HashSet::new())
1972            .unwrap();
1973        assert_eq!(progress.reasoning_token_count, Some(8));
1974        assert!(progress.boundary_forced);
1975        assert_eq!(
1976            progress.budget,
1977            Some(StructuredOutputBudgetPlan {
1978                total_output_tokens: 48,
1979                reasoning_token_limit: 8,
1980                boundary_token_count: 8,
1981                structured_reserve_tokens: 32,
1982            })
1983        );
1984
1985        processor.reset().unwrap();
1986        let reset_progress = processor
1987            .progress_with_terminals(&[], &HashSet::new())
1988            .unwrap();
1989        assert_eq!(
1990            reset_progress.phase,
1991            StructuredOutputPhase::WaitingForDelimiter
1992        );
1993        assert!(!reset_progress.boundary_forced);
1994        assert_eq!(reset_progress.reasoning_token_count, Some(0));
1995    }
1996
1997    #[test]
1998    fn reasoning_budget_reserves_half_of_a_normal_completion_for_structure() {
1999        assert_eq!(
2000            StructuredOutputBudgetPlan::automatic(1024, 1).unwrap(),
2001            StructuredOutputBudgetPlan {
2002                total_output_tokens: 1024,
2003                reasoning_token_limit: 511,
2004                boundary_token_count: 1,
2005                structured_reserve_tokens: 512,
2006            }
2007        );
2008    }
2009
2010    #[test]
2011    fn reasoning_delimiter_requires_room_beyond_the_boundary() {
2012        let error = factory()
2013            .create_processor(
2014                &ResponseFormat::JsonObject,
2015                &StructuredOutputStart::AfterDelimiter("</think>".to_string()),
2016                8,
2017                &HashSet::new(),
2018                &[],
2019            )
2020            .unwrap_err();
2021        assert!(error
2022            .to_string()
2023            .contains("max_tokens greater than its 8-token delimiter"));
2024    }
2025
2026    #[test]
2027    fn forcing_accounts_for_an_existing_delimiter_prefix() {
2028        let processor = factory()
2029            .create_processor(
2030                &ResponseFormat::JsonObject,
2031                &StructuredOutputStart::AfterDelimiter("</think>".to_string()),
2032                48,
2033                &HashSet::new(),
2034                &[],
2035            )
2036            .unwrap()
2037            .unwrap();
2038        let generated = "reason</"
2039            .bytes()
2040            .map(|byte| TokenId::new(byte as u32))
2041            .collect::<Vec<_>>();
2042        let mut logits = vec![0.0; EOS as usize + 1];
2043
2044        let outcome = processor
2045            .mask_logits_with_terminals(&mut logits, &generated, &HashSet::new(), &HashSet::new())
2046            .unwrap();
2047        assert_eq!(outcome.phase, StructuredOutputPhase::ForcingDelimiter);
2048        assert_eq!(outcome.required_delimiter_token_id, Some(b't' as u32));
2049        let progress = processor
2050            .progress_with_terminals(&generated, &HashSet::new())
2051            .unwrap();
2052        assert_eq!(progress.delimiter_prefix_token_count, 2);
2053        assert_eq!(progress.reasoning_token_count, Some(6));
2054    }
2055
2056    #[test]
2057    fn delimiter_rejects_any_conflicting_stop_condition_up_front() {
2058        let token_error = factory()
2059            .create_processor(
2060                &ResponseFormat::JsonObject,
2061                &StructuredOutputStart::AfterDelimiter("</think>".to_string()),
2062                TEST_MAX_OUTPUT_TOKENS,
2063                &HashSet::from([b'/' as u32]),
2064                &[],
2065            )
2066            .unwrap_err();
2067        assert!(token_error
2068            .to_string()
2069            .contains("conflicts with a stop token"));
2070
2071        let text_error = factory()
2072            .create_processor(
2073                &ResponseFormat::JsonObject,
2074                &StructuredOutputStart::AfterDelimiter("</think>".to_string()),
2075                TEST_MAX_OUTPUT_TOKENS,
2076                &HashSet::new(),
2077                &["think".to_string()],
2078            )
2079            .unwrap_err();
2080        assert!(text_error
2081            .to_string()
2082            .contains("conflicts with stop sequence"));
2083    }
2084}