1use std::sync::Arc;
25
26use computegraph::types::{LocalValueId, OperationRole, ValueKey, ValueRef};
27use tenferro_ad::extension::{
28 ExtensionLinearTransposeRule, ExtensionLinearizeRule, ExtensionOp, ExtensionRegistryError,
29 ExtensionRuleSet,
30};
31use tenferro_ops::ad::PrimitiveRuleBuilder;
32use tenferro_ops::std_tensor_op::StdTensorOp;
33use tenferro_ops::ShapeGuardContext;
34use tidu::{ADRuleError, ADRuleKind, ADRuleResult, PrimitiveTransposeInput};
35
36use crate::extension::{LinalgExtensionOp, LinalgOp};
37use crate::LINALG_EXTENSION_FAMILY_ID;
38
39mod rules;
40pub mod support;
41
42pub fn ad_rules() -> Result<ExtensionRuleSet, ExtensionRegistryError> {
52 ExtensionRuleSet::new()
53 .with_linearize(Arc::new(LinalgAdRule))?
54 .with_linear_transpose(Arc::new(LinalgAdRule))
55}
56
57#[derive(Debug)]
58struct LinalgAdRule;
59
60impl ExtensionLinearizeRule for LinalgAdRule {
61 fn family_id(&self) -> &'static str {
62 LINALG_EXTENSION_FAMILY_ID
63 }
64
65 fn linearize(
66 &self,
67 op: &dyn ExtensionOp,
68 builder: &mut dyn PrimitiveRuleBuilder,
69 primal_in: &[ValueKey<StdTensorOp>],
70 primal_out: &[ValueKey<StdTensorOp>],
71 tangent_in: &[Option<LocalValueId>],
72 ctx: &mut ShapeGuardContext,
73 ) -> ADRuleResult<Vec<Option<LocalValueId>>> {
74 let op = downcast_ad_op(op, ADRuleKind::Jvp)?;
75 match op.op() {
76 LinalgOp::Lu => rules::linearize_lu(builder, primal_in, primal_out, tangent_in, ctx),
77 LinalgOp::LuFactor => Ok(vec![None; op.output_count()]),
78 LinalgOp::LuSolvePrepared {
79 transpose_a,
80 conjugate_a,
81 } => rules::linearize_lu_solve_prepared(
82 builder,
83 primal_in,
84 primal_out,
85 tangent_in,
86 transpose_a,
87 conjugate_a,
88 ctx,
89 ),
90 LinalgOp::FullPivLu => {
91 rules::linearize_full_piv_lu(builder, primal_in, primal_out, tangent_in, ctx)
92 }
93 LinalgOp::FullPivLuSolve { transpose_a } => rules::linearize_full_piv_lu_solve(
94 builder,
95 primal_in,
96 primal_out,
97 tangent_in,
98 transpose_a,
99 ctx,
100 ),
101 LinalgOp::TriangularSolve {
102 left_side,
103 lower,
104 transpose_a,
105 unit_diagonal,
106 } => rules::linearize_triangular_solve(
107 builder,
108 primal_in,
109 primal_out,
110 tangent_in,
111 rules::TriangularSolveFlags::new(left_side, lower, transpose_a, unit_diagonal),
112 ctx,
113 ),
114 LinalgOp::Cholesky => {
115 rules::linearize_cholesky(builder, primal_in, primal_out, tangent_in, ctx)
116 }
117 LinalgOp::Svd { derivative_eps, .. } => rules::linearize_svd(
118 builder,
119 primal_in,
120 primal_out,
121 tangent_in,
122 derivative_eps,
123 ctx,
124 ),
125 LinalgOp::SvdVals { derivative_eps } => {
126 rules::linearize_svd_values(builder, primal_in, tangent_in, derivative_eps, ctx)
127 }
128 LinalgOp::Qr { .. } => {
129 rules::linearize_qr(builder, primal_in, primal_out, tangent_in, ctx)
130 }
131 LinalgOp::Eigh { derivative_eps, .. } => rules::linearize_eigh(
132 builder,
133 primal_in,
134 primal_out,
135 tangent_in,
136 derivative_eps,
137 ctx,
138 ),
139 LinalgOp::EighVals { derivative_eps } => {
140 rules::linearize_eigh_values(builder, primal_in, tangent_in, derivative_eps, ctx)
141 }
142 LinalgOp::Eig { input_dtype } => {
143 rules::linearize_eig(builder, primal_in, primal_out, tangent_in, input_dtype, ctx)
144 }
145 LinalgOp::EigVals { input_dtype } => {
146 rules::linearize_eig_values(builder, primal_in, tangent_in, input_dtype, ctx)
147 }
148 }
149 }
150}
151
152impl ExtensionLinearTransposeRule for LinalgAdRule {
153 fn family_id(&self) -> &'static str {
154 LINALG_EXTENSION_FAMILY_ID
155 }
156
157 fn linear_transpose(
158 &self,
159 op: &dyn ExtensionOp,
160 builder: &mut dyn PrimitiveRuleBuilder,
161 cotangent_out: &[Option<LocalValueId>],
162 inputs: &[PrimitiveTransposeInput<StdTensorOp>],
163 active_mask: &[bool],
164 ctx: &mut ShapeGuardContext,
165 ) -> ADRuleResult<Vec<Option<LocalValueId>>> {
166 let op = downcast_ad_op(op, ADRuleKind::Transpose)?;
167 let mut builder = DynBuilder(builder);
168 let mode = OperationRole::Linearized {
169 active_mask: active_mask.to_vec(),
170 };
171 match op.op() {
172 LinalgOp::TriangularSolve {
173 left_side,
174 lower,
175 transpose_a,
176 unit_diagonal,
177 } => {
178 let value_inputs =
179 linear_solve_transpose_inputs("triangular_solve", inputs, active_mask)?;
180 rules::transpose_triangular_solve(
181 &mut builder,
182 cotangent_out,
183 &value_inputs,
184 &mode,
185 rules::TriangularSolveFlags::new(left_side, lower, transpose_a, unit_diagonal),
186 ctx,
187 )
188 }
189 LinalgOp::LuSolvePrepared {
190 transpose_a,
191 conjugate_a,
192 } => {
193 let value_inputs = lu_solve_prepared_transpose_inputs(inputs, active_mask)?;
194 rules::transpose_lu_solve_prepared(
195 &mut builder,
196 cotangent_out,
197 &value_inputs,
198 &mode,
199 transpose_a,
200 conjugate_a,
201 ctx,
202 )
203 }
204 LinalgOp::FullPivLuSolve { transpose_a } => {
205 let value_inputs =
206 linear_solve_transpose_inputs("full_piv_lu_solve", inputs, active_mask)?;
207 rules::transpose_full_piv_lu_solve(
208 &mut builder,
209 cotangent_out,
210 &value_inputs,
211 &mode,
212 transpose_a,
213 ctx,
214 )
215 }
216 LinalgOp::Cholesky
217 | LinalgOp::Lu
218 | LinalgOp::LuFactor
219 | LinalgOp::FullPivLu
220 | LinalgOp::Svd { .. }
221 | LinalgOp::SvdVals { .. }
222 | LinalgOp::Qr { .. }
223 | LinalgOp::Eigh { .. }
224 | LinalgOp::EighVals { .. }
225 | LinalgOp::Eig { .. }
226 | LinalgOp::EigVals { .. } => Ok(vec![None; op.input_count()]),
227 }
228 }
229}
230
231struct DynBuilder<'a>(&'a mut dyn PrimitiveRuleBuilder);
232
233impl PrimitiveRuleBuilder for DynBuilder<'_> {
234 fn add_operation(
235 &mut self,
236 op: StdTensorOp,
237 inputs: Vec<ValueRef<StdTensorOp>>,
238 mode: OperationRole,
239 ) -> Vec<LocalValueId> {
240 self.0.add_operation(op, inputs, mode)
241 }
242}
243
244fn downcast_ad_op(op: &dyn ExtensionOp, kind: ADRuleKind) -> ADRuleResult<&LinalgExtensionOp> {
245 op.as_any()
246 .downcast_ref::<LinalgExtensionOp>()
247 .ok_or_else(|| {
248 ADRuleError::invalid_input("tenferro-linalg.linalg.v1", kind, "payload type mismatch")
249 })
250}
251
252fn linear_solve_transpose_inputs(
253 op: &str,
254 inputs: &[PrimitiveTransposeInput<StdTensorOp>],
255 active_mask: &[bool],
256) -> ADRuleResult<Vec<ValueRef<StdTensorOp>>> {
257 let matrix_active = active_mask.first().copied().unwrap_or(false);
258 inputs
259 .iter()
260 .enumerate()
261 .map(|(index, input)| {
262 if index == 0 || matrix_active {
263 fixed_transpose_value(op, index, input)
264 } else {
265 Ok(metadata_transpose_value(input))
266 }
267 })
268 .collect()
269}
270
271fn lu_solve_prepared_transpose_inputs(
272 inputs: &[PrimitiveTransposeInput<StdTensorOp>],
273 active_mask: &[bool],
274) -> ADRuleResult<Vec<ValueRef<StdTensorOp>>> {
275 let matrix_active = active_mask.first().copied().unwrap_or(false);
276 inputs
277 .iter()
278 .enumerate()
279 .map(|(index, input)| {
280 if index <= 2 || matrix_active {
281 fixed_transpose_value("lu_solve_prepared", index, input)
282 } else {
283 Ok(metadata_transpose_value(input))
284 }
285 })
286 .collect()
287}
288
289fn metadata_transpose_value(input: &PrimitiveTransposeInput<StdTensorOp>) -> ValueRef<StdTensorOp> {
290 ValueRef::External(input.key().clone())
291}
292
293fn fixed_transpose_value(
294 op: &str,
295 index: usize,
296 input: &PrimitiveTransposeInput<StdTensorOp>,
297) -> ADRuleResult<ValueRef<StdTensorOp>> {
298 match input {
299 PrimitiveTransposeInput::Residual(key) => Ok(ValueRef::External(key.clone())),
300 PrimitiveTransposeInput::Linear {
301 primal: Some(primal),
302 ..
303 } => Ok(ValueRef::External(primal.clone())),
304 PrimitiveTransposeInput::Linear { key, primal: None } => {
305 Err(ADRuleError::invalid_input(
306 op,
307 ADRuleKind::Transpose,
308 format!(
309 "transpose input {index} is linear-only and cannot be retained as a tensor operand: {key:?}"
310 ),
311 ))
312 }
313 }
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use crate::extension::{EighGauge, QrGauge, SvdGauge, DEFAULT_DECOMPOSITION_DERIVATIVE_EPS};
320 use computegraph::graph::GraphBuilder;
321 use std::collections::HashSet;
322 use tenferro_ops::input_key::TensorInputKey;
323 use tenferro_ops::{ShapeExtent, SymDim, TensorMeta};
324 use tenferro_tensor::DType;
325
326 fn input_key(id: u64) -> ValueKey<StdTensorOp> {
327 ValueKey::Input(TensorInputKey::User { id })
328 }
329
330 fn insert_meta(ctx: &mut ShapeGuardContext, key: ValueKey<StdTensorOp>, shape: &[usize]) {
331 ctx.insert_metadata(
332 key,
333 TensorMeta::exact(
334 DType::F64,
335 shape.iter().copied().map(SymDim::from).collect(),
336 ),
337 );
338 }
339
340 fn insert_typed_meta(
341 ctx: &mut ShapeGuardContext,
342 key: ValueKey<StdTensorOp>,
343 dtype: DType,
344 shape: &[usize],
345 ) {
346 ctx.insert_metadata(
347 key,
348 TensorMeta::exact(dtype, shape.iter().copied().map(SymDim::from).collect()),
349 );
350 }
351
352 fn eigh_context() -> (
353 ShapeGuardContext,
354 ValueKey<StdTensorOp>,
355 Vec<ValueKey<StdTensorOp>>,
356 ) {
357 let mut ctx = ShapeGuardContext::default();
358 let a = input_key(1);
359 let w = input_key(2);
360 let v = input_key(3);
361 insert_typed_meta(&mut ctx, a.clone(), DType::F64, &[2, 2]);
362 insert_typed_meta(&mut ctx, w.clone(), DType::F64, &[2]);
363 insert_typed_meta(&mut ctx, v.clone(), DType::F64, &[2, 2]);
364 (ctx, a, vec![w, v])
365 }
366
367 fn eig_context() -> (
368 ShapeGuardContext,
369 ValueKey<StdTensorOp>,
370 Vec<ValueKey<StdTensorOp>>,
371 ) {
372 let mut ctx = ShapeGuardContext::default();
373 let a = input_key(114);
374 let w = input_key(115);
375 let v = input_key(116);
376 insert_typed_meta(&mut ctx, a.clone(), DType::F64, &[2, 2]);
377 insert_typed_meta(&mut ctx, w.clone(), DType::C64, &[2]);
378 insert_typed_meta(&mut ctx, v.clone(), DType::C64, &[2, 2]);
379 (ctx, a, vec![w, v])
380 }
381
382 fn lu_context(
383 shape: &[usize],
384 ) -> (
385 ShapeGuardContext,
386 ValueKey<StdTensorOp>,
387 Vec<ValueKey<StdTensorOp>>,
388 ) {
389 let mut ctx = ShapeGuardContext::default();
390 let a = input_key(4);
391 let p = input_key(5);
392 let l = input_key(6);
393 let u = input_key(7);
394 let parity = input_key(8);
395 let k = shape[0].min(shape[1]);
396 insert_typed_meta(&mut ctx, a.clone(), DType::F64, shape);
397 insert_typed_meta(&mut ctx, p.clone(), DType::F64, &[shape[0], shape[0]]);
398 insert_typed_meta(&mut ctx, l.clone(), DType::F64, &[shape[0], k]);
399 insert_typed_meta(&mut ctx, u.clone(), DType::F64, &[k, shape[1]]);
400 insert_typed_meta(&mut ctx, parity.clone(), DType::F64, &[]);
401 (ctx, a, vec![p, l, u, parity])
402 }
403
404 fn svd_context(
405 shape: &[usize],
406 ) -> (
407 ShapeGuardContext,
408 ValueKey<StdTensorOp>,
409 Vec<ValueKey<StdTensorOp>>,
410 ) {
411 let mut ctx = ShapeGuardContext::default();
412 let a = input_key(120);
413 let u = input_key(121);
414 let s = input_key(122);
415 let vt = input_key(123);
416 let k = shape[0].min(shape[1]);
417 insert_typed_meta(&mut ctx, a.clone(), DType::F64, shape);
418 insert_typed_meta(&mut ctx, u.clone(), DType::F64, &[shape[0], k]);
419 insert_typed_meta(&mut ctx, s.clone(), DType::F64, &[k]);
420 insert_typed_meta(&mut ctx, vt.clone(), DType::F64, &[k, shape[1]]);
421 (ctx, a, vec![u, s, vt])
422 }
423
424 fn qr_context(
425 shape: &[usize],
426 ) -> (
427 ShapeGuardContext,
428 ValueKey<StdTensorOp>,
429 Vec<ValueKey<StdTensorOp>>,
430 ) {
431 let mut ctx = ShapeGuardContext::default();
432 let a = input_key(9);
433 let q = input_key(10);
434 let r = input_key(11);
435 let k = shape[0].min(shape[1]);
436 insert_typed_meta(&mut ctx, a.clone(), DType::F64, shape);
437 insert_typed_meta(&mut ctx, q.clone(), DType::F64, &[shape[0], k]);
438 insert_typed_meta(&mut ctx, r.clone(), DType::F64, &[k, shape[1]]);
439 (ctx, a, vec![q, r])
440 }
441
442 fn with_active_values(
443 ctx: ShapeGuardContext,
444 values: impl IntoIterator<Item = ValueKey<StdTensorOp>>,
445 ) -> ShapeGuardContext {
446 ctx.with_linearize_active_values(Arc::new(values.into_iter().collect::<HashSet<_>>()))
447 }
448
449 #[test]
450 fn full_piv_lu_jvp_returns_inactive_outputs_for_non_square_input() {
451 let mut builder = GraphBuilder::<StdTensorOp>::new();
452 let mut ctx = ShapeGuardContext::default();
453 let primal = input_key(1);
454 insert_meta(&mut ctx, primal.clone(), &[2, 3]);
455 let tangent = builder.add_input(TensorInputKey::User { id: 2 });
456 let outputs = [
457 input_key(10),
458 input_key(11),
459 input_key(12),
460 input_key(13),
461 input_key(14),
462 ];
463 let op = LinalgExtensionOp::new(LinalgOp::FullPivLu);
464
465 let result = LinalgAdRule
466 .linearize(
467 &op,
468 &mut builder,
469 &[primal],
470 &outputs,
471 &[Some(tangent)],
472 &mut ctx,
473 )
474 .unwrap();
475
476 assert_eq!(result, vec![None, None, None, None, None]);
477 assert!(builder.build().operations().is_empty());
478 }
479
480 #[test]
481 fn lu_linearize_prunes_inactive_factor_outputs() {
482 let op = LinalgExtensionOp::new(LinalgOp::Lu);
483 for (case, active_slot, expected_active) in [
484 ("l only", 1_usize, vec![false, true, false, false]),
485 ("u only", 2_usize, vec![false, false, true, false]),
486 ] {
487 let (ctx, a, outputs) = lu_context(&[2, 2]);
488 let mut ctx = with_active_values(ctx, [outputs[active_slot].clone()]);
489 let mut builder = GraphBuilder::<StdTensorOp>::new();
490 let tangent = builder.add_input(TensorInputKey::User { id: 130 });
491
492 let result = LinalgAdRule
493 .linearize(
494 &op,
495 &mut builder,
496 &[a],
497 &outputs,
498 &[Some(tangent)],
499 &mut ctx,
500 )
501 .unwrap();
502
503 assert_eq!(
504 result.iter().map(Option::is_some).collect::<Vec<_>>(),
505 expected_active,
506 "{case}"
507 );
508 let pruned_count = builder.build().operations().len();
509
510 let (full_ctx, full_a, full_outputs) = lu_context(&[2, 2]);
511 let mut full_ctx =
512 with_active_values(full_ctx, [full_outputs[1].clone(), full_outputs[2].clone()]);
513 let mut full_builder = GraphBuilder::<StdTensorOp>::new();
514 let full_tangent = full_builder.add_input(TensorInputKey::User { id: 131 });
515 let full_result = LinalgAdRule
516 .linearize(
517 &op,
518 &mut full_builder,
519 &[full_a],
520 &full_outputs,
521 &[Some(full_tangent)],
522 &mut full_ctx,
523 )
524 .unwrap();
525
526 assert_eq!(
527 full_result.iter().map(Option::is_some).collect::<Vec<_>>(),
528 vec![false, true, true, false],
529 "{case}"
530 );
531 let full_count = full_builder.build().operations().len();
532 assert!(
533 pruned_count < full_count,
534 "{case} should not emit both LU factor tangent branches: {pruned_count} >= {full_count}"
535 );
536 }
537 }
538
539 #[test]
540 fn one_input_linalg_jvps_prune_when_all_outputs_are_inactive() {
541 let cases = [
542 (
543 LinalgOp::Lu,
544 lu_context(&[2, 2]),
545 vec![None, None, None, None],
546 ),
547 (
548 LinalgOp::Svd {
549 derivative_eps: DEFAULT_DECOMPOSITION_DERIVATIVE_EPS,
550 gauge: SvdGauge::Raw,
551 },
552 svd_context(&[2, 2]),
553 vec![None, None, None],
554 ),
555 (
556 LinalgOp::Eigh {
557 derivative_eps: DEFAULT_DECOMPOSITION_DERIVATIVE_EPS,
558 gauge: EighGauge::Raw,
559 },
560 eigh_context(),
561 vec![None, None],
562 ),
563 (
564 LinalgOp::Eig {
565 input_dtype: DType::F64,
566 },
567 eig_context(),
568 vec![None, None],
569 ),
570 (
571 LinalgOp::Qr {
572 gauge: QrGauge::Raw,
573 },
574 qr_context(&[3, 2]),
575 vec![None, None],
576 ),
577 ];
578
579 for (kind, (ctx, a, outputs), expected) in cases {
580 let mut ctx = with_active_values(ctx, []);
581 let mut builder = GraphBuilder::<StdTensorOp>::new();
582 let tangent = builder.add_input(TensorInputKey::User { id: 132 });
583 let op = LinalgExtensionOp::new(kind);
584
585 let result = LinalgAdRule
586 .linearize(
587 &op,
588 &mut builder,
589 &[a],
590 &outputs,
591 &[Some(tangent)],
592 &mut ctx,
593 )
594 .unwrap();
595
596 assert_eq!(result, expected, "{kind:?}");
597 assert!(
598 builder.build().operations().is_empty(),
599 "{kind:?} should not emit AD graph operations for inactive outputs"
600 );
601 }
602 }
603
604 #[test]
605 fn svd_linearize_prunes_inactive_vector_outputs() {
606 let (ctx, a, outputs) = svd_context(&[2, 2]);
607 let mut ctx = with_active_values(ctx, [outputs[1].clone()]);
608 let mut builder = GraphBuilder::<StdTensorOp>::new();
609 let tangent = builder.add_input(TensorInputKey::User { id: 133 });
610 let op = LinalgExtensionOp::new(LinalgOp::Svd {
611 derivative_eps: DEFAULT_DECOMPOSITION_DERIVATIVE_EPS,
612 gauge: SvdGauge::Raw,
613 });
614
615 let result = LinalgAdRule
616 .linearize(
617 &op,
618 &mut builder,
619 &[a],
620 &outputs,
621 &[Some(tangent)],
622 &mut ctx,
623 )
624 .unwrap();
625
626 assert_eq!(
627 result.iter().map(Option::is_some).collect::<Vec<_>>(),
628 vec![false, true, false]
629 );
630 assert!(
631 builder.build().operations().len() <= 5,
632 "singular-value-only SVD JVP should not emit the vector F-matrix chain"
633 );
634 }
635
636 #[test]
637 fn eigh_linearize_prunes_inactive_eigenvalue_output() {
638 let (ctx, a, outputs) = eigh_context();
639 let mut ctx = with_active_values(ctx, [outputs[1].clone()]);
640 let mut builder = GraphBuilder::<StdTensorOp>::new();
641 let tangent = builder.add_input(TensorInputKey::User { id: 134 });
642 let op = LinalgExtensionOp::new(LinalgOp::Eigh {
643 derivative_eps: DEFAULT_DECOMPOSITION_DERIVATIVE_EPS,
644 gauge: EighGauge::Raw,
645 });
646
647 let result = LinalgAdRule
648 .linearize(
649 &op,
650 &mut builder,
651 &[a],
652 &outputs,
653 &[Some(tangent)],
654 &mut ctx,
655 )
656 .unwrap();
657
658 assert_eq!(
659 result.iter().map(Option::is_some).collect::<Vec<_>>(),
660 vec![false, true]
661 );
662 }
663
664 #[test]
665 fn eig_linearize_prunes_unsupported_inactive_eigenvalue_output() {
666 let (ctx, a, outputs) = eig_context();
667 let mut ctx = with_active_values(ctx, [outputs[1].clone()]);
668 let mut builder = GraphBuilder::<StdTensorOp>::new();
669 let tangent = builder.add_input(TensorInputKey::User { id: 137 });
670 let op = LinalgExtensionOp::new(LinalgOp::Eig {
671 input_dtype: DType::F64,
672 });
673
674 let result = LinalgAdRule
675 .linearize(
676 &op,
677 &mut builder,
678 &[a],
679 &outputs,
680 &[Some(tangent)],
681 &mut ctx,
682 )
683 .unwrap();
684
685 assert_eq!(result, vec![None, None]);
686 assert!(
687 builder.build().operations().is_empty(),
688 "eigenvectors-only Eig JVP is unsupported and should not emit eigenvalue tangent work"
689 );
690 }
691
692 #[test]
693 fn qr_linearize_prunes_inactive_factor_outputs() {
694 let op = LinalgExtensionOp::new(LinalgOp::Qr {
695 gauge: QrGauge::Raw,
696 });
697 for (case, active_slot, expected_active) in [
698 ("q only", 0_usize, vec![true, false]),
699 ("r only", 1_usize, vec![false, true]),
700 ] {
701 let (ctx, a, outputs) = qr_context(&[3, 2]);
702 let mut ctx = with_active_values(ctx, [outputs[active_slot].clone()]);
703 let mut builder = GraphBuilder::<StdTensorOp>::new();
704 let tangent = builder.add_input(TensorInputKey::User { id: 135 });
705
706 let result = LinalgAdRule
707 .linearize(
708 &op,
709 &mut builder,
710 &[a],
711 &outputs,
712 &[Some(tangent)],
713 &mut ctx,
714 )
715 .unwrap();
716
717 assert_eq!(
718 result.iter().map(Option::is_some).collect::<Vec<_>>(),
719 expected_active,
720 "{case}"
721 );
722 let pruned_count = builder.build().operations().len();
723
724 let (full_ctx, full_a, full_outputs) = qr_context(&[3, 2]);
725 let mut full_ctx =
726 with_active_values(full_ctx, [full_outputs[0].clone(), full_outputs[1].clone()]);
727 let mut full_builder = GraphBuilder::<StdTensorOp>::new();
728 let full_tangent = full_builder.add_input(TensorInputKey::User { id: 136 });
729 let full_result = LinalgAdRule
730 .linearize(
731 &op,
732 &mut full_builder,
733 &[full_a],
734 &full_outputs,
735 &[Some(full_tangent)],
736 &mut full_ctx,
737 )
738 .unwrap();
739
740 assert_eq!(
741 full_result.iter().map(Option::is_some).collect::<Vec<_>>(),
742 vec![true, true],
743 "{case}"
744 );
745 let full_count = full_builder.build().operations().len();
746 assert!(
747 pruned_count < full_count,
748 "{case} should not emit both QR factor tangent branches: {pruned_count} >= {full_count}"
749 );
750 }
751 }
752
753 #[test]
754 fn triangular_solve_jvp_rejects_non_matrix_operands() {
755 let mut builder = GraphBuilder::<StdTensorOp>::new();
756 let mut ctx = ShapeGuardContext::default();
757 let lhs = input_key(20);
758 let rhs = input_key(21);
759 insert_meta(&mut ctx, lhs.clone(), &[2, 2]);
760 insert_meta(&mut ctx, rhs.clone(), &[2]);
761 let rhs_tangent = builder.add_input(TensorInputKey::User { id: 22 });
762 let op = LinalgExtensionOp::new(LinalgOp::TriangularSolve {
763 left_side: true,
764 lower: true,
765 transpose_a: false,
766 unit_diagonal: false,
767 });
768
769 let err = LinalgAdRule
770 .linearize(
771 &op,
772 &mut builder,
773 &[lhs, rhs],
774 &[input_key(23)],
775 &[None, Some(rhs_tangent)],
776 &mut ctx,
777 )
778 .unwrap_err();
779
780 assert_eq!(err.rule(), ADRuleKind::Jvp);
781 assert!(err
782 .to_string()
783 .contains("expected matrix operands with rank >= 2"));
784 assert!(builder.build().operations().is_empty());
785 }
786
787 #[test]
788 fn triangular_solve_jvp_accepts_upper_bound_matrix_metadata() {
789 let mut builder = GraphBuilder::<StdTensorOp>::new();
790 let mut ctx = ShapeGuardContext::default();
791 let lhs = input_key(30);
792 let rhs = input_key(31);
793 ctx.insert_metadata(
794 lhs.clone(),
795 TensorMeta::with_extents(
796 DType::F64,
797 vec![
798 ShapeExtent::upper_bound(SymDim::from(4usize)),
799 ShapeExtent::upper_bound(SymDim::from(4usize)),
800 ],
801 ),
802 );
803 ctx.insert_metadata(
804 rhs.clone(),
805 TensorMeta::with_extents(
806 DType::F64,
807 vec![
808 ShapeExtent::upper_bound(SymDim::from(4usize)),
809 ShapeExtent::upper_bound(SymDim::from(2usize)),
810 ],
811 ),
812 );
813 let rhs_tangent = builder.add_input(TensorInputKey::User { id: 32 });
814 let op = LinalgExtensionOp::new(LinalgOp::TriangularSolve {
815 left_side: true,
816 lower: true,
817 transpose_a: false,
818 unit_diagonal: false,
819 });
820
821 let result = LinalgAdRule
822 .linearize(
823 &op,
824 &mut builder,
825 &[lhs.clone(), rhs],
826 &[input_key(33)],
827 &[None, Some(rhs_tangent)],
828 &mut ctx,
829 )
830 .unwrap();
831
832 assert!(result[0].is_some());
833 let graph = builder.build();
834 assert_eq!(graph.operations().len(), 1);
835 let solve = &graph.operations()[0];
836 assert_eq!(solve.inputs[0], ValueRef::External(lhs));
837 assert_eq!(solve.inputs[1], ValueRef::Local(rhs_tangent));
838 }
839
840 #[test]
841 fn triangular_solve_transpose_accepts_upper_bound_matrix_metadata() {
842 let mut builder = GraphBuilder::<StdTensorOp>::new();
843 let mut ctx = ShapeGuardContext::default();
844 let lhs = input_key(40);
845 let rhs = input_key(41);
846 ctx.insert_metadata(
847 lhs.clone(),
848 TensorMeta::with_extents(
849 DType::F64,
850 vec![
851 ShapeExtent::upper_bound(SymDim::from(4usize)),
852 ShapeExtent::upper_bound(SymDim::from(4usize)),
853 ],
854 ),
855 );
856 ctx.insert_metadata(
857 rhs.clone(),
858 TensorMeta::with_extents(
859 DType::F64,
860 vec![
861 ShapeExtent::upper_bound(SymDim::from(4usize)),
862 ShapeExtent::upper_bound(SymDim::from(2usize)),
863 ],
864 ),
865 );
866 let cotangent = builder.add_input(TensorInputKey::User { id: 42 });
867 let op = LinalgExtensionOp::new(LinalgOp::TriangularSolve {
868 left_side: true,
869 lower: true,
870 transpose_a: false,
871 unit_diagonal: false,
872 });
873
874 let result = LinalgAdRule
875 .linear_transpose(
876 &op,
877 &mut builder,
878 &[Some(cotangent)],
879 &[
880 PrimitiveTransposeInput::Residual(lhs.clone()),
881 PrimitiveTransposeInput::Residual(rhs),
882 ],
883 &[false, true],
884 &mut ctx,
885 )
886 .unwrap();
887
888 assert_eq!(result[0], None);
889 assert!(result[1].is_some());
890 let graph = builder.build();
891 assert_eq!(graph.operations().len(), 1);
892 assert_eq!(graph.operations()[0].inputs[0], ValueRef::External(lhs));
893 assert_eq!(graph.operations()[0].inputs[1], ValueRef::Local(cotangent));
894 }
895
896 #[test]
897 fn eigh_values_has_no_handwritten_direct_transpose() {
898 let (mut ctx, a, _primal_outputs) = eigh_context();
899 let mut builder = GraphBuilder::<StdTensorOp>::new();
900 let cotangent = builder.add_input(TensorInputKey::User { id: 85 });
901 let op = LinalgExtensionOp::new(LinalgOp::EighVals {
902 derivative_eps: DEFAULT_DECOMPOSITION_DERIVATIVE_EPS,
903 });
904
905 let result = LinalgAdRule
906 .linear_transpose(
907 &op,
908 &mut builder,
909 &[Some(cotangent)],
910 &[PrimitiveTransposeInput::Residual(a)],
911 &[true],
912 &mut ctx,
913 )
914 .unwrap();
915
916 assert_eq!(result, vec![None]);
917 assert!(
918 builder.build().operations().is_empty(),
919 "EighVals reverse support should come from linearize + generic transpose"
920 );
921 }
922
923 #[test]
924 fn full_eigh_has_no_handwritten_direct_transpose() {
925 let (mut ctx, a, _primal_outputs) = eigh_context();
926 let mut builder = GraphBuilder::<StdTensorOp>::new();
927 let g_w = builder.add_input(TensorInputKey::User { id: 86 });
928 let g_v = builder.add_input(TensorInputKey::User { id: 87 });
929 let op = LinalgExtensionOp::new(LinalgOp::Eigh {
930 derivative_eps: DEFAULT_DECOMPOSITION_DERIVATIVE_EPS,
931 gauge: EighGauge::Raw,
932 });
933
934 let result = LinalgAdRule
935 .linear_transpose(
936 &op,
937 &mut builder,
938 &[Some(g_w), Some(g_v)],
939 &[PrimitiveTransposeInput::Residual(a)],
940 &[true],
941 &mut ctx,
942 )
943 .unwrap();
944
945 assert_eq!(result, vec![None]);
946 assert!(
947 builder.build().operations().is_empty(),
948 "Eigh reverse support should come from linearize + generic transpose"
949 );
950 }
951
952 #[test]
953 fn qr_has_no_handwritten_direct_transpose() {
954 let (mut ctx, a, _primal_outputs) = qr_context(&[3, 2]);
955 let mut builder = GraphBuilder::<StdTensorOp>::new();
956 let g_q = builder.add_input(TensorInputKey::User { id: 88 });
957 let g_r = builder.add_input(TensorInputKey::User { id: 89 });
958
959 let result = LinalgAdRule
960 .linear_transpose(
961 &LinalgExtensionOp::new(LinalgOp::Qr {
962 gauge: QrGauge::Raw,
963 }),
964 &mut builder,
965 &[Some(g_q), Some(g_r)],
966 &[PrimitiveTransposeInput::Residual(a)],
967 &[true],
968 &mut ctx,
969 )
970 .unwrap();
971
972 assert_eq!(result, vec![None]);
973 assert!(
974 builder.build().operations().is_empty(),
975 "QR reverse support should come from linearize + generic transpose"
976 );
977 }
978
979 #[test]
980 fn cholesky_jvp_uses_rank_when_input_metadata_is_upper_bound() {
981 let mut builder = GraphBuilder::<StdTensorOp>::new();
982 let mut ctx = ShapeGuardContext::default();
983 let primal = input_key(50);
984 ctx.insert_metadata(
985 primal.clone(),
986 TensorMeta::with_extents(
987 DType::F64,
988 vec![
989 ShapeExtent::upper_bound(SymDim::from(4usize)),
990 ShapeExtent::upper_bound(SymDim::from(4usize)),
991 ],
992 ),
993 );
994 let tangent = builder.add_input(TensorInputKey::User { id: 51 });
995 let op = LinalgExtensionOp::new(LinalgOp::Cholesky);
996
997 let result = LinalgAdRule
998 .linearize(
999 &op,
1000 &mut builder,
1001 &[primal],
1002 &[input_key(52)],
1003 &[Some(tangent)],
1004 &mut ctx,
1005 )
1006 .unwrap();
1007
1008 assert!(result[0].is_some());
1009 assert!(!builder.build().operations().is_empty());
1010 }
1011
1012 #[test]
1013 fn cholesky_jvp_propagates_missing_input_metadata() {
1014 let mut builder = GraphBuilder::<StdTensorOp>::new();
1015 let mut ctx = ShapeGuardContext::default();
1016 let primal = input_key(55);
1017 let tangent = builder.add_input(TensorInputKey::User { id: 56 });
1018 let op = LinalgExtensionOp::new(LinalgOp::Cholesky);
1019
1020 let err = LinalgAdRule
1021 .linearize(
1022 &op,
1023 &mut builder,
1024 &[primal],
1025 &[input_key(57)],
1026 &[Some(tangent)],
1027 &mut ctx,
1028 )
1029 .unwrap_err();
1030
1031 assert_eq!(err.rule(), ADRuleKind::Jvp);
1032 assert!(err.to_string().contains("missing TensorMeta"));
1033 assert!(builder.build().operations().is_empty());
1034 }
1035
1036 #[test]
1037 fn one_input_linalg_jvps_return_inactive_for_non_matrix_input() {
1038 let cases = [
1039 LinalgOp::Cholesky,
1040 LinalgOp::Lu,
1041 LinalgOp::FullPivLu,
1042 LinalgOp::Svd {
1043 derivative_eps: DEFAULT_DECOMPOSITION_DERIVATIVE_EPS,
1044 gauge: SvdGauge::Raw,
1045 },
1046 LinalgOp::SvdVals {
1047 derivative_eps: DEFAULT_DECOMPOSITION_DERIVATIVE_EPS,
1048 },
1049 LinalgOp::Qr {
1050 gauge: QrGauge::Raw,
1051 },
1052 LinalgOp::Eigh {
1053 derivative_eps: DEFAULT_DECOMPOSITION_DERIVATIVE_EPS,
1054 gauge: EighGauge::Raw,
1055 },
1056 LinalgOp::EighVals {
1057 derivative_eps: DEFAULT_DECOMPOSITION_DERIVATIVE_EPS,
1058 },
1059 LinalgOp::Eig {
1060 input_dtype: DType::F64,
1061 },
1062 LinalgOp::EigVals {
1063 input_dtype: DType::F64,
1064 },
1065 ];
1066
1067 for (case_index, kind) in cases.into_iter().enumerate() {
1068 let mut builder = GraphBuilder::<StdTensorOp>::new();
1069 let mut ctx = ShapeGuardContext::default();
1070 let primal = input_key(100 + case_index as u64);
1071 insert_meta(&mut ctx, primal.clone(), &[3]);
1072 let tangent = builder.add_input(TensorInputKey::User {
1073 id: 200 + case_index as u64,
1074 });
1075 let op = LinalgExtensionOp::new(kind);
1076 let outputs: Vec<_> = (0..op.output_count())
1077 .map(|offset| input_key(300 + case_index as u64 * 10 + offset as u64))
1078 .collect();
1079
1080 let result = LinalgAdRule
1081 .linearize(
1082 &op,
1083 &mut builder,
1084 &[primal],
1085 &outputs,
1086 &[Some(tangent)],
1087 &mut ctx,
1088 )
1089 .unwrap();
1090
1091 assert_eq!(result, vec![None; op.output_count()], "{kind:?}");
1092 assert!(
1093 builder.build().operations().is_empty(),
1094 "{kind:?} should not emit a malformed matrix AD graph"
1095 );
1096 }
1097 }
1098}