Skip to main content

vyre_foundation/dispatch/
dialect_lookup.rs

1//! Dialect lookup contract shared by foundation-side consumers.
2//!
3//! This module is the dependency-inversion boundary between the reference
4//! interpreter and the driver registry. Reference code may ask for op ids and
5//! frozen op definitions through `DialectLookup`, but it must not depend on
6//! `vyre-driver` or the `vyre` meta crate.
7//!
8//! The trait is deliberately sealed by a hidden `__sealed` method on
9//! `DialectLookup`. Downstream crates can consume a lookup, but the only sanctioned
10//! implementations are installed by vyre driver crates so this surface can grow
11//! through additive default methods without breaking external implementors.
12
13use crate::ir_inner::model::program::Program;
14use lasso::ThreadedRodeo;
15use std::sync::{Arc, OnceLock};
16use vyre_spec::{AlgebraicLaw, CpuFn};
17
18/// Interned operation identifier used by every dialect lookup.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub struct InternedOpId(pub u32);
21
22fn get_interner() -> &'static ThreadedRodeo {
23    static INTERNER: OnceLock<ThreadedRodeo> = OnceLock::new();
24    INTERNER.get_or_init(ThreadedRodeo::new)
25}
26
27/// Intern a stable operation-id string into a compact process-local id.
28#[must_use]
29pub fn intern_string(s: &str) -> InternedOpId {
30    let interner = get_interner();
31    let key = interner.get_or_intern(s);
32    InternedOpId(key.into_inner().get())
33}
34
35/// Function pointer used by reference-backend lowerings.
36pub type ReferenceKind = CpuFn;
37
38/// Backend lowering context retained for source compatibility.
39#[derive(Default, Debug, Clone)]
40pub struct LoweringCtx<'a> {
41    /// Marker tying context references to the call lifetime.
42    pub unused: std::marker::PhantomData<&'a ()>,
43}
44
45/// Backend text module descriptor used by native lowering builders.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct TextModule {
48    /// Backend assembly text.
49    pub asm: String,
50    /// Backend format version encoded by the builder.
51    pub version: u32,
52}
53
54/// native-module module descriptor used by native lowering builders.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct NativeModule {
57    /// Backend-owned serialized AST payload.
58    pub ast: Vec<u8>,
59    /// Entry-point name.
60    pub entry: String,
61}
62
63/// Reserved builder type for the primary text lowering slot.
64pub type PrimaryTextBuilder = fn(&LoweringCtx<'_>) -> Result<(), String>;
65/// Reserved builder type for the primary binary lowering slot.
66pub type PrimaryBinaryBuilder = fn(&LoweringCtx<'_>) -> Vec<u32>;
67/// Builder type for the secondary text lowering slot.
68pub type SecondaryTextBuilder = fn(&LoweringCtx<'_>) -> TextModule;
69/// Builder type for native-module lowering.
70pub type NativeModuleBuilder = fn(&LoweringCtx<'_>) -> NativeModule;
71/// Builder-type erased payload for any out-of-tree backend.
72///
73/// Extension lowerings register a function that reads the shared
74/// [`LoweringCtx`] and writes backend-specific bytes into an opaque
75/// output buffer. The caller backend owns the payload format; the
76/// core dialect registry does not interpret the bytes — it only
77/// dispatches to the right builder by `BackendId`.
78///
79/// This is the extensibility lever: a concrete backend appends a new
80/// lowering *without*
81/// editing vyre-foundation, vyre-driver, or vyre-spec. The core
82/// surface remains frozen.
83pub type ExtensionLoweringFn =
84    fn(&LoweringCtx<'_>) -> Result<std::vec::Vec<u8>, std::string::String>;
85
86/// Lowering function table attached to an operation definition.
87///
88/// The named fields are terminal 0.6 in-tree slots. `extensions` is
89/// the open-ended slot: any
90/// out-of-tree backend registers its builder under its stable
91/// backend-id string. Look up by id via
92/// [`LoweringTable::extension`].
93///
94/// Not `#[non_exhaustive]` so static registrations can use functional
95/// record update (`..LoweringTable::empty()`) from `inventory::submit!`
96/// closures. Additive fields must carry defaults so the spread form
97/// keeps working without a breaking change.
98#[derive(Clone)]
99pub struct LoweringTable {
100    /// Portable CPU reference implementation.
101    pub cpu_ref: ReferenceKind,
102    /// Primary text builder. `None` in v0.6 pure-IR ops.
103    pub primary_text: Option<PrimaryTextBuilder>,
104    /// Primary binary builder. `None` in v0.6 pure-IR ops.
105    pub primary_binary: Option<PrimaryBinaryBuilder>,
106    /// Secondary text builder. `None` unless a concrete backend owns it.
107    pub secondary_text: Option<SecondaryTextBuilder>,
108    /// Native native-module builder. `None` until native-module support lands.
109    pub native_module: Option<NativeModuleBuilder>,
110    /// Open extension map for out-of-tree backends. Keyed by backend
111    /// id (matches the string a `VyreBackend::id` returns). Builders
112    /// are by-value function pointers so lookup is allocation-free
113    /// and the map stays `Clone + Send + Sync` without interior
114    /// locking.
115    pub extensions: rustc_hash::FxHashMap<&'static str, ExtensionLoweringFn>,
116}
117
118impl Default for LoweringTable {
119    fn default() -> Self {
120        Self::empty()
121    }
122}
123
124impl LoweringTable {
125    /// Build a CPU-only lowering table.
126    #[must_use]
127    pub fn new(cpu_ref: ReferenceKind) -> Self {
128        Self {
129            cpu_ref,
130            primary_text: None,
131            primary_binary: None,
132            secondary_text: None,
133            native_module: None,
134            extensions: rustc_hash::FxHashMap::default(),
135        }
136    }
137
138    /// Empty table whose CPU path reports the structured intrinsic fallback.
139    #[must_use]
140    pub fn empty() -> Self {
141        Self {
142            cpu_ref: crate::cpu_op::structured_intrinsic_cpu,
143            primary_text: None,
144            primary_binary: None,
145            secondary_text: None,
146            native_module: None,
147            extensions: rustc_hash::FxHashMap::default(),
148        }
149    }
150
151    /// Register an out-of-tree backend's lowering. Stable backend id
152    /// is the key `DialectRegistry::get_lowering` uses for lookup; pick it
153    /// carefully, it is a wire-like identifier.
154    #[must_use]
155    pub fn with_extension(
156        mut self,
157        backend_id: &'static str,
158        builder: ExtensionLoweringFn,
159    ) -> Self {
160        self.extensions.insert(backend_id, builder);
161        self
162    }
163
164    /// Look up an extension builder by backend id.
165    #[must_use]
166    pub fn extension(&self, backend_id: &str) -> Option<ExtensionLoweringFn> {
167        self.extensions.get(backend_id).copied()
168    }
169}
170
171impl std::fmt::Debug for LoweringTable {
172    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173        f.debug_struct("LoweringTable")
174            .field("cpu_ref", &"<fn>")
175            .field("primary_text", &self.primary_text.map(|_| "<fn>"))
176            .field("primary_binary", &self.primary_binary.map(|_| "<fn>"))
177            .field("secondary_text", &self.secondary_text.map(|_| "<fn>"))
178            .field("native_module", &self.native_module.map(|_| "<fn>"))
179            .field(
180                "extensions",
181                &self
182                    .extensions
183                    .keys()
184                    .copied()
185                    .collect::<std::vec::Vec<_>>(),
186            )
187            .finish()
188    }
189}
190
191/// Attribute value type declared by an operation schema.
192#[derive(Debug, Clone, PartialEq, Eq)]
193#[non_exhaustive]
194pub enum AttrType {
195    /// Unsigned 32-bit integer.
196    U32,
197    /// Signed 32-bit integer.
198    I32,
199    /// IEEE-754 binary32.
200    F32,
201    /// Boolean.
202    Bool,
203    /// Opaque byte string.
204    Bytes,
205    /// UTF-8 string.
206    String,
207    /// Enumerated string value.
208    Enum(&'static [&'static str]),
209    /// Unknown extension attribute.
210    Unknown,
211}
212
213/// Attribute schema entry.
214#[derive(Debug, Clone, PartialEq, Eq)]
215pub struct AttrSchema {
216    /// Attribute name.
217    pub name: &'static str,
218    /// Attribute value type.
219    pub ty: AttrType,
220    /// Optional default value.
221    pub default: Option<&'static str>,
222}
223
224/// Typed input or output parameter.
225#[derive(Debug, Clone, PartialEq, Eq)]
226pub struct TypedParam {
227    /// Parameter name.
228    pub name: &'static str,
229    /// Stable type spelling.
230    pub ty: &'static str,
231}
232
233/// Operation signature contract.
234#[derive(Debug, Clone, PartialEq, Eq)]
235pub struct Signature {
236    /// Input parameters.
237    pub inputs: &'static [TypedParam],
238    /// Output parameters.
239    pub outputs: &'static [TypedParam],
240    /// Attribute parameters.
241    pub attrs: &'static [AttrSchema],
242    /// True when this op may read `DataType::Bytes` buffers.
243    pub bytes_extraction: bool,
244}
245
246impl Signature {
247    /// Construct a signature for an op that performs bytes extraction.
248    #[must_use]
249    pub const fn bytes_extractor(
250        inputs: &'static [TypedParam],
251        outputs: &'static [TypedParam],
252        attrs: &'static [AttrSchema],
253    ) -> Self {
254        Self {
255            inputs,
256            outputs,
257            attrs,
258            bytes_extraction: true,
259        }
260    }
261}
262
263/// Operation category.
264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
265pub enum Category {
266    /// Composition over IR.
267    Composite,
268    /// Extension op supplied by another crate.
269    Extension,
270    /// Intrinsic op supplied by a backend or primitive table.
271    Intrinsic,
272}
273
274/// Frozen operation definition.
275#[derive(Debug, Clone)]
276pub struct OpDef {
277    /// Stable operation id.
278    pub id: &'static str,
279    /// Stable dialect namespace.
280    pub dialect: &'static str,
281    /// Operation category.
282    pub category: Category,
283    /// Operation signature.
284    pub signature: Signature,
285    /// Backend lowering entries.
286    pub lowerings: LoweringTable,
287    /// Algebraic laws declared for conformance.
288    pub laws: &'static [AlgebraicLaw],
289    /// Composition-inlinable program builder.
290    pub compose: Option<fn() -> Program>,
291}
292
293impl OpDef {
294    /// Stable operation id.
295    #[must_use]
296    pub const fn id(&self) -> &'static str {
297        self.id
298    }
299
300    /// Build the canonical composition program when the operation has one.
301    #[must_use]
302    pub fn program(&self) -> Option<Program> {
303        self.compose
304            .map(|compose| compose().with_entry_op_id(self.id))
305    }
306}
307
308impl Default for OpDef {
309    fn default() -> Self {
310        Self {
311            id: "",
312            dialect: "",
313            category: Category::Intrinsic,
314            signature: Signature {
315                inputs: &[],
316                outputs: &[],
317                attrs: &[],
318                bytes_extraction: false,
319            },
320            lowerings: LoweringTable::empty(),
321            laws: &[],
322            compose: None,
323        }
324    }
325}
326
327#[doc(hidden)]
328pub mod private {
329    pub trait Sealed {}
330}
331
332/// Minimal lookup surface consumed by foundation-side reference code.
333pub trait DialectLookup: private::Sealed + Send + Sync {
334    /// Stable identifier naming the provider implementation.
335    ///
336    /// Two installs sharing the same `provider_id` are treated as the same
337    /// logical provider — a second install is an idempotent no-op. Two
338    /// installs with different ids are a conflict returned from
339    /// [`install_dialect_lookup`] so callers can fail their own setup without
340    /// panicking inside foundation.
341    fn provider_id(&self) -> &'static str;
342
343    /// Intern a stable operation id.
344    fn intern_op(&self, name: &str) -> InternedOpId;
345
346    /// Resolve an interned operation id to its frozen definition.
347    fn lookup(&self, id: InternedOpId) -> Option<&'static OpDef>;
348}
349
350static DIALECT_LOOKUP: OnceLock<Arc<dyn DialectLookup>> = OnceLock::new();
351
352/// Install the process-wide dialect lookup provider.
353///
354/// First caller wins. A second install from a provider that reports the
355/// same [`DialectLookup::provider_id`] is a silent no-op so harnesses can
356/// defensively call this at the top of every test without racing. A second
357/// install from a provider reporting a DIFFERENT `provider_id` returns an error with
358/// both ids named, because two divergent providers mapping the same op ids
359/// would corrupt every lookup-dependent pass (validator, reference, shadow
360/// diff, conformance matrix) in ways that are hard to attribute back to the
361/// install site. Failing here keeps the 60-second root-cause trace from
362/// LAW 4 intact.
363///
364/// # Errors
365///
366/// Returns an actionable error when a different provider is already installed
367/// or when the process-global lookup reaches an impossible `OnceLock` state.
368pub fn install_dialect_lookup(lookup: Arc<dyn DialectLookup>) -> Result<(), String> {
369    match DIALECT_LOOKUP.get() {
370        Some(existing) => {
371            let existing_id = existing.provider_id();
372            let incoming_id = lookup.provider_id();
373            ensure_same_provider(existing_id, incoming_id)?;
374        }
375        None => {
376            if let Err(lookup) = DIALECT_LOOKUP.set(lookup) {
377                // Lost a race with another thread; still need to validate
378                // idempotency so a concurrent install with a different id
379                // does not silently corrupt the process-wide lookup.
380                let Some(existing) = DIALECT_LOOKUP.get() else {
381                    return Err(
382                        "dialect lookup install lost the value after OnceLock::set failed. Fix: report this impossible OnceLock state."
383                            .to_string(),
384                    );
385                };
386                let existing_id = existing.provider_id();
387                let incoming_id = lookup.provider_id();
388                ensure_same_provider(existing_id, incoming_id)?;
389            }
390        }
391    }
392    Ok(())
393}
394
395fn ensure_same_provider(existing_id: &str, incoming_id: &str) -> Result<(), String> {
396    if existing_id == incoming_id {
397        Ok(())
398    } else {
399        Err(format!(
400            "dialect lookup already installed by provider `{existing_id}`; second installer `{incoming_id}` reports a different id. Fix: pick one provider for the process or reuse the first provider's id. Silent replacement is refused because two divergent lookups would mis-resolve op ids at runtime."
401        ))
402    }
403}
404
405/// Return the installed process-wide dialect lookup provider.
406#[must_use]
407pub fn dialect_lookup() -> Option<&'static dyn DialectLookup> {
408    DIALECT_LOOKUP.get().map(Arc::as_ref)
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    #[test]
416    fn intern_string_is_deterministic() {
417        let a = intern_string("test::op::add");
418        let b = intern_string("test::op::add");
419        assert_eq!(a, b);
420    }
421
422    #[test]
423    fn intern_string_distinct_for_different_ops() {
424        let a = intern_string("test::op::add");
425        let b = intern_string("test::op::mul");
426        assert_ne!(a, b);
427    }
428
429    #[test]
430    fn lowering_table_empty_has_no_native_builders() {
431        let table = LoweringTable::empty();
432        assert!(table.primary_text.is_none());
433        assert!(table.primary_binary.is_none());
434        assert!(table.secondary_text.is_none());
435        assert!(table.native_module.is_none());
436        assert!(table.extensions.is_empty());
437    }
438
439    #[test]
440    fn lowering_table_extension_lookup() {
441        fn dummy_builder(_: &LoweringCtx<'_>) -> Result<Vec<u8>, String> {
442            Ok(vec![1, 2, 3])
443        }
444        let table = LoweringTable::empty().with_extension("my-extension", dummy_builder);
445        assert!(table.extension("my-extension").is_some());
446        assert!(table.extension("nonexistent").is_none());
447    }
448
449    #[test]
450    fn opdef_default_has_empty_id() {
451        let def = OpDef::default();
452        assert_eq!(def.id(), "");
453        assert!(def.program().is_none());
454    }
455
456    #[test]
457    fn signature_bytes_extractor_sets_flag() {
458        let sig = Signature::bytes_extractor(&[], &[], &[]);
459        assert!(sig.bytes_extraction);
460    }
461
462    #[test]
463    fn secondary_text_module_equality() {
464        let a = TextModule {
465            asm: ".version 7.0".into(),
466            version: 70,
467        };
468        let b = TextModule {
469            asm: ".version 7.0".into(),
470            version: 70,
471        };
472        assert_eq!(a, b);
473    }
474
475    #[test]
476    fn native_module_module_equality() {
477        let a = NativeModule {
478            ast: vec![1, 2, 3],
479            entry: "main".into(),
480        };
481        let b = NativeModule {
482            ast: vec![1, 2, 3],
483            entry: "main".into(),
484        };
485        assert_eq!(a, b);
486    }
487
488    #[test]
489    fn category_debug() {
490        assert_eq!(format!("{:?}", Category::Composite), "Composite");
491        assert_eq!(format!("{:?}", Category::Extension), "Extension");
492        assert_eq!(format!("{:?}", Category::Intrinsic), "Intrinsic");
493    }
494}