fandhe-ai 0.13.0

Facade crate for a from-scratch Rust AI/ML library: tensors, autodiff, and CPU/CUDA/Metal backends behind one composition root
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
//! Functional API(多入力・多出力グラフ。Keras Functional API 相当)の実装と公開型(イシュー #2665・親 #2663・
//! ルート #2499 Phase 4。公開は #2679)。
//!
//! Keras Functional API 相当を facade に段階導入した第 2 段で、**層ノードからなる DAG の構築**
//! ([`FunctionalBuilder`])と**挿入順(= トポロジカル順)の forward**([`FunctionalModel`])を提供する。
//! 設計の正は `docs/facade-functional-api-decision.md`(§3 グラフ構築・§4 forward・§10 公開形・§11 配置・
//! §12 OWASP 観点・§16〜§18 学習・保存・公開の実装記録)。
//!
//! # 公開範囲(イシュー #2679)
//!
//! 承認(ルート #2499 のコメント issuecomment-6033824965。同記録 §10・§13)に従い、
//! `fandhe_ai::compat` から [`FunctionalBuilder`]・[`FunctionalModel`]・[`Node`] と保存・復元の入口
//! `save_functional_model`/`load_functional_model` を公開する(`compat/mod.rs` の `pub use`)。**学習用の
//! 束縛結果型 `FunctionalVars` は非公開のまま**(`FunctionalModel::bind` も `pub(crate)`。`fit`/`evaluate` の
//! 内部が使う)。モジュール `functional` 自体も非公開で、公開面は `api_surface.rs` の
//! `facade_exposes_functional_api_only_in_approved_shape`(承認形のインベントリ)と
//! `lib.rs::FunctionalApiHoldDoctestGuard`(未承認経路のプローブ)が固定する。
//!
//! 公開メソッドは `FunctionalBuilder::{new, input, apply, concatenate, add, multiply, average,
//! max_pool{1,2,3}d_with_indices, max_unpool{1,2,3}d, build}`(MaxPool 索引・MaxUnpool は #3036。
//! `Sequential` には載せず本ビルダー限定。`docs/compat-sequential-3d-fold-lrn-layers-decision.md` §6。
//! 保存・復元は #3037 で対応済み。manifest の op 6 種を追加、`format_version` 1 据え置き)、
//! `FunctionalModel::{forward, predict, set_training, train, eval, training, named_parameters, state_dict,
//! load_state_dict, trainable_parameters, apply_parameters, compile, fit, evaluate}`。doc から非公開項目
//! (`FunctionalVars`・`bind`)へ intra-doc link は張らない(`rustdoc::private_intra_doc_links` を避けるため)。
//!
//! # 層ノードの単位と第 1 段の範囲
//!
//! ノードの単位は `compat::Sequential` ブロック(所有渡し)。層語彙・保存・学習・`add_module`
//! を再利用でき、層語彙を二重に持たない(REQ-9「薄いラッパー」)。本段が扱うのは
//! 入力ノード・ブロックノード・fan-out(1 ノードを複数ブロックが消費)・多入力・多出力・
//! 結合ノード(Concatenate/Add/Multiply/Average。イシュー #2666)まで。結合ノードは
//! [`FunctionalBuilder::concatenate`]・`add`・`multiply`・`average` で追加し、数値は
//! `fandhe_ai_autodiff::merge_ops`(既存 `Var` 演算の合成)へ委譲する。結合ノードは層を
//! 持たないため通し番号キーに影響しない。学習(`trainable_parameters`/`apply_parameters`/
//! `compile`/`fit`/`evaluate`。イシュー #2667)は子モジュール `train`、保存・復元
//! (`save_functional_model`/`load_functional_model`)は `model_io::functional_io` が担う
//! (設計記録 §18)。ノード種別は
//! 内部 enum とし入力添字を複数持てる形にしてある。shape の構築時推論は行わず(層側に推論 API
//! が無い)、不整合は forward 時に既存 `Var` 演算の型付きエラーで検出する。
//!
//! 数値は既存 `Var` 演算(`Sequential::forward`)の合成のみで、新規 `Op`・`BackendOps`
//! メソッド・VJP を追加しない。GPU は `crate::tape_for` で構築した tape 上でそのまま到達できる。
//! エラーはすべて `AutodiffError`(意味論エラーは `InvalidArgument`)で、本番経路に
//! `unwrap`/`expect`/添字 panic を置かない。`build`/`forward` は反復のみで再帰しない。

use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};

use fandhe_ai_autodiff::merge_ops;
use fandhe_ai_autodiff::nn::{MaxUnpool1d, MaxUnpool2d, MaxUnpool3d};

use super::Sequential;
use super::SequentialVars;
use super::training::{Compiled, CompiledSnapshot, compiled_from_snapshot, snapshot_of_compiled};
use crate::{AutodiffError, Tape, Tensor, Var};

mod train;

#[cfg(test)]
mod fit_parity_tests;

#[cfg(test)]
mod fit_tests;

#[cfg(test)]
mod tests;

#[cfg(test)]
mod merge_tests;

/// ビルダー ID の採番器。他ビルダー由来のハンドルの取り違えを検出するためのプロセス内一意値。
static NEXT_BUILDER_ID: AtomicU64 = AtomicU64::new(1);

/// 意味論エラー(`InvalidArgument`)を作る。メッセージには添字・件数のみを入れる。
fn invalid(message: String) -> AutodiffError {
    AutodiffError::InvalidArgument(message)
}

/// `var` が `tape` に属さなければ `TapeMismatch` を返す。
///
/// 所属判定は `autodiff::Tape::owns`(ノードを積まない読み取り専用判定)で行うため、不一致で
/// 拒否しても `tape` のノード数・メモリは増えない。
fn ensure_on_tape(tape: &Tape, var: &Var<'_>) -> Result<(), AutodiffError> {
    if !tape.0.owns(var) {
        return Err(AutodiffError::TapeMismatch);
    }
    Ok(())
}

/// [`FunctionalBuilder`] が返すノードハンドル(Keras の「シンボリックテンソル」相当の添字)。
///
/// 発行元ビルダーの ID と添字だけを持つ `Copy` 値で、他ビルダーのハンドルは `apply`/`build`
/// が拒否する。添字は発行元ビルダーのノード列への位置で、ノードは自分より前の添字しか参照
/// できないため、構築時点で DAG が保証され挿入順がそのままトポロジカル順になる。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Node {
    builder_id: u64,
    index: usize,
}

