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}