copperhead 0.9.0 → 0.11.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 (142) hide show
  1. package/NOTICE +1 -1
  2. package/README.md +47 -8
  3. package/dist/agent/context.js +2 -0
  4. package/dist/agent/context.js.map +1 -0
  5. package/dist/agent/dock-renderer.js +2 -2
  6. package/dist/agent/dock-renderer.js.map +1 -1
  7. package/dist/agent/envelope.js +105 -0
  8. package/dist/agent/envelope.js.map +1 -0
  9. package/dist/agent/filetools.js +24 -1
  10. package/dist/agent/filetools.js.map +1 -1
  11. package/dist/agent/ledger.js +24 -0
  12. package/dist/agent/ledger.js.map +1 -1
  13. package/dist/agent/loop.js +63 -72
  14. package/dist/agent/loop.js.map +1 -1
  15. package/dist/agent/prompts.js +4 -3
  16. package/dist/agent/prompts.js.map +1 -1
  17. package/dist/agent/providers/claude-code.js +17 -1
  18. package/dist/agent/providers/claude-code.js.map +1 -1
  19. package/dist/agent/providers/codex.js +84 -39
  20. package/dist/agent/providers/codex.js.map +1 -1
  21. package/dist/agent/providers/tool-protocol.js +21 -0
  22. package/dist/agent/providers/tool-protocol.js.map +1 -1
  23. package/dist/agent/recovery.js +186 -15
  24. package/dist/agent/recovery.js.map +1 -1
  25. package/dist/agent/registry.js +49 -0
  26. package/dist/agent/registry.js.map +1 -0
  27. package/dist/agent/render.js +2 -2
  28. package/dist/agent/render.js.map +1 -1
  29. package/dist/agent/theme.js +10 -5
  30. package/dist/agent/theme.js.map +1 -1
  31. package/dist/agent/tools.js +100 -586
  32. package/dist/agent/tools.js.map +1 -1
  33. package/dist/agent/transcript.js +2 -0
  34. package/dist/agent/transcript.js.map +1 -1
  35. package/dist/capabilities/define.js +35 -0
  36. package/dist/capabilities/define.js.map +1 -0
  37. package/dist/capabilities/handlers.js +744 -0
  38. package/dist/capabilities/handlers.js.map +1 -0
  39. package/dist/capabilities/helpers.js +39 -0
  40. package/dist/capabilities/helpers.js.map +1 -0
  41. package/dist/capabilities/index.js +50 -0
  42. package/dist/capabilities/index.js.map +1 -0
  43. package/dist/capabilities/skills/generate-report.js +23 -0
  44. package/dist/capabilities/skills/generate-report.js.map +1 -0
  45. package/dist/cli.js +158 -0
  46. package/dist/cli.js.map +1 -1
  47. package/dist/commands/check.js +33 -1
  48. package/dist/commands/check.js.map +1 -1
  49. package/dist/commands/create.js +181 -26
  50. package/dist/commands/create.js.map +1 -1
  51. package/dist/commands/doctor.js +83 -6
  52. package/dist/commands/doctor.js.map +1 -1
  53. package/dist/commands/skill.js +109 -0
  54. package/dist/commands/skill.js.map +1 -0
  55. package/dist/commands/sync.js +3 -1
  56. package/dist/commands/sync.js.map +1 -1
  57. package/dist/config.js +19 -6
  58. package/dist/config.js.map +1 -1
  59. package/dist/kicad/bootstrap.js +24 -3
  60. package/dist/kicad/bootstrap.js.map +1 -1
  61. package/dist/kicad/cli.js +106 -18
  62. package/dist/kicad/cli.js.map +1 -1
  63. package/dist/kicad/dossier.js +207 -0
  64. package/dist/kicad/dossier.js.map +1 -0
  65. package/dist/kicad/draft/draft.js +135 -0
  66. package/dist/kicad/draft/draft.js.map +1 -0
  67. package/dist/kicad/draft/engine.js +5310 -0
  68. package/dist/kicad/draft/engine.js.map +1 -0
  69. package/dist/kicad/draft/ir.js +368 -0
  70. package/dist/kicad/draft/ir.js.map +1 -0
  71. package/dist/kicad/draft/symsource.js +504 -0
  72. package/dist/kicad/draft/symsource.js.map +1 -0
  73. package/dist/kicad/emit.js +220 -0
  74. package/dist/kicad/emit.js.map +1 -0
  75. package/dist/kicad/fab.js +13 -0
  76. package/dist/kicad/fab.js.map +1 -1
  77. package/dist/kicad/legibility.js +608 -0
  78. package/dist/kicad/legibility.js.map +1 -0
  79. package/dist/kicad/score.js +431 -0
  80. package/dist/kicad/score.js.map +1 -0
  81. package/dist/kicad/sexp.js +265 -6
  82. package/dist/kicad/sexp.js.map +1 -1
  83. package/dist/kicad/symlib.js +346 -16
  84. package/dist/kicad/symlib.js.map +1 -1
  85. package/dist/mcp/server.js +485 -0
  86. package/dist/mcp/server.js.map +1 -0
  87. package/dist/memory/bom-table.js +75 -39
  88. package/dist/memory/bom-table.js.map +1 -1
  89. package/dist/memory/scaffold.js +14 -1
  90. package/dist/memory/scaffold.js.map +1 -1
  91. package/dist/util/redact.js +6 -0
  92. package/dist/util/redact.js.map +1 -1
  93. package/package.json +13 -8
  94. package/src/agent/context.ts +35 -0
  95. package/src/agent/dock-renderer.ts +3 -2
  96. package/src/agent/envelope.ts +124 -0
  97. package/src/agent/filetools.ts +26 -1
  98. package/src/agent/ledger.ts +24 -0
  99. package/src/agent/loop.ts +73 -78
  100. package/src/agent/prompts.ts +4 -3
  101. package/src/agent/providers/claude-code.ts +22 -1
  102. package/src/agent/providers/codex.ts +91 -42
  103. package/src/agent/providers/tool-protocol.ts +22 -0
  104. package/src/agent/recovery.ts +183 -13
  105. package/src/agent/registry.ts +58 -0
  106. package/src/agent/render.ts +4 -3
  107. package/src/agent/theme.ts +15 -5
  108. package/src/agent/tools.ts +124 -628
  109. package/src/agent/transcript.ts +6 -0
  110. package/src/agent/types.ts +10 -5
  111. package/src/capabilities/define.ts +88 -0
  112. package/src/capabilities/handlers.ts +769 -0
  113. package/src/capabilities/helpers.ts +37 -0
  114. package/src/capabilities/index.ts +53 -0
  115. package/src/capabilities/skills/generate-report.ts +25 -0
  116. package/src/cli.ts +154 -0
  117. package/src/commands/check.ts +51 -1
  118. package/src/commands/create.ts +183 -21
  119. package/src/commands/doctor.ts +85 -6
  120. package/src/commands/skill.ts +127 -0
  121. package/src/commands/sync.ts +5 -3
  122. package/src/config.ts +56 -8
  123. package/src/kicad/bootstrap.ts +24 -3
  124. package/src/kicad/cli.ts +129 -18
  125. package/src/kicad/dossier.ts +217 -0
  126. package/src/kicad/draft/draft.ts +173 -0
  127. package/src/kicad/draft/engine.ts +5274 -0
  128. package/src/kicad/draft/ir.ts +416 -0
  129. package/src/kicad/draft/symsource.ts +549 -0
  130. package/src/kicad/emit.ts +300 -0
  131. package/src/kicad/fab.ts +15 -0
  132. package/src/kicad/legibility.ts +695 -0
  133. package/src/kicad/score.ts +502 -0
  134. package/src/kicad/sexp.ts +346 -6
  135. package/src/kicad/symlib.ts +364 -18
  136. package/src/mcp/server.ts +560 -0
  137. package/src/memory/bom-table.ts +85 -38
  138. package/src/memory/scaffold.ts +14 -1
  139. package/src/util/redact.ts +6 -0
  140. package/dist/memory/synap.js +0 -152
  141. package/dist/memory/synap.js.map +0 -1
  142. package/src/memory/synap.ts +0 -217
