@vessel-dsp/compiler 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/README.md +118 -2
  2. package/dist/bake.d.ts +38 -0
  3. package/dist/bake.d.ts.map +1 -0
  4. package/dist/bake.js +101 -0
  5. package/dist/bake.js.map +1 -0
  6. package/dist/bbd-clock.d.ts +171 -0
  7. package/dist/bbd-clock.d.ts.map +1 -0
  8. package/dist/bbd-clock.js +1750 -0
  9. package/dist/bbd-clock.js.map +1 -0
  10. package/dist/block-row.d.ts +51 -0
  11. package/dist/block-row.d.ts.map +1 -0
  12. package/dist/block-row.js +17 -0
  13. package/dist/block-row.js.map +1 -0
  14. package/dist/bypass.d.ts +3 -0
  15. package/dist/bypass.d.ts.map +1 -0
  16. package/dist/bypass.js +228 -0
  17. package/dist/bypass.js.map +1 -0
  18. package/dist/compile.d.ts +24 -0
  19. package/dist/compile.d.ts.map +1 -0
  20. package/dist/compile.js +348 -0
  21. package/dist/compile.js.map +1 -0
  22. package/dist/converter-scale.d.ts +14 -0
  23. package/dist/converter-scale.d.ts.map +1 -0
  24. package/dist/converter-scale.js +133 -0
  25. package/dist/converter-scale.js.map +1 -0
  26. package/dist/couple.d.ts +28 -0
  27. package/dist/couple.d.ts.map +1 -0
  28. package/dist/couple.js +567 -0
  29. package/dist/couple.js.map +1 -0
  30. package/dist/dangling-active-terminal.d.ts +47 -0
  31. package/dist/dangling-active-terminal.d.ts.map +1 -0
  32. package/dist/dangling-active-terminal.js +542 -0
  33. package/dist/dangling-active-terminal.js.map +1 -0
  34. package/dist/declared-program.d.ts +23 -0
  35. package/dist/declared-program.d.ts.map +1 -0
  36. package/dist/declared-program.js +387 -0
  37. package/dist/declared-program.js.map +1 -0
  38. package/dist/denomination.d.ts +48 -0
  39. package/dist/denomination.d.ts.map +1 -0
  40. package/dist/denomination.js +27 -0
  41. package/dist/denomination.js.map +1 -0
  42. package/dist/device-laws.d.ts +237 -0
  43. package/dist/device-laws.d.ts.map +1 -0
  44. package/dist/device-laws.js +3513 -0
  45. package/dist/device-laws.js.map +1 -0
  46. package/dist/diagnostics.d.ts +13 -0
  47. package/dist/diagnostics.d.ts.map +1 -0
  48. package/dist/diagnostics.js +19 -0
  49. package/dist/diagnostics.js.map +1 -0
  50. package/dist/emit.d.ts +8 -0
  51. package/dist/emit.d.ts.map +1 -0
  52. package/dist/emit.js +43 -0
  53. package/dist/emit.js.map +1 -0
  54. package/dist/errors.d.ts +22 -0
  55. package/dist/errors.d.ts.map +1 -0
  56. package/dist/errors.js +39 -0
  57. package/dist/errors.js.map +1 -0
  58. package/dist/firmware-class.d.ts +69 -0
  59. package/dist/firmware-class.d.ts.map +1 -0
  60. package/dist/firmware-class.js +20 -0
  61. package/dist/firmware-class.js.map +1 -0
  62. package/dist/fixtures/circuits.d.ts +1742 -0
  63. package/dist/fixtures/circuits.d.ts.map +1 -0
  64. package/dist/fixtures/circuits.js +13896 -0
  65. package/dist/fixtures/circuits.js.map +1 -0
  66. package/dist/fixtures/expected.d.ts +261 -0
  67. package/dist/fixtures/expected.d.ts.map +1 -0
  68. package/dist/fixtures/expected.js +315 -0
  69. package/dist/fixtures/expected.js.map +1 -0
  70. package/dist/fixtures/index.d.ts +4 -0
  71. package/dist/fixtures/index.d.ts.map +1 -0
  72. package/dist/fixtures/index.js +8 -0
  73. package/dist/fixtures/index.js.map +1 -0
  74. package/dist/fixtures/registry.d.ts +52 -0
  75. package/dist/fixtures/registry.d.ts.map +1 -0
  76. package/dist/fixtures/registry.js +341 -0
  77. package/dist/fixtures/registry.js.map +1 -0
  78. package/dist/generic-tube-fit.d.ts +12 -0
  79. package/dist/generic-tube-fit.d.ts.map +1 -0
  80. package/dist/generic-tube-fit.js +45 -0
  81. package/dist/generic-tube-fit.js.map +1 -0
  82. package/dist/grounded-clock-supply.d.ts +13 -0
  83. package/dist/grounded-clock-supply.d.ts.map +1 -0
  84. package/dist/grounded-clock-supply.js +45 -0
  85. package/dist/grounded-clock-supply.js.map +1 -0
  86. package/dist/identify.d.ts +50 -0
  87. package/dist/identify.d.ts.map +1 -0
  88. package/dist/identify.js +319 -0
  89. package/dist/identify.js.map +1 -0
  90. package/dist/index.d.ts +10 -0
  91. package/dist/index.d.ts.map +1 -0
  92. package/dist/index.js +19 -0
  93. package/dist/index.js.map +1 -0
  94. package/dist/inert-controls.d.ts +15 -0
  95. package/dist/inert-controls.d.ts.map +1 -0
  96. package/dist/inert-controls.js +266 -0
  97. package/dist/inert-controls.js.map +1 -0
  98. package/dist/latch-seed.d.ts +53 -0
  99. package/dist/latch-seed.d.ts.map +1 -0
  100. package/dist/latch-seed.js +257 -0
  101. package/dist/latch-seed.js.map +1 -0
  102. package/dist/link.d.ts +7 -0
  103. package/dist/link.d.ts.map +1 -0
  104. package/dist/link.js +270 -0
  105. package/dist/link.js.map +1 -0
  106. package/dist/lower.d.ts +79 -0
  107. package/dist/lower.d.ts.map +1 -0
  108. package/dist/lower.js +1855 -0
  109. package/dist/lower.js.map +1 -0
  110. package/dist/netlist.d.ts +79 -0
  111. package/dist/netlist.d.ts.map +1 -0
  112. package/dist/netlist.js +4746 -0
  113. package/dist/netlist.js.map +1 -0
  114. package/dist/non-executable-clock-drivers.d.ts +8 -0
  115. package/dist/non-executable-clock-drivers.d.ts.map +1 -0
  116. package/dist/non-executable-clock-drivers.js +33 -0
  117. package/dist/non-executable-clock-drivers.js.map +1 -0
  118. package/dist/open-category.d.ts +98 -0
  119. package/dist/open-category.d.ts.map +1 -0
  120. package/dist/open-category.js +111 -0
  121. package/dist/open-category.js.map +1 -0
  122. package/dist/over-driven-node.d.ts +33 -0
  123. package/dist/over-driven-node.d.ts.map +1 -0
  124. package/dist/over-driven-node.js +188 -0
  125. package/dist/over-driven-node.js.map +1 -0
  126. package/dist/part-catalog.d.ts +15 -0
  127. package/dist/part-catalog.d.ts.map +1 -0
  128. package/dist/part-catalog.js +6712 -0
  129. package/dist/part-catalog.js.map +1 -0
  130. package/dist/part-number.d.ts +32 -0
  131. package/dist/part-number.d.ts.map +1 -0
  132. package/dist/part-number.js +80 -0
  133. package/dist/part-number.js.map +1 -0
  134. package/dist/partition.d.ts +7 -0
  135. package/dist/partition.d.ts.map +1 -0
  136. package/dist/partition.js +242 -0
  137. package/dist/partition.js.map +1 -0
  138. package/dist/port-full-scale.d.ts +70 -0
  139. package/dist/port-full-scale.d.ts.map +1 -0
  140. package/dist/port-full-scale.js +235 -0
  141. package/dist/port-full-scale.js.map +1 -0
  142. package/dist/pot-orientation.d.ts +114 -0
  143. package/dist/pot-orientation.d.ts.map +1 -0
  144. package/dist/pot-orientation.js +551 -0
  145. package/dist/pot-orientation.js.map +1 -0
  146. package/dist/power-domain-control.d.ts +40 -0
  147. package/dist/power-domain-control.d.ts.map +1 -0
  148. package/dist/power-domain-control.js +142 -0
  149. package/dist/power-domain-control.js.map +1 -0
  150. package/dist/proxy-declaration.d.ts +121 -0
  151. package/dist/proxy-declaration.d.ts.map +1 -0
  152. package/dist/proxy-declaration.js +50 -0
  153. package/dist/proxy-declaration.js.map +1 -0
  154. package/dist/registry.d.ts +323 -0
  155. package/dist/registry.d.ts.map +1 -0
  156. package/dist/registry.js +192 -0
  157. package/dist/registry.js.map +1 -0
  158. package/dist/sparse-schedule.d.ts +65 -0
  159. package/dist/sparse-schedule.d.ts.map +1 -0
  160. package/dist/sparse-schedule.js +421 -0
  161. package/dist/sparse-schedule.js.map +1 -0
  162. package/dist/speaker-load.d.ts +38 -0
  163. package/dist/speaker-load.d.ts.map +1 -0
  164. package/dist/speaker-load.js +97 -0
  165. package/dist/speaker-load.js.map +1 -0
  166. package/dist/stage-coverage.d.ts +21 -0
  167. package/dist/stage-coverage.d.ts.map +1 -0
  168. package/dist/stage-coverage.js +124 -0
  169. package/dist/stage-coverage.js.map +1 -0
  170. package/dist/stamp-partition.d.ts +314 -0
  171. package/dist/stamp-partition.d.ts.map +1 -0
  172. package/dist/stamp-partition.js +472 -0
  173. package/dist/stamp-partition.js.map +1 -0
  174. package/dist/supply-reference.d.ts +19 -0
  175. package/dist/supply-reference.d.ts.map +1 -0
  176. package/dist/supply-reference.js +66 -0
  177. package/dist/supply-reference.js.map +1 -0
  178. package/dist/supply-short.d.ts +32 -0
  179. package/dist/supply-short.d.ts.map +1 -0
  180. package/dist/supply-short.js +118 -0
  181. package/dist/supply-short.js.map +1 -0
  182. package/dist/supply-stamps.d.ts +51 -0
  183. package/dist/supply-stamps.d.ts.map +1 -0
  184. package/dist/supply-stamps.js +363 -0
  185. package/dist/supply-stamps.js.map +1 -0
  186. package/dist/terminal-role.d.ts +109 -0
  187. package/dist/terminal-role.d.ts.map +1 -0
  188. package/dist/terminal-role.js +46 -0
  189. package/dist/terminal-role.js.map +1 -0
  190. package/dist/terminal-roles.d.ts +59 -0
  191. package/dist/terminal-roles.d.ts.map +1 -0
  192. package/dist/terminal-roles.js +92 -0
  193. package/dist/terminal-roles.js.map +1 -0
  194. package/dist/transformer.d.ts +181 -0
  195. package/dist/transformer.d.ts.map +1 -0
  196. package/dist/transformer.js +476 -0
  197. package/dist/transformer.js.map +1 -0
  198. package/dist/types.d.ts +3157 -0
  199. package/dist/types.d.ts.map +1 -0
  200. package/dist/types.js +11 -0
  201. package/dist/types.js.map +1 -0
  202. package/dist/unbounded-operating-point.d.ts +35 -0
  203. package/dist/unbounded-operating-point.d.ts.map +1 -0
  204. package/dist/unbounded-operating-point.js +181 -0
  205. package/dist/unbounded-operating-point.js.map +1 -0
  206. package/dist/unexecuted-region.d.ts +3 -0
  207. package/dist/unexecuted-region.d.ts.map +1 -0
  208. package/dist/unexecuted-region.js +122 -0
  209. package/dist/unexecuted-region.js.map +1 -0
  210. package/dist/unimplemented-control-role.d.ts +3 -0
  211. package/dist/unimplemented-control-role.d.ts.map +1 -0
  212. package/dist/unimplemented-control-role.js +61 -0
  213. package/dist/unimplemented-control-role.js.map +1 -0
  214. package/dist/unreachable-output.d.ts +3 -0
  215. package/dist/unreachable-output.d.ts.map +1 -0
  216. package/dist/unreachable-output.js +97 -0
  217. package/dist/unreachable-output.js.map +1 -0
  218. package/dist/unreadable-terminal-role.d.ts +67 -0
  219. package/dist/unreadable-terminal-role.d.ts.map +1 -0
  220. package/dist/unreadable-terminal-role.js +409 -0
  221. package/dist/unreadable-terminal-role.js.map +1 -0
  222. package/dist/unverified-pinout.d.ts +27 -0
  223. package/dist/unverified-pinout.d.ts.map +1 -0
  224. package/dist/unverified-pinout.js +121 -0
  225. package/dist/unverified-pinout.js.map +1 -0
  226. package/package.json +75 -5
