Skip to main content

maskforge_core/
api.rs

1//! The stable, high-level facade: compile a schema once, prepare a vocabulary once, bind them,
2//! drive one generation sequence. Callers never inspect which backend a schema needs.
3//!
4//! ```no_run
5//! # use maskforge_core::{Compiler, CompilerOptions, CompiledVocabulary};
6//! # use std::sync::Arc;
7//! # fn go(vocabulary: Arc<maskforge_core::Vocabulary>, mask: &mut [u32]) -> Result<(), Box<dyn std::error::Error>> {
8//! let compiler = Compiler::new(CompilerOptions::default());
9//! let program = compiler.compile_json_schema(r#"{"type":"boolean"}"#)?;
10//! let vocabulary = CompiledVocabulary::try_from(vocabulary)?;
11//! let bound = program.bind(&vocabulary)?;
12//! let mut session = bound.start_session()?;
13//! session.write_mask(mask)?;
14//! # Ok(())
15//! # }
16//! ```
17
18use std::fmt;
19use std::sync::Arc;
20
21use crate::error::{CompileError, HashState, MatcherError, Provenance};
22use crate::frontend::SchemaResourceLimits;
23use crate::index::{BindMode, BoundByteTrie, TrieCache, VocabularyHandle};
24use crate::ir::CompileOptions;
25use crate::primitives::TokenId;
26use crate::runtime::{
27    CachedExecutable, CompiledArtifact, ExecutableCache, ExecutableCacheStats, ExecutableSchema,
28    Matcher,
29};
30use crate::structured::{StructuredMatcher, StructuredMatcherError, StructuredProgram};
31use crate::vocab::Vocabulary;
32
33/// How [`SchemaProgram::bind`] chooses a regular-schema bind mode; `Naive` stays low-level-only.
34#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
35#[non_exhaustive]
36pub enum BindPolicy {
37    /// Packed table when affordable, else lazy trie-backed rows. The default.
38    #[default]
39    Adaptive,
40    /// Always the packed table: fastest per-mask query, higher bind cost.
41    Eager,
42    /// Always lazy trie-backed rows: cheap bind, each row cached on first query.
43    Lazy,
44}
45
46/// Cache budgets a [`Compiler`] owns, in bytes rather than entry counts.
47#[derive(Clone, Copy, Debug)]
48#[non_exhaustive]
49pub struct CompilerOptions {
50    /// Byte budget for the vocabulary-independent executable cache. `0` disables caching.
51    pub executable_cache_bytes: usize,
52    /// Byte budget for the shared vocabulary byte-trie cache. `0` disables retention only.
53    pub trie_cache_bytes: usize,
54    /// How [`SchemaProgram::bind`] picks a regular-schema bind mode.
55    pub bind_policy: BindPolicy,
56}
57
58impl Default for CompilerOptions {
59    fn default() -> Self {
60        Self {
61            executable_cache_bytes: 256 << 20,
62            trie_cache_bytes: 256 << 20,
63            bind_policy: BindPolicy::Adaptive,
64        }
65    }
66}
67
68impl CompilerOptions {
69    /// Sets the executable cache budget, in bytes. `0` disables caching.
70    #[must_use]
71    pub fn with_executable_cache_bytes(mut self, bytes: usize) -> Self {
72        self.executable_cache_bytes = bytes;
73        self
74    }
75
76    /// Sets the shared vocabulary byte-trie cache budget, in bytes.
77    #[must_use]
78    pub fn with_trie_cache_bytes(mut self, bytes: usize) -> Self {
79        self.trie_cache_bytes = bytes;
80        self
81    }
82
83    /// Sets how [`SchemaProgram::bind`] picks a regular-schema bind mode.
84    #[must_use]
85    pub fn with_bind_policy(mut self, policy: BindPolicy) -> Self {
86        self.bind_policy = policy;
87        self
88    }
89}
90
91/// Current retention plus cumulative traffic for a [`Compiler`]'s trie cache.
92#[derive(Clone, Copy, Debug)]
93#[non_exhaustive]
94pub struct TrieCacheStats {
95    /// Number of vocabulary tries currently retained.
96    pub entries: usize,
97    /// Bytes currently retained across those entries.
98    pub bytes: usize,
99    /// Lifetime cache hits.
100    pub hits: u64,
101    /// Lifetime cache misses (a trie was built).
102    pub misses: u64,
103    /// Lifetime evictions (a retained trie dropped to make room for another).
104    pub evictions: u64,
105    /// Lifetime builds too large to retain under the budget (built and used, never cached).
106    pub oversized_bypasses: u64,
107}
108
109/// Owns schema-compilation cache state. Construct one per process (or per isolated tenant);
110/// nothing here is required to be a singleton.
111pub struct Compiler {
112    executable_cache: ExecutableCache,
113    trie_cache: Arc<TrieCache>,
114    bind_policy: BindPolicy,
115}
116
117impl Compiler {
118    /// A compiler with its own cache, budgeted per `options`.
119    #[must_use]
120    pub fn new(options: CompilerOptions) -> Self {
121        Self {
122            executable_cache: ExecutableCache::with_budget(options.executable_cache_bytes),
123            trie_cache: Arc::new(TrieCache::with_budget(options.trie_cache_bytes)),
124            bind_policy: options.bind_policy,
125        }
126    }
127
128    /// A compiler that caches nothing: every compile and bind rebuilds its artifacts.
129    /// Useful for deterministic tests or callers that cache `SchemaProgram` themselves.
130    #[must_use]
131    pub fn without_cache() -> Self {
132        Self::new(CompilerOptions {
133            executable_cache_bytes: 0,
134            trie_cache_bytes: 0,
135            bind_policy: BindPolicy::Adaptive,
136        })
137    }
138
139    /// Compiles JSON Schema text with default options. Vocabulary-independent: reuse the
140    /// returned [`SchemaProgram`] across every vocabulary that needs this schema.
141    pub fn compile_json_schema(&self, schema: &str) -> Result<SchemaProgram, CompileError> {
142        self.compile_json_schema_with_options(schema, CompileOptions::default())
143    }
144
145    /// Compiles JSON Schema text with explicit lowering options.
146    pub fn compile_json_schema_with_options(
147        &self,
148        schema: &str,
149        options: CompileOptions,
150    ) -> Result<SchemaProgram, CompileError> {
151        let cached = self.executable_cache.get_or_compile_json(schema, options)?;
152        Ok(self.wrap(cached))
153    }
154
155    /// Compiles against caller-supplied resources; never fetches a `$ref` target itself.
156    pub fn compile_json_schema_with_resources(
157        &self,
158        schema: &str,
159        retrieval_uri: Option<&str>,
160        resources: &[(String, String)],
161        options: CompileOptions,
162        limits: SchemaResourceLimits,
163    ) -> Result<SchemaProgram, CompileError> {
164        let cached = self.executable_cache.get_or_compile_schema_set(
165            schema,
166            retrieval_uri,
167            resources,
168            options,
169            limits,
170        )?;
171        Ok(self.wrap(cached))
172    }
173
174    /// Decodes and compiles a frozen MFIR wire payload (see [`crate::SchemaIR::to_wire`]).
175    pub fn compile_ir_wire(&self, wire: &[u8]) -> Result<SchemaProgram, CompileError> {
176        let cached = self.executable_cache.get_or_compile_wire(wire)?;
177        Ok(self.wrap(cached))
178    }
179
180    fn wrap(&self, cached: Arc<CachedExecutable>) -> SchemaProgram {
181        SchemaProgram {
182            cached,
183            trie_cache: Arc::clone(&self.trie_cache),
184            bind_policy: self.bind_policy,
185        }
186    }
187
188    /// Current retention plus cumulative traffic for the executable cache.
189    #[must_use]
190    pub fn cache_stats(&self) -> ExecutableCacheStats {
191        self.executable_cache.stats()
192    }
193
194    /// Current retention plus cumulative traffic for the shared vocabulary trie cache.
195    #[must_use]
196    pub fn trie_cache_stats(&self) -> TrieCacheStats {
197        let (hits, misses, evictions, oversized_bypasses) = self.trie_cache.stats();
198        TrieCacheStats {
199            entries: self.trie_cache.len(),
200            bytes: self.trie_cache.bytes(),
201            hits,
202            misses,
203            evictions,
204            oversized_bypasses,
205        }
206    }
207
208    /// Drops every cached executable and retained vocabulary trie; an existing `BoundSchema` keeps its own `Arc`-retained trie safely.
209    pub fn clear_caches(&self) {
210        self.executable_cache.clear();
211        self.trie_cache.clear();
212    }
213}
214
215/// A compiled schema, immutable and vocabulary-independent. Cheap to clone (an `Arc` handle);
216/// safe to share across threads and reuse across as many vocabularies as needed.
217#[derive(Clone)]
218pub struct SchemaProgram {
219    cached: Arc<CachedExecutable>,
220    trie_cache: Arc<TrieCache>,
221    bind_policy: BindPolicy,
222}
223
224impl SchemaProgram {
225    /// Binds this schema to a prepared vocabulary, trie-backed per [`BindPolicy`]; `self` stays reusable.
226    pub fn bind(&self, vocabulary: &CompiledVocabulary) -> Result<BoundSchema, CompileError> {
227        let handle = vocabulary.handle.clone();
228        let source_hash = HashState::Hash(self.cached.ir_hash());
229        let eos_token_id = TokenId(handle.eos_token_id());
230        let mask_vocab_size = handle.mask_vocab_size();
231        let inner = match self.cached.executable().as_ref() {
232            ExecutableSchema::Regular(engine) => {
233                let bound_trie = self.trie_cache.bind(&handle)?;
234                let requested = match self.bind_policy {
235                    BindPolicy::Lazy => BindMode::TrieJointByteLazy,
236                    BindPolicy::Eager | BindPolicy::Adaptive => BindMode::TrieJointBytePacked,
237                };
238                let mode = match self.bind_policy {
239                    BindPolicy::Adaptive => CompiledArtifact::recommended_bind_mode(
240                        engine.state_count(),
241                        mask_vocab_size,
242                        &bound_trie,
243                        requested,
244                    ),
245                    BindPolicy::Eager | BindPolicy::Lazy => requested,
246                };
247                let artifact = CompiledArtifact::new_from_bound_trie_shared(
248                    &handle,
249                    Arc::clone(engine),
250                    Provenance::reference(source_hash),
251                    mode,
252                    &bound_trie,
253                )?;
254                BoundSchemaKind::Regular(Arc::new(artifact))
255            }
256            ExecutableSchema::Structured(program) | ExecutableSchema::Hybrid(program) => {
257                let trie = program
258                    .uses_incremental_backend()
259                    .then(|| self.trie_cache.bind(&handle))
260                    .transpose()?;
261                BoundSchemaKind::Structured(Arc::clone(program), handle, trie)
262            }
263        };
264        Ok(BoundSchema {
265            inner,
266            eos_token_id,
267            mask_vocab_size,
268        })
269    }
270
271    /// Builds and binds a fresh [`CompiledVocabulary`] each call; prefer [`bind`](Self::bind) with a reused one.
272    pub fn bind_new_vocabulary(
273        &self,
274        vocabulary: Arc<Vocabulary>,
275    ) -> Result<BoundSchema, CompileError> {
276        self.bind(&CompiledVocabulary::new(vocabulary)?)
277    }
278}
279
280/// A prepared, reusable vocabulary; build once and reuse across binds. Cheap to clone.
281#[derive(Clone)]
282pub struct CompiledVocabulary {
283    handle: VocabularyHandle,
284}
285
286impl CompiledVocabulary {
287    /// Wraps `vocabulary`, inferring the mask width from its largest token/EOS id.
288    pub fn new(vocabulary: Arc<Vocabulary>) -> Result<Self, CompileError> {
289        Ok(Self {
290            handle: VocabularyHandle::new(vocabulary)?,
291        })
292    }
293
294    /// `new`, with an explicit mask width (e.g. a model whose logits are padded wider than the
295    /// tokenizer's own id range). Rejected if narrower than the largest token/EOS id present.
296    pub fn with_logits_vocab_size(
297        vocabulary: Arc<Vocabulary>,
298        logits_vocab_size: usize,
299    ) -> Result<Self, CompileError> {
300        Ok(Self {
301            handle: VocabularyHandle::new_with_logits_vocab_size(
302                vocabulary,
303                Some(logits_vocab_size),
304            )?,
305        })
306    }
307
308    /// Builds directly from four packed CSR buffers, skipping an intermediate
309    /// `Vocabulary`/`HashMap`.
310    pub fn from_packed(
311        token_bytes: &[u8],
312        byte_offsets: &[u32],
313        token_ids: &[u32],
314        id_offsets: &[u32],
315        eos_token_id: u32,
316    ) -> Result<Self, CompileError> {
317        Ok(Self {
318            handle: VocabularyHandle::from_packed(
319                token_bytes,
320                byte_offsets,
321                token_ids,
322                id_offsets,
323                eos_token_id,
324            )?,
325        })
326    }
327
328    /// Wraps an already-built `VocabularyHandle`, skipping re-canonicalization.
329    #[must_use]
330    pub fn from_handle(handle: VocabularyHandle) -> Self {
331        Self { handle }
332    }
333
334    /// The mask width: `write_mask` buffers must hold `ceil(mask_vocab_size / 32)` words.
335    #[must_use]
336    pub fn mask_vocab_size(&self) -> usize {
337        self.handle.mask_vocab_size()
338    }
339
340    /// This vocabulary's EOS token id.
341    #[must_use]
342    pub fn eos_token_id(&self) -> u32 {
343        self.handle.eos_token_id()
344    }
345
346    /// Exact raw bytes associated with `token_id`, or `None` for an absent/special id.
347    #[must_use]
348    pub fn token_bytes(&self, token_id: u32) -> Option<&[u8]> {
349        self.handle.token_bytes(token_id)
350    }
351}
352
353impl TryFrom<Arc<Vocabulary>> for CompiledVocabulary {
354    type Error = CompileError;
355
356    fn try_from(vocabulary: Arc<Vocabulary>) -> Result<Self, Self::Error> {
357        Self::new(vocabulary)
358    }
359}
360
361#[derive(Clone)]
362enum BoundSchemaKind {
363    Regular(Arc<CompiledArtifact>),
364    Structured(
365        Arc<StructuredProgram>,
366        VocabularyHandle,
367        Option<BoundByteTrie>,
368    ),
369}
370
371/// A schema bound to one vocabulary. Immutable; start as many independent [`Session`]s from it
372/// as there are concurrent generation sequences. Cheap to clone.
373#[derive(Clone)]
374pub struct BoundSchema {
375    inner: BoundSchemaKind,
376    eos_token_id: TokenId,
377    mask_vocab_size: usize,
378}
379
380impl BoundSchema {
381    /// The `u32` word count a [`Session::write_mask`] buffer must have: `ceil(vocab_size / 32)`.
382    #[must_use]
383    pub fn mask_word_count(&self) -> usize {
384        self.mask_vocab_size.div_ceil(32)
385    }
386
387    /// This binding's vocabulary width.
388    #[must_use]
389    pub fn mask_vocab_size(&self) -> usize {
390        self.mask_vocab_size
391    }
392
393    /// This binding's EOS token id.
394    #[must_use]
395    pub fn eos_token_id(&self) -> TokenId {
396        self.eos_token_id
397    }
398
399    /// Starts one fresh, independently mutable generation sequence, `Active` at its initial
400    /// state.
401    pub fn start_session(&self) -> Result<Session, SessionError> {
402        let inner = match &self.inner {
403            BoundSchemaKind::Regular(artifact) => {
404                SessionKind::Regular(Matcher::new(Arc::clone(artifact)))
405            }
406            BoundSchemaKind::Structured(program, handle, trie) => {
407                let matcher = program.new_matcher()?;
408                SessionKind::Structured(Box::new(StructuredSessionState {
409                    matcher,
410                    program: Arc::clone(program),
411                    vocabulary: handle.clone(),
412                    trie: trie.clone(),
413                }))
414            }
415        };
416        Ok(Session {
417            inner,
418            lifecycle: Lifecycle::Active,
419            eos_token_id: self.eos_token_id,
420            mask_vocab_size: self.mask_vocab_size,
421        })
422    }
423}
424
425struct StructuredSessionState {
426    matcher: StructuredMatcher,
427    program: Arc<StructuredProgram>,
428    vocabulary: VocabularyHandle,
429    trie: Option<BoundByteTrie>,
430}
431
432enum SessionKind {
433    Regular(Matcher),
434    Structured(Box<StructuredSessionState>),
435}
436
437#[derive(Clone, Copy, PartialEq, Eq, Debug)]
438enum Lifecycle {
439    /// Ordinary generation: legal tokens advance the position, EOS is legal only when
440    /// accepting.
441    Active,
442    /// EOS has been committed. No further token may be committed until `reset()`.
443    Stopped,
444}
445
446/// One generation sequence's mutable state. Never shared between concurrent sequences. Opaque:
447/// callers never inspect which backend it wraps.
448pub struct Session {
449    inner: SessionKind,
450    lifecycle: Lifecycle,
451    eos_token_id: TokenId,
452    mask_vocab_size: usize,
453}
454
455fn set_bit_le(mask: &mut [u8], id: u32) {
456    mask[(id / 8) as usize] |= 1u8 << (id % 8);
457}
458
459/// Reinterprets `words` as raw bytes, in place.
460// SAFETY: u8 has no alignment requirement and the byte length is exactly 4 * words.len().
461fn u32_words_as_bytes_mut(words: &mut [u32]) -> &mut [u8] {
462    unsafe { std::slice::from_raw_parts_mut(words.as_mut_ptr().cast::<u8>(), words.len() * 4) }
463}
464
465impl Session {
466    /// This session's vocabulary width.
467    #[must_use]
468    pub fn mask_vocab_size(&self) -> usize {
469        self.mask_vocab_size
470    }
471
472    /// The `u32` word count a [`Self::write_mask`] buffer must have.
473    #[must_use]
474    pub fn mask_word_count(&self) -> usize {
475        self.mask_vocab_size.div_ceil(32)
476    }
477
478    /// This session's EOS token id.
479    #[must_use]
480    pub fn eos_token_id(&self) -> TokenId {
481        self.eos_token_id
482    }
483
484    /// Returns the checked buffer length for [`Self::write_mask_le_bytes`].
485    /// Callers should use this instead of recomputing `mask_word_count() * 4`.
486    pub fn mask_byte_count(&self) -> Result<usize, SessionError> {
487        self.mask_word_count()
488            .checked_mul(4)
489            .ok_or(SessionError::MaskWidthOverflow {
490                mask_vocab_size: self.mask_vocab_size,
491            })
492    }
493
494    /// Fills `mask` (length must equal [`Self::mask_word_count`]) with the allowed-token bitmask; left zeroed on error.
495    /// Thin zero-copy wrapper over [`Self::write_mask_le_bytes`].
496    pub fn write_mask(&mut self, mask: &mut [u32]) -> Result<(), SessionError> {
497        let expected = self.mask_word_count();
498        if mask.len() != expected {
499            return Err(SessionError::MaskBufferLength {
500                expected,
501                actual: mask.len(),
502            });
503        }
504        self.write_mask_le_bytes(u32_words_as_bytes_mut(mask))?;
505        // The writer emits little-endian bytes; reading them back as native words needs one
506        // swap per word on a big-endian target.
507        #[cfg(target_endian = "big")]
508        for word in mask.iter_mut() {
509            *word = word.swap_bytes();
510        }
511        Ok(())
512    }
513
514    /// Fills `out` (length must equal [`Self::mask_byte_count`]) with the little-endian packed mask; left zeroed on error. Primary zero-extra-allocation path for both backends.
515    pub fn write_mask_le_bytes(&mut self, out: &mut [u8]) -> Result<(), SessionError> {
516        let expected = self.mask_byte_count()?;
517        if out.len() != expected {
518            return Err(SessionError::MaskByteBufferLength {
519                expected,
520                actual: out.len(),
521            });
522        }
523        out.fill(0);
524        if self.lifecycle == Lifecycle::Stopped {
525            set_bit_le(out, self.eos_token_id.get());
526            return Ok(());
527        }
528        let result = match &mut self.inner {
529            SessionKind::Regular(matcher) => {
530                let artifact = matcher.artifact();
531                artifact
532                    .write_mask_le_bytes_into(&[matcher.state()], out, None)
533                    .map_err(SessionError::from)
534            }
535            SessionKind::Structured(session) => session
536                .matcher
537                .write_mask_le_bytes_into(&session.vocabulary, session.trie.as_ref(), out)
538                .map_err(SessionError::Structured),
539        };
540        if result.is_err() {
541            out.fill(0);
542            return result;
543        }
544        if self.is_accepting() {
545            set_bit_le(out, self.eos_token_id.get());
546        }
547        Ok(())
548    }
549
550    /// Consumes one token; EOS transitions to `Stopped` instead of reaching the matcher.
551    pub fn advance(&mut self, token: TokenId) -> Result<(), SessionError> {
552        if self.lifecycle == Lifecycle::Stopped {
553            return Err(SessionError::SessionStopped);
554        }
555        if token == self.eos_token_id {
556            if !self.is_accepting() {
557                return Err(SessionError::IllegalToken { token });
558            }
559            self.lifecycle = Lifecycle::Stopped;
560            return Ok(());
561        }
562        match &mut self.inner {
563            SessionKind::Regular(matcher) => matcher.advance(token).map_err(SessionError::from),
564            SessionKind::Structured(session) => {
565                let bytes = session
566                    .vocabulary
567                    .token_bytes(token.get())
568                    .ok_or(SessionError::UnknownToken { token })?;
569                let accepted = session
570                    .matcher
571                    .advance(bytes)
572                    .map_err(SessionError::Structured)?;
573                if !accepted {
574                    return Err(SessionError::IllegalToken { token });
575                }
576                Ok(())
577            }
578        }
579    }
580
581    /// Returns to `Active` at the initial state, discarding any generated prefix. Transactional: builds the replacement before committing, so a failed reset changes nothing.
582    pub fn reset(&mut self) -> Result<(), SessionError> {
583        match &mut self.inner {
584            SessionKind::Regular(matcher) => {
585                *matcher = Matcher::new(Arc::clone(matcher.artifact()));
586            }
587            SessionKind::Structured(session) => {
588                let replacement = StructuredMatcher::new(Arc::clone(&session.program))
589                    .map_err(SessionError::Structured)?;
590                session.matcher = replacement;
591            }
592        }
593        self.lifecycle = Lifecycle::Active;
594        Ok(())
595    }
596
597    /// Stopping now (committing EOS) would yield a complete, valid instance. Independent of
598    /// [`Self::is_stopped`]: stays true after stopping too.
599    #[must_use]
600    pub fn is_accepting(&self) -> bool {
601        match &self.inner {
602            SessionKind::Regular(matcher) => matcher.is_accepting(),
603            SessionKind::Structured(session) => session.matcher.is_accepting(),
604        }
605    }
606
607    /// The permanent dead sink has been reached: no token can ever be legal again. Independent
608    /// of [`Self::is_stopped`].
609    #[must_use]
610    pub fn is_dead(&self) -> bool {
611        match &self.inner {
612            SessionKind::Regular(matcher) => matcher.is_dead(),
613            SessionKind::Structured(session) => session.matcher.is_dead(),
614        }
615    }
616
617    /// EOS has been committed; every `advance` raises until [`Self::reset`].
618    #[must_use]
619    pub fn is_stopped(&self) -> bool {
620        self.lifecycle == Lifecycle::Stopped
621    }
622}
623
624/// Errors from binding a schema or driving a [`Session`].
625#[derive(Debug)]
626#[non_exhaustive]
627pub enum SessionError {
628    /// Schema compilation or artifact-binding failed.
629    Compile(CompileError),
630    /// The regular-backend matcher rejected a token.
631    Matcher(MatcherError),
632    /// The structured-backend matcher hit a resource limit or a compile error.
633    Structured(StructuredMatcherError),
634    /// A token was rejected: no path from the current position (either backend).
635    IllegalToken {
636        /// The rejected token id.
637        token: TokenId,
638    },
639    /// `advance` was given a token id absent from the bound vocabulary.
640    UnknownToken {
641        /// The unrecognized token id.
642        token: TokenId,
643    },
644    /// `advance` was called on a session that already committed EOS; call `reset()` first.
645    SessionStopped,
646    /// `write_mask`'s output buffer length did not match the vocabulary's word count.
647    MaskBufferLength {
648        /// The required length, in `u32` words.
649        expected: usize,
650        /// The length the caller actually supplied.
651        actual: usize,
652    },
653    /// `write_mask_le_bytes`'s output buffer length did not match the vocabulary's byte width.
654    MaskByteBufferLength {
655        /// The required length, in bytes.
656        expected: usize,
657        /// The length the caller actually supplied.
658        actual: usize,
659    },
660    /// The vocabulary is too wide for its packed mask to be addressed on this target.
661    MaskWidthOverflow {
662        /// The vocabulary width whose byte length overflowed `usize`.
663        mask_vocab_size: usize,
664    },
665}
666
667impl fmt::Display for SessionError {
668    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
669        match self {
670            Self::Compile(e) => write!(f, "{e}"),
671            Self::Matcher(e) => write!(f, "{e}"),
672            Self::Structured(e) => write!(f, "{e}"),
673            Self::IllegalToken { token } => write!(f, "[IllegalToken] token {}", token.get()),
674            Self::UnknownToken { token } => write!(f, "unknown token id {}", token.get()),
675            Self::SessionStopped => write!(f, "[SessionStopped] session already committed EOS"),
676            Self::MaskBufferLength { expected, actual } => write!(
677                f,
678                "mask buffer must contain exactly {expected} words, got {actual}"
679            ),
680            Self::MaskByteBufferLength { expected, actual } => write!(
681                f,
682                "mask byte buffer must contain exactly {expected} bytes, got {actual}"
683            ),
684            Self::MaskWidthOverflow { mask_vocab_size } => write!(
685                f,
686                "vocabulary width {mask_vocab_size} overflows the packed mask byte length"
687            ),
688        }
689    }
690}
691
692impl std::error::Error for SessionError {
693    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
694        match self {
695            Self::Compile(e) => Some(e),
696            Self::Matcher(e) => Some(e),
697            Self::Structured(e) => Some(e),
698            _ => None,
699        }
700    }
701}
702
703impl From<CompileError> for SessionError {
704    fn from(e: CompileError) -> Self {
705        Self::Compile(e)
706    }
707}
708
709impl From<MatcherError> for SessionError {
710    fn from(e: MatcherError) -> Self {
711        Self::Matcher(e)
712    }
713}
714
715impl From<StructuredMatcherError> for SessionError {
716    fn from(e: StructuredMatcherError) -> Self {
717        Self::Structured(e)
718    }
719}
720
721#[cfg(test)]
722mod tests {
723    use super::*;
724    use crate::build_vocabulary;
725    use rustc_hash::FxHashMap;
726
727    fn vocabulary(eos: u32) -> CompiledVocabulary {
728        let map: FxHashMap<Vec<u8>, Vec<u32>> =
729            (0u32..256).map(|b| (vec![b as u8], vec![b])).collect();
730        CompiledVocabulary::new(Arc::new(build_vocabulary(eos, map).unwrap())).unwrap()
731    }
732
733    fn eos_bit_set(mask: &[u32], eos: u32) -> bool {
734        (mask[(eos / 32) as usize] >> (eos % 32)) & 1 == 1
735    }
736
737    #[test]
738    fn regular_write_mask_omits_eos_while_active_and_not_accepting() {
739        let compiler = Compiler::new(CompilerOptions::default());
740        let vocab = vocabulary(256);
741        let bound = compiler
742            .compile_json_schema(r#"{"type":"boolean"}"#)
743            .unwrap()
744            .bind(&vocab)
745            .unwrap();
746        let mut session = bound.start_session().unwrap();
747        let mut mask = vec![0u32; bound.mask_word_count()];
748        session.write_mask(&mut mask).unwrap();
749        assert!(
750            !eos_bit_set(&mask, 256),
751            "EOS must not be legal before any content is generated"
752        );
753    }
754
755    #[test]
756    fn regular_write_mask_sets_eos_exactly_when_accepting() {
757        let compiler = Compiler::new(CompilerOptions::default());
758        let vocab = vocabulary(256);
759        let bound = compiler
760            .compile_json_schema(r#"{"type":"boolean"}"#)
761            .unwrap()
762            .bind(&vocab)
763            .unwrap();
764        let mut session = bound.start_session().unwrap();
765        for byte in b"true" {
766            session
767                .advance(TokenId::try_from(*byte as usize).unwrap())
768                .unwrap();
769        }
770        assert!(session.is_accepting());
771        let mut mask = vec![0u32; bound.mask_word_count()];
772        session.write_mask(&mut mask).unwrap();
773        assert!(
774            eos_bit_set(&mask, 256),
775            "EOS must be legal once the prefix is a complete value"
776        );
777    }
778
779    #[test]
780    fn structured_write_mask_sets_eos_exactly_when_accepting() {
781        let compiler = Compiler::new(CompilerOptions::default());
782        let vocab = vocabulary(256);
783        let bound = compiler
784            .compile_json_schema(r#"{"not":{"type":"string"}}"#)
785            .unwrap()
786            .bind(&vocab)
787            .unwrap();
788        let mut session = bound.start_session().unwrap();
789        let mut mask = vec![0u32; bound.mask_word_count()];
790        session.write_mask(&mut mask).unwrap();
791        assert!(!eos_bit_set(&mask, 256));
792        session
793            .advance(TokenId::try_from(b'4' as usize).unwrap())
794            .unwrap();
795        assert!(session.is_accepting());
796        session.write_mask(&mut mask).unwrap();
797        assert!(
798            eos_bit_set(&mask, 256),
799            "a single digit is a complete number"
800        );
801    }
802
803    #[test]
804    fn eos_before_acceptance_is_illegal_and_leaves_state_unchanged() {
805        let compiler = Compiler::new(CompilerOptions::default());
806        let vocab = vocabulary(256);
807        let bound = compiler
808            .compile_json_schema(r#"{"type":"boolean"}"#)
809            .unwrap()
810            .bind(&vocab)
811            .unwrap();
812        let mut session = bound.start_session().unwrap();
813        let err = session.advance(TokenId(256)).unwrap_err();
814        assert!(matches!(err, SessionError::IllegalToken { .. }));
815        assert!(!session.is_stopped());
816        // still usable: a legal ordinary token still advances normally.
817        session
818            .advance(TokenId::try_from(b't' as usize).unwrap())
819            .unwrap();
820    }
821
822    #[test]
823    fn eos_at_acceptance_stops_without_reaching_the_matcher() {
824        let compiler = Compiler::new(CompilerOptions::default());
825        let vocab = vocabulary(256);
826        let bound = compiler
827            .compile_json_schema(r#"{"type":"boolean"}"#)
828            .unwrap()
829            .bind(&vocab)
830            .unwrap();
831        let mut session = bound.start_session().unwrap();
832        for byte in b"true" {
833            session
834                .advance(TokenId::try_from(*byte as usize).unwrap())
835                .unwrap();
836        }
837        session.advance(TokenId(256)).unwrap();
838        assert!(session.is_stopped());
839    }
840
841    #[test]
842    fn stopped_session_rejects_every_further_advance() {
843        let compiler = Compiler::new(CompilerOptions::default());
844        let vocab = vocabulary(256);
845        let bound = compiler
846            .compile_json_schema(r#"{"type":"boolean"}"#)
847            .unwrap()
848            .bind(&vocab)
849            .unwrap();
850        let mut session = bound.start_session().unwrap();
851        for byte in b"true" {
852            session
853                .advance(TokenId::try_from(*byte as usize).unwrap())
854                .unwrap();
855        }
856        session.advance(TokenId(256)).unwrap();
857        let err = session
858            .advance(TokenId::try_from(b' ' as usize).unwrap())
859            .unwrap_err();
860        assert!(matches!(err, SessionError::SessionStopped));
861        let err = session.advance(TokenId(256)).unwrap_err();
862        assert!(matches!(err, SessionError::SessionStopped));
863    }
864
865    #[test]
866    fn stopped_mask_is_eos_only() {
867        let compiler = Compiler::new(CompilerOptions::default());
868        let vocab = vocabulary(256);
869        let bound = compiler
870            .compile_json_schema(r#"{"type":"boolean"}"#)
871            .unwrap()
872            .bind(&vocab)
873            .unwrap();
874        let mut session = bound.start_session().unwrap();
875        for byte in b"true" {
876            session
877                .advance(TokenId::try_from(*byte as usize).unwrap())
878                .unwrap();
879        }
880        session.advance(TokenId(256)).unwrap();
881        let mut mask = vec![0xFFFF_FFFFu32; bound.mask_word_count()]; // dirty buffer
882        session.write_mask(&mut mask).unwrap();
883        for (i, word) in mask.iter().enumerate() {
884            let expected = if i == 8 { 1u32 } else { 0u32 };
885            assert_eq!(*word, expected, "word {i} must be EOS-only");
886        }
887    }
888
889    #[test]
890    fn reset_returns_to_active_at_the_initial_state() {
891        let compiler = Compiler::new(CompilerOptions::default());
892        let vocab = vocabulary(256);
893        let bound = compiler
894            .compile_json_schema(r#"{"type":"boolean"}"#)
895            .unwrap()
896            .bind(&vocab)
897            .unwrap();
898        let mut session = bound.start_session().unwrap();
899        for byte in b"true" {
900            session
901                .advance(TokenId::try_from(*byte as usize).unwrap())
902                .unwrap();
903        }
904        session.advance(TokenId(256)).unwrap();
905        assert!(session.is_stopped());
906        session.reset().unwrap();
907        assert!(!session.is_stopped());
908        assert!(!session.is_accepting());
909        // usable again from the initial state.
910        for byte in b"false" {
911            session
912                .advance(TokenId::try_from(*byte as usize).unwrap())
913                .unwrap();
914        }
915        assert!(session.is_accepting());
916    }
917
918    #[test]
919    fn structured_session_reset_also_returns_to_active() {
920        let compiler = Compiler::new(CompilerOptions::default());
921        let vocab = vocabulary(256);
922        let bound = compiler
923            .compile_json_schema(r#"{"not":{"type":"string"}}"#)
924            .unwrap()
925            .bind(&vocab)
926            .unwrap();
927        let mut session = bound.start_session().unwrap();
928        session
929            .advance(TokenId::try_from(b'4' as usize).unwrap())
930            .unwrap();
931        session.advance(TokenId(256)).unwrap();
932        assert!(session.is_stopped());
933        session.reset().unwrap();
934        assert!(!session.is_stopped());
935        assert!(!session.is_accepting());
936    }
937
938    #[test]
939    fn accessors_agree_with_the_vocabulary() {
940        let compiler = Compiler::new(CompilerOptions::default());
941        let vocab = vocabulary(256);
942        let bound = compiler
943            .compile_json_schema(r#"{"type":"boolean"}"#)
944            .unwrap()
945            .bind(&vocab)
946            .unwrap();
947        assert_eq!(bound.eos_token_id(), TokenId(256));
948        assert_eq!(bound.mask_vocab_size(), vocab.mask_vocab_size());
949        let session = bound.start_session().unwrap();
950        assert_eq!(session.eos_token_id(), TokenId(256));
951        assert_eq!(session.mask_vocab_size(), vocab.mask_vocab_size());
952        assert_eq!(session.mask_word_count(), bound.mask_word_count());
953    }
954
955    #[test]
956    fn compiled_vocabulary_is_reused_across_two_binds() {
957        let compiler = Compiler::new(CompilerOptions::default());
958        let vocab = vocabulary(256);
959        let program = compiler
960            .compile_json_schema(r#"{"type":"boolean"}"#)
961            .unwrap();
962        let bound_a = program.bind(&vocab).unwrap();
963        let bound_b = program.bind(&vocab).unwrap();
964        assert_eq!(bound_a.mask_vocab_size(), bound_b.mask_vocab_size());
965    }
966
967    #[test]
968    fn bound_schema_is_clone_and_sessions_stay_independent() {
969        let compiler = Compiler::new(CompilerOptions::default());
970        let vocab = vocabulary(256);
971        let bound = compiler
972            .compile_json_schema(r#"{"type":"boolean"}"#)
973            .unwrap()
974            .bind(&vocab)
975            .unwrap();
976        let cloned = bound.clone();
977        let mut a = bound.start_session().unwrap();
978        let mut b = cloned.start_session().unwrap();
979        for byte in b"true" {
980            a.advance(TokenId::try_from(*byte as usize).unwrap())
981                .unwrap();
982        }
983        assert!(a.is_accepting());
984        // b, from the cloned BoundSchema, must still be at its own untouched initial state.
985        assert!(!b.is_accepting());
986        for byte in b"false" {
987            b.advance(TokenId::try_from(*byte as usize).unwrap())
988                .unwrap();
989        }
990        assert!(b.is_accepting());
991    }
992
993    #[test]
994    fn compile_with_resources_resolves_a_caller_supplied_ref() {
995        let compiler = Compiler::new(CompilerOptions::default());
996        let resources = vec![(
997            "https://example.com/a.json".to_string(),
998            r#"{"$anchor":"value","type":"integer"}"#.to_string(),
999        )];
1000        let program = compiler
1001            .compile_json_schema_with_resources(
1002                r#"{"$ref":"a.json#value"}"#,
1003                Some("https://example.com/root.json"),
1004                &resources,
1005                CompileOptions::default(),
1006                SchemaResourceLimits::default(),
1007            )
1008            .unwrap();
1009        let vocab = vocabulary(256);
1010        let bound = program.bind(&vocab).unwrap();
1011        let mut session = bound.start_session().unwrap();
1012        session
1013            .advance(TokenId::try_from(b'4' as usize).unwrap())
1014            .unwrap();
1015        assert!(session.is_accepting());
1016    }
1017
1018    #[test]
1019    fn compiled_vocabulary_try_from_matches_new() {
1020        let raw = Arc::new(build_vocabulary(256, FxHashMap::default()).unwrap());
1021        let via_try_from = CompiledVocabulary::try_from(Arc::clone(&raw)).unwrap();
1022        let via_new = CompiledVocabulary::new(raw).unwrap();
1023        assert_eq!(via_try_from.eos_token_id(), via_new.eos_token_id());
1024    }
1025
1026    #[test]
1027    fn compiled_vocabulary_from_handle_matches_new() {
1028        let raw = Arc::new(build_vocabulary(256, FxHashMap::default()).unwrap());
1029        let handle = VocabularyHandle::new(Arc::clone(&raw)).unwrap();
1030        let via_handle = CompiledVocabulary::from_handle(handle);
1031        let via_new = CompiledVocabulary::new(raw).unwrap();
1032        assert_eq!(via_handle.eos_token_id(), via_new.eos_token_id());
1033        assert_eq!(via_handle.mask_vocab_size(), via_new.mask_vocab_size());
1034    }
1035
1036    #[test]
1037    fn advancing_an_unknown_token_id_is_a_typed_error_not_a_panic() {
1038        let compiler = Compiler::new(CompilerOptions::default());
1039        let vocab = vocabulary(256);
1040        let bound = compiler
1041            .compile_json_schema(r#"{"not":{"type":"string"}}"#)
1042            .unwrap()
1043            .bind(&vocab)
1044            .unwrap();
1045        let mut session = bound.start_session().unwrap();
1046        let err = session
1047            .advance(TokenId::try_from(9999usize).unwrap())
1048            .unwrap_err();
1049        assert!(matches!(err, SessionError::UnknownToken { .. }));
1050    }
1051
1052    #[test]
1053    fn one_program_binds_to_two_vocabularies_independently() {
1054        let compiler = Compiler::new(CompilerOptions::default());
1055        let program = compiler
1056            .compile_json_schema(r#"{"type":"boolean"}"#)
1057            .unwrap();
1058        let bound_a = program.bind(&vocabulary(256)).unwrap();
1059        let bound_b = program.bind(&vocabulary(257)).unwrap();
1060        let mut session_a = bound_a.start_session().unwrap();
1061        let session_b = bound_b.start_session().unwrap();
1062        for byte in b"true" {
1063            session_a
1064                .advance(TokenId::try_from(*byte as usize).unwrap())
1065                .unwrap();
1066        }
1067        assert!(session_a.is_accepting());
1068        assert!(!session_b.is_accepting());
1069    }
1070
1071    #[test]
1072    fn without_cache_still_compiles_correctly_and_caches_nothing() {
1073        let compiler = Compiler::without_cache();
1074        let schema = r#"{"type":"boolean"}"#;
1075        compiler.compile_json_schema(schema).unwrap();
1076        compiler.compile_json_schema(schema).unwrap();
1077        assert_eq!(compiler.cache_stats().entries, 0);
1078    }
1079
1080    #[test]
1081    fn clear_caches_resets_retention() {
1082        let compiler = Compiler::new(CompilerOptions::default());
1083        compiler
1084            .compile_json_schema(r#"{"type":"boolean"}"#)
1085            .unwrap();
1086        assert!(compiler.cache_stats().entries > 0);
1087        compiler.clear_caches();
1088        assert_eq!(compiler.cache_stats().entries, 0);
1089    }
1090
1091    #[test]
1092    fn a_reused_schema_program_hits_the_compiler_cache() {
1093        let compiler = Compiler::new(CompilerOptions::default());
1094        let schema = r#"{"type":"boolean"}"#;
1095        compiler.compile_json_schema(schema).unwrap();
1096        let misses_after_first = compiler.cache_stats().misses;
1097        compiler.compile_json_schema(schema).unwrap();
1098        assert_eq!(compiler.cache_stats().misses, misses_after_first);
1099        assert!(compiler.cache_stats().hits > 0);
1100    }
1101
1102    #[test]
1103    fn default_bind_policy_is_trie_backed_never_naive() {
1104        let compiler = Compiler::new(CompilerOptions::default());
1105        let vocab = vocabulary(256);
1106        let bound = compiler
1107            .compile_json_schema(r#"{"type":"boolean"}"#)
1108            .unwrap()
1109            .bind(&vocab)
1110            .unwrap();
1111        let BoundSchemaKind::Regular(artifact) = &bound.inner else {
1112            panic!("a boolean schema binds to the regular backend");
1113        };
1114        assert_ne!(artifact.bind_mode(), crate::index::BindMode::Naive);
1115    }
1116
1117    #[test]
1118    fn eager_lazy_adaptive_masks_equal_the_naive_oracle_across_states() {
1119        let vocab = vocabulary(256);
1120        let schema = r#"{"type":"boolean"}"#;
1121        let reference = Compiler::new(CompilerOptions::default());
1122        let program = reference.compile_json_schema(schema).unwrap();
1123        let engine = match program.cached.executable().as_ref() {
1124            ExecutableSchema::Regular(engine) => Arc::clone(engine),
1125            _ => panic!("a boolean schema compiles to the regular backend"),
1126        };
1127        let source_hash = HashState::Hash(program.cached.ir_hash());
1128
1129        for policy in [BindPolicy::Eager, BindPolicy::Lazy, BindPolicy::Adaptive] {
1130            let naive = CompiledArtifact::new_from_handle_shared(
1131                &vocab.handle,
1132                Arc::clone(&engine),
1133                Provenance::reference(source_hash),
1134            )
1135            .unwrap();
1136            let mut naive_matcher = Matcher::new(Arc::new(naive));
1137
1138            let policy_compiler = Compiler::new(CompilerOptions {
1139                bind_policy: policy,
1140                ..CompilerOptions::default()
1141            });
1142            let bound = policy_compiler
1143                .compile_json_schema(schema)
1144                .unwrap()
1145                .bind(&vocab)
1146                .unwrap();
1147            let mut session = bound.start_session().unwrap();
1148
1149            for byte in b"true" {
1150                let mut mask = vec![0u32; bound.mask_word_count()];
1151                session.write_mask(&mut mask).unwrap();
1152                let mut naive_mask = vec![0u32; bound.mask_word_count()];
1153                naive_matcher
1154                    .artifact()
1155                    .write_mask_into(&[naive_matcher.state()], &mut naive_mask, None)
1156                    .unwrap();
1157                assert_eq!(
1158                    mask, naive_mask,
1159                    "{policy:?} mask disagrees with the naive oracle"
1160                );
1161
1162                let token = TokenId::try_from(*byte as usize).unwrap();
1163                session.advance(token).unwrap();
1164                naive_matcher.advance(token).unwrap();
1165            }
1166        }
1167    }
1168
1169    #[test]
1170    fn structured_trie_mask_equals_the_record_scan_oracle() {
1171        use crate::frontend::schema_to_ir;
1172        let vocab = vocabulary(256);
1173        let schema = r#"{"not":{"type":"string"}}"#;
1174        let ir = Arc::new(schema_to_ir(schema, CompileOptions::default()).unwrap());
1175        let program = StructuredProgram::compile(ir).unwrap();
1176        let mut oracle = StructuredMatcher::new(Arc::clone(&program)).unwrap();
1177
1178        let compiler = Compiler::new(CompilerOptions::default());
1179        let bound = compiler
1180            .compile_json_schema(schema)
1181            .unwrap()
1182            .bind(&vocab)
1183            .unwrap();
1184        let mut session = bound.start_session().unwrap();
1185
1186        let eos_word = 256usize / 32;
1187        let eos_bit = 1u32 << (256u32 % 32);
1188        let mut mask = vec![0u32; bound.mask_word_count()];
1189        let mut oracle_mask = vec![0u32; bound.mask_word_count()];
1190
1191        session.write_mask(&mut mask).unwrap();
1192        oracle
1193            .write_record_mask_words_into(
1194                vocab.mask_vocab_size(),
1195                vocab.handle.iter_records(),
1196                &mut oracle_mask,
1197            )
1198            .unwrap();
1199        assert_eq!(mask, oracle_mask, "at the initial state");
1200
1201        session
1202            .advance(TokenId::try_from(b'4' as usize).unwrap())
1203            .unwrap();
1204        oracle.advance(b"4").unwrap();
1205        session.write_mask(&mut mask).unwrap();
1206        mask[eos_word] &= !eos_bit; // EOS is a side predicate, absent from the record-scan oracle
1207        oracle
1208            .write_record_mask_words_into(
1209                vocab.mask_vocab_size(),
1210                vocab.handle.iter_records(),
1211                &mut oracle_mask,
1212            )
1213            .unwrap();
1214        assert_eq!(
1215            mask, oracle_mask,
1216            "at the accepting state's ordinary-token bits"
1217        );
1218    }
1219
1220    #[test]
1221    fn write_mask_le_bytes_rejects_wrong_length_and_zeroes_output() {
1222        let compiler = Compiler::new(CompilerOptions::default());
1223        let vocab = vocabulary(256);
1224        let bound = compiler
1225            .compile_json_schema(r#"{"type":"boolean"}"#)
1226            .unwrap()
1227            .bind(&vocab)
1228            .unwrap();
1229        let mut session = bound.start_session().unwrap();
1230        let mut out = vec![0xFFu8; 4 * bound.mask_word_count() - 1];
1231        let err = session.write_mask_le_bytes(&mut out).unwrap_err();
1232        assert!(matches!(err, SessionError::MaskByteBufferLength { .. }));
1233    }
1234
1235    #[test]
1236    fn write_mask_le_bytes_matches_write_mask_bit_for_bit() {
1237        let compiler = Compiler::new(CompilerOptions::default());
1238        let vocab = vocabulary(256);
1239        let bound = compiler
1240            .compile_json_schema(r#"{"type":"boolean"}"#)
1241            .unwrap()
1242            .bind(&vocab)
1243            .unwrap();
1244        let mut session = bound.start_session().unwrap();
1245        for byte in b"tru" {
1246            session
1247                .advance(TokenId::try_from(*byte as usize).unwrap())
1248                .unwrap();
1249        }
1250        let mut words = vec![0u32; bound.mask_word_count()];
1251        session.write_mask(&mut words).unwrap();
1252        let mut bytes = vec![0u8; 4 * bound.mask_word_count()];
1253        session.write_mask_le_bytes(&mut bytes).unwrap();
1254        let words_from_bytes: Vec<u32> = bytes
1255            .chunks_exact(4)
1256            .map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]]))
1257            .collect();
1258        assert_eq!(words, words_from_bytes);
1259    }
1260
1261    #[test]
1262    fn compiler_without_cache_also_builds_no_retained_trie() {
1263        let compiler = Compiler::without_cache();
1264        let vocab = vocabulary(256);
1265        compiler
1266            .compile_json_schema(r#"{"type":"boolean"}"#)
1267            .unwrap()
1268            .bind(&vocab)
1269            .unwrap();
1270        assert_eq!(compiler.trie_cache_stats().entries, 0);
1271    }
1272
1273    #[test]
1274    fn clear_caches_also_clears_the_trie_cache() {
1275        let compiler = Compiler::new(CompilerOptions::default());
1276        let vocab = vocabulary(256);
1277        compiler
1278            .compile_json_schema(r#"{"type":"boolean"}"#)
1279            .unwrap()
1280            .bind(&vocab)
1281            .unwrap();
1282        assert!(compiler.trie_cache_stats().entries > 0);
1283        compiler.clear_caches();
1284        assert_eq!(compiler.trie_cache_stats().entries, 0);
1285    }
1286}