racah 0.2.0

Racah-Wigner calculus for compact Lie groups: exact SU(2) recoupling, and runtime Clebsch-Gordan / F- / R-coefficient generation for SU(N), SO(N), and Sp(2N)
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
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
//! SO(N)/Sp(2N) irreps and their Clebsch–Gordan / recoupling coefficients for
//! the B, C, D Cartan series, built by the generator bootstrap.
//!
//! An [`Irrep`](crate::bcd::Irrep) is an SO(N) or Sp(2N) highest weight; from it this module gives
//! exact Weyl dimensions, duals, Frobenius–Schur indicators, weight
//! multiplicities (Freudenthal recursion), and the exact tensor-product
//! decomposition `N^c_ab` (Brauer–Klimyk / Racah–Speiser over Weyl characters).
//! The Clebsch–Gordan coefficients and the recoupling
//! [`f_symbol`](crate::bcd::f_symbol) / [`r_symbol`](crate::bcd::r_symbol) are
//! generated through a per-(series, rank)
//! [`CanonicalCatalog`](crate::bcd::CanonicalCatalog). The label combinatorics are pure integer/rational
//! arithmetic; the generated CGC are verification-gated floating point.
//!
//! Unlike SU(N), these families are built by a **generator bootstrap** (seed
//! the defining rep, take tensor products, decompose numerically, harvest,
//! recurse), not by Gelfand–Tsetlin: the symplectic chain `Sp(2r) ⊃ Sp(2r-2)`
//! is not multiplicity-free, so no GT-type basis with practical closed-form
//! ladder elements exists (and the multiplicity-free SO chains have no
//! production-viable closed forms either). See [`docs/theory.pdf`] §8 for the
//! rationale and [`docs/references.md`] for the port provenance.
//!
//! [`docs/theory.pdf`]: https://github.com/Ryo-wtnb11/racah/blob/main/docs/theory.pdf
//! [`docs/references.md`]: https://github.com/Ryo-wtnb11/racah/blob/main/docs/references.md
//!
//! # Published object and global form (issue #18 Ruling 3; issues #87, #54)
//!
//! The object is the set of finite-dimensional linear representations of a
//! **connected compact group** with one of these root systems. Which group is
//! named by a [`GroupId`](crate::group::GroupId), and the only thing the choice
//! changes is *which highest weights are admissible* — never a coefficient
//! value (issue #87 §2).
//!
//! - [`Irrep::from_dynkin`](crate::bcd::Irrep::from_dynkin) keeps the historically published groups: SO(2r+1)
//!   (`B_r`), Sp(2r) (`C_r`), SO(2r) (`D_r`). Their irreps are exactly the
//!   **tensor** irreps — integer highest weights in the orthonormal (ε) basis —
//!   and a spinor label is rejected with
//!   [`BcdError::NotAdmissible`](crate::bcd::BcdError::NotAdmissible), as before.
//! - [`Irrep::from_dynkin_in`](crate::bcd::Irrep::from_dynkin_in) takes the group explicitly, so the **covers**
//!   `Spin(2r+1)` and `Spin(2r)` are available and their **spinor** irreps —
//!   half-integer ε-basis highest weights — are admitted (issue #54). Their
//!   coefficients are generated by the same bootstrap, from a second base case:
//!   the Clifford/Fock seeds of `docs/gauge_soN.md` §16.
//!
//! Sp(2r) is simply connected, so the `C` series has no spinor sector and no
//! second form.
//!
//! # Conventions and normalization
//!
//! An [`Irrep`](crate::bcd::Irrep) stores the highest weight as the **doubled**
//! weight `2λ` in the orthonormal ε-basis (Bourbaki/Fulton–Harris convention),
//! length `r` — the `dj = 2j` convention of the base SU(2) layer, which is what
//! makes a spinor's half-integer `λ` exactly representable. [`Irrep::partition`](crate::bcd::Irrep::partition)
//! returns `λ` itself for a tensor irrep and `None` for a spinor;
//! [`Irrep::two_partition`](crate::bcd::Irrep::two_partition) is always exact. In terms of `λ`:
//!
//! - `B_r`, `C_r`: `λ₁ ≥ λ₂ ≥ … ≥ λ_r ≥ 0`.
//! - `D_r`: `λ₁ ≥ … ≥ λ_{r-1} ≥ |λ_r|`, and `λ_r` may be negative — the sign
//!   of `λ_r` is the D-series chirality (the two `±λ_r` labels are the
//!   analog of the two spinor chiralities, but here for tensor irreps).
//!
//! Integer **Dynkin** labels `a = (a₁,…,a_r)`, `aᵢ = ⟨λ, αᵢ^∨⟩`, relate to the
//! partition by (Fulton–Harris §18.1, roots/coroots; cross-check against the
//! QSpace `findMaxWeight` z→Dynkin maps, `clebsch_aux.cc:977–1031`):
//!
//! - `B_r`: `aᵢ = λᵢ − λ_{i+1}` (`i<r`), `a_r = 2λ_r`. Tensor ⇔ `a_r` even
//!   (`a_r` odd is the spinor class, whose `≺`-minimal label is `ω_r`).
//! - `C_r`: `aᵢ = λᵢ − λ_{i+1}` (`i<r`), `a_r = λ_r`. Every non-negative
//!   integer Dynkin label is a tensor irrep (Sp(2r) is simply connected).
//! - `D_r`: `aᵢ = λᵢ − λ_{i+1}` (`i≤r-2`), `a_{r-1} = λ_{r-1} − λ_r`,
//!   `a_r = λ_{r-1} + λ_r`. Tensor ⇔ `a_{r-1} ≡ a_r (mod 2)` (odd sum is the
//!   spinor lattice).
//!
//! # Excluded low ranks (guard inventory, issue #15; QSpace
//! `clebsch_aux.cc:990/1001/1018`)
//!
//! - `B_1`: `Spin(3) ≅ SU(2)`, `SO(3)` = its integer-`j` sublattice.
//! - `C_1`: `Sp(2) ≅ SU(2)`, `PSp(2) ≅ SO(3)`.
//! - `D_2`: `Spin(4) ≅ SU(2)×SU(2)`, `SO(4)` = its `j₁+j₂ ∈ ℤ` sublattice.
//!
//! All are rejected with [`BcdError::ExcludedRank`](crate::bcd::BcdError::ExcludedRank), whose `redirect` names the
//! right SU(2) statement **for the requested form** (issue #87 Q3).
//!
//! # References
//!
//! The exact combinatorics follow Fulton–Harris (root systems, Weyl dimension,
//! weight multiplicities) and Humphreys (Freudenthal recursion, the
//! Racah–Speiser / Brauer–Klimyk character sign rule); the generator bootstrap,
//! seeds, and dimension oracle are ported from QSpace v4 (revision `dd2cc7e`,
//! the revision every `clebsch_aux.cc:LINE` / `clebsch.cc:LINE` citation in
//! this module refers to). Full citations,
//! versions, and the `file:symbol`-level provenance are in [`docs/references.md`];
//! the gauge is specified in [`docs/gauge_soN.md`].
//!
//! [`docs/references.md`]: https://github.com/Ryo-wtnb11/racah/blob/main/docs/references.md
//! [`docs/gauge_soN.md`]: https://github.com/Ryo-wtnb11/racah/blob/main/docs/gauge_soN.md
//!
//! # Example
//!
//! F/R generation for B/C/D runs through a per-(series, rank)
//! [`CanonicalCatalog`](crate::bcd::CanonicalCatalog)
//! that caches the aligned CGC. This computes an Sp(4) (`C_2`) F-symbol block;
//! with `a` trivial it is the `1×1×1×1` identity (value 1):
//!
//! ```
//! use racah::bcd::{f_symbol, CanonicalCatalog, Irrep, Series};
//!
//! let mut cat = CanonicalCatalog::new(Series::C, 2).unwrap(); // Sp(4)
//! let triv = Irrep::trivial(Series::C, 2).unwrap();
//! let v = Irrep::from_dynkin(Series::C, &[0, 1]).unwrap(); // vector
//! let adj = Irrep::from_dynkin(Series::C, &[2, 0]).unwrap(); // in v ⊗ v
//!
//! let block = f_symbol(&mut cat, &triv, &v, &v, &adj, &v, &adj).unwrap();
//! assert_eq!(block.dims(), [1, 1, 1, 1]);
//! assert!((block.at(0, 0, 0, 0) - 1.0).abs() < 1e-9);
//! ```