/// 結合ノードの種別(非公開。Keras の `Concatenate`/`Add`/`Multiply`/`Average` 相当)。
/// 数値は `merge_ops` の自由関数が持ち、ここは結線上の種別だけを表す。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum MergeKind {
    /// 軸 `dim` で連結する。
    Concatenate { dim: usize },
    /// 要素ごとの加算。
    Add,
    /// 要素ごとの乗算。
    Multiply,
    /// 要素ごとの平均。
    Average,
}

/// `max_pool*_with_indices` ノードの次元別引数(非公開。`ceil_mode=false` 固定)。
/// 引数型は `Var::max_pool{1,2,3}d` と一致させ、`stride: None` は raw のまま保持する。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum PoolSpec {
    /// 1D(入力 `[N, C, L]`)。
    D1 {
        kernel_size: usize,
        stride: Option<usize>,
        padding: usize,
        dilation: usize,
    },
    /// 2D(入力 `[N, C, H, W]`)。
    D2 {
        kernel_size: [usize; 2],
        stride: Option<[usize; 2]>,
        padding: [usize; 2],
        dilation: [usize; 2],
    },
    /// 3D(入力 `[N, C, D, H, W]`)。
    D3 {
        kernel_size: [usize; 3],
        stride: Option<[usize; 3]>,
        padding: [usize; 3],
        dilation: [usize; 3],
    },
}

/// `max_unpool*` ノードの次元別引数(非公開)。構築時に `nn::MaxUnpool*::new` で検証済み。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum UnpoolSpec {
    /// 1D。
    D1 {
        kernel_size: usize,
        stride: Option<usize>,
        padding: usize,
        output_size: Option<usize>,
    },
    /// 2D。
    D2 {
        kernel_size: [usize; 2],
        stride: Option<[usize; 2]>,
        padding: [usize; 2],
        output_size: Option<[usize; 2]>,
    },
    /// 3D。
    D3 {
        kernel_size: [usize; 3],
        stride: Option<[usize; 3]>,
        padding: [usize; 3],
        output_size: Option<[usize; 3]>,
    },
}

impl PoolSpec {
    /// 次元数(unpool との対応検査用)。
    pub(super) fn dims(&self) -> usize {
        match self {
            PoolSpec::D1 { .. } => 1,
            PoolSpec::D2 { .. } => 2,
            PoolSpec::D3 { .. } => 3,
        }
    }
}

impl UnpoolSpec {
    /// 次元数(pool との対応検査用)。
    pub(super) fn dims(&self) -> usize {
        match self {
            UnpoolSpec::D1 { .. } => 1,
            UnpoolSpec::D2 { .. } => 2,
            UnpoolSpec::D3 { .. } => 3,
        }
    }

    /// 構築時検証(0 次元・`2 * padding` オーバーフロー)。`nn::MaxUnpool*::new` に委ねる。
    fn validate(&self) -> Result<(), AutodiffError> {
        match *self {
            UnpoolSpec::D1 {
                kernel_size,
                stride,
                padding,
                ..
            } => MaxUnpool1d::new(kernel_size, stride, padding).map(|_| ()),
            UnpoolSpec::D2 {
                kernel_size,
                stride,
                padding,
                ..
            } => MaxUnpool2d::new(kernel_size, stride, padding).map(|_| ()),
            UnpoolSpec::D3 {
                kernel_size,
                stride,
                padding,
                ..
            } => MaxUnpool3d::new(kernel_size, stride, padding).map(|_| ()),
        }
    }
}

/// グラフ上のノード定義(非公開。保存側 `model_io::functional_io` が読むため `compat` 内へ可視)。
pub(super) enum NodeDef {
    /// 外部から値を受ける入力ノード(Keras `Input`)。
    Input,
    /// `Sequential` ブロックを入力ノード列へ適用するノード。現段階の入力は 1 件。
    Block {
        block: Box<Sequential>,
        inputs: Vec<usize>,
    },
    /// 2 件以上のノードを 1 つへ合流させる結合ノード(#2666)。層を持たない。
    Merge { kind: MergeKind, inputs: Vec<usize> },
    /// MaxPool(値と索引を返す。#3036)。値は通常のノード値、索引は副スロットとして `eval_graph` が保持する。
    /// `inputs` は 1 件。層を持たない。
    MaxPoolIndices { spec: PoolSpec, inputs: Vec<usize> },
    /// MaxUnpool(#3036)。`inputs` は `[特徴量ノード, 索引元の MaxPoolIndices ノード]` の 2 件。層を持たない。
    MaxUnpool {
        spec: UnpoolSpec,
        inputs: Vec<usize>,
    },
}

impl NodeDef {
    /// このノードが消費する上流ノード添字列(入力ノードは空)。`build` の到達性伝播が使う。
    fn sources(&self) -> &[usize] {
        match self {
            NodeDef::Input => &[],
            NodeDef::Block { inputs, .. }
            | NodeDef::Merge { inputs, .. }
            | NodeDef::MaxPoolIndices { inputs, .. }
            | NodeDef::MaxUnpool { inputs, .. } => inputs,
        }
    }
}

/// DAG を組み立てるアリーナ型ビルダー(`docs/facade-functional-api-decision.md` §3 案 A)。
///
/// `input` → `apply` → `build` の順で呼ぶ。`build` が構造検証(fail-closed)を行って
/// [`FunctionalModel`] を返す。
///
/// ```
/// use fandhe_ai::Tensor;
/// use fandhe_ai::compat::{FunctionalBuilder, Sequential};
///
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// // 2 入力を各ブロックで変換し、加算で合流して 1 出力にする。
/// let mut b = FunctionalBuilder::new();
/// let a = b.input()?;
/// let c = b.input()?;
/// let ha = b.apply(Sequential::new().add_linear(3, 4, 1)?.add_relu(), a)?;
/// let hc = b.apply(Sequential::new().add_linear(2, 4, 2)?.add_relu(), c)?;
/// let merged = b.add(&[ha, hc])?;
/// let out = b.apply(Sequential::new().add_linear(4, 1, 3)?, merged)?;
/// let mut model = b.build(&[a, c], &[out])?;
/// model.eval();
///
/// let xa = Tensor::new(vec![0.1f32; 6], &[2, 3])?;
/// let xc = Tensor::new(vec![0.2f32; 4], &[2, 2])?;
/// let outputs = model.predict(&[&xa, &xc])?;
/// assert_eq!(outputs.len(), 1);
/// assert_eq!(outputs[0].shape(), &[2, 1]);
/// # Ok(())
/// # }
/// ```
pub struct FunctionalBuilder {
    id: u64,
    nodes: Vec<NodeDef>,
}

