Skip to main content

tenferro_linalg/ad/
semantic.rs

1use std::collections::{HashMap, HashSet};
2use std::sync::Arc;
3
4use computegraph::traits::GraphOperation;
5use computegraph::types::{LocalValueId, OperationRole, ValueKey, ValueRef};
6use tenferro_ad::semantic_extension::{
7    AdValue, ResidualSpec, SemanticAdError, SemanticAdRuleRole, SemanticExtensionRegistryError,
8    SemanticExtensionRuleSet, SemanticLinearTransposeRequest, SemanticLinearTransposeRule,
9    SemanticLinearizeRequest, SemanticLinearizeResult, SemanticLinearizeRule,
10};
11use tenferro_ops::ad::PrimitiveRuleBuilder;
12use tenferro_ops::ad::PrimitiveTransposeInput;
13use tenferro_ops::dim_expr::DimExpr;
14use tenferro_ops::input_key::TensorInputKey;
15use tenferro_ops::shape_extent::ShapeExtent;
16use tenferro_ops::std_tensor_op::StdTensorOp;
17use tenferro_ops::{ShapeGuardContext, SymDim, TensorMeta};
18use tenferro_runtime::program::{
19    CoreSemanticOp, ProgramValue, ProgramValueMetadata, SemanticProgramBuilder,
20};
21
22use super::LinalgAdRule;
23use crate::extension::{LinalgExtensionOp, LinalgOp};
24use crate::LINALG_EXTENSION_FAMILY_ID;
25
26/// Return the linalg semantic-program AD rule set.
27///
28/// # Errors
29///
30/// Returns [`SemanticExtensionRegistryError::MalformedFamilyId`] if the linalg
31/// family identifier is invalid, or
32/// [`SemanticExtensionRegistryError::DuplicateRule`] if a semantic rule role
33/// is already registered.
34///
35/// # Examples
36///
37/// ```rust
38/// let rules = tenferro_linalg::semantic_ad_rules().unwrap();
39/// assert!(rules
40///     .lookup_linearize(tenferro_linalg::LINALG_EXTENSION_FAMILY_ID)
41///     .is_some());
42/// assert!(rules
43///     .lookup_linear_transpose(tenferro_linalg::LINALG_EXTENSION_FAMILY_ID)
44///     .is_some());
45/// assert!(rules
46///     .lookup_primal_vjp(tenferro_linalg::LINALG_EXTENSION_FAMILY_ID)
47///     .is_none());
48/// ```
49pub fn semantic_ad_rules() -> Result<SemanticExtensionRuleSet, SemanticExtensionRegistryError> {
50    SemanticExtensionRuleSet::new()
51        .with_linearize(Arc::new(LinalgAdRule))?
52        .with_linear_transpose(Arc::new(LinalgAdRule))
53}
54
55impl SemanticLinearizeRule for LinalgAdRule {
56    fn family_id(&self) -> &'static str {
57        LINALG_EXTENSION_FAMILY_ID
58    }
59
60    fn linearize(
61        &self,
62        request: SemanticLinearizeRequest<'_>,
63        builder: &mut SemanticProgramBuilder,
64    ) -> Result<SemanticLinearizeResult, SemanticAdError> {
65        let op = semantic_linalg_op(request.op(), SemanticAdRuleRole::Linearize)?;
66        if matches!(op.op(), LinalgOp::HouseholderQrThinQ { .. }) {
67            return Err(SemanticAdError::Unsupported {
68                family_id: LINALG_EXTENSION_FAMILY_ID,
69                role: SemanticAdRuleRole::Linearize,
70                message: "internal thin-Q residual is not differentiable".into(),
71            });
72        }
73        if matches!(op.op(), LinalgOp::LuFactor | LinalgOp::SvdFull) {
74            // These value-only operations can appear inside a differentiable
75            // composite (for example, `solve` uses `LuFactor` outputs as
76            // prepared-solve residuals).  Returning absent tangents lets the
77            // composite rule differentiate through the primal inputs without
78            // pretending that the factorization outputs are differentiable.
79            // A caller requesting those outputs directly still observes that
80            // no derivative output was produced, while VJP rejects their
81            // unsupported transpose below.
82            return Ok(SemanticLinearizeResult::new(
83                std::iter::repeat_n(AdValue::Absent, request.primal_outputs().len()),
84                [],
85            ));
86        }
87        let legacy = LegacyInvocation::new(
88            request.primal_inputs(),
89            request.primal_outputs(),
90            request.active_outputs(),
91            builder,
92        )?;
93        let seed_values: Vec<_> = request
94            .tangent_inputs()
95            .iter()
96            .copied()
97            .map(AdValue::value)
98            .collect();
99        let tangent_inputs: Vec<_> = seed_values
100            .iter()
101            .enumerate()
102            .map(|(index, value)| value.map(|_| index))
103            .collect();
104        let mut emitted = SemanticRuleBuilder::with_seeds(
105            &seed_values,
106            &legacy.external_values,
107            &legacy.shape_sources,
108            builder,
109            SemanticAdRuleRole::Linearize,
110        );
111        let tangent_outputs = LinalgAdRule
112            .linearize(
113                op,
114                &mut emitted,
115                &legacy.input_keys,
116                &legacy.output_keys,
117                &tangent_inputs,
118                &mut legacy.context.clone(),
119            )
120            .map_err(|error| legacy_error(SemanticAdRuleRole::Linearize, error))?;
121        let locals = emitted.finish()?;
122        Ok(SemanticLinearizeResult::new(
123            tangent_outputs.into_iter().map(|value| {
124                value
125                    .and_then(|local| locals.get(local).copied().flatten())
126                    .map_or(AdValue::Absent, AdValue::Value)
127            }),
128            [],
129        ))
130    }
131}
132
133impl SemanticLinearTransposeRule for LinalgAdRule {
134    fn family_id(&self) -> &'static str {
135        LINALG_EXTENSION_FAMILY_ID
136    }
137
138    fn residual_mask(&self) -> ResidualSpec {
139        // The linalg family is one rule across solve/eigen/qr ops whose
140        // transposes collectively read every operand: triangular solve reads
141        // both inputs plus the solution output, and the linearize+fragment
142        // path can consume any input/output value (svd reads outputs 0-2,
143        // eigh/qr read outputs 0-1).
144        // ponytail: family-level mask; per-op masks would require splitting
145        // `LinalgAdRule` per op.
146        ResidualSpec::all_inputs().with_all_outputs()
147    }
148
149    fn linear_transpose(
150        &self,
151        request: SemanticLinearTransposeRequest<'_>,
152        builder: &mut SemanticProgramBuilder,
153    ) -> Result<Box<[AdValue]>, SemanticAdError> {
154        let primal_inputs = (0..request.primal_input_count())
155            .map(|index| request.primal_input_value(index))
156            .collect::<Result<Vec<_>, _>>()?;
157        let primal_outputs = (0..request.primal_output_count())
158            .map(|index| request.primal_output_value(index))
159            .collect::<Result<Vec<_>, _>>()?;
160        let op = semantic_linalg_op(request.op(), SemanticAdRuleRole::LinearTranspose)?;
161        match op.op() {
162            LinalgOp::TriangularSolve {
163                left_side,
164                lower,
165                transpose_a,
166                unit_diagonal,
167            } => semantic_triangular_solve_transpose(
168                &primal_inputs,
169                &primal_outputs,
170                request.cotangent_outputs(),
171                request.active_inputs(),
172                request.residual_mask(),
173                builder,
174                left_side,
175                lower,
176                transpose_a,
177                unit_diagonal,
178            ),
179            LinalgOp::LuSolvePrepared { .. } => {
180                let active_inputs = lu_solve_prepared_transpose_active_inputs(
181                    request.active_inputs(),
182                    SemanticAdRuleRole::LinearTranspose,
183                )?;
184                semantic_custom_transpose(
185                    request.op(),
186                    &primal_inputs,
187                    &primal_outputs,
188                    request.cotangent_outputs(),
189                    &active_inputs,
190                    builder,
191                    SemanticAdRuleRole::LinearTranspose,
192                )
193            }
194            LinalgOp::FullPivLuSolve { .. } => semantic_custom_transpose(
195                request.op(),
196                &primal_inputs,
197                &primal_outputs,
198                request.cotangent_outputs(),
199                request.active_inputs(),
200                builder,
201                SemanticAdRuleRole::LinearTranspose,
202            ),
203            LinalgOp::Solve => semantic_custom_transpose(
204                request.op(),
205                &primal_inputs,
206                &primal_outputs,
207                request.cotangent_outputs(),
208                request.active_inputs(),
209                builder,
210                SemanticAdRuleRole::LinearTranspose,
211            ),
212            LinalgOp::LuFactor | LinalgOp::SvdFull | LinalgOp::HouseholderQrThinQ { .. } => {
213                Err(SemanticAdError::Unsupported {
214                    family_id: LINALG_EXTENSION_FAMILY_ID,
215                    role: SemanticAdRuleRole::LinearTranspose,
216                    message: format!("semantic linear transpose is unsupported for {:?}", op.op()),
217                })
218            }
219            _ => semantic_linearized_transpose(
220                request.op(),
221                &primal_inputs,
222                &primal_outputs,
223                request.cotangent_outputs(),
224                request.active_inputs(),
225                builder,
226            ),
227        }
228    }
229}
230
231fn lu_solve_prepared_transpose_active_inputs(
232    active_inputs: &[bool],
233    role: SemanticAdRuleRole,
234) -> Result<[bool; 4], SemanticAdError> {
235    let active_inputs: [bool; 4] = active_inputs.try_into().map_err(|_| {
236        semantic_internal(
237            role,
238            format!(
239                "lu_solve_prepared semantic transpose expected 4 active inputs, got {}",
240                active_inputs.len()
241            ),
242        )
243    })?;
244    // Packed LU may be an active intermediate when `solve` lowers through
245    // factorization. Pivot and parity slots remain non-cotangent-producing
246    // residuals.
247    Ok([active_inputs[0], false, false, active_inputs[3]])
248}
249
250#[allow(clippy::too_many_arguments)]
251fn semantic_triangular_solve_transpose(
252    primal_inputs: &[ProgramValue],
253    primal_outputs: &[ProgramValue],
254    cotangent_outputs: &[AdValue],
255    active_inputs: &[bool],
256    residual_mask: ResidualSpec,
257    builder: &mut SemanticProgramBuilder,
258    left_side: bool,
259    lower: bool,
260    transpose_a: bool,
261    unit_diagonal: bool,
262) -> Result<Box<[AdValue]>, SemanticAdError> {
263    let role = SemanticAdRuleRole::LinearTranspose;
264    if primal_inputs.len() != 2
265        || primal_outputs.len() != 1
266        || cotangent_outputs.len() != 1
267        || active_inputs.len() != 2
268    {
269        return Err(semantic_internal(
270            role,
271            "triangular_solve semantic transpose received malformed arity",
272        ));
273    }
274    let Some(ct) = cotangent_outputs.first().copied().and_then(AdValue::value) else {
275        return Ok(vec![AdValue::Absent; 2].into_boxed_slice());
276    };
277
278    let mut result = vec![AdValue::Absent; 2];
279    if !active_inputs[0] && !active_inputs[1] {
280        return Ok(result.into_boxed_slice());
281    }
282
283    let matrix_rank = builder.value_metadata(primal_inputs[0])?.shape().len();
284    let rhs_rank = builder.value_metadata(primal_inputs[1])?.shape().len();
285    if matrix_rank < 2 || rhs_rank < 2 {
286        return Err(semantic_internal(
287            role,
288            "triangular_solve semantic transpose expects matrix operands",
289        ));
290    }
291    if matrix_rank != rhs_rank {
292        return Err(semantic_internal(
293            role,
294            "triangular_solve semantic transpose expects equal-rank operands",
295        ));
296    }
297
298    let conjugated_a = conjugate_if_complex(builder, primal_inputs[0])?;
299    debug_assert!(
300        residual_mask.declares_input(0),
301        "linalg triangular_solve transpose read primal input 0 as a tensor operand but the \
302         residual mask does not declare it; declare it in the linalg rule's residual mask"
303    );
304    let rhs_cotangent = builder.add_extension(
305        Arc::new(LinalgExtensionOp::new(LinalgOp::TriangularSolve {
306            left_side,
307            lower,
308            transpose_a: !transpose_a,
309            unit_diagonal,
310        })),
311        &[conjugated_a, ct],
312    )?[0];
313
314    if active_inputs[1] {
315        result[1] = AdValue::Value(rhs_cotangent);
316    }
317    if active_inputs[0] {
318        debug_assert!(
319            residual_mask.declares_output(0),
320            "linalg triangular_solve transpose read primal output 0 as a tensor operand but the \
321             residual mask does not declare it; declare it in the linalg rule's residual mask"
322        );
323        let matrix_cotangent = semantic_solve_matrix_cotangent(
324            builder,
325            rhs_cotangent,
326            primal_outputs[0],
327            left_side,
328            transpose_a,
329            matrix_rank,
330        )?;
331        let k = if unit_diagonal {
332            if lower {
333                -1
334            } else {
335                1
336            }
337        } else {
338            0
339        };
340        let projected = if lower {
341            builder.add_op(CoreSemanticOp::Tril { k }, &[matrix_cotangent])?[0]
342        } else {
343            builder.add_op(CoreSemanticOp::Triu { k }, &[matrix_cotangent])?[0]
344        };
345        result[0] = AdValue::Value(projected);
346    }
347
348    Ok(result.into_boxed_slice())
349}
350
351fn semantic_linearized_transpose(
352    op: &dyn tenferro_ad::extension::ExtensionOp,
353    primal_inputs: &[ProgramValue],
354    primal_outputs: &[ProgramValue],
355    cotangent_outputs: &[AdValue],
356    active_inputs: &[bool],
357    builder: &mut SemanticProgramBuilder,
358) -> Result<Box<[AdValue]>, SemanticAdError> {
359    let legacy = LegacyInvocation::new(
360        primal_inputs,
361        primal_outputs,
362        &cotangent_outputs
363            .iter()
364            .map(|value| matches!(value, AdValue::Value(_)))
365            .collect::<Vec<_>>(),
366        builder,
367    )?;
368    let tangent_inputs: Vec<_> = active_inputs
369        .iter()
370        .copied()
371        .enumerate()
372        .map(|(index, active)| active.then_some(index))
373        .collect();
374    let mut fragment = SemanticLinearFragmentBuilder::with_seed_count(primal_inputs.len());
375    let tangent_outputs = LinalgAdRule
376        .linearize(
377            op,
378            &mut fragment,
379            &legacy.input_keys,
380            &legacy.output_keys,
381            &tangent_inputs,
382            &mut legacy.context.clone(),
383        )
384        .map_err(|error| legacy_error(SemanticAdRuleRole::LinearTranspose, error))?;
385    fragment.transpose_linear_fragment(
386        &tangent_outputs,
387        cotangent_outputs,
388        active_inputs,
389        &legacy.external_values,
390        &legacy.shape_sources,
391        builder,
392    )
393}
394
395fn semantic_custom_transpose(
396    op: &dyn tenferro_ad::extension::ExtensionOp,
397    primal_inputs: &[ProgramValue],
398    primal_outputs: &[ProgramValue],
399    cotangent_outputs: &[AdValue],
400    active_inputs: &[bool],
401    builder: &mut SemanticProgramBuilder,
402    role: SemanticAdRuleRole,
403) -> Result<Box<[AdValue]>, SemanticAdError> {
404    let legacy = LegacyInvocation::new(
405        primal_inputs,
406        primal_outputs,
407        &vec![true; primal_outputs.len()],
408        builder,
409    )?;
410    let seed_values: Vec<_> = cotangent_outputs
411        .iter()
412        .copied()
413        .map(AdValue::value)
414        .collect();
415    let cotangents: Vec<_> = seed_values
416        .iter()
417        .enumerate()
418        .map(|(index, value)| value.map(|_| index))
419        .collect();
420    let transpose_inputs: Vec<_> = legacy
421        .input_keys
422        .iter()
423        .cloned()
424        .map(PrimitiveTransposeInput::Residual)
425        .collect();
426    let mut emitted = SemanticRuleBuilder::with_seeds(
427        &seed_values,
428        &legacy.external_values,
429        &legacy.shape_sources,
430        builder,
431        role,
432    );
433    let cotangent_inputs = LinalgAdRule
434        .linear_transpose(
435            op,
436            &mut emitted,
437            &cotangents,
438            &transpose_inputs,
439            active_inputs,
440            &mut legacy.context.clone(),
441        )
442        .map_err(|error| legacy_error(role, error))?;
443    let locals = emitted.finish()?;
444    Ok(cotangent_inputs
445        .into_iter()
446        .map(|value| {
447            value
448                .and_then(|local| locals.get(local).copied().flatten())
449                .map_or(AdValue::Absent, AdValue::Value)
450        })
451        .collect())
452}
453
454struct LegacyInvocation {
455    context: ShapeGuardContext,
456    input_keys: Vec<ValueKey<StdTensorOp>>,
457    output_keys: Vec<ValueKey<StdTensorOp>>,
458    external_values: HashMap<ValueKey<StdTensorOp>, ProgramValue>,
459    shape_sources: Vec<ProgramValue>,
460}
461
462impl LegacyInvocation {
463    fn new(
464        primal_inputs: &[ProgramValue],
465        primal_outputs: &[ProgramValue],
466        active_outputs: &[bool],
467        builder: &SemanticProgramBuilder,
468    ) -> Result<Self, SemanticAdError> {
469        let values: Vec<_> = primal_inputs
470            .iter()
471            .chain(primal_outputs)
472            .copied()
473            .collect();
474        let metadata: Vec<_> = values
475            .iter()
476            .copied()
477            .map(|value| builder.value_metadata(value).cloned())
478            .collect::<Result<_, _>>()?;
479        let symbolic_inputs = synthetic_input_shapes(&metadata);
480        let symbolic_input_refs: Vec<_> = symbolic_inputs.iter().map(Vec::as_slice).collect();
481        let mut context = ShapeGuardContext::default();
482        let mut external_values = HashMap::new();
483        let keys: Vec<_> = values
484            .iter()
485            .copied()
486            .enumerate()
487            .map(|(index, value)| {
488                let key = ValueKey::Input(TensorInputKey::User {
489                    id: u64::try_from(index + 1).expect("small semantic AD invocation"),
490                });
491                context.insert_metadata(
492                    key.clone(),
493                    legacy_metadata(&metadata[index], &symbolic_input_refs),
494                );
495                external_values.insert(key.clone(), value);
496                key
497            })
498            .collect();
499        let input_count = primal_inputs.len();
500        let input_keys = keys[..input_count].to_vec();
501        let output_keys = keys[input_count..].to_vec();
502        let active_values: HashSet<_> = output_keys
503            .iter()
504            .zip(active_outputs)
505            .filter(|(_, active)| **active)
506            .map(|(key, _)| key.clone())
507            .collect();
508        context = context.with_linearize_active_values(Arc::new(active_values));
509        Ok(Self {
510            context,
511            input_keys,
512            output_keys,
513            external_values,
514            shape_sources: values,
515        })
516    }
517}
518
519fn legacy_metadata(metadata: &ProgramValueMetadata, input_shapes: &[&[SymDim]]) -> TensorMeta {
520    let extents = metadata
521        .shape()
522        .iter()
523        .cloned()
524        .map(|extent| extent.map(|dim| SymDim::from_dim_expr(&dim, input_shapes)))
525        .collect();
526    TensorMeta::with_extents(metadata.dtype(), extents)
527}
528
529fn synthetic_input_shapes(metadata: &[ProgramValueMetadata]) -> Vec<Vec<SymDim>> {
530    let mut ranks = Vec::<usize>::new();
531    for expression in metadata
532        .iter()
533        .flat_map(ProgramValueMetadata::shape)
534        .filter_map(ShapeExtent::bound_expr)
535    {
536        collect_input_ranks(expression, &mut ranks);
537    }
538    ranks
539        .into_iter()
540        .enumerate()
541        .map(|(input, rank)| {
542            (0..rank)
543                .map(|axis| {
544                    SymDim::tensor_axis(
545                        u64::try_from(input + 1).expect("small semantic input index"),
546                        axis,
547                    )
548                })
549                .collect()
550        })
551        .collect()
552}
553
554fn collect_input_ranks(expression: &DimExpr, ranks: &mut Vec<usize>) {
555    match expression {
556        DimExpr::Const(_) => {}
557        DimExpr::InputDim { input_idx, axis } => {
558            if ranks.len() <= *input_idx {
559                ranks.resize(*input_idx + 1, 0);
560            }
561            ranks[*input_idx] = ranks[*input_idx].max(*axis + 1);
562        }
563        DimExpr::Add(lhs, rhs)
564        | DimExpr::Sub(lhs, rhs)
565        | DimExpr::Mul(lhs, rhs)
566        | DimExpr::FloorDiv(lhs, rhs)
567        | DimExpr::Min(lhs, rhs)
568        | DimExpr::Max(lhs, rhs) => {
569            collect_input_ranks(lhs, ranks);
570            collect_input_ranks(rhs, ranks);
571        }
572    }
573}
574
575#[derive(Clone, Debug)]
576enum SemanticLinearFragmentOp {
577    Core(CoreSemanticOp),
578    Extension(Arc<dyn tenferro_ad::extension::ExtensionOp>),
579    Unsupported(String),
580}
581
582#[derive(Clone)]
583enum SemanticLinearFragmentInput {
584    External(ValueKey<StdTensorOp>),
585    Local(LocalValueId),
586}
587
588impl From<ValueRef<StdTensorOp>> for SemanticLinearFragmentInput {
589    fn from(value: ValueRef<StdTensorOp>) -> Self {
590        match value {
591            ValueRef::External(key) => Self::External(key),
592            ValueRef::Local(local) => Self::Local(local),
593        }
594    }
595}
596
597struct SemanticLinearFragmentOperation {
598    operation: SemanticLinearFragmentOp,
599    inputs: Vec<SemanticLinearFragmentInput>,
600    role: OperationRole,
601    outputs: Vec<LocalValueId>,
602}
603
604fn semantic_linear_fragment_op(operation: &StdTensorOp) -> SemanticLinearFragmentOp {
605    match operation {
606        StdTensorOp::Extension(extension) => {
607            SemanticLinearFragmentOp::Extension(Arc::clone(extension))
608        }
609        core => CoreSemanticOp::try_from(core).map_or_else(
610            |_| SemanticLinearFragmentOp::Unsupported(format!("{core:?}")),
611            SemanticLinearFragmentOp::Core,
612        ),
613    }
614}
615
616struct SemanticRuleBuilder<'a, 'builder> {
617    next_local: usize,
618    locals: Vec<Option<ProgramValue>>,
619    external_values: &'a HashMap<ValueKey<StdTensorOp>, ProgramValue>,
620    shape_sources: &'a [ProgramValue],
621    builder: &'builder mut SemanticProgramBuilder,
622    role: SemanticAdRuleRole,
623    error: Option<SemanticAdError>,
624}
625
626impl<'a, 'builder> SemanticRuleBuilder<'a, 'builder> {
627    fn with_seeds(
628        seeds: &[Option<ProgramValue>],
629        external_values: &'a HashMap<ValueKey<StdTensorOp>, ProgramValue>,
630        shape_sources: &'a [ProgramValue],
631        builder: &'builder mut SemanticProgramBuilder,
632        role: SemanticAdRuleRole,
633    ) -> Self {
634        Self {
635            next_local: seeds.len(),
636            locals: seeds.to_vec(),
637            external_values,
638            shape_sources,
639            builder,
640            role,
641            error: None,
642        }
643    }
644
645    fn finish(self) -> Result<Vec<Option<ProgramValue>>, SemanticAdError> {
646        if let Some(error) = self.error {
647            Err(error)
648        } else {
649            Ok(self.locals)
650        }
651    }
652}
653
654impl PrimitiveRuleBuilder for SemanticRuleBuilder<'_, '_> {
655    fn add_operation(
656        &mut self,
657        operation: StdTensorOp,
658        inputs: Vec<ValueRef<StdTensorOp>>,
659        _role: OperationRole,
660    ) -> Vec<LocalValueId> {
661        let output_count = GraphOperation::output_count(&operation);
662        let outputs: Vec<_> = (self.next_local..self.next_local + output_count).collect();
663        self.next_local += output_count;
664        self.locals.resize(self.next_local, None);
665        if self.error.is_none() {
666            let fragment_op = semantic_linear_fragment_op(&operation);
667            let fragment_inputs: Vec<_> = inputs.into_iter().map(Into::into).collect();
668            let emitted = resolve_semantic_linear_fragment_inputs(
669                &fragment_inputs,
670                self.external_values,
671                &self.locals,
672                self.role,
673            )
674            .and_then(|resolved| {
675                emit_semantic_linear_fragment_operation(
676                    &fragment_op,
677                    &resolved,
678                    self.shape_sources,
679                    self.builder,
680                    self.role,
681                )
682            });
683            match emitted {
684                Ok(values) => {
685                    if values.len() != outputs.len() {
686                        self.error = Some(semantic_internal(
687                            self.role,
688                            format!(
689                                "semantic linalg AD operation emitted {} outputs for {} slots",
690                                values.len(),
691                                outputs.len()
692                            ),
693                        ));
694                    } else {
695                        for (local, value) in outputs.iter().copied().zip(values.iter().copied()) {
696                            self.locals[local] = Some(value);
697                        }
698                    }
699                }
700                Err(error) => {
701                    self.error = Some(error);
702                }
703            }
704        }
705        outputs
706    }
707}
708
709/// Op-local semantic linear fragment used by the manifest's
710/// `LinearizeThenTranspose` route. General linalg decomposition VJPs remain
711/// derived by transposing their emitted linearization, but the fragment stores
712/// semantic core ops or extension payloads instead of replaying legacy
713/// `StdTensorOp` graphs into the destination builder.
714struct SemanticLinearFragmentBuilder {
715    seed_count: usize,
716    next_local: usize,
717    operations: Vec<SemanticLinearFragmentOperation>,
718}
719
720impl SemanticLinearFragmentBuilder {
721    fn with_seed_count(seed_count: usize) -> Self {
722        Self {
723            seed_count,
724            next_local: seed_count,
725            operations: Vec::new(),
726        }
727    }
728
729    fn transpose_linear_fragment(
730        &self,
731        tangent_outputs: &[Option<LocalValueId>],
732        cotangent_outputs: &[AdValue],
733        active_inputs: &[bool],
734        external_values: &HashMap<ValueKey<StdTensorOp>, ProgramValue>,
735        shape_sources: &[ProgramValue],
736        builder: &mut SemanticProgramBuilder,
737    ) -> Result<Box<[AdValue]>, SemanticAdError> {
738        let role = SemanticAdRuleRole::LinearTranspose;
739        let fixed_locals =
740            self.emit_fixed_primal_ops(external_values, shape_sources, builder, role)?;
741        let mut cotangents = HashMap::<LocalValueId, ProgramValue>::new();
742        for (tangent, cotangent) in tangent_outputs
743            .iter()
744            .copied()
745            .zip(cotangent_outputs.iter().copied())
746        {
747            if let (Some(tangent), AdValue::Value(cotangent)) = (tangent, cotangent) {
748                accumulate_local_cotangent(builder, &mut cotangents, tangent, cotangent)?;
749            }
750        }
751        for operation in self.operations.iter().rev() {
752            let Some(active_mask) = linear_active_mask(&operation.role) else {
753                continue;
754            };
755            if !active_mask.iter().any(|active| *active) {
756                continue;
757            }
758            let output_cotangents: Vec<_> = operation
759                .outputs
760                .iter()
761                .map(|output| cotangents.remove(output))
762                .collect();
763            if output_cotangents.iter().all(Option::is_none) {
764                continue;
765            }
766            let context = SemanticLinearFragmentTransposeContext {
767                fragment: self,
768                external_values,
769                fixed_locals: &fixed_locals,
770                shape_sources,
771                role,
772            };
773            let input_cotangents = transpose_semantic_linear_fragment_operation(
774                operation,
775                &output_cotangents,
776                active_mask,
777                &context,
778                builder,
779            )?;
780            for ((input, active), cotangent) in operation
781                .inputs
782                .iter()
783                .zip(active_mask)
784                .zip(input_cotangents)
785            {
786                if !active {
787                    continue;
788                }
789                let (SemanticLinearFragmentInput::Local(input), Some(cotangent)) =
790                    (input, cotangent)
791                else {
792                    return Err(semantic_internal(
793                        role,
794                        "linear linalg fragment has a non-local active input",
795                    ));
796                };
797                accumulate_local_cotangent(builder, &mut cotangents, *input, cotangent)?;
798            }
799        }
800        Ok(active_inputs
801            .iter()
802            .copied()
803            .enumerate()
804            .map(|(input, active)| {
805                if active {
806                    cotangents
807                        .remove(&input)
808                        .map_or(AdValue::Absent, AdValue::Value)
809                } else {
810                    AdValue::Absent
811                }
812            })
813            .collect())
814    }
815
816    fn emit_fixed_primal_ops(
817        &self,
818        external_values: &HashMap<ValueKey<StdTensorOp>, ProgramValue>,
819        shape_sources: &[ProgramValue],
820        builder: &mut SemanticProgramBuilder,
821        role: SemanticAdRuleRole,
822    ) -> Result<Vec<Option<ProgramValue>>, SemanticAdError> {
823        let mut locals = vec![None; self.next_local];
824        for operation in &self.operations {
825            if linear_active_mask(&operation.role)
826                .is_some_and(|mask| mask.iter().any(|active| *active))
827            {
828                continue;
829            }
830            let inputs = resolve_semantic_linear_fragment_inputs(
831                &operation.inputs,
832                external_values,
833                &locals,
834                role,
835            )?;
836            let outputs = emit_semantic_linear_fragment_operation(
837                &operation.operation,
838                &inputs,
839                shape_sources,
840                builder,
841                role,
842            )?;
843            for (local, value) in operation
844                .outputs
845                .iter()
846                .copied()
847                .zip(outputs.iter().copied())
848            {
849                locals[local] = Some(value);
850            }
851        }
852        Ok(locals)
853    }
854}
855
856impl PrimitiveRuleBuilder for SemanticLinearFragmentBuilder {
857    fn add_operation(
858        &mut self,
859        operation: StdTensorOp,
860        inputs: Vec<ValueRef<StdTensorOp>>,
861        role: OperationRole,
862    ) -> Vec<LocalValueId> {
863        let output_count = GraphOperation::output_count(&operation);
864        let outputs: Vec<_> = (self.next_local..self.next_local + output_count).collect();
865        self.next_local += output_count;
866        self.operations.push(SemanticLinearFragmentOperation {
867            operation: semantic_linear_fragment_op(&operation),
868            inputs: inputs.into_iter().map(Into::into).collect(),
869            role,
870            outputs: outputs.clone(),
871        });
872        outputs
873    }
874}
875
876fn linear_active_mask(role: &OperationRole) -> Option<&[bool]> {
877    match role {
878        OperationRole::Primary => None,
879        OperationRole::Linearized { active_mask } => Some(active_mask),
880    }
881}
882
883struct SemanticLinearFragmentTransposeContext<'a> {
884    fragment: &'a SemanticLinearFragmentBuilder,
885    external_values: &'a HashMap<ValueKey<StdTensorOp>, ProgramValue>,
886    fixed_locals: &'a [Option<ProgramValue>],
887    shape_sources: &'a [ProgramValue],
888    role: SemanticAdRuleRole,
889}
890
891fn resolve_semantic_linear_fragment_inputs(
892    inputs: &[SemanticLinearFragmentInput],
893    external_values: &HashMap<ValueKey<StdTensorOp>, ProgramValue>,
894    locals: &[Option<ProgramValue>],
895    role: SemanticAdRuleRole,
896) -> Result<Vec<ProgramValue>, SemanticAdError> {
897    inputs
898        .iter()
899        .map(|input| match input {
900            SemanticLinearFragmentInput::External(key) => external_values.get(key).copied(),
901            SemanticLinearFragmentInput::Local(local) => locals.get(*local).copied().flatten(),
902        })
903        .collect::<Option<_>>()
904        .ok_or_else(|| {
905            semantic_internal(
906                role,
907                "semantic linalg linear fragment references an unavailable fixed value",
908            )
909        })
910}
911
912fn emit_semantic_linear_fragment_operation(
913    operation: &SemanticLinearFragmentOp,
914    inputs: &[ProgramValue],
915    shape_sources: &[ProgramValue],
916    builder: &mut SemanticProgramBuilder,
917    role: SemanticAdRuleRole,
918) -> Result<Box<[ProgramValue]>, SemanticAdError> {
919    match operation {
920        SemanticLinearFragmentOp::Extension(extension) => {
921            Ok(builder.add_extension(Arc::clone(extension), inputs)?)
922        }
923        SemanticLinearFragmentOp::Core(core) => {
924            let fragment_core = core.clone();
925            let (core, inputs) =
926                localize_shape_expressions(core.clone(), inputs, shape_sources, builder, role)
927                    .map_err(|error| match error {
928                        SemanticAdError::Invariant {
929                            family_id,
930                            role,
931                            message,
932                        } => SemanticAdError::Invariant {
933                            family_id,
934                            role,
935                            message: format!(
936                                "{message}; linear fragment operation {fragment_core:?}"
937                            ),
938                        },
939                        other => other,
940                    })?;
941            Ok(builder.add_op(core, &inputs)?)
942        }
943        SemanticLinearFragmentOp::Unsupported(operation) => Err(semantic_internal(
944            role,
945            format!("linalg AD emitted a non-semantic standard operation {operation}"),
946        )),
947    }
948}
949
950fn localize_shape_expressions(
951    operation: CoreSemanticOp,
952    data_inputs: &[ProgramValue],
953    shape_sources: &[ProgramValue],
954    builder: &SemanticProgramBuilder,
955    role: SemanticAdRuleRole,
956) -> Result<(CoreSemanticOp, Vec<ProgramValue>), SemanticAdError> {
957    let mut inputs = data_inputs.to_vec();
958    let operation = match operation {
959        CoreSemanticOp::Reshape { to_shape } => CoreSemanticOp::Reshape {
960            to_shape: localize_dims(
961                &to_shape,
962                data_inputs,
963                shape_sources,
964                1,
965                &mut inputs,
966                builder,
967                role,
968            )?,
969        },
970        CoreSemanticOp::BroadcastInDim { shape, dims } => CoreSemanticOp::BroadcastInDim {
971            shape: localize_dims(
972                &shape,
973                data_inputs,
974                shape_sources,
975                1,
976                &mut inputs,
977                builder,
978                role,
979            )?,
980            dims,
981        },
982        CoreSemanticOp::GatherDynamicSliceSizes {
983            offset_dims,
984            collapsed_slice_dims,
985            start_index_map,
986            index_vector_dim,
987            slice_sizes,
988        } => CoreSemanticOp::GatherDynamicSliceSizes {
989            offset_dims,
990            collapsed_slice_dims,
991            start_index_map,
992            index_vector_dim,
993            slice_sizes: localize_dims(
994                &slice_sizes,
995                data_inputs,
996                shape_sources,
997                2,
998                &mut inputs,
999                builder,
1000                role,
1001            )?,
1002        },
1003        other => other,
1004    };
1005    Ok((operation, inputs))
1006}
1007
1008fn localize_dims(
1009    dims: &[DimExpr],
1010    data_inputs: &[ProgramValue],
1011    shape_sources: &[ProgramValue],
1012    fixed_data_arity: usize,
1013    operation_inputs: &mut Vec<ProgramValue>,
1014    builder: &SemanticProgramBuilder,
1015    role: SemanticAdRuleRole,
1016) -> Result<Vec<DimExpr>, SemanticAdError> {
1017    dims.iter()
1018        .map(|dim| {
1019            localize_dim(
1020                dim,
1021                data_inputs,
1022                shape_sources,
1023                fixed_data_arity,
1024                operation_inputs,
1025                builder,
1026                role,
1027            )
1028        })
1029        .collect()
1030}
1031
1032fn localize_dim(
1033    dim: &DimExpr,
1034    data_inputs: &[ProgramValue],
1035    shape_sources: &[ProgramValue],
1036    fixed_data_arity: usize,
1037    operation_inputs: &mut Vec<ProgramValue>,
1038    builder: &SemanticProgramBuilder,
1039    role: SemanticAdRuleRole,
1040) -> Result<DimExpr, SemanticAdError> {
1041    let binary = |lhs: &DimExpr,
1042                  rhs: &DimExpr,
1043                  constructor: fn(Box<DimExpr>, Box<DimExpr>) -> DimExpr,
1044                  operation_inputs: &mut Vec<ProgramValue>|
1045     -> Result<DimExpr, SemanticAdError> {
1046        Ok(constructor(
1047            Box::new(localize_dim(
1048                lhs,
1049                data_inputs,
1050                shape_sources,
1051                fixed_data_arity,
1052                operation_inputs,
1053                builder,
1054                role,
1055            )?),
1056            Box::new(localize_dim(
1057                rhs,
1058                data_inputs,
1059                shape_sources,
1060                fixed_data_arity,
1061                operation_inputs,
1062                builder,
1063                role,
1064            )?),
1065        ))
1066    };
1067    match dim {
1068        DimExpr::Const(value) => Ok(DimExpr::Const(*value)),
1069        DimExpr::InputDim { input_idx, axis } => {
1070            // Legacy linalg rules express shape dimensions in invocation
1071            // coordinates. Shape-aware primitive helpers append explicit
1072            // shape operands after the primitive's fixed data operands and
1073            // remap their dimensions into those local operand coordinates.
1074            // Keep those two coordinate spaces distinct; rank compatibility
1075            // cannot disambiguate them.
1076            let source = if *input_idx >= fixed_data_arity {
1077                data_inputs
1078                    .get(*input_idx)
1079                    .copied()
1080                    .or_else(|| shape_sources.get(*input_idx).copied())
1081            } else {
1082                shape_sources.get(*input_idx).copied()
1083            }
1084            .ok_or_else(|| {
1085                    semantic_internal(
1086                        role,
1087                        format!(
1088                            "linalg AD symbolic shape input {input_idx} is out of bounds for {} operation inputs and {} primal shape sources",
1089                            data_inputs.len(),
1090                            shape_sources.len()
1091                        ),
1092                    )
1093                })?;
1094            let rank = builder.value_metadata(source)?.shape().len();
1095            if *axis >= rank {
1096                return Err(semantic_internal(
1097                    role,
1098                    format!(
1099                        "linalg AD symbolic shape axis {axis} is out of bounds for source rank {rank}"
1100                    ),
1101                ));
1102            }
1103            let input_idx = operation_inputs
1104                .iter()
1105                .position(|value| *value == source)
1106                .unwrap_or_else(|| {
1107                    operation_inputs.push(source);
1108                    operation_inputs.len() - 1
1109                });
1110            debug_assert!(
1111                input_idx < data_inputs.len() + shape_sources.len(),
1112                "localized shape source must be an operation input"
1113            );
1114            Ok(DimExpr::InputDim {
1115                input_idx,
1116                axis: *axis,
1117            })
1118        }
1119        DimExpr::Add(lhs, rhs) => binary(lhs, rhs, DimExpr::Add, operation_inputs),
1120        DimExpr::Sub(lhs, rhs) => binary(lhs, rhs, DimExpr::Sub, operation_inputs),
1121        DimExpr::Mul(lhs, rhs) => binary(lhs, rhs, DimExpr::Mul, operation_inputs),
1122        DimExpr::FloorDiv(lhs, rhs) => binary(lhs, rhs, DimExpr::FloorDiv, operation_inputs),
1123        DimExpr::Min(lhs, rhs) => binary(lhs, rhs, DimExpr::Min, operation_inputs),
1124        DimExpr::Max(lhs, rhs) => binary(lhs, rhs, DimExpr::Max, operation_inputs),
1125    }
1126}
1127
1128fn transpose_semantic_linear_fragment_operation(
1129    operation: &SemanticLinearFragmentOperation,
1130    cotangent_outputs: &[Option<ProgramValue>],
1131    active_mask: &[bool],
1132    context: &SemanticLinearFragmentTransposeContext<'_>,
1133    builder: &mut SemanticProgramBuilder,
1134) -> Result<Vec<Option<ProgramValue>>, SemanticAdError> {
1135    let Some(cotangent) = cotangent_outputs.first().copied().flatten() else {
1136        return Ok(vec![None; operation.inputs.len()]);
1137    };
1138    let fixed = |index: usize| {
1139        if active_mask.get(index).copied().unwrap_or(false) {
1140            None
1141        } else {
1142            match operation.inputs.get(index) {
1143                Some(SemanticLinearFragmentInput::External(key)) => {
1144                    context.external_values.get(key).copied()
1145                }
1146                Some(SemanticLinearFragmentInput::Local(local)) => {
1147                    context.fixed_locals.get(*local).copied().flatten()
1148                }
1149                None => None,
1150            }
1151        }
1152    };
1153    let unary = |value| Ok(vec![Some(value)]);
1154    match &operation.operation {
1155        SemanticLinearFragmentOp::Core(CoreSemanticOp::Add) => Ok(active_mask
1156            .iter()
1157            .map(|active| active.then_some(cotangent))
1158            .collect()),
1159        SemanticLinearFragmentOp::Core(CoreSemanticOp::Sub) => {
1160            let rhs = builder.add_op(CoreSemanticOp::Neg, &[cotangent])?[0];
1161            Ok(vec![
1162                active_mask[0].then_some(cotangent),
1163                active_mask[1].then_some(rhs),
1164            ])
1165        }
1166        SemanticLinearFragmentOp::Core(CoreSemanticOp::Neg) => {
1167            let value = builder.add_op(CoreSemanticOp::Neg, &[cotangent])?[0];
1168            unary(value)
1169        }
1170        SemanticLinearFragmentOp::Core(CoreSemanticOp::Conj) => {
1171            let value = builder.add_op(CoreSemanticOp::Conj, &[cotangent])?[0];
1172            unary(value)
1173        }
1174        SemanticLinearFragmentOp::Core(CoreSemanticOp::Mul) => {
1175            transpose_mul(cotangent, active_mask, &fixed, builder, context.role)
1176        }
1177        SemanticLinearFragmentOp::Core(CoreSemanticOp::Div) => {
1178            transpose_div(cotangent, active_mask, &fixed, builder, context.role)
1179        }
1180        SemanticLinearFragmentOp::Core(CoreSemanticOp::DotGeneral { config }) => {
1181            transpose_matrix_dot(
1182                cotangent,
1183                config,
1184                active_mask,
1185                &fixed,
1186                builder,
1187                context.role,
1188            )
1189        }
1190        SemanticLinearFragmentOp::Core(CoreSemanticOp::ReduceSum { axes }) => {
1191            transpose_reduce_sum(context, operation, cotangent, axes, active_mask, builder)
1192        }
1193        SemanticLinearFragmentOp::Core(CoreSemanticOp::Transpose { perm }) => {
1194            let mut inverse = vec![0; perm.len()];
1195            for (output_axis, input_axis) in perm.iter().copied().enumerate() {
1196                inverse[input_axis] = output_axis;
1197            }
1198            let value =
1199                builder.add_op(CoreSemanticOp::Transpose { perm: inverse }, &[cotangent])?[0];
1200            unary(value)
1201        }
1202        SemanticLinearFragmentOp::Core(CoreSemanticOp::Convert { from, to }) => {
1203            let value = builder.add_op(
1204                CoreSemanticOp::Convert {
1205                    from: *to,
1206                    to: *from,
1207                },
1208                &[cotangent],
1209            )?[0];
1210            unary(value)
1211        }
1212        SemanticLinearFragmentOp::Core(CoreSemanticOp::ExtractDiag { axis_a, axis_b }) => {
1213            let value = builder.add_op(
1214                CoreSemanticOp::EmbedDiag {
1215                    axis_a: *axis_a,
1216                    axis_b: *axis_b,
1217                },
1218                &[cotangent],
1219            )?[0];
1220            unary(value)
1221        }
1222        SemanticLinearFragmentOp::Core(CoreSemanticOp::EmbedDiag { axis_a, axis_b }) => {
1223            let value = builder.add_op(
1224                CoreSemanticOp::ExtractDiag {
1225                    axis_a: *axis_a,
1226                    axis_b: *axis_b,
1227                },
1228                &[cotangent],
1229            )?[0];
1230            unary(value)
1231        }
1232        SemanticLinearFragmentOp::Core(CoreSemanticOp::Tril { k }) => {
1233            let value = builder.add_op(CoreSemanticOp::Tril { k: *k }, &[cotangent])?[0];
1234            unary(value)
1235        }
1236        SemanticLinearFragmentOp::Core(CoreSemanticOp::Triu { k }) => {
1237            let value = builder.add_op(CoreSemanticOp::Triu { k: *k }, &[cotangent])?[0];
1238            unary(value)
1239        }
1240        SemanticLinearFragmentOp::Extension(extension) => transpose_linalg_extension(
1241            extension.as_ref(),
1242            operation,
1243            cotangent,
1244            active_mask,
1245            context.external_values,
1246            context.fixed_locals,
1247            builder,
1248        ),
1249        SemanticLinearFragmentOp::Unsupported(operation) => Err(semantic_internal(
1250            context.role,
1251            format!("unsupported linear linalg fragment operation {operation}"),
1252        )),
1253        other => Err(semantic_internal(
1254            context.role,
1255            format!("unsupported linear linalg fragment operation {other:?}"),
1256        )),
1257    }
1258}
1259
1260fn transpose_reduce_sum(
1261    context: &SemanticLinearFragmentTransposeContext<'_>,
1262    operation: &SemanticLinearFragmentOperation,
1263    cotangent: ProgramValue,
1264    axes: &[usize],
1265    active_mask: &[bool],
1266    builder: &mut SemanticProgramBuilder,
1267) -> Result<Vec<Option<ProgramValue>>, SemanticAdError> {
1268    if operation.inputs.len() != 1 || active_mask.len() != 1 {
1269        return Err(semantic_internal(
1270            context.role,
1271            "linear reduce_sum fragment has malformed arity",
1272        ));
1273    }
1274    if !active_mask[0] {
1275        return Ok(vec![None]);
1276    }
1277    let mut cache = HashMap::new();
1278    let input_shape = semantic_linear_fragment_value_shape(
1279        context.fragment,
1280        &operation.inputs[0],
1281        context.external_values,
1282        context.shape_sources,
1283        builder,
1284        context.role,
1285        &mut cache,
1286    )?
1287    .ok_or_else(|| {
1288        semantic_internal(
1289            context.role,
1290            "linear reduce_sum fragment is missing input shape metadata",
1291        )
1292    })?;
1293    if axes.iter().any(|axis| *axis >= input_shape.len()) {
1294        return Err(semantic_internal(
1295            context.role,
1296            format!(
1297                "linear reduce_sum axis is out of bounds for input rank {}",
1298                input_shape.len()
1299            ),
1300        ));
1301    }
1302    let dims: Vec<_> = (0..input_shape.len())
1303        .filter(|axis| !axes.contains(axis))
1304        .collect();
1305    let mut inputs = vec![cotangent];
1306    let shape = localize_dims(
1307        &input_shape,
1308        &[cotangent],
1309        context.shape_sources,
1310        1,
1311        &mut inputs,
1312        builder,
1313        context.role,
1314    )?;
1315    Ok(vec![Some(
1316        builder.add_op(CoreSemanticOp::BroadcastInDim { shape, dims }, &inputs)?[0],
1317    )])
1318}
1319
1320fn semantic_linear_fragment_value_shape(
1321    fragment: &SemanticLinearFragmentBuilder,
1322    value: &SemanticLinearFragmentInput,
1323    external_values: &HashMap<ValueKey<StdTensorOp>, ProgramValue>,
1324    shape_sources: &[ProgramValue],
1325    builder: &SemanticProgramBuilder,
1326    role: SemanticAdRuleRole,
1327    cache: &mut HashMap<LocalValueId, Option<Vec<DimExpr>>>,
1328) -> Result<Option<Vec<DimExpr>>, SemanticAdError> {
1329    match value {
1330        SemanticLinearFragmentInput::External(key) => {
1331            let source = external_values.get(key).copied().ok_or_else(|| {
1332                semantic_internal(
1333                    role,
1334                    "semantic linalg linear-fragment shape references missing external value",
1335                )
1336            })?;
1337            source_shape(source, shape_sources, builder, role).map(Some)
1338        }
1339        SemanticLinearFragmentInput::Local(local) => semantic_linear_fragment_local_shape(
1340            fragment,
1341            *local,
1342            external_values,
1343            shape_sources,
1344            builder,
1345            role,
1346            cache,
1347        ),
1348    }
1349}
1350
1351fn semantic_linear_fragment_local_shape(
1352    fragment: &SemanticLinearFragmentBuilder,
1353    local: LocalValueId,
1354    external_values: &HashMap<ValueKey<StdTensorOp>, ProgramValue>,
1355    shape_sources: &[ProgramValue],
1356    builder: &SemanticProgramBuilder,
1357    role: SemanticAdRuleRole,
1358    cache: &mut HashMap<LocalValueId, Option<Vec<DimExpr>>>,
1359) -> Result<Option<Vec<DimExpr>>, SemanticAdError> {
1360    if let Some(cached) = cache.get(&local) {
1361        return Ok(cached.clone());
1362    }
1363    let shape = if local < fragment.seed_count {
1364        let source = shape_sources.get(local).copied().ok_or_else(|| {
1365            semantic_internal(
1366                role,
1367                format!("semantic linalg linear-fragment seed local {local} has no shape source"),
1368            )
1369        })?;
1370        Some(source_shape(source, shape_sources, builder, role)?)
1371    } else {
1372        let (operation, output_index) = fragment
1373            .operations
1374            .iter()
1375            .find_map(|operation| {
1376                operation
1377                    .outputs
1378                    .iter()
1379                    .position(|output| *output == local)
1380                    .map(|index| (operation, index))
1381            })
1382            .ok_or_else(|| {
1383                semantic_internal(
1384                    role,
1385                    format!(
1386                        "semantic linalg linear-fragment local {local} has no producing operation"
1387                    ),
1388                )
1389            })?;
1390        semantic_linear_fragment_operation_output_shape(
1391            fragment,
1392            operation,
1393            output_index,
1394            external_values,
1395            shape_sources,
1396            builder,
1397            role,
1398            cache,
1399        )?
1400    };
1401    cache.insert(local, shape.clone());
1402    Ok(shape)
1403}
1404
1405fn source_shape(
1406    source: ProgramValue,
1407    shape_sources: &[ProgramValue],
1408    builder: &SemanticProgramBuilder,
1409    role: SemanticAdRuleRole,
1410) -> Result<Vec<DimExpr>, SemanticAdError> {
1411    let index = shape_sources
1412        .iter()
1413        .position(|candidate| *candidate == source)
1414        .ok_or_else(|| {
1415            semantic_internal(role, "shape source is not part of the linalg invocation")
1416        })?;
1417    let rank = builder.value_metadata(source)?.shape().len();
1418    Ok(DimExpr::input_shape(index, rank))
1419}
1420
1421#[allow(clippy::too_many_arguments)]
1422fn semantic_linear_fragment_operation_output_shape(
1423    fragment: &SemanticLinearFragmentBuilder,
1424    operation: &SemanticLinearFragmentOperation,
1425    output_index: usize,
1426    external_values: &HashMap<ValueKey<StdTensorOp>, ProgramValue>,
1427    shape_sources: &[ProgramValue],
1428    builder: &SemanticProgramBuilder,
1429    role: SemanticAdRuleRole,
1430    cache: &mut HashMap<LocalValueId, Option<Vec<DimExpr>>>,
1431) -> Result<Option<Vec<DimExpr>>, SemanticAdError> {
1432    let input_shape = |input_index: usize,
1433                       cache: &mut HashMap<LocalValueId, Option<Vec<DimExpr>>>|
1434     -> Result<Option<Vec<DimExpr>>, SemanticAdError> {
1435        let input = operation.inputs.get(input_index).ok_or_else(|| {
1436            semantic_internal(
1437                role,
1438                "semantic linalg linear-fragment shape requested missing operation input",
1439            )
1440        })?;
1441        semantic_linear_fragment_value_shape(
1442            fragment,
1443            input,
1444            external_values,
1445            shape_sources,
1446            builder,
1447            role,
1448            cache,
1449        )
1450    };
1451    match &operation.operation {
1452        SemanticLinearFragmentOp::Extension(extension) => {
1453            let linalg = semantic_linalg_op(extension.as_ref(), role)?;
1454            match linalg.op() {
1455                LinalgOp::LuFactor => match output_index {
1456                    0 => input_shape(0, cache),
1457                    1 => Ok(input_shape(0, cache)?.map(|shape| {
1458                        let (rows, cols, batch) =
1459                            semantic_linear_fragment_matrix_shape_parts(&shape);
1460                        let mut pivots_shape =
1461                            vec![DimExpr::Min(Box::new(rows.clone()), Box::new(cols.clone()))];
1462                        pivots_shape.extend_from_slice(batch);
1463                        pivots_shape
1464                    })),
1465                    2 => Ok(input_shape(0, cache)?.map(|shape| shape[2..].to_vec())),
1466                    _ => Ok(None),
1467                },
1468                LinalgOp::LuSolvePrepared { .. } => input_shape(3, cache),
1469                _ => Ok(None),
1470            }
1471        }
1472        SemanticLinearFragmentOp::Core(CoreSemanticOp::ExtractDiag { axis_a, axis_b }) => {
1473            Ok(input_shape(0, cache)?
1474                .map(|shape| extract_diag_shape(&shape, *axis_a, *axis_b))
1475                .transpose()?)
1476        }
1477        SemanticLinearFragmentOp::Core(CoreSemanticOp::ReduceSum { axes }) => {
1478            Ok(input_shape(0, cache)?.map(|shape| {
1479                shape
1480                    .into_iter()
1481                    .enumerate()
1482                    .filter_map(|(axis, dim)| (!axes.contains(&axis)).then_some(dim))
1483                    .collect()
1484            }))
1485        }
1486        SemanticLinearFragmentOp::Core(
1487            CoreSemanticOp::Convert { .. }
1488            | CoreSemanticOp::Neg
1489            | CoreSemanticOp::Conj
1490            | CoreSemanticOp::Tril { .. }
1491            | CoreSemanticOp::Triu { .. },
1492        ) => input_shape(0, cache),
1493        SemanticLinearFragmentOp::Core(CoreSemanticOp::Transpose { perm }) => {
1494            Ok(input_shape(0, cache)?
1495                .map(|shape| perm.iter().map(|axis| shape[*axis].clone()).collect()))
1496        }
1497        _ => Ok(None),
1498    }
1499}
1500
1501fn semantic_linear_fragment_matrix_shape_parts(
1502    shape: &[DimExpr],
1503) -> (&DimExpr, &DimExpr, &[DimExpr]) {
1504    (&shape[0], &shape[1], &shape[2..])
1505}
1506
1507fn extract_diag_shape(
1508    shape: &[DimExpr],
1509    axis_a: usize,
1510    axis_b: usize,
1511) -> Result<Vec<DimExpr>, SemanticAdError> {
1512    if axis_a >= shape.len() || axis_b >= shape.len() || axis_a == axis_b {
1513        return Err(semantic_internal(
1514            SemanticAdRuleRole::LinearTranspose,
1515            "extract_diag shape derivation received invalid axes",
1516        ));
1517    }
1518    let diagonal = DimExpr::Min(
1519        Box::new(shape[axis_a].clone()),
1520        Box::new(shape[axis_b].clone()),
1521    );
1522    let mut output = Vec::with_capacity(shape.len() - 1);
1523    for (axis, dim) in shape.iter().enumerate() {
1524        if axis == axis_b {
1525            continue;
1526        }
1527        if axis == axis_a {
1528            output.push(diagonal.clone());
1529        } else {
1530            output.push(dim.clone());
1531        }
1532    }
1533    Ok(output)
1534}
1535
1536fn transpose_mul(
1537    cotangent: ProgramValue,
1538    active_mask: &[bool],
1539    fixed: &impl Fn(usize) -> Option<ProgramValue>,
1540    builder: &mut SemanticProgramBuilder,
1541    role: SemanticAdRuleRole,
1542) -> Result<Vec<Option<ProgramValue>>, SemanticAdError> {
1543    let mut result = vec![None; 2];
1544    for input in 0..2 {
1545        if !active_mask[input] {
1546            continue;
1547        }
1548        let coefficient = fixed(1 - input).ok_or_else(|| {
1549            semantic_internal(role, "linear multiply is missing its fixed coefficient")
1550        })?;
1551        let coefficient = conjugate_if_complex(builder, coefficient)?;
1552        result[input] = Some(builder.add_op(CoreSemanticOp::Mul, &[cotangent, coefficient])?[0]);
1553    }
1554    Ok(result)
1555}
1556
1557fn transpose_div(
1558    cotangent: ProgramValue,
1559    active_mask: &[bool],
1560    fixed: &impl Fn(usize) -> Option<ProgramValue>,
1561    builder: &mut SemanticProgramBuilder,
1562    role: SemanticAdRuleRole,
1563) -> Result<Vec<Option<ProgramValue>>, SemanticAdError> {
1564    let mut result = vec![None; 2];
1565    if active_mask[0] {
1566        let denominator = fixed(1).ok_or_else(|| {
1567            semantic_internal(role, "linear divide is missing its fixed denominator")
1568        })?;
1569        let denominator = conjugate_if_complex(builder, denominator)?;
1570        result[0] = Some(builder.add_op(CoreSemanticOp::Div, &[cotangent, denominator])?[0]);
1571    }
1572    if active_mask[1] {
1573        let numerator = fixed(0).ok_or_else(|| {
1574            semantic_internal(role, "linear divide is missing its fixed numerator")
1575        })?;
1576        let denominator = fixed(1).ok_or_else(|| {
1577            semantic_internal(role, "linear divide is missing its fixed denominator")
1578        })?;
1579        let square = builder.add_op(CoreSemanticOp::Mul, &[denominator, denominator])?[0];
1580        let coefficient = builder.add_op(CoreSemanticOp::Div, &[numerator, square])?[0];
1581        let coefficient = conjugate_if_complex(builder, coefficient)?;
1582        let value = builder.add_op(CoreSemanticOp::Mul, &[cotangent, coefficient])?[0];
1583        result[1] = Some(builder.add_op(CoreSemanticOp::Neg, &[value])?[0]);
1584    }
1585    Ok(result)
1586}
1587
1588fn transpose_matrix_dot(
1589    cotangent: ProgramValue,
1590    config: &tenferro_tensor::DotGeneralConfig,
1591    active_mask: &[bool],
1592    fixed: &impl Fn(usize) -> Option<ProgramValue>,
1593    builder: &mut SemanticProgramBuilder,
1594    role: SemanticAdRuleRole,
1595) -> Result<Vec<Option<ProgramValue>>, SemanticAdError> {
1596    let rank = 2 + config.lhs_batch_dims.len();
1597    let expected_batch: Vec<_> = (2..rank).collect();
1598    if config.lhs_contracting_dims != [1]
1599        || config.rhs_contracting_dims != [0]
1600        || config.lhs_batch_dims != expected_batch
1601        || config.rhs_batch_dims != expected_batch
1602    {
1603        return Err(semantic_internal(
1604            role,
1605            "linalg AD emitted an unsupported dot-general configuration",
1606        ));
1607    }
1608    let mut result = vec![None; 2];
1609    if active_mask[0] {
1610        let rhs = fixed(1).ok_or_else(|| {
1611            semantic_internal(role, "linear matrix product is missing its fixed rhs")
1612        })?;
1613        let rhs_h = matrix_adjoint(builder, rhs, rank)?;
1614        result[0] = Some(
1615            builder.add_op(
1616                CoreSemanticOp::DotGeneral {
1617                    config: config.clone(),
1618                },
1619                &[cotangent, rhs_h],
1620            )?[0],
1621        );
1622    }
1623    if active_mask[1] {
1624        let lhs = fixed(0).ok_or_else(|| {
1625            semantic_internal(role, "linear matrix product is missing its fixed lhs")
1626        })?;
1627        let lhs_h = matrix_adjoint(builder, lhs, rank)?;
1628        result[1] = Some(
1629            builder.add_op(
1630                CoreSemanticOp::DotGeneral {
1631                    config: config.clone(),
1632                },
1633                &[lhs_h, cotangent],
1634            )?[0],
1635        );
1636    }
1637    Ok(result)
1638}
1639
1640fn semantic_solve_matrix_cotangent(
1641    builder: &mut SemanticProgramBuilder,
1642    rhs_cotangent: ProgramValue,
1643    solution: ProgramValue,
1644    left_side: bool,
1645    transpose_a: bool,
1646    rank: usize,
1647) -> Result<ProgramValue, SemanticAdError> {
1648    let negative_rhs_cotangent = builder.add_op(CoreSemanticOp::Neg, &[rhs_cotangent])?[0];
1649    let solution_h = matrix_adjoint(builder, solution, rank)?;
1650    let config = semantic_matrix_multiply_config(rank)?;
1651    let matrix_cotangent = if left_side {
1652        builder.add_op(
1653            CoreSemanticOp::DotGeneral {
1654                config: config.clone(),
1655            },
1656            &[negative_rhs_cotangent, solution_h],
1657        )?[0]
1658    } else {
1659        builder.add_op(
1660            CoreSemanticOp::DotGeneral { config },
1661            &[solution_h, negative_rhs_cotangent],
1662        )?[0]
1663    };
1664    if transpose_a {
1665        semantic_matrix_transpose(builder, matrix_cotangent, rank)
1666    } else {
1667        Ok(matrix_cotangent)
1668    }
1669}
1670
1671fn semantic_matrix_multiply_config(
1672    rank: usize,
1673) -> Result<tenferro_tensor::DotGeneralConfig, SemanticAdError> {
1674    if rank < 2 {
1675        return Err(semantic_internal(
1676            SemanticAdRuleRole::LinearTranspose,
1677            "matrix multiply semantic helper expects rank >= 2",
1678        ));
1679    }
1680    let batch_dims: Vec<usize> = (2..rank).collect();
1681    Ok(tenferro_tensor::DotGeneralConfig {
1682        lhs_contracting_dims: vec![1],
1683        rhs_contracting_dims: vec![0],
1684        lhs_batch_dims: batch_dims.clone(),
1685        rhs_batch_dims: batch_dims,
1686    })
1687}
1688
1689fn semantic_matrix_transpose(
1690    builder: &mut SemanticProgramBuilder,
1691    value: ProgramValue,
1692    rank: usize,
1693) -> Result<ProgramValue, SemanticAdError> {
1694    if rank < 2 {
1695        return Err(semantic_internal(
1696            SemanticAdRuleRole::LinearTranspose,
1697            "matrix transpose semantic helper expects rank >= 2",
1698        ));
1699    }
1700    let mut perm: Vec<_> = (0..rank).collect();
1701    perm.swap(0, 1);
1702    Ok(builder.add_op(CoreSemanticOp::Transpose { perm }, &[value])?[0])
1703}
1704
1705fn matrix_adjoint(
1706    builder: &mut SemanticProgramBuilder,
1707    value: ProgramValue,
1708    rank: usize,
1709) -> Result<ProgramValue, SemanticAdError> {
1710    let value = conjugate_if_complex(builder, value)?;
1711    let mut perm: Vec<_> = (0..rank).collect();
1712    perm.swap(0, 1);
1713    Ok(builder.add_op(CoreSemanticOp::Transpose { perm }, &[value])?[0])
1714}
1715
1716fn conjugate_if_complex(
1717    builder: &mut SemanticProgramBuilder,
1718    value: ProgramValue,
1719) -> Result<ProgramValue, SemanticAdError> {
1720    if matches!(
1721        builder.value_metadata(value)?.dtype(),
1722        tenferro_tensor::DType::C32 | tenferro_tensor::DType::C64
1723    ) {
1724        Ok(builder.add_op(CoreSemanticOp::Conj, &[value])?[0])
1725    } else {
1726        Ok(value)
1727    }
1728}
1729
1730fn transpose_linalg_extension(
1731    extension: &dyn tenferro_ad::extension::ExtensionOp,
1732    operation: &SemanticLinearFragmentOperation,
1733    cotangent: ProgramValue,
1734    active_mask: &[bool],
1735    external_values: &HashMap<ValueKey<StdTensorOp>, ProgramValue>,
1736    fixed_locals: &[Option<ProgramValue>],
1737    builder: &mut SemanticProgramBuilder,
1738) -> Result<Vec<Option<ProgramValue>>, SemanticAdError> {
1739    let role = SemanticAdRuleRole::LinearTranspose;
1740    let linalg = semantic_linalg_op(extension, role)?;
1741    if !matches!(
1742        linalg.op(),
1743        LinalgOp::TriangularSolve { .. }
1744            | LinalgOp::LuSolvePrepared { .. }
1745            | LinalgOp::FullPivLuSolve { .. }
1746            | LinalgOp::Solve
1747            | LinalgOp::HouseholderQrAppendTangent
1748    ) {
1749        return Err(semantic_internal(
1750            role,
1751            format!(
1752                "linear linalg fragment contains unsupported extension {:?}",
1753                linalg.op()
1754            ),
1755        ));
1756    }
1757    let mut context = ShapeGuardContext::default();
1758    let mut fixed_values = HashMap::new();
1759    let mut keys = Vec::with_capacity(operation.inputs.len());
1760    let mut shape_sources = Vec::with_capacity(operation.inputs.len());
1761    for (index, (input, active)) in operation.inputs.iter().zip(active_mask).enumerate() {
1762        let key = ValueKey::Input(TensorInputKey::User {
1763            id: 10_000 + u64::try_from(index).expect("small linalg extension arity"),
1764        });
1765        let value = if *active {
1766            cotangent
1767        } else {
1768            match input {
1769                SemanticLinearFragmentInput::External(key) => external_values.get(key).copied(),
1770                SemanticLinearFragmentInput::Local(local) => {
1771                    fixed_locals.get(*local).copied().flatten()
1772                }
1773            }
1774            .ok_or_else(|| {
1775                semantic_internal(role, "linear solve fragment is missing a fixed operand")
1776            })?
1777        };
1778        let metadata = builder.value_metadata(value)?.clone();
1779        let symbolic_shapes = synthetic_input_shapes(std::slice::from_ref(&metadata));
1780        let symbolic_shape_refs: Vec<_> = symbolic_shapes.iter().map(Vec::as_slice).collect();
1781        context.insert_metadata(
1782            key.clone(),
1783            legacy_metadata(&metadata, &symbolic_shape_refs),
1784        );
1785        if !active {
1786            fixed_values.insert(key.clone(), value);
1787        }
1788        shape_sources.push(value);
1789        keys.push(key);
1790    }
1791    let inputs: Vec<_> = keys
1792        .iter()
1793        .cloned()
1794        .map(PrimitiveTransposeInput::Residual)
1795        .collect();
1796    let mut emitted = SemanticRuleBuilder::with_seeds(
1797        &[Some(cotangent)],
1798        &fixed_values,
1799        &shape_sources,
1800        builder,
1801        role,
1802    );
1803    let outputs = LinalgAdRule
1804        .linear_transpose(
1805            extension,
1806            &mut emitted,
1807            &[Some(0)],
1808            &inputs,
1809            active_mask,
1810            &mut context,
1811        )
1812        .map_err(|error| legacy_error(role, error))?;
1813    let locals = emitted.finish()?;
1814    Ok(outputs
1815        .into_iter()
1816        .map(|output| output.and_then(|local| locals.get(local).copied().flatten()))
1817        .collect())
1818}
1819
1820fn accumulate_local_cotangent(
1821    builder: &mut SemanticProgramBuilder,
1822    cotangents: &mut HashMap<LocalValueId, ProgramValue>,
1823    local: LocalValueId,
1824    cotangent: ProgramValue,
1825) -> Result<(), SemanticAdError> {
1826    if let Some(existing) = cotangents.get_mut(&local) {
1827        *existing = builder.add_op(CoreSemanticOp::Add, &[*existing, cotangent])?[0];
1828    } else {
1829        cotangents.insert(local, cotangent);
1830    }
1831    Ok(())
1832}
1833
1834fn semantic_linalg_op(
1835    op: &dyn tenferro_ad::extension::ExtensionOp,
1836    role: SemanticAdRuleRole,
1837) -> Result<&LinalgExtensionOp, SemanticAdError> {
1838    op.as_any()
1839        .downcast_ref::<LinalgExtensionOp>()
1840        .ok_or_else(|| SemanticAdError::Unsupported {
1841            family_id: LINALG_EXTENSION_FAMILY_ID,
1842            role,
1843            message: "linalg semantic AD received an incompatible payload".into(),
1844        })
1845}
1846
1847fn legacy_error(role: SemanticAdRuleRole, error: tenferro_ops::ad::ADRuleError) -> SemanticAdError {
1848    SemanticAdError::Rule {
1849        family_id: LINALG_EXTENSION_FAMILY_ID,
1850        role,
1851        source: Box::new(error),
1852    }
1853}
1854
1855fn semantic_internal(role: SemanticAdRuleRole, message: impl Into<String>) -> SemanticAdError {
1856    SemanticAdError::Invariant {
1857        family_id: LINALG_EXTENSION_FAMILY_ID,
1858        role,
1859        message: message.into(),
1860    }
1861}
1862
1863#[cfg(test)]
1864mod tests {
1865    use super::*;
1866    use tenferro_runtime::program::ProgramInputSpec;
1867    use tenferro_tensor::DType;
1868
1869    #[test]
1870    fn recorded_broadcast_prefers_primal_shape_source_over_rank_compatible_data_input() {
1871        let mut builder = SemanticProgramBuilder::new();
1872        let _row_anchor = builder
1873            .input(ProgramInputSpec::new(DType::F64, [DimExpr::Const(3)]))
1874            .unwrap();
1875        let _col_anchor = builder
1876            .input(ProgramInputSpec::new(DType::F64, [DimExpr::Const(2)]))
1877            .unwrap();
1878        let matrix = builder
1879            .input(ProgramInputSpec::new(
1880                DType::F64,
1881                [
1882                    DimExpr::InputDim {
1883                        input_idx: 0,
1884                        axis: 0,
1885                    },
1886                    DimExpr::InputDim {
1887                        input_idx: 1,
1888                        axis: 0,
1889                    },
1890                ],
1891            ))
1892            .unwrap();
1893        let vector = builder
1894            .input(ProgramInputSpec::new(
1895                DType::F64,
1896                [DimExpr::Min(
1897                    Box::new(DimExpr::InputDim {
1898                        input_idx: 0,
1899                        axis: 0,
1900                    }),
1901                    Box::new(DimExpr::InputDim {
1902                        input_idx: 1,
1903                        axis: 0,
1904                    }),
1905                )],
1906            ))
1907            .unwrap();
1908
1909        let (operation, inputs) = localize_shape_expressions(
1910            CoreSemanticOp::BroadcastInDim {
1911                shape: vec![
1912                    DimExpr::InputDim {
1913                        input_idx: 0,
1914                        axis: 0,
1915                    },
1916                    DimExpr::InputDim {
1917                        input_idx: 0,
1918                        axis: 1,
1919                    },
1920                ],
1921                dims: vec![1],
1922            },
1923            &[vector],
1924            &[matrix],
1925            &builder,
1926            SemanticAdRuleRole::Linearize,
1927        )
1928        .unwrap();
1929
1930        assert_eq!(inputs, vec![vector, matrix]);
1931        assert_eq!(
1932            operation,
1933            CoreSemanticOp::BroadcastInDim {
1934                shape: vec![
1935                    DimExpr::InputDim {
1936                        input_idx: 1,
1937                        axis: 0,
1938                    },
1939                    DimExpr::InputDim {
1940                        input_idx: 1,
1941                        axis: 1,
1942                    },
1943                ],
1944                dims: vec![1],
1945            }
1946        );
1947    }
1948
1949    #[test]
1950    fn linalg_residual_mask_declares_all_inputs_and_outputs() {
1951        // The linalg family is one rule across solve/eigen/qr ops. SVD reads
1952        // outputs 0-2 and eigh/qr read outputs 0-1 as tensor residuals, so the
1953        // mask must declare every input and output (issue #1665 step 5).
1954        let mask = LinalgAdRule.residual_mask();
1955        assert!(mask.declares_input(0));
1956        assert!(mask.declares_input(1));
1957        assert!(mask.declares_output(0));
1958        assert!(mask.declares_output(1));
1959        assert!(mask.declares_output(2));
1960    }
1961}