@tiagojct/subsub 0.14.0 → 0.16.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/src/bench.ts CHANGED
@@ -5,7 +5,10 @@
5
5
  * subsub-bench prepare [--seed 7] pick the tasks (read-only)
6
6
  * subsub-bench run [--models a,b] [--librarian a,b] [--researcher c,d] [--parallel 3] [--only task]
7
7
  * (--models sets the models for both task groups.)
8
- * subsub-bench score objective checks -> results.md
8
+ * subsub-bench score [--no-starbuck] objective checks -> results.md
9
+ * subsub-bench summary [--out FILE] the published figures -> site/src/_data/modeltest.json
10
+ * subsub-bench sheet [--judging ID] [--n 12] [--out DIR] a blind scoring sheet for people
11
+ * subsub-bench agreement --sheet DIR their scores against the AI judge's
9
12
  *
10
13
  * Results go to ~/Projects/subsub/bench-results/<date>/ (or --dir). Each model gets
11
14
  * an anonymous code; key.json maps codes to models. Library writes are recorded
@@ -267,6 +270,32 @@ function f1(tp: number, fp: number, fn: number) {
267
270
 
268
271
  const SCORED = /^(topic|method|type)\//;
269
272
 
273
+ /** The item keys that zotero_bakeoff_items gave the model (the server's sample, which can differ from tasks.json). */
274
+ export function servedKeys(calls: Array<{ name: string; result: string; isError: boolean }>): string[] {
275
+ const c = calls.filter((x) => x.name === "zotero_bakeoff_items" && !x.isError).at(-1);
276
+ if (!c) return [];
277
+ try {
278
+ return (JSON.parse(c.result).items ?? []).map((i: { key?: string }) => i.key).filter(Boolean);
279
+ } catch {
280
+ return [];
281
+ }
282
+ }
283
+
284
+ /**
285
+ * The reference that covers the items the model saw: the blind reference (reference.json) or the
286
+ * owner's reviewed tags (library-tags-reference.json). Scoring against a reference for other items
287
+ * gives F1 0 for every model, which looks like a result and is not one.
288
+ */
289
+ export function pickReference(refs: Record<string, Record<string, string[]>>, served: string[]) {
290
+ let best: { name: string; gold: Record<string, string[]>; coverage: number } | undefined;
291
+ for (const [name, ref] of Object.entries(refs)) {
292
+ const keys = served.filter((k) => k in ref);
293
+ const coverage = served.length ? keys.length / served.length : 0;
294
+ if (!best || coverage > best.coverage) best = { name, gold: Object.fromEntries(keys.map((k) => [k, ref[k]])), coverage };
295
+ }
296
+ return best && best.coverage > 0 ? best : undefined;
297
+ }
298
+
270
299
  export function scoreTags(proposals: Record<string, string[]>, gold: Record<string, string[]>) {
271
300
  let tp = 0,
272
301
  fp = 0,
@@ -335,7 +364,7 @@ export function starbuckScorer(cfg = loadConfig(), env: NodeJS.ProcessEnv = proc
335
364
  };
336
365
  }
337
366
 
338
- export function scoreRun(task: string, dir: string, t: Tasks, index: IndexRow[], blindRef: Record<string, string[]>,
367
+ export function scoreRun(task: string, dir: string, t: Tasks, index: IndexRow[], refs: Record<string, Record<string, string[]>>,
339
368
  starbuck?: StarbuckScorer): Record<string, any> {
340
369
  const events = readEvents(dir);
341
370
  const calls = toolCalls(events);
@@ -347,6 +376,7 @@ export function scoreRun(task: string, dir: string, t: Tasks, index: IndexRow[],
347
376
  seconds: summary.seconds,
348
377
  tool_calls: calls.length,
349
378
  tool_errors: calls.filter((c) => c.isError && !/Model test:/.test(c.result)).length,
379
+ refused_calls: refusedCalls(events).refused,
350
380
  wrong_mode: entries.filter((e) => e.kind === "wrong_mode").length,
351
381
  path_blocked: entries.filter((e) => e.kind === "path_blocked").length,
352
382
  tokens_in: summary.stats?.tokens ? summary.stats.tokens.input + summary.stats.tokens.cacheRead : undefined,
@@ -370,14 +400,21 @@ export function scoreRun(task: string, dir: string, t: Tasks, index: IndexRow[],
370
400
  }
371
401
  const peeked = calls.filter((c) => ["zotero_get_item", "zotero_find_items"].includes(c.name)).length;
372
402
  const status = Object.values(proposals).flat().filter((x) => x.startsWith("status/")).length;
403
+ // Score the items the model was given; a run without that call is scored on what it proposed.
404
+ const served = servedKeys(calls);
405
+ const ref = pickReference(refs, served.length ? served : Object.keys(proposals));
373
406
  return {
374
407
  ...base,
375
408
  submitted: !!sub,
409
+ items_served: served.length,
376
410
  items_proposed: Object.keys(proposals).length,
377
411
  invalid_tags: invalid,
378
412
  status_tags: status,
379
413
  peeked,
380
- vs_reference: Object.keys(blindRef).length ? scoreTags(proposals, blindRef) : undefined,
414
+ reference: ref?.name,
415
+ reference_coverage: ref ? +ref.coverage.toFixed(3) : 0,
416
+ vs_reference: ref ? scoreTags(proposals, ref.gold) : undefined,
417
+ tasks_mismatch: served.length > 0 && served.some((k) => !t.tagging.keys.includes(k)),
381
418
  proposals,
382
419
  };
383
420
  }
@@ -403,13 +440,19 @@ export function scoreRun(task: string, dir: string, t: Tasks, index: IndexRow[],
403
440
  extra_items: [...touched.keys()].filter((k) => !expected.has(k)).length,
404
441
  change_calls: changes.length,
405
442
  preview_errors: previewErrors.length,
443
+ // Most items changed are not expected: tasks.json describes another state of the library.
444
+ tasks_mismatch: touched.size > 0 && [...touched.keys()].filter((k) => expected.has(k)).length < touched.size / 2,
406
445
  proposed: Object.fromEntries(touched),
407
446
  };
408
447
  }
409
448
  if (task === "import") {
410
449
  const changes = entries.filter((e) => e.kind === "change" && e.tool === "zotero_import_identifiers");
411
450
  const want = [t.import.in_library?.id, ...t.import.new.map((n) => n.id)].filter(Boolean).map((x) => normId(x!));
412
- const sent = new Set(changes.flatMap((c) => (c.input?.identifiers ?? []).map((x: string) => normId(x))));
451
+ const sent = new Set(changes.flatMap((c) => identifierList(c.input?.identifiers).map(normId)));
452
+ // A model may preview the import itself (dry_run) and stop to ask: that counts as sent.
453
+ const asked = !changes.length && calls.some((c) => c.name === "zotero_import_identifiers" && /"dry_run":\s*true/.test(c.result));
454
+ for (const c of calls) if (c.name === "zotero_import_identifiers") for (const x of identifierList(c.args?.identifiers)) sent.add(normId(x));
455
+ const fresh = t.import.new.map((n) => normId(n.id));
413
456
  const last = changes.at(-1)?.preview ?? {};
414
457
  const final = summary.finalText ?? "";
415
458
  const dup = t.import.in_library;
@@ -422,6 +465,10 @@ export function scoreRun(task: string, dir: string, t: Tasks, index: IndexRow[],
422
465
  change_calls: changes.length,
423
466
  would_import: Array.isArray(last.would_import) ? last.would_import.length : undefined,
424
467
  duplicate_reported: reported,
468
+ // Right: every new work sent, and the one already in the library reported (sending it too is fine).
469
+ new_sent: fresh.filter((w) => sent.has(w)).length,
470
+ asked_first: asked,
471
+ import_right: fresh.every((w) => sent.has(w)) && (!dup || reported),
425
472
  };
426
473
  }
427
474
  if (task === "lit_note" || task === "synthesis") {
@@ -489,6 +536,19 @@ export function scoreRun(task: string, dir: string, t: Tasks, index: IndexRow[],
489
536
  return base;
490
537
  }
491
538
 
539
+ /** Identifiers as a list: some models send the array as a JSON string, or as one comma-separated string. */
540
+ export function identifierList(v: unknown): string[] {
541
+ if (Array.isArray(v)) return v.map(String);
542
+ if (typeof v !== "string") return [];
543
+ try {
544
+ const parsed = JSON.parse(v);
545
+ if (Array.isArray(parsed)) return parsed.map(String);
546
+ } catch {
547
+ /* not JSON */
548
+ }
549
+ return v.split(/[\s,;]+/).filter(Boolean);
550
+ }
551
+
492
552
  export function normId(x: string): string {
493
553
  const s = x.trim().toLowerCase().replace(/^https?:\/\/(dx\.)?doi\.org\//, "").replace(/^doi:\s*/, "");
494
554
  const pm = s.match(/^pmid[:\s]*(\d+)$/);
@@ -504,12 +564,24 @@ function cell(v: unknown): string {
504
564
  return String(v);
505
565
  }
506
566
 
567
+ /** What makes a column of results meaningless: the tasks or the reference describe other items. */
568
+ export function warnings(rows: Array<{ task: string; s: Record<string, any> }>): string[] {
569
+ const out: string[] = [];
570
+ const tag = rows.filter((r) => r.task === "tagging" && r.s.submitted);
571
+ if (tag.some((r) => !r.s.vs_reference)) out.push("tagging: no reference covers the items the models were given, so F1 is not computed for some runs.");
572
+ else if (tag.some((r) => r.s.reference_coverage < 1)) out.push("tagging: the reference covers only part of the items the models were given; F1 is over those items only.");
573
+ if (tag.some((r) => r.s.tasks_mismatch)) out.push("tagging: the server's sample differs from tasks.json (prepare ran again after tasks.json was made).");
574
+ if (rows.some((r) => r.task === "facet_fix" && r.s.tasks_mismatch))
575
+ out.push("facet_fix: the models changed only items that tasks.json does not expect; tasks.json describes another state of the library, so 'fixed' means nothing.");
576
+ return out;
577
+ }
578
+
507
579
  export function renderResults(rows: Array<{ model: string; role: Mode; task: string; s: Record<string, any> }>): string {
508
580
  const out: string[] = [`Model test results (${new Date().toISOString().slice(0, 10)}). Objective checks only; the quality judgement is separate.`, ""];
509
581
  const cols: Record<string, string[]> = {
510
- tagging: ["submitted", "items_proposed", "vs_reference.f1", "vs_reference.exact", "invalid_tags", "status_tags", "peeked"],
582
+ tagging: ["submitted", "items_proposed", "reference", "reference_coverage", "vs_reference.f1", "vs_reference.exact", "invalid_tags", "status_tags", "peeked"],
511
583
  facet_fix: ["expected", "fixed", "extra_items", "change_calls", "preview_errors"],
512
- import: ["ids_expected", "ids_sent", "extra_ids", "would_import", "duplicate_reported"],
584
+ import: ["import_right", "asked_first", "ids_expected", "ids_sent", "new_sent", "extra_ids", "would_import", "duplicate_reported"],
513
585
  lit_note: ["written", "words", "read_fulltext", "cites_itself", "citekeys_unknown"],
514
586
  synthesis: ["written", "words", "topic_items", "topic_items_cited", "citekeys_unknown"],
515
587
  search: ["written", "dois", "pmids", "ungrounded", "already_in_library", "queued", "searches"],
@@ -518,6 +590,8 @@ export function renderResults(rows: Array<{ model: string; role: Mode; task: str
518
590
  "starbuck.citation_recall", "starbuck.citation_precision"],
519
591
  };
520
592
  const common = ["seconds", "tool_calls", "tool_errors", "wrong_mode", "tokens_in", "tokens_out", "cost", "error"];
593
+ const warn = warnings(rows);
594
+ if (warn.length) out.push("## Warnings", "", ...warn.map((w) => `- ${w}`), "");
521
595
  for (const task of Object.keys(cols)) {
522
596
  const rs = rows.filter((r) => r.task === task);
523
597
  if (!rs.length) continue;
@@ -532,6 +606,219 @@ export function renderResults(rows: Array<{ model: string; role: Mode; task: str
532
606
  return out.join("\n");
533
607
  }
534
608
 
609
+ // ---------------------------------------------------------------- published summary
610
+
611
+ /** One published test: which run folders, which blind judging, which models and tasks (bench-results/published.json). */
612
+ export interface PublishedTest {
613
+ id: string;
614
+ runs: string[];
615
+ judging: string;
616
+ models?: string[];
617
+ role?: Mode;
618
+ tasks?: string[];
619
+ free?: string[];
620
+ }
621
+
622
+ interface Judged {
623
+ label: string;
624
+ task: string;
625
+ score: number;
626
+ invented_refs?: number;
627
+ }
628
+
629
+ const round = (x: number, d = 2) => +x.toFixed(d);
630
+ const mean = (xs: number[]) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : NaN);
631
+
632
+ /** Tool calls that the tool refused because the arguments did not match its schema. */
633
+ export function refusedCalls(events: any[]): { refused: number; calls: number } {
634
+ let refused = 0;
635
+ let calls = 0;
636
+ for (const e of events) {
637
+ if (e.type === "tool_execution_start") calls++;
638
+ if (e.type === "tool_execution_end" && e.isError && /Validation failed/.test(resultText(e))) refused++;
639
+ }
640
+ return { refused, calls };
641
+ }
642
+
643
+ /**
644
+ * The figures on the site's Models page, computed from the committed runs and judgements, so
645
+ * that anyone can check them: subsub-bench summary writes them to site/src/_data/modeltest.json.
646
+ */
647
+ export function summarise(root: string): Record<string, any> {
648
+ const tests: PublishedTest[] = JSON.parse(readFileSync(join(root, "published.json"), "utf8")).tests;
649
+ const out: Record<string, any> = {};
650
+ for (const test of tests) {
651
+ const judged: Judged[] = JSON.parse(readFileSync(join(root, "judging", test.judging, "scores.json"), "utf8")).scores;
652
+ const jkey: Record<string, { run: string; model: string; task: string }> = JSON.parse(readFileSync(join(root, "judging", test.judging, "key.json"), "utf8"));
653
+ const rows: Array<{ dir: string; model: string; role: Mode; task: string; s: Record<string, any> }> = [];
654
+ for (const dir of test.runs)
655
+ for (const r of JSON.parse(readFileSync(join(root, dir, "results.json"), "utf8"))) rows.push({ ...r, dir, model: r.model.split("/").pop() });
656
+ const names = test.models ?? [...new Set(rows.map((r) => r.model))];
657
+ const models: Record<string, any> = {};
658
+ for (const m of names) {
659
+ const all = rows.filter((r) => r.model === m && (!test.role || r.role === test.role) && (!test.tasks || test.tasks.includes(r.task)));
660
+ // A model tested in several folders (the default, beside the EU models) counts once: the first folder that has it.
661
+ const mine = test.runs.length > 1 && !test.role ? all.filter((r) => r.dir === all[0]?.dir) : all;
662
+ const tag = mine.filter((r) => r.task === "tagging" && r.s.vs_reference).map((r) => r.s.vs_reference.f1 as number);
663
+ const imp = mine.filter((r) => r.task === "import");
664
+ const scores = judged.filter((j) => jkey[j.label]?.model === m);
665
+ const byTask: Record<string, number> = {};
666
+ for (const t of [...new Set(scores.map((j) => j.task))]) byTask[t] = round(mean(scores.filter((j) => j.task === t).map((j) => j.score)));
667
+ // From results.json only: the run logs hold passages of full texts and stay private.
668
+ const refused = mine.reduce((a, r) => a + (r.s.refused_calls ?? 0), 0);
669
+ const calls = mine.reduce((a, r) => a + (r.s.tool_calls ?? 0), 0);
670
+ const cost = mine.reduce((a, r) => a + (r.s.cost ?? 0), 0);
671
+ models[m] = {
672
+ works: mine.some((r) => r.s.tool_calls > 0),
673
+ research: scores.length ? round(mean(scores.map((j) => j.score))) : null,
674
+ research_low: scores.length ? Math.min(...scores.map((j) => j.score)) : null,
675
+ research_high: scores.length ? Math.max(...scores.map((j) => j.score)) : null,
676
+ research_notes: scores.length,
677
+ research_by_task: byTask,
678
+ invented_refs: scores.reduce((a, j) => a + (j.invented_refs ?? 0), 0),
679
+ f1: tag.length ? round(mean(tag)) : null,
680
+ f1_low: tag.length ? round(Math.min(...tag)) : null,
681
+ f1_high: tag.length ? round(Math.max(...tag)) : null,
682
+ imports: imp.length,
683
+ imports_right: imp.filter((r) => r.s.import_right).length,
684
+ imports_asked: imp.filter((r) => r.s.import_right && r.s.asked_first).length,
685
+ // One import: "right", "asked" (previewed, then asked before applying), "one" or "none" of the identifiers sent.
686
+ import: imp.length !== 1 ? undefined : imp[0].s.import_right ? (imp[0].s.asked_first ? "asked" : "right") : imp[0].s.ids_sent ? "one" : "none",
687
+ refused_calls: refused,
688
+ tool_calls: calls,
689
+ free: test.free?.includes(m) ?? false,
690
+ cost: round(cost),
691
+ cost_per_task: mine.length ? round(cost / mine.length, 3) : null,
692
+ minutes_per_task: mine.length ? round(mean(mine.map((r) => r.s.seconds ?? 0)) / 60, 1) : null,
693
+ };
694
+ }
695
+ // The page lists the models by research score (the models that did not work last), then by name.
696
+ const order = Object.keys(models).sort((x, y) => Number(models[y].works) - Number(models[x].works) || (models[y].research ?? 0) - (models[x].research ?? 0) || x.localeCompare(y));
697
+ const f1s = order.filter((m) => models[m].f1 !== null).map((m) => models[m].f1 as number);
698
+ out[test.id] = { runs: test.runs, judging: test.judging, order, f1_low: f1s.length ? Math.min(...f1s) : null, f1_high: f1s.length ? Math.max(...f1s) : null, models };
699
+ }
700
+ return out;
701
+ }
702
+
703
+ // ---------------------------------------------------------------- calibration of the judge
704
+
705
+ /** The file a run wrote for a research task (the note, the synthesis, the search list or the review). */
706
+ export function outputFile(runDir: string): string | undefined {
707
+ if (!existsSync(runDir)) return undefined;
708
+ const md = readdirSync(runDir).filter((f) => f.endsWith(".md")).sort();
709
+ return md.length ? join(runDir, md[0]) : undefined;
710
+ }
711
+
712
+ /** A small random generator with a seed, so that a sheet can be made again the same way. */
713
+ function seeded(seed: number): () => number {
714
+ let x = seed >>> 0 || 1;
715
+ return () => ((x = (x * 1664525 + 1013904223) >>> 0) / 2 ** 32);
716
+ }
717
+
718
+ /**
719
+ * A blind scoring sheet for people: n outputs from a judging, balanced over the tasks, under new
720
+ * labels, without the judge's scores or the model names. sheet-key.json maps the new labels to the
721
+ * judge's; keep it away from the people who score.
722
+ */
723
+ export function makeSheet(root: string, judging: string, outDir: string, n = 12, seed = 1): number {
724
+ const judged: Judged[] = JSON.parse(readFileSync(join(root, "judging", judging, "scores.json"), "utf8")).scores;
725
+ const jkey: Record<string, { run: string; task: string }> = JSON.parse(readFileSync(join(root, "judging", judging, "key.json"), "utf8"));
726
+ const rand = seeded(seed);
727
+ const tasks = [...new Set(judged.map((j) => j.task))];
728
+ const pick: Judged[] = [];
729
+ const pools = tasks.map((t) => judged.filter((j) => j.task === t && outputFile(join(root, jkey[j.label].run, "runs", "", ""))!== null).sort(() => rand() - 0.5));
730
+ for (let i = 0; pick.length < n && pools.some((p) => p.length); i++) {
731
+ const p = pools[i % pools.length];
732
+ const j = p.shift();
733
+ if (!j) continue;
734
+ const [dir, code] = jkey[j.label].run.split("/");
735
+ if (outputFile(join(root, dir, "runs", code, j.task))) pick.push(j);
736
+ }
737
+ mkdirSync(outDir, { recursive: true });
738
+ // A judge's label can name a run with several tasks: the key keeps the label and the task.
739
+ const key: Record<string, { label: string; task: string }> = {};
740
+ const rows = ["label,task,score,invented_refs,notes"];
741
+ const used = new Set<string>();
742
+ for (const j of pick.sort(() => rand() - 0.5)) {
743
+ let label: string;
744
+ do label = `H${Math.floor(100 + rand() * 900)}`;
745
+ while (used.has(label));
746
+ used.add(label);
747
+ key[label] = { label: j.label, task: j.task };
748
+ const [dir, code] = jkey[j.label].run.split("/");
749
+ const file = outputFile(join(root, dir, "runs", code, j.task))!;
750
+ writeFileSync(join(outDir, `${label}.md`), readFileSync(file, "utf8"));
751
+ rows.push(`${label},${j.task},,,`);
752
+ }
753
+ writeFileSync(join(outDir, "scores.csv"), `${rows.join("\n")}\n`);
754
+ writeFileSync(join(outDir, "sheet-key.json"), JSON.stringify({ judging, key }, null, 1));
755
+ writeFileSync(join(outDir, "README.md"), [
756
+ "# Scoring sheet",
757
+ "",
758
+ "Score each file from 1 to 5 (halves allowed), without looking at the other scorers' sheets.",
759
+ "",
760
+ "- 5: every claim has a source the note read; the items are covered; quotes and numbers are right.",
761
+ "- 3: mostly right, with claims without a source or items left out.",
762
+ "- 1: wrong paper, invented references, or claims the sources do not make.",
763
+ "",
764
+ "Count invented references: a citekey not in the library, or a DOI or PMID that does not exist.",
765
+ "Write your scores in a copy of scores.csv named scores-<your initials>.csv. Do not open sheet-key.json.",
766
+ "Then: subsub-bench agreement --sheet <this folder>",
767
+ "",
768
+ ].join("\n"));
769
+ return pick.length;
770
+ }
771
+
772
+ /** Quadratic-weighted kappa on the half-point scale 1, 1.5, ... 5 (0 = chance agreement, 1 = the same scores). */
773
+ export function weightedKappa(a: number[], b: number[]): number {
774
+ const k = 9;
775
+ const idx = (x: number) => Math.min(k - 1, Math.max(0, Math.round((x - 1) * 2)));
776
+ const obs = Array.from({ length: k }, () => new Array(k).fill(0));
777
+ for (let i = 0; i < a.length; i++) obs[idx(a[i])][idx(b[i])]++;
778
+ const ra = obs.map((r) => r.reduce((x, y) => x + y, 0));
779
+ const cb = obs[0].map((_, j) => obs.reduce((x, r) => x + r[j], 0));
780
+ let num = 0;
781
+ let den = 0;
782
+ for (let i = 0; i < k; i++)
783
+ for (let j = 0; j < k; j++) {
784
+ const w = ((i - j) / (k - 1)) ** 2;
785
+ num += w * obs[i][j];
786
+ den += (w * ra[i] * cb[j]) / a.length;
787
+ }
788
+ return den ? round(1 - num / den, 3) : 1;
789
+ }
790
+
791
+ /** Agreement between each scorer and the judge, and between scorers. */
792
+ export function agreement(root: string, sheetDir: string): Array<{ a: string; b: string; n: number; mean_abs_diff: number; within_half: number; kappa: number }> {
793
+ const { judging, key } = JSON.parse(readFileSync(join(sheetDir, "sheet-key.json"), "utf8")) as { judging: string; key: Record<string, { label: string; task: string }> };
794
+ const judged: Judged[] = JSON.parse(readFileSync(join(root, "judging", judging, "scores.json"), "utf8")).scores;
795
+ const raters: Record<string, Record<string, number>> = { judge: {} };
796
+ for (const [h, j] of Object.entries(key)) {
797
+ const s = judged.find((x) => x.label === j.label && x.task === j.task)?.score;
798
+ if (s !== undefined) raters.judge[h] = s;
799
+ }
800
+ for (const f of readdirSync(sheetDir).filter((f) => /^scores-.+\.csv$/.test(f))) {
801
+ const name = f.replace(/^scores-|\.csv$/g, "");
802
+ raters[name] = {};
803
+ for (const line of readFileSync(join(sheetDir, f), "utf8").split("\n").slice(1)) {
804
+ const [label, , score] = line.split(",");
805
+ if (label && score && !Number.isNaN(Number(score))) raters[name][label.trim()] = Number(score);
806
+ }
807
+ }
808
+ const names = Object.keys(raters);
809
+ const out = [];
810
+ for (let i = 0; i < names.length; i++)
811
+ for (let j = i + 1; j < names.length; j++) {
812
+ const common = Object.keys(raters[names[i]]).filter((l) => l in raters[names[j]]);
813
+ if (!common.length) continue;
814
+ const x = common.map((l) => raters[names[i]][l]);
815
+ const y = common.map((l) => raters[names[j]][l]);
816
+ const d = x.map((v, k) => Math.abs(v - y[k]));
817
+ out.push({ a: names[i], b: names[j], n: common.length, mean_abs_diff: round(mean(d)), within_half: round(d.filter((v) => v <= 0.5).length / d.length), kappa: weightedKappa(x, y) });
818
+ }
819
+ return out;
820
+ }
821
+
535
822
  // ---------------------------------------------------------------- CLI
536
823
 
537
824
  function arg(argv: string[], name: string): string | undefined {
@@ -626,22 +913,52 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
626
913
  const t: Tasks = JSON.parse(readFileSync(join(dir, "tasks.json"), "utf8"));
627
914
  const index: IndexRow[] = JSON.parse(readFileSync(join(dir, "library-index.json"), "utf8"));
628
915
  const key: Record<string, string> = JSON.parse(readFileSync(join(dir, "key.json"), "utf8"));
629
- const blind = existsSync(join(dir, "reference.json")) ? JSON.parse(readFileSync(join(dir, "reference.json"), "utf8")) : {};
916
+ const refs: Record<string, Record<string, string[]>> = {};
917
+ for (const [name, file] of [["blind", "reference.json"], ["library", "library-tags-reference.json"]])
918
+ if (existsSync(join(dir, file))) refs[name] = JSON.parse(readFileSync(join(dir, file), "utf8"));
630
919
  const rows: Array<{ model: string; role: Mode; task: string; s: Record<string, any> }> = [];
631
- const scorer = starbuckScorer(cfg);
920
+ // --no-starbuck keeps the reference checks of the last score (they are slow and need the network).
921
+ const previous: Array<{ model: string; task: string; s: Record<string, any> }> =
922
+ argv.includes("--no-starbuck") && existsSync(join(dir, "results.json")) ? JSON.parse(readFileSync(join(dir, "results.json"), "utf8")) : [];
923
+ const scorer = argv.includes("--no-starbuck") ? undefined : starbuckScorer(cfg);
632
924
  for (const code of Object.keys(key).sort()) {
633
925
  for (const role of ["librarian", "researcher"] as Mode[])
634
926
  for (const task of TASKS[role]) {
635
927
  const d = join(dir, "runs", code, task);
636
928
  if (!existsSync(join(d, "summary.json"))) continue;
637
- rows.push({ model: key[code], role, task, s: scoreRun(task, d, t, index, blind, scorer) });
929
+ const kept = previous.find((r) => r.model === key[code] && r.task === task)?.s.starbuck;
930
+ rows.push({ model: key[code], role, task, s: scoreRun(task, d, t, index, refs, scorer ?? (kept ? () => kept : undefined)) });
638
931
  }
639
932
  }
640
933
  writeFileSync(join(dir, "results.json"), JSON.stringify(rows, null, 1));
641
934
  writeFileSync(join(dir, "results.md"), renderResults(rows));
935
+ for (const w of warnings(rows)) console.warn(`Warning: ${w}`);
642
936
  console.log(`Wrote ${join(dir, "results.md")}`);
643
937
  return;
644
938
  }
645
- console.log("Usage: subsub-bench prepare | run [--models a,b] [--librarian a,b] [--researcher c,d] [--only task] [--parallel 3] | score [--dir DIR]");
939
+ if (cmd === "summary") {
940
+ const root = join(PACKAGE_DIR, "bench-results");
941
+ const file = arg(argv, "out") ?? join(PACKAGE_DIR, "site", "src", "_data", "modeltest.json");
942
+ writeFileSync(file, `${JSON.stringify(summarise(root), null, 1)}\n`);
943
+ console.log(`Wrote ${file}`);
944
+ return;
945
+ }
946
+ if (cmd === "sheet") {
947
+ const out = expand(arg(argv, "out") ?? join(process.cwd(), "scoring-sheet"));
948
+ const n = makeSheet(join(PACKAGE_DIR, "bench-results"), arg(argv, "judging") ?? "2026-10-07-repeats", out, Number(arg(argv, "n") ?? 12), Number(arg(argv, "seed") ?? 1));
949
+ console.log(`Wrote a sheet of ${n} outputs to ${out}. Give the scorers the .md files, README.md and scores.csv, not sheet-key.json.`);
950
+ return;
951
+ }
952
+ if (cmd === "agreement") {
953
+ const sheet = arg(argv, "sheet");
954
+ if (!sheet) throw new Error("Give the sheet folder: subsub-bench agreement --sheet DIR");
955
+ const rows = agreement(join(PACKAGE_DIR, "bench-results"), expand(sheet));
956
+ writeFileSync(join(expand(sheet), "agreement.json"), JSON.stringify(rows, null, 1));
957
+ console.log("| a | b | items | mean difference | within 0.5 | weighted kappa |\n|---|---|---|---|---|---|");
958
+ for (const r of rows) console.log(`| ${r.a} | ${r.b} | ${r.n} | ${r.mean_abs_diff} | ${r.within_half} | ${r.kappa} |`);
959
+ if (!rows.length) console.log("No scores yet: add scores-<initials>.csv files to the sheet folder.");
960
+ return;
961
+ }
962
+ console.log("Usage: subsub-bench prepare | run [--models a,b] [--librarian a,b] [--researcher c,d] [--only task] [--parallel 3] | score [--no-starbuck] | summary | sheet [--judging ID] [--n 12] [--out DIR] | agreement --sheet DIR [--dir DIR]");
646
963
  }
647
964
 
package/src/notes.ts CHANGED
@@ -44,3 +44,92 @@ export function applyEdits(current: string, edits: Array<{ oldText?: unknown; ne
44
44
  }
45
45
  return out;
46
46
  }
47
+
48
+ // ---------------------------------------------------------------- quotes against the full text
49
+
50
+ /** The Zotero item key in a note's front matter (zotero: zotero://select/library/items/KEY). */
51
+ export function itemKeyOf(text: string): string | undefined {
52
+ const front = frontMatter(text);
53
+ const z = front ? field(front, "zotero") : undefined;
54
+ return z ? /items\/([A-Z0-9]{8})\b/.exec(z)?.[1] : undefined;
55
+ }
56
+
57
+ /**
58
+ * Direct quotes in a note: text in double quotes, and blockquote lines, of 25 characters or more.
59
+ * Shorter quotes are usually terms, not passages, and are not checked.
60
+ */
61
+ export function quotesIn(text: string): string[] {
62
+ const body = text.replace(FRONT, "");
63
+ const out = new Set<string>();
64
+ // An opening mark follows a space or a bracket and a closing mark precedes one, so the text
65
+ // between one quote and the next ("a" (p. 3); then "b") is not taken for a quote.
66
+ for (const m of body.matchAll(/(?:^|[\s(\[—–])"([^"\s][^"\n]{23,598}[^"\s])"(?=[\s.,;:)\]!?]|$)/gm)) out.add(m[1].trim());
67
+ for (const m of body.matchAll(/“([^“”\n]{25,600})”/g)) out.add(m[1].trim());
68
+ for (const m of body.matchAll(/^>[ \t]*(.{25,})$/gm)) {
69
+ // A blockquote may end with its source: "... (p. 12)" or "... [@smith2026, p. 12]".
70
+ const q = m[1].replace(/\s*(?:\((?:p|pp)\.[^)]*\)|\[@[^\]]*\]|[-—–]\s*p\.?\s*\d+.*)\s*$/i, "").replace(/^["“]|["”]$/g, "").trim();
71
+ if (q.length >= 25) out.add(q);
72
+ }
73
+ return [...out];
74
+ }
75
+
76
+ /** Letters and digits only, lower case: PDF text breaks lines, hyphenates words and changes quote marks. */
77
+ export function matchable(s: string): string {
78
+ return s
79
+ .normalize("NFKD")
80
+ .replace(/[̀-ͯ]/g, "")
81
+ .replace(/(\w)-\s*\n\s*(\w)/g, "$1$2")
82
+ .toLowerCase()
83
+ .replace(/[^\p{L}\p{N}]+/gu, "");
84
+ }
85
+
86
+ /**
87
+ * The quotes that are not in the full text. A quote with an ellipsis is checked in parts;
88
+ * square brackets (an editor's insertion) are left out of the comparison.
89
+ */
90
+ export function missingQuotes(note: string, fullText: string, already: string[] = []): string[] {
91
+ const source = matchable(fullText);
92
+ const old = new Set(already);
93
+ return quotesIn(note)
94
+ .filter((q) => !old.has(q))
95
+ .filter((q) => {
96
+ const parts = q.split(/\s*(?:\.\.\.|…|\[\s*(?:\.\.\.|…)\s*\])\s*/).map((p) => p.replace(/\[[^\]]*\]/g, " "));
97
+ return parts.filter((p) => matchable(p).length >= 12).some((p) => !appearsIn(source, p));
98
+ });
99
+ }
100
+
101
+ /**
102
+ * Whether a passage is in the (matchable) source: whole, or as groups of three words in order with
103
+ * short gaps, because a PDF's running header or footer can fall in the middle of a sentence.
104
+ */
105
+ export function appearsIn(source: string, passage: string, gap = 300): boolean {
106
+ if (source.includes(matchable(passage))) return true;
107
+ const words = passage.split(/\s+/).map(matchable).filter(Boolean);
108
+ const groups: string[] = [];
109
+ for (let i = 0; i < words.length; i += 3) groups.push(words.slice(i, i + 3).join(""));
110
+ if (groups.length < 2) return false;
111
+ for (let start = source.indexOf(groups[0]), tries = 0; start >= 0 && tries < 50; start = source.indexOf(groups[0], start + 1), tries++) {
112
+ let end = start + groups[0].length;
113
+ let ok = true;
114
+ for (const g of groups.slice(1)) {
115
+ const at = source.indexOf(g, end);
116
+ if (at < 0 || at - end > gap) {
117
+ ok = false;
118
+ break;
119
+ }
120
+ end = at + g.length;
121
+ }
122
+ if (ok) return true;
123
+ }
124
+ return false;
125
+ }
126
+
127
+ export function quoteProblem(missing: string[]): string | undefined {
128
+ if (!missing.length) return undefined;
129
+ const shown = missing.slice(0, 5).map((q) => `- "${q.length > 160 ? `${q.slice(0, 160)}...` : q}"`);
130
+ return [
131
+ `${missing.length} quote(s) in this note are not in the item's full text:`,
132
+ ...shown,
133
+ "Copy each quote word for word from the full text (zotero_get_fulltext), or remove the quote marks and say it in your own words. Then write the note again.",
134
+ ].join("\n");
135
+ }
package/src/roles.ts CHANGED
@@ -116,3 +116,23 @@ export function gateKind(toolName: string, input: Record<string, unknown>): Gate
116
116
  if (toolName === "write" || toolName === "edit") return "path";
117
117
  return null;
118
118
  }
119
+
120
+ /** Tools that send a query to an outside service (PubMed, Europe PMC, OpenAlex, Crossref, a web search). */
121
+ const OUTBOUND = new Set(["scholar_search_pubmed", "scholar_search_openalex", "scholar_search_europepmc", "scholar_search_multi", "zotero_find_reference", "zotero_web_search"]);
122
+ export const MAX_QUERY_CHARS = 1500;
123
+
124
+ /**
125
+ * Why an outside search is refused: a query is search terms, so a query that is long or holds
126
+ * several lines is text from a note, a full text or the library, sent where it should not go
127
+ * (for example because a document asked for it).
128
+ */
129
+ export function outboundProblem(toolName: string, input: Record<string, unknown>): string | undefined {
130
+ if (!OUTBOUND.has(toolName)) return undefined;
131
+ const queries = [input.query, ...(Array.isArray(input.queries) ? input.queries : [])].filter((q): q is string => typeof q === "string");
132
+ for (const q of queries) {
133
+ if (q.length > MAX_QUERY_CHARS || q.split("\n").length > 3) {
134
+ return `This search query is ${q.length} characters on ${q.split("\n").length} lines. A query is search terms; send no passages of notes, full texts or library records to an outside service. Shorten it to the search terms.`;
135
+ }
136
+ }
137
+ return undefined;
138
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * /ai-statement: a draft of the statement on the use of AI that journals ask for (ICMJE and
3
+ * most publishers), with the facts Sub-Sub knows: its version, the models of each mode and their
4
+ * providers, and the checks the program makes. The authors complete the bracketed parts.
5
+ */
6
+
7
+ export const SUBSUB_DOI = "10.5281/zenodo.23238602";
8
+
9
+ export interface StatementFacts {
10
+ version: string;
11
+ models: Partial<Record<"librarian" | "researcher", string>>;
12
+ uses?: string;
13
+ date: string;
14
+ starbuck: boolean;
15
+ }
16
+
17
+ /** "opencode-go/mimo-v2.6-pro" -> "mimo-v2.6-pro (OpenCode Go)". */
18
+ export function modelLabel(spec: string): string {
19
+ const i = spec.indexOf("/");
20
+ if (i < 0) return spec;
21
+ const provider = spec.slice(0, i);
22
+ const names: Record<string, string> = { "opencode-go": "OpenCode Go", google: "Google", mistral: "Mistral AI", openai: "OpenAI", anthropic: "Anthropic", openrouter: "OpenRouter", deepseek: "DeepSeek" };
23
+ return `${spec.slice(i + 1)} (${names[provider] ?? provider})`;
24
+ }
25
+
26
+ export function aiStatement(f: StatementFacts): string {
27
+ const models = [...new Set(Object.values(f.models).filter(Boolean) as string[])].map(modelLabel);
28
+ const modelText = models.length ? `the language model${models.length > 1 ? "s" : ""} ${models.join(" and ")}` : "[the language model and its provider]";
29
+ const uses = f.uses?.trim() || "[the tasks: for example, literature searches, screening of search results, reading notes on included studies, checks of the references]";
30
+ return [
31
+ "## Use of AI tools",
32
+ "",
33
+ `We used Sub-Sub ${f.version} (Jacinto T., doi:${SUBSUB_DOI}), an open-source research assistant that works from a Zotero reference library, with ${modelText}, in [month and year]. We used it for ${uses}.`,
34
+ "",
35
+ "Sub-Sub records in each note whether it was written from the full text, the abstract or the bibliographic record, checks that quotations are in the full text, and makes no change to the reference library without the authors' approval." +
36
+ (f.starbuck ? " The cited works were checked for existence, bibliographic accuracy and retraction with Starbuck." : ""),
37
+ "",
38
+ "[Choose one: No text of this manuscript was written by an AI tool. | Sub-Sub drafted [which parts]; the authors revised and rewrote this text.]",
39
+ "",
40
+ "The authors checked every reference and every statement taken from the sources against the original publications, and take full responsibility for the content of this article.",
41
+ "",
42
+ "<!-- Draft made by Sub-Sub on " + f.date + ". Complete the parts in brackets, delete this line, and follow the journal's instructions on where the statement goes (often the Methods or the Acknowledgements). -->",
43
+ "",
44
+ ].join("\n");
45
+ }