impl Default for FunctionalBuilder {
    fn default() -> Self {
        Self::new()
    }
}

impl FunctionalBuilder {
    /// 空のビルダーを作る(ID はプロセス内で一意)。
    pub fn new() -> Self {
        Self {
            id: NEXT_BUILDER_ID.fetch_add(1, Ordering::Relaxed),
            nodes: Vec::new(),
        }
    }

    /// ハンドルが自分のものか・範囲内かを検証して添字を返す。
    fn resolve(&self, node: Node, what: &str) -> Result<usize, AutodiffError> {
        if node.builder_id != self.id {
            return Err(invalid(format!(
                "{what}: 他のビルダーが発行したノードは使えない"
            )));
        }
        if node.index >= self.nodes.len() {
            return Err(invalid(format!(
                "{what}: ノード添字 {} が範囲外(ノード数 {})",
                node.index,
                self.nodes.len()
            )));
        }
        Ok(node.index)
    }

    /// ノードを 1 つ末尾へ追加し、そのハンドルを返す。確保失敗は型付きエラー。
    fn push_node(&mut self, def: NodeDef) -> Result<Node, AutodiffError> {
        self.nodes
            .try_reserve(1)
            .map_err(|_| super::alloc_failed())?;
        let index = self.nodes.len();
        self.nodes.push(def);
        Ok(Node {
            builder_id: self.id,
            index,
        })
    }

    /// 入力ノードを追加する(Keras `Input`)。shape は宣言せず、forward 時の値で決まる。
    pub fn input(&mut self) -> Result<Node, AutodiffError> {
        self.push_node(NodeDef::Input)
    }

    /// `block` を `input` へ適用するノードを追加する(Keras の層呼び出し)。
    ///
    /// 拒否: 他ビルダーのハンドル・範囲外添字・`compile()` 済みブロック(optimizer 状態を
    /// モデル側が 1 つだけ持つため、ブロック側の状態を黙って捨てない)・層 0 個のブロック
    /// (保存形式の「層範囲は重複なし・隙間なし」と相性が悪く、後から許可するのは非破壊・
    /// 逆は破壊的なため安全側)。
    pub fn apply(&mut self, block: Sequential, input: Node) -> Result<Node, AutodiffError> {
        let src = self.resolve(input, "apply")?;
        if block.compiled.is_some() {
            return Err(invalid(
                "apply: compile() 済みの Sequential は使えない(compile は Functional モデル側で行う)"
                    .to_string(),
            ));
        }
        if block.layers().is_empty() {
            return Err(invalid(
                "apply: 層を 1 つも持たない Sequential は使えない".to_string(),
            ));
        }
        let mut inputs = Vec::new();
        inputs.try_reserve(1).map_err(|_| super::alloc_failed())?;
        inputs.push(src);
        self.push_node(NodeDef::Block {
            block: Box::new(block),
            inputs,
        })
    }

    /// 軸 `dim` で連結する結合ノードを追加する(Keras `Concatenate`)。
    ///
    /// 拒否: 入力 2 件未満・他ビルダーのハンドル・範囲外添字・同一ノードの重複指定。shape の検査
    /// (rank・`dim` 範囲・非連結軸の一致)は構築時には行わず、forward 時に型付きエラーで検出する。
    pub fn concatenate(&mut self, inputs: &[Node], dim: usize) -> Result<Node, AutodiffError> {
        self.add_merge("concatenate", MergeKind::Concatenate { dim }, inputs)
    }

    /// 要素ごとの加算ノードを追加する(Keras `Add`)。拒否規則は [`Self::concatenate`] と同じ。
    pub fn add(&mut self, inputs: &[Node]) -> Result<Node, AutodiffError> {
        self.add_merge("add", MergeKind::Add, inputs)
    }

    /// 要素ごとの乗算ノードを追加する(Keras `Multiply`)。拒否規則は [`Self::concatenate`] と同じ。
    pub fn multiply(&mut self, inputs: &[Node]) -> Result<Node, AutodiffError> {
        self.add_merge("multiply", MergeKind::Multiply, inputs)
    }

    /// 要素ごとの平均ノードを追加する(Keras `Average`)。拒否規則は [`Self::concatenate`] と同じ。
    pub fn average(&mut self, inputs: &[Node]) -> Result<Node, AutodiffError> {
        self.add_merge("average", MergeKind::Average, inputs)
    }

    /// 結合ノード追加の共通検証。入力 2 件未満(0 件を含む)・他ビルダー/範囲外ハンドル・
    /// 同一ノードの重複指定を拒否する(重複は結線ミスを成功させない方針。`build` の
    /// inputs/outputs 重複拒否と整合。後から許可するのは非破壊)。
    fn add_merge(
        &mut self,
        what: &str,
        kind: MergeKind,
        inputs: &[Node],
    ) -> Result<Node, AutodiffError> {
        if inputs.len() < 2 {
            return Err(invalid(format!(
                "{what}: 入力は 2 件以上が必要(件数 {})",
                inputs.len()
            )));
        }
        let mut srcs: Vec<usize> = Vec::new();
        srcs.try_reserve_exact(inputs.len())
            .map_err(|_| super::alloc_failed())?;
        // 重複検出は線形時間(非信頼 manifest から最大 `MAX_ARRAY_LEN` 件の入力で呼ばれるため、
        // `Vec::contains` の二乗時間を避ける。#2667)。
        let mut seen = vec![false; self.nodes.len()];
        for node in inputs {
            let index = self.resolve(*node, what)?;
            let slot = seen
                .get_mut(index)
                .ok_or_else(|| invalid(format!("{what}: ノード添字 {index} が範囲外")))?;
            if *slot {
                return Err(invalid(format!(
                    "{what}: ノード {index} が重複して指定された"
                )));
            }
            *slot = true;
            srcs.push(index);
        }
        self.push_node(NodeDef::Merge { kind, inputs: srcs })
    }

