kshana 0.27.1

Open, reproducible PNT-resilience simulator with quantum-sensor performance models
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
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
// SPDX-License-Identifier: AGPL-3.0-only
//! Scenario dispatch shared by the CLI and the language bindings.
//!
//! [`run_toml`] parses a scenario from a TOML string, dispatches on its `kind`,
//! runs the matching pack, and returns the result as pretty JSON together with an
//! SVG chart and a one-line summary. The CLI, the Python binding, and the
//! WebAssembly binding all go through this one entry point so they never drift.

use crate::scenario::GnssState;
use serde::Deserialize;
use sha2::{Digest, Sha256};

/// The outputs of a scenario run: the result document, an SVG chart, a
/// human-readable one-line summary, and an optional CSV reproducibility artifact
/// (emitted by scenarios that publish a byte-stable table — e.g. `realtime-frame-eop`
/// writes its P4 Table 1 + Table 2 CSV here so a plain run produces the file the paper
/// cites, not only the `#[ignore]` golden-regen test).
#[derive(Clone, Debug, Default)]
pub struct RunOutput {
    pub json: String,
    pub svg: String,
    pub summary: String,
    /// Optional CSV artifact; `None` for scenarios that publish no CSV table.
    pub csv: Option<String>,
}

impl RunOutput {
    /// Write the CSV artifact (when present) to `path`, returning the bytes written.
    /// A no-op returning `Ok(0)` when the scenario produced no CSV.
    pub fn write_csv(&self, path: &std::path::Path) -> std::io::Result<usize> {
        match &self.csv {
            Some(csv) => {
                std::fs::write(path, csv)?;
                Ok(csv.len())
            }
            None => Ok(0),
        }
    }
}

/// Escape the five characters that matter in HTML text/attribute context.
fn html_escape(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&#39;")
}

/// Percent-encode an SVG for an inert `data:` URI: the chart renders as an image
/// (so no embedded markup or script can execute) and the report stays a single
/// self-contained file.
fn svg_data_uri(svg: &str) -> String {
    let mut out = String::from("data:image/svg+xml,");
    for b in svg.bytes() {
        match b {
            b'%' | b'#' | b'<' | b'>' | b'"' | b'&' | b'\n' | b'\r' | b'\t' => {
                out.push_str(&format!("%{b:02X}"));
            }
            _ => out.push(b as char),
        }
    }
    out
}

/// Stamp a chart SVG with a self-identifying provenance footer in the bottom-right
/// corner — `Kshana v<version> · scenario <hash> · kshana.dev` — so a saved or
/// downloaded image always carries its version, the scenario fingerprint, and the
/// source. Applied centrally so every scenario kind is stamped identically.
fn with_provenance(svg: String, hash12: &str) -> String {
    let w = parse_svg_dim(&svg, "width").unwrap_or(800.0);
    let h = parse_svg_dim(&svg, "height").unwrap_or(420.0);
    let footer = format!(
        "<text x=\"{:.0}\" y=\"{:.0}\" text-anchor=\"end\" fill=\"#62594b\" font-size=\"10\" font-family=\"sans-serif\">Kshana v{} \u{00b7} scenario {} \u{00b7} kshana.dev</text>",
        w - 8.0,
        h - 6.0,
        env!("CARGO_PKG_VERSION"),
        hash12,
    );
    match svg.rfind("</svg>") {
        Some(i) => {
            let mut s = svg;
            s.insert_str(i, &footer);
            s
        }
        None => svg,
    }
}

/// Parse the root `<svg>`'s first `width`/`height` attribute as an f64 (the root
/// tag's dimensions precede any inner `rect`/`line`, so the first match is it).
fn parse_svg_dim(svg: &str, attr: &str) -> Option<f64> {
    let needle = format!("{attr}=\"");
    let start = svg.find(&needle)? + needle.len();
    let rest = &svg[start..];
    let end = rest.find('"')?;
    rest[..end].parse().ok()
}

/// Pull the 12-char `scenario_hash` fingerprint out of a result JSON document, if
/// present, so the chart footer matches the hash shown elsewhere for that run.
fn extract_scenario_hash(json: &str) -> Option<String> {
    let key = json.find("\"scenario_hash\"")?;
    let rest = &json[key + "\"scenario_hash\"".len()..];
    let open = rest.find('"')?; // opening quote of the value, after the colon
    let val = &rest[open + 1..];
    let end = val.find('"')?;
    Some(val[..end].chars().take(12).collect())
}

/// Pull a string value for `key` out of the result document's `"meta"` object,
/// if both the object and the key are present. Scoped to the `"meta"` block (which
/// the engine emits only when [`crate::report::StudyMeta`] is set) so it cannot
/// pick up an unrelated same-named key elsewhere in the JSON. Returns `None` when
/// the result carries no metadata, keeping meta-less reports byte-identical.
fn extract_meta_str(json: &str, key: &str) -> Option<String> {
    let meta_at = json.find("\"meta\"")?;
    let block = &json[meta_at..];
    let needle = format!("\"{key}\"");
    let key_at = block.find(&needle)?;
    let rest = &block[key_at + needle.len()..];
    let colon = rest.find(':')?;
    let after = &rest[colon + 1..];
    let open = after.find('"')?; // opening quote of the string value
    let val = &after[open + 1..];
    let end = val.find('"')?;
    Some(val[..end].to_string())
}

/// A stable 12-char fingerprint of the scenario source, for charts whose result
/// document does not carry a `scenario_hash` (e.g. the integrity/lunar reports).
fn src_fingerprint(src: &str) -> String {
    let mut h = Sha256::new();
    h.update(src.as_bytes());
    hex::encode(h.finalize()).chars().take(12).collect()
}

/// The timing-domain figures of merit, in report order, paired with a human label
/// and unit. These are the [`crate::fom::FoMScores`] field names; each carries a
/// validation tier looked up from [`crate::fom_label::tier_for`] (which derives it
/// from the verification matrix). Kept here so the HTML report and the lookup never
/// drift on the set of names.
const FOM_LABELS: &[(&str, &str, &str)] = &[
    ("holdover_s", "Holdover", "s"),
    ("timing_rms_ns", "Timing RMS", "ns"),
    ("timing_p95_ns", "Timing p95", "ns"),
    ("resilience_slope_ns_per_s", "Resilience slope", "ns/s"),
    ("availability", "Availability", ""),
    ("integrity", "Integrity", ""),
    ("security", "Security", ""),
];

/// Render a per-FoM validation-tier table from a run's JSON, walking it for any
/// `fom` objects (e.g. `quantum.fom` / `classical.fom`) and emitting one row per
/// present-and-numeric figure of merit with its value and its MODELLED/VALIDATED
/// tier from the verification matrix. Returns an empty string when the result
/// carries no clock-style FoM block (ephemeris / RAIM / spoof reports), so those
/// reports are unchanged. The tier is the honesty surface this method exists for.
fn fom_tier_table(json: &str) -> String {
    let Ok(root) = serde_json::from_str::<serde_json::Value>(json) else {
        return String::new();
    };
    // Collect (clock-label, fom-object) pairs from the known result shapes.
    let mut blocks: Vec<(String, &serde_json::Map<String, serde_json::Value>)> = Vec::new();
    for clock in ["quantum", "classical"] {
        if let Some(fom) = root
            .get(clock)
            .and_then(|c| c.get("fom"))
            .and_then(|f| f.as_object())
        {
            let label = root
                .get(clock)
                .and_then(|c| c.get("spec"))
                .and_then(|s| s.get("id"))
                .and_then(|v| v.as_str())
                .unwrap_or(clock)
                .to_string();
            blocks.push((label, fom));
        }
    }
    // A single top-level `fom` block (some packs report one clock).
    if blocks.is_empty() {
        if let Some(fom) = root.get("fom").and_then(|f| f.as_object()) {
            blocks.push((String::new(), fom));
        }
    }
    if blocks.is_empty() {
        return String::new();
    }

    let mut rows = String::new();
    for (clock_label, fom) in &blocks {
        for (key, label, unit) in FOM_LABELS {
            let Some(value) = fom.get(*key).and_then(|v| v.as_f64()) else {
                continue;
            };
            let Some(tier) = crate::fom_label::tier_for(key) else {
                continue;
            };
            let metric = if unit.is_empty() {
                (*label).to_string()
            } else {
                format!("{label} ({unit})")
            };
            let clock_cell = if clock_label.is_empty() {
                String::new()
            } else {
                format!("<td>{}</td>", html_escape(clock_label))
            };
            rows.push_str(&format!(
                "<tr>{clock_cell}<td>{}</td><td class=\"num\">{:.3}</td><td><span class=\"tier\">{}</span></td></tr>",
                html_escape(&metric),
                value,
                tier.tag(),
            ));
        }
    }
    if rows.is_empty() {
        return String::new();
    }
    let clock_header = if blocks.iter().any(|(l, _)| !l.is_empty()) {
        "<th>Clock</th>"
    } else {
        ""
    };
    format!(
        "<h2 class=\"fom-h\">Figures of merit</h2>\n\
         <table class=\"fom\"><thead><tr>{clock_header}<th>Metric</th>\
         <th class=\"num\">Value</th><th>Validation</th></tr></thead><tbody>{rows}</tbody></table>\n\
         <p class=\"tier-note\">Each figure of merit is tagged VALIDATED (checked against an \
         external oracle) or MODELLED (first-principles, internally tested), derived from the \
         verification matrix.</p>\n"
    )
}

impl RunOutput {
    /// Render a self-contained, branded HTML scorecard: the one-line summary, the
    /// chart (as an inert image), a per-FoM validation-tier table, and the full
    /// JSON result.
    pub fn html_report(&self) -> String {
        // Fallback-safe study metadata: when the result document carries a
        // `meta.study_title`, the page title becomes "<title> — Kshana"; when it
        // carries a caller-supplied `meta.generated_utc`, a stamp line is appended
        // to the footer. With no metadata both extractors return None and the
        // strings are EXACTLY the legacy ones (byte back-compat). The timestamp is
        // only ever read from the document — never from a clock — so the report
        // stays pure/deterministic.
        let title = match extract_meta_str(&self.json, "study_title") {
            Some(t) => format!("{} \u{2014} Kshana", html_escape(&t)),
            None => "Kshana \u{2014} scenario result".to_string(),
        };
        let generation_stamp = match extract_meta_str(&self.json, "generated_utc") {
            Some(ts) => format!(" Study generated {}.", html_escape(&ts)),
            None => String::new(),
        };
        format!(
            "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\"/>\n\
             <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/>\n\
             <title>{title}</title>\n<style>\n\
             :root{{color-scheme:light dark}}\
             body{{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;line-height:1.55;\
             max-width:900px;margin:0 auto;padding:2rem 1.25rem 3rem}}\
             .eyebrow{{letter-spacing:.18em;text-transform:uppercase;font-size:.72rem;opacity:.6;text-align:center}}\
             h1{{text-align:center;font-size:2.4rem;margin:.1rem 0;\
             background:linear-gradient(135deg,#2dd4bf,#6366f1,#a855f7);-webkit-background-clip:text;\
             background-clip:text;color:transparent}}\
             .tag{{text-align:center;opacity:.7;margin-top:0}}\
             .summary{{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:.86rem;\
             border-left:3px solid #6366f1;padding:.7rem .9rem;background:rgba(99,102,241,.08);\
             border-radius:8px;overflow-x:auto;white-space:pre-wrap;word-break:break-word}}\
             .chart{{text-align:center;margin:1.2rem 0}}\
             .chart img{{max-width:100%;height:auto;border:1px solid #8884;border-radius:8px;background:#fff}}\
             details{{border:1px solid #8884;border-radius:8px;padding:.5rem .9rem}}\
             summary{{cursor:pointer;font-weight:600}}\
             pre{{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:.78rem;overflow:auto;max-height:520px}}\
             h2.fom-h{{font-size:1.15rem;margin:1.6rem 0 .6rem;border-bottom:1px solid #8884;padding-bottom:.2rem}}\
             table.fom{{border-collapse:collapse;width:100%;font-size:.9rem}}\
             table.fom th,table.fom td{{border:1px solid #8884;padding:.35rem .6rem;text-align:left}}\
             table.fom .num{{text-align:right;font-variant-numeric:tabular-nums}}\
             .tier{{font-size:.72rem;letter-spacing:.06em;font-weight:600;padding:.05rem .4rem;border:1px solid #8886;border-radius:4px;white-space:nowrap}}\
             .tier-note{{font-size:.8rem;opacity:.75;margin:.5rem 0 1rem}}\
             footer{{margin-top:2rem;padding-top:1rem;border-top:1px solid #8884;font-size:.85rem;opacity:.75}}\
             </style>\n</head>\n<body>\n\
             <p class=\"eyebrow\">क्षण · the precise instant</p>\n\
             <h1>Kshana</h1>\n\
             <p class=\"tag\">Hybrid quantum / classical PNT performance scorecard</p>\n\
             <p class=\"summary\">{summary}</p>\n\
             <div class=\"chart\"><img alt=\"Result chart\" src=\"{chart}\"/></div>\n\
             {fom_table}\
             <details><summary>Full result (JSON)</summary><pre>{json}</pre></details>\n\
             <footer>Generated by Kshana {version}.{generation_stamp} Reproducible from scenario + seed + engine version. \
             Free and open source (AGPL-3.0) — <a href=\"https://github.com/AshfordeOU/kshana\">source &amp; docs</a>.</footer>\n\
             </body>\n</html>\n",
            summary = html_escape(&self.summary),
            chart = svg_data_uri(&self.svg),
            fom_table = fom_tier_table(&self.json),
            json = html_escape(&self.json),
            version = env!("CARGO_PKG_VERSION"),
        )
    }
}

#[derive(Deserialize)]
struct Kind {
    #[serde(default)]
    kind: String,
}

// Serialise a report to pretty JSON. Returns an error string rather than
// panicking: `serde_json` can fail when a `Serialize` impl errors or a map is
// keyed by a non-string type, which is a property of the (evolving) report types
// rather than something provable infallible at this call site, so the failure is
// propagated to the caller instead of being asserted away.
fn json_of<T: serde::Serialize>(v: &T) -> Result<String, String> {
    serde_json::to_string_pretty(v).map_err(|e| format!("failed to serialise report to JSON: {e}"))
}

/// [`json_of`], plus the report's `units` block.
///
/// Packs whose result document is built with `serde_json::json!` insert their own
/// `units` key inline. The packs that instead serialise a typed report struct have no
/// such literal to insert into, and bolting a `units` field onto the struct would change
/// a public type that other code round-trips. This helper adds the block to the
/// serialised document instead: purely additive, and the units table still lives in the
/// pack's own module, so the convention is one convention.
fn json_of_with_units<T: serde::Serialize>(
    v: &T,
    units: &[crate::field_schema::FieldUnit],
) -> Result<String, String> {
    json_of(&Documented {
        report: v,
        units: crate::field_schema::units_block(units),
    })
}

/// The wrapper [`json_of_with_units`] serialises: the report's own fields, flattened, and
/// then one `units` key.
///
/// Flattening rather than round-tripping through [`serde_json::Value`] is deliberate.
/// `serde_json`'s map is a `BTreeMap` here, so a `to_value` round trip would alphabetise
/// every key of a released document; `#[serde(flatten)]` keeps the report's declaration
/// order byte-for-byte and appends the new key at the end. The same shape as the
/// `OrbitOutput` wrapper further down this file.
#[derive(serde::Serialize)]
struct Documented<'a, T: serde::Serialize> {
    #[serde(flatten)]
    report: &'a T,
    units: serde_json::Value,
}

/// A minimal one-line SVG banner for scenario kinds whose primary artifact is the
/// JSON report (the rich table lives in the JSON, not a bespoke chart).
fn minimal_svg(summary: &str) -> String {
    let esc = summary
        .replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;");
    format!(
        "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1180\" height=\"40\" \
         font-family=\"sans-serif\" font-size=\"12\" fill=\"#e6edf3\">\
         <rect width=\"1180\" height=\"40\" fill=\"#0b0e14\"/>\
         <text x=\"10\" y=\"24\">{esc}</text></svg>"
    )
}

fn integ(i: Option<f64>) -> String {
    i.map_or_else(|| "n/a".to_string(), |v| format!("{v:.3}"))
}

fn fnum(v: Option<f64>) -> String {
    v.map_or_else(|| "n/a".to_string(), |v| format!("{v:.2}"))
}

fn posm(v: Option<f64>) -> String {
    v.map_or_else(|| "n/a".to_string(), |v| format!("{v:.2}m"))
}

/// A structured failure taxonomy for the typed API, so binding callers can
/// pattern-match on the *kind* of failure rather than parse an opaque string.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum KshanaError {
    /// The scenario could not be parsed or violated an input constraint.
    InvalidInput(String),
    /// A solver failed to converge (reserved; the deterministic packs do not
    /// currently produce this).
    NonConvergence(String),
    /// The requested scenario kind or feature is not supported.
    Unsupported(String),
    /// An I/O failure (reserved for file-backed callers).
    IoError(String),
}

impl std::fmt::Display for KshanaError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // The message passes through unchanged so the string-returning
        // `run_toml` keeps producing the same human-readable text it always has.
        match self {
            KshanaError::InvalidInput(s)
            | KshanaError::NonConvergence(s)
            | KshanaError::Unsupported(s)
            | KshanaError::IoError(s) => write!(f, "{s}"),
        }
    }
}

impl KshanaError {
    /// A stable machine tag for the failure kind, so binding callers can branch on
    /// it without parsing the human-readable message.
    pub fn kind_tag(&self) -> &'static str {
        match self {
            KshanaError::InvalidInput(_) => "invalid_input",
            KshanaError::NonConvergence(_) => "non_convergence",
            KshanaError::Unsupported(_) => "unsupported",
            KshanaError::IoError(_) => "io_error",
        }
    }
}

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

impl From<String> for KshanaError {
    fn from(s: String) -> Self {
        KshanaError::InvalidInput(s)
    }
}

/// The scenario kinds the engine can dispatch — the typed replacement for matching
/// on a raw `kind` string. [`ScenarioKind::classify`] resolves a TOML document's
/// `kind` field to one of these; the dispatcher then matches exhaustively on the
/// enum, so adding a pack is a compile-checked change rather than a string typo.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ScenarioKind {
    Clock,
    Inertial,
    Integrity,
    TimeTransfer,
    QuantumTimeTransfer,
    QuantumGnssFreeNav,
    QuantumAnomalyDetect,
    Hybrid,
    Fusion,
    HybridUkf,
    GnssIns,
    GnssSim,
    Jamming,
    Spoof,
    SpoofDetect,
    Sweep,
    SweepNd,
    Orbit,
    Ephemeris,
    LunarIntegrity,
    LunarTime,
    LunarVlbi,
    LunarCombination,
    LunarFrameRealise,
    LunarService,
    LunarDpnt,
    LunarInterop,
    GravityMap,
    Terrain,
    TerrainSlam,
    CombinedAltPnt,
    Pvt,
    MarsPnt,
    ImpairmentEval,
    QuantumTrade,
    SpaceWeather,
    OemInterop,
    LaunchWindow,
    Reentry,
    EoCoverage,
    SpacePacket,
    AttitudeBudget,
    Passes,
    LinkBudget,
    LunarTimeBudget,
    RealtimeFrameEop,
    HybridOpticalRf,
    CislunarObservability,
    /// Independent-estimator corroboration of the cislunar arc-length observability
    /// threshold: a seeded Monte-Carlo batch least-squares recovery study whose
    /// measurement partials are central finite differences of the forward model, so it
    /// shares no Jacobian with the observability Gramian it is tested against.
    CislunarArcRecovery,
    ConflictResilience,
    LunarAttackSurface,
    /// Aperture navigation-versus-communications duty cycle from a contact plan.
    ApertureDutyCycle,
    /// Lunar surface-navigation RF jamming with a per-satellite J/S table.
    LunarJamming,
    /// Surface-beacon augmentation of a lunar orbital navigation service.
    LunarBeacon,
    /// Earth-GNSS reception at lunar distance, through the transmit sidelobes.
    EarthGnssLunar,
    /// INS/TRN coasting error growth and the coast durations reaching stated thresholds.
    InsTrnCoast,
    /// Lunar-VLBI station-coordinate covariance accumulated from the delay partials over a
    /// schedule of baselines and epochs.
    LunarVlbiFim,
    /// Tracking-loop loss of lock: C/N0 thresholds with hysteresis, time to lose lock,
    /// spoof pull-in versus code and carrier offset rate, and a loop-dynamics denial
    /// radius reported alongside the power-ratio one.
    TrackingLoop,
    /// The engine's ARAIM HPL/VPL against the published worked numerical examples
    /// of the EU-U.S. Working Group C ARAIM Technical Subgroup reference airborne
    /// algorithm, at the tolerance (`TOL_PL`) those documents themselves state.
    AraimReferenceCheck,
    /// Seven-parameter Helmert frame datum driven by a simulated observing campaign: the
    /// lunar-VLBI beacon-coordinate information accumulated over a schedule and propagated
    /// through the Helmert design, with no transform injected anywhere.
    LunarFrameCampaign,
    /// The same seven-parameter Helmert frame datum driven by a **real, archived** observing
    /// campaign: measured ILRS lunar laser ranging normal points supply the schedule and
    /// every observation weight.
    LunarLlrDatum,
}

