Skip to main content

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 the semantic AD registry in `tenferro-ad`; this
16//!   extension-operation contract does not import an AD engine.
17//! - Extension ops themselves do not require process-global registration.
18//!   Frontends carry them directly as `Arc<dyn ExtensionOp>`.
19
20use std::any::Any;
21use std::error::Error as StdError;
22use std::fmt::Debug;
23use std::hash::{Hash, Hasher};
24use std::sync::Arc;
25
26use computegraph::graph::GraphBuilder;
27use computegraph::types::ValueRef;
28use tenferro_tensor::{DType, ErrorKind, ValidationKind};
29
30use crate::std_tensor_op::StdTensorOp;
31use crate::sym_dim::SymDim;
32use crate::ExtensionShapeContext;
33
34#[doc(hidden)]
35pub use crate::shape_constraint::ExtensionShapeConstraint;
36
37/// Canonical result of one extension metadata inference callback.
38///
39/// # Examples
40///
41/// ```rust
42/// use tenferro_ops::ext_op::ExtensionShapeInference;
43///
44/// let inferred = ExtensionShapeInference {
45///     output_metas: Vec::new(),
46///     constraints: Vec::new(),
47/// };
48/// assert!(inferred.output_metas.is_empty());
49/// assert!(inferred.constraints.is_empty());
50/// ```
51#[doc(hidden)]
52#[derive(Clone, Debug, PartialEq, Eq)]
53pub struct ExtensionShapeInference {
54    /// Output dtype and symbolic-shape metadata in output-slot order.
55    pub output_metas: Vec<(DType, Vec<SymDim>)>,
56    /// Shape requirements recorded by the callback.
57    pub constraints: Vec<ExtensionShapeConstraint>,
58}
59
60/// Invoke an extension metadata callback after validating its declared arity.
61///
62/// # Examples
63///
64/// ```rust
65/// use std::any::Any;
66/// use std::sync::Arc;
67/// use tenferro_ops::ext_op::{invoke_extension_shape_inference, ExtensionOp};
68/// use tenferro_ops::{ExtensionShapeContext, SymDim};
69/// use tenferro_tensor::DType;
70///
71/// #[derive(Clone, Debug)]
72/// struct Identity;
73///
74/// impl ExtensionOp for Identity {
75///     fn family_id(&self) -> &'static str { "example.identity.v1" }
76///     fn payload_hash(&self, _hasher: &mut dyn std::hash::Hasher) {}
77///     fn payload_eq(&self, other: &dyn ExtensionOp) -> bool {
78///         other.as_any().downcast_ref::<Self>().is_some()
79///     }
80///     fn clone_arc(&self) -> Arc<dyn ExtensionOp> { Arc::new(self.clone()) }
81///     fn as_any(&self) -> &dyn Any { self }
82///     fn input_count(&self) -> usize { 1 }
83///     fn output_count(&self) -> usize { 1 }
84///     fn infer_output_meta(
85///         &self,
86///         ctx: &mut ExtensionShapeContext<'_>,
87///     ) -> tenferro_tensor::Result<Vec<(DType, Vec<SymDim>)>> {
88///         Ok(vec![(ctx.input_dtype(0)?, ctx.input_shape(0)?.to_vec())])
89///     }
90/// }
91///
92/// let shape = [SymDim::from(3usize)];
93/// let inferred = invoke_extension_shape_inference(
94///     &Identity,
95///     &[DType::F64],
96///     &[&shape],
97/// ).unwrap();
98/// assert_eq!(inferred.output_metas, vec![(DType::F64, shape.to_vec())]);
99/// ```
100#[doc(hidden)]
101pub fn invoke_extension_shape_inference(
102    op: &dyn ExtensionOp,
103    input_dtypes: &[DType],
104    input_shapes: &[&[SymDim]],
105) -> tenferro_tensor::Result<ExtensionShapeInference> {
106    let expected_inputs = op.input_count();
107    if input_dtypes.len() != expected_inputs || input_shapes.len() != expected_inputs {
108        return Err(tenferro_tensor::Error::invalid_argument(
109            "extension",
110            "input metadata",
111            format!(
112                "family_id={:?}: infer_output_meta expects {expected_inputs} input metadata entries, got {} dtypes and {} shapes",
113                op.family_id(),
114                input_dtypes.len(),
115                input_shapes.len()
116            ),
117        ));
118    }
119
120    let mut ctx =
121        ExtensionShapeContext::new_for_inference(op.family_id(), input_dtypes, input_shapes);
122    let output_metas = op.infer_output_meta(&mut ctx)?;
123    if output_metas.len() != op.output_count() {
124        return Err(tenferro_tensor::Error::invalid_argument(
125            "extension",
126            "output metadata",
127            format!(
128                "family_id={:?}: infer_output_meta produced {} output metadata entries; op declared {} outputs",
129                op.family_id(),
130                output_metas.len(),
131                op.output_count()
132            ),
133        ));
134    }
135
136    Ok(ExtensionShapeInference {
137        output_metas,
138        constraints: ctx.into_constraints(),
139    })
140}
141
142/// Error returned when an extension cannot expand itself into standard ops.
143///
144/// # Examples
145///
146/// ```
147/// use tenferro_ops::ext_op::ExtensionLoweringError;
148///
149/// let err = ExtensionLoweringError::new("example extension cannot lower");
150/// assert!(err.to_string().contains("cannot lower"));
151/// ```
152#[derive(Debug, thiserror::Error)]
153pub enum ExtensionLoweringError {
154    /// A lowering failure that has no typed source.
155    #[error("{message}")]
156    Message {
157        /// Human-readable lowering detail.
158        message: String,
159        /// Coarse classification supplied by the extension owner.
160        kind: ErrorKind,
161    },
162    /// A lowering failure retaining the domain source that caused it.
163    #[error("{source}")]
164    Source {
165        /// Coarse classification supplied by the extension owner.
166        kind: ErrorKind,
167        /// Original typed lowering source.
168        #[source]
169        source: Box<dyn StdError + Send + Sync + 'static>,
170    },
171}
172
173impl ExtensionLoweringError {
174    /// Create a lowering error with a human-readable diagnostic.
175    ///
176    /// # Examples
177    ///
178    /// ```
179    /// use tenferro_ops::ext_op::ExtensionLoweringError;
180    ///
181    /// let err = ExtensionLoweringError::new("shape must be static");
182    /// assert_eq!(err.to_string(), "shape must be static");
183    /// ```
184    pub fn new(message: impl Into<String>) -> Self {
185        Self::new_with_kind(
186            ErrorKind::Validation(ValidationKind::InvalidArgument),
187            message,
188        )
189    }
190
191    /// Create a lowering error with an explicit coarse classification.
192    ///
193    /// # Examples
194    ///
195    /// ```
196    /// use tenferro_ops::ext_op::ExtensionLoweringError;
197    /// use tenferro_tensor::ErrorKind;
198    ///
199    /// let err = ExtensionLoweringError::new_with_kind(
200    ///     ErrorKind::Unsupported,
201    ///     "extension is not supported by this lowering target",
202    /// );
203    /// assert_eq!(err.kind(), ErrorKind::Unsupported);
204    /// ```
205    pub fn new_with_kind(kind: ErrorKind, message: impl Into<String>) -> Self {
206        Self::Message {
207            message: message.into(),
208            kind,
209        }
210    }
211
212    /// Create a lowering error while retaining a typed source.
213    ///
214    /// # Examples
215    ///
216    /// ```
217    /// use std::error::Error as _;
218    /// use tenferro_ops::ext_op::ExtensionLoweringError;
219    ///
220    /// let source = std::io::Error::new(std::io::ErrorKind::Other, "shape unavailable");
221    /// let err = ExtensionLoweringError::from_source(source);
222    /// assert!(err.source().is_some());
223    /// ```
224    pub fn from_source<E>(source: E) -> Self
225    where
226        E: StdError + Send + Sync + 'static,
227    {
228        Self::from_source_with_kind(
229            ErrorKind::Validation(ValidationKind::InvalidArgument),
230            source,
231        )
232    }
233
234    /// Create a lowering error with a typed source and explicit classification.
235    ///
236    /// # Examples
237    ///
238    /// ```
239    /// use tenferro_ops::ext_op::ExtensionLoweringError;
240    /// use tenferro_tensor::ErrorKind;
241    ///
242    /// let err = ExtensionLoweringError::from_source_with_kind(
243    ///     ErrorKind::BackendFailure,
244    ///     std::io::Error::other("backend rejected lowering"),
245    /// );
246    /// assert_eq!(err.kind(), ErrorKind::BackendFailure);
247    /// assert!(std::error::Error::source(&err).is_some());
248    /// ```
249    pub fn from_source_with_kind<E>(kind: ErrorKind, source: E) -> Self
250    where
251        E: StdError + Send + Sync + 'static,
252    {
253        Self::Source {
254            kind,
255            source: Box::new(source),
256        }
257    }
258
259    /// Return the stable classification carried by this lowering failure.
260    ///
261    /// # Examples
262    ///
263    /// ```
264    /// use tenferro_ops::ext_op::ExtensionLoweringError;
265    /// use tenferro_tensor::ErrorKind;
266    ///
267    /// let error = ExtensionLoweringError::new_with_kind(
268    ///     ErrorKind::Unsupported,
269    ///     "target has no lowering",
270    /// );
271    /// assert_eq!(error.kind(), ErrorKind::Unsupported);
272    /// ```
273    pub fn kind(&self) -> ErrorKind {
274        match self {
275            Self::Message { kind, .. } | Self::Source { kind, .. } => *kind,
276        }
277    }
278}
279
280/// Result returned by [`ExtensionOp::lower_to_standard_ops`].
281pub type ExtensionLoweringResult =
282    std::result::Result<ExtensionStandardLowering, ExtensionLoweringError>;
283
284/// Typed result of trying to lower an extension into standard tensor ops.
285///
286/// Callers branch on this enum instead of encoding unsupported capability as a
287/// successful empty sentinel.
288///
289/// # Examples
290///
291/// ```
292/// use tenferro_ops::ext_op::ExtensionStandardLowering;
293///
294/// let outcome = ExtensionStandardLowering::Unsupported;
295/// assert!(matches!(outcome, ExtensionStandardLowering::Unsupported));
296/// ```
297#[derive(Clone, Debug, PartialEq, Eq)]
298pub enum ExtensionStandardLowering {
299    /// The extension emitted standard tensor graph outputs.
300    Lowered(Vec<ValueRef<StdTensorOp>>),
301    /// The extension has no standard-op lowering for the supplied metadata.
302    Unsupported,
303}
304
305/// The contract every out-of-tree extension primitive must satisfy.
306///
307/// Implementations appear in the core graph as
308/// `StdTensorOp::Extension(Arc<dyn ExtensionOp>)`. Every method is part of the
309/// `ExtensionOp` spec (`docs/spec/extension-op.md`); the short form:
310///
311/// - identity via [`family_id`][Self::family_id] + [`payload_hash`][Self::payload_hash]
312///   + [`payload_eq`][Self::payload_eq];
313/// - fixed arity via [`input_count`][Self::input_count] / [`output_count`][Self::output_count];
314/// - shape / dtype inference via [`infer_output_meta`][Self::infer_output_meta];
315/// - optional fixed-shape standard-op expansion via
316///   [`lower_to_standard_ops`][Self::lower_to_standard_ops] for peer lowerers
317///   such as XLA that cannot execute extension runtimes;
318/// - AD via separately registered role-specific extension rules.
319///
320/// # Downcast convention
321///
322/// Implementations MUST also implement [`Any`] so that
323/// [`ExtensionOp::payload_eq`] can downcast a trait-object reference to
324/// the concrete type. The helper [`ExtensionOp::as_any`] returns
325/// `&dyn Any` for this purpose. Implementations usually define it as
326/// `fn as_any(&self) -> &dyn Any { self }`.
327///
328/// # Examples
329///
330/// ```
331/// # use std::any::Any;
332/// use std::sync::Arc;
333/// use tenferro_ops::ext_op::ExtensionOp;
334/// use tenferro_ops::{ExtensionShapeContext, SymDim};
335/// use tenferro_tensor::DType;
336///
337/// #[derive(Clone, Debug)]
338/// struct IdentityExt;
339///
340/// impl ExtensionOp for IdentityExt {
341///     fn family_id(&self) -> &'static str { "example.identity.v1" }
342///     fn payload_hash(&self, _hasher: &mut dyn std::hash::Hasher) {}
343///     fn payload_eq(&self, other: &dyn ExtensionOp) -> bool {
344///         other.as_any().downcast_ref::<IdentityExt>().is_some()
345///     }
346///     fn clone_arc(&self) -> Arc<dyn ExtensionOp> { Arc::new(self.clone()) }
347///     fn as_any(&self) -> &dyn Any { self }
348///     fn input_count(&self) -> usize { 1 }
349///     fn output_count(&self) -> usize { 1 }
350///     fn infer_output_meta(
351///         &self,
352///         ctx: &mut ExtensionShapeContext<'_>,
353///     ) -> tenferro_tensor::Result<Vec<(DType, Vec<SymDim>)>> {
354///         Ok(vec![(ctx.input_dtype(0)?, ctx.input_shape(0)?.to_vec())])
355///     }
356/// }
357///
358/// let op: Arc<dyn ExtensionOp> = Arc::new(IdentityExt);
359/// assert_eq!(op.input_count(), 1);
360/// ```
361pub trait ExtensionOp: Debug + Send + Sync + 'static {
362    // ----- Identity, hashing, equality (spec Section 5) -----
363
364    /// Stable, process-independent family identifier.
365    ///
366    /// MUST be unique per extension *family* (payload schema), not per
367    /// *instance*, and MUST follow the reserved format
368    /// `"<crate-name>.<op-name>.v<major>"`.
369    fn family_id(&self) -> &'static str;
370
371    /// Hash the payload (everything except `family_id`).
372    ///
373    /// Implementations MUST be pure and deterministic across calls on the same
374    /// value. Hashes MUST NOT include transient state such as allocation
375    /// addresses or atomically updated counters.
376    fn payload_hash(&self, hasher: &mut dyn Hasher);
377
378    /// Structural equality against another extension value.
379    ///
380    /// The carrier's `PartialEq` impl first compares `family_id`s. When the
381    /// family IDs match, it calls `payload_eq`. Implementations MUST return
382    /// `true` iff the payloads are semantically equal AND
383    /// `other.family_id() == self.family_id()`.
384    fn payload_eq(&self, other: &dyn ExtensionOp) -> bool;
385
386    /// Deep-clone the payload behind an `Arc`.
387    ///
388    /// The carrier's `Clone` impl uses `Arc::clone` on the fast path; this
389    /// method exists for rare cases that need a second independent `Arc`.
390    fn clone_arc(&self) -> Arc<dyn ExtensionOp>;
391
392    /// Upcast this extension to `&dyn Any` for downcasting in `payload_eq`.
393    ///
394    /// Implementations SHOULD return `self` verbatim. The method is
395    /// object-safe (no `Self: Sized` bound) so it can be called on an
396    /// `&dyn ExtensionOp`; that's what makes
397    /// `other.as_any().downcast_ref::<ConcreteType>()` work from
398    /// [`Self::payload_eq`] implementations.
399    fn as_any(&self) -> &dyn Any;
400
401    // ----- Arity (spec Section 6) -----
402
403    /// Number of primal inputs. MUST be constant for any given
404    /// `Arc<dyn ExtensionOp>` value.
405    fn input_count(&self) -> usize;
406
407    /// Number of outputs. MUST match the length of the vector returned by a
408    /// successful [`Self::infer_output_meta`] call.
409    fn output_count(&self) -> usize;
410
411    /// Declare observable semantic effects for this extension payload.
412    ///
413    /// The compatibility default is deliberately `Undeclared`, not pure.
414    /// Semantic-program construction rejects an undeclared payload so an
415    /// extension cannot silently acquire purity during migration.
416    fn semantic_effects(&self) -> ExtensionEffectDeclaration<'_> {
417        ExtensionEffectDeclaration::Undeclared
418    }
419
420    /// Declare semantic output aliasing for this extension payload.
421    ///
422    /// The compatibility default is deliberately `Undeclared`, not fresh.
423    /// Execution-only users may continue to carry an older payload, while
424    /// semantic-program construction requires an explicit declaration.
425    fn semantic_aliases(&self) -> ExtensionAliasDeclaration<'_> {
426        ExtensionAliasDeclaration::Undeclared
427    }
428
429    // ----- Shape and dtype inference (spec Section 7) -----
430
431    /// Infer output dtypes and shapes for each output slot.
432    ///
433    /// The canonical inference driver validates arity before invoking this
434    /// callback. Implementations MUST validate rank, dtype, axis, and other
435    /// input-derived metadata through `ctx` before using it. Invalid public
436    /// input must return a typed error rather than an empty sentinel or panic.
437    ///
438    /// On success, the returned vector MUST have length `self.output_count()`,
439    /// one `(dtype, shape)` entry per output slot. Shapes use [`SymDim`] so
440    /// extension ops compose with graph-global symbolic metadata.
441    ///
442    /// # Errors
443    ///
444    /// Returns [`tenferro_tensor::Error::Validation`] for invalid rank, axis,
445    /// or dtype metadata, or [`tenferro_tensor::Error::RuntimeState`] when the
446    /// output contract cannot be inferred from unavailable metadata.
447    fn infer_output_meta(
448        &self,
449        ctx: &mut ExtensionShapeContext<'_>,
450    ) -> tenferro_tensor::Result<Vec<(DType, Vec<SymDim>)>>;
451
452    /// Try to expand this extension into standard tensor graph operations.
453    ///
454    /// Return [`ExtensionStandardLowering::Lowered`] after adding only standard
455    /// [`StdTensorOp`] operations to `builder`.
456    /// [`ExtensionStandardLowering::Unsupported`] means a peer lowerer may try a
457    /// configured fallback; an [`ExtensionLoweringError`] remains a real
458    /// lowering failure and must not be converted into a capability miss.
459    ///
460    /// # Errors
461    ///
462    /// Returns [`ExtensionLoweringError`] when the payload or input metadata
463    /// cannot be lowered safely.
464    fn lower_to_standard_ops(
465        &self,
466        _builder: &mut GraphBuilder<StdTensorOp>,
467        _inputs: &[ValueRef<StdTensorOp>],
468        _input_dtypes: &[DType],
469        _input_shapes: &[&[SymDim]],
470    ) -> ExtensionLoweringResult {
471        Ok(ExtensionStandardLowering::Unsupported)
472    }
473
474    /// Optionally return an equivalent op that produces only live outputs.
475    ///
476    /// `live_outputs` is aligned with this op's current output slots. Return
477    /// `None` when the family does not support output pruning. Return
478    /// `Some(op)` only when the new op's outputs are exactly the live output
479    /// slots, in ascending slot order, and `op.output_count()` equals the
480    /// number of `true` entries in `live_outputs`.
481    fn prune_outputs(&self, _live_outputs: &[bool]) -> Option<Arc<dyn ExtensionOp>> {
482        None
483    }
484
485    // AD rules are registered separately in `tenferro-ad`.
486}
487
488/// Access mode for one extension-declared semantic resource.
489#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
490pub enum ExtensionEffectAccess {
491    /// Read-only access.
492    Read,
493    /// Mutating access.
494    Write,
495}
496
497/// Backend-neutral resource access declared by an extension payload.
498#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
499pub struct ExtensionEffect {
500    /// Stable versioned resource family.
501    pub family: &'static str,
502    /// Family-local resource identity.
503    pub key: u64,
504    /// Read or write access.
505    pub access: ExtensionEffectAccess,
506}
507
508/// Explicit effect declaration returned by an extension payload.
509#[derive(Clone, Copy, Debug, PartialEq, Eq)]
510pub enum ExtensionEffectDeclaration<'a> {
511    /// The payload has not been migrated to the semantic contract.
512    Undeclared,
513    /// Complete ordered effect list; an empty slice explicitly means pure.
514    Declared(&'a [ExtensionEffect]),
515}
516
517/// One extension-declared output alias.
518#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
519pub enum ExtensionAlias {
520    /// The output is semantically fresh.
521    Fresh {
522        /// Operation-local output index.
523        output: usize,
524    },
525    /// The output is a view of an input.
526    ViewOf {
527        /// Operation-local output index.
528        output: usize,
529        /// Operation-local input index.
530        input: usize,
531    },
532    /// The output must alias an input.
533    MustAlias {
534        /// Operation-local output index.
535        output: usize,
536        /// Operation-local input index.
537        input: usize,
538    },
539    /// The output aliases an external typed resource.
540    ExternalAlias {
541        /// Operation-local output index.
542        output: usize,
543        /// Stable versioned resource family.
544        family: &'static str,
545        /// Family-local resource identity.
546        key: u64,
547    },
548}
549
550/// Explicit alias declaration returned by an extension payload.
551#[derive(Clone, Copy, Debug, PartialEq, Eq)]
552pub enum ExtensionAliasDeclaration<'a> {
553    /// The payload has not been migrated to the semantic contract.
554    Undeclared,
555    /// Every output is semantically fresh.
556    AllFresh,
557    /// Complete ordered alias list.
558    Declared(&'a [ExtensionAlias]),
559}
560
561/// Thin adapter that lets a generic `H: Hasher` satisfy the object-safe
562/// `&mut dyn Hasher` signature required by [`ExtensionOp::payload_hash`].
563///
564/// Only `write` and `finish` are load-bearing from the generic hasher; the
565/// various `write_u8` / `write_u16` default implementations in `Hasher`
566/// delegate to `write`. The adapter preserves that behaviour.
567pub(crate) struct DynHasherProxy<'a, H: Hasher + ?Sized> {
568    inner: &'a mut H,
569}
570
571impl<'a, H: Hasher + ?Sized> DynHasherProxy<'a, H> {
572    pub(crate) fn new(inner: &'a mut H) -> Self {
573        Self { inner }
574    }
575}
576
577impl<H: Hasher + ?Sized> Hasher for DynHasherProxy<'_, H> {
578    fn finish(&self) -> u64 {
579        self.inner.finish()
580    }
581
582    fn write(&mut self, bytes: &[u8]) {
583        self.inner.write(bytes);
584    }
585}
586
587/// Hash an `Arc<dyn ExtensionOp>` payload using the extension's
588/// [`ExtensionOp::family_id`] plus [`ExtensionOp::payload_hash`]. Shared
589/// between the `StdTensorOp::Extension` carrier's `Hash` impl and callers
590/// that need to fingerprint an `ExtensionOp` independently.
591pub(crate) fn hash_extension<H: Hasher>(op: &(dyn ExtensionOp + '_), state: &mut H) {
592    op.family_id().as_bytes().hash(state);
593    op.payload_hash(&mut DynHasherProxy::new(state));
594}
595
596/// Structural equality used by the `StdTensorOp::Extension` carrier.
597///
598/// Short-circuits on `family_id` inequality so two extensions with
599/// accidentally similar payloads but different families cannot be unified
600/// by the op interner.
601pub(crate) fn ext_op_eq(a: &dyn ExtensionOp, b: &dyn ExtensionOp) -> bool {
602    a.family_id() == b.family_id() && a.payload_eq(b)
603}