    /// 1D MaxPool ノードを追加する。値は通常のノード値、索引は [`Self::max_unpool1d`] の `indices_from`
    /// から参照できる(`ceil_mode=false` 固定)。引数は `Var::max_pool1d` と同じ。
    ///
    /// ```
    /// use fandhe_ai::Tensor;
    /// use fandhe_ai::compat::FunctionalBuilder;
    ///
    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
    /// let mut b = FunctionalBuilder::new();
    /// let x = b.input()?;
    /// let pooled = b.max_pool1d_with_indices(x, 2, None, 0, 1)?;
    /// let up = b.max_unpool1d(pooled, pooled, 2, None, 0, None)?;
    /// let mut model = b.build(&[x], &[up])?;
    /// model.eval();
    /// let input = Tensor::new(vec![1.0f32, 3.0, 2.0, 0.0], &[1, 1, 4])?;
    /// let out = model.predict(&[&input])?;
    /// assert_eq!(out[0].shape(), &[1, 1, 4]);
    /// # Ok(())
    /// # }
    /// ```
    pub fn max_pool1d_with_indices(
        &mut self,
        input: Node,
        kernel_size: usize,
        stride: Option<usize>,
        padding: usize,
        dilation: usize,
    ) -> Result<Node, AutodiffError> {
        self.add_max_pool_indices(
            "max_pool1d_with_indices",
            PoolSpec::D1 {
                kernel_size,
                stride,
                padding,
                dilation,
            },
            input,
        )
    }

    /// 2D MaxPool ノードを追加する([`Self::max_pool1d_with_indices`] の 2D 版。`Var::max_pool2d` 相当)。
    pub fn max_pool2d_with_indices(
        &mut self,
        input: Node,
        kernel_size: [usize; 2],
        stride: Option<[usize; 2]>,
        padding: [usize; 2],
        dilation: [usize; 2],
    ) -> Result<Node, AutodiffError> {
        self.add_max_pool_indices(
            "max_pool2d_with_indices",
            PoolSpec::D2 {
                kernel_size,
                stride,
                padding,
                dilation,
            },
            input,
        )
    }

    /// 3D MaxPool ノードを追加する([`Self::max_pool1d_with_indices`] の 3D 版。`Var::max_pool3d` 相当)。
    pub fn max_pool3d_with_indices(
        &mut self,
        input: Node,
        kernel_size: [usize; 3],
        stride: Option<[usize; 3]>,
        padding: [usize; 3],
        dilation: [usize; 3],
    ) -> Result<Node, AutodiffError> {
        self.add_max_pool_indices(
            "max_pool3d_with_indices",
            PoolSpec::D3 {
                kernel_size,
                stride,
                padding,
                dilation,
            },
            input,
        )
    }

    /// 1D MaxUnpool ノードを追加する。`input` は復元する特徴量ノード、`indices_from` は索引を提供する
    /// `max_pool1d_with_indices` ノード(同次元のみ)。索引は消費されないため 1 つの pool を複数の unpool が
    /// 参照できる。引数は `Var::max_unpool1d` と同じ。
    ///
    /// 拒否: 他ビルダーのハンドル・`indices_from` が同次元の pool ノードでない・0 次元・`padding` の 2 倍の
    /// オーバーフロー。索引の範囲・shape の検査は forward 時に `Var` 側が行う。
    pub fn max_unpool1d(
        &mut self,
        input: Node,
        indices_from: Node,
        kernel_size: usize,
        stride: Option<usize>,
        padding: usize,
        output_size: Option<usize>,
    ) -> Result<Node, AutodiffError> {
        self.add_max_unpool(
            "max_unpool1d",
            UnpoolSpec::D1 {
                kernel_size,
                stride,
                padding,
                output_size,
            },
            input,
            indices_from,
        )
    }

    /// 2D MaxUnpool ノードを追加する([`Self::max_unpool1d`] の 2D 版)。
    ///
    /// ```
    /// use fandhe_ai::Tensor;
    /// use fandhe_ai::compat::{FunctionalBuilder, Sequential};
    ///
    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
    /// // SegNet 型: pool の索引を後段の unpool で再利用する。
    /// let mut b = FunctionalBuilder::new();
    /// let x = b.input()?;
    /// let pooled = b.max_pool2d_with_indices(x, [2, 2], None, [0, 0], [1, 1])?;
    /// let mid = b.apply(Sequential::new().add_relu(), pooled)?;
    /// let up = b.max_unpool2d(mid, pooled, [2, 2], None, [0, 0], None)?;
    /// let mut model = b.build(&[x], &[up])?;
    /// model.eval();
    /// let input = Tensor::new((0..16).map(|v| v as f32).collect(), &[1, 1, 4, 4])?;
    /// let out = model.predict(&[&input])?;
    /// assert_eq!(out[0].shape(), &[1, 1, 4, 4]);
    /// # Ok(())
    /// # }
    /// ```
    pub fn max_unpool2d(
        &mut self,
        input: Node,
        indices_from: Node,
        kernel_size: [usize; 2],
        stride: Option<[usize; 2]>,
        padding: [usize; 2],
        output_size: Option<[usize; 2]>,
    ) -> Result<Node, AutodiffError> {
        self.add_max_unpool(
            "max_unpool2d",
            UnpoolSpec::D2 {
                kernel_size,
                stride,
                padding,
                output_size,
            },
            input,
            indices_from,
        )
    }

    /// 3D MaxUnpool ノードを追加する([`Self::max_unpool1d`] の 3D 版)。
    pub fn max_unpool3d(
        &mut self,
        input: Node,
        indices_from: Node,
        kernel_size: [usize; 3],
        stride: Option<[usize; 3]>,
        padding: [usize; 3],
        output_size: Option<[usize; 3]>,
    ) -> Result<Node, AutodiffError> {
        self.add_max_unpool(
            "max_unpool3d",
            UnpoolSpec::D3 {
                kernel_size,
                stride,
                padding,
                output_size,
            },
            input,
            indices_from,
        )
    }

    /// MaxPool ノード追加の共通処理。
    fn add_max_pool_indices(
        &mut self,
        what: &str,
        spec: PoolSpec,
        input: Node,
    ) -> Result<Node, AutodiffError> {
        let src = self.resolve(input, what)?;
        let mut inputs = Vec::new();
        inputs
            .try_reserve_exact(1)
            .map_err(|_| super::alloc_failed())?;
        inputs.push(src);
        self.push_node(NodeDef::MaxPoolIndices { spec, inputs })
    }

