earthsci-ast 0.6.0

Rust implementation of EarthSciML Abstract Syntax Tree Format
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
//! Subsystem reference loading: cross-FILE `{ "ref": ... }` inlining.
//!
//! Not to be confused with [`crate::reference_resolution`], which builds the
//! intra-document node-id / index-set dependency DAG โ€” this module is the
//! load-time pass that splices other .esm files into this one.
//!
//! Implements ESM library spec section 2.1b: walks all `subsystems` maps in
//! models and reaction systems, and replaces any `{ "ref": "..." }` reference
//! object with the resolved content of the referenced ESM file. Local file
//! references are resolved relative to a base path and cycles are detected.
//!
//! HTTP(S) URL references are recognised but not fetched in the Rust loader,
//! since this crate does not depend on an HTTP client. Callers that need
//! remote refs should download the file first and rewrite the ref to a local
//! path. URL refs raise a clear error rather than being silently ignored.
//!
//! Resolution operates on raw [`serde_json::Value`] before typed coercion to
//! [`crate::EsmFile`], because the typed model intentionally does not store
//! the `subsystems` map (it would force every consumer of `Model` /
//! `ReactionSystem` to handle nested-by-default systems). Resolving at the
//! JSON layer means refs are inlined into the parsed value, and the typed
//! model only ever sees fully resolved input.

use crate::diagnostic::{DiagnosticError, codes, err};
use serde_json::{Map, Value};
use std::borrow::Cow;
use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};

/// Capture a document's closed metaparameter environment from its top-level
/// `metaparameters` block: each name's integer `default`, overlaid with the
/// loader-API `api_meta` bindings (esm-spec ยง9.7.6 sites 4-5). This is the
/// scope against which a ยง4.7 subsystem-edge binding EXPRESSION folds at the
/// mount (e.g. `NTGT = NX*NY`), captured BEFORE the root template-machinery
/// pass consumes the `metaparameters` block. Mirrors the Python `load_string()`
/// `root_meta_env`.
pub(crate) fn root_metaparameter_env(
    value: &Value,
    api_meta: &BTreeMap<String, i64>,
) -> BTreeMap<String, i64> {
    let mut env: BTreeMap<String, i64> = BTreeMap::new();
    if let Some(decls) = value.get("metaparameters").and_then(|v| v.as_object()) {
        for (name, decl) in decls {
            if let Some(d) = decl.get("default")
                && d.as_i64().is_some()
                && !d.is_boolean()
            {
                env.insert(name.clone(), d.as_i64().expect("checked integer default"));
            }
        }
    }
    for (k, v) in api_meta {
        env.insert(k.clone(), *v);
    }
    env
}

/// Resolve all subsystem references in a parsed JSON value representing an
/// ESM file.
///
/// Walks every `subsystems` map in models and reaction systems and inlines
/// the referenced content. Resolution is recursive (referenced files may
/// contain their own refs) and circular references are detected.
///
/// A referenced subsystem file's top-level `index_sets` merge into the
/// importing document's registry (esm-spec ยง4.7, mirroring the ยง9.7.5
/// template-import merge): deep-equal redeclaration is idempotent, an absent
/// name is added, and a non-deep-equal collision is `subsystem_index_set_conflict`.
/// The merge is scoped to MODEL mounts, matching the Julia resolver (a mounted
/// mesh file whose axis size disagrees with the importer must fail at load
/// rather than the importer silently winning) โ€” and it applies to BOTH model
/// mount forms: a `subsystems.<k>` ref and a top-level `models.<k>` ref
/// ([`inline_toplevel_model_refs`]) merge identically.
///
/// # Arguments
///
/// * `value` - the parsed ESM JSON to resolve (modified in place)
/// * `base_path` - directory to resolve relative file paths against
///
/// This is the raw-`serde_json::Value` extension seam (API_SPEC.md ยง8 item
/// 14): it is what the LOADER runs, before the document is coerced to an
/// [`crate::EsmFile`]. A caller holding a typed document wants
/// [`resolve_subsystem_refs`].
pub fn resolve_subsystem_refs_raw(
    value: &mut Value,
    base_path: &Path,
) -> Result<(), DiagnosticError> {
    let staged = resolve_subsystem_refs_with_metaparameters(value, base_path, &BTreeMap::new())?;
    // This seam has no ยง9.7.6 close of its own to defer past โ€” it IS just ref
    // resolution โ€” so the staged ยง4.7 contributions land immediately.
    apply_staged_index_sets(value, staged, &BTreeMap::new())
}

/// Merge the ยง4.7 contributions a deferred root walk STAGED into the mounting
/// document's registry (esm-spec ยง4.7 "Index-set merge").
///
/// The other half of the inline/merge split: `walk_top_level` spliced the
/// referenced content in early, so the root's own ยง9.7 machinery can lower
/// through it, and held the `index_sets` back until the mounting document had
/// closed. This is where they arrive, compared deep-equal against a registry
/// whose own sizes the close has already folded.
pub(crate) fn apply_staged_index_sets(
    value: &mut Value,
    staged: Map<String, Value>,
    env: &BTreeMap<String, i64>,
) -> Result<(), DiagnosticError> {
    if staged.is_empty() {
        return Ok(());
    }
    let Some(obj) = value.as_object_mut() else {
        return Ok(());
    };
    let mut registry: Map<String, Value> = obj
        .get("index_sets")
        .and_then(|v| v.as_object())
        .cloned()
        .unwrap_or_default();
    merge_subsystem_index_sets(&mut registry, &staged, "a ยง4.7 mount", env)?;
    obj.insert("index_sets".to_string(), Value::Object(registry));
    Ok(())
}

/// Resolve every subsystem `{ "ref": ... }` in a TYPED document, in place.
///
/// The canonical entry point (API_SPEC.md ยง8 item 14): Julia
/// (`resolve_subsystem_refs!`), Python and TypeScript all take a typed
/// document and mutate it; this binding alone took an untyped
/// `serde_json::Value`. Implemented over [`resolve_subsystem_refs_raw`] โ€”
/// `file` is rendered to JSON, resolved, and read back โ€” so the two agree by
/// construction.
///
/// # Arguments
///
/// * `file` - the document to resolve (modified in place)
/// * `base_path` - directory to resolve relative file paths against
pub fn resolve_subsystem_refs(
    file: &mut crate::EsmFile,
    base_path: &Path,
) -> Result<(), DiagnosticError> {
    let mut value = serde_json::to_value(&*file).map_err(|e| {
        err(
            codes::UNRESOLVED_SUBSYSTEM_REF,
            format!("could not render the document as JSON for ref resolution: {e}"),
        )
    })?;
    resolve_subsystem_refs_raw(&mut value, base_path)?;
    *file = serde_json::from_value(value).map_err(|e| {
        err(
            codes::UNRESOLVED_SUBSYSTEM_REF,
            format!("resolved document did not deserialize back into an EsmFile: {e}"),
        )
    })?;
    Ok(())
}

/// Like [`resolve_subsystem_refs_raw`], but with the loader-API metaparameter
/// bindings (esm-spec ยง9.7.6 site 4) available, so a ยง4.7 subsystem-edge
/// binding EXPRESSION (`NTGT = NX*NY`, ยง9.7.6 site 3) folds against the root
/// document's closed metaparameter environment (defaults overlaid with the API
/// bindings) at the mount. Mirrors the Python `resolve_model_refs(...,
/// parent_metaparameters=root_meta_env)`.
pub fn resolve_subsystem_refs_with_metaparameters(
    value: &mut Value,
    base_path: &Path,
    api_meta: &BTreeMap<String, i64>,
) -> Result<Map<String, Value>, DiagnosticError> {
    let root_meta = root_metaparameter_env(value, api_meta);
    let mut visited = HashSet::new();
    walk_top_level(
        value,
        base_path,
        &mut visited,
        &root_meta,
        api_meta,
        true,
        &root_meta,
        DEFAULT_ELEMENT_TYPE,
    )
}

/// A one-line rendering of an index-set declaration for the collision message
/// โ€” `kind=interval, size=59` โ€” so the diagnostic names both definitions rather
/// than only the axis they disagree about. Mirrors the Julia `_index_set_show`.
fn show_index_set(decl: &Value) -> String {
    let Some(obj) = decl.as_object() else {
        return decl.to_string();
    };
    let parts: Vec<String> = [
        "kind", "size", "members", "of", "offsets", "values", "from_faq",
    ]
    .iter()
    .filter_map(|k| obj.get(*k).map(|v| format!("{k}={v}")))
    .collect();
    if parts.is_empty() {
        decl.to_string()
    } else {
        parts.join(", ")
    }
}

/// Merge a referenced subsystem file's top-level `index_sets` into the
/// importing document's `registry` (esm-spec ยง4.7). Deep-equal redeclaration is
/// idempotent; a non-deep-equal collision is `subsystem_index_set_conflict`.
/// (`serde_json` `Map` equality is order-independent structural equality, the
/// JSON-level analogue of the Julia typed field-wise deep-equal.)
fn merge_subsystem_index_sets(
    registry: &mut Map<String, Value>,
    loaded: &Map<String, Value>,
    ref_str: &str,
    env: &BTreeMap<String, i64>,
) -> Result<(), DiagnosticError> {
    for (n, raw) in loaded {
        // esm-spec ยง4.7 "Index-set merge": fold the incoming contribution
        // against the MOUNTING document's closed environment before comparing.
        // Two IDENTICAL declarations, one folded and one not, are otherwise a
        // `subsystem_index_set_conflict` โ€” issue #198.
        let owned = crate::template_imports::fold_mount_contribution(raw, env);
        let decl = &owned;
        if let Some(existing) = registry.get(n) {
            if existing != decl {
                return Err(err(
                    codes::SUBSYSTEM_INDEX_SET_CONFLICT,
                    format!(
                        "index set '{n}' from subsystem ref '{ref_str}' ({}) collides with a \
                         non-deep-equal declaration already in the importing document's registry \
                         ({}) โ€” contributed by the document's own `index_sets` or by an earlier \
                         mount. A referenced subsystem file's top-level index_sets merge into the \
                         importing document's registry; deep-equal redeclaration is idempotent, a \
                         size/kind disagreement is a load-time error (esm-spec ยง4.7). If the two \
                         are genuinely different axes that happen to share a name, rename one at \
                         its mount edge with `index_set_rename` (esm-spec ยง4.7 \"Mount-edge \
                         index-set renaming\"), e.g. \
                         {{\"ref\": \"{ref_str}\", \"index_set_rename\": {{\"{n}\": \"{n}_2\"}}}}.",
                        show_index_set(decl),
                        show_index_set(existing),
                    ),
                ));
            }
        } else {
            registry.insert(n.clone(), decl.clone());
        }
    }
    Ok(())
}

