@vessel-dsp/compiler 0.0.0-stage → 0.2.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 +314 -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 +344 -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 +3497 -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 +1721 -0
  63. package/dist/fixtures/circuits.d.ts.map +1 -0
  64. package/dist/fixtures/circuits.js +13404 -0
  65. package/dist/fixtures/circuits.js.map +1 -0
  66. package/dist/fixtures/expected.d.ts +257 -0
  67. package/dist/fixtures/expected.d.ts.map +1 -0
  68. package/dist/fixtures/expected.js +311 -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 +7 -0
  127. package/dist/part-catalog.d.ts.map +1 -0
  128. package/dist/part-catalog.js +6639 -0
  129. package/dist/part-catalog.js.map +1 -0
  130. package/dist/part-number.d.ts +32 -0
  131. package/dist/part-number.d.ts.map +1 -0
  132. package/dist/part-number.js +80 -0
  133. package/dist/part-number.js.map +1 -0
  134. package/dist/partition.d.ts +7 -0
  135. package/dist/partition.d.ts.map +1 -0
  136. package/dist/partition.js +242 -0
  137. package/dist/partition.js.map +1 -0
  138. package/dist/port-full-scale.d.ts +70 -0
  139. package/dist/port-full-scale.d.ts.map +1 -0
  140. package/dist/port-full-scale.js +235 -0
  141. package/dist/port-full-scale.js.map +1 -0
  142. package/dist/pot-orientation.d.ts +114 -0
  143. package/dist/pot-orientation.d.ts.map +1 -0
  144. package/dist/pot-orientation.js +551 -0
  145. package/dist/pot-orientation.js.map +1 -0
  146. package/dist/power-domain-control.d.ts +40 -0
  147. package/dist/power-domain-control.d.ts.map +1 -0
  148. package/dist/power-domain-control.js +142 -0
  149. package/dist/power-domain-control.js.map +1 -0
  150. package/dist/proxy-declaration.d.ts +121 -0
  151. package/dist/proxy-declaration.d.ts.map +1 -0
  152. package/dist/proxy-declaration.js +50 -0
  153. package/dist/proxy-declaration.js.map +1 -0
  154. package/dist/registry.d.ts +323 -0
  155. package/dist/registry.d.ts.map +1 -0
  156. package/dist/registry.js +192 -0
  157. package/dist/registry.js.map +1 -0
  158. package/dist/sparse-schedule.d.ts +65 -0
  159. package/dist/sparse-schedule.d.ts.map +1 -0
  160. package/dist/sparse-schedule.js +421 -0
  161. package/dist/sparse-schedule.js.map +1 -0
  162. package/dist/speaker-load.d.ts +38 -0
  163. package/dist/speaker-load.d.ts.map +1 -0
  164. package/dist/speaker-load.js +97 -0
  165. package/dist/speaker-load.js.map +1 -0
  166. package/dist/stage-coverage.d.ts +21 -0
  167. package/dist/stage-coverage.d.ts.map +1 -0
  168. package/dist/stage-coverage.js +124 -0
  169. package/dist/stage-coverage.js.map +1 -0
  170. package/dist/stamp-partition.d.ts +314 -0
  171. package/dist/stamp-partition.d.ts.map +1 -0
  172. package/dist/stamp-partition.js +472 -0
  173. package/dist/stamp-partition.js.map +1 -0
  174. package/dist/supply-reference.d.ts +19 -0
  175. package/dist/supply-reference.d.ts.map +1 -0
  176. package/dist/supply-reference.js +66 -0
  177. package/dist/supply-reference.js.map +1 -0
  178. package/dist/supply-short.d.ts +32 -0
  179. package/dist/supply-short.d.ts.map +1 -0
  180. package/dist/supply-short.js +118 -0
  181. package/dist/supply-short.js.map +1 -0
  182. package/dist/supply-stamps.d.ts +51 -0
  183. package/dist/supply-stamps.d.ts.map +1 -0
  184. package/dist/supply-stamps.js +363 -0
  185. package/dist/supply-stamps.js.map +1 -0
  186. package/dist/terminal-role.d.ts +109 -0
  187. package/dist/terminal-role.d.ts.map +1 -0
  188. package/dist/terminal-role.js +46 -0
  189. package/dist/terminal-role.js.map +1 -0
  190. package/dist/terminal-roles.d.ts +59 -0
  191. package/dist/terminal-roles.d.ts.map +1 -0
  192. package/dist/terminal-roles.js +92 -0
  193. package/dist/terminal-roles.js.map +1 -0
  194. package/dist/transformer.d.ts +181 -0
  195. package/dist/transformer.d.ts.map +1 -0
  196. package/dist/transformer.js +476 -0
  197. package/dist/transformer.js.map +1 -0
  198. package/dist/types.d.ts +3157 -0
  199. package/dist/types.d.ts.map +1 -0
  200. package/dist/types.js +11 -0
  201. package/dist/types.js.map +1 -0
  202. package/dist/unbounded-operating-point.d.ts +35 -0
  203. package/dist/unbounded-operating-point.d.ts.map +1 -0
  204. package/dist/unbounded-operating-point.js +181 -0
  205. package/dist/unbounded-operating-point.js.map +1 -0
  206. package/dist/unexecuted-region.d.ts +3 -0
  207. package/dist/unexecuted-region.d.ts.map +1 -0
  208. package/dist/unexecuted-region.js +122 -0
  209. package/dist/unexecuted-region.js.map +1 -0
  210. package/dist/unimplemented-control-role.d.ts +3 -0
  211. package/dist/unimplemented-control-role.d.ts.map +1 -0
  212. package/dist/unimplemented-control-role.js +61 -0
  213. package/dist/unimplemented-control-role.js.map +1 -0
  214. package/dist/unreachable-output.d.ts +3 -0
  215. package/dist/unreachable-output.d.ts.map +1 -0
  216. package/dist/unreachable-output.js +97 -0
  217. package/dist/unreachable-output.js.map +1 -0
  218. package/dist/unreadable-terminal-role.d.ts +67 -0
  219. package/dist/unreadable-terminal-role.d.ts.map +1 -0
  220. package/dist/unreadable-terminal-role.js +409 -0
  221. package/dist/unreadable-terminal-role.js.map +1 -0
  222. package/dist/unverified-pinout.d.ts +27 -0
  223. package/dist/unverified-pinout.d.ts.map +1 -0
  224. package/dist/unverified-pinout.js +121 -0
  225. package/dist/unverified-pinout.js.map +1 -0
  226. package/package.json +75 -5
