Skip to main content

rssn_advanced/custom/
descriptor.rs

1//! Core types for the unified custom-operator extension system.
2//!
3//! Build a [`CustomOpDescriptor`] with its fluent builder, register it into a
4//! [`CustomOpRegistry`], then pass the registry to each pipeline integration
5//! method instead of configuring the JIT, simplifier, and e-graph separately.
6//!
7//! # Quick start
8//!
9//! ```rust
10//! use rssn_advanced::custom::descriptor::{CustomOpDescriptor, CustomOpRegistry, EvalFn};
11//! use rssn_advanced::dag::builder::DagBuilder;
12//!
13//! extern "C" fn my_relu(x: f64) -> f64 { x.max(0.0) }
14//!
15//! let mut builder = DagBuilder::new();
16//! // intern_function is the public API for obtaining a FnId from outside the crate
17//! let fn_id = builder.intern_function("relu");
18//!
19//! let desc = CustomOpDescriptor::builder(fn_id, "relu", EvalFn::Arity1(my_relu))
20//!     .vectorizable()      // pure → safe to duplicate in ILP batch path
21//!     .simplify_rule("relu(0) → 0", 10, |_b, _kind, _children| None)
22//!     .cost(1.5)
23//!     .build();
24//!
25//! let mut registry = CustomOpRegistry::new();
26//! registry.register(desc).unwrap();
27//! ```
28
29#![allow(clippy::single_call_fn)]
30
31use std::collections::HashMap;
32use std::sync::Arc;
33
34use crate::dag::builder::DagBuilder;
35use crate::dag::node::DagNodeId;
36use crate::dag::symbol::{FnId, SymbolKind};
37use crate::heuristic::rule_registry::{RuleFn, RuleRegistry};
38
39// ── Evaluation function types ─────────────────────────────────────────────
40
41/// 1-argument `extern "C"` evaluation function (`f64 → f64`).
42///
43/// The function must be `extern "C"` so the JIT can emit a direct `call`
44/// with the platform C ABI (same as `libc` math functions).
45pub type EvalFn1 = extern "C" fn(f64) -> f64;
46
47/// 2-argument `extern "C"` evaluation function (`f64, f64 → f64`).
48pub type EvalFn2 = extern "C" fn(f64, f64) -> f64;
49
50/// 3-argument `extern "C"` evaluation function (`f64, f64, f64 → f64`).
51pub type EvalFn3 = extern "C" fn(f64, f64, f64) -> f64;
52
53/// Arity-tagged evaluation function pointer.
54///
55/// Stored in [`CustomOpDescriptor`] and used by the JIT to emit the correct
56/// `call` instruction signature.
57#[derive(Clone, Copy)]
58pub enum EvalFn {
59    /// Unary operator (`f64 → f64`).
60    Arity1(EvalFn1),
61    /// Binary operator (`f64, f64 → f64`).
62    Arity2(EvalFn2),
63    /// Ternary operator (`f64, f64, f64 → f64`).
64    Arity3(EvalFn3),
65}
66
67impl EvalFn {
68    /// Returns the number of `f64` arguments the function accepts.
69    #[must_use]
70    pub const fn arity(self) -> u8 {
71        match self {
72            Self::Arity1(_) => 1,
73            Self::Arity2(_) => 2,
74            Self::Arity3(_) => 3,
75        }
76    }
77
78    /// Evaluate with a slice of arguments.
79    ///
80    /// # Panics
81    ///
82    /// Panics in debug builds if `args.len() != self.arity()`.
83    #[must_use]
84    pub fn call(self, args: &[f64]) -> f64 {
85        debug_assert_eq!(args.len(), self.arity() as usize, "EvalFn arity mismatch");
86        match self {
87            Self::Arity1(f) => f(args[0]),
88            Self::Arity2(f) => f(args[0], args[1]),
89            Self::Arity3(f) => f(args[0], args[1], args[2]),
90        }
91    }
92}
93
94// ── Rule types ────────────────────────────────────────────────────────────
95
96/// Shared heuristic simplification rule closure.
97///
98/// Uses `Arc` (not `Box`) so the same closure can be cloned cheaply into a
99/// [`RuleRegistry`] without copying the allocation.
100pub type SimplifyRuleArc =
101    Arc<dyn Fn(&mut DagBuilder, SymbolKind, &[DagNodeId]) -> Option<DagNodeId> + Send + Sync>;
102
103/// Shared e-graph rewrite rule closure.
104pub type EGraphRuleArc =
105    Arc<dyn Fn(&mut DagBuilder, &SymbolKind, &[DagNodeId]) -> Option<DagNodeId> + Send + Sync>;
106
107/// A heuristic simplification rule attached to a [`CustomOpDescriptor`].
108pub struct SimplifyRule {
109    /// Human-readable name for fingerprinting and diagnostics.
110    pub name: String,
111    /// Dispatch priority — higher fires first.
112    pub priority: i32,
113    /// The rule closure.
114    pub rule: SimplifyRuleArc,
115}
116
117impl Clone for SimplifyRule {
118    fn clone(&self) -> Self {
119        Self {
120            name: self.name.clone(),
121            priority: self.priority,
122            rule: Arc::clone(&self.rule),
123        }
124    }
125}
126
127/// An e-graph rewrite rule attached to a [`CustomOpDescriptor`].
128pub struct EGraphRule {
129    /// When `true` the rule runs *after* built-in algebraic rules each
130    /// saturation round; `false` runs it before.
131    pub after_builtins: bool,
132    /// The rule closure.
133    pub rule: EGraphRuleArc,
134}
135
136impl Clone for EGraphRule {
137    fn clone(&self) -> Self {
138        Self {
139            after_builtins: self.after_builtins,
140            rule: Arc::clone(&self.rule),
141        }
142    }
143}
144
145// ── CustomOpDescriptor ────────────────────────────────────────────────────
146
147/// Complete descriptor for a user-defined operator.
148///
149/// A descriptor bundles every pipeline-facing property of a custom operator
150/// into a single value.  Register it with [`CustomOpRegistry::register`].
151pub struct CustomOpDescriptor {
152    /// Numeric identifier — unique within a registry.
153    pub fn_id: FnId,
154    /// Human-readable name resolved by the expression parser.
155    pub name: String,
156    /// Native evaluation function called by the JIT via `call`.
157    pub eval_fn: EvalFn,
158    /// When `true`, the operator is pure (no side effects) and may be
159    /// duplicated by the batch f64×2 ILP vectorisation path.
160    pub vectorizable: bool,
161    /// Heuristic simplification rules (fed to the `HeuristicEngine`).
162    pub simplify_rules: Vec<SimplifyRule>,
163    /// E-graph rewrite rules (fed to equality saturation).
164    pub egraph_rules: Vec<EGraphRule>,
165    /// Extraction cost hint for the e-graph extractor (default: `2.0`;
166    /// built-in binary ops are `1.0`, variables `0.5`).
167    pub cost: f64,
168}
169
170impl Clone for CustomOpDescriptor {
171    fn clone(&self) -> Self {
172        Self {
173            fn_id: self.fn_id,
174            name: self.name.clone(),
175            eval_fn: self.eval_fn,
176            vectorizable: self.vectorizable,
177            simplify_rules: self.simplify_rules.clone(),
178            egraph_rules: self.egraph_rules.clone(),
179            cost: self.cost,
180        }
181    }
182}
183
184impl CustomOpDescriptor {
185    /// Start building a descriptor with fluent API.
186    ///
187    /// ```rust
188    /// use rssn_advanced::custom::descriptor::{CustomOpDescriptor, EvalFn};
189    /// use rssn_advanced::dag::builder::DagBuilder;
190    ///
191    /// extern "C" fn sin_approx(x: f64) -> f64 { x.sin() }
192    ///
193    /// let mut builder = DagBuilder::new();
194    /// let fn_id = builder.intern_function("sin");
195    /// let _desc = CustomOpDescriptor::builder(fn_id, "sin", EvalFn::Arity1(sin_approx))
196    ///     .vectorizable()
197    ///     .build();
198    /// ```
199    #[must_use]
200    pub fn builder(
201        fn_id: impl Into<FnId>,
202        name: impl Into<String>,
203        eval_fn: EvalFn,
204    ) -> CustomOpDescriptorBuilder {
205        CustomOpDescriptorBuilder::new(fn_id.into(), name.into(), eval_fn)
206    }
207
208    /// Returns the arity of the underlying evaluation function.
209    #[must_use]
210    pub const fn arity(&self) -> u8 {
211        self.eval_fn.arity()
212    }
213}
214
215// ── CustomOpDescriptorBuilder ─────────────────────────────────────────────
216
217/// Fluent builder for [`CustomOpDescriptor`].
218pub struct CustomOpDescriptorBuilder {
219    fn_id: FnId,
220    name: String,
221    eval_fn: EvalFn,
222    vectorizable: bool,
223    simplify_rules: Vec<SimplifyRule>,
224    egraph_rules: Vec<EGraphRule>,
225    cost: f64,
226}
227
228impl CustomOpDescriptorBuilder {
229    /// Create a builder from the mandatory fields.
230    #[must_use]
231    pub const fn new(fn_id: FnId, name: String, eval_fn: EvalFn) -> Self {
232        Self {
233            fn_id,
234            name,
235            eval_fn,
236            vectorizable: false,
237            simplify_rules: Vec::new(),
238            egraph_rules: Vec::new(),
239            cost: 2.0,
240        }
241    }
242
243    /// Mark the operator as pure and safe for ILP vectorisation.
244    ///
245    /// When set, the batch f64×2 path may call the function twice in the
246    /// same loop body (once per row) to expose instruction-level parallelism.
247    #[must_use]
248    pub const fn vectorizable(mut self) -> Self {
249        self.vectorizable = true;
250        self
251    }
252
253    /// Attach a heuristic simplification rule.
254    ///
255    /// The `rule` closure receives `(builder, kind, children)` for every DAG
256    /// node visited by the simplifier.  Return `Some(replacement_id)` to
257    /// rewrite, `None` to pass.
258    #[must_use]
259    pub fn simplify_rule<F>(mut self, name: impl Into<String>, priority: i32, rule: F) -> Self
260    where
261        F: Fn(&mut DagBuilder, SymbolKind, &[DagNodeId]) -> Option<DagNodeId>
262            + Send
263            + Sync
264            + 'static,
265    {
266        self.simplify_rules.push(SimplifyRule {
267            name: name.into(),
268            priority,
269            rule: Arc::new(rule),
270        });
271        self
272    }
273
274    /// Attach an e-graph rewrite rule.
275    ///
276    /// `after_builtins = false` runs the rule before built-in algebraic rules
277    /// each saturation round; `true` runs it after.
278    #[must_use]
279    pub fn egraph_rule<F>(mut self, after_builtins: bool, rule: F) -> Self
280    where
281        F: Fn(&mut DagBuilder, &SymbolKind, &[DagNodeId]) -> Option<DagNodeId>
282            + Send
283            + Sync
284            + 'static,
285    {
286        self.egraph_rules.push(EGraphRule {
287            after_builtins,
288            rule: Arc::new(rule),
289        });
290        self
291    }
292
293    /// Override the e-graph extraction cost (default `2.0`).
294    #[must_use]
295    pub const fn cost(mut self, c: f64) -> Self {
296        self.cost = c;
297        self
298    }
299
300    /// Consume the builder and produce the descriptor.
301    #[must_use]
302    pub fn build(self) -> CustomOpDescriptor {
303        CustomOpDescriptor {
304            fn_id: self.fn_id,
305            name: self.name,
306            eval_fn: self.eval_fn,
307            vectorizable: self.vectorizable,
308            simplify_rules: self.simplify_rules,
309            egraph_rules: self.egraph_rules,
310            cost: self.cost,
311        }
312    }
313}
314
315// ── CustomOpError ─────────────────────────────────────────────────────────
316
317/// Errors returned by [`CustomOpRegistry::register`].
318#[derive(Debug, Clone, PartialEq, Eq)]
319pub enum CustomOpError {
320    /// A descriptor with the same `FnId` is already registered.
321    DuplicateFnId(FnId),
322    /// A descriptor with the same name is already registered.
323    DuplicateName(String),
324}
325
326impl std::fmt::Display for CustomOpError {
327    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
328        match self {
329            Self::DuplicateFnId(id) => write!(f, "custom op fn_id {id:?} already registered"),
330            Self::DuplicateName(name) => {
331                write!(f, "custom op name {name:?} already registered")
332            }
333        }
334    }
335}
336
337impl std::error::Error for CustomOpError {}
338
339// ── CustomOpRegistry ──────────────────────────────────────────────────────
340
341/// Registry of [`CustomOpDescriptor`]s — the single source of truth for all
342/// custom operators in the pipeline.
343///
344/// Create one registry per compilation unit (or share via `Arc`), register
345/// your operators, then feed it to each pipeline step:
346///
347/// ```rust
348/// # // cfg guard: skip when cranelift-jit feature is absent
349/// # #[cfg(not(feature = "cranelift-jit"))] fn main() {}
350/// # #[cfg(feature = "cranelift-jit")] fn main() {
351/// use std::sync::Arc;
352/// use rssn_advanced::custom::descriptor::{CustomOpDescriptor, CustomOpRegistry, EvalFn};
353/// use rssn_advanced::dag::builder::DagBuilder;
354/// use rssn_advanced::egraph::egraph::{EGraph, EGraphConfig};
355/// use rssn_advanced::jit::compiler::JitCompiler;
356///
357/// extern "C" fn double_it(x: f64) -> f64 { x * 2.0 }
358///
359/// let mut builder = DagBuilder::new();
360/// let fn_id = builder.intern_function("double");
361/// let desc = CustomOpDescriptor::builder(fn_id, "double", EvalFn::Arity1(double_it)).build();
362///
363/// let mut registry = CustomOpRegistry::new();
364/// registry.register(desc).unwrap();
365/// let registry = Arc::new(registry);
366///
367/// // JIT: register eval_fn pointers + enable batch vectorisation
368/// registry.apply_to_jit(&mut JitCompiler::try_new().unwrap());
369///
370/// // Simplifier: generate a RuleRegistry from all simplify_rules
371/// let _rule_reg = registry.build_rule_registry();
372///
373/// // E-graph: inject all egraph_rules into an EGraph instance
374/// {
375///     let mut egraph = EGraph::new(&mut builder, EGraphConfig::default());
376///     registry.apply_to_egraph(&mut egraph);
377/// }
378/// # }
379/// ```
380pub struct CustomOpRegistry {
381    /// Descriptors keyed by numeric `FnId`.
382    ops: HashMap<FnId, CustomOpDescriptor>,
383    /// Name → `FnId` lookup for the expression parser.
384    name_to_id: HashMap<String, FnId>,
385}
386
387impl Default for CustomOpRegistry {
388    fn default() -> Self {
389        Self::new()
390    }
391}
392
393impl CustomOpRegistry {
394    /// Create an empty registry.
395    #[must_use]
396    pub fn new() -> Self {
397        Self {
398            ops: HashMap::new(),
399            name_to_id: HashMap::new(),
400        }
401    }
402
403    /// Register a custom operator descriptor.
404    ///
405    /// # Errors
406    ///
407    /// Returns [`CustomOpError::DuplicateFnId`] or [`CustomOpError::DuplicateName`]
408    /// if the `fn_id` or `name` is already registered.
409    pub fn register(&mut self, desc: CustomOpDescriptor) -> Result<(), CustomOpError> {
410        if self.ops.contains_key(&desc.fn_id) {
411            return Err(CustomOpError::DuplicateFnId(desc.fn_id));
412        }
413        if self.name_to_id.contains_key(&desc.name) {
414            return Err(CustomOpError::DuplicateName(desc.name));
415        }
416        self.name_to_id.insert(desc.name.clone(), desc.fn_id);
417        self.ops.insert(desc.fn_id, desc);
418        Ok(())
419    }
420
421    /// Look up a descriptor by numeric `FnId`.
422    #[must_use]
423    pub fn get(&self, fn_id: FnId) -> Option<&CustomOpDescriptor> {
424        self.ops.get(&fn_id)
425    }
426
427    /// Look up a descriptor by operator name.
428    #[must_use]
429    pub fn get_by_name(&self, name: &str) -> Option<&CustomOpDescriptor> {
430        self.name_to_id.get(name).and_then(|id| self.ops.get(id))
431    }
432
433    /// Mutably look up a descriptor by numeric `FnId`.
434    ///
435    /// Used by the C FFI rule-attachment functions to push additional
436    /// [`SimplifyRule`]s and [`EGraphRule`]s into an already-registered
437    /// descriptor during the build phase (before the registry is shared
438    /// with the JIT via [`Arc`]).
439    pub fn get_mut(&mut self, fn_id: FnId) -> Option<&mut CustomOpDescriptor> {
440        self.ops.get_mut(&fn_id)
441    }
442
443    /// Returns `true` if the operator is registered and flagged vectorizable.
444    ///
445    /// Queried by `JitCompiler::compile_batch_f64x2` before emitting the ILP
446    /// loop — if any `Function` node in the expression is not vectorizable the
447    /// batch path is skipped.
448    #[must_use]
449    pub fn is_vectorizable(&self, fn_id: FnId) -> bool {
450        self.ops.get(&fn_id).is_some_and(|d| d.vectorizable)
451    }
452
453    /// Iterate over all registered descriptors.
454    ///
455    /// Used internally by [`JitCompiler::set_custom_op_registry`](crate::jit::compiler::JitCompiler::set_custom_op_registry) to populate
456    /// the JIT's function-pointer table.
457    pub fn ops_iter(&self) -> impl Iterator<Item = &CustomOpDescriptor> {
458        self.ops.values()
459    }
460
461    /// Pre-intern all custom operator names in a `DagBuilder`.
462    ///
463    /// Call this before parsing expressions that reference custom operators,
464    /// so the parser resolves names to the correct `FnId` values.
465    pub fn register_with_builder(&self, builder: &mut DagBuilder) {
466        for desc in self.ops.values() {
467            let _id = builder.intern_function(&desc.name);
468        }
469    }
470
471    // ── Pipeline integrations ─────────────────────────────────────────────
472
473    /// Feed all custom operator evaluation functions into a [`JitCompiler`](crate::jit::compiler::JitCompiler).
474    ///
475    /// This populates the compiler's internal custom-function registry (so
476    /// the JIT can emit `call rssn_custom_fn_N` instructions) and stores the
477    /// registry reference so `compile_batch_f64x2` can check `is_vectorizable`.
478    #[cfg(feature = "cranelift-jit")]
479    pub fn apply_to_jit(
480        self: &std::sync::Arc<Self>,
481        compiler: &mut crate::jit::compiler::JitCompiler,
482    ) {
483        compiler.set_custom_op_registry(std::sync::Arc::clone(self));
484    }
485
486    /// Build a [`RuleRegistry`] populated with every simplification rule from
487    /// all registered descriptors.
488    ///
489    /// Pass the result to `HeuristicEngine::with_rule_registry` or to
490    /// `rssn_dag_simplify_with_rules`.
491    #[must_use]
492    pub fn build_rule_registry(&self) -> RuleRegistry {
493        let mut registry = RuleRegistry::new();
494        for desc in self.ops.values() {
495            for rule in &desc.simplify_rules {
496                // Clone the Arc cheaply; the trampoline Box captures it.
497                let arc = Arc::clone(&rule.rule);
498                let trampoline: RuleFn = Box::new(move |b, k, c| arc(b, k, c));
499                registry.register_named(&rule.name, trampoline, rule.priority, None);
500            }
501        }
502        registry
503    }
504
505    /// Inject all e-graph rewrite rules from all registered descriptors into
506    /// an [`EGraph`][crate::egraph::egraph::EGraph] instance.
507    ///
508    /// Rules with `after_builtins = false` are added before built-in rules;
509    /// rules with `after_builtins = true` are added after.
510    pub fn apply_to_egraph(&self, egraph: &mut crate::egraph::egraph::EGraph<'_>) {
511        for desc in self.ops.values() {
512            for rule in &desc.egraph_rules {
513                let arc = Arc::clone(&rule.rule);
514                if rule.after_builtins {
515                    egraph.add_rule_after_builtins(move |b, k, c| arc(b, k, c));
516                } else {
517                    egraph.add_rule(move |b, k, c| arc(b, k, c));
518                }
519            }
520        }
521    }
522}