use std::collections::{BTreeMap, HashSet};
use std::fmt;

use num_bigint::BigInt;
use num_rational::Ratio;
use num_traits::One;

use crate::group::{CenterSubgroup, GlobalForm, GroupId, RootSystem};

/// The three orthogonal/symplectic Cartan series covered here.
///
/// `B_r = SO(2r+1)`, `C_r = Sp(2r)`, `D_r = SO(2r)`. The name `bcd` is used
/// for the module (rather than `son`) because `C = Sp` is *not* an SO series;
/// only the Cartan-letter naming covers all three families honestly.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Series {
    /// `B_r = SO(2r+1)` (odd orthogonal).
    B,
    /// `C_r = Sp(2r)` (symplectic).
    C,
    /// `D_r = SO(2r)` (even orthogonal).
    D,
}

impl Series {
    fn name(self) -> &'static str {
        match self {
            Series::B => "B (SO(2r+1))",
            Series::C => "C (Sp(2r))",
            Series::D => "D (SO(2r))",
        }
    }

    /// The minimum rank at which the series is not an excluded isomorphism.
    fn min_rank(self) -> usize {
        match self {
            Series::B | Series::C => 2,
            Series::D => 3,
        }
    }

    /// The root system of this bootstrap family at rank `r`.
    ///
    /// The only bridge between [`Series`] (the bootstrap-family key: it
    /// indexes the defining seeds and the canonical catalog) and
    /// [`RootSystem`] (the label-lattice type, which also covers `G2`–`E8`).
    /// The two do different jobs and neither is an alias of the other.
    pub fn root_system(self, r: usize) -> RootSystem {
        match self {
            Series::B => RootSystem::B(r),
            Series::C => RootSystem::C(r),
            Series::D => RootSystem::D(r),
        }
    }

    /// The group whose linear representations this module publishes at rank
    /// `r` — `SO(2r+1)`, `Sp(2r)`, `SO(2r)` (issue #18 Ruling 3).
    fn published_group(self, r: usize) -> GroupId {
        GroupId {
            root_system: self.root_system(r),
            form: match self {
                // Spin(2r+1)/Z2 and Spin(2r)/Z2 (the vector class).
                Series::B => GlobalForm::Quotient(CenterSubgroup::Z2),
                // Sp(2r) is simply connected.
                Series::C => GlobalForm::SimplyConnected,
                Series::D => GlobalForm::Quotient(CenterSubgroup::DVector),
            },
        }
    }

    /// The **cover** of this bootstrap family at rank `r` — `Spin(2r+1)`,
    /// `Sp(2r)`, `Spin(2r)`.
    ///
    /// This is the group the generator bootstrap works in: the catalog's base
    /// cases include the spinor seeds, and a product containing a spinor factor
    /// discovers spinor labels. The *published* labels of a query are a subset
    /// of it, cut down by [`published_group`](Self::published_group).
    pub(crate) fn cover_group(self, r: usize) -> GroupId {
        GroupId {
            root_system: self.root_system(r),
            form: GlobalForm::SimplyConnected,
        }
    }

    /// Redirection guidance for the excluded low rank, **per global form**
    /// (issue #87 Q3): the low-rank isomorphism is an isomorphism of *groups*,
    /// so the redirect a caller needs depends on which form was asked for. The
    /// simply-connected redirects are the covers, the quotient redirects name
    /// the sublattice of SU(2) labels that survives.
    ///
    /// | rank | simply connected | quotient |
    /// |---|---|---|
    /// | `B_1` | `Spin(3) ≅ SU(2)` | `SO(3)`: integer `j` only |
    /// | `C_1` | `Sp(2) ≅ SU(2)` | `PSp(2) ≅ SO(3)`: integer `j` only |
    /// | `D_2` | `Spin(4) ≅ SU(2)×SU(2)` | `SO(4)`: `j₁+j₂` integer |
    fn low_rank_redirect(self, form: GlobalForm) -> &'static str {
        match (self, form) {
            // B_1 = Spin(3) ≅ SU(2); C_1 = Sp(2) ≅ SU(2).
            (Series::B | Series::C, GlobalForm::SimplyConnected) => "use SU(2) instead",
            // SO(3) ≅ PSp(2) is the integer-spin sublattice of SU(2).
            (Series::B | Series::C, GlobalForm::Quotient(_)) => {
                "use SU(2) with integer j only instead"
            }
            // D_2 = Spin(4) ≅ SU(2)×SU(2).
            (Series::D, GlobalForm::SimplyConnected) => "use SU(2)×SU(2) instead",
            // SO(4) is the j₁+j₂ ∈ Z sublattice of SU(2)×SU(2).
            (Series::D, GlobalForm::Quotient(_)) => "use SU(2)×SU(2) with j₁+j₂ integer instead",
        }
    }
}

/// Error for a malformed or out-of-scope B/C/D irrep label, or an ill-posed
/// product. Public constructors never panic; they return this instead.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BcdError {
    /// A rank-0 (empty) Dynkin label.
    EmptyLabel,
    /// A Dynkin label with a negative component (dominant integral weights
    /// have non-negative Dynkin labels).
    NegativeDynkin {
        /// The offending Dynkin label.
        dynkin: Vec<i64>,
    },
    /// A rank that is one of the excluded low-rank isomorphisms
    /// (`SO(3)`, `Sp(2)`, `SO(4)`), which are handled by the SU(2) machinery.
    /// Carries redirection guidance (guard inventory, issue #15).
    ExcludedRank {
        /// The series.
        series: Series,
        /// The offending rank `r`.
        rank: usize,
        /// Where to go instead (e.g. `"use SU(2) instead"`).
        redirect: &'static str,
    },
    /// A [`GroupId`] whose root system is not one of the `B`/`C`/`D` families
    /// this module implements, passed to [`Irrep::from_dynkin_in`](crate::bcd::Irrep::from_dynkin_in).
    UnsupportedRootSystem {
        /// The offending root system.
        root_system: RootSystem,
    },
    /// The Dynkin label passed to [`Irrep::from_dynkin_in`](crate::bcd::Irrep::from_dynkin_in) has a different
    /// length than the rank of the group's root system.
    RankMismatch {
        /// The group's rank.
        expected: usize,
        /// The label length.
        got: usize,
    },
    /// A valid dominant integral highest weight of the **cover** that the
    /// requested global form does not admit: its central character is
    /// non-trivial on the quotiented-out subgroup of the center.
    ///
    /// This is a global-form violation, not a malformed label — it is kept
    /// distinct from [`BcdError::NegativeDynkin`] / [`BcdError::RankMismatch`]
    /// (invalid labels), [`BcdError::ExcludedRank`] (out-of-scope rank) and the
    /// generation/verification gates.
    ///
    /// For `SO(2r+1)` and `SO(2r)` the rejected labels are exactly the
    /// **spinor** labels (`B_r`: `a_r` odd; `D_r`: `a_{r-1} + a_r` odd), which
    /// are representations of the covering group `Spin(N)` — construct them
    /// through [`Irrep::from_dynkin_in`](crate::bcd::Irrep::from_dynkin_in) with `GroupId::spin(N)`.
    /// But the variant is *not* spinor-specific: `PSp(2r)` rejects
    /// `a₁+a₃+… ` odd, `PSO(2r)` and the half-spin forms reject further tensor
    /// classes, and none of those rejections is about spinors.
    NotAdmissible {
        /// The group whose global form rejected the weight.
        group: GroupId,
        /// The offending Dynkin label.
        dynkin: Vec<i64>,
    },
    /// A [`directproduct`] of irreps from different series or ranks (distinct
    /// groups share no product): an ill-posed input, not a zero fusion.
    GroupMismatch {
        /// The first operand as `(series, rank)`.
        a: (Series, usize),
        /// The second operand as `(series, rank)`.
        b: (Series, usize),
    },
    /// A defining-rep generator seed failed the exact commutator self-check
    /// ([`check_commutators`]) — the Rust analogue of QSpace's `checkCommRel`
    /// error. Names the violated relation and the generator indices involved.
    CommutatorViolation {
        /// The series.
        series: Series,
        /// The violated relation (e.g. `"cartan not mutually orthogonal"`).
        relation: &'static str,
        /// First generator index.
        i: usize,
        /// Second generator index.
        j: usize,
    },
}

