@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
package/dist/lower.js ADDED
@@ -0,0 +1,1855 @@
1
+ // Stage 5: turn each region into a block of stamps.
2
+ //
3
+ // A stamp is a *symbolic* contribution to the MNA system, not a number. Two things
4
+ // are deliberately left unevaluated here, and both are decisions rather than
5
+ // convenience:
6
+ //
7
+ // - **Sample rate.** A capacitor's companion conductance is 2C/dt, which needs a
8
+ // rate. The rate is a runtime input (decision 3), so the stamp carries C and the
9
+ // runtime computes the conductance. There is no sample rate anywhere in this
10
+ // file, no default, and no fallback.
11
+ // - **Control position.** A pot's conductance depends on its 0..1 position through
12
+ // its taper (decision 2), so the stamp carries the track resistance and the taper
13
+ // and the runtime evaluates them. Moving a knob re-evaluates coefficients; it
14
+ // never re-lowers.
15
+ //
16
+ // The result is that one lowered program is valid at every sample rate and every knob
17
+ // position, which is what makes rate independence testable: compile once, run at two
18
+ // rates, and the outputs must match.
19
+ import { SWITCH_ON_OHMS } from "./device-laws.js";
20
+ import { findBistableLatches, findLatchSeeds, } from "./latch-seed.js";
21
+ import { computeSparseSchedule } from "./sparse-schedule.js";
22
+ import { computeStampPartition, remapStampNodes, shouldEliminate, stampControl, stampNeedsNewton, } from "./stamp-partition.js";
23
+ export { stampNeedsNewton } from "./stamp-partition.js";
24
+ import { GROUND } from "./types.js";
25
+ import { foldToken } from "./registry.js";
26
+ import { LoweringError } from "./errors.js";
27
+ import { nodeOccurrenceCounts, selectorTapNodes, transformerWindings, } from "./transformer.js";
28
+ import { opampInvertingInputNodes, opampInputDistances, opampOutputAcDistances, opampOutputDistances, potTerminals, quietDistances, } from "./pot-orientation.js";
29
+ export { opampInvertingInputNodes, opampInputDistances, opampOutputAcDistances, opampOutputDistances, potTerminals, quietDistances, } from "./pot-orientation.js";
30
+ export { LoweringError } from "./errors.js";
31
+ export { stampControl } from "./stamp-partition.js";
32
+ export function lowerRegion(region, lawed, inputNode, outputNode,
33
+ /**
34
+ * Computed once by `lower` rather than per region: the rule is a pairwise scan over
35
+ * transistors, and `boss-ds-1` lowers 82 regions from one netlist.
36
+ */
37
+ latchSeeds = [], latches = [],
38
+ /**
39
+ * Computed once by `lower`, for the same reason `latchSeeds` is: the graph a pot's
40
+ * orientation reads (`quietDistances`) does not change between `boss-ds-1`'s 82 regions
41
+ * either. Empty by default so a direct call site not passing one gets today's
42
+ * declaration-order behaviour for every pot -- never a wrong answer, only an unresolved one.
43
+ */
44
+ quietDistance = new Map(),
45
+ /**
46
+ * The narrower, scoped fallback `potTerminals` only consults when `quietDistance` leaves a
47
+ * pot fully unreachable -- see `opampInputDistances`. Empty by default for the same reason
48
+ * `quietDistance` is: an unresolved pot, never a wrong one.
49
+ */
50
+ opampInputDistance = new Map(),
51
+ /**
52
+ * Companion of `opampInputDistance` for the negative-feedback gain-stage tie in
53
+ * `potTerminals`: a pot that the input-distance evidence cannot break reads which end
54
+ * sits on an op-amp's declared output instead. Empty by default for the same reason.
55
+ */
56
+ opampOutputDistance = new Map(),
57
+ /**
58
+ * Nodes that are a declared op-amp inverting input, and the AC (DC + capacitors) distance to
59
+ * an op-amp's declared output: the two signals `potTerminals` reads for the one finite-tie
60
+ * shape the DC maps cannot break -- a non-inverting gain pot whose wiper sits on the
61
+ * inverting input. Empty by default for the same reason as the other maps.
62
+ */
63
+ invertingInputNodes = new Set(), acOutputDistance = new Map()) {
64
+ if (region.kind === "macro") {
65
+ const macro = region.macro;
66
+ if (macro === null) {
67
+ throw new Error(`region ${region.id} is macro but carries no model`);
68
+ }
69
+ // Placeholders: `couple.ts` runs after every region has been lowered and needs to see
70
+ // every block to wire a macro's audio/parameter ports across them, so it cannot happen
71
+ // here where only this one region is in view.
72
+ return {
73
+ kind: "macro",
74
+ id: region.id,
75
+ modelId: macro.modelId,
76
+ parameters: macro.parameters,
77
+ audioIn: null,
78
+ audioOut: false,
79
+ parameter: null,
80
+ modulation: null,
81
+ // The open-OX2 port and law ride only when recognised: emitting
82
+ // null keys here would churn every macro block's program bytes
83
+ // without changing any of them, and the ce-5/micro-flanger
84
+ // bit-identical gate names those programs byte for byte.
85
+ ...(macro.clockLawParams !== undefined && macro.clockLawParams !== null
86
+ ? { clockLawParams: macro.clockLawParams }
87
+ : {}),
88
+ clockControl: macro.clockControl ?? null,
89
+ delayProvenance: macro.delayProvenance ?? null,
90
+ delayDeclaredReason: macro.delayDeclaredReason ?? null,
91
+ // A declared composition rides the macro road as far as composition; `couple.ts`
92
+ // fills `selector` the same way it fills `parameter`, from the terminal index.
93
+ declaredPositions: macro.declaredPositions ?? null,
94
+ declaredRouter: macro.declaredRouter ?? null,
95
+ declaredParameter: macro.declaredParameter ?? null,
96
+ router: null,
97
+ };
98
+ }
99
+ const deviceById = new Map(lawed.netlist.devices.map((device) => [device.id, device]));
100
+ const resolutionByDevice = new Map(lawed.resolutions.map((resolution) => [resolution.device, resolution]));
101
+ // Only consulted by a transformer whose own terminal grouping turns up a lone,
102
+ // unpaired end -- see `transformerWindings`. Computed once per region against the
103
+ // whole netlist, not just this region, because the question is "does anything else in
104
+ // the document touch this node", not "does anything in this region".
105
+ const terminalOccurrences = nodeOccurrenceCounts(lawed.netlist.devices);
106
+ const selectorTaps = selectorTapNodes(lawed.netlist.devices);
107
+ const stamps = [];
108
+ const extraNodes = [];
109
+ /**
110
+ * A private node no pin of the source document reaches -- a device-internal midpoint.
111
+ *
112
+ * Counted off `extraNodes.length` rather than off `stamps.length`: two devices that each
113
+ * need an internal node are only guaranteed distinct numbers if the counter advances once
114
+ * per node handed out. Keying off the stamp count instead means a device that emits
115
+ * several stamps per internal node and one that emits one can be handed the same number,
116
+ * which silently shorts two device internals together.
117
+ */
118
+ const allocateInternalNode = () => {
119
+ const highest = Math.max(...lawed.netlist.nodes);
120
+ const node = (highest + 1 + extraNodes.length);
121
+ extraNodes.push(node);
122
+ return node;
123
+ };
124
+ /** Node spans already held at a potential, so one supply is not stamped twice. */
125
+ const drivenNodes = new Map();
126
+ let stateCount = 0;
127
+ let auxCount = 0;
128
+ for (const deviceId of region.devices) {
129
+ const device = deviceById.get(deviceId);
130
+ const resolution = resolutionByDevice.get(deviceId);
131
+ if (device === undefined ||
132
+ resolution === undefined ||
133
+ resolution.outcome !== "law") {
134
+ continue;
135
+ }
136
+ const law = resolution.law;
137
+ // A supply is stated two ways: a battery names both ends, a rail names one node
138
+ // and means "against ground". Everything else genuinely needs two terminals.
139
+ const [a, b] = law.kind === "voltage-source" && device.nodes.length === 1
140
+ ? [device.nodes[0], GROUND]
141
+ : terminalPair(device);
142
+ switch (law.kind) {
143
+ case "conductance":
144
+ stamps.push({ kind: "conductance", a, b, siemens: law.siemens });
145
+ break;
146
+ case "controlled-conductance": {
147
+ // A pot has three terminals: one end, wiper, other end. Each half of the
148
+ // track becomes its own controlled element so the wiper node is real.
149
+ const [end1, wiper, end2] = potTerminals(device, quietDistance, opampInputDistance, opampOutputDistance,
150
+ // The declared role of the control this pot answers to. `potTerminals`
151
+ // reads it for one narrow case -- a rheostat whose control names a time
152
+ // constant -- and ignores it otherwise.
153
+ lawed.netlist.controls.find((control) => control.id === law.control)?.role ?? null, invertingInputNodes, acOutputDistance);
154
+ // Both halves carry the same residual: it is a property of the wiper's
155
+ // travel, so each end of the track keeps one.
156
+ stamps.push({
157
+ kind: "controlled-conductance",
158
+ a: end1,
159
+ b: wiper,
160
+ control: law.control,
161
+ taper: law.taper,
162
+ totalOhms: law.totalOhms,
163
+ side: "upper",
164
+ residualOhms: law.residualOhms,
165
+ });
166
+ stamps.push({
167
+ kind: "controlled-conductance",
168
+ a: wiper,
169
+ b: end2,
170
+ control: law.control,
171
+ taper: law.taper,
172
+ totalOhms: law.totalOhms,
173
+ side: "lower",
174
+ residualOhms: law.residualOhms,
175
+ });
176
+ break;
177
+ }
178
+ case "controlled-resistance": {
179
+ // Two terminals, one element. Unlike a pot there is no wiper node to
180
+ // create, so this is the plain two-terminal pair.
181
+ const [end1, end2] = terminalPair(device);
182
+ stamps.push({
183
+ kind: "controlled-resistance",
184
+ a: end1,
185
+ b: end2,
186
+ control: law.control,
187
+ taper: law.taper,
188
+ minOhms: law.minOhms,
189
+ maxOhms: law.maxOhms,
190
+ });
191
+ break;
192
+ }
193
+ case "switch":
194
+ stamps.push({
195
+ kind: "switch",
196
+ a,
197
+ b,
198
+ control: law.control,
199
+ onOhms: law.onOhms,
200
+ offOhms: law.offOhms,
201
+ });
202
+ break;
203
+ case "selector": {
204
+ // Every throw of every pole is stamped, and the control decides which
205
+ // conducts. One document still yields one program: a selector adds
206
+ // elements, never a second netlist per position.
207
+ const poles = switchPoles(device);
208
+ const throwCount = Math.max(...poles.map((pole) => pole.throws.length));
209
+ for (const pole of poles) {
210
+ pole.throws.forEach((throwNode, throwIndex) => {
211
+ stamps.push({
212
+ kind: "selector",
213
+ common: pole.common,
214
+ throwNode,
215
+ control: law.control,
216
+ throwIndex,
217
+ throwCount,
218
+ onOhms: law.onOhms,
219
+ offOhms: law.offOhms,
220
+ });
221
+ });
222
+ }
223
+ break;
224
+ }
225
+ case "fixed-selector": {
226
+ // Row 12: a momentary footswitch contact rests OPEN. A null-control
227
+ // switch with more than two terminals that touches a detected latch's
228
+ // collector/base set is the footswitch driving that latch: welding it
229
+ // shut (position ?? 1) drags the bypassed collector below gate
230
+ // threshold and the switch dies (boss-ph-1r measured: bypassed
231
+ // collector pinned at 0.82 V against a ~9 V pull-up by the 0.01 ohm
232
+ // common-to-throw2 weld, downstream gates never switch, audio
233
+ // bit-identical both positions). Emit no element; the latch state
234
+ // (exposed control + steering stamps) carries selection. Two-terminal
235
+ // null-control switches (boss-bd-2, boss-ds-1) and switches outside
236
+ // latch sets (boss-dm-3) never reach this branch.
237
+ const latchNodes = new Set();
238
+ for (const latch of latches) {
239
+ latchNodes.add(latch.engagedCollector);
240
+ latchNodes.add(latch.bypassedCollector);
241
+ latchNodes.add(latch.engagedBase);
242
+ latchNodes.add(latch.bypassedBase);
243
+ }
244
+ if (device.nodes.length > 2 &&
245
+ device.nodes.some((node) => latchNodes.has(node))) {
246
+ break;
247
+ }
248
+ const poles = switchPoles(device);
249
+ const throwCount = Math.max(...poles.map((pole) => pole.throws.length));
250
+ const selected = Math.min(throwCount - 1, Math.max(0, Math.floor(law.position * throwCount)));
251
+ for (const pole of poles) {
252
+ pole.throws.forEach((throwNode, throwIndex) => {
253
+ const ohms = throwIndex === selected ? law.onOhms : law.offOhms;
254
+ stamps.push({
255
+ kind: "conductance",
256
+ a: pole.common,
257
+ b: throwNode,
258
+ siemens: 1 / ohms,
259
+ });
260
+ });
261
+ }
262
+ break;
263
+ }
264
+ // `stateIndex` is a slot offset, and a reactive element occupies two slots.
265
+ // Counting one per element while the executor addressed `stateIndex * 2` made
266
+ // the program understate its own state by half: harmless against a JS array,
267
+ // which grows on write, and silent data loss against the
268
+ // `Float64Array(stateCount)` any C++/WASM/ESP32 implementer would allocate.
269
+ case "capacitance":
270
+ stamps.push({
271
+ kind: "capacitor",
272
+ a,
273
+ b,
274
+ farads: law.farads,
275
+ stateIndex: stateCount,
276
+ });
277
+ stateCount += STATE_SLOTS_PER_REACTIVE_ELEMENT;
278
+ // Declared DC leakage lowers as a second stamp, a plain conductance in
279
+ // parallel, rather than a new field on the capacitor stamp: both consoles
280
+ // already implement `conductance`, so a leaky capacitor is audible on the
281
+ // TS and C++/WASM engines without either runtime changing.
282
+ if (law.leakageSiemens > 0) {
283
+ stamps.push({
284
+ kind: "conductance",
285
+ a,
286
+ b,
287
+ siemens: law.leakageSiemens,
288
+ });
289
+ }
290
+ break;
291
+ case "inductance":
292
+ stamps.push({
293
+ kind: "inductor",
294
+ a,
295
+ b,
296
+ henries: law.henries,
297
+ stateIndex: stateCount,
298
+ // `"unknown"` is a statement, not a value: it carries no resistance into the
299
+ // stamp but is a different claim from silence, and the law records which.
300
+ ...(typeof law.seriesResistanceOhms === "number"
301
+ ? { seriesResistanceOhms: law.seriesResistanceOhms }
302
+ : {}),
303
+ });
304
+ stateCount += STATE_SLOTS_PER_REACTIVE_ELEMENT;
305
+ break;
306
+ case "diode": {
307
+ // By role, not by order: 11 corpus diodes are declared cathode-first, and
308
+ // a reversed diode still conducts, so a clipper looks fine while a
309
+ // rectifier or a protection diode is backwards.
310
+ //
311
+ // **And one component can be several junctions.** A bridge rectifier is four,
312
+ // a dual rectifier two; taking one pair silently dropped the rest. See
313
+ // `diodeJunctions`.
314
+ for (const junction of diodeJunctions(device)) {
315
+ stamps.push({
316
+ kind: "diode",
317
+ anode: junction.anode,
318
+ cathode: junction.cathode,
319
+ saturationCurrent: law.saturationCurrent,
320
+ emissionCoefficient: law.emissionCoefficient,
321
+ thermalVoltage: law.thermalVoltage,
322
+ breakdownVolts: law.breakdownVolts,
323
+ seriesResistance: law.seriesResistance,
324
+ device: device.id,
325
+ isLed: law.isLed,
326
+ });
327
+ }
328
+ break;
329
+ }
330
+ case "bjt": {
331
+ const [base, collector, emitter] = bjtTerminals(device);
332
+ stamps.push({
333
+ kind: "bjt",
334
+ base,
335
+ collector,
336
+ emitter,
337
+ polarity: law.polarity,
338
+ leakageAmps: law.leakageAmps,
339
+ saturationCurrent: law.saturationCurrent,
340
+ forwardBeta: law.forwardBeta,
341
+ reverseBeta: law.reverseBeta,
342
+ thermalVoltage: law.thermalVoltage,
343
+ });
344
+ break;
345
+ }
346
+ case "triode": {
347
+ // A tube's terminal names are its own vocabulary -- grid, cathode, plate,
348
+ // and `anode` for the plate -- so it cannot borrow `bjtTerminals`, whose
349
+ // closed sets are base/collector/emitter and gate/drain/source. Getting the
350
+ // plate and cathode the wrong way round would invert the stage and still
351
+ // render, which is the class of error this stage exists to prevent.
352
+ const [grid, cathode, plate] = triodeTerminals(device);
353
+ stamps.push({
354
+ kind: "triode",
355
+ grid,
356
+ cathode,
357
+ plate,
358
+ mu: law.mu,
359
+ kg1: law.kg1,
360
+ kp: law.kp,
361
+ kvb: law.kvb,
362
+ ex: law.ex,
363
+ gridSaturationCurrent: law.gridSaturationCurrent,
364
+ gridOnsetVolts: law.gridOnsetVolts,
365
+ gridScaleVolts: law.gridScaleVolts,
366
+ contactPotentialVolts: law.contactPotentialVolts,
367
+ });
368
+ break;
369
+ }
370
+ case "pentode": {
371
+ const [grid, cathode, plate, screen] = pentodeTerminals(device);
372
+ stamps.push({
373
+ kind: "pentode",
374
+ grid,
375
+ cathode,
376
+ plate,
377
+ screen,
378
+ mu: law.mu,
379
+ kg1: law.kg1,
380
+ kp: law.kp,
381
+ kvb: law.kvb,
382
+ ex: law.ex,
383
+ gridSaturationCurrent: law.gridSaturationCurrent,
384
+ gridOnsetVolts: law.gridOnsetVolts,
385
+ gridScaleVolts: law.gridScaleVolts,
386
+ contactPotentialVolts: law.contactPotentialVolts,
387
+ screenShare: law.screenShare ?? 0,
388
+ });
389
+ break;
390
+ }
391
+ case "tube-diode": {
392
+ // One stamp per plate, all sharing the cathode.
393
+ //
394
+ // Every tube diode in the amp corpus is a **dual**: `plate_a`, `plate_b` and one
395
+ // `cathode_filament`. Two plates in one envelope over a common cathode are two
396
+ // independent conduction paths -- there is no interaction to model, because a
397
+ // directly heated cathode emits toward whichever plate is positive -- so this needs
398
+ // no operator of its own, exactly as an N-winding transformer needed none.
399
+ //
400
+ // Taking the first plate and dropping the second would be the pentode's ganged
401
+ // case: half a device that still renders. In a centre-tapped supply, which is what
402
+ // all three instances are wired into, that is a **full-wave rectifier reduced to
403
+ // half-wave** -- the same defect the transformer's positional reading produced on
404
+ // `tycobrahe-octavia`, here doubling the ripple and halving the available current.
405
+ for (const plate of tubeDiodePlates(device)) {
406
+ stamps.push({
407
+ kind: "tube-diode",
408
+ plate: plate.plate,
409
+ cathode: plate.cathode,
410
+ perveance: law.perveance,
411
+ exponent: law.exponent,
412
+ });
413
+ }
414
+ break;
415
+ }
416
+ case "fet": {
417
+ const [gate, drain, source] = bjtTerminals(device);
418
+ stamps.push({
419
+ kind: "fet",
420
+ gate,
421
+ drain,
422
+ source,
423
+ channel: law.channel,
424
+ thresholdVolts: law.thresholdVolts,
425
+ transconductance: law.transconductance,
426
+ channelLengthModulation: law.channelLengthModulation,
427
+ subthresholdVolts: law.subthresholdVolts,
428
+ gateSaturationCurrent: law.gateSaturationCurrent,
429
+ gateOnsetVolts: law.gateOnsetVolts,
430
+ gateScaleVolts: law.gateScaleVolts,
431
+ });
432
+ break;
433
+ }
434
+ case "transformer": {
435
+ // One stamp per secondary winding.
436
+ //
437
+ // A **coupled** winding is one stamp against the shared primary. An N-winding
438
+ // ideal transformer *is* (N-1) two-winding ones on a shared primary, so this
439
+ // needs no new operator. Each winding sees the same flux, so `V_k / n_k` is
440
+ // equal across all of them, and ampere-turns balance `sum(N_k * I_k) = 0` falls
441
+ // out: a secondary reflecting `-n_k * i_k` into the primary pair means the
442
+ // primary carries `sum(i_k)`, and substituting gives
443
+ // `N_1 * sum(i_k) - sum(N_1 * i_k) = 0`. Exactly the N-winding constraint.
444
+ //
445
+ // A **driven** winding is one `ac-source` at the winding's own stated RMS, with
446
+ // no primary modelled at all -- see `transformerWindings` for when and why.
447
+ for (const winding of transformerWindings(device, law, terminalOccurrences, selectorTaps)) {
448
+ stamps.push(winding.kind === "coupled"
449
+ ? {
450
+ kind: "transformer",
451
+ primaryPlus: winding.primaryPlus,
452
+ primaryMinus: winding.primaryMinus,
453
+ secondaryPlus: winding.plus,
454
+ secondaryMinus: winding.minus,
455
+ turnsRatio: winding.turnsRatio,
456
+ sourceIndex: auxCount,
457
+ // **The declared value IS the primary-referred loop resistance**,
458
+ // used as stated. An earlier version read it as each winding's
459
+ // own resistance and formed `R * (1 + n^2)`, which for the
460
+ // 2k:4 output transformer turned a declared 50 Ω into 25 kΩ --
461
+ // **12.5x the primary impedance**, a near-open circuit rather
462
+ // than damping. That is wrong because a real transformer's
463
+ // secondary is about `R_p / n^2`, not equal to `R_p`, so the
464
+ // referred total is of order `2 * R_p` and never `n^2 * R_p`.
465
+ // One number, referred to the primary, is also the only thing
466
+ // a single property can honestly mean.
467
+ //
468
+ // `unknown` carries no value, which is a different claim from
469
+ // silence but the same stamp.
470
+ ...(typeof law.seriesResistanceOhms === "number"
471
+ ? { seriesResistanceOhms: law.seriesResistanceOhms }
472
+ : {}),
473
+ }
474
+ : {
475
+ kind: "ac-source",
476
+ positive: winding.plus,
477
+ negative: winding.minus,
478
+ amplitudeVolts: winding.amplitudeVolts,
479
+ frequencyHz: winding.frequencyHz,
480
+ sourceIndex: auxCount,
481
+ sourceOhms: winding.sourceOhms,
482
+ });
483
+ auxCount += 1;
484
+ }
485
+ break;
486
+ }
487
+ case "spring-reverb": {
488
+ // A tank's two coils come from its **declared** windings, never from terminal
489
+ // order: swapping a pair's ends inverts that port, and swapping the pairs sends
490
+ // the recovery amp's signal into the drive coil instead of taking it from the
491
+ // pickup. Order would decide both silently, so a missing declaration is a refusal.
492
+ //
493
+ // `drive`/`pickup` rather than `primary`/`secondary` because neither coil
494
+ // transforms the other's voltage -- the springs are between them. This replaced a
495
+ // four-spelling name set (`inputhot`/`inputreturn`/`outputhot`/`outputreturn`),
496
+ // which was the last terminal-name vocabulary in this file.
497
+ const coils = new Map();
498
+ for (const winding of device.identity.declaredWindings ?? []) {
499
+ const nodes = winding.terminalIndices.flatMap((index) => {
500
+ const node = device.nodes[index];
501
+ return node === undefined ? [] : [node];
502
+ });
503
+ if (nodes.length === 2 && !coils.has(winding.role)) {
504
+ coils.set(winding.role, nodes);
505
+ }
506
+ }
507
+ const drive = coils.get("drive");
508
+ const pickup = coils.get("pickup");
509
+ if (drive === undefined || pickup === undefined) {
510
+ throw new LoweringError(`spring reverb tank ${device.id}: needs a two-terminal "drive" winding and a ` +
511
+ 'two-terminal "pickup" winding declared, and which coil is which decides ' +
512
+ "whether the signal goes into the springs or comes out of them; saw " +
513
+ `[${(device.identity.declaredWindings ?? []).map((winding) => winding.role).join(", ")}]`, device.id);
514
+ }
515
+ const [inputPlus, inputMinus] = drive;
516
+ const [outputPlus, outputMinus] = pickup;
517
+ stamps.push({
518
+ kind: "spring-reverb",
519
+ inputPlus,
520
+ inputMinus,
521
+ outputPlus,
522
+ outputMinus,
523
+ inputOhms: law.inputOhms,
524
+ outputOhms: law.outputOhms,
525
+ delaySeconds: law.delaySeconds,
526
+ decaySeconds: law.decaySeconds,
527
+ dispersionStages: law.dispersionStages,
528
+ sourceIndex: auxCount,
529
+ });
530
+ auxCount += 1;
531
+ break;
532
+ }
533
+ case "voltage-source": {
534
+ // One node, one assertion. A packet may state the same supply twice --
535
+ // `boss-sd-1` declares a 9 V rail and a 9 V battery on the same node --
536
+ // and two ideal sources in parallel are two matrix rows asserting one
537
+ // constraint, which is rank-deficient and solves to nothing. Stamping it
538
+ // once is exactly equivalent.
539
+ const [positive, negative] = supplyTerminals(device);
540
+ // The span is order-normalised and the voltage is oriented onto it, so a
541
+ // twin declared the other way round is recognised as the *contradiction*
542
+ // it is rather than stamped beside the first as a second row. Keying on
543
+ // `positive:negative` made a reversed duplicate look like a different
544
+ // supply, which is two rows asserting opposite signs across one pair.
545
+ const low = Math.min(positive, negative);
546
+ const high = Math.max(positive, negative);
547
+ const span = `${low}:${high}`;
548
+ const oriented = positive === low ? law.volts : -law.volts;
549
+ const existing = drivenNodes.get(span);
550
+ if (existing !== undefined) {
551
+ if (existing !== oriented) {
552
+ throw new LoweringError(`nodes ${low} and ${high} are driven to both ${existing} V and ${oriented} V`, device.id);
553
+ }
554
+ break;
555
+ }
556
+ drivenNodes.set(span, oriented);
557
+ stamps.push({
558
+ kind: "dc-source",
559
+ positive,
560
+ negative,
561
+ volts: law.volts,
562
+ sourceIndex: auxCount,
563
+ sourceOhms: law.sourceOhms,
564
+ });
565
+ auxCount += 1;
566
+ break;
567
+ }
568
+ case "ac-source": {
569
+ // The same two ends a DC supply has, read by the same roles -- `hot`/`neutral` and
570
+ // `line`/`neutral` are the mains pairs the amp packets declare, and a reversed
571
+ // sine is a sine, so orientation matters far less here than for a rail. It is read
572
+ // anyway rather than positionally, because the phase relationship between two
573
+ // windings of one transformer is not a free choice.
574
+ //
575
+ // **No twin collapsing.** A DC supply gets one, because packets state the same rail
576
+ // twice and two ideal sources across one span are two rows asserting one
577
+ // constraint. Nothing in either corpus declares a duplicate AC source, so
578
+ // deduplicating one here would be a rule against unexercised data; the
579
+ // `node-driven-by-two-sources` warning covers the case if it ever arrives.
580
+ const [positive, negative] = supplyTerminals(device);
581
+ stamps.push({
582
+ kind: "ac-source",
583
+ positive,
584
+ negative,
585
+ amplitudeVolts: law.amplitudeVolts,
586
+ frequencyHz: law.frequencyHz,
587
+ sourceIndex: auxCount,
588
+ sourceOhms: law.sourceOhms,
589
+ });
590
+ auxCount += 1;
591
+ break;
592
+ }
593
+ case "ideal-opamp": {
594
+ const [plus, minus, output] = opampTerminals(device);
595
+ stamps.push({
596
+ kind: "ideal-opamp",
597
+ plus,
598
+ minus,
599
+ output,
600
+ sourceIndex: auxCount,
601
+ railHigh: law.railHigh,
602
+ railLow: law.railLow,
603
+ openLoopGain: law.openLoopGain,
604
+ });
605
+ auxCount += 1;
606
+ break;
607
+ }
608
+ case "ota": {
609
+ // Absent `terminalIndices` means the terminals arrived already in law order,
610
+ // which is what a registry `sections` entry produces. Present means they were
611
+ // bound by the names the device declared; see `otaTerminalBinding`.
612
+ const indices = law.terminalIndices;
613
+ const positive = device.nodes[indices?.plus ?? 0];
614
+ const negative = device.nodes[indices?.minus ?? 1];
615
+ const output = device.nodes[indices?.output ?? 2];
616
+ // `bias: null` says the device declares no amplifier-bias terminal, so this is
617
+ // a linear OTA. Only the positional reading may fall through to position 3.
618
+ const bias = indices === undefined
619
+ ? device.nodes[3]
620
+ : indices.bias === null
621
+ ? undefined
622
+ : device.nodes[indices.bias];
623
+ if (positive === undefined ||
624
+ negative === undefined ||
625
+ output === undefined) {
626
+ throw new LoweringError(`ota ${device.id} requires positive, negative, and output terminals`, device.id);
627
+ }
628
+ if (bias !== undefined) {
629
+ // 4-terminal active non-linear OTA
630
+ const vee = law.veeIndex !== undefined &&
631
+ device.nodes[law.veeIndex] !== undefined
632
+ ? device.nodes[law.veeIndex]
633
+ : 0;
634
+ stamps.push({
635
+ kind: "ota",
636
+ plus: positive,
637
+ minus: negative,
638
+ bias,
639
+ output,
640
+ vee,
641
+ saturationCurrent: law.saturationCurrent ?? 1e-12, // Physical Silicon diode default
642
+ thermalVoltage: law.thermalVoltage ?? 0.025852, // Standard thermal voltage
643
+ });
644
+ }
645
+ else {
646
+ // 3-terminal static linear OTA fallback.
647
+ //
648
+ // **The inputs are crossed on purpose, and that is a bug fix.** The `vccs`
649
+ // stamp's convention is that current *leaves* `outP` at
650
+ // `gm * (v(inP) - v(inN))`, so binding `inP: positive` made this fallback
651
+ // **inverting** while the four-terminal `ota` stamp above injects its current
652
+ // and is **non-inverting**. The same catalog part therefore inverted or did
653
+ // not depending only on how many terminals its entry mapped -- three of the
654
+ // shipped OTA entries map three, five map five -- which split nine corpus
655
+ // packets across two opposite polarities.
656
+ //
657
+ // Non-inverting is the correct one. the part catalog's own
658
+ // cited reading of the CA3080/CA3094 datasheet records that with the output
659
+ // taken at terminal 6 the datasheet's output-mode table makes pin 3 the
660
+ // non-inverting input, and the entry's role groups are that reading. An OTA
661
+ // sourcing current into a load resistor raises its output as `v(+)` rises.
662
+ //
663
+ // Crossed here rather than negating `transconductance`, so the law's value
664
+ // stays positive and matches the catalog wherever it is printed.
665
+ stamps.push({
666
+ kind: "vccs",
667
+ outP: output,
668
+ outN: 0,
669
+ inP: negative,
670
+ inN: positive,
671
+ transconductance: law.transconductance,
672
+ });
673
+ }
674
+ break;
675
+ }
676
+ case "linear-vca": {
677
+ // Positional terminals only (sections path): [plus, minus, output,
678
+ // control]. The control pin is voltage-sense: no conductance is
679
+ // stamped on it, so a high-impedance depth network drives it
680
+ // without sagging (contrast the `ota` bias diode). The input pin
681
+ // is a current input at about the minus potential; the input
682
+ // conductance below is both its impedance and the I = V*G that
683
+ // the gain multiplies.
684
+ const plus = device.nodes[0];
685
+ const minus = device.nodes[1];
686
+ const output = device.nodes[2];
687
+ const control = device.nodes[3];
688
+ if (plus === undefined ||
689
+ minus === undefined ||
690
+ output === undefined ||
691
+ control === undefined) {
692
+ throw new LoweringError(`linear-vca ${device.id} requires plus, minus, output, and control terminals`, device.id);
693
+ }
694
+ stamps.push({
695
+ kind: "conductance",
696
+ a: plus,
697
+ b: minus,
698
+ siemens: law.inputSiemens,
699
+ });
700
+ stamps.push({
701
+ kind: "linear-vca",
702
+ plus,
703
+ minus,
704
+ output,
705
+ control,
706
+ inputSiemens: law.inputSiemens,
707
+ vrefVolts: law.vrefVolts,
708
+ minGain: law.minGain,
709
+ });
710
+ break;
711
+ }
712
+ case "inverter": {
713
+ const { input, output, vdd, gnd } = inverterTerminals(device);
714
+ if (input === undefined || output === undefined) {
715
+ throw new Error(`inverter ${device.id} requires input and output terminals`);
716
+ }
717
+ if (vdd !== undefined && gnd !== undefined) {
718
+ // 1. PMOS: gate=input, drain=output, source=vdd
719
+ stamps.push({
720
+ kind: "fet",
721
+ gate: input,
722
+ drain: output,
723
+ source: vdd,
724
+ channel: "p",
725
+ thresholdVolts: law.thresholdVolts ?? 2.0,
726
+ transconductance: law.transconductance,
727
+ channelLengthModulation: 0,
728
+ subthresholdVolts: 0,
729
+ gateSaturationCurrent: 0,
730
+ gateOnsetVolts: 0,
731
+ gateScaleVolts: 1,
732
+ });
733
+ // 2. NMOS: gate=input, drain=output, source=gnd
734
+ stamps.push({
735
+ kind: "fet",
736
+ gate: input,
737
+ drain: output,
738
+ source: gnd,
739
+ channel: "n",
740
+ thresholdVolts: law.thresholdVolts ?? 2.0,
741
+ transconductance: law.transconductance,
742
+ channelLengthModulation: 0,
743
+ subthresholdVolts: 0,
744
+ gateSaturationCurrent: 0,
745
+ gateOnsetVolts: 0,
746
+ gateScaleVolts: 1,
747
+ });
748
+ }
749
+ else {
750
+ stamps.push({
751
+ kind: "vccs",
752
+ outP: output,
753
+ outN: 0, // Ground return
754
+ inP: 0, // Single-ended 0V ground reference
755
+ inN: input,
756
+ transconductance: law.transconductance,
757
+ biasVolts: law.biasVolts ?? 4.5,
758
+ });
759
+ }
760
+ break;
761
+ }
762
+ case "nand-gate": {
763
+ const inA = device.nodes[0];
764
+ const inB = device.nodes[1];
765
+ const output = device.nodes[2];
766
+ const vdd = device.nodes[3];
767
+ const gnd = device.nodes[4];
768
+ if (inA === undefined ||
769
+ inB === undefined ||
770
+ output === undefined ||
771
+ vdd === undefined ||
772
+ gnd === undefined) {
773
+ throw new Error(`nand-gate ${device.id} requires 5 terminals (inA, inB, output, vdd, gnd)`);
774
+ }
775
+ // A unique private internal node for the NMOS series midpoint.
776
+ const nodeX = allocateInternalNode();
777
+ // 1. PMOS A: gate=inA, drain=output, source=vdd
778
+ stamps.push({
779
+ kind: "fet",
780
+ gate: inA,
781
+ drain: output,
782
+ source: vdd,
783
+ channel: "p",
784
+ thresholdVolts: law.thresholdVolts,
785
+ transconductance: law.transconductance,
786
+ channelLengthModulation: 0,
787
+ subthresholdVolts: 0,
788
+ gateSaturationCurrent: 0,
789
+ gateOnsetVolts: 0,
790
+ gateScaleVolts: 1,
791
+ });
792
+ // 2. PMOS B: gate=inB, drain=output, source=vdd
793
+ stamps.push({
794
+ kind: "fet",
795
+ gate: inB,
796
+ drain: output,
797
+ source: vdd,
798
+ channel: "p",
799
+ thresholdVolts: law.thresholdVolts,
800
+ transconductance: law.transconductance,
801
+ channelLengthModulation: 0,
802
+ subthresholdVolts: 0,
803
+ gateSaturationCurrent: 0,
804
+ gateOnsetVolts: 0,
805
+ gateScaleVolts: 1,
806
+ });
807
+ // 3. NMOS A: gate=inA, drain=output, source=nodeX
808
+ stamps.push({
809
+ kind: "fet",
810
+ gate: inA,
811
+ drain: output,
812
+ source: nodeX,
813
+ channel: "n",
814
+ thresholdVolts: law.thresholdVolts,
815
+ transconductance: law.transconductance,
816
+ channelLengthModulation: 0,
817
+ subthresholdVolts: 0,
818
+ gateSaturationCurrent: 0,
819
+ gateOnsetVolts: 0,
820
+ gateScaleVolts: 1,
821
+ });
822
+ // 4. NMOS B: gate=inB, drain=nodeX, source=gnd
823
+ stamps.push({
824
+ kind: "fet",
825
+ gate: inB,
826
+ drain: nodeX,
827
+ source: gnd,
828
+ channel: "n",
829
+ thresholdVolts: law.thresholdVolts,
830
+ transconductance: law.transconductance,
831
+ channelLengthModulation: 0,
832
+ subthresholdVolts: 0,
833
+ gateSaturationCurrent: 0,
834
+ gateOnsetVolts: 0,
835
+ gateScaleVolts: 1,
836
+ });
837
+ break;
838
+ }
839
+ case "clock-driver": {
840
+ const cp1 = device.nodes[0];
841
+ const cp2 = device.nodes[1];
842
+ const vgg = device.nodes[2];
843
+ const vdd = device.nodes[3];
844
+ const ox1 = device.nodes[4];
845
+ const gnd = device.nodes[5];
846
+ if (cp1 === undefined || cp2 === undefined || ox1 === undefined) {
847
+ throw new LoweringError(`clock-driver ${device.id} requires cp1, cp2, and ox1 terminals`, device.id);
848
+ }
849
+ stamps.push({
850
+ kind: "clock-driver",
851
+ cp1,
852
+ cp2,
853
+ vgg: vgg !== undefined ? vgg : 0,
854
+ vdd: vdd !== undefined ? vdd : 0,
855
+ ox1,
856
+ // Absent means circuit ground, which is what the stamp assumed before this
857
+ // terminal existed -- so an unbound GND pin reproduces the old behaviour
858
+ // rather than refusing.
859
+ gnd: gnd !== undefined ? gnd : 0,
860
+ defaultFrequency: law.defaultFrequency ?? 10000, // 10 kHz default oscillator frequency
861
+ stateIndex: stateCount++,
862
+ sourceIndex: auxCount,
863
+ });
864
+ auxCount += 3; // CP1, CP2, and VGG voltage source outputs
865
+ break;
866
+ }
867
+ case "optocoupler": {
868
+ const { ledAnode, ledCathode, ldrA, ldrB } = optocouplerTerminals(device);
869
+ if (ledAnode === undefined ||
870
+ ledCathode === undefined ||
871
+ ldrA === undefined ||
872
+ ldrB === undefined) {
873
+ throw new Error(`optocoupler ${device.id} requires 4 terminals: anode, cathode, ldrA, ldrB`);
874
+ }
875
+ stamps.push({
876
+ kind: "optocoupler",
877
+ ledAnode,
878
+ ledCathode,
879
+ ldrA,
880
+ ldrB,
881
+ ledThresholdVolts: law.ledThresholdVolts,
882
+ ledTransconductance: law.ledTransconductance,
883
+ ldrMinOhms: law.ldrMinOhms,
884
+ ldrMaxOhms: law.ldrMaxOhms,
885
+ ldrPowerLawCoefficientOhms: law.ldrPowerLawCoefficientOhms,
886
+ ldrPowerLawExponent: law.ldrPowerLawExponent,
887
+ });
888
+ break;
889
+ }
890
+ case "logic-divider": {
891
+ const clockNode = device.nodes[0];
892
+ const qNode = device.nodes[1];
893
+ const gndNode = device.nodes[2];
894
+ if (clockNode === undefined ||
895
+ qNode === undefined ||
896
+ gndNode === undefined) {
897
+ throw new Error(`logic-divider ${device.id} requires clock, q, and gnd terminals`);
898
+ }
899
+ stamps.push({
900
+ kind: "logic-divider",
901
+ clockNode,
902
+ qNode,
903
+ gndNode,
904
+ thresholdVolts: law.thresholdVolts,
905
+ highVolts: law.highVolts,
906
+ sourceIndex: auxCount++,
907
+ stateIndex: stateCount,
908
+ });
909
+ stateCount += 2;
910
+ break;
911
+ }
912
+ case "analog-switch": {
913
+ const a = device.nodes[0];
914
+ const b = device.nodes[1];
915
+ const control = device.nodes[2];
916
+ if (a === undefined || b === undefined || control === undefined) {
917
+ throw new LoweringError(`analog-switch ${device.id} requires three terminals: sig_a, sig_b, and control`, device.id);
918
+ }
919
+ stamps.push({
920
+ kind: "analog-switch",
921
+ a,
922
+ b,
923
+ control,
924
+ onOhms: law.onOhms,
925
+ offOhms: law.offOhms,
926
+ thresholdVolts: law.thresholdVolts,
927
+ });
928
+ break;
929
+ }
930
+ case "compandor": {
931
+ // The datasheet's Figure 5 block diagram, built out of primitives. Only the
932
+ // rectifier and the gain cell are bespoke; the five internal resistors, the
933
+ // band-gap reference and the output op-amp are stamps the runtime already
934
+ // had, which is why fixing this part needed no new runtime machinery beyond
935
+ // the one `compandor` stamp below.
936
+ const rectIn = device.nodes[0];
937
+ const rectCap = device.nodes[1];
938
+ const cellIn = device.nodes[2];
939
+ const sumNode = device.nodes[3];
940
+ const r3Pin = device.nodes[4];
941
+ const output = device.nodes[5];
942
+ if (rectIn === undefined ||
943
+ rectCap === undefined ||
944
+ cellIn === undefined ||
945
+ sumNode === undefined ||
946
+ r3Pin === undefined ||
947
+ output === undefined) {
948
+ throw new LoweringError(`compandor ${device.id} requires six terminals: rect_in, rect_cap, cell_in, inv_in, r3, and output`, device.id);
949
+ }
950
+ // The internal 1.8 V band-gap reference. It has no pin, so it is a private
951
+ // node: the rectifier and gain-cell summing nodes and the op-amp's
952
+ // non-inverting input all sit here, which is what makes the chip work off a
953
+ // single supply.
954
+ const vref = allocateInternalNode();
955
+ stamps.push({
956
+ kind: "dc-source",
957
+ positive: vref,
958
+ negative: GROUND,
959
+ volts: law.vrefVolts,
960
+ sourceIndex: auxCount++,
961
+ sourceOhms: 0,
962
+ });
963
+ // R1 and R2: the rectifier's and the gain cell's input resistors. Both
964
+ // terminate on VREF (Figure 5: "the summing nodes of the rectifier and G
965
+ // cell ... have the same potential"), so they are also the input impedance
966
+ // each pin presents -- 10 k on RECT_IN and 20 k on G_CELL_IN. Without these
967
+ // the chip would load its driver with nothing at all.
968
+ stamps.push({
969
+ kind: "conductance",
970
+ a: rectIn,
971
+ b: vref,
972
+ siemens: 1 / law.r1,
973
+ });
974
+ stamps.push({
975
+ kind: "conductance",
976
+ a: cellIn,
977
+ b: vref,
978
+ siemens: 1 / law.r2,
979
+ });
980
+ // R5, the rectifier's averaging resistor, to ground. The packet's external
981
+ // CRECT on this same node is an ordinary capacitor the solve integrates, so
982
+ // `tau = R5 * CRECT` comes out of the circuit rather than out of a constant.
983
+ stamps.push({
984
+ kind: "conductance",
985
+ a: rectCap,
986
+ b: GROUND,
987
+ siemens: 1 / law.r5,
988
+ });
989
+ // R3 from the summing node out to pin 6, and R4 from the summing node to
990
+ // ground. Tying pin 6 to the output pin externally is what makes R3 the
991
+ // feedback resistor and sets the stage gain; R4 to ground is what lifts the
992
+ // quiescent output to `(1 + R3/R4) * VREF`.
993
+ stamps.push({
994
+ kind: "conductance",
995
+ a: sumNode,
996
+ b: r3Pin,
997
+ siemens: 1 / law.r3,
998
+ });
999
+ stamps.push({
1000
+ kind: "conductance",
1001
+ a: sumNode,
1002
+ b: GROUND,
1003
+ siemens: 1 / law.r4,
1004
+ });
1005
+ // The chip's own supply pins bound this output, exactly as they bound a
1006
+ // discrete op-amp's. Stage 3 substitutes them; a null here means the document
1007
+ // declared no supply for the package, not that the part swings without limit.
1008
+ stamps.push({
1009
+ kind: "ideal-opamp",
1010
+ plus: vref,
1011
+ minus: sumNode,
1012
+ output,
1013
+ sourceIndex: auxCount++,
1014
+ railHigh: law.railHigh,
1015
+ railLow: law.railLow,
1016
+ openLoopGain: law.openLoopGain,
1017
+ });
1018
+ stamps.push({
1019
+ kind: "compandor",
1020
+ rectIn,
1021
+ rectCap,
1022
+ cellIn,
1023
+ sumNode,
1024
+ vref,
1025
+ r1: law.r1,
1026
+ r2: law.r2,
1027
+ r5: law.r5,
1028
+ iBias: law.iBias,
1029
+ stateIndex: stateCount,
1030
+ });
1031
+ // Two slots: the rectified current and the cell's transconductance. Both are
1032
+ // sampled once per committed sample and held across the Newton loop.
1033
+ stateCount += 2;
1034
+ break;
1035
+ }
1036
+ case "comparator": {
1037
+ const plus = device.nodes[0];
1038
+ const minus = device.nodes[1];
1039
+ const output = device.nodes[2];
1040
+ const vee = device.nodes[3];
1041
+ if (plus === undefined || minus === undefined || output === undefined) {
1042
+ throw new LoweringError(`comparator ${device.id} requires plus, minus, and output terminals`, device.id);
1043
+ }
1044
+ stamps.push({
1045
+ kind: "comparator",
1046
+ plus,
1047
+ minus,
1048
+ output,
1049
+ vee: vee !== undefined ? vee : 0, // Defaults to ground node if VEE pin is unwired
1050
+ pullDownOhms: law.pullDownOhms,
1051
+ floatOhms: law.floatOhms,
1052
+ sensitivity: law.sensitivity,
1053
+ });
1054
+ break;
1055
+ }
1056
+ case "port-engage": {
1057
+ // The contact against the return, closed. Both were resolved when the law
1058
+ // was attached, from where the supply return landed; re-deriving them here
1059
+ // from role names is what shorted a signal terminal to ground.
1060
+ const contact = device.nodes[law.contactIndex];
1061
+ const back = device.nodes[law.againstIndex];
1062
+ if (contact === undefined || back === undefined) {
1063
+ throw new LoweringError(`jack ${device.id} has a port-engage law naming terminals it does not have`, device.id);
1064
+ }
1065
+ stamps.push({
1066
+ kind: "conductance",
1067
+ a: contact,
1068
+ b: back,
1069
+ siemens: 1 / SWITCH_ON_OHMS,
1070
+ });
1071
+ break;
1072
+ }
1073
+ case "open":
1074
+ // Interface and reference symbols carry no element.
1075
+ break;
1076
+ default: {
1077
+ // A law with no stamp would be silently dropped from the circuit and the
1078
+ // result would still render -- plausible audio for a circuit that is
1079
+ // missing a component. Refusing is the only safe fallthrough.
1080
+ const unhandled = law;
1081
+ throw new Error(`no stamp for device law ${JSON.stringify(unhandled)} on ${deviceId}`);
1082
+ }
1083
+ }
1084
+ }
1085
+ // Only the region that actually contains the input jack is driven by the signal.
1086
+ // Regions are independent subcircuits, not a chain: a dangling supply must not be
1087
+ // handed the input, and must not be read for the output.
1088
+ const ownsInput = region.nodes.includes(inputNode);
1089
+ const ownsOutput = region.nodes.includes(outputNode);
1090
+ if (ownsInput) {
1091
+ stamps.push({
1092
+ kind: "input-source",
1093
+ node: inputNode,
1094
+ sourceIndex: auxCount,
1095
+ });
1096
+ auxCount += 1;
1097
+ }
1098
+ // A seed belongs to the region that holds the node, and regions are disjoint.
1099
+ const regionNodes = new Set(region.nodes);
1100
+ const operatingPointSeeds = latchSeeds.filter((seed) => regionNodes.has(seed.node));
1101
+ // Complementary collector-steering switch stamps for bistable flip-flops:
1102
+ // When control is engaged (>= 0.5): engagedCollector is pulled to emitter (100 ohm),
1103
+ // forcing engagedCollector low (0 V), putting bypassed transistor into cutoff (bypassedCollector high).
1104
+ // When control is bypassed (< 0.5): bypassedCollector is pulled to emitter (100 ohm),
1105
+ // forcing bypassedCollector low (0 V), putting engaged transistor into cutoff (engagedCollector high).
1106
+ for (const latch of latches) {
1107
+ if (latch.controlId !== null &&
1108
+ regionNodes.has(latch.engagedCollector) &&
1109
+ regionNodes.has(latch.bypassedCollector)) {
1110
+ stamps.push({
1111
+ kind: "switch",
1112
+ a: latch.engagedCollector,
1113
+ b: latch.emitter,
1114
+ control: latch.controlId,
1115
+ onOhms: 100,
1116
+ offOhms: 1e8,
1117
+ });
1118
+ stamps.push({
1119
+ kind: "switch",
1120
+ a: latch.bypassedCollector,
1121
+ b: latch.emitter,
1122
+ control: latch.controlId,
1123
+ onOhms: 1e8,
1124
+ offOhms: 100,
1125
+ });
1126
+ }
1127
+ }
1128
+ // The row numbering. Ground is row 0 whether or not a device in this region returns
1129
+ // there, because the runtime pins row 0 and starts its gmin loop at 1 -- a region with
1130
+ // no grounded device would otherwise have its first real node silently pinned to zero.
1131
+ // Everything else follows in source-id order, so the numbering is a function of the
1132
+ // region and not of the order devices happened to be lowered in.
1133
+ const nodeIds = [
1134
+ GROUND,
1135
+ ...[...regionNodes, ...extraNodes]
1136
+ .filter((node) => node !== GROUND)
1137
+ .sort((a, b) => a - b),
1138
+ ];
1139
+ const indexByNode = new Map(nodeIds.map((node, index) => [node, index]));
1140
+ const row = (node) => {
1141
+ const index = indexByNode.get(node);
1142
+ if (index === undefined) {
1143
+ // Unreachable via `partition`, which builds `region.nodes` from exactly the devices
1144
+ // this loop stamped. Throwing rather than defaulting because the alternative -- a
1145
+ // silent 0 -- grounds an element and still renders.
1146
+ throw new LoweringError(`region ${region.id} stamped node ${node}, which is not one of its own`);
1147
+ }
1148
+ return index;
1149
+ };
1150
+ const remappedStamps = stamps.map((stamp) => remapStampNodes(stamp, row));
1151
+ const stampPartition = computeStampPartition(remappedStamps, nodeIds.length);
1152
+ const sparseSchedule = computeSparseSchedule({
1153
+ nodeCount: nodeIds.length,
1154
+ auxCount,
1155
+ stamps: remappedStamps,
1156
+ });
1157
+ return {
1158
+ kind: "mna",
1159
+ id: region.id,
1160
+ nodeCount: nodeIds.length,
1161
+ nodeIds,
1162
+ auxCount,
1163
+ stamps: remappedStamps,
1164
+ stampPartition,
1165
+ sparseSchedule,
1166
+ stateCount,
1167
+ // From the stamps, not from `region.kind`. Measured across the corpus's 191 blocks the
1168
+ // two agree everywhere today, so this is not a correction -- it is the derivation being
1169
+ // taken from the thing the runtime evaluates, so that a law which lowers to something
1170
+ // milder (or to nothing) cannot leave a block claiming a solver cost it does not have.
1171
+ linear: !stamps.some(stampNeedsNewton),
1172
+ controlFree: stamps.every((stamp) => stampControl(stamp) === null),
1173
+ eliminate: shouldEliminate(nodeIds.length + auxCount, stampPartition.portRows.length, !stamps.some(stampNeedsNewton), lawed.netlist.convergenceOptIn),
1174
+ // Only included when set, so a document that does not opt in lowers to a program
1175
+ // byte-identical to one produced before this flag existed.
1176
+ ...(lawed.netlist.convergenceOptIn
1177
+ ? { convergenceOptIn: true }
1178
+ : {}),
1179
+ inputNode: ownsInput ? row(inputNode) : null,
1180
+ outputNode: ownsOutput ? row(outputNode) : null,
1181
+ operatingPointSeeds: operatingPointSeeds.map((seed) => ({
1182
+ ...seed,
1183
+ node: row(seed.node),
1184
+ })),
1185
+ };
1186
+ }
1187
+ /**
1188
+ * A block's row index for a source `NodeId`, or `null` when the node is not one of this
1189
+ * block's own.
1190
+ *
1191
+ * The forward direction of `Block.nodeIds`. `null` rather than a throw because the two
1192
+ * callers that need it are asking a question whose honest answer can be "not this block":
1193
+ * `couple.ts` locating a macro's port node, and a report matching a declared source net
1194
+ * against whichever region owns it.
1195
+ */
1196
+ export function blockNodeIndex(block, node) {
1197
+ const index = block.nodeIds.indexOf(node);
1198
+ return index < 0 ? null : index;
1199
+ }
1200
+ /**
1201
+ * A stamp read back in the document's node ids rather than its block's rows.
1202
+ *
1203
+ * The inverse of what `lowerRegion` did on the way out, for consumers that have to state a
1204
+ * stamp's topology in the terms the source authored -- a report naming the net a device
1205
+ * sits on, or an assertion written against a fixture's declared node numbers.
1206
+ */
1207
+ export function stampInSourceNodes(block, stamp) {
1208
+ return remapStampNodes(stamp, (row) => block.nodeIds[row]);
1209
+ }
1210
+ export function lower(partitioning, lawed) {
1211
+ const { input, output } = lawed.netlist.ports;
1212
+ const latchSeeds = findLatchSeeds(lawed.netlist);
1213
+ const latches = findBistableLatches(lawed.netlist);
1214
+ const quietDistance = quietDistances(lawed.netlist);
1215
+ const opampInputDistance = opampInputDistances(lawed.netlist);
1216
+ const opampOutputDistance = opampOutputDistances(lawed.netlist);
1217
+ const invertingInputNodes = opampInvertingInputNodes(lawed.netlist);
1218
+ const acOutputDistance = opampOutputAcDistances(lawed.netlist);
1219
+ return partitioning.regions.map((region) => lowerRegion(region, lawed, input, output, latchSeeds, latches, quietDistance, opampInputDistance, opampOutputDistance, invertingInputNodes, acOutputDistance));
1220
+ }
1221
+ function terminalPair(device) {
1222
+ const a = device.nodes[0];
1223
+ const b = device.nodes[1];
1224
+ if (a === undefined || b === undefined) {
1225
+ throw new LoweringError(`device ${device.id} needs two terminals`, device.id);
1226
+ }
1227
+ return [a, b];
1228
+ }
1229
+ /**
1230
+ * Split a switch's terminals into poles: each common, then the throws that follow it.
1231
+ *
1232
+ * A multi-pole switch is one mechanism moving several independent contacts together,
1233
+ * so the poles share a control and are stamped side by side. Splitting at each common
1234
+ * handles every shape the corpus has, from `common,throwA,throwB` to a three-pole
1235
+ * `input_common,…,output_common,…,led_common,…`.
1236
+ */
1237
+ function switchPoles(device) {
1238
+ // **Declared roles where the document states them, folded names otherwise.**
1239
+ //
1240
+ // 80 of the corpus's 198 switches declare `common`/`throw`/`coil`/`pin` on every terminal
1241
+ // (`vessel-dsp/artifacts`, 2026-09-03); the other 118 name what each contact *connects to* --
1242
+ // `effect`, `bypass`, `rectifier_output`, `bplus_reservoir` -- which says nothing about which
1243
+ // contact is the pole. Those keep the name path and then the positional one, and
1244
+ // `switchPoleUnreadable` names it rather than leaving it silent.
1245
+ //
1246
+ // A declared `pin` is not a contact of the mechanism, which is what fixed eight amps' mains
1247
+ // switches: a switch-and-fuse shell declares `line, switched_fused_line, neutral`, and with
1248
+ // `line` as the pole by position `neutral` became a *second throw of it* -- connecting live
1249
+ // to neutral at the other control position.
1250
+ const declared = device.identity.declaredTerminalRoles;
1251
+ const usesDeclared = declared.some((role) => role !== null);
1252
+ const roles = device.identity.terminalRoles;
1253
+ // **A `coil` is excluded by its declaration or not at all.** A seven-name table of coil
1254
+ // spellings used to sit on the second branch; emptying it moved no corpus program and
1255
+ // changed no diagnostic, and a relay whose coil matters declares `coil` -- which core has in
1256
+ // the switch vocabulary and the first branch already reads.
1257
+ const isContact = (index) => usesDeclared
1258
+ ? declared[index] === "common" || declared[index] === "throw"
1259
+ : true;
1260
+ const contactIndices = device.nodes
1261
+ .map((_, index) => index)
1262
+ .filter((index) => isContact(index));
1263
+ const contactNodes = contactIndices.map((i) => device.nodes[i]);
1264
+ const contactRoles = contactIndices.map((i) => roles[i]);
1265
+ const contactDeclared = contactIndices.map((i) => declared[i]);
1266
+ const commonIndices = contactNodes
1267
+ .map((_, index) => index)
1268
+ // **A pole is named by its declaration or by nothing.** `switchCommonRoles` -- twelve
1269
+ // spellings of "this is the pole" -- is gone: all 126 corpus switches whose terminal
1270
+ // names stated a pole now declare `common`, and no undeclared switch carries a name that
1271
+ // would have matched. A switch declaring nothing falls to its first contact exactly as
1272
+ // before, and `switchPoleUnreadable` reports that whenever there are three or more.
1273
+ .filter((index) => contactDeclared[index] === "common");
1274
+ // No terminal names itself common, so the first is it -- which is what the shapes
1275
+ // spelling it `input` or `lug14` mean, and all a document of bare lugs can support.
1276
+ if (commonIndices.length === 0) {
1277
+ const [common, ...throws] = contactNodes;
1278
+ return common === undefined || throws.length === 0
1279
+ ? []
1280
+ : [{ common, throws }];
1281
+ }
1282
+ // One common: every other terminal is a throw of it, **wherever it sits in the
1283
+ // list**. `throw0,throw1,common` is a real corpus shape, and attaching throws to a
1284
+ // preceding common instead would drop the only real one -- which silently left the
1285
+ // input jack's node connected to nothing at all.
1286
+ if (commonIndices.length === 1) {
1287
+ const at = commonIndices[0];
1288
+ const common = contactNodes[at];
1289
+ const throws = contactNodes.filter((_, index) => index !== at);
1290
+ return throws.length === 0 ? [] : [{ common, throws }];
1291
+ }
1292
+ // Several poles of one mechanism. Each common takes the terminals that follow it;
1293
+ // anything declared before the first common belongs to that first pole.
1294
+ const poles = commonIndices.map((at) => ({
1295
+ common: contactNodes[at],
1296
+ throws: [],
1297
+ }));
1298
+ contactNodes.forEach((node, index) => {
1299
+ if (commonIndices.includes(index)) {
1300
+ return;
1301
+ }
1302
+ const owner = commonIndices.filter((at) => at < index).length;
1303
+ poles[Math.max(0, owner - 1)]?.throws.push(node);
1304
+ });
1305
+ return poles.filter((pole) => pole.throws.length > 0);
1306
+ }
1307
+ /**
1308
+ * A diode's anode and cathode, by role where the document names them.
1309
+ *
1310
+ * Falls back to declaration order for `a,b` and bare lugs, which carry no orientation
1311
+ * evidence and are all such a document supports.
1312
+ */
1313
+ function diodeTerminals(device) {
1314
+ // The declared role, not the terminal name. A document whose ends are `end`/`end` -- core's
1315
+ // role for two interchangeable ends -- states no orientation, so it falls through to
1316
+ // declaration order exactly as an unnamed pair does.
1317
+ const roles = device.identity.declaredTerminalRoles;
1318
+ const anodeIndex = roles.indexOf("anode");
1319
+ const cathodeIndex = roles.indexOf("cathode");
1320
+ if (anodeIndex !== -1 && cathodeIndex !== -1) {
1321
+ const anode = device.nodes[anodeIndex];
1322
+ const cathode = device.nodes[cathodeIndex];
1323
+ if (anode !== undefined && cathode !== undefined) {
1324
+ return [anode, cathode];
1325
+ }
1326
+ }
1327
+ return terminalPair(device);
1328
+ }
1329
+ /**
1330
+ * The junctions a diode component contains: one for a plain diode, four for a bridge, one per
1331
+ * anode for a shared-cathode pack.
1332
+ *
1333
+ * **The old reading took the first two terminals and dropped the rest, without a word.** A bridge
1334
+ * rectifier declared `[ac_a, ac_b, positive, negative]` has no `anode` role, so it fell through to
1335
+ * declaration order and lowered to a single junction **between its two AC inputs** — a diode across
1336
+ * the source, with both DC terminals left touching nothing. That is not an approximation of a
1337
+ * bridge, it is a different circuit: the rail it was supposed to produce does not exist. Same shape
1338
+ * as the transformer's positional reading, which was silently wrong on 45 of 55.
1339
+ *
1340
+ * Two topologies are recognised, and both are decided by whole role tokens against
1341
+ * `diodeTerminalRoles`, never by position:
1342
+ *
1343
+ * - **A bridge**: two AC legs, one DC positive and one DC negative. Each AC leg gets a junction up
1344
+ * to `positive` and one from `negative`, which is the four-diode bridge exactly — on either half
1345
+ * cycle the more positive AC leg conducts into `positive` and `negative` conducts into the other.
1346
+ * - **A shared-cathode pack**: one or more anodes and exactly one cathode. One junction per anode.
1347
+ * A dual rectifier and a bi-colour LED are both this, which is why the rule is the shape rather
1348
+ * than the part.
1349
+ *
1350
+ * **What this fixes is topology, not device law.** Eight of the amp corpus's multi-junction
1351
+ * `diode` components are *tube* rectifiers — a 5U4GB, four GZ34s, two 5Y3s and a dual 5U4 — spelled
1352
+ * `plate_a`/`cathode`/`heater_a` under `kind: diode`. They now lower to the right number of
1353
+ * junctions in the right direction, and each junction keeps the **silicon** law the declared kind
1354
+ * asks for: a 0.7 V drop where a 5Y3 drops 40-60 V under load. Reading a vacuum law out of a
1355
+ * terminal name would be deciding a device's physics from a token, which is precisely what this
1356
+ * pipeline forbids — the kind is the evidence for the law, and the roles are evidence only for the
1357
+ * wiring. That mismatch is a **source** question (the format has a `tube-diode` kind and three
1358
+ * instances use it) and it stays unfiled: no document compiles far enough for a diagnostic to name
1359
+ * it, and the evidence gate requires one.
1360
+ */
1361
+ function diodeJunctions(device) {
1362
+ // Two terminals keep the original reading exactly, including its positional fallback for a
1363
+ // document of bare lugs. Every diode in every compiled packet takes this path, which is what
1364
+ // makes the change corpus-neutral by construction rather than by hope.
1365
+ if (device.nodes.length === 2) {
1366
+ const [anode, cathode] = diodeTerminals(device);
1367
+ return [{ anode, cathode }];
1368
+ }
1369
+ const anodes = [];
1370
+ const acLegs = [];
1371
+ let cathode = null;
1372
+ let dcPositive = null;
1373
+ let dcNegative = null;
1374
+ const single = (held, node, electrode) => {
1375
+ if (held !== null) {
1376
+ throw new LoweringError(`diode ${device.id} names two ${electrode} terminals, so it is more than one device in one component`, device.id);
1377
+ }
1378
+ return node;
1379
+ };
1380
+ // Reads each terminal's declared `role`, which replaced a ~30-entry spelling table. `ac` is
1381
+ // the role a bridge's alternating leg declares (`@vessel-dsp/core@0.6.33`): it is neither an
1382
+ // anode nor a cathode, because on one half cycle it conducts into the positive rail and on
1383
+ // the other the negative rail conducts into it.
1384
+ device.identity.declaredTerminalRoles.forEach((role, index) => {
1385
+ const node = device.nodes[index];
1386
+ if (node === undefined) {
1387
+ return;
1388
+ }
1389
+ if (role === null) {
1390
+ throw new LoweringError(`diode ${device.id} has a terminal that declares no role, and a junction's direction cannot be read from terminal order once there are more than two`, device.id);
1391
+ }
1392
+ switch (role) {
1393
+ // A plate is an anode: eight corpus tube rectifiers declare `kind: diode`, and this
1394
+ // stage places their junctions while the declared kind still chooses the law.
1395
+ case "anode":
1396
+ case "plate":
1397
+ anodes.push(node);
1398
+ return;
1399
+ case "ac":
1400
+ acLegs.push(node);
1401
+ return;
1402
+ case "cathode":
1403
+ cathode = single(cathode, node, "cathode");
1404
+ return;
1405
+ case "positive":
1406
+ dcPositive = single(dcPositive, node, "DC positive");
1407
+ return;
1408
+ case "negative":
1409
+ dcNegative = single(dcNegative, node, "DC negative");
1410
+ return;
1411
+ // A heater carries no signal current, and an `end` states no orientation to place.
1412
+ case "heater":
1413
+ case "end":
1414
+ return;
1415
+ default:
1416
+ throw new LoweringError(`diode ${device.id}: terminal declares role "${role}", which is not an electrode a multi-junction diode has`, device.id);
1417
+ }
1418
+ });
1419
+ const positive = dcPositive;
1420
+ const negative = dcNegative;
1421
+ const shared = cathode;
1422
+ if (acLegs.length === 2 && positive !== null && negative !== null) {
1423
+ if (anodes.length > 0 || shared !== null) {
1424
+ throw new LoweringError(`diode ${device.id} names both a bridge's AC and DC legs and a separate anode or cathode, which is two topologies at once`, device.id);
1425
+ }
1426
+ return acLegs.flatMap((leg) => [
1427
+ { anode: leg, cathode: positive },
1428
+ { anode: negative, cathode: leg },
1429
+ ]);
1430
+ }
1431
+ if (acLegs.length === 0 && shared !== null && anodes.length > 0) {
1432
+ return anodes.map((anode) => ({ anode, cathode: shared }));
1433
+ }
1434
+ throw new LoweringError(`diode ${device.id} declares ${device.nodes.length} terminals in no topology this stage can place: ` +
1435
+ "a bridge needs two AC legs with a DC positive and negative, and a diode pack needs one cathode with at least one anode", device.id);
1436
+ }
1437
+ /**
1438
+ * The two ends of a supply, as the corpus names them. Counted, not guessed: `positive`
1439
+ * 47, `negative` 46, `neutral` 17, `hot` 16, `anode`/`cathode` 4 each, `+` 3, `line` 2,
1440
+ * `return` 1. `hot`/`neutral` and `line`/`neutral` are mains pairs in the amp packets.
1441
+ * `plus`/`minus` are absent from the corpus but are unambiguous polarity names a
1442
+ * hand-authored document may use, and they are what the fixtures here declare.
1443
+ */
1444
+ /**
1445
+ * A supply's driven end and its return, by role where the document names them.
1446
+ *
1447
+ * This was the last asymmetric device still read by declaration order, and it fails the
1448
+ * same way the diode and the op-amp did: reversing `positive` and `minus` inverts a rail
1449
+ * and the packet still compiles `ok`. A rail is not a subtle wrong answer -- every bias
1450
+ * point in the circuit hangs off its sign.
1451
+ *
1452
+ * **Read from the declared role since 2026-09-03**, and the fallback is now taken only when the
1453
+ * source *says* the two ends are symmetric rather than when it says nothing. `ac_a`/`ac_b` and
1454
+ * `heater_a`/`heater_b` are the two ends of a winding modelled as a source: they declare
1455
+ * `end`/`end`, which states that there is no polarity to read, and declaration order is then all
1456
+ * such a document supports. That is a different fact from a supply whose terminals declare
1457
+ * nothing, and matching folded *names* could not tell the two apart -- `'-'` folded away to no
1458
+ * role at all and landed in the same bucket as a bare lug.
1459
+ *
1460
+ * Measured across the corpus after the backfill: 72 supplies declare exactly one `positive` and
1461
+ * one `negative`, 2 declare `end`/`end`, 1 has a single terminal, and none declares nothing.
1462
+ */
1463
+ function supplyTerminals(device) {
1464
+ // A rail names one node and means "against ground", which is not a polarity question:
1465
+ // there is only one end to place. Reading roles here would refuse 34 packets for
1466
+ // wanting a terminal a rail never has.
1467
+ const single = device.nodes[0];
1468
+ if (device.nodes.length === 1 && single !== undefined) {
1469
+ return [single, GROUND];
1470
+ }
1471
+ const roles = device.identity.declaredTerminalRoles;
1472
+ const driveIndex = roles.findIndex((role) => role === "positive");
1473
+ const returnIndex = roles.findIndex((role) => role === "negative");
1474
+ // One end named is enough: the other is the remaining terminal. Requiring both would
1475
+ // discard the evidence in a supply declaring `positive` beside an unlabelled lug.
1476
+ const resolved = driveIndex !== -1 && driveIndex !== returnIndex
1477
+ ? [
1478
+ driveIndex,
1479
+ returnIndex === -1 ? 1 - driveIndex : returnIndex,
1480
+ ]
1481
+ : returnIndex !== -1
1482
+ ? [1 - returnIndex, returnIndex]
1483
+ : null;
1484
+ if (resolved !== null) {
1485
+ const positive = device.nodes[resolved[0]];
1486
+ const negative = device.nodes[resolved[1]];
1487
+ if (positive !== undefined && negative !== undefined) {
1488
+ return [positive, negative];
1489
+ }
1490
+ }
1491
+ return terminalPair(device);
1492
+ }
1493
+ /**
1494
+ * Slots a reactive element's companion model needs: the voltage across it and the current
1495
+ * through it, which is what a trapezoidal companion carries between samples.
1496
+ *
1497
+ * The program declares state in slots and each stamp declares the offset its own state
1498
+ * begins at, so both are the same unit and no executor has to know a hidden convention.
1499
+ */
1500
+ export const STATE_SLOTS_PER_REACTIVE_ELEMENT = 2;
1501
+ /**
1502
+ * The one terminal index whose **declared** role is `role`, or null when none or several are.
1503
+ *
1504
+ * This replaced seven name-vocabulary sets: `triodeGridRoles`, `triodeCathodeRoles`,
1505
+ * `triodePlateRoles`, `pentodeScreenRoles`, and the transistor control/upper/lower channel
1506
+ * triple. All seven existed to recover an electrode from a terminal's *name*, which is a
1507
+ * spelling problem the format no longer has -- `@vessel-dsp/core@0.6.28` gives every terminal a
1508
+ * typed `role`, so the electrode is read rather than inferred. There is nothing to fold, alias,
1509
+ * or guess, and `c` no longer has to mean a collector on one kind and a cathode on another.
1510
+ *
1511
+ * **Ambiguity is still null rather than the first match**, for the reason `uniqueRoleIndex`
1512
+ * records: two terminals declaring `plate` is correct on a dual rectifier and wrong on a triode,
1513
+ * and which it is belongs to the caller. A resolver asking for one plate gets null;
1514
+ * `tubeDiodePlates` asks for all of them.
1515
+ */
1516
+ function declaredIndex(device, role) {
1517
+ const found = device.identity.declaredTerminalRoles.flatMap((declared, index) => declared === role ? [index] : []);
1518
+ return found.length === 1 ? (found[0] ?? null) : null;
1519
+ }
1520
+ /**
1521
+ * Indices for `roles` in order, or null when any one is unresolved.
1522
+ *
1523
+ * All-or-nothing for the reason `roleIndices` gives: every caller treats a partial read as no
1524
+ * read, because a tube with a plate but no identifiable cathode cannot be stamped from the half
1525
+ * that resolved.
1526
+ */
1527
+ function declaredIndices(device, roles) {
1528
+ const indices = roles.map((role) => declaredIndex(device, role));
1529
+ return indices.every((index) => index !== null)
1530
+ ? indices
1531
+ : null;
1532
+ }
1533
+ /**
1534
+ * The one terminal index whose declared role is in `accepted`, or `null` when none is or
1535
+ * more than one is.
1536
+ *
1537
+ * **Ambiguity is `null` rather than the first match.** Two terminals both claiming `plate`
1538
+ * is a source defect, and choosing between them would wire the device from a coin toss —
1539
+ * so the callers that can refuse do, and the callers that fall back to declaration order
1540
+ * fall back wholesale rather than half-resolved.
1541
+ *
1542
+ * `fold` exists because the optocoupler's copy of this read carried `foldToken` where the
1543
+ * other four compared the role as given. That divergence is the reason this is one function:
1544
+ * the five copies were not identical, and nothing would have reported it.
1545
+ */
1546
+ function uniqueRoleIndex(roles, accepted, fold = (role) => role) {
1547
+ const found = roles.flatMap((role, index) => role !== null && accepted.has(fold(role)) ? [index] : []);
1548
+ return found.length === 1 ? (found[0] ?? null) : null;
1549
+ }
1550
+ /**
1551
+ * Terminal indices for `slots` in order, or `null` when any one of them is unresolved.
1552
+ *
1553
+ * All-or-nothing because every caller treats a partial read as no read: a tube with a plate
1554
+ * but no identifiable cathode cannot be stamped from the half that resolved.
1555
+ *
1556
+ * Returns **indices, not nodes**, so each caller keeps its own missing-node refusal. Those
1557
+ * are not interchangeable — a pentode that named an electrode it has no terminal for and a
1558
+ * BJT missing a third node are different source defects and say so.
1559
+ */
1560
+ function roleIndices(roles, slots, fold) {
1561
+ const indices = slots.map((slot) => uniqueRoleIndex(roles, slot, fold));
1562
+ return indices.every((index) => index !== null)
1563
+ ? indices
1564
+ : null;
1565
+ }
1566
+ /**
1567
+ * A pentode's `(grid, cathode, plate, screen)`, by role, refusing rather than guessing.
1568
+ *
1569
+ * A `suppressor` and a `heater` terminal are read and ignored on purpose: a suppressor is tied
1570
+ * to the cathode in practice, and a heater carries no signal current. Their nodes end up
1571
+ * carrying only `gmin`, which is the same situation a FET gate is already in.
1572
+ *
1573
+ * The two ganged documents in the corpus (`grid_a,grid_b,...` and `plates_a,plates_b,...`) name
1574
+ * **two** pentodes in one component, so `only` finds two grids and refuses. That is the right
1575
+ * answer: this law is one tube, and silently taking the first of each would model half the
1576
+ * device and render.
1577
+ */
1578
+ function pentodeTerminals(device) {
1579
+ const indices = declaredIndices(device, ["grid", "cathode", "plate", "screen"]);
1580
+ if (indices === null) {
1581
+ throw new LoweringError(`pentode ${device.id} does not name a single grid, cathode, plate and screen among its terminals`, device.id);
1582
+ }
1583
+ const nodes = indices.map((index) => device.nodes[index]);
1584
+ if (nodes.some((node) => node === undefined)) {
1585
+ throw new LoweringError(`pentode ${device.id} names an electrode it has no terminal for`, device.id);
1586
+ }
1587
+ return nodes;
1588
+ }
1589
+ /**
1590
+ * A triode's `(grid, cathode, plate)`, by role.
1591
+ *
1592
+ * Deliberately **not** falling back to declaration order the way the BJT does. A BJT's
1593
+ * positional fallback is defensible because its three terminals appear in a conventional
1594
+ * order across the corpus; a tube's do not, and swapping plate for cathode turns a gain
1595
+ * stage into a cathode follower that still renders. A triode whose roles cannot be read is
1596
+ * a missing source fact, so this refuses rather than guessing.
1597
+ */
1598
+ function triodeTerminals(device) {
1599
+ const indices = declaredIndices(device, ["grid", "cathode", "plate"]);
1600
+ if (indices === null) {
1601
+ throw new LoweringError(`triode ${device.id} does not name a single grid, cathode and plate among its terminals`, device.id);
1602
+ }
1603
+ const nodes = indices.map((index) => device.nodes[index]);
1604
+ if (nodes.some((node) => node === undefined)) {
1605
+ throw new LoweringError(`triode ${device.id} needs three terminals`, device.id);
1606
+ }
1607
+ return nodes;
1608
+ }
1609
+ /**
1610
+ * A tube diode's plates, each paired with the shared cathode.
1611
+ *
1612
+ * Refuses rather than falling back to declaration order, for the triode's reason: a rectifier
1613
+ * wired backwards conducts on the wrong half-cycle and still renders, and the corpus does not
1614
+ * declare these terminals in a settled order.
1615
+ */
1616
+ function tubeDiodePlates(device) {
1617
+ const plates = [];
1618
+ let cathode = null;
1619
+ // Reads the declared role, so `plate_a` and `plate_b` are two terminals carrying one role
1620
+ // rather than two spellings to look up. This is the case the `devices` construct exists for:
1621
+ // a dual rectifier is two diodes on one cathode, and until a document declares that split,
1622
+ // pairing every plate with the shared cathode is the same answer.
1623
+ device.identity.declaredTerminalRoles.forEach((role, index) => {
1624
+ const node = device.nodes[index];
1625
+ if (node === undefined) {
1626
+ return;
1627
+ }
1628
+ if (role === null) {
1629
+ throw new LoweringError(`tube diode ${device.id} has a terminal that declares no role, and a rectifier's plate cannot be told from its cathode by position`, device.id);
1630
+ }
1631
+ if (role === "plate") {
1632
+ plates.push(node);
1633
+ return;
1634
+ }
1635
+ if (role === "cathode") {
1636
+ if (cathode !== null) {
1637
+ throw new LoweringError(`tube diode ${device.id} names two cathodes, so it is two devices in one component`, device.id);
1638
+ }
1639
+ cathode = node;
1640
+ return;
1641
+ }
1642
+ // A heater carries no signal current, so it is read and ignored, as it always was. Any
1643
+ // other role is a device this law is not: refuse by name rather than place it.
1644
+ if (role !== "heater") {
1645
+ throw new LoweringError(`tube diode ${device.id}: terminal declares role "${role}", which is not an electrode a tube rectifier has`, device.id);
1646
+ }
1647
+ });
1648
+ if (cathode === null || plates.length === 0) {
1649
+ throw new LoweringError(`tube diode ${device.id} does not name a cathode and at least one plate among its terminals`, device.id);
1650
+ }
1651
+ const shared = cathode;
1652
+ return plates.map((plate) => ({ plate, cathode: shared }));
1653
+ }
1654
+ function bjtTerminals(device) {
1655
+ // Read the roles, because **449 of the corpus's 490 transistors do not put the
1656
+ // control terminal first**. `collector,base,emitter` alone is 282 of them, and
1657
+ // taking position 1 as the base exchanges base and collector on nearly every
1658
+ // transistor there is: the stage never turns on and the pedal renders silence.
1659
+ const indices = declaredIndices(device, ["base", "collector", "emitter"]) ??
1660
+ declaredIndices(device, ["gate", "drain", "source"]);
1661
+ // Unlabelled or ambiguous roles keep declaration order, which is all a document of
1662
+ // bare positional pins supports.
1663
+ const [control, upper, lower] = indices === null
1664
+ ? device.nodes
1665
+ : indices.map((index) => device.nodes[index]);
1666
+ if (control === undefined || upper === undefined || lower === undefined) {
1667
+ throw new LoweringError(`bjt ${device.id} needs base, collector and emitter`, device.id);
1668
+ }
1669
+ return [control, upper, lower];
1670
+ }
1671
+ /**
1672
+ * An op-amp's `(+in, -in, out)` from its **declared** roles, refusing rather than guessing.
1673
+ *
1674
+ * Order settles nothing: the corpus writes `inverting,nonInverting,output`,
1675
+ * `positive,out,negative` and `negative,positive,out`, so taking position exchanges the two
1676
+ * inputs and inverts the stage -- a wrong answer that still renders and is audible only by
1677
+ * comparison.
1678
+ *
1679
+ * This read the folded terminal *name* against three closed sets and fell back to declaration
1680
+ * order when they did not resolve, which is the silent half of the role-vocabulary problem: a
1681
+ * document naming its pins anything outside those sets got positions 0, 1 and 2 and nothing said
1682
+ * so. Measured after the roles were backfilled: 314 of 315 corpus op-amps declare exactly one of
1683
+ * each, and none declares no role, so the positional path had no legitimate case left.
1684
+ *
1685
+ * **Several of each is a package, not an ambiguity.** `boss-dm-3`'s `IC1` declares eight terminals
1686
+ * and two of every signal role, because it is a dual op-amp whose sections
1687
+ * `@vessel-dsp/core@0.6.33`'s `devices` construct exists to separate. Until that is declared this
1688
+ * refuses too, naming the construct, rather than picking a section by position.
1689
+ */
1690
+ function opampTerminals(device) {
1691
+ const declared = device.identity.declaredTerminalRoles;
1692
+ const indices = ["nonInverting", "inverting", "output"].map((role) => {
1693
+ const found = declared.flatMap((value, index) => value === role ? [index] : []);
1694
+ return found.length === 1 ? found[0] : null;
1695
+ });
1696
+ if (indices.some((index) => index === null)) {
1697
+ const counts = ["nonInverting", "inverting", "output"]
1698
+ .map((role) => `${role}=${declared.filter((value) => value === role).length}`)
1699
+ .join(" ");
1700
+ throw new LoweringError(`op-amp ${device.id} does not identify one non-inverting input, one inverting input ` +
1701
+ `and one output (${counts}) -- several of each is a package whose sections a ` +
1702
+ "`devices` declaration has to separate, and taking terminals by position instead " +
1703
+ "exchanges the inputs and inverts the stage", device.id);
1704
+ }
1705
+ const [plus, minus, output] = indices.map((index) => device.nodes[index]);
1706
+ if (plus === undefined || minus === undefined || output === undefined) {
1707
+ throw new LoweringError(`op-amp ${device.id} needs three terminals`, device.id);
1708
+ }
1709
+ return [plus, minus, output];
1710
+ }
1711
+ /**
1712
+ * An optocoupler's four connections from the terminals' **declared roles**.
1713
+ *
1714
+ * Core's vocabulary for the kind is `anode`, `cathode`, `end`, `pin`: the LED states a direction
1715
+ * and the cell states none, which is the physics -- a photoresistor conducts the same either way,
1716
+ * so its two `end` terminals are taken in declaration order and that is a reading rather than a
1717
+ * guess. A three-terminal part is the common tremolo package, whose LED cathode is the chassis:
1718
+ * one `anode` and two `end`s, with the return implied.
1719
+ */
1720
+ function declaredOptocouplerNodes(device) {
1721
+ const declared = device.identity.declaredTerminalRoles;
1722
+ const at = (role) => {
1723
+ const found = declared.reduce((acc, value, index) => (value === role ? [...acc, index] : acc), []);
1724
+ return found.length === 1 ? (found[0] ?? null) : null;
1725
+ };
1726
+ const ends = declared.flatMap((role, index) => (role === "end" ? [index] : []));
1727
+ const anodeIndex = at("anode");
1728
+ if (anodeIndex === null || ends.length !== 2) {
1729
+ return null;
1730
+ }
1731
+ const cathodeIndex = at("cathode");
1732
+ const ledAnode = device.nodes[anodeIndex];
1733
+ const ldrA = device.nodes[ends[0] ?? -1];
1734
+ const ldrB = device.nodes[ends[1] ?? -1];
1735
+ const ledCathode = cathodeIndex === null ? GROUND : device.nodes[cathodeIndex];
1736
+ if (ledAnode === undefined ||
1737
+ ledCathode === undefined ||
1738
+ ldrA === undefined ||
1739
+ ldrB === undefined) {
1740
+ return null;
1741
+ }
1742
+ return { ledAnode, ledCathode, ldrA, ldrB };
1743
+ }
1744
+ /**
1745
+ * The same four connections from the terminals' **names**, which is the older path and one of the
1746
+ * last two name vocabularies in this compiler. It stays until the four corpus optocouplers that
1747
+ * depend on it declare roles instead; `optocouplerBinding` is what reports them.
1748
+ */
1749
+ function namedOptocouplerNodes(device) {
1750
+ const roles = device.identity.terminalRoles;
1751
+ // Folded, unlike the four resolvers above, which compare the role as given.
1752
+ const only = (accepted) => uniqueRoleIndex(roles, accepted, foldToken);
1753
+ const lampDriveIndex = only(new Set(["lampdrive"]));
1754
+ const ldrSignalIndex = only(new Set(["ldrsignal", "ldra"]));
1755
+ const ldrReturnIndex = only(new Set(["ldrreturn", "ldrb"]));
1756
+ if (lampDriveIndex !== null &&
1757
+ ldrSignalIndex !== null &&
1758
+ ldrReturnIndex !== null &&
1759
+ device.nodes.length === 3) {
1760
+ const ledAnode = device.nodes[lampDriveIndex];
1761
+ const ldrA = device.nodes[ldrSignalIndex];
1762
+ const ldrB = device.nodes[ldrReturnIndex];
1763
+ if (ledAnode !== undefined && ldrA !== undefined && ldrB !== undefined) {
1764
+ return { ledAnode, ledCathode: GROUND, ldrA, ldrB };
1765
+ }
1766
+ }
1767
+ const anodeIndex = only(new Set(["anode", "ledanode", "lampdrive"]));
1768
+ const cathodeIndex = only(new Set(["cathode", "ledcathode", "lampreturn"]));
1769
+ const ldrAIndex = only(new Set(["ldra", "ldrsignal"]));
1770
+ const ldrBIndex = only(new Set(["ldrb", "ldrreturn"]));
1771
+ if (anodeIndex !== null &&
1772
+ cathodeIndex !== null &&
1773
+ ldrAIndex !== null &&
1774
+ ldrBIndex !== null) {
1775
+ const ledAnode = device.nodes[anodeIndex];
1776
+ const ledCathode = device.nodes[cathodeIndex];
1777
+ const ldrA = device.nodes[ldrAIndex];
1778
+ const ldrB = device.nodes[ldrBIndex];
1779
+ if (ledAnode !== undefined &&
1780
+ ledCathode !== undefined &&
1781
+ ldrA !== undefined &&
1782
+ ldrB !== undefined) {
1783
+ return { ledAnode, ledCathode, ldrA, ldrB };
1784
+ }
1785
+ }
1786
+ return null;
1787
+ }
1788
+ /**
1789
+ * Which rung decided an optocoupler's wiring, so a warning can name the devices that reached the
1790
+ * weaker ones without restating either test.
1791
+ *
1792
+ * The alternative -- a predicate in the warning module that mirrors this vocabulary -- is how a
1793
+ * silent switch mis-wiring was introduced in this compiler once already: the mirror drifted from
1794
+ * the resolver and the warning went quiet about the case it existed for.
1795
+ */
1796
+ export function optocouplerBinding(device) {
1797
+ if (declaredOptocouplerNodes(device) !== null) {
1798
+ return "declared";
1799
+ }
1800
+ return namedOptocouplerNodes(device) !== null ? "named" : "declaration-order";
1801
+ }
1802
+ /**
1803
+ * A logic gate's four connections, by declared role where the document states them.
1804
+ *
1805
+ * **The order used to be the whole binding.** `nodes[0..3]` was input, output, vdd, gnd, which is
1806
+ * right for a registry `sections` entry written against that order and is nothing at all for a
1807
+ * package that declares its own gates: a `devices:` list states which terminals a gate uses, not
1808
+ * which slot each fills, and its docstring says so -- "order carries no meaning; a law binds by
1809
+ * role". `@vessel-dsp/core@0.6.39` gives a gate `input` and `output` to bind by.
1810
+ *
1811
+ * The positional fallback stays for every document that predates the kind, and `vdd`/`gnd` stay
1812
+ * optional because the single-ended `vccs` branch below models a gate whose supplies the document
1813
+ * does not give.
1814
+ */
1815
+ function inverterTerminals(device) {
1816
+ const declared = device.identity.declaredTerminalRoles;
1817
+ const only = (role) => {
1818
+ const found = declared.flatMap((value, index) => value === role ? [index] : []);
1819
+ return found.length === 1 ? device.nodes[found[0]] : undefined;
1820
+ };
1821
+ const input = only("input");
1822
+ const output = only("output");
1823
+ if (input !== undefined && output !== undefined) {
1824
+ return {
1825
+ input,
1826
+ output,
1827
+ vdd: only("supplyPositive"),
1828
+ gnd: only("supplyNegative"),
1829
+ };
1830
+ }
1831
+ return {
1832
+ input: device.nodes[0],
1833
+ output: device.nodes[1],
1834
+ vdd: device.nodes.length >= 4 ? device.nodes[2] : undefined,
1835
+ gnd: device.nodes.length >= 4 ? device.nodes[3] : undefined,
1836
+ };
1837
+ }
1838
+ function optocouplerTerminals(device) {
1839
+ const resolved = declaredOptocouplerNodes(device) ?? namedOptocouplerNodes(device);
1840
+ if (resolved !== null) {
1841
+ return resolved;
1842
+ }
1843
+ const ledAnode = device.nodes[0];
1844
+ const ledCathode = device.nodes[1];
1845
+ const ldrA = device.nodes[2];
1846
+ const ldrB = device.nodes[3];
1847
+ if (ledAnode === undefined ||
1848
+ ledCathode === undefined ||
1849
+ ldrA === undefined ||
1850
+ ldrB === undefined) {
1851
+ throw new LoweringError(`optocoupler ${device.id} requires 4 terminals: anode, cathode, ldrA, ldrB`, device.id);
1852
+ }
1853
+ return { ledAnode, ledCathode, ldrA, ldrB };
1854
+ }
1855
+ //# sourceMappingURL=lower.js.map