/// Wording for [`load_ref_document`]'s diagnostics, so each call site keeps its
/// original error strings while sharing the load sequence.
struct RefNoun {
    /// Plural noun for the remote-URL rejection (e.g. `"subsystem refs"`).
    remote_plural: &'static str,
    /// Singular noun for the cycle message (e.g. `"subsystem reference"`).
    cycle_singular: &'static str,
    /// Item noun for the resolve / read / parse messages (e.g. `"ref"`).
    item: &'static str,
    /// Stable diagnostic code for this ref-kind's load/shape failures.
    code: &'static str,
}

/// A subsystem `{ "ref": ... }` edge (used by [`resolve_value`]).
///
/// `code` is the settled cross-binding name for a subsystem ref that cannot be
/// resolved (a missing file, a remote URL, a cycle, an unreadable document); the
/// distinct "resolved, but the file does not contain exactly one system" case is
/// [`AMBIGUOUS_SUBSYSTEM_REF`]. Both names are pinned by
/// `tests/invalid/expected_errors.json`.
const SUBSYSTEM_REF: RefNoun = RefNoun {
    remote_plural: "subsystem refs",
    cycle_singular: "subsystem reference",
    item: "ref",
    code: codes::UNRESOLVED_SUBSYSTEM_REF,
};

/// A subsystem ref that resolved to a file containing MULTIPLE (or zero)
/// top-level systems โ€” the ref mechanism requires exactly one, so which system
/// to mount is ambiguous. Distinct from a ref that could not be resolved at all.
const AMBIGUOUS_SUBSYSTEM_REF: &str = codes::AMBIGUOUS_SUBSYSTEM_REF;

/// A top-level `models.<k>` mount edge (used by [`inline_toplevel_model_refs`]).
const TOPLEVEL_MODEL_REF: RefNoun = RefNoun {
    remote_plural: "top-level model refs",
    cycle_singular: "top-level model reference",
    item: "top-level model ref",
    code: codes::TOPLEVEL_MODEL_REF_UNRESOLVED,
};

/// The shared "load a referenced .esm document" sequence both ref-resolvers run:
/// reject a remote URL, canonicalize `ref_str` against `base`, guard against a
/// cycle, mark the path visited, then read + parse it. Returns the canonical
/// path (still marked visited) and the parsed JSON.
///
/// On success the CALLER owns the visited mark: it recurses into the loaded
/// document (so nested refs see this path on the stack) and must
/// `visited.remove(&canonical)` when done โ€” mirroring the manual insert/remove
/// this used to duplicate at both sites. On any failure here the mark is not
/// left dangling (it is removed before returning a read/parse error, and is
/// never inserted on the URL / canonicalize / cycle errors).
fn load_ref_document(
    ref_str: &str,
    base: &Path,
    visited: &mut HashSet<PathBuf>,
    noun: &RefNoun,
) -> Result<(PathBuf, Value), DiagnosticError> {
    // esm-spec ยง4.7: `${VAR}` expands from the environment BEFORE the ref is
    // classified, so a variable holding a URL is recognised as the remote ref
    // it expands to rather than joined onto `base` as a path segment.
    let expanded = expand_env_refs(ref_str);
    let ref_str: &str = &expanded;
    if ref_str.starts_with("http://") || ref_str.starts_with("https://") {
        return Err(err(
            noun.code,
            format!(
                "Remote {} are not supported in the Rust loader; \
                 download {ref_str:?} to a local file first",
                noun.remote_plural
            ),
        ));
    }

    let canonical = base.join(ref_str).canonicalize().map_err(|e| {
        err(
            noun.code,
            format!("failed to resolve {} {ref_str:?}: {e}", noun.item),
        )
    })?;

    if visited.contains(&canonical) {
        return Err(err(
            noun.code,
            format!(
                "circular {} detected: {}",
                noun.cycle_singular,
                canonical.display()
            ),
        ));
    }
    visited.insert(canonical.clone());

    let loaded = (|| -> Result<Value, DiagnosticError> {
        let content = std::fs::read_to_string(&canonical).map_err(|e| {
            err(
                noun.code,
                format!(
                    "failed to read {} {}: {}",
                    noun.item,
                    canonical.display(),
                    e
                ),
            )
        })?;
        let mut parsed: Value = serde_json::from_str(&content).map_err(|e| {
            err(
                noun.code,
                format!(
                    "failed to parse {} {}: {}",
                    noun.item,
                    canonical.display(),
                    e
                ),
            )
        })?;
        // A REFERENCED document is a document: it gets the same wire-boundary
        // treatment as the root (removed-op rejection, the `faq` version gate,
        // and `aggregate` -> `faq` normalization). Without this the alias and
        // `arrayop` both survive a `{ref}` all the way into `emit`
        // (docs/content/rfcs/faq-node-rename.md ยง5.2).
        crate::parse::prepare_document_ops(&mut parsed)
            .map_err(|e| err(noun.code, format!("{}: {e}", canonical.display())))?;
        Ok(parsed)
    })();

    match loaded {
        Ok(parsed) => Ok((canonical, parsed)),
        Err(e) => {
            // Read/parse failed after we marked it visited โ€” un-mark so the set
            // is not left with a dangling entry.
            visited.remove(&canonical);
            Err(e)
        }
    }
}

/// The index-set names a document's top-level registry holds right now.
fn index_set_names(value: &Value) -> std::collections::BTreeSet<String> {
    value
        .get("index_sets")
        .and_then(|v| v.as_object())
        .map(|o| o.keys().cloned().collect())
        .unwrap_or_default()
}

/// Refuse a top-level `reaction_systems.<k>` `{ref}` mount with
/// `mount_form_unsupported`, naming the entry. The conformance producer
/// reports it at `/reaction_systems/<k>` (see `collect_subsystem_ref_errors`).
fn refuse_toplevel_reaction_system_refs(obj: &Map<String, Value>) -> Result<(), DiagnosticError> {
    let Some(rs) = obj.get("reaction_systems").and_then(|v| v.as_object()) else {
        return Ok(());
    };
    for (name, entry) in rs {
        let Some(entry) = entry.as_object() else {
            continue;
        };
        if let Some(ref_str) = entry.get("ref").and_then(|v| v.as_str())
            && !entry.contains_key("species")
        {
            return Err(err(
                codes::MOUNT_FORM_UNSUPPORTED,
                format!(
                    "reaction_systems.{name}: a top-level `reaction_systems.<k>` `{{ref}}` mount \
                     (ref '{ref_str}') is not supported by this binding. Inline the reaction \
                     system, or mount it at a `subsystems.<k>` `{{ref}}` edge (esm-spec ยง4.7 \
                     \"Two mount forms, one mechanism\")"
                ),
            ));
        }
    }
    Ok(())
}