impl fmt::Display for BcdError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            BcdError::EmptyLabel => write!(f, "B/C/D irrep label must be non-empty"),
            BcdError::NegativeDynkin { dynkin } => {
                write!(f, "Dynkin label has a negative component: {dynkin:?}")
            }
            BcdError::ExcludedRank {
                series,
                rank,
                redirect,
            } => write!(
                f,
                "series {} rank {rank} is an excluded low-rank isomorphism: {redirect}",
                series.name()
            ),
            BcdError::UnsupportedRootSystem { root_system } => write!(
                f,
                "root system {root_system} is not one of the B/C/D families of this module"
            ),
            BcdError::RankMismatch { expected, got } => write!(
                f,
                "Dynkin label of length {got} for a rank-{expected} group"
            ),
            BcdError::NotAdmissible { group, dynkin } => write!(
                f,
                "Dynkin label {dynkin:?} is a dominant integral weight of the cover but not \
                 a representation of {group:?}: its central character is non-trivial on the \
                 quotiented-out subgroup of the center — construct it through \
                 Irrep::from_dynkin_in with the simply-connected form"
            ),
            BcdError::GroupMismatch { a, b } => write!(
                f,
                "directproduct across distinct groups {:?} and {:?}",
                a, b
            ),
            BcdError::CommutatorViolation {
                series,
                relation,
                i,
                j,
            } => write!(
                f,
                "series {} defining-seed commutator self-check failed: {relation} \
                 (generators i={i}, j={j})",
                series.name()
            ),
        }
    }
}

impl std::error::Error for BcdError {}

/// An irreducible tensor representation of `SO(2r+1)`, `Sp(2r)` or `SO(2r)`,
/// labelled by its highest weight (an integer partition in the ε-basis; see
/// module docs for the normalization and chirality convention).
///
/// Build one from its `r` Dynkin labels with [`Irrep::from_dynkin`] (tensor
/// irreps of the historically published group) or [`Irrep::from_dynkin_in`]
/// (any global form, including the `Spin(N)` covers and their spinors).
/// [`Irrep::rank`] is the Cartan rank `r`, so `Series::C` at `r = 2` is
/// `Sp(4)` and its defining representation is the **4**.
///
/// ```
/// # #[cfg(feature = "cgc-gen")] {
/// use racah::bcd::{Irrep, Series};
///
/// // SO(5) = B_2: the 5-dimensional vector representation.
/// let v = Irrep::from_dynkin(Series::B, &[1, 0]).unwrap();
/// assert_eq!(v.dim(), 5u32.into());
/// assert_eq!(v.dual(), v);
/// assert_eq!(v.frobenius_schur(), 1);           // real self-duality
/// assert_eq!(v.partition(), Some(vec![1, 0]));  // λ = (1, 0)
///
/// // Sp(4) = C_2: the defining 4 is pseudo-real.
/// let four = Irrep::from_dynkin(Series::C, &[1, 0]).unwrap();
/// assert_eq!(four.dim(), 4u32.into());
/// assert_eq!(four.frobenius_schur(), -1);
/// # }
/// ```
///
/// `Ord`/`Hash` are on `(series, weight)`, so two `Irrep`s are equal iff they
/// denote the same irrep; the order is deterministic (used as a map key).
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Irrep {
    series: Series,
    /// **Doubled** highest weight `2λ` in the ε-basis, length `r`.
    ///
    /// The stored quantity is `2λ`, not `λ`: spinor highest weights are
    /// half-integers, and the doubled encoding is the crate's own precedent
    /// (`dj = 2j` in the base SU(2) layer, `src/lib.rs`). Doubling is a
    /// strictly monotone relabelling, so `Ord`/`Hash` — and every order derived
    /// from them — are unchanged for the tensor irreps.
    two_weight: Box<[i64]>,
}

impl crate::cache::CacheKeyCharge for Irrep {
    fn key_bytes(&self) -> usize {
        std::mem::size_of::<Self>().saturating_add(std::mem::size_of_val(self.two_weight.as_ref()))
    }
}

impl Irrep {
    /// Construct from the `r` integer Dynkin labels of `series` (`r =
    /// dynkin.len()`).
    ///
    /// Rejects: an empty label ([`BcdError::EmptyLabel`]); a negative component
    /// ([`BcdError::NegativeDynkin`]); an excluded low rank
    /// ([`BcdError::ExcludedRank`], with redirection); a spinor label
    /// ([`BcdError::NotAdmissible`]).
    pub fn from_dynkin(series: Series, dynkin: &[i64]) -> Result<Self, BcdError> {
        if dynkin.is_empty() {
            return Err(BcdError::EmptyLabel);
        }
        let group = series.published_group(dynkin.len());
        Self::from_dynkin_in(&group, dynkin)
    }

    /// Construct from the `r` integer Dynkin labels of `group` — the
    /// form-aware constructor (issue #87 §6).
    ///
    /// `group.root_system` must be one of `B(r)`, `C(r)`, `D(r)` (the families
    /// this module implements) and `dynkin.len()` must equal its rank. A label
    /// the form does not admit — i.e. one whose central character is
    /// non-trivial on the quotiented-out subgroup — is
    /// [`BcdError::NotAdmissible`].
    ///
    /// Under `GlobalForm::SimplyConnected` the `B`/`D` families are `Spin(N)`
    /// and the spinor labels are admitted (issue #54). Their highest weights
    /// are half-integers in the ε-basis, which is why the stored weight is
    /// doubled ([`Irrep::two_partition`](crate::bcd::Irrep::two_partition)).
    ///
    /// ```
    /// use racah::bcd::Irrep;
    /// use racah::group::GroupId;
    ///
    /// let spin7 = GroupId::spin(7).unwrap(); // B_3 + SimplyConnected
    /// let s = Irrep::from_dynkin_in(&spin7, &[0, 0, 1]).unwrap();
    /// assert_eq!(s.dim(), 8u32.into());
    /// assert_eq!(s.two_partition(), &[1, 1, 1]); // λ = (½,½,½)
    ///
    /// // The same label is not a representation of SO(7).
    /// let so7 = GroupId::so(7).unwrap();
    /// assert!(Irrep::from_dynkin_in(&so7, &[0, 0, 1]).is_err());
    /// ```
    pub fn from_dynkin_in(group: &GroupId, dynkin: &[i64]) -> Result<Self, BcdError> {
        if dynkin.is_empty() {
            return Err(BcdError::EmptyLabel);
        }
        let (series, r) = match group.root_system {
            RootSystem::B(r) => (Series::B, r),
            RootSystem::C(r) => (Series::C, r),
            RootSystem::D(r) => (Series::D, r),
            other => return Err(BcdError::UnsupportedRootSystem { root_system: other }),
        };
        if dynkin.len() != r {
            return Err(BcdError::RankMismatch {
                expected: r,
                got: dynkin.len(),
            });
        }
        if r < series.min_rank() {
            return Err(BcdError::ExcludedRank {
                series,
                rank: r,
                redirect: series.low_rank_redirect(group.form),
            });
        }
        if dynkin.iter().any(|&a| a < 0) {
            return Err(BcdError::NegativeDynkin {
                dynkin: dynkin.to_vec(),
            });
        }
        // Central-character condition of `group` — one implementation, in
        // `crate::group` (issue #87 §2). For a form that does not admit the
        // spinor lattice this is the tensor-irrep constraint.
        if !group.admits(dynkin) {
            return Err(BcdError::NotAdmissible {
                group: *group,
                dynkin: dynkin.to_vec(),
            });
        }
        Ok(Irrep {
            series,
            two_weight: dynkin_to_two_partition(series, dynkin).into_boxed_slice(),
        })
    }