impl ScenarioKind {
    /// The canonical `kind` string for this variant.
    pub fn as_str(self) -> &'static str {
        match self {
            ScenarioKind::Clock => "clock",
            ScenarioKind::Inertial => "inertial",
            ScenarioKind::Integrity => "integrity",
            ScenarioKind::TimeTransfer => "timetransfer",
            ScenarioKind::QuantumTimeTransfer => "quantum-time-transfer",
            ScenarioKind::QuantumGnssFreeNav => "quantum-gnss-free-nav",
            ScenarioKind::QuantumAnomalyDetect => "quantum-anomaly-detect",
            ScenarioKind::Hybrid => "hybrid",
            ScenarioKind::Fusion => "fusion",
            ScenarioKind::HybridUkf => "hybrid-ukf",
            ScenarioKind::GnssIns => "gnss-ins",
            ScenarioKind::GnssSim => "gnss-sim",
            ScenarioKind::Jamming => "jamming",
            ScenarioKind::Spoof => "spoof",
            ScenarioKind::SpoofDetect => "spoof-detect",
            ScenarioKind::Sweep => "sweep",
            ScenarioKind::SweepNd => "sweep-nd",
            ScenarioKind::Orbit => "orbit",
            ScenarioKind::Ephemeris => "ephemeris",
            ScenarioKind::LunarIntegrity => "lunar-integrity",
            ScenarioKind::LunarTime => "lunar-time-offset",
            ScenarioKind::LunarVlbi => "lunar-vlbi",
            ScenarioKind::LunarCombination => "lunar-joint-od-clock",
            ScenarioKind::LunarFrameRealise => "lunar-frame-realisation",
            ScenarioKind::LunarFrameCampaign => "lunar-frame-campaign",
            ScenarioKind::LunarLlrDatum => "lunar-llr-datum",
            ScenarioKind::LunarService => "moonlight-service-volume",
            ScenarioKind::LunarDpnt => "lunar-differential-pnt",
            ScenarioKind::LunarInterop => "lunar-interop-export",
            ScenarioKind::GravityMap => "gravity-map",
            ScenarioKind::Terrain => "terrain-nav",
            ScenarioKind::TerrainSlam => "terrain-slam",
            ScenarioKind::CombinedAltPnt => "combined-altpnt",
            ScenarioKind::Pvt => "pvt",
            ScenarioKind::MarsPnt => "mars-pnt",
            ScenarioKind::ImpairmentEval => "impairment-eval",
            ScenarioKind::QuantumTrade => "quantum-trade",
            ScenarioKind::SpaceWeather => "space-weather",
            ScenarioKind::OemInterop => "oem-interop",
            ScenarioKind::LaunchWindow => "launch-window",
            ScenarioKind::Reentry => "reentry",
            ScenarioKind::EoCoverage => "eo-coverage",
            ScenarioKind::SpacePacket => "space-packet",
            ScenarioKind::AttitudeBudget => "attitude-budget",
            ScenarioKind::Passes => "passes",
            ScenarioKind::LinkBudget => "link-budget",
            ScenarioKind::LunarTimeBudget => "lunar-time-budget",
            ScenarioKind::RealtimeFrameEop => "realtime-frame-eop",
            ScenarioKind::HybridOpticalRf => "hybrid-optical-rf",
            ScenarioKind::CislunarObservability => "cislunar-observability",
            ScenarioKind::CislunarArcRecovery => "cislunar-arc-recovery",
            ScenarioKind::ConflictResilience => "conflict-resilience",
            ScenarioKind::LunarAttackSurface => "lunar-attack-surface",
            ScenarioKind::ApertureDutyCycle => "aperture-duty-cycle",
            ScenarioKind::LunarJamming => "lunar-jamming",
            ScenarioKind::LunarBeacon => "lunar-beacon",
            ScenarioKind::EarthGnssLunar => "earth-gnss-lunar",
            ScenarioKind::InsTrnCoast => "ins-trn-coast",
            ScenarioKind::LunarVlbiFim => "lunar-vlbi-fim",
            ScenarioKind::TrackingLoop => "tracking-loop",
            ScenarioKind::AraimReferenceCheck => "araim-reference-check",
        }
    }

    /// Resolve the `kind` field of a TOML scenario to a typed variant. An absent or
    /// `clock` kind (and, for backward compatibility, any unrecognised kind) maps to
    /// [`ScenarioKind::Clock`], the historical default pack.
    pub fn classify(src: &str) -> Result<ScenarioKind, KshanaError> {
        let kind: Kind = toml::from_str(src).unwrap_or(Kind {
            kind: String::new(),
        });
        Ok(match kind.kind.as_str() {
            "inertial" => ScenarioKind::Inertial,
            "integrity" => ScenarioKind::Integrity,
            "timetransfer" => ScenarioKind::TimeTransfer,
            "quantum-time-transfer" => ScenarioKind::QuantumTimeTransfer,
            "quantum-gnss-free-nav" => ScenarioKind::QuantumGnssFreeNav,
            "quantum-anomaly-detect" => ScenarioKind::QuantumAnomalyDetect,
            "hybrid" => ScenarioKind::Hybrid,
            "fusion" => ScenarioKind::Fusion,
            "hybrid-ukf" => ScenarioKind::HybridUkf,
            "gnss-ins" => ScenarioKind::GnssIns,
            "gnss-sim" => ScenarioKind::GnssSim,
            "jamming" => ScenarioKind::Jamming,
            "spoof" => ScenarioKind::Spoof,
            "spoof-detect" => ScenarioKind::SpoofDetect,
            "sweep" => ScenarioKind::Sweep,
            "sweep-nd" => ScenarioKind::SweepNd,
            "orbit" => ScenarioKind::Orbit,
            "ephemeris" => ScenarioKind::Ephemeris,
            "lunar-integrity" => ScenarioKind::LunarIntegrity,
            "lunar-time-offset" => ScenarioKind::LunarTime,
            "lunar-vlbi" => ScenarioKind::LunarVlbi,
            "lunar-joint-od-clock" => ScenarioKind::LunarCombination,
            "lunar-frame-realisation" => ScenarioKind::LunarFrameRealise,
            "lunar-frame-campaign" => ScenarioKind::LunarFrameCampaign,
            "lunar-llr-datum" => ScenarioKind::LunarLlrDatum,
            "moonlight-service-volume" => ScenarioKind::LunarService,
            "lunar-differential-pnt" => ScenarioKind::LunarDpnt,
            "lunar-interop-export" => ScenarioKind::LunarInterop,
            "gravity-map" => ScenarioKind::GravityMap,
            "terrain-nav" => ScenarioKind::Terrain,
            "terrain-slam" => ScenarioKind::TerrainSlam,
            "combined-altpnt" => ScenarioKind::CombinedAltPnt,
            "pvt" => ScenarioKind::Pvt,
            "mars-pnt" => ScenarioKind::MarsPnt,
            "impairment-eval" => ScenarioKind::ImpairmentEval,
            "quantum-trade" => ScenarioKind::QuantumTrade,
            "space-weather" => ScenarioKind::SpaceWeather,
            "oem-interop" => ScenarioKind::OemInterop,
            "launch-window" => ScenarioKind::LaunchWindow,
            "reentry" => ScenarioKind::Reentry,
            "eo-coverage" => ScenarioKind::EoCoverage,
            "space-packet" => ScenarioKind::SpacePacket,
            "attitude-budget" => ScenarioKind::AttitudeBudget,
            "passes" => ScenarioKind::Passes,
            "link-budget" => ScenarioKind::LinkBudget,
            "lunar-time-budget" => ScenarioKind::LunarTimeBudget,
            "realtime-frame-eop" => ScenarioKind::RealtimeFrameEop,
            "hybrid-optical-rf" => ScenarioKind::HybridOpticalRf,
            "cislunar-observability" => ScenarioKind::CislunarObservability,
            "cislunar-arc-recovery" => ScenarioKind::CislunarArcRecovery,
            "conflict-resilience" => ScenarioKind::ConflictResilience,
            "lunar-attack-surface" => ScenarioKind::LunarAttackSurface,
            "aperture-duty-cycle" => ScenarioKind::ApertureDutyCycle,
            "lunar-jamming" => ScenarioKind::LunarJamming,
            "lunar-beacon" => ScenarioKind::LunarBeacon,
            "earth-gnss-lunar" => ScenarioKind::EarthGnssLunar,
            "ins-trn-coast" => ScenarioKind::InsTrnCoast,
            "lunar-vlbi-fim" => ScenarioKind::LunarVlbiFim,
            "tracking-loop" => ScenarioKind::TrackingLoop,
            "araim-reference-check" => ScenarioKind::AraimReferenceCheck,
            // The clock pack, named explicitly. It had no arm of its own before, because
            // it was reached through the catch-all below — so removing that catch-all
            // orphaned the one kind the engine has always had. The round-trip test found
            // this on the first run.
            "clock" => ScenarioKind::Clock,
            // An ABSENT kind keeps the historical default: the three bundled clock
            // scenarios carry no `kind` line at all, and documents written before the
            // field existed must keep running.
            "" => ScenarioKind::Clock,
            // An UNKNOWN kind is a typo, and used to fall through to the clock pack —
            // so `kind = "lunar-vbli-fim"` ran a clock-holdover simulation and reported
            // it as a successful run of something the user never asked for. An engine
            // whose whole claim is that its numbers can be believed must not silently
            // answer a different question. Name the nearest built-in, because the
            // overwhelmingly likely cause is a slip of one or two characters.
            other => {
                return Err(KshanaError::InvalidInput(match nearest_kind(other) {
                    Some(hint) => format!(
                        "unknown scenario kind {other:?} — did you mean {hint:?}? \
                         `kshana kinds` lists all {n} built-in kinds.",
                        n = list_scenario_kinds().len()
                    ),
                    None => format!(
                        "unknown scenario kind {other:?}. `kshana kinds` lists all {n} \
                         built-in kinds.",
                        n = list_scenario_kinds().len()
                    ),
                }))
            }
        })
    }

    /// The registry id for this kind — the canonical kebab string interned as a
    /// [`crate::registry::ScenarioId`].
    pub fn to_id(&self) -> crate::registry::ScenarioId {
        crate::registry::ScenarioId::from_static(self.as_str())
    }
}

/// Metadata describing one scenario kind for programmatic introspection
/// (auto-complete, UI, notebooks): the `kind` name, a one-line description, and the
/// required / optional top-level fields.
#[derive(Clone, Debug, serde::Serialize)]
pub struct ScenarioMeta {
    pub name: &'static str,
    pub description: &'static str,
    pub required_fields: &'static [&'static str],
    pub optional_fields: &'static [&'static str],
}

/// The built-in kind closest to `probe`, for the "did you mean" on an unknown kind.
///
/// Plain Levenshtein distance, accepted only when it is at most a third of the probe's
/// length (minimum 1). That bound is deliberate: a suggestion is useful for a typo and
/// actively misleading for a name the caller invented, so `"lunar-vbli-fim"` earns a
/// hint and `"frobnicate"` earns none.
fn nearest_kind(probe: &str) -> Option<&'static str> {
    fn distance(a: &str, b: &str) -> usize {
        let (a, b): (Vec<char>, Vec<char>) = (a.chars().collect(), b.chars().collect());
        let mut prev: Vec<usize> = (0..=b.len()).collect();
        let mut cur = vec![0usize; b.len() + 1];
        for (i, ca) in a.iter().enumerate() {
            cur[0] = i + 1;
            for (j, cb) in b.iter().enumerate() {
                let sub = prev[j] + usize::from(ca != cb);
                cur[j + 1] = sub.min(prev[j + 1] + 1).min(cur[j] + 1);
            }
            std::mem::swap(&mut prev, &mut cur);
        }
        prev[b.len()]
    }
    let budget = (probe.chars().count() / 3).max(1);
    list_scenario_kinds()
        .into_iter()
        .map(|m| (distance(probe, m.name), m.name))
        .filter(|(d, _)| *d <= budget)
        .min_by_key(|(d, name)| (*d, *name))
        .map(|(_, name)| name)
}