@@ -0,0 +1,3497 @@
1
+ // Stage 3: attach each device's constitutive relation.
2
+ //
3
+ // Three outcomes, and the third is a product decision rather than a technical one:
4
+ //
5
+ // Law lumped, stamped into the matrix and solved
6
+ // MacroModel not solvable as lumped elements; a DSP algorithm with ports
7
+ // Unsupported no model available -> the whole pedal/amp does not compile
8
+ //
9
+ // Decision (Joseph, 2026-08-10): an unknown chip makes the pedal unsupported. Not an
10
+ // open circuit, not a pass-through. So there is no partially-modelled circuit and no
11
+ // fabricated port assignment anywhere in the design -- and consequently a reason
12
+ // string is a required output here, not a nicety.
13
+ //
14
+ // A pot's law is R_total split by f(x), where x is the 0..1 control position. This file
15
+ // decides *which* taper f is; it deliberately cannot evaluate one. Per decision 2, f is
16
+ // carried into the program and applied by whoever runs it, so the curve itself lives in
17
+ // `src/runtime/taper.ts` and no stage here can fold it into a coefficient by accident.
18
+ import { checkDeclaredProgram } from "./declared-program.js";
19
+ import { bjtPartRegisteredAsSections, identify, mayCarryRegistrySections, opampPartRegisteredAsOtherLaw, requiresIdentification, } from "./identify.js";
20
+ import { isMultiDeviceTransistorShell } from "./unreadable-terminal-role.js";
21
+ import { deriveBbdDelayFromNetlist, resolveClockModulationSource, deriveOpenOx2ClockLaw, derivePt2399DelayFromNetlist, deriveM50195DelayFromNetlist, OPEN_OX2_VDD_VOLTS, OPEN_OX2_VTH_VOLTS, OPEN_OX2_VF_VOLTS, OPEN_OX2_FLOOR_VOLTS, } from "./bbd-clock.js";
22
+ import { foldPartId, foldToken, pinoutMatches, registryEntryFor, registryLawFor, registryModelFor, registryOpampSwingDrop, terminalWithRole, withRecordedReads, } from "./registry.js";
23
+ /** Room-temperature thermal voltage; the diode law needs a reference somewhere. */
24
+ export const THERMAL_VOLTAGE = 0.025_852;
25
+ /**
26
+ * Closed and open switch resistances. Not arbitrary: the ratio sets the matrix
27
+ * condition number, so 1e-2 to 1e9 is chosen to be electrically indistinguishable
28
+ * from ideal at audio levels while staying far from the solver's precision limit.
29
+ */
30
+ export const SWITCH_ON_OHMS = 1e-2;
31
+ /**
32
+ * The terminal a jack's switch contact makes against, and the terminal a supply calls
33
+ * its return. Closed sets, compared whole.
34
+ *
35
+ * There is deliberately **no set of contact role names**. The corpus names switch
36
+ * contacts eleven ways (`batterySwitch`, `switch`, `switched`, `contact_top`,
37
+ * `normalSwitch`, `switch_black`, ...) and they do not share a sense: a battery contact
38
+ * makes against the sleeve, a `switched_tip` makes against the tip, and a `normalSwitch`
39
+ * on a stereo output *breaks* when a plug goes in. A name table gets the sense wrong on
40
+ * two of those three, and closing a signal contact to the sleeve shorts it to ground.
41
+ * See `portEngage` for what is derived instead.
42
+ */
43
+ /**
44
+ * Series impedance of a pedal supply, in ohms: the **default position of what belongs to a
45
+ * user-selectable power profile**, not a physical claim about one cell.
46
+ *
47
+ * Decided by Joseph, 2026-08-13: source impedance is an *input profile on the UI*, alongside the
48
+ * other things a player chooses about how a pedal is fed. A fresh alkaline, a sagging one and a
49
+ * regulated adapter are three profiles a user picks between, so this stage does not have to guess
50
+ * which the packet meant — a question that has no answer, since a schematic draws a 9 V battery
51
+ * symbol and internal resistance is a property of the cell rather than of the drawing.
52
+ *
53
+ * Until that control exists, this is the default, and it is the low end of the plausible range so
54
+ * the default errs toward the ideal behaviour it replaces: a fresh 9 V alkaline measures 1-2 ohms,
55
+ * a depleted one tens, an adapter well under one. Measured, one ohm moves a working pedal's rail by
56
+ * about 10 mV — `boss-bd-2-blues-driver` by `9.9 mV`, `boss-ge-7` by `19 mV` — and the corpus's
57
+ * ngspice agreement is unchanged at 31.
58
+ *
59
+ * **What zero cost.** Zero is the one value known to be wrong, since no real supply has it, and it
60
+ * let a supply deliver unbounded current: `ibanez-ts808` drew **193 A** through the short
61
+ * `diode-forward-across-supply` warns about and still rendered a plausible gain of 0.506. One ohm
62
+ * collapses that rail instead, which is also what let the diode's `limitJunction` land after two
63
+ * reverts.
64
+ *
65
+ * **Not a battery model.** It does not deplete and it does not tell an adapter from a cell. Both
66
+ * are the profile's job once it exists; this is the mechanism and one defensible default.
67
+ */
68
+ export const SUPPLY_SOURCE_OHMS = 1;
69
+ /**
70
+ * What a bipolar transistor is when the packet does not say, and why the default is silicon.
71
+ *
72
+ * Only 10% of the corpus's 385 transistors declare `IS`, so most still land here, and a
73
+ * small-signal silicon junction is the right assumption for an undated schematic. It is
74
+ * emphatically **not** right for the ones that do declare it: germanium sits five orders of
75
+ * magnitude away at around `1e-9`, which is 0.29 V of base-emitter bias, and those packets
76
+ * now get what they declare.
77
+ *
78
+ * Nothing infers germanium from a part number. `PartNumber` is present on 97% of them and
79
+ * reading `OC41` as germanium would be prose matching; `Type` and `IS` are the evidence.
80
+ */
81
+ /**
82
+ * Open-loop gain for an op-amp that does not state one, dimensionless.
83
+ *
84
+ * A generic small-signal figure. 21 of the corpus's op-amps declare exactly this and 31 declare
85
+ * otherwise, so it is a fallback rather than a description of anything.
86
+ */
87
+ const DEFAULT_OPEN_LOOP_GAIN = 1e5;
88
+ /**
89
+ * The factor an `ac-source` law multiplies a declared magnitude by, converting the project's
90
+ * RMS convention to the peak amplitude the sine evaluation needs. See the `ac-source` branch
91
+ * of `voltage-source`'s law below for the full reasoning and the escape hatch.
92
+ */
93
+ const AC_SOURCE_RMS_TO_PEAK = Math.SQRT2;
94
+ // Exported so `report-part-store-drift.ts` can check this one shared fallback against a
95
+ // representative registry part instead of quoting a second copy of the numbers that could drift
96
+ // from this one silently -- the exact failure mode that script exists to catch.
97
+ export const SILICON_SATURATION_CURRENT = 1e-14;
98
+ export const SILICON_FORWARD_BETA = 100;
99
+ /**
100
+ * The JFET law's own parameters when the source types neither `Vt0` nor `Beta`. Not a part: no
101
+ * datasheet in the corpus prints these, and a 2N5952 or 2SK30A whose document leaves them untyped
102
+ * runs on them regardless of its grade. Exported so `compile.ts` can name every device that does
103
+ * (`fet-law-default-parameters`) against the values actually stamped, not a second copy.
104
+ */
105
+ export const JFET_DEFAULT_THRESHOLD_VOLTS = -2;
106
+ export const JFET_DEFAULT_TRANSCONDUCTANCE = 1e-3;
107
+ /**
108
+ * A winding's DC resistance, in the three states the source can be in.
109
+ *
110
+ * **Absent is not zero.** Every real winding has copper resistance, so zero is physically
111
+ * impossible rather than merely unusual -- and a lossless inductor in a feedback loop has unbounded
112
+ * Q, which is how a marginally stable real amplifier becomes an unstable simulated one. Defaulting
113
+ * absence to zero would preserve every current number and leave all 67 corpus windings quietly
114
+ * wrong, which is exactly how this arrived: until 2026-09-09 the law had no parameter at all, so
115
+ * `.vdsp` had nowhere to put a value and 29 packets were lossless by construction rather than by
116
+ * omission.
117
+ *
118
+ * So there is **no default**. A packet states a number, or states `unknown` when the drawing does
119
+ * not print it -- Fender sheets give turns ratios and impedances, never copper resistance -- and
120
+ * `unknown` is a first-class machine-readable claim rather than an omission indistinguishable from
121
+ * an oversight. Silence produces neither, and a caller can tell the three apart.
122
+ */
123
+ function windingResistance(device) {
124
+ const declared = device.parameters.windingResistanceOhms;
125
+ if (typeof declared === "number" && Number.isFinite(declared) && declared > 0) {
126
+ return { seriesResistanceOhms: declared };
127
+ }
128
+ if (device.parameters.windingResistanceUnknown === 1) {
129
+ return { seriesResistanceOhms: "unknown" };
130
+ }
131
+ return {};
132
+ }
133
+ const FERRITE_BEAD_DEFAULT_HENRIES = 10e-6;
134
+ /**
135
+ * The roles a jack uses for the terminal current returns through.
136
+ *
137
+ * **This replaced a name vocabulary that carried `cathode`**, because the `.schx` conversion
138
+ * named every two-terminal component after a diode: 265 corpus jacks called their sleeve
139
+ * `cathode` and their tip `anode`. 249 now declare `sleeve`/`tip`, decided by which terminal
140
+ * sits on ground rather than by what it is called; the 16 that were ambiguous -- both live,
141
+ * both grounded, or the pair reversed -- declare nothing and reach no port-engage law.
142
+ *
143
+ * The name path is gone rather than kept as a fallback, and that is measured: emptying it
144
+ * leaves all 21 port-engage laws standing and every one of the 142 corpus programs
145
+ * byte-identical.
146
+ */
147
+ const portReturnRoles = new Set([
148
+ "sleeve",
149
+ "ground",
150
+ "negative",
151
+ ]);
152
+ /** The index of a jack's declared return terminal, or -1 when it declares none. */
153
+ function portReturnIndex(device) {
154
+ return device.identity.declaredTerminalRoles.findIndex((role) => role !== null && portReturnRoles.has(role));
155
+ }
156
+ /** How a supply names the terminal current comes back to. */
157
+ export const SWITCH_OFF_OHMS = 1e9;
158
+ function buildNodeToVoltageMap(netlist, regulated) {
159
+ const map = new Map();
160
+ map.set(0, 0); // Ground is always 0V
161
+ // A registered regulator's output node states a voltage the document never writes down.
162
+ // Set first so a document that *does* declare the node keeps the last word. See
163
+ // `registrySuppliedVoltages`.
164
+ for (const supply of regulated) {
165
+ map.set(supply.node, supply.volts);
166
+ }
167
+ for (const device of netlist.devices) {
168
+ const volts = device.parameters.volts;
169
+ if (volts === undefined || !Number.isFinite(volts)) {
170
+ continue;
171
+ }
172
+ if (device.kind === "rail") {
173
+ if (device.parameters.frequency !== undefined) {
174
+ continue;
175
+ }
176
+ const node = device.nodes[0];
177
+ if (node !== undefined) {
178
+ map.set(node, volts);
179
+ }
180
+ }
181
+ else if (device.kind === "voltage-source") {
182
+ if (device.parameters.frequency !== undefined) {
183
+ continue;
184
+ }
185
+ // The driven end by declared role, the same field and the same value `lower.ts`'s
186
+ // `supplyTerminals` reads -- stage 3 and stage 5 must agree about which end of a
187
+ // supply is positive or they assert the rail at different nodes.
188
+ device.identity.declaredTerminalRoles.forEach((role, index) => {
189
+ if (role === "positive") {
190
+ const node = device.nodes[index];
191
+ if (node !== undefined) {
192
+ map.set(node, volts);
193
+ }
194
+ }
195
+ });
196
+ }
197
+ else if (device.kind === "ic") {
198
+ const outNode = findNodeWithRole(device, "out");
199
+ if (outNode !== undefined) {
200
+ map.set(outNode, volts);
201
+ }
202
+ }
203
+ }
204
+ return map;
205
+ }
206
+ function findNodeWithRole(device, role) {
207
+ const index = device.identity.terminalRoles.indexOf(role);
208
+ return index !== -1 ? device.nodes[index] : undefined;
209
+ }
210
+ /**
211
+ * Role tokens that can only ever name a supply pin, folded as `terminalRoleToken` leaves them.
212
+ *
213
+ * No op-amp calls a signal input `vcc` or `negativesupply`, so these are read unconditionally.
214
+ * `vccpin8`/`veepin4` are the numbered spellings: pins 8 and 4 of a single op-amp in an 8-pin
215
+ * package are its rails, and `terminalRoleToken` strips a leading `pinN` but not a trailing one.
216
+ */
217
+ const unambiguousSupplyRoles = new Map([
218
+ ["vcc", "high"],
219
+ ["vccpin8", "high"],
220
+ ["positivesupply", "high"],
221
+ ["vdd", "high"],
222
+ ["vee", "low"],
223
+ ["veepin4", "low"],
224
+ ["negativesupply", "low"],
225
+ ["vss", "low"],
226
+ ]);
227
+ /**
228
+ * Role tokens that name a supply pin **on a part that separately names its inputs**, and could
229
+ * otherwise be an input.
230
+ *
231
+ * This distinction is the whole reason this is two maps rather than one. A census of all 294
232
+ * op-amp-shaped components in the corpus found `vplus`/`vminus` used as **rails** in every one
233
+ * of the 60 devices that declare them -- always beside a named input pair (`inverting` /
234
+ * `noninverting`, or `positive` / `negative`), never instead of one. So they are supplies here.
235
+ *
236
+ * But `vplus`/`vminus` is also the natural spelling for a bare differential pair, and
237
+ * `lower.ts`'s `opampTerminals` falls back to *declaration order* when it cannot resolve a named
238
+ * input pair -- so on a three-terminal `vplus, vminus, output` op-amp it wires those two as the
239
+ * inputs. Reading them as rails as well would clamp that op-amp to its own input voltages. The
240
+ * guard below is therefore not a corpus statistic, it is the invariant: **a terminal cannot be
241
+ * both a signal input and a rail**, so these spellings are read as rails only when the part
242
+ * states its inputs some other way.
243
+ */
244
+ const supplyRolesNeedingNamedInputs = new Map([
245
+ ["vplus", "high"],
246
+ ["vpluspin8", "high"],
247
+ ["v+", "high"],
248
+ ["vminus", "low"],
249
+ ["vminuspin4", "low"],
250
+ // Folded from `v-` by `terminalRoleToken`'s separator strip. `boss-ce-2` names its
251
+ // supply pair `V+`/`V-`, which `netlist.ts`'s own supply census also records.
252
+ ["v", "low"],
253
+ ]);
254
+ /** Input-naming roles that are never a supply, so finding one proves the inputs are stated. */
255
+ const namedInputRoles = new Set([
256
+ "positive",
257
+ "negative",
258
+ "noninverting",
259
+ "inverting",
260
+ "noninvertinginput",
261
+ "invertinginput",
262
+ "inputplus",
263
+ "inputminus",
264
+ "vin+",
265
+ "vin",
266
+ "in+",
267
+ "in",
268
+ "noninvertinga",
269
+ "invertinga",
270
+ "noninvertingb",
271
+ "invertingb",
272
+ ]);
273
+ /**
274
+ * The rails this op-amp is powered by: its own supply terminals when it declares any, else the
275
+ * circuit's supply set.
276
+ *
277
+ * Before this, only the exact spellings `vcc` and `vee` were read; every other spelling fell
278
+ * through to the circuit-wide supply extremes -- the *widest* pair in the document -- so an
279
+ * op-amp on a regulated rail inside a pedal with a higher raw inlet was given a clipping ceiling
280
+ * it does not have. A fallback is the right behaviour for an op-amp that states no supply at
281
+ * all; it is a wrong answer that still renders for one that states it under another name.
282
+ *
283
+ * **One measured basis, to keep the numbers here comparable.** Censusing every lowered
284
+ * `ideal-opamp` stamp before and after this change: of **251** stamps, **4** get different
285
+ * rails -- both op-amps in `boss-bd-2-blues-driver` and its Keeley variant, `railHigh` 9 -> 8,
286
+ * which is correct because that pedal declares three supply ports (9 V, 8 V, 4 V) and both
287
+ * op-amps' `vPlus` is on the 8 V one. Many more op-amps *declare* a non-`vcc` spelling, but
288
+ * on a node whose voltage already equals a circuit extreme, so the old lookup returned the
289
+ * right answer for the wrong reason. Counting declarations rather than changed rails
290
+ * overstates this fix by an order of magnitude; the 4 is the number that means anything.
291
+ */
292
+ function opampRails(device, rails, nodeToVoltage) {
293
+ const roles = device.identity.terminalRoles;
294
+ const statesItsInputs = roles.some((role) => role !== null && namedInputRoles.has(role));
295
+ let high = rails.high;
296
+ let low = rails.low;
297
+ roles.forEach((role, index) => {
298
+ if (role === null) {
299
+ return;
300
+ }
301
+ const side = unambiguousSupplyRoles.get(role) ??
302
+ (statesItsInputs ? supplyRolesNeedingNamedInputs.get(role) : undefined);
303
+ if (side === undefined) {
304
+ return;
305
+ }
306
+ const node = device.nodes[index];
307
+ const volts = node === undefined ? undefined : nodeToVoltage.get(node);
308
+ if (volts === undefined) {
309
+ return;
310
+ }
311
+ if (side === "high") {
312
+ high = volts;
313
+ }
314
+ else {
315
+ low = volts;
316
+ }
317
+ });
318
+ // **A rail pair must be ordered, or it is not a pair.** `railHigh <= railLow` is not a
319
+ // narrow clamp, it is a contradiction: the op-amp's linear region has negative width and
320
+ // its output is pinned wherever the limiter happens to land. Reading a device's own supply
321
+ // pins can produce that from a document the old `vcc`/`vee`-only lookup never asked, so the
322
+ // guard belongs here rather than in the caller.
323
+ //
324
+ // Measured on `mxr-m117r-flanger`, which is why this is not defensive programming: its `U4`
325
+ // declares `pin8_vplus` and `pin4_vminus`, and **both resolve to the same node** -- ground,
326
+ // carrying a declared +15 V, because the packet puts its `V15` rail component on the ground
327
+ // node. That yielded `high = low = 15` on eight op-amp stamps. The document's own supply
328
+ // pins are not usable evidence there, so the circuit-wide set is the better answer, and
329
+ // falling back to it leaves those eight exactly where they were before this change.
330
+ if (high !== null && low !== null && high <= low) {
331
+ return rails;
332
+ }
333
+ return { high, low };
334
+ }
335
+ /**
336
+ * The rails an op-amp's output can reach: each supply rail pulled in by the part's stated drop.
337
+ * An unstated drop, an unknown rail, or a drop that would close the pair leaves the rails as they
338
+ * are, so a part with no entry renders bit-for-bit as before.
339
+ */
340
+ function railsAfterSwingDrop(rails, dropVolts) {
341
+ if (dropVolts === null || dropVolts <= 0 || rails.high === null || rails.low === null) {
342
+ return rails;
343
+ }
344
+ const high = rails.high - dropVolts;
345
+ const low = rails.low + dropVolts;
346
+ return high > low ? { high, low } : rails;
347
+ }
348
+ /**
349
+ * The one high-side and one low-side supply pin a device declares, read by the same role tokens
350
+ * `opampRails` uses, or `null` unless each side is declared exactly once.
351
+ *
352
+ * `gnd` counts as a low side here and not in `opampRails`: a package ground pin is never a signal
353
+ * input, and the MN3101 names its pair `vdd`/`gnd`. Which side is which does not matter to a
354
+ * conductance, which is all this is used for.
355
+ */
356
+ function supplyPinNodes(device) {
357
+ const roles = device.identity.terminalRoles;
358
+ const statesItsInputs = roles.some((role) => role !== null && namedInputRoles.has(role));
359
+ const high = [];
360
+ const low = [];
361
+ roles.forEach((role, index) => {
362
+ if (role === null) {
363
+ return;
364
+ }
365
+ const side = role === "gnd"
366
+ ? "low"
367
+ : (unambiguousSupplyRoles.get(role) ??
368
+ (statesItsInputs ? supplyRolesNeedingNamedInputs.get(role) : undefined));
369
+ const node = device.nodes[index];
370
+ if (side === undefined || node === undefined) {
371
+ return;
372
+ }
373
+ (side === "high" ? high : low).push(node);
374
+ });
375
+ if (high.length !== 1 || low.length !== 1 || high[0] === low[0]) {
376
+ return null;
377
+ }
378
+ return { high: high[0], low: low[0] };
379
+ }
380
+ /**
381
+ * **An IC's own supply draw, as a conductance across its supply pins**, or `null` when its part
382
+ * entry records no `quiescentSupplyAmps` or the pins or their voltages cannot be read.
383
+ *
384
+ * Sized at this pedal's rail span, so the device draws the datasheet current at the rail it is
385
+ * actually fitted to. A bipolar op-amp's supply current is close to flat with voltage (the
386
+ * uPC4558's curve moves 2.6 -> 2.7 mA from +/-4.5 to +/-20 V), so fixing it at the operating span
387
+ * is the right first-order model, and it needs no new stamp in either console.
388
+ *
389
+ * `expanded` is whether the registry split this device into its sections here, in which case it is
390
+ * the whole package. A device the document declared as one section of a multi-section part --
391
+ * `boss-ce-2`'s `IC1A` and `IC1B` are the two halves of one uPC4558C -- draws that section's share,
392
+ * so the package is counted once however the document split it.
393
+ */
394
+ function quiescentSupplyDraw(device, registry, expanded, rails, nodeToVoltage) {
395
+ const partNumber = device.identity.partNumber;
396
+ if (partNumber === null) {
397
+ return null;
398
+ }
399
+ const entry = registryEntryFor(registry, { partId: partNumber, evidence: "exact-part" });
400
+ const packageAmps = entry?.quiescentSupplyAmps;
401
+ if (entry === null || packageAmps === undefined) {
402
+ return null;
403
+ }
404
+ const pins = supplyPinNodes(device);
405
+ if (pins === null) {
406
+ return null;
407
+ }
408
+ const voltageAt = (node, fallback) => node === 0 ? 0 : (nodeToVoltage.get(node) ?? fallback);
409
+ const high = voltageAt(pins.high, rails.high);
410
+ const low = voltageAt(pins.low, rails.low);
411
+ if (high === null || low === null) {
412
+ return null;
413
+ }
414
+ const span = Math.abs(high - low);
415
+ if (!(span > 0)) {
416
+ return null;
417
+ }
418
+ const sections = entry.model.kind === "sections" ? entry.model.sections.length : 1;
419
+ const amps = expanded ? packageAmps : packageAmps / sections;
420
+ const id = `${device.id}#supply`;
421
+ return [
422
+ {
423
+ id,
424
+ kind: "resistor",
425
+ nodes: [pins.high, pins.low],
426
+ parameters: { ohms: span / amps },
427
+ control: null,
428
+ identity: {
429
+ partNumber: null,
430
+ declaredType: null,
431
+ terminalRoles: ["end", "end"],
432
+ declaredTerminalRoles: [null, null],
433
+ declaredWindings: null,
434
+ },
435
+ },
436
+ { outcome: "law", device: id, law: { kind: "conductance", siemens: amps / span } },
437
+ ];
438
+ }
439
+ /**
440
+ * Nodes that reach a declared voltage through one two-terminal passive
441
+ * (diode, capacitor, resistor, inductor). Covers the LT1054 → D17 → VA-rail
442
+ * path where the charge pump's output is a diode away from the declared rail.
443
+ */
444
+ function buildOneHopVoltageMap(netlist, nodeToVoltage) {
445
+ const map = new Map();
446
+ for (const device of netlist.devices) {
447
+ if (device.nodes.length !== 2 ||
448
+ (device.kind !== "diode" &&
449
+ device.kind !== "capacitor" &&
450
+ device.kind !== "resistor" &&
451
+ device.kind !== "inductor")) {
452
+ continue;
453
+ }
454
+ const [a, b] = device.nodes;
455
+ const va = nodeToVoltage.get(a);
456
+ const vb = nodeToVoltage.get(b);
457
+ if (va !== undefined && b !== 0 && vb === undefined) {
458
+ map.set(b, va);
459
+ }
460
+ else if (vb !== undefined && a !== 0 && va === undefined) {
461
+ map.set(a, vb);
462
+ }
463
+ }
464
+ return map;
465
+ }
466
+ export function attachDeviceLaws(netlist, registry) {
467
+ const regulated = registrySuppliedVoltages(netlist, registry);
468
+ const rails = supplyRails(netlist, regulated);
469
+ const supply = supplyNodes(netlist);
470
+ const nodeToVoltage = buildNodeToVoltageMap(netlist, regulated);
471
+ const oneHopVoltage = buildOneHopVoltageMap(netlist, nodeToVoltage);
472
+ const mainsFrequencyHz = mainsFrequency(netlist);
473
+ // Stage 1 (`netlist.ts`) already resolved each control's real taper -- from the panel
474
+ // control's own declaration, or failing that, from the first device bound to it -- onto
475
+ // `Netlist.controls`. This stage's job is to carry that already-decided value onto the
476
+ // law by the same control id, never to decide it again or default it.
477
+ const taperByControl = new Map(netlist.controls.map((control) => [control.id, control.taper]));
478
+ // A multi-section part becomes one device per section, here and nowhere else.
479
+ //
480
+ // This is the only stage that knows the registry, so it is the only stage that can know a part
481
+ // is multi-section. Expanding into ordinary devices means partitioning, lowering and the runtime
482
+ // see nothing new -- the alternative, a fourth `DeviceResolution` outcome, would have to be
483
+ // understood by every consumer of one.
484
+ const devicesTouchingNode = deviceCountByNode(netlist);
485
+ // Nodes inside a registered package, numbered past every node the document uses.
486
+ const internalNodes = new Map();
487
+ const firstInternal = Math.max(0, ...netlist.nodes) + 1;
488
+ const internalNode = (deviceId, index) => {
489
+ const key = `${deviceId}\n${index}`;
490
+ const existing = internalNodes.get(key);
491
+ if (existing !== undefined)
492
+ return existing;
493
+ const node = (firstInternal + internalNodes.size);
494
+ internalNodes.set(key, node);
495
+ return node;
496
+ };
497
+ const devices = [];
498
+ const resolutions = [];
499
+ for (const device of netlist.devices) {
500
+ const expanded = sectionDevices(device, registry, rails, supply, nodeToVoltage, internalNode, netlist);
501
+ const draw = quiescentSupplyDraw(device, registry, expanded !== null, rails, nodeToVoltage);
502
+ if (draw !== null) {
503
+ devices.push(draw[0]);
504
+ resolutions.push(draw[1]);
505
+ }
506
+ if (expanded === null) {
507
+ devices.push(device);
508
+ resolutions.push(resolveDevice(device, registry, rails, supply, taperByControl, mainsFrequencyHz, devicesTouchingNode, nodeToVoltage, oneHopVoltage, netlist));
509
+ continue;
510
+ }
511
+ for (const [section, resolution] of expanded) {
512
+ devices.push(section);
513
+ resolutions.push(resolution);
514
+ }
515
+ }
516
+ return {
517
+ netlist: {
518
+ ...netlist,
519
+ devices,
520
+ nodes: internalNodes.size === 0 ? netlist.nodes : [...netlist.nodes, ...internalNodes.values()],
521
+ },
522
+ resolutions,
523
+ };
524
+ }
525
+ /**
526
+ * A multi-section part as one device per section, or `null` when the part is not multi-section.
527
+ *
528
+ * Each section device carries only the terminals that section uses, in the order the registry gave
529
+ * them, and its `identity.terminalRoles` are the canonical role names for its law's arity. That is
530
+ * what lets lowering treat it as an ordinary device: a section of a dual op-amp is indistinguishable
531
+ * from a packet that declared that half as its own `opamp` component, which is how the packets that
532
+ * already compile transcribe the same chip.
533
+ *
534
+ * A section naming a terminal the device does not have is a registry error and refuses the pedal,
535
+ * rather than silently dropping a section and rendering half a circuit.
536
+ */
537
+ /**
538
+ * A single-section `voltage-source` model on a regulator or converter that declares which terminal
539
+ * is its output and which is its reference, resolved from those declarations.
540
+ *
541
+ * `null` whenever the document does not settle it: a different law, more than one section, a kind
542
+ * whose format vocabulary has no supply electrodes, or an output/reference role declared zero or
543
+ * several times. Every one of those falls through to the entry's pin naming exactly as before.
544
+ */
545
+ export function declaredRegulatorTerminals(device, model) {
546
+ // **Gated on the declaration, not on `device.kind`.** A document's `kind: regulator` folds to
547
+ // device kind `ic` by the time laws are attached -- `boss-rv-3` and `electro-harmonix-q-tron`
548
+ // both declare `regulator` and both arrive here as `ic` -- so a kind test would never fire.
549
+ // The declaration is its own gate: core admits `positive`/`negative`/`ground` only on the
550
+ // kinds whose vocabulary carries them (`regulator`, `power-converter`, `voltage-source`,
551
+ // `rail`, `battery`), and an `ic` may declare nothing but `pin`. A device that reaches here
552
+ // carrying a supply electrode was therefore declared as a kind entitled to state one.
553
+ if (model.sections.length !== 1) {
554
+ return null;
555
+ }
556
+ const section = model.sections[0];
557
+ if (section.law.kind !== "voltage-source" || section.terminals.length !== 2) {
558
+ return null;
559
+ }
560
+ const declared = device.identity.declaredTerminalRoles;
561
+ const only = (role) => {
562
+ const found = declared.flatMap((value, index) => value === role ? [index] : []);
563
+ return found.length === 1 ? found[0] : null;
564
+ };
565
+ const ground = only("ground");
566
+ const output = only("positive") ?? only("negative");
567
+ if (ground === null || output === null) {
568
+ return null;
569
+ }
570
+ const outputNode = device.nodes[output];
571
+ const groundNode = device.nodes[ground];
572
+ if (outputNode === undefined || groundNode === undefined) {
573
+ return null;
574
+ }
575
+ // A converter declaring its output `negative` states a rail below its reference, so the
576
+ // source's sense is reversed rather than its magnitude negated -- the law carries a
577
+ // magnitude.
578
+ const inverting = only("positive") === null;
579
+ const order = inverting
580
+ ? [ground, output]
581
+ : [output, ground];
582
+ // **Built exactly like any other section**, because it is one: a `#0` id and canonical roles
583
+ // for the law, not the package's own role array. The parent's array does not describe a
584
+ // section and is a different length, and leaving it in place misaligned every index-based
585
+ // reader -- `supplyTerminals` resolves an unnamed end as `1 - driveIndex`, which is only the
586
+ // other terminal when there are two.
587
+ const sectionId = `${device.id}#0`;
588
+ const nodes = order.map((index) => device.nodes[index]);
589
+ return [
590
+ [
591
+ {
592
+ ...device,
593
+ id: sectionId,
594
+ nodes,
595
+ identity: {
596
+ ...device.identity,
597
+ terminalRoles: canonicalRoles(section.law.kind, nodes.length),
598
+ declaredTerminalRoles: canonicalRoles(section.law.kind, nodes.length),
599
+ },
600
+ },
601
+ { outcome: "law", device: sectionId, law: section.law },
602
+ ],
603
+ ];
604
+ }
605
+ function sectionDevices(device, registry, rails, supply, nodeToVoltage, internalNode = () => {
606
+ throw new Error("no internal-node allocator");
607
+ }, netlist) {
608
+ // **`mayCarryRegistrySections`, not `requiresIdentification`.** An OTA reaches here so a
609
+ // registered multi-section part (the BA662A's OTA plus its Darlington buffer) can be expanded;
610
+ // when the registry has no matching entry this returns null and the caller falls through to
611
+ // the device-class law, which is what every unregistered OTA keeps. An op-amp whose registered
612
+ // *part* is not an op-amp (`opampPartRegisteredAsOtherLaw` -- the LM339 drawn as an op-amp)
613
+ // reaches here the same way, and the class override is decided entirely in `identify`.
614
+ if (!mayCarryRegistrySections(device) &&
615
+ !opampPartRegisteredAsOtherLaw(device, registry) &&
616
+ !bjtPartRegisteredAsSections(device, registry)) {
617
+ return null;
618
+ }
619
+ if (isNonExecutableIcSupportShell(device)) {
620
+ return null;
621
+ }
622
+ const identity = identify(device, registry);
623
+ if (identity === null) {
624
+ return null;
625
+ }
626
+ const entry = registryEntryFor(registry, identity, device.nodes.length);
627
+ if (entry === null) {
628
+ return null;
629
+ }
630
+ const model = entry.model;
631
+ if (model.kind !== "sections" && model.kind !== "sections-core") {
632
+ return null;
633
+ }
634
+ // A `sections` entry's `terminals` index into the entry's canonical pin order, not the
635
+ // declaration order the document happened to use. When the entry names its pins by role and
636
+ // that naming is a clean bijection onto the device's declared terminals, resolve each
637
+ // canonical position to the device's node by role, so a part declared in a different order
638
+ // still lands on the right nodes: q-tron's 78L18 declares `in, out, ground`, the entry's
639
+ // groups are `in, gnd, out`, and an all-null pinout used to skip this check entirely,
640
+ // silently swapping the regulated rail onto ground.
641
+ //
642
+ // The bijection is the guard. A part whose declared roles do not map one-to-one onto the
643
+ // entry's groups -- the MN3101 carries extra clock terminals the entry does not model, and a
644
+ // 7809 may spell its pins `inputpin1` -- falls back to the declaration order its `pinout`
645
+ // was written against, exactly as before: that order is enforced by `pinoutMatches`, and a
646
+ // role-named entry with an all-null pinout cannot be wired by role OR by position, so it is
647
+ // refused rather than mis-wired. See `PartPinout`.
648
+ // **A regulator's own declared electrodes outrank the entry's pin numbering.**
649
+ //
650
+ // The 78Lxx entry's role groups list `pin1`/`pin2`/`pin3` among their aliases, which asserts
651
+ // a package numbering the corpus does not agree on: three packets name a 78Lxx
652
+ // `in, gnd, out` and `electro-harmonix-q-tron` names it `in, out, ground`. For
653
+ // `moogerfooger-mf-102`'s `U13` -- three terminals literally named `pin1`, `pin2`, `pin3` --
654
+ // the entry's `pin2 -> gnd`, `pin3 -> out` is backwards against the packet's own source
655
+ // trace, which records pin2 on `plus5` and pin3 on `gnd`. The result was the regulator's
656
+ // reference stamped onto the pedal's rail: node 3 carried 24 devices at **-4.9989 V** and 18
657
+ // of 26 active devices sat dark, reverse-biased by a whole supply, with no diagnostic.
658
+ //
659
+ // Only the two terminals the law actually spans need to be found, and for this kind both
660
+ // carry an unambiguous role: the regulated output is `positive` (or `negative` on an
661
+ // inverting converter) and the reference is `ground`. A regulator's *input* has no distinct
662
+ // role in the format, which is why a full bijection cannot be built from declarations here
663
+ // and this binds the law's own two slots rather than every pin.
664
+ const declaredSupplyBinding = declaredRegulatorTerminals(device, model);
665
+ if (declaredSupplyBinding !== null) {
666
+ return declaredSupplyBinding;
667
+ }
668
+ const roles = device.identity.terminalRoles;
669
+ const groups = entry.terminalRoleGroups;
670
+ const positions = groups.map((group) => terminalWithRole(roles, group));
671
+ const cleanRoleResolution = groups.length > 0 &&
672
+ groups.length === roles.length &&
673
+ positions.every((position) => position !== null) &&
674
+ new Set(positions).size === positions.length;
675
+ // **For an OTA the registry only wins when it can bind by role.** Everything else here may
676
+ // fall back to declaration order, which is right for a part whose entry was written against
677
+ // that order -- but an OTA already has a correct role-bound device-class law, so a positional
678
+ // guess from a registry entry written for different role spellings is strictly worse than
679
+ // keeping it. Measured: `keeley-compressor-plus` names its terminals `iabc` and `out` while
680
+ // the LM13600 entry names its groups `i_biasa` and `vouta`, so the bijection fails, and the
681
+ // positional fallback swapped that device's `bias` and `output`.
682
+ if (device.kind === "ota" && !cleanRoleResolution) {
683
+ return null;
684
+ }
685
+ let canonicalNodeAt;
686
+ if (cleanRoleResolution) {
687
+ canonicalNodeAt = (index) => {
688
+ const position = positions[index];
689
+ return position === null ? null : device.nodes[position];
690
+ };
691
+ }
692
+ else if (!pinoutMatches(model.pinout, roles)) {
693
+ // **Opened and named, not refused.** The part *is* registered -- this is an arity
694
+ // disagreement between a real entry and a real document, not an unknown chip -- so the
695
+ // two facts a reader needs are which entry and which counts, and both are in the warning.
696
+ // Refusing here failed the whole document for one component that could not be placed,
697
+ // which is the trade this repository settled the other way earlier today: losing a pedal
698
+ // entirely is a worse answer than losing the one stage nothing can bind.
699
+ //
700
+ // `boss-dd-3b`'s `IC2` is the case that forced it. Its `SA571D` is declared as a
701
+ // six-terminal source-visible shell (`rect_cap1`, `rect_cap2`, `audio_in`, `audio_out`,
702
+ // `vcc`, `gnd`) against the SA571's real sixteen, and four of the six pins a `compandor`
703
+ // section binds -- `rect_in`, `cell_in`, `inv_in` and the R3 return -- are not declared at
704
+ // all, with both rect-cap nodes touched by nothing but the shell itself. The packet's own
705
+ // record calls it a shell and claims no terminal trace for it, so there is nothing here to
706
+ // correct upstream and nothing this stage may invent: the summing node and the R3 return
707
+ // are where the external network sets the gain, and choosing them would be choosing the
708
+ // law. The same chip is modelled in full wherever a document declares its sixteen pins.
709
+ return [
710
+ [
711
+ device,
712
+ {
713
+ outcome: "law",
714
+ device: device.id,
715
+ law: { kind: "open" },
716
+ openReason: "registry-arity-mismatch",
717
+ insteadOfRefusal: `part ${identity.partId} is registered against a ${model.pinout.length}-terminal pinout this component's ${roles.length} declared terminals do not match, and its role naming does not resolve cleanly to its sections`,
718
+ },
719
+ ],
720
+ ];
721
+ }
722
+ else {
723
+ canonicalNodeAt = (index) => device.nodes[index];
724
+ }
725
+ // **A section terminal past the part's own pins is a node inside the package** -- a digital
726
+ // transistor's base behind its built-in series resistor. Allocated once per device and index,
727
+ // so every section naming it shares it, and no pin of the document can reach it.
728
+ const pinCount = Math.max(model.pinout.length, entry.terminalRoleGroups.length);
729
+ const sectioned = model.sections.map((section, index) => {
730
+ const nodes = section.terminals.map((terminal) => terminal >= pinCount ? internalNode(device.id, terminal) : canonicalNodeAt(terminal));
731
+ if (nodes.some((node) => node === undefined || node === null)) {
732
+ return [
733
+ device,
734
+ {
735
+ outcome: "unsupported",
736
+ device: device.id,
737
+ reason: `part ${identity.partId} section ${index} names a terminal this component does not declare`,
738
+ },
739
+ ];
740
+ }
741
+ // Read from the section's *parent* device, which is where the package's supply pins
742
+ // are declared -- a section's own terminal list is only its three signal pins.
743
+ const sectionDrop = section.law.kind === "ideal-opamp" ? (section.law.outputSwingDropVolts ?? null) : null;
744
+ const sectionRails = railsAfterSwingDrop(opampRails(device, rails, nodeToVoltage), sectionDrop);
745
+ const railHigh = sectionRails.high;
746
+ const railLow = sectionRails.low;
747
+ // An op-amp's rails come from the op-amp's own supply terminals or the circuit's supply set,
748
+ // not from the registry -- the same substitution a declared `opamp` gets. A registry cannot know them:
749
+ // it describes a part, and the rails are a property of the pedal the part is fitted to.
750
+ // A compandor carries an op-amp too, and it needs the same substitution: its rails were
751
+ // computed just above and then dropped on the floor, because this only matched the
752
+ // `ideal-opamp` kind. An unrailed internal op-amp is not a small error in the Figure 7
753
+ // compressor, where the gain cell is the only AC feedback and the gain is unbounded
754
+ // until the detector charges -- `mxr-carbon-copy` answered a 2 ms burst with 195 V.
755
+ const law = section.law.kind === "ideal-opamp"
756
+ ? {
757
+ kind: "ideal-opamp",
758
+ railHigh,
759
+ railLow,
760
+ // The registry states the part's gain; only the rails are the pedal's.
761
+ openLoopGain: section.law.openLoopGain,
762
+ // Kept on the lowered law so `findOpampLawDefaults` can tell a part that
763
+ // states how close it gets to the rails from one that does not.
764
+ ...(sectionDrop === null ? {} : { outputSwingDropVolts: sectionDrop }),
765
+ }
766
+ : section.law.kind === "compandor"
767
+ ? { ...section.law, railHigh, railLow }
768
+ : section.law;
769
+ const sectionDevice = {
770
+ ...device,
771
+ id: `${device.id}#${index}`,
772
+ nodes: nodes,
773
+ identity: {
774
+ ...device.identity,
775
+ terminalRoles: canonicalRoles(law.kind, nodes.length),
776
+ // A section's terminal list is its own, so the package's declared roles do not
777
+ // describe it -- and `canonicalRoles` above already states each section pin's
778
+ // role exactly. Carrying the parent's array would misalign it by length.
779
+ declaredTerminalRoles: canonicalRoles(law.kind, nodes.length),
780
+ },
781
+ };
782
+ return [
783
+ sectionDevice,
784
+ {
785
+ outcome: "law",
786
+ device: sectionDevice.id,
787
+ law,
788
+ },
789
+ ];
790
+ });
791
+ // A `sections-core` entry additionally keeps its sampled core: the sections above
792
+ // become ordinary devices, and the whole device resolves as the existing macro
793
+ // against the core model, binding its ports to the same boundary nodes the
794
+ // sections share. The core keeps the package id; the sections took `#index`.
795
+ if (model.kind === "sections-core") {
796
+ return [
797
+ ...sectioned,
798
+ [
799
+ device,
800
+ resolveMacro(device, identity.partId, model.core, registry, netlist),
801
+ ],
802
+ ];
803
+ }
804
+ return sectioned;
805
+ }
806
+ /**
807
+ * A registry macro against one device: terminal roles resolved to indices, pedal-specific
808
+ * parameters merged in.
809
+ *
810
+ * Two things the registry cannot state and this stage supplies, both for the same reason the
811
+ * `ideal-opamp` branch above takes its rails from the circuit — a registry describes a part, and
812
+ * these belong to the pedal it was fitted to:
813
+ *
814
+ * - **where the ports landed**, from `MacroPortRoles`. Order-independent, so one entry serves
815
+ * every declaration order that names its terminals.
816
+ * - **how long the delay is**, from the source's own `DelayMs`. A bucket brigade's delay is
817
+ * `stages / (2 * f_clock)` and only the stage count is part-intrinsic, so the registry cannot
818
+ * know it. See `parametersFor`'s `ic` branch.
819
+ *
820
+ * **A macro with no locatable audio port is refused, not defaulted.** Measured: leaving
821
+ * `audioPortImpedanceOhms` null keeps both ports absent, and a macro with no tap and no write-back
822
+ * is not in the signal path at all -- `electro-harmonix-electric-mistress` renders exact zero that
823
+ * way, because its whole signal path is the macro. A pedal that compiles to silence is worse than
824
+ * one that refuses by name. See `thoughts/shared/experiments/macro-audio-port-impedance/`.
825
+ */
826
+ /**
827
+ * The physical position a bare-pin document's terminal sits at, when this entry names one. Returns
828
+ * `null` unless the entry supplies positions for `port`, the device is fully bare (every terminal a
829
+ * `pinN` with no role token), and the position is within the device's declared terminal count. A
830
+ * document that names even one terminal resolves by role and never reaches here.
831
+ */
832
+ function barePinPosition(macro, roles, port) {
833
+ const position = macro.barePinPositions?.[port];
834
+ if (position === undefined) {
835
+ return null;
836
+ }
837
+ if (!roles.every((role) => role === null)) {
838
+ return null;
839
+ }
840
+ if (position >= roles.length) {
841
+ return null;
842
+ }
843
+ return position;
844
+ }
845
+ /**
846
+ * Thin wrapper that brackets the real resolution so every registry entry consulted while computing
847
+ * this macro's parameters is recorded against the macro's device id. **No behaviour change** -- the
848
+ * wrapper only opens and closes the recording.
849
+ */
850
+ function resolveMacro(device, partId, macro, registry, netlist) {
851
+ return withRecordedReads(`macro:${device.id}`, () => resolveMacroInner(device, partId, macro, registry, netlist));
852
+ }
853
+ function resolveMacroInner(device, partId, macro,
854
+ // Required, and ahead of the optional `netlist` so it cannot be omitted. It was
855
+ // `registry?` while `bbd-clock.ts` defaulted its own parameter to the part catalog, so an
856
+ // omission here resolved to the full catalog instead of failing — the leak that kept the
857
+ // compiler from being part-free. Its one caller always had a registry to give.
858
+ registry, netlist) {
859
+ const roles = device.identity.terminalRoles;
860
+ const audioIn = terminalWithRole(roles, macro.ports.audioIn) ??
861
+ barePinPosition(macro, roles, "audioIn");
862
+ const audioOut = terminalWithRole(roles, macro.ports.audioOut) ??
863
+ barePinPosition(macro, roles, "audioOut");
864
+ // A second output, only for a part that declares one and a device that names it. Absent is
865
+ // the ordinary case and stays a one-output macro: unlike the ports above, failing to find
866
+ // this is not a refusal, because most parts genuinely have one output.
867
+ const audioOut2 = (macro.ports.audioOut2 ?? []).length === 0
868
+ ? null
869
+ : terminalWithRole(roles, macro.ports.audioOut2 ?? []);
870
+ if (audioIn === null || audioOut === null) {
871
+ const missing = [
872
+ audioIn === null ? "audio in" : null,
873
+ audioOut === null ? "audio out" : null,
874
+ ].filter((label) => label !== null);
875
+ return {
876
+ outcome: "unsupported",
877
+ device: device.id,
878
+ reason: `part ${partId} declares no terminal this registry can read as ${missing.join(" or ")}: its ${roles.length} terminals are named ${describeRoles(roles)}`,
879
+ };
880
+ }
881
+ const parameterPort = macro.ports.parameter ?? [];
882
+ const parameterTerminal = parameterPort.length === 0
883
+ ? null
884
+ : terminalWithRole(roles, parameterPort) ??
885
+ barePinPosition(macro, roles, "parameter");
886
+ // Delay time resolution precedence:
887
+ // 1. If a clock driver exists and its timing network resolves, derive delaySeconds.
888
+ // 2. Otherwise fall back to declared device.parameters.delaySeconds (DelayMs).
889
+ // 3. If neither derived delay nor declared DelayMs exists, refuse naming missing DelayMs.
890
+ let delaySeconds = device.parameters.delaySeconds;
891
+ let clockControl = null;
892
+ // Provenance, so a consumer can tell a delay the circuit produced from one the source
893
+ // asserted. Starts as `declared` and is only promoted by an actual derivation below.
894
+ // `clock-law` is the third state: the static base stays declared (it sizes the line)
895
+ // while the sweep is derived per sample by the open-OX2 law.
896
+ let delayProvenance = "declared";
897
+ let delayDeclaredReason = "this part's model carries no clock derivation";
898
+ let clockLawFields = {};
899
+ if (macro.modelId === "bucket-brigade-delay-line") {
900
+ const stages = macro.parameters.stages;
901
+ if (stages === undefined || !(stages > 0) || !Number.isInteger(stages)) {
902
+ return {
903
+ outcome: "unsupported",
904
+ device: device.id,
905
+ reason: `part ${partId} is a bucket-brigade delay line with no stage count declared in its registry entry`,
906
+ };
907
+ }
908
+ if (netlist !== undefined) {
909
+ // The open-OX2 relaxation form first: its sweep is a per-sample law, not a
910
+ // static number, so the star derivation below must not claim it. Absent
911
+ // here falls through to the existing path untouched.
912
+ const openOx2 = deriveOpenOx2ClockLaw(netlist, registry);
913
+ if (openOx2.outcome === "recognized") {
914
+ delayProvenance = "clock-law";
915
+ delayDeclaredReason =
916
+ `static base is the declared DelayMs fallback; the sweep is derived per sample ` +
917
+ `by the open-OX2 clock law from node ${openOx2.slowNode} ` +
918
+ `(R ${openOx2.rOhms} C ${openOx2.cFarads}, steered by ${openOx2.steeredBy})`;
919
+ clockLawFields = {
920
+ clockLawNode: openOx2.slowNode,
921
+ clockLawSteeredBy: openOx2.steeredBy,
922
+ clockLawParams: {
923
+ rOhms: openOx2.rOhms,
924
+ cFarads: openOx2.cFarads,
925
+ vddVolts: OPEN_OX2_VDD_VOLTS,
926
+ vthVolts: OPEN_OX2_VTH_VOLTS,
927
+ vfVolts: OPEN_OX2_VF_VOLTS,
928
+ floorVolts: OPEN_OX2_FLOOR_VOLTS,
929
+ },
930
+ };
931
+ }
932
+ else {
933
+ const clockDerivation = deriveBbdDelayFromNetlist(device, netlist, stages, registry);
934
+ delayDeclaredReason =
935
+ clockDerivation.outcome === "no-clock-driver"
936
+ ? "no clock driver device is wired to this delay line"
937
+ : clockDerivation.outcome === "refused"
938
+ ? clockDerivation.reason
939
+ : null;
940
+ if (clockDerivation.outcome === "derived") {
941
+ delayProvenance = "derived";
942
+ delaySeconds = clockDerivation.delaySeconds;
943
+ if (clockDerivation.controlId !== null) {
944
+ clockControl = {
945
+ controlId: clockDerivation.controlId,
946
+ taper: clockDerivation.taper,
947
+ ohmsAtControlMin: clockDerivation.ohmsAtControlMin,
948
+ ohmsAtControlMax: clockDerivation.ohmsAtControlMax,
949
+ farads: clockDerivation.cFarads,
950
+ stages: clockDerivation.stages,
951
+ formulaConstant: clockDerivation.formulaConstant,
952
+ offsetSeconds: clockDerivation.offsetSeconds,
953
+ };
954
+ }
955
+ }
956
+ }
957
+ }
958
+ }
959
+ else if (macro.modelId === "digital-delay-line") {
960
+ const part = foldPartId(device.identity.partNumber ?? "");
961
+ if (part === "pt2399" || part === "pt2399s") {
962
+ if (netlist !== undefined) {
963
+ const ptDerivation = derivePt2399DelayFromNetlist(device, netlist);
964
+ delayDeclaredReason =
965
+ ptDerivation.outcome === "refused" ? ptDerivation.reason : null;
966
+ if (ptDerivation.outcome === "derived") {
967
+ delayProvenance = "derived";
968
+ delaySeconds = ptDerivation.delaySeconds;
969
+ if (ptDerivation.controlId !== null) {
970
+ clockControl = {
971
+ controlId: ptDerivation.controlId,
972
+ taper: ptDerivation.taper,
973
+ ohmsAtControlMin: ptDerivation.ohmsAtControlMin,
974
+ ohmsAtControlMax: ptDerivation.ohmsAtControlMax,
975
+ farads: ptDerivation.cFarads,
976
+ stages: ptDerivation.stages,
977
+ formulaConstant: ptDerivation.formulaConstant,
978
+ offsetSeconds: ptDerivation.offsetSeconds,
979
+ };
980
+ }
981
+ }
982
+ }
983
+ }
984
+ else if (part === "m50195" || part === "m50195p") {
985
+ if (netlist !== undefined) {
986
+ const mDerivation = deriveM50195DelayFromNetlist(device, netlist);
987
+ delayDeclaredReason =
988
+ mDerivation.outcome === "refused" ? mDerivation.reason : null;
989
+ if (mDerivation.outcome === "derived") {
990
+ delayProvenance = "derived";
991
+ delaySeconds = mDerivation.delaySeconds;
992
+ if (mDerivation.controlId !== null) {
993
+ clockControl = {
994
+ controlId: mDerivation.controlId,
995
+ taper: mDerivation.taper,
996
+ ohmsAtControlMin: mDerivation.ohmsAtControlMin,
997
+ ohmsAtControlMax: mDerivation.ohmsAtControlMax,
998
+ farads: mDerivation.cFarads,
999
+ stages: mDerivation.stages,
1000
+ formulaConstant: mDerivation.formulaConstant,
1001
+ offsetSeconds: mDerivation.offsetSeconds,
1002
+ };
1003
+ }
1004
+ }
1005
+ }
1006
+ }
1007
+ }
1008
+ // **Only a delay model needs a delay time.** A reverb's decay is part-intrinsic -- the
1009
+ // BTDR-2 datasheet's T60 -- so it arrives in the registry entry's `parameters` and the pedal's
1010
+ // wiring says nothing about it. Applying the delay refusal to every macro made a reverb
1011
+ // module refuse with "declares no positive DelayMs", which misattributes the gap to the packet
1012
+ // and would invite a bogus `DelayMs` into a correct `.vdsp`.
1013
+ // **The modulation port, resolved only for a bucket brigade and only from the netlist.**
1014
+ // A delay whose clock is steered by an LFO cannot state its length as one number, which is
1015
+ // what `deriveBbdDelayFromNetlist` now refuses to pretend. This is the other half of that
1016
+ // refusal: the length still moves, and this names the node it moves with.
1017
+ let modulationFields = {};
1018
+ if (macro.modelId === "bucket-brigade-delay-line" && netlist !== undefined) {
1019
+ const source = resolveClockModulationSource(device, netlist, registry);
1020
+ if (source.outcome === "resolved") {
1021
+ modulationFields = {
1022
+ modulationNode: source.node,
1023
+ modulationSteeredBy: source.steeredBy,
1024
+ };
1025
+ }
1026
+ }
1027
+ const needsDelayTime = macro.modelId === "bucket-brigade-delay-line" ||
1028
+ macro.modelId === "digital-delay-line";
1029
+ if (needsDelayTime && (delaySeconds === undefined || !(delaySeconds > 0))) {
1030
+ return {
1031
+ outcome: "unsupported",
1032
+ device: device.id,
1033
+ reason: `part ${partId} is a delay macro and this component declares no positive DelayMs, which is the one parameter its model cannot be given by the registry`,
1034
+ };
1035
+ }
1036
+ return {
1037
+ outcome: "macro",
1038
+ device: device.id,
1039
+ macro: {
1040
+ modelId: macro.modelId,
1041
+ parameters: delaySeconds === undefined
1042
+ ? { ...macro.parameters }
1043
+ : { ...macro.parameters, delaySeconds },
1044
+ portTerminals: audioOut2 === null
1045
+ ? [audioIn, audioOut]
1046
+ : [audioIn, audioOut, audioOut2],
1047
+ audioPortImpedanceOhms: macro.audioPortImpedanceOhms,
1048
+ // Both or neither, which `MacroModel` requires: a terminal with no reference voltage
1049
+ // cannot be scaled against anything.
1050
+ ...(parameterTerminal !== null && macro.parameterReferenceVolts !== null
1051
+ ? {
1052
+ parameterTerminal,
1053
+ parameterReferenceVolts: macro.parameterReferenceVolts,
1054
+ }
1055
+ : { parameterTerminal: null, parameterReferenceVolts: null }),
1056
+ ...modulationFields,
1057
+ ...clockLawFields,
1058
+ clockControl,
1059
+ delayProvenance,
1060
+ delayDeclaredReason: delayProvenance === "derived" ? null : delayDeclaredReason,
1061
+ },
1062
+ };
1063
+ }
1064
+ /** A device's terminal roles as a diagnostic reads them, with unnamed pins called what they are. */
1065
+ function describeRoles(roles) {
1066
+ const named = roles.filter((role) => role !== null);
1067
+ return named.length === 0
1068
+ ? "by pin number alone, carrying no role tokens"
1069
+ : named.join(", ");
1070
+ }
1071
+ /**
1072
+ * Role tokens a section device presents, so lowering's per-kind terminal lookup finds them.
1073
+ *
1074
+ * Only the kinds a registry can currently supply as a section. Anything else gets no roles, which
1075
+ * leaves lowering on its positional fallback -- correct for a two-terminal law and the reason an
1076
+ * unlisted three-terminal kind must be added here rather than assumed to work.
1077
+ */
1078
+ function canonicalRoles(kind, count) {
1079
+ // **Core role values, not folded name tokens.** `declaredTerminalRoles` holds "the role each
1080
+ // terminal declares, as core parsed it", and this wrote `noninverting` -- the folded *name*
1081
+ // token the old `opampPlusRoles` set matched. Harmless while lowering compared against that
1082
+ // set, and a wrong answer the moment it compared against the core vocabulary: nine packets'
1083
+ // op-amp sections reported `nonInverting=0` and refused.
1084
+ if (kind === "ideal-opamp" && count === 3) {
1085
+ return ["nonInverting", "inverting", "output"];
1086
+ }
1087
+ if (kind === "transformer" && count === 4) {
1088
+ return ["primarya", "primaryb", "secondarya", "secondaryb"];
1089
+ }
1090
+ if (kind === "clock-driver" && count === 6) {
1091
+ // `gnd` is the part's own GND pin and is last because it was added after the other five.
1092
+ // It is not circuit ground: the MN3101/MN3102 run on a single negative supply, so a +9 V
1093
+ // pedal ties this pin to the positive rail and ties `vdd` to circuit ground.
1094
+ return ["cp1", "cp2", "vgg", "vdd", "ox1", "gnd"];
1095
+ }
1096
+ // A registry section's nodes are already in the law's canonical order -- `section.terminals`
1097
+ // indexed them -- so naming them here states what is true rather than inferring it. Added
1098
+ // 2026-09-03 because `lower.ts` now reads declared roles: without these a three-terminal
1099
+ // `bjt`/`fet` section arrived with no roles, took the positional path (correctly, since the
1100
+ // order is canonical) and was reported as an unreadable-role fallback. A false positive on a
1101
+ // binding the registry had already got right.
1102
+ if (kind === "bjt" && count === 3) {
1103
+ return ["base", "collector", "emitter"];
1104
+ }
1105
+ if (kind === "fet" && count === 3) {
1106
+ return ["gate", "drain", "source"];
1107
+ }
1108
+ // A regulator or converter lowered to a supply: `section.terminals` already put the output
1109
+ // first and the reference second, so naming them states that order rather than guessing it.
1110
+ // Without this the section arrived unlabelled, `supplyTerminals` fell through to
1111
+ // `terminalPair`, and which end got the voltage was whichever the entry happened to list
1112
+ // first -- how `moogerfooger-mf-102` came to hold its +5 rail at -4.9989 V.
1113
+ if (kind === "voltage-source" && count === 2) {
1114
+ return ["positive", "negative"];
1115
+ }
1116
+ // Same shape as the supply above: `section.terminals` has already put the LED first and its
1117
+ // return second, followed by the cell's two ends, which is the order `optocouplerTerminals`
1118
+ // falls through to. Naming them states that order instead of leaving the section to depend on
1119
+ // it -- and an unlabelled section is indistinguishable from a document that declared nothing,
1120
+ // so without this the two get the same warning and only one of them deserves it.
1121
+ //
1122
+ // `end` twice is deliberate and is the physics: a photoresistor conducts the same either way,
1123
+ // so the cell states no polarity and either declaration order is the same circuit.
1124
+ if (kind === "optocoupler" && count === 4) {
1125
+ return ["anode", "cathode", "end", "end"];
1126
+ }
1127
+ return Array.from({ length: count }, () => null);
1128
+ }
1129
+ function supplyNodes(netlist) {
1130
+ const returns = new Set();
1131
+ const drives = new Set();
1132
+ const degree = new Map();
1133
+ for (const device of netlist.devices) {
1134
+ for (const node of device.nodes) {
1135
+ degree.set(node, (degree.get(node) ?? 0) + 1);
1136
+ }
1137
+ if (device.kind !== "voltage-source") {
1138
+ continue;
1139
+ }
1140
+ // Declared roles, as everywhere else that asks which end of a supply is which.
1141
+ device.identity.declaredTerminalRoles.forEach((role, index) => {
1142
+ const node = device.nodes[index];
1143
+ if (role === null || node === undefined) {
1144
+ return;
1145
+ }
1146
+ if (role === "negative") {
1147
+ returns.add(node);
1148
+ }
1149
+ if (role === "positive") {
1150
+ drives.add(node);
1151
+ }
1152
+ });
1153
+ }
1154
+ return { returns, drives, degree };
1155
+ }
1156
+ /**
1157
+ * The generic triode, following the generic BJT's precedent exactly: an unregistered
1158
+ * transistor gets a device-class law rather than a refusal, so an unregistered tube does
1159
+ * too. A registry entry refines it; nothing here reads a part number.
1160
+ *
1161
+ * These are the 12AX7 Koren coefficients `whole-amp-5f1` already validated, taken rather
1162
+ * than re-derived. That experiment solves a 2x12AX7 + 6V6 path to a physically sane bias
1163
+ * (V1A `Vk 1.251`, `Vp 166.6`, `Ip 0.83 mA` on a 250 V rail through 100k with a 1.5k
1164
+ * cathode), which makes it a real oracle this repository already owns.
1165
+ *
1166
+ * **There are two prior tube experiments and they validate different things** — worth knowing
1167
+ * before treating either as the reference:
1168
+ *
1169
+ * - `whole-amp-5f1` uses **Koren** with registry-fit coefficients and validates a **static
1170
+ * operating point** against textbook 12AX7 values. That is what this law follows.
1171
+ * - `compact-mna-dynamic-triode-cell` uses a **different, simpler law** (`triodeK`, `mu`,
1172
+ * `triodeSoftness`) and validates **dynamics** — grid/coupling recovery — plus DK-reduction
1173
+ * equivalence to `1e-9` correlation with an analytic reduced Jacobian. Its own boundary note
1174
+ * says it "is not calibrated to a real 12AX7 datasheet or ngspice tube model yet", so its
1175
+ * coefficients are not a datasheet anchor and are deliberately not used here.
1176
+ *
1177
+ * When the re-orientation reaches state-space reduction, that second experiment is the proven
1178
+ * reference for a DK-reduced triode cell; it is not a reference for this law's coefficients.
1179
+ *
1180
+ * A 12AX7 as the *generic* triode is a choice worth stating: it is the overwhelmingly
1181
+ * common preamp triode, and a power triode is a different fit. An amp whose character
1182
+ * comes from a specific tube needs a registry entry, and this default should not be read
1183
+ * as a claim about that tube.
1184
+ */
1185
+ const GENERIC_TRIODE = {
1186
+ mu: 100.29,
1187
+ kg1: 1375.3,
1188
+ kp: 2052.0,
1189
+ kvb: 9706.8,
1190
+ ex: 1.0,
1191
+ // Grid conduction, from `compact-mna-dynamic-triode-cell`'s validated cell rather than
1192
+ // invented: `gridIs 1e-5`, `gridVt 0.06`, `gridThreshold 0.05`. Those are the values it
1193
+ // reports 0 Newton failures with across a 40x velocity ladder, at 2.1 to 2.8 iterations
1194
+ // per sample.
1195
+ gridSaturationCurrent: 1.0e-5,
1196
+ gridOnsetVolts: 0.05,
1197
+ gridScaleVolts: 0.06,
1198
+ // Omitted by the fits these coefficients came from, so 0 keeps them self-consistent.
1199
+ contactPotentialVolts: 0,
1200
+ };
1201
+ /**
1202
+ * The generic pentode: `whole-amp-5f1`'s 6V6GT fit, again taken rather than re-derived. That
1203
+ * experiment solves the 6V6 to `Vk 23.3`, `Vp 329.1`, `Ip 49.6 mA` — and records honestly that
1204
+ * this is "~10 mA hot vs a real 5F1", calling it "first-order pentode-model calibration TODO,
1205
+ * not a viability blocker". So this law is a working power pentode and **not** a calibrated one;
1206
+ * the plate current is known to run high.
1207
+ *
1208
+ * **`kg1` was rescaled from 1060.0 when the plate gate changed from `tanh` against a 40 V knee to
1209
+ * Koren's `atan(Vpk/kvb)`, so this fit is behaviour-neutral across that change.** Unlike the
1210
+ * catalogued tubes -- whose published coefficients were generated against `atan` and were therefore
1211
+ * 26-34% low under `tanh` -- these numbers were tuned locally *with* `tanh`, so its saturation at
1212
+ * 1.0 is already inside `kg1`. The factor is `atan(305.8/38) / tanh(305.8/40)` = 1.44717, taken
1213
+ * at the operating point this fit's own experiment records (`Vp 329.1`, `Vk 23.3`), which makes the
1214
+ * plate current identical there and within a percent nearby. **The known ~10 mA of excess above is
1215
+ * untouched and is a separate defect**; this rescale deliberately preserves it rather than silently
1216
+ * folding a second correction into a first.
1217
+ *
1218
+ * **The grid parameters are the weakest numbers here and are reused, not measured.** They come
1219
+ * from `compact-mna-dynamic-triode-cell`'s *preamp triode*; a power tube's grid conducts at a
1220
+ * different onset and much harder, and blocking distortion on an overdriven power stage is
1221
+ * exactly that behaviour. Omitting grid current would be worse — it is the mechanism that makes
1222
+ * an overdriven output stage sound like one — but a registry entry should refine these before
1223
+ * any claim about a specific power tube's overdrive.
1224
+ */
1225
+ const GENERIC_PENTODE = {
1226
+ mu: 8.0,
1227
+ kg1: 1534.0,
1228
+ kp: 72.0,
1229
+ kvb: 38.0,
1230
+ ex: 1.34,
1231
+ gridSaturationCurrent: 1.0e-5,
1232
+ gridOnsetVolts: 0.05,
1233
+ gridScaleVolts: 0.06,
1234
+ // Omitted by the fits these coefficients came from, so 0 keeps them self-consistent.
1235
+ contactPotentialVolts: 0,
1236
+ };
1237
+ /**
1238
+ * The generic tube rectifier, following the triode and pentode's discipline: coefficients taken
1239
+ * from a datasheet-anchored source this repository already holds, never re-derived and never
1240
+ * invented.
1241
+ *
1242
+ * The source is `thoughts/shared/experiments/power-domain-tube-rectifier/`, whose two cases fit
1243
+ * `K` from admitted datasheet operating points and reproduce them to better than 0.15%:
1244
+ *
1245
+ * ```
1246
+ * K (A/V^1.5) anchor reproduces
1247
+ * 5Y3GT 3.54e-4 50 V, 125 mA 0.125158 A
1248
+ * GZ34 3.21e-3 17 V, 225 mA 0.224998 A
1249
+ * ```
1250
+ *
1251
+ * **5Y3GT is the generic, and that choice is weaker than the triode's 12AX7.** The 12AX7 is the
1252
+ * overwhelmingly common preamp triode; there is no equivalently dominant rectifier, and the amp
1253
+ * corpus's three instances are one 5Y3GT, one 5U4 and one GZ34 — one each. The two anchored fits
1254
+ * are a **factor of nine apart in `K`**, which at 50 mA is 47 V of drop against 10 V, so this
1255
+ * default is a directly-heated 5Y3-class rectifier and a specific amp's sag needs a registry
1256
+ * entry. It is the conservative end of the range (the largest drop of the two) and it belongs to
1257
+ * `fender-5f1-champ`, the one amp whose preamp stage this repository already validates against an
1258
+ * ngspice oracle.
1259
+ *
1260
+ * Nothing here reads `PartNumber`. All three instances declare one — `5Y3GT`, `5U4`, `GZ34` — and
1261
+ * acting on it is stage 2's job through the registry, exactly as for an unregistered transistor.
1262
+ */
1263
+ const GENERIC_TUBE_DIODE = {
1264
+ perveance: 3.54e-4,
1265
+ exponent: 1.5,
1266
+ };
1267
+ /**
1268
+ * The supply extremes a circuit establishes. An op-amp cannot drive its output past
1269
+ * them, and that needs no part number: both facts are in the netlist. A circuit that
1270
+ * declares no supply has no rails, and its op-amps stay unbounded.
1271
+ */
1272
+ /**
1273
+ * Terminal roles that name a regulator's regulated output, as `terminalRoleToken` folds them.
1274
+ *
1275
+ * Closed vocabulary, compared as whole tokens. `output` is here because
1276
+ * `electro-harmonix-deluxe-memory-man`'s `U12_LM7915` spells it that way, and the only
1277
+ * pre-existing reader of this shape looked for `out` exactly and so could never have found it.
1278
+ */
1279
+ const REGULATOR_OUTPUT_ROLES = ["out", "output", "vout", "voutput"];
1280
+ /**
1281
+ * Supply voltages a **registered regulator** holds its output at, and the node each one drives.
1282
+ *
1283
+ * Why this exists: a three-terminal regulator declares no voltage of its own. `U12_LM7915` in
1284
+ * `electro-harmonix-deluxe-memory-man` carries `parameters: {}` — the −15 V is a property of the
1285
+ * *part*, stated once in the registry as a `voltage-source` law, not of the document. Both
1286
+ * readers below were built from the netlist alone, so the pedal's entire negative rail was
1287
+ * invisible to them while being perfectly visible to lowering, which stamps it as a `dc-source`.
1288
+ *
1289
+ * The cost of that gap was not subtle. All ten of that pedal's op-amps declare `vminus` on the
1290
+ * regulator's output node, `supplyRails` returned `low: 0` because it had only seen the +24 V
1291
+ * rail, and every op-amp was told it could not swing below ground. Outputs clipped at 0 V
1292
+ * instead of −15 V, and the DC error propagated until the NE570's summing node settled at
1293
+ * **−20.5 V**, five volts below a rail the compiler did not know existed.
1294
+ *
1295
+ * `supply-reference.ts` already had to learn this and says so in its header: it reads the lowered
1296
+ * `dc-source` stamps "rather than the declared `rail`/`battery` devices". This stage runs before
1297
+ * lowering, so it cannot read stamps — but it holds the registry, which is where the number is.
1298
+ */
1299
+ function registrySuppliedVoltages(netlist, registry) {
1300
+ const supplies = [];
1301
+ for (const device of netlist.devices) {
1302
+ // A device that states its own voltage is already read by both callers; this is only
1303
+ // for the ones whose voltage exists solely as a registered part fact.
1304
+ if (device.parameters.volts !== undefined) {
1305
+ continue;
1306
+ }
1307
+ const identity = identify(device, registry);
1308
+ if (identity === null) {
1309
+ continue;
1310
+ }
1311
+ const entry = registryEntryFor(registry, identity, device.nodes.length);
1312
+ if (entry === null || entry.model.kind !== "sections") {
1313
+ continue;
1314
+ }
1315
+ for (const section of entry.model.sections) {
1316
+ if (section.law.kind !== "voltage-source") {
1317
+ continue;
1318
+ }
1319
+ const volts = section.law.volts;
1320
+ if (!Number.isFinite(volts)) {
1321
+ continue;
1322
+ }
1323
+ const index = terminalWithRole(device.identity.terminalRoles, REGULATOR_OUTPUT_ROLES);
1324
+ if (index === null) {
1325
+ continue;
1326
+ }
1327
+ const node = device.nodes[index];
1328
+ if (node !== undefined && node !== 0) {
1329
+ supplies.push({ node, volts });
1330
+ }
1331
+ }
1332
+ }
1333
+ return supplies;
1334
+ }
1335
+ function supplyRails(netlist, regulated) {
1336
+ // Rails count as supplies too, or an op-amp on a rail-only pedal has no ceiling to
1337
+ // clip against and renders a voltage the circuit could not make.
1338
+ //
1339
+ // **An AC source is not a rail**, and is excluded by the same evidence the law uses: a
1340
+ // declared frequency. A mains inlet is not something an op-amp output can clip against --
1341
+ // it swings both ways at 60 Hz, and the DC rails a solid-state amp's op-amps actually see
1342
+ // are downstream of its rectifier and reservoir. Counting a 120 V mains peak as `railHigh`
1343
+ // would give every op-amp in a mains-powered amp a ceiling 20x too high, which is a wrong
1344
+ // answer that renders. No packet exercises this today: the pedal corpus has no AC supply,
1345
+ // and no amp document reaches a program.
1346
+ const volts = netlist.devices
1347
+ .filter((device) => device.kind === "voltage-source" || device.kind === "rail")
1348
+ .filter((device) => device.parameters.frequency === undefined)
1349
+ .map((device) => device.parameters.volts)
1350
+ .filter((value) => value !== undefined && Number.isFinite(value))
1351
+ // A regulator's rail counts the same as a declared one: it is a fixed supply the
1352
+ // circuit's op-amps clip against. See `registrySuppliedVoltages`.
1353
+ .concat(regulated.map((supply) => supply.volts));
1354
+ if (volts.length === 0) {
1355
+ return { high: null, low: null };
1356
+ }
1357
+ // Ground is a rail whenever any supply exists, so a single positive supply gives a
1358
+ // 0..V swing rather than a symmetric one.
1359
+ return { high: Math.max(0, ...volts), low: Math.min(0, ...volts) };
1360
+ }
1361
+ /**
1362
+ * The one AC frequency this document states, in Hz, or `null`.
1363
+ *
1364
+ * A transformer does not change frequency: every winding on one core runs at whatever the
1365
+ * primary is fed at. So a transformer lowered as independent secondary sources still needs the
1366
+ * mains frequency, and this is where it comes from -- the same place it already came from
1367
+ * before, since a coupled winding inherited it from the AC source stamped on the primary. The
1368
+ * change is that it is now read rather than propagated, not that a new fact is being assumed.
1369
+ *
1370
+ * **Zero or several distinct frequencies is `null`, and `null` is a refusal downstream.** One
1371
+ * value is unambiguous; a document with two different AC frequencies in it has no single
1372
+ * "mains" and this stage will not pick one. Measured across the 24 canonical amps: 20 declare
1373
+ * exactly one frequency, 4 declare none (`marshall-jcm800`, `orange-gro100`, `peavey-5150`,
1374
+ * and both `vox-ac15` packets, which declare no supply component at all), and none declares
1375
+ * two. So the several-frequencies branch is unexercised today and is written as a refusal for
1376
+ * the same reason every other unexercised branch here is.
1377
+ *
1378
+ * A frequency of 0 or less is not a waveform and is excluded, matching the `voltage-source`
1379
+ * law's own reading of a declared 0 Hz as DC.
1380
+ */
1381
+ function mainsFrequency(netlist) {
1382
+ const frequencies = new Set(netlist.devices
1383
+ .map((device) => device.parameters.frequency)
1384
+ .filter((value) => value !== undefined && Number.isFinite(value) && value > 0));
1385
+ return frequencies.size === 1 ? [...frequencies][0] : null;
1386
+ }
1387
+ /**
1388
+ * Is every one of this device's nodes private to itself — touched by no other device and
1389
+ * never ground?
1390
+ *
1391
+ * A component that shares no net with anything cannot affect the solve, however it would
1392
+ * have been modelled: physically, a disconnected chip does nothing. Refusing the whole
1393
+ * pedal for it is over-strict; several packets deliberately carry such blocks as declared
1394
+ * view-only power clusters (klon-centaur's `MAX1044_CLUSTER_VIEW_ONLY` draws the charge
1395
+ * pump on four nodes nothing else touches, and separately declares the rails it produces
1396
+ * as voltage-carrying ports — its own record says "not a solved terminal charge-pump
1397
+ * model"). The resolution is `open` plus a LOUD compile warning, so a cluster isolated by
1398
+ * a wiring mistake rather than by intent is still named, never silently swallowed.
1399
+ *
1400
+ * Deliberately narrow: ONE shared node — even ground — defeats it. pigtronix's LT1054
1401
+ * drives a diode-doubler from its switching pin, shares nets, and keeps refusing.
1402
+ */
1403
+ export function isElectricallyIsolated(device, devicesTouchingNode) {
1404
+ if (device.nodes.length === 0) {
1405
+ return false; // zero-terminal components are the unconnected-behavior scanner's beat
1406
+ }
1407
+ return device.nodes.every((node) => node !== 0 && (devicesTouchingNode.get(node) ?? 0) <= 1);
1408
+ }
1409
+ /** How many devices touch each node, counting a device once however many terminals it lands. */
1410
+ export function deviceCountByNode(netlist) {
1411
+ const counts = new Map();
1412
+ for (const device of netlist.devices) {
1413
+ for (const node of new Set(device.nodes)) {
1414
+ counts.set(node, (counts.get(node) ?? 0) + 1);
1415
+ }
1416
+ }
1417
+ return counts;
1418
+ }
1419
+ const CHARGE_PUMP_PARTS = new Set([
1420
+ "tc1044",
1421
+ "tc1044scpa",
1422
+ "max1044",
1423
+ "7662",
1424
+ "lt1054cp",
1425
+ "lt1054",
1426
+ ]);
1427
+ /**
1428
+ * A charge-pump IC whose output node is already backed by a declared rail is a
1429
+ * view-only structural anchor: the rail supplies the voltage, the IC adds nothing.
1430
+ * The Klon's MAX1044 is handled by `isElectricallyIsolated` (all nodes private);
1431
+ * this covers the wider class where the pump shares nets with the declared rail.
1432
+ */
1433
+ function isChargePumpWithDeclaredRail(device, nodeToVoltage, oneHopVoltage) {
1434
+ const partNumber = device.identity.partNumber;
1435
+ if (partNumber === null) {
1436
+ return false;
1437
+ }
1438
+ if (!CHARGE_PUMP_PARTS.has(partNumber.toLowerCase())) {
1439
+ return false;
1440
+ }
1441
+ return device.nodes.some((node) => node !== 0 && (nodeToVoltage.has(node) || oneHopVoltage.has(node)));
1442
+ }
1443
+ /**
1444
+ * An OTA's signal terminals, bound by the names the device declares for them.
1445
+ *
1446
+ * The five OTA parts in this corpus spell the same five pins five different ways, so
1447
+ * the closed vocabularies below are unions of what the sources actually declare, each
1448
+ * compared as a whole folded token by `terminalWithRole` -- never as a substring, and
1449
+ * never against a description. `terminalWithRole` also refuses an ambiguous match, so a
1450
+ * part declaring two terminals that both fold to `output` binds neither.
1451
+ *
1452
+ * Why this exists: this law is reached by `kind === "ota"` straight off the netlist,
1453
+ * which bypasses the registry's `sections` mapping entirely, so nothing had ever
1454
+ * reordered these terminals. Reading positions 0/1/2/3 of the *declared* order then
1455
+ * bound the CA3080's balance pin as its input, its non-inverting input as its output,
1456
+ * and its negative supply as its bias current -- and `bias === vee` makes
1457
+ * `iAbc = is * (exp(0) - 1) = 0`, so the stage had zero transconductance by
1458
+ * construction and `mxr-dyna-comp` was silent. Every OTA in the corpus mis-bound at
1459
+ * least one pin; the ones that worked did so by coincidence.
1460
+ */
1461
+ /**
1462
+ * Signal-pin positions of an 8-pin OTA package, 0-indexed.
1463
+ *
1464
+ * **Verified against the CA3094 datasheet** (Intersil/RCA `CA3094, CA3094A, CA3094B`, "30MHz,
1465
+ * High Output Current Operational Transconductance Amplifier", Pinouts, PDIP/SOIC top view), not
1466
+ * just inherited from the CA3080 this comment used to cite: pin 2 and pin 3 are the differential
1467
+ * inputs, pin 4 is GND/V-, pin 5 is I_ABC, pin 6 is the drive output (emitter), pin 7 is V+, pin 8
1468
+ * is the sink output (collector), pin 1 is external frequency compensation or the inhibit input.
1469
+ * Those map to the indices below exactly.
1470
+ *
1471
+ * **The input polarity is conditional, which the pinout diagram does not show.** The datasheet's
1472
+ * output-mode table gives it: taking the output at terminal 6 ("source" mode) makes terminal 2
1473
+ * inverting and terminal 3 non-inverting; taking it at terminal 8 ("sink" mode) swaps them,
1474
+ * because the collector inverts what the emitter follows. The indices below are the terminal-6
1475
+ * reading, which is the output this law binds. A circuit that used pin 8 instead would need the
1476
+ * inputs exchanged, so an OTA whose output is not pin 6 must not be resolved by this constant.
1477
+ */
1478
+ const OTA_DIP8_POSITIONS = {
1479
+ plus: 2,
1480
+ minus: 1,
1481
+ output: 5,
1482
+ bias: 4,
1483
+ vee: 3,
1484
+ };
1485
+ /**
1486
+ * The OTA's device-class law: what an unregistered OTA gets, and what a registered one falls back
1487
+ * to when its entry supplies no model. Terminals bind by role -- see `otaTerminalBinding`.
1488
+ */
1489
+ function otaDeviceClassLaw(device) {
1490
+ return {
1491
+ outcome: "law",
1492
+ device: device.id,
1493
+ law: {
1494
+ kind: "ota",
1495
+ transconductance: device.parameters.transconductance ?? 1e-3,
1496
+ saturationCurrent: device.parameters.saturationCurrent,
1497
+ thermalVoltage: device.parameters.thermalVoltage,
1498
+ ...otaTerminalBinding(device),
1499
+ },
1500
+ };
1501
+ }
1502
+ /**
1503
+ * An OTA's four signal terminals from its **declared** electrodes, or null when it does not
1504
+ * declare the three that decide the binding.
1505
+ *
1506
+ * Shared by `otaTerminalBinding` and `otaPackageOrderAssumed` so the resolver and the warning
1507
+ * cannot disagree about whether a document stated its pins.
1508
+ */
1509
+ function otaDeclaredBinding(device) {
1510
+ const declared = device.identity.declaredTerminalRoles;
1511
+ const declaredIndex = (role) => {
1512
+ const found = declared.findIndex((value) => value === role);
1513
+ return found === -1 ? null : found;
1514
+ };
1515
+ const plus = declaredIndex("nonInverting");
1516
+ const minus = declaredIndex("inverting");
1517
+ const output = declaredIndex("output");
1518
+ if (plus === null || minus === null || output === null) {
1519
+ return null;
1520
+ }
1521
+ return {
1522
+ terminalIndices: { plus, minus, output, bias: declaredIndex("bias") },
1523
+ veeIndex: declaredIndex("supplyNegative") ?? device.parameters.veeIndex,
1524
+ };
1525
+ }
1526
+ /**
1527
+ * An OTA whose four signal connections come from the **DIP-8 package order** rather than from
1528
+ * anything the document states about its electrodes.
1529
+ *
1530
+ * Exported so `findUnreadableTerminalRoles` can report exactly the devices this fallback catches,
1531
+ * instead of mirroring its condition. A warning kept on a different test from the resolver it
1532
+ * guards is how a silent mis-wiring was introduced once already in this compiler, on a switch,
1533
+ * and the same shape would be available here: the fallback needs eight all-null *names*, and a
1534
+ * predicate that asked about declared roles instead would report a different set of devices.
1535
+ *
1536
+ * The assumption itself is sound and deliberate -- reading a role-less eight-terminal shell as its
1537
+ * package beats reading positions 0..3, which puts the output on pin 3. It is still an assumption
1538
+ * the source does not make, which is the whole reason it should say so out loud.
1539
+ */
1540
+ export function otaPackageOrderAssumed(device) {
1541
+ // The declared branch of `otaTerminalBinding` decides before the package fallback is
1542
+ // reached, so a device that declares its electrodes assumes nothing and must not warn.
1543
+ // Shared with the resolver rather than restated: the first version of this predicate read
1544
+ // only the names, which meant a packet could declare every electrode correctly and still be
1545
+ // told its pins had been guessed.
1546
+ if (otaDeclaredBinding(device) !== null) {
1547
+ return false;
1548
+ }
1549
+ const names = device.identity.terminalRoles;
1550
+ return names.length === 8 && names.every((role) => role === null);
1551
+ }
1552
+ function otaTerminalBinding(device) {
1553
+ // A declared electrode decides; a name only speaks where the document declares nothing.
1554
+ const fromDeclaration = otaDeclaredBinding(device);
1555
+ if (fromDeclaration !== null) {
1556
+ return fromDeclaration;
1557
+ }
1558
+ // **The name vocabularies that used to sit here are gone.** Five sets -- plus, minus,
1559
+ // output, bias and vee -- spelled 24 ways between them, and emptying all five left every
1560
+ // one of the 142 corpus programs byte-identical with all 15 terminal bindings standing.
1561
+ // The 10 OTAs that need a binding declare their electrodes; the other 6 OTA laws are
1562
+ // registry *sections*, whose terminals the registry already selected in law order.
1563
+ //
1564
+ // A device that names none of its terminals still states its package by declaring
1565
+ // eight of them: `terminalRoleToken` maps a bare `pin1` to null on purpose, so
1566
+ // `electro-harmonix-small-stone`'s CA3094 shells arrive role-less with their pins in
1567
+ // package order. Reading that order as the package pinout is an assumption, but a far
1568
+ // better founded one than reading positions 0..3, which put the output on pin 3.
1569
+ // The names are read only for their *absence* here: `pin1`..`pin8` folds to null, which is
1570
+ // what says "this shell states a package rather than a set of electrodes".
1571
+ const names = device.identity.terminalRoles;
1572
+ if (otaPackageOrderAssumed(device)) {
1573
+ return {
1574
+ terminalIndices: {
1575
+ plus: OTA_DIP8_POSITIONS.plus,
1576
+ minus: OTA_DIP8_POSITIONS.minus,
1577
+ output: OTA_DIP8_POSITIONS.output,
1578
+ bias: OTA_DIP8_POSITIONS.bias,
1579
+ },
1580
+ veeIndex: OTA_DIP8_POSITIONS.vee,
1581
+ };
1582
+ }
1583
+ // Neither named nor a recognised package: leave the positional reading in place so a
1584
+ // registry `sections` entry, which has already mapped its own terminals, is unaffected.
1585
+ return { veeIndex: device.parameters.veeIndex };
1586
+ }
1587
+ /**
1588
+ * Law parameters a **registered part** contributes to a device the **source** split out of its
1589
+ * package.
1590
+ *
1591
+ * The two splits know different things and neither is sufficient alone. A registry `sections`
1592
+ * entry knows the part's numbers -- an M5218's 110 dB open-loop gain -- but decides the terminal
1593
+ * mapping by a positional *name* signature. A `devices:` declaration knows the terminals, because
1594
+ * the document states them and core scopes each role to one device, but says nothing about the
1595
+ * part. So the source decides the terminals and the registry decides the numbers.
1596
+ *
1597
+ * Measured before this existed: `boss-ch-1`'s M5218 split by declaration produced a byte-identical
1598
+ * topology -- same 151 stamps, same plus/minus/output nodes, same rails -- with `openLoopGain`
1599
+ * fallen from 316227.766 to the class default. That is the whole gap this closes.
1600
+ *
1601
+ * **Gated on `packageDeviceId`, so it cannot touch a device that reached here any other way.**
1602
+ * Widening the registry's reach over op-amps generally was tried on 2026-09-02 and measured wrong;
1603
+ * this is the opposite move, narrowed to devices whose split the source itself declared.
1604
+ *
1605
+ * Rails are excluded because they are the pedal's rather than the part's, which is the same
1606
+ * reason `sectionDevices` substitutes them. The device's own parameters win over these, so a
1607
+ * document that states a value keeps it.
1608
+ */
1609
+ const LAW_KIND_BY_DEVICE_KIND = {
1610
+ opamp: "ideal-opamp",
1611
+ ota: "ota",
1612
+ optocoupler: "optocoupler",
1613
+ };
1614
+ function packageDeviceParameters(device, registry) {
1615
+ if (device.packageDeviceId === undefined) {
1616
+ return {};
1617
+ }
1618
+ const expected = LAW_KIND_BY_DEVICE_KIND[device.kind];
1619
+ if (expected === undefined) {
1620
+ return {};
1621
+ }
1622
+ // **Matched by part id alone, deliberately not through `identify`.** That function guards the
1623
+ // registry's *terminal-mapping* use: it refuses a device whose terminal count does not equal
1624
+ // the entry's pinout length, and it refuses an op-amp that does not need registry sections at
1625
+ // all. Both are right for what they protect and both are wrong here -- a device the source
1626
+ // split out of a package has a subset of the package's pins by construction (five of the
1627
+ // M5218's eight), and it is asking for the part's numbers rather than for its own wiring.
1628
+ const partNumber = device.identity.partNumber;
1629
+ if (partNumber === null) {
1630
+ return {};
1631
+ }
1632
+ const folded = foldPartId(partNumber);
1633
+ const entry = registry.entries.find((each) => each.partIds.some((id) => foldPartId(id) === folded));
1634
+ const model = entry?.model;
1635
+ if (model === undefined || model.kind !== "sections") {
1636
+ return {};
1637
+ }
1638
+ const section = model.sections.find((each) => each.law.kind === expected);
1639
+ if (section === undefined) {
1640
+ return {};
1641
+ }
1642
+ const parameters = {};
1643
+ for (const [key, value] of Object.entries(section.law)) {
1644
+ if (typeof value === "number" && key !== "railHigh" && key !== "railLow") {
1645
+ parameters[key] = value;
1646
+ }
1647
+ }
1648
+ return parameters;
1649
+ }
1650
+ function resolveDevice(device, registry, rails, supply, taperByControl, mainsFrequencyHz, devicesTouchingNode, nodeToVoltage, oneHopVoltage, netlist) {
1651
+ // A component declared as one transistor that is really several devices in one shell. Opened
1652
+ // rather than stamped, for the reason `isNonExecutableIcSupportShell` opens an IC shell: there
1653
+ // is no single device here to describe, and a transistor law would invent one. Reported by
1654
+ // `findMultiDeviceShells`, which runs over the same predicate.
1655
+ if (isMultiDeviceTransistorShell(device)) {
1656
+ return { outcome: "law", device: device.id, law: { kind: "open" } };
1657
+ }
1658
+ // A source that states a device class we have no law for. Opened, not stamped with the
1659
+ // nearest kind the format could carry; reported by `findUnimplementedDeviceLaws`.
1660
+ if (declaresUnimplementedDeviceLaw(device)) {
1661
+ return { outcome: "law", device: device.id, law: { kind: "open" } };
1662
+ }
1663
+ if (requiresIdentification(device)) {
1664
+ // **The second resolution key, consulted only where the first has nothing.** A declared
1665
+ // program never overrides a registered model: a fixed-function part is still identified
1666
+ // and still takes the law its part number resolves to. It is reached when the registry
1667
+ // has no executable model to give, which for a reprogrammable chip is always -- one
1668
+ // catalog entry keyed on the part number cannot hold the three different effects
1669
+ // `TC25SC080AU-104` is across this corpus.
1670
+ //
1671
+ // Deliberately *after* the three shell exits below. An isolated chip, a view-only source
1672
+ // boundary and a charge pump with a declared rail are all statements that this component
1673
+ // is not in the audio path at all, and a program declaration does not put it back.
1674
+ const declared = () => resolveDeclaredProgram(device, netlist);
1675
+ if (isNonExecutableIcSupportShell(device)) {
1676
+ return openIc(device, "source-boundary-shell");
1677
+ }
1678
+ if (isElectricallyIsolated(device, devicesTouchingNode)) {
1679
+ // See `isElectricallyIsolated` -- and the warning is emitted by
1680
+ // `findElectricallyIsolatedIcs`, which compile() runs over the same predicate.
1681
+ return openIc(device, "electrically-isolated");
1682
+ }
1683
+ if (isChargePumpWithDeclaredRail(device, nodeToVoltage, oneHopVoltage)) {
1684
+ // The rail the pump generates is separately declared as a voltage-carrying
1685
+ // port; the IC itself is a view-only structural anchor, not a solved model.
1686
+ return openIc(device, "charge-pump-declared-rail");
1687
+ }
1688
+ const identity = identify(device, registry);
1689
+ if (identity === null) {
1690
+ // **The message has to say which rung failed, because two different failures land
1691
+ // here and they call for opposite fixes.** A device with no part number at all needs a
1692
+ // registry entry or a pinout. A device that NAMES a part number which matched nothing
1693
+ // has given its strongest evidence and had it rejected -- and its declared class is
1694
+ // then not allowed to supply a macro, because a macro asserts a specific chip identity
1695
+ // that the unmatched part number cannot support. Saying "declared type ... matched"
1696
+ // failed would be wrong in that second case: the class matched and was refused.
1697
+ const named = device.identity.partNumber?.trim();
1698
+ const fromProgram = declared();
1699
+ if (fromProgram !== null)
1700
+ return fromProgram;
1701
+ return openWhereClassPermits(device, named !== undefined && named !== ""
1702
+ ? `integrated circuit has no model: its part number "${named}" matched no registry entry, and a declared class may not supply a macro for a part whose identity is unresolved`
1703
+ : "integrated circuit has no model: no registered part id, declared type or pinout signature matched");
1704
+ }
1705
+ const model = registryModelFor(registry, identity, device.nodes.length);
1706
+ if (model === null) {
1707
+ const fromProgram = declared();
1708
+ if (fromProgram !== null)
1709
+ return fromProgram;
1710
+ return openWhereClassPermits(device, `part ${identity.partId} was identified but the registry supplies no model`);
1711
+ }
1712
+ if (model.kind === "law") {
1713
+ // A registered `open` is the registry *stating* that this part has no executable
1714
+ // model, which is a different claim from every other law it can supply and the only
1715
+ // one whose consequence -- a component in the source and not in the program -- a
1716
+ // reader has to be told about.
1717
+ if (model.law.kind === "open") {
1718
+ const fromProgram = declared();
1719
+ if (fromProgram !== null)
1720
+ return fromProgram;
1721
+ return openIc(device, "registry-open");
1722
+ }
1723
+ return { outcome: "law", device: device.id, law: model.law };
1724
+ }
1725
+ if (model.kind === "macro") {
1726
+ return resolveMacro(device, identity.partId, model.macro, registry, netlist);
1727
+ }
1728
+ // Expanded by `sectionDevices` before this runs, so reaching here is a compiler defect
1729
+ // rather than a source or registry one.
1730
+ return {
1731
+ outcome: "unsupported",
1732
+ device: device.id,
1733
+ reason: `part ${identity.partId} is multi-section and was not expanded`,
1734
+ };
1735
+ }
1736
+ // A registered part's numbers reach a source-declared device here; see
1737
+ // `packageDeviceParameters`. A no-op for every device that did not come from a `devices:`
1738
+ // declaration, which today is all of them.
1739
+ const fromRegistry = packageDeviceParameters(device, registry);
1740
+ if (Object.keys(fromRegistry).length > 0) {
1741
+ device = {
1742
+ ...device,
1743
+ parameters: { ...fromRegistry, ...device.parameters },
1744
+ };
1745
+ }
1746
+ switch (device.kind) {
1747
+ case "resistor": {
1748
+ // `parametersFor` already refuses a resistor that declares no resistance at all
1749
+ // (`netlist.ts`'s `component ... resistor has no Resistance`), so reaching here with
1750
+ // `ohms === 0` means the source stated exactly zero, not that it stated nothing.
1751
+ //
1752
+ // `mxr-noise-gate-line-driver`'s `R_U1B_FB` is `Resistance: { raw: "0 Ohm", value: 0,
1753
+ // unit: "Ω" }`, described in the same document as "Direct feedback wire from U1B
1754
+ // output to negative input represented as a zero-ohm link" -- a schematic-capture
1755
+ // convention for an ideal wire, not a missing or invalid value. `1 / 0` is not
1756
+ // representable as a conductance, so this reuses `SWITCH_ON_OHMS`, the same
1757
+ // electrically-indistinguishable-from-ideal short this file already stamps for a
1758
+ // closed switch, rather than refusing a resistance the source did in fact state.
1759
+ const ohms = device.parameters.ohms ?? 0;
1760
+ if (ohms === 0) {
1761
+ return {
1762
+ outcome: "law",
1763
+ device: device.id,
1764
+ law: { kind: "conductance", siemens: 1 / SWITCH_ON_OHMS },
1765
+ };
1766
+ }
1767
+ if (!(ohms > 0)) {
1768
+ return unsupported(device, "resistor has a non-positive resistance");
1769
+ }
1770
+ return {
1771
+ outcome: "law",
1772
+ device: device.id,
1773
+ law: { kind: "conductance", siemens: 1 / ohms },
1774
+ };
1775
+ }
1776
+ case "potentiometer": {
1777
+ const ohms = device.parameters.ohms ?? 0;
1778
+ if (!(ohms > 0)) {
1779
+ return unsupported(device, "potentiometer has a non-positive resistance");
1780
+ }
1781
+ if (device.control === null) {
1782
+ return unsupported(device, "potentiometer is bound to no control");
1783
+ }
1784
+ // Declared-only end resistance, and it has to fit inside the track twice: both
1785
+ // legs carry one, and a residual at or above half the track would leave the
1786
+ // wiper no travel at all (or invert it). Refused rather than clamped, because a
1787
+ // residual that large is a source error, not a value to quietly reinterpret.
1788
+ const declaredResidual = device.parameters.minOhms ?? 0;
1789
+ const residualOhms = declaredResidual > 0 && declaredResidual < ohms / 2
1790
+ ? declaredResidual
1791
+ : 0;
1792
+ // The wiper element this device represents is decided at stamp time from
1793
+ // terminal order; the law carries the whole track plus its taper.
1794
+ return {
1795
+ outcome: "law",
1796
+ device: device.id,
1797
+ law: {
1798
+ kind: "controlled-conductance",
1799
+ control: device.control,
1800
+ // Falls back to "linear" only for a control id `netlist.controls` never
1801
+ // carried -- unreachable for a real compiled netlist, since every device
1802
+ // bound to a control puts that id there (`netlist.ts`'s `controlIds`).
1803
+ taper: taperByControl.get(device.control) ?? "linear",
1804
+ totalOhms: ohms,
1805
+ side: "upper",
1806
+ residualOhms,
1807
+ },
1808
+ };
1809
+ }
1810
+ case "rheostat": {
1811
+ const maxOhms = device.parameters.maxOhms ?? 0;
1812
+ if (!(maxOhms > 0)) {
1813
+ return unsupported(device, "rheostat has a non-positive resistance");
1814
+ }
1815
+ if (device.control === null) {
1816
+ return unsupported(device, "rheostat is bound to no control");
1817
+ }
1818
+ // A residual minimum is normal -- a pot used as a rheostat rarely reaches
1819
+ // zero -- but it must be below the track, or the sweep runs backwards.
1820
+ const declaredMin = device.parameters.minOhms ?? 0;
1821
+ const minOhms = declaredMin > 0 && declaredMin < maxOhms ? declaredMin : 0;
1822
+ return {
1823
+ outcome: "law",
1824
+ device: device.id,
1825
+ law: {
1826
+ kind: "controlled-resistance",
1827
+ control: device.control,
1828
+ // Same fallback note as the pot law above: unreachable in practice, since
1829
+ // `netlist.controls` always carries an entry for a bound control's id.
1830
+ taper: taperByControl.get(device.control) ?? "linear",
1831
+ minOhms,
1832
+ maxOhms,
1833
+ },
1834
+ };
1835
+ }
1836
+ case "capacitor": {
1837
+ const farads = device.parameters.farads ?? 0;
1838
+ if (!(farads > 0)) {
1839
+ return unsupported(device, "capacitor has a non-positive capacitance");
1840
+ }
1841
+ // Declared-only DC leakage; 0 means ideal, and ideal is the diagnostic default.
1842
+ // `InsulationResistance` is already a resistance. The electrolytic form is the
1843
+ // datasheet's leakage current at rated voltage read as V/I -- a linear stand-in
1844
+ // for a current that in the physical part grows sub-linearly below the rating,
1845
+ // so at a working voltage under the rating this slightly overstates the leak.
1846
+ const insulationOhms = device.parameters.insulationOhms ?? 0;
1847
+ const leakageAmps = device.parameters.leakageAmps ?? 0;
1848
+ const ratedVolts = device.parameters.ratedVolts ?? 0;
1849
+ const leakageSiemens = insulationOhms > 0
1850
+ ? 1 / insulationOhms
1851
+ : leakageAmps > 0 && ratedVolts > 0
1852
+ ? leakageAmps / ratedVolts
1853
+ : 0;
1854
+ return {
1855
+ outcome: "law",
1856
+ device: device.id,
1857
+ law: { kind: "capacitance", farads, leakageSiemens },
1858
+ };
1859
+ }
1860
+ case "inductor": {
1861
+ // A ferrite bead spec'd by impedance-vs-frequency (e.g. "100 Ω @ 100 MHz")
1862
+ // carries no inductance in the source. At audio frequencies it is a small
1863
+ // inductor well below self-resonance; 10 µH matches the corpus' only
1864
+ // decoded bead (Boss DD-3a DSS306 "103" = 10 µH).
1865
+ const henries = device.parameters.henries ?? FERRITE_BEAD_DEFAULT_HENRIES;
1866
+ return {
1867
+ outcome: "law",
1868
+ device: device.id,
1869
+ law: {
1870
+ kind: "inductance",
1871
+ henries,
1872
+ ...windingResistance(device),
1873
+ },
1874
+ };
1875
+ }
1876
+ case "diode": {
1877
+ // **A catalogued tube rectifier is not a semiconductor.** Eight of the corpus's eleven
1878
+ // tube rectifiers are declared `kind: diode` while naming a tube part, and a vacuum
1879
+ // rectifier drops tens of volts where silicon drops less than one -- which sets the
1880
+ // amp's whole B+. Resolved by **exact whole part id** against the catalog, which is
1881
+ // admissible evidence; this is not the string inference the comment below rules out.
1882
+ // That one is about reading a *parameter* out of a part number's shape (`1N4733` versus
1883
+ // `1N4148`); this is a closed list of ids compared whole.
1884
+ const tubeRectifier = registryLawFor(registry, device.identity.partNumber, "tube-diode");
1885
+ if (tubeRectifier !== null) {
1886
+ return { outcome: "law", device: device.id, law: tubeRectifier };
1887
+ }
1888
+ const catalogued = registryLawFor(registry, device.identity.partNumber, "diode");
1889
+ if (catalogued !== null) {
1890
+ // Declared parameters win over the catalog, every one of them: the entries
1891
+ // exist to fill absence, not to overrule a packet that states the value.
1892
+ // A zener entry's forward parameters are avowed class defaults, so a source
1893
+ // that types its own saturation current or series resistance keeps it, and
1894
+ // a device the source declares as an LED stays one (`boss-ph-1r` declares
1895
+ // 11 V where the registry's RD11EB3 row says 10.99, and keeps that too).
1896
+ if (catalogued.kind === "diode") {
1897
+ const declared = device.parameters;
1898
+ if (declared.breakdownVolts !== undefined ||
1899
+ declared.saturationCurrent !== undefined ||
1900
+ declared.seriesResistance !== undefined ||
1901
+ device.isLed === true) {
1902
+ return {
1903
+ outcome: "law",
1904
+ device: device.id,
1905
+ law: {
1906
+ ...catalogued,
1907
+ breakdownVolts: declared.breakdownVolts ?? catalogued.breakdownVolts,
1908
+ saturationCurrent: declared.saturationCurrent ?? catalogued.saturationCurrent,
1909
+ seriesResistance: declared.seriesResistance ?? catalogued.seriesResistance,
1910
+ isLed: device.isLed === true ? true : catalogued.isLed,
1911
+ },
1912
+ };
1913
+ }
1914
+ }
1915
+ return { outcome: "law", device: device.id, law: catalogued };
1916
+ }
1917
+ const isLed = device.isLed === true;
1918
+ return {
1919
+ outcome: "law",
1920
+ device: device.id,
1921
+ law: {
1922
+ kind: "diode",
1923
+ // If it is an LED, use physical LED defaults (higher forward knee ~1.8V to 2.2V),
1924
+ // otherwise use class defaults (1N4148-ish silicon signal diode).
1925
+ saturationCurrent: device.parameters.saturationCurrent ?? (isLed ? 1e-12 : 2.52e-9),
1926
+ emissionCoefficient: 1.752,
1927
+ thermalVoltage: device.parameters.thermalVoltage ?? (isLed ? 0.05 : THERMAL_VOLTAGE),
1928
+ // 0 for an ordinary diode. Read from the source, never inferred from a part
1929
+ // number: `1N4733` is a 5.1 V zener and `1N4148` is not, and telling them apart
1930
+ // from the string is the prose matching the engineering principles forbid.
1931
+ breakdownVolts: device.parameters.breakdownVolts ?? 0,
1932
+ seriesResistance: device.parameters.seriesResistance ?? (isLed ? 2.0 : 1.0),
1933
+ isLed,
1934
+ },
1935
+ };
1936
+ }
1937
+ case "triode":
1938
+ if (device.nodes.length !== 3) {
1939
+ return unsupported(device, `triode has ${device.nodes.length} terminals, not three`);
1940
+ }
1941
+ // The registry refines the class default, exactly as `GENERIC_TRIODE`'s own note
1942
+ // says it should. Exact part number only -- see `registryLawFor`.
1943
+ return {
1944
+ outcome: "law",
1945
+ device: device.id,
1946
+ law: registryLawFor(registry, device.identity.partNumber, "triode") ?? {
1947
+ kind: "triode",
1948
+ ...GENERIC_TRIODE,
1949
+ },
1950
+ };
1951
+ case "pentode":
1952
+ // Four signal electrodes minimum. 8 of the corpus's 56 add a suppressor and 4 add
1953
+ // a heater as well; both are read and ignored by the lowering, because a
1954
+ // suppressor is tied to the cathode in practice and a heater carries no signal.
1955
+ if (device.nodes.length < 4) {
1956
+ return unsupported(device, `pentode has ${device.nodes.length} terminals, fewer than the four electrodes its law needs`);
1957
+ }
1958
+ return {
1959
+ outcome: "law",
1960
+ device: device.id,
1961
+ law: registryLawFor(registry, device.identity.partNumber, "pentode") ?? {
1962
+ kind: "pentode",
1963
+ ...GENERIC_PENTODE,
1964
+ },
1965
+ };
1966
+ case "tube-diode":
1967
+ // Two electrodes minimum. The corpus's three are all five-terminal duals -- two
1968
+ // plates, a shared cathode and two heaters -- and how many elements that becomes is
1969
+ // the lowering's question, not this one.
1970
+ if (device.nodes.length < 2) {
1971
+ return unsupported(device, `tube diode has ${device.nodes.length} terminals, fewer than a plate and a cathode`);
1972
+ }
1973
+ return {
1974
+ outcome: "law",
1975
+ device: device.id,
1976
+ law: registryLawFor(registry, device.identity.partNumber, "tube-diode") ?? {
1977
+ kind: "tube-diode",
1978
+ ...GENERIC_TUBE_DIODE,
1979
+ },
1980
+ };
1981
+ case "opamp": {
1982
+ const declaredDrop = registryOpampSwingDrop(registry, device.identity.partNumber);
1983
+ const declaredRails = railsAfterSwingDrop(opampRails(device, rails, nodeToVoltage), declaredDrop);
1984
+ const railHigh = declaredRails.high;
1985
+ const railLow = declaredRails.low;
1986
+ return {
1987
+ outcome: "law",
1988
+ device: device.id,
1989
+ law: {
1990
+ kind: "ideal-opamp",
1991
+ railHigh,
1992
+ railLow,
1993
+ openLoopGain: device.parameters.openLoopGain ?? DEFAULT_OPEN_LOOP_GAIN,
1994
+ ...(declaredDrop === null ? {} : { outputSwingDropVolts: declaredDrop }),
1995
+ },
1996
+ };
1997
+ }
1998
+ case "jack":
1999
+ return portEngage(device, supply);
2000
+ case "ground":
2001
+ // Interface and reference symbols carry no element of their own.
2002
+ return { outcome: "law", device: device.id, law: { kind: "open" } };
2003
+ case "rail": {
2004
+ // A rail is not an interface symbol. It **asserts a potential** at its node,
2005
+ // and 84 of the corpus's 90 declare that voltage. Treating it as `open`
2006
+ // deleted the supply: `ibanez-ts808` states its whole 9 V supply as one rail,
2007
+ // so every node sat at 0 V, no bias network biased anything, and the pedal
2008
+ // rendered leakage. A rail that declares no voltage is a named support shell
2009
+ // with nothing to assert, and stays open.
2010
+ const volts = device.parameters.volts;
2011
+ if (volts === undefined || !Number.isFinite(volts)) {
2012
+ return { outcome: "law", device: device.id, law: { kind: "open" } };
2013
+ }
2014
+ return {
2015
+ outcome: "law",
2016
+ device: device.id,
2017
+ law: { kind: "voltage-source", volts, sourceOhms: SUPPLY_SOURCE_OHMS },
2018
+ };
2019
+ }
2020
+ case "switch": {
2021
+ // A few amp packets encode the tremolo optocoupler shell as a three-terminal
2022
+ // `kind: switch` (`lampdrive`, `ldrsignal`, `ldrreturn`). Treat that exact
2023
+ // typed role set as the operator it is, not as a routed selector.
2024
+ if (hasExactRoleSet(device, ["lampdrive", "ldrsignal", "ldrreturn"])) {
2025
+ return {
2026
+ outcome: "law",
2027
+ device: device.id,
2028
+ law: {
2029
+ kind: "optocoupler",
2030
+ ledThresholdVolts: device.parameters.ledThresholdVolts ?? 1.6,
2031
+ ledTransconductance: device.parameters.ledTransconductance ?? 1e-3,
2032
+ ldrMinOhms: device.parameters.ldrMinOhms ?? 100,
2033
+ ldrMaxOhms: device.parameters.ldrMaxOhms ?? 1e7,
2034
+ ldrPowerLawCoefficientOhms: device.parameters.ldrPowerLawCoefficientOhms,
2035
+ ldrPowerLawExponent: device.parameters.ldrPowerLawExponent,
2036
+ },
2037
+ };
2038
+ }
2039
+ if (device.control === null) {
2040
+ const position = device.parameters.position ?? 1;
2041
+ if (device.nodes.length > 2) {
2042
+ return {
2043
+ outcome: "law",
2044
+ device: device.id,
2045
+ law: {
2046
+ kind: "fixed-selector",
2047
+ position,
2048
+ onOhms: SWITCH_ON_OHMS,
2049
+ offOhms: SWITCH_OFF_OHMS,
2050
+ },
2051
+ };
2052
+ }
2053
+ const isClosed = position >= 0.5;
2054
+ return {
2055
+ outcome: "law",
2056
+ device: device.id,
2057
+ law: {
2058
+ kind: "conductance",
2059
+ siemens: isClosed ? 1 / SWITCH_ON_OHMS : 1 / SWITCH_OFF_OHMS,
2060
+ },
2061
+ };
2062
+ }
2063
+ // Two terminals make or break; three or more route. 84 of the corpus's 118
2064
+ // wired switches have three or more, and stamping only the first two threw
2065
+ // every other throw away -- a selector silently reduced to a two-terminal
2066
+ // short, which is how a bypass switch ends up rendering the wrong path.
2067
+ return {
2068
+ outcome: "law",
2069
+ device: device.id,
2070
+ law: {
2071
+ kind: device.nodes.length > 2 ? "selector" : "switch",
2072
+ control: device.control,
2073
+ onOhms: SWITCH_ON_OHMS,
2074
+ offOhms: SWITCH_OFF_OHMS,
2075
+ },
2076
+ };
2077
+ }
2078
+ case "voltage-source": {
2079
+ const volts = device.parameters.volts ?? 0;
2080
+ // **A frequency is what makes this an AC source**, and the discriminator is the
2081
+ // presence of the parameter rather than anything in the packet's prose. Stage 1
2082
+ // writes `frequency` only when the component declares a typed `Frequency`, and a
2083
+ // battery cannot declare one, so a mains inlet and a 9 V cell are distinguishable by
2084
+ // schema shape alone. Before this, 21 of the two corpora's 122 supplies were AC by
2085
+ // their own text and every one of them compiled into a DC battery -- silently, which
2086
+ // is the failure class this pipeline refuses hardest.
2087
+ //
2088
+ // A declared **0 Hz is DC**, which is a physical reading rather than a guard: a
2089
+ // zero-frequency sine is a constant, and `amplitude * sin(0)` would hold the rail at
2090
+ // 0 V forever -- a wrong answer that renders. A negative or non-finite frequency is
2091
+ // not a waveform and falls through the same way.
2092
+ const frequency = device.parameters.frequency;
2093
+ if (frequency !== undefined &&
2094
+ Number.isFinite(frequency) &&
2095
+ frequency > 0) {
2096
+ // **Decided 2026-08-14: a typed AC-source magnitude means RMS.** This is a
2097
+ // convention this project declares, not a prose read -- the law never inspects
2098
+ // `raw`. A supply states `{raw: "120 VAC RMS assumed nominal", value: 120, unit:
2099
+ // "V"}`: the typed part is a bare magnitude, the word "RMS" lives only in `raw`,
2100
+ // and no stage may read it. Mains is quoted RMS by universal convention, and
2101
+ // every amp packet's prose agrees, so treating the typed magnitude as RMS is the
2102
+ // reading consistent with what these packets actually mean -- unlike the
2103
+ // previous reading (typed magnitude taken directly as peak), which put every
2104
+ // B+ figure from a mains inlet about 41% (`sqrt(2)`) high.
2105
+ //
2106
+ // So this law is where the declared RMS magnitude is converted to the peak
2107
+ // amplitude `AcSourceLaw.amplitudeVolts` states and the sine evaluation (here and
2108
+ // in the runtime) needs: `amplitudeVolts = volts * sqrt(2)`. Both consumers of
2109
+ // this field -- the runtime's `sin()` evaluation and the ngspice deck's `SIN`
2110
+ // amplitude parameter, which is itself peak -- read `amplitudeVolts` as peak, so
2111
+ // converting once here keeps them in agreement by construction rather than by
2112
+ // each independently reproducing the same `sqrt(2)`.
2113
+ //
2114
+ // **Escape hatch, unused today:** this conversion is unconditional because the
2115
+ // schema has no typed field distinguishing an RMS-declared supply from a
2116
+ // peak-declared one -- every AC `voltage-source` in both corpora is read as RMS.
2117
+ // If a packet is ever authored to mean peak, the source must say so through a new
2118
+ // typed field (there is none yet, so nothing exercises this branch); this law is
2119
+ // where that field would be read and the conversion skipped.
2120
+ return {
2121
+ outcome: "law",
2122
+ device: device.id,
2123
+ law: {
2124
+ kind: "ac-source",
2125
+ amplitudeVolts: volts * AC_SOURCE_RMS_TO_PEAK,
2126
+ frequencyHz: frequency,
2127
+ sourceOhms: SUPPLY_SOURCE_OHMS,
2128
+ },
2129
+ };
2130
+ }
2131
+ return {
2132
+ outcome: "law",
2133
+ device: device.id,
2134
+ law: { kind: "voltage-source", volts, sourceOhms: SUPPLY_SOURCE_OHMS },
2135
+ };
2136
+ }
2137
+ case "bjt":
2138
+ return {
2139
+ outcome: "law",
2140
+ device: device.id,
2141
+ law: {
2142
+ kind: "bjt",
2143
+ polarity: device.parameters.pnp === 1 ? "pnp" : "npn",
2144
+ saturationCurrent: device.parameters.saturationCurrent ?? SILICON_SATURATION_CURRENT,
2145
+ forwardBeta: device.parameters.beta ?? SILICON_FORWARD_BETA,
2146
+ reverseBeta: device.parameters.reverseBeta ?? 1,
2147
+ thermalVoltage: THERMAL_VOLTAGE,
2148
+ // Absent for every silicon part in the corpus, and for the one germanium
2149
+ // packet whose `LeakageCurrent` is a sentence rather than a value.
2150
+ leakageAmps: device.parameters.leakageAmps ?? 0,
2151
+ },
2152
+ };
2153
+ case "jfet":
2154
+ return {
2155
+ outcome: "law",
2156
+ device: device.id,
2157
+ law: {
2158
+ kind: "fet",
2159
+ channel: device.parameters.pChannel === 1 ? "p" : "n",
2160
+ // A JFET is depletion mode: it conducts at zero gate bias and pinches
2161
+ // off as the gate goes negative, so the threshold is negative.
2162
+ thresholdVolts: device.parameters.thresholdVolts ?? JFET_DEFAULT_THRESHOLD_VOLTS,
2163
+ transconductance: device.parameters.transconductance ?? JFET_DEFAULT_TRANSCONDUCTANCE,
2164
+ channelLengthModulation: 0,
2165
+ // Subthreshold conduction (see the `fet` law docs in types.ts): the
2166
+ // gradual region a knee-biased JFET actually sweeps in. MOSFETs
2167
+ // keep 0 below.
2168
+ subthresholdVolts: 0.07,
2169
+ // A JFET gate is a silicon PN junction, so it conducts once forward-biased.
2170
+ // Saturation current and scale are **reused from the triode grid**, which
2171
+ // `compact-mna-dynamic-triode-cell` reports 0 Newton failures with; the onset
2172
+ // is moved from a tube grid's 0.05 V to a silicon junction's 0.5 V. These are
2173
+ // not measured against any specific JFET and a registry entry should refine
2174
+ // them -- the same caveat the triode's grid defaults carry.
2175
+ gateSaturationCurrent: 1.0e-5,
2176
+ gateOnsetVolts: 0.5,
2177
+ gateScaleVolts: 0.06,
2178
+ },
2179
+ };
2180
+ case "mosfet":
2181
+ return {
2182
+ outcome: "law",
2183
+ device: device.id,
2184
+ law: {
2185
+ kind: "fet",
2186
+ channel: device.parameters.pChannel === 1 ? "p" : "n",
2187
+ thresholdVolts: device.parameters.thresholdVolts ?? 2,
2188
+ transconductance: device.parameters.transconductance ?? 1e-3,
2189
+ channelLengthModulation: 0,
2190
+ // Insulated gate: no subthreshold region is modelled, behavior unchanged.
2191
+ subthresholdVolts: 0,
2192
+ // An insulated gate draws no current at any bias. Zero, deliberately: this
2193
+ // is what the law did for every FET before the JFET junction was added.
2194
+ gateSaturationCurrent: 0,
2195
+ gateOnsetVolts: 0,
2196
+ gateScaleVolts: 1,
2197
+ },
2198
+ };
2199
+ case "ota":
2200
+ return otaDeviceClassLaw(device);
2201
+ case "inverter":
2202
+ return {
2203
+ outcome: "law",
2204
+ device: device.id,
2205
+ law: {
2206
+ kind: "inverter",
2207
+ transconductance: device.parameters.transconductance ?? 5e-3,
2208
+ biasVolts: device.parameters.biasVolts ?? 4.5,
2209
+ thresholdVolts: device.parameters.thresholdVolts ?? 2.0,
2210
+ },
2211
+ };
2212
+ case "nand-gate":
2213
+ return {
2214
+ outcome: "law",
2215
+ device: device.id,
2216
+ law: {
2217
+ kind: "nand-gate",
2218
+ thresholdVolts: device.parameters.thresholdVolts ?? 2.0,
2219
+ transconductance: device.parameters.transconductance ?? 1e-3,
2220
+ },
2221
+ };
2222
+ case "clock-driver":
2223
+ return {
2224
+ outcome: "law",
2225
+ device: device.id,
2226
+ law: {
2227
+ kind: "clock-driver",
2228
+ defaultFrequency: device.parameters.defaultFrequency,
2229
+ },
2230
+ };
2231
+ case "optocoupler":
2232
+ return {
2233
+ outcome: "law",
2234
+ device: device.id,
2235
+ law: {
2236
+ kind: "optocoupler",
2237
+ ledThresholdVolts: device.parameters.ledThresholdVolts ?? 1.6,
2238
+ ledTransconductance: device.parameters.ledTransconductance ?? 1e-3,
2239
+ ldrMinOhms: device.parameters.ldrMinOhms ?? 100,
2240
+ ldrMaxOhms: device.parameters.ldrMaxOhms ?? 1e7,
2241
+ ldrPowerLawCoefficientOhms: device.parameters.ldrPowerLawCoefficientOhms,
2242
+ ldrPowerLawExponent: device.parameters.ldrPowerLawExponent,
2243
+ },
2244
+ };
2245
+ case "transformer": {
2246
+ // A spring reverb tank arrives here because that is what its shell declares -- a
2247
+ // two-terminal-pair `Circuit.Transformer` with `InputImpedance`/`OutputImpedance`.
2248
+ // The interface is right and the behaviour is not: an ideal transformer is
2249
+ // memoryless, so a tank admitted as one is a wire with a turns ratio. An exact part
2250
+ // number is the evidence that separates the two, and it is the only evidence used
2251
+ // here -- never the shell's authored `SourceBoundaryRole` prose.
2252
+ const tankPartNumber = device.identity.partNumber;
2253
+ const tank = tankPartNumber === null
2254
+ ? undefined
2255
+ : springReverbTanks[foldPartId(tankPartNumber)];
2256
+ if (tank !== undefined) {
2257
+ return {
2258
+ outcome: "law",
2259
+ device: device.id,
2260
+ law: {
2261
+ kind: "spring-reverb",
2262
+ // Source first: the packet's own declared pair wins over the part
2263
+ // code's published figures, which are the fallback for a tank whose
2264
+ // shell states no impedances.
2265
+ inputOhms: device.parameters.primaryImpedanceOhms ?? tank.inputOhms,
2266
+ outputOhms: device.parameters.secondaryImpedanceOhms ?? tank.outputOhms,
2267
+ delaySeconds: tank.delaySeconds,
2268
+ decaySeconds: tank.decaySeconds,
2269
+ // Cost/character tradeoff, not a measured device fact: enough allpass
2270
+ // sections to disperse audibly, few enough to stay inside the sample
2271
+ // budget. Stated plainly so it is not read as a datasheet number.
2272
+ dispersionStages: 16,
2273
+ },
2274
+ };
2275
+ }
2276
+ // The source states a winding voltage in RMS, by the same project convention the
2277
+ // `ac-source` branch above records, and the conversion to peak happens here for the
2278
+ // same reason it happens there: one place, so the runtime's sine and the ngspice
2279
+ // deck's `SIN` amplitude agree by construction.
2280
+ // One entry per declared coil, in declaration order, so a transformer with two coils
2281
+ // of the same role keeps two voltages. A class-keyed record kept one.
2282
+ const windingAmplitudeVolts = (device.identity.declaredWindings ?? []).map((winding) => winding.voltageRmsVolts === null
2283
+ ? null
2284
+ : winding.voltageRmsVolts * AC_SOURCE_RMS_TO_PEAK);
2285
+ return {
2286
+ outcome: "law",
2287
+ device: device.id,
2288
+ law: {
2289
+ kind: "transformer",
2290
+ turnsRatio: device.parameters.ratio ?? 1,
2291
+ windingAmplitudeVolts,
2292
+ mainsFrequencyHz,
2293
+ windingSourceOhms: SUPPLY_SOURCE_OHMS,
2294
+ ...windingResistance(device),
2295
+ },
2296
+ };
2297
+ }
2298
+ case "logic-divider":
2299
+ return {
2300
+ outcome: "law",
2301
+ device: device.id,
2302
+ law: {
2303
+ kind: "logic-divider",
2304
+ thresholdVolts: device.parameters.thresholdVolts ?? 2.0,
2305
+ highVolts: device.parameters.highVolts ?? 5.0,
2306
+ },
2307
+ };
2308
+ case "analog-switch":
2309
+ return {
2310
+ outcome: "law",
2311
+ device: device.id,
2312
+ law: {
2313
+ kind: "analog-switch",
2314
+ onOhms: device.parameters.onOhms ?? 100.0,
2315
+ offOhms: device.parameters.offOhms ?? 1e9,
2316
+ thresholdVolts: device.parameters.thresholdVolts ?? 2.5,
2317
+ },
2318
+ };
2319
+ default:
2320
+ return unsupported(device, `device kind "${device.kind}" has no law`);
2321
+ }
2322
+ }
2323
+ /**
2324
+ * Source-boundary/support wrappers expressed as `ic` for topology continuity, not execution.
2325
+ *
2326
+ * These rows carry no executable device law and are deliberately represented as open circuits so
2327
+ * explicit package/boundary shells do not block execution for separately-declared active sections.
2328
+ */
2329
+ /**
2330
+ * Spring reverb tanks, by exact part number.
2331
+ *
2332
+ * **Closed, and deliberately not extrapolated.** Accutronics/Belton codes are decodable --
2333
+ * `4AB3C1B` is input `A` (8 Ohm), output `B` (2250 Ohm), decay `3` (long, 2.75-4.0 s) -- but
2334
+ * decoding an arbitrary code would admit tanks nobody has checked, and a tank this table does
2335
+ * not know stays a `transformer` exactly as it is today rather than getting guessed parameters.
2336
+ * Add a row when a packet declares a tank, the same standing every `part-catalog.ts` entry has.
2337
+ *
2338
+ * The impedances here are the published figures for the code and are used only when the packet
2339
+ * declares none of its own; the four corpus packets all declare `8 Ohm`/`2250 Ohm` themselves,
2340
+ * which agrees with the code and is what actually gets used.
2341
+ */
2342
+ const springReverbTanks = {
2343
+ // Accutronics type 4, the 16.75" long pan Fender used across the blackface reverb amps.
2344
+ // `delaySeconds` is the shortest of its three springs' published transit times (29/34/41 ms);
2345
+ // the runtime spreads the other two from it. `decaySeconds` is the midpoint of decay code
2346
+ // `3`'s stated 2.75-4.0 s band -- a band, so the midpoint is a choice, and it is named here
2347
+ // rather than hidden as a magic number.
2348
+ [foldPartId("4AB3C1B")]: {
2349
+ inputOhms: 8,
2350
+ outputOhms: 2250,
2351
+ delaySeconds: 0.029,
2352
+ decaySeconds: 3.375,
2353
+ },
2354
+ };
2355
+ /**
2356
+ * Device classes a source can *state* but this pipeline has no law for.
2357
+ *
2358
+ * **The problem this solves.** `.vdsp` carries a typed `kind` from a closed vocabulary, and that
2359
+ * vocabulary has no entry for every real device. A transcriber facing one picks the nearest kind
2360
+ * available and records the truth in the equally typed `sourceTypeName` --
2361
+ * `electro-harmonix-slapback-echo`'s `Q1_UJT` is `kind: bjt` with
2362
+ * `sourceTypeName: Circuit.UnijunctionTransistor` and `PartNumber: 2N4871`. Stamping it as a BJT
2363
+ * then models a device the source never claimed: a UJT is a negative-resistance relaxation
2364
+ * element, its `base1`/`base2` are the ends of one resistive bar rather than a collector and an
2365
+ * emitter, and no bias point makes a BJT behave like one. That packet's own `Role` for it is
2366
+ * `delay clock oscillator`, which is precisely the behaviour a BJT model cannot produce.
2367
+ *
2368
+ * **Typed evidence, compared whole.** `declaredType` is the source's own closed type vocabulary,
2369
+ * so this is a whole-value comparison against it -- not a reading of `Description`, `Role` or the
2370
+ * component name.
2371
+ *
2372
+ * **One entry, deliberately.** It is the only kind/type mismatch in the corpus that names a law
2373
+ * this pipeline lacks. Measured 2026-09-02 over all 142 packets: the other primitive-kind
2374
+ * mismatches are `circuit.led` on a `diode` (an LED *is* a diode and the law carries `isLed`) and
2375
+ * `circuit.tube` on a `diode` (the tube-rectifier-as-silicon case `diodeJunctions` already
2376
+ * documents and deliberately leaves unfiled). Every remaining mismatch sits on `kind: ic`, where
2377
+ * naming any class is exactly what that kind is for. Adding a class here on speculation would be
2378
+ * vocabulary against unexercised data.
2379
+ */
2380
+ const unimplementedDeviceLaws = new Set([
2381
+ "circuit.unijunctiontransistor",
2382
+ ]);
2383
+ /** An `ic`/`power-amp` given no element, tagged with the branch that decided it. */
2384
+ /**
2385
+ * A component's declared program as a resolution, or null where it declares none.
2386
+ *
2387
+ * **Why an unreadable declaration refuses rather than opens.** An `ic` with no model is opened and
2388
+ * the packet renders silence, which CLAUDE.md records as the gap that cost a session: the engine
2389
+ * cannot tell an unimplemented DSP pedal from an analog pedal with a severed net. A component that
2390
+ * *declares* its program is not that case. The source has stated what the chip does, so failing to
2391
+ * execute it is this pipeline's gap rather than the document's, and the console/ROM invariant
2392
+ * applies in full -- named, never silent.
2393
+ *
2394
+ * Blast radius today is zero: no corpus packet declares a program, so no packet that compiles can
2395
+ * change. That is the bound the design set for this step, and it is checked by the corpus ratchet
2396
+ * rather than asserted here.
2397
+ */
2398
+ function resolveDeclaredProgram(device, netlist) {
2399
+ const program = device.program;
2400
+ if (program === undefined)
2401
+ return null;
2402
+ const check = checkDeclaredProgram(program);
2403
+ if (!check.ok) {
2404
+ return {
2405
+ outcome: "unsupported",
2406
+ device: device.id,
2407
+ reason: `component declares a program this pipeline cannot execute: ${check.reason}`,
2408
+ };
2409
+ }
2410
+ // A swept gain reads its knob directly, like a scanned router, so the knob it names has to be
2411
+ // on the panel. Otherwise the gain would read a control that does not exist and sit at 0.5.
2412
+ for (const position of check.positions) {
2413
+ for (const op of position.ops) {
2414
+ if (op.op !== "mix")
2415
+ continue;
2416
+ for (const term of op.terms) {
2417
+ if (term.sweep !== undefined && !controlExists(term.sweep.controlId, netlist)) {
2418
+ return {
2419
+ outcome: "unsupported",
2420
+ device: device.id,
2421
+ reason: `component's program "${position.id}" sweeps a gain with control "${term.sweep.controlId}", which this document does not declare`,
2422
+ };
2423
+ }
2424
+ }
2425
+ }
2426
+ }
2427
+ // The audio boundary, read from the typed roles core 0.8.1 made legal on an opaque chip.
2428
+ // Reading the terminals' authored *names* instead is what the Typed Source Evidence rule
2429
+ // forbids, and it is why this fact had to ship upstream before this branch could exist.
2430
+ const roles = device.identity.declaredTerminalRoles;
2431
+ const audioIn = roles.indexOf("input");
2432
+ const audioOut = roles.indexOf("output");
2433
+ if (audioIn < 0 || audioOut < 0) {
2434
+ const missing = [
2435
+ audioIn < 0 ? "input" : null,
2436
+ audioOut < 0 ? "output" : null,
2437
+ ].filter((label) => label !== null);
2438
+ return {
2439
+ outcome: "unsupported",
2440
+ device: device.id,
2441
+ reason: `component declares a program but no terminal declares role ${missing.join(" or ")}, so its audio boundary is unknown; a program says what the chip computes, never which pins carry the signal`,
2442
+ };
2443
+ }
2444
+ // A cited control on a line's `delaySeconds` is a claim that a knob moves that length, and a
2445
+ // claim nothing wires is a dead knob. Two routes, by how the source says the chip reads it:
2446
+ //
2447
+ // - **scanned / tapped**: per line, below, since a DELAY position and a TEMPO position read
2448
+ // different controls. The knob has to be on the panel; core checked the reader is on the
2449
+ // board.
2450
+ // - **node**: a solved wiper voltage through the block's one `parameter` port, and so at most
2451
+ // one control across the whole block, as before.
2452
+ for (const position of program.positions) {
2453
+ for (const parameters of Object.values(position.lines)) {
2454
+ for (const parameter of Object.values(parameters)) {
2455
+ if (parameter.read === "node" || parameter.source === undefined)
2456
+ continue;
2457
+ if (parameter.control === undefined || !controlExists(parameter.control, netlist)) {
2458
+ return {
2459
+ outcome: "unsupported",
2460
+ device: device.id,
2461
+ reason: `component's program "${position.id}" cites ${parameter.read} control "${parameter.control ?? "(none)"}", which this document does not declare`,
2462
+ };
2463
+ }
2464
+ }
2465
+ }
2466
+ }
2467
+ let declaredParameter = null;
2468
+ const timeControl = citedNodeControl(program);
2469
+ if (timeControl !== null) {
2470
+ const node = selectorNodeFor(timeControl, netlist);
2471
+ const supply = highestPositiveRailVolts(netlist);
2472
+ if (typeof node === "number" && supply !== null) {
2473
+ declaredParameter = { controlId: timeControl, node, referenceVolts: supply };
2474
+ }
2475
+ }
2476
+ // The router. Only needed where there is a choice to make: one program runs whatever any
2477
+ // control is doing.
2478
+ let declaredRouter = null;
2479
+ const router = program.router;
2480
+ if (router !== null) {
2481
+ if (router.positions < check.positions.length) {
2482
+ return {
2483
+ outcome: "unsupported",
2484
+ device: device.id,
2485
+ reason: `component declares ${check.positions.length} programs but its router says control "${router.control}" has only ${router.positions} positions`,
2486
+ };
2487
+ }
2488
+ // **A scanned control has no node, by declaration.** The chip that reads it sits behind
2489
+ // a path the source cannot state, so there is nothing to resolve and nothing to refuse;
2490
+ // the runtime takes the control's position directly. Core has already checked that the
2491
+ // reader named exists in the document, which is what keeps this from being a way to
2492
+ // make any dangling control appear to work.
2493
+ let node = null;
2494
+ let supply = null;
2495
+ if (router.read === "node") {
2496
+ const resolved = selectorNodeFor(router.control, netlist);
2497
+ if (typeof resolved === "string") {
2498
+ return { outcome: "unsupported", device: device.id, reason: resolved };
2499
+ }
2500
+ node = resolved;
2501
+ supply = highestPositiveRailVolts(netlist);
2502
+ if (supply === null) {
2503
+ return {
2504
+ outcome: "unsupported",
2505
+ device: device.id,
2506
+ reason: `component declares a router on control "${router.control}" but the document declares no positive supply rail, so the control's node has no full scale to be read against`,
2507
+ };
2508
+ }
2509
+ }
2510
+ else if (!controlExists(router.control, netlist)) {
2511
+ // A scanned router still has to name a control this document declares. Otherwise the
2512
+ // knob it claims to read is not on the panel at all.
2513
+ return {
2514
+ outcome: "unsupported",
2515
+ device: device.id,
2516
+ reason: `component declares a scanned router on control "${router.control}", which this document does not declare`,
2517
+ };
2518
+ }
2519
+ // Dense, one entry per detent: a lookup per sample must be an index, not a search.
2520
+ // -1 is "the source declares no program here", which is a statement rather than a gap.
2521
+ const byId = new Map(check.positions.map((position, index) => [position.id, index]));
2522
+ const routes = new Array(router.positions).fill(-1);
2523
+ for (const route of router.routes) {
2524
+ const index = byId.get(route.program);
2525
+ if (index === undefined) {
2526
+ return {
2527
+ outcome: "unsupported",
2528
+ device: device.id,
2529
+ reason: `router sends position ${route.position} to program "${route.program}", which this component does not declare`,
2530
+ };
2531
+ }
2532
+ routes[route.position] = index;
2533
+ }
2534
+ declaredRouter = {
2535
+ controlId: router.control,
2536
+ node,
2537
+ referenceVolts: supply,
2538
+ positions: router.positions,
2539
+ routes,
2540
+ };
2541
+ }
2542
+ else if (check.positions.length > 1) {
2543
+ return {
2544
+ outcome: "unsupported",
2545
+ device: device.id,
2546
+ reason: `component declares ${check.positions.length} programs and no router saying which control chooses between them`,
2547
+ };
2548
+ }
2549
+ return {
2550
+ outcome: "macro",
2551
+ device: device.id,
2552
+ macro: {
2553
+ // The part's own id, so a composition carries what chip it came from rather
2554
+ // than a synthetic name. A declared program is not a registry model and must
2555
+ // not borrow one's identity.
2556
+ modelId: device.identity.partNumber?.trim() || `${device.id}-program`,
2557
+ parameters: {},
2558
+ portTerminals: [audioIn, audioOut],
2559
+ audioPortImpedanceOhms: DECLARED_PROGRAM_PORT_OHMS,
2560
+ parameterTerminal: null,
2561
+ parameterReferenceVolts: null,
2562
+ declaredParameter,
2563
+ declaredPositions: check.positions.map((position) => ({
2564
+ id: position.id,
2565
+ ops: position.ops,
2566
+ out: publishedTemp(position.ops),
2567
+ lines: Object.fromEntries(position.lines.map((lineId) => [
2568
+ lineId,
2569
+ (() => {
2570
+ const sweep = declaredLineSweep(program, position.id, lineId);
2571
+ return {
2572
+ delaySeconds: declaredLineSeconds(program, position.id, lineId),
2573
+ minSeconds: declaredLineFloorSeconds(program, position.id, lineId),
2574
+ ...(sweep === undefined ? {} : { sweep }),
2575
+ };
2576
+ })(),
2577
+ ])),
2578
+ })),
2579
+ declaredRouter,
2580
+ },
2581
+ };
2582
+ }
2583
+ /**
2584
+ * Audio port impedances for a declared composition.
2585
+ *
2586
+ * A registry macro states its part's real input and output impedances. A declaration
2587
+ * does not, and inventing per-part numbers from nothing would be exactly the guess this
2588
+ * programme exists to remove -- so these are the neutral pair: a high input that loads
2589
+ * the stage before it as little as a chip's input pin does, and a low output that drives
2590
+ * the stage after it. Stated here once rather than spread as literals.
2591
+ */
2592
+ const DECLARED_PROGRAM_PORT_OHMS = { input: 1e6, output: 1e3 };
2593
+ /**
2594
+ * Which temporary a declared position publishes.
2595
+ *
2596
+ * **The last value the graph produces**, which is the last op carrying an `out`. Not simply
2597
+ * the last op: `delay-push` writes into a line and produces nothing, and a delay graph
2598
+ * almost always ends with one, so taking the final index would publish a temporary that
2599
+ * was never written and render silence on a program that is correct.
2600
+ *
2601
+ * The format does not state this and does not need to. A composition is evaluated top to
2602
+ * bottom and a source may only read an earlier op, so "the last value produced" is
2603
+ * unambiguous from the op list alone.
2604
+ */
2605
+ function publishedTemp(ops) {
2606
+ for (let index = ops.length - 1; index >= 0; index -= 1) {
2607
+ const op = ops[index];
2608
+ if (op !== undefined && "out" in op)
2609
+ return op.out;
2610
+ }
2611
+ return 0;
2612
+ }
2613
+ /**
2614
+ * The cited delay for one line of one position, or zero where the declaration cites none.
2615
+ *
2616
+ * **An uncited parameter is not set**, which is the declaration format's own rule: a
2617
+ * number with no `source` is not evidenced and the block makes no claim on that axis.
2618
+ * Zero here means the line has no length, which a delay op then reads as no delay -- the
2619
+ * honest reading of "the document does not say".
2620
+ */
2621
+ function declaredLineSeconds(program, positionId, lineId) {
2622
+ const position = program.positions.find((entry) => entry.id === positionId);
2623
+ const cited = position?.lines[lineId]?.delaySeconds;
2624
+ if (cited === undefined || cited.source === undefined)
2625
+ return 0;
2626
+ // The maximum of the cited range: the line is sized for the longest delay the mode can
2627
+ // be asked for, exactly as a clocked macro sizes for the longest its knob can reach.
2628
+ return Number.isFinite(cited.max) && cited.max > 0 ? cited.max : 0;
2629
+ }
2630
+ /**
2631
+ * The minimum of a line's cited range, or 0 where the source cites none.
2632
+ *
2633
+ * The floor a control sweeps up from; the line itself is sized from the maximum. A range the
2634
+ * source does not cite stays unset, as for the maximum, and so does a minimum that is not below
2635
+ * the maximum -- a floor that reaches the top would silently pin the knob.
2636
+ */
2637
+ function declaredLineFloorSeconds(program, positionId, lineId) {
2638
+ const position = program.positions.find((entry) => entry.id === positionId);
2639
+ const cited = position?.lines[lineId]?.delaySeconds;
2640
+ if (cited === undefined || cited.source === undefined)
2641
+ return 0;
2642
+ return Number.isFinite(cited.min) && cited.min > 0 && cited.min < cited.max ? cited.min : 0;
2643
+ }
2644
+ /**
2645
+ * The one control a declaration's **node-read** parameters name, or null.
2646
+ *
2647
+ * **One, deliberately.** A composed block has a single `parameter` port, so node-read citations
2648
+ * of two different controls have no faithful lowering, and taking the first would silently drop
2649
+ * the second knob. Null in that case, which leaves those lengths at their stated maximum. Scanned
2650
+ * and tapped citations do not come through here: each line carries its own (`ComposedLineSweep`).
2651
+ */
2652
+ function citedNodeControl(program) {
2653
+ const named = new Set();
2654
+ for (const position of program.positions) {
2655
+ for (const parameters of Object.values(position.lines)) {
2656
+ for (const parameter of Object.values(parameters)) {
2657
+ if (parameter.read === "node" &&
2658
+ parameter.control !== undefined &&
2659
+ parameter.source !== undefined) {
2660
+ named.add(parameter.control);
2661
+ }
2662
+ }
2663
+ }
2664
+ }
2665
+ return named.size === 1 ? ([...named][0] ?? null) : null;
2666
+ }
2667
+ /**
2668
+ * The per-line sweep a line's cited `delaySeconds` declares, or undefined for a fixed or
2669
+ * node-read length.
2670
+ */
2671
+ function declaredLineSweep(program, positionId, lineId) {
2672
+ const position = program.positions.find((entry) => entry.id === positionId);
2673
+ const cited = position?.lines[lineId]?.delaySeconds;
2674
+ if (cited === undefined ||
2675
+ cited.source === undefined ||
2676
+ cited.control === undefined ||
2677
+ cited.read === "node") {
2678
+ return undefined;
2679
+ }
2680
+ return { controlId: cited.control, read: cited.read, ratio: cited.ratio ?? 1 };
2681
+ }
2682
+ /**
2683
+ * The node whose voltage carries a control's position, or why none does.
2684
+ *
2685
+ * **This is the pot's wiper, and it is deliberately not a pin of the declaring chip.** The
2686
+ * knob does not reach the DSP; on a DD-5 it reaches the CPU's analog input, and the CPU tells
2687
+ * the DSP which program to run. What the engine can solve is the voltage the wiper produces,
2688
+ * and that is all a router needs. Requiring the node to sit on the declaring component would
2689
+ * refuse every firmware-mediated control, which is most of them.
2690
+ *
2691
+ * The wiper is found by its declared `role`, a closed vocabulary compared whole. A control
2692
+ * bound to a device with no wiper -- a plain switch -- has no continuous reading to quantize,
2693
+ * and is refused by name rather than guessed at.
2694
+ */
2695
+ function selectorNodeFor(control, netlist) {
2696
+ if (netlist === undefined) {
2697
+ return `component declares a router on control "${control}" but this stage was given no netlist to resolve it against`;
2698
+ }
2699
+ const bound = netlist.devices.filter((device) => device.control === control);
2700
+ if (bound.length === 0) {
2701
+ return `component declares a router on control "${control}", which no device in this document is bound to`;
2702
+ }
2703
+ const wipers = [];
2704
+ for (const device of bound) {
2705
+ for (const [index, role] of device.identity.declaredTerminalRoles.entries()) {
2706
+ if (role !== "wiper")
2707
+ continue;
2708
+ const node = device.nodes[index];
2709
+ if (node !== undefined)
2710
+ wipers.push(node);
2711
+ }
2712
+ }
2713
+ const distinct = [...new Set(wipers)];
2714
+ if (distinct.length === 0) {
2715
+ return `component declares a router on control "${control}", but no device bound to it declares a wiper terminal, so the control produces no voltage this engine can read`;
2716
+ }
2717
+ if (distinct.length > 1) {
2718
+ return `component declares a router on control "${control}", and ${distinct.length} distinct wiper nodes are bound to it, so which one carries the selection is ambiguous`;
2719
+ }
2720
+ return distinct[0] ?? -1;
2721
+ }
2722
+ /** Whether the document declares this panel control at all. */
2723
+ function controlExists(control, netlist) {
2724
+ return netlist?.controls.some((candidate) => candidate.id === control) ?? false;
2725
+ }
2726
+ /** The highest positive rail the document declares, or null where it declares none. */
2727
+ function highestPositiveRailVolts(netlist) {
2728
+ if (netlist === undefined)
2729
+ return null;
2730
+ let highest = null;
2731
+ for (const device of netlist.devices) {
2732
+ if (device.kind !== "voltage-source" && device.kind !== "rail")
2733
+ continue;
2734
+ const volts = device.parameters.volts;
2735
+ if (typeof volts !== "number" || !Number.isFinite(volts) || volts <= 0)
2736
+ continue;
2737
+ if (highest === null || volts > highest)
2738
+ highest = volts;
2739
+ }
2740
+ return highest;
2741
+ }
2742
+ function openIc(device, reason) {
2743
+ return {
2744
+ outcome: "law",
2745
+ device: device.id,
2746
+ law: { kind: "open" },
2747
+ openReason: reason,
2748
+ };
2749
+ }
2750
+ /**
2751
+ * Declared component classes for which an unmodellable chip is **opened rather than refused**.
2752
+ *
2753
+ * **This is a degradation rule, not an identification rung, and the distinction is the whole
2754
+ * point.** It never says what a component *is*, produces no `PartIdentity`, and is consulted only
2755
+ * after the registry has already failed to bind a model -- so an exact part id and a fitting
2756
+ * pinout always win. `identify.ts`'s ladder is untouched.
2757
+ *
2758
+ * **What it replaced.** Until 2026-09-04 these class names sat in the `declaredTypes` of the
2759
+ * catalog's DRAM entry (`M12L64164A`), and `Circuit.IC` additionally in the uPC1252H2 VCA's, so a
2760
+ * chip the registry could not place was *identified as one of those parts* and took its law.
2761
+ * Measured: `boss-dd-3b`'s `SA571D` compandor and `electro-harmonix-holy-grail`'s `CS4811` DSP both
2762
+ * reported `partId: M12L64164A` with evidence `exact-part`, and four components reached the VCA
2763
+ * entry only by declaring the generic `Circuit.IC`. A wide `declaredTypes` net is a registry entry
2764
+ * claiming a class it does not model, which is the wrong answer `identify.ts` refuses to give:
2765
+ * "a wrong identity is worse than none". The degradation those entries were really providing is
2766
+ * this, stated as itself.
2767
+ *
2768
+ * **Sized from measurement, not from the old list.** The DRAM entry carried seventeen classes;
2769
+ * these are the seven the corpus exercises. The other ten (`Circuit.AudioCodec`, `Circuit.Codec`,
2770
+ * `Circuit.VoltageDetector`, `Circuit.SwitchingRegulator`, `Circuit.EEPROM`,
2771
+ * `Circuit.SupportChip`, `Circuit.DCDCConverter`, `Circuit.Compander`, `Circuit.PowerConverter`,
2772
+ * `Circuit.LogicIC`) are reached today only by exact part id, so listing them would be
2773
+ * speculative vocabulary against unexercised data -- and dropping them is what makes a future
2774
+ * compandor or logic IC whose pinout does not fit **refuse and name itself** rather than quietly
2775
+ * become a hole. Several of these classes have modelled members: a `Circuit.LogicIC` is often a
2776
+ * CD4049 inverter, a `Circuit.Compander` an NE570 that binds. Membership here is not a claim about
2777
+ * the class.
2778
+ *
2779
+ * **`circuit.ic` is deliberately absent**, and the tests are what settled it. It is the format's
2780
+ * generic "this is a chip", so admitting it would mean *no* unidentified chip ever refuses again --
2781
+ * it defeats the stated decision that an unknown chip makes the pedal unsupported, and it stops
2782
+ * `emptyRegistry` refusing at all, which is the measurement `--empty-registry` exists to take.
2783
+ * Four corpus components declaring it still need to compile; `namesNoPartToLookUp` is the narrower
2784
+ * reason they get instead.
2785
+ */
2786
+ const classesOpenedWithoutModel = new Set([
2787
+ "circuit.digitalsignalprocessor",
2788
+ "circuit.microcontroller",
2789
+ "circuit.memoryic",
2790
+ "circuit.resetsupervisor",
2791
+ "circuit.crystal",
2792
+ "circuit.sourcevisiblesubsystem",
2793
+ // A converter is the same case as the DSP and the microcontroller above it: what it does to a
2794
+ // sample is digital and no dump makes it modellable, so an open with a named warning is the
2795
+ // honest outcome and a refusal that takes the whole packet down is not.
2796
+ //
2797
+ // Reached on `boss-dd-5` only once its supply rails were declared. Before that its AK5345-VS
2798
+ // was excused for being electrically isolated, which is the weaker reason: a packet should
2799
+ // not depend on a chip being unwired in order to compile.
2800
+ //
2801
+ "circuit.adc",
2802
+ // Admitted on a packet, as the rule above demanded before it was. `boss-dd-5` v1.36 wires its
2803
+ // output mixer as drawn, so its uPD6379's RO pin now drives the panning-out wet chain and the
2804
+ // chip stopped being isolated. Same case as the ADC: the conversion is the declared program's
2805
+ // output, what the chip does to a sample is digital, and refusing it would take a working
2806
+ // packet down for wiring what the sheet shows. The other two corpus DACs (`boss-rv-3`,
2807
+ // `boss-dsd-3`) compile byte-identically with and without this entry.
2808
+ "circuit.dac",
2809
+ ]);
2810
+ /** Whether the source states one of {@link classesOpenedWithoutModel}. */
2811
+ function declaresClassOpenedWithoutModel(device) {
2812
+ const declaredType = device.identity.declaredType;
2813
+ return (declaredType !== null &&
2814
+ classesOpenedWithoutModel.has(foldToken(declaredType)));
2815
+ }
2816
+ /**
2817
+ * A component declaring the generic `Circuit.IC` and **no part number at all**.
2818
+ *
2819
+ * **This is the discriminator the failing tests produced, and it is sharper than the class.** The
2820
+ * `unknownChip` fixture declares `Circuit.IC` with `PartNumber: UNKNOWN-PART-XYZ`, and it must
2821
+ * refuse: someone fitted a real chip, the catalog does not model it, and the refusal is
2822
+ * actionable -- add the part. The four corpus components here declare `Circuit.IC` and name no
2823
+ * part, so every rung of `identify`'s ladder is empty *by construction*: there is no part id to
2824
+ * match, the class is the format's generic "this is a chip", and rung 3 sees only shell pin names
2825
+ * (`jackinput`, `codeclinrin`, `outputshellin`). A refusal nobody can act on -- there is no part
2826
+ * number to add to the catalog -- is a worse answer than an open the reader is told about.
2827
+ *
2828
+ * `jhs-clover`'s `ACTIVE_EQ_BOUNDARY` and `boss-st-2`'s three analog-support shells are the four,
2829
+ * and each is a boundary the packet drew rather than a chip it fitted. `jhs-clover`'s says so in
2830
+ * its own `SourceBoundaryStatus`, which nothing reads -- and reading it was not chosen here
2831
+ * because exactly one component in 142 documents carries that property, so it would be a rule
2832
+ * sized to one row.
2833
+ *
2834
+ * **Checked only after identification has already failed**, like the class rule beside it, so a
2835
+ * part-less component the registry *can* place by declared type -- a `Circuit.DelayMemoryChip`
2836
+ * reaching the bucket-brigade macro -- never comes near this.
2837
+ */
2838
+ function namesNoPartToLookUp(device) {
2839
+ return (device.identity.partNumber === null &&
2840
+ device.identity.declaredType !== null &&
2841
+ foldToken(device.identity.declaredType) === "circuit.ic");
2842
+ }
2843
+ /**
2844
+ * An unmodellable IC opened because its source states one of the classes above, or the refusal it
2845
+ * would otherwise have been.
2846
+ *
2847
+ * **The refusal is still the default**, and `identify.ts` records why: an `ic` has no behaviour
2848
+ * except a part's, so the registry's silence is a refusal rather than a default. What this adds is
2849
+ * that a digital or support subsystem the source declares as such does not take the whole pedal
2850
+ * down with it -- the analog circuit around a DSP is still a circuit. The suppressed refusal is
2851
+ * carried onto the resolution so the warning can say which lookup failed and how, which is the
2852
+ * half a reader can act on.
2853
+ */
2854
+ function openWhereClassPermits(device, refusal) {
2855
+ if (!declaresClassOpenedWithoutModel(device) && !namesNoPartToLookUp(device)) {
2856
+ return { outcome: "unsupported", device: device.id, reason: refusal };
2857
+ }
2858
+ return {
2859
+ outcome: "law",
2860
+ device: device.id,
2861
+ law: { kind: "open" },
2862
+ openReason: "declared-class-without-model",
2863
+ insteadOfRefusal: refusal,
2864
+ };
2865
+ }
2866
+ /**
2867
+ * Does this device's source state a class this pipeline cannot model? Primitive kinds only: an
2868
+ * `ic` declares its class in `declaredType` by design and is resolved through the registry.
2869
+ */
2870
+ function declaresUnimplementedDeviceLaw(device) {
2871
+ if (requiresIdentification(device)) {
2872
+ return false;
2873
+ }
2874
+ const declaredType = device.identity.declaredType;
2875
+ return (declaredType !== null &&
2876
+ unimplementedDeviceLaws.has(foldToken(declaredType)));
2877
+ }
2878
+ /**
2879
+ * The devices opened by `declaresUnimplementedDeviceLaw`, so a packet is told which stated device
2880
+ * is absent from its program rather than silently receiving the nearest law.
2881
+ */
2882
+ export function findUnimplementedDeviceLaws(lawed) {
2883
+ const opened = new Set(lawed.resolutions.flatMap((resolution) => resolution.outcome === "law" && resolution.law.kind === "open"
2884
+ ? [resolution.device]
2885
+ : []));
2886
+ return lawed.netlist.devices.flatMap((device) => opened.has(device.id) && declaresUnimplementedDeviceLaw(device)
2887
+ ? [
2888
+ {
2889
+ code: "device-law-not-implemented",
2890
+ device: device.id,
2891
+ detail: `${device.id} is declared \`kind: ${device.kind}\` but its source states ` +
2892
+ `${device.identity.declaredType}, a device class this pipeline has no law for. ` +
2893
+ "It is not executed: modelling it as its declared kind would simulate a different " +
2894
+ "device, so whatever it does in the real circuit is absent from this program.",
2895
+ },
2896
+ ]
2897
+ : []);
2898
+ }
2899
+ function isNonExecutableIcSupportShell(device) {
2900
+ const declaredType = device.identity.declaredType === null
2901
+ ? null
2902
+ : foldToken(device.identity.declaredType);
2903
+ if (declaredType === "circuit.opamp" &&
2904
+ hasExactRoleSet(device, ["sectiona", "sectionb", "vplus", "vminus"])) {
2905
+ return true;
2906
+ }
2907
+ if (declaredType === "circuit.audiocodec" &&
2908
+ (hasExactRoleSet(device, ["analoginputshell", "adcboundary"]) ||
2909
+ hasExactRoleSet(device, ["dacboundary", "analogoutputshell"]))) {
2910
+ return true;
2911
+ }
2912
+ if (declaredType === "circuit.logicic" &&
2913
+ hasExactRoleSet(device, ["vcc", "gnd"])) {
2914
+ return true;
2915
+ }
2916
+ if (declaredType === "circuit.poweramp" ||
2917
+ declaredType === "circuit.reverb" ||
2918
+ declaredType === "circuit.module") {
2919
+ return true;
2920
+ }
2921
+ if ((declaredType === "circuit.ic" || declaredType === "circuit.ota") &&
2922
+ (foldToken(device.identity.partNumber ?? "") === "ir3109" ||
2923
+ foldToken(device.identity.partNumber ?? "") === "jhscloveractiveeqboundary")) {
2924
+ return true;
2925
+ }
2926
+ const partNumberFolded = foldToken(device.identity.partNumber ?? "");
2927
+ if (partNumberFolded.startsWith("preampsupportnetwork") ||
2928
+ partNumberFolded === "slp24b" ||
2929
+ partNumberFolded === "slp24bleds" ||
2930
+ partNumberFolded === "ce1powertransformer" ||
2931
+ partNumberFolded === "ta7504s") {
2932
+ return true;
2933
+ }
2934
+ return false;
2935
+ }
2936
+ function hasExactRoleSet(device, roles) {
2937
+ if (device.identity.terminalRoles.length !== roles.length) {
2938
+ return false;
2939
+ }
2940
+ const observed = new Set();
2941
+ for (const role of device.identity.terminalRoles) {
2942
+ if (role === null) {
2943
+ return false;
2944
+ }
2945
+ observed.add(foldToken(role));
2946
+ }
2947
+ for (const role of roles.map((entry) => foldToken(entry))) {
2948
+ if (!observed.has(role)) {
2949
+ return false;
2950
+ }
2951
+ }
2952
+ return true;
2953
+ }
2954
+ function unsupported(device, reason) {
2955
+ return { outcome: "unsupported", device: device.id, reason };
2956
+ }
2957
+ /**
2958
+ * A jack is a pure interface symbol until a **supply runs through it**. Then it is a
2959
+ * switch in the supply path, and `open` severs the supply.
2960
+ *
2961
+ * Which terminal the contact makes against is derived from *which end of the supply is
2962
+ * stranded*, never from the contact's name. The corpus names these contacts eleven ways
2963
+ * and they do not share a sense, so a name table gets two of three wrong -- it shorted 13
2964
+ * jacks, five of them supply rails. Two cases are derivable, and they are mirror images:
2965
+ *
2966
+ * A. A supply **return** sits on a jack terminal. It has to reach the jack's own
2967
+ * return or the supply has no path back. `boss-hm-2` and `boss-sp-1-spectrum` put
2968
+ * the battery negative on the input jack's switch contact; without this the whole
2969
+ * circuit floats and renders leakage. 15 jacks.
2970
+ * B. A supply **drive** sits on a jack terminal whose node carries nothing but that
2971
+ * supply and this jack, so the rail is stranded behind the contact and must reach
2972
+ * the jack's remaining terminal. `boss-od-3` routes its 9 V to the pedal only
2973
+ * through the DC jack's contact -- `BATTERY.positive` and `DC_JACK.tip` alone on
2974
+ * node 45, the circuit rail on node 1 with 13 devices -- so without this the entire
2975
+ * pedal compiles into a block with no voltage source and Newton burns its whole
2976
+ * iteration cap every sample hunting an operating point that cannot exist. 3 jacks.
2977
+ *
2978
+ * The direction is not fixed and does not need to be: `boss-od-3` has the supply on the
2979
+ * tip and the circuit on the contact, `boss-ce-5` has it the other way round, and joining
2980
+ * the stranded end to the remaining terminal is right for both.
2981
+ *
2982
+ * Everything else stays open. A `switched_tip` or a `normalSwitch` carrying signal has a
2983
+ * sense that depends on a plug state the packet does not state, and no structural fact
2984
+ * distinguishes them -- so guessing would short a signal rather than leave it unmodelled.
2985
+ */
2986
+ function portEngage(device, supply) {
2987
+ const open = {
2988
+ outcome: "law",
2989
+ device: device.id,
2990
+ law: { kind: "open" },
2991
+ };
2992
+ const returnIndex = portReturnIndex(device);
2993
+ if (returnIndex === -1) {
2994
+ return open;
2995
+ }
2996
+ const engage = (contactIndex, toIndex) =>
2997
+ // Already the same node: the path exists and an element would be a dead short
2998
+ // across nothing.
2999
+ device.nodes[contactIndex] === device.nodes[toIndex]
3000
+ ? open
3001
+ : {
3002
+ outcome: "law",
3003
+ device: device.id,
3004
+ law: { kind: "port-engage", contactIndex, againstIndex: toIndex },
3005
+ };
3006
+ const strandedReturn = device.nodes.findIndex((node, index) => index !== returnIndex && supply.returns.has(node));
3007
+ if (strandedReturn !== -1) {
3008
+ return engage(strandedReturn, returnIndex);
3009
+ }
3010
+ const strandedDrive = device.nodes.findIndex((node, index) => index !== returnIndex &&
3011
+ supply.drives.has(node) &&
3012
+ // Stranded means the node carries only the supply end and this jack terminal.
3013
+ // A drive that already reaches the circuit needs no help, and closing a contact
3014
+ // onto it would bridge two live nodes.
3015
+ (supply.degree.get(node) ?? 0) <= 2);
3016
+ if (strandedDrive === -1) {
3017
+ return open;
3018
+ }
3019
+ const onward = device.nodes
3020
+ .map((node, index) => ({ node, index }))
3021
+ .filter(({ node, index }) => index !== returnIndex &&
3022
+ index !== strandedDrive &&
3023
+ node !== device.nodes[returnIndex]);
3024
+ // More than one candidate is a genuine ambiguity, not a tie to break: `boss-ch-1`
3025
+ // declares `adapter_positive`, `battery_positive` and `switched_positive` on one jack,
3026
+ // and picking between them would be guessing which is wired to the pedal.
3027
+ const only = onward.length === 1 ? onward[0] : undefined;
3028
+ return only === undefined ? open : engage(strandedDrive, only.index);
3029
+ }
3030
+ // The taper *curve* deliberately does not live here. A pot's law carries which taper it
3031
+ // is; evaluating it is `src/runtime/taper.ts`, so no compiler stage can fold a control
3032
+ // curve into a coefficient even by accident. See decision 2 in the pipeline plan.
3033
+ /**
3034
+ * Every integrated circuit the source declares and the program does not execute.
3035
+ *
3036
+ * **A residue reporter, deliberately.** It does not ask the five opening predicates their
3037
+ * questions again -- it reads the reason `resolveDevice` recorded when it chose the `open`. So a
3038
+ * sixth opening path added later is reported the day it is added, as `unrecorded`, instead of
3039
+ * joining the silent class this exists to close; and no copy of a resolver's question can drift
3040
+ * from the resolver, which is the defect that bit this file twice on 2026-09-03.
3041
+ *
3042
+ * `electrically-isolated` is excluded because `findElectricallyIsolatedIcs` already names those
3043
+ * devices and says more about them than this could. The exclusion is on the recorded decision, not
3044
+ * on a re-run of `isElectricallyIsolated`, for the reason above.
3045
+ *
3046
+ * Runs after section expansion, which is what makes it safe: a package the registry could expand
3047
+ * is no longer in `lawed.netlist.devices` at all, so a working dual op-amp cannot appear here.
3048
+ * Non-IC opens are out of scope and stay so -- a ground symbol and an unwired jack contact carry
3049
+ * no element because the symbol has none, which is not a hole in the model.
3050
+ */
3051
+ /**
3052
+ * `digital-delay-line` as a composition of primitives (board-p3 row 4):
3053
+ * an integer delay line plus one multiply-accumulate for the feedback mix.
3054
+ *
3055
+ * The op order mirrors the kernel's own evaluation order exactly -- read the
3056
+ * delayed sample, publish it, write back tap plus feedback times delayed --
3057
+ * so the interpreter reproduces its bits rather than approximating them.
3058
+ * Length-mode priority mirrors the kernel's branch priority (clock control,
3059
+ * then parameter port, else capacity). Returns `null` for anything but this
3060
+ * model, and for a block carrying `modulation`, which the kernel has no
3061
+ * branch for: there is no faithful composition of a port the kernel ignores.
3062
+ */
3063
+ export function decomposeDigitalDelayLine(block) {
3064
+ if (block.modelId !== "digital-delay-line") {
3065
+ return null;
3066
+ }
3067
+ if (block.modulation !== undefined && block.modulation !== null) {
3068
+ return null;
3069
+ }
3070
+ const feedback = block.parameters.feedback ?? 0;
3071
+ return {
3072
+ kind: "composed",
3073
+ modelSource: "registry",
3074
+ id: block.id,
3075
+ modelId: block.modelId,
3076
+ parameters: block.parameters,
3077
+ audioIn: block.audioIn,
3078
+ audioOut: block.audioOut,
3079
+ parameter: block.parameter,
3080
+ clockControl: block.clockControl,
3081
+ delayProvenance: block.delayProvenance,
3082
+ delayDeclaredReason: block.delayDeclaredReason,
3083
+ modulationRefusal: block.modulationRefusal,
3084
+ router: null,
3085
+ positions: [
3086
+ {
3087
+ id: "default",
3088
+ ops: [
3089
+ {
3090
+ op: "delay-tap",
3091
+ line: "dl",
3092
+ length: block.clockControl !== undefined && block.clockControl !== null
3093
+ ? { mode: "clock" }
3094
+ : block.parameter !== null && block.parameter.referenceVolts > 0
3095
+ ? { mode: "parameter" }
3096
+ : { mode: "capacity" },
3097
+ out: 0,
3098
+ },
3099
+ {
3100
+ op: "mix",
3101
+ terms: [
3102
+ { source: { kind: "input" }, gain: 1 },
3103
+ { source: { kind: "temp", index: 0 }, gain: feedback },
3104
+ ],
3105
+ out: 1,
3106
+ },
3107
+ {
3108
+ op: "delay-push",
3109
+ line: "dl",
3110
+ input: { kind: "temp", index: 1 },
3111
+ },
3112
+ ],
3113
+ out: 0,
3114
+ lines: {
3115
+ dl: { delaySeconds: block.parameters.delaySeconds ?? 0, minSeconds: 0 },
3116
+ },
3117
+ },
3118
+ ],
3119
+ };
3120
+ }
3121
+ /**
3122
+ * `bucket-brigade-delay-line` as a composition of primitives (board-p3
3123
+ * row 4): input coupling estimator, fractionally interpolated delay line,
3124
+ * write-back.
3125
+ *
3126
+ * Op order mirrors the kernel exactly -- couple the tap to AC, read the
3127
+ * delayed sample with linear interpolation, publish it, write the AC back --
3128
+ * and length priority mirrors its branches (clock, clock-law, modulation,
3129
+ * parameter).
3130
+ *
3131
+ * Deliberately no `converter` op: the shipped kernel performs no conversion
3132
+ * stage (no companding, no quantization -- `grep compand` finds a device
3133
+ * law and comments), so a bit-identical decomposition has nowhere to put
3134
+ * one. The enum gains `converter` when a decomposition needs it, not before.
3135
+ */
3136
+ export function decomposeBucketBrigadeDelayLine(block) {
3137
+ if (block.modelId !== "bucket-brigade-delay-line") {
3138
+ return null;
3139
+ }
3140
+ const clockLawLength = block.clockLaw !== undefined &&
3141
+ block.clockLaw !== null &&
3142
+ block.clockLawParams !== undefined &&
3143
+ block.clockLawParams !== null
3144
+ ? {
3145
+ mode: "clock-law",
3146
+ rOhms: block.clockLawParams.rOhms,
3147
+ cFarads: block.clockLawParams.cFarads,
3148
+ vddVolts: block.clockLawParams.vddVolts,
3149
+ vthVolts: block.clockLawParams.vthVolts,
3150
+ vfVolts: block.clockLawParams.vfVolts,
3151
+ floorVolts: block.clockLawParams.floorVolts,
3152
+ stages: block.parameters.stages ?? 0,
3153
+ }
3154
+ : null;
3155
+ return {
3156
+ kind: "composed",
3157
+ modelSource: "registry",
3158
+ id: block.id,
3159
+ modelId: block.modelId,
3160
+ parameters: block.parameters,
3161
+ audioIn: block.audioIn,
3162
+ audioOut: block.audioOut,
3163
+ parameter: block.parameter,
3164
+ clockControl: block.clockControl,
3165
+ modulation: block.modulation,
3166
+ // Present only when recognised: same byte-churn rule as `lower.ts` --
3167
+ // every other program must stay digest-identical.
3168
+ ...(block.clockLaw !== undefined &&
3169
+ block.clockLaw !== null &&
3170
+ block.clockLawParams !== undefined &&
3171
+ block.clockLawParams !== null
3172
+ ? { clockLaw: block.clockLaw, clockLawParams: block.clockLawParams }
3173
+ : {}),
3174
+ delayProvenance: block.delayProvenance,
3175
+ delayDeclaredReason: block.delayDeclaredReason,
3176
+ modulationRefusal: block.modulationRefusal,
3177
+ router: null,
3178
+ positions: [
3179
+ {
3180
+ id: "default",
3181
+ ops: [
3182
+ { op: "filter-dcblock", input: { kind: "input" }, out: 0 },
3183
+ {
3184
+ op: "delay-tap-fractional",
3185
+ line: "dl",
3186
+ length: block.clockControl !== undefined && block.clockControl !== null
3187
+ ? { mode: "clock" }
3188
+ : clockLawLength ?? (block.modulation !== undefined && block.modulation !== null
3189
+ ? { mode: "modulation" }
3190
+ : block.parameter !== null && block.parameter.referenceVolts > 0
3191
+ ? { mode: "parameter" }
3192
+ : { mode: "capacity" }),
3193
+ out: 1,
3194
+ },
3195
+ {
3196
+ op: "delay-push",
3197
+ line: "dl",
3198
+ input: { kind: "temp", index: 0 },
3199
+ },
3200
+ ],
3201
+ out: 1,
3202
+ lines: {
3203
+ dl: { delaySeconds: block.parameters.delaySeconds ?? 0, minSeconds: 0 },
3204
+ },
3205
+ },
3206
+ ],
3207
+ };
3208
+ }
3209
+ /**
3210
+ * `digital-reverb-module` as a composition of primitives (board-p3 row 4):
3211
+ * four parallel feedback combs averaged, two series allpass diffusers,
3212
+ * output gain. A Schroeder reverb, which is what the kernel is.
3213
+ *
3214
+ * Arithmetic mirrors the kernel term for term: combs accumulate
3215
+ * left-to-right then scale by exactly one quarter (a power of two, so the
3216
+ * multiply rounds like the kernel's divide), allpasses keep the kernel's
3217
+ * multiply order. The kernel has no clock, parameter, or modulation
3218
+ * branches, so those ports are dropped rather than carried -- carrying a
3219
+ * port the kernel ignores would imply behaviour the composition does not
3220
+ * have. `decaySeconds` travels per comb op; the echo-density tables and
3221
+ * the allpass coefficient stay interpreter-owned, exactly as today.
3222
+ */
3223
+ export function decomposeDigitalReverbModule(block) {
3224
+ if (block.modelId !== "digital-reverb-module") {
3225
+ return null;
3226
+ }
3227
+ const decaySeconds = block.parameters.decaySeconds ?? 0;
3228
+ const outputGain = block.parameters.outputGain ?? 1;
3229
+ return {
3230
+ kind: "composed",
3231
+ modelSource: "registry",
3232
+ id: block.id,
3233
+ modelId: block.modelId,
3234
+ parameters: block.parameters,
3235
+ audioIn: block.audioIn,
3236
+ audioOut: block.audioOut,
3237
+ parameter: null,
3238
+ delayProvenance: block.delayProvenance,
3239
+ delayDeclaredReason: block.delayDeclaredReason,
3240
+ modulationRefusal: block.modulationRefusal,
3241
+ router: null,
3242
+ positions: [
3243
+ {
3244
+ id: "default",
3245
+ ops: [
3246
+ { op: "comb", index: 0, decaySeconds, input: { kind: "input" }, out: 0 },
3247
+ { op: "comb", index: 1, decaySeconds, input: { kind: "input" }, out: 1 },
3248
+ { op: "comb", index: 2, decaySeconds, input: { kind: "input" }, out: 2 },
3249
+ { op: "comb", index: 3, decaySeconds, input: { kind: "input" }, out: 3 },
3250
+ {
3251
+ op: "mix",
3252
+ terms: [
3253
+ { source: { kind: "temp", index: 0 }, gain: 1 },
3254
+ { source: { kind: "temp", index: 1 }, gain: 1 },
3255
+ { source: { kind: "temp", index: 2 }, gain: 1 },
3256
+ { source: { kind: "temp", index: 3 }, gain: 1 },
3257
+ ],
3258
+ out: 4,
3259
+ },
3260
+ {
3261
+ op: "mix",
3262
+ // The kernel averages over its four combs; 0.25 is exact here
3263
+ // because the interpreter's comb table is fixed at four. If
3264
+ // that table ever grows, this literal must follow it.
3265
+ terms: [{ source: { kind: "temp", index: 4 }, gain: 0.25 }],
3266
+ out: 5,
3267
+ },
3268
+ { op: "allpass", index: 0, input: { kind: "temp", index: 5 }, out: 6 },
3269
+ { op: "allpass", index: 1, input: { kind: "temp", index: 6 }, out: 7 },
3270
+ {
3271
+ op: "mix",
3272
+ terms: [{ source: { kind: "temp", index: 7 }, gain: outputGain }],
3273
+ out: 8,
3274
+ },
3275
+ ],
3276
+ out: 8,
3277
+ lines: {},
3278
+ },
3279
+ ],
3280
+ };
3281
+ }
3282
+ /**
3283
+ * Emit compositions for the three decomposed models, passthrough for
3284
+ * everything else (board-p3 row 4, the flip). Called once per program after
3285
+ * `couple` wires ports: decompositions read fully-resolved blocks, so an
3286
+ * earlier flip would compose unwired placeholders. A `null` decomposition
3287
+ * (a port the kernel ignores, a model outside the three) keeps the macro
3288
+ * block rather than failing the program.
3289
+ */
3290
+ export function composeMacroBlocks(blocks) {
3291
+ return blocks.map((block) => {
3292
+ if (block.kind !== "macro") {
3293
+ return block;
3294
+ }
3295
+ // **A declared composition composes to itself.** The three decompositions below
3296
+ // translate a registry model into ops; a declaration arrives as ops already, so
3297
+ // there is nothing to translate and no model table to consult. That is the whole
3298
+ // point of the second resolution key: the source states the program.
3299
+ const declared = block.declaredPositions;
3300
+ if (declared !== null && declared !== undefined && declared.length > 0) {
3301
+ return {
3302
+ kind: "composed",
3303
+ modelSource: "declared",
3304
+ id: block.id,
3305
+ modelId: block.modelId,
3306
+ parameters: block.parameters,
3307
+ audioIn: block.audioIn,
3308
+ audioOut: block.audioOut,
3309
+ parameter: block.parameter,
3310
+ clockControl: block.clockControl,
3311
+ delayProvenance: block.delayProvenance,
3312
+ delayDeclaredReason: block.delayDeclaredReason,
3313
+ modulationRefusal: block.modulationRefusal,
3314
+ positions: declared,
3315
+ router: block.router ?? null,
3316
+ };
3317
+ }
3318
+ if (block.modelId === "digital-delay-line") {
3319
+ return decomposeDigitalDelayLine(block) ?? block;
3320
+ }
3321
+ if (block.modelId === "bucket-brigade-delay-line") {
3322
+ return decomposeBucketBrigadeDelayLine(block) ?? block;
3323
+ }
3324
+ if (block.modelId === "digital-reverb-module") {
3325
+ return decomposeDigitalReverbModule(block) ?? block;
3326
+ }
3327
+ return block;
3328
+ });
3329
+ }
3330
+ /**
3331
+ * Which phase-3 bucket a gap-candidate open IC belongs in (board-p3 row 1).
3332
+ *
3333
+ * Pure over typed and structural evidence only: the entry's `firmwareClass`
3334
+ * (compared whole-value, never prose) and whether the chip shares a net with
3335
+ * a device the program executes. `fixed-function` means no program is
3336
+ * missing, so the only question left is what covers the chip's behaviour: an
3337
+ * executing macro (memory inside a delay kernel, e.g. a 4164 behind an
3338
+ * M50195P) or an executing law (a redundant view of modelled behaviour, e.g.
3339
+ * a single-terminal divider placeholder on an executing divider's clock
3340
+ * net). Everything else stays `programmable-no-program` -- including
3341
+ * `undetermined` firmware, which is an open question rather than evidence of
3342
+ * a fixed function, so the phase-3 set keeps the row until proven otherwise.
3343
+ */
3344
+ export function classifyOpenIcGap(input) {
3345
+ if (input.firmwareClass === "fixed-function" &&
3346
+ input.sharesNodeWithExecutedMacro) {
3347
+ return "support-chip-subsumed";
3348
+ }
3349
+ if (input.firmwareClass === "fixed-function" &&
3350
+ input.sharesNodeWithExecutedLaw) {
3351
+ return "part-number-miss";
3352
+ }
3353
+ return "programmable-no-program";
3354
+ }
3355
+ /**
3356
+ * The entry's firmware class for an exact part-number match, whole-value.
3357
+ * No entry or no evidence reads `undetermined`, which classifies as
3358
+ * `programmable-no-program`: an open question keeps the phase-3 row.
3359
+ */
3360
+ function firmwareClassOf(registry, partNumber) {
3361
+ if (partNumber === null) {
3362
+ return "undetermined";
3363
+ }
3364
+ const folded = foldPartId(partNumber);
3365
+ for (const entry of registry.entries) {
3366
+ if (entry.partIds.some((id) => foldPartId(id) === folded)) {
3367
+ return entry.firmware?.firmwareClass ?? "undetermined";
3368
+ }
3369
+ }
3370
+ return "undetermined";
3371
+ }
3372
+ export function findIcsNotExecuted(lawed, original, registry) {
3373
+ // **One device, one warning, and isolation is the half that speaks.** A chip sharing no net
3374
+ // with anything is not a modelling question -- no entry at any terminal count would connect
3375
+ // it -- so where `findElectricallyIsolatedIcs` already names a device, this stays quiet.
3376
+ // `boss-oc-3`'s `SRC_AK4552VT` carried both: every one of its twenty pins is a private node,
3377
+ // and it reached the pinout check only because section expansion runs before the isolation
3378
+ // branch in `resolveDevice`. Counted over `original` -- the same pre-expansion netlist the
3379
+ // isolation reporter reads -- so the two cannot disagree about the same device.
3380
+ const devicesTouchingNode = deviceCountByNode(original);
3381
+ const openById = new Map(lawed.resolutions.flatMap((resolution) => resolution.outcome === "law" && resolution.law.kind === "open"
3382
+ ? [[resolution.device, resolution]]
3383
+ : []));
3384
+ const warnings = [];
3385
+ const deviceById = new Map(lawed.netlist.devices.map((device) => [device.id, device]));
3386
+ const executedMacroNodes = new Set();
3387
+ const executedLawNodes = new Set();
3388
+ for (const resolution of lawed.resolutions) {
3389
+ const resolved = deviceById.get(resolution.device);
3390
+ if (resolved === undefined) {
3391
+ continue;
3392
+ }
3393
+ if (resolution.outcome === "macro") {
3394
+ for (const node of resolved.nodes) {
3395
+ executedMacroNodes.add(node);
3396
+ }
3397
+ }
3398
+ else if (resolution.outcome === "law" &&
3399
+ resolution.law.kind !== "open") {
3400
+ for (const node of resolved.nodes) {
3401
+ executedLawNodes.add(node);
3402
+ }
3403
+ }
3404
+ }
3405
+ for (const device of lawed.netlist.devices) {
3406
+ if (!requiresIdentification(device)) {
3407
+ continue;
3408
+ }
3409
+ const resolution = openById.get(device.id);
3410
+ if (resolution === undefined || resolution.outcome !== "law") {
3411
+ continue;
3412
+ }
3413
+ const reason = resolution.openReason;
3414
+ if (reason === "electrically-isolated" ||
3415
+ isElectricallyIsolated(device, devicesTouchingNode)) {
3416
+ continue;
3417
+ }
3418
+ // Names the chip by the hardest evidence the document carries, because that is what a
3419
+ // reader needs to look it up: a part number where there is one, the declared class where
3420
+ // there is not.
3421
+ const named = device.identity.partNumber ?? device.identity.declaredType ?? "unnamed";
3422
+ const head = `${device.kind} ${device.id} (${named}) is in the source and not in the program`;
3423
+ // **How much of it is wired at all**, appended to the arity reason because it decides which
3424
+ // half of that reason's advice applies. A pin on a node no other device touches cannot be
3425
+ // connected by any registry entry, so where most of a component's terminals are private the
3426
+ // gap is a source trace and adding a catalog entry at this count would produce stamps that
3427
+ // cannot affect the circuit. Measured on the AK4552VT codecs: every one of them, at every
3428
+ // arity the corpus declares, has all four of its analog pins on private nodes -- including
3429
+ // the two the catalog already models, whose buffers stamp into nothing.
3430
+ const privatePins = device.nodes.filter(
3431
+ // Literal 0 for ground, as `isElectricallyIsolated` writes it just above.
3432
+ (node) => node !== 0 && (devicesTouchingNode.get(node) ?? 0) <= 1).length;
3433
+ const wiring = privatePins === 0
3434
+ ? ""
3435
+ : ` ${privatePins} of its ${device.nodes.length} terminals sit on nodes no other component touches, which no registry entry can connect.`;
3436
+ const gapClass = reason === "registry-open" || reason === "declared-class-without-model"
3437
+ ? classifyOpenIcGap({
3438
+ firmwareClass: firmwareClassOf(registry, device.identity.partNumber),
3439
+ sharesNodeWithExecutedMacro: device.nodes.some((node) => executedMacroNodes.has(node)),
3440
+ sharesNodeWithExecutedLaw: device.nodes.some((node) => executedLawNodes.has(node)),
3441
+ })
3442
+ : undefined;
3443
+ const gapSuffix = gapClass === "support-chip-subsumed"
3444
+ ? " Classified support-chip-subsumed (phase-1 row, not phase 3): its catalog entry states fixed-function behaviour and it shares a net with an executing macro, so no program is missing."
3445
+ : gapClass === "part-number-miss"
3446
+ ? " Classified part-number-miss (phase-1 row, not phase 3): its catalog entry states fixed-function behaviour and it shares a net with an executing law, so it is a redundant view of modelled behaviour rather than an unmodelled core."
3447
+ : "";
3448
+ warnings.push({
3449
+ code: "ic-not-executed",
3450
+ device: device.id,
3451
+ reason: reason ?? "unrecorded",
3452
+ detail: (reason === "source-boundary-shell"
3453
+ ? `${head}: it is registered as a source-boundary shell, drawn for topological continuity rather than execution, so it is compiled as open.`
3454
+ : reason === "charge-pump-declared-rail"
3455
+ ? `${head}: it is a charge pump whose generated rail the document declares separately, so the rail is modelled and the pump itself is compiled as open.`
3456
+ : reason === "registry-arity-mismatch"
3457
+ ? `${head}: ${resolution.insteadOfRefusal ?? "its registry entry could not bind the declared terminals"}. The part is registered, so this is an arity disagreement rather than an unknown chip: either the document declares a shell rather than the part's own pins, or the catalog needs an entry at this terminal count.${wiring} It is compiled as open rather than refusing the pedal.`
3458
+ : reason === "registry-open"
3459
+ ? `${head}: the registry states no executable model for it and it is compiled as open. Whatever it does in the real circuit is absent.`
3460
+ : reason === "declared-class-without-model"
3461
+ ? `${head}: no registered part model binds it -- ${resolution.insteadOfRefusal ?? "the registry supplied none"} -- and ${device.identity.partNumber === null
3462
+ ? `its source names no part number and declares only the generic ${device.identity.declaredType}, so there is nothing to look up`
3463
+ : `its source declares ${device.identity.declaredType}, a class this pipeline opens rather than refusing the pedal for`}. Fixing it means a registry entry, a pinout that fits, or a packet correction.`
3464
+ : `${head}: it was compiled as open by a path that records no reason, so this cannot say which. A new opening branch in \`resolveDevice\` needs to tag itself.`) + gapSuffix,
3465
+ ...(gapClass === undefined ? {} : { gapClass }),
3466
+ });
3467
+ }
3468
+ return warnings;
3469
+ }
3470
+ /**
3471
+ * The loud half of `isElectricallyIsolated`: every identification-requiring device whose
3472
+ * nodes are all private got an `open` law instead of a refusal, and each one is named here
3473
+ * so a cluster isolated by a wiring mistake rather than by intent is never silently
3474
+ * swallowed. Runs over the ORIGINAL netlist (pre-section-expansion), because isolation is a
3475
+ * property of the drawn component, not of a registry section.
3476
+ */
3477
+ export function findElectricallyIsolatedIcs(netlist) {
3478
+ const devicesTouchingNode = deviceCountByNode(netlist);
3479
+ const warnings = [];
3480
+ for (const device of netlist.devices) {
3481
+ if (!requiresIdentification(device)) {
3482
+ continue;
3483
+ }
3484
+ if (!isElectricallyIsolated(device, devicesTouchingNode)) {
3485
+ continue;
3486
+ }
3487
+ warnings.push({
3488
+ code: "electrically-isolated-ic",
3489
+ device: device.id,
3490
+ detail: `component ${device.id} shares no net with any other component, so it is treated ` +
3491
+ `as a view-only block and compiled as open. If that is a wiring mistake rather ` +
3492
+ `than a declared boundary, the source needs the missing nets, not a device model.`,
3493
+ });
3494
+ }
3495
+ return warnings;
3496
+ }
3497
+ //# sourceMappingURL=device-laws.js.map