    /// MaxUnpool ノード追加の共通処理。`indices_from` が同次元の MaxPoolIndices ノードであることと、
    /// 引数(0 次元・オーバーフロー)を構築時に検査する。
    fn add_max_unpool(
        &mut self,
        what: &str,
        spec: UnpoolSpec,
        input: Node,
        indices_from: Node,
    ) -> Result<Node, AutodiffError> {
        let src = self.resolve(input, what)?;
        let pool = self.resolve(indices_from, what)?;
        match self.nodes.get(pool) {
            Some(NodeDef::MaxPoolIndices {
                spec: pool_spec, ..
            }) => {
                if pool_spec.dims() != spec.dims() {
                    return Err(invalid(format!(
                        "{what}: indices_from(ノード {pool})の pool は {} 次元で、unpool の次元 {} と一致しない",
                        pool_spec.dims(),
                        spec.dims()
                    )));
                }
            }
            _ => {
                return Err(invalid(format!(
                    "{what}: indices_from(ノード {pool})は max_pool*_with_indices ノードでない"
                )));
            }
        }
        spec.validate()?;
        let mut inputs = Vec::new();
        inputs
            .try_reserve_exact(2)
            .map_err(|_| super::alloc_failed())?;
        inputs.push(src);
        inputs.push(pool);
        self.push_node(NodeDef::MaxUnpool { spec, inputs })
    }

    /// グラフを検証して [`FunctionalModel`] へ確定する(Keras `Model(inputs, outputs)`)。
    ///
    /// 拒否(すべて型付き `Err`): `inputs`/`outputs` が空・他ビルダーのハンドル・範囲外・
    /// 重複・`inputs` に入力ノード以外・`inputs` に載らない入力ノード(未束縛)・どの出力にも
    /// 寄与しないノード。入力ノードを出力へそのまま指定すること(素通し)は許可する。
    /// 到達性は出力から添字の降順に 1 回走査して伝播する(前方参照が構造的に無いため 1 パスで
    /// 足り、再帰を使わない)。
    pub fn build(
        self,
        inputs: &[Node],
        outputs: &[Node],
    ) -> Result<FunctionalModel, AutodiffError> {
        if inputs.is_empty() {
            return Err(invalid("build: inputs が空".to_string()));
        }
        if outputs.is_empty() {
            return Err(invalid("build: outputs が空".to_string()));
        }
        let node_count = self.nodes.len();

        let mut listed = vec![false; node_count];
        let mut input_indices = Vec::new();
        input_indices
            .try_reserve(inputs.len())
            .map_err(|_| super::alloc_failed())?;
        for node in inputs {
            let index = self.resolve(*node, "build inputs")?;
            if !matches!(self.nodes.get(index), Some(NodeDef::Input)) {
                return Err(invalid(format!(
                    "build inputs: ノード {index} は入力ノードではない"
                )));
            }
            let slot = listed
                .get_mut(index)
                .ok_or_else(|| invalid(format!("build inputs: ノード {index} が範囲外")))?;
            if *slot {
                return Err(invalid(format!("build inputs: ノード {index} が重複")));
            }
            *slot = true;
            input_indices.push(index);
        }
        for (index, def) in self.nodes.iter().enumerate() {
            let is_listed = listed.get(index).copied().unwrap_or(false);
            if matches!(def, NodeDef::Input) && !is_listed {
                return Err(invalid(format!(
                    "build: 入力ノード {index} が inputs に列挙されていない(未束縛)"
                )));
            }
        }

        let mut reached = vec![false; node_count];
        let mut output_indices = Vec::new();
        output_indices
            .try_reserve(outputs.len())
            .map_err(|_| super::alloc_failed())?;
        for node in outputs {
            let index = self.resolve(*node, "build outputs")?;
            let slot = reached
                .get_mut(index)
                .ok_or_else(|| invalid(format!("build outputs: ノード {index} が範囲外")))?;
            if *slot {
                return Err(invalid(format!("build outputs: ノード {index} が重複")));
            }
            *slot = true;
            output_indices.push(index);
        }
        for index in (0..node_count).rev() {
            if !reached.get(index).copied().unwrap_or(false) {
                continue;
            }
            if let Some(def) = self.nodes.get(index) {
                for src in def.sources() {
                    if let Some(slot) = reached.get_mut(*src) {
                        *slot = true;
                    }
                }
            }
        }
        if let Some(dead) = reached.iter().position(|r| !*r) {
            return Err(invalid(format!(
                "build: ノード {dead} はどの出力にも寄与しない"
            )));
        }

        let mut layer_starts = Vec::new();
        layer_starts
            .try_reserve(node_count)
            .map_err(|_| super::alloc_failed())?;
        let mut next_layer = 0usize;
        for def in &self.nodes {
            layer_starts.push(next_layer);
            if let NodeDef::Block { block, .. } = def {
                next_layer = next_layer
                    .checked_add(block.layers().len())
                    .ok_or_else(|| {
                        invalid("build: 層の通し番号がオーバーフローする".to_string())
                    })?;
            }
        }

        Ok(FunctionalModel {
            nodes: self.nodes,
            inputs: input_indices,
            outputs: output_indices,
            layer_starts,
            compiled: None,
        })
    }
}

/// 検証済みの多入力・多出力グラフ(Keras `Model`)。
///
/// ノードを挿入順に反復で 1 回ずつ評価する。Dropout の RNG 消費・BatchNorm の running stats
/// 更新はノード挿入順に各 1 回で、fan-out しても上流ノードは再評価しない。パラメータキーは
/// 全ブロックを通した層の通し番号 `i` による `"{i}.{name}"`(`docs/facade-functional-api-
/// decision.md` §4。Block ノードの挿入順に層数を累積)。
pub struct FunctionalModel {
    pub(super) nodes: Vec<NodeDef>,
    pub(super) inputs: Vec<usize>,
    pub(super) outputs: Vec<usize>,
    /// ノードごとの通し番号の先頭(入力ノードは次のブロックの先頭と同値で未使用)。
    pub(super) layer_starts: Vec<usize>,
    /// `compile` 済みの optimizer/loss(イシュー #2667。`Sequential::compiled` と同じ型・同じ
    /// 契約で、`fit` 中は一時的に取り外して必ず書き戻す)。ブロック側の `compiled` は `apply` が
    /// 拒否するため常に `None`。
    pub(super) compiled: Option<Compiled>,
}

