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.
@@ -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 e of key.nonDefects) {
199
- for (const t of [e.title, ...e.examples])
200
- check(e.id, "nonDefect", t, true);
201
- for (const t of e.counterExamples)
202
- check(e.id, "nonDefect", t, false);
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, and one it
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 = { judged: 0, correct: 0, notInKey: 0, ambiguous: 0, badConfidence: 0, unsure: 0, outOfScope: 0, buckets: [], ece: 0, brier: 0 };
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
- return { labelled: `${c.correct}/${labelled} (${pct(c.correct, labelled)})`, low: pct(c.correct, c.findings), high: pct(c.correct + open, c.findings) };
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 ? ` — ${open} of ${c.findings} unlabelled, so between ${p.low} and ${p.high} of all findings` : ""),
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
+ }