/// List every built-in scenario kind with its metadata. Bindings expose this so a
/// caller can discover the packs and their fields without reading the source.
pub fn list_scenario_kinds() -> Vec<ScenarioMeta> {
    vec![
        ScenarioMeta { name: "clock", description: "Clock holdover vs spec; optional Monte-Carlo ensemble (runs > 1).", required_fields: &["threshold_ns", "time", "gnss", "clock_quantum", "clock_classical"], optional_fields: &["seed", "runs"] },
        ScenarioMeta { name: "inertial", description: "1-DOF inertial dead-reckoning during a GNSS outage.", required_fields: &["threshold_m", "time", "gnss", "accel_quantum", "accel_classical"], optional_fields: &["seed", "runs"] },
        ScenarioMeta { name: "orbit", description: "GNSS availability + DOP from an orbital constellation (Walker / TLE / RINEX).", required_fields: &["threshold_ns", "time", "user", "constellation", "clock_quantum", "clock_classical"], optional_fields: &["mask_deg", "sigma_uere_m", "seed"] },
        ScenarioMeta { name: "ephemeris", description: "Ephemeris & ground track: propagate one satellite (TLE→SGP4 or analytic orbit) and emit its TEME/GCRS state (position + velocity), ITRF/ECEF position, WGS-84 sub-satellite lat/lon/alt, and per-step station az/el/range + range-rate (Doppler).", required_fields: &[], optional_fields: &["tle", "orbit", "epoch", "step_s", "duration_s", "station", "dut1_s", "xp_arcsec", "yp_arcsec", "carrier_hz", "eop_finals2000a"] },
        ScenarioMeta { name: "integrity", description: "Snapshot / solution-separation / ARAIM RAIM with HPL/VPL and a Stanford diagram.", required_fields: &["time", "user", "constellation"], optional_fields: &["mask_deg", "sigma_uere_m", "p_fa", "p_md"] },
        ScenarioMeta { name: "lunar-integrity", description: "Lunar south-pole ARAIM protection-level pass vs a representative LunaNet relay set. sigma_ure_m exposes the signal-in-space ranging accuracy (protection levels scale linearly with it, so sweeping it answers what ranging accuracy an alert limit requires). Defaults to the historical LNIS-class south-pole case.", required_fields: &[], optional_fields: &["step_s", "duration_s", "alert_limit_m", "p_hmi", "sigma_ure_m"] },
        ScenarioMeta { name: "lunar-time-offset", description: "Modelled relativistic Earth–Moon clock rate (Lunar Coordinate Time, LTC/TCL): the secular LTC−TT rate from the self-potential difference and the Moon's kinetic term, reported with the published 56–59 µs/day band, plus the accumulated offset over a horizon.", required_fields: &[], optional_fields: &["epoch_year", "epoch_month", "epoch_day", "horizon_days"] },
        ScenarioMeta { name: "lunar-vlbi", description: "Modelled lunar geodetic VLBI delay observable: an Earth baseline (two ground stations, GCRS) observes a one-way signal from a NovaMoon-class lunar-surface beacon. Emits the near-field two-range-difference delay, its rate, and the wavefront-curvature near-field correction over a pass — cross-checked against the same-codebase plane-wave Δ-DOR observable in the far-field limit, with finite-difference-verified partials. MODELLED, NOT validated against real VLBI data; carries the frame-consistency, xp=yp=0 polar-motion and plane-wave-vs-near-field caveats.", required_fields: &[], optional_fields: &["station1_lat_deg", "station1_lon_deg", "station1_alt_m", "station2_lat_deg", "station2_lon_deg", "station2_alt_m", "beacon_lat_deg", "beacon_lon_deg", "beacon_alt_m", "epoch_year", "epoch_month", "epoch_day", "horizon_hours", "step_min"] },
        ScenarioMeta { name: "lunar-joint-od-clock", description: "Modelled joint multi-technique lunar OD + clock batch estimator on a SIMULATED network: a Gauss-Newton snapshot fit that fuses Earth-baseline geodetic VLBI delays, lunar-local station↔satellite ranges and inter-satellite ranges to recover, together, a lunar surface station's 3-D position, a small constellation's positions and every asset's clock offset from an injected truth. The headline honest result — VLBI makes the station's full 3-D position observable where lunar-local ranging alone leaves a weakly-observed direction — is reported as the with-vs-without-VLBI station-error contrast. MODELLED simulated closed-loop recovery (truth shares the observation model), deterministic (seeded), NOT real-data validated; no force-model propagation inside the solver; no TRL/heritage/agency endorsement.", required_fields: &[], optional_fields: &["n_sat", "n_earth", "seed", "sigma_vlbi_s", "sigma_range_m", "sigma_isl_m", "station_lat_deg", "station_lon_deg", "station_alt_m", "orbit_radius_km", "epoch_year", "epoch_month", "epoch_day"] },
        ScenarioMeta { name: "lunar-frame-realisation", description: "Modelled lunar reference-frame realisation: a 7-parameter Helmert (similarity) datum fit — 3 translation, 3 small-angle rotation, 1 scale — tying an estimated set of selenographic-derived MCMF point coordinates to a datum by weighted least squares (crate::batch_ls::gauss_newton), plus a simple orientation tie expressing the realised small rotation about the ICRF axes relative to the IAU 2015 WGCCRE body orientation. The scenario injects a known small transform (translation ~tens of m, rotation ~µrad, scale ~1e-7) into a well-spread synthetic point network, adds seeded Gaussian noise, recovers the datum, and reports the recovered transform, the per-parameter recovery error vs the injected truth, and the post-fit RMS residual. MODELLED self-consistency — recovers an injected similarity transform (noiseless to ~machine precision), NOT a realisation against real tracking/VLBI data; deterministic (seeded); no TRL/heritage/agency endorsement.", required_fields: &[], optional_fields: &["n_points", "tx_m", "ty_m", "tz_m", "rot_x_urad", "rot_y_urad", "rot_z_urad", "scale_ppb", "noise_sigma_m", "seed", "epoch_year", "epoch_month", "epoch_day"] },
        ScenarioMeta { name: "moonlight-service-volume", description: "Modelled lunar navigation service-volume analysis from an ILLUSTRATIVE, public-source Moonlight/LCNS-class lunar-orbit constellation (not affiliated with ESA): sweeps a selenographic lat/lon grid over a time horizon and reports DOP / coverage / availability (≥4 sats AND PDOP < threshold) plus a generalised lunar ARAIM protection-level (HPL/VPL) envelope over the volume. The DOP geometry REUSES the gnss_lib_py-VALIDATED kernel (crate::orbit::dop); the protection level REUSES the LunaNet LNIS lunar ARAIM machinery (crate::lunar, σ_URE≈30 m) and reduces to the existing south-pole PL as a special case. MODELLED composition: a circular-/elliptical-Keplerian relay set (not the real differential-corrected LCNS/NRHO ephemeris), a mean-rotation Moon (no libration/precessing pole). Deterministic (pure geometry). No TRL/heritage/agency endorsement.", required_fields: &[], optional_fields: &["n_sats", "sma_km", "eccentricity", "inc_deg", "argp_deg", "lat_min_deg", "lat_max_deg", "lat_step_deg", "lon_min_deg", "lon_max_deg", "lon_step_deg", "horizon_hours", "step_min", "elev_mask_deg", "pdop_threshold", "alert_limit_m", "p_hmi", "perturbed", "sigma_ure_m", "export_site_lat_deg", "export_site_lon_deg", "export_antenna", "ephemeris_path"] },
        ScenarioMeta { name: "lunar-differential-pnt", description: "Modelled lunar DIFFERENTIAL PNT (a lunar DGNSS/SBAS analogue): a NovaMoon-class reference station at a KNOWN selenographic location computes per-satellite differential corrections from an ILLUSTRATIVE, public-source Moonlight/LCNS-class constellation (NovaMoon referenced only as a system CLASS, not affiliated with ESA), and a user offset by baseline_km applies them so the COMMON-MODE orbit + clock errors cancel. The clock term cancels EXACTLY (an algebraic identity); the orbit term leaves only the line-of-sight-difference projection, which → 0 as baseline → 0 (the spatial-decorrelation floor) and grows ≈ linearly with baseline. Reports the user 3-D position error WITH vs WITHOUT corrections, the reduction factor, the error-vs-baseline curve, and a user protection level that REUSES the DO-229E SBAS machinery (crate::sbas) with the differential residual σ. MODELLED — exact cancellation identity + first-order decorrelation model; not real-data validated; no TRL/heritage/agency endorsement. Deterministic if seeded.", required_fields: &[], optional_fields: &["n_sats", "sma_km", "eccentricity", "inc_deg", "argp_deg", "ref_lat_deg", "ref_lon_deg", "baseline_km", "orbit_err_m", "clock_err_m", "noise_m", "seed", "t_s", "residual_sigma_m", "p_hmi", "survey_sigma_m", "latency_s", "quantization_bits"] },
        ScenarioMeta { name: "lunar-interop-export", description: "Modelled lunar interoperability export: emits the lunar reference frame, lunar time scale and lunar ephemeris in LunaNet/IOAG-aligned, CCSDS-based interchange forms with round-trip / field conformance. REUSES the crate's CCSDS OEM 2.0 emitter+parser (crate::oem) re-tagged for the lunar context — the OEM REF_FRAME carries the IAU 2015 WGCCRE lunar body frame (MOON_ME / MOON_PA), TIME_SYSTEM the lunar time scale (LTC / TCL / UTC), CENTER_NAME = MOON — over a sample illustrative LCNS-class ephemeris (positions from crate::lunar_service, velocity by finite difference). Also emits a LunaNet/IOAG-aligned lunar-time descriptor (scale id, secular rate µs/day from crate::lunar_time, published band, reference surface) that round-trips via serde_json, and wraps the artifacts in the existing KIF envelope (crate::interchange) with the MODELLED honesty label. Reports artifacts emitted, OEM line count, field-conformance pass + present/missing field list, OEM round-trip ok, time-metadata round-trip ok, and KIF byte size. MODELLED — deterministic round-trip + field-name conformance vs published CCSDS OEM + LunaNet/IOAG field semantics is the oracle; NOT a certified interoperability conformance test; illustrative public-source ephemeris, not affiliated with ESA; no TRL/heritage/agency endorsement.", required_fields: &[], optional_fields: &["frame", "time_system", "n_states", "epoch", "step_min", "object"] },
        ScenarioMeta { name: "timetransfer", description: "Optical vs RF two-way time/frequency transfer.", required_fields: &["time", "optical", "rf"], optional_fields: &["seed"] },
        ScenarioMeta { name: "quantum-anomaly-detect", description: "MODELLED fault/anomaly detection for quantum PNT systems: a labelled fault catalog (clock frequency-jump/drift/lock-loss; sensor bias-step/dropout), a detection-statistic ROC AUC (with a bootstrap CI from the externally-validated eval_stats) and a minimum-detectable-fault at a fixed false-alarm rate, with the quantum-clock-aided monitor (lower noise) detecting smaller faults — as honest TradeEvidence + representativeness. Gaussian detection-statistic model (AUC = Phi(mu/(sigma*sqrt2))); models the class, illustrative public-source params, no TRL/flight/certification.", required_fields: &[], optional_fields: &["fault_mu", "quantum_sigma", "classical_sigma", "pfa", "pd", "samples", "seed"] },
        ScenarioMeta { name: "quantum-gnss-free-nav", description: "MODELLED GNSS-free quantum navigation: during a GNSS outage, a quantum (cold-atom interferometer) inertial budget vs a classical navigation-grade INS — position-error growth over the coast, holdover to a position threshold, and the quantum-vs-classical trade as honest TradeEvidence with representativeness. Honest observability note: with no external fix the accelerometer bias is unobservable so the error grows; the quantum sensor slows but does not close that gap. Illustrative public-source device params; models the class, no TRL/flight/certification.", required_fields: &[], optional_fields: &["outage_s", "threshold_m", "quantum_bias_m_s2", "classical_bias_m_s2"] },
        ScenarioMeta { name: "quantum-time-transfer", description: "MODELLED trusted-quantum-timing chain: an end-to-end quantum (optical-lattice clock + entanglement/single-photon link) vs classical (CSAC + RF two-way) time-transfer budget, a reused timing protection level + a delay/replay-attack security FoM (1-P_md), a clock-anomaly detection probability + CUSUM latency, and the quantum-vs-classical trade as honest TradeEvidence with a representativeness + gaps-to-flight record. Illustrative public-source device/link params; models the class, no TRL/flight/certification claimed.", required_fields: &[], optional_fields: &["integration_s", "dissemination_interval_s", "link_loss_db", "classical_link_sigma_s", "monitor_pfa", "attack_delay_s", "clock_fault_sigma"] },
        ScenarioMeta { name: "hybrid", description: "Hybrid PNT capstone: clock + IMU + time-transfer aiding.", required_fields: &["timing_spec_ns", "position_spec_m", "time", "gnss", "clock_quantum", "clock_classical", "accel_quantum", "accel_classical"], optional_fields: &["resync", "seed"] },
        ScenarioMeta { name: "fusion", description: "Joint Kalman sensor-fusion PNT over the same hybrid inputs.", required_fields: &["timing_spec_ns", "position_spec_m", "time", "gnss", "clock_quantum", "clock_classical", "accel_quantum", "accel_classical"], optional_fields: &["resync", "seed"] },
        ScenarioMeta { name: "hybrid-ukf", description: "17-state hybrid quantum+classical tightly-coupled GNSS/INS UKF (MODELLED): 15 INS error states + CAI-derived accel-bias correction + a 2-state (phase+frequency) clock from the q-parameter clock engine, driven by the bracketed CAI error model. The figure of merit is filter self-consistency (NEES + innovation-whiteness vs χ² bounds) — a self-consistency statement, NOT a real-world accuracy guarantee. Simulation only; no TRL>3, no flight heritage, no external validation.", required_fields: &["time", "gnss", "accel", "clock"], optional_fields: &["seed", "residual_accel_bias_m_s2", "speed_m_s", "sigma_pr_m", "sigma_rr_mps", "consistency_seeds", "q_factor", "r_factor"] },
        ScenarioMeta { name: "gnss-ins", description: "Loosely- and tightly-coupled GNSS/INS error-state EKF.", required_fields: &["time", "gnss", "imu_quantum", "imu_classical"], optional_fields: &["seed", "threshold_m", "fix_interval_s", "sigma_pos_m", "sigma_vel_mps", "lat_deg", "lon_deg", "alt_m"] },
        ScenarioMeta { name: "gnss-sim", description: "Measurement-domain pseudorange simulation (Klobuchar iono, Saastamoinen/Niell tropo) + RAIM.", required_fields: &["seed", "time", "receiver", "constellation"], optional_fields: &["iono", "tropo", "mask_deg", "noise_sigma_m", "multipath_m", "sat_clock_rms_m", "uere_m", "p_fa", "p_md", "alert_limit_h_m", "alert_limit_v_m"] },
        ScenarioMeta { name: "jamming", description: "Link-budget jamming: J/S → effective C/N₀ → loss of lock.", required_fields: &["seed", "time", "receiver", "constellation"], optional_fields: &["jammer", "mask_deg", "tracking_threshold_dbhz", "degraded_margin_db", "signal_power_dbw", "temp_k", "freq_hz", "chip_rate_hz"] },
        ScenarioMeta { name: "spoof", description: "Stochastic time-spoof detector (Neyman–Pearson / χ²₁) with Monte-Carlo P_fa/P_md.", required_fields: &["threshold_ns", "time", "attack", "clock_quantum", "clock_classical"], optional_fields: &[] },
        ScenarioMeta { name: "spoof-detect", description: "Combined RF/measurement spoof detector (multi-SV RAIM-consistency + AGC + SQM, fused) vs a parameterised attack (power advantage, carrier-phase alignment, time/position push; TEXBAT-style).", required_fields: &["attack"], optional_fields: &["satellites", "detector"] },
        ScenarioMeta { name: "sweep", description: "1-D trade-study sweep over a clock-pack parameter.", required_fields: &["parameter", "metric", "start", "stop", "steps", "base"], optional_fields: &["scale"] },
        ScenarioMeta { name: "sweep-nd", description: "Generic N-D sweep over any pack via dotted TOML keys / JSON metric paths.", required_fields: &["base", "axes", "metrics"], optional_fields: &[] },
        ScenarioMeta { name: "gravity-map", description: "GPS-denied gravity-map-matching navigation: a cold-atom gravimeter recovers a constant INS drift from the gravity-anomaly sequence it flies through.", required_fields: &["nmax", "start_lat_deg", "start_lon_deg", "step_lat_deg", "step_lon_deg", "waypoints", "drift_lat_deg", "drift_lon_deg", "gravimeter_asd", "averaging_time_s", "map_sigma_mgal", "search_half_deg", "search_step_deg"], optional_fields: &["coeffs", "mascons", "refine_stages", "refine_factor", "noise_seed"] },
        ScenarioMeta { name: "terrain-nav", description: "GPS-denied terrain-referenced navigation (TERCOM/SITAN): a radar/baro altimeter matches the ground-elevation profile against an SRTM-style DEM to recover the INS drift.", required_fields: &["dem_seed", "start_lat_deg", "start_lon_deg", "step_lat_deg", "step_lon_deg", "waypoints", "drift_lat_deg", "drift_lon_deg", "altimeter_sigma_m", "map_sigma_m", "search_half_deg", "search_step_deg"], optional_fields: &["refine_stages", "refine_factor", "noise_seed"] },
        ScenarioMeta { name: "terrain-slam", description: "GPS-denied sequential (recursive) terrain-referenced navigation: a particle filter runs the terrain-match measurement model epoch by epoch (SITAN as a running filter) so a time-varying INS drift is tracked along the track, where the batch terrain-nav only recovers a single constant offset.", required_fields: &["dem_seed", "start_lat_deg", "start_lon_deg", "step_lat_deg", "step_lon_deg", "waypoints", "drift_rate_lat_deg", "drift_rate_lon_deg", "altimeter_sigma_m", "map_sigma_m"], optional_fields: &["n_particles", "init_pos_sigma_deg", "process_sigma_deg", "resample_ess_frac", "seed"] },
        ScenarioMeta { name: "combined-altpnt", description: "GPS-denied combined gravity + magnetic + terrain navigator: three scalar field channels fused per waypoint for a sharper (lower-CRLB) drift fix than any single field.", required_fields: &["start_lat_deg", "start_lon_deg", "step_lat_deg", "step_lon_deg", "waypoints", "drift_lat_deg", "drift_lon_deg", "search_half_deg", "search_step_deg", "nmax", "gravity_sigma_mgal", "igrf_year", "magnetic_sigma_nt", "dem_seed", "terrain_sigma_m"], optional_fields: &["coeffs", "mascons", "magnetic_mascons", "igrf_alt_km", "refine_stages", "refine_factor", "noise_seed"] },
        ScenarioMeta { name: "pvt", description: "Real-observation single-point positioning: solve a receiver's position from a RINEX 3 observation file and a broadcast-navigation file (code pseudoranges, broadcast ephemeris, Klobuchar iono, Saastamoinen/Niell tropo), optionally validated against a surveyed coordinate.", required_fields: &["obs_rinex", "nav_rinex"], optional_fields: &["truth_ecef", "apriori_ecef", "mask_deg"] },
        ScenarioMeta { name: "mars-pnt", description: "Deep-space Mars PNT: a simulated MARCONI relay constellation (areostationary + inclined relays broadcasting one-way + relaying two-way to a deep-space station) navigates a reference user (transfer | lmo | surface) through the joint one-way/two-way radiometric fusion estimator. Reports per-epoch geometry/visibility, achieved RMS vs truth, and the formal covariance (1σ / 3σ position) — an honest simulated FoM, NOT a certified protection level.", required_fields: &[], optional_fields: &["user", "clock_class", "step_s", "duration_s", "nmax", "range_sigma_m", "doppler_sigma_mps", "dynamic_tightness", "two_way_period_s", "seed"] },
        ScenarioMeta { name: "impairment-eval", description: "AI/ML RF-impairment detection evaluation testbed (13494): generate a labelled, parameter-grounded SYNTHETIC corpus (nominal/jamming/spoof-time/spoof-position/multipath), score a detector (energy|agc|sqm|parity|fused) with the detector-agnostic harness, and report AUC/ROC/confusion + per-class Pd at a target Pfa, plus the in- vs out-of-distribution optimism gap. MODELLED operating characteristics only — never field/IQ, no good/bad verdict.", required_fields: &[], optional_fields: &["seed", "n_per_class", "nominal_cn0_dbhz", "meas_noise", "detector", "target_pfa", "shift_severity_scale", "optimism_tol"] },
        ScenarioMeta { name: "quantum-trade", description: "Quantum-vs-classical PNT trade (13503): timing-holdover + inertial-holdover benefit of a candidate clock (a measured-ADEV curve — the defensibility hinge — or a quantum clock class) vs a classical baseline class, with the long-tau floor-assumption caveat carried on the artifact, plus a GNSS-denied resilience-vs-time envelope. MODELLED; quantifies (never validates) a partner device.", required_fields: &["timing_threshold_s", "position_threshold_m", "baseline_clock_class"], optional_fields: &["candidate_clock_class", "candidate_adev_taus", "candidate_adev_values", "baseline_ins", "candidate_ins", "resilience_times_s", "alt_pnt_bound_m"] },
        ScenarioMeta { name: "space-weather", description: "Space-weather environment model: solar (F10.7/F10.7a) and geomagnetic (Kp, with the definitional Kp↔ap table) activity indices, the Jacchia-1971 exospheric temperature they drive (validated vs published solar-min/mean/max), and the activity-corrected vs static thermospheric neutral density at a set of altitudes — the solar-cycle density dependence the static USSA76 atmosphere omits. MODELLED: the density correction is a calibrated first-order scale-height coupling, NOT a data-validated (NRLMSISE) atmosphere.", required_fields: &[], optional_fields: &["f107", "f107a", "kp", "altitudes_km"] },
        ScenarioMeta { name: "oem-interop", description: "CCSDS OEM interoperability bridge: import an Orbit Ephemeris Message produced by an external flight-dynamics tool (GMAT/Orekit/STK all emit OEM) and report its segments/objects/frames/epoch-span plus a velocity-consistency check; with no input it round-trips a generated reference orbit and reports the import↔export fidelity. MODELLED structural/physical ingest check, NOT an orbit-accuracy validation of the source.", required_fields: &[], optional_fields: &["oem_text"] },
        ScenarioMeta { name: "launch-window", description: "Two-body launch & ascent geometry: launch azimuth(s) (sin Az = cos i / cos lat), minimum reachable inclination, circular velocity, the Earth-rotation eastward bonus, dogleg plane-change Δv when the target inclination is below the site latitude, and the number of daily launch opportunities. MODELLED spherical-Earth geometry (no rotating-Earth velocity-triangle correction, no ascent/drag-loss model).", required_fields: &[], optional_fields: &["site_lat_deg", "target_inclination_deg", "altitude_km"] },
        ScenarioMeta { name: "reentry", description: "Allen-Eggers ballistic re-entry corridor: peak deceleration (ballistic-coefficient-independent, V_e^2 sin|γ|/(2eH)), the velocity and altitude at peak-g, and the peak-heating velocity, for an entry velocity/flight-path-angle/ballistic-coefficient through an exponential atmosphere. MODELLED ballistic (no-lift) analytic entry; heating output is the peak-heating VELOCITY, not a heat-flux (no aerothermal/TPS model).", required_fields: &[], optional_fields: &["entry_velocity_m_s", "flight_path_angle_deg", "ballistic_coeff_kg_m2", "scale_height_m", "rho0_kg_m3"] },
        ScenarioMeta { name: "eo-coverage", description: "Earth-observation payload footprint & coverage geometry (SMAD space triangle): Earth angular radius, swath width, nadir ground sample distance, maximum off-nadir access, circular period and equatorial ground-track spacing with a contiguous-coverage flag, for an orbit altitude + sensor FOV/IFOV. MODELLED spherical-Earth geometry (no radiometry/MTF/atmosphere/jitter/glint; nodal R_e·ω·T spacing, no J2 regression).", required_fields: &[], optional_fields: &["altitude_km", "half_fov_deg", "ifov_microrad", "max_off_nadir_deg"] },
        ScenarioMeta { name: "space-packet", description: "CCSDS 133.0-B Space Packet Protocol framing: encode a synthetic TM/TC packet stream (6-octet primary header + data field) and report the per-packet header decode, total octets and an exact encode↔decode round trip. Deterministic exact bit-layout framing — the agency packet-format interop layer; NOT a conformance certification (no secondary-header/CRC/segmentation logic beyond the flags).", required_fields: &[], optional_fields: &["apid", "telecommand", "packet_count", "data_len"] },
        ScenarioMeta { name: "attitude-budget", description: "3-DOF attitude & pointing error budget: the worst-case gravity-gradient disturbance torque ((3/2)(μ/R³)ΔI) and a root-sum-square pointing-error budget over named 1σ contributors (sensor noise, reaction-wheel jitter, thermal, alignment) with the dominant term, for an orbit altitude + body inertia spread. MODELLED scalar AOCS budget — a pre-hardware complement to Basilisk/42, not a control-loop/6-DoF/flexible-mode simulation.", required_fields: &[], optional_fields: &["altitude_km", "i_max_kg_m2", "i_min_kg_m2", "contributors"] },
        ScenarioMeta { name: "passes", description: "Ground-station pass prediction: the time-domain visibility passes (AOS/TCA/LOS, maximum elevation, duration) of a circular orbit over a station above an elevation mask across a window, with interpolated rise/set crossings and total access time. MODELLED Keplerian propagation + Earth rotation (no SGP4 drag/J2 regression), TCA at the sample-step resolution, no light-time/refraction correction.", required_fields: &[], optional_fields: &["altitude_km", "inclination_deg", "raan_deg", "arg_lat_deg", "station_lat_deg", "station_lon_deg", "station_alt_m", "epoch", "mask_deg", "duration_hours", "step_s"] },
        ScenarioMeta { name: "link-budget", description: "One-way link budget over the CCSDS 401 / DSN 810-005 link equation: free-space path loss, C/N₀, Eb/N₀, margin and closure for a transmit EIRP, receive G/T, range, data rate and band (s|x|ka) against a required Eb/N₀. A deterministic engineering calculation from the supplied inputs (not a calibrated terminal datasheet).", required_fields: &[], optional_fields: &["band", "eirp_dbw", "g_over_t_db", "range_km", "data_rate_bps", "other_losses_db", "required_eb_n0_db", "tsys_k"] },
        ScenarioMeta { name: "lunar-time-budget", description: "MODELLED end-to-end Coordinated Lunar Time (LTC) time-error budget: the seven LTC error terms assembled as time-error curves x_i(τ) over a whole averaging-time grid, root-summed into x_Σ(τ), and the clock-vs-frame CROSSOVER τ at which the growing clock term overtakes the constant real-time frame-realisation term (below it the budget is frame-limited, above it clock-limited) — the honest answer to the single-τ artifact. The τ-slopes are closed-form and analytically checkable (clock τ^{+1/2}/τ^{+1}, floors τ^0, measurement τ^{-1/2}) and the clock rows reproduce the published one-day clock specs (crate::clock_specs); the RF/optical-link, frame-realisation, relativistic-residual and ephemeris floor MAGNITUDES are Modelled budget allocations (documented defaults, caller-overridable), not measurements. The contribution is the reproducible crossover τ, not a certified per-term number; not certified for operational timekeeping.", required_fields: &[], optional_fields: &["clock", "tau_min_s", "tau_max_s", "points_per_decade", "clocks"] },
        ScenarioMeta { name: "hybrid-optical-rf", description: "MODELLED heterogeneous optical + RF PNT joint figure of merit (P5): composes the 1550 nm two-way optical link budget (photon-limited two-way ranging CRLB σ_τ/√N and diffraction footprint λ/D·range), a cross-modality solution-separation RAIM protection level (position AND timing) that fuses the loose RF and tight optical solutions with disparate covariances, the N-station optical clear-sky availability (independent-union 1−Π(1−a_i) and a spatially-correlated variant), an optical↔RF state/covariance handoff with a PROVEN bit-continuous (no-jump) mean and a NEES χ² consistency gate, and a joint P(available AND precision-grade AND integrity-assured) score with correlation handling. VALIDATED closed form: the ranging CRLB, diffraction footprint, χ² protection-level quantile, union combinatorics, handoff mean-continuity + NEES gate, and the joint independent product. MODELLED: the optical loss allocations, RF/optical σ magnitudes, cloud-climatology inputs, correlations, and P_HMI budget. Not a certified availability/integrity product.", required_fields: &[], optional_fields: &["wavelength_nm", "tx_power_w", "tx_aperture_m", "rx_aperture_m", "range_km", "pulse_rms_ps", "integration_s", "atmospheric_loss_db", "pointing_loss_db", "optics_efficiency", "detector_efficiency", "two_way", "rf_pos_sigma_m", "rf_vertical_sigma_m", "rf_clock_sigma_s", "p_fa", "p_md", "alert_limit_h_m", "alert_limit_v_m", "alert_limit_t_s", "grade_pos_m", "grade_time_s", "n_optical_sites", "site_correlation", "fom_correlation", "handoff_inflation", "p_hmi", "fault_ramp_rate_pos_m_s", "fault_ramp_rate_clock_s_s", "process_noise_pos_psd_m2_s", "process_noise_clock_psd_s2_s", "coast_coverage_k", "rf_band", "rf_eirp_dbw", "rf_g_over_t_db", "rf_other_losses_db", "rf_data_rate_bps", "rf_required_eb_n0_db", "rf_chip_rate_hz", "rf_correlator_spacing_chips", "rf_dll_bandwidth_hz", "rf_tracking_threshold_dbhz", "rf_degraded_margin_db"] },
        ScenarioMeta { name: "cislunar-observability", description: "MODELLED planar cislunar constellation observability (P6): tracks a four-spacecraft differential-corrected planar-DRO constellation with inter-satellite ranging and reports how much of a spacecraft's four-state [x,y,ẋ,ẏ] the arc makes observable. Emits (1) the rank-vs-arc-length table for a single range-only link — instantaneously rank-1, growing toward the full four-state as the arc extends (P6 Table 1); (2) the observability-Gramian eigen-spectrum + condition number over the arc; (3) the range-only-vs-range+range-rate instantaneous-rank comparison (the Doppler design lever) plus the range-only-singular / range+rate-defined GDOP reporting; and (4) an independent SRIF cross-validation whose posterior covariance turns finite / well-conditioned exactly at the arc where the observable rank reaches four. VALIDATED core: the observable rank is a rank-revealing singular-value threshold cross-checked against the Gramian eigen-rank; the eigen-spectrum obeys the spectral invariants (trace=Σλ, det=Πλ, Frobenius²=Σλ²); the variational STM is the finite-difference-validated CR3BP STM; the range/range-rate Jacobian rows are finite-difference-validated analytic partials (cross-checked against the crate's 3-D range-rate observable); the four initial conditions are differential-corrected planar DROs that close to a tight periodicity residual and are retrograde; the rank transition is cross-validated against the crate's square-root information filter (posterior covariance finite exactly at full rank, cond(P)=cond(OᵀO)); a rank-deficient snapshot is flagged GDOP-undefined (fim condition=inf), never a bogus finite value — the same singular-geometry guard pvt::solve_spp applies. MODELLED: the constellation design (DRO perilune amplitudes and phases) and the specific rank progression it produces. Not a certified navigation-performance product.", required_fields: &[], optional_fields: &["mu", "arc_hours", "epochs", "steps", "rel_tol", "spatial", "sigma_range_m", "family", "n_spacecraft", "sigma_pos_threshold_km"] },
        ScenarioMeta { name: "conflict-resilience", description: "MODELLED layered-PNT conflict resilience (P7): a contested-environment user fields several PNT layers (open-service GNSS, wideband GNSS, an authenticated constellation, an augmentation relay), each with a base availability, a 1σ accuracy and a per-vector denial vulnerability to the shared jamming/spoofing threat. An intensity-swept SEEDED Monte-Carlo denies each layer with probability clamp(vulnerability·intensity·vector_weight,0,1), fuses the survivors by the closed-form inverse-variance rule σ_fused=(Σ 1/σ_i²)^(−1/2), and reports the total-loss probability (all layers denied), the median fused error and per-layer usable/denial statistics vs intensity. The headline resilience ratio (single-layer vs layered total-loss probability) lands at ~7x under the INDEPENDENCE assumption; a one-factor Gaussian-copula correlated-denial sweep then shows that ~7x collapse toward 1 as denial correlation rises (correlation defeats layering). A prior-sensitivity block ranges the headline over the SOURCED vulnerability priors via the mcda tornado + a Dirichlet threat-effort re-allocation + percentile CIs. VALIDATED core: the Monte-Carlo total-loss converges to the closed-form independent product Π_i p_deny_i (within MC standard error at a fixed seed and large N); the inverse-variance fuse is a closed-form identity; at ρ=0 the copula reduces to the independent model and every ρ preserves each layer's marginal denial rate. MODELLED: the per-layer vulnerability/availability/accuracy magnitudes are sourced-but-Modelled inputs (JammerTest 2024, TEXBAT, EASA SIB, RTCA DO-229, LunaNet/IOAG — see conflict_threat_params), and the specific ~7x magnitude and the ratio-vs-correlation curve shape follow from that parameterisation. A §4.2 per-vector survival breakdown then resolves the shared RF threat into the four named vectors (jamming/spoofing/kinetic/cyber) and reports each vector's usable-PNT graceful-degradation curve S_v(x)=1-Prod_i(1-a_i(1-clamp(susceptibility_i,v·x,0,1))) — VALIDATED: the seeded per-layer Monte-Carlo converges to that closed form; jamming is the sharpest vector for the correlated-RF baseline and the RF-immune inertial layer is the decisive survivor. Not a certified navigation-availability product.", required_fields: &[], optional_fields: &["layers", "intensity", "correlation", "trials", "seed", "primary_layer"] },
        ScenarioMeta { name: "lunar-attack-surface", description: "Lunar surface-navigation signal-security attack surface (P1): composes the open signal-security analyses into one binary-reachable run. Reports (1) the AFS received power and its power SURPLUS versus a terrestrial GPS reference, plus the 12-18 dB sensitivity band as a genuine multi-axis sweep over the link inputs (reference level x EIRP x slant range) with the 25x-rounded / 28x-unrounded linear-factor reconciliation. REVISED (rule R4): this reported a 15.6 dB power DEFICIT and a 32x/36x factor until the GPS reference was corrected from -125 / -128.5 (which are the dBm figures) to -155 / -158.5 dBW, the ICD-GPS-200 values the jamming module has always carried. The sign inverts: the modelled lunar AFS signal is 14.4 dB STRONGER than terrestrial GPS L1 C/A, not weaker. The received power itself did not move by a bit, and the linear factor is the old one over exactly 1000, which is exactly the 30 dB dBm-to-dBW offset; (2) the required attacker transmit power to spoof (J/S = 3 dB) and to deny (J/S = 30 dB) at each standoff, the inverse of the J/S link; (3) the orbital capture footprint under a real uniform-aperture antenna pattern (Airy [2 J1(x)/x]^2), an altitude-limited sub-hemispheric cap whose limb is NOT captured; (4) a computed tracking-loop spoof-capture pull-in outcome (does a matched-code spoofer at a given power advantage and code offset actually drag the DLL/PLL) rather than the asserted 3 dB threshold; (5) the airless-body geometric horizon reach of a raised surface transmitter; and (6) the OSNMA/TESLA authentication budget (20 bit/s overhead = ~40 % of a 50 bit/s AFS nav message, key-disclosure latency, 2^-40 forgery). An empty body reproduces the P1 baseline; every input is defaulted and overridable. VALIDATED sub-results carry their source module's oracle (closed-form dB radiometry; inverse-J/S round trip; Airy pattern vs A&S Bessel and spherical-cap geometry; DLL/PLL pull-in vs Kaplan & Hegarty; spherical-tangent horizon identity vs eo_payload; OSNMA SIS-ICD field sizing). MODELLED: the representative geometry/power magnitudes and the specific capture-map cell values. Not a certified security product.", required_fields: &[], optional_fields: &["afs_eirp_dbw", "user_gain_dbi", "slant_range_m", "slant_range_max_m", "carrier_hz", "gps_reference_dbw", "gps_reference_min_dbw", "afs_isotropic_signal_dbw", "transmitter_altitude_m", "transmitter_power_dbw", "antenna_diameter_m", "footprint_grid", "spoof_power_advantage_db", "spoof_code_offset_chips", "attacker_gain_dbi", "spoof_capture_js_db", "jam_denial_js_db", "standoffs_m", "mast_height_m", "user_antenna_height_m", "footprint_altitude_min_m", "footprint_altitude_max_m", "footprint_altitude_steps", "footprint_altitude_scale", "footprint_diameter_min_m", "footprint_diameter_max_m", "footprint_diameter_steps", "footprint_diameter_scale"] },
        ScenarioMeta { name: "realtime-frame-eop", description: "Real-time lunar frame / Earth-orientation prediction budget: P4 Table 1 (the frame-error consistency check — post-processed ~0.27 m ↔ ~0.010 ms and real-time ~15 m ↔ ~0.5 ms, each frame position expressed as its equivalent UT1 error via the L19 lever arm Δr = D_EM·ω⊕·ΔUT1) and Table 2 (measured UT1 prediction error vs horizon — the L18 curve read directly off the real IERS finals2000A series: the Bulletin A − Bulletin B final floor and the multi-day persistence-predictor error, each mapped to a Moon-frame position by L19), plus the L21 root-sum-square real-time frame-error budget (EOP + ephemeris + realisation floor). VALIDATED closed form (the L19 lever arm, ω⊕ cross-checked against the CIO Earth-rotation angle) and VALIDATED real data (the L18 curve off the real finals2000A rows); MODELLED are the lunar-relay OD covariance magnitudes and frame-realisation floor (representative allocations) and the persistence predictor (not IERS's operational Bulletin A algorithm). Not a certified real-time frame product.", required_fields: &[], optional_fields: &["epoch", "horizons_days", "ephemeris_pos_sigma_m", "ephemeris_vel_sigma_mps", "latency_s", "frame_realization_floor_m", "delta_ut1_ms", "delta_xp_mas", "delta_yp_mas", "eop_finals2000a", "eop_finals2000a_later", "frame_realization_tie_noise_m", "operational_window_days", "operational_min_cycle_fraction", "operational_anchor_residual"] },
        ScenarioMeta { name: "aperture-duty-cycle", description: "Aperture duty cycle: the navigation-versus-communications time-share a contact plan implies for a pool of steerable apertures. Takes a contact plan (a list of `[[contacts]]` aos_s/los_s windows, each asking for navigation or communications \u{2014} the same aos_s/los_s vocabulary the `passes` predictor emits), an aperture count, and an ARBITRATION POLICY (navigation-priority by default, or communications-priority, or non-preemptive first-come-first-served), and reports the navigation duty, the communications duty, the idle duty, and the per-session outage \u{2014} the contact time a session requested and no aperture served. An exact interval sweep over the window boundaries, so touching windows never contend and no aperture-second is double-counted; navigation, communications and idle aperture-seconds are accumulated independently and close against apertures*horizon_s. The report states the resolved policy, its full definition, and the duty and outage definitions, because a duty figure quoted without its arbitration policy is not reproducible. MODELLED scheduling arithmetic: no slew / retune / changeover time is charged when an aperture changes service or session, no data volume, buffer state, energy budget or link closure enters the decision, and the contact windows are inputs \u{2014} their geometry is whatever produced them. Not a ground-segment scheduling product.", required_fields: &[], optional_fields: &["apertures", "arbitration", "horizon_start_s", "horizon_end_s", "contacts"] },
        ScenarioMeta { name: "lunar-jamming", description: "Lunar-native RF jamming: per-satellite jammer-to-signal ratio, effective C/N₀ and loss of lock for a selenographic surface user under a lunar surface (or raised) jammer, over the illustrative public-source Moonlight/LCNS-class lunar-orbit constellation. Composes the existing open jamming physics (jamming::j_over_s_db, effective_cn0_dbhz, rx_antenna_gain_db, lock_status, q_factor) with the existing lunar sky geometry (lunar_service::LunarConstellation + topocentric, lunar::selenographic_to_mcmf); no geometry or radiometry is re-derived. Unlike the Earth `jamming` kind, the signal leg is NOT a fixed received power: each satellite's isotropic received power is its own link budget EIRP − FSPL(slant range), so the J/S varies satellite by satellite with lunar range and elevation. Reports ONE ROW PER VISIBLE (epoch, satellite) LINK — azimuth, elevation, slant range, both received powers the J/S is the difference of, the J/S, the nominal and effective C/N₀ and the lock status — plus aggregate figures of merit beside (never in place of) that table, and the same table as a CSV artifact. Every emitted numeric field carries a unit and a provenance class. VALIDATED sub-results carry their source module oracle (the anti-jam equation and J/S link of Kaplan & Hegarty §9.4, externally referenced in tests/gnss_denied_jamming_resilience_reference.rs; the lunar geometry of lunar_service). The composition itself is cross-checked against an independent two-link-budget path (linkbudget::received_signal_power_dbw, a different free-space-loss expression) to 1e-9 dB. MODELLED: the illustrative constellation, the representative EIRP / jammer power / antenna gains / noise temperature, and the absence of terrain shadowing, multipath, AGC dynamics and adaptive nulling. Not a certified denial-of-service product. With a jammer configured the report also carries a DENIAL CONTOUR WITH AN UNCERTAINTY BAND rather than a contour read off one scalar: the full measured wanted-signal C/N₀ distribution over the per-satellite table (n, min, p05, p25, median, p75, p95, max, mean, sample stdev, and the sample's own asymmetry), and the contour evaluated at each of those order statistics under BOTH denial criteria the engine recognises — the incumbent power-ratio one (J/S = 30 dB, as in attack-surface and tracking-loop) and the loss-of-lock one (effective C/N₀ falling to tracking_threshold_dbhz, the criterion this report's own status column uses) — on both axes of the denial plane (required jammer EIRP at the scenario standoff, denial standoff at the scenario EIRP). The band edges ARE contour(p05) and contour(p95), the same closed-form map applied to the sample's own quantiles: not a sigma fitted to the sample and not median ± k·stdev, with stdev emitted for continuity and used by nothing. A quantile already at or below the tracking threshold has no finite denying J/S and its loss-of-lock columns are null with a counted reason, never a clamped radius.", required_fields: &[], optional_fields: &["n_sats", "sma_km", "eccentricity", "inc_deg", "argp_deg", "site_lat_deg", "site_lon_deg", "site_alt_m", "horizon_hours", "step_min", "elev_mask_deg", "sat_eirp_dbw", "carrier_hz", "chip_rate_hz", "user_boresight_gain_dbi", "temp_k", "tracking_threshold_dbhz", "degraded_margin_db", "jammer"] },
        ScenarioMeta { name: "ins-trn-coast", description: "INS/TRN coasting error model: position error against coast duration, built from IMU coefficients rather than swept as an assumed drift rate. Five growth contributions, each with its own power of time — accelerometer bias (t²), gyro-bias tilt through gravity (t³), velocity random walk (t^1.5), angle random walk (t^2.5) and scale factor against travelled distance (t¹ cruising, t² under sustained specific force) — combined under a STATED rule (root-sum-square by default, or linear-sum, or deterministic-sum with stochastic-rss; all three are computed on every run so the choice is visible rather than buried). The coast durations reaching caller-supplied position thresholds (10 m and 50 m by default) are located by the engine's existing bisection, each with a per-contribution breakdown naming the dominant source, and each single contribution's crossing is additionally inverted algebraically so the two agree. A terrain-relative-navigation mode bounds the coast with periodic position fixes and reports the largest fix interval that holds each threshold; a position-only fix leaves velocity error and tilt alive across the fix, so it does not always bound the coast at any fix rate, and the report says so rather than returning a zero. MODELLED: the IMU class coefficients are representative band figures, not a datasheet, and the terrain-fix residual is an input. An unreached threshold is reported as null with a status, never as a zero. Not a certified inertial-navigation performance product.", required_fields: &[], optional_fields: &["imu_grade", "accel_bias_ug", "accel_vrw_m_s_per_sqrt_hr", "accel_scale_factor_ppm", "gyro_bias_deg_per_hr", "gyro_arw_deg_per_sqrt_hr", "speed_m_s", "ref_accel_m_s2", "combination", "crossing_thresholds_m", "grades", "drift_band_lo_m_per_s", "drift_band_hi_m_per_s", "trn_fix_mode", "trn_fix_interval_s", "trn_fix_residual_m", "mission_duration_s"] },
        ScenarioMeta { name: "lunar-vlbi-fim", description: "Lunar-VLBI station-coordinate covariance: the delay partials of the lunar-vlbi observable accumulated over an explicit SCHEDULE (baselines x epochs) into a Fisher information matrix, inverted, and reported as the station coordinate covariance and the per-coordinate station sigma — a computed engine output rather than a scalar delay precision pushed through an assumed isotropic g = 3 equipartition factor. The state carries Earth-FIXED (ITRS) station coordinates, so the Jacobian is the inertial partial rotated by each epoch's GCRS->ITRS matrix: Earth rotation is what makes those coordinates observable, and the report MEASURES the Earth-fixed line-of-sight sweep and the beacon declination rather than assuming them. One observation is one (baseline, epoch) pair with BOTH stations above the elevation mask, weighted 1/delay_sigma_s^2. Reports rank, datum defect, condition number, the full information spectrum, the covariance matrix, per-station per-axis and 3-D sigmas, and the free-network null space on every run; under a rank deficiency the headline sigma is published as NULL with a status, never read out of a near-singular inverse. The equipartition value c*delay_sigma_s*sqrt(g/N) for the SAME schedule is emitted beside the computed value with the ratio, together with the isotropic trace bound sqrt(p/trace(M)) that AM-HM makes a hard floor — so the ratio is the anisotropy the assumption discarded. The delay closure tau_ik = tau_ij + tau_jk makes only n-1 of the n(n-1)/2 baselines geometrically independent; the report states both counts. MODELLED: the Moon-centre ephemeris, station clocks, troposphere and Earth-orientation parameters are held FIXED and observations are treated as independent, so the covariance is a Cramer-Rao bound for a reduced parameter set, not a predicted session result; the delay sigma, station coordinates and schedule are inputs. The partial the geometric-delay Jacobian leaves out (the differenced Shapiro term) is measured by finite difference and emitted. Not a geodetic product.", required_fields: &[], optional_fields: &["stations", "beacon_lat_deg", "beacon_lon_deg", "beacon_alt_m", "epoch_year", "epoch_month", "epoch_day", "arc_hours", "step_min", "delay_sigma_s", "elevation_mask_deg", "datum", "estimate_beacon", "rel_tol", "equipartition_g"] },
        ScenarioMeta { name: "tracking-loop", description: "Tracking-loop loss of lock: C/N0 -> lock/unlock WITH HYSTERESIS, the time it takes to lose lock, and spoof pull-in as a function of code and carrier offset RATE. The engine's existing denial and capture criteria are ratios on received power (a jammer denies at J/S = 30 dB, a spoofer captures at 3 dB); real loss of lock is not a power ratio, it is where a loop's jitter plus dynamic stress leaves its tracking-threshold budget. Reports (1) carrier (Costas) and code (non-coherent early/late) 1-sigma thermal jitter versus C/N0, squaring loss included, against the stated rules 3*sigma_PLL + theta_e <= 45 deg (the 15-degree rule in three-sigma form) and 3*sigma_DLL + ramp lag <= d/2 chips; (2) the drop and re-lock C/N0 thresholds with the binding loop named, the re-lock threshold DERIVED from the wider bandwidth a receiver re-pulls-in at rather than asserted, so the hysteresis width must fall between 5*log10(ratio) and 10*log10(ratio) dB; (3) the declared time to lose lock from a two-threshold lock detector with confirmation dwells driven over a C/N0 ramp, reported separately from the physical phase-escape time (the Viterbi mean time between cycle slips, in log10 s because it spans hundreds of decades); (4) spoof pull-in limits — the largest code slew and carrier Doppler rate a victim's loops can follow, with a map over both axes, because a spoofer that slews faster than these does not capture the loop, it outruns it; and (5) the DENIAL RADIUS the loop dynamics imply reported ALONGSIDE the existing power-ratio radius, never in place of it, with their signed difference as its own named field (denial_radius_delta_km) and a definition beside it. Every emitted numeric field carries a unit and a provenance class. VALIDATED closed forms: the carrier and code jitter expressions, the loop-filter reduction sqrt(2*B_n*T) and the first-order ramp lag are cross-checked against the engine's own sdr correlator stepped forward on seeded synthetic IF — a different route to the same numbers — to 0.6 %, 3.7 % and 0.17 % respectively, and the modified Bessel I0 behind the cycle-slip time against standard tabulated values to 1e-7. The lower validity limit is asserted, not merely stated: below ~32 dB-Hz at 1 ms the real atan discriminator saturates against its +-pi/2 range and measures less jitter than any linear theory. MODELLED: the loop bandwidths, integration time, correlator spacing, pull-in bandwidth ratio, confirmation dwells, jammer power and antenna gains are representative band figures, not a datasheet. NOT modelled: oscillator (Allan-deviation) and vibration jitter, front-end bandwidth limiting, multipath, AGC dynamics, data-bit-transition loss, external aiding, and any half-cycle correction to the Costas cycle-slip formula. Under loop dynamics spoof capture has no radius at all — the binding constraint is offset rate, not power — so that field is null with a reason rather than a fabricated number. Not a certified receiver-performance product.", required_fields: &[], optional_fields: &["pll_bandwidth_hz", "dll_bandwidth_hz", "predetection_integration_s", "correlator_spacing_chips", "carrier_allowance_deg", "code_allowance_chips", "pullin_bandwidth_ratio", "drop_confirm_epochs", "relock_confirm_epochs", "doppler_rate_hz_per_s", "code_slew_chips_per_s", "cn0_high_dbhz", "cn0_low_dbhz", "ramp_duration_s", "jitter_table_cn0_dbhz", "jammer_power_dbw", "jammer_gain_dbi", "rx_gain_toward_jammer_db", "rx_gain_toward_sat_db", "signal_power_dbw", "temp_k", "freq_hz", "chip_rate_hz", "jammer_type", "q_override", "denial_js_threshold_db", "capture_js_threshold_db", "spoof_code_slew_chips_per_s", "spoof_doppler_rate_hz_per_s"] },
        ScenarioMeta { name: "araim-reference-check", description: "Published ARAIM certification reference vectors: the engine's ARAIM horizontal and vertical protection levels run against the WG-C ARAIM Technical Subgroup's OWN worked numerical examples, at the tolerance (TOL_PL = 5e-2 m) those documents themselves state. Two vectors are committed as fixtures, each carrying its retrieval URL, retrieval date, source-file SHA-256 and page: the Reference Airborne Algorithm Description Document v3.1 (2019) Appendix D — self-consistent, and the acceptance vector — and the Milestone 3 Report (2016) Annex A section A.IX, the statement the research bibliographies cite. Every published input (the 10-satellite 2-constellation geometry matrix, the C_int and C_acc variance diagonals, b_nom, P_sat, P_const and the LPV-200 constant set) and every published output (VPL, HPL, EMT, sigma_v_acc, K_fa_3 and the two constellation-fault modes' sigma_3, sigma_ss_3 and b_3) is reported beside the engine's own number and their absolute difference. The protection-level equation is NOT re-solved here: the reference's mode list is handed to the engine's existing raim::araim_protection_level / araim_integrity_risk, so the bisection and the Gaussian-tail algebra under test are the engine's own; only the geometry, the sub-solutions, the detection thresholds and the published budget split are built for it. EXTERNALLY CHECKED against the 2019 vector: VPL 18.2926 m vs 18.3 m published, HPL 13.4063 m vs 13.45 m, EMT 7.2997 m vs 7.2998 m, sigma_v_acc 1.3694 m vs 1.3694 m, and all six published intermediates to within half a unit in their last printed decimal. The 2016 vector carries two internal defects the report states rather than hides — a sign typo in row 3 of G, and a K_fa_3 evaluated at 57 fault modes while the document states N_fault_max = 1 (12 modes) — so its geometry intermediates reproduce exactly while its protection levels are reported as measured discrepancies and excluded from the acceptance figure; the tolerance is never widened to absorb them. Every emitted numeric field carries a unit and a provenance class, and a transcribed figure is labelled `published` so it can never be mistaken for a computed one. SCOPE: only N_fault_max = 1 (single-satellite and single-constellation fault modes) is implemented, and a case whose priors require simultaneous multi-event fault subsets is REFUSED rather than silently truncated; fault detection, exclusion, the chi-square consistency check and the double-counting re-allocation step of the reference algorithm are out of scope. This reproduces a published reference algorithm's worked example — it is not a certification, an airworthiness artefact or an approval.", required_fields: &[], optional_fields: &["vector"] },
        ScenarioMeta { name: "lunar-frame-campaign", description: "Seven-parameter Helmert (similarity) frame datum driven by a SIMULATED OBSERVING CAMPAIGN rather than by an injected transform. A network of Earth stations observes a catalogue of lunar-surface beacons over an explicit schedule; the lunar-VLBI delay partials are accumulated over that schedule into a beacon-coordinate Fisher information matrix exactly as lunar-vlbi-fim does; and that information is propagated through the Helmert design A = [I3 | [p]_x | p] at the catalogue points into the datum covariance H = A^T M_b A, diagnosed by crate::fim::crlb. NO transform is injected and nothing is recovered from a planted answer, so the reported datum accuracy derives from the observing programme: halving the delay sigma halves it exactly, and lengthening the arc improves it because the libration the report MEASURES (sub_earth_direction_sweep_deg) is what separates the line-of-sight beacon coordinate from the plane-of-sky ones. Rank, datum defect, condition number, the full spectrum, the unobservable directions IN THE SEVEN-PARAMETER BASIS, the weakest direction even at full rank and each parameter's share of it are emitted on every run; a parameter the campaign does not constrain is published as NULL with a status, never read out of a near-singular inverse. Beacon-error correlation is measured, not assumed: with the stations held fixed no observation touches two beacons so the information matrix is exactly block-diagonal (offblock_fraction 0), and with the stations estimated the Schur-marginalised block is coupled, with the induced correlation printed and the whole propagation re-run with it discarded so the price of an independence assumption is a number. The same propagation under an isotropic per-coordinate sigma, and the recovery error the lunar-frame-realisation scenario reports, are both run here and printed beside the campaign value with their ratios. MODELLED: the beacon catalogue is sourced but the CAMPAIGN is simulated — the station network and the delay sigma are illustrative inputs, individual delay observations are treated as independent, and the Moon-centre ephemeris, station clocks, troposphere and Earth-orientation parameters are held FIXED, so the datum covariance is a Cramer-Rao bound for a reduced parameter set. Not a geodetic product.", required_fields: &[], optional_fields: &["stations", "beacons", "epoch_year", "epoch_month", "epoch_day", "arc_hours", "step_min", "delay_sigma_s", "elevation_mask_deg", "earth_elevation_mask_deg", "station_datum", "rel_tol", "assumed_coordinate_sigma_m"] },
        ScenarioMeta { name: "lunar-llr-datum", description: "Seven-parameter Helmert lunar frame datum driven by a REAL, ARCHIVED observing campaign rather than a simulated one. Where lunar-frame-campaign removed the injected transform but still stated its station network, its schedule and its per-observation sigma as ILLUSTRATIVE inputs, this kind takes all three from measurement: the epochs are the ground-transmit times of archived ILRS Consolidated Laser Ranging Data normal points, the observations are the laser ranges really fired at the five retroreflector arrays on the Moon, and every observation weight is that normal point's own archived precision bin_rms/sqrt(n_raw) read out of the file. Station coordinates come from the IERS ITRF2020 SLR solution (propagated to epoch by their published velocities) and the reflector coordinates from JPL DE430 Table 7 in the mean-Earth/mean-rotation frame the IAU 2015 WGCCRE model realises. Range partials accumulate into the joint reflector-coordinate Fisher information matrix and propagate through the same Helmert design into the datum covariance, diagnosed by crate::fim::crlb. Rank, defect, condition, spectrum, unobservable directions in the seven-parameter basis, the weakest direction at full rank and each parameter's share of it are emitted on every run, and an unconstrained parameter is published as NULL with a status. Inter-array correlation is MEASURED at exactly zero rather than assumed, because a laser range touches one array. A record the catalogues cannot place — a station with no published ITRF position — is SKIPPED AND COUNTED, never given a substituted coordinate. WHICH LINKS ARE MEASURED AND WHICH ARE MODELLED IS THE REPORT, NOT A FOOTNOTE: the Moon-centre ephemeris and the IAU body orientation remain modelled and their combined size is emitted as the observed-minus-computed one-way range residual over every point used; troposphere, solid-body tides, station eccentricity, polar motion, UT1-UTC, relativistic delay and station clocks are absent and inside that residual. Because an ephemeris error reaches a covariance only through the direction of each line of sight, every run also re-solves the whole datum with all partials tilted by sensitivity_tilt_deg (sign alternating) and emits the ratio, so the covariance's insensitivity to the modelled ephemeris is a number rather than an argument. The simulated campaign's own figures are RUN and printed beside the measured ones, not transcribed. MODELLED: the seven-parameter figures are a Cramer-Rao bound for a reduced parameter set on a real schedule — not a solved datum, not an LLR analysis and not a geodetic product. The committed real-data slice ships with the repository but not with the published crate; point data_dir at a copy of it.", required_fields: &[], optional_fields: &["data_dir", "normal_points_dir", "reflectors_path", "stations_path", "dut1_s", "rel_tol", "compare_simulated_campaign", "sensitivity_tilt_deg"] },
        ScenarioMeta { name: "lunar-beacon", description: "Surface-beacon augmentation of a lunar orbital navigation service, as a runnable before/after table. A south-polar user sees a sparse orbital set in a narrow patch of sky, so the ranging geometry is ill-conditioned; a few SURVEYED SURFACE BEACONS supply the low-elevation, wide-azimuth line-of-sight rows an all-overhead set lacks, and the horizontal dilution of precision collapses. The scenario reports three configurations at one epoch — satellites alone, satellites plus the visible beacons, and a larger constellation as the alternative route to the same geometry — each with its visible-source counts, its full DOP, and the realised 1-sigma accuracy in METRES obtained by multiplying through a per-beacon user-equivalent ranging error assembled as the root-sum-square of clock-synchronisation, multipath and survey terms. Beacon visibility is the airless-Moon two-height geometric horizon (no atmosphere, so the bound is exact), and the DOP assembly is the same kernel cross-checked against gnss_lib_py in tests/dop_reference.rs. WHY THIS KIND EXISTS: the module behind it has been in the engine and checked against an independent DOP path since L08/L09, but it was not reachable from a run — no kind, no dispatch arm, no bundled file. The README advertised the capability and the verification matrix carried NO row for it at all, so it was simultaneously claimed in prose and unclaimed in the ledger, while being unreachable in the engine. The defaults are the geometry that already carries a committed golden (a user at -80 deg with a 2 m antenna, three beacons at -80/0, -79/+60 and -79/-60 at 2 km, a six-satellite illustrative LCNS snapshot at t=0, a 5 deg mask), so a bare run reproduces a table an oracle already covers rather than inventing fresh numbers. MODELLED: the constellation design, the beacon placement and every error-budget magnitude are illustrative inputs, not a fielded survey or a measured link; only the DOP arithmetic, the horizon closed form and the sigma = DOP x sigma_URE relation are externally anchored. Not a service-performance commitment.", required_fields: &[], optional_fields: &["user", "beacons", "n_satellites", "comparison_n_satellites", "epoch_s", "elevation_mask_deg", "clock_sync_m", "multipath_m", "survey_m"] },
        ScenarioMeta { name: "earth-gnss-lunar", description: "Earth-GNSS reception at lunar distance, the weak-signal layer the conflict-resilience paper names and the engine had no model for. A GNSS satellite points its antenna at the Earth; a receiver near the Moon sits about fifteen times the orbital radius away, so three things bite at once. The Earth OCCULTS THE BORESIGHT: from 26,560 km it subtends a 13.9 deg half-angle, so any lunar-bound ray must leave the transmitter at least that far off nadir, which removes the peak of the beam before any power is computed. What remains is the MAIN-LOBE EDGE AND THE SIDELOBES, the regime the LuGRE payload actually operated in during 2025. And the free-space loss is about 208 dB at L1, roughly 25 dB more than a terrestrial user pays. The report emits one row per satellite (off-boresight angle, Earth occultation, range, path loss, transmit gain at that angle, received power, carrier-to-noise density) and aggregates only the links clearing the tracking threshold. IT ALSO REPORTS THE CONDITIONING, because that is what defines this layer: every visible satellite lies inside a cone a couple of degrees wide as seen from the Moon, so the lines of sight are nearly parallel, the dilution of precision is enormous, and an Earth-GNSS fix at lunar distance is a TIMING-grade observation far more than a position-grade one. MODELLED, and the transmit pattern is the reason: the gain at angle is a uniform circular aperture (Airy), while a real GPS L1 antenna is a twelve-element helical array with a shaped main lobe and non-Airy sidelobes. Measured patterns exist and are not vendored here, so orderings and orders of magnitude are meaningful while the dB of any single satellite is not. Deliberately ABSENT, each making the budget optimistic: no ionospheric or tropospheric loss on the limb-grazing rays, no polarisation, pointing or implementation loss, and a spherical Earth with no refractive extension. The Moon position is an INPUT (range plus inertial direction) rather than an ephemeris lookup, because the quantity under test is the link and the beam geometry. Upgrading this row to Validated needs a measured transmit pattern and LuGRE normal points; neither is in the repository and neither is invented here.", required_fields: &[], optional_fields: &["altitude_km", "inclination_deg", "planes", "sats_per_plane", "phasing_f", "epoch_s", "receiver_range_m", "receiver_ra_deg", "receiver_dec_deg", "tx_power_dbw", "tx_diameter_m", "tx_efficiency", "freq_hz", "rx_gain_dbi", "system_temp_k", "tracking_threshold_dbhz"] },
        ScenarioMeta { name: "cislunar-arc-recovery", description: "MODELLED independent-estimator test of the cislunar arc-length observability threshold. That threshold is a RANK read on the observability matrix assembled from the ANALYTIC range Jacobian rows and the ANALYTIC variational state-transition matrix, and its square-root-information-filter cross-check folds those same rows — a consistency check between two numerical machines, not corroboration. This kind supplies the missing arbiter: a batch least-squares estimator that actually RECOVERS the chief's initial state from simulated measurements, with the measurement partials taken as CENTRAL FINITE DIFFERENCES of the composed forward model. It calls no analytic Jacobian row, no variational STM, no singular-value or eigen decomposition, no rank tolerance and no square-root information filter — a source-text guard in tests/cislunar_arc_recovery_reference.rs makes that enforceable — while the dynamics (the same RK4 CR3BP flow), the initial conditions (the same differential-corrected constellation), the scalar observable and the epoch grid are shared deliberately and named in the emitted `independence` block, because two analyses of one physical problem must agree about the physics to be comparable. Two criteria, each swept over five decades of its own bound: NOISE-FREE RECOVERY (every seeded trial's final state error at most recovery_factor times its a-priori displacement — the rank analogue, with no singular-value tolerance in it) and MONTE-CARLO ESTIMABILITY (the measured RMS position error over a seeded noise ensemble below a stated bound — the estimability analogue, measured rather than predicted). Supports the planar four-state and the spatial six-state, the DRO / L2 halo / L2 NRHO families, range and range-rate observables, and a Moon-centred polar parameterisation in which the transformation is applied to the STATE and the forward model re-differenced there. MEASURED on the published planar DRO grid: the estimator recovers the four-state from 0.782609 h against the rank criterion's 2.086957 h at rel_tol 1e-6 (ratio 0.375) — the rank threshold is NOT corroborated as a recoverability boundary — while the measured estimability boundary lands in the same grid cell as the formal one, 5.739130 h, and the measured error curve reproduces the formal 1-sigma over 21 arc lengths to a geometric-mean ratio of 0.98. MODELLED: the constellation design, the epoch grid, the single tracked link, the displacement magnitude, the measurement sigma and the two stated bounds. Not a certified navigation-performance product.", required_fields: &[], optional_fields: &["mu", "arc_hours", "epochs", "steps", "spatial", "family", "n_spacecraft", "observable", "coordinates", "trials", "seed", "displacement_nd", "recovery_factor", "sigma_range_m", "sigma_range_rate_mm_s", "error_bound_km", "max_iterations", "rel_tol"] },
    ]
}