#[allow(clippy::too_many_arguments)]
fn walk_top_level(
    value: &mut Value,
    base_path: &Path,
    visited: &mut HashSet<PathBuf>,
    parent_meta: &BTreeMap<String, i64>,
    api_meta: &BTreeMap<String, i64>,
    defer: bool,
    caller_env: &BTreeMap<String, i64>,
    enclosing_precision: &str,
) -> Result<Map<String, Value>, DiagnosticError> {
    // esm-spec ยง4.7 "Working precision crosses a mount": THIS document's
    // effective precision is its own `domain.element_type`, else the one it
    // inherits from the document mounting it (`"Float64"` at a root). Every
    // edge this document carries compares its leaf's declaration against it.
    let precision = effective_element_type(value, enclosing_precision);
    // esm-spec ยง4.7 "Which environment a contribution folds against": the
    // registry a contribution lands in here belongs to THIS document, so the
    // fold environment is this document's own CLOSED one โ€” not the caller's.
    //
    // Closed means the edge `bindings` that closed this document are part of it,
    // overlaid on its own `default`s: ยง9.7.6 site 3 says an explicit edge binding
    // wins over the referenced document's default. `caller_env` carries them in.
    // Filtered to the names this document DECLARES, which is the same filter
    // site 4 applies to the backfill โ€” an outer metaparameter this document
    // never declared must not size an axis here. At depth zero the overlay is a
    // no-op: `caller_env` IS this environment there.
    //
    // Without it one resolved document disagrees with ITSELF: an axis the
    // document declares goes through `resolve_template_machinery` with the edge
    // bindings and folds to 7, while an axis contributed by its own nested mount
    // folded against the unoverlaid defaults and came out 4.
    let merge_env = {
        let mut env = root_metaparameter_env(value, api_meta);
        let declared: Vec<String> = value
            .get("metaparameters")
            .and_then(|v| v.as_object())
            .map(|o| o.keys().cloned().collect())
            .unwrap_or_default();
        for (k, v) in caller_env {
            if declared.contains(k) {
                env.insert(k.clone(), *v);
            }
        }
        env
    };
    let obj = match value.as_object_mut() {
        Some(o) => o,
        None => return Ok(Map::new()),
    };

    // esm-spec ยง4.7: a top-level `reaction_systems.<k>` entry that is a bare
    // `{ref}` โ€” a `ref` string and no `species`, the Julia reference's
    // discriminator for this form โ€” is a mount edge at a form this binding does
    // not implement. Refuse it here, before the typed parse would reject the
    // stub with an incidental `missing field species`. An inline reaction system
    // is a component, not a mount, and passes untouched.
    refuse_toplevel_reaction_system_refs(obj)?;

    // esm-spec ยง4.7 / ยง9.7.10: a top-level `models.<k>` that is a bare `{ref}`
    // (has `ref`, no inline `variables`) is a model-ref MOUNT EDGE โ€” splice in
    // the referenced leaf's single model, carrying the edge's
    // `expression_template_imports` into that model's own scope, BEFORE the
    // ordinary subsystem walk (the spliced model may itself carry subsystems).
    // The leaf resolves in its OWN scope at the edge โ€” the ยง4.7 edge pipeline,
    // the same one a `subsystems.<k>` ref runs โ€” so BOTH environments are
    // threaded in, and they are NOT interchangeable: edge `bindings` fold
    // against `parent_meta` (the mounting document's full closed environment),
    // while only `api_meta` โ€” the loader-API bindings, ยง9.7.6 site 4 โ€”
    // backfills the leaf's own close. See `inline_toplevel_model_refs`.
    // `defer` splits INLINING from MERGING (esm-spec ยง4.7). The content must be
    // spliced in HERE, before the root's own ยง9.7 machinery, so a rewrite target
    // inside a mounted component lowers at all; but the `index_sets` it
    // contributes must not join the registry until the mounting document has
    // CLOSED (ยง9.7.6 site 3: "subsystem refs resolve post-close"). At the root
    // the contributions are therefore collected and handed back for
    // `parse::load_value` to merge after the close; nested scopes have already
    // closed by the time they get here and merge in place.
    let mut staged: Map<String, Value> = Map::new();
    inline_toplevel_model_refs(
        obj,
        base_path,
        visited,
        parent_meta,
        api_meta,
        if defer { Some(&mut staged) } else { None },
        &merge_env,
        &precision,
    )?;

    // The importing document's index-set registry starts from its own
    // top-level `index_sets` โ€” already carrying whatever the top-level model
    // mounts above merged in, since they run first โ€” and model subsystem refs
    // merge theirs into it (esm-spec ยง4.7). Reaction-system subsystem refs do
    // NOT merge (Julia resolver scope), so they thread `None`.
    // When deferring, the registry starts EMPTY and the document's own
    // `index_sets` are left alone: they are the mounting document's to close,
    // and the contributions are compared against them only afterwards.
    let mut registry: Map<String, Value> = if defer {
        staged
    } else {
        obj.get("index_sets")
            .and_then(|v| v.as_object())
            .cloned()
            .unwrap_or_default()
    };

    if let Some(map) = obj.get_mut("models").and_then(|v| v.as_object_mut()) {
        for (_name, system) in map.iter_mut() {
            walk_subsystems(
                system,
                base_path,
                visited,
                Some(&mut registry),
                parent_meta,
                api_meta,
                &merge_env,
                &precision,
            )?;
        }
    }
    if let Some(map) = obj
        .get_mut("reaction_systems")
        .and_then(|v| v.as_object_mut())
    {
        for (_name, system) in map.iter_mut() {
            walk_subsystems(
                system,
                base_path,
                visited,
                None,
                parent_meta,
                api_meta,
                &merge_env,
                &precision,
            )?;
        }
    }

    // Write the merged registry back so the merged axes are visible post-load
    // (and, for a file loaded as a subsystem ref, so the importer can read this
    // file's complete index_sets to merge in turn).
    if defer {
        return Ok(registry);
    }
    if !registry.is_empty() {
        obj.insert("index_sets".to_string(), Value::Object(registry));
    }
    Ok(Map::new())
}