    /// The trivial (vacuum) irrep of `series` at rank `r` — the zero weight.
    pub fn trivial(series: Series, r: usize) -> Result<Self, BcdError> {
        Self::from_dynkin(series, &vec![0i64; r])
    }

    /// Construct directly from an ε-basis integer partition `weight`, bypassing
    /// the Dynkin validation. `pub(crate)` for the S3.3 catalog's bounded-dim
    /// irrep enumeration (`bcd::catalog`), which only ever produces valid
    /// integer dominant weights of this family; not a public constructor
    /// (the public path is [`Irrep::from_dynkin`](crate::bcd::Irrep::from_dynkin), which validates).
    pub(crate) fn from_two_weight(series: Series, two_weight: Vec<i64>) -> Irrep {
        Irrep {
            series,
            two_weight: two_weight.into_boxed_slice(),
        }
    }

    /// The series (`B`, `C` or `D`).
    pub fn series(&self) -> Series {
        self.series
    }

    /// The rank `r`.
    pub fn rank(&self) -> usize {
        self.two_weight.len()
    }

    /// The **doubled** highest weight `2λ` in the ε-basis — the always-exact
    /// form of the highest weight, integral for tensor *and* spinor irreps
    /// (the `dj = 2j` convention of the base SU(2) layer).
    pub fn two_partition(&self) -> &[i64] {
        &self.two_weight
    }

    /// The highest weight `λ` as an integer partition in the ε-basis, or
    /// `None` for a spinor irrep (whose `λ` is half-integral — use
    /// [`two_partition`](Self::two_partition)).
    pub fn partition(&self) -> Option<Vec<i64>> {
        if self.two_weight.iter().any(|x| x % 2 != 0) {
            return None;
        }
        Some(self.two_weight.iter().map(|x| x / 2).collect())
    }

    /// Whether this is a spinor irrep (half-integral ε-basis highest weight) —
    /// a representation of `Spin(N)` that is not one of `SO(N)`. Always `false`
    /// for the `C` series.
    pub fn is_spinor(&self) -> bool {
        self.two_weight.iter().any(|x| x % 2 != 0)
    }

    /// The `r` integer Dynkin labels.
    pub fn dynkin(&self) -> Vec<i64> {
        two_partition_to_dynkin(self.series, &self.two_weight)
    }

    /// The exact Weyl dimension.
    ///
    /// Computed from the Weyl dimension formula (Fulton–Harris §24.3,
    /// eq. 24.30) `dim = ∏_{α>0} ⟨λ+ρ,α⟩ / ⟨ρ,α⟩`, evaluated exactly over the
    /// positive roots as a `Ratio<BigInt>` (the product is integral). This
    /// reproduces the QSpace values `wdim_B/C/D` (`clebsch_aux.cc:458–559`).
    pub fn dim(&self) -> BigInt {
        let r = self.rank();
        let two_rho = two_rho(self.series, r);
        let mut acc = Ratio::<BigInt>::one();
        for alpha in positive_roots(self.series, r) {
            // ⟨λ+ρ,α⟩ / ⟨ρ,α⟩ = (2⟨λ,α⟩ + ⟨2ρ,α⟩) / ⟨2ρ,α⟩ — all integers,
            // and `2⟨λ,α⟩ = ⟨2λ,α⟩` is read straight off the stored weight.
            let two_lam = dot(&self.two_weight, &alpha);
            let two_rho_a = dot(&two_rho, &alpha);
            acc *= Ratio::new(BigInt::from(two_lam + two_rho_a), BigInt::from(two_rho_a));
        }
        acc.to_integer()
    }

    /// The dual (complex-conjugate) irrep.
    ///
    /// Derivation (Fulton–Harris §26; Bourbaki, `-w₀` = the diagram
    /// automorphism): the dual highest weight is `-w₀(λ)`, where `w₀` is the
    /// longest Weyl element.
    ///
    /// - `B_r`, `C_r`, and `D_r` with `r` **even**: `-w₀ = 1`, so every tensor
    ///   irrep is **self-dual**.
    /// - `D_r` with `r` **odd**: `-w₀` is the order-2 diagram automorphism that
    ///   swaps the last two nodes, i.e. `λ_r ↦ -λ_r` in the ε-basis
    ///   (equivalently, swap the last two Dynkin labels). This is the chirality
    ///   flip: `so(6) = D_3` has `dual((0,2,0)) = (0,0,2)` (a tensor chiral
    ///   pair) and the vector `(1,0,0)` self-dual.
    pub fn dual(&self) -> Irrep {
        let mut w = self.two_weight.to_vec();
        if self.series == Series::D && self.rank() % 2 == 1 {
            let last = self.rank() - 1;
            w[last] = -w[last];
        }
        Irrep {
            series: self.series,
            two_weight: w.into_boxed_slice(),
        }
    }

    /// The Frobenius–Schur indicator: `+1` (real/orthogonal), `-1`
    /// (quaternionic/symplectic), or `0` (complex, i.e. not self-dual).
    ///
    /// Derivation: a self-dual irrep of a compact simple group has indicator
    /// `(-1)^{⟨λ, 2ρ^∨⟩}`, where `2ρ^∨` is the sum of the positive **coroots**
    /// (Bourbaki VIII §7; equivalently the value of the principal central
    /// element `exp(2πi ρ^∨)` on `λ`). Evaluated in the ε-basis by
    /// `two_rho_vee` (the ε-basis sum of the positive coroots, this module).
    /// Consequences per family:
    ///
    /// - **`B_r`, `D_r` (`SO(N)` tensor irreps)**: `2ρ^∨` has all-even ε-basis
    ///   components, so an integer `λ` gives `+1` — every tensor irrep is
    ///   real, as it must be (it lives in a tensor power of the *real* vector
    ///   rep). The non-self-dual `D_r` (`r` odd, `λ_r ≠ 0`) chiral pair is `0`.
    /// - **`C_r` (`Sp(2r)`)**: `2ρ^∨` components are odd, so the indicator is
    ///   `(-1)^{Σ_i λ_i}` — the sign by which the central element `-I ∈ Sp(2r)`
    ///   acts (the vector `(1,0,…)` is quaternionic, the adjoint `(2,0,…)`
    ///   real).
    /// - **Spinor irreps of `Spin(N)` (issue #54)**: the same formula gives the
    ///   `N mod 8` reality type, e.g. `Spin(5)`'s `4` and `Spin(12)`'s `32` are
    ///   quaternionic (`-1`) while `Spin(7)`'s `8` and `Spin(8)`'s `8_s` are
    ///   real (`+1`).
    pub fn frobenius_schur(&self) -> i32 {
        if *self != self.dual() {
            return 0;
        }
        // ⟨2λ, 2ρ^∨⟩ = 2⟨λ, 2ρ^∨⟩; the exponent is the half of it (an integer
        // for every self-dual λ, tensor or spinor).
        let twice = dot(&self.two_weight, &two_rho_vee(self.series, self.rank()));
        debug_assert_eq!(twice % 2, 0, "⟨λ,2ρ^∨⟩ must be an integer");
        if (twice / 2).rem_euclid(2) == 0 {
            1
        } else {
            -1
        }
    }

