@vessel-dsp/compiler 0.4.0 → 0.6.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 (80) hide show
  1. package/README.md +4 -2
  2. package/dist/bbd-clock.d.ts.map +1 -1
  3. package/dist/bbd-clock.js +5 -0
  4. package/dist/bbd-clock.js.map +1 -1
  5. package/dist/compile.d.ts.map +1 -1
  6. package/dist/compile.js +305 -12
  7. package/dist/compile.js.map +1 -1
  8. package/dist/device-laws.d.ts.map +1 -1
  9. package/dist/device-laws.js +552 -97
  10. package/dist/device-laws.js.map +1 -1
  11. package/dist/fixtures/circuits.d.ts +23 -2
  12. package/dist/fixtures/circuits.d.ts.map +1 -1
  13. package/dist/fixtures/circuits.js +205 -2
  14. package/dist/fixtures/circuits.js.map +1 -1
  15. package/dist/index.d.ts +2 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +1 -0
  18. package/dist/index.js.map +1 -1
  19. package/dist/lower.d.ts.map +1 -1
  20. package/dist/lower.js +4 -0
  21. package/dist/lower.js.map +1 -1
  22. package/dist/netlist.d.ts +52 -0
  23. package/dist/netlist.d.ts.map +1 -1
  24. package/dist/netlist.js +585 -13
  25. package/dist/netlist.js.map +1 -1
  26. package/dist/part-catalog.d.ts +35 -6
  27. package/dist/part-catalog.d.ts.map +1 -1
  28. package/dist/part-catalog.js +2544 -443
  29. package/dist/part-catalog.js.map +1 -1
  30. package/dist/registry-data/bjt.d.ts +3 -0
  31. package/dist/registry-data/bjt.d.ts.map +1 -0
  32. package/dist/registry-data/bjt.js +1350 -0
  33. package/dist/registry-data/bjt.js.map +1 -0
  34. package/dist/registry-data/charge-pump.d.ts +3 -0
  35. package/dist/registry-data/charge-pump.d.ts.map +1 -0
  36. package/dist/registry-data/charge-pump.js +204 -0
  37. package/dist/registry-data/charge-pump.js.map +1 -0
  38. package/dist/registry-data/diode.d.ts +3 -0
  39. package/dist/registry-data/diode.d.ts.map +1 -0
  40. package/dist/registry-data/diode.js +683 -0
  41. package/dist/registry-data/diode.js.map +1 -0
  42. package/dist/registry-data/index.d.ts +20 -0
  43. package/dist/registry-data/index.d.ts.map +1 -0
  44. package/dist/registry-data/index.js +100 -0
  45. package/dist/registry-data/index.js.map +1 -0
  46. package/dist/registry-data/jfet.d.ts +3 -0
  47. package/dist/registry-data/jfet.d.ts.map +1 -0
  48. package/dist/registry-data/jfet.js +605 -0
  49. package/dist/registry-data/jfet.js.map +1 -0
  50. package/dist/registry-data/loader.d.ts +137 -0
  51. package/dist/registry-data/loader.d.ts.map +1 -0
  52. package/dist/registry-data/loader.js +350 -0
  53. package/dist/registry-data/loader.js.map +1 -0
  54. package/dist/registry-data/mosfet.d.ts +3 -0
  55. package/dist/registry-data/mosfet.d.ts.map +1 -0
  56. package/dist/registry-data/mosfet.js +63 -0
  57. package/dist/registry-data/mosfet.js.map +1 -0
  58. package/dist/registry-data/opamp.d.ts +37 -0
  59. package/dist/registry-data/opamp.d.ts.map +1 -0
  60. package/dist/registry-data/opamp.js +669 -0
  61. package/dist/registry-data/opamp.js.map +1 -0
  62. package/dist/registry-data/regulator.d.ts +3 -0
  63. package/dist/registry-data/regulator.d.ts.map +1 -0
  64. package/dist/registry-data/regulator.js +189 -0
  65. package/dist/registry-data/regulator.js.map +1 -0
  66. package/dist/registry-data/schema.d.ts +56 -0
  67. package/dist/registry-data/schema.d.ts.map +1 -0
  68. package/dist/registry-data/schema.js +142 -0
  69. package/dist/registry-data/schema.js.map +1 -0
  70. package/dist/registry-data/zener.d.ts +3 -0
  71. package/dist/registry-data/zener.d.ts.map +1 -0
  72. package/dist/registry-data/zener.js +547 -0
  73. package/dist/registry-data/zener.js.map +1 -0
  74. package/dist/registry.d.ts +72 -5
  75. package/dist/registry.d.ts.map +1 -1
  76. package/dist/registry.js +65 -6
  77. package/dist/registry.js.map +1 -1
  78. package/dist/types.d.ts +255 -19
  79. package/dist/types.d.ts.map +1 -1
  80. package/package.json +1 -1
package/dist/netlist.js CHANGED
@@ -18,7 +18,8 @@
18
18
  import { componentDevices, deviceTerminalRoles, parseInterchangeYamlWithTopology, } from "@vessel-dsp/core";
19
19
  import { load as loadYaml } from "js-yaml";
20
20
  import { StageRefusal } from "./errors.js";
21
- import { foldToken } from "./registry.js";
21
+ import { emptyRegistry, foldToken, registryEntryFor } from "./registry.js";
22
+ import { classParamNames, requiredClassParams, } from "./registry-data/index.js";
22
23
  import { findBistableLatches } from "./latch-seed.js";
23
24
  import { speakerOnePort } from "./speaker-load.js";
24
25
  import { GROUND, } from "./types.js";