/// Inline every top-level `models.<k>` MOUNT EDGE โ€” a bare `{ref}` (has `ref`,
/// no inline `variables`) โ€” by resolving the referenced leaf IN ITS OWN SCOPE
/// and splicing in its single model.
///
/// This runs the SAME normative edge pipeline (esm-spec ยง4.7 "Edge pipeline")
/// that [`resolve_value`] runs at a `subsystems.<k>` edge, because "Two mount
/// forms, one mechanism" forbids the two attachment points from differing:
///
/// 1. the referenced document resolves in its OWN scope โ€” its own nested refs,
///    of EITHER form, through [`walk_top_level`] in the leaf's directory
///    FIRST (esm-spec ยง4.7 "before validation or any other processing", so the
///    fixpoint below sees the components the leaf mounts โ€” issue #311), then
///    the library gates, this edge's `bindings` and ยง9.7.10 form-A injection,
///    its metaparameter close and fold, the ยง9.6.3 fixpoint;
/// 2. this edge's `index_set_rename` applies to that resolved document, over
///    the axes the leaf itself declares and imports only โ€” an axis that
///    arrived through one of those nested mounts is renamed at ITS edge;
/// 3. the renamed `index_sets` merge into THIS document's registry under the
///    deep-equal-or-`subsystem_index_set_conflict` rule, and the component
///    splices in.
///
/// The nested walk shares this walk's path-scoped cycle set, so a self- or
/// mutually-importing document is still `circular โ€ฆ` rather than unbounded
/// recursion, and the merge composes transitively. On top of the
/// shared pipeline this form additionally merges the leaf's `function_tables` /
/// `data_sources` up (parent wins on a key clash) and drops the leaf's
/// inline `tests` (ยง6.6: they do not cross a mount edge).
///
/// `parent_meta` is the MOUNTING document's closed metaparameter environment
/// (its `default`s overlaid with the loader-API bindings, ยง9.7.6 sites 4-5).
/// Edge `bindings` expressions fold against it, and โ€” mirroring the Python
/// `_load_ref_data` backfill โ€” the entries naming a metaparameter the LEAF
/// declares also seed the leaf's close, so a leaf mounted with no explicit edge
/// `bindings` still resolves under the assembler's grid (`NX`/`NY`) instead of
/// falling back to its own defaults. Explicit edge `bindings` win; names the
/// leaf does not declare are never forwarded, so they cannot raise
/// `template_import_unknown_name` against the leaf.
#[allow(clippy::too_many_arguments)]
fn inline_toplevel_model_refs(
    obj: &mut Map<String, Value>,
    base_path: &Path,
    visited: &mut HashSet<PathBuf>,
    parent_meta: &BTreeMap<String, i64>,
    api_meta: &BTreeMap<String, i64>,
    mut staged: Option<&mut Map<String, Value>>,
    merge_env: &BTreeMap<String, i64>,
    precision: &str,
) -> Result<(), DiagnosticError> {
    let edge_names: Vec<String> = match obj.get("models").and_then(|v| v.as_object()) {
        Some(models) => models
            .iter()
            .filter(|(_, m)| {
                m.is_object() && m.get("ref").is_some() && m.get("variables").is_none()
            })
            .map(|(k, _)| k.clone())
            .collect(),
        None => return Ok(()),
    };
    if edge_names.is_empty() {
        return Ok(());
    }

    for name in edge_names {
        let entry = obj
            .get_mut("models")
            .and_then(|v| v.as_object_mut())
            .and_then(|m| m.remove(&name))
            .expect("edge entry present");
        let entry_obj = entry.as_object().expect("edge entry is an object");
        let ref_str = entry_obj
            .get("ref")
            .and_then(|v| v.as_str())
            .ok_or_else(|| {
                err(
                    TOPLEVEL_MODEL_REF.code,
                    "top-level model ref must be a string",
                )
            })?;
        // `read_edge_bindings` is shared with the `subsystems.<k>` edge, so it
        // is told which mount form it is reporting for.
        let mount_noun = format!("top-level model ref '{ref_str}'");
        let (canonical, mut comp) =
            load_ref_document(ref_str, base_path, visited, &TOPLEVEL_MODEL_REF)?;
        // Read before anything below can consume the leaf's `domain`.
        let leaf_precision = declared_element_type(&comp);
        let leaf_dir = canonical.parent().unwrap_or(base_path).to_path_buf();

        // `comp` stays owned here; the closure only borrows it (so the visited
        // mark can be removed and `comp`'s by-name blocks merged below on every
        // exit path), and yields the spliced model.
        let build: Result<Value, DiagnosticError> = (|| {
            // esm-spec ยง4.7 "Edge pipeline" step (1): the referenced document
            // resolves in its OWN scope. Identical to the `subsystems.<k>` edge
            // in `resolve_value` โ€” the two mount forms are one mechanism, so a
            // binding MUST NOT make them differ.
            crate::lower_expression_templates::reject_expression_templates_pre_v04(&comp)?;
            crate::template_imports::reject_template_imports_pre_v08(&comp)?;
            if crate::template_imports::is_template_library_doc(&comp) {
                return Err(err(
                    codes::SUBSYSTEM_REF_IS_TEMPLATE_LIBRARY,
                    format!(
                        "Model ref '{ref_str}' targets a template-library file ({}); \
                         libraries are imported via expression_template_imports \
                         (esm-spec 9.7.1)",
                        canonical.display()
                    ),
                ));
            }
            if crate::coupling_imports::is_coupling_library_doc(&comp) {
                return Err(err(
                    codes::SUBSYSTEM_REF_IS_COUPLING_LIBRARY,
                    format!(
                        "Model ref '{ref_str}' targets a coupling-library file ({}); \
                         libraries are imported via a coupling_import coupling entry \
                         (esm-spec 10.9)",
                        canonical.display()
                    ),
                ));
            }

            // ยง9.7.6 binding site 3: close the leaf's metaparameters. Explicit
            // edge `bindings` win, backfilled by the LOADER-API bindings (site
            // 4) for the names the leaf DECLARES โ€” so a leaf mounted with no
            // edge bindings still inherits the loader's grid instead of falling
            // to its own defaults. Names the leaf does not declare are never
            // forwarded, so they cannot raise `template_import_unknown_name`
            // against it. Mirrors the Python `_load_ref_data` backfill.
            //
            // The backfill reads `api_meta`, NOT `parent_meta`. `parent_meta`
            // additionally carries the mounting document's OWN declared
            // defaults, and forwarding those would let an assembler's unrelated
            // `NLEV` silently override the leaf's own default for a name the
            // edge never bound โ€” a different answer from the one the SAME leaf
            // gets at a `subsystems.<k>` mount, which is exactly what ยง4.7
            // forbids. `parent_meta` is the scope edge `bindings` EXPRESSIONS
            // fold against, and that is all it is.
            let leaf_declared: Vec<String> = comp
                .get("metaparameters")
                .and_then(|v| v.as_object())
                .map(|o| o.keys().cloned().collect())
                .unwrap_or_default();
            let mut bindings: BTreeMap<String, i64> = api_meta
                .iter()
                .filter(|(k, _)| leaf_declared.contains(k))
                .map(|(k, v)| (k.clone(), *v))
                .collect();
            bindings.extend(read_edge_bindings(entry_obj, parent_meta, &mount_noun)?);

            // ยง4.7: a `{ref}` resolves "before validation or any other
            // processing", so the leaf's OWN nested refs โ€” of either mount form,
            // in the leaf's directory โ€” are inlined HERE, before the ยง9.6.3
            // fixpoint below. A leaf's template scope reaches the components it
            // mounts (a rule declared in its own `expression_template_imports`
            // lowers a rewrite-target inside its `subsystems.<k>`), and ยง9.7.10
            // defines the edge injection as "as if the target had added those
            // entries to the end of its own `expression_template_imports`" โ€” so
            // the fixpoint must see the mounted content for the two to agree.
            // Resolving them after it left a nested rewrite-target unlowerable
            // from any mount edge (issue #311).
            //
            // Nested edge `bindings` fold against the leaf's own closed
            // metaparameter environment โ€” its declared defaults overlaid with
            // this edge's close โ€” which is exactly what a STANDALONE load of the
            // leaf hands them (`root_metaparameter_env` in `load_with_meta`).
            let leaf_env = root_metaparameter_env(&comp, &bindings);
            //
            // `defer: true` is load-bearing, and it is the SAME split #317 made
            // at the root: the content splices in early so the fixpoint can
            // lower through it, but the `index_sets` it contributes are STAGED,
            // not merged, because THIS leaf has not closed yet. They land in
            // `apply_staged_index_sets` after the close below.
            let staged_nested =
                // `caller_env` is this edge's `bindings` โ€” the overlay that makes the
                // leaf's fold environment CLOSED rather than just its defaults
                // (see `walk_top_level`). `parent_meta` stays the leaf's full
                // environment: this walk runs BEFORE the leaf's close, so a nested
                // edge's binding EXPRESSIONS still name the leaf's metaparameters.
                walk_top_level(
                    &mut comp, &leaf_dir, visited, &leaf_env, api_meta, true, &bindings,
                    precision,
                )?;

            // ยง9.7.10 form A: the edge's `expression_template_imports` inject a
            // discretization into the leaf's own scope BEFORE resolution, so the
            // ยง9.6.3 fixpoint lowers its rewrite-targets at the mount. Their own
            // relative refs are authored by the assembler carrying the edge, so
            // they absolutize against THIS document's directory, not the leaf's.
            let injected: Vec<Value> = entry_obj
                .get("expression_template_imports")
                .and_then(|v| v.as_array())
                .map(|a| {
                    let mut a = a.clone();
                    for e in a.iter_mut() {
                        absolutize_nested_refs(e, base_path);
                    }
                    a
                })
                .unwrap_or_default();
            crate::template_imports::apply_scope_injections(&mut comp, &injected)?;

            // This IS a ยง4.7 mount edge, so the leaf resolves under the two
            // mount-scope facts a root document does not have:
            //
            // * `mount_declared` โ€” what the leaf's OWN nested mounts declare โ€”
            //   so the ยง9.7.6 site-4 widening composes down the reference DAG;
            // * `mounted_leaf` โ€” the leaf is NOT the last scope that can close a
            //   name. Its `index_sets` merge into THIS document's registry below
            //   and close there (ยง9.7.6 site 5), so an axis sized by a name only
            //   the assembler declares stays symbolic rather than failing here.
            let leaf_mount_declared =
                crate::template_imports::collect_mount_declared_metaparameters(&comp, &leaf_dir);
            let mut resolved = crate::template_imports::resolve_template_machinery_scoped(
                &comp,
                &leaf_dir,
                &bindings,
                &leaf_mount_declared,
                true,
            )?
            .unwrap_or_else(|| std::mem::take(&mut comp));
            // A mounted component is a self-contained build boundary: lower
            // under Option B, then `expand`, so the spliced component carries
            // the fully-expanded Option-A image and the assembling document's
            // lowering never resolves the leaf's template names against its
            // own registry. A leaf with no ยง9.7 machinery still expands here:
            // its own calls bind their parameters, so an `enum` op a parameter
            // spells resolves against the leaf's block below (esm-spec ยง9.3).
            crate::lower_expression_templates::lower_expression_templates(&mut resolved)?;
            crate::lower_expression_templates::expand(&mut resolved)?;
            comp = resolved;

            // esm-spec ยง9.3: the leaf's `enum` ops resolve against ITS OWN
            // `enums` block, here, while that block is still at hand. The
            // mounting document's block is a different one and `enums` do not
            // merge across a mount, so an importer declaring an enum of the same
            // name cannot change what the leaf computes. The leaf's own nested
            // mounts were lowered at their own edges above.
            lower_mounted_enums_at_edge(&mut comp, &mount_noun)?;

            // The leaf has now CLOSED, so the contributions its own nested
            // mounts staged can land โ€” folded against the leaf's own closed
            // environment and compared deep-equal, the ยง4.7 merge rule run at
            // the leaf rather than at the importer. A nested contribution that
            // is deep-equal to an axis the leaf declares itself is idempotent
            // and adds no key, which is what leaves it renameable below.
            let before_nested = index_set_names(&comp);
            apply_staged_index_sets(&mut comp, staged_nested, &leaf_env)?;
            let nested_contributed: std::collections::BTreeSet<String> = index_set_names(&comp)
                .difference(&before_nested)
                .cloned()
                .collect();

            // Step (2): `index_set_rename` speaks the resolved leaf's own
            // post-resolution vocabulary, so it applies HERE โ€” after the close
            // and fold above, over the leaf's own and imported axes only (each
            // nested edge renames what IT brings, at its own edge).
            crate::template_imports::apply_mount_index_set_rename(
                &mut comp,
                entry_obj,
                &format!("top-level model ref '{ref_str}'"),
                &nested_contributed,
            )?;

            let sel = entry_obj.get("model").and_then(|v| v.as_str());
            let mut model = extract_toplevel_model(&comp, sel, ref_str, &canonical)?;
            // esm-spec ยง6.6: inline tests do NOT cross a mount edge. They are
            // assertions about the leaf under the leaf's OWN standalone
            // conditions, and the mounting document may couple it โ€” replacing a
            // parameter, reshaping it, feeding it another component's state โ€” so
            // re-running them here would check a claim the leaf's author never
            // made. They run when the leaf's own file is the test target, which
            // a directory-wide `esm test` reaches anyway.
            if let Some(model_obj) = model.as_object_mut() {
                model_obj.remove("tests");
            }
            // esm-spec ยง4.7 "Working precision crosses a mount": the leaf's
            // declared `domain.element_type` does not merge (one document, one
            // domain), so it travels on the variables it governs.
            carry_mounted_element_type(
                &mut model,
                leaf_precision.as_deref(),
                precision,
                &mount_noun,
            )?;
            // Any relative `{ref}` the resolution above did not consume anchors
            // at the LEAF's directory, not at the parent it is about to land in.
            absolutize_nested_refs(&mut model, &leaf_dir);
            Ok(model)
        })();
        visited.remove(&canonical);
        let model = build?;

        // Splice the resolved model back under the same key, then merge the
        // leaf's by-name blocks (parent wins on a clash).
        obj.get_mut("models")
            .and_then(|v| v.as_object_mut())
            .expect("models map present")
            .insert(name, model);
        // esm-spec ยง8.2.1: a hoisted `data_sources` block's locations anchor on
        // the directory of the file they were READ from, not on the importer's.
        // Resolved here, before the merge below, because afterwards the entry is
        // indistinguishable from one the importer declared. A no-op when the leaf
        // declares no sources, which is almost every leaf.
        crate::data_source_urls::resolve_data_source_urls(&mut comp, &leaf_dir)
            .map_err(|e| err(e.code, e.message))?;
        // esm-spec ยง4.7 "Index-set merge": a top-level `models.<k>` mount edge
        // merges the leaf's document-scoped `index_sets` into THIS document's
        // registry, exactly as a subsystem mount does โ€” the two mount forms are
        // one mechanism at two attachment points, so an assembly may shape its
        // coupling over the leaf's axes without redeclaring them. Deep-equal
        // redeclaration is idempotent; a non-deep-equal collision is
        // `subsystem_index_set_conflict`. Merged AFTER the leaf's own resolution
        // and nested mounts above, so `comp`'s registry already carries whatever
        // ITS own mounts brought in and the merge composes transitively. Unlike
        // the by-name blocks below, the importer does NOT silently win a clash.
        //
        // Every declaration merges, including an `interval` whose `size` is a
        // metaparameter name. Before the edge close existed this merge held such
        // a declaration back, and the assembly had to redeclare the axis โ€” the
        // gap ยง4.7's "without redeclaring them" forbids.
        //
        // A `size` is concrete here only when the leaf HAS ยง9.7 machinery: then
        // the edge closed and folded its `metaparameters` above (ยง9.7.6 site 3).
        // A leaf with NO machinery has no close to run, so a `size` naming a
        // metaparameter the ASSEMBLER declares still arrives here SYMBOLIC and
        // merges that way, to be closed by the mounting document's own ยง9.7.6
        // pass. `merge_subsystem_index_sets` compares declarations structurally
        // and has no symbolic handling, so on that path the idempotence it
        // enforces is SYNTACTIC: a mount that restates the leaf's axis verbatim
        // (`size: "n_rows"`) merges clean, and one that restates it with the
        // concrete number the name folds to (`size: 7`) is a
        // `subsystem_index_set_conflict`. Python agrees on both.
        if let Some(loaded) = comp.get("index_sets").and_then(|v| v.as_object()).cloned()
            && !loaded.is_empty()
        {
            // Deferred at the ROOT (see `walk_top_level`): the contribution is
            // staged, not merged, until the mounting document has closed.
            if let Some(staged) = staged.as_deref_mut() {
                merge_subsystem_index_sets(staged, &loaded, ref_str, merge_env)?;
            } else {
                let registry = obj
                    .entry("index_sets".to_string())
                    .or_insert_with(|| Value::Object(Map::new()));
                if let Some(registry) = registry.as_object_mut() {
                    merge_subsystem_index_sets(registry, &loaded, ref_str, merge_env)?;
                }
            }
        }
        // Not `enums`: it is file-local (esm-spec ยง9.3), and the leaf's `enum`
        // ops were already lowered against it at the edge.
        for blk in ["function_tables", "data_sources"] {
            let Some(src) = comp.get(blk).and_then(|v| v.as_object()) else {
                continue;
            };
            if src.is_empty() {
                continue;
            }
            let src = src.clone();
            let dst = obj
                .entry(blk.to_string())
                .or_insert_with(|| Value::Object(Map::new()));
            if let Some(dst) = dst.as_object_mut() {
                for (k, v) in src {
                    dst.entry(k).or_insert(v);
                }
            }
        }
    }
    Ok(())
}