    /// Exact dominant-weight multiplicities of this irrep, computed by
    /// Freudenthal's recursion (Humphreys §13.4) in integer arithmetic.
    ///
    /// Keys are **doubled** dominant weights `2μ` (ε-basis) with `μ ≤ λ`;
    /// values are their multiplicities `m_λ(μ) ≥ 1`. Every weight of the irrep
    /// is a Weyl-image of exactly one key, with the same multiplicity.
    pub fn two_weight_multiplicities(&self) -> BTreeMap<Vec<i64>, u64> {
        freudenthal(self.series, &self.two_weight)
    }

    /// [`two_weight_multiplicities`](Self::two_weight_multiplicities) with the
    /// keys undoubled — the dominant weights `μ` themselves. `None` for a
    /// spinor irrep, whose weights are half-integers.
    pub fn weight_multiplicities(&self) -> Option<BTreeMap<Vec<i64>, u64>> {
        if self.is_spinor() {
            return None;
        }
        Some(
            self.two_weight_multiplicities()
                .into_iter()
                .map(|(k, v)| (k.iter().map(|x| x / 2).collect(), v))
                .collect(),
        )
    }
}

// ---- label ↔ partition maps ----------------------------------------------

/// Doubled partition (ε-basis) `2λ` from Dynkin labels `a`.
///
/// Doubling is what makes these maps exact for **every** admissible label of
/// the cover: the spinor labels (`B_r`: `a_r` odd; `D_r`: `a_{r-1}+a_r` odd)
/// have half-integer `λ` and were representable only because they used to be
/// rejected before this point (issue #87 §4(1)).
fn dynkin_to_two_partition(series: Series, a: &[i64]) -> Vec<i64> {
    let r = a.len();
    let mut lam = vec![0i64; r];
    match series {
        Series::B => {
            // 2λ_r = a_r, 2λ_i = 2λ_{i+1} + 2a_i.
            lam[r - 1] = a[r - 1];
            for i in (0..r - 1).rev() {
                lam[i] = lam[i + 1] + 2 * a[i];
            }
        }
        Series::C => {
            // 2λ_r = 2a_r, 2λ_i = 2λ_{i+1} + 2a_i.
            lam[r - 1] = 2 * a[r - 1];
            for i in (0..r - 1).rev() {
                lam[i] = lam[i + 1] + 2 * a[i];
            }
        }
        Series::D => {
            // 2λ_{r-1} = a_{r-1}+a_r, 2λ_r = a_r-a_{r-1}, 2λ_i = 2λ_{i+1}+2a_i.
            lam[r - 1] = a[r - 1] - a[r - 2];
            lam[r - 2] = a[r - 1] + a[r - 2];
            for i in (0..r - 2).rev() {
                lam[i] = lam[i + 1] + 2 * a[i];
            }
        }
    }
    lam
}

/// Dynkin labels `a` from a doubled partition `2λ` (inverse of
/// [`dynkin_to_two_partition`]).
fn two_partition_to_dynkin(series: Series, two_lam: &[i64]) -> Vec<i64> {
    let r = two_lam.len();
    let mut a = vec![0i64; r];
    for i in 0..r - 1 {
        a[i] = (two_lam[i] - two_lam[i + 1]) / 2;
    }
    match series {
        Series::B => a[r - 1] = two_lam[r - 1],
        Series::C => a[r - 1] = two_lam[r - 1] / 2,
        Series::D => {
            a[r - 2] = (two_lam[r - 2] - two_lam[r - 1]) / 2;
            a[r - 1] = (two_lam[r - 2] + two_lam[r - 1]) / 2;
        }
    }
    a
}

// ---- root system in the ε-basis ------------------------------------------

/// Euclidean inner product of two ε-basis integer vectors (roots/weights are
/// carried as their ε-coefficient vectors, e.g. `2e_i` as a `2` in slot `i`).
fn dot(u: &[i64], v: &[i64]) -> i64 {
    u.iter().zip(v).map(|(a, b)| a * b).sum()
}

/// `2ρ` (twice the Weyl vector, integer-valued) in the ε-basis:
/// `B: 2r-2i+1`, `C: 2r-2i+2`, `D: 2r-2i` (`i = 1..r`).
fn two_rho(series: Series, r: usize) -> Vec<i64> {
    (0..r)
        .map(|i0| {
            let i = i0 as i64 + 1;
            let rr = r as i64;
            match series {
                Series::B => 2 * rr - 2 * i + 1,
                Series::C => 2 * rr - 2 * i + 2,
                Series::D => 2 * rr - 2 * i,
            }
        })
        .collect()
}

/// `2ρ^∨` (the sum of the positive **coroots**) in the ε-basis:
/// `B: 2(r-i+1)`, `C: 2(r-i)+1`, `D: 2(r-i)` (`i = 1..r`).
///
/// Each pair `i<j` contributes `(e_i−e_j)^∨ + (e_i+e_j)^∨ = 2e_i` (those roots
/// have length² 2, so coroot = root); on top of that `B`'s short root `e_i`
/// contributes `2e_i` and `C`'s long root `2e_i` contributes `e_i`. Used by
/// [`Irrep::frobenius_schur`].
fn two_rho_vee(series: Series, r: usize) -> Vec<i64> {
    (0..r)
        .map(|i0| {
            let i = i0 as i64 + 1;
            let rr = r as i64;
            match series {
                Series::B => 2 * (rr - i) + 2,
                Series::C => 2 * (rr - i) + 1,
                Series::D => 2 * (rr - i),
            }
        })
        .collect()
}

/// The positive roots of the series in the ε-basis, as integer coefficient
/// vectors (Fulton–Harris §18). Common to all: `e_i − e_j`, `e_i + e_j`
/// (`i<j`); plus `e_i` for `B`, `2e_i` for `C`, none for `D`.
fn positive_roots(series: Series, r: usize) -> Vec<Vec<i64>> {
    let mut roots = Vec::new();
    for i in 0..r {
        for j in i + 1..r {
            let mut minus = vec![0i64; r];
            minus[i] = 1;
            minus[j] = -1;
            roots.push(minus);
            let mut plus = vec![0i64; r];
            plus[i] = 1;
            plus[j] = 1;
            roots.push(plus);
        }
    }
    match series {
        Series::B => {
            for i in 0..r {
                let mut e = vec![0i64; r];
                e[i] = 1;
                roots.push(e);
            }
        }
        Series::C => {
            for i in 0..r {
                let mut e = vec![0i64; r];
                e[i] = 2;
                roots.push(e);
            }
        }
        Series::D => {}
    }
    roots
}

// ---- Weyl-group dominant conjugation -------------------------------------

/// Sort a vector descending, returning the sorted vector and the sign of the
/// sorting permutation (`+1`/`-1`). For distinct entries this is `sgn(perm)`;
/// callers that need the sign guarantee distinctness first.
fn sort_desc_with_parity(v: &[i64]) -> (Vec<i64>, i32) {
    let n = v.len();
    let mut inv = 0usize;
    for i in 0..n {
        for j in i + 1..n {
            if v[i] < v[j] {
                inv += 1;
            }
        }
    }
    let mut out = v.to_vec();
    out.sort_unstable_by(|a, b| b.cmp(a));
    (out, if inv.is_multiple_of(2) { 1 } else { -1 })
}

/// The dominant Weyl-orbit representative of a weight `v` (ε-basis), ignoring
/// singularity (weights may lie on walls; the dominant representative is still
/// well defined). Used for Freudenthal multiplicity lookups.
///
/// - `B`/`C`: `|v|` sorted descending (Weyl group = all signed permutations).
/// - `D`: `|v|` sorted descending, last entry negated iff an odd number of
///   components were negative **and** the smallest `|v|` is non-zero (Weyl
///   group = *even* sign changes; a zero component frees the parity).
fn weyl_dominant(series: Series, v: &[i64]) -> Vec<i64> {
    let negcount = v.iter().filter(|&&x| x < 0).count();
    let absv: Vec<i64> = v.iter().map(|x| x.abs()).collect();
    let (mut sorted, _) = sort_desc_with_parity(&absv);
    if series == Series::D && !negcount.is_multiple_of(2) {
        let last = sorted.len() - 1;
        if sorted[last] != 0 {
            sorted[last] = -sorted[last];
        }
    }
    sorted
}