/// [`FunctionalModel::bind`] が返す、1 学習ステップ分のテープ登録済みハンドル(イシュー #2667)。
///
/// 全ブロックを挿入順に `Sequential::bind` した `SequentialVars` を持ち、`forward`・
/// `trainable_vars`・`trainable_grads` を提供する。パラメータの並びは「ブロックの挿入順 → 各ブロック内は
/// `Sequential::trainable_parameters` と同じ順」で、`FunctionalModel::trainable_parameters`/
/// `apply_parameters` と位置対応する(`fandhe_ai::optim` の `params[i]` ↔ `grads[i]` 契約)。
/// 同一 tape 上で複数ブロックを `bind` する構成は ResNet examples に先例がある。
/// 生 `Tape`・`BackendOps` は署名に出さない(REQ-12)。§13 項 11 で非公開と承認した内部型のまま
/// (`docs/facade-functional-api-decision.md` §18)。
pub(crate) struct FunctionalVars<'m, 't> {
    model: &'m FunctionalModel,
    /// `(ノード添字, bind 結果)` をブロックの挿入順に保持する。
    blocks: Vec<(usize, SequentialVars<'m, 't>)>,
}

/// ブロックのローカルキー `"{j}.{name}"` を最初の `.` でのみ分割し、通し番号キー
/// `"{layer_start + j}.{name}"` へ写す。名前側に `.` を含みうるため最初の 1 か所だけで分割する。
/// 通し番号キーの採番規則は本関数 1 か所に置く。
pub(super) fn to_global_key(layer_start: usize, local: &str) -> Result<String, AutodiffError> {
    let (index, name) = local.split_once('.').ok_or_else(|| {
        invalid(format!(
            "パラメータキー {local:?} が \"{{index}}.{{name}}\" 形式でない"
        ))
    })?;
    let index: usize = index
        .parse()
        .map_err(|_| invalid(format!("パラメータキー {local:?} の層番号が数値でない")))?;
    let global = layer_start
        .checked_add(index)
        .ok_or_else(|| invalid("パラメータキーの通し番号がオーバーフローする".to_string()))?;
    Ok(format!("{global}.{name}"))
}

/// [`to_global_key`] の逆写像。通し番号キー `"{g}.{name}"` を、ブロックの先頭 `layer_start` を
/// 引いたローカルキー `"{g - layer_start}.{name}"` へ戻す(保存形式の復元で、ブロックごとに
/// `build_model`/`load_state_dict` が要求するローカルキーを作る。採番規則を 1 か所に保つため
/// ここに置く)。`g < layer_start` は `None`。
pub(super) fn to_local_key(layer_start: usize, global: &str) -> Option<String> {
    let (index, name) = global.split_once('.')?;
    let index: usize = index.parse().ok()?;
    let local = index.checked_sub(layer_start)?;
    Some(format!("{local}.{name}"))
}

impl FunctionalModel {
    /// ブロックノードの `(ノード添字, ブロック)` を挿入順に列挙する。
    pub(super) fn blocks(&self) -> impl Iterator<Item = (usize, &Sequential)> {
        self.nodes
            .iter()
            .enumerate()
            .filter_map(|(i, def)| match def {
                NodeDef::Block { block, .. } => Some((i, block.as_ref())),
                NodeDef::Input
                | NodeDef::Merge { .. }
                | NodeDef::MaxPoolIndices { .. }
                | NodeDef::MaxUnpool { .. } => None,
            })
    }

    /// 通し番号の先頭を引く(`build` が全ノード分を作るため常に存在する)。
    pub(super) fn layer_start(&self, node_index: usize) -> Result<usize, AutodiffError> {
        self.layer_starts
            .get(node_index)
            .copied()
            .ok_or_else(|| invalid(format!("内部不整合: ノード {node_index} の層番号がない")))
    }

    /// 訓練対象パラメータの shape 列(ブロックの挿入順・`trainable_parameters` と同順)。
    /// compile 状態の復元で `Lbfgs` 以外は使わないが、復元関数の契約どおり常に渡す。
    pub(super) fn slot_shapes(&self) -> Vec<Vec<usize>> {
        self.trainable_parameters()
            .iter()
            .map(|p| p.shape().to_vec())
            .collect()
    }

    /// compile 状態の写し(未 compile は `None`)。保存側 `model_io::functional_io` が呼ぶ。
    pub(super) fn compile_state_snapshot(&self) -> Result<Option<CompiledSnapshot>, AutodiffError> {
        self.compiled.as_ref().map(snapshot_of_compiled).transpose()
    }

    /// [`Self::compile_state_snapshot`] の写しから compile 状態を復元する。construct-before-assign
    /// (失敗時は `self.compiled` を変更しない)。復元側 `model_io::functional_io` が呼ぶ。
    pub(super) fn restore_compile_state(
        &mut self,
        snap: CompiledSnapshot,
    ) -> Result<(), AutodiffError> {
        let slots = self.slot_shapes();
        self.compiled = Some(compiled_from_snapshot(snap, &slots)?);
        Ok(())
    }