/// Lower `doc`'s `enum` ops against its own `enums` block at a ยง4.7 mount edge
/// (esm-spec ยง9.3), naming the edge in the diagnostic.
fn lower_mounted_enums_at_edge(doc: &mut Value, mount_noun: &str) -> Result<(), DiagnosticError> {
    crate::lower_enums::lower_mounted_document_enums(doc).map_err(|e| {
        err(
            e.code,
            format!(
                "{mount_noun}: {} โ€” an `enum` op in a mounted file resolves against that \
                 file's own `enums` block (esm-spec ยง9.3)",
                e.message
            ),
        )
    })
}

/// Extract the single top-level model from a referenced component file (or the
/// `sel`-named one when the file holds several), for a top-level model-ref mount.
fn extract_toplevel_model(
    comp: &Value,
    sel: Option<&str>,
    ref_str: &str,
    source: &Path,
) -> Result<Value, DiagnosticError> {
    let models = comp
        .as_object()
        .and_then(|o| o.get("models"))
        .and_then(|v| v.as_object())
        .ok_or_else(|| {
            err(
                TOPLEVEL_MODEL_REF.code,
                format!(
                    "top-level model ref '{ref_str}' resolves to a file with no models block ({})",
                    source.display()
                ),
            )
        })?;
    match sel {
        Some(name) => models.get(name).cloned().ok_or_else(|| {
            let mut avail: Vec<&String> = models.keys().collect();
            avail.sort();
            err(
                TOPLEVEL_MODEL_REF.code,
                format!(
                    "top-level model ref '{ref_str}' has no model '{name}' (available: {})",
                    avail
                        .iter()
                        .map(|s| s.as_str())
                        .collect::<Vec<_>>()
                        .join(", ")
                ),
            )
        }),
        None => {
            if models.len() == 1 {
                Ok(models.values().next().cloned().expect("one model"))
            } else {
                let mut avail: Vec<&String> = models.keys().collect();
                avail.sort();
                Err(err(
                    TOPLEVEL_MODEL_REF.code,
                    format!(
                        "top-level model ref '{ref_str}' resolves to {} models; add a \"model\" \
                         selector to choose one (available: {})",
                        models.len(),
                        avail
                            .iter()
                            .map(|s| s.as_str())
                            .collect::<Vec<_>>()
                            .join(", ")
                    ),
                ))
            }
        }
    }
}

/// Rewrite every relative `{"ref": "..."}` under `value` to an absolute path
/// anchored at `base_dir`, so the references resolve after the containing model
/// is spliced into a parent whose directory differs. Absolute paths and URLs are
/// left untouched. Mirrors the Julia `_absolutize_nested_refs!`.
fn absolutize_nested_refs(value: &mut Value, base_dir: &Path) {
    match value {
        Value::Object(map) => {
            if let Some(Value::String(r)) = map.get("ref") {
                // esm-spec ยง4.7: expand `${VAR}` BEFORE deciding whether the ref
                // is absolute, as the Julia reference does โ€” classifying first
                // would anchor an expanded ABSOLUTE ref at `base_dir` as though
                // it were relative.
                let r = expand_env_refs(r).into_owned();
                let is_abs =
                    r.starts_with('/') || r.starts_with("http://") || r.starts_with("https://");
                if !is_abs {
                    let joined = base_dir.join(&r);
                    let abs = joined
                        .canonicalize()
                        .map(|p| p.to_string_lossy().into_owned())
                        .unwrap_or_else(|_| joined.to_string_lossy().into_owned());
                    map.insert("ref".to_string(), Value::String(abs));
                }
            }
            for v in map.values_mut() {
                absolutize_nested_refs(v, base_dir);
            }
        }
        Value::Array(arr) => {
            for v in arr.iter_mut() {
                absolutize_nested_refs(v, base_dir);
            }
        }
        _ => {}
    }
}

/// Walk a model or reaction system value and resolve any refs in its
/// `subsystems` map. `registry`, when `Some`, accumulates referenced files'
/// top-level `index_sets` (esm-spec ยง4.7 model-subsystem merge).
///
/// `parent_meta` and `api_meta` are the two mounting-scope environments a
/// `subsystems.<k>` edge needs, and they are NOT interchangeable โ€” exactly as at
/// the top-level `models.<k>` form ([`walk_top_level`]): edge `bindings`
/// EXPRESSIONS fold against `parent_meta`, while only `api_meta` (the loader-API
/// bindings, ยง9.7.6 site 4) backfills the leaf's own close.
#[allow(clippy::too_many_arguments)]
fn walk_subsystems(
    value: &mut Value,
    base_path: &Path,
    visited: &mut HashSet<PathBuf>,
    mut registry: Option<&mut Map<String, Value>>,
    parent_meta: &BTreeMap<String, i64>,
    api_meta: &BTreeMap<String, i64>,
    merge_env: &BTreeMap<String, i64>,
    precision: &str,
) -> Result<(), DiagnosticError> {
    let obj = match value.as_object_mut() {
        Some(o) => o,
        None => return Ok(()),
    };

    let subs_val = match obj.get_mut("subsystems") {
        Some(v) => v,
        None => return Ok(()),
    };

    let subs = match subs_val.as_object_mut() {
        Some(m) => m,
        None => return Ok(()),
    };

    let names: Vec<String> = subs.keys().cloned().collect();
    for name in names {
        let entry = subs.remove(&name).unwrap_or(Value::Null);
        let resolved = resolve_value(
            entry,
            base_path,
            visited,
            registry.as_deref_mut(),
            parent_meta,
            api_meta,
            merge_env,
            precision,
        )?;
        subs.insert(name, resolved);
    }

    Ok(())
}

/// Read the optional metaparameter `bindings` off a `{ ref, bindings }`
/// subsystem entry (esm-spec ยง9.7.6 binding site 3). A value may be a
/// *metaparameter expression* โ€” an integer literal, a name in the MOUNTING
/// document's metaparameter scope, or a `{op: +|-|*|/, args}` tree over the
/// same (e.g. `NTGT = NX*NY`). A subsystem ref is resolved as a complete
/// document and folded to concrete integers at the mount, so โ€” unlike an
/// import edge โ€” its binding values cannot be carried symbolically: each folds
/// IMMEDIATELY against `parent_meta`, the mounting document's already-closed
/// metaparameter environment (the parent closes before its refs resolve).
/// A free name absent from `parent_meta` is `template_import_unknown_name`;
/// a bad op / empty args / float is `metaparameter_type_error`.
fn read_edge_bindings(
    obj: &serde_json::Map<String, Value>,
    parent_meta: &std::collections::BTreeMap<String, i64>,
    mount_noun: &str,
) -> Result<std::collections::BTreeMap<String, i64>, DiagnosticError> {
    let mut out = std::collections::BTreeMap::new();
    let Some(bindings) = obj.get("bindings") else {
        return Ok(out);
    };
    let Some(bindings_obj) = bindings.as_object() else {
        return Err(err(
            codes::METAPARAMETER_TYPE_ERROR,
            format!(
                "{mount_noun} `bindings` must be an object of \
                 metaparameter expressions (esm-spec 9.7.6)"
            ),
        ));
    };
    for (k, v) in bindings_obj {
        let ctx = format!("{mount_noun}, binding '{k}'");
        // Structural grammar check at the edge (bad op / empty args / float โ€”
        // even with a symbolic arg), then fold against the mounting scope.
        crate::template_imports::require_meta_expr(v, &ctx)?;
        let folded = crate::template_imports::eval_meta_expr(v, parent_meta, &ctx)?;
        out.insert(k.clone(), folded);
    }
    Ok(out)
}

