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.4.1 pure-IR ops.
103 pub primary_text: Option<PrimaryTextBuilder>,
104 /// Primary binary builder. `None` in v0.4.1 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 lowering table with only the explicit CPU reference oracle
126 /// populated. Production execution still requires a concrete backend
127 /// lowering (`primary_*`, `secondary_text`, `native_module`, or an
128 /// extension); this constructor is for parity/conformance surfaces and
129 /// incremental backend registration.
130 #[must_use]
131 pub fn new(cpu_ref: ReferenceKind) -> Self {
132 Self {
133 cpu_ref,
134 primary_text: None,
135 primary_binary: None,
136 secondary_text: None,
137 native_module: None,
138 extensions: rustc_hash::FxHashMap::default(),
139 }
140 }
141
142 /// Empty table whose reference-oracle slot is the structured-intrinsic
143 /// sentinel. Invoking that slot panics after clearing output so missing
144 /// reference adapters cannot masquerade as empty CPU results. This is not
145 /// a production fallback path.
146 #[must_use]
147 pub fn empty() -> Self {
148 // Read through the static, never by naming the function: see
149 // `cpu_op::SENTINEL_CPU_REF` for why the address of the function
150 // itself is not a reliable identity.
151 let cpu_ref = crate::cpu_op::SENTINEL_CPU_REF;
152 Self {
153 cpu_ref,
154 primary_text: None,
155 primary_binary: None,
156 secondary_text: None,
157 native_module: None,
158 extensions: rustc_hash::FxHashMap::default(),
159 }
160 }
161
162 /// Register an out-of-tree backend's lowering. Stable backend id
163 /// is the key `DialectRegistry::get_lowering` uses for lookup; pick it
164 /// carefully, it is a wire-like identifier.
165 #[must_use]
166 pub fn with_extension(
167 mut self,
168 backend_id: &'static str,
169 builder: ExtensionLoweringFn,
170 ) -> Self {
171 self.extensions.insert(backend_id, builder);
172 self
173 }
174
175 /// Look up an extension builder by backend id.
176 #[must_use]
177 pub fn extension(&self, backend_id: &str) -> Option<ExtensionLoweringFn> {
178 self.extensions.get(backend_id).copied()
179 }
180}
181
182impl std::fmt::Debug for LoweringTable {
183 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184 f.debug_struct("LoweringTable")
185 .field("cpu_ref", &"<fn>")
186 .field("primary_text", &self.primary_text.map(|_| "<fn>"))
187 .field("primary_binary", &self.primary_binary.map(|_| "<fn>"))
188 .field("secondary_text", &self.secondary_text.map(|_| "<fn>"))
189 .field("native_module", &self.native_module.map(|_| "<fn>"))
190 .field(
191 "extensions",
192 &self
193 .extensions
194 .keys()
195 .copied()
196 .collect::<std::vec::Vec<_>>(),
197 )
198 .finish()
199 }
200}
201
202/// Attribute value type declared by an operation schema.
203#[derive(Debug, Clone, PartialEq, Eq)]
204#[non_exhaustive]
205pub enum AttrType {
206 /// Unsigned 32-bit integer.
207 U32,
208 /// Signed 32-bit integer.
209 I32,
210 /// IEEE-754 binary32.
211 F32,
212 /// Boolean.
213 Bool,
214 /// Opaque byte string.
215 Bytes,
216 /// UTF-8 string.
217 String,
218 /// Enumerated string value.
219 Enum(&'static [&'static str]),
220 /// Unknown extension attribute.
221 Unknown,
222}
223
224/// Attribute schema entry.
225#[derive(Debug, Clone, PartialEq, Eq)]
226pub struct AttrSchema {
227 /// Attribute name.
228 pub name: &'static str,
229 /// Attribute value type.
230 pub ty: AttrType,
231 /// Optional default value.
232 pub default: Option<&'static str>,
233}
234
235/// Typed input or output parameter.
236#[derive(Debug, Clone, PartialEq, Eq)]
237pub struct TypedParam {
238 /// Parameter name.
239 pub name: &'static str,
240 /// Stable type spelling.
241 pub ty: &'static str,
242}
243
244/// Operation signature contract.
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct Signature {
247 /// Input parameters.
248 pub inputs: &'static [TypedParam],
249 /// Output parameters.
250 pub outputs: &'static [TypedParam],
251 /// Attribute parameters.
252 pub attrs: &'static [AttrSchema],
253 /// True when this op may read `DataType::Bytes` buffers.
254 pub bytes_extraction: bool,
255}
256
257impl Signature {
258 /// Construct a signature for an op that performs bytes extraction.
259 #[must_use]
260 pub const fn bytes_extractor(
261 inputs: &'static [TypedParam],
262 outputs: &'static [TypedParam],
263 attrs: &'static [AttrSchema],
264 ) -> Self {
265 Self {
266 inputs,
267 outputs,
268 attrs,
269 bytes_extraction: true,
270 }
271 }
272}
273
274/// Operation category.
275#[derive(Debug, Clone, Copy, PartialEq, Eq)]
276pub enum Category {
277 /// Composition over IR.
278 Composite,
279 /// Extension op supplied by another crate.
280 Extension,
281 /// Intrinsic op supplied by a backend or primitive table.
282 Intrinsic,
283}
284
285/// Frozen operation definition.
286#[derive(Debug, Clone)]
287pub struct OpDef {
288 /// Stable operation id.
289 pub id: &'static str,
290 /// Stable dialect namespace.
291 pub dialect: &'static str,
292 /// Operation category.
293 pub category: Category,
294 /// Operation signature.
295 pub signature: Signature,
296 /// Backend lowering entries.
297 pub lowerings: LoweringTable,
298 /// Algebraic laws declared for conformance.
299 pub laws: &'static [AlgebraicLaw],
300 /// Composition-inlinable program builder.
301 pub compose: Option<fn() -> Program>,
302}
303
304impl OpDef {
305 /// Stable operation id.
306 #[must_use]
307 pub const fn id(&self) -> &'static str {
308 self.id
309 }
310
311 /// Build the canonical composition program when the operation has one.
312 #[must_use]
313 pub fn program(&self) -> Option<Program> {
314 self.compose
315 .map(|compose| compose().with_entry_op_id(self.id))
316 }
317}
318
319impl Default for OpDef {
320 fn default() -> Self {
321 Self {
322 id: "",
323 dialect: "",
324 category: Category::Intrinsic,
325 signature: Signature {
326 inputs: &[],
327 outputs: &[],
328 attrs: &[],
329 bytes_extraction: false,
330 },
331 lowerings: LoweringTable::empty(),
332 laws: &[],
333 compose: None,
334 }
335 }
336}
337
338#[doc(hidden)]
339pub mod private {
340 pub trait Sealed {}
341}
342
343/// Minimal lookup surface consumed by foundation-side reference code.
344pub trait DialectLookup: private::Sealed + Send + Sync {
345 /// Stable identifier naming the provider implementation.
346 ///
347 /// Two installs sharing the same `provider_id` are treated as the same
348 /// logical provider - a second install is an idempotent no-op. Two
349 /// installs with different ids are a conflict returned from
350 /// [`install_dialect_lookup`] so callers can fail their own setup without
351 /// panicking inside foundation.
352 fn provider_id(&self) -> &'static str;
353
354 /// Intern a stable operation id.
355 fn intern_op(&self, name: &str) -> InternedOpId;
356
357 /// Resolve an interned operation id to its frozen definition.
358 fn lookup(&self, id: InternedOpId) -> Option<&'static OpDef>;
359}
360
361static DIALECT_LOOKUP: OnceLock<Arc<dyn DialectLookup>> = OnceLock::new();
362
363/// Install the process-wide dialect lookup provider.
364///
365/// First caller wins. A second install from a provider that reports the
366/// same [`DialectLookup::provider_id`] is a silent no-op so harnesses can
367/// defensively call this at the top of every test without racing. A second
368/// install from a provider reporting a DIFFERENT `provider_id` returns an error with
369/// both ids named, because two divergent providers mapping the same op ids
370/// would corrupt every lookup-dependent pass (validator, reference, shadow
371/// diff, conformance matrix) in ways that are hard to attribute back to the
372/// install site. Failing here keeps the 60-second root-cause trace from
373/// LAW 4 intact.
374///
375/// # Errors
376///
377/// Returns an actionable error when a different provider is already installed
378/// or when the process-global lookup reaches an impossible `OnceLock` state.
379pub fn install_dialect_lookup(lookup: Arc<dyn DialectLookup>) -> Result<(), String> {
380 match DIALECT_LOOKUP.get() {
381 Some(existing) => {
382 let existing_id = existing.provider_id();
383 let incoming_id = lookup.provider_id();
384 ensure_same_provider(existing_id, incoming_id)?;
385 }
386 None => {
387 if let Err(lookup) = DIALECT_LOOKUP.set(lookup) {
388 // Lost a race with another thread; still need to validate
389 // idempotency so a concurrent install with a different id
390 // does not silently corrupt the process-wide lookup.
391 let Some(existing) = DIALECT_LOOKUP.get() else {
392 return Err(
393 "dialect lookup install lost the value after OnceLock::set failed. Fix: report this impossible OnceLock state."
394 .to_string(),
395 );
396 };
397 let existing_id = existing.provider_id();
398 let incoming_id = lookup.provider_id();
399 ensure_same_provider(existing_id, incoming_id)?;
400 }
401 }
402 }
403 Ok(())
404}
405
406fn ensure_same_provider(existing_id: &str, incoming_id: &str) -> Result<(), String> {
407 if existing_id == incoming_id {
408 Ok(())
409 } else {
410 Err(format!(
411 "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."
412 ))
413 }
414}
415
416/// Return the installed process-wide dialect lookup provider.
417#[must_use]
418pub fn dialect_lookup() -> Option<&'static dyn DialectLookup> {
419 DIALECT_LOOKUP.get().map(Arc::as_ref)
420}
421
422#[cfg(test)]
423mod tests;