@littlebigbrain/create 0.1.0 → 0.3.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 (56) hide show
  1. package/README.md +6 -2
  2. package/lib/create.mjs +33 -5
  3. package/package.json +1 -1
  4. package/templates/_shared/testing.ts +53 -9
  5. package/templates/documents-link/README.md +9 -7
  6. package/templates/documents-link/workflow.test.ts +28 -1
  7. package/templates/documents-link/workflow.ts +24 -14
  8. package/templates/documents-map/README.md +212 -0
  9. package/templates/documents-map/map.ts +1135 -0
  10. package/templates/documents-map/samples/ausstattungsliste.pdf +76 -0
  11. package/templates/documents-map/samples/baubeschreibung.pdf +54 -0
  12. package/templates/documents-map/samples/expected.json +50 -0
  13. package/templates/documents-map/samples/pages/raumblaetter.json +1580 -0
  14. package/templates/documents-map/samples/raumblaetter.pdf +109 -0
  15. package/templates/documents-map/samples/trace.json +613 -0
  16. package/templates/documents-map/samples.ts +182 -0
  17. package/templates/documents-map/workflow.test.ts +770 -0
  18. package/templates/documents-map/workflow.ts +1199 -0
  19. package/templates/link/README.md +203 -0
  20. package/templates/link/candidates.ts +432 -0
  21. package/templates/link/decide.test.ts +368 -0
  22. package/templates/link/decide.ts +340 -0
  23. package/templates/link/fixture.ts +727 -0
  24. package/templates/link/grade-run.ts +53 -0
  25. package/templates/link/grade.test.ts +113 -0
  26. package/templates/link/grade.ts +335 -0
  27. package/templates/link/items.ts +333 -0
  28. package/templates/link/names.test.ts +260 -0
  29. package/templates/link/names.ts +590 -0
  30. package/templates/link/reads.ts +302 -0
  31. package/templates/link/samples/gold.json +108 -0
  32. package/templates/link/samples/records.json +48 -0
  33. package/templates/link/samples.ts +64 -0
  34. package/templates/link/settings.ts +162 -0
  35. package/templates/link/setup.ts +18 -0
  36. package/templates/link/source-turn.ts +894 -0
  37. package/templates/link/sources/records.ts +429 -0
  38. package/templates/link/things-turn.ts +984 -0
  39. package/templates/link/things.test.ts +204 -0
  40. package/templates/link/things.ts +426 -0
  41. package/templates/link/try.ts +28 -0
  42. package/templates/link/worker.ts +24 -0
  43. package/templates/link/workflow.test.ts +484 -0
  44. package/templates/link/workflow.ts +202 -0
  45. package/templates/ontology-fit/workflow.test.ts +7 -1
  46. package/templates/ontology-fit/workflow.ts +5 -2
  47. package/templates/review/README.md +158 -0
  48. package/templates/review/decision.ts +483 -0
  49. package/templates/review/samples/expected.json +37 -0
  50. package/templates/review/samples/proposals.json +35 -0
  51. package/templates/review/samples.ts +154 -0
  52. package/templates/review/workflow.test.ts +438 -0
  53. package/templates/review/workflow.ts +459 -0
  54. package/templates/values-extract/workflow.test.ts +24 -1
  55. package/templates/values-extract/workflow.ts +11 -3
  56. package/templates/versions.json +1 -1