/// The built-in scenario kinds and their metadata as a JSON array — the form the
/// language bindings expose for programmatic introspection.
pub fn list_scenario_kinds_json() -> String {
    // `ScenarioMeta` holds only `&'static str` and `&[&'static str]` fields (see
    // `list_scenario_kinds`): no floats, no maps, no fallible custom `Serialize`
    // impls. Serialising a `Vec` of such values to JSON cannot fail, so the error
    // arm is unreachable by construction.
    json_of(&list_scenario_kinds())
        .expect("ScenarioMeta is all-static-string data; JSON serialisation is infallible")
}

/// Render the built-in scenario-kind catalogue as a standalone Markdown reference
/// (`docs/SCENARIOS.md`): every dispatchable kind with its one-line description and
/// its required / optional TOML fields.
///
/// Generated from [`list_scenario_kinds`] — the single source of truth — so the
/// per-kind reference can never drift from the dispatcher.
/// `tests/scenarios_reference_doc_sync.rs` fails the build if the committed copy is
/// stale; regenerate with `cargo run --bin gen_validation_artifacts`.
pub fn scenarios_reference_md() -> String {
    // Escape the one character that would break a Markdown table cell.
    fn cell(s: &str) -> String {
        s.replace('|', "\\|")
    }
    // A field list as inline code, or an explicit "none" so an empty pack is not
    // rendered as a silent blank.
    fn fields(list: &[&str]) -> String {
        if list.is_empty() {
            "*(none)*".to_string()
        } else {
            list.iter()
                .map(|f| format!("`{f}`"))
                .collect::<Vec<_>>()
                .join(", ")
        }
    }

    let kinds = list_scenario_kinds();
    let mut s = String::new();
    s.push_str("# Scenario kinds\n\n");
    s.push_str(&format!(
        "The {} built-in scenario kinds that `kshana::api::run_toml` dispatches over, \
each with its one-line description and its required / optional TOML fields.\n\n\
This file is **generated** from `api::list_scenario_kinds()` — the single source of \
truth — by `cargo run --bin gen_validation_artifacts`; edit the source, not this file. \
Every binding (the Python package, the MCP server's `list_scenario_kinds` tool, and \
the WASM playground) exposes this same catalogue, so what is listed here is exactly \
what every surface can run.\n\n",
        kinds.len()
    ));

    s.push_str("| # | Kind | Description |\n|--:|------|-------------|\n");
    for (i, m) in kinds.iter().enumerate() {
        s.push_str(&format!(
            "| {} | [`{}`](#{}) | {} |\n",
            i + 1,
            m.name,
            m.name,
            cell(m.description)
        ));
    }
    s.push('\n');

    for m in &kinds {
        s.push_str(&format!("## `{}`\n\n{}\n\n", m.name, m.description));
        s.push_str(&format!(
            "- **Required fields:** {}\n",
            fields(m.required_fields)
        ));
        s.push_str(&format!(
            "- **Optional fields:** {}\n\n",
            fields(m.optional_fields)
        ));
    }
    s
}