/// If `value` is a `{ "ref": "..." }` object, load the referenced file and
/// inline the single top-level system from it. Otherwise recurse into the
/// value's own `subsystems` map.
///
/// esm-spec ยง9.7: a subsystem ref MUST NOT target a template-library file
/// (`subsystem_ref_is_template_library` โ€” the two reference mechanisms are
/// disjoint, ยง9.7.1), and the referenced document's own ยง9.7 machinery
/// (template imports + metaparameters) is resolved in the referenced file's
/// directory, closed with this edge's `bindings` (ยง9.7.6 binding site 3) and
/// lowered to the ยง9.6.3 fixpoint, before the single component is extracted.
/// Template-import diagnostics propagate as
/// [`crate::lower_expression_templates::ExpressionTemplateError`] (a
/// [`DiagnosticError`]), carrying their stable `code`, matching how `load_string()`
/// surfaces them.
#[allow(clippy::too_many_arguments)]
fn resolve_value(
    value: Value,
    base_path: &Path,
    visited: &mut HashSet<PathBuf>,
    registry: Option<&mut Map<String, Value>>,
    parent_meta: &BTreeMap<String, i64>,
    api_meta: &BTreeMap<String, i64>,
    merge_env: &BTreeMap<String, i64>,
    precision: &str,
) -> Result<Value, DiagnosticError> {
    if let Some(obj) = value.as_object()
        && let Some(ref_val) = obj.get("ref")
    {
        let ref_str = ref_val
            .as_str()
            .ok_or_else(|| err(SUBSYSTEM_REF.code, "subsystem ref must be a string"))?;

        let (canonical, mut parsed) =
            load_ref_document(ref_str, base_path, visited, &SUBSYSTEM_REF)?;
        // Read before anything below can consume the leaf's `domain`.
        let leaf_precision = declared_element_type(&parsed);

        // A ยง4.7 subsystem ref MUST NOT target a template-library file โ€” the
        // two reference mechanisms are disjoint (esm-spec ยง9.7.1).
        if crate::template_imports::is_template_library_doc(&parsed) {
            visited.remove(&canonical);
            return Err(err(
                codes::SUBSYSTEM_REF_IS_TEMPLATE_LIBRARY,
                format!(
                    "Subsystem ref '{ref_str}' targets a \
                     template-library file ({}); libraries are imported via \
                     expression_template_imports (esm-spec 9.7.1)",
                    canonical.display()
                ),
            ));
        }

        // Likewise, a ยง4.7 subsystem ref MUST NOT target a coupling-library
        // file โ€” those are imported via a `coupling_import` coupling entry, not
        // mounted as a subsystem (esm-spec ยง10.9).
        if crate::coupling_imports::is_coupling_library_doc(&parsed) {
            visited.remove(&canonical);
            return Err(err(
                codes::SUBSYSTEM_REF_IS_COUPLING_LIBRARY,
                format!(
                    "Subsystem ref '{ref_str}' targets a \
                     coupling-library file ({}); libraries are imported via a coupling_import \
                     coupling entry (esm-spec 10.9)",
                    canonical.display()
                ),
            ));
        }

        // Resolve the referenced document's ยง9.7 machinery in its own
        // directory, closing its metaparameters with this edge's `bindings`
        // (esm-spec ยง9.7.6 binding site 3), then run the ยง9.6.3 fixpoint so
        // the inlined component carries only concrete Expression ASTs.
        let parent_dir = canonical.parent().unwrap_or(base_path).to_path_buf();
        let edge_result: Result<(), DiagnosticError> = (|| {
            crate::lower_expression_templates::reject_expression_templates_pre_v04(&parsed)?;
            crate::template_imports::reject_template_imports_pre_v08(&parsed)?;
            // ยง9.7.6 binding site 3: close the leaf's metaparameters. Explicit
            // edge `bindings` win, backfilled by the LOADER-API bindings (site
            // 4) for the names the leaf DECLARES โ€” the SAME backfill the
            // top-level `models.<k>` mount runs (ยง4.7 "Two mount forms, one
            // mechanism"). Without it a discovered `extent` (ยง8.9.4) reached a
            // top-level-mounted leaf and not a subsystem-mounted one, so the
            // same two documents sized the same axis differently depending only
            // on which attachment point the assembler picked โ€” and the
            // subsystem form sized it at the leaf's placeholder default, so the
            // ingested field came back ZERO-LENGTH with no diagnostic at all.
            //
            // The backfill reads `api_meta`, NOT `parent_meta`. `parent_meta`
            // additionally carries the mounting document's OWN declared
            // defaults, and forwarding those would let an assembler's unrelated
            // metaparameter silently resize a leaf axis the edge never bound.
            // Names the leaf does not declare are never forwarded, so they
            // cannot raise `template_import_unknown_name` against it.
            let leaf_declared: Vec<String> = parsed
                .get("metaparameters")
                .and_then(|v| v.as_object())
                .map(|o| o.keys().cloned().collect())
                .unwrap_or_default();
            let mut bindings: BTreeMap<String, i64> = api_meta
                .iter()
                .filter(|(k, _)| leaf_declared.contains(k))
                .map(|(k, v)| (k.clone(), *v))
                .collect();
            bindings.extend(read_edge_bindings(obj, parent_meta, "subsystem ref")?);
            // esm-spec ยง4.7: a `{ref}` resolves "before validation or any other
            // processing", so the referenced document's OWN nested refs are
            // inlined HERE, before the ยง9.6.3 fixpoint below โ€” otherwise a
            // rewrite-target inside a component the leaf itself mounts is
            // unlowerable from this edge, while the same library declared in the
            // leaf's own `expression_template_imports` does lower it (#311).
            // ยง9.7.10 defines the injection as "as if the target had added those
            // entries to the end of its own `expression_template_imports`", so
            // the two MUST agree.
            //
            // `defer: true` is load-bearing, and it is the SAME split #317 made
            // at the root: the content splices in early so the fixpoint can
            // lower through it, but the `index_sets` it contributes are STAGED,
            // not merged, because THIS leaf has not closed yet โ€” its
            // `resolve_template_machinery` call is still below. They land in
            // `apply_staged_index_sets` after the close, folded against the
            // leaf's own closed environment. Merging them here would put a ยง4.7
            // contribution back before a ยง9.7.6 close, which is exactly what
            // #317 removed.
            let leaf_env = root_metaparameter_env(&parsed, &bindings);
            let staged_nested = walk_top_level(
                &mut parsed,
                &parent_dir,
                visited,
                &leaf_env,
                api_meta,
                true,
                &bindings,
                precision,
            )?;
            // esm-spec ยง9.7.10 form A: the edge's `expression_template_imports`
            // inject a discretization into the referenced component's own
            // scope, appended BEFORE resolution so the ยง9.6.3 fixpoint lowers
            // its rewrite-targets at the mount. Consumed here (does not survive
            // parse โ†’ emit; the mount round-trips as the lowered inline
            // component). Refs resolve against the referenced file's directory.
            let injected: Vec<Value> = obj
                .get("expression_template_imports")
                .and_then(|v| v.as_array())
                .cloned()
                .unwrap_or_default();
            crate::template_imports::apply_scope_injections(&mut parsed, &injected)?;
            // As at the top-level mount form: the leaf's own nested mounts
            // widen its ยง9.7.6 site-4 check, and `mounted_leaf` keeps an axis
            // this scope cannot size symbolic for the merge below to close
            // (ยง9.7.6 site 5).
            let leaf_mount_declared =
                crate::template_imports::collect_mount_declared_metaparameters(
                    &parsed,
                    &parent_dir,
                );
            let mut resolved = crate::template_imports::resolve_template_machinery_scoped(
                &parsed,
                &parent_dir,
                &bindings,
                &leaf_mount_declared,
                true,
            )?
            .unwrap_or_else(|| std::mem::take(&mut parsed));
            // A referenced subsystem is a self-contained build boundary
            // (mirrors Julia `_load_ref` โ†’ `_lower_and_coerce`): lower under
            // Option B, then `expand` so the inlined component carries the
            // fully-expanded Option-A image with no surviving references โ€”
            // the parent document's lowering must not resolve the
            // subsystem's own template names against the parent registry. A
            // leaf with no ยง9.7 machinery still expands, as at the top-level
            // form (esm-spec ยง9.3).
            crate::lower_expression_templates::lower_expression_templates(&mut resolved)?;
            crate::lower_expression_templates::expand(&mut resolved)?;
            parsed = resolved;
            // esm-spec ยง9.3: the referenced document's `enum` ops resolve
            // against ITS OWN `enums` block, here, as at the top-level form.
            lower_mounted_enums_at_edge(&mut parsed, &format!("subsystem ref '{ref_str}'"))?;
            // The leaf has now CLOSED, so the contributions its own nested
            // mounts staged can land โ€” folded against the leaf's own closed
            // environment and compared deep-equal, the ยง4.7 merge rule run at
            // the leaf rather than at the importer. A nested contribution that
            // is deep-equal to an axis the leaf declares itself is idempotent
            // and adds no key, which is what leaves it renameable below.
            let before_nested = index_set_names(&parsed);
            apply_staged_index_sets(&mut parsed, staged_nested, &leaf_env)?;
            let nested_contributed: std::collections::BTreeSet<String> = index_set_names(&parsed)
                .difference(&before_nested)
                .cloned()
                .collect();

            // esm-spec ยง4.7 "Mount-edge index-set renaming", pipeline step 2.
            // The referenced document has now resolved in its OWN scope โ€” its
            // imports, this edge's `bindings` and injection, its metaparameter
            // close and fold, the ยง9.6.3 fixpoint โ€” so its `index_sets` are the
            // post-resolution vocabulary the edge's `index_set_rename` speaks.
            // Scoped to what THIS document declares and imports: an axis that
            // reached the registry through a mount nested inside it is renamed
            // (or not) at that nested edge, so `nested_contributed` is held out.
            // Absent or empty โ‡’ identity, so an edge that does not use the field
            // resolves exactly as before.
            crate::template_imports::apply_mount_index_set_rename(
                &mut parsed,
                obj,
                &format!("subsystem ref '{ref_str}'"),
                &nested_contributed,
            )?;
            Ok(())
        })();
        if let Err(e) = edge_result {
            visited.remove(&canonical);
            return Err(e);
        }

        // Recursively resolve any refs inside the loaded file, then merge its
        // top-level `index_sets` into the importing document's registry
        // (esm-spec ยง4.7, bottom-up; `registry` is `None` for reaction-system
        // subsystem refs). Wrapped in a closure so `visited.remove` below runs
        // on the error paths too โ€” a load error aborts the whole resolution
        // today, but a path left marked visited would silently skip the file
        // if resolution ever becomes partially recoverable.
        let nested_result = (|| -> Result<(), DiagnosticError> {
            // The leaf's own nested refs were inlined inside the edge closure
            // above, before the fixpoint (#311), and the `index_sets` they
            // contributed were staged there and merged after the leaf's close;
            // what remains here is the bottom-up merge into the IMPORTING
            // document.
            if let Some(reg) = registry
                && let Some(loaded) = parsed.get("index_sets").and_then(|v| v.as_object())
            {
                merge_subsystem_index_sets(reg, &loaded.clone(), ref_str, merge_env)?;
            }
            Ok(())
        })();

        visited.remove(&canonical);
        nested_result?;

        let mut system = extract_single_system(parsed, &canonical)?;
        // esm-spec ยง4.7 "Working precision crosses a mount", at this form too.
        carry_mounted_element_type(
            &mut system,
            leaf_precision.as_deref(),
            precision,
            &format!("subsystem ref '{ref_str}'"),
        )?;
        return Ok(system);
    }

    let mut value = value;
    walk_subsystems(
        &mut value,
        base_path,
        visited,
        registry,
        parent_meta,
        api_meta,
        merge_env,
        precision,
    )?;
    Ok(value)
}