@@ -0,0 +1,53 @@
1
+ // Grade a run of the link workflow against a gold file: link precision at
2
+ // the gate and recall, cluster purity and completeness, the review share,
3
+ // and identity precision (general linking 9.2). The gold file's format is in
4
+ // grade.ts.
5
+ //
6
+ // A run on a graph of your stack (it reads the mentions with SPARQL and the
7
+ // workflow's review items):
8
+ //
9
+ // LBB_URL=... LBB_API_KEY=... LBB_GRAPH=... npx tsx src/grade-run.ts --gold <gold.json> [--name link] [--save <run.json>]
10
+ //
11
+ // A run saved before:
12
+ //
13
+ // npx tsx src/grade-run.ts --gold <gold.json> --run <run.json>
14
+ import { readFile, writeFile } from "node:fs/promises";
15
+ import { LbbClient, lbbActivities, type Schemas } from "@littlebigbrain/client";
16
+ import { formatReport, grade, readGraphRun, reviewOfItems, type Gold, type Run } from "./grade.js";
17
+
18
+ const args = process.argv.slice(2);
19
+ const option = (name: string) => {
20
+ const at = args.indexOf(`--${name}`);
21
+ return at < 0 ? undefined : args[at + 1];
22
+ };
23
+ const goldFile = option("gold");
24
+ if (!goldFile)
25
+ throw new Error("--gold <file> is required: the gold groups (format lbb.link.gold.v1)");
26
+ const gold = JSON.parse(await readFile(goldFile, "utf8")) as Gold;
27
+ if (gold.format !== "lbb.link.gold.v1") throw new Error(`${goldFile} is no lbb.link.gold.v1 file`);
28
+
29
+ let run: Run;
30
+ const runFile = option("run");
31
+ if (runFile) run = JSON.parse(await readFile(runFile, "utf8")) as Run;
32
+ else {
33
+ const graph = process.env.LBB_GRAPH ?? "main";
34
+ const client = new LbbClient({
35
+ baseUrl: process.env.LBB_URL ?? "http://127.0.0.1:7400",
36
+ apiKey: process.env.LBB_API_KEY,
37
+ graph,
38
+ });
39
+ const name = option("name") ?? "link";
40
+ const graphRun = await readGraphRun(lbbActivities(client));
41
+ const list = await client.ontology.suggestions.list({ originId: `starter:${name}`, limit: 500 });
42
+ const items: Schemas["OntologyChangeSuggestion"][] = [];
43
+ for (const summary of list.suggestions)
44
+ items.push(await client.ontology.suggestions.get(summary.suggestion_id));
45
+ if (list.truncated)
46
+ console.log(
47
+ "The graph holds more review items than one read lists (500): the review share and identities count those read.",
48
+ );
49
+ run = { ...graphRun, ...reviewOfItems(items) };
50
+ const save = option("save");
51
+ if (save) await writeFile(save, `${JSON.stringify(run, null, 2)}\n`);
52
+ }
53
+ console.log(formatReport(grade(gold, run), `link run graded against ${goldFile}`));
@@ -0,0 +1,113 @@
1
+ // The grading script's metrics (general linking 9.2) on a run small enough
2
+ // to count by hand.
3
+ import { test } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { formatReport, grade, wilsonLow, type Gold, type Run } from "./grade.js";
6
+
7
+ const gold: Gold = {
8
+ format: "lbb.link.gold.v1",
9
+ records: {
10
+ contact: "e:contact",
11
+ lead: "e:lead",
12
+ deal: "e:deal",
13
+ note: "e:note",
14
+ other: "e:other",
15
+ },
16
+ groups: [
17
+ {
18
+ id: "mira",
19
+ mentions: [
20
+ { record: "contact", field: "name", span: "Mira Tannenholt-Ruiz" },
21
+ { record: "lead", field: "first_name+last_name", span: "Mira Ruiz" },
22
+ { record: "deal", field: "name", span: "M. Tannenholt" },
23
+ { record: "note", field: "body", span: "Mimi T." },
24
+ ],
25
+ },
26
+ {
27
+ id: "kim",
28
+ mentions: [
29
+ { record: "other", field: "name", span: "Kim Oak" },
30
+ { record: "note", field: "body", span: "K. Oak" },
31
+ ],
32
+ },
33
+ ],
34
+ };
35
+
36
+ const run: Run = {
37
+ mentions: [
38
+ {
39
+ iri: "m1",
40
+ record: "e:contact",
41
+ field: "name",
42
+ span: "Mira Tannenholt-Ruiz",
43
+ role: "self",
44
+ thing: "t1",
45
+ },
46
+ {
47
+ iri: "m2",
48
+ record: "e:lead",
49
+ field: "first_name+last_name",
50
+ span: "Mira Ruiz",
51
+ role: "self",
52
+ thing: "t2",
53
+ },
54
+ {
55
+ iri: "m3",
56
+ record: "e:deal",
57
+ field: "name",
58
+ span: "M. Tannenholt",
59
+ role: "refers",
60
+ thing: "t1",
61
+ },
62
+ { iri: "m4", record: "e:note", field: "body", span: "Mimi T.", role: "refers", thing: null },
63
+ { iri: "m5", record: "e:other", field: "name", span: "Kim Oak", role: "self", thing: "t3" },
64
+ // A wrong link: K. Oak in Mira's thing. And a mention the gold does not list.
65
+ { iri: "m6", record: "e:note", field: "body", span: "K. Oak", role: "refers", thing: "t1" },
66
+ { iri: "m7", record: "e:note", field: "body", span: "Lena Fair", role: "refers", thing: null },
67
+ ],
68
+ links: [
69
+ { a: "m3", b: "m1", confidence: 0.9 },
70
+ { a: "m6", b: "m1", confidence: 0.85 },
71
+ { a: "m7", b: "m5", confidence: 0.81 },
72
+ ],
73
+ review: [{ a: "m4", b: "m3", confidence: 0.6, kind: "link" }],
74
+ identities: [
75
+ { records: ["e:contact", "e:lead"], confidence: 1 },
76
+ { records: ["e:contact", "e:other"], confidence: 0.9 },
77
+ ],
78
+ };
79
+
80
+ test("the metrics of a run against its gold", () => {
81
+ const report = grade(gold, run);
82
+ assert.deepEqual(report.mentions, { run: 7, gold: 6, matched: 6, unjudged: 1 });
83
+ // m3-m1 right, m6-m1 wrong; m7-m5 is unjudged (m7 is not in the gold, and the gold is not complete).
84
+ assert.deepEqual([report.links.right, report.links.total, report.links.unjudged], [1, 2, 1]);
85
+ // Things: t1 = {m1, m3, m6}: pairs m1-m3 (right), m1-m6, m3-m6 (wrong). Gold pairs: 6 of Mira, 1 of Kim.
86
+ assert.deepEqual([report.pairs.precision.right, report.pairs.precision.total], [1, 3]);
87
+ assert.deepEqual([report.pairs.recall.right, report.pairs.recall.total], [1, 7]);
88
+ // B-cubed purity: m1, m3: 2/3; m6: 1/3; m2, m4, m5 alone: 1 → (2/3 + 2/3 + 1/3 + 1 + 1 + 1) / 6.
89
+ assert.equal(report.purity, Math.round((((2 / 3) * 2 + 1 / 3 + 3) / 6) * 10_000) / 10_000);
90
+ // Completeness: m1, m3: 2/4; m2, m4: 1/4; m5: 1/2; m6: 1/2.
91
+ assert.equal(
92
+ report.completeness,
93
+ Math.round((((2 / 4) * 2 + (1 / 4) * 2 + 1 / 2 + 1 / 2) / 6) * 10_000) / 10_000,
94
+ );
95
+ // Linkable: every matched mention (all groups have two or more); m4 has an open row.
96
+ assert.deepEqual([report.reviewShare.right, report.reviewShare.total], [1, 6]);
97
+ // Identities: contact-lead right, contact-other wrong; the gold's one record pair is found.
98
+ assert.deepEqual([report.identities.precision.right, report.identities.precision.total], [1, 2]);
99
+ assert.deepEqual([report.identities.recall.right, report.identities.recall.total], [1, 1]);
100
+ assert.match(formatReport(report), /link precision at the gate: 0.5 \(1 of 2/);
101
+ });
102
+
103
+ test("with a complete gold, a mention it does not list is a thing of its own", () => {
104
+ const report = grade({ ...gold, complete: true }, run);
105
+ assert.deepEqual([report.links.right, report.links.total, report.links.unjudged], [1, 3, 0]);
106
+ });
107
+
108
+ test("the 95% Wilson interval starts where the bars read it", () => {
109
+ assert.equal(wilsonLow(19, 19), 0.8318);
110
+ assert.equal(wilsonLow(95, 100), 0.8882);
111
+ assert.equal(wilsonLow(0, 10), 0);
112
+ assert.equal(wilsonLow(0, 0), null);
113
+ });
@@ -0,0 +1,335 @@
1
+ // Grade a run of the link workflow against a gold file (general linking 9.2):
2
+ // link precision at the gate and recall, pairwise over things, cluster
3
+ // purity and completeness (B-cubed), the review share, and identity
4
+ // precision. Pure functions, and the reads that take a run from a graph.
5
+ //
6
+ // The gold file (`lbb.link.gold.v1`):
7
+ //
8
+ // {
9
+ // "format": "lbb.link.gold.v1",
10
+ // "complete": true, // optional: a mention the gold does not list is a thing of its own
11
+ // "records": { "person-mira": "https://littlebigbrain.com/e/…" }, // optional names for record IRIs
12
+ // "groups": [
13
+ // { "id": "mira", "kind": "person", "mentions": [
14
+ // { "record": "person-mira", "field": "name", "span": "Mira Tannenholt-Ruiz" },
15
+ // { "record": "<a record IRI>", "field": "body", "span": "Mimi T.", "occurrence": 0 }
16
+ // ] }
17
+ // ]
18
+ // }
19
+ //
20
+ // A gold mention names its record (an IRI, or a name of `records`), the
21
+ // field (the property name; `first_name+last_name` for a name joined from a
22
+ // given and a family field), the span as the field writes it, and which
23
+ // equal span of the field it is (`occurrence`, default 0). Without
24
+ // `complete`, a mention the gold does not list is unjudged: a link to it
25
+ // counts apart.
26
+ import type { LbbActivities, Schemas } from "@littlebigbrain/client";
27
+ import { CLASS_IRI, P, PROPERTY_IRI } from "./candidates.js";
28
+ import { iriOfRef, itemKind } from "./items.js";
29
+ import { fold } from "./names.js";
30
+
31
+ export interface GoldMention {
32
+ record: string;
33
+ field: string;
34
+ span: string;
35
+ occurrence?: number;
36
+ }
37
+ export interface Gold {
38
+ format: "lbb.link.gold.v1";
39
+ about?: string;
40
+ complete?: boolean;
41
+ records?: Record<string, string>;
42
+ groups: { id: string; kind?: string; mentions: GoldMention[] }[];
43
+ }
44
+
45
+ /** A mention of a run, as the graph holds it. */
46
+ export interface RunMention {
47
+ iri: string;
48
+ record: string;
49
+ field: string;
50
+ span: string;
51
+ role: string;
52
+ thing: string | null;
53
+ }
54
+ /** A `SAME_THING_AS` of the graph: a written link. */
55
+ export interface RunLink {
56
+ a: string;
57
+ b: string;
58
+ confidence: number | null;
59
+ }
60
+ /** A row of an open review item: a link a person decides. */
61
+ export interface RunReview {
62
+ a: string;
63
+ b: string;
64
+ confidence: number;
65
+ kind: "link" | "nolink";
66
+ }
67
+ export interface RunIdentity {
68
+ records: string[];
69
+ confidence: number;
70
+ }
71
+ export interface Run {
72
+ mentions: RunMention[];
73
+ links: RunLink[];
74
+ review: RunReview[];
75
+ identities: RunIdentity[];
76
+ }
77
+
78
+ export interface Ratio {
79
+ right: number;
80
+ total: number;
81
+ value: number | null;
82
+ /** The lower end of the 95% Wilson interval. */
83
+ wilsonLow: number | null;
84
+ }
85
+
86
+ export interface Report {
87
+ mentions: { run: number; gold: number; matched: number; unjudged: number };
88
+ /** Written links (edges): right over written; unjudged ones count apart. */
89
+ links: Ratio & { unjudged: number };
90
+ /** Pairs of mentions in one thing: precision and recall against the gold groups. */
91
+ pairs: { precision: Ratio; recall: Ratio };
92
+ /** B-cubed over the gold mentions. */
93
+ purity: number | null;
94
+ completeness: number | null;
95
+ /** Mentions with an open review row over the linkable mentions. */
96
+ reviewShare: Ratio;
97
+ /** Proposed record pairs that the gold puts in one group. */
98
+ identities: { precision: Ratio; recall: Ratio };
99
+ }
100
+
101
+ /** The lower end of the 95% Wilson interval of `right` out of `total`. */
102
+ export function wilsonLow(right: number, total: number, z = 1.96): number | null {
103
+ if (!total) return null;
104
+ const p = right / total;
105
+ const center = p + (z * z) / (2 * total);
106
+ const margin = z * Math.sqrt((p * (1 - p)) / total + (z * z) / (4 * total * total));
107
+ return Math.max(0, round((center - margin) / (1 + (z * z) / total))) || 0;
108
+ }
109
+
110
+ const round = (x: number) => Math.round(x * 10_000) / 10_000;
111
+ const ratio = (right: number, total: number): Ratio => ({
112
+ right,
113
+ total,
114
+ value: total ? round(right / total) : null,
115
+ wilsonLow: wilsonLow(right, total),
116
+ });
117
+
118
+ /** The local name of a field: the end of a property IRI, or the name as it is. */
119
+ const fieldName = (field: string) =>
120
+ field.startsWith(PROPERTY_IRI) ? decodeURIComponent(field.slice(PROPERTY_IRI.length)) : field;
121
+ const spanKey = (span: string) => fold(span).replace(/\s+/g, " ").trim();
122
+ const pairKey = (a: string, b: string) => (a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`);
123
+
124
+ /** Grade a run against the gold. */
125
+ export function grade(gold: Gold, run: Run): Report {
126
+ const recordOf = (ref: string) => gold.records?.[ref] ?? ref;
127
+ // Match each gold mention to a run mention: record, field, span, occurrence.
128
+ const byPlace = new Map<string, RunMention[]>();
129
+ for (const m of run.mentions) {
130
+ const key = `${m.record}\u0000${fieldName(m.field)}\u0000${spanKey(m.span)}`;
131
+ (byPlace.get(key) ?? byPlace.set(key, []).get(key)!).push(m);
132
+ }
133
+ for (const list of byPlace.values()) list.sort((a, b) => (a.iri < b.iri ? -1 : 1));
134
+ const groupOf = new Map<string, string>();
135
+ const goldMentions: { group: string; iri: string | null }[] = [];
136
+ for (const g of gold.groups)
137
+ for (const m of g.mentions) {
138
+ const list =
139
+ byPlace.get(`${recordOf(m.record)}\u0000${m.field}\u0000${spanKey(m.span)}`) ?? [];
140
+ const found = list.find((r) => !groupOf.has(r.iri) && list.indexOf(r) >= (m.occurrence ?? 0));
141
+ if (found) groupOf.set(found.iri, g.id);
142
+ goldMentions.push({ group: g.id, iri: found?.iri ?? null });
143
+ }
144
+ const known = (iri: string) => groupOf.has(iri) || gold.complete === true;
145
+ const goldGroup = (iri: string) => groupOf.get(iri) ?? `alone:${iri}`;
146
+ const unjudged = run.mentions.filter((m) => !groupOf.has(m.iri)).length;
147
+
148
+ // Written links.
149
+ let right = 0;
150
+ let wrong = 0;
151
+ let open = 0;
152
+ const edges = new Set<string>();
153
+ for (const l of run.links) {
154
+ const key = pairKey(l.a, l.b);
155
+ if (l.a === l.b || edges.has(key)) continue;
156
+ edges.add(key);
157
+ if (!known(l.a) || !known(l.b)) open += 1;
158
+ else if (goldGroup(l.a) === goldGroup(l.b)) right += 1;
159
+ else wrong += 1;
160
+ }
161
+
162
+ // Pairs in one thing: the run's clusters are its things, and each mention without a thing alone.
163
+ const clusterOf = new Map(run.mentions.map((m) => [m.iri, m.thing ?? `alone:${m.iri}`]));
164
+ const judged = run.mentions.filter((m) => known(m.iri));
165
+ const clusters = new Map<string, string[]>();
166
+ for (const m of judged) {
167
+ const c = clusterOf.get(m.iri)!;
168
+ (clusters.get(c) ?? clusters.set(c, []).get(c)!).push(m.iri);
169
+ }
170
+ let runPairs = 0;
171
+ let rightPairs = 0;
172
+ for (const members of clusters.values())
173
+ for (let i = 0; i < members.length; i++)
174
+ for (let j = i + 1; j < members.length; j++) {
175
+ runPairs += 1;
176
+ if (goldGroup(members[i]!) === goldGroup(members[j]!)) rightPairs += 1;
177
+ }
178
+ const sizes = new Map<string, number>();
179
+ for (const g of goldMentions) sizes.set(g.group, (sizes.get(g.group) ?? 0) + 1);
180
+ const goldPairs = [...sizes.values()].reduce((sum, n) => sum + (n * (n - 1)) / 2, 0);
181
+
182
+ // B-cubed over the gold mentions.
183
+ let purity = 0;
184
+ let completeness = 0;
185
+ for (const g of goldMentions) {
186
+ const size = sizes.get(g.group)!;
187
+ if (!g.iri) {
188
+ purity += 1;
189
+ completeness += 1 / size;
190
+ continue;
191
+ }
192
+ const members = clusters.get(clusterOf.get(g.iri)!) ?? [g.iri];
193
+ const same = members.filter((m) => goldGroup(m) === g.group).length;
194
+ purity += same / members.length;
195
+ completeness += same / size;
196
+ }
197
+
198
+ // The review share: mentions with an open row, over the mentions the gold links to another.
199
+ const linkable = new Set(
200
+ run.mentions
201
+ .filter((m) => groupOf.has(m.iri) && (sizes.get(groupOf.get(m.iri)!) ?? 0) > 1)
202
+ .map((m) => m.iri),
203
+ );
204
+ const reviewed = new Set(run.review.map((r) => r.a).filter((a) => linkable.has(a)));
205
+
206
+ // Identities: proposed record pairs, and the gold's record pairs.
207
+ const recordGroups = new Map<string, Set<string>>();
208
+ for (const m of run.mentions)
209
+ if (m.role === "self" && groupOf.has(m.iri))
210
+ (recordGroups.get(m.record) ?? recordGroups.set(m.record, new Set()).get(m.record)!).add(
211
+ groupOf.get(m.iri)!,
212
+ );
213
+ const sameRecord = (a: string, b: string) =>
214
+ [...(recordGroups.get(a) ?? [])].some((g) => recordGroups.get(b)?.has(g));
215
+ const proposed = new Set<string>();
216
+ for (const identity of run.identities)
217
+ for (let i = 0; i < identity.records.length; i++)
218
+ for (let j = i + 1; j < identity.records.length; j++)
219
+ proposed.add(pairKey(identity.records[i]!, identity.records[j]!));
220
+ const proposedRight = [...proposed].filter((p) => {
221
+ const [a, b] = p.split("\u0000") as [string, string];
222
+ return sameRecord(a, b);
223
+ }).length;
224
+ const records = [...recordGroups.keys()].sort();
225
+ let goldRecordPairs = 0;
226
+ let foundRecordPairs = 0;
227
+ for (let i = 0; i < records.length; i++)
228
+ for (let j = i + 1; j < records.length; j++)
229
+ if (sameRecord(records[i]!, records[j]!)) {
230
+ goldRecordPairs += 1;
231
+ if (proposed.has(pairKey(records[i]!, records[j]!))) foundRecordPairs += 1;
232
+ }
233
+
234
+ return {
235
+ mentions: {
236
+ run: run.mentions.length,
237
+ gold: goldMentions.length,
238
+ matched: goldMentions.filter((g) => g.iri).length,
239
+ unjudged,
240
+ },
241
+ links: { ...ratio(right, right + wrong), unjudged: open },
242
+ pairs: { precision: ratio(rightPairs, runPairs), recall: ratio(rightPairs, goldPairs) },
243
+ purity: goldMentions.length ? round(purity / goldMentions.length) : null,
244
+ completeness: goldMentions.length ? round(completeness / goldMentions.length) : null,
245
+ reviewShare: ratio(reviewed.size, linkable.size),
246
+ identities: {
247
+ precision: ratio(proposedRight, proposed.size),
248
+ recall: ratio(foundRecordPairs, goldRecordPairs),
249
+ },
250
+ };
251
+ }
252
+
253
+ /** The report as lines of text. */
254
+ export function formatReport(report: Report, title = "link run"): string {
255
+ const show = (r: Ratio) =>
256
+ r.value === null
257
+ ? "n/a (0 of 0)"
258
+ : `${r.value} (${r.right} of ${r.total}, Wilson low ${r.wilsonLow})`;
259
+ return [
260
+ `# ${title}`,
261
+ `mentions: ${report.mentions.run} in the run, ${report.mentions.matched} of ${report.mentions.gold} gold mentions found, ${report.mentions.unjudged} not in the gold`,
262
+ `link precision at the gate: ${show(report.links)}${report.links.unjudged ? `, ${report.links.unjudged} unjudged` : ""}`,
263
+ `pairwise precision: ${show(report.pairs.precision)}`,
264
+ `pairwise recall: ${show(report.pairs.recall)}`,
265
+ `cluster purity (B-cubed precision): ${report.purity}`,
266
+ `cluster completeness (B-cubed recall): ${report.completeness}`,
267
+ `review share: ${show(report.reviewShare)}`,
268
+ `identity precision: ${show(report.identities.precision)}`,
269
+ `identity recall: ${show(report.identities.recall)}`,
270
+ ].join("\n");
271
+ }
272
+
273
+ // ---- a run from a graph ---------------------------------------------------------
274
+
275
+ /** Every mention of the graph with its source, field, span, role, thing and links. */
276
+ export const RUN_QUERY = `# link: grade\n${P}PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
277
+ PREFIX lbb: <https://littlebigbrain.com/ns#>
278
+ SELECT ?m ?src ?field ?span ?role ?thing ?same ?c WHERE {
279
+ ?m a <${CLASS_IRI}mention> ; p:span ?span ; p:role ?role ; p:field ?field ; r:mentioned_in ?src .
280
+ OPTIONAL { ?m r:refers_to ?thing }
281
+ OPTIONAL {
282
+ ?m r:same_thing_as ?same
283
+ OPTIONAL { ?st rdf:subject ?m ; rdf:predicate r:same_thing_as ; rdf:object ?same ; lbb:confidence ?c }
284
+ }
285
+ } LIMIT 200000`;
286
+
287
+ /** The mentions and written links of a graph. */
288
+ export async function readGraphRun(
289
+ lbb: Pick<LbbActivities, "sparql">,
290
+ ): Promise<Pick<Run, "mentions" | "links">> {
291
+ const { rows } = await lbb.sparql(RUN_QUERY, { consistency: "strong" });
292
+ const mentions = new Map<string, RunMention>();
293
+ const links: RunLink[] = [];
294
+ for (const r of rows) {
295
+ if (!r.m || !r.src) continue;
296
+ if (!mentions.has(r.m))
297
+ mentions.set(r.m, {
298
+ iri: r.m,
299
+ record: r.src,
300
+ field: r.field ?? "",
301
+ span: r.span ?? "",
302
+ role: r.role ?? "refers",
303
+ thing: r.thing ?? null,
304
+ });
305
+ if (r.same)
306
+ links.push({ a: r.m, b: r.same, confidence: r.c !== undefined ? Number(r.c) : null });
307
+ }
308
+ return { mentions: [...mentions.values()], links };
309
+ }
310
+
311
+ /** The open review rows, and the identities filed (open or decided), of the workflow's items. */
312
+ export function reviewOfItems(
313
+ items: Schemas["OntologyChangeSuggestion"][],
314
+ ): Pick<Run, "review" | "identities"> {
315
+ const review: RunReview[] = [];
316
+ const identities: RunIdentity[] = [];
317
+ for (const item of items) {
318
+ const kind = itemKind(item.key);
319
+ // An identity counts once filed, open or decided; a review row only while it is open.
320
+ if (kind === "identity" && item.status !== "superseded") {
321
+ for (const identity of item.proposed_identities ?? item.identities ?? [])
322
+ identities.push({
323
+ records: identity.members.map((m) => iriOfRef(m) ?? m.key),
324
+ confidence: identity.confidence,
325
+ });
326
+ continue;
327
+ }
328
+ if (item.status !== "open" || (kind !== "link" && kind !== "nolink")) continue;
329
+ for (const t of item.facts?.triplets ?? []) {
330
+ const [a, b] = [iriOfRef(t.source), iriOfRef(t.target)];
331
+ if (a && b) review.push({ a, b, confidence: t.confidence ?? 0, kind });
332
+ }
333
+ }
334
+ return { review, identities };
335
+ }