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}