Skip to main content

tenferro_ad/
traced.rs

1use std::collections::{HashMap, HashSet};
2use std::sync::Arc;
3
4use computegraph::graph::GraphBuilder;
5use computegraph::{LocalValueId, OperationRole, ValueRef};
6use tenferro_ops::input_key::TensorInputKey;
7use tenferro_ops::std_tensor_op::StdTensorOp;
8use tenferro_ops::{SymDim, TensorMeta};
9use tenferro_runtime::ad_support::{
10    allocate_input_key, allocate_shape_tensor_id, checkpoint_tensor, compile_ad_source,
11    frozen_input_value, inputs_map as tensor_inputs_map, leaf_input_key, merge_traced_leaf_metas,
12    metadata_scopes as tensor_metadata_scopes, metadata_scopes_with_new, ones_tensor,
13    register_scoped_graph_analysis, shape_hint as tensor_shape_hint, tensor_from_parts,
14    ConstraintScopeTransfer, RetainedValue, TracedTensorParts,
15};
16use tenferro_runtime::program::{FrozenProgram, ProgramValue, ProgramValueMetadata, SemanticOpRef};
17use tenferro_runtime::{
18    CompiledGraph, Error, ErrorPhase, ExecutionEngine, GraphCompiler, Result, Runtime, Tensor,
19    TracedTensor,
20};
21
22use crate::semantic_extension::{SemanticAdError, SemanticExtensionRuleSet};
23use crate::semantic_transform::{
24    semantic_jvp, semantic_vjp, SemanticAdProgram, SemanticAdTransformError,
25};
26use crate::transform_cache::{AdTransformCache, SemanticAdTransformCacheKey};
27
28pub(crate) fn next_input_key() -> TensorInputKey {
29    tenferro_runtime::ad_support::allocate_input_key()
30}
31
32fn error_shape_hint(tensor: &TracedTensor) -> Vec<usize> {
33    tensor
34        .try_concrete_shape()
35        .unwrap_or_else(|| vec![0; tensor.rank])
36}
37
38/// Typed error for the documented, user-reachable inactive-`wrt` state.
39///
40/// The `_optional` siblings expose this state as `Ok(None)`; the strict wrapper
41/// must not report a reachable input condition as an internal invariant.
42pub(crate) fn inactive_wrt_error(op: &'static str, wrt_key: impl std::fmt::Debug) -> Error {
43    Error::invalid_argument(
44        op,
45        ErrorPhase::GraphBuild,
46        "wrt",
47        format!(
48            "{op} is inactive for {wrt_key:?}; use {op}_optional to observe the inactive state"
49        ),
50    )
51}
52
53pub(crate) fn grad_with_rules_and_cache(
54    output: &TracedTensor,
55    wrt: &TracedTensor,
56    rules: &SemanticExtensionRuleSet,
57    ad_transform_cache: Option<&AdTransformCache>,
58) -> Result<TracedTensor> {
59    grad_with_optional_rules(output, wrt, rules, ad_transform_cache)
60}
61
62pub(crate) fn jvp_with_rules_and_cache(
63    output: &TracedTensor,
64    wrt: &TracedTensor,
65    tangent: &TracedTensor,
66    rules: &SemanticExtensionRuleSet,
67    ad_transform_cache: Option<&AdTransformCache>,
68) -> Result<TracedTensor> {
69    let wrt_input_key = leaf_input_key(wrt)?;
70    jvp_many_with_rules_and_cache(output, &[(wrt, tangent)], rules, ad_transform_cache)?
71        .ok_or_else(|| inactive_wrt_error("jvp", &wrt_input_key))
72}
73
74pub(crate) fn jvp_many_with_rules_and_cache(
75    output: &TracedTensor,
76    wrt_tangents: &[(&TracedTensor, &TracedTensor)],
77    rules: &SemanticExtensionRuleSet,
78    ad_transform_cache: Option<&AdTransformCache>,
79) -> Result<Option<TracedTensor>> {
80    jvp_many_optional_impl(output, wrt_tangents, rules, ad_transform_cache)
81}
82
83pub(crate) fn grad_optional_with_rules_and_cache(
84    output: &TracedTensor,
85    wrt: &TracedTensor,
86    rules: &SemanticExtensionRuleSet,
87    ad_transform_cache: Option<&AdTransformCache>,
88) -> Result<Option<TracedTensor>> {
89    if output.rank != 0 {
90        return Err(Error::NonScalarGrad {
91            shape: error_shape_hint(output),
92        });
93    }
94
95    let ones = ones_tensor(output.dtype, vec![])?;
96    let seed = TracedTensor::from_tensor_concrete_shape(ones)?;
97    vjp_many_with_transform_and_cache(output, &[wrt], &seed, rules, "grad", ad_transform_cache)
98        .map(|mut results| results.pop().flatten())
99}
100
101pub(crate) fn jvp_optional_with_rules_and_cache(
102    output: &TracedTensor,
103    wrt: &TracedTensor,
104    tangent: &TracedTensor,
105    rules: &SemanticExtensionRuleSet,
106    ad_transform_cache: Option<&AdTransformCache>,
107) -> Result<Option<TracedTensor>> {
108    jvp_many_with_rules_and_cache(output, &[(wrt, tangent)], rules, ad_transform_cache)
109}
110
111pub(crate) fn vjp_with_rules_and_cache(
112    output: &TracedTensor,
113    wrt: &TracedTensor,
114    cotangent: &TracedTensor,
115    rules: &SemanticExtensionRuleSet,
116    ad_transform_cache: Option<&AdTransformCache>,
117) -> Result<TracedTensor> {
118    let wrt_input_key = leaf_input_key(wrt)?;
119    vjp_many_with_rules_and_cache(output, &[wrt], cotangent, rules, ad_transform_cache)?
120        .into_iter()
121        .next()
122        .flatten()
123        .ok_or_else(|| inactive_wrt_error("vjp", &wrt_input_key))
124}
125
126pub(crate) fn vjp_many_with_rules_and_cache(
127    output: &TracedTensor,
128    wrts: &[&TracedTensor],
129    cotangent: &TracedTensor,
130    rules: &SemanticExtensionRuleSet,
131    ad_transform_cache: Option<&AdTransformCache>,
132) -> Result<Vec<Option<TracedTensor>>> {
133    vjp_many_with_transform_and_cache(output, wrts, cotangent, rules, "vjp", ad_transform_cache)
134}
135
136fn vjp_many_with_transform_and_cache(
137    output: &TracedTensor,
138    wrts: &[&TracedTensor],
139    cotangent: &TracedTensor,
140    rules: &SemanticExtensionRuleSet,
141    transform: &'static str,
142    ad_transform_cache: Option<&AdTransformCache>,
143) -> Result<Vec<Option<TracedTensor>>> {
144    vjp_many_optional_impl(
145        output,
146        wrts,
147        cotangent,
148        rules,
149        transform,
150        ad_transform_cache,
151    )
152}
153
154pub(crate) fn vjp_optional_with_rules_and_cache(
155    output: &TracedTensor,
156    wrt: &TracedTensor,
157    cotangent: &TracedTensor,
158    rules: &SemanticExtensionRuleSet,
159    ad_transform_cache: Option<&AdTransformCache>,
160) -> Result<Option<TracedTensor>> {
161    vjp_many_with_rules_and_cache(output, &[wrt], cotangent, rules, ad_transform_cache)
162        .map(|mut results| results.pop().flatten())
163}
164
165fn grad_with_optional_rules(
166    output: &TracedTensor,
167    wrt: &TracedTensor,
168    rules: &SemanticExtensionRuleSet,
169    ad_transform_cache: Option<&AdTransformCache>,
170) -> Result<TracedTensor> {
171    if output.rank != 0 {
172        return Err(Error::NonScalarGrad {
173            shape: error_shape_hint(output),
174        });
175    }
176
177    let ones = ones_tensor(output.dtype, vec![])?;
178    let seed = TracedTensor::from_tensor_concrete_shape(ones)?;
179    let wrt_input_key = leaf_input_key(wrt)?;
180    vjp_many_with_transform_and_cache(output, &[wrt], &seed, rules, "grad", ad_transform_cache)?
181        .pop()
182        .flatten()
183        .ok_or_else(|| inactive_wrt_error("grad", &wrt_input_key))
184}
185
186fn single_runtime_output(mut outputs: Vec<Tensor>, op: &'static str) -> Result<Tensor> {
187    let actual = outputs.len();
188    if actual != 1 {
189        return Err(Error::runtime_state(
190            op,
191            ErrorPhase::Execution,
192            format!("expected one runtime output, got {actual}"),
193        ));
194    }
195    outputs.pop().ok_or_else(|| {
196        Error::runtime_state(
197            op,
198            ErrorPhase::Execution,
199            "runtime returned no output after successful output-count validation",
200        )
201    })
202}
203
204/// Automatic differentiation helpers for [`TracedTensor`].
205///
206/// # Examples
207///
208/// ```rust
209/// use tenferro_ad::TracedTensorAdExt;
210/// use tenferro_runtime::TracedTensor;
211///
212/// let x = TracedTensor::from_vec_col_major(vec![], vec![3.0_f64]).unwrap();
213/// let loss = x.scale_real(2.0).unwrap();
214/// let maybe_dx = loss.grad_optional(&x).unwrap();
215/// assert!(maybe_dx.is_some());
216/// ```
217pub trait TracedTensorAdExt {
218    /// Gradient of a scalar output with respect to a traced input.
219    ///
220    /// For complex scalar outputs, tenferro returns the Hermitian-adjoint
221    /// cotangent. To compare seed-`1` scalar gradients with JAX's public
222    /// `grad` values, use the complex conjugate of this result. See
223    /// <https://tensor4all.org/tenferro-rs/guides/complex-ad.html>.
224    ///
225    /// # Examples
226    ///
227    /// ```rust
228    /// use tenferro_ad::TracedTensorAdExt;
229    /// use tenferro_runtime::{GraphCompiler, Runtime, TracedTensor};
230    ///
231    /// fn eval(tensor: &TracedTensor) -> tenferro_runtime::Tensor {
232    ///     let mut compiler = GraphCompiler::new();
233    ///     let program = compiler.compile(tensor).unwrap();
234    ///     let mut engine = tenferro_cpu::CpuEngine::serial();
235    ///     let mut builder = Runtime::builder();
236    ///     builder
237    ///         .register_engine(tenferro_cpu::runtime_engine_registration(&engine).unwrap())
238    ///         .unwrap();
239    ///     let runtime = builder.build().unwrap();
240    ///     let target = tenferro_runtime::ExecutionEngine::target(&engine);
241    ///     let prepared = runtime.prepare(&program, &[], &[target]).unwrap();
242    ///     let mut outputs = runtime.run(&mut [&mut engine], &prepared, &[]).unwrap();
243    ///     outputs.pop().unwrap().into_owned().unwrap()
244    /// }
245    ///
246    /// let x = TracedTensor::from_vec_col_major(vec![], vec![3.0_f64]).unwrap();
247    /// let loss = (&x * &x).unwrap();
248    /// let dx = loss.grad(&x).unwrap();
249    ///
250    /// assert_eq!(eval(&dx).as_slice::<f64>().unwrap(), &[6.0]);
251    /// ```
252    ///
253    /// # Errors
254    ///
255    /// Returns [`tenferro_runtime::Error::NonScalarGrad`] for a non-scalar
256    /// output, [`tenferro_runtime::Error::UnsupportedAdRule`] when an AD rule
257    /// is unavailable, or a typed validation/backend/runtime-state error. An
258    /// inactive `wrt` returns [`Error::Validation`] with `argument: "wrt"`; use
259    /// [`grad_optional`](Self::grad_optional) to observe that state.
260    ///
261    /// # Deferred errors
262    ///
263    /// Symbolic shape constraints can later produce
264    /// [`tenferro_runtime::Error::ShapeConstraintViolation`] or
265    /// [`tenferro_runtime::Error::ShapeConstraintEvaluation`] during compile
266    /// or execution.
267    fn grad(&self, wrt: &TracedTensor) -> Result<TracedTensor>;
268
269    /// Like [`grad`](Self::grad), but returns `None` when `wrt` is inactive.
270    ///
271    /// # Examples
272    ///
273    /// ```rust
274    /// use tenferro_ad::TracedTensorAdExt;
275    /// use tenferro_runtime::TracedTensor;
276    ///
277    /// let x = TracedTensor::from_vec_col_major(vec![], vec![3.0_f64]).unwrap();
278    /// let y = TracedTensor::from_vec_col_major(vec![], vec![4.0_f64]).unwrap();
279    /// let loss = (&y * &y).unwrap();
280    ///
281    /// assert!(loss.grad_optional(&x).unwrap().is_none());
282    /// ```
283    ///
284    /// # Errors
285    ///
286    /// Returns [`tenferro_runtime::Error::NonScalarGrad`] for a non-scalar
287    /// output, [`Error::UnsupportedAdRule`] when an AD rule is unavailable, or
288    /// a typed validation/backend/runtime-state error.
289    ///
290    /// # Deferred errors
291    ///
292    /// Symbolic shape constraints can later produce
293    /// [`tenferro_runtime::Error::ShapeConstraintViolation`] or
294    /// [`tenferro_runtime::Error::ShapeConstraintEvaluation`] during compile
295    /// or execution.
296    fn grad_optional(&self, wrt: &TracedTensor) -> Result<Option<TracedTensor>>;
297
298    /// Evaluate this tensor and replace its graph with a concrete leaf while
299    /// preserving the previous graph for AD replay.
300    ///
301    /// # Examples
302    ///
303    /// ```rust
304    /// use tenferro_ad::TracedTensorAdExt;
305    /// use tenferro_runtime::{GraphCompiler, Runtime, TracedTensor};
306    ///
307    /// let mut compiler = GraphCompiler::new();
308    /// let backend = tenferro_cpu::CpuEngine::serial();
309    /// let mut builder = Runtime::builder();
310    /// builder
311    ///     .register_engine(tenferro_cpu::runtime_engine_registration(&backend).unwrap())
312    ///     .unwrap();
313    /// let runtime = builder.build().unwrap();
314    /// let x = TracedTensor::from_vec_col_major(vec![], vec![3.0_f64]).unwrap();
315    /// let mut y = (&x * &x).unwrap();
316    ///
317    /// let mut engine = tenferro_cpu::CpuEngine::serial();
318    /// y.checkpoint(&mut compiler, &runtime, &mut engine).unwrap();
319    ///
320    /// let value = y.attached_value().unwrap();
321    /// let materialized = value.tensor_read().unwrap().tensor_view().duplicate().unwrap();
322    /// assert_eq!(materialized.as_slice::<f64>().unwrap(), &[9.0]);
323    /// ```
324    ///
325    /// # Errors
326    ///
327    /// Returns [`tenferro_runtime::Error::Validation`] when checkpoint metadata
328    /// is invalid, [`Error::RuntimeState`] when graph metadata or runtime
329    /// state is unavailable, or a typed backend error from evaluation.
330    fn checkpoint(
331        &mut self,
332        compiler: &mut GraphCompiler,
333        runtime: &Runtime,
334        engine: &mut dyn ExecutionEngine,
335    ) -> Result<()>;
336
337    /// Forward-mode Jacobian-vector product.
338    ///
339    /// # Examples
340    ///
341    /// ```rust
342    /// use tenferro_ad::TracedTensorAdExt;
343    /// use tenferro_runtime::{GraphCompiler, Runtime, TracedTensor};
344    ///
345    /// fn eval(tensor: &TracedTensor) -> tenferro_runtime::Tensor {
346    ///     let mut compiler = GraphCompiler::new();
347    ///     let program = compiler.compile(tensor).unwrap();
348    ///     let mut engine = tenferro_cpu::CpuEngine::serial();
349    ///     let mut builder = Runtime::builder();
350    ///     builder
351    ///         .register_engine(tenferro_cpu::runtime_engine_registration(&engine).unwrap())
352    ///         .unwrap();
353    ///     let runtime = builder.build().unwrap();
354    ///     let target = tenferro_runtime::ExecutionEngine::target(&engine);
355    ///     let prepared = runtime.prepare(&program, &[], &[target]).unwrap();
356    ///     let mut outputs = runtime.run(&mut [&mut engine], &prepared, &[]).unwrap();
357    ///     outputs.pop().unwrap().into_owned().unwrap()
358    /// }
359    ///
360    /// let x = TracedTensor::from_vec_col_major(vec![], vec![3.0_f64]).unwrap();
361    /// let tangent = TracedTensor::from_vec_col_major(vec![], vec![2.0_f64]).unwrap();
362    /// let y = (&x * &x).unwrap();
363    /// let dy = y.jvp(&x, &tangent).unwrap();
364    ///
365    /// assert_eq!(eval(&dy).as_slice::<f64>().unwrap(), &[12.0]);
366    /// ```
367    ///
368    /// # Errors
369    ///
370    /// Returns [`tenferro_runtime::Error::UnsupportedAdRule`] when a JVP rule
371    /// is unavailable, [`Error::Validation`] for incompatible tangent metadata,
372    /// or a typed backend/runtime-state error. An inactive `wrt` returns
373    /// [`Error::Validation`] with `argument: "wrt"`; use
374    /// [`jvp_optional`](Self::jvp_optional) to observe that state.
375    ///
376    /// # Deferred errors
377    ///
378    /// Symbolic shape constraints can later produce
379    /// [`tenferro_runtime::Error::ShapeConstraintViolation`] or
380    /// [`tenferro_runtime::Error::ShapeConstraintEvaluation`] during compile
381    /// or execution.
382    fn jvp(&self, wrt: &TracedTensor, tangent: &TracedTensor) -> Result<TracedTensor>;
383
384    /// Like [`jvp`](Self::jvp), but returns `None` when `wrt` is inactive.
385    ///
386    /// # Examples
387    ///
388    /// ```rust
389    /// use tenferro_ad::TracedTensorAdExt;
390    /// use tenferro_runtime::TracedTensor;
391    ///
392    /// let x = TracedTensor::from_vec_col_major(vec![], vec![3.0_f64]).unwrap();
393    /// let y = TracedTensor::from_vec_col_major(vec![], vec![4.0_f64]).unwrap();
394    /// let tangent = TracedTensor::from_vec_col_major(vec![], vec![1.0_f64]).unwrap();
395    /// let loss = (&y * &y).unwrap();
396    ///
397    /// assert!(loss.jvp_optional(&x, &tangent).unwrap().is_none());
398    /// ```
399    ///
400    /// # Errors
401    ///
402    /// Returns [`tenferro_runtime::Error::UnsupportedAdRule`] when a JVP rule
403    /// is unavailable, [`Error::Validation`] for incompatible tangent metadata,
404    /// or a typed backend/runtime-state error.
405    ///
406    /// # Deferred errors
407    ///
408    /// Symbolic shape constraints can later produce
409    /// [`tenferro_runtime::Error::ShapeConstraintViolation`] or
410    /// [`tenferro_runtime::Error::ShapeConstraintEvaluation`] during compile
411    /// or execution.
412    fn jvp_optional(
413        &self,
414        wrt: &TracedTensor,
415        tangent: &TracedTensor,
416    ) -> Result<Option<TracedTensor>>;
417
418    /// Reverse-mode vector-Jacobian product.
419    ///
420    /// Complex cotangents use tenferro's Hermitian real-inner-product
421    /// convention. Non-real complex cotangent seeds therefore need an explicit
422    /// seed-convention comparison when matching JAX. See
423    /// <https://tensor4all.org/tenferro-rs/guides/complex-ad.html>.
424    ///
425    /// # Examples
426    ///
427    /// ```rust
428    /// use tenferro_ad::TracedTensorAdExt;
429    /// use tenferro_runtime::{GraphCompiler, Runtime, TracedTensor};
430    ///
431    /// fn eval(tensor: &TracedTensor) -> tenferro_runtime::Tensor {
432    ///     let mut compiler = GraphCompiler::new();
433    ///     let program = compiler.compile(tensor).unwrap();
434    ///     let mut engine = tenferro_cpu::CpuEngine::serial();
435    ///     let mut builder = Runtime::builder();
436    ///     builder
437    ///         .register_engine(tenferro_cpu::runtime_engine_registration(&engine).unwrap())
438    ///         .unwrap();
439    ///     let runtime = builder.build().unwrap();
440    ///     let target = tenferro_runtime::ExecutionEngine::target(&engine);
441    ///     let prepared = runtime.prepare(&program, &[], &[target]).unwrap();
442    ///     let mut outputs = runtime.run(&mut [&mut engine], &prepared, &[]).unwrap();
443    ///     outputs.pop().unwrap().into_owned().unwrap()
444    /// }
445    ///
446    /// let x = TracedTensor::from_vec_col_major(vec![], vec![3.0_f64]).unwrap();
447    /// let cotangent = TracedTensor::from_vec_col_major(vec![], vec![0.5_f64]).unwrap();
448    /// let y = (&x * &x).unwrap();
449    /// let dx = y.vjp(&x, &cotangent).unwrap();
450    ///
451    /// assert_eq!(eval(&dx).as_slice::<f64>().unwrap(), &[3.0]);
452    /// ```
453    ///
454    /// # Errors
455    ///
456    /// Returns [`tenferro_runtime::Error::UnsupportedAdRule`] when a VJP rule
457    /// is unavailable, [`Error::Validation`] for incompatible cotangent
458    /// metadata, or a typed backend/runtime-state error. An inactive `wrt`
459    /// returns [`Error::Validation`] with `argument: "wrt"`; use
460    /// [`vjp_optional`](Self::vjp_optional) to observe that state.
461    ///
462    /// # Deferred errors
463    ///
464    /// Symbolic shape constraints can later produce
465    /// [`tenferro_runtime::Error::ShapeConstraintViolation`] or
466    /// [`tenferro_runtime::Error::ShapeConstraintEvaluation`] during compile
467    /// or execution.
468    fn vjp(&self, wrt: &TracedTensor, cotangent: &TracedTensor) -> Result<TracedTensor>;
469
470    /// Like [`vjp`](Self::vjp), but returns `None` when `wrt` is inactive.
471    ///
472    /// # Examples
473    ///
474    /// ```rust
475    /// use tenferro_ad::TracedTensorAdExt;
476    /// use tenferro_runtime::TracedTensor;
477    ///
478    /// let x = TracedTensor::from_vec_col_major(vec![], vec![3.0_f64]).unwrap();
479    /// let y = TracedTensor::from_vec_col_major(vec![], vec![4.0_f64]).unwrap();
480    /// let cotangent = TracedTensor::from_vec_col_major(vec![], vec![1.0_f64]).unwrap();
481    /// let loss = (&y * &y).unwrap();
482    ///
483    /// assert!(loss.vjp_optional(&x, &cotangent).unwrap().is_none());
484    /// ```
485    ///
486    /// # Errors
487    ///
488    /// Returns [`tenferro_runtime::Error::UnsupportedAdRule`] when a VJP rule
489    /// is unavailable, [`Error::Validation`] for incompatible cotangent
490    /// metadata, or a typed backend/runtime-state error.
491    ///
492    /// # Deferred errors
493    ///
494    /// Symbolic shape constraints can later produce
495    /// [`tenferro_runtime::Error::ShapeConstraintViolation`] or
496    /// [`tenferro_runtime::Error::ShapeConstraintEvaluation`] during compile
497    /// or execution.
498    fn vjp_optional(
499        &self,
500        wrt: &TracedTensor,
501        cotangent: &TracedTensor,
502    ) -> Result<Option<TracedTensor>>;
503}
504
505impl TracedTensorAdExt for TracedTensor {
506    fn grad(&self, wrt: &TracedTensor) -> Result<TracedTensor> {
507        let rules = SemanticExtensionRuleSet::default();
508        grad_with_optional_rules(self, wrt, &rules, None)
509    }
510
511    fn grad_optional(&self, wrt: &TracedTensor) -> Result<Option<TracedTensor>> {
512        if self.rank != 0 {
513            return Err(Error::NonScalarGrad {
514                shape: error_shape_hint(self),
515            });
516        }
517
518        let ones = ones_tensor(self.dtype, vec![])?;
519        let seed = TracedTensor::from_tensor_concrete_shape(ones)?;
520        let rules = SemanticExtensionRuleSet::default();
521        vjp_many_with_transform_and_cache(self, &[wrt], &seed, &rules, "grad", None)
522            .map(|mut results| results.pop().flatten())
523    }
524
525    fn checkpoint(
526        &mut self,
527        compiler: &mut GraphCompiler,
528        runtime: &Runtime,
529        engine: &mut dyn ExecutionEngine,
530    ) -> Result<()> {
531        let data = if let Some(data) = self.attached_value() {
532            Arc::clone(data)
533        } else {
534            let program = compiler.compile(self)?;
535            let target = engine.target();
536            let prepared = runtime.prepare(&program, &[], std::slice::from_ref(&target))?;
537            let outputs = crate::eager::run_prepared_on(runtime, engine, &prepared, &[])?;
538            Arc::new(RetainedValue::from_tensor(single_runtime_output(
539                outputs,
540                "TracedTensorAdExt::checkpoint",
541            )?))
542        };
543        checkpoint_tensor(self, data)?;
544        Ok(())
545    }
546
547    fn jvp(&self, wrt: &TracedTensor, tangent: &TracedTensor) -> Result<TracedTensor> {
548        let wrt_input_key = leaf_input_key(wrt)?;
549        self.jvp_optional(wrt, tangent)?
550            .ok_or_else(|| inactive_wrt_error("jvp", &wrt_input_key))
551    }
552
553    fn jvp_optional(
554        &self,
555        wrt: &TracedTensor,
556        tangent: &TracedTensor,
557    ) -> Result<Option<TracedTensor>> {
558        let rules = SemanticExtensionRuleSet::default();
559        jvp_many_optional_impl(self, &[(wrt, tangent)], &rules, None)
560    }
561
562    fn vjp(&self, wrt: &TracedTensor, cotangent: &TracedTensor) -> Result<TracedTensor> {
563        let wrt_input_key = leaf_input_key(wrt)?;
564        self.vjp_optional(wrt, cotangent)?
565            .ok_or_else(|| inactive_wrt_error("vjp", &wrt_input_key))
566    }
567
568    fn vjp_optional(
569        &self,
570        wrt: &TracedTensor,
571        cotangent: &TracedTensor,
572    ) -> Result<Option<TracedTensor>> {
573        let rules = SemanticExtensionRuleSet::default();
574        vjp_many_with_transform_and_cache(self, &[wrt], cotangent, &rules, "vjp", None)
575            .map(|mut results| results.pop().flatten())
576    }
577}
578
579fn jvp_many_optional_impl(
580    output: &TracedTensor,
581    wrt_tangents: &[(&TracedTensor, &TracedTensor)],
582    rules: &SemanticExtensionRuleSet,
583    ad_transform_cache: Option<&AdTransformCache>,
584) -> Result<Option<TracedTensor>> {
585    if wrt_tangents.is_empty() {
586        return Ok(None);
587    }
588
589    let mut seen = HashSet::with_capacity(wrt_tangents.len());
590    let mut requested = Vec::with_capacity(wrt_tangents.len());
591    for (wrt, tangent) in wrt_tangents {
592        let key = leaf_input_key(wrt)?;
593        if !seen.insert(key.clone()) {
594            return Err(Error::invalid_argument(
595                "jvp",
596                ErrorPhase::GraphBuild,
597                "wrt_tangents",
598                "wrt_tangents contains duplicate wrt leaves",
599            ));
600        }
601        let tangent_data = tangent.attached_value().cloned().ok_or_else(|| {
602            Error::invalid_argument(
603                "jvp",
604                ErrorPhase::GraphBuild,
605                "tangent",
606                "jvp tangent must have concrete tensor data",
607            )
608        })?;
609        requested.push((key, tangent_data));
610    }
611
612    let mut compiler = GraphCompiler::new();
613    let source = compile_ad_source(&mut compiler, output)?;
614    let mut active_inputs = vec![false; source.input_count()];
615    let mut source_indices = Vec::with_capacity(requested.len());
616    for (key, _) in &requested {
617        let source_index = source.input_key_index(key);
618        if let Some(index) = source_index {
619            active_inputs[index] = true;
620        }
621        source_indices.push(source_index);
622    }
623    if !active_inputs.iter().any(|active| *active) {
624        return Ok(None);
625    }
626
627    let derivative = semantic_jvp_with_cache(
628        source.frozen_program(),
629        &active_inputs,
630        rules,
631        ad_transform_cache,
632    )?;
633    let Some(derivative_output_index) = derivative
634        .derivative_output_indices()
635        .first()
636        .copied()
637        .flatten()
638    else {
639        return Ok(None);
640    };
641
642    let mut seed_tensors = Vec::with_capacity(requested.len());
643    for ((_, tangent_data), source_index) in requested.iter().zip(source_indices) {
644        let Some(source_index) = source_index else {
645            continue;
646        };
647        let Some(seed_input_index) = derivative
648            .derivative_input_indices()
649            .get(source_index)
650            .copied()
651            .flatten()
652        else {
653            continue;
654        };
655        seed_tensors.push((seed_input_index, Arc::clone(tangent_data)));
656    }
657    let mut inherited_tensors = Vec::with_capacity(1 + 2 * wrt_tangents.len());
658    inherited_tensors.push(output);
659    for (wrt, tangent) in wrt_tangents {
660        inherited_tensors.push(*wrt);
661        inherited_tensors.push(*tangent);
662    }
663    let mut traces = derivative_tensors_from_program(
664        &source,
665        &derivative,
666        &[derivative_output_index],
667        &seed_tensors,
668        &inherited_tensors,
669        &[tensor_shape_hint(output)],
670        "jvp",
671    )?;
672    traces.pop().map(Some).ok_or_else(|| {
673        Error::runtime_state(
674            "jvp",
675            ErrorPhase::GraphBuild,
676            "derivative program returned no requested output",
677        )
678    })
679}
680
681fn vjp_many_optional_impl(
682    output: &TracedTensor,
683    wrts: &[&TracedTensor],
684    cotangent: &TracedTensor,
685    rules: &SemanticExtensionRuleSet,
686    transform: &'static str,
687    ad_transform_cache: Option<&AdTransformCache>,
688) -> Result<Vec<Option<TracedTensor>>> {
689    let cotangent_data = cotangent.attached_value().cloned().ok_or_else(|| {
690        Error::invalid_argument(
691            transform,
692            ErrorPhase::GraphBuild,
693            "cotangent",
694            "vjp cotangent must have concrete tensor data",
695        )
696    })?;
697    if wrts.is_empty() {
698        return Ok(Vec::new());
699    }
700
701    let requested_keys = wrts
702        .iter()
703        .map(|wrt| leaf_input_key(wrt))
704        .collect::<Result<Vec<_>>>()?;
705    let mut compiler = GraphCompiler::new();
706    let source = compile_ad_source(&mut compiler, output)?;
707    let source_indices: Vec<_> = requested_keys
708        .iter()
709        .map(|key| source.input_key_index(key))
710        .collect();
711    let mut active_inputs = vec![false; source.input_count()];
712    for source_index in source_indices.iter().flatten().copied() {
713        active_inputs[source_index] = true;
714    }
715    if !active_inputs.iter().any(|active| *active) {
716        return Ok(vec![None; wrts.len()]);
717    }
718
719    let active_outputs = vec![true; source.output_count()];
720    let derivative = semantic_vjp_with_cache(
721        source.frozen_program(),
722        &active_inputs,
723        &active_outputs,
724        rules,
725        ad_transform_cache,
726    )?;
727    let Some(seed_input_index) = derivative
728        .derivative_input_indices()
729        .first()
730        .copied()
731        .flatten()
732    else {
733        return Ok(vec![None; wrts.len()]);
734    };
735
736    let mut output_slots = vec![None; source.input_count()];
737    let mut derivative_output_indices = Vec::new();
738    let mut fallback_shape_hints = Vec::new();
739    for (request_index, source_index) in source_indices.iter().enumerate() {
740        let Some(source_index) = source_index else {
741            continue;
742        };
743        if output_slots[*source_index].is_some() {
744            continue;
745        }
746        let Some(derivative_output_index) = derivative
747            .derivative_output_indices()
748            .get(*source_index)
749            .copied()
750            .flatten()
751        else {
752            continue;
753        };
754        output_slots[*source_index] = Some(derivative_output_indices.len());
755        derivative_output_indices.push(derivative_output_index);
756        fallback_shape_hints.push(tensor_shape_hint(wrts[request_index]));
757    }
758    if derivative_output_indices.is_empty() {
759        return Ok(vec![None; wrts.len()]);
760    }
761
762    let inherited_tensors: Vec<&TracedTensor> = std::iter::once(output)
763        .chain(wrts.iter().copied())
764        .chain(std::iter::once(cotangent))
765        .collect();
766    let traces = derivative_tensors_from_program(
767        &source,
768        &derivative,
769        &derivative_output_indices,
770        &[(seed_input_index, cotangent_data)],
771        &inherited_tensors,
772        &fallback_shape_hints,
773        transform,
774    )?;
775    Ok(source_indices
776        .into_iter()
777        .map(|source_index| {
778            source_index
779                .and_then(|index| output_slots[index])
780                .map(|slot| traces[slot].clone())
781        })
782        .collect())
783}
784
785fn semantic_jvp_with_cache(
786    source: &FrozenProgram,
787    active_inputs: &[bool],
788    rules: &SemanticExtensionRuleSet,
789    ad_transform_cache: Option<&AdTransformCache>,
790) -> Result<SemanticAdProgram> {
791    let key = SemanticAdTransformCacheKey::jvp(source, active_inputs);
792    if let Some(cache) = ad_transform_cache
793        && let Some(cached) = cache.get_semantic(&key, source)?
794    {
795        return cached
796            .as_ref()
797            .with_input_prefix_bindings_from(source)
798            .map_err(|source| {
799                Error::runtime_state_source(
800                    "semantic traced jvp cache",
801                    ErrorPhase::GraphBuild,
802                    source,
803                )
804            });
805    }
806    let derivative =
807        semantic_jvp(source, active_inputs, rules).map_err(semantic_transform_error("jvp"))?;
808    if let Some(cache) = ad_transform_cache {
809        cache.put_semantic(key, source, Arc::new(derivative.clone()))?;
810    }
811    Ok(derivative)
812}
813
814fn semantic_vjp_with_cache(
815    source: &FrozenProgram,
816    active_inputs: &[bool],
817    active_outputs: &[bool],
818    rules: &SemanticExtensionRuleSet,
819    ad_transform_cache: Option<&AdTransformCache>,
820) -> Result<SemanticAdProgram> {
821    let key = SemanticAdTransformCacheKey::vjp(source, active_inputs, active_outputs);
822    if let Some(cache) = ad_transform_cache
823        && let Some(cached) = cache.get_semantic(&key, source)?
824    {
825        return cached
826            .as_ref()
827            .with_input_prefix_bindings_from(source)
828            .map_err(|source| {
829                Error::runtime_state_source(
830                    "semantic traced vjp cache",
831                    ErrorPhase::GraphBuild,
832                    source,
833                )
834            });
835    }
836    let derivative = semantic_vjp(source, active_inputs, active_outputs, rules)
837        .map_err(semantic_transform_error("vjp"))?;
838    if let Some(cache) = ad_transform_cache {
839        cache.put_semantic(key, source, Arc::new(derivative.clone()))?;
840    }
841    Ok(derivative)
842}
843
844fn semantic_transform_error(
845    transform: &'static str,
846) -> impl FnOnce(SemanticAdTransformError) -> Error {
847    move |source| {
848        semantic_transform_validation_error(transform, &source).unwrap_or_else(|| {
849            Error::runtime_state_source(transform, ErrorPhase::GraphBuild, source)
850        })
851    }
852}
853
854fn semantic_transform_validation_error(
855    transform: &'static str,
856    source: &SemanticAdTransformError,
857) -> Option<Error> {
858    if let SemanticAdTransformError::Extension(
859        SemanticAdError::Unsupported { family_id, .. }
860        | SemanticAdError::MissingRule { family_id, .. },
861    ) = source
862    {
863        return Some(Error::UnsupportedAdRule {
864            transform,
865            op: (*family_id).to_owned(),
866        });
867    }
868
869    let SemanticAdTransformError::Extension(SemanticAdError::Rule { source, .. }) = source else {
870        return None;
871    };
872    let tenferro_ops::ad::ADRuleError::InvalidInput { op, message, .. } =
873        source.downcast_ref::<tenferro_ops::ad::ADRuleError>()?
874    else {
875        return None;
876    };
877    Some(Error::invalid_argument(
878        transform,
879        ErrorPhase::GraphBuild,
880        "semantic_ad_rule",
881        format!("{op}: {message}"),
882    ))
883}
884
885fn derivative_tensors_from_program(
886    source: &CompiledGraph,
887    derivative: &SemanticAdProgram,
888    derivative_output_indices: &[usize],
889    seed_tensors: &[(usize, Arc<RetainedValue>)],
890    inherited_tensors: &[&TracedTensor],
891    fallback_shape_hints: &[Option<Vec<SymDim>>],
892    transform: &'static str,
893) -> Result<Vec<TracedTensor>> {
894    derivative_traces_from_frozen_program(
895        source,
896        derivative.frozen(),
897        derivative_output_indices,
898        seed_tensors,
899        inherited_tensors,
900        fallback_shape_hints,
901        transform,
902    )
903}
904
905pub(crate) fn derivative_trace_from_frozen_program(
906    source: &CompiledGraph,
907    frozen: &FrozenProgram,
908    derivative_output_index: usize,
909    seed_tensors: &[(usize, Arc<RetainedValue>)],
910    inherited_tensors: &[&TracedTensor],
911    fallback_shape_hint: Option<Vec<SymDim>>,
912    transform: &'static str,
913) -> Result<TracedTensor> {
914    let mut outputs = derivative_traces_from_frozen_program(
915        source,
916        frozen,
917        &[derivative_output_index],
918        seed_tensors,
919        inherited_tensors,
920        &[fallback_shape_hint],
921        transform,
922    )?;
923    outputs.pop().ok_or_else(|| {
924        Error::runtime_state(
925            transform,
926            ErrorPhase::GraphBuild,
927            "derivative program returned no requested output",
928        )
929    })
930}
931
932fn derivative_traces_from_frozen_program(
933    source: &CompiledGraph,
934    frozen: &FrozenProgram,
935    derivative_output_indices: &[usize],
936    seed_tensors: &[(usize, Arc<RetainedValue>)],
937    inherited_tensors: &[&TracedTensor],
938    fallback_shape_hints: &[Option<Vec<SymDim>>],
939    transform: &'static str,
940) -> Result<Vec<TracedTensor>> {
941    let input_shapes = symbolic_input_shapes(frozen)?;
942    let input_shape_refs: Vec<_> = input_shapes.iter().map(Vec::as_slice).collect();
943    let input_metas = frozen
944        .program
945        .inputs()
946        .iter()
947        .copied()
948        .map(|value| tensor_meta_for_value(frozen, value, &input_shape_refs, transform))
949        .collect::<Result<Vec<_>>>()?;
950
951    if derivative_output_indices.len() != fallback_shape_hints.len() {
952        return Err(Error::runtime_state(
953            transform,
954            ErrorPhase::GraphBuild,
955            "derivative output indices and fallback shape hints differ in length",
956        ));
957    }
958    let output_metas = derivative_output_indices
959        .iter()
960        .map(|&derivative_output_index| {
961            let output_value = *frozen
962                .program
963                .outputs()
964                .get(derivative_output_index)
965                .ok_or_else(|| {
966                    Error::runtime_state(
967                        transform,
968                        ErrorPhase::GraphBuild,
969                        format!(
970                            "derivative output index {derivative_output_index} is outside {} outputs",
971                            frozen.program.outputs().len()
972                        ),
973                    )
974                })?;
975            tensor_meta_for_value(frozen, output_value, &input_shape_refs, transform)
976        })
977        .collect::<Result<Vec<_>>>()?;
978
979    let mut builder = GraphBuilder::<StdTensorOp>::new();
980    let mut value_map = HashMap::<ProgramValue, LocalValueId>::new();
981    let mut input_keys = Vec::with_capacity(frozen.program.inputs().len());
982    for (input_index, input) in frozen.program.inputs().iter().copied().enumerate() {
983        let key = if input_index < source.input_keys().len() {
984            source.input_keys()[input_index].clone()
985        } else {
986            allocate_input_key()
987        };
988        let local = builder.add_input(key.clone());
989        value_map.insert(input, local);
990        input_keys.push(key);
991    }
992
993    for operation in frozen.program.operations() {
994        let inputs = operation
995            .inputs()
996            .iter()
997            .copied()
998            .map(|value| {
999                value_map
1000                    .get(&value)
1001                    .copied()
1002                    .map(ValueRef::Local)
1003                    .ok_or_else(|| missing_program_value(transform, "operation input"))
1004            })
1005            .collect::<Result<Vec<_>>>()?;
1006        let op = match operation.op() {
1007            SemanticOpRef::Core(op) => StdTensorOp::from(op),
1008            SemanticOpRef::Extension(op) => StdTensorOp::Extension(op.clone_arc()),
1009            _ => {
1010                return Err(Error::runtime_state(
1011                    transform,
1012                    ErrorPhase::GraphBuild,
1013                    "unsupported semantic operation variant in derivative graph",
1014                ));
1015            }
1016        };
1017        let outputs = builder.add_operation(op, inputs, OperationRole::Primary);
1018        if outputs.len() != operation.outputs().len() {
1019            return Err(Error::runtime_state(
1020                transform,
1021                ErrorPhase::GraphBuild,
1022                format!(
1023                    "semantic operation expected {} outputs, graph builder produced {}",
1024                    operation.outputs().len(),
1025                    outputs.len()
1026                ),
1027            ));
1028        }
1029        for (value, local) in operation.outputs().iter().copied().zip(outputs) {
1030            value_map.insert(value, local);
1031        }
1032    }
1033
1034    let graph_outputs = frozen
1035        .program
1036        .outputs()
1037        .iter()
1038        .copied()
1039        .map(|value| {
1040            value_map
1041                .get(&value)
1042                .copied()
1043                .ok_or_else(|| missing_program_value(transform, "program output"))
1044        })
1045        .collect::<Result<Vec<_>>>()?;
1046    let vals = derivative_output_indices
1047        .iter()
1048        .map(|&index| {
1049            graph_outputs.get(index).copied().ok_or_else(|| {
1050                Error::runtime_state(
1051                    transform,
1052                    ErrorPhase::GraphBuild,
1053                    "derivative output index missing after graph conversion",
1054                )
1055            })
1056        })
1057        .collect::<Result<Vec<_>>>()?;
1058    builder.set_outputs(graph_outputs);
1059    let graph = Arc::new(builder.build());
1060
1061    let Some(primary_tensor) = inherited_tensors.first() else {
1062        return Err(Error::runtime_state(
1063            transform,
1064            ErrorPhase::GraphBuild,
1065            "derivative trace construction requires inherited source tensors",
1066        ));
1067    };
1068    let mut inputs_map = (*tensor_inputs_map(primary_tensor)).clone();
1069    for (input_index, key) in input_keys.iter().enumerate() {
1070        if let Some(tensor) = frozen_input_value(frozen, input_index) {
1071            inputs_map.insert(key.clone(), tensor);
1072        }
1073    }
1074    for (seed_input_index, tensor) in seed_tensors {
1075        let meta = input_metas.get(*seed_input_index).ok_or_else(|| {
1076            Error::runtime_state(
1077                transform,
1078                ErrorPhase::GraphBuild,
1079                format!("seed input index {seed_input_index} is outside derivative inputs"),
1080            )
1081        })?;
1082        validate_seed_tensor(transform, *seed_input_index, tensor.as_ref(), meta)?;
1083        let key = input_keys.get(*seed_input_index).ok_or_else(|| {
1084            Error::runtime_state(
1085                transform,
1086                ErrorPhase::GraphBuild,
1087                format!("seed input key {seed_input_index} is outside derivative inputs"),
1088            )
1089        })?;
1090        inputs_map.insert(key.clone(), Arc::clone(tensor));
1091    }
1092
1093    let source_input_count = source.input_keys().len();
1094    let graph_input_metadata = graph
1095        .inputs()
1096        .iter()
1097        .copied()
1098        .zip(input_metas.iter().cloned())
1099        .enumerate()
1100        .filter_map(|(input_index, (input, meta))| {
1101            // Source input keys are reused so derivative traces compose with the
1102            // original eager tensor. Keep those keys owned by the source tensor's
1103            // metadata scopes; derivative input metadata may be shape-specialized
1104            // for the current run and must not shadow the source while the VJP/JVP
1105            // result stays alive.
1106            if input_index < source_input_count {
1107                None
1108            } else {
1109                Some((graph.values()[input].key.clone(), meta))
1110            }
1111        });
1112    let analysis = register_scoped_graph_analysis(graph.as_ref(), graph_input_metadata)?;
1113    let inherited_constraint_scopes = inherited_tensors
1114        .iter()
1115        .map(|tensor| ConstraintScopeTransfer::from_tensor(tensor))
1116        .collect::<Vec<_>>();
1117
1118    let inputs_map = Arc::new(inputs_map);
1119    // The derivative graph is standalone (its input keys reference the source
1120    // leaves directly), so inherited leaf metadata covers every leaf key the
1121    // deferred eager analysis may re-seed from these traces.
1122    let leaf_metas = merge_traced_leaf_metas(inherited_tensors.iter().copied());
1123    let metadata_scopes = metadata_scopes_with_new(
1124        analysis.metadata,
1125        inherited_tensors
1126            .iter()
1127            .map(|tensor| tensor_metadata_scopes(tensor)),
1128    );
1129    let constraint_scope_transfer =
1130        ConstraintScopeTransfer::with_new(analysis.constraints, inherited_constraint_scopes.iter());
1131
1132    Ok(vals
1133        .into_iter()
1134        .zip(output_metas)
1135        .zip(fallback_shape_hints)
1136        .map(|((val, output_meta), fallback_shape_hint)| {
1137            tensor_from_parts(TracedTensorParts {
1138                rank: output_meta.rank(),
1139                dtype: output_meta.dtype,
1140                graph: Arc::clone(&graph),
1141                val,
1142                data: None,
1143                shape_hint: output_meta
1144                    .exact_shape()
1145                    .or_else(|| fallback_shape_hint.clone()),
1146                inputs_map: Arc::clone(&inputs_map),
1147                leaf_metas: Arc::clone(&leaf_metas),
1148                extra_roots: Vec::new(),
1149                checkpoint_chain: None,
1150                metadata_scopes: metadata_scopes.clone(),
1151                constraint_scope_transfer: constraint_scope_transfer.clone(),
1152            })
1153        })
1154        .collect())
1155}
1156
1157fn missing_program_value(transform: &'static str, role: &'static str) -> Error {
1158    Error::runtime_state(
1159        transform,
1160        ErrorPhase::GraphBuild,
1161        format!("semantic derivative graph references missing {role}"),
1162    )
1163}
1164
1165fn symbolic_input_shapes(frozen: &FrozenProgram) -> Result<Vec<Vec<SymDim>>> {
1166    frozen
1167        .program
1168        .inputs()
1169        .iter()
1170        .copied()
1171        .map(|value| {
1172            let meta = frozen.program.value_metadata(value).map_err(|source| {
1173                Error::runtime_state_source(
1174                    "semantic traced AD input metadata",
1175                    ErrorPhase::GraphBuild,
1176                    source,
1177                )
1178            })?;
1179            let tensor_id = allocate_shape_tensor_id();
1180            Ok((0..meta.shape().len())
1181                .map(|axis| SymDim::tensor_axis(tensor_id, axis))
1182                .collect())
1183        })
1184        .collect()
1185}
1186
1187fn tensor_meta_for_value(
1188    frozen: &FrozenProgram,
1189    value: ProgramValue,
1190    input_shapes: &[&[SymDim]],
1191    transform: &'static str,
1192) -> Result<TensorMeta> {
1193    let meta = frozen
1194        .program
1195        .value_metadata(value)
1196        .map_err(|source| Error::runtime_state_source(transform, ErrorPhase::GraphBuild, source))?;
1197    Ok(program_metadata_to_tensor_meta(meta, input_shapes))
1198}
1199
1200fn program_metadata_to_tensor_meta(
1201    metadata: &ProgramValueMetadata,
1202    input_shapes: &[&[SymDim]],
1203) -> TensorMeta {
1204    let extents = metadata
1205        .shape()
1206        .iter()
1207        .cloned()
1208        .map(|extent| extent.map(|dim| SymDim::from_dim_expr(&dim, input_shapes)))
1209        .collect();
1210    // An externally defined scalar keeps the canonical identity its program declared,
1211    // so a differentiated value can still be given a reproducible program identity.
1212    let meta = TensorMeta::with_extents(metadata.dtype(), extents);
1213    match metadata.scalar_identity() {
1214        Some(identity) => meta.with_scalar_identity(identity),
1215        None => meta,
1216    }
1217}
1218
1219fn validate_seed_tensor(
1220    transform: &'static str,
1221    input_index: usize,
1222    tensor: &RetainedValue,
1223    expected: &TensorMeta,
1224) -> Result<()> {
1225    let actual_dtype = tensor.dtype();
1226    if actual_dtype != expected.dtype {
1227        return Err(Error::invalid_argument(
1228            transform,
1229            ErrorPhase::GraphBuild,
1230            "seed",
1231            format!(
1232                "seed input {input_index} dtype mismatch: expected {:?}, got {:?}",
1233                expected.dtype, actual_dtype
1234            ),
1235        ));
1236    }
1237    let actual_shape = tensor.shape();
1238    if actual_shape.len() != expected.rank() {
1239        return Err(Error::invalid_argument(
1240            transform,
1241            ErrorPhase::GraphBuild,
1242            "seed",
1243            format!(
1244                "seed input {input_index} rank mismatch: expected {}, got {}",
1245                expected.rank(),
1246                actual_shape.len()
1247            ),
1248        ));
1249    }
1250    if let Some(expected_shape) = expected
1251        .exact_shape()
1252        .filter(|shape| shape.iter().all(|dim| dim.constant_value().is_some()))
1253        .map(|shape| {
1254            shape
1255                .into_iter()
1256                .map(|dim| dim.constant_value().expect("filtered constant shape"))
1257                .collect::<Vec<_>>()
1258        })
1259        && expected_shape != actual_shape
1260    {
1261        return Err(Error::invalid_argument(
1262            transform,
1263            ErrorPhase::GraphBuild,
1264            "seed",
1265            format!(
1266                "seed input {input_index} shape mismatch: expected {:?}, got {:?}",
1267                expected_shape, actual_shape
1268            ),
1269        ));
1270    }
1271    Ok(())
1272}
1273
1274#[cfg(test)]
1275mod semantic_transform_error_tests {
1276    use super::*;
1277    use crate::semantic_extension::SemanticAdRuleRole;
1278
1279    #[test]
1280    fn unsupported_semantic_rule_maps_to_public_transform_error() {
1281        let source = SemanticAdTransformError::Extension(SemanticAdError::Unsupported {
1282            family_id: "tenferro-tests.unsupported.v1",
1283            role: SemanticAdRuleRole::LinearTranspose,
1284            message: "unsupported test payload".into(),
1285        });
1286
1287        let error = semantic_transform_validation_error("vjp", &source)
1288            .expect("semantic rejection must map to a public unsupported-rule error");
1289
1290        assert!(matches!(
1291            error,
1292            Error::UnsupportedAdRule { transform: "vjp", ref op }
1293                if op == "tenferro-tests.unsupported.v1"
1294        ));
1295    }
1296
1297    #[test]
1298    fn missing_semantic_rule_maps_to_public_transform_error() {
1299        let source = SemanticAdTransformError::Extension(SemanticAdError::MissingRule {
1300            family_id: "tenferro-tests.missing.v1",
1301            role: SemanticAdRuleRole::Linearize,
1302        });
1303
1304        let error = semantic_transform_validation_error("jvp", &source)
1305            .expect("missing semantic rule must map to a public unsupported-rule error");
1306
1307        assert!(matches!(
1308            error,
1309            Error::UnsupportedAdRule { transform: "jvp", ref op }
1310                if op == "tenferro-tests.missing.v1"
1311        ));
1312    }
1313}