Skip to main content

maskforge_core/
error.rs

1//! The error spine: stable machine-readable codes are the contract; the English `message` is advisory.
2
3use crate::primitives::TokenId;
4
5/// Stable, machine-readable error code. Callers (including Python) key on this, never on text.
6#[non_exhaustive]
7#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
8pub enum ErrorCode {
9    /// A keyword/shape the engine deliberately does not support (no silent downgrade).
10    Unsupported,
11    /// Structurally invalid input (e.g. empty enum, array `max < min`, duplicate object keys).
12    Malformed,
13    /// A `$ref`/recursion cycle was detected.
14    RecursionDetected,
15    /// A recursive semantic equation is non-monotone and has no defined bounded fixed point.
16    NonMonotoneRecursion,
17    /// A URI or URI reference is malformed or cannot be resolved against its active base.
18    InvalidUri,
19    /// A referenced schema resource, pointer, or anchor cannot be resolved.
20    ReferenceResolution,
21    /// Two schema resources claim the same canonical URI.
22    DuplicateResource,
23    /// Two static or dynamic anchors claim the same name in one resource.
24    DuplicateAnchor,
25    /// A resource cap was exceeded. The variant is a typed seam; it enforces no numeric bound.
26    InternalLimitExceeded,
27    /// A vocabulary token had zero bytes.
28    EmptyToken,
29    /// Tokenizer metadata (not a payload) was malformed.
30    MalformedTokenizer,
31    /// A packed mask access fell outside the buffer.
32    ArtifactOutOfBounds,
33    /// A provenance field required by its own profile was absent.
34    ProvenanceIncomplete,
35    /// A token was fed to a dead/non-advancing matcher.
36    IllegalToken,
37    /// A cached artifact did not match its expected fingerprint.
38    ArtifactMismatch,
39    /// The bounded diagnostics buffer filled before lowering finished.
40    TooManyDiagnostics,
41}
42
43/// The compilation stage an error arose in.
44#[non_exhaustive]
45#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
46pub enum Stage {
47    /// Schema-frontend parsing.
48    L1,
49    /// IR lowering and canonicalization.
50    L2,
51    /// Byte-level automaton construction.
52    L3,
53    /// Vocabulary binding.
54    L4Bind,
55    /// Vocabulary construction, the single validated entry point.
56    VocabBuild,
57    /// Compiled-artifact validation.
58    ArtifactValidate,
59}
60
61/// Resource-limit kinds. The variants and error type are stable; no numeric bound is enforced
62/// for any of them.
63#[non_exhaustive]
64#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
65pub enum LimitKind {
66    /// Number of members in an enum.
67    EnumSize,
68    /// Length of an array.
69    ArrayLength,
70    /// Number of IR nodes.
71    NodeCount,
72    /// Depth of nested recursion.
73    RecursionDepth,
74    /// Number of reference states.
75    StateCount,
76    /// Number of stored transitions.
77    TransitionCount,
78    /// Number of independently orderable shuffle fields.
79    ShuffleFields,
80    /// Number of states admitted by one shuffle construction.
81    ShuffleStates,
82    /// Number of edges admitted by one shuffle construction.
83    ShuffleEdges,
84    /// Number of branches admitted by one product construction.
85    ProductBranches,
86    /// Number of states admitted by one product construction.
87    ProductStates,
88    /// Number of edges admitted by one product construction.
89    ProductEdges,
90    /// Size of the vocabulary.
91    VocabSize,
92    /// Byte length of a single token.
93    TokenByteLen,
94    /// Size in bytes of a compiled artifact.
95    ArtifactBytes,
96    /// Number of properties observed on one object instance.
97    PropertyCount,
98    /// Byte length of the accumulated document a session has processed.
99    DocumentBytes,
100    /// Byte length of one object property key.
101    KeyBytes,
102    /// Byte length of one string value.
103    StringBytes,
104    /// Byte length of one number literal.
105    NumberBytes,
106    /// Number of simultaneously active nested validators (e.g. `dependentSchemas`/`unevaluated*`).
107    ActiveValidators,
108    /// Retained bytes in the `uniqueItems` canonical-value arena.
109    CanonicalBytes,
110    /// Number of entries in a session's undo log.
111    UndoEntries,
112    /// Accounted work units spent computing one mask.
113    MaskWork,
114    /// Retained bytes in one structured matcher traversal stack.
115    MaskScratchBytes,
116    /// Size in bytes of a compiled `StructuredPlan`.
117    PlanBytes,
118    /// Retained bytes of one session's mutable state.
119    SessionBytes,
120    /// Bytes requested from an allocator for a bounded runtime container.
121    AllocationBytes,
122    /// Number of registered schema documents.
123    DocumentCount,
124    /// Number of values retained by parsed schema ASTs.
125    AstNodeCount,
126    /// Heap bytes retained by parsed schema ASTs.
127    AstBytes,
128    /// Number of discovered schema resources.
129    ResourceCount,
130    /// Aggregate bytes of registered schema documents.
131    InputBytes,
132    /// Source bytes accepted by the canonical ECMA pattern lowering path.
133    PatternInputBytes,
134    /// Estimated syntax/HIR nodes produced by pattern parsing.
135    PatternHirNodes,
136    /// Thompson NFA bytes or states retained during pattern construction.
137    PatternNfaBytes,
138    /// Determinized DFA bytes or states retained during pattern construction.
139    PatternDfaBytes,
140    /// Bounded pattern parsing and automaton-construction work units.
141    PatternCompileWork,
142    /// URI bytes retained by the resource graph.
143    UriBytes,
144    /// Number of static and dynamic anchors.
145    AnchorCount,
146    /// Number of `$ref` and `$dynamicRef` occurrences.
147    ReferenceCount,
148    /// Number of indexed schema locations.
149    SchemaLocationCount,
150    /// Nested schema-resource depth established by `$id`.
151    ResourceDepth,
152    /// Number of schema resources retained in one runtime dynamic-scope chain.
153    DynamicScopeDepth,
154    /// Bounded resource-discovery and reference-resolution work.
155    ResolutionWork,
156    /// Retained bytes in the frozen schema-resource graph.
157    ResourceGraphBytes,
158    /// Peak bytes retained while constructing a schema-resource graph.
159    ResourceBuildBytes,
160}
161
162/// A structured compile-time error. `Send + Sync`; the machine contract is the fields, not `message`.
163#[derive(Clone, PartialEq, Eq, Debug)]
164pub struct CompileError {
165    /// Stable machine code.
166    pub code: ErrorCode,
167    /// The stage the error arose in.
168    pub stage: Stage,
169    /// JSON Pointer (RFC 6901) to the offending node, when known.
170    pub json_pointer_path: Option<String>,
171    /// The offending keyword, when applicable.
172    pub keyword: Option<String>,
173    /// The observed value/shape, when useful for diagnostics.
174    pub observed: Option<String>,
175    /// `(kind, observed, cap)` when a resource limit produced the error.
176    pub limit: Option<(LimitKind, usize, usize)>,
177    /// Whether a fallback path exists for this error.
178    pub recoverable: bool,
179    /// Advisory human-readable text. NEVER the machine contract.
180    pub message: &'static str,
181}
182
183impl CompileError {
184    /// Creates an error with the given code, stage, and advisory message; all optional fields empty.
185    #[must_use]
186    pub fn new(code: ErrorCode, stage: Stage, message: &'static str) -> Self {
187        Self {
188            code,
189            stage,
190            json_pointer_path: None,
191            keyword: None,
192            observed: None,
193            limit: None,
194            recoverable: false,
195            message,
196        }
197    }
198
199    /// Attaches a JSON Pointer path.
200    #[must_use]
201    pub fn with_pointer(mut self, path: impl Into<String>) -> Self {
202        self.json_pointer_path = Some(path.into());
203        self
204    }
205
206    /// Attaches the offending keyword.
207    #[must_use]
208    pub fn with_keyword(mut self, keyword: impl Into<String>) -> Self {
209        self.keyword = Some(keyword.into());
210        self
211    }
212
213    /// Attaches an observed-value description.
214    #[must_use]
215    pub fn with_observed(mut self, observed: impl Into<String>) -> Self {
216        self.observed = Some(observed.into());
217        self
218    }
219}
220
221impl std::fmt::Display for CompileError {
222    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
223        write!(f, "[{:?}/{:?}] {}", self.code, self.stage, self.message)?;
224        if let Some(path) = &self.json_pointer_path {
225            write!(f, " (at {path})")?;
226        }
227        Ok(())
228    }
229}
230
231impl std::error::Error for CompileError {}
232
233/// A structured vocabulary-boundary error.
234#[derive(Clone, PartialEq, Eq, Debug)]
235pub struct VocabError {
236    /// Stable machine code (`EmptyToken` or `MalformedTokenizer`).
237    pub code: ErrorCode,
238    /// The offending token id, when one applies.
239    pub token: Option<TokenId>,
240    /// Advisory detail. Not the machine contract.
241    pub detail: &'static str,
242}
243
244impl VocabError {
245    /// An empty-byte token was rejected at the choke-point.
246    #[must_use]
247    pub fn empty_token() -> Self {
248        Self {
249            code: ErrorCode::EmptyToken,
250            token: None,
251            detail: "vocabulary token has zero bytes",
252        }
253    }
254
255    /// Tokenizer metadata (not a byte payload) was malformed.
256    #[must_use]
257    pub fn malformed_tokenizer(detail: &'static str) -> Self {
258        Self {
259            code: ErrorCode::MalformedTokenizer,
260            token: None,
261            detail,
262        }
263    }
264
265    /// Tokenizer metadata was malformed because of a specific, known token id.
266    #[must_use]
267    pub fn malformed_tokenizer_for(token: TokenId, detail: &'static str) -> Self {
268        Self {
269            code: ErrorCode::MalformedTokenizer,
270            token: Some(token),
271            detail,
272        }
273    }
274
275    /// A vocabulary resource cap was exceeded; the input is well-formed but too large.
276    #[must_use]
277    pub fn limit_exceeded(detail: &'static str) -> Self {
278        Self {
279            code: ErrorCode::InternalLimitExceeded,
280            token: None,
281            detail,
282        }
283    }
284}
285
286impl std::fmt::Display for VocabError {
287    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
288        write!(f, "[{:?}/VocabBuild] {}", self.code, self.detail)
289    }
290}
291
292impl std::error::Error for VocabError {}
293
294/// A runtime matcher error.
295#[non_exhaustive]
296#[derive(Clone, PartialEq, Eq, Debug)]
297pub enum MatcherError {
298    /// A token was fed that the matcher cannot legally consume (dead/no transition).
299    IllegalToken {
300        /// The rejected token id.
301        token: TokenId,
302    },
303    /// A cached artifact did not match its expected fingerprint.
304    ArtifactMismatch,
305}
306
307impl std::fmt::Display for MatcherError {
308    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
309        match self {
310            Self::IllegalToken { token } => write!(f, "[IllegalToken] token {}", token.get()),
311            Self::ArtifactMismatch => write!(f, "[ArtifactMismatch]"),
312        }
313    }
314}
315
316impl std::error::Error for MatcherError {}
317
318/// Fingerprint state of a provenance field.
319#[derive(Copy, Clone, PartialEq, Eq, Debug)]
320pub enum HashState {
321    /// No hash was computed; legal for the `Reference` profile.
322    Unhashed,
323    /// A 32-byte fingerprint.
324    Hash([u8; 32]),
325}
326
327impl HashState {
328    /// A present, non-zero fingerprint. An all-zero hash is never a valid fingerprint.
329    #[must_use]
330    pub fn is_valid_fingerprint(&self) -> bool {
331        matches!(self, Self::Hash(h) if *h != [0u8; 32])
332    }
333}
334
335/// Which provenance profile an artifact was produced under. Validation is BY PROFILE.
336#[non_exhaustive]
337#[derive(Copy, Clone, PartialEq, Eq, Debug)]
338pub enum ProvenanceProfile {
339    /// A slow, unoptimized reference build; legal with `ir_hash = Unhashed`; never cache-valid.
340    Reference,
341    /// An artifact built from a hashed intermediate representation.
342    Ir,
343    /// A validated, cache-eligible artifact.
344    CachedArtifact,
345}
346
347/// An artifact's provenance record, validated BY PROFILE.
348#[derive(Copy, Clone, PartialEq, Eq, Debug)]
349pub struct Provenance {
350    /// Which profile produced the artifact.
351    pub profile: ProvenanceProfile,
352    /// Hash of the source input.
353    pub source_input_hash: HashState,
354    /// Hash of the lowered IR (legal to be `Unhashed` for the `Reference` profile).
355    pub ir_hash: HashState,
356    /// Whether the artifact may be served from cache.
357    pub cache_valid: bool,
358}
359
360impl Provenance {
361    /// A fresh reference-build provenance: `ir_hash` unhashed, never cache-valid.
362    #[must_use]
363    pub fn reference(source_input_hash: HashState) -> Self {
364        Self {
365            profile: ProvenanceProfile::Reference,
366            source_input_hash,
367            ir_hash: HashState::Unhashed,
368            cache_valid: false,
369        }
370    }
371
372    /// Validates the record against its own profile. Every profile requires a real
373    /// `source_input_hash`. `Reference` is legal with an unhashed IR; `Ir` and `CachedArtifact`
374    /// require a real IR hash too. Only `CachedArtifact` may be `cache_valid`, and it must be.
375    pub fn validate(&self) -> Result<(), CompileError> {
376        let err = |msg| {
377            Err(CompileError::new(
378                ErrorCode::ProvenanceIncomplete,
379                Stage::ArtifactValidate,
380                msg,
381            ))
382        };
383        if matches!(self.source_input_hash, HashState::Hash(h) if h == [0u8; 32])
384            || matches!(self.ir_hash, HashState::Hash(h) if h == [0u8; 32])
385        {
386            return err("an all-zero hash is never a valid fingerprint");
387        }
388        if !self.source_input_hash.is_valid_fingerprint() {
389            return err("every provenance record requires a source_input_hash");
390        }
391        match self.profile {
392            ProvenanceProfile::Reference => {
393                if self.cache_valid {
394                    return err("a reference-build artifact is never cache-valid");
395                }
396                Ok(())
397            }
398            ProvenanceProfile::Ir => {
399                if !self.ir_hash.is_valid_fingerprint() {
400                    return err("an IR-profile artifact requires an ir_hash");
401                }
402                if self.cache_valid {
403                    return err("an IR-profile artifact is never cache-valid");
404                }
405                Ok(())
406            }
407            ProvenanceProfile::CachedArtifact => {
408                if !self.ir_hash.is_valid_fingerprint() {
409                    return err("a cached artifact requires an ir_hash");
410                }
411                if !self.cache_valid {
412                    return err("a cached artifact must be cache-valid");
413                }
414                Ok(())
415            }
416        }
417    }
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    fn assert_send_sync<T: Send + Sync>() {}
425
426    #[test]
427    fn error_types_are_send_sync() {
428        assert_send_sync::<CompileError>();
429        assert_send_sync::<VocabError>();
430        assert_send_sync::<MatcherError>();
431        assert_send_sync::<ErrorCode>();
432    }
433
434    #[test]
435    fn machine_fields_survive_without_message() {
436        let mut e = CompileError::new(ErrorCode::InternalLimitExceeded, Stage::L2, "cap")
437            .with_pointer("/properties/name")
438            .with_keyword("maxItems")
439            .with_observed("5");
440        e.limit = Some((LimitKind::ArrayLength, 5, 4));
441        assert_eq!(e.code, ErrorCode::InternalLimitExceeded);
442        assert_eq!(e.stage, Stage::L2);
443        assert_eq!(e.json_pointer_path.as_deref(), Some("/properties/name"));
444        assert_eq!(e.keyword.as_deref(), Some("maxItems"));
445        assert_eq!(e.observed.as_deref(), Some("5"));
446        assert_eq!(e.limit, Some((LimitKind::ArrayLength, 5, 4)));
447        assert_eq!(VocabError::empty_token().code, ErrorCode::EmptyToken);
448        match (MatcherError::IllegalToken { token: TokenId(9) }) {
449            MatcherError::IllegalToken { token } => assert_eq!(token, TokenId(9)),
450            MatcherError::ArtifactMismatch => unreachable!(),
451        }
452    }
453
454    #[test]
455    fn provenance_validated_by_profile() {
456        assert!(Provenance::reference(HashState::Hash([2; 32]))
457            .validate()
458            .is_ok());
459        assert_eq!(
460            Provenance::reference(HashState::Unhashed)
461                .validate()
462                .unwrap_err()
463                .code,
464            ErrorCode::ProvenanceIncomplete
465        );
466        let mut cache_bad = Provenance::reference(HashState::Hash([2; 32]));
467        cache_bad.cache_valid = true;
468        assert_eq!(
469            cache_bad.validate().unwrap_err().code,
470            ErrorCode::ProvenanceIncomplete
471        );
472        let ir_profile = Provenance {
473            profile: ProvenanceProfile::Ir,
474            source_input_hash: HashState::Hash([2; 32]),
475            ir_hash: HashState::Unhashed,
476            cache_valid: false,
477        };
478        assert_eq!(
479            ir_profile.validate().unwrap_err().code,
480            ErrorCode::ProvenanceIncomplete
481        );
482        let cached = Provenance {
483            profile: ProvenanceProfile::CachedArtifact,
484            source_input_hash: HashState::Hash([2; 32]),
485            ir_hash: HashState::Hash([1; 32]),
486            cache_valid: true,
487        };
488        assert!(cached.validate().is_ok());
489    }
490
491    #[test]
492    fn only_cached_artifact_may_be_cache_valid() {
493        let ir_cache_valid = Provenance {
494            profile: ProvenanceProfile::Ir,
495            source_input_hash: HashState::Hash([2; 32]),
496            ir_hash: HashState::Hash([3; 32]),
497            cache_valid: true,
498        };
499        assert_eq!(
500            ir_cache_valid.validate().unwrap_err().code,
501            ErrorCode::ProvenanceIncomplete
502        );
503        let cached_not_valid = Provenance {
504            profile: ProvenanceProfile::CachedArtifact,
505            source_input_hash: HashState::Hash([2; 32]),
506            ir_hash: HashState::Hash([3; 32]),
507            cache_valid: false,
508        };
509        assert_eq!(
510            cached_not_valid.validate().unwrap_err().code,
511            ErrorCode::ProvenanceIncomplete
512        );
513    }
514
515    #[test]
516    fn every_profile_requires_a_source_input_hash() {
517        for profile in [
518            ProvenanceProfile::Reference,
519            ProvenanceProfile::Ir,
520            ProvenanceProfile::CachedArtifact,
521        ] {
522            let p = Provenance {
523                profile,
524                source_input_hash: HashState::Unhashed,
525                ir_hash: HashState::Hash([1; 32]),
526                cache_valid: matches!(profile, ProvenanceProfile::CachedArtifact),
527            };
528            assert_eq!(
529                p.validate().unwrap_err().code,
530                ErrorCode::ProvenanceIncomplete
531            );
532        }
533    }
534
535    #[test]
536    fn zero_hash_is_never_a_valid_fingerprint() {
537        assert!(!HashState::Hash([0; 32]).is_valid_fingerprint());
538        assert!(!HashState::Unhashed.is_valid_fingerprint());
539        assert!(HashState::Hash([1; 32]).is_valid_fingerprint());
540        let zero_source = Provenance {
541            profile: ProvenanceProfile::CachedArtifact,
542            source_input_hash: HashState::Hash([0; 32]),
543            ir_hash: HashState::Hash([1; 32]),
544            cache_valid: true,
545        };
546        assert_eq!(
547            zero_source.validate().unwrap_err().code,
548            ErrorCode::ProvenanceIncomplete
549        );
550    }
551}