copperhead 0.9.0 → 0.10.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.
- package/NOTICE +1 -1
- package/README.md +6 -6
- package/dist/agent/filetools.js +24 -1
- package/dist/agent/filetools.js.map +1 -1
- package/dist/agent/ledger.js +24 -0
- package/dist/agent/ledger.js.map +1 -1
- package/dist/agent/loop.js +29 -58
- package/dist/agent/loop.js.map +1 -1
- package/dist/agent/prompts.js +4 -3
- package/dist/agent/prompts.js.map +1 -1
- package/dist/agent/providers/tool-protocol.js +21 -0
- package/dist/agent/providers/tool-protocol.js.map +1 -1
- package/dist/agent/recovery.js +95 -1
- package/dist/agent/recovery.js.map +1 -1
- package/dist/agent/tools.js +185 -1
- package/dist/agent/tools.js.map +1 -1
- package/dist/agent/transcript.js +2 -0
- package/dist/agent/transcript.js.map +1 -1
- package/dist/cli.js +75 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/check.js +33 -1
- package/dist/commands/check.js.map +1 -1
- package/dist/commands/create.js +177 -25
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/doctor.js +50 -3
- package/dist/commands/doctor.js.map +1 -1
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -1
- package/dist/kicad/bootstrap.js +24 -3
- package/dist/kicad/bootstrap.js.map +1 -1
- package/dist/kicad/dossier.js +207 -0
- package/dist/kicad/dossier.js.map +1 -0
- package/dist/kicad/draft/draft.js +132 -0
- package/dist/kicad/draft/draft.js.map +1 -0
- package/dist/kicad/draft/engine.js +2389 -0
- package/dist/kicad/draft/engine.js.map +1 -0
- package/dist/kicad/draft/ir.js +368 -0
- package/dist/kicad/draft/ir.js.map +1 -0
- package/dist/kicad/draft/symsource.js +490 -0
- package/dist/kicad/draft/symsource.js.map +1 -0
- package/dist/kicad/emit.js +181 -0
- package/dist/kicad/emit.js.map +1 -0
- package/dist/kicad/fab.js +13 -0
- package/dist/kicad/fab.js.map +1 -1
- package/dist/kicad/legibility.js +561 -0
- package/dist/kicad/legibility.js.map +1 -0
- package/dist/kicad/score.js +261 -0
- package/dist/kicad/score.js.map +1 -0
- package/dist/kicad/sexp.js +239 -6
- package/dist/kicad/sexp.js.map +1 -1
- package/dist/kicad/symlib.js +346 -16
- package/dist/kicad/symlib.js.map +1 -1
- package/dist/memory/bom-table.js +75 -39
- package/dist/memory/bom-table.js.map +1 -1
- package/dist/memory/scaffold.js +6 -0
- package/dist/memory/scaffold.js.map +1 -1
- package/dist/util/redact.js +6 -0
- package/dist/util/redact.js.map +1 -1
- package/package.json +9 -7
- package/src/agent/filetools.ts +26 -1
- package/src/agent/ledger.ts +24 -0
- package/src/agent/loop.ts +28 -61
- package/src/agent/prompts.ts +4 -3
- package/src/agent/providers/tool-protocol.ts +22 -0
- package/src/agent/recovery.ts +94 -1
- package/src/agent/tools.ts +189 -1
- package/src/agent/transcript.ts +6 -0
- package/src/cli.ts +71 -0
- package/src/commands/check.ts +51 -1
- package/src/commands/create.ts +179 -20
- package/src/commands/doctor.ts +51 -3
- package/src/config.ts +24 -0
- package/src/kicad/bootstrap.ts +24 -3
- package/src/kicad/dossier.ts +217 -0
- package/src/kicad/draft/draft.ts +171 -0
- package/src/kicad/draft/engine.ts +2466 -0
- package/src/kicad/draft/ir.ts +416 -0
- package/src/kicad/draft/symsource.ts +535 -0
- package/src/kicad/emit.ts +236 -0
- package/src/kicad/fab.ts +15 -0
- package/src/kicad/legibility.ts +646 -0
- package/src/kicad/score.ts +323 -0
- package/src/kicad/sexp.ts +315 -6
- package/src/kicad/symlib.ts +364 -18
- package/src/memory/bom-table.ts +85 -38
- package/src/memory/scaffold.ts +6 -0
- package/src/util/redact.ts +6 -0
- package/dist/memory/synap.js +0 -152
- package/dist/memory/synap.js.map +0 -1
- package/src/memory/synap.ts +0 -217
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { readSheetGeometry, 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
|
+
/** Applied error-finding cap, or null. */
|
|
29
|
+
cap: { appliedAt: number; reason: string } | null;
|
|
30
|
+
legibility: { error: number; advisory: number };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const DEFAULT_WEIGHTS: Record<string, number> = {
|
|
34
|
+
'wire-crossings': 10,
|
|
35
|
+
'wire-bends': 5,
|
|
36
|
+
'wire-length': 5,
|
|
37
|
+
'label-to-wire-ratio': 5,
|
|
38
|
+
'group-cohesion': 10,
|
|
39
|
+
'flow-direction': 10,
|
|
40
|
+
utilization: 10,
|
|
41
|
+
'axis-alignment': 10,
|
|
42
|
+
'spacing-uniformity': 10,
|
|
43
|
+
'straight-wire-ratio': 5,
|
|
44
|
+
'label-alignment': 5,
|
|
45
|
+
'whitespace-balance': 10,
|
|
46
|
+
'pair-symmetry': 5,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Composite is capped this far below the Tier A floor when errors exist. */
|
|
50
|
+
const DEFAULT_FLOOR = 85;
|
|
51
|
+
const ERROR_CAP = 40;
|
|
52
|
+
/** Top-edge spread (mm) within which two group boxes count as the same row. */
|
|
53
|
+
const ROW_BAND = 12;
|
|
54
|
+
|
|
55
|
+
const PAPER: Record<string, { w: number; h: number }> = {
|
|
56
|
+
A5: { w: 210, h: 148 },
|
|
57
|
+
A4: { w: 297, h: 210 },
|
|
58
|
+
A3: { w: 420, h: 297 },
|
|
59
|
+
A2: { w: 594, h: 420 },
|
|
60
|
+
A1: { w: 841, h: 594 },
|
|
61
|
+
A0: { w: 1189, h: 841 },
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
interface Seg {
|
|
65
|
+
x1: number;
|
|
66
|
+
y1: number;
|
|
67
|
+
x2: number;
|
|
68
|
+
y2: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const segLen = (s: Seg): number => Math.hypot(s.x2 - s.x1, s.y2 - s.y1);
|
|
72
|
+
const isVert = (s: Seg): boolean => s.x1 === s.x2;
|
|
73
|
+
|
|
74
|
+
function properCrossing(a: Seg, b: Seg): boolean {
|
|
75
|
+
// orthogonal proper crossing (interiors intersect); shared endpoints are joins
|
|
76
|
+
const [v, h] = isVert(a) && !isVert(b) ? [a, b] : !isVert(a) && isVert(b) ? [b, a] : [null, null];
|
|
77
|
+
if (!v || !h) return false;
|
|
78
|
+
const [hy, vx] = [h.y1, v.x1];
|
|
79
|
+
const [hx1, hx2] = [Math.min(h.x1, h.x2), Math.max(h.x1, h.x2)];
|
|
80
|
+
const [vy1, vy2] = [Math.min(v.y1, v.y2), Math.max(v.y1, v.y2)];
|
|
81
|
+
return vx > hx1 + 0.01 && vx < hx2 - 0.01 && hy > vy1 + 0.01 && hy < vy2 - 0.01;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ScoreOptions {
|
|
85
|
+
docsDir?: string | null;
|
|
86
|
+
config?: LegibilityUserConfig;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function scoreSchematic(rootSch: string, opts: ScoreOptions = {}): Promise<ScoreReport> {
|
|
90
|
+
const sheets = await readSheetGeometry(rootSch);
|
|
91
|
+
const legibility = await checkLegibility(rootSch, {
|
|
92
|
+
docsDir: opts.docsDir ?? null,
|
|
93
|
+
...(opts.config ? { config: opts.config } : {}),
|
|
94
|
+
});
|
|
95
|
+
return scoreFromGeometry(sheets, legibility, opts.config);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function scoreFromGeometry(
|
|
99
|
+
sheets: SheetGeometry[],
|
|
100
|
+
legibility: LegibilityReport,
|
|
101
|
+
config?: LegibilityUserConfig,
|
|
102
|
+
): ScoreReport {
|
|
103
|
+
const weights = { ...DEFAULT_WEIGHTS };
|
|
104
|
+
for (const [k, v] of Object.entries(config?.score?.weights ?? {})) {
|
|
105
|
+
if (k in weights && typeof v === 'number' && Number.isFinite(v) && v >= 0) weights[k] = v;
|
|
106
|
+
}
|
|
107
|
+
// Sanitized like the weights above: a floor of 0 (or a non-number) would
|
|
108
|
+
// push the error cap `min(ERROR_CAP, floor - 1)` negative or NaN.
|
|
109
|
+
const rawFloor = config?.score?.floor;
|
|
110
|
+
const floor = typeof rawFloor === 'number' && Number.isFinite(rawFloor) && rawFloor >= 1 ? rawFloor : DEFAULT_FLOOR;
|
|
111
|
+
|
|
112
|
+
// ---- collect geometry across sheets ----
|
|
113
|
+
const segs: Seg[] = sheets.flatMap((s) => s.wires);
|
|
114
|
+
const labels = sheets.flatMap((s) => s.labels);
|
|
115
|
+
const realSyms = sheets.flatMap((s) =>
|
|
116
|
+
s.symbols.filter((y) => !y.isPower).map((y) => ({ ...y, sheet: s.sheetName })),
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
// wire crossings
|
|
120
|
+
let crossings = 0;
|
|
121
|
+
for (let i = 0; i < segs.length; i++) {
|
|
122
|
+
for (let j = i + 1; j < segs.length; j++) if (properCrossing(segs[i]!, segs[j]!)) crossings++;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// bends: points where exactly two segments of different orientation meet
|
|
126
|
+
const ends = new Map<string, Seg[]>();
|
|
127
|
+
for (const s of segs) {
|
|
128
|
+
for (const [x, y] of [[s.x1, s.y1], [s.x2, s.y2]] as const) {
|
|
129
|
+
const k = `${x},${y}`;
|
|
130
|
+
ends.set(k, [...(ends.get(k) ?? []), s]);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
let bends = 0;
|
|
134
|
+
for (const list of ends.values()) {
|
|
135
|
+
if (list.length === 2 && isVert(list[0]!) !== isVert(list[1]!)) bends++;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const totalLen = segs.reduce((s, w) => s + segLen(w), 0);
|
|
139
|
+
|
|
140
|
+
// group cohesion: same-name label pairs, mean anchor distance vs sheet diagonal
|
|
141
|
+
const byName = new Map<string, { x: number; y: number }[]>();
|
|
142
|
+
for (const l of labels) byName.set(l.name, [...(byName.get(l.name) ?? []), { x: l.x, y: l.y }]);
|
|
143
|
+
const paper = PAPER[sheets[0]?.paper.name ?? 'A4'] ?? PAPER.A4!;
|
|
144
|
+
const diag = Math.hypot(paper.w, paper.h);
|
|
145
|
+
const pairDists: number[] = [];
|
|
146
|
+
for (const pts of byName.values()) {
|
|
147
|
+
for (let i = 0; i < pts.length; i++) {
|
|
148
|
+
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));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const meanPair = pairDists.length ? pairDists.reduce((a, b) => a + b, 0) / pairDists.length : 0;
|
|
152
|
+
|
|
153
|
+
// flow-direction: cross-group label nets should connect adjacent groups in
|
|
154
|
+
// reading order; a hop across intervening groups reads as against-the-flow
|
|
155
|
+
// wiring
|
|
156
|
+
const rects = sheets.flatMap((s) => s.rectangles.map((r) => ({
|
|
157
|
+
minX: Math.min(r.x1, r.x2),
|
|
158
|
+
maxX: Math.max(r.x1, r.x2),
|
|
159
|
+
minY: Math.min(r.y1, r.y2),
|
|
160
|
+
maxY: Math.max(r.y1, r.y2),
|
|
161
|
+
})));
|
|
162
|
+
// Reading order is rows first, then left-to-right within a row. Ordering by
|
|
163
|
+
// minX alone was correct only while every group sat in one band; on a sheet
|
|
164
|
+
// whose groups wrap onto several rows it interleaves row 2 back into row 1
|
|
165
|
+
// and reports flow violations for a drawing that reads perfectly.
|
|
166
|
+
const byRow = [...rects].sort((a, b) => a.minY - b.minY || a.minX - b.minX);
|
|
167
|
+
const rows: (typeof rects)[] = [];
|
|
168
|
+
for (const r of byRow) {
|
|
169
|
+
const row = rows[rows.length - 1];
|
|
170
|
+
// Rows are separated by a group box height; members of one row share a top
|
|
171
|
+
// edge to within a symbol cell, so a generous band still cannot merge two.
|
|
172
|
+
if (row && r.minY - row[0]!.minY <= ROW_BAND) row.push(r);
|
|
173
|
+
else rows.push([r]);
|
|
174
|
+
}
|
|
175
|
+
const ordered = rows.flatMap((row) => [...row].sort((a, b) => a.minX - b.minX));
|
|
176
|
+
const groupIdx = (x: number, y: number): number =>
|
|
177
|
+
ordered.findIndex((r) => x > r.minX && x < r.maxX && y > r.minY && y < r.maxY);
|
|
178
|
+
/** Which wrapped row a point falls in; -1 when it is in no group box. */
|
|
179
|
+
const rowIdx = (x: number, y: number): number =>
|
|
180
|
+
rows.findIndex((row) => row.some((r) => x > r.minX && x < r.maxX && y > r.minY && y < r.maxY));
|
|
181
|
+
let crossNets = 0;
|
|
182
|
+
let flowViolations = 0;
|
|
183
|
+
for (const pts of byName.values()) {
|
|
184
|
+
const gs = [...new Set(pts.map((p) => groupIdx(p.x, p.y)).filter((g) => g >= 0))];
|
|
185
|
+
if (gs.length > 1) {
|
|
186
|
+
crossNets++;
|
|
187
|
+
if (Math.max(...gs) - Math.min(...gs) > 1) flowViolations++;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// utilization + whitespace balance over the first sheet's usable frame
|
|
192
|
+
const items: Bounds[] = [
|
|
193
|
+
...realSyms.map((s) => ({ minX: s.at.x - 2, minY: s.at.y - 2, maxX: s.at.x + 2, maxY: s.at.y + 2 })),
|
|
194
|
+
...rects,
|
|
195
|
+
];
|
|
196
|
+
const content = items.length
|
|
197
|
+
? items.reduce((a, b) => ({
|
|
198
|
+
minX: Math.min(a.minX, b.minX),
|
|
199
|
+
minY: Math.min(a.minY, b.minY),
|
|
200
|
+
maxX: Math.max(a.maxX, b.maxX),
|
|
201
|
+
maxY: Math.max(a.maxY, b.maxY),
|
|
202
|
+
}))
|
|
203
|
+
: null;
|
|
204
|
+
const usable = { minX: 10, minY: 10, maxX: paper.w - 10, maxY: paper.h - 10 - 30 };
|
|
205
|
+
const usableArea = (usable.maxX - usable.minX) * (usable.maxY - usable.minY);
|
|
206
|
+
const utilization = content
|
|
207
|
+
? Math.min(1, ((content.maxX - content.minX) * (content.maxY - content.minY)) / usableArea)
|
|
208
|
+
: 0;
|
|
209
|
+
const centroidOff = content
|
|
210
|
+
? Math.hypot(
|
|
211
|
+
(content.minX + content.maxX) / 2 - (usable.minX + usable.maxX) / 2,
|
|
212
|
+
(content.minY + content.maxY) / 2 - (usable.minY + usable.maxY) / 2,
|
|
213
|
+
) / (diag / 2)
|
|
214
|
+
: 1;
|
|
215
|
+
|
|
216
|
+
// axis alignment: symbols sharing an origin axis with at least one other
|
|
217
|
+
const sharesAxis = (i: number): boolean =>
|
|
218
|
+
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));
|
|
219
|
+
const axisAligned = realSyms.length > 1 ? realSyms.filter((_, i) => sharesAxis(i)).length / realSyms.length : 1;
|
|
220
|
+
|
|
221
|
+
// spacing uniformity: variance of consecutive gaps within shared-x columns
|
|
222
|
+
const cols = new Map<string, number[]>();
|
|
223
|
+
for (const s of realSyms) {
|
|
224
|
+
// Keyed by row as well as x: a column is a vertical run the eye reads as
|
|
225
|
+
// one stack. Once groups wrap, two symbols in different rows can share an x
|
|
226
|
+
// while sitting a whole row apart, and treating that jump as a "gap" pushes
|
|
227
|
+
// the coefficient of variation — and the score — off a cliff.
|
|
228
|
+
const k = `${s.sheet}:${Math.round(s.at.x * 100)}:${rowIdx(s.at.x, s.at.y)}`;
|
|
229
|
+
cols.set(k, [...(cols.get(k) ?? []), s.at.y]);
|
|
230
|
+
}
|
|
231
|
+
const gapCVs: number[] = [];
|
|
232
|
+
for (const ys of cols.values()) {
|
|
233
|
+
if (ys.length < 3) continue;
|
|
234
|
+
ys.sort((a, b) => a - b);
|
|
235
|
+
const gaps = ys.slice(1).map((y, i) => y - ys[i]!);
|
|
236
|
+
const mean = gaps.reduce((a, b) => a + b, 0) / gaps.length;
|
|
237
|
+
const sd = Math.sqrt(gaps.reduce((a, g) => a + (g - mean) ** 2, 0) / gaps.length);
|
|
238
|
+
gapCVs.push(mean > 0 ? sd / mean : 0);
|
|
239
|
+
}
|
|
240
|
+
const spacingUniformity = gapCVs.length ? Math.max(0, 1 - gapCVs.reduce((a, b) => a + b, 0) / gapCVs.length) : 1;
|
|
241
|
+
|
|
242
|
+
// pair symmetry: same-libId+value symbols in one group aligned on a shared axis
|
|
243
|
+
const pairKeys = new Map<string, { x: number; y: number }[]>();
|
|
244
|
+
for (const s of realSyms) {
|
|
245
|
+
const g = groupIdx(s.at.x, s.at.y);
|
|
246
|
+
const k = `${g}:${s.libId}:${s.value}`;
|
|
247
|
+
pairKeys.set(k, [...(pairKeys.get(k) ?? []), { x: s.at.x, y: s.at.y }]);
|
|
248
|
+
}
|
|
249
|
+
let pairs = 0;
|
|
250
|
+
let symmetric = 0;
|
|
251
|
+
for (const pts of pairKeys.values()) {
|
|
252
|
+
if (pts.length < 2) continue;
|
|
253
|
+
for (let i = 0; i < pts.length; i++) {
|
|
254
|
+
for (let j = i + 1; j < pts.length; j++) {
|
|
255
|
+
pairs++;
|
|
256
|
+
if (Math.abs(pts[i]!.x - pts[j]!.x) < 0.01 || Math.abs(pts[i]!.y - pts[j]!.y) < 0.01) symmetric++;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const pairSymmetry = pairs ? symmetric / pairs : 1;
|
|
261
|
+
|
|
262
|
+
const horizLabels = labels.length
|
|
263
|
+
? labels.filter((l) => Math.abs(l.rot % 180) !== 90).length / labels.length
|
|
264
|
+
: 1;
|
|
265
|
+
const straightRatio = segs.length ? Math.max(0, 1 - bends / segs.length) : 1;
|
|
266
|
+
const labelRatio = segs.length ? labels.length / segs.length : labels.length ? 1 : 0;
|
|
267
|
+
|
|
268
|
+
// ---- normalize to [0,1] goodness and compose ----
|
|
269
|
+
const raw: Record<string, { raw: number; score: number }> = {
|
|
270
|
+
'wire-crossings': { raw: crossings, score: 1 / (1 + crossings) },
|
|
271
|
+
'wire-bends': { raw: bends, score: 1 / (1 + bends / 8) },
|
|
272
|
+
'wire-length': { raw: totalLen, score: 1 / (1 + totalLen / (4 * diag)) },
|
|
273
|
+
'label-to-wire-ratio': { raw: labelRatio, score: labelRatio <= 1.5 ? 1 : 1.5 / labelRatio },
|
|
274
|
+
'group-cohesion': { raw: meanPair, score: pairDists.length ? Math.max(0, 1 - meanPair / diag) : 1 },
|
|
275
|
+
'flow-direction': { raw: flowViolations, score: crossNets ? 1 - flowViolations / crossNets : 1 },
|
|
276
|
+
utilization: { raw: utilization, score: Math.min(1, utilization / 0.5) },
|
|
277
|
+
'axis-alignment': { raw: axisAligned, score: axisAligned },
|
|
278
|
+
'spacing-uniformity': { raw: spacingUniformity, score: spacingUniformity },
|
|
279
|
+
'straight-wire-ratio': { raw: straightRatio, score: straightRatio },
|
|
280
|
+
'label-alignment': { raw: horizLabels, score: horizLabels },
|
|
281
|
+
'whitespace-balance': { raw: centroidOff, score: Math.max(0, 1 - centroidOff) },
|
|
282
|
+
'pair-symmetry': { raw: pairSymmetry, score: pairSymmetry },
|
|
283
|
+
};
|
|
284
|
+
const totalWeight = Object.values(weights).reduce((a, b) => a + b, 0) || 1;
|
|
285
|
+
const metrics: ScoreMetric[] = Object.entries(raw).map(([name, m]) => ({
|
|
286
|
+
name,
|
|
287
|
+
raw: m.raw,
|
|
288
|
+
score: m.score,
|
|
289
|
+
weight: weights[name]!,
|
|
290
|
+
contribution: (100 * weights[name]! * m.score) / totalWeight,
|
|
291
|
+
}));
|
|
292
|
+
let composite = metrics.reduce((a, m) => a + m.contribution, 0);
|
|
293
|
+
|
|
294
|
+
let cap: ScoreReport['cap'] = null;
|
|
295
|
+
if (legibility.counts.error > 0) {
|
|
296
|
+
const capValue = Math.min(ERROR_CAP, floor - 1);
|
|
297
|
+
if (composite > capValue) {
|
|
298
|
+
cap = {
|
|
299
|
+
appliedAt: capValue,
|
|
300
|
+
reason: `${legibility.counts.error} error-severity legibility finding(s) cap the composite below the known-good floor (${floor})`,
|
|
301
|
+
};
|
|
302
|
+
composite = capValue;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
composite: Math.round(composite * 100) / 100,
|
|
308
|
+
metrics,
|
|
309
|
+
cap,
|
|
310
|
+
legibility: legibility.counts,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function formatScore(report: ScoreReport): string {
|
|
315
|
+
const lines = [`score: ${report.composite}/100`];
|
|
316
|
+
if (report.cap) lines.push(` cap: ${report.cap.appliedAt} — ${report.cap.reason}`);
|
|
317
|
+
for (const m of report.metrics) {
|
|
318
|
+
lines.push(
|
|
319
|
+
` ${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}`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
return lines.join('\n');
|
|
323
|
+
}
|