/// The dominant conjugate of a **ρ-shifted** vector `two_v = 2(a+ρ+μ)`
/// (carried at twice scale so `B`'s half-integer `ρ` stays integral),
/// together with `det(w) = ±1`, or `None` if `two_v` is Weyl-singular (lies on
/// a reflection wall — contributes `0` to the Racah–Speiser sum).
///
/// Singular ⇔ two components equal in absolute value (wall `e_i ± e_j`), or,
/// for `B`/`C`, a zero component (wall `e_i` resp. `2e_i`).
fn dominant_conjugate_signed(series: Series, two_v: &[i64]) -> Option<(Vec<i64>, i32)> {
    let negcount = two_v.iter().filter(|&&x| x < 0).count();
    let absv: Vec<i64> = two_v.iter().map(|x| x.abs()).collect();
    // Wall e_i±e_j: two equal absolute values.
    for i in 0..absv.len() {
        for j in i + 1..absv.len() {
            if absv[i] == absv[j] {
                return None;
            }
        }
    }
    let (mut sorted, perm_sign) = sort_desc_with_parity(&absv);
    match series {
        Series::B | Series::C => {
            // Wall e_i (B) / 2e_i (C): a zero component.
            if absv.contains(&0) {
                return None;
            }
            // det(w) = sgn(perm) · (-1)^{#flips}, #flips = #negatives.
            let sign = perm_sign * if negcount.is_multiple_of(2) { 1 } else { -1 };
            Some((sorted, sign))
        }
        Series::D => {
            // det(w) = sgn(perm) (even sign changes have det +1). Choose the
            // last sign to match the even-flip parity of the orbit: negative
            // iff #negatives is odd and the smallest |·| is non-zero.
            let last = sorted.len() - 1;
            if !negcount.is_multiple_of(2) && sorted[last] != 0 {
                sorted[last] = -sorted[last];
            }
            Some((sorted, perm_sign))
        }
    }
}

// ---- Freudenthal weight multiplicities -----------------------------------

/// Exact dominant-weight multiplicities of the irrep with **doubled** highest
/// weight `2λ`, by Freudenthal's recursion (Humphreys §13.4), in integer
/// arithmetic. Keys are the doubled dominant weights `2μ`.
///
/// Everything is carried at the doubled scale — weights `2μ`, roots `2α`, and
/// `2·(2ρ)`. Both the Freudenthal numerator and its denominator are quadratic
/// in that scale, so they pick up the same factor `4` and the multiplicities
/// are exactly the ones the undoubled recursion returns.
///
/// `ponytail:` weight coordinates and inner products are tiny for the ranks in
/// scope; multiplicities are accumulated in `i128`. Upgrade to `BigInt` here
/// only if an application drives rank/label high enough to overflow (weights
/// would have to reach thousands).
fn freudenthal(series: Series, lambda: &[i64]) -> BTreeMap<Vec<i64>, u64> {
    let r = lambda.len();
    let two_rho: Vec<i64> = two_rho(series, r).into_iter().map(|x| 2 * x).collect();
    let roots: Vec<Vec<i64>> = positive_roots(series, r)
        .into_iter()
        .map(|a| a.into_iter().map(|x| 2 * x).collect())
        .collect();

    // Dominant weights μ ≤ λ (same root lattice), with their depth = height of
    // λ-μ in simple roots. Enumerate a box of dominant weights and keep those
    // with λ-μ a non-negative *integer* combination of simple roots.
    let mut doms: Vec<(i64, Vec<i64>)> = enumerate_dominant_below(series, lambda)
        .into_iter()
        .map(|mu| (depth(series, lambda, &mu), mu))
        .collect();
    doms.sort();

    // ⟨λ+ρ,λ+ρ⟩ contribution that survives the difference: ⟨λ,λ⟩ + ⟨λ,2ρ⟩.
    let casimir = |w: &[i64]| -> i128 { (dot(w, w) + dot(w, &two_rho)) as i128 };
    let cas_lambda = casimir(lambda);

    let mut mult: BTreeMap<Vec<i64>, u64> = BTreeMap::new();
    for (_, mu) in &doms {
        if mu == lambda {
            mult.insert(mu.clone(), 1);
            continue;
        }
        let denom = cas_lambda - casimir(mu);
        debug_assert!(denom > 0, "Freudenthal denominator must be positive");
        let mut num: i128 = 0;
        for alpha in &roots {
            let aa = dot(alpha, alpha) as i128;
            let mu_a = dot(mu, alpha) as i128;
            let mut k: i128 = 1;
            loop {
                // μ + kα
                let shifted: Vec<i64> = mu
                    .iter()
                    .zip(alpha)
                    .map(|(&m, &al)| m + (k as i64) * al)
                    .collect();
                let dom = weyl_dominant(series, &shifted);
                match mult.get(&dom) {
                    Some(&m) if m > 0 => {
                        num += 2 * (mu_a + k * aa) * (m as i128);
                        k += 1;
                    }
                    _ => break,
                }
            }
        }
        debug_assert_eq!(num % denom, 0, "Freudenthal must divide exactly");
        let m = num / denom;
        if m > 0 {
            mult.insert(mu.clone(), m as u64);
        }
    }
    mult
}

/// Height of `λ − μ` in the simple-root basis (its coefficient sum), assuming
/// `μ ≤ λ` so all coefficients are non-negative integers.
fn depth(series: Series, lambda: &[i64], mu: &[i64]) -> i64 {
    let d: Vec<i64> = lambda.iter().zip(mu).map(|(&l, &m)| l - m).collect();
    simple_root_coeffs(series, &d)
        .map(|c| c.iter().sum())
        .unwrap_or(-1)
}

/// Coefficients `c` with `d = Σ cᵢ αᵢ` (simple roots, ε-basis) from the
/// **doubled** difference `dd = 2d`, or `None` if `d` is not a non-negative
/// integer combination — which now also rejects the half-integer `d` of a
/// tensor/spinor weight pair (they lie in different classes of `P/Q`, so
/// neither is a weight of the other's irrep). Closed forms from the simple-root
/// structure (Fulton–Harris §18):
/// - `B`/`C`/`D` share `cⱼ = Σ_{i≤j} dᵢ` for the `e_i − e_{i+1}` part;
///   the short/spin root closes the last one(s).
fn simple_root_coeffs(series: Series, dd: &[i64]) -> Option<Vec<i64>> {
    let r = dd.len();
    // Prefix sums = coefficients of the e_i - e_{i+1} chain. Each must be an
    // integer: at the doubled scale that is "each prefix of `dd` is even",
    // which holds iff every dᵢ is an integer.
    let mut c = vec![0i64; r];
    let mut acc = 0i64;
    for i in 0..r {
        acc += dd[i];
        if acc % 2 != 0 {
            return None;
        }
        c[i] = acc / 2; // provisional; last one(s) fixed per series below
    }
    let total = acc; // = 2·Σ dᵢ
    match series {
        Series::B => {
            // α_r = e_r, c_r = Σ d_i = total/2 (already c[r-1]).
        }
        Series::C => {
            // α_r = 2e_r, c_r = (Σ d_i)/2 = total/4.
            if total % 4 != 0 {
                return None;
            }
            c[r - 1] = total / 4;
        }
        Series::D => {
            // α_r = e_{r-1}+e_r: c_r = (Σ d_i)/2, c_{r-1} = c_r - d_r.
            if total % 4 != 0 {
                return None;
            }
            c[r - 1] = total / 4;
            c[r - 2] = total / 4 - dd[r - 1] / 2;
        }
    }
    if c.iter().all(|&x| x >= 0) {
        Some(c)
    } else {
        None
    }
}