@@ -0,0 +1,502 @@
1
+ import { readSheetGeometry, pinsOfUnit, pinAbsolute, type SheetGeometry, type Bounds } from './sexp.js';
2
+ import { checkLegibility, type LegibilityReport } from './legibility.js';
3
+ import type { LegibilityUserConfig } from '../config.js';
4
+
5
+ /**
6
+ * Deterministic quantitative scorer (design D7/D11). The checker decides
7
+ * pass/fail on unambiguous defects; this measures the judgment calls — wiring
8
+ * economy, alignment, symmetry, balance — as a weighted 0-100 composite whose
9
+ * per-metric breakdown is always reported. Any error-severity legibility
10
+ * finding caps the composite below the known-good floor, so a high score can
11
+ * never coexist with a gating defect. No LLM, no network, no subprocess.
12
+ */
13
+
14
+ export interface ScoreMetric {
15
+ name: string;
16
+ /** Raw measured value, full precision before rounding. */
17
+ raw: number;
18
+ /** Normalized goodness in [0, 1]. */
19
+ score: number;
20
+ weight: number;
21
+ /** Weighted contribution to the composite (points). */
22
+ contribution: number;
23
+ }
24
+
25
+ export interface ScoreReport {
26
+ composite: number;
27
+ metrics: ScoreMetric[];
28
+ /**
29
+ * Wiring style, scored without the drafting standard's conventions. The
30
+ * composite above needs group boxes, captions and a title block, so a
31
+ * hand-drawn sheet that lacks them is capped at 40 whatever its craft.
32
+ * This block measures only how the parts connect — the thing a reader of
33
+ * any schematic judges first — and is never capped, so an engine draft
34
+ * and the human drawing of the same circuit compare on one scale.
35
+ */
36
+ style: { composite: number; metrics: ScoreMetric[] };
37
+ /** Applied error-finding cap, or null. */
38
+ cap: { appliedAt: number; reason: string } | null;
39
+ legibility: { error: number; advisory: number };
40
+ }
41
+
42
+ const DEFAULT_WEIGHTS: Record<string, number> = {
43
+ 'pin-attachment': 10,
44
+ 'island-parts': 5,
45
+ 'wire-crossings': 10,
46
+ 'wire-bends': 5,
47
+ 'wire-length': 5,
48
+ 'label-to-wire-ratio': 5,
49
+ 'group-cohesion': 10,
50
+ 'flow-direction': 10,
51
+ utilization: 10,
52
+ 'axis-alignment': 10,
53
+ 'spacing-uniformity': 10,
54
+ 'straight-wire-ratio': 5,
55
+ 'label-alignment': 5,
56
+ 'whitespace-balance': 10,
57
+ 'pair-symmetry': 5,
58
+ };
59
+
60
+ /**
61
+ * Weights of the convention-free style composite. Calibrated on the KiCad
62
+ * demo corpus (fifteen hand-drawn projects, 17 to 1138 parts): a person wires
63
+ * 90 to 100 % of two-pin parts straight to another part, leaves 0 to 2 % as
64
+ * label-only islands, draws 0.3 to 1.2 power symbols per part and, outside
65
+ * bus-heavy boards, under one label per part. Engine drafts before pin
66
+ * attachment measured 14 to 53 % attached and 9 to 13 % islands.
67
+ */
68
+ const STYLE_WEIGHTS: Record<string, number> = {
69
+ 'pin-attachment': 30,
70
+ 'island-parts': 20,
71
+ 'power-symbol-economy': 15,
72
+ 'labels-per-part': 15,
73
+ 'crossings-per-wire': 10,
74
+ 'straight-wire-ratio': 10,
75
+ };
76
+
77
+ /** Composite is capped this far below the Tier A floor when errors exist. */
78
+ const DEFAULT_FLOOR = 85;
79
+ const ERROR_CAP = 40;
80
+ /** Top-edge spread (mm) within which two group boxes count as the same row. */
81
+ const ROW_BAND = 12;
82
+
83
+ const PAPER: Record<string, { w: number; h: number }> = {
84
+ A5: { w: 210, h: 148 },
85
+ A4: { w: 297, h: 210 },
86
+ A3: { w: 420, h: 297 },
87
+ A2: { w: 594, h: 420 },
88
+ A1: { w: 841, h: 594 },
89
+ A0: { w: 1189, h: 841 },
90
+ };
91
+
92
+ interface Seg {
93
+ x1: number;
94
+ y1: number;
95
+ x2: number;
96
+ y2: number;
97
+ }
98
+
99
+ const segLen = (s: Seg): number => Math.hypot(s.x2 - s.x1, s.y2 - s.y1);
100
+ const isVert = (s: Seg): boolean => s.x1 === s.x2;
101
+
102
+ function properCrossing(a: Seg, b: Seg): boolean {
103
+ // orthogonal proper crossing (interiors intersect); shared endpoints are joins
104
+ const [v, h] = isVert(a) && !isVert(b) ? [a, b] : !isVert(a) && isVert(b) ? [b, a] : [null, null];
105
+ if (!v || !h) return false;
106
+ const [hy, vx] = [h.y1, v.x1];
107
+ const [hx1, hx2] = [Math.min(h.x1, h.x2), Math.max(h.x1, h.x2)];
108
+ const [vy1, vy2] = [Math.min(v.y1, v.y2), Math.max(v.y1, v.y2)];
109
+ return vx > hx1 + 0.01 && vx < hx2 - 0.01 && hy > vy1 + 0.01 && hy < vy2 - 0.01;
110
+ }
111
+
112
+ /** Library prefixes whose multi-pin symbols are not ICs for the style metrics. */
113
+ const NOT_IC = /^(Connector|Switch|TestPoint|Mechanical|Jumper|Graphic|Device:Crystal|Device:Q_|Device:D_|Device:LED)/;
114
+
115
+ export interface WiringStyle {
116
+ parts: number;
117
+ ics: number;
118
+ twoPin: number;
119
+ powerSymbols: number;
120
+ labels: number;
121
+ /** two-pin parts with at least one pin wired straight to another part */
122
+ twoPinAttached: number;
123
+ /** two-pin parts whose every pin ends in a label or nothing */
124
+ twoPinIslands: number;
125
+ icPins: number;
126
+ icPinsWired: number;
127
+ icPinsLabelled: number;
128
+ }
129
+
130
+ /**
131
+ * How the parts of a sheet set connect, by following wires from each placed
132
+ * pin: to another part's pin, to a power symbol, to a net label, or nowhere.
133
+ * Wire joins are endpoint-to-endpoint and endpoint-onto-segment (a T), the
134
+ * same two ways KiCad itself joins them.
135
+ */
136
+ export function measureWiringStyle(sheets: SheetGeometry[]): WiringStyle {
137
+ const W: WiringStyle = { parts: 0, ics: 0, twoPin: 0, powerSymbols: 0, labels: 0, twoPinAttached: 0, twoPinIslands: 0, icPins: 0, icPinsWired: 0, icPinsLabelled: 0 };
138
+ const key = (x: number, y: number): string => `${Math.round(x * 100)},${Math.round(y * 100)}`;
139
+ for (const sh of sheets) {
140
+ const parent = new Map<string, string>();
141
+ const find = (k: string): string => {
142
+ let r = k;
143
+ while (parent.get(r) !== r) r = parent.get(r)!;
144
+ while (parent.get(k) !== r) {
145
+ const n = parent.get(k)!;
146
+ parent.set(k, r);
147
+ k = n;
148
+ }
149
+ return r;
150
+ };
151
+ const add = (k: string): void => {
152
+ if (!parent.has(k)) parent.set(k, k);
153
+ };
154
+ const union = (a: string, b: string): void => {
155
+ add(a);
156
+ add(b);
157
+ const ra = find(a);
158
+ const rb = find(b);
159
+ if (ra !== rb) parent.set(ra, rb);
160
+ };
161
+ for (const w of sh.wires) union(key(w.x1, w.y1), key(w.x2, w.y2));
162
+ const placed = sh.symbols.map((s) => ({
163
+ s,
164
+ pins: pinsOfUnit(sh.libPins.get(s.libId) ?? [], s.unit).map((p) => pinAbsolute(s.at, s.mirror, p)),
165
+ }));
166
+ for (const { pins } of placed) for (const p of pins) add(key(p.x, p.y));
167
+ for (const l of sh.labels) add(key(l.x, l.y));
168
+ // a point on a segment's interior joins it (T junction); index segments by
169
+ // their fixed coordinate so this stays linear in practice
170
+ const horiz = new Map<number, Seg[]>();
171
+ const vert = new Map<number, Seg[]>();
172
+ for (const w of sh.wires) {
173
+ if (Math.abs(w.y1 - w.y2) < 0.01) horiz.set(Math.round(w.y1 * 100), [...(horiz.get(Math.round(w.y1 * 100)) ?? []), w]);
174
+ else if (Math.abs(w.x1 - w.x2) < 0.01) vert.set(Math.round(w.x1 * 100), [...(vert.get(Math.round(w.x1 * 100)) ?? []), w]);
175
+ }
176
+ for (const k of [...parent.keys()]) {
177
+ const [xs, ys] = k.split(',');
178
+ const x = Number(xs) / 100;
179
+ const y = Number(ys) / 100;
180
+ for (const w of horiz.get(Number(ys)) ?? []) {
181
+ if (x > Math.min(w.x1, w.x2) + 0.01 && x < Math.max(w.x1, w.x2) - 0.01) union(k, key(w.x1, w.y1));
182
+ }
183
+ for (const w of vert.get(Number(xs)) ?? []) {
184
+ if (y > Math.min(w.y1, w.y2) + 0.01 && y < Math.max(w.y1, w.y2) - 0.01) union(k, key(w.x1, w.y1));
185
+ }
186
+ }
187
+ const compOf = (x: number, y: number): string => find(key(x, y));
188
+ const labelComps = new Set(sh.labels.map((l) => compOf(l.x, l.y)));
189
+ const pinComps = new Map<string, { ref: string; power: boolean }[]>();
190
+ for (const { s, pins } of placed) {
191
+ for (const p of pins) {
192
+ const c = compOf(p.x, p.y);
193
+ pinComps.set(c, [...(pinComps.get(c) ?? []), { ref: s.ref, power: s.isPower }]);
194
+ }
195
+ }
196
+ W.labels += sh.labels.length;
197
+ for (const { s, pins } of placed) {
198
+ if (s.isPower) {
199
+ W.powerSymbols++;
200
+ continue;
201
+ }
202
+ if (!pins.length) continue;
203
+ W.parts++;
204
+ const kinds = pins.map((p) => {
205
+ const c = compOf(p.x, p.y);
206
+ const others = (pinComps.get(c) ?? []).filter((o) => o.ref !== s.ref);
207
+ if (others.some((o) => !o.power)) return 'part';
208
+ if (others.some((o) => o.power)) return 'power';
209
+ if (labelComps.has(c)) return 'label';
210
+ return 'open';
211
+ });
212
+ if (pins.length === 2) {
213
+ W.twoPin++;
214
+ if (kinds.includes('part')) W.twoPinAttached++;
215
+ else if (kinds.every((k) => k === 'label' || k === 'open')) W.twoPinIslands++;
216
+ }
217
+ if (pins.length >= 3 && !NOT_IC.test(s.libId)) {
218
+ W.ics++;
219
+ for (const k of kinds) {
220
+ W.icPins++;
221
+ if (k === 'part') W.icPinsWired++;
222
+ else if (k === 'label') W.icPinsLabelled++;
223
+ }
224
+ }
225
+ }
226
+ }
227
+ return W;
228
+ }
229
+
230
+ export interface ScoreOptions {
231
+ docsDir?: string | null;
232
+ config?: LegibilityUserConfig;
233
+ }
234
+
235
+ export async function scoreSchematic(rootSch: string, opts: ScoreOptions = {}): Promise<ScoreReport> {
236
+ const sheets = await readSheetGeometry(rootSch);
237
+ const legibility = await checkLegibility(rootSch, {
238
+ docsDir: opts.docsDir ?? null,
239
+ ...(opts.config ? { config: opts.config } : {}),
240
+ });
241
+ return scoreFromGeometry(sheets, legibility, opts.config);
242
+ }
243
+
244
+ export function scoreFromGeometry(
245
+ sheets: SheetGeometry[],
246
+ legibility: LegibilityReport,
247
+ config?: LegibilityUserConfig,
248
+ ): ScoreReport {
249
+ const weights = { ...DEFAULT_WEIGHTS };
250
+ for (const [k, v] of Object.entries(config?.score?.weights ?? {})) {
251
+ if (k in weights && typeof v === 'number' && Number.isFinite(v) && v >= 0) weights[k] = v;
252
+ }
253
+ // Sanitized like the weights above: a floor of 0 (or a non-number) would
254
+ // push the error cap `min(ERROR_CAP, floor - 1)` negative or NaN.
255
+ const rawFloor = config?.score?.floor;
256
+ const floor = typeof rawFloor === 'number' && Number.isFinite(rawFloor) && rawFloor >= 1 ? rawFloor : DEFAULT_FLOOR;
257
+
258
+ // ---- collect geometry across sheets ----
259
+ const segs: Seg[] = sheets.flatMap((s) => s.wires);
260
+ const labels = sheets.flatMap((s) => s.labels);
261
+ const realSyms = sheets.flatMap((s) =>
262
+ s.symbols.filter((y) => !y.isPower).map((y) => ({ ...y, sheet: s.sheetName })),
263
+ );
264
+
265
+ // wire crossings
266
+ let crossings = 0;
267
+ for (let i = 0; i < segs.length; i++) {
268
+ for (let j = i + 1; j < segs.length; j++) if (properCrossing(segs[i]!, segs[j]!)) crossings++;
269
+ }
270
+
271
+ // bends: points where exactly two segments of different orientation meet
272
+ const ends = new Map<string, Seg[]>();
273
+ for (const s of segs) {
274
+ for (const [x, y] of [[s.x1, s.y1], [s.x2, s.y2]] as const) {
275
+ const k = `${x},${y}`;
276
+ ends.set(k, [...(ends.get(k) ?? []), s]);
277
+ }
278
+ }
279
+ let bends = 0;
280
+ for (const list of ends.values()) {
281
+ if (list.length === 2 && isVert(list[0]!) !== isVert(list[1]!)) bends++;
282
+ }
283
+
284
+ const totalLen = segs.reduce((s, w) => s + segLen(w), 0);
285
+
286
+ // group cohesion: same-name label pairs, mean anchor distance vs sheet diagonal
287
+ const byName = new Map<string, { x: number; y: number }[]>();
288
+ for (const l of labels) byName.set(l.name, [...(byName.get(l.name) ?? []), { x: l.x, y: l.y }]);
289
+ const paper = PAPER[sheets[0]?.paper.name ?? 'A4'] ?? PAPER.A4!;
290
+ const diag = Math.hypot(paper.w, paper.h);
291
+ const pairDists: number[] = [];
292
+ for (const pts of byName.values()) {
293
+ for (let i = 0; i < pts.length; i++) {
294
+ for (let j = i + 1; j < pts.length; j++) pairDists.push(Math.hypot(pts[i]!.x - pts[j]!.x, pts[i]!.y - pts[j]!.y));
295
+ }
296
+ }
297
+ const meanPair = pairDists.length ? pairDists.reduce((a, b) => a + b, 0) / pairDists.length : 0;
298
+
299
+ // flow-direction: cross-group label nets should connect adjacent groups in
300
+ // reading order; a hop across intervening groups reads as against-the-flow
301
+ // wiring
302
+ const rects = sheets.flatMap((s) => s.rectangles.map((r) => ({
303
+ minX: Math.min(r.x1, r.x2),
304
+ maxX: Math.max(r.x1, r.x2),
305
+ minY: Math.min(r.y1, r.y2),
306
+ maxY: Math.max(r.y1, r.y2),
307
+ })));
308
+ // Reading order is rows first, then left-to-right within a row. Ordering by
309
+ // minX alone was correct only while every group sat in one band; on a sheet
310
+ // whose groups wrap onto several rows it interleaves row 2 back into row 1
311
+ // and reports flow violations for a drawing that reads perfectly.
312
+ const byRow = [...rects].sort((a, b) => a.minY - b.minY || a.minX - b.minX);
313
+ const rows: (typeof rects)[] = [];
314
+ for (const r of byRow) {
315
+ const row = rows[rows.length - 1];
316
+ // Rows are separated by a group box height; members of one row share a top
317
+ // edge to within a symbol cell, so a generous band still cannot merge two.
318
+ if (row && r.minY - row[0]!.minY <= ROW_BAND) row.push(r);
319
+ else rows.push([r]);
320
+ }
321
+ const ordered = rows.flatMap((row) => [...row].sort((a, b) => a.minX - b.minX));
322
+ const groupIdx = (x: number, y: number): number =>
323
+ ordered.findIndex((r) => x > r.minX && x < r.maxX && y > r.minY && y < r.maxY);
324
+ /** Which wrapped row a point falls in; -1 when it is in no group box. */
325
+ const rowIdx = (x: number, y: number): number =>
326
+ rows.findIndex((row) => row.some((r) => x > r.minX && x < r.maxX && y > r.minY && y < r.maxY));
327
+ let crossNets = 0;
328
+ let flowViolations = 0;
329
+ for (const pts of byName.values()) {
330
+ const gs = [...new Set(pts.map((p) => groupIdx(p.x, p.y)).filter((g) => g >= 0))];
331
+ if (gs.length > 1) {
332
+ crossNets++;
333
+ if (Math.max(...gs) - Math.min(...gs) > 1) flowViolations++;
334
+ }
335
+ }
336
+
337
+ // utilization + whitespace balance over the first sheet's usable frame
338
+ const items: Bounds[] = [
339
+ ...realSyms.map((s) => ({ minX: s.at.x - 2, minY: s.at.y - 2, maxX: s.at.x + 2, maxY: s.at.y + 2 })),
340
+ ...rects,
341
+ ];
342
+ const content = items.length
343
+ ? items.reduce((a, b) => ({
344
+ minX: Math.min(a.minX, b.minX),
345
+ minY: Math.min(a.minY, b.minY),
346
+ maxX: Math.max(a.maxX, b.maxX),
347
+ maxY: Math.max(a.maxY, b.maxY),
348
+ }))
349
+ : null;
350
+ const usable = { minX: 10, minY: 10, maxX: paper.w - 10, maxY: paper.h - 10 - 30 };
351
+ const usableArea = (usable.maxX - usable.minX) * (usable.maxY - usable.minY);
352
+ const utilization = content
353
+ ? Math.min(1, ((content.maxX - content.minX) * (content.maxY - content.minY)) / usableArea)
354
+ : 0;
355
+ const centroidOff = content
356
+ ? Math.hypot(
357
+ (content.minX + content.maxX) / 2 - (usable.minX + usable.maxX) / 2,
358
+ (content.minY + content.maxY) / 2 - (usable.minY + usable.maxY) / 2,
359
+ ) / (diag / 2)
360
+ : 1;
361
+
362
+ // axis alignment: symbols sharing an origin axis with at least one other
363
+ const sharesAxis = (i: number): boolean =>
364
+ realSyms.some((o, j) => j !== i && (Math.abs(o.at.x - realSyms[i]!.at.x) < 0.01 || Math.abs(o.at.y - realSyms[i]!.at.y) < 0.01));
365
+ const axisAligned = realSyms.length > 1 ? realSyms.filter((_, i) => sharesAxis(i)).length / realSyms.length : 1;
366
+
367
+ // spacing uniformity: variance of consecutive gaps within shared-x columns
368
+ const cols = new Map<string, number[]>();
369
+ for (const s of realSyms) {
370
+ // Keyed by row as well as x: a column is a vertical run the eye reads as
371
+ // one stack. Once groups wrap, two symbols in different rows can share an x
372
+ // while sitting a whole row apart, and treating that jump as a "gap" pushes
373
+ // the coefficient of variation — and the score — off a cliff.
374
+ const k = `${s.sheet}:${Math.round(s.at.x * 100)}:${rowIdx(s.at.x, s.at.y)}`;
375
+ cols.set(k, [...(cols.get(k) ?? []), s.at.y]);
376
+ }
377
+ const gapCVs: number[] = [];
378
+ for (const ys of cols.values()) {
379
+ if (ys.length < 3) continue;
380
+ ys.sort((a, b) => a - b);
381
+ const gaps = ys.slice(1).map((y, i) => y - ys[i]!);
382
+ const mean = gaps.reduce((a, b) => a + b, 0) / gaps.length;
383
+ const sd = Math.sqrt(gaps.reduce((a, g) => a + (g - mean) ** 2, 0) / gaps.length);
384
+ gapCVs.push(mean > 0 ? sd / mean : 0);
385
+ }
386
+ const spacingUniformity = gapCVs.length ? Math.max(0, 1 - gapCVs.reduce((a, b) => a + b, 0) / gapCVs.length) : 1;
387
+
388
+ // pair symmetry: same-libId+value symbols in one group aligned on a shared axis
389
+ const pairKeys = new Map<string, { x: number; y: number }[]>();
390
+ for (const s of realSyms) {
391
+ const g = groupIdx(s.at.x, s.at.y);
392
+ const k = `${g}:${s.libId}:${s.value}`;
393
+ pairKeys.set(k, [...(pairKeys.get(k) ?? []), { x: s.at.x, y: s.at.y }]);
394
+ }
395
+ let pairs = 0;
396
+ let symmetric = 0;
397
+ for (const pts of pairKeys.values()) {
398
+ if (pts.length < 2) continue;
399
+ for (let i = 0; i < pts.length; i++) {
400
+ for (let j = i + 1; j < pts.length; j++) {
401
+ pairs++;
402
+ if (Math.abs(pts[i]!.x - pts[j]!.x) < 0.01 || Math.abs(pts[i]!.y - pts[j]!.y) < 0.01) symmetric++;
403
+ }
404
+ }
405
+ }
406
+ const pairSymmetry = pairs ? symmetric / pairs : 1;
407
+
408
+ // wiring style: how the parts connect (see STYLE_WEIGHTS for the calibration)
409
+ const ws = measureWiringStyle(sheets);
410
+ const attachment = ws.twoPin ? ws.twoPinAttached / ws.twoPin : 1;
411
+ const islands = ws.twoPin ? ws.twoPinIslands / ws.twoPin : 0;
412
+ const powerPerPart = ws.parts ? ws.powerSymbols / ws.parts : 0;
413
+ const labelsPerPart = ws.parts ? ws.labels / ws.parts : 0;
414
+
415
+ const horizLabels = labels.length
416
+ ? labels.filter((l) => Math.abs(l.rot % 180) !== 90).length / labels.length
417
+ : 1;
418
+ const straightRatio = segs.length ? Math.max(0, 1 - bends / segs.length) : 1;
419
+ const labelRatio = segs.length ? labels.length / segs.length : labels.length ? 1 : 0;
420
+
421
+ // ---- normalize to [0,1] goodness and compose ----
422
+ const raw: Record<string, { raw: number; score: number }> = {
423
+ 'pin-attachment': { raw: attachment, score: Math.min(1, attachment / 0.9) },
424
+ 'island-parts': { raw: islands, score: Math.max(0, 1 - islands / 0.2) },
425
+ 'power-symbol-economy': { raw: powerPerPart, score: powerPerPart <= 0.8 ? 1 : 0.8 / powerPerPart },
426
+ 'labels-per-part': { raw: labelsPerPart, score: labelsPerPart <= 1 ? 1 : 1 / labelsPerPart },
427
+ 'wire-crossings': { raw: crossings, score: 1 / (1 + crossings) },
428
+ // the absolute count above floors at zero on any large board; per wire it
429
+ // stays comparable across sizes (the 1138-part demo crosses 0.77 per wire,
430
+ // the 89-part one 0.03)
431
+ 'crossings-per-wire': { raw: segs.length ? crossings / segs.length : 0, score: 1 / (1 + 10 * (segs.length ? crossings / segs.length : 0)) },
432
+ 'wire-bends': { raw: bends, score: 1 / (1 + bends / 8) },
433
+ 'wire-length': { raw: totalLen, score: 1 / (1 + totalLen / (4 * diag)) },
434
+ 'label-to-wire-ratio': { raw: labelRatio, score: labelRatio <= 1.5 ? 1 : 1.5 / labelRatio },
435
+ 'group-cohesion': { raw: meanPair, score: pairDists.length ? Math.max(0, 1 - meanPair / diag) : 1 },
436
+ 'flow-direction': { raw: flowViolations, score: crossNets ? 1 - flowViolations / crossNets : 1 },
437
+ utilization: { raw: utilization, score: Math.min(1, utilization / 0.5) },
438
+ 'axis-alignment': { raw: axisAligned, score: axisAligned },
439
+ 'spacing-uniformity': { raw: spacingUniformity, score: spacingUniformity },
440
+ 'straight-wire-ratio': { raw: straightRatio, score: straightRatio },
441
+ 'label-alignment': { raw: horizLabels, score: horizLabels },
442
+ 'whitespace-balance': { raw: centroidOff, score: Math.max(0, 1 - centroidOff) },
443
+ 'pair-symmetry': { raw: pairSymmetry, score: pairSymmetry },
444
+ };
445
+ const totalWeight = Object.entries(weights).reduce((a, [k, v]) => a + (k in raw ? v : 0), 0) || 1;
446
+ const metrics: ScoreMetric[] = Object.entries(raw)
447
+ .filter(([name]) => name in weights)
448
+ .map(([name, m]) => ({
449
+ name,
450
+ raw: m.raw,
451
+ score: m.score,
452
+ weight: weights[name]!,
453
+ contribution: (100 * weights[name]! * m.score) / totalWeight,
454
+ }));
455
+ let composite = metrics.reduce((a, m) => a + m.contribution, 0);
456
+ const styleTotal = Object.values(STYLE_WEIGHTS).reduce((a, b) => a + b, 0);
457
+ const styleMetrics: ScoreMetric[] = Object.entries(STYLE_WEIGHTS).map(([name, weight]) => ({
458
+ name,
459
+ raw: raw[name]!.raw,
460
+ score: raw[name]!.score,
461
+ weight,
462
+ contribution: (100 * weight * raw[name]!.score) / styleTotal,
463
+ }));
464
+ const styleComposite = Math.round(styleMetrics.reduce((a, m) => a + m.contribution, 0) * 100) / 100;
465
+
466
+ let cap: ScoreReport['cap'] = null;
467
+ if (legibility.counts.error > 0) {
468
+ const capValue = Math.min(ERROR_CAP, floor - 1);
469
+ if (composite > capValue) {
470
+ cap = {
471
+ appliedAt: capValue,
472
+ reason: `${legibility.counts.error} error-severity legibility finding(s) cap the composite below the known-good floor (${floor})`,
473
+ };
474
+ composite = capValue;
475
+ }
476
+ }
477
+
478
+ return {
479
+ composite: Math.round(composite * 100) / 100,
480
+ metrics,
481
+ style: { composite: styleComposite, metrics: styleMetrics },
482
+ cap,
483
+ legibility: legibility.counts,
484
+ };
485
+ }
486
+
487
+ export function formatScore(report: ScoreReport): string {
488
+ const lines = [`score: ${report.composite}/100`];
489
+ if (report.cap) lines.push(` cap: ${report.cap.appliedAt} — ${report.cap.reason}`);
490
+ for (const m of report.metrics) {
491
+ lines.push(
492
+ ` ${m.name}: raw ${Math.round(m.raw * 10000) / 10000}, score ${Math.round(m.score * 1000) / 1000}, weight ${m.weight}, contributes ${Math.round(m.contribution * 100) / 100}`,
493
+ );
494
+ }
495
+ if (report.style) {
496
+ lines.push(`wiring style: ${report.style.composite}/100 (convention-free, never capped)`);
497
+ for (const m of report.style.metrics) {
498
+ lines.push(` ${m.name}: raw ${Math.round(m.raw * 10000) / 10000}, score ${Math.round(m.score * 1000) / 1000}, weight ${m.weight}`);
499
+ }
500
+ }
501
+ return lines.join('\n');
502
+ }