scenescout 3.10.0 → 3.11.1
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/CHANGELOG.md +18 -0
- package/README.md +12 -3
- package/dist/browsers.js +64 -0
- package/dist/check-run.js +79 -5
- package/dist/cli.js +32 -6
- package/dist/engine/bench.js +154 -17
- package/dist/engine/browser.js +426 -26
- package/dist/engine/calibration.js +99 -14
- package/dist/engine/check.js +251 -33
- package/dist/engine/fingerprint.js +12 -7
- package/dist/engine/flow.js +398 -0
- package/dist/engine/lane.js +81 -0
- package/dist/engine/memory.js +48 -6
- package/dist/engine/unload.js +201 -0
- package/dist/engine/verify.js +119 -0
- package/dist/mcp-server.js +4 -4
- package/package.json +4 -2
- package/skills/scenescout/SKILL.md +1 -1
package/dist/engine/bench.js
CHANGED
|
@@ -79,6 +79,19 @@ const NonDefect = z
|
|
|
79
79
|
counterExamples: z.array(z.string()).default([]),
|
|
80
80
|
})
|
|
81
81
|
.strict();
|
|
82
|
+
const Contextual = z
|
|
83
|
+
.object({
|
|
84
|
+
id: z.string().min(1),
|
|
85
|
+
route: z.string().startsWith("/"),
|
|
86
|
+
title: z.string().min(1),
|
|
87
|
+
category: z.string().min(1),
|
|
88
|
+
/** The convention that decides it: where it would be a defect, and why the run cannot tell whether it holds here. */
|
|
89
|
+
reason: z.string().min(1),
|
|
90
|
+
match: z.array(Pattern).min(1),
|
|
91
|
+
examples: z.array(z.string()).default([]),
|
|
92
|
+
counterExamples: z.array(z.string()).default([]),
|
|
93
|
+
})
|
|
94
|
+
.strict();
|
|
82
95
|
const Key = z
|
|
83
96
|
.object({
|
|
84
97
|
app: z.string().min(1),
|
|
@@ -92,6 +105,14 @@ const Key = z
|
|
|
92
105
|
*/
|
|
93
106
|
alsoReal: z.array(Entry).default([]),
|
|
94
107
|
nonDefects: z.array(NonDefect).default([]),
|
|
108
|
+
/**
|
|
109
|
+
* Observations that are defects only under a convention the run cannot
|
|
110
|
+
* see — a spacing scale the project may or may not declare, say. Neither
|
|
111
|
+
* lane is wrong about them, so they are set aside: a finding matching one
|
|
112
|
+
* is neither correct nor a false positive, and a verdict on one is not
|
|
113
|
+
* scored for calibration.
|
|
114
|
+
*/
|
|
115
|
+
contextual: z.array(Contextual).default([]),
|
|
95
116
|
})
|
|
96
117
|
.strict();
|
|
97
118
|
/**
|
|
@@ -105,7 +126,7 @@ export function parseKey(raw) {
|
|
|
105
126
|
const issue = parsed.error.issues[0];
|
|
106
127
|
throw new Error(`The answer key is not valid at ${issue.path.join(".") || "(root)"}: ${issue.message}`);
|
|
107
128
|
}
|
|
108
|
-
const ids = [...parsed.data.defects, ...parsed.data.alsoReal, ...parsed.data.nonDefects].map((e) => e.id);
|
|
129
|
+
const ids = [...parsed.data.defects, ...parsed.data.alsoReal, ...parsed.data.nonDefects, ...parsed.data.contextual].map((e) => e.id);
|
|
109
130
|
const dup = ids.find((id, i) => ids.indexOf(id) !== i);
|
|
110
131
|
if (dup)
|
|
111
132
|
throw new Error(`The answer key uses the id ${JSON.stringify(dup)} twice.`);
|
|
@@ -119,7 +140,7 @@ export function parseKey(raw) {
|
|
|
119
140
|
}
|
|
120
141
|
/** A short, stable fingerprint of a key, so a scorecard says which key produced it. */
|
|
121
142
|
export function keyHash(key) {
|
|
122
|
-
const canonical = JSON.stringify({ defects: key.defects, alsoReal: key.alsoReal, nonDefects: key.nonDefects });
|
|
143
|
+
const canonical = JSON.stringify({ defects: key.defects, alsoReal: key.alsoReal, nonDefects: key.nonDefects, contextual: key.contextual });
|
|
123
144
|
return createHash("sha256").update(canonical).digest("hex").slice(0, 10);
|
|
124
145
|
}
|
|
125
146
|
const RANK = { minimal: 0, medium: 1, extensive: 2 };
|
|
@@ -138,12 +159,19 @@ function matches(patterns, text) {
|
|
|
138
159
|
* match would quietly count a finding about two defects as one, and letting a
|
|
139
160
|
* non-defect win everywhere turned realistic rewordings of real defects into
|
|
140
161
|
* false positives with a confident explanation beside them.
|
|
162
|
+
*
|
|
163
|
+
* A contextual entry is one more claimant on the same terms as a real one: it
|
|
164
|
+
* wins over nothing, no non-defect can override it, and text it shares with
|
|
165
|
+
* any other entry is ambiguous. Setting a finding aside removes it from
|
|
166
|
+
* precision, so a pattern that let it win would hide real defects from the
|
|
167
|
+
* score as quietly as a greedy non-defect once did.
|
|
141
168
|
*/
|
|
142
169
|
export function classify(text, key) {
|
|
143
170
|
const nd = key.nonDefects.filter((e) => matches(e.match, text));
|
|
144
171
|
const real = [
|
|
145
172
|
...key.defects.filter((e) => matches(e.match, text)).map((entry) => ({ kind: "defect", entry })),
|
|
146
173
|
...key.alsoReal.filter((e) => matches(e.match, text)).map((entry) => ({ kind: "alsoReal", entry })),
|
|
174
|
+
...key.contextual.filter((e) => matches(e.match, text)).map((entry) => ({ kind: "contextual", entry })),
|
|
147
175
|
];
|
|
148
176
|
if (nd.length > 1)
|
|
149
177
|
return { kind: "ambiguous", ids: [...nd, ...real.map((m) => m.entry)].map((e) => e.id) };
|
|
@@ -171,7 +199,7 @@ export function decisionText(d) {
|
|
|
171
199
|
* A key that disagrees with its own examples is wrong before any run is
|
|
172
200
|
* scored. Returns every title or example — non-defects' included — that
|
|
173
201
|
* classifies anywhere but its own entry, and every counter-example that
|
|
174
|
-
* classifies TO its entry.
|
|
202
|
+
* classifies TO its entry. Contextual entries are held to the same rule.
|
|
175
203
|
*/
|
|
176
204
|
export function lintKey(key) {
|
|
177
205
|
const problems = [];
|
|
@@ -195,11 +223,16 @@ export function lintKey(key) {
|
|
|
195
223
|
check(e.id, kind, t, false);
|
|
196
224
|
}
|
|
197
225
|
}
|
|
198
|
-
for (const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
226
|
+
for (const [kind, list] of [
|
|
227
|
+
["nonDefect", key.nonDefects],
|
|
228
|
+
["contextual", key.contextual],
|
|
229
|
+
]) {
|
|
230
|
+
for (const e of list) {
|
|
231
|
+
for (const t of [e.title, ...e.examples])
|
|
232
|
+
check(e.id, kind, t, true);
|
|
233
|
+
for (const t of e.counterExamples)
|
|
234
|
+
check(e.id, kind, t, false);
|
|
235
|
+
}
|
|
203
236
|
}
|
|
204
237
|
return problems;
|
|
205
238
|
}
|
|
@@ -208,8 +241,8 @@ export function lintKey(key) {
|
|
|
208
241
|
*
|
|
209
242
|
* "defect" on a planted or also-real defect is right, and on a known
|
|
210
243
|
* non-defect is wrong. "not_a_defect" is the reverse. An "unsure" verdict, a
|
|
211
|
-
* dismissal as another lane's, a decision the key does not name,
|
|
212
|
-
* names ambiguously are not scored — the same rule the in-product calibration
|
|
244
|
+
* dismissal as another lane's, a decision the key does not name, one it
|
|
245
|
+
* names ambiguously, and one about a contextual entry are not scored — the same rule the in-product calibration
|
|
213
246
|
* follows, for the same reason.
|
|
214
247
|
*/
|
|
215
248
|
export function judgeDecision(d, key) {
|
|
@@ -218,7 +251,7 @@ export function judgeDecision(d, key) {
|
|
|
218
251
|
if (isScopeDismissal(d))
|
|
219
252
|
return null;
|
|
220
253
|
const m = classify(decisionText(d), key);
|
|
221
|
-
if (!m || m.kind === "ambiguous")
|
|
254
|
+
if (!m || m.kind === "ambiguous" || m.kind === "contextual")
|
|
222
255
|
return null;
|
|
223
256
|
const isDefect = m.kind !== "nonDefect";
|
|
224
257
|
return d.verdict === "defect" ? isDefect : !isDefect;
|
|
@@ -233,7 +266,19 @@ export function calibrateAgainstKey(decisions, key) {
|
|
|
233
266
|
if (decisions.length === 0)
|
|
234
267
|
return null;
|
|
235
268
|
const buckets = BUCKET_EDGES.map(() => ({ n: 0, conf: 0, right: 0 }));
|
|
236
|
-
const out = {
|
|
269
|
+
const out = {
|
|
270
|
+
judged: 0,
|
|
271
|
+
correct: 0,
|
|
272
|
+
notInKey: 0,
|
|
273
|
+
ambiguous: 0,
|
|
274
|
+
badConfidence: 0,
|
|
275
|
+
unsure: 0,
|
|
276
|
+
outOfScope: 0,
|
|
277
|
+
contextual: 0,
|
|
278
|
+
buckets: [],
|
|
279
|
+
ece: 0,
|
|
280
|
+
brier: 0,
|
|
281
|
+
};
|
|
237
282
|
for (const d of decisions) {
|
|
238
283
|
if (d.verdict === "unsure") {
|
|
239
284
|
out.unsure += 1;
|
|
@@ -257,6 +302,10 @@ export function calibrateAgainstKey(decisions, key) {
|
|
|
257
302
|
out.ambiguous += 1;
|
|
258
303
|
continue;
|
|
259
304
|
}
|
|
305
|
+
if (m.kind === "contextual") {
|
|
306
|
+
out.contextual += 1;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
260
309
|
const right = d.verdict === "defect" ? m.kind !== "nonDefect" : m.kind === "nonDefect";
|
|
261
310
|
out.judged += 1;
|
|
262
311
|
if (right)
|
|
@@ -290,6 +339,7 @@ export function score(key, findings, decisions, level = "medium") {
|
|
|
290
339
|
const falsePositives = [];
|
|
291
340
|
const unknown = [];
|
|
292
341
|
const ambiguous = [];
|
|
342
|
+
const contextual = [];
|
|
293
343
|
let correct = 0;
|
|
294
344
|
for (const f of findings) {
|
|
295
345
|
const m = classify(findingText(f), key);
|
|
@@ -305,6 +355,10 @@ export function score(key, findings, decisions, level = "medium") {
|
|
|
305
355
|
falsePositives.push({ id: m.entry.id, title: f.title, why: m.entry.why, severity: f.severity });
|
|
306
356
|
continue;
|
|
307
357
|
}
|
|
358
|
+
if (m.kind === "contextual") {
|
|
359
|
+
contextual.push({ id: m.entry.id, title: f.title });
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
308
362
|
correct += 1;
|
|
309
363
|
if (m.kind === "defect") {
|
|
310
364
|
const list = hitsByDefect.get(m.entry.id) ?? [];
|
|
@@ -355,6 +409,7 @@ export function score(key, findings, decisions, level = "medium") {
|
|
|
355
409
|
findings: findings.length,
|
|
356
410
|
correct,
|
|
357
411
|
falsePositives,
|
|
412
|
+
contextual,
|
|
358
413
|
unknown,
|
|
359
414
|
ambiguous,
|
|
360
415
|
duplicates,
|
|
@@ -367,12 +422,14 @@ const pct = (n, d) => (d === 0 ? "—" : `${Math.round((n / d) * 100)}%`);
|
|
|
367
422
|
* Precision, with its bounds. The labelled ratio leaves unlabelled findings out
|
|
368
423
|
* of the denominator, so on its own it cannot move when a change adds five new
|
|
369
424
|
* false claims nobody has judged yet. The bounds can: the lower one counts
|
|
370
|
-
* every open finding as wrong, the upper one as right.
|
|
425
|
+
* every open finding as wrong, the upper one as right. Findings set aside as
|
|
426
|
+
* contextual are in neither: they are not claims the run could get right.
|
|
371
427
|
*/
|
|
372
428
|
export function precisionBounds(c) {
|
|
373
429
|
const labelled = c.correct + c.falsePositives.length;
|
|
374
430
|
const open = c.unknown.length + c.ambiguous.length;
|
|
375
|
-
|
|
431
|
+
const scored = c.findings - c.contextual.length;
|
|
432
|
+
return { labelled: `${c.correct}/${labelled} (${pct(c.correct, labelled)})`, low: pct(c.correct, scored), high: pct(c.correct + open, scored) };
|
|
376
433
|
}
|
|
377
434
|
/** The scorecard as a person reads it. Leads with the two numbers, then says what each is made of. */
|
|
378
435
|
export function formatScorecard(c) {
|
|
@@ -383,9 +440,13 @@ export function formatScorecard(c) {
|
|
|
383
440
|
``,
|
|
384
441
|
`Recall ${c.found.length}/${c.expected} (${pct(c.found.length, c.expected)}) of the planted defects expected at this level`,
|
|
385
442
|
`Precision ${p.labelled} of the findings the key can label` +
|
|
386
|
-
(open
|
|
387
|
-
|
|
443
|
+
(open
|
|
444
|
+
? ` — ${open} of ${c.findings - c.contextual.length} unlabelled, so between ${p.low} and ${p.high} of ${c.contextual.length ? "the findings not set aside" : "all findings"}`
|
|
445
|
+
: ""),
|
|
388
446
|
];
|
|
447
|
+
if (c.contextual.length)
|
|
448
|
+
lines.push(`Set aside ${c.contextual.length} of ${c.findings} finding(s): defects only under a convention the run cannot see, so in neither count`);
|
|
449
|
+
lines.push(``);
|
|
389
450
|
if (c.missed.length)
|
|
390
451
|
lines.push(`Missed: ${c.missed.join(", ")}`);
|
|
391
452
|
if (c.judgedNotFiled.length)
|
|
@@ -397,6 +458,11 @@ export function formatScorecard(c) {
|
|
|
397
458
|
for (const fp of c.falsePositives)
|
|
398
459
|
lines.push(` [${fp.severity}] ${fp.title} — ${fp.why}`);
|
|
399
460
|
}
|
|
461
|
+
if (c.contextual.length) {
|
|
462
|
+
lines.push(``, `Set aside as contextual (${c.contextual.length}):`);
|
|
463
|
+
for (const x of c.contextual)
|
|
464
|
+
lines.push(` ${x.title} (${x.id})`);
|
|
465
|
+
}
|
|
400
466
|
if (c.ambiguous.length) {
|
|
401
467
|
lines.push(``, `Ambiguous (${c.ambiguous.length}) — the key claims each twice; sharpen it:`);
|
|
402
468
|
for (const a of c.ambiguous)
|
|
@@ -425,6 +491,7 @@ export function formatScorecard(c) {
|
|
|
425
491
|
k.badConfidence && `${k.badConfidence} with an unusable confidence`,
|
|
426
492
|
k.unsure && `${k.unsure} unsure`,
|
|
427
493
|
k.outOfScope && `${k.outOfScope} dismissed as another lane's`,
|
|
494
|
+
k.contextual && `${k.contextual} about things that are defects only under a convention the run cannot see`,
|
|
428
495
|
].filter(Boolean);
|
|
429
496
|
lines.push(``, k.judged === 0
|
|
430
497
|
? `Lane calibration against the key: nothing the key could judge` + (skipped.length ? ` (${skipped.join(", ")})` : "")
|
|
@@ -460,6 +527,13 @@ export function runDate(decisions, given) {
|
|
|
460
527
|
throw new Error(`The run's last decision was made on ${last}; it cannot be dated ${given}.`);
|
|
461
528
|
return given ?? last;
|
|
462
529
|
}
|
|
530
|
+
/**
|
|
531
|
+
* The order `--all` lists runs in: by date, then by name with its numbers
|
|
532
|
+
* compared as numbers, so run-10 follows run-9 rather than run-1.
|
|
533
|
+
*/
|
|
534
|
+
export function byRunOrder(a, b) {
|
|
535
|
+
return a.date.localeCompare(b.date) || a.run.localeCompare(b.run, undefined, { numeric: true });
|
|
536
|
+
}
|
|
463
537
|
/**
|
|
464
538
|
* Paths into a machine's own directories, which a finding's evidence can pick
|
|
465
539
|
* up from a stack trace or an upload. An archive is committed, and a home
|
|
@@ -469,8 +543,9 @@ const LOCAL_PATH_RE = /(?:\/Users\/[^/\s"']+|\/home\/[^/\s"']+|\/private\/tmp|\/
|
|
|
469
543
|
export function sanitize(text) {
|
|
470
544
|
return text.replace(LOCAL_PATH_RE, "<path>");
|
|
471
545
|
}
|
|
472
|
-
export function toArchive(run, date, note, findings, decisions) {
|
|
546
|
+
export function toArchive(run, date, note, findings, decisions, app) {
|
|
473
547
|
return {
|
|
548
|
+
app,
|
|
474
549
|
run,
|
|
475
550
|
date,
|
|
476
551
|
note,
|
|
@@ -483,3 +558,65 @@ export function toArchive(run, date, note, findings, decisions) {
|
|
|
483
558
|
decisions: decisions.map((d) => ({ ...d, observation: sanitize(d.observation), evidence: d.evidence === null ? null : sanitize(d.evidence) })),
|
|
484
559
|
};
|
|
485
560
|
}
|
|
561
|
+
// ── which app, and so which key ─────────────────────────────────────────────
|
|
562
|
+
/**
|
|
563
|
+
* The app a run is for when nothing says otherwise. Every archive made before
|
|
564
|
+
* there was a second benchmark app is this one's, and carries no `app`.
|
|
565
|
+
*/
|
|
566
|
+
export const DEFAULT_APP = "demo";
|
|
567
|
+
/** The app an archive was made against. */
|
|
568
|
+
export function archiveApp(a) {
|
|
569
|
+
return a.app ?? DEFAULT_APP;
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* The app whose key scores a run. Scoring a run against another app's key
|
|
573
|
+
* produces a plausible wrong number — every planted defect "missed", every
|
|
574
|
+
* finding "unlabelled" — so a request that disagrees with what the archive
|
|
575
|
+
* records is refused rather than obeyed.
|
|
576
|
+
*
|
|
577
|
+
* `requested` is what the caller asked for (`--app`), `archived` what the
|
|
578
|
+
* run's archive records, and `known` the apps that have a key.
|
|
579
|
+
*/
|
|
580
|
+
export function chooseApp(opts) {
|
|
581
|
+
const { requested, archived, known } = opts;
|
|
582
|
+
for (const [what, app] of [
|
|
583
|
+
["--app", requested],
|
|
584
|
+
["The archive's app", archived],
|
|
585
|
+
]) {
|
|
586
|
+
if (app !== undefined && !known.includes(app))
|
|
587
|
+
throw new Error(`${what} ${JSON.stringify(app)} is not a benchmark app; the apps are ${known.join(", ")}.`);
|
|
588
|
+
}
|
|
589
|
+
if (requested !== undefined && archived !== undefined && requested !== archived)
|
|
590
|
+
throw new Error(`This run was archived for the ${archived} app; scoring it against the ${requested} app's key would score the wrong answers.`);
|
|
591
|
+
return requested ?? archived ?? DEFAULT_APP;
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Which benchmark app a key file belongs to, by the name the key gives its
|
|
595
|
+
* app, or undefined for a key no benchmark app ships (a draft, a test key).
|
|
596
|
+
* A copy of one app's key, edited or not, still names that app.
|
|
597
|
+
*/
|
|
598
|
+
export function appOfKey(key, keys) {
|
|
599
|
+
return Object.keys(keys).find((app) => keys[app].app === key.app);
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Refuse a key file that belongs to another benchmark app than the archive
|
|
603
|
+
* being scored: `--key` naming the held-out key scored a demo archive against
|
|
604
|
+
* the wrong answers without a word.
|
|
605
|
+
*/
|
|
606
|
+
export function checkKeyForArchive(key, archive, keys) {
|
|
607
|
+
const keyApp = appOfKey(key, keys);
|
|
608
|
+
const runApp = archiveApp(archive);
|
|
609
|
+
if (keyApp !== undefined && keyApp !== runApp)
|
|
610
|
+
throw new Error(`This run was archived for the ${runApp} app, and that key is the ${keyApp} app's; scoring one against the other would score the wrong answers.`);
|
|
611
|
+
}
|
|
612
|
+
/** Archives grouped by app, in the order each app is first met, each group in `byRunOrder`. */
|
|
613
|
+
export function groupByApp(archives) {
|
|
614
|
+
const groups = new Map();
|
|
615
|
+
for (const a of archives) {
|
|
616
|
+
const app = archiveApp(a);
|
|
617
|
+
groups.set(app, [...(groups.get(app) ?? []), a]);
|
|
618
|
+
}
|
|
619
|
+
for (const list of groups.values())
|
|
620
|
+
list.sort(byRunOrder);
|
|
621
|
+
return groups;
|
|
622
|
+
}
|