tenferro_ops/ext_op.rs
1//! Out-of-tree extension-operation mechanism.
2//!
3//! This module implements the [`ExtensionOp`] trait and its process-local
4//! registry. Together they let external crates contribute fused primitives
5//! that participate in the [`crate::std_tensor_op::StdTensorOp`] graph through
6//! the single carrier variant
7//! `StdTensorOp::Extension(Arc<dyn ExtensionOp>)`.
8//!
9//! See `docs/spec/extension-op.md` for the normative contract. Key points:
10//!
11//! - Identity / hashing / equality are expressed on the trait so the
12//! type-erased `Arc<dyn ExtensionOp>` carrier can satisfy
13//! `Clone + Hash + Eq + Send + Sync + 'static` (computegraph's
14//! `GraphOperation` requirements).
15//! - AD rules are owned by explicit [`ExtensionRuleSet`] values. A rule may
16//! emit core [`StdTensorOp`] values and registered `Extension` values so
17//! out-of-tree operations remain in the same graph.
18//! - Extension ops themselves do not require process-global registration.
19//! Frontends carry them directly as `Arc<dyn ExtensionOp>`.
20
21use std::any::Any;
22use std::fmt::Debug;
23use std::hash::{Hash, Hasher};
24use std::sync::Arc;
25
26use computegraph::graph::GraphBuilder;
27#[cfg(not(feature = "autodiff"))]
28use computegraph::types::ValueRef;
29#[cfg(feature = "autodiff")]
30use computegraph::types::{LocalValueId, OperationRole, ValueKey, ValueRef};
31use tenferro_tensor::{DType, Tensor};
32#[cfg(feature = "autodiff")]
33use tidu::{ADRuleError, ADRuleKind, ADRuleResult, PrimitiveTransposeInput};
34
35#[cfg(feature = "autodiff")]
36use crate::ad::context::ShapeGuardContext;
37#[cfg(feature = "autodiff")]
38use crate::ad::PrimitiveRuleBuilder;
39use crate::std_tensor_op::StdTensorOp;
40use crate::sym_dim::SymDim;
41#[cfg(feature = "autodiff")]
42use std::collections::HashMap;
43
44/// Error returned when an extension cannot expand itself into standard ops.
45///
46/// # Examples
47///
48/// ```
49/// use tenferro_ops::ext_op::ExtensionLoweringError;
50///
51/// let err = ExtensionLoweringError::new("example extension cannot lower");
52/// assert!(err.to_string().contains("cannot lower"));
53/// ```
54#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
55#[error("{message}")]
56pub struct ExtensionLoweringError {
57 message: String,
58}
59
60impl ExtensionLoweringError {
61 /// Create a lowering error with a human-readable diagnostic.
62 ///
63 /// # Examples
64 ///
65 /// ```
66 /// use tenferro_ops::ext_op::ExtensionLoweringError;
67 ///
68 /// let err = ExtensionLoweringError::new("shape must be static");
69 /// assert_eq!(err.to_string(), "shape must be static");
70 /// ```
71 pub fn new(message: impl Into<String>) -> Self {
72 Self {
73 message: message.into(),
74 }
75 }
76}
77
78/// Result returned by [`ExtensionOp::lower_to_standard_ops`].
79pub type ExtensionLoweringResult =
80 std::result::Result<Option<Vec<ValueRef<StdTensorOp>>>, ExtensionLoweringError>;
81
82/// Host/reference implementation for an extension family.
83///
84/// This capability is optional. Backend-only extension families can omit it
85/// and still implement [`ExtensionOp`]; runtimes that specifically delegate to
86/// host reference execution must report a typed capability-missing error when
87/// this hook is absent.
88///
89/// # Examples
90///
91/// ```
92/// use tenferro_ops::ext_op::HostReference;
93/// use tenferro_tensor::Tensor;
94///
95/// #[derive(Debug)]
96/// struct IdentityHost;
97///
98/// impl HostReference for IdentityHost {
99/// fn execute(&self, inputs: &[&Tensor]) -> tenferro_tensor::Result<Vec<Tensor>> {
100/// Ok(vec![inputs[0].clone()])
101/// }
102/// }
103///
104/// let input = Tensor::from_vec_col_major(vec![1], vec![3.0_f64]).unwrap();
105/// let output = IdentityHost.execute(&[&input]).unwrap();
106/// assert_eq!(output[0].as_slice::<f64>().unwrap(), &[3.0]);
107/// ```
108pub trait HostReference: Debug + Send + Sync + 'static {
109 /// Execute the extension op on host/reference tensors.
110 fn execute(&self, inputs: &[&Tensor]) -> tenferro_tensor::Result<Vec<Tensor>>;
111}
112
113/// The contract every out-of-tree extension primitive must satisfy.
114///
115/// Implementations appear in the core graph as
116/// `StdTensorOp::Extension(Arc<dyn ExtensionOp>)`. Every method is part of the
117/// `ExtensionOp` spec (`docs/spec/extension-op.md`); the short form:
118///
119/// - identity via [`family_id`][Self::family_id] + [`payload_hash`][Self::payload_hash]
120/// + [`payload_eq`][Self::payload_eq];
121/// - fixed arity via [`input_count`][Self::input_count] / [`output_count`][Self::output_count];
122/// - shape / dtype inference via [`infer_output_meta`][Self::infer_output_meta];
123/// - optional host/reference forward execution via
124/// [`host_reference`][Self::host_reference];
125/// - optional fixed-shape standard-op expansion via
126/// [`lower_to_standard_ops`][Self::lower_to_standard_ops] for peer lowerers
127/// such as XLA that cannot execute extension runtimes;
128/// - AD via separately registered role-specific extension rules.
129///
130/// # Downcast convention
131///
132/// Implementations MUST also implement [`Any`] so that
133/// [`ExtensionOp::payload_eq`] can downcast a trait-object reference to
134/// the concrete type. The helper [`ExtensionOp::as_any`] returns
135/// `&dyn Any` for this purpose. Implementations usually define it as
136/// `fn as_any(&self) -> &dyn Any { self }`.
137///
138/// # Examples
139///
140/// ```
141/// # use std::any::Any;
142/// use std::sync::Arc;
143/// use tenferro_ops::ext_op::{ExtensionOp, HostReference};
144/// use tenferro_ops::SymDim;
145/// use tenferro_tensor::{DType, Tensor};
146///
147/// #[derive(Clone, Debug)]
148/// struct IdentityExt;
149///
150/// impl ExtensionOp for IdentityExt {
151/// fn family_id(&self) -> &'static str { "example.identity.v1" }
152/// fn payload_hash(&self, _hasher: &mut dyn std::hash::Hasher) {}
153/// fn payload_eq(&self, other: &dyn ExtensionOp) -> bool {
154/// other.as_any().downcast_ref::<IdentityExt>().is_some()
155/// }
156/// fn clone_arc(&self) -> Arc<dyn ExtensionOp> { Arc::new(self.clone()) }
157/// fn as_any(&self) -> &dyn Any { self }
158/// fn input_count(&self) -> usize { 1 }
159/// fn output_count(&self) -> usize { 1 }
160/// fn infer_output_meta(
161/// &self,
162/// dtypes: &[DType],
163/// shapes: &[&[SymDim]],
164/// ) -> tenferro_tensor::Result<Vec<(DType, Vec<SymDim>)>> {
165/// Ok(vec![(dtypes[0], shapes[0].to_vec())])
166/// }
167/// fn host_reference(&self) -> Option<&dyn HostReference> {
168/// Some(self)
169/// }
170/// }
171///
172/// impl HostReference for IdentityExt {
173/// fn execute(&self, inputs: &[&Tensor]) -> tenferro_tensor::Result<Vec<Tensor>> {
174/// Ok(vec![inputs[0].clone()])
175/// }
176/// }
177///
178/// let op: Arc<dyn ExtensionOp> = Arc::new(IdentityExt);
179/// assert_eq!(op.input_count(), 1);
180/// ```
181pub trait ExtensionOp: Debug + Send + Sync + 'static {
182 // ----- Identity, hashing, equality (spec Section 5) -----
183
184 /// Stable, process-independent family identifier.
185 ///
186 /// MUST be unique per extension *family* (payload schema), not per
187 /// *instance*, and MUST follow the reserved format
188 /// `"<crate-name>.<op-name>.v<major>"`.
189 fn family_id(&self) -> &'static str;
190
191 /// Hash the payload (everything except `family_id`).
192 ///
193 /// Implementations MUST be pure and deterministic across calls on the same
194 /// value. Hashes MUST NOT include transient state such as allocation
195 /// addresses or atomically updated counters.
196 fn payload_hash(&self, hasher: &mut dyn Hasher);
197
198 /// Structural equality against another extension value.
199 ///
200 /// The carrier's `PartialEq` impl first compares `family_id`s. When the
201 /// family IDs match, it calls `payload_eq`. Implementations MUST return
202 /// `true` iff the payloads are semantically equal AND
203 /// `other.family_id() == self.family_id()`.
204 fn payload_eq(&self, other: &dyn ExtensionOp) -> bool;
205
206 /// Deep-clone the payload behind an `Arc`.
207 ///
208 /// The carrier's `Clone` impl uses `Arc::clone` on the fast path; this
209 /// method exists for rare cases that need a second independent `Arc`.
210 fn clone_arc(&self) -> Arc<dyn ExtensionOp>;
211
212 /// Upcast this extension to `&dyn Any` for downcasting in `payload_eq`.
213 ///
214 /// Implementations SHOULD return `self` verbatim. The method is
215 /// object-safe (no `Self: Sized` bound) so it can be called on an
216 /// `&dyn ExtensionOp`; that's what makes
217 /// `other.as_any().downcast_ref::<ConcreteType>()` work from
218 /// [`Self::payload_eq`] implementations.
219 fn as_any(&self) -> &dyn Any;
220
221 // ----- Arity (spec Section 6) -----
222
223 /// Number of primal inputs. MUST be constant for any given
224 /// `Arc<dyn ExtensionOp>` value.
225 fn input_count(&self) -> usize;
226
227 /// Number of outputs. MUST match the length of the vector returned by a
228 /// successful [`Self::infer_output_meta`] call.
229 fn output_count(&self) -> usize;
230
231 // ----- Shape and dtype inference (spec Section 7) -----
232
233 /// Infer output dtypes and shapes for each output slot.
234 ///
235 /// Implementations MUST validate arity, rank, dtype, axis, and other
236 /// input-derived metadata before indexing shape arrays. Invalid public
237 /// input must return a typed error rather than an empty sentinel or panic.
238 ///
239 /// On success, the returned vector MUST have length `self.output_count()`,
240 /// one `(dtype, shape)` entry per output slot. Shapes use [`SymDim`] so
241 /// extension ops compose with graph-global symbolic metadata.
242 fn infer_output_meta(
243 &self,
244 input_dtypes: &[DType],
245 input_shapes: &[&[SymDim]],
246 ) -> tenferro_tensor::Result<Vec<(DType, Vec<SymDim>)>>;
247
248 // ----- Optional host/reference execution (spec Section 8) -----
249
250 /// Return the host/reference implementation for this payload, when the
251 /// family has one.
252 ///
253 /// Backend-only extension families may leave the default `None`. Runtime
254 /// execution never silently falls back to this hook; a runtime must opt in
255 /// explicitly by using this capability.
256 fn host_reference(&self) -> Option<&dyn HostReference> {
257 None
258 }
259
260 /// Optionally expand this extension into standard tensor graph operations.
261 ///
262 /// Peer lowerers call this when all input metadata is known and extension
263 /// runtime dispatch is not available. Return `Ok(Some(outputs))` after
264 /// adding only standard [`StdTensorOp`] operations to `builder`. Return
265 /// `Ok(None)` when this extension family has no standard-op lowering for
266 /// the supplied metadata; strict lowerers should surface that as an
267 /// explicit unsupported-extension error. Return [`ExtensionLoweringError`]
268 /// when the payload is malformed or the lowering detects invalid metadata.
269 ///
270 /// The default implementation returns `Ok(None)` so existing extension
271 /// runtimes keep their native dispatch behavior until their owning crate
272 /// deliberately implements this hook.
273 fn lower_to_standard_ops(
274 &self,
275 _builder: &mut GraphBuilder<StdTensorOp>,
276 _inputs: &[ValueRef<StdTensorOp>],
277 _input_dtypes: &[DType],
278 _input_shapes: &[&[SymDim]],
279 ) -> ExtensionLoweringResult {
280 Ok(None)
281 }
282
283 /// Optionally return an equivalent op that produces only live outputs.
284 ///
285 /// `live_outputs` is aligned with this op's current output slots. Return
286 /// `None` when the family does not support output pruning. Return
287 /// `Some(op)` only when the new op's outputs are exactly the live output
288 /// slots, in ascending slot order, and `op.output_count()` equals the
289 /// number of `true` entries in `live_outputs`.
290 fn prune_outputs(&self, _live_outputs: &[bool]) -> Option<Arc<dyn ExtensionOp>> {
291 None
292 }
293
294 // AD rules are registered separately; see the role-specific rule traits.
295}
296
297/// Definitional JVP rule provider for an extension family.
298///
299/// Required only for families that appear in primal graphs and must be
300/// differentiable.
301#[cfg(feature = "autodiff")]
302pub trait ExtensionLinearizeRule: Debug + Send + Sync + 'static {
303 /// The extension family this rule handles.
304 fn family_id(&self) -> &'static str;
305
306 /// Emit the linear (JVP) rule.
307 fn linearize(
308 &self,
309 op: &dyn ExtensionOp,
310 builder: &mut dyn PrimitiveRuleBuilder,
311 primal_in: &[ValueKey<StdTensorOp>],
312 primal_out: &[ValueKey<StdTensorOp>],
313 tangent_in: &[Option<LocalValueId>],
314 ctx: &mut ShapeGuardContext,
315 ) -> ADRuleResult<Vec<Option<LocalValueId>>>;
316}
317
318/// Adjoint rule for an extension op viewed as a linear map in active inputs.
319///
320/// This rule is used only while `linear_transpose` walks a linearized graph.
321#[cfg(feature = "autodiff")]
322pub trait ExtensionLinearTransposeRule: Debug + Send + Sync + 'static {
323 /// The extension family this rule handles.
324 fn family_id(&self) -> &'static str;
325
326 /// Emit cotangents for active linear inputs.
327 fn linear_transpose(
328 &self,
329 op: &dyn ExtensionOp,
330 builder: &mut dyn PrimitiveRuleBuilder,
331 cotangent_out: &[Option<LocalValueId>],
332 inputs: &[PrimitiveTransposeInput<StdTensorOp>],
333 active_mask: &[bool],
334 ctx: &mut ShapeGuardContext,
335 ) -> ADRuleResult<Vec<Option<LocalValueId>>>;
336}
337
338/// Optional direct primal VJP rule provider for an extension family.
339///
340/// This is an opt-in performance or compatibility escape hatch. The default
341/// reverse-mode path remains `linearize` followed by `linear_transpose`.
342#[cfg(feature = "autodiff")]
343pub trait ExtensionPrimalVjpRule: Debug + Send + Sync + 'static {
344 /// The extension family this rule handles.
345 fn family_id(&self) -> &'static str;
346
347 /// Emit a direct VJP from the primal op.
348 fn primal_vjp(
349 &self,
350 op: &dyn ExtensionOp,
351 builder: &mut dyn PrimitiveRuleBuilder,
352 cotangent_out: &[Option<LocalValueId>],
353 inputs: &[ValueRef<StdTensorOp>],
354 ctx: &mut ShapeGuardContext,
355 ) -> ADRuleResult<Vec<Option<LocalValueId>>>;
356}
357
358/// Extension AD rule role.
359#[cfg(feature = "autodiff")]
360#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
361pub enum ExtensionRuleRole {
362 /// Definitional JVP.
363 Linearize,
364 /// Transpose of an op as a linear map.
365 LinearTranspose,
366 /// Direct VJP from the primal graph.
367 PrimalVjp,
368}
369
370/// Errors returned from extension registries.
371#[cfg(feature = "autodiff")]
372#[derive(Debug, thiserror::Error)]
373pub enum ExtensionRegistryError {
374 /// An AD rule with the same `family_id` was already registered for one role.
375 #[error("extension AD {role:?} rule for family_id {family_id:?} already registered")]
376 DuplicateRule {
377 /// Duplicate extension family id.
378 family_id: &'static str,
379 /// Duplicate AD rule role.
380 role: ExtensionRuleRole,
381 },
382 /// The `family_id` does not match the namespaced format
383 /// `"<crate-name>.<op-name>.v<major>"`.
384 #[error("family_id {family_id:?} does not match the namespaced format")]
385 MalformedFamilyId { family_id: &'static str },
386}
387
388#[cfg(feature = "autodiff")]
389type LinearizeRuleMap = HashMap<&'static str, Arc<dyn ExtensionLinearizeRule>>;
390#[cfg(feature = "autodiff")]
391type LinearTransposeRuleMap = HashMap<&'static str, Arc<dyn ExtensionLinearTransposeRule>>;
392#[cfg(feature = "autodiff")]
393type PrimalVjpRuleMap = HashMap<&'static str, Arc<dyn ExtensionPrimalVjpRule>>;
394
395/// Explicit, owned set of extension AD rules.
396///
397/// This is the rule container used by higher-level AD contexts. Extension AD
398/// intentionally has no process-global fallback; callers must pass the rule set
399/// that their graph needs.
400#[cfg(feature = "autodiff")]
401#[derive(Clone, Default)]
402pub struct ExtensionRuleSet {
403 linearize_rules: Arc<LinearizeRuleMap>,
404 linear_transpose_rules: Arc<LinearTransposeRuleMap>,
405 primal_vjp_rules: Arc<PrimalVjpRuleMap>,
406}
407
408#[cfg(feature = "autodiff")]
409impl std::fmt::Debug for ExtensionRuleSet {
410 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
411 let mut linearize: Vec<_> = self.linearize_rules.keys().copied().collect();
412 let mut linear_transpose: Vec<_> = self.linear_transpose_rules.keys().copied().collect();
413 let mut primal_vjp: Vec<_> = self.primal_vjp_rules.keys().copied().collect();
414 linearize.sort_unstable();
415 linear_transpose.sort_unstable();
416 primal_vjp.sort_unstable();
417 f.debug_struct("ExtensionRuleSet")
418 .field("linearize", &linearize)
419 .field("linear_transpose", &linear_transpose)
420 .field("primal_vjp", &primal_vjp)
421 .finish()
422 }
423}
424
425#[cfg(feature = "autodiff")]
426impl ExtensionRuleSet {
427 /// Create an empty extension rule set.
428 ///
429 /// # Examples
430 ///
431 /// ```
432 /// use tenferro_ops::ExtensionRuleSet;
433 ///
434 /// let rules = ExtensionRuleSet::new();
435 /// assert!(!rules.is_linearize_registered("example.missing.v1"));
436 /// ```
437 pub fn new() -> Self {
438 Self::default()
439 }
440
441 /// Add one linearize rule to this owned set.
442 ///
443 /// # Examples
444 ///
445 /// ```
446 /// use std::sync::Arc;
447 /// use tidu::ADRuleResult;
448 /// use computegraph::types::{LocalValueId, ValueKey};
449 /// use tenferro_ops::ad::PrimitiveRuleBuilder;
450 /// use tenferro_ops::ext_op::{ExtensionLinearizeRule, ExtensionOp};
451 /// use tenferro_ops::{ExtensionRuleSet, ShapeGuardContext};
452 /// use tenferro_ops::std_tensor_op::StdTensorOp;
453 ///
454 /// #[derive(Debug)]
455 /// struct Rule;
456 ///
457 /// impl ExtensionLinearizeRule for Rule {
458 /// fn family_id(&self) -> &'static str { "example.register_linearize.v1" }
459 /// fn linearize(
460 /// &self,
461 /// _op: &dyn ExtensionOp,
462 /// _builder: &mut dyn PrimitiveRuleBuilder,
463 /// _primal_in: &[ValueKey<StdTensorOp>],
464 /// _primal_out: &[ValueKey<StdTensorOp>],
465 /// tangent_in: &[Option<LocalValueId>],
466 /// _ctx: &mut ShapeGuardContext,
467 /// ) -> ADRuleResult<Vec<Option<LocalValueId>>> {
468 /// Ok(tangent_in.to_vec())
469 /// }
470 /// }
471 ///
472 /// let mut rules = ExtensionRuleSet::new();
473 /// rules.register_linearize(Arc::new(Rule)).unwrap();
474 /// assert!(rules.lookup_linearize("example.register_linearize.v1").is_some());
475 /// ```
476 pub fn register_linearize(
477 &mut self,
478 rule: Arc<dyn ExtensionLinearizeRule>,
479 ) -> Result<(), ExtensionRegistryError> {
480 let family_id = rule.family_id();
481 validate_linearize_insert(&self.linearize_rules, family_id)?;
482 let rules = Arc::make_mut(&mut self.linearize_rules);
483 rules.insert(family_id, rule);
484 Ok(())
485 }
486
487 /// Add one linear-transpose rule to this owned set.
488 pub fn register_linear_transpose(
489 &mut self,
490 rule: Arc<dyn ExtensionLinearTransposeRule>,
491 ) -> Result<(), ExtensionRegistryError> {
492 let family_id = rule.family_id();
493 validate_linear_transpose_insert(&self.linear_transpose_rules, family_id)?;
494 let rules = Arc::make_mut(&mut self.linear_transpose_rules);
495 rules.insert(family_id, rule);
496 Ok(())
497 }
498
499 /// Add one primal-VJP rule to this owned set.
500 pub fn register_primal_vjp(
501 &mut self,
502 rule: Arc<dyn ExtensionPrimalVjpRule>,
503 ) -> Result<(), ExtensionRegistryError> {
504 let family_id = rule.family_id();
505 validate_primal_vjp_insert(&self.primal_vjp_rules, family_id)?;
506 let rules = Arc::make_mut(&mut self.primal_vjp_rules);
507 rules.insert(family_id, rule);
508 Ok(())
509 }
510
511 /// Return a new rule set containing a linearize rule.
512 ///
513 /// # Examples
514 ///
515 /// ```
516 /// use std::sync::Arc;
517 /// use tidu::ADRuleResult;
518 /// use computegraph::types::{LocalValueId, ValueKey};
519 /// use tenferro_ops::ad::PrimitiveRuleBuilder;
520 /// use tenferro_ops::ext_op::{ExtensionLinearizeRule, ExtensionOp};
521 /// use tenferro_ops::{ExtensionRuleSet, ShapeGuardContext};
522 /// use tenferro_ops::std_tensor_op::StdTensorOp;
523 ///
524 /// #[derive(Debug)]
525 /// struct Rule;
526 ///
527 /// impl ExtensionLinearizeRule for Rule {
528 /// fn family_id(&self) -> &'static str { "example.with_linearize.v1" }
529 /// fn linearize(
530 /// &self,
531 /// _op: &dyn ExtensionOp,
532 /// _builder: &mut dyn PrimitiveRuleBuilder,
533 /// _primal_in: &[ValueKey<StdTensorOp>],
534 /// _primal_out: &[ValueKey<StdTensorOp>],
535 /// tangent_in: &[Option<LocalValueId>],
536 /// _ctx: &mut ShapeGuardContext,
537 /// ) -> ADRuleResult<Vec<Option<LocalValueId>>> {
538 /// Ok(tangent_in.to_vec())
539 /// }
540 /// }
541 ///
542 /// let rules = ExtensionRuleSet::new().with_linearize(Arc::new(Rule)).unwrap();
543 /// assert!(rules.is_linearize_registered("example.with_linearize.v1"));
544 /// ```
545 pub fn with_linearize(
546 mut self,
547 rule: Arc<dyn ExtensionLinearizeRule>,
548 ) -> Result<Self, ExtensionRegistryError> {
549 self.register_linearize(rule)?;
550 Ok(self)
551 }
552
553 /// Return a new rule set containing a linear-transpose rule.
554 pub fn with_linear_transpose(
555 mut self,
556 rule: Arc<dyn ExtensionLinearTransposeRule>,
557 ) -> Result<Self, ExtensionRegistryError> {
558 self.register_linear_transpose(rule)?;
559 Ok(self)
560 }
561
562 /// Return a new rule set containing a primal-VJP rule.
563 pub fn with_primal_vjp(
564 mut self,
565 rule: Arc<dyn ExtensionPrimalVjpRule>,
566 ) -> Result<Self, ExtensionRegistryError> {
567 self.register_primal_vjp(rule)?;
568 Ok(self)
569 }
570
571 /// Merge another owned rule set into this one.
572 ///
573 /// The merge is atomic: if any rule in `other` is invalid or duplicates an
574 /// existing family, `self` is left unchanged.
575 ///
576 /// # Examples
577 ///
578 /// ```
579 /// use tenferro_ops::ExtensionRuleSet;
580 ///
581 /// let mut rules = ExtensionRuleSet::new();
582 /// rules.merge(ExtensionRuleSet::new()).unwrap();
583 /// assert!(!rules.is_linearize_registered("example.missing.v1"));
584 /// ```
585 pub fn merge(&mut self, other: ExtensionRuleSet) -> Result<(), ExtensionRegistryError> {
586 let mut linearize_rules = (*self.linearize_rules).clone();
587 let mut linear_transpose_rules = (*self.linear_transpose_rules).clone();
588 let mut primal_vjp_rules = (*self.primal_vjp_rules).clone();
589 for rule in other.linearize_rules.values() {
590 insert_linearize_rule(&mut linearize_rules, Arc::clone(rule))?;
591 }
592 for rule in other.linear_transpose_rules.values() {
593 insert_linear_transpose_rule(&mut linear_transpose_rules, Arc::clone(rule))?;
594 }
595 for rule in other.primal_vjp_rules.values() {
596 insert_primal_vjp_rule(&mut primal_vjp_rules, Arc::clone(rule))?;
597 }
598 self.linearize_rules = Arc::new(linearize_rules);
599 self.linear_transpose_rules = Arc::new(linear_transpose_rules);
600 self.primal_vjp_rules = Arc::new(primal_vjp_rules);
601 Ok(())
602 }
603
604 /// Look up a linearize rule in this set.
605 ///
606 /// # Examples
607 ///
608 /// ```
609 /// use tenferro_ops::ExtensionRuleSet;
610 ///
611 /// let rules = ExtensionRuleSet::new();
612 /// assert!(rules.lookup_linearize("example.missing.v1").is_none());
613 /// ```
614 pub fn lookup_linearize(&self, family_id: &str) -> Option<Arc<dyn ExtensionLinearizeRule>> {
615 self.linearize_rules.get(family_id).cloned()
616 }
617
618 /// Look up a linear-transpose rule in this set.
619 pub fn lookup_linear_transpose(
620 &self,
621 family_id: &str,
622 ) -> Option<Arc<dyn ExtensionLinearTransposeRule>> {
623 self.linear_transpose_rules.get(family_id).cloned()
624 }
625
626 /// Look up a primal-VJP rule in this set.
627 pub fn lookup_primal_vjp(&self, family_id: &str) -> Option<Arc<dyn ExtensionPrimalVjpRule>> {
628 self.primal_vjp_rules.get(family_id).cloned()
629 }
630
631 /// Return whether `family_id` has a linearize rule.
632 ///
633 /// # Examples
634 ///
635 /// ```
636 /// use tenferro_ops::ExtensionRuleSet;
637 ///
638 /// let rules = ExtensionRuleSet::new();
639 /// assert!(!rules.is_linearize_registered("example.missing.v1"));
640 /// ```
641 pub fn is_linearize_registered(&self, family_id: &str) -> bool {
642 self.linearize_rules.contains_key(family_id)
643 }
644
645 /// Return whether `family_id` has a linear-transpose rule.
646 pub fn is_linear_transpose_registered(&self, family_id: &str) -> bool {
647 self.linear_transpose_rules.contains_key(family_id)
648 }
649
650 /// Return whether `family_id` has a primal-VJP rule.
651 pub fn is_primal_vjp_registered(&self, family_id: &str) -> bool {
652 self.primal_vjp_rules.contains_key(family_id)
653 }
654}
655
656#[cfg(feature = "autodiff")]
657fn is_valid_family_id(family_id: &str) -> bool {
658 // Required shape: `<crate>.<op>.v<major>` with at least one non-empty
659 // `<crate>` chunk, at least one non-empty `<op>` chunk (which may itself
660 // contain `.`), and a final `.v<integer>` component. `<crate>` and
661 // `<op>` segments must be ASCII with no whitespace.
662 let mut parts = family_id.rsplitn(2, '.');
663 let Some(version_part) = parts.next() else {
664 return false;
665 };
666 let Some(prefix) = parts.next() else {
667 return false;
668 };
669 if !version_part.starts_with('v') {
670 return false;
671 }
672 let digits = &version_part[1..];
673 if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
674 return false;
675 }
676 let Some((crate_name, op_name)) = prefix.split_once('.') else {
677 return false;
678 };
679 if crate_name.is_empty() || op_name.is_empty() {
680 return false;
681 }
682 let any_invalid = |s: &str| s.chars().any(|c| c.is_whitespace() || !c.is_ascii());
683 if any_invalid(crate_name) || any_invalid(op_name) {
684 return false;
685 }
686 true
687}
688
689#[cfg(feature = "autodiff")]
690fn insert_linearize_rule(
691 rules: &mut LinearizeRuleMap,
692 rule: Arc<dyn ExtensionLinearizeRule>,
693) -> Result<(), ExtensionRegistryError> {
694 let family_id = rule.family_id();
695 validate_linearize_insert(rules, family_id)?;
696 rules.insert(family_id, rule);
697 Ok(())
698}
699
700#[cfg(feature = "autodiff")]
701fn insert_linear_transpose_rule(
702 rules: &mut LinearTransposeRuleMap,
703 rule: Arc<dyn ExtensionLinearTransposeRule>,
704) -> Result<(), ExtensionRegistryError> {
705 let family_id = rule.family_id();
706 validate_linear_transpose_insert(rules, family_id)?;
707 rules.insert(family_id, rule);
708 Ok(())
709}
710
711#[cfg(feature = "autodiff")]
712fn insert_primal_vjp_rule(
713 rules: &mut PrimalVjpRuleMap,
714 rule: Arc<dyn ExtensionPrimalVjpRule>,
715) -> Result<(), ExtensionRegistryError> {
716 let family_id = rule.family_id();
717 validate_primal_vjp_insert(rules, family_id)?;
718 rules.insert(family_id, rule);
719 Ok(())
720}
721
722#[cfg(feature = "autodiff")]
723fn validate_rule_insert(
724 contains_family: bool,
725 family_id: &'static str,
726 role: ExtensionRuleRole,
727) -> Result<(), ExtensionRegistryError> {
728 if !is_valid_family_id(family_id) {
729 return Err(ExtensionRegistryError::MalformedFamilyId { family_id });
730 }
731 if contains_family {
732 return Err(ExtensionRegistryError::DuplicateRule { family_id, role });
733 }
734 Ok(())
735}
736
737#[cfg(feature = "autodiff")]
738fn validate_linearize_insert(
739 rules: &LinearizeRuleMap,
740 family_id: &'static str,
741) -> Result<(), ExtensionRegistryError> {
742 validate_rule_insert(
743 rules.contains_key(family_id),
744 family_id,
745 ExtensionRuleRole::Linearize,
746 )
747}
748
749#[cfg(feature = "autodiff")]
750fn validate_linear_transpose_insert(
751 rules: &LinearTransposeRuleMap,
752 family_id: &'static str,
753) -> Result<(), ExtensionRegistryError> {
754 validate_rule_insert(
755 rules.contains_key(family_id),
756 family_id,
757 ExtensionRuleRole::LinearTranspose,
758 )
759}
760
761#[cfg(feature = "autodiff")]
762fn validate_primal_vjp_insert(
763 rules: &PrimalVjpRuleMap,
764 family_id: &'static str,
765) -> Result<(), ExtensionRegistryError> {
766 validate_rule_insert(
767 rules.contains_key(family_id),
768 family_id,
769 ExtensionRuleRole::PrimalVjp,
770 )
771}
772
773/// Emit a registered extension linearization rule.
774#[cfg(feature = "autodiff")]
775pub fn linearize_extension_rule(
776 op: &dyn ExtensionOp,
777 builder: &mut dyn PrimitiveRuleBuilder,
778 primal_in: &[ValueKey<StdTensorOp>],
779 primal_out: &[ValueKey<StdTensorOp>],
780 tangent_in: &[Option<LocalValueId>],
781 ctx: &mut ShapeGuardContext,
782) -> ADRuleResult<Vec<Option<LocalValueId>>> {
783 match ctx.extension_linearize_rule_for(op.family_id()) {
784 Some(rule) => rule.linearize(op, builder, primal_in, primal_out, tangent_in, ctx),
785 None => Err(ADRuleError::unsupported(op.family_id(), ADRuleKind::Jvp)),
786 }
787}
788
789/// Emit a registered extension transpose rule.
790#[cfg(feature = "autodiff")]
791pub fn transpose_extension_rule(
792 op: &dyn ExtensionOp,
793 builder: &mut dyn PrimitiveRuleBuilder,
794 cotangent_out: &[Option<LocalValueId>],
795 inputs: &[PrimitiveTransposeInput<StdTensorOp>],
796 mode: &OperationRole,
797 ctx: &mut ShapeGuardContext,
798) -> ADRuleResult<Vec<Option<LocalValueId>>> {
799 match mode {
800 OperationRole::Linearized { active_mask } => {
801 match ctx.extension_linear_transpose_rule_for(op.family_id()) {
802 Some(rule) => {
803 rule.linear_transpose(op, builder, cotangent_out, inputs, active_mask, ctx)
804 }
805 None => Err(ADRuleError::unsupported(
806 op.family_id(),
807 ADRuleKind::Transpose,
808 )),
809 }
810 }
811 OperationRole::Primary => match ctx.extension_primal_vjp_rule_for(op.family_id()) {
812 Some(rule) => {
813 let value_inputs = primal_vjp_inputs(inputs)?;
814 rule.primal_vjp(op, builder, cotangent_out, &value_inputs, ctx)
815 }
816 None => Err(ADRuleError::unsupported(
817 op.family_id(),
818 ADRuleKind::Transpose,
819 )),
820 },
821 }
822}
823
824#[cfg(feature = "autodiff")]
825fn primal_vjp_inputs(
826 inputs: &[PrimitiveTransposeInput<StdTensorOp>],
827) -> ADRuleResult<Vec<ValueRef<StdTensorOp>>> {
828 inputs
829 .iter()
830 .enumerate()
831 .map(|(index, input)| match input {
832 PrimitiveTransposeInput::Residual(key) => Ok(ValueRef::External(key.clone())),
833 PrimitiveTransposeInput::Linear {
834 primal: Some(primal),
835 ..
836 } => Ok(ValueRef::External(primal.clone())),
837 PrimitiveTransposeInput::Linear { key, primal: None } => {
838 Err(ADRuleError::invalid_input(
839 "extension transpose",
840 ADRuleKind::Transpose,
841 format!(
842 "input {index} is linear-only and cannot be used as a primal VJP value: {key:?}"
843 ),
844 ))
845 }
846 })
847 .collect()
848}
849
850/// Thin adapter that lets a generic `H: Hasher` satisfy the object-safe
851/// `&mut dyn Hasher` signature required by [`ExtensionOp::payload_hash`].
852///
853/// Only `write` and `finish` are load-bearing from the generic hasher; the
854/// various `write_u8` / `write_u16` default implementations in `Hasher`
855/// delegate to `write`. The adapter preserves that behaviour.
856pub(crate) struct DynHasherProxy<'a, H: Hasher + ?Sized> {
857 inner: &'a mut H,
858}
859
860impl<'a, H: Hasher + ?Sized> DynHasherProxy<'a, H> {
861 pub(crate) fn new(inner: &'a mut H) -> Self {
862 Self { inner }
863 }
864}
865
866impl<H: Hasher + ?Sized> Hasher for DynHasherProxy<'_, H> {
867 fn finish(&self) -> u64 {
868 self.inner.finish()
869 }
870
871 fn write(&mut self, bytes: &[u8]) {
872 self.inner.write(bytes);
873 }
874}
875
876/// Hash an `Arc<dyn ExtensionOp>` payload using the extension's
877/// [`ExtensionOp::family_id`] plus [`ExtensionOp::payload_hash`]. Shared
878/// between the `StdTensorOp::Extension` carrier's `Hash` impl and callers
879/// that need to fingerprint an `ExtensionOp` independently.
880pub(crate) fn hash_extension<H: Hasher>(op: &(dyn ExtensionOp + '_), state: &mut H) {
881 op.family_id().as_bytes().hash(state);
882 op.payload_hash(&mut DynHasherProxy::new(state));
883}
884
885/// Structural equality used by the `StdTensorOp::Extension` carrier.
886///
887/// Short-circuits on `family_id` inequality so two extensions with
888/// accidentally similar payloads but different families cannot be unified
889/// by the op interner.
890pub(crate) fn ext_op_eq(a: &dyn ExtensionOp, b: &dyn ExtensionOp) -> bool {
891 a.family_id() == b.family_id() && a.payload_eq(b)
892}