@@ -632,6 +633,7 @@ export function readNetlist(source, options) {
632
633
  ? declaredNodesFromConnectivity(document, parsed.connectivity)
633
634
  : new Map();
634
635
  const nodeOf = resolveNodes(document, declaredNodes);
636
+ const registry = options?.registry ?? emptyRegistry;
635
637
  const warnings = [];
636
638
  /**
637
639
  * **A switch throw or winding tap alone on its node cannot route anything.**
@@ -931,7 +933,11 @@ export function readNetlist(source, options) {
931
933
  packageDeviceId: declared.id,
932
934
  kind: deviceKind,
933
935
  nodes: owned.map((entry) => entry.node),
934
- parameters: parametersFor(component, deviceKind),
936
+ ...applyDeviceOverride(component, deviceKind, parametersFor(component, deviceKind), {
937
+ warnings,
938
+ registry,
939
+ deviceId: `${component.id}.${declared.id}`,
940
+ }),
935
941
  // The control belongs to the package, so only the first device carries it.
936
942
  // A ganged part states the same control on both and the owner map already
937
943
  // keeps the first.
@@ -946,7 +952,11 @@ export function readNetlist(source, options) {
946
952
  id: String(component.id),
947
953
  kind,
948
954
  nodes,
949
- parameters: parametersFor(component, kind),
955
+ ...applyDeviceOverride(component, kind, parametersFor(component, kind), {
956
+ warnings,
957
+ registry,
958
+ deviceId: String(component.id),
959
+ }),
950
960
  control,
951
961
  identity: identityOf(effectiveTerminals, effectiveTerminals.map((terminal) => declaredRole(terminal))),
952
962
  isLed: componentKind === "led" ? true : undefined,
@@ -1098,7 +1108,7 @@ export function readNetlist(source, options) {
1098
1108
  device: owner.id,
1099
1109
  declared: componentTaper.unreadable,
1100
1110
  detail: `Control ${id}: ${owner.id} declares taper "${componentTaper.unreadable}", which ` +
1101
- "this stage cannot turn into one of its four laws, so the control renders " +
1111
+ "this stage cannot turn into one of its laws, so the control renders " +
1102
1112
  "linear. An audio pot rendered linear is audible across the whole sweep.",
1103
1113
  });
1104
1114
  }
@@ -1998,6 +2008,152 @@ kind) {
1998
2008
  ? value
1999
2009
  : null;
2000
2010
  }
2011
+ /** Hertz per frequency unit, compared whole. `mHz` and any other spelling are refused, not guessed. */
2012
+ const frequencyUnitScale = {
2013
+ "": 1,
2014
+ Hz: 1,
2015
+ hz: 1,
2016
+ HZ: 1,
2017
+ kHz: 1e3,
2018
+ KHz: 1e3,
2019
+ khz: 1e3,
2020
+ MHz: 1e6,
2021
+ GHz: 1e9,
2022
+ };
2023
+ /**
2024
+ * A declared frequency, in hertz, or `null` when the component states none.
2025
+ *
2026
+ * **The whole value is read, and a value that is not a frequency is refused rather than skipped.**
2027
+ * Three forms are legal and each is checked end to end:
2028
+ *
2029
+ * - a number, taken as hertz;
2030
+ * - a string that is exactly a number and an optional frequency unit -- `"3 MHz"`, `"1.2 MHz"`,
2031
+ * `"3000000"`, `"3e6 Hz"` -- where a missing unit means hertz and the unit is compared as a closed
2032
+ * set (`Hz`, `kHz`, `MHz`, `GHz`), never by stripping after a delimiter;
2033
+ * - a structured `{value, unit}`, whose `value` wins over `raw` because it already carries the
2034
+ * conversion (the same reason `Aol` reads `value`), with the unit from the same closed set.
2035
+ *
2036
+ * `parseQuantity` is not used here on purpose. It reads a number then ignores whatever follows, so
2037
+ * `"1.2 V/us"` -- a slew rate -- would come back as 1.2: the right number for the wrong reason, which
2038
+ * is the trap the `SR` note above describes. A frequency property that holds `"3 MHz typ"`, `"3M"` or
2039
+ * a slew rate is a packet error, so this throws and names the component instead of dropping it.
2040
+ *
2041
+ * A non-positive or non-finite frequency is refused for the same reason: it states something, and
2042
+ * what it states is not a frequency.
2043
+ */
2044
+ export function typedFrequencyHz(component, key) {
2045
+ const property = component.properties[key];
2046
+ if (property === undefined || property === null) {
2047
+ return null;
2048
+ }
2049
+ const refuse = (why) => {
2050
+ throw new NetlistError(`${component.id}: ${key} ${JSON.stringify(property)} ${why}; a declared frequency must be a number, a number with Hz/kHz/MHz/GHz, or a {value, unit}`);
2051
+ };
2052
+ let hertz;
2053
+ if (typeof property === "number") {
2054
+ hertz = property;
2055
+ }
2056
+ else if (typeof property === "string") {
2057
+ const text = property
2058
+ .trim()
2059
+ .replace(/^['"]+|['"]+$/gu, "")
2060
+ .trim();
2061
+ if (text === "") {
2062
+ return null;
2063
+ }
2064
+ const match = /^([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)\s*([A-Za-z]*)$/u.exec(text);
2065
+ const scale = match === null ? undefined : frequencyUnitScale[match[2]];
2066
+ if (match === null || scale === undefined) {
2067
+ return refuse("is not a number with a frequency unit");
2068
+ }
2069
+ hertz = Number(match[1]) * scale;
2070
+ }
2071
+ else if (typeof property === "object" && !Array.isArray(property)) {
2072
+ const { value, unit } = property;
2073
+ if (typeof value !== "number") {
2074
+ return refuse("has no numeric value");
2075
+ }
2076
+ const token = typeof unit === "string" ? unit.replaceAll("'", "").trim() : "";
2077
+ const scale = frequencyUnitScale[token];
2078
+ if (scale === undefined) {
2079
+ return refuse(`has unit "${token}", which is not a frequency`);
2080
+ }
2081
+ hertz = value * scale;
2082
+ }
2083
+ else {
2084
+ return refuse("is not a number, string or structured quantity");
2085
+ }
2086
+ if (!Number.isFinite(hertz) || hertz <= 0) {
2087
+ return refuse("is not a positive finite frequency");
2088
+ }
2089
+ return hertz;
2090
+ }
2091
+ /**
2092
+ * The bounds of a declared diode `EmissionCoefficient` (the SPICE `N`, the ideality factor): 1, the
2093
+ * Shockley limit nothing physical sits below, to 10, beyond which the part is not one junction. A
2094
+ * value outside them is refused by name, never clamped.
2095
+ */
2096
+ export const EMISSION_COEFFICIENT_MIN = 1;
2097
+ export const EMISSION_COEFFICIENT_MAX = 10;
2098
+ /**
2099
+ * A diode's declared `EmissionCoefficient`, or `null` when the component states none.
2100
+ *
2101
+ * **The whole value is read, and a value that is not an emission coefficient is refused rather than
2102
+ * skipped or clamped** -- the `typedFrequencyHz` pattern for a dimensionless number. Legal forms,
2103
+ * each checked end to end: a bare number; a string that is exactly a number (`"1.9"`, `"1.9e0"`,
2104
+ * nothing after it: not `"1.9 typ"`, not `"1.9n"`); and a structured `{value, unit}` whose `value`
2105
+ * wins over `raw` and whose unit must be empty. The property is read under this one spelling only
2106
+ * (the corpus has one); nothing here reads a part number, a name or a description.
2107
+ *
2108
+ * A property that is present and is not a number in `EMISSION_COEFFICIENT_MIN` to
2109
+ * `EMISSION_COEFFICIENT_MAX` is a packet error, so this throws and names the component and the
2110
+ * offending value. It states something, and what it states is not an ideality factor.
2111
+ */
2112
+ export function typedEmissionCoefficient(component) {
2113
+ const key = "EmissionCoefficient";
2114
+ const property = component.properties[key];
2115
+ if (property === undefined || property === null) {
2116
+ return null;
2117
+ }
2118
+ const refuse = (why) => {
2119
+ throw new NetlistError(`${component.id}: ${key} ${JSON.stringify(property)} ${why}; a declared emission coefficient must be a number from ${EMISSION_COEFFICIENT_MIN} to ${EMISSION_COEFFICIENT_MAX}, bare, as a number-only string, or as a {value, unit: ""}`, String(component.id));
2120
+ };
2121
+ let n;
2122
+ if (typeof property === "number") {
2123
+ n = property;
2124
+ }
2125
+ else if (typeof property === "string") {
2126
+ const text = property
2127
+ .trim()
2128
+ .replace(/^['"]+|['"]+$/gu, "")
2129
+ .trim();
2130
+ if (text === "") {
2131
+ return null;
2132
+ }
2133
+ if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/u.test(text)) {
2134
+ return refuse("is not a number");
2135
+ }
2136
+ n = Number(text);
2137
+ }
2138
+ else if (typeof property === "object" && !Array.isArray(property)) {
2139
+ const { value, unit } = property;
2140
+ if (typeof value !== "number") {
2141
+ return refuse("has no numeric value");
2142
+ }
2143
+ const token = typeof unit === "string" ? unit.replaceAll("'", "").trim() : "";
2144
+ if (token !== "") {
2145
+ return refuse(`has unit "${token}", which is not dimensionless`);
2146
+ }
2147
+ n = value;
2148
+ }
2149
+ else {
2150
+ return refuse("is not a number, string or structured quantity");
2151
+ }
2152
+ if (!Number.isFinite(n) || n < EMISSION_COEFFICIENT_MIN || n > EMISSION_COEFFICIENT_MAX) {
2153
+ return refuse(`is not a finite number from ${EMISSION_COEFFICIENT_MIN} to ${EMISSION_COEFFICIENT_MAX}`);
2154
+ }
2155
+ return n;
2156
+ }
2001
2157
  /**
2002
2158
  * Parse a quantity to SI base units. A value that cannot be parsed is an error, not a
2003
2159
  * default -- a silently defaulted component is a silently wrong circuit.
@@ -2074,6 +2230,396 @@ export function parseQuantity(raw) {
2074
2230
  }
2075
2231
  return value * multiplier;
2076
2232
  }
2233
+ /**
2234
+ * Stage 2: the explicit device-parameter override block.
2235
+ *
2236
+ * A `.vdsp` NAMES the part; the registry holds its parameters. A component may carry one
2237
+ * `DeviceOverride` object in its `properties` stating measured values for this device
2238
+ * (`kind: "measured"`, allowed on any part) or the full parameter set for a part the
2239
+ * registry does not have (`kind: "custom"`). Parameter names are the part's OWN class-schema
2240
+ * names (`CLASS_PARAM_SCHEMAS` for its class); values are plain SI numbers, one per parameter.
2241
+ *
2242
+ * Placement is a nested structured property -- not a new component field -- so no
2243
+ * `circuit-interchange` schema version, no `Component` type, no parser/serializer and no
2244
+ * core change was needed: nested objects round-trip through `parsePropertyValue` /
2245
+ * `propertiesBlock` untouched. An override changes only law numbers that already flow into
2246
+ * program blocks the way today's legacy typed keys do, so `Program`, the stamps and the
2247
+ * consoles are untouched too.
2248
+ *
2249
+ * Precedence, decided under the brief: override > legacy typed key > registry > class default.
2250
+ * Legacy reading is untouched (REMOVAL is stage 4, not this task); while it is still read,
2251
+ * every device carrying a still-read legacy key gets the additive `legacy-device-parameter`
2252
+ * warning, and every applied override gets the additive `registry-override-applied` notice
2253
+ * (emitted in `compile.ts`, where the registry is in scope).
2254
+ *
2255
+ * Every refusal names the component, the parameter and the rule (`NetlistError` carries the
2256
+ * component id, and `compile` turns it into an `unsupported` result at stage `netlist`).
2257
+ */
2258
+ const DEVICE_OVERRIDE_KEY = "DeviceOverride";
2259
+ /**
2260
+ * The schema class an override block on this device is checked against, or `null` when the
2261
+ * device kind takes no registry-class parameters and an override block is an error.
2262
+ *
2263
+ * `diode` device kind covers three classes with the same five names: zener when the component
2264
+ * declares itself one (`Type: Zener`, the only two corpus spellings, or the canonical
2265
+ * `Circuit.ZenerDiode` source type), led when the component kind is `led`, else diode. Only
2266
+ * the *required set* differs between them (a zener additionally requires `breakdownVolts`,
2267
+ * because without it the law stamps an ordinary diode).
2268
+ */
2269
+ function overrideTargetClass(component, deviceKind) {
2270
+ switch (deviceKind) {
2271
+ case "bjt":
2272
+ return "bjt";
2273
+ case "jfet":
2274
+ return "jfet";
2275
+ case "mosfet":
2276
+ return "mosfet";
2277
+ case "diode": {
2278
+ const declaredType = propertyString(component, "Type");
2279
+ if (declaredType === "Zener" ||
2280
+ declaredType === "zener" ||
2281
+ component.sourceTypeName === "Circuit.ZenerDiode") {
2282
+ return "zener";
2283
+ }
2284
+ if (String(component.kind) === "led") {
2285
+ return "led";
2286
+ }
2287
+ return "diode";
2288
+ }
2289
+ case "opamp":
2290
+ return "opamp";
2291
+ default:
2292
+ return null;
2293
+ }
2294
+ }
2295
+ /**
2296
+ * Legacy typed device-parameter keys this stage still reads, per device kind. Only these warn:
2297
+ * keys the compiler never reads (`SR`, `Rout`, `Rin`, `Vsat`, a diode's `IS`/`n`, ...) change
2298
+ * nothing when deleted and belong to the migration table, not to a compile warning.
2299
+ */
2300
+ const LEGACY_DEVICE_PARAM_KEYS = {
2301
+ bjt: ["IS", "BF", "BR", "LeakageCurrent"],
2302
+ jfet: [
2303
+ "Vt0",
2304
+ "VTO",
2305
+ "ThresholdVoltage",
2306
+ "thresholdVolts",
2307
+ "Beta",
2308
+ "BETA",
2309
+ "Transconductance",
2310
+ "transconductance",
2311
+ ],
2312
+ mosfet: [
2313
+ "Vt0",
2314
+ "VTO",
2315
+ "ThresholdVoltage",
2316
+ "thresholdVolts",
2317
+ "Beta",
2318
+ "BETA",
2319
+ "Transconductance",
2320
+ "transconductance",
2321
+ ],
2322
+ diode: [
2323
+ "ReverseBreakdownVoltage",
2324
+ "BreakdownVoltage",
2325
+ "ZenerVoltage",
2326
+ "SaturationCurrent",
2327
+ "SeriesResistance",
2328
+ "OhmicResistance",
2329
+ "RS",
2330
+ "Rs",
2331
+ "EmissionCoefficient",
2332
+ ],
2333
+ opamp: ["Aol", "GBP"],
2334
+ };
2335
+ /**
2336
+ * The engine field a legacy key feeds, for the superseded computation. A legacy key is
2337
+ * superseded when the override supplies the same engine field -- compared on fields, not on
2338
+ * spellings, so the compiler holds no legacy-name to schema-name translation table (that
2339
+ * table lives only in the step-3 migration script).
2340
+ */
2341
+ const LEGACY_KEY_ENGINE_FIELD = {
2342
+ IS: "saturationCurrent",
2343
+ BF: "beta",
2344
+ BR: "reverseBeta",
2345
+ LeakageCurrent: "leakageAmps",
2346
+ Vt0: "thresholdVolts",
2347
+ VTO: "thresholdVolts",
2348
+ ThresholdVoltage: "thresholdVolts",
2349
+ thresholdVolts: "thresholdVolts",
2350
+ Beta: "transconductance",
2351
+ BETA: "transconductance",
2352
+ Transconductance: "transconductance",
2353
+ transconductance: "transconductance",
2354
+ ReverseBreakdownVoltage: "breakdownVolts",
2355
+ BreakdownVoltage: "breakdownVolts",
2356
+ ZenerVoltage: "breakdownVolts",
2357
+ SaturationCurrent: "saturationCurrent",
2358
+ SeriesResistance: "seriesResistance",
2359
+ OhmicResistance: "seriesResistance",
2360
+ RS: "seriesResistance",
2361
+ Rs: "seriesResistance",
2362
+ EmissionCoefficient: "emissionCoefficient",
2363
+ Aol: "openLoopGain",
2364
+ GBP: "gainBandwidthHz",
2365
+ };
2366
+ /**
2367
+ * The engine field an override schema name writes. Every schema name already IS the engine
2368
+ * field except the BJT's gain: the document-typed legacy key is `BF`, the engine field is
2369
+ * `beta` (read by `device-laws.ts` and the provisional-value suppression), and the schema
2370
+ * name is `forwardBeta`.
2371
+ */
2372
+ function overrideEngineField(target, name) {
2373
+ if (target === "bjt" && name === "forwardBeta") {
2374
+ return "beta";
2375
+ }
2376
+ return name;
2377
+ }
2378
+ /**
2379
+ * The class parameters an override block may actually set, by target class: the subset of
2380
+ * the class schema that the engine reads from the document.
2381
+ *
2382
+ * A schema name outside this set is refused even though it is a real class parameter, because
2383
+ * accepting it would be a silent drop: `device-laws.ts` reads a JFET's gate terms
2384
+ * (`gateSaturationCurrent`, `gateOnsetVolts`, `gateScaleVolts`), its
2385
+ * `channelLengthModulation` and `subthresholdVolts`, and an op-amp's output-swing pair ONLY
2386
+ * from the registry entry, never from `device.parameters`. An override value for one of those
2387
+ * would merge, stamp nothing, and change nothing -- the wrong-answer-that-renders this file
2388
+ * exists to prevent. Those numbers come from registry entries (stage 3 fills them), never
2389
+ * from a per-device override; polarity and channel stay structural for the same reason.
2390
+ */
2391
+ const OVERRIDABLE_CLASS_PARAMS = {
2392
+ bjt: [
2393
+ "saturationCurrent",
2394
+ "forwardBeta",
2395
+ "reverseBeta",
2396
+ "thermalVoltage",
2397
+ "leakageAmps",
2398
+ ],
2399
+ jfet: ["thresholdVolts", "transconductance"],
2400
+ mosfet: ["thresholdVolts", "transconductance"],
2401
+ diode: [
2402
+ "saturationCurrent",
2403
+ "emissionCoefficient",
2404
+ "thermalVoltage",
2405
+ "breakdownVolts",
2406
+ "seriesResistance",
2407
+ ],
2408
+ zener: [
2409
+ "saturationCurrent",
2410
+ "emissionCoefficient",
2411
+ "thermalVoltage",
2412
+ "breakdownVolts",
2413
+ "seriesResistance",
2414
+ ],
2415
+ led: [
2416
+ "saturationCurrent",
2417
+ "emissionCoefficient",
2418
+ "thermalVoltage",
2419
+ "breakdownVolts",
2420
+ "seriesResistance",
2421
+ ],
2422
+ opamp: ["openLoopGain", "gainBandwidthHz"],
2423
+ };
2424
+ /** A non-empty authored string, or `null` when the value is not one. */
2425
+ function nonEmptyText(value) {
2426
+ if (typeof value !== "string" || value.trim().length === 0) {
2427
+ return null;
2428
+ }
2429
+ return value.trim();
2430
+ }
2431
+ /**
2432
+ * Check one override value: finite, and positive (or bounded) where the parameter is
2433
+ * physically required to be. Names the component, the parameter and the rule.
2434
+ */
2435
+ function checkOverrideValue(componentId, target, name, value) {
2436
+ const refuse = (rule) => {
2437
+ throw new NetlistError(`component ${componentId}: DeviceOverride parameter "${name}" ${JSON.stringify(value)} ${rule}`, componentId);
2438
+ };
2439
+ if (typeof value !== "number" || !Number.isFinite(value)) {
2440
+ refuse("is not a finite SI number");
2441
+ }
2442
+ const n = value;
2443
+ switch (name) {
2444
+ case "emissionCoefficient":
2445
+ if (n < EMISSION_COEFFICIENT_MIN || n > EMISSION_COEFFICIENT_MAX) {
2446
+ refuse(`is not an ideality factor from ${EMISSION_COEFFICIENT_MIN} to ${EMISSION_COEFFICIENT_MAX}`);
2447
+ }
2448
+ return n;
2449
+ case "leakageAmps":
2450
+ case "breakdownVolts":
2451
+ case "seriesResistance":
2452
+ case "channelLengthModulation":
2453
+ case "subthresholdVolts":
2454
+ case "gateSaturationCurrent":
2455
+ if (n < 0) {
2456
+ refuse(`is negative, and a negative ${name} is not physical`);
2457
+ }
2458
+ return n;
2459
+ case "thresholdVolts":
2460
+ case "gateOnsetVolts":
2461
+ // Sign carries the physics here (depletion negative, enhancement positive, and
2462
+ // p-channel JFETs pinch off positive), so any finite value -- including zero --
2463
+ // parses, exactly as the legacy `Vt0` reader accepts any quantity.
2464
+ return n;
2465
+ default:
2466
+ // saturationCurrent, forwardBeta, reverseBeta, thermalVoltage, transconductance,
2467
+ // gateScaleVolts, openLoopGain, gainBandwidthHz, volts, sourceOhms,
2468
+ // chargePumpLossVolts: zero or negative is not a value for any of them.
2469
+ if (!(n > 0)) {
2470
+ refuse(`is not a positive finite number, which ${name} must be`);
2471
+ }
2472
+ return n;
2473
+ }
2474
+ }
2475
+ /**
2476
+ * Parse and validate the `DeviceOverride` block on this component, or return null when it
2477
+ * declares none. Throws `NetlistError` (naming component, parameter and rule) on every
2478
+ * malformed block: unknown kind, missing/malformed provenance, malformed parameter list,
2479
+ * unknown name for the class, duplicate parameter, non-physical value, `custom` on a known
2480
+ * part, or `custom` missing a required parameter. Override on a device kind that takes no
2481
+ * registry-class parameters is an error, so a block on a resistor or an IC can never sit
2482
+ * silently unread.
2483
+ */
2484
+ function readDeviceOverride(component, deviceKind, registry) {
2485
+ const raw = component.properties[DEVICE_OVERRIDE_KEY];
2486
+ if (raw === undefined || raw === null) {
2487
+ return null;
2488
+ }
2489
+ const componentId = String(component.id);
2490
+ const target = overrideTargetClass(component, deviceKind);
2491
+ if (target === null) {
2492
+ throw new NetlistError(`component ${componentId}: DeviceOverride is not supported for ${deviceKind} devices (bjt, jfet, mosfet, diode and opamp only)`, componentId);
2493
+ }
2494
+ if (typeof raw !== "object" || Array.isArray(raw)) {
2495
+ throw new NetlistError(`component ${componentId}: DeviceOverride must be an object with kind, provenance and parameters, not ${JSON.stringify(raw)}`, componentId);
2496
+ }
2497
+ const block = raw;
2498
+ const kind = block.kind;
2499
+ if (kind !== "measured" && kind !== "custom") {
2500
+ throw new NetlistError(`component ${componentId}: DeviceOverride.kind ${JSON.stringify(kind)} is not "measured" or "custom"`, componentId);
2501
+ }
2502
+ const source = nonEmptyText(block.source);
2503
+ if (source === null) {
2504
+ throw new NetlistError(`component ${componentId}: DeviceOverride.source is missing (what was measured, or which document the numbers come from)`, componentId);
2505
+ }
2506
+ const method = nonEmptyText(block.method);
2507
+ if (method === null) {
2508
+ throw new NetlistError(`component ${componentId}: DeviceOverride.method is missing (instrument/procedure for measured, why the registry lacks the part for custom)`, componentId);
2509
+ }
2510
+ const dateRaw = nonEmptyText(block.date);
2511
+ if (dateRaw === null ||
2512
+ !/^\d{4}-\d{2}-\d{2}$/u.test(dateRaw) ||
2513
+ Number.isNaN(Date.parse(`${dateRaw}T00:00:00Z`))) {
2514
+ throw new NetlistError(`component ${componentId}: DeviceOverride.date ${JSON.stringify(block.date)} is not an ISO date (YYYY-MM-DD)`, componentId);
2515
+ }
2516
+ const by = nonEmptyText(block.by);
2517
+ if (by === null) {
2518
+ throw new NetlistError(`component ${componentId}: DeviceOverride.by is missing (a named person or organisation)`, componentId);
2519
+ }
2520
+ const conditions = block.conditions;
2521
+ if (conditions !== undefined && typeof conditions !== "string") {
2522
+ throw new NetlistError(`component ${componentId}: DeviceOverride.conditions must be free text, not ${JSON.stringify(conditions)}`, componentId);
2523
+ }
2524
+ const list = block.parameters;
2525
+ if (!Array.isArray(list) || list.length === 0) {
2526
+ throw new NetlistError(`component ${componentId}: DeviceOverride.parameters must be a non-empty list of {name, value}`, componentId);
2527
+ }
2528
+ const allowed = new Set(classParamNames(target));
2529
+ const overridable = new Set(OVERRIDABLE_CLASS_PARAMS[target]);
2530
+ const seen = new Set();
2531
+ const parameters = [];
2532
+ for (const [index, entry] of list.entries()) {
2533
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
2534
+ throw new NetlistError(`component ${componentId}: DeviceOverride.parameters[${index}] must be {name, value}, not ${JSON.stringify(entry)}`, componentId);
2535
+ }
2536
+ const record = entry;
2537
+ const name = record.name;
2538
+ if (typeof name !== "string" || !allowed.has(name)) {
2539
+ throw new NetlistError(`component ${componentId}: DeviceOverride parameter ${JSON.stringify(name)} is not a ${target} parameter (accepted: ${[...allowed].join(", ")})`, componentId);
2540
+ }
2541
+ if (!overridable.has(name)) {
2542
+ throw new NetlistError(`component ${componentId}: DeviceOverride parameter "${name}" is a ${target} parameter but the engine reads it only from the registry entry, never from the document, so an override cannot set it (overridable: ${[...overridable].join(", ")})`, componentId);
2543
+ }
2544
+ if (seen.has(name)) {
2545
+ throw new NetlistError(`component ${componentId}: DeviceOverride parameter "${name}" is stated twice (one value per parameter)`, componentId);
2546
+ }
2547
+ seen.add(name);
2548
+ const value = checkOverrideValue(componentId, target, name, record.value);
2549
+ parameters.push({ name, value });
2550
+ }
2551
+ if (kind === "custom") {
2552
+ const partNumber = propertyString(component, "PartNumber");
2553
+ if (partNumber !== null &&
2554
+ registryEntryFor(registry, { partId: partNumber, evidence: "exact-part" }) !== null) {
2555
+ throw new NetlistError(`component ${componentId}: DeviceOverride kind "custom" on part "${partNumber}", which the registry already has (use kind "measured", or remove the block)`, componentId);
2556
+ }
2557
+ const missing = requiredClassParams(target).filter((name) => !seen.has(name));
2558
+ if (missing.length > 0) {
2559
+ throw new NetlistError(`component ${componentId}: DeviceOverride kind "custom" for class ${target} is missing required parameter(s): ${missing.join(", ")}`, componentId);
2560
+ }
2561
+ }
2562
+ const provenance = {
2563
+ kind,
2564
+ source,
2565
+ method,
2566
+ date: dateRaw,
2567
+ by,
2568
+ ...(conditions === undefined ? {} : { conditions }),
2569
+ };
2570
+ return { override: { provenance, parameters }, target };
2571
+ }
2572
+ /**
2573
+ * Merge an explicit override over this device's legacy typed parameters (override wins),
2574
+ * and emit the additive `legacy-device-parameter` warning for every legacy key still read.
2575
+ * No override block: legacy parameters pass through bit-identically and only the warning
2576
+ * is new.
2577
+ */
2578
+ function applyDeviceOverride(component, deviceKind, legacy, context) {
2579
+ const read = readDeviceOverride(component, deviceKind, context.registry);
2580
+ const legacyKeys = (LEGACY_DEVICE_PARAM_KEYS[deviceKind] ?? []).filter((key) => component.properties[key] !== undefined);
2581
+ if (read === null) {
2582
+ if (legacyKeys.length > 0) {
2583
+ const warning = {
2584
+ code: "legacy-device-parameter",
2585
+ device: context.deviceId,
2586
+ keys: legacyKeys,
2587
+ overridden: [],
2588
+ detail: `${context.deviceId} still types device parameters under legacy keys (${legacyKeys.join(", ")}), ` +
2589
+ `which the compiler still reads and the stage-4 removal will stop reading: ` +
2590
+ `migrate them to a DeviceOverride block (measured with provenance, or custom ` +
2591
+ `for a part the registry lacks).`,
2592
+ };
2593
+ context.warnings.push(warning);
2594
+ }
2595
+ return { parameters: legacy, deviceOverride: undefined };
2596
+ }
2597
+ const { override, target } = read;
2598
+ const values = { ...legacy };
2599
+ const overrideFields = new Set(override.parameters.map((parameter) => overrideEngineField(target, parameter.name)));
2600
+ for (const parameter of override.parameters) {
2601
+ values[overrideEngineField(target, parameter.name)] =
2602
+ parameter.value;
2603
+ }
2604
+ const overridden = legacyKeys.filter((key) => {
2605
+ const field = LEGACY_KEY_ENGINE_FIELD[key];
2606
+ return field !== undefined && overrideFields.has(field);
2607
+ });
2608
+ const warning = {
2609
+ code: "legacy-device-parameter",
2610
+ device: context.deviceId,
2611
+ keys: legacyKeys,
2612
+ overridden,
2613
+ detail: overridden.length > 0
2614
+ ? `${context.deviceId} applies a ${override.provenance.kind} DeviceOverride that supersedes legacy keys (${overridden.join(", ")}); the override values win. ` +
2615
+ `Remaining legacy keys (${legacyKeys.filter((key) => !overridden.includes(key)).join(", ") || "none"}) still read as before.`
2616
+ : `${context.deviceId} applies a ${override.provenance.kind} DeviceOverride alongside legacy keys (${legacyKeys.join(", ") || "none"}); no legacy key feeds an overridden parameter, so all legacy values still read.`,
2617
+ };
2618
+ if (legacyKeys.length > 0) {
2619
+ context.warnings.push(warning);
2620
+ }
2621
+ return { parameters: values, deviceOverride: override };
2622
+ }
2077
2623
  function parametersFor(component, kind) {
2078
2624
  // A supply rail asserts a potential at its node. Most declare one -- 84 of the
2079
2625
  // corpus's 90 -- and the six that do not are named support shells with nothing to
@@ -2224,10 +2770,18 @@ function parametersFor(component, kind) {
2224
2770
  // with the decibel conversion done -- `{raw: "92 dB", value: 39810, unit: ""}`. So the typed
2225
2771
  // `value` is taken as-is and nothing here converts from dB, which would double-apply it.
2226
2772
  //
2227
- // `GBP` and `SR` are deliberately not read. The gain-bandwidth product has no consumer until a
2228
- // dominant pole exists, and that was implemented and reverted; reading it into the stamp now
2229
- // would be a field nothing uses. `SR` is stated as `"1.2 V/us"`, and `parseQuantity` strips
2230
- // after the slash, so it would yield 1.2 for the wrong reason -- right number, lost units.
2773
+ // `GBP`, the gain-bandwidth product, is read as a typed frequency. It was left unread while no
2774
+ // dominant pole existed for it to feed; the pole landed 2026-08-21 and both consoles now take
2775
+ // the product from the stamp. It is stated in several forms across the corpus -- `"3 MHz"`,
2776
+ // `"3000000"`, `1.2 MHz`, and structured `{value, unit}` -- so it goes through
2777
+ // `typedFrequencyHz`, which reads the unit whole and refuses what it cannot read. Nothing here
2778
+ // falls back to a number: a packet that states none leaves the field absent, and the catalog's
2779
+ // datasheet figure for the exact part id, else `null`, is decided in `device-laws.ts`.
2780
+ //
2781
+ // `SR` stays unread. It is stated as `"1.2 V/us"`, and `parseQuantity` strips after the slash,
2782
+ // so it would yield 1.2 for the wrong reason -- right number, lost units -- and no consumer
2783
+ // exists for a slew rate yet. `typedFrequencyHz` does not inherit that trap: it compares the
2784
+ // whole string against a number and a frequency unit and refuses anything else.
2231
2785
  if (kind === "opamp") {
2232
2786
  // `typedNumber`, not `optionalQuantity`, and the difference is a 433x error.
2233
2787
  // `optionalQuantity` reads `raw` first, so `{raw: "92 dB", value: 39810, unit: ""}` parsed as
@@ -2235,7 +2789,11 @@ function parametersFor(component, kind) {
2235
2789
  // the conversion, so it has to win. Caught by measuring the stamped gains after the change
2236
2790
  // rather than by reading the code.
2237
2791
  const gain = typedNumber(component, "Aol", [""]);
2238
- return gain !== null && gain > 0 ? { openLoopGain: gain } : {};
2792
+ const bandwidth = typedFrequencyHz(component, "GBP");
2793
+ return {
2794
+ ...(gain !== null && gain > 0 ? { openLoopGain: gain } : {}),
2795
+ ...(bandwidth === null ? {} : { gainBandwidthHz: bandwidth }),
2796
+ };
2239
2797
  }
2240
2798
  // A delay-memory shell's own delay time, which is a **packet** fact rather than a part fact.
2241
2799
  //
@@ -2374,6 +2932,12 @@ function parametersFor(component, kind) {
2374
2932
  optionalQuantity(component, "OhmicResistance") ??
2375
2933
  optionalQuantity(component, "RS") ??
2376
2934
  optionalQuantity(component, "Rs");
2935
+ // `EmissionCoefficient` -- the junction's ideality, the slope of its knee. One packet declares
2936
+ // it (`boss-sd-1`, 1.9 on three clipping diodes) and until this read it was discarded: the law
2937
+ // stamped 1.752 whatever the source said, and nothing said so, so a knee that a packet states
2938
+ // was a knee the engine never had. Typed and refused when unreadable, like `GBP`; absent stays
2939
+ // absent, so `deviceLaw` keeps ownership of what an undeclared coefficient means.
2940
+ const emission = typedEmissionCoefficient(component);
2377
2941
  return {
2378
2942
  ...(breakdown !== null && breakdown > 0
2379
2943
  ? { breakdownVolts: breakdown }
@@ -2384,6 +2948,7 @@ function parametersFor(component, kind) {
2384
2948
  ...(seriesResistance !== null && seriesResistance > 0
2385
2949
  ? { seriesResistance }
2386
2950
  : {}),
2951
+ ...(emission === null ? {} : { emissionCoefficient: emission }),
2387
2952
  };
2388
2953
  }
2389
2954
  // A bipolar transistor's device type and SPICE parameters, which this stage used to
@@ -3302,6 +3867,11 @@ function selectedOption(component, stated) {
3302
3867
  * Deliberately absent: `W`, `W20`, `G`, `D`, `L`, `BH`, `Stepped`, `Trim`,
3303
3868
  * `Boss-G-taper` and the other manufacturer-specific curves. We do not know their
3304
3869
  * shapes, and guessing one would be inventing a device law from a letter.
3870
+ *
3871
+ * `s-curve` is the one S-shaped law present, and it is the *law's name*, not Alpha's letter: a
3872
+ * pot marked `W` or `W20` stays absent, because makers disagree on W and a letter selects a
3873
+ * curve only with its maker. A document that has established its pot's S-curve is Alpha W(4B)
3874
+ * says so by writing `s-curve`.
3305
3875
  */
3306
3876
  const taperSpellings = new Map([
3307
3877
  ["linear", "linear"],
@@ -3319,6 +3889,9 @@ const taperSpellings = new Map([
3319
3889
  ["negativelog", "reverse-logarithmic"],
3320
3890
  ["c", "reverse-logarithmic"],
3321
3891
  ["reverselinear", "reverse-linear"],
3892
+ // Alpha W(4B). `foldTaper` strips `-`, `_` and whitespace, so this one key is `s-curve`,
3893
+ // `S-Curve`, `S Curve` and `scurve`; a hyphenated key would be unreachable.
3894
+ ["scurve", "s-curve"],
3322
3895
  ]);
3323
3896
  /**
3324
3897
  * Values whose meaning is "the source does not state a taper".
@@ -3336,12 +3909,11 @@ const taperUnknownSpellings = new Set([
3336
3909
  /**
3337
3910
  * Tapers `@vessel-dsp/core`'s format supports and this runtime has no curve for.
3338
3911
  *
3339
- * `PotentiometerTaper` carries seven values; `TaperKind` -- the executable law -- carries four.
3340
- * A document declaring one of these three has stated a real taper that the render cannot produce,
3912
+ * `PotentiometerTaper` carries seven values; `TaperKind` -- the executable law -- carries five.
3913
+ * A document declaring one of these two has stated a real taper that the render cannot produce,
3341
3914
  * which is a different failure from an unreadable marking and is worth saying differently.
3342
3915
  */
3343
3916
  const taperWithoutLaw = new Set([
3344
- "scurve",
3345
3917
  "stepped",
3346
3918
  "custom",
3347
3919
  ]);
@@ -3370,7 +3942,7 @@ function taperKind(value) {
3370
3942
  * have read;
3371
3943
  * - the value states anything else -> linear, and **name it**, whether it is a part marking whose
3372
3944
  * curve is not derivable here (`W20`, `Boss-G-taper`) or a taper the format supports and this
3373
- * runtime has no law for (`s-curve`, `stepped`).
3945
+ * runtime has no law for (`stepped`, `custom`).
3374
3946
  */
3375
3947
  function declaredTaper(component) {
3376
3948
  // `Sweep` is what a variable-resistor calls its taper. Both are closed vocabularies