    /// `inputs`(`build` に渡した `inputs` の順)を入力ノードへ束ね、ノードを挿入順に 1 回ずつ
    /// 評価して `outputs`(`build` に渡した順)の値を返す。入力件数の不一致は `InvalidArgument`。
    /// shape の不整合は既存 `Var` 演算の型付きエラーに委ねる。
    pub fn forward<'t>(
        &self,
        tape: &'t Tape,
        inputs: &[Var<'t>],
    ) -> Result<Vec<Var<'t>>, AutodiffError> {
        self.eval_graph(tape, inputs, &mut |_, block, x| block.forward(tape, x))
    }

    /// ノード評価の共通本体([`Self::forward`] と `FunctionalVars::forward` が共用する。
    /// 評価順・入力検査・結合演算を 1 か所に保ち、推論経路と学習経路で演算列がずれないようにする)。
    /// ブロックノードの評価だけを `eval_block(ノード添字, ブロック, 入力)` へ委ねる。
    fn eval_graph<'t>(
        &self,
        tape: &'t Tape,
        inputs: &[Var<'t>],
        eval_block: &mut dyn FnMut(usize, &Sequential, &Var<'t>) -> Result<Var<'t>, AutodiffError>,
    ) -> Result<Vec<Var<'t>>, AutodiffError> {
        if inputs.len() != self.inputs.len() {
            return Err(invalid(format!(
                "forward: 入力数 {} がモデルの入力数 {} と一致しない",
                inputs.len(),
                self.inputs.len()
            )));
        }
        // 素通しグラフ(入力をそのまま出力にする構成)ではブロック評価の演算が走らず、別 tape の
        // `Var` が成功として返りうる。入力を格納する前に全入力の所属 tape を検証する。
        for var in inputs {
            ensure_on_tape(tape, var)?;
        }
        let mut values: Vec<Option<Var<'t>>> = Vec::new();
        values
            .try_reserve_exact(self.nodes.len())
            .map_err(|_| super::alloc_failed())?;
        values.resize(self.nodes.len(), None);
        // MaxPoolIndices ノードの索引の副スロット(値スロットと同じ添字。pool 以外は `None`)。
        let mut indices: Vec<Option<Tensor<i32>>> = Vec::new();
        indices
            .try_reserve_exact(self.nodes.len())
            .map_err(|_| super::alloc_failed())?;
        indices.resize(self.nodes.len(), None);
        for (slot, var) in self.inputs.iter().zip(inputs) {
            *values
                .get_mut(*slot)
                .ok_or_else(|| invalid(format!("内部不整合: 入力ノード {slot} が範囲外")))? =
                Some(*var);
        }
        for (index, def) in self.nodes.iter().enumerate() {
            let y =
                match def {
                    NodeDef::Input => continue,
                    NodeDef::Block { block, inputs } => {
                        let [src] = inputs.as_slice() else {
                            return Err(invalid(format!(
                                "内部不整合: ブロックノード {index} の入力数が 1 でない"
                            )));
                        };
                        let x =
                            values.get(*src).copied().flatten().ok_or_else(|| {
                                invalid(format!("内部不整合: ノード {src} が未評価"))
                            })?;
                        eval_block(index, block.as_ref(), &x)?
                    }
                    NodeDef::Merge { kind, inputs } => {
                        let mut xs: Vec<Var<'t>> = Vec::new();
                        xs.try_reserve_exact(inputs.len())
                            .map_err(|_| super::alloc_failed())?;
                        for src in inputs {
                            xs.push(values.get(*src).copied().flatten().ok_or_else(|| {
                                invalid(format!("内部不整合: ノード {src} が未評価"))
                            })?);
                        }
                        match kind {
                            MergeKind::Concatenate { dim } => {
                                merge_ops::merge_concatenate(&xs, *dim)?
                            }
                            MergeKind::Add => merge_ops::merge_add(&xs)?,
                            MergeKind::Multiply => merge_ops::merge_multiply(&xs)?,
                            MergeKind::Average => merge_ops::merge_average(&xs)?,
                        }
                    }
                    NodeDef::MaxPoolIndices { spec, inputs } => {
                        let [src] = inputs.as_slice() else {
                            return Err(invalid(format!(
                                "内部不整合: MaxPool ノード {index} の入力数が 1 でない"
                            )));
                        };
                        let x =
                            values.get(*src).copied().flatten().ok_or_else(|| {
                                invalid(format!("内部不整合: ノード {src} が未評価"))
                            })?;
                        let (y, idx) = match *spec {
                            PoolSpec::D1 {
                                kernel_size,
                                stride,
                                padding,
                                dilation,
                            } => x.max_pool1d(kernel_size, stride, padding, dilation, false)?,
                            PoolSpec::D2 {
                                kernel_size,
                                stride,
                                padding,
                                dilation,
                            } => x.max_pool2d(kernel_size, stride, padding, dilation, false)?,
                            PoolSpec::D3 {
                                kernel_size,
                                stride,
                                padding,
                                dilation,
                            } => x.max_pool3d(kernel_size, stride, padding, dilation, false)?,
                        };
                        *indices.get_mut(index).ok_or_else(|| {
                            invalid(format!("内部不整合: ノード {index} が範囲外"))
                        })? = Some(idx);
                        y
                    }
                    NodeDef::MaxUnpool { spec, inputs } => {
                        let [src, pool] = inputs.as_slice() else {
                            return Err(invalid(format!(
                                "内部不整合: MaxUnpool ノード {index} の入力数が 2 でない"
                            )));
                        };
                        let x =
                            values.get(*src).copied().flatten().ok_or_else(|| {
                                invalid(format!("内部不整合: ノード {src} が未評価"))
                            })?;
                        let idx = indices.get(*pool).and_then(Option::as_ref).ok_or_else(|| {
                            invalid(format!("内部不整合: ノード {pool} の索引が未評価"))
                        })?;
                        match *spec {
                            UnpoolSpec::D1 {
                                kernel_size,
                                stride,
                                padding,
                                output_size,
                            } => x.max_unpool1d(idx, kernel_size, stride, padding, output_size)?,
                            UnpoolSpec::D2 {
                                kernel_size,
                                stride,
                                padding,
                                output_size,
                            } => x.max_unpool2d(idx, kernel_size, stride, padding, output_size)?,
                            UnpoolSpec::D3 {
                                kernel_size,
                                stride,
                                padding,
                                output_size,
                            } => x.max_unpool3d(idx, kernel_size, stride, padding, output_size)?,
                        }
                    }
                };
            *values
                .get_mut(index)
                .ok_or_else(|| invalid(format!("内部不整合: ノード {index} が範囲外")))? = Some(y);
        }
        let mut outputs = Vec::new();
        outputs
            .try_reserve_exact(self.outputs.len())
            .map_err(|_| super::alloc_failed())?;
        for slot in &self.outputs {
            let value = values
                .get(*slot)
                .copied()
                .flatten()
                .ok_or_else(|| invalid(format!("内部不整合: 出力ノード {slot} が未評価")))?;
            outputs.push(value);
        }
        Ok(outputs)
    }

    /// 推論の入口。`crate::tape()`(既定 CPU)を 1 回構築して [`Self::forward`] を呼び、各出力を
    /// `Tensor` へ取り出す。`Sequential::predict` の tape 不要経路(Linear→ReLU 融合)は使わず
    /// 第 1 段は単純さを優先する。モードを暗黙に切り替えない(`Sequential::predict` と同じ。
    /// 決定的な推論は先に `eval()` を呼ぶ)。
    pub fn predict(&self, inputs: &[&Tensor<f32>]) -> Result<Vec<Tensor<f32>>, AutodiffError> {
        let tape = crate::tape();
        let mut vars = Vec::new();
        vars.try_reserve_exact(inputs.len())
            .map_err(|_| super::alloc_failed())?;
        for tensor in inputs {
            vars.push(tape.var(tensor));
        }
        let outputs = self.forward(&tape, &vars)?;
        Ok(outputs.iter().map(Var::to_tensor).collect())
    }

    /// 全ブロックへモードを伝播する。モデル側に別フラグは持たない(ブロックと食い違う状態を
    /// 作らないため。`build` はブロックのモードを同期しない契約で、`Sequential::add_dropout` と
    /// 同じ)。
    pub fn set_training(&mut self, training: bool) {
        for def in &mut self.nodes {
            if let NodeDef::Block { block, .. } = def {
                block.set_training(training);
            }
        }
    }

    /// `set_training(true)` の別名。
    pub fn train(&mut self) {
        self.set_training(true);
    }

    /// `set_training(false)` の別名。
    pub fn eval(&mut self) {
        self.set_training(false);
    }

    /// 全ブロックが train のとき `true`(ブロック 0 個なら `true`)の導出値。
    pub fn training(&self) -> bool {
        self.blocks().all(|(_, block)| block.training())
    }

    /// 「通し番号キー, パラメータ参照」列。順序はブロックの挿入順・各ブロック内は
    /// `Sequential::named_parameters` と同じ。
    pub fn named_parameters(&self) -> Result<Vec<(String, &Tensor<f32>)>, AutodiffError> {
        let mut out = Vec::new();
        for (index, block) in self.blocks() {
            let start = self.layer_start(index)?;
            for (local, tensor) in block.named_parameters() {
                out.try_reserve(1).map_err(|_| super::alloc_failed())?;
                out.push((to_global_key(start, &local)?, tensor));
            }
        }
        Ok(out)
    }

    /// 通し番号キー付きのパラメータ辞書(`named_parameters` と同じキー集合)。
    pub fn state_dict(&self) -> Result<HashMap<String, Tensor<f32>>, AutodiffError> {
        let mut out = HashMap::new();
        for (index, block) in self.blocks() {
            let start = self.layer_start(index)?;
            for (local, tensor) in block.state_dict() {
                out.try_reserve(1).map_err(|_| super::alloc_failed())?;
                out.insert(to_global_key(start, &local)?, tensor);
            }
        }
        Ok(out)
    }

    /// [`Self::state_dict`] の逆(strict。`fandhe_ai_autodiff::nn::Module::load_state_dict` と同型の
    /// 「2 パス + ベストエフォート・ロールバック」契約)。
    ///
    /// 第 1 パスでキー集合の完全一致(未知キー・欠落キーを拒否)と各テンソルの shape 一致を
    /// 何も変更しないうちに検査する。第 2 パスでブロックごとにローカルキーへ戻して
    /// `Sequential::load_state_dict` へ委譲し、途中で失敗した場合は開始前のスナップショットで
    /// 適用済みブロックと失敗した現在のブロックを巻き戻して元のエラーを返す。巻き戻し自体が失敗した場合は、
    /// 適用失敗と巻き戻し失敗の双方と部分適用の可能性を示す `InvalidArgument` を返す。
    pub fn load_state_dict(
        &mut self,
        mut state: HashMap<String, Tensor<f32>>,
    ) -> Result<(), AutodiffError> {
        // 第 1 パス: ブロックごとに (ローカルキー, 通し番号キー, 現在の shape) を引く。
        let mut plan: Vec<(usize, Vec<(String, String)>)> = Vec::new();
        let mut expected_keys = 0usize;
        for (index, block) in self.blocks() {
            let start = self.layer_start(index)?;
            let mut pairs = Vec::new();
            for (local, current) in block.state_dict() {
                let global = to_global_key(start, &local)?;
                let Some(given) = state.get(&global) else {
                    return Err(invalid(format!("load_state_dict: キー {global:?} が欠落")));
                };
                if given.shape() != current.shape() {
                    return Err(invalid(format!(
                        "load_state_dict: キー {global:?} の shape {:?} が期待 {:?} と一致しない",
                        given.shape(),
                        current.shape()
                    )));
                }
                expected_keys += 1;
                pairs.try_reserve(1).map_err(|_| super::alloc_failed())?;
                pairs.push((local, global));
            }
            plan.try_reserve(1).map_err(|_| super::alloc_failed())?;
            plan.push((index, pairs));
        }
        if state.len() != expected_keys {
            let unknown = state
                .keys()
                .find(|k| !plan.iter().any(|(_, p)| p.iter().any(|(_, g)| g == *k)));
            return Err(invalid(format!(
                "load_state_dict: 未知のキー {unknown:?} がある(期待 {expected_keys} 件・入力 {} 件)",
                state.len()
            )));
        }

        // 第 2 パス: スナップショットを取ってからブロック単位で適用する。
        let mut snapshots: Vec<HashMap<String, Tensor<f32>>> = Vec::new();
        snapshots
            .try_reserve_exact(plan.len())
            .map_err(|_| super::alloc_failed())?;
        for (index, _) in &plan {
            match self.nodes.get(*index) {
                Some(NodeDef::Block { block, .. }) => snapshots.push(block.state_dict()),
                _ => {
                    return Err(invalid(format!(
                        "内部不整合: ノード {index} がブロックでない"
                    )));
                }
            }
        }
        for (applied, (index, pairs)) in plan.iter().enumerate() {
            let mut local_state = HashMap::new();
            for (local, global) in pairs {
                let tensor = state.remove(global).ok_or_else(|| {
                    invalid(format!("内部不整合: キー {global:?} が第 2 パスで消えた"))
                })?;
                local_state.insert(local.clone(), tensor);
            }
            let result = match self.nodes.get_mut(*index) {
                Some(NodeDef::Block { block, .. }) => block.load_state_dict(local_state),
                _ => Err(invalid(format!(
                    "内部不整合: ノード {index} がブロックでない"
                ))),
            };
            if let Err(err) = result {
                // 巻き戻し失敗は握りつぶさず、部分適用の可能性を明示して fail-closed に返す
                // (`Module::load_state_dict` 契約・security.md A08)。
                let mut rollback_failures: Vec<String> = Vec::new();
                for ((done_index, _), snapshot) in
                    plan.iter().zip(snapshots.iter()).take(applied + 1)
                {
                    match self.nodes.get_mut(*done_index) {
                        Some(NodeDef::Block { block, .. }) => {
                            if let Err(rb) = block.load_state_dict(snapshot.clone()) {
                                rollback_failures.push(format!("ノード {done_index}: {rb}"));
                            }
                        }
                        _ => rollback_failures
                            .push(format!("ノード {done_index}: ブロックでないため復元不能")),
                    }
                }
                if !rollback_failures.is_empty() {
                    return Err(invalid(format!(
                        "load_state_dict: ノード {index} の適用に失敗({err})し、失敗ブロック自身と先行ブロックの巻き戻しにも失敗した({})。モデルが部分適用のまま残っている可能性がある",
                        rollback_failures.join("; ")
                    )));
                }
                return Err(err);
            }
        }
        Ok(())
    }
}