/// All dominant weights `2μ` with `μ ≤ λ` (dominance) and `λ − μ` in the root
/// lattice, by enumerating a bounded box of dominant weights and filtering
/// with [`simple_root_coeffs`]. Doubled throughout; the enumeration steps by
/// `2`, so it stays inside `λ`'s class of `P/Q` (integer or half-integer
/// coordinates, never a mix).
fn enumerate_dominant_below(series: Series, lambda: &[i64]) -> Vec<Vec<i64>> {
    let r = lambda.len();
    let hi = lambda[0]; // μ ≤ λ ⇒ μ₁ ≤ λ₁; all |μ_i| ≤ λ₁.
    let mut out = Vec::new();
    let mut cur = vec![0i64; r];
    enum_dom_rec(series, lambda, hi, 0, &mut cur, &mut out);
    out
}

fn enum_dom_rec(
    series: Series,
    lambda: &[i64],
    hi: i64,
    pos: usize,
    cur: &mut Vec<i64>,
    out: &mut Vec<Vec<i64>>,
) {
    let r = cur.len();
    if pos == r {
        if simple_root_coeffs(series, &sub(lambda, cur)).is_some() {
            out.push(cur.clone());
        }
        return;
    }
    // Dominant: μ_pos ≤ μ_{pos-1} (and ≤ hi). For D the last slot also allows
    // negatives down to -μ_{r-2} (chirality); for B/C the floor is 0. Values
    // step by 2 (doubled scale), keeping μ in λ's class — for a spinor λ the
    // floor 0 is therefore never reached, the smallest value is 1.
    let upper = if pos == 0 { hi } else { cur[pos - 1] };
    let lower = if series == Series::D && pos == r - 1 {
        -cur[pos - 1]
    } else {
        0
    };
    let mut v = upper;
    while v >= lower {
        cur[pos] = v;
        enum_dom_rec(series, lambda, hi, pos + 1, cur, out);
        v -= 2;
    }
    cur[pos] = 0;
}

fn sub(a: &[i64], b: &[i64]) -> Vec<i64> {
    a.iter().zip(b).map(|(&x, &y)| x - y).collect()
}

// ---- Weyl orbit ----------------------------------------------------------

/// All distinct Weyl-group images of a dominant weight `mu` (ε-basis):
/// signed permutations for `B`/`C`, even-signed permutations for `D`.
fn weyl_orbit(series: Series, mu: &[i64]) -> Vec<Vec<i64>> {
    let r = mu.len();
    let mut set: HashSet<Vec<i64>> = HashSet::new();
    for signs in 0u32..(1u32 << r) {
        let flips = signs.count_ones() as usize;
        if series == Series::D && !flips.is_multiple_of(2) {
            continue;
        }
        let signed: Vec<i64> = (0..r)
            .map(|i| if signs & (1 << i) != 0 { -mu[i] } else { mu[i] })
            .collect();
        permute_into(&signed, &mut set);
    }
    set.into_iter().collect()
}

/// Insert every permutation of `v` into `set`.
fn permute_into(v: &[i64], set: &mut HashSet<Vec<i64>>) {
    let mut idx: Vec<usize> = (0..v.len()).collect();
    permute_rec(v, &mut idx, 0, set);
}

fn permute_rec(v: &[i64], idx: &mut Vec<usize>, k: usize, set: &mut HashSet<Vec<i64>>) {
    let n = idx.len();
    if k == n {
        set.insert(idx.iter().map(|&i| v[i]).collect());
        return;
    }
    for i in k..n {
        idx.swap(k, i);
        permute_rec(v, idx, k + 1, set);
        idx.swap(k, i);
    }
}

// ---- Brauer–Klimyk / Racah–Speiser product decomposition -----------------

/// Exact tensor-product decomposition: the fusion multiplicities `N^c_ab` of
/// `a ⊗ b`, keyed by the resulting irrep `c`.
///
/// Requires `a` and `b` to label the same group (same series and rank); a
/// mismatch is an ill-posed input across distinct groups and returns
/// [`BcdError::GroupMismatch`].
///
/// Algorithm (Racah–Speiser / Brauer–Klimyk, Humphreys §24): for every weight
/// # Returns
///
/// Every irrep `c` with `N^c_ab > 0`, mapped to that multiplicity. Channels
/// with `N^c_ab = 0` are **absent**, not present with value `0`. Iteration
/// order is the deterministic `Ord` order of [`Irrep`]. No
/// [`CanonicalCatalog`] is needed — this is pure exact combinatorics, so you
/// can explore fusion channels without generating any coefficient.
///
/// ```
/// # #[cfg(feature = "cgc-gen")] {
/// use racah::bcd::{directproduct, Irrep, Series};
///
/// // SO(5) = B_2: 5 (x) 5 = 1 (+) 10 (+) 14, each once.
/// let v = Irrep::from_dynkin(Series::B, &[1, 0]).unwrap();
/// let out = directproduct(&v, &v).unwrap();
/// assert_eq!(out.len(), 3);
/// assert!(out.values().all(|&m| m == 1));
/// # }
/// ```
///
/// Algorithm (Racah–Speiser / Brauer–Klimyk, Humphreys §24): for every weight
/// `μ` of `b` (multiplicity `m_b(μ)` from Freudenthal, expanded over its Weyl
/// orbit), form `ξ = a + μ + ρ`. If `ξ` is Weyl-singular it contributes `0`;
/// otherwise let `w` be the Weyl element making `ξ` dominant, and add
/// `det(w)·m_b(μ)` to the coefficient of the irrep with highest weight
/// `w(ξ) − ρ`. All arithmetic is exact integer; `ρ`-shifts are carried at
/// twice scale to keep `B`'s half-integer `ρ` integral.
pub fn directproduct(a: &Irrep, b: &Irrep) -> Result<BTreeMap<Irrep, u32>, BcdError> {
    if a.series != b.series || a.rank() != b.rank() {
        return Err(BcdError::GroupMismatch {
            a: (a.series, a.rank()),
            b: (b.series, b.rank()),
        });
    }
    let series = a.series;
    let r = a.rank();
    let two_rho = two_rho(series, r);
    // 2(a + ρ), constant across the μ loop (the stored weight is already 2a).
    let two_a_rho: Vec<i64> = a
        .two_weight
        .iter()
        .zip(&two_rho)
        .map(|(&av, &tr)| av + tr)
        .collect();

    let mut acc: BTreeMap<Vec<i64>, i64> = BTreeMap::new();
    for (mu, &m) in &b.two_weight_multiplicities() {
        let m = m as i64;
        for omega in weyl_orbit(series, mu) {
            // 2ξ = 2(a+ρ) + 2μ (the orbit is already at the doubled scale).
            let two_xi: Vec<i64> = two_a_rho
                .iter()
                .zip(&omega)
                .map(|(&ar, &w)| ar + w)
                .collect();
            if let Some((dom, sign)) = dominant_conjugate_signed(series, &two_xi) {
                // 2c = dom - 2ρ.
                let c: Vec<i64> = dom.iter().zip(&two_rho).map(|(&d, &tr)| d - tr).collect();
                *acc.entry(c).or_insert(0) += sign as i64 * m;
            }
        }
    }

    let mut result: BTreeMap<Irrep, u32> = BTreeMap::new();
    for (c, n) in acc {
        debug_assert!(n >= 0, "Racah–Speiser multiplicity must be non-negative");
        if n > 0 {
            result.insert(
                Irrep {
                    series,
                    two_weight: c.into_boxed_slice(),
                },
                n as u32,
            );
        }
    }
    Ok(result)
}

mod seeds;
pub use seeds::{check_commutators, defining_seed, spinor_seeds, CommReport, Seed};

// The decomposition sweep (S3.2) and its dense linalg seam depend on
// `tenferro-linalg`, so they live behind the `cgc-gen` feature like the SU(N)
// CGC pipeline; the base crate stays dependency-light.
#[cfg(feature = "cgc-gen")]
mod linalg;
#[cfg(feature = "cgc-gen")]
mod sweep;
#[cfg(feature = "cgc-gen")]
pub use sweep::{
    decompose, decompose_defining_product, Block, Decomposition, Generators, SweepError,
};