/// Lint a scenario TOML against the crate's own introspection and return a list of
/// problems — so a caller can be told what is wrong BEFORE a (possibly long) run,
/// instead of hitting a late runtime failure.
///
/// The check reuses the one schema the crate already publishes: it
/// [`ScenarioKind::classify`]es the kind, then looks up that kind's REQUIRED
/// top-level fields from [`list_scenario_kinds`] and reports one violation for each
/// required field that is absent from the document (e.g.
/// `clock: missing required field "time"`). It is deliberately conservative —
/// it only flags what the metadata already marks required and makes NO new validity
/// claims — and it never runs the scenario. An empty Vec means the scenario is
/// well-formed as far as the published metadata can express.
pub fn validate_scenario(src: &str) -> Vec<String> {
    // 1. The document must parse as TOML at all. A parse failure is the clearest,
    //    earliest problem to report, so we return it on its own.
    let value: toml::Value = match toml::from_str(src) {
        Ok(v) => v,
        Err(e) => return vec![format!("TOML parse error: {e}")],
    };

    // 2. Classify the kind via the existing classifier (defaults to Clock for an
    //    absent/unknown kind, exactly as the runner does).
    let kind = match ScenarioKind::classify(src) {
        Ok(k) => k,
        Err(e) => return vec![format!("could not classify scenario kind: {e}")],
    };
    let kind_name = kind.as_str();

    // 3. Look up this kind's published REQUIRED fields and report each one that is
    //    absent from the document's top-level table. If the document is not a table
    //    (e.g. a bare value), every required field is treated as absent.
    let table = value.as_table();
    let required = list_scenario_kinds()
        .into_iter()
        .find(|m| m.name == kind_name)
        .map(|m| m.required_fields)
        .unwrap_or(&[]);

    let mut violations = Vec::new();
    for field in required {
        let present = table.map(|t| t.contains_key(*field)).unwrap_or(false);
        if !present {
            violations.push(format!("{kind_name}: missing required field \"{field}\""));
        }
    }
    violations
}

/// Export an orbit/constellation scenario's propagated constellation as SP3-c text.
/// Errors if the scenario is not an `orbit` kind (only that pack has a constellation
/// to write). This is the CLI `--export-sp3` path.
pub fn export_sp3(src: &str) -> Result<String, String> {
    match ScenarioKind::classify(src).map_err(|e| e.to_string())? {
        ScenarioKind::Orbit => {
            let scn: crate::orbit::OrbitClockScenario =
                toml::from_str(src).map_err(|e| format!("invalid orbit scenario: {e}"))?;
            scn.to_sp3_string()
        }
        k => Err(format!(
            "SP3 export requires an orbit scenario, not '{}'",
            k.as_str()
        )),
    }
}

/// If `src` is an orbit scenario with `export_sp3 = true`, return its SP3-c text;
/// otherwise `None`. Lets the CLI auto-write an SP3 alongside the usual outputs.
pub fn auto_export_sp3(src: &str) -> Result<Option<String>, String> {
    if ScenarioKind::classify(src).map_err(|e| e.to_string())? != ScenarioKind::Orbit {
        return Ok(None);
    }
    let scn: crate::orbit::OrbitClockScenario =
        toml::from_str(src).map_err(|e| format!("invalid orbit scenario: {e}"))?;
    if scn.export_sp3 {
        Ok(Some(scn.to_sp3_string()?))
    } else {
        Ok(None)
    }
}

/// Export an orbit scenario's TLE mean elements as a CCSDS OMM catalogue — one OMM
/// (Orbit Mean-Elements Message) per TLE-defined satellite, in KVN form, carrying
/// the real NORAD catalogue number, COSPAR designator, and epoch from each TLE.
/// Errors if the scenario is not an `orbit` kind, or has no TLE-defined
/// constellation (a synthetic Walker or RINEX scenario has no mean elements to
/// publish). This is the CLI `--export-omm` path, mirroring [`export_sp3`].
pub fn export_omm(src: &str) -> Result<String, String> {
    match ScenarioKind::classify(src).map_err(|e| e.to_string())? {
        ScenarioKind::Orbit => {
            let scn: crate::orbit::OrbitClockScenario =
                toml::from_str(src).map_err(|e| format!("invalid orbit scenario: {e}"))?;
            scn.to_omm_string()
        }
        k => Err(format!(
            "OMM export requires an orbit scenario, not '{}'",
            k.as_str()
        )),
    }
}

/// If `src` is an orbit scenario with `export_omm = true`, return its OMM catalogue
/// text; otherwise `None`. Lets the CLI auto-write an OMM alongside the usual outputs.
pub fn auto_export_omm(src: &str) -> Result<Option<String>, String> {
    if ScenarioKind::classify(src).map_err(|e| e.to_string())? != ScenarioKind::Orbit {
        return Ok(None);
    }
    let scn: crate::orbit::OrbitClockScenario =
        toml::from_str(src).map_err(|e| format!("invalid orbit scenario: {e}"))?;
    if scn.export_omm {
        Ok(Some(scn.to_omm_string()?))
    } else {
        Ok(None)
    }
}

/// Export an orbit scenario's propagated constellation as CCSDS OEM text — the
/// inertial (TEME) state time series, position AND velocity, in the spacecraft-
/// ephemeris interchange format flight-dynamics tools (GMAT/Orekit/STK) read. This
/// is the velocity-carrying complement of the position-only [`export_sp3`]. Errors
/// if the scenario is not an `orbit` kind. This is the CLI `--export-oem` path.
pub fn export_oem(src: &str) -> Result<String, String> {
    match ScenarioKind::classify(src).map_err(|e| e.to_string())? {
        ScenarioKind::Orbit => {
            let scn: crate::orbit::OrbitClockScenario =
                toml::from_str(src).map_err(|e| format!("invalid orbit scenario: {e}"))?;
            scn.to_oem_string()
        }
        k => Err(format!(
            "OEM export requires an orbit scenario, not '{}'",
            k.as_str()
        )),
    }
}

/// If `src` is an orbit scenario with `export_oem = true`, return its OEM text;
/// otherwise `None`. Lets the CLI auto-write an OEM alongside the usual outputs.
pub fn auto_export_oem(src: &str) -> Result<Option<String>, String> {
    if ScenarioKind::classify(src).map_err(|e| e.to_string())? != ScenarioKind::Orbit {
        return Ok(None);
    }
    let scn: crate::orbit::OrbitClockScenario =
        toml::from_str(src).map_err(|e| format!("invalid orbit scenario: {e}"))?;
    if scn.export_oem {
        Ok(Some(scn.to_oem_string()?))
    } else {
        Ok(None)
    }
}

/// Inline a real IERS `finals2000A` body into a scenario's `eop_finals2000a` field
/// (the `--eop <file>` CLI path), returning the merged TOML. Carrying the data in
/// the scenario keeps the run reproducible and filesystem-free downstream; the
/// ephemeris runner then reduces the ground track through the real Earth-orientation
/// series instead of the nominal scalars. The key is inserted at the table root, so
/// `toml` serialises it ahead of any `[section]` headers.
pub fn inject_eop(src: &str, finals2000a_body: &str) -> Result<String, String> {
    let mut value: toml::Value =
        toml::from_str(src).map_err(|e| format!("invalid scenario TOML: {e}"))?;
    let table = value
        .as_table_mut()
        .ok_or_else(|| "scenario TOML is not a table".to_string())?;
    table.insert(
        "eop_finals2000a".to_string(),
        toml::Value::String(finals2000a_body.to_string()),
    );
    toml::to_string(&value).map_err(|e| format!("could not re-serialise scenario: {e}"))
}

/// Splice a study-metadata block into an already-rendered result JSON document,
/// returning the document with a top-level `"meta"` key inserted right after the
/// opening brace. Pure and deterministic — the [`crate::report::StudyMeta`] (incl.
/// its caller-supplied timestamp) is passed in, no clock is read here — and it
/// preserves every existing key and its order, so only the additive `"meta"` block
/// changes. Used by the CLI when `--study-name` is given; meta-less runs never call
/// it and stay byte-identical.
pub fn with_study_meta(json: &str, meta: &crate::report::StudyMeta) -> String {
    // Serialize the meta on its own, then indent it to match the surrounding
    // pretty document and splice it after the opening brace + newline. If the
    // document is not a pretty object starting with "{\n", fall back to returning
    // it unchanged (the engine always emits pretty objects).
    let meta_json = match serde_json::to_string_pretty(meta) {
        Ok(s) => s,
        Err(_) => return json.to_string(),
    };
    let Some(rest) = json.strip_prefix("{\n") else {
        return json.to_string();
    };
    // Re-indent the meta object's lines by two spaces to sit at the same depth as
    // the other top-level fields in the pretty document.
    let indented: String = meta_json
        .lines()
        .map(|l| format!("  {l}"))
        .collect::<Vec<_>>()
        .join("\n");
    format!("{{\n  \"meta\": {},\n{rest}", indented.trim_start())
}

/// The contract a scenario pack fulfils: run itself and produce the unified output
/// envelope, returning a structured [`KshanaError`] on failure.
pub trait Scenario {
    fn run(&self) -> Result<RunOutput, KshanaError>;
}

/// Extension point for third-party packs: a registrable pack implements
/// [`Scenario`] plus identifies its kind and metadata. See `ARCHITECTURE.md`
/// (“Extending Kshana with an external pack”). The trait is intentionally small and
/// semver-stable so out-of-tree packs do not need to fork core.
pub trait ExternalPack: Scenario {
    /// The `kind` string this pack answers to.
    fn kind_name(&self) -> &'static str;
    /// Introspection metadata, surfaced alongside the built-ins.
    fn meta(&self) -> ScenarioMeta;
    /// Register this pack's [`crate::registry::ScenarioFactory`] into `reg` so the
    /// dispatch seam can build it by id. The default is a no-op: pre-existing and
    /// out-of-tree implementors that predate this seam keep compiling unchanged, and
    /// only packs that opt into registry dispatch override it (typically by inserting
    /// a `Box<dyn ScenarioFactory>` keyed on [`kind_name`](ExternalPack::kind_name)).
    fn register_into(_reg: &mut crate::registry::PackRegistry)
    where
        Self: Sized,
    {
    }
}

// A built-in pack implemented through the `Scenario` trait, as the worked example
// the dispatcher and any external pack follow. (The other built-ins run inline in
// `run_toml`; migrating each is a mechanical follow-on.)
impl Scenario for crate::jamming::JammingScenario {
    fn run(&self) -> Result<RunOutput, KshanaError> {
        self.time.validate()?;
        let r = crate::jamming::run_jamming(self);
        let summary = format!(
            "scenario {} | jamming {} | availability under jamming {:.2} (nominal {:.2}) | min tracking {} | mean J/S {}",
            &r.scenario_hash[..12],
            if r.jammer_present { "ON" } else { "OFF" },
            r.fom.availability_under_jamming,
            r.fom.availability_nominal,
            r.fom.min_tracking,
            if r.fom.mean_js_db.is_nan() { "n/a".to_string() } else { format!("{:.1} dB", r.fom.mean_js_db) },
        );
        Ok(RunOutput {
            json: json_of_with_units(&r, crate::jamming::UNITS)
                .map_err(KshanaError::InvalidInput)?,
            svg: crate::jamming::to_svg(&r),
            summary,
            csv: None,
        })
    }
}

/// Run a scenario and return a typed result with a structured error — the entry
/// point binding callers should prefer over the string-error [`run_toml`].
pub fn run_scenario(src: &str) -> Result<RunOutput, KshanaError> {
    // Resolve the kind with the typed classifier (a real structured error), then
    // run; pack-level parse/validation failures surface as `InvalidInput`.
    ScenarioKind::classify(src)?;
    run_toml(src).map_err(KshanaError::InvalidInput)
}

/// Parse, dispatch, and run a scenario given as a TOML string. Dispatch is on the
/// typed [`ScenarioKind`]; the string-error signature is retained for the CLI and
/// the existing bindings (see [`run_scenario`] for the structured-error variant).
/// Every chart is stamped with a provenance footer so saved images stand alone.
pub fn run_toml(src: &str) -> Result<RunOutput, String> {
    let mut out = run_toml_inner(src)?;
    let hash = extract_scenario_hash(&out.json).unwrap_or_else(|| src_fingerprint(src));
    out.svg = with_provenance(out.svg, &hash);
    Ok(out)
}

/// The dispatch itself, before the chart is provenance-stamped. Classification is
/// behaviour-preserving: resolve the typed kind, then route through the
/// [`crate::registry::PackRegistry`] of built-ins. The error mapping is identical to
/// the historical inline dispatch (the registry wraps the built-in's `String` error
/// in `InvalidInput`, whose `Display` passes it through unchanged).
fn run_toml_inner(src: &str) -> Result<RunOutput, String> {
    let kind = ScenarioKind::classify(src).map_err(|e| e.to_string())?;
    crate::registry::PackRegistry::with_builtins()
        .build(&kind.to_id(), src)?
        .run()
        .map_err(|e| e.to_string())
}