/// The working precision a document that declares none evaluates in
/// (esm-spec ยง11.3: `domain.element_type` defaults to `"Float64"`).
const DEFAULT_ELEMENT_TYPE: &str = "Float64";

/// The `domain.element_type` a document declares, if it declares one.
fn declared_element_type(doc: &Value) -> Option<String> {
    doc.get("domain")
        .and_then(|d| d.get("element_type"))
        .and_then(Value::as_str)
        .map(str::to_string)
}

/// A document's EFFECTIVE working precision (esm-spec ยง4.7 "Working precision
/// crosses a mount"): its own declaration, else the one it inherits from the
/// document that mounts it. A root inherits [`DEFAULT_ELEMENT_TYPE`].
fn effective_element_type(doc: &Value, enclosing: &str) -> String {
    declared_element_type(doc).unwrap_or_else(|| enclosing.to_string())
}

/// Carry a mounted document's declared working precision onto the component
/// it splices in (esm-spec ยง4.7 "Working precision crosses a mount").
///
/// A leaf that declares no `domain.element_type` declares no precision and
/// adopts the mounting document's, so nothing is written. A leaf whose
/// declaration equals the mounting document's EFFECTIVE precision needs
/// nothing written either. Otherwise every `ModelVariable` of the component โ€”
/// and of every subsystem it carries, at any depth โ€” that does not declare its
/// own `element_type` gets the leaf's; a variable that does declare one keeps
/// it. The result is exactly the set of overrides an author writing the
/// assembly inline would have had to write, so `emit โˆ˜ load` stays a fixed
/// point and the evaluator needs no mount-specific path.
fn carry_mounted_element_type(
    component: &mut Value,
    leaf: Option<&str>,
    enclosing: &str,
    mount_noun: &str,
) -> Result<(), DiagnosticError> {
    let Some(leaf) = leaf else {
        return Ok(());
    };
    if leaf == enclosing {
        return Ok(());
    }
    stamp_element_type(component, leaf, enclosing, mount_noun, "")
}

fn stamp_element_type(
    system: &mut Value,
    leaf: &str,
    enclosing: &str,
    mount_noun: &str,
    path: &str,
) -> Result<(), DiagnosticError> {
    let Some(obj) = system.as_object_mut() else {
        return Ok(());
    };
    // A reaction system's species and parameters have no `element_type`, so
    // the format cannot state its precision per component. Refused rather than
    // evaluated in the mounting document's precision (esm-spec ยง11.3.1).
    if obj.contains_key("species") || obj.contains_key("reactions") {
        let at = if path.is_empty() {
            String::new()
        } else {
            format!(" (subsystem '{path}')")
        };
        return Err(err(
            codes::ELEMENT_TYPE_UNCARRIED,
            format!(
                "{mount_noun}: the mounted document declares `domain.element_type: \"{leaf}\"` \
                 but the document mounting it evaluates in {enclosing}, and the component \
                 carries a reaction system{at} whose species and parameters cannot declare a \
                 per-variable `element_type`; the mount cannot keep the leaf's precision \
                 (esm-spec ยง4.7 \"Working precision crosses a mount\"). Declare the same \
                 `domain.element_type` on the mounting document, or remove it from the leaf"
            ),
        ));
    }
    if let Some(vars) = obj.get_mut("variables").and_then(Value::as_object_mut) {
        for var in vars.values_mut() {
            if let Some(v) = var.as_object_mut()
                && !v.contains_key("element_type")
            {
                v.insert("element_type".to_string(), Value::String(leaf.to_string()));
            }
        }
    }
    if let Some(subs) = obj.get_mut("subsystems").and_then(Value::as_object_mut) {
        for (name, sub) in subs.iter_mut() {
            let sub_path = if path.is_empty() {
                name.clone()
            } else {
                format!("{path}.{name}")
            };
            stamp_element_type(sub, leaf, enclosing, mount_noun, &sub_path)?;
        }
    }
    Ok(())
}

/// A referenced file must contain exactly one top-level model, reaction
/// system, or data loader. Extract that single entry as a JSON value to inline
/// into the caller's subsystem slot. Precedence is models -> reaction_systems
/// -> data_sources.
fn extract_single_system(value: Value, source: &Path) -> Result<Value, DiagnosticError> {
    let obj = value.as_object().ok_or_else(|| {
        err(
            SUBSYSTEM_REF.code,
            format!("ref {} did not parse to a JSON object", source.display()),
        )
    })?;

    // "Exactly one top-level system" counts across ALL system kinds TOGETHER โ€”
    // it is not a per-kind question.
    //
    // The previous chain asked each kind in turn (`models` โ†’ `reaction_systems`
    // โ†’ `data_sources`) and took the first that happened to hold exactly one
    // entry. A file with TWO models and ONE data loader therefore fell past the
    // ambiguous `models` map and silently mounted the LOADER โ€” the caller asked
    // for a subsystem and got a completely different object, with no diagnostic.
    // That is precisely tests/invalid/subsystem_ref_ambiguous.esm (2 models +
    // 1 loader), and it is a worse failure than the error it was avoiding.
    // `data_sources` is NOT a top-level system. From 1.0.0 a data source is not
    // a component (esm-spec ยง8) โ€” it cannot be a coupling endpoint, a subsystem
    // or a scoped-name path root โ€” so a file holding a model plus the sources
    // that model consumes still declares exactly one mountable system. Counting
    // sources made that shape, which is the natural 1.0.0 one now that a loader
    // is no longer a component, the one shape a `ref` could not mount.
    let systems: Vec<&Value> = ["models", "reaction_systems"]
        .iter()
        .filter_map(|key| obj.get(*key).and_then(|v| v.as_object()))
        .flat_map(|m| m.values())
        .collect();

    match systems.as_slice() {
        [only] => Ok((*only).clone()),
        // The file WAS found and read โ€” it just does not contain exactly one
        // top-level system (zero, or several), so which system to mount is
        // AMBIGUOUS. This is a different defect from a ref that could not be
        // resolved at all, and the corpus pins the two under different codes.
        _ => Err(err(
            AMBIGUOUS_SUBSYSTEM_REF,
            format!(
                "Subsystem reference '{}' resolves to a file containing multiple top-level \
                 systems; exactly one is required",
                source.display()
            ),
        )),
    }
}

/// Expand `${VAR}` tokens in a `ref` from the loader's environment
/// (esm-spec ยง4.7) โ€” the mechanism a document uses to reach a sibling library
/// repository checked out at a deployment-chosen path.
///
/// Three rules, matching the Julia reference and the Python binding, which both
/// spell them as the regex `\$\{([A-Za-z_][A-Za-z0-9_]*)\}`:
///
/// * only the BRACED form with a C-identifier name expands โ€” never a bare
///   `$VAR`, never a non-identifier name, never an unclosed `${`;
/// * an UNSET variable is left verbatim, so the ref fails with the ordinary
///   unresolved diagnostic naming the `${VAR}` text rather than misresolving
///   against a path built from an empty string;
/// * callers expand BEFORE classifying the ref as remote / absolute / relative,
///   because ยง4.7 resolves the EXPANDED string: an expanded relative ref
///   anchors against the referencing file's directory and an expanded
///   absolute-or-URL ref is used as-is.
pub(crate) fn expand_env_refs(ref_str: &str) -> Cow<'_, str> {
    if !ref_str.contains("${") {
        return Cow::Borrowed(ref_str);
    }
    let mut out = String::with_capacity(ref_str.len());
    let mut rest = ref_str;
    while let Some(start) = rest.find("${") {
        out.push_str(&rest[..start]);
        let after = &rest[start + 2..];
        match after.find('}') {
            Some(end) if is_env_var_name(&after[..end]) => {
                match std::env::var(&after[..end]) {
                    Ok(value) => out.push_str(&value),
                    // Unset: keep the whole token, so the ref fails unresolved.
                    Err(_) => out.push_str(&rest[start..start + 3 + end]),
                }
                rest = &after[end + 1..];
            }
            // Not a `${IDENT}` token, so `${` is an ordinary path character.
            _ => {
                out.push_str("${");
                rest = after;
            }
        }
    }
    out.push_str(rest);
    Cow::Owned(out)
}

