Skip to main content

fandhe_ai_autodiff/
create_graph.rs

1//! 子テープ方式の高階微分(`create_graph`。イシュー #1942・親 #1940・
2//! 設計 `docs/autodiff-higher-order-grad-decision.md` §7〜§9)。
3//!
4//! [`Tape::backward_create_graph`] は既存の 1 階 [`Tape::backward`]
5//! (`backward.rs::backward_impl`。無変更)を呼んで 1 階勾配
6//! ([`CreateGraphResult::first_order`])を得たうえで、**呼び出し側が
7//! あらかじめ構築した別インスタンスの空 `Tape`**(`child`)へ、
8//! `loss` から到達する祖先ノードの forward 値を「写し」(mirror。
9//! [`build_mirror`])として登録し、その写しを使って VJP の各ステップを
10//! `Var` 演算として記録する([`build_cgrads`])。子テープ上に記録された
11//! 1 階勾配([`CreateGraphResult::grad`] が返す `Var<'c>`)は通常の
12//! `Var` と同じくさらに `child.backward(..)` で微分できるため、これが
13//! 二階微分(grad of grad)の実体となる。
14//!
15//! **既存契約への影響(設計 doc §3「契約整理」)**: 1 階
16//! `backward_impl`・`grad.rs`・REQ-2 統一複合判定・tolerance/baseline
17//! は一切変更しない——本モジュールは `Tape::backward`(公開 API)を
18//! 素のまま呼ぶのみで、`backward.rs` へのコード変更を伴わない(`grad.rs` は #3077 の
19//! V3 用にホスト関数を `pub(crate)` 化し LogSoftmax の dispatch を共有
20//! ヘルパーへ移しただけで 1 階の挙動は不変)。二階側の数値方式(本モジュールが子テープへ記録する `Var`
21//! 演算列)は既存 VJP ヘルパー(`grad.rs`)とは独立の実装であり、
22//! 一般には bit 同一を主張しない——正しさは有限差分突合
23//! (`tests/create_graph.rs`)で検証する。ただし broadcast 縮約
24//! (`reduce_to`)は例外で、`grad.rs::reduce_to_shape` の f32 逐次和
25//! アルゴリズムを `Var::narrow`/`Var::add` の連鎖として逐語再現して
26//! おり、1 階 VJP と数値方式(縮約順序・アキュムレータ精度)が一致
27//! する(`reduce_to` のドキュメント参照。codex-review 指摘・PR
28//! #1998 で是正——当初 `Var::sum_dims`〈CPU `sum` の `f64` アキュム
29//! レータ縮約〉を使っていたため、桁落ちを伴う broadcast 入力で 1 階
30//! 勾配と乖離し REQ-2 統一複合判定を満たさない具体例があった)。
31//! `Op::Add` の bias パターン(`upstream: [m, n]` → `[n]`/`[1, n]`
32//! の行方向縮約)に限っては [`reduce_bias_grad_var`] が
33//! `grad.rs::reduce_bias_grad`(f64 相当のアキュムレータ。2026-09-12
34//! ユーザー承認・`.claude/rules/coding-rust.md` の勾配長軸縮約契約)
35//! と数値方式を揃える別経路を使う——`eval::reduce_bias_grad_rows`
36//! (1 階 VJP と同一のホスト関数)でホスト側の値を直接計算し
37//! `Tape::push_eager` で子テープへ登録する(`Var::sum` は
38//! `child.ops()` の実装〈テスト用 `naive_ops()` は f32 逐次和〉に
39//! 依存するため使わない。codex-review 指摘・PR #1998 是正)。
40//!
41//! REQ-12「利用者向け融合制御 API を提供しない」は、`create_graph` が
42//! 二階の勾配グラフを構築するかどうかの選択であり `docs/
43//! fusion-graph-design.md` §3.3 の融合境界制御(forward の elementwise
44//! 遅延グラフ)とは別軸のため抵触しない(設計 doc §9「REQ-12 整理」。
45//! 子テープに記録された `Var` 演算自体は通常どおり融合対象になりうる)。
46//!
47//! **facade 公開済み**(イシュー #2545・決定記録 §17): facade は
48//! `CreateGraphResult` を crate ルートで再エクスポートし、facade `Tape` に
49//! 本メソッドへの薄い委譲 `Tape::backward_create_graph` を 1 件だけ持つ
50//! (利用者は `fandhe_ai::tape()`/`tape_for(..)` で作った空の `Tape` を
51//! 子テープとして渡す)。`TapeRef` 版・子テープ構築ヘルパー・HVP 専用 API
52//! は公開していない(§17.3。`crates/facade/tests/api_surface.rs` の正ガード
53//! が固定)。
54//!
55//! **対象スコープ(設計 doc §8。`Op::supports_create_graph` が判定する
56//! 対象)**: `Leaf`・`Add`・`Mul`・`Relu`・`Exp`・`Tanh`・`Sigmoid`・
57//! `Sum`・`Mean`・`Reshape`・`BroadcastTo`・`MatMul`(rank 2 × rank 2
58//! 限定。#1943)の 12 variant に加え、イシュー #2062 で
59//! `ScalarUnary`(#3081 で `Gelu`・`GeluTanh`・`Erf` も対象)・`ScalarBinary`(既知 13
60//! variant すべて)・`Transpose`・`Permute`・`Narrow`・`Concat`・
61//! `Contiguous`・`Where`・`RmsNorm`・`LayerNorm`(#3078。`create_graph_norm`
62//! モジュール doc 参照)を拡張済み([`scalar_unary_replayable`]/
63//! [`scalar_binary_replayable`] 参照)。さらにイシュー #3080 で 2D 畳み込み族
64//! (`Conv2d`・`ConvTranspose2d`・`Unfold`・`Fold`。実装は `conv` サブモジュール、
65//! 決定記録 §23)を拡張済み。`F32` 以外の `compute_dtype` を持つ `Conv2d`
66//! (低精度 forward)は [`validate_ancestors`] が明示拒否する。`Conv3d`・
67//! `ConvTranspose3d` は引き続き対象外(#3080(b)。fail-closed)。イシュー #3083 で索引・累積族
68//! (`Gather`・`Scatter`・`Embedding`・`Sort`・`Topk`・`Cummax`・`Cummin`・`Cumsum`。実装は
69//! `index_cum` サブモジュール、決定記録 §25)を拡張済み。`IndexedScatterReduce`・`Cumprod`・
70//! `Logcumsumexp` は引き続き対象外(fail-closed)。イシュー #3084 で `Pad`・`PadMode`・`MaskedFill`
71//! (実装は `pad_mask` サブモジュール、決定記録 §29)を拡張済み。
72//! イシュー #3086(決定記録 §30)で FFT 4 件(`Rfft`・`Irfft`・`Fft`・`Ifft`)と線形代数 4 件
73//! (`Inv`・`Solve`・`Det`・`SlogdetLogAbsDet`。実装は `linalg_fft` サブモジュール)を拡張済み。
74//! `Stft`・`Istft`・`Cholesky`・`Pinv`・`Lstsq`・`MatrixNorm`・`Qr*`・`Svd*`・`Eigh*`・
75//! `SlogdetSign`・`MatrixRank` の 15 件は fail-closed に固定した。
76//! 損失 Op は `MseLoss`・`L1Loss`・`HuberLoss`・`CrossEntropyLoss`・`NllLoss` の 5 variant を
77//! イシュー #3082(決定記録 §26。V1/V2 方式)で対象にした。残る損失は
78//! `CrossEntropyLossWithOptions`(イシュー #2166。同型の理由で対象外)・
79//! `CosineEmbeddingLoss`・`MarginRankingLoss`・`TripletMarginLoss`・
80//! `PoissonNllLoss`(イシュー #2167。同型の理由で対象外)・
81//! `CtcLoss`(イシュー #2168。同型の理由で対象外)・
82//! `BceWithLogitsPosWeightLoss`・`HingeEmbeddingLoss`・`SoftMarginLoss`・
83//! `GaussianNllLoss`(イシュー #2652。同型の理由で対象外)・
84//! `MultiMarginLoss`・`MultiLabelMarginLoss`・`MultiLabelSoftMarginLoss`・
85//! `SigmoidFocalLoss`(イシュー #2653。同型の理由で対象外)
86//! は引き続き対象外(後続イシューへ引き継ぐ)。`BatchNorm`(train/eval。
87//! イシュー #3079)は対象——方式は `create_graph_norm.rs` と決定記録 §28。プーリング族
88//! (`MaxPool2d`・`MaxPool3d`・`AvgPool2d`・`AvgPool3d`・`AdaptiveAvgPool2d`。イシュー #3085)も対象——
89//! 方式は `pool` サブモジュール doc と決定記録 §30(`MaxUnpool` は対象外)。それ以外の追跡対象 Op・rank≥3 の
90//! `MatMul` へ到達した場合は `Err(AutodiffError::Backward)`
91//! (fail-closed。後続イシューへ引き継ぐ)。`resident`/`fused` 経路
92//! (`ResidentLeaf`/`LinearResident`/`LinearAct`)・checkpoint 済み
93//! 親テープも同様に fail-closed で拒否する([`validate_ancestors`]。
94//! `child` へ一切書き込む前の入口で判定するため、途中失敗時も
95//! `child` は無変更のまま保たれる)。
96//!
97//! **`MatMul` の数値契約(イシュー #1943・PR #2003 codex-review 指摘
98//! で是正)**: 子テープの matmul VJP([`build_cgrads`] の
99//! `Op::MatMul` 腕)は [`Var::matmul_fp32_strict`](`ops().
100//! gemm_fp32_strict` 経由。`crate::var` 限定公開)を使い、1 階
101//! `matmul_vjp`(`grad.rs`。同じく `ops.gemm_fp32_strict` 経由)と
102//! 入口を揃えている——当初案の `Var::matmul`(`ops().gemm`)は CUDA
103//! TF32 opt-in(`docs/cuda-tf32-optin-api-decision.md`)が有効な間、
104//! 1 階 `matmul_vjp` が守る「バックプロパゲーションは常に FP32 厳密」
105//! という契約を二階微分の記録経路でだけ破ってしまうため、`Var::
106//! matmul_fp32_strict` へ切り替えた。CPU バックエンドは両者が同一
107//! カーネルへ帰着するため bit 同一のまま不変。さらに、
108//! `matmul_fp32_strict` が記録する `Op::MatMul` ノードは
109//! `TapeNode::fp32_strict` フラグにより activation checkpointing
110//! (`Tape::checkpoint`/`Var::checkpoint_from`)の解放対象からも
111//! 除外される(`release_checkpoint_region` doc 参照)——`Op::MatMul`
112//! variant 自体は forward 精度の情報を持たないため、解放後の再計算
113//! (`recompute_value`)が非厳密な `matmul_forward`(`ops.gemm`)を
114//! 使ってしまう事故を防ぐ。本モジュールは元々「子テープの数値方式は
115//! 一般に bit 同一を主張せず、正しさは有限差分突合で検証する」立場
116//! (本ファイル冒頭の既存契約節)を取っており、この整理はその範囲内
117//! に収まる(tolerance/baseline は無変更)。
118
119use fandhe_ai_tensor_core::{ScalarBinaryOp, ScalarUnaryOp, Tensor, batch_norm_layout};
120
121use crate::backward::Gradients;
122use crate::error::AutodiffError;
123use crate::tape::{NodeId, Op, Tape, TapeId, TapeNode, materialize_fallible};
124use crate::var::Var;
125
126mod conv;
127mod index_cum;
128mod linalg_fft;
129mod pad_mask;
130mod pool;
131
132/// [`Op::supports_create_graph`] が `Op::ScalarUnary` へ委譲する判定
133/// (イシュー #2062)。`replay_op`(forward 再生。`Var::scalar_unary`
134/// への薄い委譲で全 variant 対応)とは異なり、[`build_cgrads`] の VJP
135/// 合成は variant ごとに手書きのため、ここで対応済み variant のみを
136/// 許可する。`Gelu`(誤差関数版)・`GeluTanh`・`Erf` はイシュー #3081
137/// (決定記録 §21.5・§22)で対象へ追加した。`Gelu` の導関数は `Φ(x)` に
138/// `erf` を要するため `Erf` を `Var::scalar_unary` で子テープへ記録し、
139/// その `Erf` 自体も孫テープでの再生(閉包性)のため同時に対象にする。
140/// `GeluTanh` は飽和域の死に枝を安全化して `NaN` を避ける。`ScalarUnaryOp` は `tensor-core` 側で
141/// `#[non_exhaustive]` のため、末尾のワイルドカードは未知 variant を
142/// 安全側の `false` へ倒す(`tape.rs::supports_create_graph` doc
143/// 「例外」参照)。イシュー #2649 の `Selu`/`Celu`/`Softsign`/
144/// `Hardsigmoid`/`LogSigmoid` も対象外で `false`(高階微分は型付きエラーで拒否)。
145pub(crate) fn scalar_unary_replayable(op: ScalarUnaryOp) -> bool {
146    matches!(
147        op,
148        ScalarUnaryOp::Neg
149            | ScalarUnaryOp::Abs
150            | ScalarUnaryOp::Sqrt
151            | ScalarUnaryOp::Log
152            | ScalarUnaryOp::Log2
153            | ScalarUnaryOp::Log10
154            | ScalarUnaryOp::Sin
155            | ScalarUnaryOp::Cos
156            | ScalarUnaryOp::Tan
157            | ScalarUnaryOp::Relu
158            | ScalarUnaryOp::Exp
159            | ScalarUnaryOp::Tanh
160            | ScalarUnaryOp::Sigmoid
161            | ScalarUnaryOp::Silu
162            | ScalarUnaryOp::Hardswish
163            | ScalarUnaryOp::LeakyRelu { .. }
164            | ScalarUnaryOp::Elu { .. }
165            | ScalarUnaryOp::Softplus { .. }
166            | ScalarUnaryOp::Clamp { .. }
167            | ScalarUnaryOp::PowScalar { .. }
168            | ScalarUnaryOp::Gelu
169            | ScalarUnaryOp::GeluTanh
170            | ScalarUnaryOp::Erf
171    )
172}
173
174/// [`Op::supports_create_graph`] が `Op::ScalarBinary` へ委譲する判定
175/// (イシュー #2062)。既知 13 variant(`Add`〜`Ne`。#2634 で追加した `Atan2` は
176/// 対象外で `false`)はすべて `Var` 演算で VJP を
177/// 合成できるため `true`([`build_cgrads`] の `Op::ScalarBinary` 腕
178/// 参照)。`ScalarBinaryOp` も `#[non_exhaustive]` のため末尾ワイルド
179/// カードは未知 variant を `false` へ倒す。
180pub(crate) fn scalar_binary_replayable(op: ScalarBinaryOp) -> bool {
181    matches!(
182        op,
183        ScalarBinaryOp::Add
184            | ScalarBinaryOp::Sub
185            | ScalarBinaryOp::Mul
186            | ScalarBinaryOp::Div
187            | ScalarBinaryOp::Pow
188            | ScalarBinaryOp::Maximum
189            | ScalarBinaryOp::Minimum
190            | ScalarBinaryOp::Gt
191            | ScalarBinaryOp::Ge
192            | ScalarBinaryOp::Lt
193            | ScalarBinaryOp::Le
194            | ScalarBinaryOp::Eq
195            | ScalarBinaryOp::Ne
196    )
197}
198
199/// [`Tape::backward_create_graph`] の戻り値。1 階勾配
200/// ([`Self::first_order`]。既存 `backward_impl` の無変更な結果)に
201/// 加え、子テープ `'c` 上に構築した「元テープの祖先ノードの写し」
202/// ([`Self::child_var`])と「その 1 階勾配(子テープ上の `Var`)」
203/// ([`Self::grad`])を保持する。
204#[derive(Debug)]
205pub struct CreateGraphResult<'c> {
206    first_order: Gradients,
207    parent_id: TapeId,
208    parent_epoch: u64,
209    /// 元テープの `NodeId.0` を添字とする、子テープ上の写し
210    /// (祖先ノードのみ `Some`)。
211    mirror: Vec<Option<Var<'c>>>,
212    /// 同じ添字で、子テープ上に構築した 1 階勾配 `Var`(loss から
213    /// 到達した対象ノードのみ `Some`)。
214    cgrads: Vec<Option<Var<'c>>>,
215}
216
217impl<'c> CreateGraphResult<'c> {
218    /// 既存 `Tape::backward` と同一の 1 階勾配(`Tensor<f32>` の
219    /// 入れ物)。子テープ構築とは独立のパス(`Tape::backward` をその
220    /// まま呼ぶのみ)で計算するため、単体で `backward` を呼んだ場合と
221    /// bit 同一(設計 doc §9「1 階勾配の bit 同一性」選択肢 (i)。
222    /// 推奨として本イシューで採用)。
223    pub fn first_order(&self) -> &Gradients {
224        &self.first_order
225    }
226
227    /// `parent_var`(元テープ上の `Var`)の 1 階勾配を、子テープ上の
228    /// `Var<'c>` として返す(これをさらに `child.backward(..)` で
229    /// 微分すれば二階勾配が得られる)。
230    ///
231    /// - `parent_var` が本 `CreateGraphResult` を生んだ親テープ・世代と
232    ///   一致しない場合: `Err(TapeMismatch)`。
233    /// - `parent_var` が `requires_grad == false`(`var_no_grad`/
234    ///   `detach` の葉、またはそれのみを祖先に持つノード)の場合:
235    ///   `Err(GradientTrackingDisabled)`(`Gradients::get` と同じ区別。
236    ///   `docs/autodiff-nograd-leaf-dinput-skip-decision.md` §5)。
237    /// - `loss` から未到達、または対象 Op が
238    ///   `Op::supports_create_graph` を満たさない部分木の外側にある
239    ///   場合: `Ok(None)`。
240    pub fn grad(&self, parent_var: &Var<'_>) -> Result<Option<Var<'c>>, AutodiffError> {
241        self.check(parent_var)?;
242        Ok(self.cgrads.get(parent_var.node_id().0).copied().flatten())
243    }
244
245    /// `parent_var` の子テープ上の写し(forward 値の再生・または
246    /// 定数葉化)を返す。`cg.grad(&cg.child_var(&x)?.unwrap())` の
247    /// ように、子テープ上でさらに `backward` した [`Gradients`] から
248    /// 二階勾配を読み出す際の鍵として使う。
249    ///
250    /// 検査は [`Self::grad`] と同一。
251    pub fn child_var(&self, parent_var: &Var<'_>) -> Result<Option<Var<'c>>, AutodiffError> {
252        self.check(parent_var)?;
253        Ok(self.mirror.get(parent_var.node_id().0).copied().flatten())
254    }
255
256    /// 元テープの `NodeId.0` を添字とする子テープ上の写し一覧(祖先ノードのみ `Some`)。
257    /// `functional_ops::jvp_create_graph` が 2 段の写し(親→子→孫)を合成するために使う
258    /// crate 内部アクセサで、公開面は増えない(#3067・設計記録 §31.2)。
259    pub(crate) fn mirror(&self) -> &[Option<Var<'c>>] {
260        &self.mirror
261    }
262
263    fn check(&self, parent_var: &Var<'_>) -> Result<(), AutodiffError> {
264        if parent_var.tape_id() != self.parent_id || parent_var.tape_epoch() != self.parent_epoch {
265            return Err(AutodiffError::TapeMismatch);
266        }
267        if !parent_var.requires_grad() {
268            return Err(AutodiffError::GradientTrackingDisabled);
269        }
270        Ok(())
271    }
272}
273
274impl Tape {
275    /// 子テープ方式の `create_graph`(PyTorch
276    /// `torch.autograd.grad(..., create_graph=True)` 相当。イシュー
277    /// #1942)。`loss` を起点に逆伝播しつつ、
278    /// `Op::supports_create_graph` を満たす祖先ノードの VJP を
279    /// `child`(呼び出し側があらかじめ [`Tape::new_with_ops`] 等で
280    /// 構築した**空**の `Tape`)上へ `Var` 演算として記録する。
281    ///
282    /// **入口検査(順序固定)**:
283    /// 1. `loss` が `self` に属さない → `Err(TapeMismatch)`。
284    /// 2. `child` が `self` と同一テープ → `Err(Backward)`
285    ///    (親と子を同一インスタンスにはできない)。
286    /// 3. `child.device() != self.device()` → `Err(DeviceMismatch)`。
287    /// 4. `child` が空でない → `Err(Backward)`(子テープの葉プレ
288    ///    フィックス契約〈`Tape::reset` doc〉を素直に保つため、既存
289    ///    ノードを持つテープの再利用は許さない)。
290    /// 5. `self` に checkpoint 区間が登録済み → `Err(Backward)`
291    ///    (`Tape::has_registered_checkpoints` doc 参照)。
292    /// 6. `loss` から到達する祖先ノードを `collect_ancestors` で
293    ///    走査し、`validate_ancestors` で resident/fused 経路・
294    ///    未対応 Op・rank≥3 の `MatMul` を事前拒否する
295    ///    (`Err(AutodiffError::Backward)`)。**素の [`Tape::backward`]
296    ///    より前に行う**——resident グラフ(`Op::ResidentLeaf`/
297    ///    `Op::LinearResident`)に対しては素の `backward` 自体が
298    ///    `AutodiffError::InvalidArgument`(`DeviceParamStore::
299    ///    backward` を使えという誤誘導的なメッセージ)を返してしまう
300    ///    ため、先に構造的検査で正確な型付きエラーを返す
301    ///    (イシュー #1943)。
302    /// 7. 上記を満たせば既存 [`Tape::backward`] をそのまま呼んで 1 階
303    ///    勾配を得る(`loss.requires_grad() == false` の拒否もここで
304    ///    既存どおり発生する)。
305    ///
306    /// 4〜7 のいずれかで失敗した場合、`child` は空のまま残る(4 の事前検査により
307    /// `child` の空性が確認済みであること、6 が `build_mirror`/`build_cgrads` より前に
308    /// 走ることに加え、`build_mirror`/`build_cgrads` の途中失敗では積んだノードを
309    /// `Tape::rollback_to` で取り除く)。
310    pub fn backward_create_graph<'c>(
311        &self,
312        loss: &Var<'_>,
313        child: &'c Tape,
314    ) -> Result<CreateGraphResult<'c>, AutodiffError> {
315        if loss.tape_id() != self.id {
316            return Err(AutodiffError::TapeMismatch);
317        }
318        if child.id == self.id {
319            return Err(AutodiffError::Backward(
320                "create_graph: 子テープに親と同一の Tape は指定できない".into(),
321            ));
322        }
323        if child.device() != self.device() {
324            return Err(AutodiffError::DeviceMismatch {
325                requested: child.device(),
326                actual: self.device(),
327            });
328        }
329        if !child.is_empty() {
330            return Err(AutodiffError::Backward(
331                "create_graph: 子テープは空である必要がある(既存ノードを持つ Tape は渡せない)"
332                    .into(),
333            ));
334        }
335        if self.has_registered_checkpoints() {
336            return Err(AutodiffError::Backward(
337                "create_graph: checkpoint 区間が登録済みの親テープでは create_graph は未対応(\
338                 docs/autodiff-higher-order-grad-decision.md §8 参照)"
339                    .into(),
340            ));
341        }
342
343        // `ancestors` の収集・検査([`validate_ancestors`])は `self.
344        // backward(loss)` より前に行う——resident グラフ(`Op::
345        // ResidentLeaf`/`Op::LinearResident`)は素の `Tape::backward`
346        // 自体が `AutodiffError::InvalidArgument`(`DeviceParamStore::
347        // backward` を使えという誤誘導的なメッセージ)を返してしまう
348        // ため、先に構造的な事前検査で「create_graph は resident 経路
349        // 非対応」という型付き `Err(Backward)` を返す方が呼び出し側に
350        // とって正確(イシュー #1943)。`collect_ancestors`/
351        // `validate_ancestors` はいずれも `self`(構造の読み取りのみ)
352        // に依存し `backward` の結果を必要としないため、順序を入れ替え
353        // ても既存の 1 階勾配計算([`Self::backward`]。無変更)には
354        // 影響しない。
355        let ancestors = collect_ancestors(self, loss.node_id());
356        validate_ancestors(self, &ancestors)?;
357
358        let first_order = self.backward(loss)?;
359
360        let n = self.nodes.borrow().len();
361        let mut mirror: Vec<Option<Var<'c>>> = vec![None; n];
362        // `build_mirror`/`build_cgrads` は子テープへノードを積んだ後でも失敗しうる
363        // (例: LayerNorm/RmsNorm の upstream 値は子テープ上で初めて分かり、構築の途中で
364        // 拒否される。PR #3346 レビュー指摘)。失敗時は積んだノードをすべて取り除いて
365        // `child` を空へ戻し、同じ `child` での再試行を可能にする(空性検査を通る)。
366        let point = child.rollback_point();
367        let built = build_mirror(self, child, &ancestors, &mut mirror)
368            .and_then(|()| build_cgrads(self, child, &ancestors, &mirror, loss.node_id()));
369        let cgrads = match built {
370            Ok(c) => c,
371            Err(e) => {
372                mirror.clear();
373                child.rollback_to(point);
374                return Err(e);
375            }
376        };
377
378        Ok(CreateGraphResult {
379            first_order,
380            parent_id: self.id,
381            parent_epoch: self.epoch(),
382            mirror,
383            cgrads,
384        })
385    }
386}
387
388/// `root` から [`Op::for_each_input`] を辿って到達する祖先ノードの
389/// `NodeId` を昇順(元テープの発生順。トポロジカル順と一致する——
390/// 入力は常に自身より小さい `NodeId` を持つため。`Tape::push_*` の
391/// 追記専用構造から導かれる不変条件)で返す。
392///
393/// **`requires_grad == false` のノードでは descend しない**(設計 doc
394/// §8「`var_no_grad`/`detach` 葉との相互作用」): 当該ノードは
395/// [`build_mirror`] が forward 値をそのまま定数葉化するため、その入力
396/// 側を祖先集合へ含める必要がない——むしろ含めると、対象外の Op
397/// (resident 経路等)がたまたま `requires_grad == false` の部分木に
398/// あるだけで `Err` になってしまい、PyTorch の「定数として扱われる
399/// 演算は任意の Op でよい」という直感的な挙動を壊す。
400fn collect_ancestors(tape: &Tape, root: NodeId) -> Vec<NodeId> {
401    let nodes = tape.nodes.borrow();
402    let n = nodes.len();
403    let mut visited = vec![false; n];
404    let mut stack = vec![root];
405    let mut ids: Vec<NodeId> = Vec::new();
406    while let Some(id) = stack.pop() {
407        if visited[id.0] {
408            continue;
409        }
410        visited[id.0] = true;
411        ids.push(id);
412        let node = &nodes[id.0];
413        if node.requires_grad {
414            node.op.for_each_input(|input_id| stack.push(input_id));
415        }
416    }
417    ids.sort_unstable_by_key(|id| id.0);
418    ids
419}
420
421/// [`Tape::backward_create_graph`] の入口検査 7([`build_mirror`]/
422/// [`build_cgrads`] が `child` へ一切書き込む前に呼ぶ純関数。イシュー
423/// #1943)。`ancestors`([`collect_ancestors`] の結果)を走査し、
424/// 以下のいずれかに該当するノードがあれば即座に `Err` を返す:
425///
426/// (a) `Op::ResidentLeaf`/`Op::LinearResident`/`Op::LinearAct`
427///     (`requires_grad` の値に関わらず。[`build_mirror`] 段 1 の
428///     拒否と同一条件——値の実体化自体が成立しないため)。
429/// (b) `requires_grad == true` かつ非葉(`Op::Leaf` でない)で
430///     `Op::supports_create_graph() == false`。
431/// (d) `requires_grad == true` で `TapeNode::low_precision` が立つノード
432///     (Op を問わず。MatMul と elementwise 5 演算。イシュー #2628)。
433/// (c) `requires_grad == true` の `Op::MatMul` で、いずれかの入力
434///     shape の rank が 2 でない(子テープの matmul VJP は
435///     rank 2 × rank 2 限定。[`Op::supports_create_graph`] の doc
436///     参照)。
437///
438/// **多層防御**: [`build_mirror`]/[`build_cgrads`] 自身も同型の
439/// 拒否分岐((a) は段 1 の手前・(b) は段 2 の `supports_create_graph`
440/// 検査)を保持したまま残す——本関数はそれらより前に実行され、失敗
441/// 時に `child` へ一部ノードが残る事態(4 の事前検査が保証するのは
442/// 「呼び出し開始時点で `child` が空」であって「呼び出し失敗時に
443/// `child` が空のまま」ではない)を避けるための追加ゲートである。
444fn validate_ancestors(parent: &Tape, ancestors: &[NodeId]) -> Result<(), AutodiffError> {
445    let parent_nodes = parent.nodes.borrow();
446    for &id in ancestors {
447        let node = &parent_nodes[id.0];
448        if matches!(
449            node.op,
450            Op::ResidentLeaf { .. } | Op::LinearResident { .. } | Op::LinearAct { .. }
451        ) {
452            return Err(AutodiffError::Backward(format!(
453                "create_graph: resident/fused 経路の Op(NodeId({}))は非対応(\
454                 docs/autodiff-higher-order-grad-decision.md §8 参照)",
455                id.0
456            )));
457        }
458        if !node.requires_grad || matches!(node.op, Op::Leaf) {
459            continue;
460        }
461        // 低精度 forward ノード(`TapeNode::low_precision`。MatMul〈イシュー
462        // #2071〉と elementwise 5 演算〈イシュー #2628〉)は Op を問わず
463        // 拒否する。Op variant 自体は精度情報を持たないため、`replay_op`
464        // (`fp32_strict` のみを読む)へ通すと子テープで静かに f32 として
465        // 再生され、opt-in が精度について嘘をつく(fail-closed 方針。
466        // `.claude/rules/security.md` A04。`crate::low_precision_ops`
467        // モジュール doc 参照)。`requires_grad == false` のノードは上の
468        // `continue` で対象外——`build_mirror` 段 1 が丸め済みの記録値を
469        // そのまま定数葉にするだけで再生しないため精度は後退しない。
470        if node.low_precision {
471            return Err(AutodiffError::Backward(format!(
472                "create_graph: 低精度 forward のノード(NodeId({}))は非対応(\
473                 テープへ精度情報を保持しないため子テープへ replay すると静かに \
474                 f32 精度へフォールバックする。イシュー #2071・#2628)",
475                id.0
476            )));
477        }
478        if !node.op.supports_create_graph() {
479            return Err(AutodiffError::Backward(format!(
480                "create_graph: 未対応の Op(NodeId({}))へ到達した(\
481                 docs/autodiff-higher-order-grad-decision.md §8 の対象 Op のみ再生可能)",
482                id.0
483            )));
484        }
485        // FFT 4 件(イシュー #3086): 未知の `FftNorm` は随伴の `norm` を決められないため、
486        // 子テープへ書く前に拒否する(`ScatterReduce` の未知 variant と同じ多層防御)。
487        if let Op::Rfft { norm, .. }
488        | Op::Irfft { norm, .. }
489        | Op::Fft { norm, .. }
490        | Op::Ifft { norm, .. } = &node.op
491        {
492            linalg_fft::check_fft_norm(*norm)?;
493        }
494        // BatchNorm(イシュー #3079): 空テンソル(`n`/`c`/`spatial` のいずれかが 0。
495        // eval は `M<=1` も通る)は二階 VJP の統計構造(行 `narrow`・`mean`)が
496        // 成立しないため、子テープへ書く前に fail-closed で拒否する。
497        if let Op::BatchNorm { input, .. } = &node.op
498            // 形状の積は `[usize::MAX, 2, 0]` で overflow して panic するため、
499            // ゼロ軸の有無で判定する(本番経路 panic 禁止)。
500            && parent_nodes[input.0].shape.contains(&0)
501        {
502            return Err(AutodiffError::Backward(format!(
503                "create_graph: 空テンソルの BatchNorm(NodeId({}))は非対応(\
504                 docs/autodiff-higher-order-grad-decision.md §28 参照)",
505                id.0
506            )));
507        }
508        // 低精度 `Conv2d`(イシュー #2071)は `TapeNode::low_precision` を立てず
509        // `compute_dtype` で記録するため、ここで明示拒否する(イシュー #3080・
510        // 決定記録 §21.3。子テープへ精度情報を持たず静かに f32 再生される
511        // のを防ぐ fail-closed。`child` へ書き込む前の入口検査)。
512        if let Op::Conv2d { compute_dtype, .. } = &node.op
513            && *compute_dtype != fandhe_ai_tensor_core::ScalarDType::F32
514        {
515            return Err(AutodiffError::Backward(format!(
516                "create_graph: 低精度 conv2d({compute_dtype:?}。NodeId({}))は非対応(\
517                 子テープへ精度情報を持たず静かに f32 再生される。イシュー #2071・#3080)",
518                id.0
519            )));
520        }
521        // `ScatterReduce` は `#[non_exhaustive]`。未知 variant は子テープへ
522        // 書き込む前にここで拒否する(イシュー #3083。`child.is_empty()` を保つ)。
523        if let Op::Scatter { reduce, .. } = &node.op
524            && !matches!(
525                reduce,
526                fandhe_ai_tensor_core::ScatterReduce::Add
527                    | fandhe_ai_tensor_core::ScatterReduce::Overwrite
528            )
529        {
530            return Err(AutodiffError::Backward(format!(
531                "create_graph: 未知の ScatterReduce variant {reduce:?}(NodeId({}))は非対応",
532                id.0
533            )));
534        }
535        // `Embedding` の `padding_idx` は forward で検査済みだが、cgrad が行マスクを
536        // 構築するため入口で範囲を再確認する(境界検査を省略しない。REQ-8)。
537        if let Op::Embedding {
538            weight,
539            padding_idx: Some(p),
540            ..
541        } = &node.op
542        {
543            let w_shape = &parent_nodes[weight.0].shape;
544            if w_shape.len() != 2 || *p >= w_shape[0] {
545                return Err(AutodiffError::Backward(format!(
546                    "create_graph: Embedding の padding_idx={p} が weight shape {w_shape:?} \
547                     の範囲外(NodeId({}))",
548                    id.0
549                )));
550            }
551        }
552        if let Op::MatMul(a, b) = node.op {
553            let a_rank = parent_nodes[a.0].shape.len();
554            let b_rank = parent_nodes[b.0].shape.len();
555            if a_rank != 2 || b_rank != 2 {
556                return Err(AutodiffError::Backward(format!(
557                    "create_graph: rank≥3 の MatMul(NodeId({}))は非対応(\
558                     子テープの matmul VJP は rank 2 × rank 2 限定。\
559                     docs/autodiff-higher-order-grad-decision.md §14 参照)",
560                    id.0
561                )));
562            }
563        }
564        // RmsNorm/LayerNorm/BatchNorm の S1 構造が安全に扱えない入力(非有限・overflow 域)を
565        // `child` へ書く前に拒否する(イシュー #3078・#3079。`create_graph_norm` モジュール doc)。
566        crate::create_graph_norm::validate_norm_s1(&parent_nodes, parent.ops(), &node.op)?;
567    }
568    Ok(())
569}
570
571/// 祖先ノードの「写し」を子テープ上へ構築する([`Tape::backward_
572/// create_graph`] の本体その 1)。**2 段構成**(`Tape::reset` の葉
573/// プレフィックス契約を子テープ側でも保つため。設計 doc §9「世代
574/// 契約」):
575///
576/// 1. 祖先すべてを昇順で走査し、葉相当(`Op::Leaf`、または
577///    `requires_grad == false` の任意ノード)を先に子テープの葉として
578///    登録する——`requires_grad == false` の任意ノードは、その Op が
579///    `resident`/`fused`/未対応のいずれであっても、値を実体化して
580///    子テープの `var_no_grad` 葉へ変換するだけで済む(replay 不要。
581///    ただし resident/fused 経路〈`ResidentLeaf`/`LinearResident`/
582///    `LinearAct`〉は値の実体化自体が成立しないため、`requires_grad`
583///    の値に関わらず本段の**手前**で無条件に拒否する)。
584/// 2. 残り(`requires_grad == true` かつ非葉)を昇順で走査し、
585///    `Op::supports_create_graph` を満たす場合のみ `Var` 演算として
586///    再生する。満たさない場合は `Err`(fail-closed。#1943 等へ引き継ぐ
587///    未対応 Op)。
588fn build_mirror<'c>(
589    parent: &Tape,
590    child: &'c Tape,
591    ancestors: &[NodeId],
592    mirror: &mut [Option<Var<'c>>],
593) -> Result<(), AutodiffError> {
594    let parent_nodes = parent.nodes.borrow();
595    let parent_ops = parent.ops();
596
597    // 段 1: resident/fused 経路を拒否しつつ、葉相当を先にすべて登録する。
598    for &id in ancestors {
599        let node = &parent_nodes[id.0];
600        if matches!(
601            node.op,
602            Op::ResidentLeaf { .. } | Op::LinearResident { .. } | Op::LinearAct { .. }
603        ) {
604            return Err(AutodiffError::Backward(format!(
605                "create_graph: resident/fused 経路の Op(NodeId({}))は非対応(\
606                 docs/autodiff-higher-order-grad-decision.md §8 参照)",
607                id.0
608            )));
609        }
610        if !node.requires_grad {
611            let value = materialize_fallible(&parent_nodes, parent_ops, id)?.clone();
612            mirror[id.0] = Some(child.var_no_grad(&value));
613            continue;
614        }
615        if matches!(node.op, Op::Leaf) {
616            let value = materialize_fallible(&parent_nodes, parent_ops, id)?.clone();
617            mirror[id.0] = Some(child.var(&value));
618        }
619    }
620
621    // 段 2: 残り(requires_grad == true かつ非葉)を再生する。
622    for &id in ancestors {
623        if mirror[id.0].is_some() {
624            continue;
625        }
626        let node = &parent_nodes[id.0];
627        if !node.op.supports_create_graph() {
628            return Err(AutodiffError::Backward(format!(
629                "create_graph: 未対応の Op(NodeId({}))へ到達した(\
630                 docs/autodiff-higher-order-grad-decision.md §8 の対象 Op のみ再生可能)",
631                id.0
632            )));
633        }
634        let replayed = replay_op(node.op.clone(), &node.shape, node.fp32_strict, mirror)?;
635        mirror[id.0] = Some(replayed);
636    }
637
638    Ok(())
639}
640
641/// 子テープ上の写しを取得する(未構築なら内部不変条件違反として
642/// `Err`。本番経路 panic 禁止方針〈`.claude/rules/coding-rust.md`〉に
643/// 従い `unwrap`/`expect` は使わない)。[`collect_ancestors`]・
644/// [`build_mirror`] の不変条件(祖先はトポロジカル順に登録されるため、
645/// 非葉ノードの再生・逆走査の時点で入力側の写しは必ず存在する)が
646/// 保たれている限り、この `Err` 分岐は実行時には到達しない。
647fn get_mirror<'c>(mirror: &[Option<Var<'c>>], id: NodeId) -> Result<Var<'c>, AutodiffError> {
648    mirror[id.0].ok_or_else(|| {
649        AutodiffError::Backward(format!(
650            "create_graph: 祖先ノード(NodeId({}))の写しが未構築(内部不変条件違反)",
651            id.0
652        ))
653    })
654}
655
656/// 祖先ノード 1 個分の forward を子テープ上で再生する([`build_mirror`]
657/// 段 2 の本体)。呼び出し元は `op.supports_create_graph()` を確認済み
658/// のため、ここでの `_ =>` 到達は契約違反(`AutodiffError::Backward`
659/// で fail-closed に拒否する。本番経路 panic 禁止方針に従い `panic!`
660/// ではなく型付きエラーとする)。
661///
662/// `shape` は再生対象ノード自身の `TapeNode::shape`(`Op::Reshape`/
663/// `Op::BroadcastTo` の目的 shape はこの `shape` から読む——`Op` 自身は
664/// 入力 `NodeId` のみを保持し目的 shape を持たないため)。
665///
666/// `fp32_strict` は再生対象ノード自身の `TapeNode::fp32_strict`
667/// (PR #2003 codex-review 指摘で追加)。`Op::MatMul` variant 自体は
668/// forward 精度の情報を持たないため、親テープ側で
669/// `Var::matmul_fp32_strict` により記録されたノード(1 階
670/// `matmul_vjp` の `d_input`/`d_weight` 等)を再生する際は、子テープ
671/// 側でも `Var::matmul_fp32_strict` を使い、通常の `Var::matmul`
672/// (`ops.gemm`。CUDA TF32 opt-in が有効な場合は非厳密)へ落ちて
673/// 「バックプロパゲーションは常に FP32 厳密」という契約
674/// (`.claude/rules/coding-rust.md`・`Var::matmul_fp32_strict` doc)を
675/// 二階微分の記録経路でだけ破ることを防ぐ。
676fn replay_op<'c>(
677    op: Op,
678    shape: &[usize],
679    fp32_strict: bool,
680    mirror: &[Option<Var<'c>>],
681) -> Result<Var<'c>, AutodiffError> {
682    match op {
683        Op::Add(a, b) => get_mirror(mirror, a)?.add(&get_mirror(mirror, b)?),
684        Op::Mul(a, b) => get_mirror(mirror, a)?.mul(&get_mirror(mirror, b)?),
685        // rank 2 × rank 2 限定(`validate_ancestors` (c) が入口で
686        // 事前検査済み)。親ノードが `fp32_strict` フラグを立てて
687        // いれば(`Var::matmul_fp32_strict` 由来)子テープ側も
688        // `matmul_fp32_strict` で再生し、`ops.gemm_fp32_strict`
689        // 経由の厳密精度契約を引き継ぐ。フラグが立っていなければ
690        // 通常の `Var::matmul`(`ops.gemm`)で forward と同一カーネル
691        // を経由する。
692        Op::MatMul(a, b) => {
693            let lhs = get_mirror(mirror, a)?;
694            let rhs = get_mirror(mirror, b)?;
695            if fp32_strict {
696                lhs.matmul_fp32_strict(&rhs)
697            } else {
698                lhs.matmul(&rhs)
699            }
700        }
701        Op::Relu(a) => Ok(get_mirror(mirror, a)?.relu()),
702        Op::Exp(a) => Ok(get_mirror(mirror, a)?.exp()),
703        Op::Tanh(a) => Ok(get_mirror(mirror, a)?.tanh()),
704        Op::Sigmoid(a) => Ok(get_mirror(mirror, a)?.sigmoid()),
705        Op::Sum { input, dim } => get_mirror(mirror, input)?.sum(dim),
706        Op::Mean { input, dim } => get_mirror(mirror, input)?.mean(dim),
707        Op::Reshape { input } => get_mirror(mirror, input)?.contiguous()?.reshape(shape),
708        Op::BroadcastTo { input } => get_mirror(mirror, input)?.broadcast_to(shape),
709        // `Var::scalar_unary`/`scalar_binary`(`pub(crate)`。同一
710        // crate 内のため呼べる)は forward 数式そのものを内包している
711        // ため、対応済み variant(`scalar_unary_replayable`/
712        // `scalar_binary_replayable` が事前検査済み)であれば variant
713        // ごとの分岐なしに再生できる(イシュー #2062)。
714        Op::ScalarUnary { op: sop, input } => get_mirror(mirror, input)?.scalar_unary(sop),
715        Op::ScalarBinary { op: sop, a, b } => {
716            get_mirror(mirror, a)?.scalar_binary(&get_mirror(mirror, b)?, sop)
717        }
718        Op::Transpose { input, dim0, dim1 } => get_mirror(mirror, input)?.transpose(dim0, dim1),
719        Op::Permute { input, perm } => get_mirror(mirror, input)?.permute(&perm),
720        Op::Narrow {
721            input,
722            dim,
723            start,
724            len,
725        } => get_mirror(mirror, input)?.narrow(dim, start, len),
726        Op::Concat { inputs, dim } => {
727            let mirrored: Result<Vec<Var<'c>>, AutodiffError> =
728                inputs.iter().map(|&id| get_mirror(mirror, id)).collect();
729            Var::cat(&mirrored?, dim)
730        }
731        Op::Contiguous { input } => get_mirror(mirror, input)?.contiguous(),
732        // forward と同じ dispatch(backend の行カーネル → `Unsupported`
733        // ならホスト)を公開 `Var` メソッド経由で通す(イシュー #3077)。
734        Op::Softmax { input, dim } => get_mirror(mirror, input)?.softmax(dim),
735        Op::LogSoftmax { input, dim } => get_mirror(mirror, input)?.log_softmax(dim),
736        // d_weight のホスト値ノード: 孫テープでも同じホスト関数で値を
737        // 再計算する(f32 の構造側合成へ落とさない。§21.1(3)(5))。
738        Op::ConvWeightGradHost {
739            structure,
740            g,
741            x,
742            params,
743            transposed,
744        } => {
745            let structure_m = get_mirror(mirror, structure)?;
746            conv::host_weight_grad_node(
747                structure_m.tape(),
748                &structure_m,
749                &get_mirror(mirror, g)?,
750                &get_mirror(mirror, x)?,
751                &params,
752                transposed,
753            )
754        }
755        // BatchNorm の dx/dw/db ホスト値ノード(イシュー #3079): 孫テープでも
756        // 1 階と同じホスト関数で値を再計算する(f64 縮約の補正を失わない)。
757        Op::BatchNormGradHost {
758            structure,
759            g,
760            x,
761            weight,
762            has_bias,
763            eps,
764            fixed_stats,
765            kind,
766        } => {
767            let structure_m = get_mirror(mirror, structure)?;
768            let w_m = weight.map(|w| get_mirror(mirror, w)).transpose()?;
769            crate::create_graph_norm::host_grad_node(
770                structure_m.tape(),
771                &structure_m,
772                &get_mirror(mirror, g)?,
773                &get_mirror(mirror, x)?,
774                w_m.as_ref(),
775                has_bias,
776                eps,
777                fixed_stats,
778                kind,
779            )
780        }
781        // 2D 畳み込み族(イシュー #3080)。公開 `Var` メソッドへの委譲で、
782        // forward と同じカーネルを通るため写しの値は親と一致する。
783        Op::Conv2d {
784            input,
785            weight,
786            bias,
787            params,
788            compute_dtype,
789        } => {
790            let bias_m = bias.map(|b| get_mirror(mirror, b)).transpose()?;
791            conv::replay_conv2d(
792                &get_mirror(mirror, input)?,
793                &get_mirror(mirror, weight)?,
794                bias_m.as_ref(),
795                &params,
796                compute_dtype,
797            )
798        }
799        Op::ConvTranspose2d {
800            input,
801            weight,
802            bias,
803            params,
804        } => {
805            let bias_m = bias.map(|b| get_mirror(mirror, b)).transpose()?;
806            conv::replay_conv_transpose2d(
807                shape,
808                &get_mirror(mirror, input)?,
809                &get_mirror(mirror, weight)?,
810                bias_m.as_ref(),
811                &params,
812            )
813        }
814        Op::Unfold { input, params } => conv::replay_unfold(&get_mirror(mirror, input)?, &params),
815        Op::Fold { input, params } => {
816            conv::replay_fold(shape, &get_mirror(mirror, input)?, &params)
817        }
818        // プーリング族(イシュー #3085。方式は `pool` モジュール doc 参照)。
819        Op::MaxPool2d { input, index } | Op::MaxPool3d { input, index } => {
820            pool::replay_max_pool(&get_mirror(mirror, input)?, &index, shape)
821        }
822        Op::AvgPool2d {
823            input,
824            params,
825            count_include_pad,
826        } => pool::replay_avg_pool2d(&get_mirror(mirror, input)?, &params, count_include_pad),
827        Op::AvgPool3d {
828            input,
829            params,
830            count_include_pad,
831        } => pool::replay_avg_pool3d(&get_mirror(mirror, input)?, &params, count_include_pad),
832        Op::AdaptiveAvgPool2d { input } => {
833            pool::replay_adaptive_avg_pool2d(&get_mirror(mirror, input)?, shape)
834        }
835        // 索引・累積族(イシュー #3083。方式は `index_cum` モジュール doc 参照)。
836        // `Sort`/`Topk`/`Cummax`/`Cummin` は値の選択のみのため `gather` で再生する。
837        Op::Gather { input, dim, index }
838        | Op::Sort { input, dim, index }
839        | Op::Topk { input, dim, index }
840        | Op::Cummax { input, dim, index }
841        | Op::Cummin { input, dim, index } => get_mirror(mirror, input)?.gather(dim, &index),
842        Op::Embedding { weight, index, .. } => get_mirror(mirror, weight)?.gather(0, &index),
843        Op::Scatter {
844            input,
845            dim,
846            index,
847            src,
848            reduce,
849        } => index_cum::replay_scatter(
850            &get_mirror(mirror, input)?,
851            &get_mirror(mirror, src)?,
852            dim,
853            &index,
854            reduce,
855        ),
856        Op::Cumsum { input, dim } => get_mirror(mirror, input)?.cumsum(dim),
857        // Pad 族(イシュー #3084)。forward の検証(上限・確保・shape)を迂回しないよう
858        // 公開 `Var` メソッド/`pad_with_mode` へ委譲する。`mask` は forward 時点で
859        // 出力 shape へ broadcast 済みの f32(`c != 0.0`)。
860        Op::Pad { input, pads, value } => get_mirror(mirror, input)?.pad(&pads, value),
861        Op::MaskedFill { input, mask, value } => {
862            get_mirror(mirror, input)?.masked_fill(&f32_mask_to_bool(&mask)?, value)
863        }
864        Op::PadMode { input, pads, mode } => {
865            crate::pad_ops::pad_with_mode(&get_mirror(mirror, input)?, &pads, mode)
866        }
867        // `cond` は forward 時点(`Var::where_cond`)で `a`/`b` と
868        // 同じ `out_shape` へ broadcast 済みの f32 マスク(`c != 0.0`
869        // 判定契約。`Op::Where` doc 参照)として `Op` へ焼き込まれて
870        // いるため、`Tensor<bool>`(`c != 0.0`)へ変換してそのまま
871        // `Var::where_cond` へ渡せる(追加の broadcast は不要)。
872        Op::Where { cond, a, b } => {
873            let cond_bool = f32_mask_to_bool(&cond)?;
874            Var::where_cond(&cond_bool, &get_mirror(mirror, a)?, &get_mirror(mirror, b)?)
875        }
876        // BatchNorm(イシュー #3079): `fixed_stats` payload が eval の
877        // 固定統計を保持しているため `Op` の拡張は不要。公開 `Var`
878        // メソッド(forward と同じカーネル)へ委譲する。running stats の
879        // 更新は `nn::BatchNormCore` だけが行い、`Var::` プリミティブは
880        // 触れないため、再生で二重更新されない。
881        Op::BatchNorm {
882            input,
883            weight,
884            bias,
885            eps,
886            fixed_stats,
887        } => {
888            let x = get_mirror(mirror, input)?;
889            let w = weight.map(|id| get_mirror(mirror, id)).transpose()?;
890            let b = bias.map(|id| get_mirror(mirror, id)).transpose()?;
891            match fixed_stats {
892                None => x.batch_norm(w.as_ref(), b.as_ref(), eps),
893                Some((mean, var)) => x.batch_norm_infer(w.as_ref(), b.as_ref(), &mean, &var, eps),
894            }
895        }
896        Op::RmsNorm { .. } | Op::LayerNorm { .. } => {
897            crate::create_graph_norm::replay_norm(&op, mirror)
898        }
899        // 損失 Op 第 1 弾(イシュー #3082・決定記録 §26)。公開 `Var` メソッドへ
900        // 委譲し、forward の検証(shape・delta・targets 範囲)を迂回しない。
901        Op::MseLoss {
902            pred,
903            target,
904            reduction,
905        } => get_mirror(mirror, pred)?.mse_loss_with(&get_mirror(mirror, target)?, reduction),
906        Op::L1Loss {
907            pred,
908            target,
909            reduction,
910        } => get_mirror(mirror, pred)?.l1_loss(&get_mirror(mirror, target)?, reduction),
911        Op::HuberLoss {
912            pred,
913            target,
914            kind,
915            delta,
916            reduction,
917        } => get_mirror(mirror, pred)?.huber_loss_impl(
918            &get_mirror(mirror, target)?,
919            kind,
920            delta,
921            reduction,
922        ),
923        Op::CrossEntropyLoss {
924            logits,
925            targets,
926            class_dim,
927            reduction,
928        } => get_mirror(mirror, logits)?.cross_entropy_loss(&targets, class_dim, reduction),
929        Op::NllLoss {
930            input,
931            targets,
932            class_dim,
933            reduction,
934        } => get_mirror(mirror, input)?.nll_loss(&targets, class_dim, reduction),
935        // FFT 4 件・線形代数 4 件(イシュー #3086。方式は `linalg_fft` モジュール doc 参照)。
936        // forward と同じ dispatch を公開 `Var` メソッド経由で通す。`SlogdetLogAbsDet` の写しは
937        // `slogdet` が積む未使用の `SlogdetSign` ノードを伴うが、どの根からも到達しないため
938        // `collect_ancestors` の対象にならない。
939        Op::Rfft {
940            input,
941            n,
942            dim,
943            norm,
944        } => get_mirror(mirror, input)?.rfft(Some(n), Some(dim), norm),
945        Op::Irfft {
946            input,
947            n,
948            dim,
949            norm,
950        } => get_mirror(mirror, input)?.irfft(Some(n), Some(dim), norm),
951        Op::Fft {
952            input,
953            n,
954            dim,
955            norm,
956        } => get_mirror(mirror, input)?.fft(Some(n), Some(dim), norm),
957        Op::Ifft {
958            input,
959            n,
960            dim,
961            norm,
962        } => get_mirror(mirror, input)?.ifft(Some(n), Some(dim), norm),
963        Op::Inv { input } => get_mirror(mirror, input)?.inv(),
964        Op::Solve { a, b } => get_mirror(mirror, a)?.solve(&get_mirror(mirror, b)?),
965        Op::Det { input } => get_mirror(mirror, input)?.det(),
966        Op::SlogdetLogAbsDet { input } => Ok(get_mirror(mirror, input)?.slogdet()?.logabsdet),
967        _ => Err(AutodiffError::Backward(
968            "create_graph: replay_op: supports_create_graph() が true の未対応 Op(内部契約違反)"
969                .into(),
970        )),
971    }
972}
973
974/// V3 の共通前処理: `v` の子テープ上の実体値を取り出す(借用は
975/// 戻る前に解放する。`reduce_bias_grad_var` と同じ形)。
976fn child_value(child: &Tape, v: &Var<'_>) -> Result<Tensor<f32>, AutodiffError> {
977    let nodes = child.nodes.borrow();
978    Ok(materialize_fallible(&nodes, child.ops(), v.node_id())?.clone())
979}
980
981/// V3(`docs/autodiff-higher-order-grad-decision.md` §21.1・§21.3・§23)の
982/// 値と構造の結合。値 `host_value` は 1 階 VJP と同じホスト関数の結果、
983/// 構造 `composed` は二階微分を流すための `Var` 演算グラフ。値を持つ
984/// `Op::Contiguous`(恒等 VJP)ノードとして子テープへ登録する。
985/// shape 不一致は内部不変条件違反として型付きエラーにする。
986fn push_v3<'c>(
987    child: &'c Tape,
988    composed: &Var<'c>,
989    host_value: Tensor<f32>,
990) -> Result<Var<'c>, AutodiffError> {
991    if host_value.shape() != composed.shape().as_slice() {
992        return Err(AutodiffError::Backward(
993            "create_graph: V3 のホスト値と構造グラフの shape が不一致(内部契約違反)".into(),
994        ));
995    }
996    let nid = child.push_eager(
997        Op::Contiguous {
998            input: composed.node_id(),
999        },
1000        host_value,
1001    );
1002    Ok(Var::from_raw(child, nid))
1003}
1004
1005/// `dim` 軸を縮約して長さ 1 で復元し `shape` へ broadcast する。
1006fn reduce_dim_bcast<'c>(
1007    v: &Var<'c>,
1008    dim: usize,
1009    shape: &[usize],
1010) -> Result<Var<'c>, AutodiffError> {
1011    v.sum(Some(dim))?
1012        .contiguous()?
1013        .unsqueeze(dim)?
1014        .broadcast_to(shape)
1015}
1016
1017/// `Op::Softmax` の create_graph(V3。イシュー #3077)。値は 1 階と同じ
1018/// `grad::softmax_vjp_along`(f64 縮約)、構造は `y⊙g − y⊙Σ(g⊙y)`
1019/// (`Σy=1`・`y≥0` ゆえ中間値が `max|g|` 以下で overflow しない並べ方)。
1020///
1021/// 二階の逆伝播(codex-review 指摘・PR #3344): `y` を 1 つのノードで
1022/// 3 箇所に使うと、`y` への寄与(`g`・`dot`・`g·(-y)` の各項)が同一ノードで
1023/// 蓄積され、個々は有限でも和が f32 範囲を超える(Softmax VJP の
1024/// 定数シフト不変性で本来は相殺される量)。そこで 3 つの使用ごとに入力から
1025/// `softmax` ミラーを別ノードとして立て、各寄与を別々に Softmax VJP へ
1026/// 通してから入力側で蓄積する(各項は `max|g|` 以下)。値は 1 階と同一の
1027/// ホスト関数が決めるため 1 階の bit 一致は不変。
1028fn softmax_cgrad_v3<'c>(
1029    parent: &Tape,
1030    child: &'c Tape,
1031    parent_nodes: &[TapeNode],
1032    id: NodeId,
1033    x_m: Var<'c>,
1034    g: &Var<'c>,
1035    dim: usize,
1036) -> Result<Var<'c>, AutodiffError> {
1037    let shape = parent_nodes[id.0].shape.clone();
1038    if shape.contains(&0) {
1039        return Ok(child.var_no_grad(&Tensor::zeros(&shape)?));
1040    }
1041    let y_a = x_m.softmax(dim)?;
1042    let y_b = x_m.softmax(dim)?;
1043    let y_c = x_m.softmax(dim)?;
1044    let out_value = materialize_fallible(parent_nodes, parent.ops(), id)?;
1045    let g_value = child_value(child, g)?;
1046    let t1 = y_a.mul(g)?;
1047    let dot = reduce_dim_bcast(&g.mul(&y_c)?, dim, &shape)?;
1048    let composed = t1.sub(&y_b.mul(&dot)?)?;
1049    let host = crate::grad::softmax_vjp_along(out_value, &g_value, dim);
1050    push_v3(child, &composed, host)
1051}
1052
1053/// レーンごとのスケール `s = max|g|`(`dim` 軸を長さ 1 にした shape)。
1054/// `s == 0` と非有限(`g` に `inf`/`NaN`)のレーンは `1`(IEEE 伝播を
1055/// 構造側へ任せる。§24)。有限の小さい `max|g|` は下限
1056/// [`LANE_SCALE_FLOOR`] で持ち上げる(codex-review 指摘・PR #3344:
1057/// `g.div(&s)` の VJP は `1/s`・`-g/s²` を f32 で先に評価するため、
1058/// 非正規数級の `s` では二階微分の係数が `inf` になる。構造式は任意の
1059/// `s > 0` で数学的に同値なので下限を設けても 1 階値・二階の意味は
1060/// 不変)。
1061/// `lane_scale` の下限(`2^-20`)。`1/s² ≤ 2^40`・`|g|/s² ≤ 2^20/…` が
1062/// いずれも f32 で有限に収まる大きさ。
1063const LANE_SCALE_FLOOR: f32 = 9.536_743e-7;
1064
1065fn lane_scale(g: &Tensor<f32>, dim: usize) -> Result<Tensor<f32>, AutodiffError> {
1066    let shape = g.shape().to_vec();
1067    let outer: usize = shape[..dim].iter().product();
1068    let axis_len = shape[dim];
1069    let inner: usize = shape[dim + 1..].iter().product();
1070    let data = crate::eval::dense_vec(g);
1071    let mut s = Vec::with_capacity(outer * inner);
1072    for o in 0..outer {
1073        for i in 0..inner {
1074            let mut m = 0f32;
1075            for a in 0..axis_len {
1076                let v = data[(o * axis_len + a) * inner + i].abs();
1077                // NaN は `>` が偽になるため明示的に非有限扱いへ倒す。
1078                if v.is_nan() {
1079                    m = f32::NAN;
1080                    break;
1081                }
1082                if v > m {
1083                    m = v;
1084                }
1085            }
1086            s.push(if m == 0.0 || !m.is_finite() {
1087                1.0
1088            } else {
1089                m.max(LANE_SCALE_FLOOR)
1090            });
1091        }
1092    }
1093    let mut sshape = shape;
1094    sshape[dim] = 1;
1095    Ok(Tensor::new(s, &sshape)?)
1096}
1097
1098/// LogSoftmax の二階逆伝播で `exp` ミラーへ入る最大寄与
1099/// `q·s·|mean_gs| = n·p·s·|mean_gs|` をレーンごとにホストで評価し、
1100/// f32 範囲(約 3.4e38)に収まる上限 `SPLIT_LIMIT` を超える分だけ
1101/// ミラーの分割数を増やす(`1..=SPLIT_MAX`。通常は 1)。
1102const SPLIT_LIMIT: f64 = 2.0e38;
1103const SPLIT_MAX: usize = 64;
1104
1105fn log_softmax_split_count(
1106    y: &Tensor<f32>,
1107    g: &Tensor<f32>,
1108    s_lane: &Tensor<f32>,
1109    dim: usize,
1110) -> usize {
1111    let shape = g.shape().to_vec();
1112    let outer: usize = shape[..dim].iter().product();
1113    let n = shape[dim];
1114    let inner: usize = shape[dim + 1..].iter().product();
1115    let yd = crate::eval::dense_vec(y);
1116    let gd = crate::eval::dense_vec(g);
1117    let sd = crate::eval::dense_vec(s_lane);
1118    let mut worst = 0f64;
1119    for o in 0..outer {
1120        for i in 0..inner {
1121            let s = sd[o * inner + i] as f64;
1122            let mut sum = 0f64;
1123            let mut pmax = 0f64;
1124            for a in 0..n {
1125                let idx = (o * n + a) * inner + i;
1126                sum += gd[idx] as f64 / s;
1127                pmax = pmax.max((yd[idx] as f64).exp());
1128            }
1129            let mean = (sum / n as f64).abs();
1130            let m = n as f64 * pmax * s * mean;
1131            // 非有限は分割では救えない(IEEE 伝播に任せる)。
1132            if m.is_finite() {
1133                worst = worst.max(m);
1134            }
1135        }
1136    }
1137    ((worst / SPLIT_LIMIT).ceil() as usize).clamp(1, SPLIT_MAX)
1138}
1139
1140/// `Op::LogSoftmax` の create_graph(V3。イシュー #3077)。値は 1 階と
1141/// 同じ `grad::log_softmax_vjp_dispatch`、構造は
1142/// `s ⊙ (g/s − exp(y) ⊙ Σ(g/s))`(`|g/s| ≤ 1` で縮約が overflow しない)。
1143fn log_softmax_cgrad_v3<'c>(
1144    parent: &Tape,
1145    child: &'c Tape,
1146    parent_nodes: &[TapeNode],
1147    x_m: Var<'c>,
1148    id: NodeId,
1149    g: &Var<'c>,
1150    dim: usize,
1151) -> Result<Var<'c>, AutodiffError> {
1152    let shape = parent_nodes[id.0].shape.clone();
1153    if shape.contains(&0) {
1154        return Ok(child.var_no_grad(&Tensor::zeros(&shape)?));
1155    }
1156    let out_value = materialize_fallible(parent_nodes, parent.ops(), id)?;
1157    let g_value = child_value(child, g)?;
1158    let s_lane = lane_scale(&g_value, dim)?;
1159    let pieces = log_softmax_split_count(out_value, &g_value, &s_lane, dim);
1160    // 二階の逆伝播の保護(codex-review 指摘・PR #3344。決定記録 §24.1b):
1161    // `exp(y)·Σgs` の係数 `Σgs`(最大 `n`)が `exp` ノードへの寄与 `-s·Σgs`
1162    // を f32 範囲外へ押し出す。`q = exp(y + ln n) = n·p`・`mean_gs = Σgs / n`
1163    // と分け、`q` への寄与を `-s·mean_gs`(`≤ s`)に抑える。さらに `q` 自身
1164    // の VJP(`q ⊙ 寄与`、最大 `n·s`)が LogSoftmax VJP へ入る前に溢れる
1165    // ため、`q·mean_gs` を `pieces` 個の独立な `log_softmax` ミラー
1166    // (入力 `x_m` から別々に立てる)へ等分し、各ミラーへの寄与を別々に
1167    // LogSoftmax VJP へ通してから入力側で蓄積する(Softmax の `y` 3 分割と
1168    // 同型)。`pieces` はホストで最大寄与の上界から決める(通常は 1)。
1169    let n_lane = shape[dim] as f32;
1170    let ln_n = child.var_no_grad(&Tensor::full(&shape, n_lane.ln())?);
1171    let inv_nk = child.var_no_grad(&Tensor::full(&shape, 1.0 / (n_lane * pieces as f32))?);
1172    // `s ⊙ (gs − q·mean_gs) = g − q·mean_g` と恒等変形し、`s` を掛ける積を
1173    // 作らない(codex-review 指摘・PR #3344。入力依存の `g` でも `mean_gs_k`
1174    // 側へ `-s·q`(最大 `n·s`)が逆伝播しない)。`mean_g_k = Σ(g/(n·pieces))`
1175    // は各項 `≤ max|g|/n` で縮約が overflow しない。
1176    let mean_g_k = reduce_dim_bcast(&g.mul(&inv_nk)?, dim, &shape)?;
1177    let mut sub: Option<Var<'c>> = None;
1178    for _ in 0..pieces {
1179        let y_k = x_m.log_softmax(dim)?;
1180        let term = y_k.add(&ln_n)?.exp().mul(&mean_g_k)?;
1181        sub = Some(match sub {
1182            Some(acc) => acc.add(&term)?,
1183            None => term,
1184        });
1185    }
1186    let sub = sub.ok_or_else(|| AutodiffError::Backward("LogSoftmax 分割数が 0".into()))?;
1187    let composed = g.sub(&sub)?;
1188    let host = crate::grad::log_softmax_vjp_dispatch(child.ops(), out_value, &g_value, dim)?;
1189    push_v3(child, &composed, host)
1190}
1191
1192/// [`Tape::backward_create_graph`] の逆走査(本体その 2。VJP を `Var`
1193/// 演算として子テープへ記録する)。`backward_impl`(`backward.rs`)と
1194/// 同型の「発生順とは逆順に走査し寄与を蓄積する」構造だが、蓄積先の
1195/// 値そのものが `Tensor<f32>` ではなく子テープ上の `Var<'c>`(さらに
1196/// 微分可能)である点が異なる。
1197fn build_cgrads<'c>(
1198    parent: &Tape,
1199    child: &'c Tape,
1200    ancestors: &[NodeId],
1201    mirror: &[Option<Var<'c>>],
1202    loss_id: NodeId,
1203) -> Result<Vec<Option<Var<'c>>>, AutodiffError> {
1204    let parent_nodes = parent.nodes.borrow();
1205    let parent_ops = parent.ops();
1206    let n = parent_nodes.len();
1207    let mut cgrads: Vec<Option<Var<'c>>> = vec![None; n];
1208
1209    // シード: `Tape::backward`(`backward.rs::backward_impl`)と同じ
1210    // 「非スカラー loss は全要素 1 の暗黙の総和射影」意味論。
1211    let loss_shape = parent_nodes[loss_id.0].shape.clone();
1212    let seed_tensor = Tensor::full(&loss_shape, 1.0f32)?;
1213    cgrads[loss_id.0] = Some(child.var_no_grad(&seed_tensor));
1214
1215    for &id in ancestors.iter().rev() {
1216        let Some(g) = cgrads[id.0] else {
1217            continue;
1218        };
1219        let node = &parent_nodes[id.0];
1220        match node.op.clone() {
1221            Op::Leaf => {}
1222            Op::Add(a, b) => {
1223                let a_shape = parent_nodes[a.0].shape.clone();
1224                let b_shape = parent_nodes[b.0].shape.clone();
1225                // codex-review 指摘(PR #1998): `grad.rs::Op::Add` の
1226                // VJP は bias パターン(`upstream: [m, n]` →
1227                // `[n]`/`[1, n]` の行方向縮約)に限り `reduce_bias_
1228                // grad`(f64 相当アキュムレータ)へ委譲する
1229                // (`.claude/rules/coding-rust.md` の勾配長軸縮約
1230                // 契約・2026-09-12 ユーザー承認)。子テープ側も同じ
1231                // 分岐を [`reduce_bias_grad_var`] で再現し、bias
1232                // パターン以外は従来どおり [`reduce_to`](`reduce_to_
1233                // shape` と同じ f32 逐次和)のまま維持する。
1234                let da = reduce_bias_grad_var(child, &g, &a_shape)?;
1235                let db = reduce_bias_grad_var(child, &g, &b_shape)?;
1236                accumulate(&parent_nodes, &mut cgrads, a, da)?;
1237                accumulate(&parent_nodes, &mut cgrads, b, db)?;
1238            }
1239            Op::Mul(a, b) => {
1240                let a_m = get_mirror(mirror, a)?;
1241                let b_m = get_mirror(mirror, b)?;
1242                let ga = g.mul(&b_m)?;
1243                let gb = g.mul(&a_m)?;
1244                let da = reduce_to(child, &ga, &a_m.shape())?;
1245                let db = reduce_to(child, &gb, &b_m.shape())?;
1246                accumulate(&parent_nodes, &mut cgrads, a, da)?;
1247                accumulate(&parent_nodes, &mut cgrads, b, db)?;
1248            }
1249            Op::MatMul(a, b) => {
1250                // 1 階 `grad.rs::matmul_vjp`(rank 2 経路)と同一の
1251                // オペランド順序(`da = gemm(g, bᵀ)`・
1252                // `db = gemm(aᵀ, g)`)を `transpose` と
1253                // `Var::matmul_fp32_strict`(codex-review 指摘。
1254                // PR #2003)の合成として子テープ上へ記録する。
1255                // `Var::matmul`(`ops.gemm`)ではなく
1256                // `matmul_fp32_strict`(`ops.gemm_fp32_strict`)を
1257                // 使うのは、CUDA TF32 opt-in(`set_cuda_gemm_precision`)
1258                // が有効な間も 1 階 `matmul_vjp` と同じく
1259                // バックプロパゲーションを FP32 厳密のまま保つため
1260                // (`Var::matmul_fp32_strict` doc 参照)。
1261                // `requires_grad == false` 側(`accumulate` が捨てる)
1262                // でも VJP 自体は記録して構わない——`transpose`/
1263                // `matmul_fp32_strict` は追加の副作用を持たないため
1264                // 無駄なノードが増えるだけで正しさに影響しない
1265                // (既存 `Op::Add`/`Mul` 腕と同じ方針)。
1266                let a_m = get_mirror(mirror, a)?;
1267                let b_m = get_mirror(mirror, b)?;
1268                let da = g.matmul_fp32_strict(&b_m.transpose(0, 1)?)?;
1269                let db = a_m.transpose(0, 1)?.matmul_fp32_strict(&g)?;
1270                accumulate(&parent_nodes, &mut cgrads, a, da)?;
1271                accumulate(&parent_nodes, &mut cgrads, b, db)?;
1272            }
1273            Op::Relu(a) => {
1274                // 劣勾配は x = 0 で 0(`grad.rs::Op::Relu` の VJP と同じ
1275                // 規約)。マスクは**入力側**の実測値(親テープ上で既に
1276                // 実体化済み)から作る——出力値ではなく入力値を見るのは
1277                // `grad.rs` の既存規約(`v > 0.0`)と一致させるため。
1278                let a_val = materialize_fallible(&parent_nodes, parent_ops, a)?;
1279                let mask = positive_mask(a_val)?;
1280                let zeros = child.var_no_grad(&Tensor::zeros(&g.shape())?);
1281                let da = Var::where_cond(&mask, &g, &zeros)?;
1282                accumulate(&parent_nodes, &mut cgrads, a, da)?;
1283            }
1284            Op::Exp(a) => {
1285                // d/dx exp(x) = exp(x)。子テープ上の写し(= exp(x) の
1286                // 子テープ上の再生値)を再利用し、`exp` を再計算しない
1287                // (`grad.rs::Op::Exp` の `out_value` 再利用と同じ方針)。
1288                let out_c = get_mirror(mirror, id)?;
1289                let da = g.mul(&out_c)?;
1290                accumulate(&parent_nodes, &mut cgrads, a, da)?;
1291            }
1292            Op::Tanh(a) => {
1293                // d/dx tanh(x) = 1 - tanh(x)^2。
1294                let out_c = get_mirror(mirror, id)?;
1295                let one = child.var_no_grad(&Tensor::full(&out_c.shape(), 1.0f32)?);
1296                let sq = out_c.mul(&out_c)?;
1297                let factor = one.sub(&sq)?;
1298                let da = g.mul(&factor)?;
1299                accumulate(&parent_nodes, &mut cgrads, a, da)?;
1300            }
1301            Op::Sigmoid(a) => {
1302                // d/dx sigmoid(x) = sigmoid(x) * (1 - sigmoid(x))。
1303                let out_c = get_mirror(mirror, id)?;
1304                let one = child.var_no_grad(&Tensor::full(&out_c.shape(), 1.0f32)?);
1305                let comp = one.sub(&out_c)?;
1306                let factor = out_c.mul(&comp)?;
1307                let da = g.mul(&factor)?;
1308                accumulate(&parent_nodes, &mut cgrads, a, da)?;
1309            }
1310            Op::Sum { input, dim } => {
1311                let input_shape = parent_nodes[input.0].shape.clone();
1312                let da = match dim {
1313                    None => g.broadcast_to(&input_shape)?,
1314                    Some(axis) => g
1315                        .contiguous()?
1316                        .unsqueeze(axis)?
1317                        .broadcast_to(&input_shape)?,
1318                };
1319                accumulate(&parent_nodes, &mut cgrads, input, da)?;
1320            }
1321            Op::Mean { input, dim } => {
1322                let input_shape = parent_nodes[input.0].shape.clone();
1323                let count: usize = match dim {
1324                    None => input_shape.iter().product(),
1325                    Some(axis) => input_shape[axis],
1326                };
1327                let scale = child.var_no_grad(&Tensor::scalar(1.0f32 / count as f32));
1328                let g_scaled = g.mul(&scale)?;
1329                let da = match dim {
1330                    None => g_scaled.broadcast_to(&input_shape)?,
1331                    Some(axis) => g_scaled
1332                        .contiguous()?
1333                        .unsqueeze(axis)?
1334                        .broadcast_to(&input_shape)?,
1335                };
1336                accumulate(&parent_nodes, &mut cgrads, input, da)?;
1337            }
1338            Op::Reshape { input } => {
1339                let input_shape = parent_nodes[input.0].shape.clone();
1340                let da = g.contiguous()?.reshape(&input_shape)?;
1341                accumulate(&parent_nodes, &mut cgrads, input, da)?;
1342            }
1343            Op::BroadcastTo { input } => {
1344                let input_shape = parent_nodes[input.0].shape.clone();
1345                let da = reduce_to(child, &g, &input_shape)?;
1346                accumulate(&parent_nodes, &mut cgrads, input, da)?;
1347            }
1348            Op::Transpose { input, dim0, dim1 } => {
1349                // 対合性(`transpose` を 2 回適用すると恒等)を使い、
1350                // 同じ軸で upstream を transpose するだけで閉じる
1351                // (`grad.rs::Op::Transpose` の VJP と同じ規約)。
1352                let da = g.transpose(dim0, dim1)?;
1353                accumulate(&parent_nodes, &mut cgrads, input, da)?;
1354            }
1355            Op::Permute { input, perm } => {
1356                // `grad.rs::inverse_permutation`(イシュー #2062 で
1357                // `pub(crate)` 化)を再利用し、逆置換の数式を二重管理
1358                // しない。
1359                let inv = crate::grad::inverse_permutation(&perm);
1360                let da = g.permute(&inv)?;
1361                accumulate(&parent_nodes, &mut cgrads, input, da)?;
1362            }
1363            Op::Narrow {
1364                input,
1365                dim,
1366                start,
1367                len,
1368            } => {
1369                // 「Split(Narrow)の VJP は Concat」の原則(`grad.rs::
1370                // Op::Narrow` の VJP と同型)。選択されなかった前後の
1371                // 区間を zero 定数で埋め、`Var::cat` で連結する。
1372                let input_shape = parent_nodes[input.0].shape.clone();
1373                let before_len = start;
1374                let after_len = input_shape[dim] - start - len;
1375                let mut pieces: Vec<Var<'c>> = Vec::with_capacity(3);
1376                if before_len > 0 {
1377                    let mut before_shape = input_shape.clone();
1378                    before_shape[dim] = before_len;
1379                    pieces.push(child.var_no_grad(&Tensor::zeros(&before_shape)?));
1380                }
1381                pieces.push(g);
1382                if after_len > 0 {
1383                    let mut after_shape = input_shape.clone();
1384                    after_shape[dim] = after_len;
1385                    pieces.push(child.var_no_grad(&Tensor::zeros(&after_shape)?));
1386                }
1387                let da = if pieces.len() == 1 {
1388                    g
1389                } else {
1390                    Var::cat(&pieces, dim)?
1391                };
1392                accumulate(&parent_nodes, &mut cgrads, input, da)?;
1393            }
1394            Op::Concat { inputs, dim } => {
1395                // 「Concat の VJP は Split(Narrow)」の原則(`grad.rs::
1396                // Op::Concat` の VJP と同型)。同一 `NodeId` の重複
1397                // (`cat(&[x, x])`)は `accumulate` が合算する。
1398                let mut off = 0usize;
1399                for input in inputs {
1400                    let len = parent_nodes[input.0].shape[dim];
1401                    let da = g.narrow(dim, off, len)?;
1402                    accumulate(&parent_nodes, &mut cgrads, input, da)?;
1403                    off += len;
1404                }
1405            }
1406            Op::Softmax { input, dim } => {
1407                let da = softmax_cgrad_v3(
1408                    parent,
1409                    child,
1410                    &parent_nodes,
1411                    id,
1412                    get_mirror(mirror, input)?,
1413                    &g,
1414                    dim,
1415                )?;
1416                accumulate(&parent_nodes, &mut cgrads, input, da)?;
1417            }
1418            Op::LogSoftmax { input, dim } => {
1419                let da = log_softmax_cgrad_v3(
1420                    parent,
1421                    child,
1422                    &parent_nodes,
1423                    get_mirror(mirror, input)?,
1424                    id,
1425                    &g,
1426                    dim,
1427                )?;
1428                accumulate(&parent_nodes, &mut cgrads, input, da)?;
1429            }
1430            Op::MseLoss {
1431                pred,
1432                target,
1433                reduction,
1434            } => {
1435                let pred_m = get_mirror(mirror, pred)?;
1436                let target_m = get_mirror(mirror, target)?;
1437                let shape = parent_nodes[pred.0].shape.clone();
1438                let dpred = if shape.contains(&0) {
1439                    child.var_no_grad(&Tensor::zeros(&shape)?)
1440                } else {
1441                    let n: usize = shape.iter().product();
1442                    let s_b = loss_scale_bcast(child, &g, reduction, true, n, &shape)?;
1443                    pred_m.sub(&target_m)?.mul(&s_b)?
1444                };
1445                let dtarget = dpred.neg()?;
1446                accumulate(&parent_nodes, &mut cgrads, pred, dpred)?;
1447                accumulate(&parent_nodes, &mut cgrads, target, dtarget)?;
1448            }
1449            Op::L1Loss {
1450                pred,
1451                target,
1452                reduction,
1453            } => {
1454                let pred_m = get_mirror(mirror, pred)?;
1455                let target_m = get_mirror(mirror, target)?;
1456                let shape = parent_nodes[pred.0].shape.clone();
1457                let dpred = if shape.contains(&0) {
1458                    child.var_no_grad(&Tensor::zeros(&shape)?)
1459                } else {
1460                    let n: usize = shape.iter().product();
1461                    let g_b = loss_scale_bcast(child, &g, reduction, false, n, &shape)?;
1462                    let p_val = materialize_fallible(&parent_nodes, parent_ops, pred)?;
1463                    let t_val = materialize_fallible(&parent_nodes, parent_ops, target)?;
1464                    let pos = pairwise_mask(p_val, t_val, |p, t| p - t > 0.0)?;
1465                    let neg = pairwise_mask(p_val, t_val, |p, t| p - t < 0.0)?;
1466                    let nan = pairwise_mask(p_val, t_val, |p, t| (p - t).is_nan())?;
1467                    let d_m = pred_m.sub(&target_m)?;
1468                    let zeros = child.var_no_grad(&Tensor::zeros(&shape)?);
1469                    // `d == 0` は区分定数の 0(V2。`g.mul(0/1 定数)` は使わない)。
1470                    // NaN は 1 階 `l1_grad_sign` と同じく `d_m` を通して伝播する。
1471                    let nan_branch = Var::where_cond(&nan, &d_m, &zeros)?;
1472                    let neg_branch = Var::where_cond(&neg, &g_b.neg()?, &nan_branch)?;
1473                    Var::where_cond(&pos, &g_b, &neg_branch)?
1474                };
1475                let dtarget = dpred.neg()?;
1476                accumulate(&parent_nodes, &mut cgrads, pred, dpred)?;
1477                accumulate(&parent_nodes, &mut cgrads, target, dtarget)?;
1478            }
1479            Op::HuberLoss {
1480                pred,
1481                target,
1482                kind,
1483                delta,
1484                reduction,
1485            } => {
1486                let pred_m = get_mirror(mirror, pred)?;
1487                let target_m = get_mirror(mirror, target)?;
1488                let shape = parent_nodes[pred.0].shape.clone();
1489                let smooth_l1 = match kind {
1490                    fandhe_ai_tensor_core::HuberKind::Huber => false,
1491                    fandhe_ai_tensor_core::HuberKind::SmoothL1 => true,
1492                    _ => {
1493                        return Err(AutodiffError::Backward(
1494                            "create_graph: HuberLoss: 未知の HuberKind は fail-closed".into(),
1495                        ));
1496                    }
1497                };
1498                let dpred = if shape.contains(&0) {
1499                    child.var_no_grad(&Tensor::zeros(&shape)?)
1500                } else {
1501                    let n: usize = shape.iter().product();
1502                    let g_b = loss_scale_bcast(child, &g, reduction, false, n, &shape)?;
1503                    let p_val = materialize_fallible(&parent_nodes, parent_ops, pred)?;
1504                    let t_val = materialize_fallible(&parent_nodes, parent_ops, target)?;
1505                    // 二次領域は `!(|d| >= delta)`(NaN の `d` は二次側へ入り、
1506                    // `d_m` 経由で NaN を伝播する。`eval::huber_elem_grad` と一致)。
1507                    let quad = pairwise_mask(p_val, t_val, |p, t| {
1508                        let a = (p - t).abs();
1509                        a < delta || a.is_nan()
1510                    })?;
1511                    let lin_data: Vec<f32> = {
1512                        let pc = p_val.contiguous();
1513                        let tc = t_val.contiguous();
1514                        let ps = pc.as_slice().unwrap_or(&[]);
1515                        let ts = tc.as_slice().unwrap_or(&[]);
1516                        ps.iter()
1517                            .zip(ts.iter())
1518                            .map(|(&p, &t)| {
1519                                let d = p - t;
1520                                if smooth_l1 {
1521                                    1.0f32.copysign(d)
1522                                } else {
1523                                    delta.copysign(d)
1524                                }
1525                            })
1526                            .collect()
1527                    };
1528                    let lin_const = child.var_no_grad(&Tensor::new(lin_data, &shape)?);
1529                    let d_m = pred_m.sub(&target_m)?;
1530                    // 線形領域(非選択分岐)の `d_m` は除算・乗算の前に 0 へマスクする。
1531                    // マスクせず `d_m / delta` を作ると、有限入力(例: pred=1e30・
1532                    // beta=1e-10)でも非選択側が inf になり、`where_cond` が二次側へ
1533                    // 流すゼロ余接と Mul/Div の VJP で積算されて NaN が二階微分へ
1534                    // 漏れる。NaN の `d` は `quad` に含まれるためマスク後も伝播する。
1535                    let zeros_q = child.var_no_grad(&Tensor::zeros(&shape)?);
1536                    let d_safe = Var::where_cond(&quad, &d_m, &zeros_q)?;
1537                    let quad_grad = if smooth_l1 {
1538                        let delta_c = child.var_no_grad(&Tensor::scalar(delta));
1539                        g_b.mul(&d_safe.div(&delta_c)?)?
1540                    } else {
1541                        g_b.mul(&d_safe)?
1542                    };
1543                    let lin_grad = g_b.mul(&lin_const)?;
1544                    Var::where_cond(&quad, &quad_grad, &lin_grad)?
1545                };
1546                let dtarget = dpred.neg()?;
1547                accumulate(&parent_nodes, &mut cgrads, pred, dpred)?;
1548                accumulate(&parent_nodes, &mut cgrads, target, dtarget)?;
1549            }
1550            Op::CrossEntropyLoss {
1551                logits,
1552                targets,
1553                class_dim,
1554                reduction,
1555            } => {
1556                let logits_m = get_mirror(mirror, logits)?;
1557                let shape = parent_nodes[logits.0].shape.clone();
1558                let dlogits = if shape.contains(&0) {
1559                    child.var_no_grad(&Tensor::zeros(&shape)?)
1560                } else {
1561                    let n = class_sample_count(&shape, class_dim);
1562                    let s_b = loss_scale_bcast(child, &g, reduction, false, n, &shape)?;
1563                    let onehot = child.var_no_grad(&onehot_f32(&targets, &shape, class_dim)?);
1564                    let y = logits_m.softmax(class_dim)?;
1565                    y.sub(&onehot)?.mul(&s_b)?
1566                };
1567                accumulate(&parent_nodes, &mut cgrads, logits, dlogits)?;
1568            }
1569            Op::NllLoss {
1570                input,
1571                targets,
1572                class_dim,
1573                reduction,
1574            } => {
1575                let shape = parent_nodes[input.0].shape.clone();
1576                let dinput = if shape.contains(&0) {
1577                    child.var_no_grad(&Tensor::zeros(&shape)?)
1578                } else {
1579                    let n = class_sample_count(&shape, class_dim);
1580                    let s_b = loss_scale_bcast(child, &g, reduction, false, n, &shape)?;
1581                    let mask = onehot_mask(&targets, &shape, class_dim)?;
1582                    let zeros = child.var_no_grad(&Tensor::zeros(&shape)?);
1583                    Var::where_cond(&mask, &s_b.neg()?, &zeros)?
1584                };
1585                accumulate(&parent_nodes, &mut cgrads, input, dinput)?;
1586            }
1587            Op::Contiguous { input } => {
1588                // 恒等(`grad.rs::Op::Contiguous` の VJP と同型)。
1589                accumulate(&parent_nodes, &mut cgrads, input, g)?;
1590            }
1591            Op::ConvWeightGradHost { structure, .. } | Op::BatchNormGradHost { structure, .. } => {
1592                // 恒等で `structure` のみへ(`grad.rs` の VJP と同型)。
1593                accumulate(&parent_nodes, &mut cgrads, structure, g)?;
1594            }
1595            Op::Where { cond, a, b } => {
1596                // `grad.rs::where_vjp` と同型: `cond` の真偽で `g` を
1597                // 通すか 0 にするかを選択し、`a`/`b` 元 shape へ縮約
1598                // する(`reduce_to`。`Op::Mul` 等と同じ broadcast 逆
1599                // 演算)。
1600                let a_shape = parent_nodes[a.0].shape.clone();
1601                let b_shape = parent_nodes[b.0].shape.clone();
1602                let mask = f32_mask_to_bool(&cond)?;
1603                let zeros = child.var_no_grad(&Tensor::zeros(&g.shape())?);
1604                let ga = Var::where_cond(&mask, &g, &zeros)?;
1605                let gb = Var::where_cond(&mask, &zeros, &g)?;
1606                let da = reduce_to(child, &ga, &a_shape)?;
1607                let db = reduce_to(child, &gb, &b_shape)?;
1608                accumulate(&parent_nodes, &mut cgrads, a, da)?;
1609                accumulate(&parent_nodes, &mut cgrads, b, db)?;
1610            }
1611            Op::BatchNorm {
1612                input,
1613                weight,
1614                bias,
1615                eps,
1616                fixed_stats,
1617            } => {
1618                // 方式(V3 値+S1 構造・総括的な適用判定)は `create_graph_norm.rs`
1619                // と決定記録 §28 を参照。
1620                let x_host = materialize_fallible(&parent_nodes, parent_ops, input)?.clone();
1621                let (n_, c_, spatial_) = batch_norm_layout(x_host.shape())?;
1622                let inputs = crate::create_graph_norm::BatchNormCgradInputs {
1623                    x_mirror: get_mirror(mirror, input)?,
1624                    w_mirror: weight.map(|w| get_mirror(mirror, w)).transpose()?,
1625                    has_bias: bias.is_some(),
1626                    eps,
1627                    fixed_stats: fixed_stats.as_ref(),
1628                    x_host: &x_host,
1629                    need_dx: parent_nodes[input.0].requires_grad,
1630                    need_dw: weight.is_some_and(|w| parent_nodes[w.0].requires_grad),
1631                    need_db: bias.is_some_and(|b| parent_nodes[b.0].requires_grad),
1632                    n: n_,
1633                    c: c_,
1634                    spatial: spatial_,
1635                };
1636                let out = crate::create_graph_norm::batch_norm_cgrads(child, &inputs, &g)?;
1637                if let Some(dx) = out.dx {
1638                    accumulate(&parent_nodes, &mut cgrads, input, dx)?;
1639                }
1640                if let (Some(w), Some(dw)) = (weight, out.dw) {
1641                    accumulate(&parent_nodes, &mut cgrads, w, dw)?;
1642                }
1643                if let (Some(b), Some(db)) = (bias, out.db) {
1644                    accumulate(&parent_nodes, &mut cgrads, b, db)?;
1645                }
1646            }
1647            Op::ScalarUnary { op: sop, input } => {
1648                // `x_val`(materialize のクローン)・`one`(child.var_no_grad
1649                // の leaf ノード)は使う variant でのみ構築する(全 variant
1650                // 共通の先頭構築は使わない variant でも子テープへ不要な
1651                // ノードを積んでしまうため。review 指摘 2026-09-22)。
1652                let x_m = get_mirror(mirror, input)?;
1653                let da = match sop {
1654                    ScalarUnaryOp::Neg => g.neg()?,
1655                    ScalarUnaryOp::Abs => {
1656                        let x_val = materialize_fallible(&parent_nodes, parent_ops, input)?;
1657                        let pos = mask_from_pred(x_val, |v| v > 0.0)?;
1658                        let neg = mask_from_pred(x_val, |v| v < 0.0)?;
1659                        let zeros = child.var_no_grad(&Tensor::zeros(&g.shape())?);
1660                        let neg_branch = Var::where_cond(&neg, &g.neg()?, &zeros)?;
1661                        Var::where_cond(&pos, &g, &neg_branch)?
1662                    }
1663                    ScalarUnaryOp::Sqrt => {
1664                        let y_c = get_mirror(mirror, id)?;
1665                        let half = child.var_no_grad(&Tensor::scalar(0.5f32));
1666                        let factor = half.div(&y_c)?;
1667                        g.mul(&factor)?
1668                    }
1669                    ScalarUnaryOp::Log => {
1670                        let one = child.var_no_grad(&Tensor::scalar(1.0f32));
1671                        let factor = one.div(&x_m)?;
1672                        g.mul(&factor)?
1673                    }
1674                    ScalarUnaryOp::Log2 => {
1675                        let one = child.var_no_grad(&Tensor::scalar(1.0f32));
1676                        let ln2 = child.var_no_grad(&Tensor::scalar(std::f32::consts::LN_2));
1677                        let factor = one.div(&x_m.mul(&ln2)?)?;
1678                        g.mul(&factor)?
1679                    }
1680                    ScalarUnaryOp::Log10 => {
1681                        let one = child.var_no_grad(&Tensor::scalar(1.0f32));
1682                        let ln10 = child.var_no_grad(&Tensor::scalar(std::f32::consts::LN_10));
1683                        let factor = one.div(&x_m.mul(&ln10)?)?;
1684                        g.mul(&factor)?
1685                    }
1686                    ScalarUnaryOp::Sin => {
1687                        let factor = x_m.cos()?;
1688                        g.mul(&factor)?
1689                    }
1690                    ScalarUnaryOp::Cos => {
1691                        let factor = x_m.sin()?.neg()?;
1692                        g.mul(&factor)?
1693                    }
1694                    ScalarUnaryOp::Tan => {
1695                        let one = child.var_no_grad(&Tensor::scalar(1.0f32));
1696                        let c = x_m.cos()?;
1697                        let factor = one.div(&c.mul(&c)?)?;
1698                        g.mul(&factor)?
1699                    }
1700                    ScalarUnaryOp::Relu => {
1701                        let x_val = materialize_fallible(&parent_nodes, parent_ops, input)?;
1702                        let mask = positive_mask(x_val)?;
1703                        let zeros = child.var_no_grad(&Tensor::zeros(&g.shape())?);
1704                        Var::where_cond(&mask, &g, &zeros)?
1705                    }
1706                    ScalarUnaryOp::Exp => {
1707                        let out_c = get_mirror(mirror, id)?;
1708                        g.mul(&out_c)?
1709                    }
1710                    ScalarUnaryOp::Tanh => {
1711                        let out_c = get_mirror(mirror, id)?;
1712                        let onec = child.var_no_grad(&Tensor::full(&out_c.shape(), 1.0f32)?);
1713                        let sq = out_c.mul(&out_c)?;
1714                        let factor = onec.sub(&sq)?;
1715                        g.mul(&factor)?
1716                    }
1717                    ScalarUnaryOp::Sigmoid => {
1718                        let out_c = get_mirror(mirror, id)?;
1719                        let onec = child.var_no_grad(&Tensor::full(&out_c.shape(), 1.0f32)?);
1720                        let comp = onec.sub(&out_c)?;
1721                        let factor = out_c.mul(&comp)?;
1722                        g.mul(&factor)?
1723                    }
1724                    ScalarUnaryOp::Silu => {
1725                        let s = x_m.sigmoid();
1726                        let onec = child.var_no_grad(&Tensor::full(&x_m.shape(), 1.0f32)?);
1727                        let one_minus_s = onec.sub(&s)?;
1728                        let term = x_m.mul(&one_minus_s)?;
1729                        let inner = onec.add(&term)?;
1730                        let factor = s.mul(&inner)?;
1731                        g.mul(&factor)?
1732                    }
1733                    ScalarUnaryOp::Hardswish => {
1734                        let x_val = materialize_fallible(&parent_nodes, parent_ops, input)?;
1735                        let lo = mask_from_pred(x_val, |v| v <= -3.0)?;
1736                        let hi = mask_from_pred(x_val, |v| v >= 3.0)?;
1737                        let two = child.var_no_grad(&Tensor::scalar(2.0f32));
1738                        let three = child.var_no_grad(&Tensor::scalar(3.0f32));
1739                        let six = child.var_no_grad(&Tensor::scalar(6.0f32));
1740                        let mid = x_m.mul(&two)?.add(&three)?.div(&six)?;
1741                        let onec = child.var_no_grad(&Tensor::full(&x_m.shape(), 1.0f32)?);
1742                        let hi_branch = Var::where_cond(&hi, &onec, &mid)?;
1743                        let zeros = child.var_no_grad(&Tensor::zeros(&x_m.shape())?);
1744                        let factor = Var::where_cond(&lo, &zeros, &hi_branch)?;
1745                        g.mul(&factor)?
1746                    }
1747                    ScalarUnaryOp::LeakyRelu { negative_slope } => {
1748                        let x_val = materialize_fallible(&parent_nodes, parent_ops, input)?;
1749                        let mask = mask_from_pred(x_val, |v| v >= 0.0)?;
1750                        let slope = child.var_no_grad(&Tensor::scalar(negative_slope));
1751                        let else_branch = g.mul(&slope)?;
1752                        Var::where_cond(&mask, &g, &else_branch)?
1753                    }
1754                    ScalarUnaryOp::Elu { alpha } => {
1755                        let x_val = materialize_fallible(&parent_nodes, parent_ops, input)?;
1756                        let mask = mask_from_pred(x_val, |v| v > 0.0)?;
1757                        let alpha_c = child.var_no_grad(&Tensor::scalar(alpha));
1758                        let factor = x_m.exp().mul(&alpha_c)?;
1759                        let else_branch = g.mul(&factor)?;
1760                        Var::where_cond(&mask, &g, &else_branch)?
1761                    }
1762                    ScalarUnaryOp::Softplus { beta, threshold } => {
1763                        let x_val = materialize_fallible(&parent_nodes, parent_ops, input)?;
1764                        let mask = mask_from_pred(x_val, |v| v * beta > threshold)?;
1765                        let beta_c = child.var_no_grad(&Tensor::scalar(beta));
1766                        let factor = x_m.mul(&beta_c)?.sigmoid();
1767                        let else_branch = g.mul(&factor)?;
1768                        Var::where_cond(&mask, &g, &else_branch)?
1769                    }
1770                    ScalarUnaryOp::Clamp { min, max } => {
1771                        let x_val = materialize_fallible(&parent_nodes, parent_ops, input)?;
1772                        let mask = mask_from_pred(x_val, |v| {
1773                            !(v.is_nan() || min > max || v < min || v > max)
1774                        })?;
1775                        let zeros = child.var_no_grad(&Tensor::zeros(&g.shape())?);
1776                        Var::where_cond(&mask, &g, &zeros)?
1777                    }
1778                    ScalarUnaryOp::PowScalar { exponent } => {
1779                        if exponent == 0.0 {
1780                            child.var_no_grad(&Tensor::zeros(&g.shape())?)
1781                        } else {
1782                            let exp_c = child.var_no_grad(&Tensor::scalar(exponent));
1783                            let factor = x_m
1784                                .scalar_unary(ScalarUnaryOp::PowScalar {
1785                                    exponent: exponent - 1.0,
1786                                })?
1787                                .mul(&exp_c)?;
1788                            g.mul(&factor)?
1789                        }
1790                    }
1791                    ScalarUnaryOp::Erf => {
1792                        // erf'(x) = 2/√π · exp(-x²)(`erf_grad` と同順)。
1793                        let c = child.var_no_grad(&Tensor::scalar(
1794                            (2.0f64 * 0.564_189_583_547_756_3f64) as f32,
1795                        ));
1796                        let factor = x_m.mul(&x_m)?.neg()?.exp().mul(&c)?;
1797                        g.mul(&factor)?
1798                    }
1799                    ScalarUnaryOp::Gelu => {
1800                        // Φ(x) + x·φ(x)(`gelu_erf_grad` と同順)。Φ は子テープへ
1801                        // `Erf` として記録する(閉包性。#3081・§22)。
1802                        let inv_sqrt2 =
1803                            child.var_no_grad(&Tensor::scalar(std::f32::consts::FRAC_1_SQRT_2));
1804                        let half = child.var_no_grad(&Tensor::scalar(0.5f32));
1805                        let one = child.var_no_grad(&Tensor::scalar(1.0f32));
1806                        let neg_half = child.var_no_grad(&Tensor::scalar(-0.5f32));
1807                        let inv_sqrt_2pi = child.var_no_grad(&Tensor::scalar(0.398_942_3_f32));
1808                        let e = x_m.mul(&inv_sqrt2)?.scalar_unary(ScalarUnaryOp::Erf)?;
1809                        let cdf = one.add(&e)?.mul(&half)?;
1810                        let pdf = x_m.mul(&x_m)?.mul(&neg_half)?.exp().mul(&inv_sqrt_2pi)?;
1811                        let factor = cdf.add(&x_m.mul(&pdf)?)?;
1812                        g.mul(&factor)?
1813                    }
1814                    ScalarUnaryOp::GeluTanh => {
1815                        // 飽和域(1 - tanh² == 0)は host マスクで検出し、滑らかな式は
1816                        // 飽和要素を 0 に置換した x_safe で組む(double-where)。
1817                        // where_cond の死に枝へも 0 の余接が流れ、後続 Mul の VJP が
1818                        // `0·相方` を作るため、相方が inf だと 2 階へ NaN が混入する。
1819                        let x_val = materialize_fallible(&parent_nodes, parent_ops, input)?;
1820                        const C: f32 = 0.797_884_6;
1821                        const K: f32 = 0.044_715;
1822                        let t_host = |v: f32| (C * (v + K * v * v * v)).tanh();
1823                        let sat = mask_from_pred(x_val, |v| {
1824                            let t = t_host(v);
1825                            1.0 - t * t == 0.0
1826                        })?;
1827                        let contiguous = x_val.contiguous();
1828                        let limit_data: Vec<f32> = contiguous
1829                            .as_slice()
1830                            .map(|s| s.iter().map(|&v| 0.5 * (1.0 + t_host(v))).collect())
1831                            .unwrap_or_default();
1832                        let limit =
1833                            child.var_no_grad(&Tensor::new(limit_data, contiguous.shape())?);
1834                        let zeros = child.var_no_grad(&Tensor::zeros(&x_m.shape())?);
1835                        let x_safe = Var::where_cond(&sat, &zeros, &x_m)?;
1836                        let c = child.var_no_grad(&Tensor::scalar(C));
1837                        let k = child.var_no_grad(&Tensor::scalar(K));
1838                        let k3 = child.var_no_grad(&Tensor::scalar(3.0 * K));
1839                        let half = child.var_no_grad(&Tensor::scalar(0.5f32));
1840                        let one = child.var_no_grad(&Tensor::scalar(1.0f32));
1841                        let x2 = x_safe.mul(&x_safe)?;
1842                        let x3 = x2.mul(&x_safe)?;
1843                        let u = x_safe.add(&k.mul(&x3)?)?.mul(&c)?;
1844                        let t = u.tanh();
1845                        let sech2 = one.sub(&t.mul(&t)?)?;
1846                        let du = one.add(&k3.mul(&x2)?)?.mul(&c)?;
1847                        let first = one.add(&t)?.mul(&half)?;
1848                        let second = x_safe.mul(&sech2)?.mul(&du)?.mul(&half)?;
1849                        let smooth = first.add(&second)?;
1850                        let factor = Var::where_cond(&sat, &limit, &smooth)?;
1851                        g.mul(&factor)?
1852                    }
1853                    _ => {
1854                        return Err(AutodiffError::Backward(format!(
1855                            "create_graph: build_cgrads: 未対応の ScalarUnaryOp {sop:?}\
1856                             (内部契約違反。scalar_unary_replayable との判定不整合)"
1857                        )));
1858                    }
1859                };
1860                accumulate(&parent_nodes, &mut cgrads, input, da)?;
1861            }
1862            Op::ScalarBinary { op: sop, a, b } => {
1863                let a_shape = parent_nodes[a.0].shape.clone();
1864                let b_shape = parent_nodes[b.0].shape.clone();
1865                if sop.is_comparison() {
1866                    // 比較演算は区分定数で両入力の勾配が恒等的にゼロ
1867                    // (`grad.rs::Op::ScalarBinary` の比較演算分岐と
1868                    // 同型。`0 * upstream` を経由せず直接ゼロ定数を
1869                    // 返し `inf`/`NaN` 汚染を避ける)。
1870                    let da = child.var_no_grad(&Tensor::zeros(&a_shape)?);
1871                    let db = child.var_no_grad(&Tensor::zeros(&b_shape)?);
1872                    accumulate(&parent_nodes, &mut cgrads, a, da)?;
1873                    accumulate(&parent_nodes, &mut cgrads, b, db)?;
1874                } else {
1875                    let out_shape = parent_nodes[id.0].shape.clone();
1876                    let a_m = get_mirror(mirror, a)?;
1877                    let b_m = get_mirror(mirror, b)?;
1878                    // `a_bc`/`b_bc`(child tape 上への broadcast_to view
1879                    // ノード)は Add/Sub/Maximum/Minimum では使わない
1880                    // ため、使う分岐(Mul/Div/Pow)の内側でのみ構築
1881                    // する(review 指摘 2026-09-22。全分岐共通で先頭
1882                    // 構築すると使わない分岐のたびに未使用の子テープ
1883                    // ノードが 2 つ余分に積まれる)。
1884                    let (da_full, db_full) = match sop {
1885                        ScalarBinaryOp::Add => (g, g),
1886                        ScalarBinaryOp::Sub => (g, g.neg()?),
1887                        ScalarBinaryOp::Mul => {
1888                            let a_bc = a_m.broadcast_to(&out_shape)?;
1889                            let b_bc = b_m.broadcast_to(&out_shape)?;
1890                            (g.mul(&b_bc)?, g.mul(&a_bc)?)
1891                        }
1892                        ScalarBinaryOp::Div => {
1893                            let a_bc = a_m.broadcast_to(&out_shape)?;
1894                            let b_bc = b_m.broadcast_to(&out_shape)?;
1895                            let one = child.var_no_grad(&Tensor::scalar(1.0f32));
1896                            let factor_a = one.div(&b_bc)?;
1897                            let factor_b = a_bc.div(&b_bc)?.div(&b_bc)?.neg()?;
1898                            (g.mul(&factor_a)?, g.mul(&factor_b)?)
1899                        }
1900                        ScalarBinaryOp::Pow => {
1901                            let a_bc = a_m.broadcast_to(&out_shape)?;
1902                            let b_bc = b_m.broadcast_to(&out_shape)?;
1903                            let a_val = materialize_fallible(&parent_nodes, parent_ops, a)?;
1904                            let b_val = materialize_fallible(&parent_nodes, parent_ops, b)?;
1905                            let (a_bc_val, b_bc_val) =
1906                                a_val.broadcast_with(b_val).map_err(AutodiffError::Shape)?;
1907                            let mask_b0 = mask_from_pred(&b_bc_val, |v| v == 0.0)?;
1908                            let mask_a0 = mask_from_pred(&a_bc_val, |v| v == 0.0)?;
1909                            let zeros = child.var_no_grad(&Tensor::zeros(&out_shape)?);
1910                            let out_c = get_mirror(mirror, id)?;
1911                            let one = child.var_no_grad(&Tensor::scalar(1.0f32));
1912                            let b_minus_one = b_bc.sub(&one)?;
1913                            // `a_pow`(da 用)・`log(a)`(db 用)はいずれも
1914                            // `where_cond` で選ばれない側の枝として構築
1915                            // されるが、値自体は forward で即座に評価
1916                            // される(child tape は実値を持つ imperative
1917                            // グラフのため)。a==0 のとき `a^(負の指数)`
1918                            // /`log(a)` は inf/-inf になり、この枝を
1919                            // Hessian(child tape を再度 backward する
1920                            // 経路)で微分すると、局所勾配が `1/a` 等の
1921                            // 発散値になる。`where_cond` の逆伝播は不選択
1922                            // 枝へ厳密に 0 を流すが、その 0 が発散した局所
1923                            // 勾配と掛け合わさり `0 * inf = NaN` へ汚染
1924                            // される(review 指摘 2026-09-22。db 側の
1925                            // `a_bc.log()` で顕在化。da 側の `a_bc.pow
1926                            // (b_minus_one)` も `a==0 ∧ b==0` で同型の
1927                            // 危険〈`0^(-1)` 等〉を持つため同じ安全化を
1928                            // 適用する)。危険になりうる入力を選択前に
1929                            // 安全値(`log(1)=0`/`1^e=1`)へ置換して
1930                            // から `log`/`pow` を取ることで、当該枝の
1931                            // 局所勾配自体を有限に保つ(最終的な forward
1932                            // 値は `where_cond` の選択で変わらない)。
1933                            let safe_a_for_pow = Var::where_cond(&mask_b0, &one, &a_bc)?;
1934                            let safe_a_for_log = Var::where_cond(&mask_a0, &one, &a_bc)?;
1935                            let a_pow = safe_a_for_pow.pow(&b_minus_one)?;
1936                            let da_factor = Var::where_cond(&mask_b0, &zeros, &b_bc.mul(&a_pow)?)?;
1937                            let db_factor = Var::where_cond(
1938                                &mask_a0,
1939                                &zeros,
1940                                &out_c.mul(&safe_a_for_log.log()?)?,
1941                            )?;
1942                            (g.mul(&da_factor)?, g.mul(&db_factor)?)
1943                        }
1944                        ScalarBinaryOp::Maximum | ScalarBinaryOp::Minimum => {
1945                            // Maximum/Minimum の勾配は mask 選択で `g` を
1946                            // 直接使う(`grad.rs` の 1 階側と同型)ため
1947                            // `a_bc`/`b_bc`(broadcast_to view ノード)は
1948                            // 不要。構築しない。
1949                            let a_val = materialize_fallible(&parent_nodes, parent_ops, a)?;
1950                            let b_val = materialize_fallible(&parent_nodes, parent_ops, b)?;
1951                            let (a_bc_val, b_bc_val) =
1952                                a_val.broadcast_with(b_val).map_err(AutodiffError::Shape)?;
1953                            let a_favored = matches!(sop, ScalarBinaryOp::Maximum);
1954                            let mask_a_wins = pairwise_mask(&a_bc_val, &b_bc_val, |x, y| {
1955                                !(x.is_nan() || y.is_nan()) && if a_favored { x > y } else { x < y }
1956                            })?;
1957                            let mask_b_wins = pairwise_mask(&a_bc_val, &b_bc_val, |x, y| {
1958                                !(x.is_nan() || y.is_nan()) && if a_favored { x < y } else { x > y }
1959                            })?;
1960                            let mask_tie = pairwise_mask(&a_bc_val, &b_bc_val, |x, y| {
1961                                !(x.is_nan() || y.is_nan()) && x == y
1962                            })?;
1963                            let zeros = child.var_no_grad(&Tensor::zeros(&out_shape)?);
1964                            let half = child.var_no_grad(&Tensor::scalar(0.5f32));
1965                            let half_g = g.mul(&half)?;
1966                            let tie_branch = Var::where_cond(&mask_tie, &half_g, &zeros)?;
1967                            let da = Var::where_cond(&mask_a_wins, &g, &tie_branch)?;
1968                            let db = Var::where_cond(&mask_b_wins, &g, &tie_branch)?;
1969                            (da, db)
1970                        }
1971                        _ => {
1972                            return Err(AutodiffError::Backward(format!(
1973                                "create_graph: build_cgrads: 未対応の ScalarBinaryOp {sop:?}\
1974                                 (内部契約違反。scalar_binary_replayable との判定不整合)"
1975                            )));
1976                        }
1977                    };
1978                    let da = reduce_bias_grad_var(child, &da_full, &a_shape)?;
1979                    let db = reduce_bias_grad_var(child, &db_full, &b_shape)?;
1980                    accumulate(&parent_nodes, &mut cgrads, a, da)?;
1981                    accumulate(&parent_nodes, &mut cgrads, b, db)?;
1982                }
1983            }
1984            Op::RmsNorm { input, weight, eps } => {
1985                let targets = crate::create_graph_norm::Targets {
1986                    input,
1987                    weight,
1988                    bias: None,
1989                };
1990                let contributions = crate::create_graph_norm::rms_norm_cgrads(
1991                    child,
1992                    &parent_nodes,
1993                    parent_ops,
1994                    mirror,
1995                    g,
1996                    &targets,
1997                    eps,
1998                )?;
1999                for (target, v) in contributions {
2000                    accumulate(&parent_nodes, &mut cgrads, target, v)?;
2001                }
2002            }
2003            Op::LayerNorm {
2004                input,
2005                weight,
2006                bias,
2007                eps,
2008            } => {
2009                let targets = crate::create_graph_norm::Targets {
2010                    input,
2011                    weight,
2012                    bias,
2013                };
2014                let contributions = crate::create_graph_norm::layer_norm_cgrads(
2015                    child,
2016                    &parent_nodes,
2017                    parent_ops,
2018                    mirror,
2019                    g,
2020                    &targets,
2021                    eps,
2022                )?;
2023                for (target, v) in contributions {
2024                    accumulate(&parent_nodes, &mut cgrads, target, v)?;
2025                }
2026            }
2027            // 2D 畳み込み族(イシュー #3080。方式は `conv` モジュール doc 参照)。
2028            Op::Conv2d {
2029                input,
2030                weight,
2031                bias,
2032                params,
2033                ..
2034            } => {
2035                let need = conv::ConvNeeds {
2036                    input: parent_nodes[input.0].requires_grad,
2037                    weight: parent_nodes[weight.0].requires_grad,
2038                    bias: bias.is_some_and(|b| parent_nodes[b.0].requires_grad),
2039                };
2040                let bias_shape = bias.map(|b| parent_nodes[b.0].shape.clone());
2041                let r = conv::conv2d_vjp(
2042                    child,
2043                    &g,
2044                    &get_mirror(mirror, input)?,
2045                    &get_mirror(mirror, weight)?,
2046                    bias_shape.as_deref(),
2047                    &params,
2048                    need,
2049                )?;
2050                if let Some(d) = r.d_input {
2051                    accumulate(&parent_nodes, &mut cgrads, input, d)?;
2052                }
2053                if let Some(d) = r.d_weight {
2054                    accumulate(&parent_nodes, &mut cgrads, weight, d)?;
2055                }
2056                if let (Some(b), Some(d)) = (bias, r.d_bias) {
2057                    accumulate(&parent_nodes, &mut cgrads, b, d)?;
2058                }
2059            }
2060            Op::ConvTranspose2d {
2061                input,
2062                weight,
2063                bias,
2064                params,
2065            } => {
2066                let need = conv::ConvNeeds {
2067                    input: parent_nodes[input.0].requires_grad,
2068                    weight: parent_nodes[weight.0].requires_grad,
2069                    bias: bias.is_some_and(|b| parent_nodes[b.0].requires_grad),
2070                };
2071                let bias_shape = bias.map(|b| parent_nodes[b.0].shape.clone());
2072                let r = conv::conv_transpose2d_vjp(
2073                    child,
2074                    &g,
2075                    &get_mirror(mirror, input)?,
2076                    &get_mirror(mirror, weight)?,
2077                    bias_shape.as_deref(),
2078                    &params,
2079                    need,
2080                )?;
2081                if let Some(d) = r.d_input {
2082                    accumulate(&parent_nodes, &mut cgrads, input, d)?;
2083                }
2084                if let Some(d) = r.d_weight {
2085                    accumulate(&parent_nodes, &mut cgrads, weight, d)?;
2086                }
2087                if let (Some(b), Some(d)) = (bias, r.d_bias) {
2088                    accumulate(&parent_nodes, &mut cgrads, b, d)?;
2089                }
2090            }
2091            Op::Unfold { input, params } => {
2092                let input_shape = parent_nodes[input.0].shape.clone();
2093                let d = conv::unfold_vjp(&g, &input_shape, &params)?;
2094                accumulate(&parent_nodes, &mut cgrads, input, d)?;
2095            }
2096            Op::Fold { input, params } => {
2097                let d = conv::fold_vjp(&g, &params)?;
2098                accumulate(&parent_nodes, &mut cgrads, input, d)?;
2099            }
2100            // プーリング族(イシュー #3085。方式は `pool` モジュール doc 参照)。
2101            Op::MaxPool2d { input, index } | Op::MaxPool3d { input, index } => {
2102                if parent_nodes[input.0].requires_grad {
2103                    let input_shape = parent_nodes[input.0].shape.clone();
2104                    let d = pool::max_pool_vjp(child, &g, &input_shape, &index)?;
2105                    accumulate(&parent_nodes, &mut cgrads, input, d)?;
2106                }
2107            }
2108            Op::AvgPool2d {
2109                input,
2110                params,
2111                count_include_pad,
2112            } => {
2113                if parent_nodes[input.0].requires_grad {
2114                    let input_shape = parent_nodes[input.0].shape.clone();
2115                    let d = pool::avg_pool2d_cgrad(
2116                        child,
2117                        &g,
2118                        &input_shape,
2119                        &params,
2120                        count_include_pad,
2121                    )?;
2122                    accumulate(&parent_nodes, &mut cgrads, input, d)?;
2123                }
2124            }
2125            Op::AvgPool3d {
2126                input,
2127                params,
2128                count_include_pad,
2129            } => {
2130                if parent_nodes[input.0].requires_grad {
2131                    let input_shape = parent_nodes[input.0].shape.clone();
2132                    let d = pool::avg_pool3d_cgrad(
2133                        child,
2134                        &g,
2135                        &input_shape,
2136                        &params,
2137                        count_include_pad,
2138                    )?;
2139                    accumulate(&parent_nodes, &mut cgrads, input, d)?;
2140                }
2141            }
2142            Op::AdaptiveAvgPool2d { input } => {
2143                if parent_nodes[input.0].requires_grad {
2144                    let input_shape = parent_nodes[input.0].shape.clone();
2145                    let d = pool::adaptive_avg_pool2d_cgrad(child, &g, &input_shape)?;
2146                    accumulate(&parent_nodes, &mut cgrads, input, d)?;
2147                }
2148            }
2149            // 索引・累積族(イシュー #3083。方式は `index_cum` モジュール doc 参照)。
2150            Op::Gather { input, dim, index }
2151            | Op::Sort { input, dim, index }
2152            | Op::Topk { input, dim, index }
2153            | Op::Cummax { input, dim, index }
2154            | Op::Cummin { input, dim, index } => {
2155                if parent_nodes[input.0].requires_grad {
2156                    let input_shape = parent_nodes[input.0].shape.clone();
2157                    let d = index_cum::gather_like_vjp(child, &g, &input_shape, dim, &index)?;
2158                    accumulate(&parent_nodes, &mut cgrads, input, d)?;
2159                }
2160            }
2161            // Pad 族(イシュー #3084。方式は `pad_mask` モジュール doc 参照)。
2162            Op::Pad { input, pads, .. } => {
2163                if parent_nodes[input.0].requires_grad {
2164                    let input_shape = parent_nodes[input.0].shape.clone();
2165                    let d = pad_mask::pad_cgrad(&g, &input_shape, &pads)?;
2166                    accumulate(&parent_nodes, &mut cgrads, input, d)?;
2167                }
2168            }
2169            Op::MaskedFill { input, mask, .. } => {
2170                if parent_nodes[input.0].requires_grad {
2171                    let d = pad_mask::masked_fill_cgrad(child, &g, &f32_mask_to_bool(&mask)?)?;
2172                    accumulate(&parent_nodes, &mut cgrads, input, d)?;
2173                }
2174            }
2175            Op::PadMode { input, pads, mode } => {
2176                if parent_nodes[input.0].requires_grad {
2177                    let input_shape = parent_nodes[input.0].shape.clone();
2178                    let d = pad_mask::pad_mode_cgrad(child, &g, &input_shape, &pads, mode)?;
2179                    accumulate(&parent_nodes, &mut cgrads, input, d)?;
2180                }
2181            }
2182            Op::Embedding {
2183                weight,
2184                index,
2185                padding_idx,
2186            } => {
2187                if parent_nodes[weight.0].requires_grad {
2188                    let weight_shape = parent_nodes[weight.0].shape.clone();
2189                    let d =
2190                        index_cum::embedding_vjp(child, &g, &weight_shape, &index, padding_idx)?;
2191                    accumulate(&parent_nodes, &mut cgrads, weight, d)?;
2192                }
2193            }
2194            Op::Scatter {
2195                input,
2196                dim,
2197                index,
2198                src,
2199                reduce,
2200            } => {
2201                let input_shape = parent_nodes[input.0].shape.clone();
2202                let src_shape = parent_nodes[src.0].shape.clone();
2203                let r = index_cum::scatter_vjp(
2204                    child,
2205                    &g,
2206                    &index_cum::ScatterSpec {
2207                        input_shape: &input_shape,
2208                        src_shape: &src_shape,
2209                        dim,
2210                        index: &index,
2211                        reduce,
2212                    },
2213                    parent_nodes[input.0].requires_grad,
2214                    parent_nodes[src.0].requires_grad,
2215                )?;
2216                if let Some(d) = r.d_input {
2217                    accumulate(&parent_nodes, &mut cgrads, input, d)?;
2218                }
2219                if let Some(d) = r.d_src {
2220                    accumulate(&parent_nodes, &mut cgrads, src, d)?;
2221                }
2222            }
2223            Op::Cumsum { input, dim } => {
2224                if parent_nodes[input.0].requires_grad {
2225                    let d = index_cum::cumsum_cgrad(child, &g, dim)?;
2226                    accumulate(&parent_nodes, &mut cgrads, input, d)?;
2227                }
2228            }
2229            // FFT 4 件(イシュー #3086。随伴は別の FFT+長さ合わせ)。
2230            Op::Rfft {
2231                input,
2232                n,
2233                dim,
2234                norm,
2235            }
2236            | Op::Irfft {
2237                input,
2238                n,
2239                dim,
2240                norm,
2241            }
2242            | Op::Fft {
2243                input,
2244                n,
2245                dim,
2246                norm,
2247            }
2248            | Op::Ifft {
2249                input,
2250                n,
2251                dim,
2252                norm,
2253            } => {
2254                if parent_nodes[input.0].requires_grad {
2255                    let kind = match &node.op {
2256                        Op::Rfft { .. } => linalg_fft::FftKind::Rfft,
2257                        Op::Irfft { .. } => linalg_fft::FftKind::Irfft,
2258                        Op::Fft { .. } => linalg_fft::FftKind::Fft,
2259                        _ => linalg_fft::FftKind::Ifft,
2260                    };
2261                    let input_shape = parent_nodes[input.0].shape.clone();
2262                    let d = linalg_fft::fft_vjp(child, &g, kind, &input_shape, n, dim, norm)?;
2263                    accumulate(&parent_nodes, &mut cgrads, input, d)?;
2264                }
2265            }
2266            // 線形代数 4 件(イシュー #3086)。構造を積む前に f64 参照で総括的な適用判定を行い、
2267            // 外れる入力は `Err`(呼び出し側のロールバックで `child` は空へ戻る)。
2268            Op::Inv { input } => {
2269                if parent_nodes[input.0].requires_grad {
2270                    let a_val = materialize_fallible(&parent_nodes, parent_ops, input)?;
2271                    linalg_fft::guard_inv(a_val, &value_of(child, &g)?)?;
2272                    let x = get_mirror(mirror, id)?;
2273                    let a_shape = parent_nodes[input.0].shape.clone();
2274                    let d = linalg_fft::inv_cgrad(child, &g, &x, &a_shape)?;
2275                    accumulate(&parent_nodes, &mut cgrads, input, d)?;
2276                }
2277            }
2278            Op::Solve { a, b } => {
2279                if parent_nodes[a.0].requires_grad || parent_nodes[b.0].requires_grad {
2280                    let a_val = materialize_fallible(&parent_nodes, parent_ops, a)?;
2281                    let b_val = materialize_fallible(&parent_nodes, parent_ops, b)?;
2282                    linalg_fft::guard_solve(a_val, b_val, &value_of(child, &g)?)?;
2283                    let a_m = get_mirror(mirror, a)?;
2284                    let x = get_mirror(mirror, id)?;
2285                    let (da, db) = linalg_fft::solve_cgrad(child, &g, &a_m, &x)?;
2286                    accumulate(&parent_nodes, &mut cgrads, a, da)?;
2287                    accumulate(&parent_nodes, &mut cgrads, b, db)?;
2288                }
2289            }
2290            Op::Det { input } | Op::SlogdetLogAbsDet { input } => {
2291                if parent_nodes[input.0].requires_grad {
2292                    let with_det = matches!(node.op, Op::Det { .. });
2293                    let a_val = materialize_fallible(&parent_nodes, parent_ops, input)?;
2294                    linalg_fft::guard_det_like(a_val, &value_of(child, &g)?, with_det)?;
2295                    let a_m = get_mirror(mirror, input)?;
2296                    let scale = if with_det {
2297                        g.mul(&get_mirror(mirror, id)?)?
2298                    } else {
2299                        g
2300                    };
2301                    let d = linalg_fft::det_like_cgrad(child, &scale, &a_m)?;
2302                    accumulate(&parent_nodes, &mut cgrads, input, d)?;
2303                }
2304            }
2305            _ => {
2306                return Err(AutodiffError::Backward(
2307                    "create_graph: build_cgrads: supports_create_graph() が true の未対応 Op\
2308                     (内部契約違反)"
2309                        .into(),
2310                ));
2311            }
2312        }
2313    }
2314
2315    Ok(cgrads)
2316}
2317
2318/// 同一入力ノードへ複数経路から流入した勾配(子テープ上の `Var`)を
2319/// `Var::add` で合算する(`backward.rs::accumulate` の子テープ版)。
2320/// `target` が `requires_grad == false`(`docs/
2321/// autodiff-nograd-leaf-dinput-skip-decision.md` の契約と同型)なら
2322/// 寄与を捨てる——`backward_impl` が `requires_grad == false` の
2323/// ノードへの寄与を `accumulate` へ渡さず捨てるのと同じ意味論
2324/// (`target` の `mirror` は `var_no_grad` の定数葉であり、その勾配を
2325/// 蓄積する意味がないため)。
2326fn accumulate<'c>(
2327    parent_nodes: &[TapeNode],
2328    cgrads: &mut [Option<Var<'c>>],
2329    target: NodeId,
2330    contribution: Var<'c>,
2331) -> Result<(), AutodiffError> {
2332    if !parent_nodes[target.0].requires_grad {
2333        return Ok(());
2334    }
2335    match cgrads[target.0] {
2336        Some(existing) => {
2337            cgrads[target.0] = Some(existing.add(&contribution)?);
2338        }
2339        None => {
2340            cgrads[target.0] = Some(contribution);
2341        }
2342    }
2343    Ok(())
2344}
2345
2346/// broadcast された `v` を `target_shape` へ縮約する(`Var::add`/
2347/// `mul` の VJP が使う `reduce_to_shape`〈`grad.rs`〉の `Var` 演算版)。
2348///
2349/// **数値方式は `grad.rs::reduce_to_shape` と同一のアルゴリズムを
2350/// `Var` 演算列として逐語再現する**(`Var::narrow` で縮約対象軸を
2351/// 長さ 1 のスライスへ分解し、`Var::add` で添字昇順に逐次加算する。
2352/// `reduce_to_shape` の二重ループ `for axis { for a in 0..axis_len {
2353/// reduced[...] += data[src] } }` と同じ縮約順序・同じ f32 逐次和で
2354/// あり、CPU 上の `Var::add` は要素ごとの単純加算〈f64 アキュムレータ
2355/// を挟まない〉のため 1 階 VJP の `reduce_to_shape` と bit 同一になる
2356/// ——当初実装〈`Var::sum_dims`。CPU `sum` の f64 アキュムレータ縮約〉
2357/// では、桁落ちを伴う broadcast 入力〈大きさの異なる値が完全に
2358/// 相殺するケース〉で 1 階勾配と乖離し REQ-2 統一複合判定を満たさない
2359/// 具体例が確認されたため是正した。codex-review 指摘(PR #1998)。
2360fn reduce_to<'c>(
2361    child: &'c Tape,
2362    v: &Var<'c>,
2363    target_shape: &[usize],
2364) -> Result<Var<'c>, AutodiffError> {
2365    let v_shape = v.shape();
2366    if v_shape == target_shape {
2367        return Ok(*v);
2368    }
2369    debug_assert!(
2370        v_shape.len() >= target_shape.len(),
2371        "reduce_to: broadcast 後 shape の rank は入力 rank 以上のはず(契約違反)"
2372    );
2373    let rank_diff = v_shape.len().saturating_sub(target_shape.len());
2374    let mut padded_target = vec![1usize; rank_diff];
2375    padded_target.extend_from_slice(target_shape);
2376
2377    let mut cur = *v;
2378    let mut cur_shape = v_shape;
2379    for axis in 0..cur_shape.len() {
2380        if padded_target[axis] == 1 && cur_shape[axis] != 1 {
2381            let axis_len = cur_shape[axis];
2382            if axis_len == 0 {
2383                // 合法な空テンソル(縮約対象の軸長が 0。例:
2384                // `x: [3]` を `broadcast_to(&[0, 3])` した結果を
2385                // 逆縮約する場合)。`reduce_to_shape`(1 階 VJP)の
2386                // 対応する二重ループは `for a in 0..axis_len {...}`
2387                // が 0 回実行されるため、ゼロ初期化された `reduced`
2388                // バッファがそのまま結果になる(codex-review P2
2389                // 是正・PR #1998)。`Var::narrow(axis, 0, 1)` は
2390                // `axis_len == 0` では範囲外(`NarrowOutOfBounds`)
2391                // になるため呼ばず、縮約後 shape を持つ明示的な
2392                // ゼロ定数へ直接差し替える。
2393                let mut reduced_shape = cur_shape.clone();
2394                reduced_shape[axis] = 1;
2395                cur = child.var_no_grad(&Tensor::zeros(&reduced_shape)?);
2396                cur_shape[axis] = 1;
2397                continue;
2398            }
2399            // `reduce_to_shape` の `for a in 0..axis_len { reduced[..] +=
2400            // data[src] }` を、添字昇順の `Var::narrow`+`Var::add` の
2401            // 逐次連鎖として同じ順序で再現する(f32 逐次和・f64
2402            // アキュムレータなし)。
2403            let mut acc = cur.narrow(axis, 0, 1)?;
2404            for a in 1..axis_len {
2405                let slice = cur.narrow(axis, a, 1)?;
2406                acc = acc.add(&slice)?;
2407            }
2408            cur = acc;
2409            cur_shape[axis] = 1;
2410        }
2411    }
2412    if cur_shape == target_shape {
2413        Ok(cur)
2414    } else {
2415        // `cur_shape` は `padded_target`(先頭 `rank_diff` 個の 1 軸を
2416        // 含む)と一致しているはずであり、要素数は `target_shape` と
2417        // 同一のため `reshape` で先頭の 1 軸を落とせる。
2418        cur.contiguous()?.reshape(target_shape)
2419    }
2420}
2421
2422/// 子テープ上の `v` の実体値を所有値として取り出す(`conv` モジュールの V3
2423/// ノード〈1 階と同じホスト関数の前方値〉の入力値取得に使う)。
2424fn value_of<'c>(child: &'c Tape, v: &Var<'c>) -> Result<Tensor<f32>, AutodiffError> {
2425    let nodes = child.nodes.borrow();
2426    Ok(materialize_fallible(&nodes, child.ops(), v.node_id())?.clone())
2427}
2428
2429/// `Op::Add` の bias パターンに限り `grad.rs::reduce_bias_grad`(f64
2430/// 相当のアキュムレータで行方向を縮約する)と数値方式を揃える
2431/// `reduce_to` の薄いラッパー(codex-review 指摘・PR #1998 是正)。
2432///
2433/// **背景**: `.claude/rules/coding-rust.md` の「勾配の長軸縮約は `f64`
2434/// アキュムレータで統一する」契約は `Op::LinearResident`/
2435/// `Op::LinearAct` の bias フォールバックに加え、2026-09-12 ユーザー
2436/// 承認により `Op::Add`(`nn::Linear` 既定 forward 経路 `matmul → add`
2437/// が経由する)の bias パターンへも横展開済み(`grad.rs::
2438/// reduce_bias_grad`)。本モジュール(子テープ上の `Var` 演算列)の
2439/// `reduce_to` は 1 階 `reduce_to_shape`(`f32` 逐次和・bias 以外の
2440/// 一般的な broadcast 縮約)を逐語再現する設計のため、`Op::Add` を
2441/// 無条件に `reduce_to` へ委譲すると bias パターンでのみ 1 階勾配
2442/// (`reduce_bias_grad` 経由)と符号レベルで乖離する(例:
2443/// `x: [3, 1]`・`b: [1]`・`c = [1e8, 1, -1e8]: [3, 1]` に対する
2444/// `loss = ((x + b) * c).sum()` で `b` の 1 階勾配は `1` だが `f32`
2445/// 逐次和では丸め誤差により `0` になる)。
2446///
2447/// **判定条件は `reduce_bias_grad` と同一**(`g_shape.len() == 2` かつ
2448/// `target_shape` が「軸 0 方向の縮約」を表す形状——末尾次元が `g` の
2449/// 列数と一致し、それより前の全次元が `1`)。条件を満たさない場合は
2450/// 従来どおり [`reduce_to`] へそのまま委譲し挙動を変えない(bias
2451/// パターン限定の横展開であり、汎用 `Op::Add`・`reduce_to` 本体は
2452/// 不変)。
2453///
2454/// **数値方式の再現方法**: `Var::sum(Some(0))` は `child.ops().sum()`
2455/// (`BackendOps` 実装依存。テスト用 `naive_ops()` は `f32` 逐次和・
2456/// 本番 `backend-cpu` は `f64` アキュムレータの `axis_reduce_sum` など
2457/// バックエンドごとに異なる)を経由するため、`Var::sum` へは委譲
2458/// **しない**(当初案は `naive_ops()` を使う統合テストで 1 階 VJP と
2459/// 再度乖離する回帰があった。実装時の実測で判明)。代わりに
2460/// `eval::reduce_bias_grad_rows`(`grad.rs::reduce_bias_grad` が呼ぶ
2461/// のと**同一のホスト関数**。行 `0..m` を列ごとに `f64` で逐次加算し
2462/// 最後に 1 回 `f32` へ downcast。`m == 1` の短絡〈符号付きゼロ保持〉
2463/// も同関数内で処理される)を直接呼んでホスト側で縮約後の値を計算し、
2464/// [`Tape::push_eager`](`pub(crate)`)で `Op::Sum { input: g.id, dim:
2465/// Some(0) }` として子テープへ登録する。`child.ops()` の実装(テスト
2466/// 用 `naive_ops()` を含む)に依存せず 1 階 VJP と常に一致する。
2467///
2468/// `Op` タグを `Op::Sum { dim: Some(0) }` のまま維持することは、
2469/// `child.backward(..)`(さらなる微分。設計上 3 階微分は対象外だが、
2470/// 2 階勾配 `cg.grad(&b)` 自身を通常の `Var` として扱うために必要)に
2471/// 対する整合性を壊さない——`grad.rs::Op::Sum` の VJP(上流勾配を
2472/// `input` の shape へ broadcast するのみ)は前方値の精度に一切
2473/// 依存しないため、値を host 側で計算し直しても VJP の正しさは
2474/// 影響を受けない。
2475fn reduce_bias_grad_var<'c>(
2476    child: &'c Tape,
2477    g: &Var<'c>,
2478    target_shape: &[usize],
2479) -> Result<Var<'c>, AutodiffError> {
2480    let g_shape = g.shape();
2481    let is_row_axis_reduction = g_shape.len() == 2
2482        && target_shape.last() == Some(&g_shape[1])
2483        && target_shape[..target_shape.len().saturating_sub(1)]
2484            .iter()
2485            .all(|&d| d == 1);
2486    if !is_row_axis_reduction {
2487        return reduce_to(child, g, target_shape);
2488    }
2489    let g_val = {
2490        let nodes = child.nodes.borrow();
2491        materialize_fallible(&nodes, child.ops(), g.node_id())?.clone()
2492    };
2493    let reduced = crate::eval::reduce_bias_grad_rows(&g_val);
2494    let value = Tensor::new(reduced, &[g_shape[1]])?;
2495    let id = child.push_eager(
2496        Op::Sum {
2497            input: g.node_id(),
2498            dim: Some(0),
2499        },
2500        value,
2501    );
2502    Var::from_raw(child, id).reshape(target_shape)
2503}
2504
2505/// `t`(親テープ側の実測値)の各要素が正かどうかを表す `Tensor<bool>`
2506/// を構築する(`Op::Relu` の VJP マスク。`v > 0.0`。NaN はマスク
2507/// 不成立——`grad.rs::elementwise_mul_mask` が `Op::Relu` に適用する
2508/// 規約と同じ)。
2509fn positive_mask(t: &Tensor<f32>) -> Result<Tensor<bool>, AutodiffError> {
2510    mask_from_pred(t, |v| v > 0.0)
2511}
2512
2513/// `t`(親テープ側の実測値)の各要素へ述語 `pred` を適用した
2514/// `Tensor<bool>` を構築する([`positive_mask`] の一般化。イシュー
2515/// #2062。`ScalarUnaryOp` の区分定数な導関数——`Abs`・`LeakyRelu`・
2516/// `Elu`・`Softplus`・`Clamp`・`Hardswish`——が使う host マスク構築の
2517/// 単一情報源)。
2518fn mask_from_pred(
2519    t: &Tensor<f32>,
2520    pred: impl Fn(f32) -> bool,
2521) -> Result<Tensor<bool>, AutodiffError> {
2522    let contiguous = t.contiguous();
2523    let data: Vec<bool> = contiguous
2524        .as_slice()
2525        .map(|s| s.iter().map(|&v| pred(v)).collect())
2526        .unwrap_or_default();
2527    Tensor::new(data, contiguous.shape()).map_err(AutodiffError::from)
2528}
2529
2530/// broadcast 後の 2 テンソル(同一 shape)へ 2 項述語 `pred` を適用した
2531/// `Tensor<bool>` を構築する([`mask_from_pred`] の 2 項版。
2532/// `ScalarBinaryOp::Pow`(`a == 0`/`b == 0` ガード)・`Maximum`/
2533/// `Minimum`(勝ち/タイ/`NaN` 判定)が使う。`a`/`b` は呼び出し元が
2534/// 既に同一 shape へ broadcast 済みであること(`Tensor::broadcast_
2535/// with` の戻り値等)。
2536fn pairwise_mask(
2537    a: &Tensor<f32>,
2538    b: &Tensor<f32>,
2539    pred: impl Fn(f32, f32) -> bool,
2540) -> Result<Tensor<bool>, AutodiffError> {
2541    let a_c = a.contiguous();
2542    let b_c = b.contiguous();
2543    let data: Vec<bool> = match (a_c.as_slice(), b_c.as_slice()) {
2544        (Some(sa), Some(sb)) => sa
2545            .iter()
2546            .zip(sb.iter())
2547            .map(|(&x, &y)| pred(x, y))
2548            .collect(),
2549        _ => Vec::new(),
2550    };
2551    Tensor::new(data, a_c.shape()).map_err(AutodiffError::from)
2552}
2553
2554/// 損失 Op(イシュー #3082・決定記録 §26)の上流勾配 `g`(出力 shape `[]`)
2555/// から、`shape`(入力の要素 shape)へ broadcast した scale を作る。演算順は
2556/// 1 階 VJP に合わせる(`mse_loss_scale`: Mean は `(g·2)/n`・Sum は `g·2`。
2557/// その他の損失: Mean は `g/n`・Sum は `g`)。`g` は子テープ上の `Var` のまま
2558/// 扱い、損失が根でない場合や孫テープ再生でも二階の勾配が `g` を通って流れる。
2559fn loss_scale_bcast<'c>(
2560    child: &'c Tape,
2561    g: &Var<'c>,
2562    reduction: crate::var::Reduction,
2563    mse: bool,
2564    n: usize,
2565    shape: &[usize],
2566) -> Result<Var<'c>, AutodiffError> {
2567    let g2 = if mse {
2568        g.mul(&child.var_no_grad(&Tensor::scalar(2.0f32)))?
2569    } else {
2570        *g
2571    };
2572    let s = match reduction {
2573        crate::var::Reduction::Mean => g2.div(&child.var_no_grad(&Tensor::scalar(n as f32)))?,
2574        crate::var::Reduction::Sum => g2,
2575    };
2576    s.broadcast_to(shape)
2577}
2578
2579/// クラス軸を除いたサンプル数 `outer · inner`(`CrossEntropyLoss`/`NllLoss` の
2580/// `Mean` の分母。1 階 `cross_entropy_loss_vjp`/`nll_loss_vjp` と同じ)。
2581fn class_sample_count(shape: &[usize], class_dim: usize) -> usize {
2582    let outer: usize = shape[..class_dim].iter().product();
2583    let inner: usize = shape[class_dim + 1..].iter().product();
2584    outer * inner
2585}
2586
2587/// `targets`(クラス添字)の onehot 位置を `(o·C + c)·inner + i` の平坦添字で
2588/// 列挙する。範囲外の添字は 1 階 VJP と同じくスキップする(forward が検査済み。
2589/// panic・範囲外アクセスを起こさない)。
2590fn onehot_positions(targets: &Tensor<i32>, shape: &[usize], class_dim: usize) -> Vec<usize> {
2591    let outer: usize = shape[..class_dim].iter().product();
2592    let axis_len = shape[class_dim];
2593    let inner: usize = shape[class_dim + 1..].iter().product();
2594    let data = crate::eval::dense_vec_i32(targets);
2595    let mut out = Vec::with_capacity(outer * inner);
2596    for o in 0..outer {
2597        for i in 0..inner {
2598            let Some(&t) = data.get(o * inner + i) else {
2599                continue;
2600            };
2601            if t >= 0 && (t as usize) < axis_len {
2602                out.push((o * axis_len + t as usize) * inner + i);
2603            }
2604        }
2605    }
2606    out
2607}
2608
2609/// onehot(f32。`CrossEntropyLoss` の `softmax − onehot` 用の定数)。
2610fn onehot_f32(
2611    targets: &Tensor<i32>,
2612    shape: &[usize],
2613    class_dim: usize,
2614) -> Result<Tensor<f32>, AutodiffError> {
2615    let mut data = vec![0f32; shape.iter().product()];
2616    for idx in onehot_positions(targets, shape, class_dim) {
2617        data[idx] = 1.0;
2618    }
2619    Tensor::new(data, shape).map_err(AutodiffError::from)
2620}
2621
2622/// onehot マスク(bool。`NllLoss` の target 位置選択用)。
2623fn onehot_mask(
2624    targets: &Tensor<i32>,
2625    shape: &[usize],
2626    class_dim: usize,
2627) -> Result<Tensor<bool>, AutodiffError> {
2628    let mut data = vec![false; shape.iter().product()];
2629    for idx in onehot_positions(targets, shape, class_dim) {
2630        data[idx] = true;
2631    }
2632    Tensor::new(data, shape).map_err(AutodiffError::from)
2633}
2634
2635/// [`Op::Where`] が `Op` payload に保持する `cond`(forward 時点で
2636/// `out_shape` へ broadcast 済みの f32 マスク。`c != 0.0` 判定契約。
2637/// `tape.rs::Op::Where` doc 参照)を `Var::where_cond` が要求する
2638/// `Tensor<bool>` へ変換する(イシュー #2062)。
2639fn f32_mask_to_bool(cond: &Tensor<f32>) -> Result<Tensor<bool>, AutodiffError> {
2640    mask_from_pred(cond, |v| v != 0.0)
2641}
2642
2643#[cfg(test)]
2644mod contiguous_tests {
2645    use super::*;
2646
2647    /// `Op::Contiguous`(`Var::contiguous`)の create_graph 対応
2648    /// (イシュー #2062)。`Var::contiguous` は `pub(crate)` のため
2649    /// `crates/autodiff/tests/create_graph.rs`(別クレート扱いの統合
2650    /// テスト)から直接呼べず、本クレート内部の単体テストで検証する。
2651    ///
2652    /// `x: [2, 2]` を permute(非 contiguous view)してから
2653    /// `contiguous()` を挟み `sum(y * y)` を取る。`Op::Permute` と同じ
2654    /// 添字並べ替えのため、二次形式全体としては `sum(x * x)` と数値上
2655    /// 同値になる——`finite_diff_hessian` 相当の独立クロスチェックは
2656    /// 統合テスト側の `hessian_permute_quadratic_matches_finite_
2657    /// difference` が既に担っており、本テストは「`Op::Contiguous` の
2658    /// replay/VJP が恒等として正しく機能する」ことを対角 Hessian が
2659    /// `2` になることで確認する(`Tape::backward` 経由の 1 階勾配とも
2660    /// 突合する)。
2661    #[test]
2662    fn contiguous_replay_and_vjp_are_identity() {
2663        let tape = Tape::new_with_ops(crate::default_ops::naive_ops());
2664        let child = Tape::new_with_ops(crate::default_ops::naive_ops());
2665        let x = tape.var(&Tensor::new(vec![1.0f32, -2.0, 0.5, 3.0], &[2, 2]).unwrap());
2666        let y = x.permute(&[1, 0]).unwrap().contiguous().unwrap();
2667        let loss = y.mul(&y).unwrap().sum(None).unwrap();
2668
2669        let cg = tape
2670            .backward_create_graph(&loss, &child)
2671            .expect("backward_create_graph に失敗(Op::Contiguous は対象)");
2672        let gx = cg
2673            .grad(&x)
2674            .expect("grad: クロステープ検査は通るはず")
2675            .expect("x は loss に到達するはず(1 階勾配)");
2676
2677        // 1 階勾配が `Tape::backward`(既存経路)と一致すること
2678        // (`d/dx sum(permute(x)^2) = 2x`)。
2679        let expected = cg.first_order().get(&x).unwrap().unwrap();
2680        assert_eq!(gx.to_tensor().as_slice(), expected.as_slice());
2681
2682        // 二階微分(対角 Hessian = 2)。
2683        let cx = cg.child_var(&x).unwrap().unwrap();
2684        let row0 = child
2685            .backward(
2686                &gx.narrow(0, 0, 1)
2687                    .unwrap()
2688                    .narrow(1, 0, 1)
2689                    .unwrap()
2690                    .sum(None)
2691                    .unwrap(),
2692            )
2693            .unwrap();
2694        let d2 = row0.get(&cx).unwrap().unwrap();
2695        assert_eq!(d2.get(&[0, 0]).unwrap(), 2.0);
2696    }
2697}
2698
2699/// `ScalarBinaryOp::Maximum`/`Minimum`(イシュー #2062)の
2700/// `create_graph` 対応を検証する単体テスト。`Var::scalar_binary` は
2701/// `pub(crate)` のため `crates/autodiff/tests/create_graph.rs`(別
2702/// クレート扱いの統合テスト)から直接呼べず、`contiguous_tests` と
2703/// 同じ理由で本クレート内部の単体テストに置く。
2704///
2705/// `build_cgrads` の Maximum/Minimum 分岐(`pairwise_mask` による
2706/// 勝ち/タイ判定。`NaN` はいずれのマスクにも一致せず勾配 0)を、
2707/// 「a 勝ち」「b 勝ち」「タイ」の 3 要素を 1 テンソルへ詰めて検証する。
2708/// `a = x^2`(曲率 2)・`b` は `x` に依存しない定数(曲率 0)とし、
2709/// 要素ごとに `a`/`b` の大小関係を作為的に作る:
2710/// - index 0: `a`(`x^2 = 9`)> `b`(`0.2`)→ a 勝ち。曲率は `a` の
2711///   曲率 `2` がそのまま伝播する
2712/// - index 1: `a`(`x^2 = 0.01`)< `b`(`30.0`)→ b 勝ち。`b` は `x`
2713///   非依存のため出力は `x` に無関係——曲率は恒等的に 0
2714/// - index 2: `a == b`(`x^2` の値をそのまま `b` の定数へコピーして
2715///   タイを固定的に作る)→ タイ分岐(勾配 `0.5` 分割)。`a` 側にのみ
2716///   曲率が伝播するため実効曲率は `0.5 * 2 = 1`
2717#[cfg(test)]
2718mod maximum_minimum_tests {
2719    use super::*;
2720
2721    fn maximum_probe<'t>(tape: &'t Tape, x0: &[f32; 3]) -> (Tape, Var<'t>, Var<'t>) {
2722        // a = x^2(曲率 2)。b は x 非依存の定数(曲率 0)で、index ごと
2723        // に a 勝ち/b 勝ち/タイを作為的に配置する。
2724        let x = tape.var(&Tensor::new(x0.to_vec(), &[3]).unwrap());
2725        let a = x.mul(&x).unwrap();
2726        let b_data = vec![0.2f32, 30.0, x0[2] * x0[2]];
2727        let b = tape.var_no_grad(&Tensor::new(b_data, &[3]).unwrap());
2728        let loss = a
2729            .scalar_binary(&b, ScalarBinaryOp::Maximum)
2730            .unwrap()
2731            .sum(None)
2732            .unwrap();
2733        let child = Tape::new_with_ops(crate::default_ops::naive_ops());
2734        (child, x, loss)
2735    }
2736
2737    #[test]
2738    fn hessian_maximum_win_lose_tie_matches_expected_curvature() {
2739        let x0 = [3.0f32, 0.1, -2.0];
2740        let tape = Tape::new_with_ops(crate::default_ops::naive_ops());
2741        let (child, x, loss) = maximum_probe(&tape, &x0);
2742
2743        let cg = tape
2744            .backward_create_graph(&loss, &child)
2745            .expect("backward_create_graph に失敗(Op::ScalarBinary::Maximum は対象)");
2746        let gx = cg.grad(&x).unwrap().unwrap();
2747        let cx = cg.child_var(&x).unwrap().unwrap();
2748
2749        let mut diag = [0.0f32; 3];
2750        for (i, d) in diag.iter_mut().enumerate() {
2751            let gi = gx.narrow(0, i, 1).unwrap().sum(None).unwrap();
2752            let row = child.backward(&gi).unwrap();
2753            let d2 = row.get(&cx).unwrap().unwrap();
2754            *d = d2.get(&[i]).unwrap();
2755        }
2756        assert_eq!(diag[0], 2.0, "index0(a 勝ち)は a=x^2 の曲率 2 のはず");
2757        assert_eq!(
2758            diag[1], 0.0,
2759            "index1(b 勝ち。b は x 非依存)は曲率 0 のはず"
2760        );
2761        assert_eq!(diag[2], 1.0, "index2(タイ。0.5 分割)は 0.5*2=1 のはず");
2762    }
2763
2764    #[test]
2765    fn hessian_minimum_win_lose_tie_matches_expected_curvature() {
2766        // Minimum は勝敗の向きが逆になるだけ(`a_favored = false`)。
2767        // a 勝ち(Minimum なので a < b): index0 は a=x^2=0.01 < b=30
2768        // → a 勝ち。index1 は a=x^2=9 > b=0.2 → b 勝ち。index2 はタイ。
2769        let x0 = [0.1f32, 3.0, -2.0];
2770        let tape = Tape::new_with_ops(crate::default_ops::naive_ops());
2771        let x = tape.var(&Tensor::new(x0.to_vec(), &[3]).unwrap());
2772        let a = x.mul(&x).unwrap();
2773        let b_data = vec![30.0f32, 0.2, x0[2] * x0[2]];
2774        let b = tape.var_no_grad(&Tensor::new(b_data, &[3]).unwrap());
2775        let loss = a
2776            .scalar_binary(&b, ScalarBinaryOp::Minimum)
2777            .unwrap()
2778            .sum(None)
2779            .unwrap();
2780        let child = Tape::new_with_ops(crate::default_ops::naive_ops());
2781
2782        let cg = tape
2783            .backward_create_graph(&loss, &child)
2784            .expect("backward_create_graph に失敗(Op::ScalarBinary::Minimum は対象)");
2785        let gx = cg.grad(&x).unwrap().unwrap();
2786        let cx = cg.child_var(&x).unwrap().unwrap();
2787
2788        let mut diag = [0.0f32; 3];
2789        for (i, d) in diag.iter_mut().enumerate() {
2790            let gi = gx.narrow(0, i, 1).unwrap().sum(None).unwrap();
2791            let row = child.backward(&gi).unwrap();
2792            let d2 = row.get(&cx).unwrap().unwrap();
2793            *d = d2.get(&[i]).unwrap();
2794        }
2795        assert_eq!(diag[0], 2.0, "index0(a 勝ち)は a=x^2 の曲率 2 のはず");
2796        assert_eq!(
2797            diag[1], 0.0,
2798            "index1(b 勝ち。b は x 非依存)は曲率 0 のはず"
2799        );
2800        assert_eq!(diag[2], 1.0, "index2(タイ。0.5 分割)は 0.5*2=1 のはず");
2801    }
2802
2803    #[test]
2804    fn maximum_nan_element_grad_is_zero() {
2805        // `NaN` を含む要素は `pairwise_mask` のいずれのマスク(勝ち/
2806        // タイ)にも一致しない(`!(x.is_nan() || y.is_nan())` ガード)
2807        // ため、`Maximum` 自体の両入力への勾配は 0 になる(`eval/
2808        // scalar.rs` の 1 階契約 `binary_partials(Maximum, NaN, ..)`
2809        // と同型の意味論)。`a` は `x` の恒等写像(`a = x`。曲率 0)と
2810        // し、`x^2` 等の曲率を持つ合成にしない——`0 * NaN = NaN`
2811        // という IEEE 754 の連鎖律で「a=x^2 のような合成先の勾配が
2812        // NaN 汚染される」現象は本テストの検証対象(Maximum 自体の
2813        // ゼロ勾配契約)とは別の話のため混同しない。
2814        let tape = Tape::new_with_ops(crate::default_ops::naive_ops());
2815        let child = Tape::new_with_ops(crate::default_ops::naive_ops());
2816        let x = tape.var(&Tensor::new(vec![f32::NAN, 1.0, 2.0], &[3]).unwrap());
2817        let b = tape.var_no_grad(&Tensor::new(vec![1.0f32, 2.0, 0.5], &[3]).unwrap());
2818        let loss = x
2819            .scalar_binary(&b, ScalarBinaryOp::Maximum)
2820            .unwrap()
2821            .sum(None)
2822            .unwrap();
2823
2824        let cg = tape
2825            .backward_create_graph(&loss, &child)
2826            .expect("backward_create_graph に失敗");
2827        let gx = cg.grad(&x).unwrap().unwrap();
2828        let g0 = gx.to_tensor().get(&[0]).expect("shape 範囲内のはず");
2829        assert_eq!(g0, 0.0, "NaN 要素の 1 階勾配は 0 のはず");
2830
2831        // 子テープ上に記録した 1 階勾配(`gx`。`build_cgrads` が
2832        // `Var` 演算列として再構成した値)が、既存 1 階経路
2833        // (`Tape::backward`。`backward.rs::backward_impl` 無変更)の
2834        // 結果である `cg.first_order()` と bit 完全一致することを
2835        // 確認する(`contiguous_replay_and_vjp_are_identity` と同型の
2836        // 突合方式)。NaN 要素(index0)を含む全要素で一致することが、
2837        // `pairwise_mask` の NaN 除外ガードが 1 階側の `eval::scalar::
2838        // binary_partials` 契約と矛盾なく再現されていることの根拠になる。
2839        let expected = cg.first_order().get(&x).unwrap().unwrap();
2840        assert_eq!(
2841            gx.to_tensor().as_slice(),
2842            expected.as_slice(),
2843            "子テープ上の 1 階勾配が既存 Tape::backward 経路と一致しない"
2844        );
2845    }
2846}
2847
2848/// イシュー #2649: 活性化 5 kind は `create_graph`(高階微分)の対象外で、
2849/// `scalar_unary_replayable` が `false`(型付きエラーで拒否・panic しない)を
2850/// 返し続けることを固定する。
2851#[cfg(test)]
2852mod new_2649_tests {
2853    use super::*;
2854
2855    #[test]
2856    fn gelu_family_kinds_are_replayable() {
2857        // #3081: Gelu・GeluTanh と、Gelu の Φ を記録する Erf(閉包性)。
2858        assert!(scalar_unary_replayable(ScalarUnaryOp::Gelu));
2859        assert!(scalar_unary_replayable(ScalarUnaryOp::GeluTanh));
2860        assert!(scalar_unary_replayable(ScalarUnaryOp::Erf));
2861    }
2862
2863    #[test]
2864    fn new_2649_kinds_are_not_replayable() {
2865        for op in [
2866            ScalarUnaryOp::Selu,
2867            ScalarUnaryOp::Celu { alpha: 1.0 },
2868            ScalarUnaryOp::Softsign,
2869            ScalarUnaryOp::Hardsigmoid,
2870            ScalarUnaryOp::LogSigmoid,
2871        ] {
2872            assert!(!scalar_unary_replayable(op), "{op:?}");
2873        }
2874    }
2875}
2876/// 低精度 `Op::Conv2d` の fail-closed(イシュー #3080・決定記録 §21.3)。
2877/// `Var::conv2d_low_precision` は `pub(crate)`、F16 の typed ops を持つ
2878/// テスト用バックエンド(`test_support`)も crate 内限定のため、統合テスト
2879/// ではなく本クレート内の単体テストで検証する。
2880#[cfg(test)]
2881mod conv_low_precision_tests {
2882    use super::*;
2883    use fandhe_ai_tensor_core::ScalarDType;
2884
2885    #[test]
2886    fn low_precision_conv2d_is_rejected_and_child_stays_empty() {
2887        let tape = Tape::new_with_ops(crate::test_support::low_precision_test_ops());
2888        let child = Tape::new_with_ops(crate::default_ops::naive_ops());
2889        let x = tape.var(&Tensor::full(&[1, 1, 3, 3], 0.5f32).unwrap());
2890        let w = tape.var(&Tensor::full(&[1, 1, 2, 2], 0.25f32).unwrap());
2891        let y = x
2892            .conv2d_low_precision(&w, None, [1, 1], [0, 0], [1, 1], 1, ScalarDType::F16)
2893            .unwrap();
2894        let loss = y.sum(None).unwrap();
2895        let err = tape
2896            .backward_create_graph(&loss, &child)
2897            .expect_err("低精度 conv2d は fail-closed で拒否されるはず");
2898        assert!(matches!(err, AutodiffError::Backward(_)), "{err:?}");
2899        assert!(child.is_empty(), "入口検査は child へ書き込む前に拒否する");
2900    }
2901}