/// The built-in dispatch table, keyed on an already-resolved [`ScenarioKind`]. This
/// is the same exhaustive match the engine has always run; it is now reached through
/// the registry seam (see [`run_toml_inner`]) so out-of-tree packs can interpose
/// without forking core.
pub(crate) fn run_builtin_kind(kind: ScenarioKind, src: &str) -> Result<RunOutput, String> {
    match kind {
        ScenarioKind::Inertial => {
            let scn: crate::inertial::InertialScenario =
                toml::from_str(src).map_err(|e| format!("invalid inertial scenario: {e}"))?;
            scn.time.validate()?;
            let r = crate::inertial::run_inertial(&scn);
            let summary = format!(
                "scenario {} | quantum holdover {:.0}s p95 {:.2}m | classical holdover {:.0}s p95 {:.1}m",
                &r.scenario_hash[..12],
                r.quantum.fom.holdover_s, r.quantum.fom.pos_p95_m,
                r.classical.fom.holdover_s, r.classical.fom.pos_p95_m,
            );
            Ok(RunOutput {
                json: json_of_with_units(&r, crate::inertial::UNITS)?,
                svg: crate::inertial::to_svg(&r),
                summary,
                csv: None,
            })
        }
        ScenarioKind::Integrity => {
            let scn: crate::raim::IntegrityScenario =
                toml::from_str(src).map_err(|e| format!("invalid integrity scenario: {e}"))?;
            scn.time.validate()?;
            let n_sats = scn.all_satellites()?.len();
            let report = scn.run()?;
            let summary = format!(
                "integrity | {} satellites | {}/{} epochs available ({:.1}%) | HAL {:.0} m VAL {:.0} m | sigma {:.1} m | Stanford(V): {} integrity events, {} HMI",
                n_sats,
                report.samples_available,
                report.samples_total,
                report.availability() * 100.0,
                report.al_h_m,
                report.al_v_m,
                scn.sigma_uere_m,
                report.stanford.integrity_events(),
                report
                    .stanford
                    .count(crate::raim::StanfordRegion::HazardouslyMisleadingInformation),
            );
            Ok(RunOutput {
                json: json_of_with_units(&report, crate::raim::UNITS)?,
                svg: crate::raim::availability_svg(&report),
                summary,
                csv: None,
            })
        }
        ScenarioKind::LunarIntegrity => {
            let scn: crate::lunar::LunarScenario = toml::from_str(src)
                .map_err(|e| format!("invalid lunar-integrity scenario: {e}"))?;
            let report = scn.run();
            let summary = format!(
                "lunar-integrity | south pole | {}/{} epochs available ({:.1}%) | AL {:.0} m | σ_URE {:.0} m | HPL {:.0}–{:.0} m",
                report.samples_available,
                report.samples_total,
                report.availability() * 100.0,
                report.alert_limit_m,
                report.sigma_ure_m,
                report.min_hpl_m,
                report.max_hpl_m,
            );
            Ok(RunOutput {
                json: json_of_with_units(&report, crate::lunar::UNITS)?,
                svg: crate::lunar::lunar_report_svg(&report),
                summary,
                csv: None,
            })
        }
        ScenarioKind::LunarTime => {
            let scn: crate::lunar_time::LunarTimeScenario = toml::from_str(src)
                .map_err(|e| format!("invalid lunar-time-offset scenario: {e}"))?;
            let report = scn.run();
            let summary = format!(
                "lunar-time-offset | secular LTC−TT rate {:.2} µs/day (band {:.0}–{:.0}) | self-pot {:.2} kinetic {:.2} | offset @ {:.2} d = {:.2} µs",
                report.secular_rate_us_per_day,
                report.band_low,
                report.band_high,
                report.self_potential_us_per_day,
                report.kinetic_us_per_day,
                report.horizon_days,
                report.offset_at_horizon_us,
            );
            Ok(RunOutput {
                json: json_of_with_units(&report, crate::lunar_time::UNITS)?,
                svg: crate::lunar_time::lunar_time_svg(&report),
                summary,
                csv: None,
            })
        }
        ScenarioKind::LunarVlbi => {
            let scn: crate::lunar_vlbi::LunarVlbiScenario =
                toml::from_str(src).map_err(|e| format!("invalid lunar-vlbi scenario: {e}"))?;
            let report = scn.run();
            let summary = format!(
                "lunar-vlbi | baseline {:.0} km | beacon range {:.0} km | delay {:.3} µs (rate {:.3e} s/s) | near-field {:.1} µs | {} samples over {:.1} h",
                report.baseline_km,
                report.beacon_range_km,
                report.delay_s * 1e6,
                report.delay_rate_s_per_s,
                report.near_field_correction_us,
                report.samples,
                report.horizon_hours,
            );
            Ok(RunOutput {
                json: json_of_with_units(&report, crate::lunar_vlbi::UNITS)?,
                svg: crate::lunar_vlbi::lunar_vlbi_svg(&report),
                summary,
                csv: None,
            })
        }
        ScenarioKind::LunarCombination => {
            let scn: crate::lunar_combination::LunarCombinationScenario = toml::from_str(src)
                .map_err(|e| format!("invalid lunar-joint-od-clock scenario: {e}"))?;
            let report = scn.run();
            let summary = format!(
                "lunar-joint-od-clock | {} sats, {} Earth stations | station 3-D err with VLBI {:.2} m vs range-only {:.2} m ({:.1}× sharper) | sat pos RMS {:.2} m | station clk err {:.2e} s | obs {}/{} params",
                report.n_sat,
                report.n_earth,
                report.with_vlbi.station_pos_err_m,
                report.without_vlbi.station_pos_err_m,
                report.station_observability_improvement_factor,
                report.with_vlbi.sat_pos_rms_m,
                report.with_vlbi.station_clock_err_s,
                report.with_vlbi.n_obs,
                report.with_vlbi.n_params,
            );
            Ok(RunOutput {
                json: json_of_with_units(&report, crate::lunar_combination::UNITS)?,
                svg: crate::lunar_combination::lunar_combination_svg(&report),
                summary,
                csv: None,
            })
        }
        ScenarioKind::LunarFrameRealise => {
            let scn: crate::lunar_frame_realise::LunarFrameRealiseScenario = toml::from_str(src)
                .map_err(|e| format!("invalid lunar-frame-realisation scenario: {e}"))?;
            let report = scn.run();
            let summary = format!(
                "lunar-frame-realisation | {} points | translation err {:.3e} m, rotation err {:.3e} rad, scale err {:.3e} ppb | rms residual {:.3} m | converged {}",
                report.n_points,
                report.trans_err_norm_m,
                report.rot_err_norm_rad,
                report.scale_err_ppb,
                report.rms_residual_m,
                report.converged,
            );
            Ok(RunOutput {
                json: json_of_with_units(&report, crate::lunar_frame_realise::UNITS)?,
                svg: crate::lunar_frame_realise::lunar_frame_realise_svg(&report),
                summary,
                csv: None,
            })
        }
        ScenarioKind::LunarService => {
            let scn: crate::lunar_service::LunarServiceScenario = toml::from_str(src)
                .map_err(|e| format!("invalid moonlight-service-volume scenario: {e}"))?;
            // `try_run` rather than `run`: with `ephemeris_path` set the scenario reads a
            // file, and a missing or malformed one must surface as a clean dispatch error
            // rather than a panic. With the path unset the two are identical.
            let report = scn.try_run()?;
            // The headline geometry is the illustrative set UNLESS a file supplied it, in
            // which case saying "illustrative LCNS-class" of it would be false. The
            // default wording is byte-unchanged.
            let geometry_label = match &report.ephemeris {
                None => "illustrative LCNS-class, not affiliated",
                Some(e) if e.provenance_class == "published-ephemeris" => {
                    "retrieved ephemeris, published-ephemeris"
                }
                Some(_) => "retrieved constellation definition, published-elements",
            };
            let mut summary = format!(
                "moonlight-service-volume | {} sats ({geometry_label}) | {} pts × {} epochs | coverage {:.1}% (PDOP<{:.1}) | sats {}–{} | PDOP {:.2}/{:.2}/{:.2} | HPL {:.0}–{:.0} m | PL avail {:.1}% | MODELLED",
                report.n_sats,
                report.n_grid_points,
                report.n_epochs,
                report.coverage_pct,
                report.pdop_threshold,
                report.min_sats,
                report.max_sats,
                report.pdop_min,
                report.pdop_mean,
                report.pdop_max,
                report.hpl_min_m,
                report.hpl_max_m,
                report.pl_availability_pct,
            );
            // Appended only when `ephemeris_path` named a file, so the default summary
            // line is unchanged: the sigma requirement under the retrieved geometry and
            // the one it is reported beside, never in place of.
            if let Some(c) = &report.ephemeris_comparison {
                let fmt = |v: Option<f64>| match v {
                    Some(x) => format!("{x:.4} m"),
                    None => "n/a".to_string(),
                };
                summary.push_str(&format!(
                    " | ephemeris {} ({}) σ_URE required {} vs Keplerian {} vs perturbed {} (Δ {})",
                    report
                        .ephemeris
                        .as_ref()
                        .map(|e| e.name.as_str())
                        .unwrap_or(""),
                    c.ephemeris.provenance,
                    fmt(c.ephemeris.sigma_required_m),
                    fmt(c.keplerian.sigma_required_m),
                    fmt(c.perturbed.sigma_required_m),
                    fmt(c.sigma_requirement_delta_vs_keplerian_m),
                ));
            }
            Ok(RunOutput {
                json: json_of_with_units(&report, crate::lunar_service::UNITS)?,
                svg: crate::lunar_service::lunar_service_svg(&report),
                summary,
                csv: None,
            })
        }
        ScenarioKind::LunarDpnt => {
            let scn: crate::lunar_dpnt::LunarDpntScenario = toml::from_str(src)
                .map_err(|e| format!("invalid lunar-differential-pnt scenario: {e}"))?;
            let report = scn.run();
            let summary = format!(
                "lunar-differential-pnt | {} sats (illustrative LCNS-class, not affiliated) | baseline {:.0} km | user error {:.2} m → {:.4} m ({:.0}× reduction) | HPL {:.1} m (σ_resid {:.1} m) | MODELLED",
                report.n_sats,
                report.baseline_km,
                report.user_error_uncorrected_m,
                report.user_error_corrected_m,
                report.reduction_factor,
                report.protection_level_m,
                report.residual_sigma_m,
            );
            Ok(RunOutput {
                json: json_of(&report)?,
                svg: crate::lunar_dpnt::lunar_dpnt_svg(&report),
                summary,
                csv: None,
            })
        }
        ScenarioKind::LunarInterop => {
            let scn: crate::lunar_interop::LunarInteropScenario = toml::from_str(src)
                .map_err(|e| format!("invalid lunar-interop-export scenario: {e}"))?;
            let report = scn.run();
            let summary = format!(
                "lunar-interop-export | REF_FRAME {} / TIME_SYSTEM {} | {} states, OEM {} lines | conformance {} ({} present / {} missing) | OEM round-trip {} | time-meta round-trip {} | KIF {} bytes | MODELLED",
                report.frame,
                report.time_system,
                report.n_states,
                report.oem_line_count,
                if report.conformance.pass { "PASS" } else { "FAIL" },
                report.conformance.present_fields.len(),
                report.conformance.missing_fields.len(),
                if report.oem_roundtrip_ok { "ok" } else { "fail" },
                if report.time_metadata_roundtrip_ok { "ok" } else { "fail" },
                report.kif_bytes,
            );
            Ok(RunOutput {
                json: json_of_with_units(&report, crate::lunar_interop::UNITS)?,
                svg: crate::lunar_interop::lunar_interop_svg(&report),
                summary,
                csv: None,
            })
        }
        ScenarioKind::TimeTransfer => {
            let scn: crate::timetransfer::TimeTransferScenario =
                toml::from_str(src).map_err(|e| format!("invalid time-transfer scenario: {e}"))?;
            let r = crate::timetransfer::run_timetransfer(&scn);
            let summary = format!(
                "scenario {} | optical sync_rms {:.2}ps range_rms {:.3}mm adev(1s) {:.2e} | RF sync_rms {:.1}ps range_rms {:.1}mm adev(1s) {:.2e}",
                &r.scenario_hash[..12],
                r.quantum.fom.sync_rms_ps, r.quantum.fom.range_rms_mm, r.quantum.fom.adev_tau0,
                r.classical.fom.sync_rms_ps, r.classical.fom.range_rms_mm, r.classical.fom.adev_tau0,
            );
            Ok(RunOutput {
                json: json_of_with_units(&r, crate::timetransfer::UNITS)?,
                svg: crate::timetransfer::to_svg(&r),
                summary,
                csv: None,
            })
        }
        ScenarioKind::QuantumTimeTransfer => {
            let scn: crate::timetransfer_chain::QuantumTimeTransferScenario =
                toml::from_str(src)
                    .map_err(|e| format!("invalid quantum-time-transfer scenario: {e}"))?;
            let r = scn.run();
            let summary = format!(
                "quantum-time-transfer | quantum chain {:.3e}s vs classical {:.3e}s | PL {:.2} ns | security Pd {:.3} (Pfa {:.0e}) | anomaly Pd {:.3} | trade quantum wins {}/{} | MODELLED",
                r.quantum_chain_sigma_s,
                r.classical_chain_sigma_s,
                r.protection_level_ns,
                r.security_pd,
                r.monitor_pfa,
                r.anomaly_pd,
                r.trade.quantum_wins(),
                r.trade.foms.len(),
            );
            Ok(RunOutput {
                json: json_of_with_units(&r, crate::timetransfer_chain::UNITS)?,
                svg: crate::timetransfer_chain::to_svg(&r),
                summary,
                csv: None,
            })
        }
        ScenarioKind::QuantumGnssFreeNav => {
            let scn: crate::quantum_nav_od::QuantumNavOdScenario = toml::from_str(src)
                .map_err(|e| format!("invalid quantum-gnss-free-nav scenario: {e}"))?;
            let r = scn.run();
            let summary = format!(
                "quantum-gnss-free-nav | outage {:.0}s | quantum pos err {:.2} m vs classical {:.2} m ({:.1}x) | holdover quantum {:.0}s vs classical {:.0}s (thr {:.0} m) | trade quantum wins {}/{} | MODELLED",
                r.outage_s,
                r.quantum_pos_err_m,
                r.classical_pos_err_m,
                r.improvement_x,
                r.quantum_holdover_s,
                r.classical_holdover_s,
                r.threshold_m,
                r.trade.quantum_wins(),
                r.trade.foms.len(),
            );
            Ok(RunOutput {
                json: json_of_with_units(&r, crate::quantum_nav_od::UNITS)?,
                svg: crate::quantum_nav_od::to_svg(&r),
                summary,
                csv: None,
            })
        }
        ScenarioKind::QuantumAnomalyDetect => {
            let scn: crate::quantum_faults::QuantumAnomalyScenario = toml::from_str(src)
                .map_err(|e| format!("invalid quantum-anomaly-detect scenario: {e}"))?;
            let r = scn.run();
            let summary = format!(
                "quantum-anomaly-detect | AUC quantum {:.4} vs classical {:.4} | min-detectable fault quantum {:.3} vs classical {:.3} | {} fault classes | trade quantum wins {}/{} | MODELLED",
                r.quantum_auc,
                r.classical_auc,
                r.quantum_min_detectable,
                r.classical_min_detectable,
                r.fault_catalog.len(),
                r.trade.quantum_wins(),
                r.trade.foms.len(),
            );
            Ok(RunOutput {
                json: json_of_with_units(&r, crate::quantum_faults::UNITS)?,
                svg: crate::quantum_faults::to_svg(&r),
                summary,
                csv: None,
            })
        }
        ScenarioKind::Hybrid => {
            let scn: crate::hybrid::HybridScenario =
                toml::from_str(src).map_err(|e| format!("invalid hybrid scenario: {e}"))?;
            scn.time.validate()?;
            let r = crate::hybrid::run_hybrid(&scn);
            let summary = format!(
                "scenario {} | quantum PNT-holdover {:.0}s (t {:.0}s/p {:.0}s) integrity {} security {} | classical PNT-holdover {:.0}s (t {:.0}s/p {:.0}s) integrity {} security {}",
                &r.scenario_hash[..12],
                r.quantum.fom.pnt_holdover_s, r.quantum.fom.timing_holdover_s, r.quantum.fom.position_holdover_s, integ(r.quantum.fom.integrity), integ(r.quantum.fom.security),
                r.classical.fom.pnt_holdover_s, r.classical.fom.timing_holdover_s, r.classical.fom.position_holdover_s, integ(r.classical.fom.integrity), integ(r.classical.fom.security),
            );
            Ok(RunOutput {
                json: json_of_with_units(&r, crate::hybrid::UNITS)?,
                svg: crate::hybrid::to_svg(&r),
                summary,
                csv: None,
            })
        }
        ScenarioKind::Fusion => {
            let scn: crate::hybrid::HybridScenario =
                toml::from_str(src).map_err(|e| format!("invalid fusion scenario: {e}"))?;
            scn.time.validate()?;
            let r = crate::fusion::run_fusion(&scn);
            let summary = format!(
                "scenario {} | fused | quantum PNT-holdover {:.0}s (t {:.0}s/p {:.0}s) integrity {} security {} | classical PNT-holdover {:.0}s (t {:.0}s/p {:.0}s) integrity {} security {}",
                &r.scenario_hash[..12],
                r.quantum.fom.pnt_holdover_s, r.quantum.fom.timing_holdover_s, r.quantum.fom.position_holdover_s, integ(r.quantum.fom.integrity), integ(r.quantum.fom.security),
                r.classical.fom.pnt_holdover_s, r.classical.fom.timing_holdover_s, r.classical.fom.position_holdover_s, integ(r.classical.fom.integrity), integ(r.classical.fom.security),
            );
            Ok(RunOutput {
                json: json_of_with_units(&r, crate::fusion::UNITS)?,
                svg: crate::hybrid::to_svg(&r),
                summary,
                csv: None,
            })
        }
        ScenarioKind::HybridUkf => {
            let scn: crate::fusion::hybrid_ukf::HybridUkfScenario =
                toml::from_str(src).map_err(|e| format!("invalid hybrid-ukf scenario: {e}"))?;
            scn.time.validate()?;
            let r = crate::fusion::hybrid_ukf::run_hybrid_ukf(&scn);
            let c = &r.consistency;
            let summary = format!(
                "scenario {} | hybrid-ukf (17-state, MODELLED) | sensor {} (q_va {:.2e}) | clock q_wf {:.2e} q_rw {:.2e} | NIS {:.2}/{} in [{:.2},{:.2}] | NEES {:.2}/{} in [{:.2},{:.2}] | {} | aided {:.1}m -> coast {:.1}m over {:.0}s | self-consistency, not accuracy",
                &r.scenario_hash[..12],
                if r.quantum_cai { "quantum-CAI" } else { "classical" },
                r.effective_q_va,
                r.clock_q_wf, r.clock_q_rw,
                c.nis_mean, c.nis_dof, c.nis_chi2_lower_95, c.nis_chi2_upper_95,
                c.nees_mean, c.nees_dof, c.nees_chi2_lower_95, c.nees_chi2_upper_95,
                if c.consistent { "CONSISTENT" } else { "INCONSISTENT" },
                r.coast.aided_pos_rms_m, r.coast.coast_end_pos_rms_m, r.coast.coast_duration_s,
            );
            Ok(RunOutput {
                json: json_of_with_units(&r, crate::fusion::hybrid_ukf::UNITS)?,
                svg: crate::fusion::hybrid_ukf::to_svg(&r),
                summary,
                csv: None,
            })
        }
        ScenarioKind::GnssIns => {
            let scn: crate::fusion::pack::GnssInsScenario =
                toml::from_str(src).map_err(|e| format!("invalid gnss-ins scenario: {e}"))?;
            scn.time.validate()?;
            let r = crate::fusion::pack::run_gnss_ins(&scn);
            let summary = format!(
                "scenario {} | gnss-ins | quantum outage-RMS fused {:.1}m vs free {:.1}m (hold {:.0}s, avail {:.2}) | classical fused {:.1}m vs free {:.1}m (hold {:.0}s, avail {:.2})",
                &r.scenario_hash[..12],
                r.quantum.fused_outage_rms_m, r.quantum.free_outage_rms_m, r.quantum.fom.holdover_s, r.quantum.fom.availability,
                r.classical.fused_outage_rms_m, r.classical.free_outage_rms_m, r.classical.fom.holdover_s, r.classical.fom.availability,
            );
            Ok(RunOutput {
                json: json_of_with_units(&r, crate::fusion::pack::UNITS)?,
                svg: crate::fusion::pack::to_svg(&r),
                summary,
                csv: None,
            })
        }
        ScenarioKind::GnssSim => {
            let scn: crate::gnss_sim::GnssSimScenario =
                toml::from_str(src).map_err(|e| format!("invalid gnss-sim scenario: {e}"))?;
            scn.time.validate()?;
            let (alert_h, alert_v) = (scn.alert_limit_h_m, scn.alert_limit_v_m);
            let r = crate::gnss_sim::run_gnss_sim(&scn);
            let summary = format!(
                "scenario {} | gnss-sim | mean iono {:.1}m tropo {:.1}m | RAIM avail {:.2} mean HPL {:.1}m VPL {:.1}m fault-rate {:.3}",
                &r.scenario_hash[..12],
                r.fom.mean_iono_m, r.fom.mean_tropo_m,
                r.fom.raim_availability, r.fom.mean_hpl_m, r.fom.mean_vpl_m, r.fom.fault_rate,
            );
            Ok(RunOutput {
                json: json_of_with_units(&r, crate::gnss_sim::UNITS)?,
                svg: crate::gnss_sim::to_svg(&r, alert_h, alert_v),
                summary,
                csv: None,
            })
        }
        ScenarioKind::Jamming => {
            // Routed through the `Scenario` trait — the extension-point contract.
            let scn: crate::jamming::JammingScenario =
                toml::from_str(src).map_err(|e| format!("invalid jamming scenario: {e}"))?;
            scn.run().map_err(|e| e.to_string())
        }
        ScenarioKind::Spoof => {
            let scn: crate::spoof::SpoofScenario =
                toml::from_str(src).map_err(|e| format!("invalid spoof scenario: {e}"))?;
            scn.time.validate()?;
            let r = crate::spoof::run_spoof(&scn);
            let det = |c: &crate::spoof::SpoofClock| {
                c.detect_time_s
                    .map_or_else(|| "undetected".to_string(), |t| format!("detected {t:.0}s"))
            };
            let summary = format!(
                "scenario {} | spoof {:?} vs {:.3} ns spec (P_fa {:.3}) | quantum security {:.3} (P_md {:.3}, MC {:.3}) {} | classical security {:.3} (P_md {:.3}, MC {:.3}) {}",
                &r.scenario_hash[..12], scn.attack.resolved_shape(), r.threshold_ns, scn.attack.target_pfa,
                r.quantum.security_fom, r.quantum.detection.analytic_pmd, r.quantum.detection.mc_pmd, det(&r.quantum),
                r.classical.security_fom, r.classical.detection.analytic_pmd, r.classical.detection.mc_pmd, det(&r.classical),
            );
            Ok(RunOutput {
                json: json_of_with_units(&r, crate::spoof::UNITS)?,
                svg: crate::spoof::to_svg(&r),
                summary,
                csv: None,
            })
        }
        ScenarioKind::SpoofDetect => {
            let scn: crate::spoof_detect::SpoofDetectScenario =
                toml::from_str(src).map_err(|e| format!("invalid spoof-detect scenario: {e}"))?;
            let r = crate::spoof_detect::run_spoof_detect(&scn);
            let summary = format!(
                "scenario {} | spoof-detect | {} SVs, +{:.1} dB{} | {:?} push | fused score {:.2} (thr {:.2}) | {}",
                &r.scenario_hash[..12],
                r.n_sats,
                r.attack.power_advantage_db,
                if r.attack.carrier_aligned { ", carrier-aligned" } else { "" },
                r.attack.push,
                r.decision.fused.score,
                scn.detector.fusion_threshold,
                r.verdict,
            );
            Ok(RunOutput {
                json: json_of_with_units(&r, crate::spoof_detect::UNITS)?,
                svg: crate::spoof_detect::to_svg(&r),
                summary,
                csv: None,
            })
        }
        ScenarioKind::Sweep => {
            let scn: crate::sweep::SweepScenario =
                toml::from_str(src).map_err(|e| format!("invalid sweep scenario: {e}"))?;
            scn.base.time.validate()?;
            let r = crate::sweep::run_sweep(&scn)?;
            let (first, last) = (r.points.first(), r.points.last());
            let summary = format!(
                "sweep {} over {} ({:.2e}..{:.2e}, {} pts, {} scale) | quantum {:.3}->{:.3} | classical {:.3}->{:.3}",
                r.metric, r.parameter,
                first.map_or(0.0, |p| p.value), last.map_or(0.0, |p| p.value), r.points.len(), r.scale,
                first.map_or(0.0, |p| p.quantum), last.map_or(0.0, |p| p.quantum),
                first.map_or(0.0, |p| p.classical), last.map_or(0.0, |p| p.classical),
            );
            Ok(RunOutput {
                json: json_of_with_units(&r, &crate::sweep::units(&r.parameter, &r.metric))?,
                svg: crate::sweep::to_svg(&r),
                summary,
                csv: None,
            })
        }
        ScenarioKind::SweepNd => {
            let scn: crate::sweep::GenericSweepScenario =
                toml::from_str(src).map_err(|e| format!("invalid generic sweep scenario: {e}"))?;
            let r = crate::sweep::run_generic_sweep(&scn)?;
            let summary = format!(
                "generic sweep of `{}` over [{}] | {} nodes (shape {:?}) | metrics [{}]",
                r.kind,
                r.keys.join(", "),
                r.points.len(),
                r.shape,
                r.metrics.join(", "),
            );
            Ok(RunOutput {
                json: json_of_with_units(&r, crate::sweep::GENERIC_UNITS)?,
                svg: crate::sweep::generic_to_svg(&r),
                summary,
                csv: None,
            })
        }
        ScenarioKind::Orbit => {
            let scn: crate::orbit::OrbitClockScenario =
                toml::from_str(src).map_err(|e| format!("invalid orbit scenario: {e}"))?;
            scn.time.validate()?;
            let r = crate::run::run_orbit_clock(&scn)?;
            let geometry = crate::orbit::summarize_dop(
                &scn.user.to_orbit(),
                &scn.all_satellites()?,
                scn.time.step_s,
                scn.time.duration_s,
                scn.mask_deg,
                scn.sigma_uere_m,
            );
            let nominal = r
                .quantum
                .series
                .iter()
                .filter(|s| s.gnss == GnssState::Nominal)
                .count();
            let summary = format!(
                "scenario {} | {}/{} samples GNSS-nominal | best PDOP {} pos {} | quantum holdover {:.0}s p95 {:.1}ns integrity {} security {} | classical holdover {:.0}s p95 {:.1}ns integrity {} security {}",
                &r.scenario_hash[..12],
                nominal, r.quantum.series.len(),
                fnum(geometry.best_pdop), posm(geometry.best_position_sigma_m),
                r.quantum.fom.holdover_s, r.quantum.fom.timing_p95_ns, integ(r.quantum.fom.integrity), integ(r.quantum.fom.security),
                r.classical.fom.holdover_s, r.classical.fom.timing_p95_ns, integ(r.classical.fom.integrity), integ(r.classical.fom.security),
            );
            #[derive(serde::Serialize)]
            struct OrbitOutput<'a> {
                #[serde(flatten)]
                run: &'a crate::report::RunResult,
                geometry: crate::orbit::DopSummary,
            }
            Ok(RunOutput {
                json: json_of_with_units(
                    &OrbitOutput { run: &r, geometry },
                    &[crate::orbit::UNITS, crate::report::UNITS].concat(),
                )?,
                svg: crate::report::to_svg(&r),
                summary,
                csv: None,
            })
        }
        ScenarioKind::Ephemeris => {
            let scn: crate::ephemeris::EphemerisScenario =
                toml::from_str(src).map_err(|e| format!("invalid ephemeris scenario: {e}"))?;
            let r = crate::ephemeris::run_ephemeris(&scn).map_err(|e| format!("ephemeris: {e}"))?;
            let pass = match (r.max_elevation_deg, r.peak_doppler_hz) {
                (Some(el), Some(d)) => {
                    format!(" | max el {el:.1}° peak Doppler {:.1} kHz", d / 1000.0)
                }
                _ => String::new(),
            };
            let summary = format!(
                "scenario {} | {} | {} samples | alt {:.0}–{:.0} km | |lat| ≤ {:.1}° | speed {:.0}–{:.0} m/s{}",
                &r.scenario_hash[..12],
                r.source,
                r.n_samples,
                r.alt_min_km,
                r.alt_max_km,
                r.lat_max_deg.abs().max(r.lat_min_deg.abs()),
                r.speed_min_m_s,
                r.speed_max_m_s,
                pass,
            );
            Ok(RunOutput {
                json: json_of_with_units(&r, crate::ephemeris::UNITS)?,
                svg: crate::ephemeris::to_svg(&r),
                summary,
                csv: None,
            })
        }
        ScenarioKind::GravityMap => {
            let cfg: crate::gravimeter::GravityMapBenchmarkCfg =
                toml::from_str(src).map_err(|e| format!("invalid gravity-map scenario: {e}"))?;
            let r = crate::gravimeter::run_gps_denied_gravity_nav(&cfg);
            let summary = format!(
                "gravity-map | free-inertial drift {:.0} m | gravity-matched {:.0} m | matching sigma {:.3e} mGal",
                r.free_inertial_drift_m, r.map_matched_error_m, r.measurement_sigma_mgal,
            );
            #[derive(serde::Serialize)]
            struct GravityMapOut {
                free_inertial_drift_m: f64,
                map_matched_error_m: f64,
                measurement_sigma_mgal: f64,
            }
            let out = GravityMapOut {
                free_inertial_drift_m: r.free_inertial_drift_m,
                map_matched_error_m: r.map_matched_error_m,
                measurement_sigma_mgal: r.measurement_sigma_mgal,
            };
            Ok(RunOutput {
                json: json_of_with_units(&out, crate::gravimeter::UNITS)?,
                svg: crate::altpnt::terrain::gravity_nav_svg(
                    r.free_inertial_drift_m,
                    r.map_matched_error_m,
                ),
                summary,
                csv: None,
            })
        }
        ScenarioKind::Terrain => {
            let cfg: crate::altpnt::terrain::TerrainNavCfg =
                toml::from_str(src).map_err(|e| format!("invalid terrain-nav scenario: {e}"))?;
            let r = crate::altpnt::terrain::run_terrain_nav(&cfg);
            let summary = format!(
                "terrain-nav | free-inertial drift {:.0} m | terrain-matched {:.0} m ({:.0}x cut) | matching sigma {:.1} m",
                r.free_inertial_drift_m,
                r.matched_error_m,
                if r.matched_error_m > 0.0 { r.free_inertial_drift_m / r.matched_error_m } else { 0.0 },
                r.measurement_sigma_m,
            );
            Ok(RunOutput {
                json: json_of_with_units(&r, crate::altpnt::terrain::TERRAIN_NAV_UNITS)?,
                svg: crate::altpnt::terrain::terrain_nav_svg(&r),
                summary,
                csv: None,
            })
        }
        ScenarioKind::TerrainSlam => {
            let cfg: crate::altpnt::sequential::SequentialTrnCfg =
                toml::from_str(src).map_err(|e| format!("invalid terrain-slam scenario: {e}"))?;
            let r = crate::altpnt::sequential::run_sequential_trn(&cfg);
            let summary = format!(
                "terrain-slam | {} waypoints | free-inertial RMS {:.0} m (final {:.0} m) | terrain-matched RMS {:.0} m (final {:.0} m, {:.0}x cut) | mean ESS {:.0}/{} | matching sigma {:.1} m",
                r.waypoints,
                r.free_inertial_rms_m,
                r.free_inertial_final_m,
                r.matched_rms_m,
                r.matched_final_m,
                if r.matched_rms_m > 0.0 { r.free_inertial_rms_m / r.matched_rms_m } else { 0.0 },
                r.mean_ess,
                cfg.n_particles.max(1),
                r.measurement_sigma_m,
            );
            Ok(RunOutput {
                json: json_of_with_units(&r, crate::altpnt::sequential::UNITS)?,
                svg: crate::altpnt::sequential::sequential_trn_svg(&r),
                summary,
                csv: None,
            })
        }
        ScenarioKind::CombinedAltPnt => {
            let cfg: crate::altpnt::terrain::CombinedAltPntCfg = toml::from_str(src)
                .map_err(|e| format!("invalid combined-altpnt scenario: {e}"))?;
            let r = crate::altpnt::terrain::run_combined_altpnt(&cfg);
            let summary = format!(
                "combined-altpnt | free-inertial drift {:.0} m | gravity {:.0} m magnetic {:.0} m terrain {:.0} m | FUSED {:.0} m",
                r.free_inertial_drift_m,
                r.gravity_only_m,
                r.magnetic_only_m,
                r.terrain_only_m,
                r.combined_m,
            );
            Ok(RunOutput {
                json: json_of_with_units(&r, crate::altpnt::terrain::COMBINED_ALTPNT_UNITS)?,
                svg: crate::altpnt::terrain::combined_altpnt_svg(&r),
                summary,
                csv: None,
            })
        }
        ScenarioKind::Pvt => {
            let scn: crate::pvt::PvtScenario =
                toml::from_str(src).map_err(|e| format!("invalid pvt scenario: {e}"))?;
            let r = crate::pvt::run_pvt(&scn)?;
            let summary = crate::pvt::summary(&r);
            Ok(RunOutput {
                json: json_of_with_units(&r, crate::pvt::UNITS)?,
                svg: crate::pvt::pvt_svg(&r),
                summary,
                csv: None,
            })
        }
        ScenarioKind::MarsPnt => {
            let scn: crate::mars_pnt::MarsScenario =
                toml::from_str(src).map_err(|e| format!("invalid mars-pnt scenario: {e}"))?;
            let r = crate::mars_pnt::run_mars_pnt(&scn)?;
            let summary = crate::mars_pnt::summary(&r);
            Ok(RunOutput {
                json: json_of_with_units(&r, crate::mars_pnt::UNITS)?,
                svg: crate::mars_pnt::to_svg(&r),
                summary,
                csv: None,
            })
        }
        ScenarioKind::ImpairmentEval => {
            let scn: crate::impairment_eval::ImpairmentEvalScenario = toml::from_str(src)
                .map_err(|e| format!("invalid impairment-eval scenario: {e}"))?;
            let (json, summary) = scn.run_json()?;
            let svg = minimal_svg(&summary);
            Ok(RunOutput {
                json,
                svg,
                summary,
                csv: None,
            })
        }
        ScenarioKind::QuantumTrade => {
            let scn: crate::quantum_trade::QuantumTradeScenario =
                toml::from_str(src).map_err(|e| format!("invalid quantum-trade scenario: {e}"))?;
            let (json, summary) = scn.run_json()?;
            let svg = minimal_svg(&summary);
            Ok(RunOutput {
                json,
                svg,
                summary,
                csv: None,
            })
        }
        ScenarioKind::SpaceWeather => {
            let scn: crate::space_weather::SpaceWeatherScenario =
                toml::from_str(src).map_err(|e| format!("invalid space-weather scenario: {e}"))?;
            let (json, summary) = scn.run_json()?;
            let svg = minimal_svg(&summary);
            Ok(RunOutput {
                json,
                svg,
                summary,
                csv: None,
            })
        }
        ScenarioKind::OemInterop => {
            let scn: crate::oem::OemInteropScenario =
                toml::from_str(src).map_err(|e| format!("invalid oem-interop scenario: {e}"))?;
            let (json, summary) = scn.run_json()?;
            let svg = minimal_svg(&summary);
            Ok(RunOutput {
                json,
                svg,
                summary,
                csv: None,
            })
        }
        ScenarioKind::LaunchWindow => {
            let scn: crate::launch::LaunchWindowScenario =
                toml::from_str(src).map_err(|e| format!("invalid launch-window scenario: {e}"))?;
            let (json, summary) = scn.run_json()?;
            let svg = minimal_svg(&summary);
            Ok(RunOutput {
                json,
                svg,
                summary,
                csv: None,
            })
        }
        ScenarioKind::Reentry => {
            let scn: crate::reentry::ReentryScenario =
                toml::from_str(src).map_err(|e| format!("invalid reentry scenario: {e}"))?;
            let (json, summary) = scn.run_json()?;
            let svg = minimal_svg(&summary);
            Ok(RunOutput {
                json,
                svg,
                summary,
                csv: None,
            })
        }
        ScenarioKind::EoCoverage => {
            let scn: crate::eo_payload::EoCoverageScenario =
                toml::from_str(src).map_err(|e| format!("invalid eo-coverage scenario: {e}"))?;
            let (json, summary) = scn.run_json()?;
            let svg = minimal_svg(&summary);
            Ok(RunOutput {
                json,
                svg,
                summary,
                csv: None,
            })
        }
        ScenarioKind::SpacePacket => {
            let scn: crate::space_packet::SpacePacketScenario =
                toml::from_str(src).map_err(|e| format!("invalid space-packet scenario: {e}"))?;
            let (json, summary) = scn.run_json()?;
            let svg = minimal_svg(&summary);
            Ok(RunOutput {
                json,
                svg,
                summary,
                csv: None,
            })
        }
        ScenarioKind::AttitudeBudget => {
            let scn: crate::attitude_budget::AttitudeBudgetScenario = toml::from_str(src)
                .map_err(|e| format!("invalid attitude-budget scenario: {e}"))?;
            let (json, summary) = scn.run_json()?;
            let svg = minimal_svg(&summary);
            Ok(RunOutput {
                json,
                svg,
                summary,
                csv: None,
            })
        }
        ScenarioKind::Passes => {
            let scn: crate::passes::PassesScenario =
                toml::from_str(src).map_err(|e| format!("invalid passes scenario: {e}"))?;
            let (json, summary) = scn.run_json()?;
            let svg = minimal_svg(&summary);
            Ok(RunOutput {
                json,
                svg,
                summary,
                csv: None,
            })
        }
        ScenarioKind::LinkBudget => {
            let scn: crate::linkbudget::LinkBudgetScenario =
                toml::from_str(src).map_err(|e| format!("invalid link-budget scenario: {e}"))?;
            let (json, summary) = scn.run_json()?;
            let svg = minimal_svg(&summary);
            Ok(RunOutput {
                json,
                svg,
                summary,
                csv: None,
            })
        }
        ScenarioKind::LunarTimeBudget => {
            let scn: crate::lunar_time_budget_scenario::LunarTimeBudgetScenario =
                toml::from_str(src)
                    .map_err(|e| format!("invalid lunar-time-budget scenario: {e}"))?;
            let (json, summary) = scn.run_json()?;
            let svg = minimal_svg(&summary);
            // G11: publish the long-form (tau, term) budget table as a runtime artifact.
            // The array-valued fields previously reached consumers only as JSON arrays,
            // which is how a released table came to publish 23 of its 57 averaging times.
            let csv = Some(scn.to_csv()?);
            Ok(RunOutput {
                json,
                svg,
                summary,
                csv,
            })
        }
        ScenarioKind::RealtimeFrameEop => {
            let scn: crate::realtime_frame_eop::RealtimeFrameEopScenario = toml::from_str(src)
                .map_err(|e| format!("invalid realtime-frame-eop scenario: {e}"))?;
            let (json, summary, svg) = scn.run_output()?;
            // G4: publish the P4 Table 1 + Table 2 reproducibility CSV as a runtime
            // artifact, so a plain scenario run produces the file the paper cites (not
            // only the #[ignore] golden-regen test).
            let csv = Some(scn.to_csv()?);
            Ok(RunOutput {
                json,
                svg,
                summary,
                csv,
            })
        }
        ScenarioKind::HybridOpticalRf => {
            let scn: crate::hybrid_integrity::HybridOpticalRfScenario = toml::from_str(src)
                .map_err(|e| format!("invalid hybrid-optical-rf scenario: {e}"))?;
            let (json, summary, svg) = scn.run_output()?;
            Ok(RunOutput {
                json,
                svg,
                summary,
                csv: None,
            })
        }
        ScenarioKind::CislunarObservability => {
            let scn: crate::cislunar_observability::CislunarObservabilityScenario =
                toml::from_str(src)
                    .map_err(|e| format!("invalid cislunar-observability scenario: {e}"))?;
            let (json, summary, svg) = scn.run_output()?;
            Ok(RunOutput {
                json,
                svg,
                summary,
                csv: None,
            })
        }
        ScenarioKind::CislunarArcRecovery => {
            let scn: crate::cislunar_arc_recovery::CislunarArcRecoveryScenario =
                toml::from_str(src)
                    .map_err(|e| format!("invalid cislunar-arc-recovery scenario: {e}"))?;
            let (json, summary, svg) = scn.run_output()?;
            Ok(RunOutput {
                json,
                svg,
                summary,
                csv: None,
            })
        }
        ScenarioKind::EarthGnssLunar => {
            let scn: crate::earth_gnss_lunar::EarthGnssLunarScenario = toml::from_str(src)
                .map_err(|e| format!("invalid earth-gnss-lunar scenario: {e}"))?;
            let (json, summary, svg) = scn.run_output()?;
            Ok(RunOutput {
                json,
                svg,
                summary,
                csv: None,
            })
        }
        ScenarioKind::LunarBeacon => {
            let scn: crate::lunar_beacon::LunarBeaconScenario =
                toml::from_str(src).map_err(|e| format!("invalid lunar-beacon scenario: {e}"))?;
            let (json, summary, svg) = scn.run_output()?;
            Ok(RunOutput {
                json,
                svg,
                summary,
                csv: None,
            })
        }
        ScenarioKind::ConflictResilience => {
            let scn: crate::conflict_resilience::ConflictResilienceScenario =
                toml::from_str(src)
                    .map_err(|e| format!("invalid conflict-resilience scenario: {e}"))?;
            let (json, summary, svg) = scn.run_output()?;
            Ok(RunOutput {
                json,
                svg,
                summary,
                csv: None,
            })
        }
        ScenarioKind::LunarAttackSurface => {
            let scn: crate::attack_surface::LunarAttackSurfaceScenario = toml::from_str(src)
                .map_err(|e| format!("invalid lunar-attack-surface scenario: {e}"))?;
            let (json, summary, svg) = scn.run_output()?;
            Ok(RunOutput {
                json,
                svg,
                summary,
                csv: None,
            })
        }
        ScenarioKind::ApertureDutyCycle => {
            let scn: crate::aperture_duty::ApertureDutyCycleScenario = toml::from_str(src)
                .map_err(|e| format!("invalid aperture-duty-cycle scenario: {e}"))?;
            let (json, summary) = scn.run_json()?;
            let svg = minimal_svg(&summary);
            Ok(RunOutput {
                json,
                svg,
                summary,
                csv: None,
            })
        }
        ScenarioKind::TrackingLoop => {
            let scn: crate::tracking_loop::TrackingLoopScenario =
                toml::from_str(src).map_err(|e| format!("invalid tracking-loop scenario: {e}"))?;
            let (json, summary) = scn.run_json()?;
            let svg = minimal_svg(&summary);
            Ok(RunOutput {
                json,
                svg,
                summary,
                csv: None,
            })
        }
        ScenarioKind::AraimReferenceCheck => {
            let scn: crate::araim_reference::AraimReferenceCheckScenario = toml::from_str(src)
                .map_err(|e| format!("invalid araim-reference-check scenario: {e}"))?;
            let (json, summary) = scn.run_json()?;
            let svg = minimal_svg(&summary);
            Ok(RunOutput {
                json,
                svg,
                summary,
                csv: None,
            })
        }
        ScenarioKind::InsTrnCoast => {
            let scn: crate::inertial::coast::InsTrnCoastScenario =
                toml::from_str(src).map_err(|e| format!("invalid ins-trn-coast scenario: {e}"))?;
            let (json, summary) = scn.run_json()?;
            let svg = minimal_svg(&summary);
            Ok(RunOutput {
                json,
                svg,
                summary,
                csv: None,
            })
        }
        ScenarioKind::LunarLlrDatum => {
            let scn: crate::lunar_llr::LunarLlrDatumScenario = toml::from_str(src)
                .map_err(|e| format!("invalid lunar-llr-datum scenario: {e}"))?;
            let (json, summary) = scn.run_json()?;
            let svg = minimal_svg(&summary);
            Ok(RunOutput {
                json,
                svg,
                summary,
                csv: None,
            })
        }
        ScenarioKind::LunarFrameCampaign => {
            let scn: crate::lunar_frame_campaign::LunarFrameCampaignScenario = toml::from_str(src)
                .map_err(|e| format!("invalid lunar-frame-campaign scenario: {e}"))?;
            let (json, summary) = scn.run_json()?;
            let svg = minimal_svg(&summary);
            Ok(RunOutput {
                json,
                svg,
                summary,
                csv: None,
            })
        }
        ScenarioKind::LunarVlbiFim => {
            let scn: crate::lunar_vlbi_fim::LunarVlbiFimScenario =
                toml::from_str(src).map_err(|e| format!("invalid lunar-vlbi-fim scenario: {e}"))?;
            let (json, summary) = scn.run_json()?;
            let svg = minimal_svg(&summary);
            Ok(RunOutput {
                json,
                svg,
                summary,
                csv: None,
            })
        }
        ScenarioKind::LunarJamming => {
            let scn: crate::lunar_jamming::LunarJammingScenario =
                toml::from_str(src).map_err(|e| format!("invalid lunar-jamming scenario: {e}"))?;
            let (json, summary, svg) = scn.run_output()?;
            // The per-satellite J/S table is the result, so it is published as a runtime
            // CSV artifact too: the table a paper cites is then a file the engine wrote,
            // not a block a manuscript transcribed out of two separate link-budget runs.
            let csv = Some(scn.to_csv()?);
            Ok(RunOutput {
                json,
                svg,
                summary,
                csv,
            })
        }
        ScenarioKind::Clock => {
            let scn: crate::scenario::Scenario =
                toml::from_str(src).map_err(|e| format!("invalid scenario: {e}"))?;
            scn.time.validate()?;
            if scn.runs > 1 {
                // Monte Carlo ensemble: report confidence bands instead of one run.
                let r = crate::ensemble::run_ensemble(&scn);
                let q = &r.quantum;
                let c = &r.classical;
                let summary = format!(
                    "scenario {} | {} runs | quantum holdover {:.0}s [{:.0}-{:.0}] p95 {:.1}ns security {} | classical holdover {:.0}s [{:.0}-{:.0}] p95 {:.1}ns security {}",
                    &r.scenario_hash[..12], r.runs,
                    q.holdover_s.mean, q.holdover_s.p05, q.holdover_s.p95, q.timing_p95_ns.mean, integ(q.security),
                    c.holdover_s.mean, c.holdover_s.p05, c.holdover_s.p95, c.timing_p95_ns.mean, integ(c.security),
                );
                return Ok(RunOutput {
                    json: json_of_with_units(&r, crate::ensemble::UNITS)?,
                    svg: crate::ensemble::to_svg(&r),
                    summary,
                    csv: None,
                });
            }
            let r = crate::run::run(&scn);
            let summary = format!(
                "scenario {} | quantum holdover {:.0}s p95 {:.1}ns integrity {} security {} | classical holdover {:.0}s p95 {:.1}ns integrity {} security {}",
                &r.scenario_hash[..12],
                r.quantum.fom.holdover_s, r.quantum.fom.timing_p95_ns, integ(r.quantum.fom.integrity), integ(r.quantum.fom.security),
                r.classical.fom.holdover_s, r.classical.fom.timing_p95_ns, integ(r.classical.fom.integrity), integ(r.classical.fom.security),
            );
            Ok(RunOutput {
                json: json_of_with_units(&r, crate::report::UNITS)?,
                svg: crate::report::to_svg(&r),
                summary,
                csv: None,
            })
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn scenario_kind_classifies_and_round_trips() {
        // Every built-in kind classifies to its variant and back to its string.
        for meta in list_scenario_kinds() {
            let src = format!("kind = \"{}\"\n", meta.name);
            let k = ScenarioKind::classify(&src).unwrap();
            assert_eq!(k.as_str(), meta.name, "round-trip for {}", meta.name);
        }
        // An ABSENT kind keeps the historical clock default — three bundled scenarios
        // carry no `kind` line and must keep running.
        assert_eq!(ScenarioKind::classify("").unwrap(), ScenarioKind::Clock);
        assert_eq!(
            ScenarioKind::classify("threshold_ns = 20.0\n").unwrap(),
            ScenarioKind::Clock
        );

        // An UNKNOWN kind is an ERROR, not a silent clock run. This used to return
        // ScenarioKind::Clock, so a one-character typo ran a clock-holdover simulation
        // and reported it as a successful run of the kind the caller asked for.
        let invented = ScenarioKind::classify("kind = \"frobnicate\"").unwrap_err();
        let msg = invented.to_string();
        assert!(
            msg.contains("unknown scenario kind") && msg.contains("frobnicate"),
            "an invented kind must be named back to the caller, got: {msg}"
        );
        assert!(
            !msg.contains("did you mean"),
            "an invented name is not a typo and must not earn a suggestion: {msg}"
        );

        // A TYPO earns the nearest built-in by name.
        let typo = ScenarioKind::classify("kind = \"lunar-vbli-fim\"")
            .unwrap_err()
            .to_string();
        assert!(
            typo.contains("did you mean \"lunar-vlbi-fim\""),
            "a transposition must be pointed at the real kind, got: {typo}"
        );

        // And the validator no longer calls such a document valid.
        let problems = validate_scenario("kind = \"lunar-vbli-fim\"\n");
        assert!(
            problems.iter().any(|p| p.contains("unknown scenario kind")),
            "validate_scenario must report an unknown kind, got: {problems:?}"
        );
    }

    #[test]
    fn list_scenario_kinds_covers_every_dispatch_variant() {
        let names: std::collections::HashSet<_> =
            list_scenario_kinds().iter().map(|m| m.name).collect();
        for k in [
            ScenarioKind::Clock,
            ScenarioKind::Inertial,
            ScenarioKind::Integrity,
            ScenarioKind::TimeTransfer,
            ScenarioKind::QuantumTimeTransfer,
            ScenarioKind::QuantumGnssFreeNav,
            ScenarioKind::QuantumAnomalyDetect,
            ScenarioKind::Hybrid,
            ScenarioKind::Fusion,
            ScenarioKind::HybridUkf,
            ScenarioKind::GnssIns,
            ScenarioKind::GnssSim,
            ScenarioKind::Jamming,
            ScenarioKind::Spoof,
            ScenarioKind::Sweep,
            ScenarioKind::SweepNd,
            ScenarioKind::Orbit,
            ScenarioKind::Ephemeris,
            ScenarioKind::LunarIntegrity,
            ScenarioKind::LunarTime,
            ScenarioKind::LunarVlbi,
            ScenarioKind::LunarCombination,
            ScenarioKind::LunarFrameRealise,
            ScenarioKind::LunarService,
            ScenarioKind::LunarDpnt,
            ScenarioKind::LunarInterop,
            ScenarioKind::GravityMap,
            ScenarioKind::Terrain,
            ScenarioKind::CombinedAltPnt,
            ScenarioKind::Pvt,
            ScenarioKind::MarsPnt,
        ] {
            assert!(
                names.contains(k.as_str()),
                "metadata missing for {}",
                k.as_str()
            );
        }
        // The JSON form parses and is non-empty.
        let j: serde_json::Value = serde_json::from_str(&list_scenario_kinds_json()).unwrap();
        assert!(j.as_array().unwrap().len() >= 16);
    }

    #[test]
    fn run_scenario_returns_a_structured_error_taxonomy() {
        // A malformed scenario yields a typed InvalidInput, not an opaque string.
        let err = run_scenario("kind = \"inertial\"\nthis is not valid toml = =").unwrap_err();
        assert!(matches!(err, KshanaError::InvalidInput(_)));
        // A valid scenario runs through the typed entry too.
        let out = run_scenario(include_str!("../scenarios/jamming-demo.toml")).unwrap();
        assert!(out.json.starts_with('{'));
    }

    #[test]
    fn jamming_pack_runs_through_the_scenario_trait() {
        // The trait is the real execution path for at least one built-in.
        let scn: crate::jamming::JammingScenario =
            toml::from_str(include_str!("../scenarios/jamming-demo.toml")).unwrap();
        let out = Scenario::run(&scn).unwrap();
        assert!(out.summary.contains("jamming"));
        assert!(out.svg.starts_with("<svg"));
    }

    #[test]
    fn dispatches_each_kind_and_emits_json_and_svg() {
        for src in [
            include_str!("../scenarios/clock-holdover.toml"),
            include_str!("../scenarios/clock-ensemble.toml"),
            include_str!("../scenarios/imu-deadreckoning.toml"),
            include_str!("../scenarios/timetransfer.toml"),
            include_str!("../scenarios/hybrid-pnt.toml"),
            include_str!("../scenarios/fusion-pnt.toml"),
            include_str!("../scenarios/gnss-ins.toml"),
            include_str!("../scenarios/orbit-gnss-challenged.toml"),
            include_str!("../scenarios/orbit-molniya.toml"),
            include_str!("../scenarios/orbit-multignss.toml"),
            include_str!("../scenarios/orbit-real-tle.toml"),
            include_str!("../scenarios/ephemeris.toml"),
            include_str!("../scenarios/sweep-clock-stability.toml"),
            include_str!("../scenarios/spoof-attack.toml"),
            include_str!("../scenarios/spoof-meaconing.toml"),
            include_str!("../scenarios/spoof-detect.toml"),
            include_str!("../scenarios/integrity-raim.toml"),
            include_str!("../scenarios/jamming-demo.toml"),
            include_str!("../scenarios/gnss-sim-raim.toml"),
            include_str!("../scenarios/gps-denied-gravity-nav.toml"),
            include_str!("../scenarios/terrain-nav.toml"),
            include_str!("../scenarios/terrain-slam.toml"),
            include_str!("../scenarios/combined-altpnt.toml"),
            include_str!("../scenarios/mars-pnt-lmo.toml"),
            include_str!("../scenarios/moonlight-service-volume.toml"),
            include_str!("../scenarios/lunar-differential-pnt.toml"),
            include_str!("../scenarios/lunar-interop-export.toml"),
        ] {
            let out = run_toml(src).expect("scenario runs");
            assert!(out.json.starts_with('{'));
            assert!(out.svg.starts_with("<svg"));
            assert!(!out.summary.is_empty());
        }
    }

    #[test]
    fn mars_pnt_kind_round_trips_through_the_dispatch() {
        // The new deep-space kind dispatches end-to-end through the shared entry point the
        // CLI/Python/wasm/MCP bindings all use: a `mars-pnt` TOML in, valid JSON + SVG out, with
        // the honest covariance figure of merit (and its explicit "not a certified protection
        // level" labelling) present.
        let out = run_toml(include_str!("../scenarios/mars-pnt-lmo.toml"))
            .expect("mars-pnt scenario dispatches");
        // Classifies to the new variant.
        assert_eq!(
            ScenarioKind::classify(include_str!("../scenarios/mars-pnt-lmo.toml")).unwrap(),
            ScenarioKind::MarsPnt
        );
        // Valid JSON carrying the FoM and the honesty note (covariance, not a certified PL).
        assert!(out.json.starts_with('{'));
        assert!(out.json.contains("\"scenario_hash\""));
        assert!(out.json.contains("converged_pos_rms_m"));
        assert!(out.json.contains("converged_pos_3sigma_m"));
        assert!(out
            .json
            .contains("NOT aviation-certified protection levels"));
        // Non-empty SVG and a one-line summary that names the kind and the FoM honestly.
        assert!(out.svg.starts_with("<svg"));
        assert!(out.summary.starts_with("mars-pnt "));
        assert!(out.summary.contains("not a certified PL"));
    }

    #[test]
    fn moonlight_service_volume_kind_round_trips_through_the_dispatch() {
        // The lunar service-volume kind dispatches end-to-end through the shared entry
        // point the CLI/Python/wasm/MCP bindings use, even with a bare kind line (every
        // field has a serde default): valid JSON + SVG out, with the honest
        // illustrative/MODELLED labelling present and the summary naming the kind.
        let src = "kind = \"moonlight-service-volume\"\n";
        assert_eq!(
            ScenarioKind::classify(src).unwrap(),
            ScenarioKind::LunarService
        );
        let out = run_toml(src).expect("moonlight-service-volume scenario dispatches");
        assert!(out.json.starts_with('{'));
        // Valid JSON that parses.
        let _: serde_json::Value = serde_json::from_str(&out.json).unwrap();
        // The honesty labels are carried in the JSON note.
        assert!(out.json.contains("not affiliated with ESA"));
        assert!(out.json.contains("MODELLED"));
        assert!(out.json.contains("coverage_pct"));
        // SVG and a one-line summary that names the kind.
        assert!(out.svg.starts_with("<svg"));
        assert!(out.summary.contains("moonlight-service-volume"));
    }

    #[test]
    fn lunar_jamming_kind_round_trips_through_the_dispatch_with_a_per_satellite_table() {
        // The lunar-native jamming kind dispatches end-to-end through the shared entry
        // point the CLI / Python / wasm / MCP bindings use, with a bare kind line (every
        // field is defaulted). What it must carry out is the per-satellite J/S table —
        // the thing a manuscript previously had to compose by hand from two separate
        // link-budget runs — plus the CSV artifact of the same table, the units block,
        // and the honesty labelling.
        let src = "kind = \"lunar-jamming\"\n[jammer]\npower_dbw = 10.0\nrange_m = 25000.0\n";
        assert_eq!(
            ScenarioKind::classify(src).unwrap(),
            ScenarioKind::LunarJamming
        );
        let out = run_toml(src).expect("lunar-jamming scenario dispatches");
        let v: serde_json::Value = serde_json::from_str(&out.json).unwrap();
        let links = v["links"].as_array().expect("a per-satellite link table");
        assert!(links.len() > 1, "a table, not a single collapsed number");
        for l in links {
            assert!(l["js_db"].is_number(), "every row carries its own J/S");
            assert!(l["sat"].is_number());
            assert!(l["cn0_effective_dbhz"].is_number());
        }
        // No central statistic stands in for the table: the aggregate lives beside it.
        assert!(v["fom"]["n_links"].as_u64().unwrap() as usize == links.len());
        assert!(v["units"]["links.js_db"]["unit"].as_str() == Some("dB"));
        assert!(v["units"]["links.js_db"]["provenance"].is_string());
        // …and the denial contour comes out through the same dispatch, carrying the
        // measured C/N0 distribution and a band, not one scalar. This source is the
        // documented mixed operating point (jammer 25 km, 10 dBW), so the band must
        // bracket it: that is the whole claim, checked on the surface a caller sees.
        let c = &v["denial_contour"];
        assert!(c["cn0_nominal"]["median_dbhz"].is_number());
        assert!(c["cn0_nominal"]["p05_dbhz"].is_number());
        assert!(c["cn0_nominal"]["p95_dbhz"].is_number());
        assert_eq!(
            c["cn0_nominal"]["n"].as_u64().unwrap() as usize,
            links.len(),
            "the distribution must be over the very rows the table carries"
        );
        assert_eq!(c["points"].as_array().unwrap().len(), 7);
        let b = &c["loss_of_lock"];
        let (lo, med, hi) = (
            b["standoff_p95_km"].as_f64().unwrap(),
            b["standoff_median_km"].as_f64().unwrap(),
            b["standoff_p05_km"].as_f64().unwrap(),
        );
        assert!(lo < 25.0 && 25.0 < hi, "the band must bracket 25 km");
        assert!(lo < med && med < hi);
        assert!(
            (hi - med - (med - lo)).abs() > 1e-6,
            "a band symmetric about the median contour would be a reparametrised sigma"
        );
        assert!(v["units"]["denial_contour.loss_of_lock.standoff_p05_km"]["unit"] == "km");
        assert!(v["units"]["denial_contour.cn0_nominal.stdev_dbhz"]["provenance"].is_string());
        // The table is also published as a CSV artifact, one row per link.
        let csv = out.csv.as_ref().expect("a CSV table artifact");
        assert_eq!(csv.lines().count(), links.len() + 1);
        assert!(out.svg.starts_with("<svg"));
        assert!(out.summary.contains("lunar-jamming"));
        assert!(out.json.contains("MODELLED"));
    }

    #[test]
    fn lunar_attack_surface_kind_round_trips_through_the_dispatch() {
        // The composed P1 attack-surface kind dispatches end-to-end through the shared
        // entry point the CLI/Python/wasm/MCP bindings use, with a bare kind line (every
        // field has a serde default reproducing the P1 baseline): valid JSON + SVG out,
        // carrying each composed sub-result, and a summary naming the kind. This is the
        // binary-reachability the P1 audit (G1/G2/G5/G6) requires — the analyses are no
        // longer library-only.
        let src = "kind = \"lunar-attack-surface\"\n";
        assert_eq!(
            ScenarioKind::classify(src).unwrap(),
            ScenarioKind::LunarAttackSurface
        );
        let out = run_toml(src).expect("lunar-attack-surface scenario dispatches");
        assert!(out.json.starts_with('{'));
        let v: serde_json::Value = serde_json::from_str(&out.json).unwrap();
        // Each composed analysis is present in the JSON.
        assert!(v.get("deficit_band_lo_db").is_some());
        assert!(v.get("standoff_curve").is_some());
        assert!(v.get("footprint_captured_fraction").is_some());
        assert!(v.get("spoof_captured").is_some());
        assert!(v.get("surface_transmitter_reach_m").is_some());
        assert!(v.get("nma_overhead_bps").is_some());
        // SVG and a one-line summary that names the kind.
        assert!(out.svg.starts_with("<svg"));
        assert!(out.summary.contains("lunar-attack-surface"));
    }

    #[test]
    fn lunar_differential_pnt_kind_round_trips_through_the_dispatch() {
        // The lunar differential-PNT kind dispatches end-to-end through the shared entry
        // point the CLI/Python/wasm/MCP bindings use, even with a bare kind line (every
        // field has a serde default): valid JSON + SVG out, with the honest
        // illustrative/MODELLED labelling present and the summary naming the kind.
        let src = "kind = \"lunar-differential-pnt\"\n";
        assert_eq!(
            ScenarioKind::classify(src).unwrap(),
            ScenarioKind::LunarDpnt
        );
        let out = run_toml(src).expect("lunar-differential-pnt scenario dispatches");
        assert!(out.json.starts_with('{'));
        // Valid JSON that parses.
        let _: serde_json::Value = serde_json::from_str(&out.json).unwrap();
        // The honesty labels are carried in the JSON note.
        assert!(out.json.contains("not affiliated with ESA"));
        assert!(out.json.contains("MODELLED"));
        assert!(out.json.contains("reduction_factor"));
        // SVG and a one-line summary that names the kind.
        assert!(out.svg.starts_with("<svg"));
        assert!(out.summary.contains("lunar-differential-pnt"));
    }

    #[test]
    fn lunar_interop_export_kind_round_trips_through_the_dispatch() {
        // The lunar interoperability-export kind dispatches end-to-end through the shared
        // entry point the CLI/Python/wasm/MCP bindings use, even with a bare kind line (every
        // field has a serde default): valid JSON + SVG out, naming the kind, with the lunar
        // REF_FRAME/TIME_SYSTEM and the MODELLED honesty label present.
        let src = "kind = \"lunar-interop-export\"\n";
        assert_eq!(
            ScenarioKind::classify(src).unwrap(),
            ScenarioKind::LunarInterop
        );
        let out = run_toml(src).expect("lunar-interop-export scenario dispatches");
        assert!(out.json.starts_with('{'));
        // Valid JSON that parses.
        let _: serde_json::Value = serde_json::from_str(&out.json).unwrap();
        // The lunar interchange tokens and honesty label are carried in the JSON.
        assert!(out.json.contains("MOON_ME"));
        assert!(out.json.contains("MODELLED"));
        assert!(out.json.contains("conformance"));
        // SVG and a one-line summary that names the kind.
        assert!(out.svg.starts_with("<svg"));
        assert!(out.summary.contains("lunar-interop-export"));
    }

    #[test]
    fn hybrid_ukf_kind_round_trips_through_the_dispatch() {
        // The 17-state hybrid quantum+classical UKF scenario dispatches end-to-end through the
        // shared entry point the CLI/Python/wasm/MCP bindings use: a `hybrid-ukf` TOML in, valid
        // JSON + SVG out, carrying the consistency oracle (NEES + innovation-whiteness) and the
        // explicit MODELLED / self-consistency-not-accuracy honesty labels.
        let src = include_str!("../scenarios/hybrid-ukf.toml");
        assert_eq!(
            ScenarioKind::classify(src).unwrap(),
            ScenarioKind::HybridUkf
        );
        let out = run_toml(src).expect("hybrid-ukf scenario dispatches");
        assert!(out.json.starts_with('{'));
        assert!(out.json.contains("\"scenario_hash\""));
        // The statistical oracle is present in the JSON…
        assert!(out.json.contains("nis_mean") && out.json.contains("nees_mean"));
        assert!(out.json.contains("nis_chi2_lower_95") && out.json.contains("nees_chi2_upper_95"));
        assert!(out.json.contains("\"consistent\""));
        // …and the honesty labelling is unmissable.
        assert!(out.json.contains("MODELLED SIMULATION"));
        assert!(out.json.contains("NOT a"));
        // Non-empty SVG and a one-line summary that names the kind and the honesty caveat.
        assert!(out.svg.starts_with("<svg"));
        assert!(out.summary.contains("hybrid-ukf"));
        assert!(out.summary.contains("MODELLED"));
        assert!(out.summary.contains("self-consistency, not accuracy"));
    }

    #[test]
    fn every_chart_carries_the_provenance_footer() {
        // A saved/downloaded chart must be self-identifying: every scenario kind's
        // SVG ends with the "Kshana v<ver> · <hash> · kshana.dev" footer, just
        // before the closing tag, so the image stands on its own.
        for src in [
            include_str!("../scenarios/clock-holdover.toml"),
            include_str!("../scenarios/imu-deadreckoning.toml"),
            include_str!("../scenarios/timetransfer.toml"),
            include_str!("../scenarios/hybrid-pnt.toml"),
            include_str!("../scenarios/gnss-ins.toml"),
            include_str!("../scenarios/integrity-raim.toml"),
            include_str!("../scenarios/jamming-demo.toml"),
            include_str!("../scenarios/spoof-attack.toml"),
            include_str!("../scenarios/spoof-detect.toml"),
            include_str!("../scenarios/sweep-clock-stability.toml"),
            include_str!("../scenarios/gnss-sim-raim.toml"),
            include_str!("../scenarios/orbit-gnss-challenged.toml"),
            include_str!("../scenarios/ephemeris.toml"),
        ] {
            let out = run_toml(src).expect("scenario runs");
            assert!(out.svg.contains("\u{00b7} kshana.dev"), "footer present");
            assert!(out.svg.contains("Kshana v"), "version stamped");
            assert!(
                out.svg.contains("\u{00b7} scenario "),
                "hash labelled as scenario"
            );
            // The footer is inside the chart, just before the closing tag.
            assert!(out.svg.trim_end().ends_with("</svg>"));
            let foot = out.svg.rfind("kshana.dev").unwrap();
            let close = out.svg.rfind("</svg>").unwrap();
            assert!(foot < close, "footer sits inside the svg");
            // Exactly one footer (no duplication from a per-chart + central stamp).
            assert_eq!(out.svg.matches("kshana.dev").count(), 1, "single footer");
        }
    }

    #[test]
    fn integrity_scenario_reports_an_availability_map() {
        let out = run_toml(include_str!("../scenarios/integrity-raim.toml"))
            .expect("integrity scenario runs");
        assert!(out.summary.contains("epochs available"));
        // JSON carries the per-epoch availability map and the alert limits.
        assert!(out.json.contains("samples_available"));
        assert!(out.json.contains("\"epochs\""));
        assert!(out.json.contains("hpl_m") && out.json.contains("vpl_m"));
        // The vertical Stanford integrity diagram is exported end-to-end, and the
        // summary reports its integrity-event / HMI counts.
        assert!(out.json.contains("\"stanford\"") && out.json.contains("alert_limit_m"));
        assert!(out.json.contains("region") && out.json.contains("error_m"));
        assert!(out.summary.contains("Stanford(V)") && out.summary.contains("HMI"));
        // The chart is a self-contained protection-level/availability SVG.
        assert!(out.svg.starts_with("<svg") && out.svg.contains("protection level"));
    }

    #[test]
    fn rinex_constellation_scenario_runs_end_to_end() {
        // A real RINEX 3 broadcast-ephemeris block drives an orbit scenario from
        // the same entry point the CLI/Python/wasm bindings use: RINEX in, PNT
        // geometry out.
        let out = run_toml(include_str!("../scenarios/orbit-rinex.toml"))
            .expect("rinex constellation scenario runs");
        assert!(out.summary.contains("GNSS-nominal"));
        assert!(out.json.contains("\"geometry\"") && out.json.contains("best_pdop"));
        assert!(out.svg.starts_with("<svg"));
    }

    #[test]
    fn invalid_scenario_is_an_error() {
        assert!(run_toml("kind = \"orbit\"\nnot_valid = true").is_err());
    }

    #[test]
    fn html_report_is_self_contained_and_escaped() {
        let out = run_toml(include_str!("../scenarios/clock-holdover.toml")).unwrap();
        let html = out.html_report();
        assert!(html.starts_with("<!doctype html>"));
        assert!(html.contains("<img alt=\"Result chart\" src=\"data:image/svg+xml,"));
        assert!(html.contains("Kshana"));
        assert!(html.trim_end().ends_with("</html>"));
        // The embedded JSON must be HTML-escaped (no raw quotes from the document).
        assert!(html.contains("&quot;"));
        // The chart is an inert data-URI image, not inline markup that could execute.
        assert!(!html.contains("<svg"));
        // The per-FoM validation-tier table renders, with the MODELLED tag inline
        // next to the holdover figure of merit (surfaced from the matrix).
        assert!(html.contains("Figures of merit"));
        assert!(html.contains("Holdover"));
        assert!(html.contains("MODELLED"));
        assert!(html.contains("Validation"));
    }

    #[test]
    fn html_report_is_byte_identical_without_meta() {
        // Back-compat: a result with no study metadata renders the EXACT current
        // title and footer strings (no new markup, no timestamp).
        let out = run_toml(include_str!("../scenarios/clock-holdover.toml")).unwrap();
        let html = out.html_report();
        assert!(html.contains("<title>Kshana \u{2014} scenario result</title>"));
        assert!(html.contains("Generated by Kshana "));
        // No generation-stamp line is emitted when meta is absent.
        assert!(!html.contains("Study generated"));
    }

    #[test]
    fn html_report_uses_study_title_and_generation_stamp_from_meta() {
        // When the embedded JSON carries study metadata, the title uses the study
        // title and the footer shows the caller-supplied UTC stamp.
        let base = run_toml(include_str!("../scenarios/clock-holdover.toml")).unwrap();
        // Inject a meta block into the result JSON the way the engine would, so the
        // report surfaces it without RunOutput growing a field or reading a clock.
        let json = base.json.replacen(
            "{\n",
            "{\n  \"meta\": {\n    \"study_title\": \"Holdover Study A\",\n    \"generated_utc\": \"2026-06-23T00:00:00Z\"\n  },\n",
            1,
        );
        let out = RunOutput {
            json,
            svg: base.svg.clone(),
            summary: base.summary.clone(),
            csv: None,
        };
        let html = out.html_report();
        // Study title drives the <title>; the default title string is gone.
        assert!(html.contains("<title>Holdover Study A \u{2014} Kshana</title>"));
        assert!(!html.contains("<title>Kshana \u{2014} scenario result</title>"));
        // The caller-supplied stamp surfaces in the footer.
        assert!(html.contains("Study generated 2026-06-23T00:00:00Z"));
        // Still self-contained and escaped.
        assert!(html.starts_with("<!doctype html>"));
        assert!(html.trim_end().ends_with("</html>"));
    }

    #[test]
    fn html_report_title_only_meta_keeps_default_footer() {
        // study_title without a generated_utc: custom title, but no stamp line.
        let base = run_toml(include_str!("../scenarios/clock-holdover.toml")).unwrap();
        let json = base.json.replacen(
            "{\n",
            "{\n  \"meta\": {\n    \"study_title\": \"Only A Title\"\n  },\n",
            1,
        );
        let out = RunOutput {
            json,
            svg: base.svg.clone(),
            summary: base.summary.clone(),
            csv: None,
        };
        let html = out.html_report();
        assert!(html.contains("<title>Only A Title \u{2014} Kshana</title>"));
        assert!(!html.contains("Study generated"));
    }

    #[test]
    fn with_study_meta_inserts_a_parseable_meta_block() {
        let base = run_toml(include_str!("../scenarios/clock-holdover.toml")).unwrap();
        let meta = crate::report::study_meta_with_title("My Study", "2026-06-23T00:00:00Z");
        let withed = with_study_meta(&base.json, &meta);
        // The result is still a single valid JSON object.
        let v: serde_json::Value = serde_json::from_str(&withed).unwrap();
        assert_eq!(v["meta"]["study_title"], "My Study");
        assert_eq!(v["meta"]["generated_utc"], "2026-06-23T00:00:00Z");
        // Every original top-level key survives unchanged.
        assert_eq!(
            v["scenario_hash"],
            serde_json::from_str::<serde_json::Value>(&base.json).unwrap()["scenario_hash"]
        );
        // And the report surfaces it (title + stamp), end to end.
        let out = RunOutput {
            json: withed,
            svg: base.svg.clone(),
            summary: base.summary.clone(),
            csv: None,
        };
        let html = out.html_report();
        assert!(html.contains("<title>My Study \u{2014} Kshana</title>"));
        assert!(html.contains("Study generated 2026-06-23T00:00:00Z"));
    }

    #[test]
    fn fom_tier_table_is_empty_for_a_no_clock_fom_result() {
        // A RAIM/integrity report has no clock-style FoM block, so the tier table is
        // omitted entirely (those reports stay unchanged).
        let out = run_toml(include_str!("../scenarios/integrity-raim.toml")).unwrap();
        assert!(fom_tier_table(&out.json).is_empty());
    }

    #[test]
    fn html_escape_handles_the_five_characters() {
        assert_eq!(
            html_escape("<a href=\"x\">&'</a>"),
            "&lt;a href=&quot;x&quot;&gt;&amp;&#39;&lt;/a&gt;"
        );
    }

    #[test]
    fn inject_eop_inlines_a_real_finals_body_and_still_runs() {
        // The `--eop <file>` path: a real IERS finals2000A row is folded into the
        // ephemeris scenario, the merged TOML re-parses with the field set, and the
        // run still succeeds — i.e. the data travels in the self-contained scenario.
        let row = "22 1 1 59580.00 I  0.054644 0.000026  0.276986 0.000032  I-0.1104988 0.0000023 -0.0267 0.0022  I     0.095    0.060    -0.250    0.299  0.054574  0.276983 -0.1105197     0.059    -0.259  ";
        let src = include_str!("../scenarios/ephemeris.toml");
        let merged = inject_eop(src, row).expect("inject");
        let scn: crate::ephemeris::EphemerisScenario =
            toml::from_str(&merged).expect("merged TOML re-parses");
        assert_eq!(scn.eop_finals2000a.as_deref(), Some(row));
        // Top-level key serialised ahead of any [section] header (valid TOML).
        run_toml(&merged).expect("scenario with inlined EOP runs");
        // A non-TOML input is a clean error, not a panic.
        assert!(inject_eop("=$ not toml", row).is_err());
    }

    #[test]
    fn aperture_duty_cycle_kind_round_trips_through_the_dispatch() {
        // The scheduling kind dispatches end-to-end through the shared entry point the
        // CLI/Python/wasm/MCP bindings all use, with a bare kind line (every field has a
        // serde default, including the bundled illustrative contact plan): valid JSON +
        // SVG out, the duty numbers and per-session outage present, and the arbitration
        // policy named in both the document and the summary — a duty figure quoted
        // without its policy is not reproducible.
        let src = "kind = \"aperture-duty-cycle\"\n";
        assert_eq!(
            ScenarioKind::classify(src).unwrap(),
            ScenarioKind::ApertureDutyCycle
        );
        let out = run_toml(src).expect("aperture-duty-cycle scenario dispatches");
        assert!(out.json.starts_with('{'));
        let v: serde_json::Value = serde_json::from_str(&out.json).unwrap();
        assert_eq!(v["kind"], "aperture-duty-cycle");
        assert!(v.get("navigation_duty").is_some());
        assert!(v.get("communications_duty").is_some());
        assert!(v["sessions"][0].get("outage_s").is_some());
        assert_eq!(v["arbitration_policy"], "navigation-priority");
        assert!(v["arbitration_policy_definition"].is_string());
        assert!(v["label"].as_str().unwrap().contains("MODELLED"));
        assert!(out.svg.starts_with("<svg"));
        assert!(out.summary.contains("aperture-duty-cycle"));
        assert!(out.summary.contains("navigation-priority"));
        // The bundled TOML file is the same run.
        let file = run_toml(include_str!("../scenarios/aperture-duty-cycle.toml"))
            .expect("the bundled contact plan runs");
        assert_eq!(file.json, out.json);
    }

    #[test]
    fn tracking_loop_kind_round_trips_through_the_dispatch() {
        // The tracking-loop kind dispatches end-to-end through the shared entry point the
        // CLI/Python/wasm/MCP bindings all use, from a bare kind line. The acceptance
        // contract travels with it: BOTH denial radii are present and their difference is
        // a named field, so the loop-dynamics answer is reported alongside the existing
        // power-ratio one rather than silently replacing it.
        let src = "kind = \"tracking-loop\"\n";
        assert_eq!(
            ScenarioKind::classify(src).unwrap(),
            ScenarioKind::TrackingLoop
        );
        let out = run_toml(src).expect("tracking-loop scenario dispatches");
        assert!(out.json.starts_with('{'));
        let v: serde_json::Value = serde_json::from_str(&out.json).unwrap();
        assert_eq!(v["kind"], "tracking-loop");
        let d = &v["denial"];
        assert!(d["denial_radius_threshold_km"].as_f64().is_some());
        assert!(d["denial_radius_loop_dynamics_km"].as_f64().is_some());
        assert!(d["denial_radius_delta_km"].as_f64().is_some());
        assert!(d["denial_radius_delta_definition"].is_string());
        assert!(v["thresholds"]["hysteresis_db"].as_f64().is_some());
        assert!(v["lock_history"]["declared_loss_of_lock_s"]
            .as_f64()
            .is_some());
        assert!(v["spoof_pull_in"]["max_code_slew_chips_per_s"]
            .as_f64()
            .is_some());
        assert!(v["units"]["denial.denial_radius_delta_km"]["provenance"].is_string());
        assert!(v["label"].as_str().unwrap().contains("MODELLED"));
        assert!(out.svg.starts_with("<svg"));
        assert!(out.summary.contains("tracking-loop"));
        // The bundled TOML file is the same run.
        let file = run_toml(include_str!("../scenarios/tracking-loop.toml"))
            .expect("the bundled tracking-loop scenario runs");
        assert_eq!(file.json, out.json);
    }

    /// The engine-flow diagram states how many figures of merit the engine scores. Pin
    /// that number to [`FOM_LABELS`], which is the set the report and the tier lookup
    /// both read, so the picture cannot drift from the engine.
    ///
    /// It already had. The diagram, its committed SVG and the README's inline copy all
    /// said "the 6 figures of merit" while `FoMScores` carried seven — `timing_p95_ns`
    /// was added and the figure was never revisited. Nothing caught it: every other
    /// diagram has a doc-sync guard, and these two (engine-flow, distribution) had none
    /// since June. A published figure that contradicts the code is worse than no figure,
    /// because a reader has no reason to doubt it.
    ///
    /// The SVG is checked separately from the prose because mermaid splits a label into
    /// one element per word — the count lands in its own `<tspan>`, so no phrase search
    /// over the rendered file can ever see it. That is precisely why this went unnoticed.
    #[test]
    fn the_engine_flow_diagram_states_the_live_figure_of_merit_count() {
        let n = FOM_LABELS.len();
        assert!(
            n >= 5,
            "FOM_LABELS collapsed to {n} — the guard would be vacuous"
        );

        let phrase = format!("vs the {n} figures of merit");
        for (name, body) in [
            (
                "docs/diagrams/engine-flow.mmd",
                include_str!("../docs/diagrams/engine-flow.mmd"),
            ),
            ("README.md", include_str!("../README.md")),
        ] {
            assert!(
                body.contains(&phrase),
                "{name} does not say {phrase:?}; the engine scores {n} figures of merit                  (FOM_LABELS). Fix the .mmd AND the README's inline mermaid block AND the                  committed SVG, then re-render the PNG with tools/render-diagram.sh — the                  README embeds the PNG, not the source."
            );
        }

        // The rendered SVG: mermaid emits the number as its own tspan, so look for the
        // digit as a standalone text node rather than inside the sentence.
        let svg = include_str!("../docs/assets/diagrams/engine-flow.svg");
        assert!(
            svg.contains("> figures<") && svg.contains("> merit<"),
            "the engine-flow SVG no longer splits the label word-per-word; this guard's              assumption about the rendering changed, so re-derive it rather than delete it"
        );
        assert!(
            svg.contains(&format!("> {n}</tspan>")),
            "the committed engine-flow SVG does not carry {n} as a standalone tspan — the              picture a reader sees still states the old count"
        );
    }
}