// The S3.3 canonical catalog (append-only generator ownership) sits on top of
// the sweep, so it shares the `cgc-gen` feature gate.
#[cfg(feature = "cgc-gen")]
mod catalog;
#[cfg(feature = "cgc-gen")]
pub use catalog::{CanonicalCatalog, CatalogCgc, CatalogError};

// S3.4 (#27): the B/C/D binding of the family-generic F/R core, over the S3.3
// catalog's canonical CGC.
#[cfg(feature = "cgc-gen")]
mod fr;
#[cfg(feature = "cgc-gen")]
pub use fr::{
    cgc_sweeps, check_f_unitarity, check_hexagon, check_pentagon, f_symbol, r_symbol, FBlock,
    FrError, RBlock,
};

/// Opaque authority fingerprint of the generated SO(N)/Sp(2N) provider — **the
/// version of the B/C/D gauge specification**.
///
/// `docs/gauge_soN.md` is a **frozen normative specification**: it is the
/// authority, and this crate is an implementation of it. These bytes name the
/// version of that document, not "whatever the current code outputs". A
/// refactor that moves a coefficient value is a deviation from the spec (a bug,
/// and `tests/gauge_golden.rs` fails on it), not a new gauge deserving a new
/// fingerprint. The value changes only on a specification correction, which
/// requires — in one PR — the spec edit stating the defect it corrects, the
/// `epoch` bump here and in `tests/bcd_fingerprint.rs`, a CHANGELOG
/// breaking-change entry, and regenerated golden values (`docs/gauge.md`,
/// "Status", which governs all three families). So: **same fingerprint, same
/// coefficients**, within the tolerance class the binding contract below
/// disclaims.
///
/// The bytes identify the *convention set*, generation pipeline, and
/// verification/tolerance policy under which every B/C/D Clebsch–Gordan isometry
/// (and the F/R symbols contracted from it) is produced. Their sole use is
/// equality comparison: a consumer may persist the bytes next to data derived
/// from these coefficients and later compare them to decide whether that derived
/// data was produced under the same convention.
///
/// # Contract (binding)
///
/// > Equal fingerprints identify the same convention, generation pipeline, and
/// > tolerance policy. They do not imply byte-identical values or independently
/// > prove numerical agreement.
///
/// This is deliberately weaker than the base SU(2) fingerprint
/// ([`crate::su2_authority_fingerprint`]), whose exact big-rational surface lets
/// equal bytes mean equal values. The generated B/C/D family is a *two-layer*
/// contract (`docs/gauge_soN.md` §12: value agreement within the verification
/// tolerances, bitwise reproducible only single-threaded in-process, not across
/// processes): the seed/sweep/sign/alignment gauge is a deterministic function
/// of the subspace, but the QR/matmul stages run in `f64` and the backend's
/// reductions are not bit-reproducible across processes. **Numerical agreement
/// is established by the generation-time verification gates** (`docs/gauge_soN.md`
/// §5, §6, §10: orthonormality, Cartan diagonality, exact-multiplicity — typed
/// `SweepError`/`CatalogError`, never silent) **and the independent oracle
/// suites** (`docs/gauge_soN.md` §13: exact decomposition vs `directproduct`,
/// OM ≥ 2, determinism, sign convention), **never by this fingerprint.**
///
/// # Consumer contract
///
/// - **Opaque.** Compare by equality only; never parse the tags or split on
///   `:` / `=`. The internal shape is not a stable interface.
/// - **Stable across patch and minor releases.** The value is not derived from
///   the crate version, source, docs, a pointer, or any process-local state.
/// - **Changes exactly with a specification correction.** The trailing `epoch` is
///   bumped by hand — and only — when `docs/gauge_soN.md` is corrected in a way
///   that alters a returned coefficient value, its normalization, or the
///   canonical gauge it is expressed in (the four-step rule of `docs/gauge.md`,
///   "Status"). The compatibility-policy test (`tests/bcd_fingerprint.rs`) pins
///   the exact bytes, so any such change is a mutation-visible review event.
/// - **Epoch is per-family and independent.** The B/C/D `epoch` moves
///   independently of the SU(2) and SU(N) epochs; a B/C/D gauge change never
///   invalidates SU(2)-derived or SU(N)-derived consumer state (and vice versa).
///   The base SU(2) surface is untouched by this fingerprint.
///
/// # Tags and the conventions they pin (each cites `docs/gauge_soN.md`)
///
/// Every tag names a rule the gauge document already pins; nothing here invents
/// a convention. The backend identity is deliberately excluded — per-backend ULP
/// differences are inside the tolerance class this fingerprint's contract
/// disclaims (`docs/gauge_soN.md` §12); backend structural identity is instead a
/// separate acceptance gate (`tests/generated_backend_identity.rs`).
///
/// - `ref=qspace-v4-dd2cc7e` — the port reference: QSpace v4 (Weichselbaum),
///   revision `dd2cc7e` (`docs/gauge_soN.md`, header).
/// - `kron=a-fast` — the Kronecker/product-basis convention `composite(m_a, m_b)
///   = m_a + d_a·m_b` (first factor fast); a different convention permutes the
///   CGC rows and is a different gauge (`docs/gauge_soN.md` §1).
/// - `parent=canonical-parent` — the canonical-parent well-order that makes each
///   irrep's stored generator frame query-order-independent (`docs/gauge_soN.md`
///   §14).
/// - `sweep=gs2-qrpos-posdiag` — the decomposition sweep: persistent seed
///   selection, ascending-index raise/lower, two-pass Gram–Schmidt, and
///   `PositiveDiagonal` QR orthonormalization (`docs/gauge_soN.md` §2–§4, 4a).
/// - `sort=maxweight-desc` — the descending-weight sort (reversed Cartan columns)
///   with ascending-basis-index tie-break (`docs/gauge_soN.md` §7).
/// - `sign=first-significant-positive` — the unconditional block sign convention:
///   the first significant CGC entry (storage order) is made positive
///   (`docs/gauge_soN.md` §8, incl. racah deviation #2).
/// - `align=procrustes-canonical` — the intertwiner alignment that rotates a
///   rediscovered block's frame onto the stored canonical frame via the
///   orthogonal Procrustes solution (`docs/gauge_soN.md` §15).
/// - `tol=cg-eps-tier` — the QSpace CG_EPS tolerance tier (`EPS_SWEEP`,
///   `EPS_VERIFY`, `CG_EPS1`, `EPS_MW_UNIQUE`, `FIXRATIONAL_TOL`;
///   `docs/gauge_soN.md` §11).
/// - `epoch=2` — the per-family manual epoch (see above). Moved `1` → `2` by
///   the base-case frame correction of `docs/gauge_soN.md` §14.2 (issue #90),
///   which re-frames the B/D defining seed into the sweep's descending-weight
///   order and moves every B/D value that couples through it; C values are
///   unchanged.
///
/// # Stability
///
/// **Unstable: shape may change while the generated-provider contract is
/// negotiated.** Cargo features cannot express instability tiers; this label and
/// issue #47 are the ledger.
#[cfg(feature = "cgc-gen")]
pub fn bcd_authority_fingerprint() -> &'static [u8] {
    // Spec version. Bump the trailing `epoch=N` (and the literal in
    // tests/bcd_fingerprint.rs) only when docs/gauge_soN.md is corrected, under
    // the four-step rule in docs/gauge.md "Status". A refactor never bumps it.
    b"racah:bcd-bootstrap:ref=qspace-v4-dd2cc7e:kron=a-fast:parent=canonical-parent:sweep=gs2-qrpos-posdiag:sort=maxweight-desc:sign=first-significant-positive:align=procrustes-canonical:tol=cg-eps-tier:epoch=2"
}

#[cfg(test)]
mod tests;

// S3.5 external anchor: QSpace CGC oracle, behind the factor-basis dictionary.
#[cfg(all(test, feature = "cgc-gen"))]
mod qspace_oracle_tests;