/// A C-identifier โ€” the only variable-name shape ยง4.7's `${VAR}` token admits.
fn is_env_var_name(name: &str) -> bool {
    let mut chars = name.chars();
    matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;
    use tempfile::TempDir;

    #[test]
    fn test_resolve_no_refs() {
        let mut value = json!({
            "esm": "1.0.0",
            "metadata": { "name": "test" },
            "models": {
                "Main": { "variables": {}, "equations": [] }
            }
        });
        let result = resolve_subsystem_refs_raw(&mut value, Path::new("/tmp"));
        assert!(result.is_ok());
    }

    #[test]
    fn test_resolve_local_subsystem_ref() {
        let dir = TempDir::new().unwrap();
        let inner = json!({
            "esm": "1.0.0",
            "metadata": { "name": "inner" },
            "models": {
                "Inner": {
                    "variables": {},
                    "equations": []
                }
            }
        });
        std::fs::write(
            dir.path().join("inner.json"),
            serde_json::to_string(&inner).unwrap(),
        )
        .unwrap();

        let mut value = json!({
            "esm": "1.0.0",
            "metadata": { "name": "main" },
            "models": {
                "Outer": {
                    "variables": {},
                    "equations": [],
                    "subsystems": {
                        "Inner": { "ref": "inner.json" }
                    }
                }
            }
        });

        resolve_subsystem_refs_raw(&mut value, dir.path()).unwrap();

        let inner_resolved = &value["models"]["Outer"]["subsystems"]["Inner"];
        assert!(inner_resolved.get("variables").is_some());
        assert!(inner_resolved.get("ref").is_none());
    }

    #[test]
    fn test_reject_data_source_only_ref() {
        // A subsystem ref to a SOURCE-ONLY file must be REJECTED. From 1.0.0 a
        // data source is not a component: `$defs/DataSource` says it "cannot be
        // a coupling endpoint, a subsystem, or a scoped-name path root", and a
        // model reaches one only through a parameter whose `update` names it.
        //
        // This test asserted the opposite until 2026-08-20 โ€” that the ref
        // resolved TO the loader object, complete with the `grid` and
        // `variables` blocks that 0.8.0 and 1.0.0 respectively removed. It was
        // a 0.x test relabelled `esm: 1.0.0` without its premise re-examined.
        let dir = TempDir::new().unwrap();
        let source_only = json!({
            "esm": "1.0.0",
            "metadata": { "name": "source-only" },
            "data_sources": {
                "MetData": {
                    "kind": "grid",
                    "source": {
                        "url_template": "https://example.org/data/{date:%Y%m%d}.nc"
                    }
                }
            }
        });
        std::fs::write(
            dir.path().join("source.esm"),
            serde_json::to_string(&source_only).unwrap(),
        )
        .unwrap();

        let mut value = json!({
            "esm": "1.0.0",
            "metadata": { "name": "main" },
            "models": {
                "Outer": {
                    "variables": {},
                    "equations": [],
                    "subsystems": {
                        "Met": { "ref": "source.esm" }
                    }
                }
            }
        });

        let err = resolve_subsystem_refs_raw(&mut value, dir.path())
            .expect_err("a source-only file declares no mountable system");
        assert!(err.to_string().contains("multiple top-level systems"));
    }

    #[test]
    fn test_resolve_model_plus_its_data_sources_ref() {
        // The shape 1.0.0 makes natural, and the one the old count forbade: ONE
        // model plus the sources that model consumes, in one file. The 0.x
        // `X.esm` / `X_loader.esm` split existed only because a loader was a
        // component; with sources uncounted this mounts like any other model.
        let dir = TempDir::new().unwrap();
        let leaf = json!({
            "esm": "1.0.0",
            "metadata": { "name": "leaf" },
            "data_sources": {
                "MetData": {
                    "kind": "grid",
                    "source": { "url_template": "https://example.org/{var}.nc" }
                }
            },
            "models": {
                "Leaf": {
                    "variables": {
                        "T": {
                            "type": "parameter",
                            "units": "K",
                            "shape": [],
                            "update": {
                                "kind": "data",
                                "source": "MetData",
                                "from": { "file_variable": "temperature" }
                            }
                        }
                    },
                    "equations": []
                }
            }
        });
        std::fs::write(
            dir.path().join("leaf.esm"),
            serde_json::to_string(&leaf).unwrap(),
        )
        .unwrap();

        let mut value = json!({
            "esm": "1.0.0",
            "metadata": { "name": "main" },
            "models": {
                "Outer": {
                    "variables": {},
                    "equations": [],
                    "subsystems": {
                        "Met": { "ref": "leaf.esm" }
                    }
                }
            }
        });

        resolve_subsystem_refs_raw(&mut value, dir.path()).unwrap();

        // The ref is replaced by the single MODEL โ€” the sources rode along in
        // the document but were never candidates for the mount.
        let resolved = &value["models"]["Outer"]["subsystems"]["Met"];
        assert!(resolved.get("ref").is_none());
        assert_eq!(resolved["variables"]["T"]["update"]["source"], "MetData");
    }

    #[test]
    fn test_reject_remote_ref() {
        let mut value = json!({
            "esm": "1.0.0",
            "metadata": { "name": "main" },
            "models": {
                "Outer": {
                    "variables": {},
                    "equations": [],
                    "subsystems": {
                        "Remote": { "ref": "https://example.com/inner.json" }
                    }
                }
            }
        });

        let result = resolve_subsystem_refs_raw(&mut value, Path::new("/tmp"));
        assert!(result.is_err());
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains("Remote subsystem refs")
        );
    }

    #[test]
    fn test_circular_ref_detection() {
        let dir = TempDir::new().unwrap();
        let a = json!({
            "esm": "1.0.0",
            "metadata": { "name": "a" },
            "models": {
                "A": {
                    "variables": {},
                    "equations": [],
                    "subsystems": { "Cycle": { "ref": "b.json" } }
                }
            }
        });
        let b = json!({
            "esm": "1.0.0",
            "metadata": { "name": "b" },
            "models": {
                "B": {
                    "variables": {},
                    "equations": [],
                    "subsystems": { "Cycle": { "ref": "a.json" } }
                }
            }
        });
        std::fs::write(
            dir.path().join("a.json"),
            serde_json::to_string(&a).unwrap(),
        )
        .unwrap();
        std::fs::write(
            dir.path().join("b.json"),
            serde_json::to_string(&b).unwrap(),
        )
        .unwrap();

        let mut value = json!({
            "esm": "1.0.0",
            "metadata": { "name": "main" },
            "models": {
                "Root": {
                    "variables": {},
                    "equations": [],
                    "subsystems": { "Start": { "ref": "a.json" } }
                }
            }
        });

        let result = resolve_subsystem_refs_raw(&mut value, dir.path());
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("circular"));
    }

    #[test]
    fn test_nonexistent_ref() {
        let dir = TempDir::new().unwrap();
        let mut value = json!({
            "esm": "1.0.0",
            "metadata": { "name": "main" },
            "models": {
                "Outer": {
                    "variables": {},
                    "equations": [],
                    "subsystems": {
                        "Missing": { "ref": "does-not-exist.json" }
                    }
                }
            }
        });

        let result = resolve_subsystem_refs_raw(&mut value, dir.path());
        assert!(result.is_err());
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains("failed to resolve ref")
        );
    }

    #[test]
    fn test_resolves_inside_reaction_systems() {
        let dir = TempDir::new().unwrap();
        let sub = json!({
            "esm": "1.0.0",
            "metadata": { "name": "sub" },
            "reaction_systems": {
                "Sub": {
                    "species": {},
                    "parameters": {},
                    "reactions": []
                }
            }
        });
        std::fs::write(
            dir.path().join("sub.json"),
            serde_json::to_string(&sub).unwrap(),
        )
        .unwrap();

        let mut value = json!({
            "esm": "1.0.0",
            "metadata": { "name": "main" },
            "reaction_systems": {
                "Main": {
                    "species": {},
                    "parameters": {},
                    "reactions": [],
                    "subsystems": {
                        "SubKey": { "ref": "sub.json" }
                    }
                }
            }
        });

        resolve_subsystem_refs_raw(&mut value, dir.path()).unwrap();
        let resolved = &value["reaction_systems"]["Main"]["subsystems"]["SubKey"];
        assert!(resolved.get("species").is_some());
        assert!(resolved.get("ref").is_none());
    }

    /// API_SPEC.md ยง8 item 14: the canonical `resolve_subsystem_refs` takes a
    /// TYPED document, as Julia, Python and TypeScript do. It must resolve
    /// exactly what the raw seam resolves.
    #[test]
    fn typed_resolve_subsystem_refs_matches_the_raw_pass() {
        let dir = TempDir::new().unwrap();
        let inner = json!({
            "esm": "1.0.0",
            "metadata": { "name": "inner" },
            "models": { "Inner": { "variables": {}, "equations": [] } }
        });
        std::fs::write(
            dir.path().join("inner.json"),
            serde_json::to_string(&inner).unwrap(),
        )
        .unwrap();

        let doc = json!({
            "esm": "1.0.0",
            "metadata": { "name": "main" },
            "models": {
                "Outer": {
                    "variables": {},
                    "equations": [],
                    "subsystems": { "Inner": { "ref": "inner.json" } }
                }
            }
        });

        let mut raw = doc.clone();
        resolve_subsystem_refs_raw(&mut raw, dir.path()).unwrap();

        let mut typed: crate::EsmFile = serde_json::from_value(doc).expect("typed document");
        resolve_subsystem_refs(&mut typed, dir.path()).unwrap();

        let inner_resolved = &typed.models.as_ref().unwrap()["Outer"]
            .subsystems
            .as_ref()
            .unwrap()["Inner"];
        assert!(inner_resolved.get("variables").is_some());
        assert!(inner_resolved.get("ref").is_none());
        assert_eq!(serde_json::to_value(&typed).unwrap(), raw);
    }
}