@@ -0,0 +1,4746 @@
1
+ // Stage 1: `.vdsp` source text -> Netlist.
2
+ //
3
+ // This is the only stage permitted to touch the format libraries, and the only stage
4
+ // that ever sees geometry, names or prose. Everything it does not put into the
5
+ // Netlist is unreachable from every later stage, which is the whole point: the
6
+ // evidence discipline is a property of the type, not a rule someone has to follow.
7
+ //
8
+ // Dropped here, permanently: origins, rotation, flip, terminal coordinates, wires,
9
+ // panel layout, appearance, component names, Description, Role, and every other
10
+ // free-text field. Kept: typed kinds, resolved nodes, parameters as numbers,
11
+ // controls, and the closed identity evidence stage 2 needs.
12
+ //
13
+ // Node precedence, stated once: the document-level `nodes:` ledger is authoritative when
14
+ // present for a terminal; the terminal's inline `node:` is fallback where the ledger is
15
+ // absent; geometry resolves only what neither encoding states. Geometry is used to
16
+ // *resolve* nodes and is then discarded -- no coordinate survives into the Netlist, so no
17
+ // later stage can accidentally derive connectivity from one.
18
+ import { componentDevices, deviceTerminalRoles, parseInterchangeYamlWithTopology, } from "@vessel-dsp/core";
19
+ import { load as loadYaml } from "js-yaml";
20
+ import { StageRefusal } from "./errors.js";
21
+ import { foldToken } from "./registry.js";
22
+ import { findBistableLatches } from "./latch-seed.js";
23
+ import { speakerOnePort } from "./speaker-load.js";
24
+ import { GROUND, } from "./types.js";
25
+ export class NetlistError extends StageRefusal {
26
+ constructor(message, device = null, cause) {
27
+ super("netlist", device, message, cause);
28
+ }
29
+ }
30
+ /** Source component kinds that map to a device the compiler models. */
31
+ const deviceKindByComponentKind = {
32
+ resistor: "resistor",
33
+ capacitor: "capacitor",
34
+ inductor: "inductor",
35
+ diode: "diode",
36
+ led: "diode",
37
+ potentiometer: "potentiometer",
38
+ // A rheostat, not a pot. Every one of the corpus's variable-resistors has exactly
39
+ // two terminals, so aliasing it to `potentiometer` refused all of them for want of
40
+ // a wiper the device does not have.
41
+ "variable-resistor": "rheostat",
42
+ opamp: "opamp",
43
+ // 126 of the 24 canonical amp documents' tube instances are triodes, and every one of
44
+ // those documents refused at this table until now.
45
+ triode: "triode",
46
+ pentode: "pentode",
47
+ // Three instances across the 24 amp documents, and all three are **dual** rectifiers:
48
+ // `plate_a`, `plate_b` and one shared `cathode_filament`. See `tubeDiodeElements`.
49
+ "tube-diode": "tube-diode",
50
+ bjt: "bjt",
51
+ jfet: "jfet",
52
+ mosfet: "mosfet",
53
+ transformer: "transformer",
54
+ "voltage-source": "voltage-source",
55
+ battery: "voltage-source",
56
+ jack: "jack",
57
+ ground: "ground",
58
+ rail: "rail",
59
+ switch: "switch",
60
+ // **A fuse is a resistor, not a switch** (`kind: fuse`, core 0.6.38). It conducts until it
61
+ // opens once and nothing operates it, so it has no control to bind and no throw to select.
62
+ // Mapping it here is also what stops it being offered as one: the control fallback in
63
+ // `controlIdFor` fires for `potentiometer`, `rheostat` and `switch`, and a resistor is none
64
+ // of those -- so `vox-ac30-top-boost`'s four fuses stop arriving as knobs a player can sweep,
65
+ // with no property read and no spelling table.
66
+ fuse: "resistor",
67
+ ic: "ic",
68
+ bbd: "ic",
69
+ "delay-ic": "ic",
70
+ flipflop: "ic",
71
+ "analog-switch": "analog-switch",
72
+ regulator: "ic",
73
+ "power-converter": "ic",
74
+ ota: "ota",
75
+ // **Its own kind, not `ic`.** An optocoupler carries a universal device-class law -- an LED
76
+ // driving a photoresistor -- so an unregistered one must still work, exactly as an `ota` does.
77
+ // Mapping it to `ic` made the registry the only source of its behaviour, which is right for a
78
+ // VTL5C1 and wrong for the discrete neon/LDR pair in the Fender tremolo circuits: they name no
79
+ // part to look up, so they resolved to `open` and the tremolo was silent unless something read
80
+ // their terminal *names*. See `mayCarryRegistrySections`, which is what keeps the 13 packets
81
+ // whose optocouplers *are* registry parts resolving through their sections.
82
+ // A logic gate names itself as of `@vessel-dsp/core@0.6.39`, so a hex inverter can declare its
83
+ // six gates as devices instead of depending on a registry entry keyed on the part number to
84
+ // split it. The device kind, the law and the stamp all already existed; only the source had no
85
+ // way to say the word.
86
+ inverter: "inverter",
87
+ optocoupler: "optocoupler",
88
+ "power-amp": "power-amp",
89
+ };
90
+ /**
91
+ * Kinds that carry no electrical behaviour and are dropped rather than modelled.
92
+ *
93
+ * `port` is here because a schematic port is usually an annotation — a sheet connector, a test
94
+ * point, a name for a net. **But a port that declares a typed `Voltage` is a rail**, and dropping
95
+ * those cost the corpus 76 of them across 39 packets, 24 of which compile: `boss-sd-1`'s
96
+ * `VBIAS_IN` sits on node 4 with `{raw: "4.5 V", value: 4.5, unit: "V"}` and the description
97
+ * "SD-1 virtual half-supply bias rail", and `klon-centaur` loses 4.5 V, 18 V and -9 V at once.
98
+ *
99
+ * The consequence was not a refusal but a *wrong answer that renders*: the bias resistor's far end
100
+ * became a node nothing else touched, the stage sat unbiased, the pedal went silent, and
101
+ * `active-device-terminal-unwired` reported the terminal as unwired. Several packets were filed as
102
+ * source omissions on the strength of that.
103
+ *
104
+ * See `promotedKindFor` for how the decision is made, and note that it is the *presence of a typed
105
+ * property*, never the component's name or description — both of which say "rail" here and must
106
+ * stay unread.
107
+ */
108
+ const ignoredComponentKinds = new Set([
109
+ "label",
110
+ "named-wire",
111
+ "display",
112
+ "port",
113
+ "unsupported",
114
+ ]);
115
+ /**
116
+ * Terminal roles that name an op-amp's driven output.
117
+ *
118
+ * An op-amp output forces its node exactly as a supply does, so it counts as a potential the
119
+ * circuit already generates. **Only the output**: an op-amp's inputs draw no current and assert
120
+ * nothing, and treating all three terminals as driven would suppress the promotion `boss-sd-1`
121
+ * needs, whose `VBIAS_OP` reaches an op-amp *input* through its bias resistor.
122
+ *
123
+ * Missing this cost `boss-bd-2-blues-driver` and its Keeley mod a `node-driven-by-two-sources`
124
+ * warning apiece: their promoted rail landed on a node an op-amp output already drove.
125
+ */
126
+ const opampOutputRoles = new Set([
127
+ "out",
128
+ "output",
129
+ "vout",
130
+ ]);
131
+ /**
132
+ * Role tokens naming an op-amp's **supply** pin, folded as `terminalRoleToken` leaves them.
133
+ *
134
+ * An op-amp is powered by its rails: a rail is an input to every op-amp that touches it, and no
135
+ * op-amp *output* generates one. `generated()` below uses this to stop the op-amp-output branch
136
+ * from certifying a supply node.
137
+ *
138
+ * Built from an exhaustive census of every op-amp terminal-role token the corpus declares.
139
+ * Excludes `vplus`/`vminus` -- and the original justification for that ("`lower.ts`'s
140
+ * `opampInputRoles` documents them as this corpus's input spelling") did not survive a sharper
141
+ * census: checked against each op-amp's *full* role set (2026-08-25, `device-laws.ts`'s
142
+ * `opampRails`), those spellings are supply rails in all 60 corpus devices that declare them.
143
+ * The exclusion is kept anyway, on a different and honest ground: leaving them out only
144
+ * *narrows* the no-generate guard below, which is the safe direction, and widening it changes
145
+ * which rails `generated()` protects -- a behaviour change that needs its own corpus
146
+ * measurement, not a comment edit. The numbered `vpluspin8`/`vminuspin4` ARE included, because
147
+ * pins 8 and 4 of a dual op-amp are unambiguously its rails. `v+`/`v` (folded from `v-`) is
148
+ * `boss-ce-2`'s supply pair under that document's own naming.
149
+ */
150
+ const opampSupplyRoles = new Set([
151
+ "vcc",
152
+ "vee",
153
+ "vccpin8",
154
+ "veepin4",
155
+ "vpluspin8",
156
+ "vminuspin4",
157
+ "positivesupply",
158
+ "negativesupply",
159
+ "supply",
160
+ "v+",
161
+ "v",
162
+ ]);
163
+ /**
164
+ * DC-conducting device kinds, for asking whether a node's potential is already generated.
165
+ *
166
+ * Exported so `lower.ts` can reuse the identical evidence class for a pot's orientation --
167
+ * which end sits electrically closer to a quiet DC reference -- rather than defining a second,
168
+ * possibly-drifting closed set for the same question of "what conducts a steady current here".
169
+ */
170
+ export const dcConductingKinds = new Set([
171
+ "resistor",
172
+ "potentiometer",
173
+ "rheostat",
174
+ "inductor",
175
+ "switch",
176
+ "selector",
177
+ ]);
178
+ /**
179
+ * A rectifier's electrode roles, coarsened to just "supply-side" and "load-side", for the single
180
+ * question `voltagePortRails` asks: does current reach this node from somewhere already asserted?
181
+ *
182
+ * **A fresh, deliberately coarser copy of `lower.ts`'s `diodeTerminalRoles` /
183
+ * `tubeDiodeTerminalRoles`, not an import.** Those tables answer a harder question -- exactly how
184
+ * many junctions a component is and which pairs they connect, needed to lower a bridge or a
185
+ * shared-cathode pack to the right elements -- and live in `lower.ts`, which already imports
186
+ * `dcConductingKinds` from this file; importing the other way would be a cycle. This table only
187
+ * needs "which terminals are upstream (anode/plate/AC leg) and which are downstream (cathode/DC
188
+ * leg)", which every one of those roles already answers unambiguously, so one closed table
189
+ * covering both `diode` and `tube-diode` roles is enough here.
190
+ */
191
+ const rectifierElectrodeRoles = {
192
+ anode: "supply",
193
+ anodea: "supply",
194
+ anodeb: "supply",
195
+ redanode: "supply",
196
+ greenanode: "supply",
197
+ plate: "supply",
198
+ platea: "supply",
199
+ plateb: "supply",
200
+ aca: "supply",
201
+ acb: "supply",
202
+ acleft: "supply",
203
+ acright: "supply",
204
+ cathode: "load",
205
+ cathodefilament: "load",
206
+ positive: "load",
207
+ positiveraw: "load",
208
+ negative: "load",
209
+ negativeraw: "load",
210
+ };
211
+ /**
212
+ * Ports that assert a potential nothing else in the circuit can generate, as rail devices.
213
+ *
214
+ * **A voltage-carrying port is not always a rail, and getting that wrong regresses the corpus.**
215
+ * `ibanez-ts808` declares `VBIAS_4V5` at 4.5 V on node 3, and that node already reaches the 9 V
216
+ * supply through the divider the packet models — so the port *documents* a node the circuit makes
217
+ * for itself, and forcing an ideal source there double-drives it. Measured: promoting every
218
+ * voltage port took parity from 33 agrees to 31 and turned both Tube Screamers from `agrees` into
219
+ * `both silent`, with 8 A of supply draw.
220
+ *
221
+ * `boss-sd-1` is the opposite and is why this exists at all. Its `VBIAS_IN` sits on node 4, which
222
+ * **reaches no supply through any conductor**, so nothing in the modelled circuit can put a
223
+ * potential there. Dropping it left the bias resistor's far end touched by one terminal, the stage
224
+ * unbiased, the pedal silent, and `active-device-terminal-unwired` reporting a dangling terminal
225
+ * that is not dangling in the source.
226
+ *
227
+ * So the test is whether the node's potential is *already generated*, which is a topological
228
+ * question with no ratio in it — unlike bias adequacy, where two structural rules were measured
229
+ * and rejected.
230
+ *
231
+ * **A rectifier output is generated too, decided 2026-08-14 and added the same way.** A B+
232
+ * reservoir fed only through a `diode` or `tube-diode` used to read as ungenerated, because
233
+ * neither kind conducts DC the way `dcConductingKinds`' set does -- so 94 typed-`Voltage` ports
234
+ * across 21 of 24 amp documents were promoted to ideal rails, deleting the rectifier/reservoir
235
+ * supply that actually feeds them (measured: rail 0.78 V high, ripple suppressed 3.7x, the
236
+ * rectifier passing a thirteenth of the current, no warning). The fix is directional and
237
+ * one-way on purpose -- a load-side (cathode) node may reach back to its supply-side (anode)
238
+ * node, never the other way -- because a rectifier only conducts anode-to-cathode: treating it
239
+ * as bidirectional would let an unrelated node on a diode's load side (a clipping diode's
240
+ * return, a ground reference) retroactively call the *supply* side "generated", which is the
241
+ * same over-broad promotion shape that took parity from 33 to 31 above. This is additive to
242
+ * that history, not a repeat of it: it recognises one more thing that already generates a
243
+ * node's potential, it does not stop checking whether one does.
244
+ *
245
+ * Deduplicated per node: `marshall-blues-breaker` declares `V2`, `V3`, `V4` and `V5` all at 4.5 V
246
+ * on node 7, and four ideal sources on one node is the over-determined system
247
+ * `node-driven-by-two-sources` exists to name.
248
+ *
249
+ * **An op-amp output is only an anchor if reaching it does not loop back through its own other
250
+ * terminals, found 2026-08-14.** `jhs-morning-glory`'s `VBIAS` and `vemuram-jan-ray`'s `VREF` are
251
+ * each declared exactly the `boss-sd-1` way -- a typed-`Voltage` port with no independently-wired
252
+ * divider -- and each was silently not promoted, because the BFS below reached that op-amp's own
253
+ * output through a resistor network that passes back through that *same* op-amp's own input
254
+ * first. That is the BFS re-entering through the device it started from: an op-amp's output is a
255
+ * *dependent* source (a function of its own inputs), so proving a candidate node can reach it by a
256
+ * path that runs through the op-amp's own terminals proves nothing about independent generation --
257
+ * `render:v2 --bias` shows both islands settle away from their declared value (one collapses to
258
+ * millivolts, one to a flat ~3.8 V blob), and both still "agree" with ngspice, because the parity
259
+ * deck is emitted from this same program and inherits the same missing rail. Full trace in
260
+ * `docs/troubleshootings/vbias-vref-ports-not-promoted-through-a-self-referential-opamp-loop.md`.
261
+ *
262
+ * Asking the same question of `loadToSupply` and `windingCoupling` beside it: neither is exposed,
263
+ * because neither adds one of *its own* device's nodes to `asserted` the way the op-amp rule does
264
+ * -- both only add edges (extended reachability), so there is nothing for either rectifier or
265
+ * transformer to circularly assert about itself. The op-amp rule is the only one of the three that
266
+ * claims one of its own terminals is independently generated, so it is the only one that needs
267
+ * this guard.
268
+ */
269
+ function voltagePortRails(components, devices, nodeOf) {
270
+ const asserted = new Set();
271
+ /**
272
+ * An op-amp output node, mapped to every *other* node that same op-amp device touches.
273
+ *
274
+ * Ground (node 0) is excluded from the exclusion set for the same reason `loadToSupply` and
275
+ * `windingCoupling` exclude it from their own maps: node 0 is the one node every unrelated
276
+ * grounded device in the document shares, so barring it as a pass-through would block unrelated
277
+ * legitimate paths, not just this op-amp's self-reference. Real independent sources (a
278
+ * `voltage-source`/`rail` node, including this same op-amp's own `vcc`/`vee` pins, which are
279
+ * ordinarily wired to one) are never excluded either: they are checked by the unconstrained walk
280
+ * below before any op-amp-specific exclusion applies, so barring them here would only weaken
281
+ * this op-amp's own reachability check, never the independent-source one.
282
+ */
283
+ const opampSelfTerminals = new Map();
284
+ const opampSupplyNodes = new Set();
285
+ /**
286
+ * Nodes an op-amp *input* terminal sits on.
287
+ *
288
+ * **No op-amp input generates the potential it is handed.** The docstring on
289
+ * `opampOutputRoles` already states the half of this that concerns one op-amp's own
290
+ * terminals -- "an op-amp's inputs draw no current and assert nothing" -- but the
291
+ * per-op-amp exclusion below only bars *that* op-amp's nodes, so a second op-amp's output,
292
+ * reached through the network, still certified the first one's reference and the port on it
293
+ * was dropped. Every dual op-amp sharing one half-supply bias rail is that shape.
294
+ *
295
+ * Measured 2026-09-10 by compiling every packet that declares a voltage port and asking
296
+ * which ports never reach a `dc-source` stamp: 10 of 15 across 6 packets were dropped, and
297
+ * **two** of them are this class -- `analog-man-prince-of-tone` (`VB` 4.5 V) and
298
+ * `klon-centaur` (`VBPLUS_RAIL` 4.5 V), each a half-supply port declared **on** an op-amp's
299
+ * non-inverting input. What that costs is not a refusal but a wrong answer that renders: the
300
+ * bias node floats, the stage biases itself against a rail through its own feedback network,
301
+ * audio still crosses the packet through the passives, and every control reads flat.
302
+ * `analog-man-prince-of-tone` measured 0.0 dB on all four knobs at an output within 2% of
303
+ * its input, and was carried in the ledger as one of two packets whose whole panel is dead
304
+ * with *no* structural explanation.
305
+ *
306
+ * The other eight dropped ports are **not** this and are deliberately left dropped: they are
307
+ * packet-side. `boss-sp-1-spectrum` declares `Voltage: 4.5V nominal`, which is not a
308
+ * quantity, so its port is skipped before this guard is ever consulted -- the same shape as
309
+ * `boss-ce-1`'s three MN3002 bias ports and `boss-dd-3a`'s two. Naming that here keeps the
310
+ * two causes apart: this guard recovers a port the document declared correctly, and it
311
+ * cannot recover one the document did not.
312
+ *
313
+ * Read from `declaredTerminalRoles` -- the same evidence `lower.ts`'s `opampTerminals` uses
314
+ * to place the stamp -- rather than a second folded-token set beside `opampSupplyRoles`,
315
+ * because two vocabularies for one question are how the pin maps drifted.
316
+ *
317
+ * Ordered after the unconstrained walk in `generated()`, exactly as `opampSupplyNodes` is: a
318
+ * bias node genuinely fed by a real independent source is still found by that walk and still
319
+ * left unforced, so this can only narrow the guard, never overrule a real supply.
320
+ */
321
+ const opampInputNodes = new Set();
322
+ for (const device of devices) {
323
+ if (device.kind === "voltage-source" || device.kind === "rail") {
324
+ for (const node of device.nodes) {
325
+ if (node !== 0) {
326
+ asserted.add(node);
327
+ }
328
+ }
329
+ continue;
330
+ }
331
+ if (device.kind !== "opamp") {
332
+ continue;
333
+ }
334
+ device.identity.terminalRoles.forEach((role, index) => {
335
+ const node = device.nodes[index];
336
+ if (role !== null &&
337
+ opampSupplyRoles.has(role) &&
338
+ node !== undefined &&
339
+ node !== 0) {
340
+ opampSupplyNodes.add(node);
341
+ }
342
+ if (role !== null &&
343
+ opampOutputRoles.has(role) &&
344
+ node !== undefined &&
345
+ node !== 0) {
346
+ const own = new Set();
347
+ for (const other of device.nodes) {
348
+ if (other !== node && other !== 0) {
349
+ own.add(other);
350
+ }
351
+ }
352
+ opampSelfTerminals.set(node, own);
353
+ }
354
+ });
355
+ device.identity.declaredTerminalRoles.forEach((role, index) => {
356
+ const node = device.nodes[index];
357
+ if ((role === "nonInverting" || role === "inverting") &&
358
+ node !== undefined &&
359
+ node !== 0) {
360
+ opampInputNodes.add(node);
361
+ }
362
+ });
363
+ }
364
+ const adjacency = new Map();
365
+ for (const device of devices) {
366
+ if (!dcConductingKinds.has(device.kind)) {
367
+ continue;
368
+ }
369
+ for (let i = 0; i < device.nodes.length; i += 1) {
370
+ for (let j = i + 1; j < device.nodes.length; j += 1) {
371
+ const a = device.nodes[i];
372
+ const b = device.nodes[j];
373
+ if (a === b) {
374
+ continue;
375
+ }
376
+ adjacency.set(a, [...(adjacency.get(a) ?? []), b]);
377
+ adjacency.set(b, [...(adjacency.get(b) ?? []), a]);
378
+ }
379
+ }
380
+ }
381
+ // A rectifier's load-side (cathode) node may step back to its supply-side (anode) node --
382
+ // never the reverse, see `rectifierElectrodeRoles` above. Kept as its own directed map rather
383
+ // than folded into `adjacency`, which is symmetric by construction.
384
+ //
385
+ // Ground (node 0) excluded from both roles, for the same reason the transformer coupling
386
+ // below excludes it: a bridge's DC-negative leg is routinely grounded, and node 0 is the one
387
+ // node every unrelated device in the whole document can share, so keying or targeting an
388
+ // edge on it would let an unrelated node that merely touches ground borrow a rectifier's
389
+ // supply-side status through no real conduction path at all.
390
+ const loadToSupply = new Map();
391
+ for (const device of devices) {
392
+ if (device.kind !== "diode" && device.kind !== "tube-diode") {
393
+ continue;
394
+ }
395
+ const supplyNodes = [];
396
+ const loadNodes = [];
397
+ device.identity.terminalRoles.forEach((role, index) => {
398
+ const node = device.nodes[index];
399
+ if (node === undefined || node === 0 || role === null) {
400
+ return;
401
+ }
402
+ const electrode = rectifierElectrodeRoles[role];
403
+ if (electrode === "supply") {
404
+ supplyNodes.push(node);
405
+ }
406
+ else if (electrode === "load") {
407
+ loadNodes.push(node);
408
+ }
409
+ });
410
+ for (const load of loadNodes) {
411
+ for (const supply of supplyNodes) {
412
+ if (load === supply) {
413
+ continue;
414
+ }
415
+ loadToSupply.set(load, [...(loadToSupply.get(load) ?? []), supply]);
416
+ }
417
+ }
418
+ }
419
+ // A transformer winding may borrow generated status from another winding of the *same*
420
+ // transformer that is itself supply-reachable -- decided 2026-08-14, alongside the
421
+ // rectifier fix above. "Conducts DC" and "generates this node's potential" are different
422
+ // questions: a transformer is correctly excluded from `dcConductingKinds` because it does
423
+ // not conduct DC (galvanic isolation is real), but generating a secondary's potential from a
424
+ // primary's is the device's entire function, so it must count for *this* question.
425
+ //
426
+ // Deliberately **not** read from `primary`/`secondary` role names: those are terminal labels,
427
+ // not evidence of which side drives the other (a power transformer's primary is mains-driven;
428
+ // an output transformer's primary is plate-driven, and both call the driven side "primary").
429
+ // So every terminal of one transformer device is treated as one mutually-coupled group --
430
+ // finer-grained winding boundaries are a strict subset of this and would reach the same
431
+ // answer, since two terminals of one physical winding are already connected once either
432
+ // reaches an asserted node through this same coupling.
433
+ //
434
+ // Kept symmetric rather than directional like `loadToSupply`: unlike a rectifier, a
435
+ // transformer's coupling is not one-way, and guessing a direction from role names is exactly
436
+ // what this decision forbids. This still cannot run backwards into a source by construction
437
+ // -- `asserted` is fixed before this map is built and this map is never written to it, only
438
+ // read from during the BFS below, so a winding can be discovered generated but can never
439
+ // become a generator itself.
440
+ const windingCoupling = new Map();
441
+ for (const device of devices) {
442
+ if (device.kind !== "transformer") {
443
+ continue;
444
+ }
445
+ // Ground (node 0) excluded: it is the one node every unrelated device in the whole
446
+ // document can share (a primary return, a centre tap), and unlike a real winding
447
+ // terminal it asserts nothing on its own -- `asserted` never contains it either. Coupling
448
+ // through it would bridge two unrelated transformers that merely both ground a leg, which
449
+ // is a wider mistake than the one this fix exists to make safe.
450
+ const nodes = [...new Set(device.nodes)].filter((node) => node !== 0);
451
+ for (let i = 0; i < nodes.length; i += 1) {
452
+ for (let j = i + 1; j < nodes.length; j += 1) {
453
+ const a = nodes[i];
454
+ const b = nodes[j];
455
+ windingCoupling.set(a, [...(windingCoupling.get(a) ?? []), b]);
456
+ windingCoupling.set(b, [...(windingCoupling.get(b) ?? []), a]);
457
+ }
458
+ }
459
+ }
460
+ /**
461
+ * Every node reachable from `from` over `adjacency`, `loadToSupply` and `windingCoupling`,
462
+ * without ever stepping onto a node in `excluded` or node 0 (ground). `from` itself is
463
+ * excluded too if it is in `excluded` or is 0 -- the walk cannot even start, which is exactly
464
+ * right when `from` *is* one of the op-amp's own other terminals or ground: ground is a 0 V
465
+ * reference sink that does not conduct DC power supply or op-amp output potentials between
466
+ * unrelated branches.
467
+ */
468
+ const reachableWithout = (from, excluded) => {
469
+ const seen = new Set();
470
+ if (excluded.has(from) || from === 0) {
471
+ return seen;
472
+ }
473
+ seen.add(from);
474
+ const queue = [from];
475
+ while (queue.length > 0) {
476
+ const at = queue.pop();
477
+ for (const next of [
478
+ ...(adjacency.get(at) ?? []),
479
+ ...(loadToSupply.get(at) ?? []),
480
+ ...(windingCoupling.get(at) ?? []),
481
+ ]) {
482
+ if (next === 0 || excluded.has(next) || seen.has(next)) {
483
+ continue;
484
+ }
485
+ seen.add(next);
486
+ queue.push(next);
487
+ }
488
+ }
489
+ return seen;
490
+ };
491
+ const generated = (from) => {
492
+ // Real independent sources first, with no exclusion: a `voltage-source`/`rail` node asserts
493
+ // nothing about itself circularly, so plain reachability is the whole question.
494
+ const unconstrained = reachableWithout(from, new Set());
495
+ for (const node of unconstrained) {
496
+ if (asserted.has(node)) {
497
+ return true;
498
+ }
499
+ }
500
+ // **No op-amp output generates a supply rail.** An op-amp is powered by its rails, so a
501
+ // rail is an input to every op-amp that touches it. The per-op-amp exclusion below is not
502
+ // enough on its own: it bars only *that* op-amp's terminals, so an op-amp on a *different*
503
+ // rail certifies this one. Measured on `sunn-beta-lead`, whose Channel A +15 V rail was
504
+ // declared "generated" by three Channel B op-amps (`IC108A`, `IC111A`, `IC111B`, all on
505
+ // nodes 22/21) reached through a protection diode from an op-amp output to the rail --
506
+ // `CR103`, whose cathode sits on +15 and which can only ever dump *into* that rail. Both
507
+ // of that packet's +15 V rail ports were dropped, its supplies floated, and its solve ran
508
+ // away to 1e283 V. The unconstrained walk above still runs first, so a rail genuinely fed
509
+ // by a real source is still correctly left unforced.
510
+ if (opampSupplyNodes.has(from)) {
511
+ return false;
512
+ }
513
+ // **No op-amp input generates the reference it is handed** -- the same argument one line
514
+ // up, on the other side of the part. See `opampInputNodes` for the two packets this was
515
+ // measured on and for why it cannot overrule a real source.
516
+ //
517
+ // **Unless an op-amp output is on that same node**, which a unity-gain follower always
518
+ // puts there: its output *is* its inverting input. Without this second clause the rule
519
+ // reintroduced the exact regression `opampOutputRoles` records --
520
+ // `boss-bd-2-blues-driver` and its Keeley mod each raising `node-driven-by-two-sources`
521
+ // on node 5, where a promoted rail met an op-amp output that genuinely drives it. An
522
+ // output forces its node whatever else sits on it, so it wins over this clause.
523
+ if (opampInputNodes.has(from) && !opampSelfTerminals.has(from)) {
524
+ return false;
525
+ }
526
+ // Each op-amp output is checked on its own, excluding only *that* op-amp's own other
527
+ // terminals -- see the docstring above `voltagePortRails`. Skipped entirely when the
528
+ // unconstrained walk never reaches the output at all, which is the common case.
529
+ for (const [output, ownTerminals] of opampSelfTerminals) {
530
+ if (!unconstrained.has(output)) {
531
+ continue;
532
+ }
533
+ if (reachableWithout(from, ownTerminals).has(output)) {
534
+ return true;
535
+ }
536
+ }
537
+ return false;
538
+ };
539
+ const added = [];
540
+ const claimed = new Set();
541
+ for (const component of components) {
542
+ if (String(component.kind) !== "port" || component.terminals.length !== 1) {
543
+ continue;
544
+ }
545
+ const volts = optionalQuantity(component, "Voltage");
546
+ if (volts === null) {
547
+ continue;
548
+ }
549
+ const terminal = component.terminals[0];
550
+ if (terminal === undefined) {
551
+ continue;
552
+ }
553
+ const node = nodeOf(String(component.id), String(terminal.name), 0);
554
+ if (node === null || node === 0 || claimed.has(node) || generated(node)) {
555
+ continue;
556
+ }
557
+ claimed.add(node);
558
+ added.push({
559
+ id: String(component.id),
560
+ kind: "rail",
561
+ nodes: [node],
562
+ parameters: { volts },
563
+ control: null,
564
+ identity: {
565
+ partNumber: propertyString(component, "PartNumber"),
566
+ declaredType: typeof component.sourceTypeName === "string" &&
567
+ component.sourceTypeName.length > 0
568
+ ? component.sourceTypeName
569
+ : null,
570
+ terminalRoles: [terminalRoleToken(String(terminal.name))],
571
+ declaredTerminalRoles: [declaredRole(terminal)],
572
+ declaredWindings: null,
573
+ },
574
+ });
575
+ }
576
+ return added;
577
+ }
578
+ const parameterKeyByDeviceKind = {
579
+ resistor: "Resistance",
580
+ capacitor: "Capacitance",
581
+ inductor: "Inductance",
582
+ potentiometer: "Resistance",
583
+ rheostat: "Resistance",
584
+ "voltage-source": "Voltage",
585
+ transformer: "Ratio",
586
+ };
587
+ /**
588
+ * Second spellings of a value property, per kind, tried only after the primary is absent.
589
+ *
590
+ * **Per kind and not globally, because `R` is not a resistance.** The corpus uses it as a generic
591
+ * printed-value field: 196 resistors state their resistance there (`"470 ohm"`, `"10k"`), and so do
592
+ * 126 potentiometers, but 51 capacitors put a *capacitance* in it (`"100/16"`), 13 ICs a part
593
+ * designation (`"1/2NE570"`), and 7 transistors a part number (`"2SC2458GR"`). Reading `R` wherever
594
+ * it appears would be wrong for 86 of the 408 components carrying it — a part number parsed as ohms,
595
+ * which is the wrong-answer-that-still-renders this file exists to avoid.
596
+ *
597
+ * Surfaced by `report-source-coverage.ts`, whose synonym check exists for exactly this: a quantity
598
+ * the source states plainly under a spelling nothing reads, which refuses the component or silently
599
+ * takes a class default.
600
+ */
601
+ const fallbackParameterKeys = {
602
+ resistor: ["R"],
603
+ potentiometer: ["R"],
604
+ capacitor: ["C"],
605
+ inductor: ["L"],
606
+ };
607
+ const parameterNameByDeviceKind = {
608
+ resistor: "ohms",
609
+ capacitor: "farads",
610
+ inductor: "henries",
611
+ potentiometer: "ohms",
612
+ rheostat: "maxOhms",
613
+ "voltage-source": "volts",
614
+ transformer: "ratio",
615
+ };
616
+ export function readNetlist(source, options) {
617
+ // One parse. core 0.6.26 contract v1 merges the inline terminal `node:` keys and the
618
+ // `nodes:` ledger into a single connectivity, and refuses a document where the two
619
+ // disagree -- so the three recovery passes this stage ran, and the divergence warning that
620
+ // reported the disagreement, have nothing left to do.
621
+ const parsed = parseDocumentWithConnectivity(source);
622
+ // Properties only. The parser still flattens some structured quantities to strings, so this
623
+ // recovery stays until core preserves them everywhere; its node-token half is gone, replaced
624
+ // by the connectivity below.
625
+ const document = repairStructuredProperties(parsed.document, readRawComponents(source));
626
+ const declaredControlIds = declaredControlNames(document);
627
+ // Core's connectivity is authoritative only where the document *declares* it. When core
628
+ // falls back to geometry this stage keeps its own union-find instead: the two geometric
629
+ // resolutions are not the same, and swapping one for the other is a separate change from
630
+ // adopting the declared-connectivity contract.
631
+ const declaredNodes = parsed.connectivitySource === "declared"
632
+ ? declaredNodesFromConnectivity(document, parsed.connectivity)
633
+ : new Map();
634
+ const nodeOf = resolveNodes(document, declaredNodes);
635
+ const warnings = [];
636
+ /**
637
+ * **A switch throw or winding tap alone on its node cannot route anything.**
638
+ *
639
+ * `marshall-jcm800`'s output-transformer taps were stranded exactly this way: `secondary_4`
640
+ * and `throw_4` named node ids the document never declared, so each became its own isolated
641
+ * net and the impedance selector had two positions that went nowhere. Three sessions
642
+ * diagnosed the resulting output stage behaviourally.
643
+ *
644
+ * The **general** dangling-terminal rule was measured and rejected first — 2020 findings, no
645
+ * signal-to-noise (`dangling-active-terminal.ts` records it). This is that narrowing applied
646
+ * to routing, and measured the same way: of 1904 single-pin terminals in the corpus, 1858 are
647
+ * spare pins and test points, **25 are throws and 10 are winding taps** — 35 findings across
648
+ * 18 packets, a 54x cut. It also names, for free, bypass footswitches whose throws are
649
+ * unwired (`boss-bf-2`, `boss-ce-2`, `boss-ce-5`, `boss-ds-2`), which is a structural account
650
+ * of packets whose bypass never engages.
651
+ *
652
+ * Selected on the **typed `role` field**, never on the terminal's name: `role: throw` and
653
+ * `role: winding` are what the format states, and a name is prose.
654
+ */
655
+ if (parsed.connectivitySource === "declared") {
656
+ const memberCount = new Map();
657
+ for (const [node, pins] of parsed.connectivity.nodeMembers) {
658
+ memberCount.set(node, pins.length);
659
+ }
660
+ for (const component of document.components) {
661
+ // A potentiometer terminal alone on its node is broken whichever terminal it is: a
662
+ // divider needs all three joined to something and a rheostat shares two on one
663
+ // node, so neither shape leaves one stranded. Measured over the corpus's 559 pots:
664
+ // 323 dividers, 128 rheostats, and **31 with a terminal alone** -- and those 31 land
665
+ // on packets already flagged behaviourally, `boss-ch-1` (all four, silent),
666
+ // `boss-ce-5` (four, whole panel flat), `boss-mt-2`, `boss-sp-1-spectrum`,
667
+ // `mxr-m117r-flanger` and `earthquaker-devices-plumes`'s Gain.
668
+ const isPot = String(component.kind) === "potentiometer";
669
+ for (const terminal of component.terminals) {
670
+ const role = declaredRole(terminal);
671
+ if (!isPot && role !== "throw" && role !== "winding") {
672
+ continue;
673
+ }
674
+ const node = declaredNodes.get(declaredKey(String(component.id), String(terminal.name)));
675
+ if (node === undefined || node === GROUND) {
676
+ continue;
677
+ }
678
+ if ((memberCount.get(node) ?? 0) > 1) {
679
+ continue;
680
+ }
681
+ warnings.push({
682
+ code: "throw-or-winding-unwired",
683
+ device: String(component.id),
684
+ detail: `${component.id}.${terminal.name} (role ${role ?? "none"}) is alone on node ${node}: ` +
685
+ `a ${role === "throw" ? "switch position that routes nowhere" : isPot ? "potentiometer terminal joined to nothing, so the track cannot divide" : "winding tap nothing can select"}.`,
686
+ });
687
+ }
688
+ }
689
+ }
690
+ const devices = [];
691
+ const controlIds = new Set();
692
+ const ownerByControl = new Map();
693
+ // Controls whose every device is a two-terminal switch with no authored position,
694
+ // and so are candidates for the port-connectivity default below. A control that
695
+ // also drives anything else -- a pot, a selector, a switch that states a position
696
+ // -- is disqualified, because the source has spoken for it.
697
+ const unpositionedSwitchControls = new Set();
698
+ const positionedControls = new Set();
699
+ for (const component of document.components) {
700
+ const componentKind = String(component.kind);
701
+ if (ignoredComponentKinds.has(componentKind)) {
702
+ // `unsupported` is a modeled device class the format has a typed entry for but no
703
+ // solver law; `sourceTypeName` carries the source's own name for it. Other ignored
704
+ // kinds (label, port, etc.) are structural noise and do not name a device, so they
705
+ // stay silently dropped.
706
+ if (componentKind === "unsupported") {
707
+ const typeName = typeof component.sourceTypeName === "string" &&
708
+ component.sourceTypeName.length > 0
709
+ ? ` (${component.sourceTypeName})`
710
+ : "";
711
+ warnings.push({
712
+ code: "unsupported-component",
713
+ device: String(component.id),
714
+ detail: `Component ${component.id} is declared as an unsupported device class${typeName} and is excluded from the solve.`,
715
+ });
716
+ }
717
+ continue;
718
+ }
719
+ // The source marks this component interface or source context, not modeled graph. v1 gives
720
+ // it an interface-only / source-only disposition that keeps it out of the electrical
721
+ // analysis; v2 reads the same typed flag and drops it, so an open placeholder the source
722
+ // says not to model (schaller-tremolo's R_LED, InterfaceOnly and Value: open) stops
723
+ // refusing the pedal for a value the source deliberately omits.
724
+ if (isInterfaceOrSourceOnly(component)) {
725
+ warnings.push({
726
+ code: "interface-or-source-only",
727
+ device: String(component.id),
728
+ detail: `Component ${component.id} (${componentKind}) is declared interface-only or source-only and is excluded from the solve.`,
729
+ });
730
+ continue;
731
+ }
732
+ // An unpopulated position is an open circuit, so it is dropped for the same reason and
733
+ // by the same mechanism -- but under its own code, because "nothing is fitted here" and
734
+ // "this is interface context" are different facts about a document.
735
+ if (isNotPopulated(component)) {
736
+ // **Say whether this DNP is load-bearing**, i.e. whether dropping the component is
737
+ // the only reason the document compiles. See `NotPopulatedWithoutValueWarning`: a
738
+ // part that declares its value and is also marked DNP conceals nothing, while one
739
+ // that declares no value would have refused by name without the mark. Both are
740
+ // dropped -- an unpopulated position is an open circuit either way -- but only one
741
+ // of them is carrying the compile, and a reader should be told which.
742
+ //
743
+ // Read from the value property alone. Provenance fields (`SourceStatus`,
744
+ // `SourceConfidence`) would say more, and `report-source-integrity.ts` does read
745
+ // them to catch a `DNP` asserted over a deferred decision -- but they are source-lane
746
+ // metadata and lowering must not start branching on them.
747
+ const valueKey = parameterKeyByDeviceKind[componentKind];
748
+ const declaresValue = valueKey === undefined ||
749
+ propertyQuantityText(component, valueKey) !== null ||
750
+ (fallbackParameterKeys[componentKind] ?? []).some((fallback) => propertyQuantityText(component, fallback) !== null);
751
+ warnings.push(declaresValue
752
+ ? {
753
+ code: "not-populated",
754
+ device: String(component.id),
755
+ detail: `Component ${component.id} (${componentKind}) is declared DNP and is excluded from the solve as an unpopulated, open position. It declares its value, so what would sit there is still on the record.`,
756
+ }
757
+ : {
758
+ code: "not-populated-without-value",
759
+ device: String(component.id),
760
+ detail: `Component ${component.id} (${componentKind}) is declared DNP and declares no ${valueKey}, so dropping it is what allows this document to compile: without the DNP it would refuse by name. That is what a real unpopulated position looks like, and also what a guess looks like -- check it against the board.`,
761
+ });
762
+ continue;
763
+ }
764
+ // A component declaring no terminals is connected to nothing, so it has no
765
+ // nodes and cannot contribute a row or a column to any matrix. Dropping it
766
+ // removes nothing electrical, *by construction* -- which is the whole reason
767
+ // this is safe where silently dropping a connected inductor was not.
768
+ //
769
+ // The corpus is full of these: panel LEDs, footswitches from a photo, bias
770
+ // markers, pots from a build document. Refusing a pedal over an unconnected
771
+ // panel LED would be strictness with no purpose. Note that the decision is the
772
+ // empty terminal list and never the component's name or description, both of
773
+ // which say "photo" and "marker" here and must stay unread.
774
+ if (component.terminals.length === 0) {
775
+ if (Object.hasOwn(deviceKindByComponentKind, componentKind)) {
776
+ warnings.push({
777
+ code: "unconnected-behavior-component",
778
+ device: String(component.id),
779
+ detail: `Component ${component.id} (${component.kind}) is a behavior-owning part but declares 0 connected terminals and is skipped from MNA modeling.`,
780
+ });
781
+ }
782
+ continue;
783
+ }
784
+ const kind = deviceKindByComponentKind[componentKind];
785
+ if (kind === undefined) {
786
+ throw new NetlistError(`component ${component.id}: unsupported component kind "${componentKind}"`, String(component.id));
787
+ }
788
+ const rawNodes = component.terminals.map((terminal, index) => nodeOf(component.id, terminal.name, index));
789
+ const connectedIndices = [];
790
+ for (let i = 0; i < rawNodes.length; i++) {
791
+ if (rawNodes[i] !== null) {
792
+ connectedIndices.push(i);
793
+ }
794
+ }
795
+ if (connectedIndices.length === 0) {
796
+ if (Object.hasOwn(deviceKindByComponentKind, componentKind)) {
797
+ warnings.push({
798
+ code: "unconnected-behavior-component",
799
+ device: String(component.id),
800
+ detail: `Component ${component.id} (${component.kind}) is a behavior-owning part but declares 0 connected terminals and is skipped from MNA modeling.`,
801
+ });
802
+ }
803
+ continue;
804
+ }
805
+ if (component.terminals.length >= 2 &&
806
+ connectedIndices.length === 1 &&
807
+ kind !== "ic") {
808
+ if (Object.hasOwn(deviceKindByComponentKind, componentKind)) {
809
+ warnings.push({
810
+ code: "unconnected-behavior-component",
811
+ device: String(component.id),
812
+ detail: `Component ${component.id} (${component.kind}) has only 1 connected terminal and is skipped from MNA modeling.`,
813
+ });
814
+ }
815
+ continue;
816
+ }
817
+ let effectiveTerminals = component.terminals;
818
+ let nodes;
819
+ if (connectedIndices.length < component.terminals.length) {
820
+ if (kind === "ic" && connectedIndices.length >= 2) {
821
+ effectiveTerminals = connectedIndices.map((i) => component.terminals[i]);
822
+ nodes = connectedIndices.map((i) => rawNodes[i]);
823
+ }
824
+ else {
825
+ const missingIndex = rawNodes.findIndex((n) => n === null);
826
+ const missingTerminal = component.terminals[missingIndex];
827
+ throw new NetlistError(`component ${component.id}: terminal "${missingTerminal?.name}" has no declared node and no wire`, String(component.id));
828
+ }
829
+ }
830
+ else {
831
+ nodes = rawNodes;
832
+ }
833
+ // **A switch whose every connected terminal resolves to one node is a fixed
834
+ // bridge, not a variable** — its pole and throw are the same wire, so no
835
+ // position can change the circuit and exposing a control for it is noise.
836
+ // `boss-od-1`'s `INPUT_BATTERY_CONTACT` is the corpus case: a two-terminal
837
+ // contact with both ends on node 0, offered to the player as a knob to twist.
838
+ // Connectivity-only evidence (`new Set(nodes).size === 1`), because the
839
+ // document gives the device no role to refuse by. Measured across the corpus,
840
+ // four other switches sit on a single node and none of them gains a control
841
+ // from it: two are already unbound and the other two are declared controls,
842
+ // which bind before this gate is reached.
843
+ const fixedContactSwitch = kind === "switch" &&
844
+ nodes.length > 0 &&
845
+ new Set(nodes).size === 1;
846
+ const control = controlBindingFor(component, declaredControlIds, kind, fixedContactSwitch);
847
+ if (control !== null) {
848
+ controlIds.add(control);
849
+ if (kind === "switch" &&
850
+ nodes.length === 2 &&
851
+ !statesPosition(component)) {
852
+ unpositionedSwitchControls.add(control);
853
+ }
854
+ else {
855
+ positionedControls.add(control);
856
+ }
857
+ // The first device bound to a control supplies the taper and wiper position
858
+ // the control itself may not state. On a ganged control both devices carry
859
+ // the same values, so first is enough.
860
+ if (!ownerByControl.has(control)) {
861
+ ownerByControl.set(control, component);
862
+ }
863
+ }
864
+ const identityOf = (terminals, roles) => ({
865
+ partNumber: propertyString(component, "PartNumber"),
866
+ declaredType: typeof component.sourceTypeName === "string" &&
867
+ component.sourceTypeName.length > 0
868
+ ? component.sourceTypeName
869
+ : null,
870
+ terminalRoles: terminals.map((terminal) => terminalRoleToken(String(terminal.name))),
871
+ declaredTerminalRoles: roles,
872
+ declaredWindings: declaredWindings(component, terminals),
873
+ });
874
+ // **A package that states its own devices is split here, from the source.** A dual op-amp
875
+ // is two amplifiers on one supply pair, and until now the only thing that knew so was a
876
+ // registry `sections` entry keyed on the part id -- which means a chip nobody registered
877
+ // could not be split at all, and a registered one was split by a *name signature*
878
+ // (`pinoutMatches` compares folded terminal names positionally). `componentDevices`
879
+ // reads the split the document declares, so it needs neither.
880
+ //
881
+ // Roles are read per device and that is the whole point: a dual op-amp declares two
882
+ // `output`s across its package and neither is ambiguous once scoped to one amplifier.
883
+ // `deviceTerminalRoles` is what scopes it; core validates the claim against the
884
+ // *device's* kind, so an `ic` package may legitimately carry `opamp` devices.
885
+ //
886
+ // **Known limitation, measured rather than assumed: a source-declared split takes the
887
+ // device-class law, not the part's numbers.** `boss-ch-1`'s M5218 split this way produces
888
+ // a byte-identical topology -- same 151 stamps, same plus/minus/output nodes, same rails
889
+ // -- with one difference: `openLoopGain` falls from the registry's 316227.766 (the part's
890
+ // 110 dB) to `DEFAULT_OPEN_LOOP_GAIN`. The two mechanisms should compose, with the source
891
+ // deciding the terminals and the registry deciding the numbers, and until they do a
892
+ // packet that declares `devices` for a *registered* part trades a real gain figure for a
893
+ // class default. No corpus packet declares `devices` yet, so nothing is affected today.
894
+ // **Declared, not merely resolved.** `componentDevices` returns one device for a component
895
+ // that declares none, which is the overwhelming majority, so counting its result cannot
896
+ // tell a package that states a single device from one that states nothing. A single-device
897
+ // declaration is a real statement -- a TA7504S is one amplifier in an eight-pin package,
898
+ // and saying which three pins it uses is exactly the fact the binding was missing.
899
+ const declaredDevices = component.devices !== undefined && component.devices.length > 0
900
+ ? componentDevices(component)
901
+ : [];
902
+ if (declaredDevices.length > 0) {
903
+ const terminalByName = new Map(effectiveTerminals.map((terminal) => [String(terminal.name), terminal]));
904
+ const nodeByName = new Map(effectiveTerminals.map((terminal, index) => [
905
+ String(terminal.name),
906
+ nodes[index],
907
+ ]));
908
+ for (const declared of declaredDevices) {
909
+ const roleByName = deviceTerminalRoles(component, declared);
910
+ const owned = declared.terminals.flatMap((name) => {
911
+ const terminal = terminalByName.get(name);
912
+ const node = nodeByName.get(name);
913
+ // A terminal the device names but this component does not connect is
914
+ // dropped rather than guessed at. `validateComponentDevices` is what
915
+ // reports a declaration naming a terminal that does not exist at all.
916
+ return terminal !== undefined && node !== undefined
917
+ ? [{ terminal, node, name }]
918
+ : [];
919
+ });
920
+ if (owned.length === 0) {
921
+ continue;
922
+ }
923
+ const deviceKind = deviceKindByComponentKind[declared.kind];
924
+ if (deviceKind === undefined) {
925
+ throw new NetlistError(`component ${component.id} device ${declared.id}: unsupported kind "${declared.kind}"`, String(component.id));
926
+ }
927
+ devices.push({
928
+ // Core's address for a device inside a package, and deliberately not the
929
+ // `#N` a registry section uses: these two splits must stay tellable apart.
930
+ id: `${component.id}.${declared.id}`,
931
+ packageDeviceId: declared.id,
932
+ kind: deviceKind,
933
+ nodes: owned.map((entry) => entry.node),
934
+ parameters: parametersFor(component, deviceKind),
935
+ // The control belongs to the package, so only the first device carries it.
936
+ // A ganged part states the same control on both and the owner map already
937
+ // keeps the first.
938
+ control: declared.id === declaredDevices[0]?.id ? control : null,
939
+ identity: identityOf(owned.map((entry) => entry.terminal), owned.map((entry) => roleByName.get(entry.name) ?? null)),
940
+ isLed: declared.kind === "led" ? true : undefined,
941
+ });
942
+ }
943
+ continue;
944
+ }
945
+ devices.push({
946
+ id: String(component.id),
947
+ kind,
948
+ nodes,
949
+ parameters: parametersFor(component, kind),
950
+ control,
951
+ identity: identityOf(effectiveTerminals, effectiveTerminals.map((terminal) => declaredRole(terminal))),
952
+ isLed: componentKind === "led" ? true : undefined,
953
+ program: declaredProgram(component),
954
+ });
955
+ }
956
+ devices.push(...voltagePortRails(document.components, devices, nodeOf));
957
+ // A panel control states its taper only sometimes; the pot it drives states one far
958
+ // more often. Taking the panel's silence as "linear" discards a taper the document
959
+ // gives -- an audio knob rendered linear feels like a different pedal -- so the
960
+ // declaration wins and the component fills the gap.
961
+ //
962
+ // A device bound to no panel control is an internal trimmer or a fixed switch:
963
+ // still adjustable, just not on the enclosure. It gets a control of its own rather
964
+ // than refusing the pedal.
965
+ const userControls = (document.deviceInterface?.controls ?? []).filter(isUserPanelControl);
966
+ const declaredTapers = new Map(userControls.map((control) => [String(control.id), taperFor(control)]));
967
+ // The panel's own role for each control, carried verbatim. Same shape as the tapers above:
968
+ // the declaration is the only source, and absence is `null` rather than a guess.
969
+ const declaredRoles = new Map(userControls.map((control) => [String(control.id), roleFor(control)]));
970
+ const declaredLabels = new Map(userControls.map((control) => [
971
+ String(control.id),
972
+ typeof control.label === "string" &&
973
+ String(control.label).trim().length > 0
974
+ ? String(control.label).trim()
975
+ : null,
976
+ ]));
977
+ // Where the panel says its knobs ship set, which is a different claim from where a
978
+ // component says its wiper physically sits.
979
+ //
980
+ // **This block was read for `taper` and `role` and not for `defaultPosition`, and one
981
+ // packet paid for it in full.** `mxr-carbon-copy` declares MIX 0.75, REGEN 0.6 and DELAY
982
+ // 1.0 here; every render of it took 0.5 for all three, because the only position source
983
+ // below was `declaredPosition(owner)`, which reads the *component's* `Wipe`/`Position`.
984
+ // MIX is a source-declared logarithmic pot, so 0.5 is 10% of its track against 32.5% at
985
+ // the declared 0.75 -- 10 dB thrown away on the wet leg of a delay whose echo was then
986
+ // investigated as missing. It is the only packet in the corpus that declares these, and
987
+ // `report-source-coverage.ts` cannot see the omission because it scans component
988
+ // properties and this is not one.
989
+ //
990
+ // Read from the raw YAML rather than the parsed document because the format library keeps
991
+ // `id`/`label`/`kind`/`role`/`taper` off a control entry and drops exactly this field --
992
+ // see `readRawControlPositions`.
993
+ const declaredPositions = readRawControlPositions(source);
994
+ const declaredDiscretePositions = readRawControlDiscretePositions(source);
995
+ // Panel controls keep the order the document declares them in, which is the order
996
+ // they sit on the enclosure; the invented ones follow. Order is not electrical, but
997
+ // it is what a panel reads off, so it should not depend on which device happened to
998
+ // bind first.
999
+ // **A scanned control is admitted although no device is bound to it**, and it has to be.
1000
+ // A control reaches the program one of two ways: by varying a device the solver stamps, or
1001
+ // by being read directly because the path to the chip is not modelled. Only the first leaves
1002
+ // a bound device behind, so the second would vanish here -- and then `setControl` would move
1003
+ // nothing, the panel would show no knob, and the program that names it could never be
1004
+ // selected. Core has already checked that the component doing the reading exists.
1005
+ const tappedControls = tappedControlIds(document);
1006
+ const tapLaws = tapLawsByControl(document);
1007
+ // Any declared panel control qualifies here, whatever its kind: a program that says a chip
1008
+ // reads it (scanned) or times it (tapped) is the evidence that it is played, which a jack you
1009
+ // tap into is, even though a jack is not a knob-style user panel control.
1010
+ const anyDeclaredControlById = new Map((document.deviceInterface?.controls ?? []).map((control) => [String(control.id), control]));
1011
+ const anyDeclaredControl = new Set(anyDeclaredControlById.keys());
1012
+ const controllers = declaredControllers(document, nodeOf);
1013
+ const latchToggles = new Set(controllers.latches.map((latch) => latch.toggledBy));
1014
+ const pinControls = controllers.pins.flatMap((pin) => (pin.highAt === undefined ? [] : [pin.highAt.control]));
1015
+ const chipReadControls = new Set([...scannedControls(document), ...latchToggles, ...pinControls]);
1016
+ for (const control of chipReadControls) {
1017
+ if (declaredControlIds.has(control) ||
1018
+ declaredTapers.has(control) ||
1019
+ anyDeclaredControl.has(control)) {
1020
+ controlIds.add(control);
1021
+ }
1022
+ }
1023
+ const orderedIds = [
1024
+ ...declaredTapers.keys(),
1025
+ ...[...controlIds].filter((id) => !declaredTapers.has(id)),
1026
+ ].filter((id) => controlIds.has(id));
1027
+ // Ports are resolved before controls because the unpositioned-switch default is a
1028
+ // question about the path between them.
1029
+ const ports = resolvePorts(document, devices, options);
1030
+ // A document that declares a transformer but resolves its output to a node no winding
1031
+ // touches is being measured **before its power stage**, which is how an amp's render reads
1032
+ // as a plausible level that is really a preamp or phase-inverter tap. Measured 2026-08-21:
1033
+ // five of six amps were in exactly that state, and `resolvePorts` now prefers the
1034
+ // transformer-coupled jack, so what reaches here is a document that declares no such jack
1035
+ // at all -- `mesa-boogie-mark-v` declares three monitor taps and no speaker load. Naming
1036
+ // it keeps that a stated boundary instead of a flattering number.
1037
+ // Keyed on an **output** transformer -- one declaring a `ratio` -- for the same reason
1038
+ // `resolvePorts` prefers one: a mains transformer's windings say nothing about where the
1039
+ // audio output is. Both conditions here are typed parameters compared whole.
1040
+ const outputTransformers = devices.filter((device) => device.kind === "transformer" &&
1041
+ typeof device.parameters.ratio === "number");
1042
+ if (outputTransformers.length > 0) {
1043
+ // **The same reachability `outputJackLoads` uses, not a stricter copy.** This test used to
1044
+ // require the output port to sit *directly* on a winding, while the port preference beside it
1045
+ // already followed series passives -- so a jack behind a secondary winding resistance, which
1046
+ // is exactly what Stage C models, warned about its own speaker output. `hiwatt-dr103` is the
1047
+ // selector case: its port is the switch common, one hop past three taps that are all on the
1048
+ // winding. Two tests of one question that disagree is how a packet gets told its speaker is a
1049
+ // preamp tap.
1050
+ const windingNodes = transformerCoupledNodes(devices);
1051
+ if (!windingNodes.has(ports.output)) {
1052
+ const owner = devices.find((device) => device.kind === "jack" && device.nodes.includes(ports.output));
1053
+ warnings.push({
1054
+ code: "output-port-not-transformer-coupled",
1055
+ device: owner === undefined ? null : String(owner.id),
1056
+ detail: `the output port is node ${ports.output}` +
1057
+ (owner === undefined ? "" : ` (${owner.id})`) +
1058
+ ", which no transformer winding touches, while this document declares a " +
1059
+ "transformer: the render is measured upstream of the output transformer, so " +
1060
+ "its level is a tap rather than a speaker output",
1061
+ });
1062
+ }
1063
+ }
1064
+ for (const id of positionedControls) {
1065
+ unpositionedSwitchControls.delete(id);
1066
+ }
1067
+ const closedForPortPath = switchesClosedForPortPath(devices, ports, unpositionedSwitchControls);
1068
+ const closedForSupplyPath = switchesClosedForSupplyPath(devices, ports, unpositionedSwitchControls);
1069
+ // The bypass declaration, parsed once for the control defaults below and the
1070
+ // bypass record at the end (same shape, same validation -- see
1071
+ // parseBypassDeclaration).
1072
+ const bypassDeclaration = parseBypassDeclaration(document);
1073
+ const controls = orderedIds.map((id) => {
1074
+ const owner = ownerByControl.get(id);
1075
+ // The panel's declaration wins over both the connectivity fallback (a guess about an
1076
+ // unpositioned switch) and the component's own `Wipe` -- but a disagreement between the
1077
+ // two authored values is a contradiction in the source, not a precedence question, so it
1078
+ // is named rather than silently resolved. An internal trimmer the panel never declares is
1079
+ // unaffected: it has no entry here and keeps `declaredPosition(owner)`.
1080
+ const panelPosition = declaredPositions.get(id);
1081
+ if (panelPosition !== undefined &&
1082
+ owner !== undefined &&
1083
+ statesPosition(owner) &&
1084
+ declaredPosition(owner) !== panelPosition) {
1085
+ warnings.push({
1086
+ code: "control-position-disagrees",
1087
+ device: String(owner.id),
1088
+ detail: `Control ${id}: deviceInterface declares defaultPosition ${panelPosition} but component ${owner.id} states position ${declaredPosition(owner)}; the panel declaration is used.`,
1089
+ });
1090
+ }
1091
+ // The panel's own `taper` wins where it declares one; otherwise the owning component's,
1092
+ // and a component whose declaration this stage cannot execute is named rather than
1093
+ // quietly rendered linear.
1094
+ const componentTaper = owner === undefined ? null : declaredTaper(owner);
1095
+ if (owner !== undefined && componentTaper?.unreadable != null) {
1096
+ warnings.push({
1097
+ code: "taper-not-executable",
1098
+ device: owner.id,
1099
+ declared: componentTaper.unreadable,
1100
+ detail: `Control ${id}: ${owner.id} declares taper "${componentTaper.unreadable}", which ` +
1101
+ "this stage cannot turn into one of its four laws, so the control renders " +
1102
+ "linear. An audio pot rendered linear is audible across the whole sweep.",
1103
+ });
1104
+ }
1105
+ const discretePositions = discretePositionsForControl(id, owner, document, declaredDiscretePositions);
1106
+ // Only for a control a chip reads (scanned or tapped): a jack you tap into is not a
1107
+ // knob-style panel control, so its label and role come from its own declaration. Every
1108
+ // other control keeps the knob-style reading, so this changes nothing else's role.
1109
+ const declaredAny = chipReadControls.has(id) ? anyDeclaredControlById.get(id) : undefined;
1110
+ // A spring-return contact rests released. Derived like `momentary` itself, so the
1111
+ // first press is a rising edge rather than a 0.5 -> 1 step nothing counts.
1112
+ // The one exception is the bypass switch itself when the document declares
1113
+ // it engaged at position 1: every fallback below would rest it at 0, so
1114
+ // every default render plays bypass while the product shows the pedal
1115
+ // engaged. The override is fenced on both sides. It applies only to a
1116
+ // maintained switch: a momentary or latch-toggled contact declaring 1
1117
+ // would rest "held pressed", and its first press would produce no rising
1118
+ // edge -- so those keep 0 and are named by the warning below instead.
1119
+ // And a declared 0 overrides only a default that is no position at all:
1120
+ // half travel on a two-position switch is the boundary between its
1121
+ // throws, which is what an unresolvable stated position falls to
1122
+ // (`boss-ce-2`'s one-throw contact states `Position: Effect` against
1123
+ // `Options: Normal,Effect`, which names no throw, and rested at 0.5,
1124
+ // which the runtime plays as the NORMAL throw). A derived real position
1125
+ // keeps winning over a declared 0: `boss-ph-1r` declares 0 while its
1126
+ // CHECK LED lights at latch 1, so its declaration is not evidence.
1127
+ // Owner-matched, not id-matched: the control id and the switch device
1128
+ // id coincide in some packets and differ in others.
1129
+ const isBypassSwitch = owner !== undefined &&
1130
+ bypassDeclaration.switchId !== null &&
1131
+ String(owner.id) === bypassDeclaration.switchId;
1132
+ const isMomentary = tappedControls.has(id) || latchToggles.has(id);
1133
+ const baseDefault = isMomentary
1134
+ ? 0
1135
+ : (panelPosition ??
1136
+ (closedForPortPath.has(id) || closedForSupplyPath.has(id)
1137
+ ? 1
1138
+ : owner === undefined
1139
+ ? 0.5
1140
+ : declaredPosition(owner)));
1141
+ const engagedOverride = !isBypassSwitch || isMomentary || bypassDeclaration.engaged === null
1142
+ ? null
1143
+ : bypassDeclaration.engaged === 1 ||
1144
+ (discretePositions === 2 && baseDefault === 0.5)
1145
+ ? bypassDeclaration.engaged
1146
+ : null;
1147
+ const defaultPosition = engagedOverride ?? baseDefault;
1148
+ // A declared engaged position the default does not honor is a product
1149
+ // defect either way (shows engaged while rendering dry, or the reverse),
1150
+ // so it is named rather than silently kept or silently dropped. The one
1151
+ // silent case is the override above doing its job.
1152
+ if (isBypassSwitch &&
1153
+ bypassDeclaration.engaged !== null &&
1154
+ defaultPosition !== bypassDeclaration.engaged) {
1155
+ warnings.push({
1156
+ code: "bypass-engaged-disagrees",
1157
+ device: owner !== undefined ? owner.id : null,
1158
+ detail: `Control ${id}: audio.bypass declares engagedPosition ${bypassDeclaration.engaged} but the derived default is ${defaultPosition}` +
1159
+ (isMomentary
1160
+ ? "; the override is withheld because a momentary/latch contact rests released"
1161
+ : "; the declaration stands as read until a source read confirms or corrects it"),
1162
+ });
1163
+ }
1164
+ return {
1165
+ id,
1166
+ taper: declaredTapers.get(id) ?? componentTaper?.taper ?? "linear",
1167
+ defaultPosition,
1168
+ positions: discretePositions,
1169
+ role: declaredRoles.get(id) ??
1170
+ (typeof declaredAny?.role === "string" ? declaredAny.role : null),
1171
+ label: declaredLabels.get(id) ??
1172
+ (typeof declaredAny?.label === "string" ? declaredAny.label : null),
1173
+ // Derived from the program, never declared: a contact a chip times by its presses
1174
+ // springs back, so a panel draws it as a button.
1175
+ // A contact whose press a controller counts to flip a latch springs back the same way.
1176
+ ...(tappedControls.has(id) || latchToggles.has(id) ? { momentary: true } : {}),
1177
+ ...(tapLaws.has(id) ? { tap: tapLaws.get(id) } : {}),
1178
+ };
1179
+ });
1180
+ // A latch is a control whose position the firmware sets, so it joins the list a program and a
1181
+ // driven pin read from. Two positions, starting where the declaration says the chip powers up.
1182
+ for (const latch of controllers.latches) {
1183
+ const engaged = declaredRoles.get(latch.toggledBy) === "bypass" ? engagedLatchState(document, latch.id) : null;
1184
+ controls.push({
1185
+ id: latch.id,
1186
+ taper: "linear",
1187
+ defaultPosition: engaged ?? latch.initial,
1188
+ positions: 2,
1189
+ role: null,
1190
+ label: latch.id,
1191
+ latch: { toggledBy: latch.toggledBy, initial: latch.initial },
1192
+ });
1193
+ }
1194
+ // A momentary footswitch operating a BJT bistable latch controls the electronic
1195
+ // bypass latch state rather than holding a mechanical contact across DC-blocking
1196
+ // capacitors. The mechanical contact is fixed open at rest (`device.control = null`),
1197
+ // while the control remains in `netlist.controls` to steer the latch.
1198
+ const bistableLatches = findBistableLatches({
1199
+ devices,
1200
+ controls,
1201
+ });
1202
+ if (bistableLatches.length > 0) {
1203
+ const latchControlIds = new Set(bistableLatches
1204
+ .map((latch) => latch.controlId)
1205
+ .filter((id) => id !== null));
1206
+ for (let i = 0; i < devices.length; i += 1) {
1207
+ const device = devices[i];
1208
+ if (device !== undefined &&
1209
+ device.kind === "switch" &&
1210
+ device.control !== null &&
1211
+ latchControlIds.has(device.control)) {
1212
+ devices[i] = { ...device, control: null };
1213
+ }
1214
+ }
1215
+ }
1216
+ // **A power stage the graph does not contain.** `mesa-boogie-mark-v` declares a power
1217
+ // transformer with a typed `voltsHv` winding -- a B+ supply, which only an amp has -- and no
1218
+ // output transformer at all, because its packet models the preamp and hands the OT and
1219
+ // speaker to a companion fixture: its README claims a "2k:8 ohm OT descriptor" and an
1220
+ // "8 ohm WDF speaker-load handoff", so this is a documented boundary rather than a defect.
1221
+ // It is also why that packet's render is a **preamp level** -- 11.7 V at a monitor tap,
1222
+ // which spent a day being read alongside `fender-5e3-deluxe-tweed`'s 26.2 V into a real
1223
+ // 8 ohm load. Naming it is what stops the comparison being made again.
1224
+ //
1225
+ // No pedal can trip this: `voltsHv` is a high-voltage supply winding nothing in the pedal
1226
+ // corpus declares.
1227
+ if (outputTransformers.length === 0 &&
1228
+ devices.some((device) => device.kind === "transformer" &&
1229
+ typeof device.parameters.voltsHv === "number")) {
1230
+ warnings.push({
1231
+ code: "no-output-transformer",
1232
+ device: null,
1233
+ detail: "this document declares a high-voltage power transformer but no output " +
1234
+ "transformer, so the graph contains no output stage: the rendered level is a " +
1235
+ "preamp level and is not comparable with an amp measured at its speaker",
1236
+ });
1237
+ }
1238
+ // A jack's own declared load impedance, lowered into a resistor across its own two
1239
+ // terminals -- appended last and read from `document.components` rather than folded into
1240
+ // the main device loop above, so it cannot perturb port resolution, the switch-closing
1241
+ // path search or control defaults, all of which already ran against `devices` by this
1242
+ // point. See `outputJackLoads` for the measurement and the exclusions.
1243
+ const speakerLoads = outputJackLoads(document, devices);
1244
+ devices.push(...speakerLoads.devices);
1245
+ warnings.push(...speakerLoads.warnings);
1246
+ // A driven controller pin, lowered as the selector it is: the pin to the chip's own ground at
1247
+ // latch 0 and to its own supply at 1 (reversed by `invert`). Appended last for the same reason
1248
+ // as the speaker loads -- port resolution and the switch defaults have already run.
1249
+ for (const pin of controllers.pins) {
1250
+ devices.push(controllerPinDevice(pin, controls));
1251
+ }
1252
+ const nodes = [
1253
+ ...new Set([GROUND, ...devices.flatMap((device) => device.nodes)]),
1254
+ ].sort((a, b) => a - b);
1255
+ const rawBypass = parseBypassDeclaration(document);
1256
+ const bypass = rawBypass.switchId !== null
1257
+ ? {
1258
+ declared: "switch",
1259
+ switch: rawBypass.switchId,
1260
+ engagedPosition: rawBypass.rawEngagedPosition,
1261
+ }
1262
+ : { declared: "none" };
1263
+ return {
1264
+ nodes,
1265
+ devices,
1266
+ controls,
1267
+ ports,
1268
+ bypass,
1269
+ portImpedanceOhms: portImpedanceOhms(document, devices, ports),
1270
+ portDeclaredFullScaleVolts: portDeclaredFullScaleVolts(document, devices, ports),
1271
+ convergenceOptIn: convergenceOptInDeclared(source),
1272
+ warnings,
1273
+ };
1274
+ }
1275
+ /**
1276
+ * Which unpositioned two-terminal switches must close for the input port to reach the
1277
+ * output port, taken as the fewest of them that does it.
1278
+ *
1279
+ * A source that does not state a switch position states nothing about its sense
1280
+ * either, and the corpus uses both senses, so no constant is right. `pro-co-rat` wires
1281
+ * its effect switches *into* the signal path -- node 24 is the input port itself -- and
1282
+ * opening them disconnects the pedal; `ibanez-ts808`'s CR1/CR2 latch contacts are
1283
+ * internal and closing them shorts the path around the circuit. A constant `0.5` kills
1284
+ * the Screamer and a constant `0` kills the Rat. This is the same shape the jack-engage
1285
+ * work already paid for: *same terminal name, opposite electrical sense*, and a
1286
+ * per-packet answer would be product knowledge this stage may not hold.
1287
+ *
1288
+ * Connectivity decides instead, which is evidence this stage may read. Crossing an
1289
+ * unpositioned switch costs 1 and every other device costs 0; take a cheapest input ->
1290
+ * output path and close exactly the switches on it. A pedal whose path already exists
1291
+ * closes nothing, and a pedal whose only path runs through its switches closes them.
1292
+ * The default is fail-safe toward a pedal you can hear either way.
1293
+ *
1294
+ * Rails are not routes. Ground and every node a voltage source touches are excluded
1295
+ * from the traversal: they reach nearly every node in the circuit through returns and
1296
+ * decoupling, so leaving them in makes every path free and the answer always "close
1297
+ * nothing". That exclusion is connectivity too -- it reads device kinds and nodes, no
1298
+ * text.
1299
+ */
1300
+ function switchesClosedForPortPath(devices, ports, candidates) {
1301
+ const closed = new Set();
1302
+ if (candidates.size === 0 || ports.input === ports.output) {
1303
+ return closed;
1304
+ }
1305
+ const rails = new Set([GROUND]);
1306
+ for (const device of devices) {
1307
+ if (device.kind === "voltage-source") {
1308
+ for (const node of device.nodes) {
1309
+ rails.add(node);
1310
+ }
1311
+ }
1312
+ }
1313
+ // A port sitting on a rail would be unreachable from the start. That is a different
1314
+ // defect and not this function's to diagnose, so it declines rather than guessing.
1315
+ if (rails.has(ports.input) || rails.has(ports.output)) {
1316
+ return closed;
1317
+ }
1318
+ const adjacency = new Map();
1319
+ const link = (a, b, gate) => {
1320
+ if (a === b || rails.has(a) || rails.has(b)) {
1321
+ return;
1322
+ }
1323
+ for (const [from, to] of [
1324
+ [a, b],
1325
+ [b, a],
1326
+ ]) {
1327
+ const edges = adjacency.get(from);
1328
+ if (edges === undefined) {
1329
+ adjacency.set(from, [{ to, gate }]);
1330
+ }
1331
+ else {
1332
+ edges.push({ to, gate });
1333
+ }
1334
+ }
1335
+ };
1336
+ for (const device of devices) {
1337
+ const gate = device.control !== null && candidates.has(device.control)
1338
+ ? device.control
1339
+ : null;
1340
+ if (gate !== null) {
1341
+ link(device.nodes[0] ?? GROUND, device.nodes[1] ?? GROUND, gate);
1342
+ continue;
1343
+ }
1344
+ // Every other device conducts. This is a path question and not a frequency one:
1345
+ // a coupling capacitor carries the signal it is there to carry, and a transistor
1346
+ // passes signal between its terminals.
1347
+ for (let i = 0; i < device.nodes.length; i += 1) {
1348
+ for (let j = i + 1; j < device.nodes.length; j += 1) {
1349
+ link(device.nodes[i] ?? GROUND, device.nodes[j] ?? GROUND, null);
1350
+ }
1351
+ }
1352
+ }
1353
+ // 0-1 BFS: free edges go to the front of the queue, gated ones to the back, so the
1354
+ // first time the output settles it has crossed the fewest switches. Adjacency keeps
1355
+ // device declaration order, so a tie between two equal-cost paths always resolves
1356
+ // the same way for the same document.
1357
+ const cost = new Map([[ports.input, 0]]);
1358
+ const cameFrom = new Map();
1359
+ const queue = [
1360
+ { node: ports.input, cost: 0 },
1361
+ ];
1362
+ while (queue.length > 0) {
1363
+ const entry = queue.shift();
1364
+ if (entry === undefined ||
1365
+ entry.cost > (cost.get(entry.node) ?? Infinity)) {
1366
+ continue;
1367
+ }
1368
+ if (entry.node === ports.output) {
1369
+ break;
1370
+ }
1371
+ for (const edge of adjacency.get(entry.node) ?? []) {
1372
+ const next = entry.cost + (edge.gate === null ? 0 : 1);
1373
+ if (next >= (cost.get(edge.to) ?? Infinity)) {
1374
+ continue;
1375
+ }
1376
+ cost.set(edge.to, next);
1377
+ cameFrom.set(edge.to, { from: entry.node, gate: edge.gate });
1378
+ const step = { node: edge.to, cost: next };
1379
+ if (edge.gate === null) {
1380
+ queue.unshift(step);
1381
+ }
1382
+ else {
1383
+ queue.push(step);
1384
+ }
1385
+ }
1386
+ }
1387
+ // No path even with every candidate closed. Closing them all would be a guess with
1388
+ // no evidence behind it, so every switch keeps the open default.
1389
+ if (!cost.has(ports.output)) {
1390
+ return closed;
1391
+ }
1392
+ for (let node = ports.output; node !== ports.input;) {
1393
+ const step = cameFrom.get(node);
1394
+ if (step === undefined) {
1395
+ break;
1396
+ }
1397
+ if (step.gate !== null) {
1398
+ closed.add(step.gate);
1399
+ }
1400
+ node = step.from;
1401
+ }
1402
+ return closed;
1403
+ }
1404
+ /**
1405
+ * Switches that must be closed to connect power sources to active device power terminals.
1406
+ *
1407
+ * In tube amplifiers and analog circuits, mains/standby switches sit on the DC power rail
1408
+ * or transformer primary/secondary. If unpositioned in source, defaulting open isolates
1409
+ * active devices from power supplies, leaving plates, screens and op-amp rails at 0V.
1410
+ */
1411
+ function switchesClosedForSupplyPath(devices, ports, candidates) {
1412
+ const closed = new Set();
1413
+ if (candidates.size === 0) {
1414
+ return closed;
1415
+ }
1416
+ const powerSourceNodes = new Set();
1417
+ for (const device of devices) {
1418
+ if (device.kind === "voltage-source") {
1419
+ for (const node of device.nodes) {
1420
+ if (node !== GROUND && node !== ports.input) {
1421
+ powerSourceNodes.add(node);
1422
+ }
1423
+ }
1424
+ }
1425
+ }
1426
+ if (powerSourceNodes.size === 0) {
1427
+ return closed;
1428
+ }
1429
+ const activeSupplyTargets = new Set();
1430
+ for (const device of devices) {
1431
+ if (device.kind === "triode") {
1432
+ device.identity.terminalRoles.forEach((role, idx) => {
1433
+ const n = device.nodes[idx];
1434
+ if (role === "plate" && n !== undefined && n !== GROUND) {
1435
+ activeSupplyTargets.add(n);
1436
+ }
1437
+ });
1438
+ }
1439
+ else if (device.kind === "pentode") {
1440
+ device.identity.terminalRoles.forEach((role, idx) => {
1441
+ const n = device.nodes[idx];
1442
+ if ((role === "plate" || role === "screen") &&
1443
+ n !== undefined &&
1444
+ n !== GROUND) {
1445
+ activeSupplyTargets.add(n);
1446
+ }
1447
+ });
1448
+ }
1449
+ else if (device.kind === "opamp" || device.kind === "ota") {
1450
+ device.identity.terminalRoles.forEach((role, idx) => {
1451
+ const n = device.nodes[idx];
1452
+ if ((role === "vcc" || role === "vee") &&
1453
+ n !== undefined &&
1454
+ n !== GROUND) {
1455
+ activeSupplyTargets.add(n);
1456
+ }
1457
+ });
1458
+ }
1459
+ }
1460
+ if (activeSupplyTargets.size === 0) {
1461
+ return closed;
1462
+ }
1463
+ const adjacency = new Map();
1464
+ const link = (a, b, gate) => {
1465
+ if (a === b || a === GROUND || b === GROUND) {
1466
+ return;
1467
+ }
1468
+ for (const [from, to] of [
1469
+ [a, b],
1470
+ [b, a],
1471
+ ]) {
1472
+ const edges = adjacency.get(from);
1473
+ if (edges === undefined) {
1474
+ adjacency.set(from, [{ to, gate }]);
1475
+ }
1476
+ else {
1477
+ edges.push({ to, gate });
1478
+ }
1479
+ }
1480
+ };
1481
+ for (const device of devices) {
1482
+ const gate = device.control !== null && candidates.has(device.control)
1483
+ ? device.control
1484
+ : null;
1485
+ if (gate !== null) {
1486
+ for (let i = 0; i < device.nodes.length; i += 1) {
1487
+ for (let j = i + 1; j < device.nodes.length; j += 1) {
1488
+ link(device.nodes[i] ?? GROUND, device.nodes[j] ?? GROUND, gate);
1489
+ }
1490
+ }
1491
+ continue;
1492
+ }
1493
+ if (device.kind === "resistor" ||
1494
+ device.kind === "inductor" ||
1495
+ device.kind === "potentiometer") {
1496
+ for (let i = 0; i < device.nodes.length; i += 1) {
1497
+ for (let j = i + 1; j < device.nodes.length; j += 1) {
1498
+ link(device.nodes[i] ?? GROUND, device.nodes[j] ?? GROUND, null);
1499
+ }
1500
+ }
1501
+ }
1502
+ else if (device.kind === "diode" || device.kind === "tube-diode") {
1503
+ for (let i = 0; i < device.nodes.length; i += 1) {
1504
+ for (let j = i + 1; j < device.nodes.length; j += 1) {
1505
+ link(device.nodes[i] ?? GROUND, device.nodes[j] ?? GROUND, null);
1506
+ }
1507
+ }
1508
+ }
1509
+ else if (device.kind === "transformer") {
1510
+ const nonZeroNodes = device.nodes.filter((node) => node !== GROUND);
1511
+ for (let i = 0; i < nonZeroNodes.length; i += 1) {
1512
+ for (let j = i + 1; j < nonZeroNodes.length; j += 1) {
1513
+ link(nonZeroNodes[i] ?? GROUND, nonZeroNodes[j] ?? GROUND, null);
1514
+ }
1515
+ }
1516
+ }
1517
+ }
1518
+ const cost = new Map();
1519
+ const cameFrom = new Map();
1520
+ const queue = [];
1521
+ for (const src of powerSourceNodes) {
1522
+ cost.set(src, 0);
1523
+ queue.push({ node: src, cost: 0 });
1524
+ }
1525
+ while (queue.length > 0) {
1526
+ const entry = queue.shift();
1527
+ if (entry === undefined ||
1528
+ entry.cost > (cost.get(entry.node) ?? Infinity)) {
1529
+ continue;
1530
+ }
1531
+ for (const edge of adjacency.get(entry.node) ?? []) {
1532
+ const next = entry.cost + (edge.gate === null ? 0 : 1);
1533
+ if (next >= (cost.get(edge.to) ?? Infinity)) {
1534
+ continue;
1535
+ }
1536
+ cost.set(edge.to, next);
1537
+ cameFrom.set(edge.to, { from: entry.node, gate: edge.gate });
1538
+ const step = { node: edge.to, cost: next };
1539
+ if (edge.gate === null) {
1540
+ queue.unshift(step);
1541
+ }
1542
+ else {
1543
+ queue.push(step);
1544
+ }
1545
+ }
1546
+ }
1547
+ for (const target of activeSupplyTargets) {
1548
+ if (!cost.has(target)) {
1549
+ continue;
1550
+ }
1551
+ let curr = target;
1552
+ while (!powerSourceNodes.has(curr)) {
1553
+ const step = cameFrom.get(curr);
1554
+ if (step === undefined) {
1555
+ break;
1556
+ }
1557
+ if (step.gate !== null) {
1558
+ closed.add(step.gate);
1559
+ }
1560
+ curr = step.from;
1561
+ }
1562
+ }
1563
+ return closed;
1564
+ }
1565
+ function parseDocument(source) {
1566
+ try {
1567
+ return parseInterchangeYamlWithTopology(source).document;
1568
+ }
1569
+ catch (error) {
1570
+ throw new NetlistError(`invalid .vdsp document: ${error instanceof Error ? error.message : String(error)}`, null, error);
1571
+ }
1572
+ }
1573
+ /** The document together with the one connectivity core resolves for it. */
1574
+ function parseDocumentWithConnectivity(source) {
1575
+ try {
1576
+ return parseInterchangeYamlWithTopology(source);
1577
+ }
1578
+ catch (error) {
1579
+ throw new NetlistError(`invalid .vdsp document: ${error instanceof Error ? error.message : String(error)}`, null, error);
1580
+ }
1581
+ }
1582
+ /**
1583
+ * Core's pin -> node map, in this stage's key shape, renumbered so ground is `GROUND`.
1584
+ *
1585
+ * Two things this has to do that a straight copy of core's ids does not.
1586
+ *
1587
+ * **Every ground, not just the one core names.** Core reports a single `groundNodeId`; a
1588
+ * document can declare many ground symbols, each on its own node unless the source ties them.
1589
+ * Folding only core's id leaves the rest as ordinary floating nodes -- measured on
1590
+ * `electro-harmonix-holy-grail`, that dropped the devices touching ground from 45 to 2. Which
1591
+ * nodes are ground is still decided by what sits on them, never by what they are called: a
1592
+ * ground symbol's own terminal, and a jack's `sleeve`, are closed typed facts.
1593
+ *
1594
+ * **Renumber, do not fold.** Core's ids are arbitrary, so writing ground's pins to `GROUND`
1595
+ * while leaving the others alone collides whenever some *other* node is already numbered 0 --
1596
+ * `namedNodeDivider` is exactly that shape, and folding merged its input node into ground. Every
1597
+ * node therefore gets a fresh id: ground `GROUND`, the rest 1..N in ascending source order so
1598
+ * the numbering stays deterministic.
1599
+ */
1600
+ function declaredNodesFromConnectivity(document, connectivity) {
1601
+ const nodeOfPin = new Map();
1602
+ for (const [node, pins] of connectivity.nodeMembers) {
1603
+ for (const pin of pins) {
1604
+ nodeOfPin.set(declaredKey(pin.componentId, pin.terminalName), node);
1605
+ }
1606
+ }
1607
+ const grounded = new Set();
1608
+ if (connectivity.groundNodeId !== null) {
1609
+ grounded.add(connectivity.groundNodeId);
1610
+ }
1611
+ for (const component of document.components) {
1612
+ const kind = String(component.kind);
1613
+ if (kind !== "ground" && kind !== "jack") {
1614
+ continue;
1615
+ }
1616
+ for (const terminal of component.terminals) {
1617
+ if (kind !== "ground" &&
1618
+ terminalRoleToken(String(terminal.name)) !== "sleeve") {
1619
+ continue;
1620
+ }
1621
+ const node = nodeOfPin.get(declaredKey(String(component.id), String(terminal.name)));
1622
+ if (node !== undefined) {
1623
+ grounded.add(node);
1624
+ }
1625
+ }
1626
+ }
1627
+ // Ground becomes `GROUND`; everything else keeps core's id. The one exception is a
1628
+ // non-ground node that already carries id 0 -- core's ids are arbitrary, so that happens
1629
+ // (`namedNodeDivider` is exactly that shape) and folding onto it would merge the two. Such a
1630
+ // node is moved above the highest id in use instead, which leaves every other id untouched.
1631
+ const ids = [...connectivity.nodeMembers.keys()];
1632
+ // Nothing is ground: leave every id exactly as core assigned it. Moving a node off 0 with
1633
+ // no ground to take its place would leave the system with no reference node at all, which
1634
+ // the solver cannot fix -- the CMOS NAND fixture diverges to 1e141 that way.
1635
+ if (grounded.size === 0) {
1636
+ return nodeOfPin;
1637
+ }
1638
+ let spare = Math.max(GROUND, ...ids) + 1;
1639
+ const renumbered = new Map();
1640
+ for (const node of ids) {
1641
+ renumbered.set(node, grounded.has(node) ? GROUND : node === GROUND ? spare++ : node);
1642
+ }
1643
+ const declared = new Map();
1644
+ for (const [key, node] of nodeOfPin) {
1645
+ declared.set(key, renumbered.get(node) ?? node);
1646
+ }
1647
+ return declared;
1648
+ }
1649
+ /**
1650
+ * Recover the `node:` each terminal declares. The format library drops it, so the
1651
+ * raw YAML is read once here for that single field. Confined to this stage.
1652
+ */
1653
+ function readRawComponents(source) {
1654
+ const map = new Map();
1655
+ let parsed;
1656
+ try {
1657
+ parsed = loadYaml(source);
1658
+ }
1659
+ catch {
1660
+ return map;
1661
+ }
1662
+ if (parsed === null || typeof parsed !== "object") {
1663
+ return map;
1664
+ }
1665
+ const netNodes = new Map();
1666
+ const nodesList = parsed.nodes;
1667
+ if (Array.isArray(nodesList)) {
1668
+ for (const net of nodesList) {
1669
+ if (net === null || typeof net !== "object") {
1670
+ continue;
1671
+ }
1672
+ const netRecord = net;
1673
+ // **A net id is a token, not a number** -- the identical lesson the terminal
1674
+ // `node:` filter below already carries, unlearned one function up. The corpus
1675
+ // writes `id: 4` and `id: "n_gnd"` interchangeably, and requiring a string here
1676
+ // silently discarded the ENTIRE ledger for 100 of 142 packets -- usually every
1677
+ // net they declare (`boss-bf-3`: 430 of 430) -- and then reported the terminals
1678
+ // that depended on it as unconnected, with no diagnostic naming the ledger.
1679
+ let netId = null;
1680
+ if (typeof netRecord.id === "number" && Number.isFinite(netRecord.id)) {
1681
+ netId = String(netRecord.id);
1682
+ }
1683
+ else if (typeof netRecord.id === "string" &&
1684
+ netRecord.id.trim() !== "") {
1685
+ netId = netRecord.id.trim();
1686
+ }
1687
+ if (netId === null || !Array.isArray(netRecord.members)) {
1688
+ continue;
1689
+ }
1690
+ // A net with only 1 member connects a terminal to nothing. If the terminal has an
1691
+ // authored inline node connecting it to other components, a singleton ledger entry
1692
+ // must not disconnect it. Only multi-terminal nets (real connections) override inline refs.
1693
+ if (netRecord.members.length <= 1) {
1694
+ continue;
1695
+ }
1696
+ for (const member of netRecord.members) {
1697
+ if (member === null || typeof member !== "object") {
1698
+ continue;
1699
+ }
1700
+ const memberRecord = member;
1701
+ if (typeof memberRecord.componentId === "string" &&
1702
+ typeof memberRecord.terminalName === "string") {
1703
+ netNodes.set(`${memberRecord.componentId}/${memberRecord.terminalName}`, netId);
1704
+ }
1705
+ }
1706
+ }
1707
+ }
1708
+ const components = parsed.components;
1709
+ if (!Array.isArray(components)) {
1710
+ return map;
1711
+ }
1712
+ for (const component of components) {
1713
+ if (component === null || typeof component !== "object") {
1714
+ continue;
1715
+ }
1716
+ const record = component;
1717
+ if (typeof record.id !== "string") {
1718
+ continue;
1719
+ }
1720
+ const nodes = new Map();
1721
+ if (Array.isArray(record.terminals)) {
1722
+ for (const terminal of record.terminals) {
1723
+ if (terminal === null || typeof terminal !== "object") {
1724
+ continue;
1725
+ }
1726
+ const entry = terminal;
1727
+ if (typeof entry.name !== "string") {
1728
+ continue;
1729
+ }
1730
+ // A node id is a token, not a number. The corpus writes bare numerics,
1731
+ // quoted numerics and names (`n_gnd`) interchangeably, and an earlier
1732
+ // version of this filter required `typeof === "number"` -- which dropped
1733
+ // every quoted and every named node without saying so, then reported the
1734
+ // terminal as unconnected. Keep the token as authored and intern later.
1735
+ let inlineNode = null;
1736
+ if (typeof entry.node === "number") {
1737
+ inlineNode = String(entry.node);
1738
+ }
1739
+ else if (typeof entry.node === "string" &&
1740
+ entry.node.trim() !== "") {
1741
+ inlineNode = entry.node.trim();
1742
+ }
1743
+ // The declared node ledger is authoritative when both encodings are present.
1744
+ const netNode = netNodes.get(`${record.id}/${entry.name}`);
1745
+ const node = netNode ?? inlineNode;
1746
+ if (node !== null) {
1747
+ nodes.set(entry.name, node);
1748
+ }
1749
+ }
1750
+ }
1751
+ const properties = record.properties !== null && typeof record.properties === "object"
1752
+ ? record.properties
1753
+ : {};
1754
+ map.set(record.id, { nodes, properties });
1755
+ }
1756
+ return map;
1757
+ }
1758
+ /**
1759
+ * Restore structured quantities the format library flattened to text.
1760
+ *
1761
+ * `Resistance: { raw: "1M", value: 1000000, unit: "" }` is a flow mapping, and the
1762
+ * library hands it back as the literal characters `{ raw: "1M", ... }` -- which then
1763
+ * fails to parse as a quantity. The value is fully present in the source, so this is
1764
+ * repairing a lossy read, not repairing the document.
1765
+ */
1766
+ function repairStructuredProperties(document, raw) {
1767
+ let changed = false;
1768
+ const components = document.components.map((component) => {
1769
+ const entry = raw.get(String(component.id));
1770
+ if (entry === undefined) {
1771
+ return component;
1772
+ }
1773
+ const properties = component.properties;
1774
+ let repaired = null;
1775
+ for (const [key, value] of Object.entries(entry.properties)) {
1776
+ const quantity = asParsedQuantity(value);
1777
+ if (quantity === null || typeof properties[key] !== "string") {
1778
+ continue;
1779
+ }
1780
+ repaired ??= { ...properties };
1781
+ repaired[key] = quantity;
1782
+ }
1783
+ if (repaired === null) {
1784
+ return component;
1785
+ }
1786
+ changed = true;
1787
+ return { ...component, properties: repaired };
1788
+ });
1789
+ return changed ? { ...document, components } : document;
1790
+ }
1791
+ function propertyString(component, key) {
1792
+ const value = component.properties[key];
1793
+ if (typeof value === "string") {
1794
+ return value.trim().length === 0 ? null : value.trim();
1795
+ }
1796
+ if (value !== null && typeof value === "object" && "raw" in value) {
1797
+ const raw = value.raw;
1798
+ return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : null;
1799
+ }
1800
+ return null;
1801
+ }
1802
+ /**
1803
+ * A component the source files as interface or source context, not part of the modeled graph.
1804
+ *
1805
+ * v1 reads the same two typed booleans in `source-device-capabilities.ts` (`isInterfaceOnlyComponent`,
1806
+ * `isSourceOnlyComponent`) and gives the component an interface-only / source-only disposition that
1807
+ * keeps it out of the electrical analysis. v2 never read them, so an open placeholder the source says
1808
+ * not to model was lowered as an ordinary part and refused for a value the source deliberately omits
1809
+ * -- schaller-tremolo's `R_LED`, `InterfaceOnly: "true"` and `Value: open`, a panel-LED shell with no
1810
+ * driver the selected schematic specifies. The flag is a typed boolean compared whole, not prose.
1811
+ */
1812
+ function isInterfaceOrSourceOnly(component) {
1813
+ return (unquoteBool(component, "InterfaceOnly") ||
1814
+ unquoteBool(component, "SourceOnly"));
1815
+ }
1816
+ /**
1817
+ * A board position the source says is unpopulated.
1818
+ *
1819
+ * `DNP` -- "do not populate" -- is a typed boolean stating that the footprint exists and nothing
1820
+ * is fitted in it. That is a *fact about the circuit*, not a missing value: an unpopulated
1821
+ * two-terminal position is an **open circuit**. So the component is dropped, exactly as if the
1822
+ * document had not listed it, rather than refusing the pedal for a value it is correct not to
1823
+ * state. Refusing was the previous behaviour, and it is the wrong reading of a complete source.
1824
+ *
1825
+ * `mxr-carbon-copy` is the corpus instance and the only one: `R45`, `R50`, `C32` and `C34`, each
1826
+ * `DNP: 'true'`, `SourceValue: Empty`, `SourceStatus: defer`. Two of them (`R45`, `C34`) bridge
1827
+ * the same node pair, which is what an unfitted either/or position looks like.
1828
+ *
1829
+ * **The typed flag only.** All four also say "unpopulated" in their `Description`, and that prose
1830
+ * is deliberately not read: a component whose description happens to mention an unpopulated
1831
+ * neighbour must not vanish from the solve. Same rule as `isInterfaceOrSourceOnly` above.
1832
+ */
1833
+ function isNotPopulated(component) {
1834
+ if (unquoteBool(component, "DNP")) {
1835
+ return true;
1836
+ }
1837
+ const sourceStatus = propertyString(component, "SourceStatus");
1838
+ const sourceValue = propertyString(component, "SourceValue");
1839
+ if (sourceStatus === "defer" &&
1840
+ sourceValue !== null &&
1841
+ foldToken(sourceValue) === foldToken("visible-no-value-or-DNP")) {
1842
+ return true;
1843
+ }
1844
+ return false;
1845
+ }
1846
+ /** The interchange parser strips double quotes but keeps single; this reads both. */
1847
+ function unquoteBool(component, key) {
1848
+ const value = propertyString(component, key);
1849
+ if (value === null) {
1850
+ return false;
1851
+ }
1852
+ const unquoted = (value.startsWith("'") && value.endsWith("'")) ||
1853
+ (value.startsWith('"') && value.endsWith('"'))
1854
+ ? value.slice(1, -1).trim()
1855
+ : value;
1856
+ return unquoted === "true";
1857
+ }
1858
+ /**
1859
+ * The document's own `convergence-opt-in` declaration, read from the source text.
1860
+ *
1861
+ * Core's interchange parser lifts a fixed allow-list of front-matter scalars into the parsed
1862
+ * document and drops every other key, so a new flag cannot reach the compiler through the
1863
+ * parsed shape without a core change. This reads the flag straight off the document's text --
1864
+ * the same typed-flag evidence class as `DNP` / `InterfaceOnly` above, at document scope
1865
+ * instead of component scope. It matches one exact closed-vocabulary line compared whole, not
1866
+ * prose, so a description that mentions the words cannot set it.
1867
+ */
1868
+ function convergenceOptInDeclared(source) {
1869
+ for (const line of source.split(/\r?\n/)) {
1870
+ const trimmed = line.trim();
1871
+ if (trimmed === "convergence-opt-in: true" ||
1872
+ trimmed === "convergence-opt-in: 'true'" ||
1873
+ trimmed === 'convergence-opt-in: "true"') {
1874
+ return true;
1875
+ }
1876
+ }
1877
+ return false;
1878
+ }
1879
+ /**
1880
+ * `propertyString`, plus a bare unquoted YAML number.
1881
+ *
1882
+ * Scoped to the quantity fallback path below (`Resistance`/`R`, `Capacitance`/`C`,
1883
+ * `Inductance`/`L`, a rheostat's `MinResistance`, an unclaimed `voltage-source`'s
1884
+ * `Voltage`) and deliberately not folded into `propertyString` itself, whose other
1885
+ * callers read prose-ish closed-vocabulary fields (`Wipe`, `Position`, `Type`,
1886
+ * `PartNumber`, ...) where a bare number carries a different, unrelated meaning
1887
+ * (a control's stated 0..1 travel) that this change must not start reading.
1888
+ *
1889
+ * `boss-dd-3a` writes six resistors' fallback `R` as a plain unquoted number --
1890
+ * `R: 100`, not `R: "100"` or a typed `{raw, value, unit}` -- which is exactly as
1891
+ * unambiguous as its quoted twin: base units, no multiplier, nothing lost. But
1892
+ * `typeof value === "number"` matched neither branch above, so the value was
1893
+ * fully present and still read as absent, refusing `boss-dd-3a` over a value this
1894
+ * stage had in hand -- a wrong answer (refusal) that looked like a stricter read.
1895
+ */
1896
+ function propertyQuantityText(component, key) {
1897
+ const value = component.properties[key];
1898
+ return typeof value === "number" && Number.isFinite(value)
1899
+ ? String(value)
1900
+ : propertyString(component, key);
1901
+ }
1902
+ /**
1903
+ * Unit tokens that mean "already in base units" for each device kind. A prefixed unit
1904
+ * such as `kohm` is deliberately absent: this stage will not scale a unit it did not
1905
+ * read, because guessing wrong is a wrong answer that still renders.
1906
+ */
1907
+ const baseUnitsByDeviceKind = {
1908
+ resistor: ["", "ohm", "ohms", "Ω"],
1909
+ potentiometer: ["", "ohm", "ohms", "Ω"],
1910
+ rheostat: ["", "ohm", "ohms", "Ω"],
1911
+ capacitor: ["f", "farad", "farads"],
1912
+ inductor: ["h", "henry", "henries"],
1913
+ "voltage-source": ["v", "volt", "volts"],
1914
+ transformer: [""],
1915
+ };
1916
+ /**
1917
+ * Accepted unit tokens for a BJT's typed SPICE parameters, per property rather than per
1918
+ * device, because they do not share a dimension: `IS` is a current and the betas and the
1919
+ * emission coefficient are dimensionless.
1920
+ *
1921
+ * These are compared whole, like every other unit token here. Nothing reads `PartNumber`:
1922
+ * `OC41` is a germanium PNP and `2N3904` is a silicon NPN, and inferring that from the
1923
+ * string would be exactly the prose matching the engineering principles forbid. The device
1924
+ * type comes from `Type`, which the packets declare.
1925
+ */
1926
+ const bjtParameterUnits = {
1927
+ // `""` is accepted for `IS` because a SPICE saturation current has no plausible alternative
1928
+ // unit, and the corpus states it both ways: `electro-harmonix-lpb-1` declares
1929
+ // `IS: {raw: "1e-12", value: 1e-12, unit: ""}`. Rejecting the unlabelled form silently
1930
+ // substituted the silicon default and lost the declared value -- found by the value comparison
1931
+ // in `report-compiler-fidelity.ts` on its first run, which is the class of bug it exists for.
1932
+ IS: ["", "a", "amp", "amps", "ampere", "amperes"],
1933
+ BF: [""],
1934
+ BR: [""],
1935
+ };
1936
+ /**
1937
+ * The two device types a bipolar transistor can be, as a closed vocabulary compared whole.
1938
+ *
1939
+ * Case is normalised and nothing else is: an unrecognised value is refused by the caller
1940
+ * rather than resolved by prefix, so a `Type` of `N` does not silently become `NPN`. Five
1941
+ * devices in one corpus packet declare exactly that, and guessing would be indistinguishable
1942
+ * from reading it correctly right up to the point where it is wrong.
1943
+ */
1944
+ const bjtPolarityByType = {
1945
+ NPN: 0,
1946
+ PNP: 1,
1947
+ };
1948
+ /**
1949
+ * A typed numeric property, taken from its structured `value` with the unit checked whole.
1950
+ *
1951
+ * Prefers `value` over `raw` because `raw` is authored prose: `IS` is declared as
1952
+ * `{raw: "1 nA", value: 1e-9, unit: A}`, and parsing "1 nA" means re-deriving a number the
1953
+ * packet already states exactly. Falls back to parsing a bare string, which is how a packet
1954
+ * that never adopted the structured form still reads.
1955
+ */
1956
+ function typedNumber(component, key, acceptedUnits) {
1957
+ const property = component.properties[key];
1958
+ if (property !== null && typeof property === "object") {
1959
+ const { value, unit } = property;
1960
+ if (typeof value === "number" && Number.isFinite(value)) {
1961
+ const token = typeof unit === "string" ? unit.replaceAll("'", "").trim() : "";
1962
+ return acceptedUnits.includes(token.toLowerCase()) ? value : null;
1963
+ }
1964
+ return null;
1965
+ }
1966
+ const raw = propertyString(component, key);
1967
+ if (raw === null) {
1968
+ return null;
1969
+ }
1970
+ try {
1971
+ return parseQuantity(raw);
1972
+ }
1973
+ catch {
1974
+ return null;
1975
+ }
1976
+ }
1977
+ /** A structured quantity's value, but only when it is already in base units. */
1978
+ function baseUnitQuantity(component, key,
1979
+ // A plain string, not `DeviceKind`: callers pass the component's own `kind`, whose type
1980
+ // admits `"unsupported"`. The lookup is by string and an unknown key already returns null,
1981
+ // which is the same answer a narrower type would have forced at the call site.
1982
+ kind) {
1983
+ const property = component.properties[key];
1984
+ if (property === null || typeof property !== "object") {
1985
+ return null;
1986
+ }
1987
+ const { value, unit } = property;
1988
+ if (typeof value !== "number" || !Number.isFinite(value)) {
1989
+ return null;
1990
+ }
1991
+ const accepted = baseUnitsByDeviceKind[kind];
1992
+ if (accepted === undefined) {
1993
+ return null;
1994
+ }
1995
+ // Some packets quote the empty unit, so `''` reaches here as two apostrophes.
1996
+ const token = typeof unit === "string" ? unit.replaceAll("'", "").trim() : "";
1997
+ return accepted.includes(token) || accepted.includes(token.toLowerCase())
1998
+ ? value
1999
+ : null;
2000
+ }
2001
+ /**
2002
+ * Parse a quantity to SI base units. A value that cannot be parsed is an error, not a
2003
+ * default -- a silently defaulted component is a silently wrong circuit.
2004
+ */
2005
+ export function parseQuantity(raw) {
2006
+ // Case is load-bearing and must survive: `M` is mega and `m` is milli, a factor
2007
+ // of 1e9 apart. Lower-casing first, as an earlier version of this function did,
2008
+ // silently turned every 1M resistor in the corpus into 1 milliohm -- a wrong
2009
+ // answer that still renders, which is this domain's characteristic failure.
2010
+ //
2011
+ // Real schematic values carry more than a number: `10uF/16V` is a capacitance
2012
+ // with a voltage rating, `2.7nF 5%` a tolerance, `100kB` a taper code, `10 ko`
2013
+ // an ohm sign transliterated to a letter. Reading those is parsing the notation,
2014
+ // not repairing the document -- the value is unambiguous and fully present. What
2015
+ // is still refused is a leading token that is not a quantity at all.
2016
+ let text = raw
2017
+ .trim()
2018
+ .replace(/^['"]+|['"]+$/gu, "")
2019
+ .trim();
2020
+ // A rating after a slash describes the part, not its value.
2021
+ const slash = text.indexOf("/");
2022
+ if (slash > 0) {
2023
+ text = text.slice(0, slash).trim();
2024
+ }
2025
+ // R-notation, where the multiplier letter stands in for the decimal point, so
2026
+ // `4u7` is 4.7 microfarads and `1k5` is 1500 ohms. Checked before the general
2027
+ // form, which would otherwise read `1k5` as 1000 and silently drop the 5.
2028
+ const compact = text.replace(/\s+/gu, "");
2029
+ const rNotation = /^([+-]?\d+)(R|r|k|K|M|m|u|µ|μ|n|p|G|g)(\d+)$/u.exec(compact);
2030
+ if (rNotation !== null) {
2031
+ const whole = rNotation[1];
2032
+ const unit = rNotation[2];
2033
+ const fraction = rNotation[3];
2034
+ const isOhmMarker = unit === "R" || unit === "r";
2035
+ return parseQuantity(`${whole}.${fraction}${isOhmMarker ? "" : unit}`);
2036
+ }
2037
+ // Number, optional multiplier, optional unit. Anything after that is annotation.
2038
+ // The multiplier group is case-sensitive; the unit group is not, because a unit
2039
+ // carries no scale and no multiplier letter appears in it.
2040
+ const match = /^([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)\s*(meg|MEG|Meg|[pnuµμmkKMGg]|[PNU])?\s*(ohms?|Ω|ω|o|O|R|farads?|henr(?:y|ies)|volts?|amps?|hz|[fFhHvVaA])?/u.exec(text);
2041
+ if (match === null) {
2042
+ throw new NetlistError(`cannot parse quantity "${raw}"`);
2043
+ }
2044
+ const value = Number(match[1]);
2045
+ if (!Number.isFinite(value)) {
2046
+ throw new NetlistError(`cannot parse quantity "${raw}"`);
2047
+ }
2048
+ const multipliers = {
2049
+ p: 1e-12,
2050
+ P: 1e-12,
2051
+ n: 1e-9,
2052
+ N: 1e-9,
2053
+ u: 1e-6,
2054
+ U: 1e-6,
2055
+ µ: 1e-6,
2056
+ μ: 1e-6,
2057
+ m: 1e-3,
2058
+ k: 1e3,
2059
+ K: 1e3,
2060
+ M: 1e6,
2061
+ meg: 1e6,
2062
+ MEG: 1e6,
2063
+ Meg: 1e6,
2064
+ G: 1e9,
2065
+ g: 1e9,
2066
+ };
2067
+ const symbol = match[2];
2068
+ if (symbol === undefined) {
2069
+ return value;
2070
+ }
2071
+ const multiplier = multipliers[symbol];
2072
+ if (multiplier === undefined) {
2073
+ throw new NetlistError(`cannot parse quantity "${raw}": unknown multiplier "${symbol}"`);
2074
+ }
2075
+ return value * multiplier;
2076
+ }
2077
+ function parametersFor(component, kind) {
2078
+ // A supply rail asserts a potential at its node. Most declare one -- 84 of the
2079
+ // corpus's 90 -- and the six that do not are named support shells with nothing to
2080
+ // assert, so an absent voltage is silence rather than an error.
2081
+ // **A fuse states a current rating, never a resistance.** `FuseRating: "2 A Slow Blow"` is
2082
+ // what it opens at, not what it drops while intact, so demanding a resistance would refuse
2083
+ // every fuse in the corpus for a value no fuse datasheet leads with.
2084
+ //
2085
+ // Zero is the honest answer and the law below already knows what to do with it: the resistor
2086
+ // case stamps a stated zero as `SWITCH_ON_OHMS`, "the same electrically-indistinguishable-
2087
+ // from-ideal short this file already stamps for a closed switch". A fuse is that short --
2088
+ // which is the whole content of "a fuse is just a hard wire" -- and it reaches it through
2089
+ // machinery that was already there rather than a constant invented for fuses.
2090
+ if (component.kind === "fuse") {
2091
+ // **A declared resistance wins, because a real fuse has one.** An intact fuse is a thin
2092
+ // wire, and a lower-rated one is thinner: `marshall-jcm800` states 0.05 Ω for its T2A and
2093
+ // 0.5 Ω for its T1A, which is the only pair in the corpus that scales with rating at all.
2094
+ // `marshall-jtm45` states the same 0.001 Ω for a 2 A and a 500 mA fuse and its own raw
2095
+ // calls that a "closed fuse link"; `orange-gro100` states 0.01 Ω, which is exactly
2096
+ // `SWITCH_ON_OHMS`. Those two are ideal-short placeholders, jcm800's are physical, and
2097
+ // forcing zero here would discard the physical ones on sight.
2098
+ //
2099
+ // Zero is the fallback rather than the rule, and the resistor law turns it into
2100
+ // `SWITCH_ON_OHMS` -- the short this file already stamps for a closed switch. So a fuse
2101
+ // that states nothing still conducts, and one that states a resistance keeps it.
2102
+ // **Read the typed value, not the raw.** `baseUnitQuantity` is the reader the resistor
2103
+ // path uses for exactly this reason -- its own note says to prefer a stated `unit: "ohm"`
2104
+ // "instead of re-parsing an ambiguous schematic label". A fuse is the sharpest case of
2105
+ // that ambiguity: `jcm800`'s `Resistance` is `{ raw: "2 A", value: 0.05, unit: "Ω" }`, and
2106
+ // parsing the raw yields 2 Ω, forty times the stated value. Its T1A raw parses to nothing
2107
+ // at all, which would have silently become the zero fallback.
2108
+ // The typed value first, then the string -- the same order the resistor path uses, and
2109
+ // both rungs are needed. `jcm800` states a typed `{ value: 0.05, unit: "Ω" }` whose raw
2110
+ // says "2 A", so only the typed rung is right there; `orange-rockerverb` states a plain
2111
+ // `R: "0.01 ohm"` with no typed value at all, so only the parsing rung reaches it. Taking
2112
+ // the typed rung alone silently zeroed six of rockerverb's fuses.
2113
+ const declared = baseUnitQuantity(component, "Resistance", "resistor") ??
2114
+ baseUnitQuantity(component, "R", "resistor") ??
2115
+ optionalQuantity(component, "Resistance") ??
2116
+ optionalQuantity(component, "R");
2117
+ return { ohms: declared ?? 0 };
2118
+ }
2119
+ if (kind === "rail") {
2120
+ const volts = optionalQuantity(component, "Voltage");
2121
+ return volts === null ? {} : { volts };
2122
+ }
2123
+ if (kind === "switch") {
2124
+ const pos = isHardwareSwitch(component) && !statesPosition(component)
2125
+ ? 1
2126
+ : declaredPosition(component);
2127
+ return { position: pos };
2128
+ }
2129
+ // A transformer's turns ratio, from the impedances the source states as typed quantities.
2130
+ //
2131
+ // **Not one of the 52 transformers in the amp corpus declares a `Ratio`**, so the property
2132
+ // this stage was demanding can never be satisfied by that corpus. What they do declare, and
2133
+ // often as structured `{value, unit}` rather than prose, is a primary and secondary
2134
+ // impedance — and `Zp/Zs = (Np/Ns)^2` is transformer theory, so `n = sqrt(Zp/Zs)` is
2135
+ // arithmetic on typed data rather than a reading of authored text. `fender-5e3-deluxe-tweed`
2136
+ // states `8000 Ω` and `8 Ω`, giving 31.6; `marshall-jtm45` states `6600` and `16`, giving
2137
+ // 20.3.
2138
+ //
2139
+ // Only typed quantities count. Many of the same properties appear elsewhere as prose
2140
+ // (`PrimaryImpedance: "2 kΩ plate-to-plate reference-class"`, `TurnsRatio: "44.72:1"`) and
2141
+ // those stay unread, because parsing a number out of a sentence is exactly what this
2142
+ // pipeline forbids — a typed `Ratio` or `TurnsRatio` remains the direct way to state it.
2143
+ if (kind === "transformer") {
2144
+ // `TurnsRatio` is the same quantity under the spelling two packets already use for it:
2145
+ // `fender-bassman` states `44.72:1` where its impedances give `sqrt(4000/2) = 44.72`, and
2146
+ // `marshall-1959-super-lead-plexi` states `10.31:1` against `sqrt(1700/16) = 10.31`. Both
2147
+ // are prose today and stay unread; typed, either spelling now resolves.
2148
+ const declared = optionalQuantity(component, "Ratio") ??
2149
+ typedRatio(component, "TurnsRatio");
2150
+ if (declared !== null) {
2151
+ return {
2152
+ ...windingResistanceParameters(component), ratio: declared
2153
+ };
2154
+ }
2155
+ // The pair comes off the coils that carry it (`@vessel-dsp/core@0.6.37`), replacing
2156
+ // `PrimaryImpedance`/`SecondaryImpedance` and their `InputImpedance`/`OutputImpedance`
2157
+ // spellings -- which were the same two ratings on a reverb tank's drive and pickup coils.
2158
+ // The reference is the coil the signal enters; a tank's is `drive`.
2159
+ const primary = windingRatingOhms(component, ["primary", "drive"]);
2160
+ const secondary = windingRatingOhms(component, ["secondary", "pickup"]);
2161
+ if (primary !== null &&
2162
+ secondary !== null &&
2163
+ primary > 0 &&
2164
+ secondary > 0) {
2165
+ // The pair is carried alongside the ratio it derives, not only folded into it: a
2166
+ // spring reverb tank is specified by this same `InputImpedance`/`OutputImpedance`
2167
+ // pair, and its law needs the two port impedances themselves -- the drive coil's
2168
+ // load and the pickup's source impedance -- which `sqrt(Zp/Zs)` has already thrown
2169
+ // away. Every other transformer ignores these two and reads `ratio` as before.
2170
+ return {
2171
+ ...windingResistanceParameters(component),
2172
+ ratio: Math.sqrt(primary / secondary),
2173
+ primaryImpedanceOhms: primary,
2174
+ secondaryImpedanceOhms: secondary,
2175
+ };
2176
+ }
2177
+ // Third and last: a transformer the source specifies by its **coil voltages**, which is
2178
+ // every mains power transformer. It declares no ratio because it does not have one -- a
2179
+ // 290 V HV winding, a 5 V rectifier heater and a 6.3 V filament bus off one primary are
2180
+ // three different ratios on one core -- and, decisively, it does not need one.
2181
+ //
2182
+ // Those voltages live on the coils now, so there is nothing to lift into `parameters`:
2183
+ // `device-laws.ts` reads each coil's own `voltageRmsVolts`. What remains here is the
2184
+ // refusal, which has moved to a sharper question. It used to be "is this `*Secondary`
2185
+ // spelling one of the eight I know", a spelling check that could not see a stated voltage
2186
+ // whose winding was never declared -- `mesa-boogie-dual-rectifier` states a filament
2187
+ // voltage for terminals its transformer does not draw, and that was silently discarded.
2188
+ // Now it is "does any coil state a voltage", and a leftover typed winding property is a
2189
+ // quantity no coil claims.
2190
+ requireWindingSpecification(component);
2191
+ return {
2192
+ ...windingResistanceParameters(component),
2193
+ };
2194
+ }
2195
+ // A voltage source's **frequency is what tells AC from DC**, and it is declared as a typed
2196
+ // quantity rather than left to prose.
2197
+ //
2198
+ // 19 of the 25 supplies in the amp corpus carry a `Frequency` beside their `Voltage`, often
2199
+ // structured: `fender-5f1-champ` states
2200
+ // `Frequency: {raw: "60 Hz assumed nominal", value: 60, unit: "Hz"}`. A mains inlet has one and
2201
+ // a battery cannot, so the *presence* of the property is the discriminator — a closed key name
2202
+ // compared whole, not a reading of `raw`, and nothing here parses "VAC" out of a sentence.
2203
+ //
2204
+ // This was previously recorded as an amp-lane blocker needing a source change, on the grounds
2205
+ // that "the compiler has no AC concept and the typed voltage does not say VAC". The first half
2206
+ // was true and the second was not: the discriminator was already in the packets and this stage
2207
+ // simply discarded it, because `parameterKeyByDeviceKind` reads exactly one property per kind.
2208
+ //
2209
+ // **This stage still does not say RMS or peak** — `value: 120` with `unit: "V"` is passed
2210
+ // through as a bare magnitude; only the prose says RMS, and this stage does not read prose.
2211
+ // That reading is decided downstream, in `device-laws.ts`'s `ac-source` law: this project's
2212
+ // convention is that a typed AC-source magnitude means RMS, so the law converts it to the
2213
+ // peak amplitude the sine evaluation needs.
2214
+ if (kind === "voltage-source") {
2215
+ const volts = optionalQuantity(component, "Voltage");
2216
+ const frequency = optionalQuantity(component, "Frequency");
2217
+ if (volts !== null) {
2218
+ return frequency === null ? { volts } : { volts, frequency };
2219
+ }
2220
+ }
2221
+ // An op-amp's open-loop gain, which 31 of the corpus's 52 declare and none of them reached.
2222
+ //
2223
+ // `Aol` is stated two ways and both are already linear: a bare number, and the structured form
2224
+ // with the decibel conversion done -- `{raw: "92 dB", value: 39810, unit: ""}`. So the typed
2225
+ // `value` is taken as-is and nothing here converts from dB, which would double-apply it.
2226
+ //
2227
+ // `GBP` and `SR` are deliberately not read. The gain-bandwidth product has no consumer until a
2228
+ // dominant pole exists, and that was implemented and reverted; reading it into the stamp now
2229
+ // would be a field nothing uses. `SR` is stated as `"1.2 V/us"`, and `parseQuantity` strips
2230
+ // after the slash, so it would yield 1.2 for the wrong reason -- right number, lost units.
2231
+ if (kind === "opamp") {
2232
+ // `typedNumber`, not `optionalQuantity`, and the difference is a 433x error.
2233
+ // `optionalQuantity` reads `raw` first, so `{raw: "92 dB", value: 39810, unit: ""}` parsed as
2234
+ // **92** -- the decibel figure taken as a linear gain. The structured `value` already carries
2235
+ // the conversion, so it has to win. Caught by measuring the stamped gains after the change
2236
+ // rather than by reading the code.
2237
+ const gain = typedNumber(component, "Aol", [""]);
2238
+ return gain !== null && gain > 0 ? { openLoopGain: gain } : {};
2239
+ }
2240
+ // A delay-memory shell's own delay time, which is a **packet** fact rather than a part fact.
2241
+ //
2242
+ // A bucket brigade's delay is `stages / (2 * f_clock)`, so the same chip is a 0.5 ms flanger in
2243
+ // one pedal and a 300 ms echo in another -- Panasonic's own MN3007 record spans 5.12 ms to
2244
+ // 51.2 ms across its clock range alone. The stage count belongs to the part and lives in the
2245
+ // registry; the clock rate belongs to the circuit. Where the clock network is modelled the
2246
+ // `parameter` port derives it, but a real BBD's clock net is a chip-only net between the
2247
+ // memory and its clock driver, so for a packet whose clock driver has no model there is no
2248
+ // derivation to make and the source's own declaration is the only fact available.
2249
+ //
2250
+ // Six corpus packets declare it and five are delay-memory shells (`boss-ce-1`, `boss-vb-2`,
2251
+ // `mxr-m117r-flanger`, `mxr-micro-flanger`, `electro-harmonix-electric-mistress`), so this is
2252
+ // the corpus's own convention for the quantity and not one packet's special case. It was
2253
+ // declared and discarded until now, in the same way `Aol` and `ZenerVoltage` were.
2254
+ //
2255
+ // `MinDelayMs`/`MaxDelayMs` are deliberately not read. They bound a control's travel, and no
2256
+ // control is bound to this parameter yet, so reading them would be a field nothing uses.
2257
+ if (kind === "ic") {
2258
+ const params = {};
2259
+ const delayProp = component.properties?.DelayMs;
2260
+ if (delayProp !== undefined && delayProp !== null) {
2261
+ if (typeof delayProp === "object") {
2262
+ const { value, unit } = delayProp;
2263
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
2264
+ const u = typeof unit === "string"
2265
+ ? unit.replaceAll("'", "").trim().toLowerCase()
2266
+ : "";
2267
+ if (u === "s" || u === "sec") {
2268
+ params.delaySeconds = value;
2269
+ }
2270
+ else if (u === "ms" || u === "") {
2271
+ params.delaySeconds = value / 1000;
2272
+ }
2273
+ }
2274
+ }
2275
+ else {
2276
+ const raw = propertyString(component, "DelayMs");
2277
+ if (raw !== null) {
2278
+ try {
2279
+ const num = parseQuantity(raw);
2280
+ if (num > 0) {
2281
+ params.delaySeconds =
2282
+ raw.toLowerCase().includes("s") &&
2283
+ !raw.toLowerCase().includes("ms")
2284
+ ? num
2285
+ : num / 1000;
2286
+ }
2287
+ }
2288
+ catch { }
2289
+ }
2290
+ }
2291
+ }
2292
+ const outVolts = optionalQuantity(component, "OutputVoltage");
2293
+ if (outVolts !== null) {
2294
+ params.volts = outVolts;
2295
+ }
2296
+ return params;
2297
+ }
2298
+ // A capacitor's DC leakage, opt-in per declared part. At DC an ideal capacitor is an open
2299
+ // circuit, and that openness is load-bearing: a transcription error that leaves a bias
2300
+ // network reachable only through a capacitor shows up as a floating operating point
2301
+ // instead of rendering a plausible wrong answer. So leakage is read only where the source
2302
+ // declares it, never defaulted by class, and an undeclared capacitor stays ideal.
2303
+ //
2304
+ // Two printed datasheet forms, tried in this order:
2305
+ // - `InsulationResistance`, the film/ceramic spec, an ohm quantity used directly;
2306
+ // - `LeakageCurrent` at `VoltageRating`, the electrolytic spec, which only becomes a
2307
+ // resistance when both are declared. A leakage current with no stated voltage is
2308
+ // refused rather than guessed at, the same rule the BJT's `LeakageCurrent` follows
2309
+ // for prose: an unusable declaration is absent, not approximated.
2310
+ // Falls through to the generic tail below rather than returning, because a capacitor's
2311
+ // capacitance still comes from `parameterKeyByDeviceKind` -- these are extras beside it,
2312
+ // not a replacement for it.
2313
+ const capacitorLeak = {};
2314
+ if (kind === "capacitor") {
2315
+ const insulation = optionalQuantity(component, "InsulationResistance");
2316
+ if (insulation !== null && insulation > 0) {
2317
+ capacitorLeak.insulationOhms = insulation;
2318
+ }
2319
+ const leakage = optionalQuantity(component, "LeakageCurrent");
2320
+ const rated = optionalQuantity(component, "VoltageRating");
2321
+ if (leakage !== null && leakage > 0 && rated !== null && rated > 0) {
2322
+ capacitorLeak.leakageAmps = leakage;
2323
+ capacitorLeak.ratedVolts = rated;
2324
+ }
2325
+ }
2326
+ // A zener's breakdown voltage, which is the only thing separating it from a plain diode.
2327
+ //
2328
+ // A ferrite bead spec'd by impedance-vs-frequency carries no inductance in the source.
2329
+ // The default is applied downstream in `device-laws.ts`; an absent value is not an error.
2330
+ if (kind === "inductor") {
2331
+ // **Winding DC resistance, in three states.** A number is stamped; the string `unknown` is
2332
+ // the packet stating that the source does not print it -- Fender sheets give turns ratios
2333
+ // and impedances, never copper resistance -- and is carried as a claim rather than a value;
2334
+ // absence is neither, and is never defaulted to zero. Zero is the one physically impossible
2335
+ // value for a winding, and defaulting to it is how 67 corpus windings came to be lossless.
2336
+ //
2337
+ // `parameters` is `Record<string, number>`, so the third state is carried as its own flag
2338
+ // rather than by widening that map: `windingResistanceOhms` present means declared,
2339
+ // `windingResistanceUnknown: 1` means the packet said so explicitly, and neither means
2340
+ // nobody has said anything. Three states, still machine-readable, no default.
2341
+ const winding = windingResistanceParameters(component);
2342
+ const henries = optionalQuantity(component, "Inductance");
2343
+ if (henries !== null && henries > 0) {
2344
+ return { henries, ...winding };
2345
+ }
2346
+ const fallback = optionalQuantity(component, "L");
2347
+ if (fallback !== null && fallback > 0) {
2348
+ return { henries: fallback, ...winding };
2349
+ }
2350
+ return { ...winding };
2351
+ }
2352
+ // 25 diodes across 18 packets declare it, 13 of those packets compiling, and every one was
2353
+ // discarded: the stamp had no field for it, so a zener never broke down, never clamped, and
2354
+ // every zener-regulated rail floated to the supply. `boss-ph-1r` is the clean case -- it
2355
+ // declares a `5.1 V zener reference for the PH-1r bias rail` *and* a port stating 5.1 V on
2356
+ // that same node, so the source agrees with itself and the rail still solved at 8.441 V.
2357
+ //
2358
+ // Read from `ReverseBreakdownVoltage`, a typed quantity. `Type: Zener` alone is not enough to
2359
+ // act on -- it says the part is a zener without saying at what voltage -- so it is deliberately
2360
+ // not treated as evidence for a magnitude, and nothing reads a part number to guess one.
2361
+ if (kind === "diode") {
2362
+ // Three spellings of one quantity, all volts, all unambiguous. `ReverseBreakdownVoltage`
2363
+ // appears on one packet's diodes; `BreakdownVoltage` on four and `ZenerVoltage` on five
2364
+ // carried the same datum and were discarded, so the zener law reached 15 stamps and missed
2365
+ // nine purely on vocabulary.
2366
+ //
2367
+ // `SaturationCurrent` is not a synonym but the same shape of gap: a diode's own saturation
2368
+ // current, stated in amps, against a hardcoded class default of 2.52e-9.
2369
+ const breakdown = optionalQuantity(component, "ReverseBreakdownVoltage") ??
2370
+ optionalQuantity(component, "BreakdownVoltage") ??
2371
+ optionalQuantity(component, "ZenerVoltage");
2372
+ const saturation = optionalQuantity(component, "SaturationCurrent");
2373
+ const seriesResistance = optionalQuantity(component, "SeriesResistance") ??
2374
+ optionalQuantity(component, "OhmicResistance") ??
2375
+ optionalQuantity(component, "RS") ??
2376
+ optionalQuantity(component, "Rs");
2377
+ return {
2378
+ ...(breakdown !== null && breakdown > 0
2379
+ ? { breakdownVolts: breakdown }
2380
+ : {}),
2381
+ ...(saturation !== null && saturation > 0
2382
+ ? { saturationCurrent: saturation }
2383
+ : {}),
2384
+ ...(seriesResistance !== null && seriesResistance > 0
2385
+ ? { seriesResistance }
2386
+ : {}),
2387
+ };
2388
+ }
2389
+ // A bipolar transistor's device type and SPICE parameters, which this stage used to
2390
+ // discard in full.
2391
+ //
2392
+ // `deviceLaw` reads `parameters.pnp` and `parameters.beta`, and **nothing wrote either**:
2393
+ // there was no `bjt` entry here, so every one of the corpus's 385 bipolar transistors was
2394
+ // stamped as the same silicon NPN with `beta = 100` and `IS = 1e-14`, whatever the packet
2395
+ // said. 43 of them declare `Type: PNP`.
2396
+ //
2397
+ // The parameters are declared, typed, and specific. `sola-sound-tone-bender-professional-mkii`
2398
+ // gives `Q1` a `Type` of PNP, `IS` of `{raw: "1 nA", value: 1e-9, unit: A}`, `BF` 70, `BR` 2
2399
+ // and a `PartNumber` of `OC41` -- a germanium PNP. `Vbe = Vt * ln(Ic / IS)` at 1 mA is 0.345 V
2400
+ // at the declared `IS` against 0.633 V at the silicon default, so a germanium bias network
2401
+ // modelled with silicon never reaches turn-on and the pedal renders near-silence.
2402
+ //
2403
+ // Each is omitted when absent rather than defaulted here, so `deviceLaw` keeps ownership of
2404
+ // what an undeclared parameter means. Only 10% of the corpus's transistors declare `IS`.
2405
+ if (kind === "bjt") {
2406
+ // `Polarity` is the same closed vocabulary under a second spelling, and 17 of the corpus's
2407
+ // transistors declare it with no `Type` at all -- including `boss-tw-1`'s `Q4` and `Q5`,
2408
+ // declared `PNP` with a `2SA1015-Y` part number and stamped NPN because only `Type` was
2409
+ // read. Found by `report-source-coverage.ts` on its first run.
2410
+ const declaredType = propertyString(component, "Type") ??
2411
+ propertyString(component, "Polarity");
2412
+ const parameters = {};
2413
+ if (declaredType !== null) {
2414
+ const polarity = bjtPolarityByType[declaredType.toUpperCase()];
2415
+ if (polarity === undefined) {
2416
+ throw new NetlistError(`component ${component.id}: bjt has Type "${declaredType}", which is not NPN or PNP`, String(component.id));
2417
+ }
2418
+ parameters.pnp = polarity;
2419
+ }
2420
+ for (const [key, name] of [
2421
+ ["IS", "saturationCurrent"],
2422
+ ["BF", "beta"],
2423
+ ["BR", "reverseBeta"],
2424
+ ]) {
2425
+ const value = typedNumber(component, key, bjtParameterUnits[key] ?? []);
2426
+ if (value !== null && value > 0) {
2427
+ parameters[name] = value;
2428
+ }
2429
+ }
2430
+ // `LeakageCurrent` is the one BJT property the packets do **not** declare in the
2431
+ // structured form: all 14 that carry it carry a bare string, and they are not
2432
+ // homogeneous -- "0.15 uA", "0.1 \u03bcA" with the micro sign, "100 uA", and on
2433
+ // `dallas-arbiter-fuzz-face-ac128` the sentence "source-bounded germanium specimen
2434
+ // parameter; exact original unit unclaimed", which is a note and not a value.
2435
+ //
2436
+ // So this reads it as a quantity and treats an unparseable one as absent. That is the
2437
+ // same rule the other three follow, and it is honest here for a specific reason: the
2438
+ // one string that will not parse says in words that its value is unclaimed. Nothing
2439
+ // pattern-matches the prose -- `parseQuantity` refuses a leading token that is not a
2440
+ // quantity, and the refusal is the whole decision.
2441
+ const leakage = propertyString(component, "LeakageCurrent");
2442
+ if (leakage !== null) {
2443
+ try {
2444
+ const amps = parseQuantity(leakage);
2445
+ if (Number.isFinite(amps) && amps > 0) {
2446
+ parameters.leakageAmps = amps;
2447
+ }
2448
+ }
2449
+ catch {
2450
+ // Not a quantity, so not declared.
2451
+ }
2452
+ }
2453
+ return parameters;
2454
+ }
2455
+ /**
2456
+ * A FET's channel polarity from its declared type, or `null` when the document does not say.
2457
+ *
2458
+ * **Whole tokens against a closed vocabulary, never `includes("P")`.** The first version tested
2459
+ * `upper.includes("P")` then `upper.includes("N")`, which was correct on all eight values the
2460
+ * corpus happens to carry today only because `'N-channel JFET'` contains no P. Values one word
2461
+ * away break it, and P was tested first so P always won:
2462
+ *
2463
+ * 'N-channel depletion' -> p-channel (the P is in "depletion")
2464
+ * 'N-ch (P-suffix pkg)' -> p-channel
2465
+ * 'Depletion' -> p-channel (carries no channel information at all)
2466
+ *
2467
+ * A polarity read backwards inverts the device's whole transfer curve and stays silent, because
2468
+ * the circuit still solves. This is the same rule `wiperRoles` and `switchCommonRoles` already
2469
+ * follow, and the same failure the CD4047 pin map and the pot-wiper short were.
2470
+ */
2471
+ function declaredChannel(declared) {
2472
+ // Folded the way `foldToken` folds: lowercased, and every separator dropped, so
2473
+ // `P-Channel`, `p channel`, `P_CHANNEL` and `pchannel` are one token.
2474
+ const folded = declared.toLowerCase().replace(/[^a-z0-9]/gu, "");
2475
+ const P = new Set([
2476
+ "p",
2477
+ "pchannel",
2478
+ "pchanneljfet",
2479
+ "pchannelmosfet",
2480
+ "pjfet",
2481
+ // SPICE's own device keywords, which is what `boss-pq-4` declares ("NJF").
2482
+ "pjf",
2483
+ "pmos",
2484
+ "pmosfet",
2485
+ "pfet",
2486
+ "pch",
2487
+ ]);
2488
+ const N = new Set([
2489
+ "n",
2490
+ "nchannel",
2491
+ "nchanneljfet",
2492
+ "nchannelmosfet",
2493
+ "njfet",
2494
+ "njf",
2495
+ "nmos",
2496
+ "nmosfet",
2497
+ "nfet",
2498
+ "nch",
2499
+ ]);
2500
+ if (P.has(folded)) {
2501
+ return "p";
2502
+ }
2503
+ if (N.has(folded)) {
2504
+ return "n";
2505
+ }
2506
+ // A prefixed spelling the closed set does not enumerate -- `n-channel depletion`,
2507
+ // `p-channel enhancement`. Anchored at the start and required to be followed by the word
2508
+ // `channel`, so the polarity letter cannot be picked up from the middle of another word.
2509
+ const prefixed = /^([np])channel/u.exec(folded);
2510
+ if (prefixed !== null) {
2511
+ return prefixed[1] === "p" ? "p" : "n";
2512
+ }
2513
+ return null;
2514
+ }
2515
+ if (kind === "jfet" || kind === "mosfet") {
2516
+ const parameters = {};
2517
+ const declaredType = propertyString(component, "Type") ??
2518
+ propertyString(component, "Polarity") ??
2519
+ propertyString(component, "Channel");
2520
+ if (declaredType !== null) {
2521
+ const channel = declaredChannel(declaredType);
2522
+ if (channel !== null) {
2523
+ parameters.pChannel = channel === "p" ? 1 : 0;
2524
+ }
2525
+ }
2526
+ const rawPChannel = component.properties?.pChannel;
2527
+ if (rawPChannel === 1 ||
2528
+ rawPChannel === "1" ||
2529
+ rawPChannel === true ||
2530
+ rawPChannel === "true") {
2531
+ parameters.pChannel = 1;
2532
+ }
2533
+ // "Vt0"/"Beta" first: those are the live corpus spellings (mxr-phase-90,
2534
+ // boss-ph-1r, mxr-noise-gate-line-driver, jhs-morning-glory), and no corpus
2535
+ // file uses the all-caps SPICE forms. Closed whole-key list, not matching.
2536
+ const vto = optionalQuantity(component, "Vt0") ??
2537
+ optionalQuantity(component, "VTO") ??
2538
+ optionalQuantity(component, "ThresholdVoltage") ??
2539
+ optionalQuantity(component, "thresholdVolts");
2540
+ if (vto !== null) {
2541
+ parameters.thresholdVolts = vto;
2542
+ }
2543
+ const beta = optionalQuantity(component, "Beta") ??
2544
+ optionalQuantity(component, "BETA") ??
2545
+ optionalQuantity(component, "Transconductance") ??
2546
+ optionalQuantity(component, "transconductance");
2547
+ if (beta !== null) {
2548
+ parameters.transconductance = beta;
2549
+ }
2550
+ return parameters;
2551
+ }
2552
+ const key = parameterKeyByDeviceKind[kind];
2553
+ if (key === undefined) {
2554
+ return {};
2555
+ }
2556
+ const name = parameterNameByDeviceKind[kind] ?? "value";
2557
+ const raw = propertyQuantityText(component, key) ??
2558
+ (fallbackParameterKeys[kind] ?? []).reduce((found, fallback) => found ?? propertyQuantityText(component, fallback), null);
2559
+ if (raw === null) {
2560
+ throw new NetlistError(`component ${component.id}: ${kind} has no ${key}`, String(component.id));
2561
+ }
2562
+ // A rheostat is the one device with two values: the track it sweeps and the residual
2563
+ // resistance at the bottom of the sweep. Absent, the bottom is a dead short, so the
2564
+ // stamp floors it rather than this stage inventing a minimum.
2565
+ // A pot reads it too, and used not to. This was gated on `rheostat` alone, so a
2566
+ // `MinResistance` declared on a `potentiometer` was parsed and then dropped on the
2567
+ // floor -- the same silent-drop shape as the five parameters `report-source-coverage.ts`
2568
+ // was written to catch, latent rather than live only because all 7 of the corpus's
2569
+ // declarations happen to sit on `variable-resistor` rows. The two kinds spend it
2570
+ // differently (`device-laws.ts`: a sweep floor for a rheostat, an end resistance taken
2571
+ // out of a fixed track for a pot), so reading it here is not the same as sharing a law.
2572
+ const extra = kind === "rheostat" || kind === "potentiometer"
2573
+ ? { minOhms: optionalOhms(component, "MinResistance") }
2574
+ : {};
2575
+ // If the source states the quantity in base units (`unit: "F"`, `unit: "ohm"`, etc.),
2576
+ // use that directly instead of re-parsing an ambiguous schematic label like `.01 CER`
2577
+ // or `100/16` where parseQuantity would see `.01` or `100` as base units.
2578
+ const base = baseUnitQuantity(component, key, kind) ??
2579
+ (fallbackParameterKeys[kind] ?? []).reduce((found, fallback) => found ?? baseUnitQuantity(component, fallback, kind), null);
2580
+ if (base !== null) {
2581
+ return { [name]: base, ...extra, ...capacitorLeak };
2582
+ }
2583
+ return { [name]: parseQuantity(raw), ...extra, ...capacitorLeak };
2584
+ }
2585
+ /** An optional quantity, absent rather than defaulted when it is not declared. */
2586
+ function optionalOhms(component, key) {
2587
+ const raw = propertyString(component, key);
2588
+ if (raw === null) {
2589
+ return 0;
2590
+ }
2591
+ try {
2592
+ return parseQuantity(raw);
2593
+ }
2594
+ catch {
2595
+ // Same defect as `optionalQuantity`: read the component's own kind first, keeping
2596
+ // `resistor` as a second attempt so this cannot change a value that already resolved.
2597
+ return (baseUnitQuantity(component, key, component.kind) ??
2598
+ baseUnitQuantity(component, key, "resistor") ??
2599
+ 0);
2600
+ }
2601
+ }
2602
+ /** An optional quantity, null when the component does not declare it at all. */
2603
+ /**
2604
+ * An impedance the source states as a **structured quantity** — `{raw, value, unit}` — in ohms.
2605
+ *
2606
+ * Deliberately strict. The same property names carry prose elsewhere in the corpus
2607
+ * (`"2 kΩ plate-to-plate reference-class"`), and a reader that fell back to scraping a number
2608
+ * out of that sentence would be deciding a device parameter from authored text. `null` here
2609
+ * means "the source did not state this as data", which is a source fact and refusable, not a
2610
+ * gap to paper over.
2611
+ */
2612
+ /**
2613
+ * A turns ratio the source states as a **structured quantity**, or `null`.
2614
+ *
2615
+ * Typed only, and deliberately stricter than the `Ratio` beside it. `Ratio` keeps `optionalQuantity`,
2616
+ * which accepts the format's ordinary string value encoding -- `Ratio: "2"`, the same shape as a
2617
+ * resistor's `R: "10k"` -- and has fixture coverage for it.
2618
+ *
2619
+ * `TurnsRatio` does not inherit that, because its only two occurrences in the corpus are the colon
2620
+ * form: `fender-bassman` states `"44.72:1"` and `marshall-1959-super-lead-plexi` `"10.31:1"`.
2621
+ * `parseQuantity` reads those as `44.72` and `10.31`, which happens to be right -- and would read
2622
+ * `"1:44.72"` as `1`, silently inverting the transformer. A newly recognised spelling should not
2623
+ * inherit a coincidence, so this one requires the source to have committed to a number.
2624
+ *
2625
+ * No transformer declares either spelling typed today, so this reads nothing yet; it exists so that
2626
+ * typing one is not a trap. Both real occurrences match what the impedances give --
2627
+ * `sqrt(4000/2) = 44.72` and `sqrt(1700/16) = 10.31` -- confirming it is the same quantity.
2628
+ *
2629
+ * Dimensionless: an empty or absent unit, or the `:1` a ratio is conventionally written with. A
2630
+ * ratio carrying volts or ohms is a different quantity and is refused rather than guessed at.
2631
+ */
2632
+ function typedRatio(component, key) {
2633
+ const property = component.properties[key];
2634
+ if (property === null || typeof property !== "object") {
2635
+ return null;
2636
+ }
2637
+ const { value, unit } = property;
2638
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
2639
+ return null;
2640
+ }
2641
+ const token = typeof unit === "string" ? unit.trim().toLowerCase() : "";
2642
+ return token === "" || token === ":1" ? value : null;
2643
+ }
2644
+ function typedOhms(component, key) {
2645
+ const property = component.properties[key];
2646
+ if (property === null || typeof property !== "object") {
2647
+ return null;
2648
+ }
2649
+ const { value, unit } = property;
2650
+ if (typeof value !== "number" || !Number.isFinite(value)) {
2651
+ return null;
2652
+ }
2653
+ const token = typeof unit === "string" ? unit.trim().toLowerCase() : "";
2654
+ return token === "" || token === "ω" || token === "ohm" || token === "ohms"
2655
+ ? value
2656
+ : null;
2657
+ }
2658
+ /**
2659
+ * A winding voltage the source states as a **structured quantity** in volts.
2660
+ *
2661
+ * Same discipline as `typedOhms`, and deliberately without the empty-unit fallback that
2662
+ * ohms accepts: an unlabelled number could be almost anything, and a voltage-shaped
2663
+ * property with no unit is not the same source fact as one that says `V`. Every winding
2664
+ * voltage in the corpus that reaches this reads `unit: V`.
2665
+ */
2666
+ function typedVolts(component, key) {
2667
+ const property = component.properties[key];
2668
+ if (property === null || typeof property !== "object") {
2669
+ return null;
2670
+ }
2671
+ const { value, unit } = property;
2672
+ if (typeof value !== "number" || !Number.isFinite(value)) {
2673
+ return null;
2674
+ }
2675
+ const token = typeof unit === "string" ? unit.trim().toLowerCase() : "";
2676
+ return token === "v" || token === "volt" || token === "volts" ? value : null;
2677
+ }
2678
+ /**
2679
+ * The impedance one of this transformer's coils is rated at, in ohms, or null.
2680
+ *
2681
+ * `roles` is tried in order and the first coil carrying a rating wins, which is how one reader
2682
+ * serves both a transformer (`primary`/`secondary`) and a spring reverb tank (`drive`/`pickup`) --
2683
+ * the two shapes that used to need `PrimaryImpedance`/`SecondaryImpedance` and
2684
+ * `InputImpedance`/`OutputImpedance` as separate property spellings.
2685
+ *
2686
+ * A coil may carry several ratings, one per rated tap. This returns the **first**, which for every
2687
+ * corpus coil but one is the only one; the multi-tap case is decided in `transformer.ts`, where
2688
+ * connectivity says which taps are live and each stamped winding takes the rating of its own tap.
2689
+ * A single number could not answer it here: `orange-rockerverb`'s output secondary rates a 16 Ω
2690
+ * and an 8 Ω tap that are loaded at the same time.
2691
+ */
2692
+ function windingRatingOhms(component, roles) {
2693
+ const windings = component.windings;
2694
+ if (!Array.isArray(windings)) {
2695
+ return null;
2696
+ }
2697
+ for (const role of roles) {
2698
+ for (const winding of windings) {
2699
+ if (winding === null || typeof winding !== "object") {
2700
+ continue;
2701
+ }
2702
+ const entry = winding;
2703
+ if (entry.role !== role || !Array.isArray(entry.impedances)) {
2704
+ continue;
2705
+ }
2706
+ for (const rating of entry.impedances) {
2707
+ const ohms = quantityValue(rating?.impedance);
2708
+ if (ohms !== null && ohms > 0) {
2709
+ return ohms;
2710
+ }
2711
+ }
2712
+ }
2713
+ }
2714
+ return null;
2715
+ }
2716
+ /**
2717
+ * A transformer must state *something* a stamp can be built from, and this is where that is
2718
+ * required.
2719
+ *
2720
+ * Either a ratio, or a rated impedance pair, or at least one coil's own voltage. A document
2721
+ * satisfying none of the three describes a transformer nobody can lower, and it refuses here
2722
+ * rather than producing a silent open circuit downstream.
2723
+ *
2724
+ * **A typed winding-voltage property that survives on the component is a quantity no coil
2725
+ * claims.** Ratings live on the coils now, so a leftover typed `*Secondary` is either a value
2726
+ * whose winding the document never declared, or a backfill that stopped early. The old check in
2727
+ * this position asked whether the *spelling* was one of eight it recognised, which could not see
2728
+ * either case: `mesa-boogie-dual-rectifier` states a filament voltage for terminals its
2729
+ * transformer does not draw, under a spelling that was in the list, and it was silently dropped.
2730
+ */
2731
+ function requireWindingSpecification(component) {
2732
+ const windings = component.windings;
2733
+ const coils = Array.isArray(windings) ? windings : [];
2734
+ for (const key of Object.keys(component.properties)) {
2735
+ if (!key.endsWith("Secondary") || typedVolts(component, key) === null) {
2736
+ continue;
2737
+ }
2738
+ throw new NetlistError(`component ${component.id}: transformer states a typed "${key}" winding voltage as a component property, but a coil's voltage belongs to the coil -- either no declared winding claims this value, or it has not been moved onto one`, String(component.id));
2739
+ }
2740
+ if (coils.some((winding) => quantityValue(winding?.voltage) !==
2741
+ null)) {
2742
+ return;
2743
+ }
2744
+ throw new NetlistError(`component ${component.id}: transformer states no Ratio, no rated impedance pair on its coils, and no coil voltage -- a transformer whose primary carries signal needs a ratio or an impedance pair, and one specified by its secondaries needs each coil's own voltage`, String(component.id));
2745
+ }
2746
+ /**
2747
+ * A winding's declared DC resistance, in three states, shared by the inductor and transformer
2748
+ * branches. One definition rather than two, because a shared policy module carrying two spellings
2749
+ * of its own policy is the defect `settle.ts` had this morning.
2750
+ *
2751
+ * A number is a value; the literal `unknown` is the packet stating the source does not print it --
2752
+ * Fender sheets give turns ratios and impedances, never copper resistance -- and is carried as its
2753
+ * own flag because `parameters` is `Record<string, number>`; silence produces neither. **Never
2754
+ * defaulted to zero**: zero is the one physically impossible value for a winding.
2755
+ */
2756
+ function windingResistanceParameters(component) {
2757
+ // **Every spelling the corpus actually uses, not the one that came to mind.** Surveyed rather
2758
+ // than guessed: `WindingResistance` 2 packets, `WindingResistanceOhms` 4, `SeriesResistance` 2.
2759
+ // The first version of this function read the first and `DCResistance` -- which no packet uses
2760
+ // at all -- so it caught 2 of 8 declared values and silently discarded 6. That is the
2761
+ // synonym-orphan class this repository already has a report for, reproduced in new code hours
2762
+ // after it was written down: a reader that knows one spelling reports the others as absent, and
2763
+ // absent reads as a finding.
2764
+ const KEYS = [
2765
+ "WindingResistance",
2766
+ "WindingResistanceOhms",
2767
+ "SeriesResistance",
2768
+ "DCResistance",
2769
+ ];
2770
+ let raw = null;
2771
+ let ohms = null;
2772
+ for (const key of KEYS) {
2773
+ raw ??= propertyString(component, key);
2774
+ ohms ??= optionalQuantity(component, key);
2775
+ }
2776
+ if (ohms !== null && ohms > 0)
2777
+ return { windingResistanceOhms: ohms };
2778
+ if (raw !== null && raw.trim().toLowerCase() === "unknown")
2779
+ return { windingResistanceUnknown: 1 };
2780
+ return {};
2781
+ }
2782
+ function optionalQuantity(component, key) {
2783
+ const raw = propertyString(component, key);
2784
+ if (raw === null) {
2785
+ return null;
2786
+ }
2787
+ try {
2788
+ return parseQuantity(raw);
2789
+ }
2790
+ catch {
2791
+ // The recovery read must use the COMPONENT'S OWN kind. Hardcoding one kind here
2792
+ // checks every typed quantity against that kind's units, so a well-formed
2793
+ // `value: 10, unit: H` on an inductor was tested against volts, rejected, and the
2794
+ // declared value thrown away -- `vox-ac30-top-boost`'s 292-767 supply choke states
2795
+ // 10 H and compiled to `FERRITE_BEAD_DEFAULT_HENRIES`, 10 uH: a factor of 1e6, and a
2796
+ // power-supply filter choke modelled as a wire. Its `raw` is prose-leading
2797
+ // ("source-visible choke 292-767, validation-derived 10 H"), which is why the string
2798
+ // parse threw and the typed value was the only thing left to read.
2799
+ //
2800
+ // The old kind is kept as a second attempt rather than replaced, so this is a pure
2801
+ // widening: every quantity that resolved before resolves to the same number.
2802
+ return (baseUnitQuantity(component, key, component.kind) ??
2803
+ baseUnitQuantity(component, key, "voltage-source"));
2804
+ }
2805
+ }
2806
+ /** Keys that may carry a reference to a declared control, checked in this order. */
2807
+ const controlReferenceKeys = [
2808
+ "InterfaceControlId",
2809
+ "interfaceControlId",
2810
+ "controlId",
2811
+ "ControlId",
2812
+ "control_id",
2813
+ "Control_Id",
2814
+ "Control",
2815
+ "control",
2816
+ "ControlledBy",
2817
+ "controlledBy",
2818
+ "Gang",
2819
+ "gang",
2820
+ "ControlName",
2821
+ "controlName",
2822
+ "ControlRole",
2823
+ "controlRole",
2824
+ "PhysicalControl",
2825
+ "physicalControl",
2826
+ "PanelLabel",
2827
+ "panelLabel",
2828
+ "Name",
2829
+ "name",
2830
+ "Label",
2831
+ "label",
2832
+ ];
2833
+ export const HARDWARE_SWITCH_ROLES = new Set([
2834
+ "power",
2835
+ "power-switch",
2836
+ "standby",
2837
+ "mains",
2838
+ "service-trim",
2839
+ "ground-switch",
2840
+ "voltage-selector",
2841
+ "impedance-selector",
2842
+ ]);
2843
+ export const HARDWARE_SWITCH_KINDS = new Set([
2844
+ "mains-power",
2845
+ "standby",
2846
+ "mains-voltage-selector",
2847
+ "rotary-impedance-selector",
2848
+ "ground-switch",
2849
+ ]);
2850
+ /**
2851
+ * The `DeviceInterfaceControlKind` values that admit a real panel control into
2852
+ * `Program.controls`.
2853
+ *
2854
+ * Closed vocabulary compared as whole values, exactly as `@vessel-dsp/core`'s
2855
+ * `parseDeviceInterfaceControlKind` validates: `knob`, `slider`, `switch`,
2856
+ * `selector`, `footswitch`, `led`, `display`, `jack`. The potentiometer/sweep
2857
+ * class (`knob`, `slider`) is admitted together with the switches a player
2858
+ * operates — bypass footswitches, mode/range selectors, effect switches. The
2859
+ * hardware that cannot move the audio is excluded: `jack`, `led`, `display`,
2860
+ * plus anything outside the vocabulary. A control whose declared kind is not a
2861
+ * member is dropped from `Program.controls`; a control with no declared kind
2862
+ * passes, because an older document without the field is not assumed to be
2863
+ * hardware. A kind is compared whole, never by matching a control's name or
2864
+ * description.
2865
+ */
2866
+ export const USER_CONTROL_KINDS = new Set([
2867
+ "knob",
2868
+ "slider",
2869
+ "switch",
2870
+ "selector",
2871
+ "footswitch",
2872
+ ]);
2873
+ /**
2874
+ * Declared panel-control roles that name non-user hardware which is not a
2875
+ * switch: a contact a power-entry jack carries (`battery-contact`) or a status
2876
+ * light (`indicator`). Closed vocabulary compared as whole values — a role
2877
+ * that is not a member is never matched by substring or prose.
2878
+ */
2879
+ export const NON_USER_HARDWARE_ROLES = new Set([
2880
+ "indicator",
2881
+ "battery-contact",
2882
+ ]);
2883
+ /**
2884
+ * Whether a declared panel control is user-visible hardware the runtime should
2885
+ * expose.
2886
+ *
2887
+ * All decisions are whole-value comparisons against closed vocabularies — the
2888
+ * kind set {@link USER_CONTROL_KINDS} and the role sets
2889
+ * {@link HARDWARE_SWITCH_ROLES} and {@link NON_USER_HARDWARE_ROLES} — never a
2890
+ * read of the control's name or description, which the engineering principles
2891
+ * forbid. An absent kind or role passes: an older document that states neither
2892
+ * is not assumed to be hardware.
2893
+ */
2894
+ export function isUserPanelControl(control) {
2895
+ const role = control.role;
2896
+ if (typeof role === "string" &&
2897
+ (HARDWARE_SWITCH_ROLES.has(role.toLowerCase()) ||
2898
+ NON_USER_HARDWARE_ROLES.has(role.toLowerCase()))) {
2899
+ return false;
2900
+ }
2901
+ const kind = control.kind;
2902
+ if (typeof kind === "string" &&
2903
+ kind.length > 0 &&
2904
+ !USER_CONTROL_KINDS.has(kind.toLowerCase())) {
2905
+ return false;
2906
+ }
2907
+ return true;
2908
+ }
2909
+ /**
2910
+ * Whether a component represents non-performance hardware, power-domain switching,
2911
+ * or internal service configuration rather than a player-adjustable control.
2912
+ */
2913
+ export function isHardwareSwitch(component) {
2914
+ const role = component.role ??
2915
+ component.properties?.Role;
2916
+ if (typeof role === "string" &&
2917
+ HARDWARE_SWITCH_ROLES.has(role.toLowerCase())) {
2918
+ return true;
2919
+ }
2920
+ const switchKind = propertyString(component, "SwitchKind") ??
2921
+ propertyString(component, "switchKind");
2922
+ if (switchKind !== null &&
2923
+ HARDWARE_SWITCH_KINDS.has(switchKind.toLowerCase())) {
2924
+ return true;
2925
+ }
2926
+ const powerSwitch = propertyString(component, "PowerSwitch");
2927
+ if (powerSwitch === "true" ||
2928
+ component.properties
2929
+ ?.PowerSwitch === true) {
2930
+ return true;
2931
+ }
2932
+ const serviceControl = propertyString(component, "ServiceControl");
2933
+ if (serviceControl === "true" ||
2934
+ component.properties
2935
+ ?.ServiceControl === true) {
2936
+ return true;
2937
+ }
2938
+ const visibility = propertyString(component, "Visibility");
2939
+ if (visibility !== null && visibility.toLowerCase() === "internal") {
2940
+ return true;
2941
+ }
2942
+ if (propertyString(component, "ThermalFuse") !== null ||
2943
+ propertyString(component, "SelectedValidationState") !== null ||
2944
+ propertyString(component, "SelectedValidationTap") !== null) {
2945
+ return true;
2946
+ }
2947
+ return false;
2948
+ }
2949
+ /**
2950
+ * Strips multi-pole designator suffixes (e.g. `FOOTSWITCH_A` -> `FOOTSWITCH`, `S1A` -> `S1`, `SW2B` -> `SW2`)
2951
+ * so ganged switch poles bind to the shared base actuator.
2952
+ */
2953
+ export function stripPoleSuffix(id) {
2954
+ const underscoreMatch = /^(.+?)_[A-Za-z0-9]$/.exec(id);
2955
+ if (underscoreMatch && underscoreMatch[1]) {
2956
+ return underscoreMatch[1];
2957
+ }
2958
+ const letterMatch = /^([A-Za-z]+\d+)[A-Za-z]$/.exec(id);
2959
+ if (letterMatch && letterMatch[1]) {
2960
+ return letterMatch[1];
2961
+ }
2962
+ return id;
2963
+ }
2964
+ /**
2965
+ * Which control varies this device.
2966
+ *
2967
+ * The test is **exact equality against the ids and labels this document itself declares** in
2968
+ * `deviceInterface.controls` -- a closed vocabulary, compared whole. It is emphatically
2969
+ * not "does this property mention a knob": a value only binds if it *is* a declared id or label.
2970
+ * That is why reading `Name` here is safe, and why it would not be safe to read it for
2971
+ * anything else.
2972
+ *
2973
+ * A device matching no declared id still gets a control, named for the component: a
2974
+ * trimmer or an internal switch is adjustable, just not on the enclosure. Refusing the
2975
+ * pedal over one unmapped trimmer would be strictness without a purpose.
2976
+ */
2977
+ function controlBindingFor(component, declaredControls, kind,
2978
+ // A switch whose every connected terminal resolves to one node. It is a fixed
2979
+ // bridge, so it manufactures no control even though it binds no declared one.
2980
+ fixedContactSwitch) {
2981
+ if (isHardwareSwitch(component)) {
2982
+ return null;
2983
+ }
2984
+ for (const key of controlReferenceKeys) {
2985
+ const value = propertyString(component, key);
2986
+ if (value !== null) {
2987
+ const bound = declaredControls.get(value) ??
2988
+ declaredControls.get(value.toLowerCase());
2989
+ if (bound !== undefined) {
2990
+ return bound;
2991
+ }
2992
+ }
2993
+ }
2994
+ const idStr = String(component.id);
2995
+ const idBound = declaredControls.get(idStr) ?? declaredControls.get(idStr.toLowerCase());
2996
+ if (idBound !== undefined) {
2997
+ return idBound;
2998
+ }
2999
+ const baseId = stripPoleSuffix(idStr);
3000
+ if (baseId !== idStr) {
3001
+ const baseBound = declaredControls.get(baseId) ??
3002
+ declaredControls.get(baseId.toLowerCase());
3003
+ if (baseBound !== undefined) {
3004
+ return baseBound;
3005
+ }
3006
+ }
3007
+ if (kind === "potentiometer" || kind === "rheostat") {
3008
+ return idStr;
3009
+ }
3010
+ if (kind === "switch") {
3011
+ if (fixedContactSwitch) {
3012
+ return null;
3013
+ }
3014
+ return baseId;
3015
+ }
3016
+ return null;
3017
+ }
3018
+ /**
3019
+ * Every name this document uses for each control it declares, mapped to the control's
3020
+ * id.
3021
+ *
3022
+ * A control usually names itself twice: once as `id`, and once as the component name
3023
+ * its `audioBinding` points at -- `id: MID_FREQ` with `controlName: "Mid Frequency"`.
3024
+ * Components then reference whichever of the two their author had to hand, so matching
3025
+ * only the id leaves a pot bound to nothing and it invents a control of its own. That
3026
+ * is how one dual-gang knob became two independent knobs.
3027
+ *
3028
+ * Still a closed vocabulary compared as whole values: every entry is a name this
3029
+ * document declares for a control it declares.
3030
+ */
3031
+ function declaredControlNames(document) {
3032
+ const names = new Map();
3033
+ for (const control of document.deviceInterface?.controls ?? []) {
3034
+ if (!isUserPanelControl(control)) {
3035
+ continue;
3036
+ }
3037
+ const id = String(control.id);
3038
+ names.set(id, id);
3039
+ names.set(id.toLowerCase(), id);
3040
+ const label = control.label;
3041
+ if (typeof label === "string" && label.trim().length > 0) {
3042
+ const l = label.trim();
3043
+ if (!names.has(l)) {
3044
+ names.set(l, id);
3045
+ }
3046
+ if (!names.has(l.toLowerCase())) {
3047
+ names.set(l.toLowerCase(), id);
3048
+ }
3049
+ }
3050
+ const bound = control
3051
+ .audioBinding?.controlName;
3052
+ if (typeof bound === "string" && bound.trim().length > 0) {
3053
+ const b = bound.trim();
3054
+ // The id wins if two controls claim the same alias.
3055
+ if (!names.has(b)) {
3056
+ names.set(b, id);
3057
+ }
3058
+ if (!names.has(b.toLowerCase())) {
3059
+ names.set(b.toLowerCase(), id);
3060
+ }
3061
+ }
3062
+ }
3063
+ return names;
3064
+ }
3065
+ /**
3066
+ * A switch's contact state, as a **closed vocabulary compared as whole values** — never a
3067
+ * substring or a regex over authored prose, which the engineering principles forbid.
3068
+ *
3069
+ * A switch is the one kind that states where it is set as a contact state rather than as a
3070
+ * travel position: `State: effect-on` on `pro-co-rat`'s bypass-control contact, `State:
3071
+ * closed` on three amp switches. Both mean the contacts are made, which is `1` against the
3072
+ * runtime's `position >= 0.5` threshold.
3073
+ *
3074
+ * Measured before it was written: across every `.vdsp` in the pedal *and* amp corpora,
3075
+ * `State` appears on `kind: switch` and on nothing else, and carries exactly these two
3076
+ * values on nine components. An unrecognised value is a refusal that names it (see
3077
+ * `declaredPosition`) rather than a guess, for the same reason the runtime refuses an
3078
+ * operator it does not implement: a switch silently defaulted to the wrong contact is a
3079
+ * plausible wrong circuit, not a visible failure.
3080
+ */
3081
+ const switchContactStates = {
3082
+ "effect-on": 1,
3083
+ closed: 1,
3084
+ // The amp corpus's spellings for the same fact. `vox-ac15-top-boost`'s standby switch says
3085
+ // `closed/on`, and until this was here that statement was dropped and the switch fell to the
3086
+ // "nobody said" default of open -- which disconnected B+ from the whole amp.
3087
+ "closed/on": 1,
3088
+ on: 1,
3089
+ // `fender-bassman` spells the same fact this way on its AC and standby switches. Compared
3090
+ // whole, like every other entry: this stage does not read a substring out of it. The spelling
3091
+ // is worth normalising on the artifact side, and until then dropping it would cost that amp
3092
+ // its supply.
3093
+ "closed/on for operating validation": 1,
3094
+ // `hiwatt-dr103`'s power switch states `SelectedState: "ON for validation"`.
3095
+ "on for validation": 1,
3096
+ // `pigtronix-philosophers-tone`'s footswitch states `DefaultState: "effect"`.
3097
+ effect: 1,
3098
+ // `mesa-boogie-mark-v`'s power switch states `SelectedState: "FULL"`.
3099
+ full: 1,
3100
+ };
3101
+ function statedContactState(component) {
3102
+ if (String(component.kind) !== "switch") {
3103
+ return null;
3104
+ }
3105
+ return propertyString(component, "State");
3106
+ }
3107
+ /**
3108
+ * A contact state this stage recognises, from either spelling the corpus uses, or `null`.
3109
+ *
3110
+ * **`State` stays strict and `SelectedState` does not, and the difference is what the corpus
3111
+ * declares.** `State` has one meaning, so an unrecognised value there is an error the caller
3112
+ * raises. `SelectedState` carries a contact state on 5 of its 12 appearances (`closed/on`,
3113
+ * `closed`, `on`) and something else entirely on the rest -- `16 ohms`, `full`,
3114
+ * `90w full-power silicon path`, a whole sentence about input routing -- so it is compared as a
3115
+ * whole value against the closed vocabulary and ignored when it is not in it. Throwing on those
3116
+ * would refuse packets over a field that is not making this claim.
3117
+ *
3118
+ * Measured 2026-08-21: `vox-ac15-top-boost`, `vox-ac30-top-boost` and `fender-bassman` each state
3119
+ * `SelectedState: "closed/on"` on the standby switch that feeds their B+, nothing read it, and all
3120
+ * three rendered silence with every plate at 0.0 V while their rectifiers reached 424 V.
3121
+ */
3122
+ function recognisedContactPosition(component) {
3123
+ if (String(component.kind) !== "switch") {
3124
+ return null;
3125
+ }
3126
+ // **A fuse conducts.** A two-terminal switch declaring a `FuseRating` is a fuse, and a fuse
3127
+ // is a closed conductor unless it has blown -- which is a fault a source would have to state,
3128
+ // not a default this stage should assume. Measured 2026-08-21: **26 switch components across
3129
+ // the amp corpus declare a `FuseRating` and every one of them states no contact position**, so
3130
+ // every fuse in every amp defaulted open. On `vox-ac15-top-boost` that is an HT fuse sitting
3131
+ // between the standby switch and the reservoir cap, which alone kept B+ off every plate.
3132
+ //
3133
+ // The evidence is the presence of a typed property name, compared whole. Nothing reads its
3134
+ // value, and nothing reads a name or a description.
3135
+ if (propertyString(component, "FuseRating") !== null) {
3136
+ return 1;
3137
+ }
3138
+ const closed = component.properties?.Closed;
3139
+ if (closed === true || closed === "true" || closed === 1 || closed === "1") {
3140
+ return 1;
3141
+ }
3142
+ if (closed === false || closed === "false" || closed === 0 || closed === "0") {
3143
+ return 0;
3144
+ }
3145
+ const selected = propertyString(component, "SelectedState") ??
3146
+ propertyString(component, "DefaultState") ??
3147
+ propertyString(component, "SelectedThrow");
3148
+ if (selected === null) {
3149
+ return null;
3150
+ }
3151
+ const simple = switchContactStates[selected.trim().toLowerCase()];
3152
+ if (simple !== undefined) {
3153
+ return simple;
3154
+ }
3155
+ return selectedThrowPosition(component, selected);
3156
+ }
3157
+ /**
3158
+ * Is this terminal one of a switch's throws, for the purpose of resolving *which* throw a control
3159
+ * selects?
3160
+ *
3161
+ * The declared role decides where the document states one, and a name is read only where it does
3162
+ * not. Both callers used to consult `switchCommonRoles` alone -- the last two program-affecting
3163
+ * readers of it once `switchPoles` moved to declarations, and the reason ten packets still changed
3164
+ * when it was emptied. `coil` and `pin` are excluded here too: a relay coil and a mounting lug are
3165
+ * not throws a control can select, which a name vocabulary of pole spellings could never say.
3166
+ */
3167
+ function isSelectableThrow(terminal) {
3168
+ // A terminal declaring nothing is treated as selectable, which is what the deleted
3169
+ // `switchCommonRoles` fallback did for every name that was not a pole spelling -- and no
3170
+ // corpus switch reaches here undeclared with a pole-shaped name.
3171
+ const declared = typeof terminal.role === "string" ? terminal.role : null;
3172
+ return declared === null || declared === "throw";
3173
+ }
3174
+ /**
3175
+ * Resolve a multi-throw selector position from authored throw text (e.g. `SelectedThrow: 16 ohm`).
3176
+ */
3177
+ function selectedThrowPosition(component, stated) {
3178
+ const throwTerminals = component.terminals.filter(isSelectableThrow);
3179
+ if (throwTerminals.length <= 1) {
3180
+ return null;
3181
+ }
3182
+ const statedNorm = stated.toLowerCase().replace(/[^a-z0-9]/g, "");
3183
+ let index = throwTerminals.findIndex((t) => {
3184
+ const token = terminalRoleToken(String(t.name)) ?? "";
3185
+ return token.length > 0 && (statedNorm.includes(token) || token.includes(statedNorm));
3186
+ });
3187
+ if (index === -1) {
3188
+ const numbersInStated = stated.match(/\d+/g) ?? [];
3189
+ for (const num of numbersInStated) {
3190
+ const found = throwTerminals.findIndex((t) => {
3191
+ const numInTerm = String(t.name).match(/\d+/);
3192
+ return numInTerm !== null && numInTerm[0] === num;
3193
+ });
3194
+ if (found !== -1) {
3195
+ index = found;
3196
+ break;
3197
+ }
3198
+ }
3199
+ }
3200
+ if (index === -1) {
3201
+ return null;
3202
+ }
3203
+ return (index + 0.5) / throwTerminals.length;
3204
+ }
3205
+ /** Wiper position as authored, 0..1. */
3206
+ /** Whether the source states where this component is set, at all. */
3207
+ function statesPosition(component) {
3208
+ return ((propertyString(component, "Wipe") ??
3209
+ propertyString(component, "Position") ??
3210
+ statedContactState(component)) !== null ||
3211
+ recognisedContactPosition(component) !== null);
3212
+ }
3213
+ function declaredPosition(component) {
3214
+ const raw = propertyString(component, "Wipe") ?? propertyString(component, "Position");
3215
+ if (raw === null) {
3216
+ // The second spelling first, because it is the non-throwing one: a `SelectedState` this
3217
+ // stage recognises is a stated position, and one it does not recognise is a different
3218
+ // claim that must not become an error.
3219
+ const recognised = recognisedContactPosition(component);
3220
+ if (recognised !== null) {
3221
+ return recognised;
3222
+ }
3223
+ // A switch that states a contact state instead of a travel position. Read before
3224
+ // the `0` default below, because that default is "nobody said", and here somebody
3225
+ // did -- `pro-co-rat`'s bypass-control contact sits off the input-to-output path,
3226
+ // so the port-connectivity fallback left it open against a source that says it is
3227
+ // made.
3228
+ const contact = statedContactState(component);
3229
+ if (contact !== null) {
3230
+ const position = switchContactStates[contact.trim().toLowerCase()];
3231
+ if (position === undefined) {
3232
+ throw new NetlistError(`component ${component.id}: switch declares State "${contact}", which is not a recognised contact state (${Object.keys(switchContactStates).join(", ")})`, String(component.id));
3233
+ }
3234
+ return position;
3235
+ }
3236
+ if (String(component.kind) === "switch") {
3237
+ return 0;
3238
+ }
3239
+ return 0.5;
3240
+ }
3241
+ // A switch states its position as one of the options it declares -- `Position:
3242
+ // Effect` against `Options: Normal,Effect` -- so it must be resolved against that
3243
+ // list before it is read as a number. `Number("Effect")` is NaN and would silently
3244
+ // become half travel, which on a two-throw switch is the *other* throw.
3245
+ const selected = selectedOption(component, raw);
3246
+ if (selected !== null) {
3247
+ return selected;
3248
+ }
3249
+ const value = Number(raw);
3250
+ return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0.5;
3251
+ }
3252
+ /**
3253
+ * Where a stated position sits among the throws the component declares, as 0..1.
3254
+ *
3255
+ * Two lists have to be reconciled: the `Options` the component names, and the throw
3256
+ * terminals it wires. **They are not always in the same order** -- three corpus
3257
+ * selectors declare `Options: low,high` against throws `high,low`, and taking the
3258
+ * option's index would stick a range switch on the wrong range. So the throw whose
3259
+ * role token *is* the selected option name wins, and the option's index is only the
3260
+ * fallback for throws named `throwA`/`throw1`, which carry no such evidence.
3261
+ *
3262
+ * The result is the centre of the selected throw's band, so the runtime's even
3263
+ * division of control travel lands back on the same throw.
3264
+ */
3265
+ function selectedOption(component, stated) {
3266
+ const declared = propertyString(component, "Options");
3267
+ if (declared === null) {
3268
+ return null;
3269
+ }
3270
+ const options = declared
3271
+ .split(",")
3272
+ .map((option) => option.trim())
3273
+ .filter((option) => option.length > 0);
3274
+ if (options.length < 2) {
3275
+ return null;
3276
+ }
3277
+ const wanted = stated.trim().toLowerCase();
3278
+ const optionIndex = options.findIndex((option) => option.toLowerCase() === wanted);
3279
+ if (optionIndex === -1) {
3280
+ return null;
3281
+ }
3282
+ const throwRoles = component.terminals
3283
+ .filter(isSelectableThrow)
3284
+ .map((terminal) => terminalRoleToken(String(terminal.name)));
3285
+ if (throwRoles.length === 0) {
3286
+ return null;
3287
+ }
3288
+ const named = throwRoles.findIndex((role) => role !== null && role === terminalRoleToken(options[optionIndex] ?? ""));
3289
+ const index = named === -1 ? optionIndex : named;
3290
+ return index >= throwRoles.length ? null : (index + 0.5) / throwRoles.length;
3291
+ }
3292
+ /**
3293
+ * Every taper spelling the corpus declares, as whole values.
3294
+ *
3295
+ * Measured across the corpus rather than guessed, because prefix matching was wrong in
3296
+ * both directions: `startsWith("rev")` read `ReverseLinear` as reverse-*logarithmic*,
3297
+ * and `AntiLogarithmic` matched no rule at all and fell through to linear -- silently,
3298
+ * in both Tube Screamers among others. A taper is one of the most audible things about
3299
+ * a pedal, so an unrecognised spelling must stay unrecognised rather than land on a
3300
+ * plausible neighbour.
3301
+ *
3302
+ * Deliberately absent: `W`, `W20`, `G`, `D`, `L`, `BH`, `Stepped`, `Trim`,
3303
+ * `Boss-G-taper` and the other manufacturer-specific curves. We do not know their
3304
+ * shapes, and guessing one would be inventing a device law from a letter.
3305
+ */
3306
+ const taperSpellings = new Map([
3307
+ ["linear", "linear"],
3308
+ ["b", "linear"],
3309
+ ["log", "logarithmic"],
3310
+ ["logarithmic", "logarithmic"],
3311
+ ["audio", "logarithmic"],
3312
+ ["a", "logarithmic"],
3313
+ ["a/log", "logarithmic"],
3314
+ ["reverselog", "reverse-logarithmic"],
3315
+ ["reverselogarithmic", "reverse-logarithmic"],
3316
+ ["reverseaudio", "reverse-logarithmic"],
3317
+ ["antilog", "reverse-logarithmic"],
3318
+ ["antilogarithmic", "reverse-logarithmic"],
3319
+ ["negativelog", "reverse-logarithmic"],
3320
+ ["c", "reverse-logarithmic"],
3321
+ ["reverselinear", "reverse-linear"],
3322
+ ]);
3323
+ /**
3324
+ * Values whose meaning is "the source does not state a taper".
3325
+ *
3326
+ * These are not markings this stage failed to read -- they are the packet recording that its
3327
+ * source does not say, which is a complete statement. Linear is the stated fallback for them and
3328
+ * no warning is raised, because nothing was lost. 19 of the corpus's declarations are this shape.
3329
+ */
3330
+ const taperUnknownSpellings = new Set([
3331
+ "unknown",
3332
+ "sourceunmarked",
3333
+ "sourcemarked",
3334
+ "sourceunspecified",
3335
+ ]);
3336
+ /**
3337
+ * Tapers `@vessel-dsp/core`'s format supports and this runtime has no curve for.
3338
+ *
3339
+ * `PotentiometerTaper` carries seven values; `TaperKind` -- the executable law -- carries four.
3340
+ * A document declaring one of these three has stated a real taper that the render cannot produce,
3341
+ * which is a different failure from an unreadable marking and is worth saying differently.
3342
+ */
3343
+ const taperWithoutLaw = new Set([
3344
+ "scurve",
3345
+ "stepped",
3346
+ "custom",
3347
+ ]);
3348
+ /** Case and separators folded, so `reverse-log` and `Reverse Log` are one spelling. */
3349
+ function foldTaper(value) {
3350
+ return value.trim().toLowerCase().replace(/[\s_-]+/gu, "");
3351
+ }
3352
+ /**
3353
+ * The taper a declared value names, or null when this vocabulary does not contain it.
3354
+ * Whole-value lookup against a closed set; separators fold so `reverse-log`,
3355
+ * `Reverse Log` and `ReverseLog` are one spelling, but nothing is ever matched as a
3356
+ * substring of a longer phrase.
3357
+ */
3358
+ function taperKind(value) {
3359
+ return taperSpellings.get(foldTaper(value)) ?? null;
3360
+ }
3361
+ /**
3362
+ * The taper to execute, and whether anything was lost getting there.
3363
+ *
3364
+ * **The fallback to linear is unchanged; what changes is that it stops being silent.** Measured
3365
+ * 2026-09-03, 69 of the corpus's 608 declared tapers fell through to linear without a word, an
3366
+ * audible difference across the whole sweep of an audio knob. Three outcomes now:
3367
+ *
3368
+ * - the value names a law -> use it, say nothing;
3369
+ * - the value says the source does not know -> linear, say nothing, because there is nothing to
3370
+ * have read;
3371
+ * - the value states anything else -> linear, and **name it**, whether it is a part marking whose
3372
+ * curve is not derivable here (`W20`, `Boss-G-taper`) or a taper the format supports and this
3373
+ * runtime has no law for (`s-curve`, `stepped`).
3374
+ */
3375
+ function declaredTaper(component) {
3376
+ // `Sweep` is what a variable-resistor calls its taper. Both are closed vocabularies
3377
+ // compared as whole values, never substrings of a description.
3378
+ const declared = propertyString(component, "Taper") ?? propertyString(component, "Sweep");
3379
+ if (declared === null) {
3380
+ return { taper: "linear", unreadable: null };
3381
+ }
3382
+ const resolved = taperKind(declared);
3383
+ if (resolved !== null) {
3384
+ return { taper: resolved, unreadable: null };
3385
+ }
3386
+ return {
3387
+ taper: "linear",
3388
+ unreadable: taperUnknownSpellings.has(foldTaper(declared)) ? null : declared,
3389
+ };
3390
+ }
3391
+ /**
3392
+ * The `role` a terminal declares, or null.
3393
+ *
3394
+ * Read verbatim: this is a closed vocabulary core validates, so there is nothing here to fold,
3395
+ * alias or guess. The whole point of the field is that a consumer does not interpret it.
3396
+ */
3397
+ function declaredRole(terminal) {
3398
+ const value = terminal !== null && typeof terminal === "object"
3399
+ ? terminal.role
3400
+ : undefined;
3401
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
3402
+ }
3403
+ /** Panel control ids that some component's program reads `tapped` (core 0.13.0). */
3404
+ function tappedControlIds(document) {
3405
+ const named = new Set();
3406
+ const components = document.components;
3407
+ if (!Array.isArray(components))
3408
+ return named;
3409
+ for (const component of components) {
3410
+ const program = declaredProgram(component);
3411
+ if (program === undefined)
3412
+ continue;
3413
+ for (const position of program.positions) {
3414
+ const cited = [
3415
+ ...Object.values(position.lines).flatMap((parameters) => Object.values(parameters)),
3416
+ ...Object.values(position.parameters),
3417
+ ];
3418
+ for (const parameter of cited) {
3419
+ if (parameter.read === "tapped" && parameter.control !== undefined) {
3420
+ named.add(parameter.control);
3421
+ }
3422
+ }
3423
+ // A hold sampler's gate is a pedal the chip reads while it is held: a press, so the
3424
+ // contact springs back exactly as a tapped one does. The op name is a closed-vocabulary
3425
+ // token compared whole.
3426
+ for (const op of position.ops) {
3427
+ if (op.op !== "hold-loop")
3428
+ continue;
3429
+ const gate = op.gate;
3430
+ const name = typeof gate?.parameter === "string" ? gate.parameter : undefined;
3431
+ const control = name === undefined ? undefined : position.parameters[name]?.control;
3432
+ if (control !== undefined)
3433
+ named.add(control);
3434
+ }
3435
+ }
3436
+ }
3437
+ return named;
3438
+ }
3439
+ /**
3440
+ * Panel control ids that some component's program declares it reads by scanning or timing -- as
3441
+ * its router, or as a cited parameter.
3442
+ *
3443
+ * Read from the raw document rather than from the devices, because the whole point of a
3444
+ * scanned control is that no device carries it.
3445
+ */
3446
+ function scannedControls(document) {
3447
+ const named = new Set();
3448
+ const components = document.components;
3449
+ if (!Array.isArray(components))
3450
+ return named;
3451
+ for (const component of components) {
3452
+ const program = declaredProgram(component);
3453
+ if (program === undefined)
3454
+ continue;
3455
+ if (program.router?.read === "scanned")
3456
+ named.add(program.router.control);
3457
+ for (const position of program.positions) {
3458
+ const cited = [
3459
+ ...Object.values(position.lines).flatMap((parameters) => Object.values(parameters)),
3460
+ ...Object.values(position.parameters),
3461
+ ];
3462
+ for (const parameter of cited) {
3463
+ if ((parameter.read === "scanned" || parameter.read === "tapped") && parameter.control !== undefined) {
3464
+ named.add(parameter.control);
3465
+ }
3466
+ }
3467
+ }
3468
+ }
3469
+ return named;
3470
+ }
3471
+ /**
3472
+ * The program a component declares, or null.
3473
+ *
3474
+ * Read verbatim like `declaredRole`, and for the same reason: core has already refused the shapes
3475
+ * that cannot be executed -- no positions, several positions with no selector naming the control
3476
+ * that chooses, a position with no ops, an op with no name. What survives that is a declaration
3477
+ * this stage has no business re-adjudicating.
3478
+ *
3479
+ * **The ops are not validated here and that is deliberate.** Their vocabulary is the runtime's op
3480
+ * enum, which stage 1 knows nothing about; checking it here would put a second copy of that
3481
+ * vocabulary in the reader. `validateDeclaredOp` does it at resolution, where the refusal can name
3482
+ * the op and the device together.
3483
+ */
3484
+ function declaredProgram(component) {
3485
+ const raw = component !== null && typeof component === "object"
3486
+ ? component.program
3487
+ : undefined;
3488
+ if (raw === null || typeof raw !== "object")
3489
+ return undefined;
3490
+ const positionsRaw = raw.positions;
3491
+ if (!Array.isArray(positionsRaw) || positionsRaw.length === 0)
3492
+ return undefined;
3493
+ const positions = [];
3494
+ for (const entry of positionsRaw) {
3495
+ if (entry === null || typeof entry !== "object")
3496
+ continue;
3497
+ const position = entry;
3498
+ if (typeof position.id !== "string" || !Array.isArray(position.ops))
3499
+ continue;
3500
+ positions.push({
3501
+ id: position.id,
3502
+ label: typeof position.label === "string" ? position.label : null,
3503
+ ops: position.ops.filter((op) => op !== null &&
3504
+ typeof op === "object" &&
3505
+ typeof op.op === "string"),
3506
+ lines: declaredProgramLines(position.lines),
3507
+ parameters: declaredPositionParameters(position.parameters),
3508
+ });
3509
+ }
3510
+ if (positions.length === 0)
3511
+ return undefined;
3512
+ return { router: declaredProgramRouter(raw.router), positions };
3513
+ }
3514
+ /**
3515
+ * The router a component's program declares, read verbatim.
3516
+ *
3517
+ * Core has already refused every router that cannot be executed -- no control, fewer than two
3518
+ * positions, no routes, a route out of range, a duplicate route, a route naming a program the
3519
+ * component does not declare. What survives that is a declaration this stage has no business
3520
+ * re-adjudicating, so it is copied rather than checked.
3521
+ */
3522
+ function declaredProgramRouter(raw) {
3523
+ if (raw === null || typeof raw !== "object")
3524
+ return null;
3525
+ const router = raw;
3526
+ if (typeof router.control !== "string" || router.control.trim().length === 0)
3527
+ return null;
3528
+ if (typeof router.positions !== "number" || !Number.isInteger(router.positions))
3529
+ return null;
3530
+ if (!Array.isArray(router.routes))
3531
+ return null;
3532
+ const routes = [];
3533
+ for (const entry of router.routes) {
3534
+ if (entry === null || typeof entry !== "object")
3535
+ continue;
3536
+ const route = entry;
3537
+ if (typeof route.position !== "number" || !Number.isInteger(route.position))
3538
+ continue;
3539
+ if (typeof route.program !== "string" || route.program.length === 0)
3540
+ continue;
3541
+ routes.push({ position: route.position, program: route.program });
3542
+ }
3543
+ if (routes.length === 0)
3544
+ return null;
3545
+ // Core validated these: `read` is one of two values, and a scanned router names a reader
3546
+ // this document has. Read verbatim, as everything else at this boundary is.
3547
+ const read = router.read === "scanned" ? "scanned" : "node";
3548
+ const scannedBy = typeof router.scannedBy === "string" && router.scannedBy.trim().length > 0
3549
+ ? router.scannedBy.trim()
3550
+ : null;
3551
+ return {
3552
+ control: router.control.trim(),
3553
+ read,
3554
+ scannedBy,
3555
+ positions: router.positions,
3556
+ routes,
3557
+ };
3558
+ }
3559
+ /**
3560
+ * One cited program parameter, read verbatim: a line's or a position's, the same way. Core
3561
+ * validated `read` / `scannedBy` exactly as it validates a router's.
3562
+ */
3563
+ function declaredProgramParameter(valueRaw) {
3564
+ if (valueRaw === null || typeof valueRaw !== "object")
3565
+ return null;
3566
+ const value = valueRaw;
3567
+ if (typeof value.min !== "number" || typeof value.max !== "number")
3568
+ return null;
3569
+ const tap = value.tap !== undefined &&
3570
+ typeof value.tap.presses === "number" &&
3571
+ typeof value.tap.timeoutSeconds === "number"
3572
+ ? {
3573
+ presses: value.tap.presses,
3574
+ timeoutSeconds: value.tap.timeoutSeconds,
3575
+ defaultSeconds: typeof value.tap.defaultSeconds === "number" ? value.tap.defaultSeconds : null,
3576
+ }
3577
+ : undefined;
3578
+ return {
3579
+ min: value.min,
3580
+ max: value.max,
3581
+ read: value.read === "scanned" || value.read === "tapped" ? value.read : "node",
3582
+ ratio: typeof value.ratio === "number" && Number.isFinite(value.ratio) ? value.ratio : null,
3583
+ ...(tap === undefined ? {} : { tap }),
3584
+ scannedBy: typeof value.scannedBy === "string" && value.scannedBy.trim().length > 0
3585
+ ? value.scannedBy.trim()
3586
+ : null,
3587
+ ...(typeof value.control === "string" ? { control: value.control } : {}),
3588
+ ...(typeof value.source === "string" ? { source: value.source } : {}),
3589
+ };
3590
+ }
3591
+ /** A position's named parameters (core 0.12.0), read verbatim. */
3592
+ function declaredPositionParameters(raw) {
3593
+ if (raw === null || typeof raw !== "object")
3594
+ return {};
3595
+ const parameters = {};
3596
+ for (const [name, valueRaw] of Object.entries(raw)) {
3597
+ const parameter = declaredProgramParameter(valueRaw);
3598
+ if (parameter !== null)
3599
+ parameters[name] = parameter;
3600
+ }
3601
+ return parameters;
3602
+ }
3603
+ /** The cited parameters of one position's delay lines, keyed line then parameter. */
3604
+ function declaredProgramLines(raw) {
3605
+ if (raw === null || typeof raw !== "object")
3606
+ return {};
3607
+ const lines = {};
3608
+ for (const [lineId, parametersRaw] of Object.entries(raw)) {
3609
+ if (parametersRaw === null || typeof parametersRaw !== "object")
3610
+ continue;
3611
+ const parameters = {};
3612
+ for (const [name, valueRaw] of Object.entries(parametersRaw)) {
3613
+ const parameter = declaredProgramParameter(valueRaw);
3614
+ if (parameter !== null)
3615
+ parameters[name] = parameter;
3616
+ }
3617
+ if (Object.keys(parameters).length > 0)
3618
+ lines[lineId] = parameters;
3619
+ }
3620
+ return lines;
3621
+ }
3622
+ /**
3623
+ * The coils a component declares, as terminal indices in coil order.
3624
+ *
3625
+ * Read verbatim like `declaredRole`, with one consumer-side step: a terminal *name* becomes the
3626
+ * index this stage addresses ports by. A winding naming a terminal the component does not have is
3627
+ * dropped here rather than refused, because core's own validation already errors on it and this
3628
+ * stage's job is to read, not to re-adjudicate the document.
3629
+ */
3630
+ function declaredWindings(component, terminals) {
3631
+ const value = component !== null && typeof component === "object"
3632
+ ? component.windings
3633
+ : undefined;
3634
+ if (!Array.isArray(value) || value.length === 0) {
3635
+ return null;
3636
+ }
3637
+ const indexOf = new Map();
3638
+ const centerTaps = new Set();
3639
+ terminals.forEach((terminal, index) => {
3640
+ if (typeof terminal.name === "string") {
3641
+ indexOf.set(terminal.name, index);
3642
+ }
3643
+ if (declaredRole(terminal) === "windingCenterTap") {
3644
+ centerTaps.add(index);
3645
+ }
3646
+ });
3647
+ const windings = value.flatMap((entry) => {
3648
+ if (entry === null || typeof entry !== "object") {
3649
+ return [];
3650
+ }
3651
+ const { role, id, terminals: names } = entry;
3652
+ if (typeof role !== "string" || !Array.isArray(names)) {
3653
+ return [];
3654
+ }
3655
+ const terminalIndices = names.flatMap((name) => {
3656
+ const index = typeof name === "string" ? indexOf.get(name) : undefined;
3657
+ return index === undefined ? [] : [index];
3658
+ });
3659
+ const centerTapAt = terminalIndices.findIndex((index) => centerTaps.has(index));
3660
+ const { voltage, impedances: rated } = entry;
3661
+ const impedances = (Array.isArray(rated) ? rated : []).flatMap((rating) => {
3662
+ if (rating === null || typeof rating !== "object") {
3663
+ return [];
3664
+ }
3665
+ const { across, impedance } = rating;
3666
+ const ohms = quantityValue(impedance);
3667
+ if (!Array.isArray(across) || across.length !== 2 || ohms === null) {
3668
+ return [];
3669
+ }
3670
+ const pair = across.map((name) => typeof name === "string" ? indexOf.get(name) : undefined);
3671
+ return pair[0] === undefined || pair[1] === undefined
3672
+ ? []
3673
+ : [{ across: [pair[0], pair[1]], ohms }];
3674
+ });
3675
+ return terminalIndices.length === 0
3676
+ ? []
3677
+ : [
3678
+ {
3679
+ role,
3680
+ id: typeof id === "string" && id.trim().length > 0 ? id.trim() : null,
3681
+ terminalIndices,
3682
+ centerTapAt: centerTapAt === -1 ? null : centerTapAt,
3683
+ voltageRmsVolts: quantityValue(voltage),
3684
+ impedances,
3685
+ },
3686
+ ];
3687
+ });
3688
+ return windings.length === 0 ? null : windings;
3689
+ }
3690
+ /** A typed quantity's numeric value, or null. Core validates the shape; this only reads it. */
3691
+ function quantityValue(value) {
3692
+ if (value === null || typeof value !== "object") {
3693
+ return null;
3694
+ }
3695
+ const magnitude = value.value;
3696
+ return typeof magnitude === "number" && Number.isFinite(magnitude)
3697
+ ? magnitude
3698
+ : null;
3699
+ }
3700
+ /** A bare positional pin carries no role. Anything else is a whole role token. */
3701
+ function terminalRoleToken(name) {
3702
+ const folded = name
3703
+ .trim()
3704
+ .toLowerCase()
3705
+ .replace(/[\s_-]+/gu, "");
3706
+ if (folded.length === 0 || /^(?:pin)?\d+$/u.test(folded)) {
3707
+ return null;
3708
+ }
3709
+ return folded.replace(/^pin\d+/u, "");
3710
+ }
3711
+ /**
3712
+ * The taper a control declares for itself, or null when it declares none.
3713
+ *
3714
+ * Same vocabulary as a component's, because a panel control and the pot it drives are
3715
+ * describing the same track. Keeping two lists let a control's `ReverseAudio` or
3716
+ * `AntiLogarithmic` read as "unstated" while the identical value on the pot resolved.
3717
+ */
3718
+ /** A control's declared panel role, verbatim, or `null`. Never inferred from the id. */
3719
+ function roleFor(control) {
3720
+ const value = control !== null && typeof control === "object"
3721
+ ? control.role
3722
+ : undefined;
3723
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
3724
+ }
3725
+ function taperFor(control) {
3726
+ const value = control !== null && typeof control === "object"
3727
+ ? control.taper
3728
+ : undefined;
3729
+ return typeof value === "string" ? taperKind(value) : null;
3730
+ }
3731
+ /**
3732
+ * Composite map key. The separator must be a character that cannot occur in either
3733
+ * part, or `R1`+`a` and `R`+`1a` collide and two terminals silently share a node.
3734
+ * A newline is safe here because neither an id nor a terminal name may contain one.
3735
+ */
3736
+ function declaredKey(componentId, terminalName) {
3737
+ return `${componentId}\n${terminalName}`;
3738
+ }
3739
+ /** The `{ raw, value, unit }` shape, or null for anything else. */
3740
+ function asParsedQuantity(value) {
3741
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
3742
+ return null;
3743
+ }
3744
+ const candidate = value;
3745
+ if (typeof candidate.raw !== "string" ||
3746
+ typeof candidate.value !== "number" ||
3747
+ !Number.isFinite(candidate.value) ||
3748
+ typeof candidate.unit !== "string") {
3749
+ return null;
3750
+ }
3751
+ return { raw: candidate.raw, value: candidate.value, unit: candidate.unit };
3752
+ }
3753
+ /**
3754
+ * Recover `deviceInterface.controls[].defaultPosition`. The format library drops it -- it keeps
3755
+ * `id`, `label`, `kind`, `role` and `taper` from the same entry and silently discards this one --
3756
+ * so the raw YAML is read here for that single field, exactly as `readRawComponents` below does
3757
+ * for terminal `node:`.
3758
+ *
3759
+ * **The durable fix belongs in `@vessel-dsp/core`**, which owns the format; this recovery is the
3760
+ * consumer-side half and should be deleted the day the parser preserves the field.
3761
+ */
3762
+ function readRawControlPositions(source) {
3763
+ const positions = new Map();
3764
+ let parsed;
3765
+ try {
3766
+ parsed = loadYaml(source);
3767
+ }
3768
+ catch {
3769
+ return positions;
3770
+ }
3771
+ if (parsed === null || typeof parsed !== "object") {
3772
+ return positions;
3773
+ }
3774
+ const controls = (parsed.deviceInterface ??
3775
+ {}).controls;
3776
+ if (!Array.isArray(controls)) {
3777
+ return positions;
3778
+ }
3779
+ for (const control of controls) {
3780
+ if (control === null || typeof control !== "object") {
3781
+ continue;
3782
+ }
3783
+ const record = control;
3784
+ // A control id is a token, not a number -- the same lesson the net-id filter below
3785
+ // carries, and the same cost if it is forgotten.
3786
+ const id = typeof record.id === "number" && Number.isFinite(record.id)
3787
+ ? String(record.id)
3788
+ : typeof record.id === "string" && record.id.trim() !== ""
3789
+ ? record.id.trim()
3790
+ : null;
3791
+ if (id === null ||
3792
+ typeof record.defaultPosition !== "number" ||
3793
+ !Number.isFinite(record.defaultPosition)) {
3794
+ continue;
3795
+ }
3796
+ positions.set(id, Math.min(1, Math.max(0, record.defaultPosition)));
3797
+ }
3798
+ return positions;
3799
+ }
3800
+ /**
3801
+ * Reads discrete position counts declared on `deviceInterface.controls[].positions` or
3802
+ * `deviceInterface.controls[].options`.
3803
+ */
3804
+ function readRawControlDiscretePositions(source) {
3805
+ const positions = new Map();
3806
+ let parsed;
3807
+ try {
3808
+ parsed = loadYaml(source);
3809
+ }
3810
+ catch {
3811
+ return positions;
3812
+ }
3813
+ if (parsed === null || typeof parsed !== "object") {
3814
+ return positions;
3815
+ }
3816
+ const controls = (parsed.deviceInterface ??
3817
+ {}).controls;
3818
+ if (!Array.isArray(controls)) {
3819
+ return positions;
3820
+ }
3821
+ for (const control of controls) {
3822
+ if (control === null || typeof control !== "object") {
3823
+ continue;
3824
+ }
3825
+ const record = control;
3826
+ const id = typeof record.id === "number" && Number.isFinite(record.id)
3827
+ ? String(record.id)
3828
+ : typeof record.id === "string" && record.id.trim() !== ""
3829
+ ? record.id.trim()
3830
+ : null;
3831
+ if (id === null) {
3832
+ continue;
3833
+ }
3834
+ if (Array.isArray(record.positions) && record.positions.length > 0) {
3835
+ positions.set(id, record.positions.length);
3836
+ }
3837
+ else if (typeof record.positions === "number" &&
3838
+ Number.isFinite(record.positions) &&
3839
+ record.positions > 1) {
3840
+ positions.set(id, record.positions);
3841
+ }
3842
+ else if (Array.isArray(record.options) && record.options.length > 0) {
3843
+ positions.set(id, record.options.length);
3844
+ }
3845
+ else if (typeof record.kind === "string" &&
3846
+ (record.kind === "switch" || record.kind === "footswitch")) {
3847
+ positions.set(id, 2);
3848
+ }
3849
+ }
3850
+ return positions;
3851
+ }
3852
+ /**
3853
+ * Computes discrete position count for a control (e.g. 2 for 2-way toggle/footswitch,
3854
+ * N for multi-way selector), or `null` for continuous controls (potentiometers, rheostats).
3855
+ */
3856
+ function discretePositionsForControl(id, owner, document, rawPositionsMap) {
3857
+ const raw = rawPositionsMap.get(id);
3858
+ if (raw !== undefined && raw > 1) {
3859
+ return raw;
3860
+ }
3861
+ for (const control of document.deviceInterface?.controls ?? []) {
3862
+ if (String(control.id) === id) {
3863
+ const positions = control.positions;
3864
+ if (Array.isArray(positions) && positions.length > 0) {
3865
+ return positions.length;
3866
+ }
3867
+ if (typeof positions === "number" && positions > 1) {
3868
+ return positions;
3869
+ }
3870
+ const options = control.options;
3871
+ if (Array.isArray(options) && options.length > 0) {
3872
+ return options.length;
3873
+ }
3874
+ const kind = control.kind;
3875
+ if (kind === "switch" || kind === "footswitch") {
3876
+ return 2;
3877
+ }
3878
+ }
3879
+ }
3880
+ if (owner !== undefined) {
3881
+ if (owner.kind === "potentiometer") {
3882
+ return null;
3883
+ }
3884
+ const throwsVal = propertyString(owner, "Throws") ?? propertyString(owner, "throws");
3885
+ if (throwsVal !== null) {
3886
+ const count = Number(throwsVal);
3887
+ if (Number.isFinite(count) && count > 1) {
3888
+ return count;
3889
+ }
3890
+ }
3891
+ const positionsVal = propertyString(owner, "Positions") ?? propertyString(owner, "positions");
3892
+ if (positionsVal !== null) {
3893
+ const count = Number(positionsVal);
3894
+ if (Number.isFinite(count) && count > 1) {
3895
+ return count;
3896
+ }
3897
+ }
3898
+ const optionsVal = propertyString(owner, "Options") ?? propertyString(owner, "options");
3899
+ if (optionsVal !== null) {
3900
+ const count = optionsVal
3901
+ .split(",")
3902
+ .map((s) => s.trim())
3903
+ .filter(Boolean).length;
3904
+ if (count > 1) {
3905
+ return count;
3906
+ }
3907
+ }
3908
+ if (owner.kind === "switch") {
3909
+ return 2;
3910
+ }
3911
+ }
3912
+ return null;
3913
+ }
3914
+ /** Union-find over wire geometry, used to resolve nodes and then discarded. */
3915
+ class UnionFind {
3916
+ parents = new Map();
3917
+ find(value) {
3918
+ const parent = this.parents.get(value);
3919
+ if (parent === undefined) {
3920
+ this.parents.set(value, value);
3921
+ return value;
3922
+ }
3923
+ if (parent === value) {
3924
+ return value;
3925
+ }
3926
+ const root = this.find(parent);
3927
+ this.parents.set(value, root);
3928
+ return root;
3929
+ }
3930
+ union(a, b) {
3931
+ const rootA = this.find(a);
3932
+ const rootB = this.find(b);
3933
+ if (rootA !== rootB) {
3934
+ this.parents.set(rootB, rootA);
3935
+ }
3936
+ }
3937
+ }
3938
+ function pointKey(point) {
3939
+ return `${point.x},${point.y}`;
3940
+ }
3941
+ function pointOnSegment(point, a, b) {
3942
+ // Direct endpoint shortcuts
3943
+ const distAPoint = Math.abs(point.x - a.x) + Math.abs(point.y - a.y);
3944
+ const distBPoint = Math.abs(point.x - b.x) + Math.abs(point.y - b.y);
3945
+ if (distAPoint < 1e-6 || distBPoint < 1e-6) {
3946
+ return true;
3947
+ }
3948
+ const withinX = point.x >= Math.min(a.x, b.x) - 1e-6 &&
3949
+ point.x <= Math.max(a.x, b.x) + 1e-6;
3950
+ const withinY = point.y >= Math.min(a.y, b.y) - 1e-6 &&
3951
+ point.y <= Math.max(a.y, b.y) + 1e-6;
3952
+ if (!withinX || !withinY) {
3953
+ return false;
3954
+ }
3955
+ // Collinear with a safe geometry tolerance
3956
+ // One test covers horizontal, vertical and diagonal alike: before this, a wire at any other angle
3957
+ // returned false, so a terminal touching a diagonal wire was dropped as unconnected.
3958
+ const crossProduct = (b.x - a.x) * (point.y - a.y) - (b.y - a.y) * (point.x - a.x);
3959
+ return Math.abs(crossProduct) < 1e-3;
3960
+ }
3961
+ function resolveNodes(document, declared) {
3962
+ const unionFind = new UnionFind();
3963
+ const terminalPoints = [];
3964
+ for (const component of document.components) {
3965
+ for (const terminal of component.terminals) {
3966
+ terminalPoints.push({
3967
+ componentId: String(component.id),
3968
+ terminalName: String(terminal.name),
3969
+ point: terminal.position,
3970
+ });
3971
+ }
3972
+ }
3973
+ for (const wire of document.wires) {
3974
+ const [a, b] = wire.endpoints;
3975
+ const anchor = pointKey(a);
3976
+ unionFind.union(anchor, pointKey(b));
3977
+ for (const entry of terminalPoints) {
3978
+ if (pointOnSegment(entry.point, a, b)) {
3979
+ unionFind.union(anchor, pointKey(entry.point));
3980
+ }
3981
+ }
3982
+ }
3983
+ // A second pass joins wires that meet at a shared point.
3984
+ for (const wire of document.wires) {
3985
+ for (const other of document.wires) {
3986
+ if (wire.id === other.id) {
3987
+ continue;
3988
+ }
3989
+ for (const point of wire.endpoints) {
3990
+ if (pointOnSegment(point, other.endpoints[0], other.endpoints[1])) {
3991
+ unionFind.union(pointKey(wire.endpoints[0]), pointKey(point));
3992
+ unionFind.union(pointKey(point), pointKey(other.endpoints[0]));
3993
+ }
3994
+ }
3995
+ }
3996
+ }
3997
+ const groundComponentIds = new Set(document.components
3998
+ .filter((component) => String(component.kind) === "ground")
3999
+ .map((component) => String(component.id)));
4000
+ // Geometric groups get ids after the declared ones, so the two never collide.
4001
+ const declaredMax = Math.max(0, ...[...declared.values()]);
4002
+ const geometricId = new Map();
4003
+ let nextGeometric = declaredMax + 1;
4004
+ return (componentId, terminalName, index) => {
4005
+ const declaredNode = declared.get(declaredKey(componentId, terminalName));
4006
+ if (declaredNode !== undefined) {
4007
+ return declaredNode;
4008
+ }
4009
+ const entry = terminalPoints.find((candidate) => candidate.componentId === componentId &&
4010
+ candidate.terminalName === terminalName);
4011
+ if (entry === undefined) {
4012
+ return null;
4013
+ }
4014
+ const root = unionFind.find(pointKey(entry.point));
4015
+ // Any group touching a ground symbol is node 0.
4016
+ const touchesGround = terminalPoints.some((candidate) => groundComponentIds.has(candidate.componentId) &&
4017
+ unionFind.find(pointKey(candidate.point)) === root);
4018
+ if (touchesGround) {
4019
+ return GROUND;
4020
+ }
4021
+ const existing = geometricId.get(root);
4022
+ if (existing !== undefined) {
4023
+ return existing;
4024
+ }
4025
+ // A terminal on no wire at all is unconnected, which is an authoring error
4026
+ // rather than a node: refuse instead of inventing an isolated net.
4027
+ const onAnyWire = document.wires.some((wire) => pointOnSegment(entry.point, wire.endpoints[0], wire.endpoints[1]));
4028
+ if (!onAnyWire && index >= 0) {
4029
+ return null;
4030
+ }
4031
+ const assigned = nextGeometric;
4032
+ nextGeometric += 1;
4033
+ geometricId.set(root, assigned);
4034
+ return assigned;
4035
+ };
4036
+ }
4037
+ /**
4038
+ * The role a document declares for each jack component, by component id.
4039
+ *
4040
+ * `deviceInterface.controls` carries a required `role` on every entry, and a jack's
4041
+ * entry says `input`, `output`, `power`, `cv`, `direct-output` and so on. That is the
4042
+ * authoritative statement of what a jack is: a closed vocabulary, in a structured
4043
+ * field, that distinguishes things a type name cannot -- a direct out from the main
4044
+ * out, an expression socket from an audio port.
4045
+ *
4046
+ * Two links reach it, and 104 of the corpus's 114 jack-bearing documents have one:
4047
+ * the interface entry's id may be the component id, or `panel` binds the two with
4048
+ * `componentId` + `controlId`. Reading this rather than `sourceTypeName` is also what
4049
+ * keeps `Circuit.Speaker` meaning a speaker load in the 22 amp packets that use it
4050
+ * that way, instead of being collapsed into "output jack" to suit pedals.
4051
+ */
4052
+ function declaredJackRoles(document) {
4053
+ const roleByControlId = new Map();
4054
+ for (const control of document.deviceInterface?.controls ?? []) {
4055
+ if (String(control.kind) !== "jack") {
4056
+ continue;
4057
+ }
4058
+ const role = String(control.role ?? "")
4059
+ .trim()
4060
+ .toLowerCase();
4061
+ if (role.length > 0) {
4062
+ roleByControlId.set(String(control.id), role);
4063
+ }
4064
+ }
4065
+ const roleByComponent = new Map();
4066
+ // The interface entry named the component directly.
4067
+ for (const [id, role] of roleByControlId) {
4068
+ roleByComponent.set(id, role);
4069
+ }
4070
+ // Or the panel binds a component to it, which is how a document keeps
4071
+ // panel-facing ids (`INPUT_JACK`) separate from schematic designators (`V1`).
4072
+ for (const face of document.panel?.faces ?? []) {
4073
+ for (const element of face.elements) {
4074
+ const componentId = element.bind?.componentId;
4075
+ const controlId = element.bind?.controlId;
4076
+ if (componentId === undefined || controlId === undefined) {
4077
+ continue;
4078
+ }
4079
+ const role = roleByControlId.get(String(controlId));
4080
+ if (role !== undefined) {
4081
+ roleByComponent.set(String(componentId), role);
4082
+ }
4083
+ }
4084
+ }
4085
+ return roleByComponent;
4086
+ }
4087
+ /**
4088
+ * Declared roles that make a jack the signal input or output.
4089
+ *
4090
+ * Deliberately narrow. `direct-output` is a dry send and not the effect's output;
4091
+ * `cv`, `expression` and `tempo-tap` are control sockets that carry no program audio.
4092
+ * A type name cannot express any of those distinctions, which is the reason to prefer
4093
+ * the role.
4094
+ */
4095
+ const inputJackRoles = new Set(["input"]);
4096
+ // `output-a-mono` before `output-b`, so a stereo pedal read in mono takes the jack the
4097
+ // pedal itself calls mono rather than whichever came first.
4098
+ const outputJackRoles = new Set([
4099
+ "output",
4100
+ "output-a-mono",
4101
+ "mono-output",
4102
+ "panning-output",
4103
+ "output-b",
4104
+ "wet-output",
4105
+ "dry-output",
4106
+ ]);
4107
+ const powerJackRoles = new Set(["power", "power-input"]);
4108
+ /**
4109
+ * Declared jack types, the fallback for the 10 documents that state no role.
4110
+ *
4111
+ * Closed sets, compared whole and lower-cased. `Circuit.Output` is the minority
4112
+ * spelling -- the corpus writes an output jack as `Circuit.Speaker` 99 times and
4113
+ * `Circuit.OutputJack` 9 times against 8 for `Circuit.Output` -- so matching only the
4114
+ * last of those left 108 output jacks unrecognised. This table exists because those
4115
+ * ten documents declare no role; it is not the primary evidence.
4116
+ */
4117
+ const inputJackTypeNames = new Set([
4118
+ "circuit.input",
4119
+ "circuit.inputjack",
4120
+ "circuit.inputjackswitch",
4121
+ ]);
4122
+ const outputJackTypeNames = new Set([
4123
+ "circuit.output",
4124
+ "circuit.outputjack",
4125
+ "circuit.speaker",
4126
+ ]);
4127
+ const powerJackTypeNames = new Set([
4128
+ "circuit.powerjack",
4129
+ "circuit.dcjack",
4130
+ "circuit.dcpowerjack",
4131
+ ]);
4132
+ /**
4133
+ * The impedance each port declares **about itself as a port**, for the chain's seam divider (§3.3).
4134
+ *
4135
+ * A port impedance is what the *connection* sees: an output port's source impedance is what the next
4136
+ * slot loads, and an input port's is the load this slot presents to whatever drives it. Neither is
4137
+ * stamped -- stamping the input one would put a resistor across the input jack, which is a different
4138
+ * claim from "this is what the port presents".
4139
+ *
4140
+ * **A `Circuit.Speaker` jack is excluded, and finding that out is the whole result of this
4141
+ * function.** Measured over 142 documents, every typed `Impedance` in the corpus sits on a
4142
+ * `Circuit.Speaker` jack -- `vox-ac15-top-boost` 16 Ω, `fender-5e3-deluxe-tweed` 8 Ω,
4143
+ * `marshall-blues-breaker` 1 MΩ, `mxr-m117r-flanger` 1 kΩ, `mxr-noise-gate-line-driver` 10 kΩ. Those
4144
+ * are **loads**, not source impedances: 8 Ω is the driver the transformer secondary drives, and
4145
+ * `outputJackLoads` already consumes exactly that number to build the Stage B speaker network.
4146
+ * Reading it a second time as the port's *source* impedance would both double-count it and invert
4147
+ * its meaning -- a divider given `Zout = 8` claims the amp has an 8 Ω output impedance, when 8 Ω is
4148
+ * what it is driving into.
4149
+ *
4150
+ * The plan's §3.3 recorded this as "four output jacks declare a typed impedance, all of which are the
4151
+ * source impedance this section wants". The first half is close; the second half is wrong, and the
4152
+ * exclusion below is why the divider is not built on it.
4153
+ *
4154
+ * The discriminator is a **closed typed value compared whole** (`sourceTypeName`), never a name or a
4155
+ * description -- the same rule everything else in this file follows.
4156
+ *
4157
+ * So this returns `{ input: null, output: null }` for every corpus document today, and that is the
4158
+ * honest state rather than a gap to paper over: a fabricated "standard 1 MΩ input" would look like a
4159
+ * measurement in every number downstream of it. What §3.3 needs is a typed impedance on **input**
4160
+ * jacks and on non-speaker **output** jacks.
4161
+ *
4162
+ * Matched through the resolved devices, since a parsed component's terminals do not carry their node
4163
+ * (the format library drops it, which is why `readRawComponents` exists at all).
4164
+ */
4165
+ /**
4166
+ * The full-scale voltage a port's own jack **declares**, in volts, or null.
4167
+ *
4168
+ * `V0dBFS` is the source stating directly what `port-full-scale.ts` otherwise has to infer, and it
4169
+ * is the only number available where nothing infers one: a preamp monitor tap has no output
4170
+ * transformer to step a rail down, so the derived bound is the bare rail and a render of it writes
4171
+ * hundreds of volts into a +/-1 file. `marshall-1959-super-lead-plexi`'s `Tone-Stack-Monitor-Out`
4172
+ * and `mesa-boogie-mark-v`'s `Preamp-Monitor-Out` each declare 100 V on the exact jack the output
4173
+ * port sits on, and both clipped 20,722 and 87,439 samples of 96,000 until this was read.
4174
+ *
4175
+ * **`Circuit.Speaker` jacks are read here, unlike in `portImpedanceOhms`.** That reader excludes
4176
+ * them because a speaker's declared `Impedance` describes the *load* hanging off the port rather
4177
+ * than the port's own level. `V0dBFS` is the opposite: it is only ever a level, and it is the
4178
+ * corpus's monitor taps that carry it -- which are typed `Circuit.Speaker`, since that is how a
4179
+ * tap-off point is drawn. Excluding them dropped every declaration there is.
4180
+ */
4181
+ function portDeclaredFullScaleVolts(document, devices, ports) {
4182
+ const byId = new Map(document.components
4183
+ .filter((component) => String(component.kind) === "jack")
4184
+ .map((component) => [String(component.id), component]));
4185
+ const at = (node) => {
4186
+ for (const device of devices) {
4187
+ if (device.kind !== "jack" || !device.nodes.includes(node)) {
4188
+ continue;
4189
+ }
4190
+ const component = byId.get(device.id);
4191
+ if (component === undefined) {
4192
+ continue;
4193
+ }
4194
+ const volts = typedVolts(component, "V0dBFS");
4195
+ if (volts !== null && volts > 0 && Number.isFinite(volts)) {
4196
+ return volts;
4197
+ }
4198
+ }
4199
+ return null;
4200
+ };
4201
+ return { input: at(ports.input), output: at(ports.output) };
4202
+ }
4203
+ function portImpedanceOhms(document, devices, ports) {
4204
+ const byId = new Map(document.components
4205
+ .filter((component) => String(component.kind) === "jack" &&
4206
+ // Folded, not exact. An exact match is a claim that every packet spells the
4207
+ // token identically, and the corpus already disproves it: `circuit.led` is
4208
+ // written `Circuit.LED` 111 times and `Circuit.Led` twice. The consumer is the
4209
+ // only place that can be made not to care.
4210
+ foldToken(String(component.sourceTypeName ?? "")) !==
4211
+ foldToken("Circuit.Speaker"))
4212
+ .map((component) => [String(component.id), component]));
4213
+ const at = (node) => {
4214
+ for (const device of devices) {
4215
+ if (device.kind !== "jack" || !device.nodes.includes(node)) {
4216
+ continue;
4217
+ }
4218
+ const component = byId.get(device.id);
4219
+ if (component === undefined) {
4220
+ continue;
4221
+ }
4222
+ const ohms = typedOhms(component, "Impedance");
4223
+ if (ohms !== null && ohms > 0 && Number.isFinite(ohms)) {
4224
+ return ohms;
4225
+ }
4226
+ }
4227
+ return null;
4228
+ };
4229
+ return { input: at(ports.input), output: at(ports.output) };
4230
+ }
4231
+ /**
4232
+ * A jack's own declared load impedance, lowered into a resistor across its own two declared
4233
+ * terminals -- the amp output-stage plan's Stage A.
4234
+ *
4235
+ * Measured 2026-08-21: no amp in the corpus drives a modelled load. `fender-5e3-deluxe-
4236
+ * tweed` (port node 44), `vox-ac15-top-boost` (63) and `vox-ac30-top-boost` (129) have no
4237
+ * resistive element on their output transformer's secondary at all: an unloaded secondary
4238
+ * reflects no load into the primary, so the power tubes work against the wrong plate load and
4239
+ * no power is delivered -- `fender-5e3`'s 26.2 V there is an open-circuit voltage, and 26 V
4240
+ * into 8 Ω would be 43 W from a 12 W amp.
4241
+ *
4242
+ * Exactly 8 jacks across 7 packets state a typed load, read with `typedOhms` -- the same
4243
+ * structured `{raw, value, unit}` discipline `PrimaryImpedance`/`SecondaryImpedance` above
4244
+ * use. The rest of the corpus's speaker-typed jacks are not readable as data this stage may
4245
+ * act on: 25 monitor taps declare `Impedance: "∞ Ω"`, a bare string `typedOhms` never opens
4246
+ * because it is not `{value, unit}`, and five -- `fender-bassman`'s `"2 Ohm selected load"`
4247
+ * among them -- are prose with a number sitting in a sentence, exactly what this pipeline may
4248
+ * not parse. `typedOhms`'s structured-only read already refuses both without any extra check
4249
+ * here.
4250
+ *
4251
+ * Output-side only, by the same `outputJackRoles`/`outputJackTypeNames` evidence
4252
+ * `resolvePorts` uses below for the port itself: an *input* jack's impedance is a claim about
4253
+ * the source driving this circuit, not a load this circuit itself terminates, and lowering it
4254
+ * here would load the wrong side.
4255
+ *
4256
+ * Not a speaker model. This is a nominal-impedance resistor, so it carries no Thiele-Small
4257
+ * impedance curve -- no bass-resonance rise, no voice-coil inductance -- only the plate load
4258
+ * line becoming real. The plan's Stage B (the Thiele-Small one-port) is what earns those.
4259
+ *
4260
+ * Lives in this stage, not `lower.ts`: the load is a source fact (the jack's own declared
4261
+ * `Impedance`) turned directly into a device, exactly like every other typed-quantity-to-
4262
+ * resistor reading in this file (`PrimaryImpedance`, `SecondaryImpedance`, …). It needs no
4263
+ * region, admission, or program-layout knowledge that only `lower.ts` has, and appending it to
4264
+ * `devices` here means it is visible to every later stage as an ordinary two-terminal resistor
4265
+ * -- no new device kind, no new lowering path -- which is the smaller, more honest option
4266
+ * between (a) and (b) in the plan's decision. Appended after port resolution and control
4267
+ * defaulting run, so it cannot perturb either.
4268
+ */
4269
+ /**
4270
+ * Nodes electrically on an **output** transformer's windings, widened to where its load actually
4271
+ * sits.
4272
+ *
4273
+ * One function because three places asked this question and two of them answered it differently:
4274
+ * `outputJackLoads` followed series passives, while the `output-port-not-transformer-coupled`
4275
+ * warning required direct membership, so a jack one passive hop away was simultaneously loaded as a
4276
+ * speaker and warned about as a preamp tap.
4277
+ *
4278
+ * Keyed on a declared `ratio`, which is what distinguishes an output transformer from a mains one:
4279
+ * a power transformer states winding voltages instead, and its nodes are not a speaker's.
4280
+ *
4281
+ * Widened two ways, both by connectivity and device kind rather than by any name:
4282
+ *
4283
+ * - through two-terminal **passives**, because a source that states the secondary's own winding
4284
+ * resistance puts a resistor between the winding and the jack -- measured on
4285
+ * `report-speaker-load.ts`'s fixture, requiring direct membership dropped the speaker network
4286
+ * from three stamps to none, left the secondary open and doubled the output;
4287
+ * - through an **impedance selector**, meaning a switch two or more of whose nodes are already on
4288
+ * the winding. An ohms selector sits between a secondary's taps and the speaker, so the jack on
4289
+ * its common is this winding's load. The two-or-more guard is what stops this leaking: a switch
4290
+ * selecting among one winding's taps necessarily touches that winding twice, while a pedal's
4291
+ * footswitch touches an output transformer zero times -- and the four pedals declaring a
4292
+ * `Circuit.Speaker` output with a typed impedance are the case the passive-only rule excludes.
4293
+ */
4294
+ function transformerCoupledNodes(devices) {
4295
+ const reached = new Set();
4296
+ for (const device of devices) {
4297
+ if (device.kind !== "transformer" ||
4298
+ typeof device.parameters.ratio !== "number") {
4299
+ continue;
4300
+ }
4301
+ for (const node of device.nodes) {
4302
+ if (node !== GROUND) {
4303
+ reached.add(node);
4304
+ }
4305
+ }
4306
+ }
4307
+ if (reached.size === 0) {
4308
+ return reached;
4309
+ }
4310
+ const seriesKinds = new Set(["resistor", "inductor", "capacitor"]);
4311
+ for (let widened = true; widened;) {
4312
+ widened = false;
4313
+ for (const device of devices) {
4314
+ const ends = [...new Set(device.nodes)].filter((node) => node !== GROUND);
4315
+ if (device.kind === "switch") {
4316
+ const onWinding = ends.filter((node) => reached.has(node));
4317
+ if (onWinding.length >= 2 && onWinding.length < ends.length) {
4318
+ for (const node of ends) {
4319
+ reached.add(node);
4320
+ }
4321
+ widened = true;
4322
+ }
4323
+ continue;
4324
+ }
4325
+ if (!seriesKinds.has(device.kind) || ends.length !== 2) {
4326
+ continue;
4327
+ }
4328
+ const [a, b] = ends;
4329
+ if (reached.has(a) !== reached.has(b)) {
4330
+ reached.add(a);
4331
+ reached.add(b);
4332
+ widened = true;
4333
+ }
4334
+ }
4335
+ }
4336
+ return reached;
4337
+ }
4338
+ function outputJackLoads(document, devices) {
4339
+ const roles = declaredJackRoles(document);
4340
+ // Stage B needs two nodes per speaker that no component declares. Allocated above every node
4341
+ // already in use, so they cannot collide with a declared or geometric id.
4342
+ let nextNode = Math.max(GROUND, ...devices.flatMap((device) => [...device.nodes])) + 1;
4343
+ // Nodes on a winding of a transformer that declares a `ratio` -- an output transformer. A
4344
+ // mains transformer declares winding voltages instead and its nodes are not a speaker's.
4345
+ const secondaryNodes = transformerCoupledNodes(devices);
4346
+ // **Follow the winding through series passives.** A speaker does not always sit directly on the
4347
+ // winding: a source that states the transformer's own **secondary winding resistance** puts a
4348
+ // resistor between them, and Stage C names exactly that. Requiring direct membership silently
4349
+ // declined to load such a jack -- measured on `report-speaker-load.ts`'s fixture, adding a 0.5 Ω
4350
+ // secondary resistance dropped the speaker network from three stamps to none, left the secondary
4351
+ // open, and doubled the output. It did at least warn, via
4352
+ // `output-port-not-transformer-coupled`.
4353
+ //
4354
+ // Reachability is through two-terminal **passive** elements only -- a resistor, an inductor, a
4355
+ // capacitor. Not through a switch, a pot, an active device or another transformer: those are
4356
+ // where a jack stops being this winding's load and starts being something else's, and the
4357
+ // four pedals that declare a `Circuit.Speaker` output with a typed impedance are exactly the
4358
+ // case this gate exists to exclude.
4359
+ const added = [];
4360
+ const warnings = [];
4361
+ for (const component of document.components) {
4362
+ if (String(component.kind) !== "jack") {
4363
+ continue;
4364
+ }
4365
+ const ohms = typedOhms(component, "Impedance");
4366
+ if (ohms === null || !(ohms > 0)) {
4367
+ continue;
4368
+ }
4369
+ const device = devices.find((candidate) => candidate.kind === "jack" && candidate.id === String(component.id));
4370
+ if (device === undefined) {
4371
+ continue;
4372
+ }
4373
+ const role = roles.get(String(component.id));
4374
+ const typeName = String(component.sourceTypeName ?? "").toLowerCase();
4375
+ const isOutputSide = role === undefined
4376
+ ? outputJackTypeNames.has(typeName)
4377
+ : outputJackRoles.has(role);
4378
+ if (!isOutputSide) {
4379
+ continue;
4380
+ }
4381
+ // **On an output transformer's winding, or it is not a speaker load.** Output-side and
4382
+ // typed is not enough, and the corpus says so plainly: four *pedals* declare an output
4383
+ // jack as `Circuit.Speaker` with a typed impedance -- `marshall-blues-breaker`'s `S1` at
4384
+ // 1 MΩ, `mxr-m117r-flanger`'s `J3` at 1 kΩ, `mxr-noise-gate-line-driver`'s `OUT` at
4385
+ // 10 kΩ, `pigtronix-philosophers-tone`'s `OUTPUT` at 150 Ω. Those are interface facts,
4386
+ // an output impedance or a nominal load written on the port, not a driver hanging off a
4387
+ // secondary; stamping them as resistors to ground took `mxr-m117r-flanger` from
4388
+ // `agrees corr=1.0000` to `both silent` against ngspice on the first measured run.
4389
+ //
4390
+ // A speaker sits on the secondary of a transformer that declares a `ratio`, which is
4391
+ // the same evidence `resolvePorts` uses to prefer the speaker over a monitor tap, and
4392
+ // it separates the two cases without reading a name, a description, or guessing from
4393
+ // the magnitude of the impedance.
4394
+ if (!device.nodes.some((node) => secondaryNodes.has(node))) {
4395
+ continue;
4396
+ }
4397
+ // The jack's own resolved nodes, from the device this same component already
4398
+ // produced above. A third terminal -- an impedance-selector tap, as `fender-
4399
+ // bassman`'s speaker jack has -- makes "its own two declared terminals" ambiguous,
4400
+ // so this stage picks neither rather than guessing which pair the load bridges.
4401
+ const nodes = [...new Set(device.nodes)];
4402
+ if (nodes.length !== 2) {
4403
+ continue;
4404
+ }
4405
+ // Stage B: the driver as `Re -- Le -- (Rmot || Lmot || Cmot)`, not a resistor. `ohms` is
4406
+ // the jack's declared nominal, which scales the generic profile -- see `speaker-load.ts`
4407
+ // for why the driver is generic and what that costs the claim.
4408
+ const [hot, ret] = nodes;
4409
+ const coil = nextNode;
4410
+ const motional = nextNode + 1;
4411
+ nextNode += 2;
4412
+ const port = speakerOnePort(ohms);
4413
+ const element = (suffix, kind, pair, parameters) => ({
4414
+ id: `${component.id}-speaker-${suffix}`,
4415
+ kind,
4416
+ nodes: [...pair],
4417
+ parameters,
4418
+ control: null,
4419
+ identity: {
4420
+ partNumber: null,
4421
+ declaredType: null,
4422
+ terminalRoles: [null, null],
4423
+ declaredTerminalRoles: [null, null],
4424
+ declaredWindings: null,
4425
+ },
4426
+ });
4427
+ added.push(element("re", "resistor", [hot, coil], { ohms: port.reOhms }), element("le", "inductor", [coil, motional], { henries: port.leHenries }), element("rmot", "resistor", [motional, ret], { ohms: port.motionalOhms }), element("lmot", "inductor", [motional, ret], {
4428
+ henries: port.motionalHenries,
4429
+ }), element("cmot", "capacitor", [motional, ret], {
4430
+ farads: port.motionalFarads,
4431
+ }));
4432
+ warnings.push({
4433
+ code: "generic-speaker-profile",
4434
+ device: String(component.id),
4435
+ nominalOhms: ohms,
4436
+ detail: `${component.id} is modelled as a generic ${ohms} Ω driver, not the one this amp uses: no packet identifies a driver in a form that resolves, so the impedance curve is a seed profile scaled to the declared nominal. Its shape is a real driver's; its identity is not this amp's.`,
4437
+ });
4438
+ }
4439
+ return { devices: added, warnings };
4440
+ }
4441
+ function resolvePorts(document, devices, options) {
4442
+ const typeNameOf = (device) => {
4443
+ const component = document.components.find((candidate) => String(candidate.id) === device.id);
4444
+ return String(component?.sourceTypeName ?? "").toLowerCase();
4445
+ };
4446
+ // A power inlet is a jack and is never a signal port. Removing it first matters
4447
+ // more than which jack is chosen: the fallback used to take the last jack, and in a
4448
+ // document that lists the DC inlet last, **the output was read off the power jack**
4449
+ // and the pedal rendered silence.
4450
+ const roles = declaredJackRoles(document);
4451
+ const jacks = devices
4452
+ .filter((device) => device.kind === "jack")
4453
+ .filter((device) => {
4454
+ // A declared role decides; the type name only speaks when there is none.
4455
+ const role = roles.get(device.id);
4456
+ return role === undefined
4457
+ ? !powerJackTypeNames.has(typeNameOf(device))
4458
+ : !powerJackRoles.has(role);
4459
+ });
4460
+ const byRole = (acceptedRoles, acceptedTypes) => jacks.find((device) => {
4461
+ const role = roles.get(device.id);
4462
+ return role === undefined
4463
+ ? acceptedTypes.has(typeNameOf(device))
4464
+ : acceptedRoles.has(role);
4465
+ });
4466
+ let input;
4467
+ if (options?.inputJack !== undefined) {
4468
+ const target = options.inputJack.toLowerCase();
4469
+ input = devices.find((d) => d.kind === "jack" &&
4470
+ (d.id === options.inputJack || d.id.toLowerCase() === target));
4471
+ if (input === undefined) {
4472
+ const available = devices
4473
+ .filter((d) => d.kind === "jack")
4474
+ .map((d) => d.id);
4475
+ throw new NetlistError(`requested input jack "${options.inputJack}" was not found. Available jacks: ${available.join(", ")}`);
4476
+ }
4477
+ }
4478
+ else {
4479
+ // A port comes from a jack that declares its role, or it does not come at all.
4480
+ input = byRole(inputJackRoles, inputJackTypeNames);
4481
+ }
4482
+ // An amp's output port is **after** its output transformer, and a monitor tap is not.
4483
+ //
4484
+ // **Reached through a switch or selector, not only directly.** A speaker jack usually sits
4485
+ // behind an impedance selector: `orange-gro100`'s `J_SPEAKER` is on node 79 and its
4486
+ // `S_SPEAKER_IMPEDANCE` joins that to the winding's 76/77/78 taps, so a direct node test
4487
+ // missed it and the output port fell through to `Output`, a jack whose own
4488
+ // `SourceBoundaryRole` reads "tone-stack output-amp handoff label". The amp then rendered its
4489
+ // tone stack, and the render clipped because a preamp node has no transformer bounding it.
4490
+ //
4491
+ // Which throw is closed is a control position this stage does not read, so every terminal of
4492
+ // a switch is treated as reachable from every other -- the same bound `port-full-scale.ts`
4493
+ // takes for the same reason, since any throw may be the live one.
4494
+ const transformerNodes = new Set();
4495
+ for (const device of devices) {
4496
+ if (device.kind !== "transformer") {
4497
+ continue;
4498
+ }
4499
+ if (typeof device.parameters.ratio !== "number") {
4500
+ continue;
4501
+ }
4502
+ for (const node of device.nodes) {
4503
+ if (node !== GROUND) {
4504
+ transformerNodes.add(node);
4505
+ }
4506
+ }
4507
+ }
4508
+ // Two hops covers a jack behind a selector behind a switch. Unbounded would let a chain of
4509
+ // contacts carry the label across the amp to a jack the winding does not feed.
4510
+ for (let hop = 0; hop < 2; hop += 1) {
4511
+ const reached = [];
4512
+ for (const device of devices) {
4513
+ // Only `switch` at this stage: a selector is still one, and `lower.ts` is where it
4514
+ // becomes per-throw stamps.
4515
+ if (device.kind !== "switch") {
4516
+ continue;
4517
+ }
4518
+ const touches = device.nodes.some((node) => node !== GROUND && transformerNodes.has(node));
4519
+ if (!touches) {
4520
+ continue;
4521
+ }
4522
+ for (const node of device.nodes) {
4523
+ if (node !== GROUND) {
4524
+ reached.push(node);
4525
+ }
4526
+ }
4527
+ }
4528
+ for (const node of reached) {
4529
+ transformerNodes.add(node);
4530
+ }
4531
+ }
4532
+ let output;
4533
+ if (options?.outputJack !== undefined) {
4534
+ const target = options.outputJack.toLowerCase();
4535
+ output = devices.find((d) => d.kind === "jack" &&
4536
+ (d.id === options.outputJack || d.id.toLowerCase() === target));
4537
+ if (output === undefined) {
4538
+ const available = devices
4539
+ .filter((d) => d.kind === "jack")
4540
+ .map((d) => d.id);
4541
+ throw new NetlistError(`requested output jack "${options.outputJack}" was not found. Available jacks: ${available.join(", ")}`);
4542
+ }
4543
+ }
4544
+ else {
4545
+ // **By role only, and the empty type set is the fix rather than an omission.** This
4546
+ // preference exists for a jack that *declares* `wet-output`; there is no type name that
4547
+ // means it. Passing `outputJackTypeNames` here made it match the first output-**typed**
4548
+ // jack in any document that declares no roles at all -- which is every amp -- so it
4549
+ // always won, and it silently preempted anything ranked below it. Behaviour-neutral while
4550
+ // it happened to pick what the positional fallback would have picked, and a wrong answer
4551
+ // the moment a better-evidenced preference was added underneath.
4552
+ const wetOutput = byRole(new Set(["wet-output"]), new Set());
4553
+ const speakerSide = jacks.find((device) => {
4554
+ const role = roles.get(device.id);
4555
+ const accepted = role === undefined
4556
+ ? outputJackTypeNames.has(typeNameOf(device))
4557
+ : outputJackRoles.has(role);
4558
+ return (accepted &&
4559
+ device.nodes.some((node) => node !== GROUND && transformerNodes.has(node)));
4560
+ });
4561
+ output =
4562
+ wetOutput ?? speakerSide ?? byRole(outputJackRoles, outputJackTypeNames);
4563
+ }
4564
+ if (input === undefined || output === undefined) {
4565
+ // Two different source facts, and conflating them sends the reader to the wrong
4566
+ // place: a document with no connected jack at all is missing its interface,
4567
+ // where one whose jacks are all untyped is missing only their roles.
4568
+ if (jacks.length === 0) {
4569
+ throw new NetlistError("document declares no connected jack");
4570
+ }
4571
+ const missing = input === undefined && output === undefined
4572
+ ? "an input or an output role"
4573
+ : input === undefined
4574
+ ? "an input role"
4575
+ : "an output role";
4576
+ throw new NetlistError(`no jack declares ${missing}, so the port cannot be resolved without inventing one`);
4577
+ }
4578
+ const signalNode = (device) => device.nodes.find((node) => node !== GROUND) ?? GROUND;
4579
+ // A speaker jack can carry more than two terminals -- `fender-bassman`'s
4580
+ // `J_SPEAKER_OUTPUT` declares `[43, 0, 41]`, an impedance-selected load -- and only one of
4581
+ // them is the transformer secondary. Taking the first non-ground node probed node 43 while
4582
+ // the winding is node 41, so the render measured a terminal the power stage does not
4583
+ // drive: 6.07e-16 of swing, read as a silent amp. Picking the winding-touching node when
4584
+ // the jack has one is the same connectivity evidence that chose the jack.
4585
+ const outputNode = output.nodes.find((node) => node !== GROUND && transformerNodes.has(node)) ?? signalNode(output);
4586
+ return { input: signalNode(input), output: outputNode };
4587
+ }
4588
+ /**
4589
+ * A driven pin as the selector it is. `follows` a latch: two throws, ground then supply.
4590
+ * `highAt` a control's detents: one throw per detent, each on the supply or the ground, so the
4591
+ * selector law's own detent mapping (`floor(position x count)`) chooses the level.
4592
+ */
4593
+ function controllerPinDevice(pin, controls) {
4594
+ const [on, off] = pin.invert ? [pin.low, pin.high] : [pin.high, pin.low];
4595
+ const control = pin.highAt?.control ?? pin.follows ?? "";
4596
+ const count = pin.highAt === undefined
4597
+ ? 2
4598
+ : Math.max(2, controls.find((candidate) => candidate.id === control)?.positions ?? 2);
4599
+ const throws = pin.highAt === undefined
4600
+ ? [off, on]
4601
+ : Array.from({ length: count }, (_, detent) => (pin.highAt?.positions.includes(detent) ? on : off));
4602
+ return {
4603
+ id: pin.id,
4604
+ kind: "switch",
4605
+ nodes: [pin.node, ...throws],
4606
+ parameters: {},
4607
+ control,
4608
+ identity: {
4609
+ partNumber: null,
4610
+ declaredType: null,
4611
+ terminalRoles: ["common", ...throws.map(() => "throw")],
4612
+ declaredTerminalRoles: ["common", ...throws.map(() => "throw")],
4613
+ declaredWindings: null,
4614
+ },
4615
+ };
4616
+ }
4617
+ /**
4618
+ * The firmware rules the document's controllers declare: their latches, and each driven pin as a
4619
+ * three-node switch device between the chip's declared supply and ground.
4620
+ *
4621
+ * Core has already refused a pin on a terminal the chip lacks, a pin following no latch, and pins
4622
+ * on a chip with no declared supply, so what reaches here is executable. The terminal a pin names
4623
+ * is an exact identifier the declaration points at, never a name this stage interprets.
4624
+ */
4625
+ function declaredControllers(document, nodeOf) {
4626
+ const latches = [];
4627
+ const pins = [];
4628
+ const components = document.components;
4629
+ if (!Array.isArray(components))
4630
+ return { latches, pins };
4631
+ for (const component of components) {
4632
+ const controller = component.controller;
4633
+ if (controller === undefined)
4634
+ continue;
4635
+ for (const latch of controller.latches) {
4636
+ latches.push({ id: latch.id, toggledBy: latch.toggledBy, initial: latch.initial });
4637
+ }
4638
+ const terminalNode = (predicate) => {
4639
+ const index = component.terminals.findIndex(predicate);
4640
+ const terminal = component.terminals[index];
4641
+ return terminal === undefined ? null : nodeOf(component.id, terminal.name, index);
4642
+ };
4643
+ const high = terminalNode((terminal) => terminal.role === "supplyPositive");
4644
+ const low = terminalNode((terminal) => terminal.role === "supplyNegative");
4645
+ for (const pin of controller.pins ?? []) {
4646
+ const node = terminalNode((terminal) => terminal.name === pin.terminal);
4647
+ if (node === null || high === null || low === null)
4648
+ continue;
4649
+ pins.push({
4650
+ id: `${component.id}:${pin.terminal}`,
4651
+ node,
4652
+ high,
4653
+ low,
4654
+ invert: pin.invert === true,
4655
+ ...(pin.follows === undefined ? {} : { follows: pin.follows }),
4656
+ ...(pin.highAt === undefined ? {} : { highAt: pin.highAt }),
4657
+ });
4658
+ }
4659
+ }
4660
+ return { latches, pins };
4661
+ }
4662
+ /**
4663
+ * The bypass declaration both the control defaults and the bypass record
4664
+ * read: which switch is the bypass, and which of its positions the document
4665
+ * declares engaged. One parse, one validation, two readers -- the control
4666
+ * assembly above and the NetlistBypass record below used to read the raw
4667
+ * block independently and could disagree about its shape.
4668
+ */
4669
+ function parseBypassDeclaration(document) {
4670
+ const raw = document.audio?.bypass;
4671
+ const switchId = raw !== null &&
4672
+ typeof raw === "object" &&
4673
+ "switch" in raw &&
4674
+ typeof raw.switch === "string"
4675
+ ? raw.switch
4676
+ : null;
4677
+ const rawEngaged = raw !== null && typeof raw === "object" && "engagedPosition" in raw
4678
+ ? raw.engagedPosition
4679
+ : undefined;
4680
+ const engaged = typeof rawEngaged === "number" &&
4681
+ Number.isFinite(rawEngaged) &&
4682
+ (rawEngaged === 0 || rawEngaged === 1)
4683
+ ? rawEngaged
4684
+ : null;
4685
+ const rawEngagedPosition = typeof rawEngaged === "string" || typeof rawEngaged === "number"
4686
+ ? rawEngaged
4687
+ : undefined;
4688
+ return { switchId, engaged, rawEngagedPosition };
4689
+ }
4690
+ /**
4691
+ * The latch state a program takes as its effect: the end of the latch at which the parameters
4692
+ * reading it are largest. Null where no program reads the latch, or its readers disagree, so a
4693
+ * latch whose meaning the document does not show keeps its declared power-on state.
4694
+ */
4695
+ function engagedLatchState(document, latchId) {
4696
+ const components = document.components;
4697
+ if (!Array.isArray(components))
4698
+ return null;
4699
+ const votes = new Set();
4700
+ for (const component of components) {
4701
+ const program = declaredProgram(component);
4702
+ if (program === undefined)
4703
+ continue;
4704
+ for (const position of program.positions) {
4705
+ const cited = [
4706
+ ...Object.values(position.lines).flatMap((parameters) => Object.values(parameters)),
4707
+ ...Object.values(position.parameters),
4708
+ ];
4709
+ for (const parameter of cited) {
4710
+ if (parameter.control !== latchId || parameter.max === parameter.min)
4711
+ continue;
4712
+ votes.add(parameter.max > parameter.min ? 1 : 0);
4713
+ }
4714
+ }
4715
+ }
4716
+ return votes.size === 1 ? [...votes][0] : null;
4717
+ }
4718
+ /**
4719
+ * The tap law each tapped control is read with. Every tapped parameter of one control reads the
4720
+ * same presses, so the first stated law is the control's; a DD-5's four TEMPO positions state one.
4721
+ */
4722
+ function tapLawsByControl(document) {
4723
+ const laws = new Map();
4724
+ const components = document.components;
4725
+ if (!Array.isArray(components))
4726
+ return laws;
4727
+ for (const component of components) {
4728
+ const program = declaredProgram(component);
4729
+ if (program === undefined)
4730
+ continue;
4731
+ for (const position of program.positions) {
4732
+ const cited = [
4733
+ ...Object.values(position.lines).flatMap((parameters) => Object.values(parameters)),
4734
+ ...Object.values(position.parameters),
4735
+ ];
4736
+ for (const parameter of cited) {
4737
+ if (parameter.read !== "tapped" || parameter.control === undefined || parameter.tap === undefined)
4738
+ continue;
4739
+ if (!laws.has(parameter.control))
4740
+ laws.set(parameter.control, parameter.tap);
4741
+ }
4742
+ }
4743
+ }
4744
+ return laws;
4745
+ }
4746
+ //# sourceMappingURL=netlist.js.map