@vessel-dsp/core 0.6.18 → 0.6.20

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.
@@ -0,0 +1,732 @@
1
+ /**
2
+ * trace-plausibility.ts — advisory `.vdsp` trace checks.
3
+ *
4
+ * Cheap, static heuristics that flag *suspicious* traces for a human/agent to
5
+ * double-check against the source. Every issue is severity `"warning"`. This is
6
+ * NOT a verifier and NOT a rating input, and it is deliberately incomplete.
7
+ * Simple value/corner checks cannot catch a plausible `1n`->`1u` slip, but the
8
+ * opt-in audio-topology pass can warn when surrounding graph context makes the
9
+ * resulting shunt destructive. Dynamic admission is still required for
10
+ * behavioral validation.
11
+ *
12
+ * Structure vs value are split on purpose:
13
+ * - `validateTraceStructure` — connectivity-dependent (floating/shorted/divider).
14
+ * It is COVERAGE-GATED and fails closed: if the resolved net graph is not
15
+ * sufficiently complete, it emits a single `trace-connectivity-incomplete`
16
+ * note and runs NO net checks (rather than emitting hundreds of artifacts).
17
+ * - `validatePreferredValues` — connectivity-independent E24 check. Noisy
18
+ * (vintage/E48/E96 values, tolerances), so it is OPT-IN, not in the default.
19
+ * - `validateRcCornerHeuristic` — a rough RC-corner heuristic, OPT-IN only; it
20
+ * has a high intrinsic false-positive rate (intentional sub-/supersonic poles)
21
+ * and does NOT catch in-band value slips, so it is not a default check.
22
+ * - `validateAudioTopologyWarnings` — OPT-IN, role- and connectivity-gated
23
+ * warnings for destructive audio shunts, extreme direct input loading, and
24
+ * explicitly declared op-amp buffers without a passive feedback path.
25
+ *
26
+ * `validateTracePlausibility` runs the safe default (structure only) plus any
27
+ * opt-in checks requested via options.
28
+ */
29
+ import { getPinNode, resolveConnectivity, } from "./connectivity.js";
30
+ import { propertyQuantityValue, propertyStringValue } from "./properties.js";
31
+ const E24 = [
32
+ 1, 1.1, 1.2, 1.3, 1.5, 1.6, 1.8, 2, 2.2, 2.4, 2.7, 3, 3.3, 3.6, 3.9, 4.3, 4.7,
33
+ 5.1, 5.6, 6.2, 6.8, 7.5, 8.2, 9.1,
34
+ ];
35
+ const SUPPLY_KINDS = new Set([
36
+ "voltage-source",
37
+ "current-source",
38
+ "battery",
39
+ "rail",
40
+ "ground",
41
+ "regulator",
42
+ "power-converter",
43
+ ]);
44
+ // A lone terminal is a real "broken trace" signal only for 2-terminal passives.
45
+ // Unused/NC pins on ICs, op-amps, transistors, jacks, etc. are expected.
46
+ const FLOAT_FLAG_KINDS = new Set([
47
+ "resistor",
48
+ "capacitor",
49
+ "inductor",
50
+ "diode",
51
+ "led",
52
+ "variable-resistor",
53
+ ]);
54
+ const AUDIO_INPUT_ROLES = new Set(["input", "audio-input", "in"]);
55
+ const AUDIO_OUTPUT_ROLES = new Set(["output", "audio-output", "out"]);
56
+ const AUDIO_REFERENCE_ROLES = new Set(["bias", "bias-reference", "reference"]);
57
+ const SUPPLY_NODE_ROLES = new Set([
58
+ "main-supply",
59
+ "negative-supply",
60
+ "regulated-output",
61
+ "charge-pump-output",
62
+ "power",
63
+ "power-input",
64
+ "rail",
65
+ "supply",
66
+ ]);
67
+ const PASSIVE_PATH_KINDS = new Set([
68
+ "resistor",
69
+ "capacitor",
70
+ "inductor",
71
+ "diode",
72
+ "switch",
73
+ "variable-resistor",
74
+ "potentiometer",
75
+ ]);
76
+ const AUDIO_SHUNT_REFERENCE_HZ = 1000;
77
+ const AUDIO_SHUNT_WARNING_DB = -12;
78
+ const INPUT_LOADING_WARNING_OHMS = 2000;
79
+ function readSiValue(prop) {
80
+ const q = propertyQuantityValue(prop);
81
+ if (q && Number.isFinite(q.value))
82
+ return q.value;
83
+ if (typeof prop === "string") {
84
+ const m = prop.match(/value:\s*(-?[0-9.]+(?:e-?[0-9]+)?)/i);
85
+ if (m) {
86
+ const n = Number(m[1]);
87
+ if (Number.isFinite(n))
88
+ return n;
89
+ }
90
+ }
91
+ return null;
92
+ }
93
+ function componentValue(c) {
94
+ if (c.kind === "resistor" || c.kind === "variable-resistor")
95
+ return readSiValue(c.properties?.Resistance);
96
+ if (c.kind === "capacitor")
97
+ return readSiValue(c.properties?.Capacitance);
98
+ return null;
99
+ }
100
+ // Robust decade normalization (avoids float drift from iterative *10).
101
+ function mantissa(v) {
102
+ if (!(v > 0))
103
+ return Number.NaN;
104
+ return v / 10 ** Math.floor(Math.log10(v));
105
+ }
106
+ function isE24(v) {
107
+ const m = mantissa(v);
108
+ if (!Number.isFinite(m))
109
+ return false;
110
+ // treat ~10 (float edge) as the next decade's 1.0
111
+ const mm = m >= 9.95 ? 1 : m;
112
+ return E24.some((e) => Math.abs(e - mm) / e < 0.02);
113
+ }
114
+ function fmt(v) {
115
+ const a = Math.abs(v);
116
+ if (a >= 1e6)
117
+ return `${+(v / 1e6).toPrecision(3)}M`;
118
+ if (a >= 1e3)
119
+ return `${+(v / 1e3).toPrecision(3)}k`;
120
+ if (a >= 1)
121
+ return `${+v.toPrecision(3)}`;
122
+ if (a >= 1e-3)
123
+ return `${+(v * 1e3).toPrecision(3)}m`;
124
+ if (a >= 1e-6)
125
+ return `${+(v * 1e6).toPrecision(3)}u`;
126
+ if (a >= 1e-9)
127
+ return `${+(v * 1e9).toPrecision(3)}n`;
128
+ return `${+(v * 1e12).toPrecision(3)}p`;
129
+ }
130
+ function nodesOf(conn, c) {
131
+ const out = [];
132
+ for (const t of c.terminals ?? []) {
133
+ const n = getPinNode(conn, { componentId: c.id, terminalName: t.name });
134
+ if (n !== undefined)
135
+ out.push(n);
136
+ }
137
+ return out;
138
+ }
139
+ function uniqueNodesOf(conn, component) {
140
+ return [...new Set(nodesOf(conn, component))];
141
+ }
142
+ function normalizedRole(value) {
143
+ return value
144
+ .trim()
145
+ .toLowerCase()
146
+ .replace(/[_\s]+/g, "-");
147
+ }
148
+ function propertyText(component, ...names) {
149
+ const wanted = new Set(names.map((name) => name.toLowerCase()));
150
+ for (const [name, value] of Object.entries(component.properties ?? {})) {
151
+ if (!wanted.has(name.toLowerCase()))
152
+ continue;
153
+ const text = propertyStringValue(value);
154
+ if (text?.trim())
155
+ return text.trim();
156
+ }
157
+ return null;
158
+ }
159
+ function componentRole(doc, component) {
160
+ const direct = propertyText(component, "Role", "AudioRole", "BoundaryRole");
161
+ if (direct)
162
+ return normalizedRole(direct);
163
+ const control = doc.deviceInterface?.controls.find((item) => item.id === component.id || item.binding?.componentId === component.id);
164
+ return control ? normalizedRole(control.role) : "";
165
+ }
166
+ function terminalNode(conn, component, aliases) {
167
+ const wanted = new Set(aliases.map((name) => name.toLowerCase().replace(/[^a-z0-9]/g, "")));
168
+ for (const terminal of component.terminals) {
169
+ const name = terminal.name.toLowerCase().replace(/[^a-z0-9]/g, "");
170
+ if (!wanted.has(name))
171
+ continue;
172
+ const node = getPinNode(conn, {
173
+ componentId: component.id,
174
+ terminalName: terminal.name,
175
+ });
176
+ if (node !== undefined)
177
+ return node;
178
+ }
179
+ return undefined;
180
+ }
181
+ function jackSignalNode(conn, component) {
182
+ return terminalNode(conn, component, [
183
+ "tip",
184
+ "signal",
185
+ "anode",
186
+ "positive",
187
+ "t",
188
+ ]);
189
+ }
190
+ function audioPorts(doc) {
191
+ const inputs = [];
192
+ const outputs = [];
193
+ for (const component of doc.components) {
194
+ if (component.kind !== "jack")
195
+ continue;
196
+ const role = componentRole(doc, component);
197
+ if (AUDIO_INPUT_ROLES.has(role))
198
+ inputs.push(component);
199
+ if (AUDIO_OUTPUT_ROLES.has(role))
200
+ outputs.push(component);
201
+ }
202
+ return { inputs, outputs };
203
+ }
204
+ function addRoleNode(roleValue, nodeId, ground, audioReferences, supplies) {
205
+ const role = normalizedRole(roleValue);
206
+ if (role === "ground")
207
+ ground.add(nodeId);
208
+ if (AUDIO_REFERENCE_ROLES.has(role))
209
+ audioReferences.add(nodeId);
210
+ if (SUPPLY_NODE_ROLES.has(role))
211
+ supplies.add(nodeId);
212
+ }
213
+ function componentBetweenNodes(doc, conn, kind, node, targets) {
214
+ return doc.components.find((component) => {
215
+ if (component.kind !== kind)
216
+ return false;
217
+ const nodes = uniqueNodesOf(conn, component);
218
+ return (nodes.length === 2 &&
219
+ nodes.includes(node) &&
220
+ nodes.some((candidate) => candidate !== node && targets.has(candidate)));
221
+ });
222
+ }
223
+ function audioNodeSets(doc, conn, nodeRoles, ports) {
224
+ const ground = new Set();
225
+ const audioReferences = new Set();
226
+ const supplies = new Set();
227
+ if (conn.groundNodeId !== null)
228
+ ground.add(conn.groundNodeId);
229
+ for (const [nodeId, role] of nodeRoles ?? []) {
230
+ addRoleNode(role, nodeId, ground, audioReferences, supplies);
231
+ }
232
+ const powerRailRoles = new Map();
233
+ for (const domain of doc.power?.domains ?? []) {
234
+ for (const rail of domain.rails) {
235
+ powerRailRoles.set(rail.railComponentId, rail.role);
236
+ }
237
+ }
238
+ for (const component of doc.components) {
239
+ const nodes = uniqueNodesOf(conn, component);
240
+ if (component.kind === "ground") {
241
+ for (const node of nodes)
242
+ ground.add(node);
243
+ }
244
+ if (component.kind === "rail") {
245
+ const role = powerRailRoles.get(component.id);
246
+ for (const node of nodes) {
247
+ if (role === "bias-reference")
248
+ audioReferences.add(node);
249
+ else
250
+ supplies.add(node);
251
+ }
252
+ }
253
+ }
254
+ if (ports.inputs.length === 1 && ports.outputs.length === 1) {
255
+ const input = ports.inputs[0];
256
+ const output = ports.outputs[0];
257
+ if (input && output) {
258
+ const inputSleeve = terminalNode(conn, input, [
259
+ "sleeve",
260
+ "ground",
261
+ "cathode",
262
+ "negative",
263
+ "return",
264
+ ]);
265
+ const outputSleeve = terminalNode(conn, output, [
266
+ "sleeve",
267
+ "ground",
268
+ "cathode",
269
+ "negative",
270
+ "return",
271
+ ]);
272
+ if (inputSleeve !== undefined && inputSleeve === outputSleeve) {
273
+ ground.add(inputSleeve);
274
+ }
275
+ }
276
+ }
277
+ for (const nodeId of conn.nodeMembers.keys()) {
278
+ if (ground.has(nodeId) || supplies.has(nodeId))
279
+ continue;
280
+ const hasGroundResistor = componentBetweenNodes(doc, conn, "resistor", nodeId, ground);
281
+ const hasSupplyResistor = componentBetweenNodes(doc, conn, "resistor", nodeId, supplies);
282
+ const hasGroundCapacitor = componentBetweenNodes(doc, conn, "capacitor", nodeId, ground);
283
+ if (hasGroundResistor && hasSupplyResistor && hasGroundCapacitor) {
284
+ audioReferences.add(nodeId);
285
+ }
286
+ }
287
+ const blocked = new Set([...ground, ...audioReferences, ...supplies]);
288
+ return { ground, audioReferences, supplies, blocked };
289
+ }
290
+ function passivePathEdges(doc, conn, blocked, excludedComponentId) {
291
+ const edges = [];
292
+ for (const component of doc.components) {
293
+ if (component.id === excludedComponentId ||
294
+ !PASSIVE_PATH_KINDS.has(component.kind)) {
295
+ continue;
296
+ }
297
+ const nodes = uniqueNodesOf(conn, component).filter((node) => !blocked.has(node));
298
+ for (let index = 0; index < nodes.length; index += 1) {
299
+ const nodeA = nodes[index];
300
+ if (nodeA === undefined)
301
+ continue;
302
+ for (const nodeB of nodes.slice(index + 1)) {
303
+ edges.push([nodeA, nodeB, component]);
304
+ }
305
+ }
306
+ }
307
+ return edges;
308
+ }
309
+ function audioSignalEdges(doc, conn, blocked, excludedComponentId) {
310
+ const edges = passivePathEdges(doc, conn, blocked, excludedComponentId);
311
+ for (const component of doc.components) {
312
+ if (component.kind !== "opamp")
313
+ continue;
314
+ const signalNodes = [
315
+ terminalNode(conn, component, ["positive", "plus", "nonInverting"]),
316
+ terminalNode(conn, component, ["negative", "minus", "inverting"]),
317
+ terminalNode(conn, component, ["out", "output"]),
318
+ ].filter((node) => node !== undefined && !blocked.has(node));
319
+ for (let index = 0; index < signalNodes.length; index += 1) {
320
+ const nodeA = signalNodes[index];
321
+ if (nodeA === undefined)
322
+ continue;
323
+ for (const nodeB of signalNodes.slice(index + 1)) {
324
+ edges.push([nodeA, nodeB, component]);
325
+ }
326
+ }
327
+ }
328
+ return edges;
329
+ }
330
+ function shortestPathNodes(start, target, edges) {
331
+ if (start === undefined || target === undefined)
332
+ return null;
333
+ if (start === target)
334
+ return [start];
335
+ const adjacency = new Map();
336
+ for (const [nodeA, nodeB] of edges) {
337
+ if (!adjacency.has(nodeA))
338
+ adjacency.set(nodeA, new Set());
339
+ if (!adjacency.has(nodeB))
340
+ adjacency.set(nodeB, new Set());
341
+ adjacency.get(nodeA)?.add(nodeB);
342
+ adjacency.get(nodeB)?.add(nodeA);
343
+ }
344
+ const queue = [start];
345
+ const seen = new Set([start]);
346
+ const previous = new Map();
347
+ for (let index = 0; index < queue.length; index += 1) {
348
+ const node = queue[index];
349
+ if (node === undefined)
350
+ continue;
351
+ for (const neighbor of adjacency.get(node) ?? []) {
352
+ if (seen.has(neighbor))
353
+ continue;
354
+ seen.add(neighbor);
355
+ previous.set(neighbor, node);
356
+ if (neighbor === target) {
357
+ const path = [target];
358
+ while (path[0] !== start) {
359
+ const child = path[0];
360
+ if (child === undefined)
361
+ return null;
362
+ const parent = previous.get(child);
363
+ if (parent === undefined)
364
+ return null;
365
+ path.unshift(parent);
366
+ }
367
+ return path;
368
+ }
369
+ queue.push(neighbor);
370
+ }
371
+ }
372
+ return null;
373
+ }
374
+ function reachable(start, target, edges) {
375
+ return shortestPathNodes(start, target, edges) !== null;
376
+ }
377
+ /** Fraction of component terminals that sit on a ≥2-member net (connectivity completeness). */
378
+ export function traceConnectivityCompleteness(doc, conn) {
379
+ let total = 0;
380
+ let connected = 0;
381
+ for (const c of doc.components) {
382
+ for (const t of c.terminals ?? []) {
383
+ total++;
384
+ const n = getPinNode(conn, { componentId: c.id, terminalName: t.name });
385
+ if (n !== undefined && (conn.nodeMembers.get(n)?.length ?? 0) >= 2)
386
+ connected++;
387
+ }
388
+ }
389
+ return { fraction: total === 0 ? 1 : connected / total, connected, total };
390
+ }
391
+ function connectivityIncompleteIssue(coverage) {
392
+ return {
393
+ code: "trace-connectivity-incomplete",
394
+ severity: "warning",
395
+ message: `resolved net graph is only ${(coverage.fraction * 100).toFixed(0)}% complete (${coverage.connected}/${coverage.total} terminals on a >=2-member net); connectivity-dependent trace checks skipped (fail-closed). Provide complete connectivity to enable them.`,
396
+ };
397
+ }
398
+ /** Connectivity-dependent structural checks. Coverage-gated; fails closed. */
399
+ export function validateTraceStructure(doc, options = {}) {
400
+ const conn = options.connectivity ?? resolveConnectivity(doc);
401
+ const required = options.requiredCompleteness ?? 0.9;
402
+ const dividerRatio = options.dividerRatio ?? 50;
403
+ const issues = [];
404
+ const byId = new Map(doc.components.map((c) => [c.id, c]));
405
+ const cov = traceConnectivityCompleteness(doc, conn);
406
+ if (cov.fraction < required) {
407
+ return [connectivityIncompleteIssue(cov)];
408
+ }
409
+ const railNodes = new Set();
410
+ if (conn.groundNodeId != null)
411
+ railNodes.add(conn.groundNodeId);
412
+ for (const [nid, members] of conn.nodeMembers) {
413
+ for (const m of members) {
414
+ const comp = byId.get(m.componentId);
415
+ if (comp && SUPPLY_KINDS.has(comp.kind)) {
416
+ railNodes.add(nid);
417
+ break;
418
+ }
419
+ }
420
+ }
421
+ // 1. floating passive terminal
422
+ for (const [nid, members] of conn.nodeMembers) {
423
+ const only = members[0];
424
+ if (members.length !== 1 || !only || railNodes.has(nid))
425
+ continue;
426
+ const comp = byId.get(only.componentId);
427
+ if (!comp || !FLOAT_FLAG_KINDS.has(comp.kind))
428
+ continue;
429
+ issues.push({
430
+ code: "trace-floating-node",
431
+ severity: "warning",
432
+ message: `${only.componentId}.${only.terminalName} is the only pin on net #${nid} — floating/unconnected; double-check the trace.`,
433
+ componentId: only.componentId,
434
+ });
435
+ }
436
+ // 2. shorted two-terminal passive
437
+ for (const c of doc.components) {
438
+ if (c.kind !== "resistor" &&
439
+ c.kind !== "capacitor" &&
440
+ c.kind !== "inductor")
441
+ continue;
442
+ const ns = nodesOf(conn, c);
443
+ if (ns.length >= 2 && new Set(ns).size === 1) {
444
+ issues.push({
445
+ code: "trace-shorted-part",
446
+ severity: "warning",
447
+ message: `${c.id} (${c.kind}) has both terminals on net #${ns[0]} — shorted; double-check.`,
448
+ componentId: c.id,
449
+ });
450
+ }
451
+ }
452
+ // 3. bias-divider asymmetry
453
+ for (const [nid, members] of conn.nodeMembers) {
454
+ if (railNodes.has(nid))
455
+ continue;
456
+ const resIds = [
457
+ ...new Set(members
458
+ .map((m) => byId.get(m.componentId))
459
+ .filter((c) => !!c && c.kind === "resistor")
460
+ .map((c) => c.id)),
461
+ ];
462
+ if (resIds.length !== 2)
463
+ continue;
464
+ const legs = resIds
465
+ .map((id) => byId.get(id))
466
+ .filter((c) => c !== undefined);
467
+ const [ra, rb] = legs;
468
+ if (!ra || !rb)
469
+ continue;
470
+ const railBacked = legs.filter((r) => nodesOf(conn, r).some((n) => n !== nid && railNodes.has(n)));
471
+ if (railBacked.length !== 2)
472
+ continue;
473
+ const a = componentValue(ra);
474
+ const b = componentValue(rb);
475
+ if (a == null || b == null || a <= 0 || b <= 0)
476
+ continue;
477
+ const ratio = Math.max(a, b) / Math.min(a, b);
478
+ if (ratio >= dividerRatio) {
479
+ issues.push({
480
+ code: "trace-divider-asymmetry",
481
+ severity: "warning",
482
+ message: `bias-divider legs ${ra.id}=${fmt(a)} and ${rb.id}=${fmt(b)} differ ${Math.round(ratio)}x (net #${nid}) — possible k/M or magnitude slip; double-check.`,
483
+ componentId: ra.id,
484
+ });
485
+ }
486
+ }
487
+ return issues;
488
+ }
489
+ function findInputLoadingWarnings(doc, conn, input, nodes) {
490
+ const inputNode = jackSignalNode(conn, input);
491
+ if (inputNode === undefined)
492
+ return [];
493
+ const references = new Set([...nodes.ground, ...nodes.audioReferences]);
494
+ const issues = [];
495
+ for (const component of doc.components) {
496
+ if (component.kind !== "resistor")
497
+ continue;
498
+ const value = componentValue(component);
499
+ const componentNodes = uniqueNodesOf(conn, component);
500
+ if (value === null ||
501
+ value > INPUT_LOADING_WARNING_OHMS ||
502
+ componentNodes.length !== 2 ||
503
+ !componentNodes.includes(inputNode)) {
504
+ continue;
505
+ }
506
+ const otherNode = componentNodes.find((node) => node !== inputNode);
507
+ if (otherNode === undefined || !references.has(otherNode))
508
+ continue;
509
+ issues.push({
510
+ code: "trace-input-loading-extreme",
511
+ severity: "warning",
512
+ message: `${component.id} loads audio input ${input.id} with ${fmt(value)} ohm to an AC reference; double-check the value and input trace.`,
513
+ componentId: component.id,
514
+ });
515
+ }
516
+ return issues;
517
+ }
518
+ function findAudioShuntWarnings(doc, conn, input, output, nodes) {
519
+ const inputNode = jackSignalNode(conn, input);
520
+ const outputNode = jackSignalNode(conn, output);
521
+ const issues = [];
522
+ for (const capacitor of doc.components) {
523
+ if (capacitor.kind !== "capacitor")
524
+ continue;
525
+ const capacitance = componentValue(capacitor);
526
+ const capacitorNodes = uniqueNodesOf(conn, capacitor);
527
+ if (capacitance === null || capacitorNodes.length !== 2)
528
+ continue;
529
+ const [nodeA, nodeB] = capacitorNodes;
530
+ if (nodeA === undefined || nodeB === undefined)
531
+ continue;
532
+ const aIsReference = nodes.ground.has(nodeA) || nodes.audioReferences.has(nodeA);
533
+ const bIsReference = nodes.ground.has(nodeB) || nodes.audioReferences.has(nodeB);
534
+ if (aIsReference === bIsReference)
535
+ continue;
536
+ const signalNode = aIsReference ? nodeB : nodeA;
537
+ if (nodes.supplies.has(signalNode))
538
+ continue;
539
+ const path = shortestPathNodes(inputNode, outputNode, audioSignalEdges(doc, conn, nodes.blocked, capacitor.id));
540
+ if (!path?.includes(signalNode))
541
+ continue;
542
+ const adjacent = doc.components.filter((component) => {
543
+ if (component.kind !== "resistor" || componentValue(component) === null) {
544
+ return false;
545
+ }
546
+ const resistorNodes = uniqueNodesOf(conn, component);
547
+ return (resistorNodes.length === 2 &&
548
+ resistorNodes.includes(signalNode) &&
549
+ resistorNodes.every((node) => node === signalNode || !nodes.blocked.has(node)));
550
+ });
551
+ const resistor = adjacent.sort((a, b) => (componentValue(a) ?? Infinity) - (componentValue(b) ?? Infinity))[0];
552
+ if (!resistor)
553
+ continue;
554
+ const resistance = componentValue(resistor);
555
+ if (resistance === null)
556
+ continue;
557
+ const cornerHz = 1 / (2 * Math.PI * resistance * capacitance);
558
+ const attenuation = 1 / Math.sqrt(1 + (AUDIO_SHUNT_REFERENCE_HZ / cornerHz) ** 2);
559
+ const attenuationDb = 20 * Math.log10(attenuation);
560
+ if (attenuationDb > AUDIO_SHUNT_WARNING_DB)
561
+ continue;
562
+ issues.push({
563
+ code: "trace-audio-shunt-extreme",
564
+ severity: "warning",
565
+ message: `if declared connectivity is correct, ${resistor.id} and ${capacitor.id} form an audio-path shunt with ~${cornerHz.toFixed(1)} Hz low-pass corner and ${attenuationDb.toFixed(1)} dB at 1 kHz; double-check the node assignment and both values.`,
566
+ componentId: capacitor.id,
567
+ });
568
+ }
569
+ return issues;
570
+ }
571
+ function findOpampFeedbackWarnings(doc, conn, input, output, nodes) {
572
+ const inputNode = jackSignalNode(conn, input);
573
+ const outputNode = jackSignalNode(conn, output);
574
+ const edges = passivePathEdges(doc, conn, nodes.blocked);
575
+ const issues = [];
576
+ for (const component of doc.components) {
577
+ if (component.kind !== "opamp" ||
578
+ !componentRole(doc, component).includes("buffer")) {
579
+ continue;
580
+ }
581
+ const plus = terminalNode(conn, component, [
582
+ "positive",
583
+ "plus",
584
+ "nonInverting",
585
+ ]);
586
+ const minus = terminalNode(conn, component, [
587
+ "negative",
588
+ "minus",
589
+ "inverting",
590
+ ]);
591
+ const out = terminalNode(conn, component, ["out", "output"]);
592
+ if (!reachable(inputNode, plus, edges) ||
593
+ !reachable(out, outputNode, edges) ||
594
+ out === undefined ||
595
+ minus === undefined ||
596
+ out === minus ||
597
+ reachable(out, minus, edges)) {
598
+ continue;
599
+ }
600
+ issues.push({
601
+ code: "trace-opamp-feedback-open",
602
+ severity: "warning",
603
+ message: `${component.id} is declared as an audio buffer on the input/output path but has no passive negative-feedback path from output to inverting input; double-check its pins and feedback trace.`,
604
+ componentId: component.id,
605
+ });
606
+ }
607
+ return issues;
608
+ }
609
+ /** Role-aware audio warnings. Locally coverage-gated and opt-in from the aggregate API. */
610
+ export function validateAudioTopologyWarnings(doc, options = {}) {
611
+ const conn = options.connectivity ?? resolveConnectivity(doc);
612
+ const ports = audioPorts(doc);
613
+ if (ports.inputs.length !== 1 || ports.outputs.length !== 1) {
614
+ return [
615
+ {
616
+ code: "trace-audio-role-ambiguous",
617
+ severity: "warning",
618
+ message: `audio topology checks skipped: expected exactly one audio input and one audio output, found ${ports.inputs.length} input(s) and ${ports.outputs.length} output(s).`,
619
+ },
620
+ ];
621
+ }
622
+ const input = ports.inputs[0];
623
+ const output = ports.outputs[0];
624
+ if (!input || !output)
625
+ return [];
626
+ const inputNode = jackSignalNode(conn, input);
627
+ const outputNode = jackSignalNode(conn, output);
628
+ if (inputNode === undefined ||
629
+ outputNode === undefined ||
630
+ (conn.nodeMembers.get(inputNode)?.length ?? 0) < 2 ||
631
+ (conn.nodeMembers.get(outputNode)?.length ?? 0) < 2) {
632
+ return [
633
+ connectivityIncompleteIssue(traceConnectivityCompleteness(doc, conn)),
634
+ ];
635
+ }
636
+ const nodeSets = audioNodeSets(doc, conn, options.nodeRoles, ports);
637
+ return [
638
+ ...findInputLoadingWarnings(doc, conn, input, nodeSets),
639
+ ...findAudioShuntWarnings(doc, conn, input, output, nodeSets),
640
+ ...findOpampFeedbackWarnings(doc, conn, input, output, nodeSets),
641
+ ];
642
+ }
643
+ /** Connectivity-independent: flags R/C values off the E24 grid. Noisy — OPT-IN. */
644
+ export function validatePreferredValues(doc) {
645
+ const issues = [];
646
+ for (const c of doc.components) {
647
+ if (c.kind !== "resistor" && c.kind !== "capacitor")
648
+ continue;
649
+ const v = componentValue(c);
650
+ if (v == null || v <= 0 || isE24(v))
651
+ continue;
652
+ issues.push({
653
+ code: "trace-nonstandard-value",
654
+ severity: "warning",
655
+ message: `${c.id} value ${fmt(v)} is not a standard E24 value — verify transcription.`,
656
+ componentId: c.id,
657
+ });
658
+ }
659
+ return issues;
660
+ }
661
+ /** Rough RC-corner heuristic. High false-positive rate; OPT-IN only. Does NOT catch in-band slips. */
662
+ export function validateRcCornerHeuristic(doc, options = {}) {
663
+ const conn = options.connectivity ?? resolveConnectivity(doc);
664
+ const [fLo, fHi] = options.audioBand ?? [0.2, 120000];
665
+ const issues = [];
666
+ const byId = new Map(doc.components.map((c) => [c.id, c]));
667
+ const railNodes = new Set();
668
+ if (conn.groundNodeId != null)
669
+ railNodes.add(conn.groundNodeId);
670
+ for (const [nid, members] of conn.nodeMembers)
671
+ for (const m of members) {
672
+ const comp = byId.get(m.componentId);
673
+ if (comp && SUPPLY_KINDS.has(comp.kind)) {
674
+ railNodes.add(nid);
675
+ break;
676
+ }
677
+ }
678
+ for (const c of doc.components) {
679
+ if (c.kind !== "capacitor")
680
+ continue;
681
+ const cv = componentValue(c);
682
+ if (cv == null || cv <= 0)
683
+ continue;
684
+ const cNodes = nodesOf(conn, c);
685
+ if (cv >= 1e-6 && cNodes.some((n) => railNodes.has(n)))
686
+ continue;
687
+ let flagged = false;
688
+ for (const nid of cNodes) {
689
+ if (flagged)
690
+ break;
691
+ for (const m of conn.nodeMembers.get(nid) ?? []) {
692
+ const r = byId.get(m.componentId);
693
+ if (r?.kind !== "resistor")
694
+ continue;
695
+ const rv = componentValue(r);
696
+ if (rv == null || rv <= 0)
697
+ continue;
698
+ const f = 1 / (2 * Math.PI * rv * cv);
699
+ if (f < fLo || f > fHi) {
700
+ issues.push({
701
+ code: "trace-rc-corner",
702
+ severity: "warning",
703
+ message: `${c.id}=${fmt(cv)}F with ${r.id}=${fmt(rv)} gives ~${f < 1 ? f.toFixed(2) : Math.round(f)} Hz corner (net #${nid}) — outside plausible range; verify.`,
704
+ componentId: c.id,
705
+ });
706
+ flagged = true;
707
+ break;
708
+ }
709
+ }
710
+ }
711
+ }
712
+ return issues;
713
+ }
714
+ /** Default advisory run: structure only (coverage-gated). Opt-in extras via options. */
715
+ export function validateTracePlausibility(doc, options = {}) {
716
+ const issues = [...validateTraceStructure(doc, options)];
717
+ if (options.includeAudioTopology) {
718
+ for (const issue of validateAudioTopologyWarnings(doc, options)) {
719
+ if (issue.code === "trace-connectivity-incomplete" &&
720
+ issues.some((existing) => existing.code === issue.code)) {
721
+ continue;
722
+ }
723
+ issues.push(issue);
724
+ }
725
+ }
726
+ if (options.includePreferredValue)
727
+ issues.push(...validatePreferredValues(doc));
728
+ if (options.includeRcCorner)
729
+ issues.push(...validateRcCornerHeuristic(doc, options));
730
+ return issues;
731
+ }
732
+ //# sourceMappingURL=trace-plausibility.js.map