Skip to main content

vyre_foundation/validate/
validation_error.rs

1//! Structured validation issues for vyre IR programs.
2
3use core::fmt;
4use std::borrow::Cow;
5use std::sync::Arc;
6
7use serde::{Deserialize, Serialize};
8
9use crate::diagnostics::{
10    Diagnostic, DiagnosticCode, DiagnosticStage, OpLocation, RetryClass, Severity,
11};
12
13/// Stable validation rule identity.
14///
15/// Codes are explicit at every emission site. New validator rules use a new
16/// identity rather than encoding ownership in prose.
17#[derive(Debug, Clone, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize)]
18#[serde(transparent)]
19pub struct ValidationCode(Cow<'static, str>);
20
21impl<'de> Deserialize<'de> for ValidationCode {
22    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
23    where
24        D: serde::Deserializer<'de>,
25    {
26        let code = String::deserialize(deserializer)?;
27        let validation_code = Self(Cow::Owned(code));
28        if validation_code.phase().is_none() {
29            return Err(serde::de::Error::custom(format!(
30                "unknown validation code `{validation_code}`"
31            )));
32        }
33        Ok(validation_code)
34    }
35}
36
37const VALIDATION_RULES: &[(&str, ValidationPhase)] = &[
38    ("V008", ValidationPhase::Node),
39    ("V009", ValidationPhase::Memory),
40    ("V010", ValidationPhase::Memory),
41    ("V011", ValidationPhase::Node),
42    ("V012", ValidationPhase::Expression),
43    ("V013", ValidationPhase::Memory),
44    ("V014", ValidationPhase::Memory),
45    ("V016", ValidationPhase::Expression),
46    ("V018", ValidationPhase::Limits),
47    ("V019", ValidationPhase::Limits),
48    ("V020", ValidationPhase::Expression),
49    ("V021", ValidationPhase::Expression),
50    ("V022", ValidationPhase::Expression),
51    ("V023", ValidationPhase::Expression),
52    ("V025", ValidationPhase::Memory),
53    ("V027", ValidationPhase::Memory),
54    ("V028", ValidationPhase::Type),
55    ("V029", ValidationPhase::Expression),
56    ("V030", ValidationPhase::Expression),
57    ("V031", ValidationPhase::Node),
58    ("V032", ValidationPhase::Node),
59    ("V033", ValidationPhase::Limits),
60    ("V034", ValidationPhase::Expression),
61    ("V035", ValidationPhase::Type),
62    ("V036", ValidationPhase::Node),
63    ("V041", ValidationPhase::Expression),
64    ("V042", ValidationPhase::Memory),
65    ("V043", ValidationPhase::Memory),
66    ("V044", ValidationPhase::Type),
67    ("V045", ValidationPhase::Node),
68    ("V046", ValidationPhase::Node),
69    ("V047", ValidationPhase::Expression),
70    ("V051", ValidationPhase::Expression),
71    ("V052", ValidationPhase::Expression),
72    ("V053", ValidationPhase::Expression),
73    ("V054", ValidationPhase::Expression),
74    ("V055", ValidationPhase::Memory),
75    ("V056", ValidationPhase::Capability),
76    ("V057", ValidationPhase::Memory),
77    ("V058", ValidationPhase::Memory),
78    ("V059", ValidationPhase::Memory),
79    ("V060", ValidationPhase::Memory),
80    ("V061", ValidationPhase::Memory),
81    ("V063", ValidationPhase::Memory),
82    ("V064", ValidationPhase::Memory),
83    ("V065", ValidationPhase::Memory),
84    ("V066", ValidationPhase::Expression),
85    ("V067", ValidationPhase::Expression),
86    ("V068", ValidationPhase::Expression),
87    ("V070", ValidationPhase::Program),
88    ("V083", ValidationPhase::Program),
89    ("V084", ValidationPhase::Type),
90    ("V085", ValidationPhase::Type),
91    ("V086", ValidationPhase::Type),
92    ("V087", ValidationPhase::Type),
93    ("V088", ValidationPhase::Type),
94    ("V089", ValidationPhase::Type),
95    ("V090", ValidationPhase::Type),
96    ("V091", ValidationPhase::Type),
97    ("V092", ValidationPhase::Type),
98    ("V093", ValidationPhase::Type),
99    ("V094", ValidationPhase::Type),
100    ("V095", ValidationPhase::Type),
101    ("V096", ValidationPhase::Type),
102    ("V097", ValidationPhase::Type),
103    ("V098", ValidationPhase::Type),
104    ("V099", ValidationPhase::Type),
105    ("V100", ValidationPhase::Type),
106    ("V101", ValidationPhase::Type),
107    ("V102", ValidationPhase::Type),
108    ("V103", ValidationPhase::Type),
109    ("V104", ValidationPhase::Type),
110    ("V105", ValidationPhase::Program),
111    ("V106", ValidationPhase::Program),
112    ("V107", ValidationPhase::Program),
113    ("V108", ValidationPhase::Program),
114    ("V109", ValidationPhase::Program),
115    ("V110", ValidationPhase::Program),
116    ("V111", ValidationPhase::Node),
117    ("V112", ValidationPhase::Node),
118    ("V113", ValidationPhase::Node),
119    ("V114", ValidationPhase::Node),
120    ("V115", ValidationPhase::Composition),
121    ("V116", ValidationPhase::Composition),
122    ("V117", ValidationPhase::Node),
123    ("V118", ValidationPhase::Node),
124    ("V119", ValidationPhase::Node),
125    ("V120", ValidationPhase::Node),
126    ("V121", ValidationPhase::Node),
127    ("V122", ValidationPhase::Node),
128    ("V123", ValidationPhase::Node),
129    ("V124", ValidationPhase::Node),
130    ("V125", ValidationPhase::Node),
131    ("V126", ValidationPhase::Node),
132    ("V127", ValidationPhase::Node),
133    ("V128", ValidationPhase::Node),
134    ("V129", ValidationPhase::Memory),
135    ("V130", ValidationPhase::Program),
136];
137
138impl ValidationCode {
139    /// Backend capability rejected an operation used by the program.
140    pub const V056: Self = Self(Cow::Borrowed("V056"));
141
142    /// Construct a stable rule identity.
143    #[must_use]
144    pub(crate) const fn new(code: &'static str) -> Self {
145        Self(Cow::Borrowed(code))
146    }
147
148    /// Return the stable code spelling.
149    #[must_use]
150    pub fn as_str(&self) -> &str {
151        &self.0
152    }
153
154    /// Iterate every registered validation rule and its sole owning phase.
155    ///
156    /// The registry is the source for diagnostics tooling and documentation
157    /// coverage. New rules must be added here before they can deserialize.
158    pub fn registered() -> impl ExactSizeIterator<Item = (&'static str, ValidationPhase)> + Clone {
159        VALIDATION_RULES.iter().copied()
160    }
161
162    /// Return the sole validator phase allowed to emit this rule.
163    #[must_use]
164    pub fn phase(&self) -> Option<ValidationPhase> {
165        VALIDATION_RULES
166            .iter()
167            .find_map(|(code, phase)| (*code == self.as_str()).then_some(*phase))
168    }
169}
170
171impl fmt::Display for ValidationCode {
172    fn fmt(&self, output: &mut fmt::Formatter<'_>) -> fmt::Result {
173        output.write_str(&self.0)
174    }
175}
176
177/// Validator phase that owns a rule emission.
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
179#[serde(rename_all = "snake_case")]
180#[non_exhaustive]
181pub enum ValidationPhase {
182    /// Program header, workgroup, and buffer declarations.
183    Program,
184    /// Node structure, scope, and control flow.
185    Node,
186    /// Expression structure and call validation.
187    Expression,
188    /// Static type rules.
189    Type,
190    /// Memory access and ordering rules.
191    Memory,
192    /// Backend capability-sensitive validation.
193    Capability,
194    /// Whole-program composition and fusion rules.
195    Composition,
196    /// Resource and recursion bounds.
197    Limits,
198}
199
200impl ValidationPhase {
201    /// Stable phase spelling used by structured causes and traces.
202    #[must_use]
203    pub const fn as_str(self) -> &'static str {
204        match self {
205            Self::Program => "program",
206            Self::Node => "node",
207            Self::Expression => "expression",
208            Self::Type => "type",
209            Self::Memory => "memory",
210            Self::Capability => "capability",
211            Self::Composition => "composition",
212            Self::Limits => "limits",
213        }
214    }
215}
216
217/// Typed location within the validated program.
218#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
219#[non_exhaustive]
220pub enum ValidationLocation {
221    /// The complete program.
222    Program,
223    /// One workgroup axis.
224    WorkgroupAxis(u8),
225    /// One declared buffer.
226    Buffer(Cow<'static, str>),
227    /// One node in pre-order traversal.
228    Node(u32),
229    /// One expression owned by a node.
230    Expression {
231        /// Pre-order node identity.
232        node: u32,
233        /// Expression depth below the node.
234        depth: u32,
235    },
236    /// One operand of an expression or call.
237    Operand {
238        /// Pre-order node identity.
239        node: u32,
240        /// Zero-based operand index.
241        operand: u32,
242    },
243    /// One issue within a deterministic validator traversal.
244    Traversal {
245        /// Zero-based node or issue order within the owning validation phase.
246        ordinal: u64,
247    },
248    /// One registered semantic operation.
249    Operation(Cow<'static, str>),
250}
251
252impl ValidationLocation {
253    pub(crate) fn diagnostic_location(&self) -> OpLocation {
254        match self {
255            Self::Program => OpLocation::op("program"),
256            Self::WorkgroupAxis(axis) => {
257                OpLocation::op("program.workgroup_size").with_operand(u32::from(*axis))
258            }
259            Self::Buffer(name) => OpLocation::op("program.buffer").with_attr(name.clone()),
260            Self::Node(node) => OpLocation::op("program.node").with_graph_node(*node),
261            Self::Expression { node, depth } => OpLocation::op("program.expression")
262                .with_graph_node(*node)
263                .with_operand(*depth),
264            Self::Operand { node, operand } => OpLocation::op("program.expression")
265                .with_graph_node(*node)
266                .with_operand(*operand),
267            Self::Traversal { ordinal } => OpLocation::op("program.validation")
268                .with_graph_node(u32::try_from(*ordinal).unwrap_or(u32::MAX)),
269            Self::Operation(op_id) => OpLocation::op(op_id.clone()),
270        }
271    }
272}
273
274/// One trace record produced at the shared validation issue choke point.
275#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
276pub struct ValidationTraceEvent {
277    /// Emitted rule identity.
278    pub code: ValidationCode,
279    /// Rule-owning validator phase.
280    pub phase: ValidationPhase,
281    /// Typed program location.
282    pub location: ValidationLocation,
283}
284
285/// A structured validation issue.
286#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
287pub struct ValidationError {
288    /// Stable validation rule identity.
289    code: ValidationCode,
290    /// Rule-owning validator phase.
291    phase: ValidationPhase,
292    /// Typed program location.
293    location: ValidationLocation,
294    /// Deterministic cause detail without a code or corrective-action prefix.
295    cause: Cow<'static, str>,
296    /// Corrective action.
297    corrective_action: Cow<'static, str>,
298    /// Retry policy.
299    retry: RetryClass,
300}
301
302#[derive(Deserialize)]
303struct ValidationErrorWire {
304    code: ValidationCode,
305    phase: ValidationPhase,
306    location: ValidationLocation,
307    cause: Cow<'static, str>,
308    corrective_action: Cow<'static, str>,
309    retry: RetryClass,
310}
311
312impl<'de> Deserialize<'de> for ValidationError {
313    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
314    where
315        D: serde::Deserializer<'de>,
316    {
317        let wire = ValidationErrorWire::deserialize(deserializer)?;
318        if wire.code.phase() != Some(wire.phase) {
319            return Err(serde::de::Error::custom(format!(
320                "validation rule {} belongs to phase {:?}, not {:?}",
321                wire.code,
322                wire.code.phase(),
323                wire.phase
324            )));
325        }
326        if wire.retry != RetryClass::Never {
327            return Err(serde::de::Error::custom(format!(
328                "validation rule {} has invalid retry class {:?}",
329                wire.code, wire.retry
330            )));
331        }
332        Ok(Self {
333            code: wire.code,
334            phase: wire.phase,
335            location: wire.location,
336            cause: wire.cause,
337            corrective_action: wire.corrective_action,
338            retry: wire.retry,
339        })
340    }
341}
342
343impl ValidationError {
344    /// Construct one validation issue at the shared choke point.
345    #[must_use]
346    pub(crate) fn new(
347        code: ValidationCode,
348        phase: ValidationPhase,
349        location: ValidationLocation,
350        cause: impl Into<Cow<'static, str>>,
351        corrective_action: impl Into<Cow<'static, str>>,
352    ) -> Self {
353        assert_eq!(
354            code.phase(),
355            Some(phase),
356            "validation rule {code} emitted from the wrong phase"
357        );
358        Self {
359            code,
360            phase,
361            location,
362            cause: cause.into(),
363            corrective_action: corrective_action.into(),
364            retry: RetryClass::Never,
365        }
366    }
367
368    /// Build an unsupported-operation diagnostic for backend capability checks.
369    #[must_use]
370    pub fn unsupported_op(backend: &'static str, op_id: &Arc<str>, node_index: usize) -> Self {
371        Self::new(
372            ValidationCode::V056,
373            ValidationPhase::Capability,
374            ValidationLocation::Operation(Cow::Owned(op_id.to_string())),
375            format!(
376                "backend `{backend}` does not support operation `{op_id}` at node {node_index}"
377            ),
378            format!(
379                "choose a backend whose capability set includes this operation, lower the program through a supported backend pipeline, or register an implementation for `{op_id}`"
380            ),
381        )
382    }
383
384    /// Stable rule identity.
385    #[must_use]
386    pub fn code(&self) -> &ValidationCode {
387        &self.code
388    }
389
390    /// Rule-owning validation phase.
391    #[must_use]
392    pub const fn phase(&self) -> ValidationPhase {
393        self.phase
394    }
395
396    /// Typed program location.
397    #[must_use]
398    pub const fn location(&self) -> &ValidationLocation {
399        &self.location
400    }
401
402    /// Deterministic cause detail.
403    #[must_use]
404    pub fn cause(&self) -> &str {
405        &self.cause
406    }
407
408    /// Corrective action.
409    #[must_use]
410    pub fn corrective_action(&self) -> &str {
411        &self.corrective_action
412    }
413
414    /// Retry policy.
415    #[must_use]
416    pub const fn retry(&self) -> RetryClass {
417        self.retry
418    }
419
420    pub(crate) fn set_location(&mut self, location: ValidationLocation) {
421        self.location = location;
422    }
423
424    /// Render the stable human-readable issue detail.
425    #[must_use]
426    pub fn message(&self) -> Cow<'_, str> {
427        Cow::Owned(format!(
428            "{}: {}. Fix: {}",
429            self.code, self.cause, self.corrective_action
430        ))
431    }
432
433    /// Return the trace event for this emission.
434    #[must_use]
435    pub fn trace_event(&self) -> ValidationTraceEvent {
436        ValidationTraceEvent {
437            code: self.code.clone(),
438            phase: self.phase,
439            location: self.location.clone(),
440        }
441    }
442
443    /// Project the issue into the shared diagnostic protocol.
444    #[must_use]
445    pub fn diagnostic(&self) -> Diagnostic {
446        Diagnostic {
447            severity: Severity::Error,
448            code: DiagnosticCode::from_owned(self.code.as_str().to_string()),
449            stage: DiagnosticStage::Validate,
450            message: self.cause.clone(),
451            location: Some(self.location.diagnostic_location()),
452            suggested_fix: Some(self.corrective_action.clone()),
453            cause: Some(crate::diagnostics::DiagnosticCause {
454                kind: self.phase.as_str().to_string(),
455                detail: self.cause.to_string(),
456            }),
457            retry: self.retry,
458            doc_url: Some(Cow::Owned(format!(
459                "https://docs.vyre.dev/validator-errors#{}",
460                self.code.as_str().to_ascii_lowercase()
461            ))),
462        }
463    }
464}
465
466impl From<&ValidationError> for Diagnostic {
467    fn from(issue: &ValidationError) -> Self {
468        issue.diagnostic()
469    }
470}
471
472impl From<ValidationError> for Diagnostic {
473    fn from(issue: ValidationError) -> Self {
474        issue.diagnostic()
475    }
476}
477
478impl fmt::Display for ValidationError {
479    fn fmt(&self, output: &mut fmt::Formatter<'_>) -> fmt::Result {
480        write!(output, "vyre IR validation: {}", self.message())
481    }
482}
483
484impl std::error::Error for ValidationError {}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489
490    fn issue() -> ValidationError {
491        ValidationError::new(
492            ValidationCode::new("V028"),
493            ValidationPhase::Type,
494            ValidationLocation::Operand {
495                node: 7,
496                operand: 1,
497            },
498            "Fma operand has type i32, expected f32",
499            "cast the operand to f32",
500        )
501    }
502
503    #[test]
504    fn every_validation_family_is_enforced_at_the_shared_choke_point() {
505        let cases = [
506            ("V105", ValidationPhase::Program),
507            ("V112", ValidationPhase::Node),
508            ("V012", ValidationPhase::Expression),
509            ("V084", ValidationPhase::Type),
510            ("V057", ValidationPhase::Memory),
511            ("V056", ValidationPhase::Capability),
512            ("V115", ValidationPhase::Composition),
513            ("V018", ValidationPhase::Limits),
514        ];
515        for (code, phase) in cases {
516            let issue = ValidationError::new(
517                ValidationCode::new(code),
518                phase,
519                ValidationLocation::Program,
520                "family mutation",
521                "restore the owning phase",
522            );
523            assert_eq!(issue.code.phase(), Some(phase));
524        }
525    }
526
527    #[test]
528    #[should_panic(expected = "emitted from the wrong phase")]
529    fn phase_mutation_fails_at_the_shared_choke_point() {
530        let _ = ValidationError::new(
531            ValidationCode::new("V105"),
532            ValidationPhase::Node,
533            ValidationLocation::Program,
534            "mutated rule owner",
535            "restore the program phase",
536        );
537    }
538
539    #[test]
540    fn typed_issue_projects_without_parsing_prose() {
541        let issue = issue();
542        assert_eq!(issue.code().as_str(), "V028");
543        assert_eq!(
544            issue.message(),
545            "V028: Fma operand has type i32, expected f32. Fix: cast the operand to f32"
546        );
547        assert_eq!(issue.trace_event().phase, ValidationPhase::Type);
548
549        let diagnostic = issue.diagnostic();
550        assert_eq!(diagnostic.code.as_str(), "V028");
551        assert_eq!(diagnostic.stage, DiagnosticStage::Validate);
552        assert_eq!(diagnostic.retry, RetryClass::Never);
553        assert_eq!(
554            diagnostic
555                .location
556                .as_ref()
557                .and_then(|location| location.graph_node),
558            Some(7)
559        );
560        assert_eq!(
561            diagnostic.suggested_fix.as_deref(),
562            Some("cast the operand to f32")
563        );
564        assert_eq!(
565            diagnostic.cause.as_ref().map(|cause| cause.kind.as_str()),
566            Some("type")
567        );
568    }
569
570    #[test]
571    fn serialization_preserves_every_issue_field() {
572        let issue = issue();
573        let encoded = serde_json::to_vec(&issue).expect("validation issue must serialize");
574        let decoded: ValidationError =
575            serde_json::from_slice(&encoded).expect("validation issue must deserialize");
576        assert_eq!(decoded, issue);
577        assert_eq!(decoded.diagnostic(), issue.diagnostic());
578    }
579
580    #[test]
581    fn deserialization_rejects_unknown_rule_identity() {
582        let encoded = serde_json::to_value(issue()).expect("issue must serialize");
583        let mut mutated = encoded;
584        mutated["code"] = serde_json::Value::String(format!("V{}", 999));
585        let error = serde_json::from_value::<ValidationError>(mutated)
586            .expect_err("unknown validation rule must fail closed");
587        assert!(error.to_string().contains("unknown validation code"));
588    }
589
590    #[test]
591    fn deserialization_rejects_phase_mutation() {
592        let encoded = serde_json::to_value(issue()).expect("issue must serialize");
593        let mut mutated = encoded;
594        mutated["phase"] = serde_json::Value::String("node".to_string());
595        let error = serde_json::from_value::<ValidationError>(mutated)
596            .expect_err("phase mutation must fail closed");
597        assert!(error.to_string().contains("belongs to phase"));
598    }
599
600    #[test]
601    fn unsupported_op_has_typed_capability_identity() {
602        let issue = ValidationError::unsupported_op("backend-a", &Arc::from("math::fma"), 3);
603        assert_eq!(issue.code().as_str(), "V056");
604        assert_eq!(issue.phase(), ValidationPhase::Capability);
605        assert!(issue.message().contains("backend-a"));
606        assert!(issue.message().contains("math::fma"));
607        assert!(issue.message().contains("3"));
608        assert!(issue.message().contains("Fix:"));
609    }
610}