@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,727 @@
1
+ // Test support of the link template: the invented CRM on a graph in memory,
2
+ // an answer to each `# link:` query from that graph, mock ports and mock
3
+ // models, a driver that runs the source and things turns in order, a
4
+ // reviewer that decides the review items as a gold file says, and the
5
+ // storage measurement of decision 6 (keys, facts and bytes per mention).
6
+ import {
7
+ LbbError,
8
+ type DecideAnswer,
9
+ type DecideRequest,
10
+ type Schemas,
11
+ type WorkflowDefinition,
12
+ type WorkflowTriggerMessage,
13
+ } from "@littlebigbrain/client";
14
+ import {
15
+ MockActivities,
16
+ runMessage,
17
+ triggerMessage,
18
+ type MemoryGraph,
19
+ type MockRules,
20
+ type Turn,
21
+ } from "../_shared/testing.js";
22
+ import {
23
+ CLASS_IRI,
24
+ LINK_RELATIONS,
25
+ PROPERTY_IRI,
26
+ RDFS_LABEL,
27
+ RELATION_IRI,
28
+ iriSegment,
29
+ } from "./candidates.js";
30
+ import { grade, readGraphRun, reviewOfItems, type Gold, type Report, type Run } from "./grade.js";
31
+ import { iriOfRef, itemKind } from "./items.js";
32
+ import { nameSimilarity, normalName } from "./names.js";
33
+ import {
34
+ reviewsWorkflowName,
35
+ sourceWorkflowName,
36
+ thingsId,
37
+ type DecidedMessage,
38
+ type LinkPorts,
39
+ type Settings,
40
+ } from "./settings.js";
41
+ import { nameShaped, textSpans } from "./sources/records.js";
42
+ import { createWorkflow, type LinkState } from "./workflow.js";
43
+
44
+ /** One record of the fixture: its class, display name, field values and relations by record id. */
45
+ export interface FixtureRecord {
46
+ id: string;
47
+ class: string;
48
+ name: string;
49
+ fields?: Record<string, string>;
50
+ relations?: Record<string, string[]>;
51
+ }
52
+
53
+ /** Put the records on the graph; returns each record id's IRI. */
54
+ export function loadFixture(graph: MemoryGraph, records: FixtureRecord[]): Map<string, string> {
55
+ const ref = (r: FixtureRecord) => ({ type: r.class, name: r.name, key: r.id });
56
+ const byId = new Map(records.map((r) => [r.id, r]));
57
+ graph.apply({
58
+ subjects: [],
59
+ entity_properties: records.map((r) => ({ ...ref(r), properties: { ...(r.fields ?? {}) } })),
60
+ triplets: records.flatMap((r) =>
61
+ Object.entries(r.relations ?? {}).flatMap(([relation, targets]) =>
62
+ targets.map((t) => ({ source: ref(r), relation, target: ref(byId.get(t)!) })),
63
+ ),
64
+ ),
65
+ });
66
+ return new Map(records.map((r) => [r.id, graph.iriOf(ref(r))]));
67
+ }
68
+
69
+ // ---- the queries, answered from the graph ---------------------------------------
70
+
71
+ const relationIri = (relation: string) => RELATION_IRI + iriSegment(relation);
72
+ const propertyIri = (field: string) => PROPERTY_IRI + iriSegment(field);
73
+ const classIri = (type: string) => CLASS_IRI + iriSegment(type);
74
+
75
+ /** The values of `VALUES ?<name> { … }`: IRIs and string literals. */
76
+ function valuesOf(query: string, name: string): string[] {
77
+ const block = query.match(new RegExp(`VALUES \\?${name} \\{([^}]*)\\}`))?.[1] ?? "";
78
+ return [...block.matchAll(/<([^>]+)>|("(?:[^"\\]|\\.)*")/g)].map(
79
+ (m) => m[1] ?? (JSON.parse(m[2]!) as string),
80
+ );
81
+ }
82
+
83
+ /** The predicates a FILTER(?p IN (…)) names. */
84
+ const predicatesOf = (query: string) =>
85
+ [...(query.match(/IN \(([^)]*)\)/)?.[1] ?? "").matchAll(/<([^>]+)>/g)].map((m) => m[1]!);
86
+
87
+ const list = (value: unknown): string[] =>
88
+ value === undefined || value === null
89
+ ? []
90
+ : Array.isArray(value)
91
+ ? value.map(String)
92
+ : [String(value)];
93
+
94
+ /** Answer one query of the link workflow from the graph in memory. */
95
+ export function memorySparql(query: string, graph: MemoryGraph): Record<string, string>[] {
96
+ const head = query.split("\n", 1)[0]!;
97
+ const edgesFrom = (iri: string, relation: string) =>
98
+ graph.edges.filter((e) => e.source === iri && e.relation === relation);
99
+ const mentionOf = (m: string) => edgesFrom(m, "MENTIONED_IN")[0]?.target;
100
+ const thingOf = (m: string) => edgesFrom(m, "REFERS_TO")[0]?.target;
101
+ const mentions = () => graph.ofType("Mention");
102
+ const linkRelation = (relation: string) => LINK_RELATIONS.includes(relation.toLowerCase());
103
+ switch (head) {
104
+ case "# link: sources": {
105
+ const wanted = predicatesOf(query);
106
+ return valuesOf(query, "s").flatMap((s) => {
107
+ const e = graph.entities.get(s);
108
+ if (!e) return [];
109
+ const rows: Record<string, string>[] = [];
110
+ // A record's display name is rdfs:label, as the published RDF holds it.
111
+ if (wanted.includes(RDFS_LABEL)) rows.push({ s, p: RDFS_LABEL, v: e.name });
112
+ for (const [field, value] of Object.entries(e.properties))
113
+ if (wanted.includes(propertyIri(field)))
114
+ for (const v of list(value)) rows.push({ s, p: propertyIri(field), v });
115
+ return rows;
116
+ });
117
+ }
118
+ case "# link: source mentions":
119
+ return valuesOf(query, "s").flatMap((s) =>
120
+ mentions()
121
+ .filter((m) => mentionOf(m.iri) === s)
122
+ .flatMap((m) => {
123
+ const cannot = list(m.properties.cannot_link);
124
+ const base = { s, m: m.iri, ...(thingOf(m.iri) ? { thing: thingOf(m.iri)! } : {}) };
125
+ return cannot.length ? cannot.map((c) => ({ ...base, c })) : [base];
126
+ }),
127
+ );
128
+ case "# link: key counts":
129
+ return valuesOf(query, "key").flatMap((key) => {
130
+ const n = mentions().filter((m) => list(m.properties.block_key).includes(key)).length;
131
+ return n ? [{ key, n: String(n) }] : [];
132
+ });
133
+ case "# link: keys":
134
+ return valuesOf(query, "key").flatMap((key) =>
135
+ mentions()
136
+ .filter((m) => list(m.properties.block_key).includes(key) && mentionOf(m.iri))
137
+ .map((m) => {
138
+ const thing = thingOf(m.iri);
139
+ const p = m.properties as Record<string, unknown>;
140
+ return {
141
+ key,
142
+ m: m.iri,
143
+ role: String(p.role),
144
+ span: String(p.span),
145
+ src: mentionOf(m.iri)!,
146
+ ...(p.kind ? { kind: String(p.kind) } : {}),
147
+ ...(p.window ? { window: String(p.window) } : {}),
148
+ ...(thing ? { thing, label: graph.entities.get(thing)?.name ?? "" } : {}),
149
+ };
150
+ }),
151
+ );
152
+ case "# link: hop":
153
+ return valuesOf(query, "a").flatMap((a) =>
154
+ [
155
+ ...graph.edges
156
+ .filter((e) => e.source === a && !linkRelation(e.relation))
157
+ .map((e) => ({ e, other: e.target, dir: "out" })),
158
+ ...graph.edges
159
+ .filter((e) => e.target === a && !linkRelation(e.relation))
160
+ .map((e) => ({ e, other: e.source, dir: "in" })),
161
+ ].flatMap(({ e, other, dir }) => {
162
+ const o = graph.entities.get(other);
163
+ return o
164
+ ? [
165
+ {
166
+ a,
167
+ r: relationIri(e.relation),
168
+ b: other,
169
+ dir,
170
+ name: o.name,
171
+ class: classIri(o.type),
172
+ },
173
+ ]
174
+ : [];
175
+ }),
176
+ );
177
+ case "# link: neighbour mentions":
178
+ return valuesOf(query, "n").flatMap((n) =>
179
+ mentions()
180
+ .filter((m) => mentionOf(m.iri) === n)
181
+ .map((m) => {
182
+ const thing = thingOf(m.iri);
183
+ return {
184
+ n,
185
+ m: m.iri,
186
+ role: String(m.properties.role),
187
+ span: String(m.properties.span),
188
+ ...(thing ? { thing, label: graph.entities.get(thing)?.name ?? "" } : {}),
189
+ };
190
+ }),
191
+ );
192
+ case "# link: mention facts":
193
+ return valuesOf(query, "m").flatMap((m) => {
194
+ const e = graph.entities.get(m);
195
+ if (!e || e.type !== "Mention") return [];
196
+ const rows: Record<string, string>[] = [];
197
+ for (const field of [
198
+ "role",
199
+ "span",
200
+ "kind",
201
+ "field",
202
+ "window",
203
+ "block_key",
204
+ "linked_to",
205
+ "must_link",
206
+ "cannot_link",
207
+ ])
208
+ for (const v of list(e.properties[field])) rows.push({ m, p: propertyIri(field), v });
209
+ for (const relation of ["MENTIONED_IN", "REFERS_TO"])
210
+ for (const edge of edgesFrom(m, relation))
211
+ rows.push({ m, p: relationIri(relation), v: edge.target });
212
+ for (const edge of edgesFrom(m, "SAME_THING_AS"))
213
+ rows.push({
214
+ m,
215
+ p: relationIri("SAME_THING_AS"),
216
+ v: edge.target,
217
+ ...(edge.confidence !== null && edge.confidence !== undefined
218
+ ? { c: String(edge.confidence) }
219
+ : {}),
220
+ });
221
+ return rows;
222
+ });
223
+ case "# link: thing members":
224
+ return valuesOf(query, "t").flatMap((t) =>
225
+ graph.edges
226
+ .filter((e) => e.relation === "REFERS_TO" && e.target === t)
227
+ .map((e) => ({ t, x: e.source })),
228
+ );
229
+ case "# link: records":
230
+ return valuesOf(query, "s").flatMap((s) => {
231
+ const e = graph.entities.get(s);
232
+ return e ? [{ s, name: e.name, class: classIri(e.type) }] : [];
233
+ });
234
+ case "# link: thing facts":
235
+ return valuesOf(query, "t").flatMap((t) => {
236
+ const e = graph.entities.get(t);
237
+ if (!e) return [];
238
+ return [
239
+ ...(e.properties.anchor_key
240
+ ? [{ t, p: propertyIri("anchor_key"), v: String(e.properties.anchor_key) }]
241
+ : []),
242
+ { t, p: RDFS_LABEL, v: e.name },
243
+ ...edgesFrom(t, "MERGED_INTO").map((edge) => ({
244
+ t,
245
+ p: relationIri("MERGED_INTO"),
246
+ v: edge.target,
247
+ })),
248
+ ];
249
+ });
250
+ case "# link: same as":
251
+ return valuesOf(query, "a").flatMap((a) =>
252
+ graph.edges
253
+ .filter((e) => e.relation === "SAME_AS" && (e.source === a || e.target === a))
254
+ .map((e) => ({ a, b: e.source === a ? e.target : e.source })),
255
+ );
256
+ case "# link: orphan mentions":
257
+ return mentions()
258
+ .filter((m) => !mentionOf(m.iri))
259
+ .map((m) => ({ m: m.iri, ...(thingOf(m.iri) ? { thing: thingOf(m.iri)! } : {}) }));
260
+ case "# link: grade":
261
+ return mentions().flatMap((m) => {
262
+ const p = m.properties as Record<string, unknown>;
263
+ const thing = thingOf(m.iri);
264
+ const base = {
265
+ m: m.iri,
266
+ src: mentionOf(m.iri) ?? "",
267
+ field: String(p.field),
268
+ span: String(p.span),
269
+ role: String(p.role),
270
+ ...(thing ? { thing } : {}),
271
+ };
272
+ const same = edgesFrom(m.iri, "SAME_THING_AS");
273
+ return same.length
274
+ ? same.map((e) => ({
275
+ ...base,
276
+ same: e.target,
277
+ ...(e.confidence != null ? { c: String(e.confidence) } : {}),
278
+ }))
279
+ : [base];
280
+ });
281
+ }
282
+ throw new Error(`a query the mock does not know: ${head}`);
283
+ }
284
+
285
+ // ---- the mock models ---------------------------------------------------------------
286
+
287
+ const answer = (choice: string, p: number, options: string[]): DecideAnswer => ({
288
+ choice,
289
+ confidence: p,
290
+ probabilities: Object.fromEntries(
291
+ options.map((o) => [
292
+ o,
293
+ o === choice ? p : Math.round(((1 - p) / Math.max(1, options.length - 1)) * 1000) / 1000,
294
+ ]),
295
+ ),
296
+ });
297
+ const quoted = (text: string) => [...text.matchAll(/"([^"]*)"/g)].map((m) => m[1]!);
298
+
299
+ /**
300
+ * The decision model, by fixed rules. A kind question: a span written like
301
+ * a name is a person. A names question: the candidate named most alike,
302
+ * with the one near the source first; at least 0.95 alike links at 0.96, at
303
+ * least 0.75 alike and near the source at 0.9, at least 0.8 alike at 0.85;
304
+ * else a new thing. A same-record question: the same when the two names
305
+ * are equal and the records share a related name; for a span, when the
306
+ * names are alike.
307
+ */
308
+ export function mockDecide(request: DecideRequest): Record<string, DecideAnswer> {
309
+ const out: Record<string, DecideAnswer> = {};
310
+ for (const [id, q] of Object.entries(request.questions)) {
311
+ const options = Object.keys(q.criteria);
312
+ if (id.endsWith("-kind")) {
313
+ const span = q.instructions.match(/span "([^"]*)"/)?.[1] ?? "";
314
+ out[id] = nameShaped(span) ? answer("person", 0.92, options) : answer("other", 0.7, options);
315
+ continue;
316
+ }
317
+ if (id.endsWith("-names")) {
318
+ const span = q.instructions.match(/span "([^"]*)"/)?.[1] ?? "";
319
+ let best = { option: "new_thing", score: 0, near: false };
320
+ for (const option of options.filter((o) => /^c\d+$/.test(o))) {
321
+ const text = q.criteria[option]!;
322
+ const names = [
323
+ text.split("; ")[0]!,
324
+ ...(text.match(/names: ([^;]*)/)?.[1]?.split(", ") ?? []),
325
+ ];
326
+ const score = Math.max(...names.map((n) => nameSimilarity(span, n)));
327
+ const near = text.includes("→");
328
+ if (score + (near ? 0.05 : 0) > best.score + (best.near ? 0.05 : 0))
329
+ best = { option, score, near };
330
+ }
331
+ const p =
332
+ best.score >= 0.95
333
+ ? 0.96
334
+ : best.score >= 0.75 && best.near
335
+ ? 0.9
336
+ : best.score >= 0.8
337
+ ? 0.85
338
+ : 0;
339
+ out[id] = p ? answer(best.option, p, options) : answer("new_thing", 0.7, options);
340
+ continue;
341
+ }
342
+ if (id.endsWith("-same")) {
343
+ const [a, b] = q.instructions.split(" Record B: ");
344
+ const [nameA, nameB] = [quoted(a ?? "")[0] ?? "", quoted(b ?? "")[0] ?? ""];
345
+ const related = (text: string) =>
346
+ new Set(text.match(/\(related: ([^)]*)\)/)?.[1]?.split(", ") ?? []);
347
+ if ((a ?? "").includes("the span")) {
348
+ // Alike names are the same; a sentence that names a related record of it makes it surer.
349
+ const sentence = normalName(quoted(a ?? "")[1] ?? "");
350
+ const named = [...related(b ?? "")].some((n) =>
351
+ n.split(" ").some((w) => w.length > 3 && sentence.split(" ").includes(w)),
352
+ );
353
+ out[id] =
354
+ nameSimilarity(nameA, nameB) >= 0.75
355
+ ? answer("same", named ? 0.95 : 0.9, options)
356
+ : answer("different", 0.8, options);
357
+ continue;
358
+ }
359
+ const [x, y] = [related(a ?? ""), related(b ?? "")];
360
+ const shared = [...x].some((n) => y.has(n));
361
+ out[id] =
362
+ normalName(nameA) === normalName(nameB) && shared
363
+ ? answer("same", 0.95, options)
364
+ : answer("different", 0.8, options);
365
+ }
366
+ }
367
+ return out;
368
+ }
369
+
370
+ /** The reasoning model, by a fixed rule: every span of the text written like a name. */
371
+ export function mockReason(request: { prompt: string }): unknown {
372
+ const text = request.prompt.slice(request.prompt.lastIndexOf("\n\n") + 2);
373
+ return {
374
+ spans: textSpans(text)
375
+ .filter((s) => nameShaped(s.span))
376
+ .map((s) => ({ span: s.span, kind: s.span.includes("@") ? "code" : "person" })),
377
+ };
378
+ }
379
+
380
+ // ---- the ports ---------------------------------------------------------------------
381
+
382
+ /** The decision a person made on an item: its status and what the accept kept. */
383
+ interface Decided {
384
+ status: "accepted" | "dismissed" | "superseded";
385
+ facts?: Schemas["SuggestedFacts"];
386
+ identities?: Schemas["SuggestedIdentity"][];
387
+ }
388
+
389
+ /** The ports in memory: an inbox of the sent messages (each id once), and the items of the mock activities. */
390
+ export class MockPorts implements LinkPorts {
391
+ readonly inbox: { id: string; message: DecidedMessage }[] = [];
392
+ readonly sent = new Set<string>();
393
+ readonly decided = new Map<string, Decided>();
394
+ constructor(readonly lbb: MockActivities) {}
395
+
396
+ async send(_workflowId: string, message: DecidedMessage, options: { id: string }) {
397
+ if (this.sent.has(options.id)) return;
398
+ this.sent.add(options.id);
399
+ this.inbox.push({
400
+ id: options.id,
401
+ message: JSON.parse(JSON.stringify(message)) as DecidedMessage,
402
+ });
403
+ }
404
+
405
+ async suggestion(id: string): Promise<Schemas["OntologyChangeSuggestion"]> {
406
+ const filed = this.lbb.reviews.get(id);
407
+ if (!filed) throw new LbbError(404, "{}", { code: "not_found", message: id });
408
+ const decided = this.decided.get(id);
409
+ const status = decided?.status ?? (filed.status as Decided["status"] | "open");
410
+ return {
411
+ suggestion_id: id,
412
+ key: filed.key ?? id,
413
+ status,
414
+ facts: decided?.facts ?? filed.facts ?? null,
415
+ proposed_facts: decided?.facts ? (filed.facts ?? null) : null,
416
+ identities: decided?.identities ?? filed.identities ?? [],
417
+ proposed_identities: decided?.identities ? (filed.identities ?? null) : null,
418
+ mapping: filed.mapping ?? null,
419
+ } as unknown as Schemas["OntologyChangeSuggestion"];
420
+ }
421
+
422
+ async supersede(id: string) {
423
+ const filed = this.lbb.reviews.get(id);
424
+ if (filed && filed.status === "open") filed.status = "superseded";
425
+ }
426
+ }
427
+
428
+ // ---- the driver ------------------------------------------------------------------
429
+
430
+ /** A run of the workflow on the fixture. */
431
+ export interface LinkRun {
432
+ lbb: MockActivities;
433
+ ports: MockPorts;
434
+ settings: Settings;
435
+ ids: Map<string, string>;
436
+ turns: Turn[];
437
+ shards: Map<string, unknown>;
438
+ things: unknown;
439
+ counters: Map<string, number>;
440
+ definition: WorkflowDefinition<LinkState, WorkflowTriggerMessage>;
441
+ }
442
+
443
+ /** The mock rules of a run: the graph's queries, the mock models. */
444
+ export const linkRules = (more: MockRules = {}): MockRules => ({
445
+ sparql: (query, graph) => memorySparql(query, graph),
446
+ decide: mockDecide,
447
+ reason: (request) => mockReason(request),
448
+ ...more,
449
+ });
450
+
451
+ /** A new run: the fixture on a graph in memory, nothing sent yet. */
452
+ export function newRun(
453
+ records: FixtureRecord[],
454
+ settings: Settings,
455
+ more: MockRules = {},
456
+ ): LinkRun {
457
+ const lbb = new MockActivities(linkRules(more));
458
+ const ports = new MockPorts(lbb);
459
+ return {
460
+ lbb,
461
+ ports,
462
+ settings,
463
+ ids: loadFixture(lbb.graph, records),
464
+ turns: [],
465
+ shards: new Map(),
466
+ things: {},
467
+ counters: new Map(),
468
+ definition: createWorkflow(lbb, ports, settings) as unknown as WorkflowDefinition<
469
+ LinkState,
470
+ WorkflowTriggerMessage
471
+ >,
472
+ };
473
+ }
474
+
475
+ async function runAs(run: LinkRun, id: string, message: WorkflowTriggerMessage, state: unknown) {
476
+ const turn = (run.counters.get(id) ?? 0) + 1;
477
+ const out = await runMessage(run.definition, message, state, { workflowId: id, turn });
478
+ run.counters.set(id, turn + out.turns.length - 1);
479
+ run.turns.push(...out.turns);
480
+ return out.state;
481
+ }
482
+
483
+ /** Run the things turns of every message in the inbox. */
484
+ export async function drain(run: LinkRun) {
485
+ while (run.ports.inbox.length) {
486
+ const { message } = run.ports.inbox.shift()!;
487
+ run.things = await runAs(
488
+ run,
489
+ thingsId(run.settings),
490
+ message as unknown as WorkflowTriggerMessage,
491
+ run.things,
492
+ );
493
+ }
494
+ }
495
+
496
+ /** Send records of a class to their named workflow's shard, then run the things turns. */
497
+ export async function sendRecords(
498
+ run: LinkRun,
499
+ cls: string,
500
+ iris: string[],
501
+ removed: string[] = [],
502
+ ) {
503
+ const name = sourceWorkflowName(run.settings, cls);
504
+ const id = `starter.${name}`;
505
+ run.shards.set(
506
+ id,
507
+ await runAs(run, id, triggerMessage(name, iris, removed), run.shards.get(id) ?? {}),
508
+ );
509
+ await drain(run);
510
+ }
511
+
512
+ /** Send every record of the fixture, class by class in `order`, in batches of the settings. */
513
+ export async function runAll(run: LinkRun, records: FixtureRecord[], order: string[]) {
514
+ for (const cls of order) {
515
+ const iris = records.filter((r) => r.class === cls).map((r) => run.ids.get(r.id)!);
516
+ for (let n = 0; n < iris.length; n += run.settings.sourcesPerTurn)
517
+ await sendRecords(run, cls, iris.slice(n, n + run.settings.sourcesPerTurn));
518
+ }
519
+ }
520
+
521
+ // ---- reading and grading ---------------------------------------------------------------
522
+
523
+ /** The run as the grader reads it: the graph's mentions and links, and the open items. */
524
+ export async function readRun(run: LinkRun): Promise<Run> {
525
+ const graphRun = await readGraphRun(run.lbb);
526
+ const items = await Promise.all(
527
+ [...run.lbb.reviews.keys()].map((key) => run.ports.suggestion(key)),
528
+ );
529
+ return { ...graphRun, ...reviewOfItems(items) };
530
+ }
531
+
532
+ /** The gold of the fixture with its record ids as IRIs. */
533
+ export const goldOf = (gold: Gold, ids: Map<string, string>): Gold => ({
534
+ ...gold,
535
+ records: Object.fromEntries(ids),
536
+ });
537
+
538
+ export async function gradeRun(run: LinkRun, gold: Gold): Promise<Report> {
539
+ return grade(goldOf(gold, run.ids), await readRun(run));
540
+ }
541
+
542
+ // ---- a person's review -----------------------------------------------------------------
543
+
544
+ /**
545
+ * Decide every open item as the gold says: keep the rows whose two mentions
546
+ * the gold puts in one group, accept with them or dismiss when none is
547
+ * right; accept an identity with the records of its largest group in the
548
+ * gold. An accept writes its facts and identities to the graph as the
549
+ * server does; then the reviews workflow is sent the mentions whose
550
+ * `same_thing_as` changed, with the accept's request id. Returns the number
551
+ * of items decided.
552
+ */
553
+ export async function reviewAsGold(run: LinkRun, gold: Gold): Promise<number> {
554
+ const g = goldOf(gold, run.ids);
555
+ const { mentions } = await readGraphRun(run.lbb);
556
+ const groupOf = new Map<string, string>();
557
+ const spanKey = (s: string) => s.normalize("NFC").trim();
558
+ for (const group of g.groups)
559
+ for (const m of group.mentions) {
560
+ const found = mentions.find(
561
+ (x) =>
562
+ x.record === (g.records?.[m.record] ?? m.record) &&
563
+ x.field === m.field &&
564
+ spanKey(x.span) === spanKey(m.span) &&
565
+ !groupOf.has(x.iri),
566
+ );
567
+ if (found) groupOf.set(found.iri, group.id);
568
+ }
569
+ const same = (a: string | null, b: string | null) =>
570
+ !!a && !!b && groupOf.has(a) && groupOf.get(a) === groupOf.get(b);
571
+ const touched = new Set<string>();
572
+ const requests: string[] = [];
573
+ let decided = 0;
574
+ for (const [key, filed] of run.lbb.reviews) {
575
+ if (filed.status !== "open" || run.ports.decided.has(key)) continue;
576
+ decided += 1;
577
+ const kind = itemKind(key);
578
+ if (kind === "identity") {
579
+ const members = filed.identities?.[0]?.members ?? [];
580
+ const selves =
581
+ (filed.mapping as { identity?: { selves?: Record<string, string> } }).identity?.selves ??
582
+ {};
583
+ const byGroup = new Map<string, typeof members>();
584
+ for (const member of members) {
585
+ const record = iriOfRef(member)!;
586
+ const group = groupOf.get(selves[record] ?? "") ?? `alone:${record}`;
587
+ byGroup.set(group, [...(byGroup.get(group) ?? []), member]);
588
+ }
589
+ const keep = [...byGroup.values()].sort((a, b) => b.length - a.length)[0] ?? [];
590
+ if (keep.length < 2) {
591
+ run.ports.decided.set(key, { status: "dismissed" });
592
+ continue;
593
+ }
594
+ const identity = { ...filed.identities![0]!, members: keep };
595
+ const keptRecords = new Set(keep.map((m) => iriOfRef(m)));
596
+ const facts = (filed.facts?.triplets ?? []).filter((t) => {
597
+ const [a, b] = [iriOfRef(t.source), iriOfRef(t.target)];
598
+ return (
599
+ [...keptRecords].some((r) => selves[r!] === a) &&
600
+ [...keptRecords].some((r) => selves[r!] === b)
601
+ );
602
+ });
603
+ run.ports.decided.set(key, {
604
+ status: "accepted",
605
+ identities: [identity],
606
+ facts: { triplets: facts },
607
+ });
608
+ run.lbb.graph.apply({
609
+ subjects: [],
610
+ triplets: [
611
+ ...keep.slice(1).map((m) => ({ source: m, relation: "SAME_AS", target: keep[0]! })),
612
+ ...facts,
613
+ ],
614
+ });
615
+ for (const t of facts) touched.add(iriOfRef(t.source)!);
616
+ requests.push(`ontology-suggestion:${key}:facts:1`);
617
+ continue;
618
+ }
619
+ const rows = filed.facts?.triplets ?? [];
620
+ const kept = rows.filter((t) => same(iriOfRef(t.source), iriOfRef(t.target)));
621
+ if (!kept.length) {
622
+ run.ports.decided.set(key, { status: "dismissed" });
623
+ continue;
624
+ }
625
+ run.ports.decided.set(key, { status: "accepted", facts: { ...filed.facts, triplets: kept } });
626
+ run.lbb.graph.apply({ subjects: [], triplets: kept });
627
+ for (const t of kept) touched.add(iriOfRef(t.source)!);
628
+ requests.push(`ontology-suggestion:${key}:facts:1`);
629
+ }
630
+ for (const [key, d] of run.ports.decided) {
631
+ const filed = run.lbb.reviews.get(key);
632
+ if (filed && filed.status === "open") filed.status = d.status;
633
+ }
634
+ if (touched.size) {
635
+ const name = reviewsWorkflowName(run.settings);
636
+ const id = `starter.${name}`;
637
+ const message = {
638
+ ...triggerMessage(name, [...touched]),
639
+ reason: "changed",
640
+ origin: { kind: "commit", trigger: "accepts", requests },
641
+ } as WorkflowTriggerMessage;
642
+ run.shards.set(id, await runAs(run, id, message, run.shards.get(id) ?? {}));
643
+ await drain(run);
644
+ }
645
+ return decided;
646
+ }
647
+
648
+ // ---- storage (decision 6) ---------------------------------------------------------------
649
+
650
+ /** The statements of the graph in N-Triples, one per value and edge: how block keys cost. */
651
+ export interface Storage {
652
+ mentions: number;
653
+ things: number;
654
+ keys: number;
655
+ keysPerMention: number;
656
+ factsPerMention: number;
657
+ bytesPerMention: number;
658
+ keyFactsShare: number;
659
+ keyBytesShare: number;
660
+ thingFacts: number;
661
+ thingBytes: number;
662
+ }
663
+
664
+ const nt = (s: string, p: string, o: string) => `<${s}> <${p}> ${o} .\n`.length;
665
+ const literal = (v: unknown) => JSON.stringify(String(v));
666
+
667
+ /** Keys, facts and bytes per mention, and of the things, as N-Triples statements. */
668
+ export function measure(graph: MemoryGraph): Storage {
669
+ const mentions = graph.ofType("Mention");
670
+ const things = graph.ofType("LinkedThing");
671
+ let facts = 0;
672
+ let bytes = 0;
673
+ let keyFacts = 0;
674
+ let keyBytes = 0;
675
+ const statements = (
676
+ iri: string,
677
+ type: string,
678
+ name: string,
679
+ properties: Record<string, unknown>,
680
+ ) => {
681
+ let n = 2;
682
+ let b =
683
+ nt(iri, "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", `<${classIri(type)}>`) +
684
+ nt(iri, RDFS_LABEL, literal(name));
685
+ for (const [field, value] of Object.entries(properties))
686
+ for (const v of list(value)) {
687
+ n += 1;
688
+ const size = nt(iri, propertyIri(field), literal(v));
689
+ b += size;
690
+ if (field === "block_key") {
691
+ keyFacts += 1;
692
+ keyBytes += size;
693
+ }
694
+ }
695
+ for (const e of graph.edges.filter((x) => x.source === iri)) {
696
+ n += 1;
697
+ b += nt(iri, relationIri(e.relation), `<${e.target}>`);
698
+ }
699
+ return { n, b };
700
+ };
701
+ for (const m of mentions) {
702
+ const s = statements(m.iri, m.type, m.name, m.properties);
703
+ facts += s.n;
704
+ bytes += s.b;
705
+ }
706
+ let thingFacts = 0;
707
+ let thingBytes = 0;
708
+ for (const t of things) {
709
+ const s = statements(t.iri, t.type, t.name, t.properties);
710
+ thingFacts += s.n;
711
+ thingBytes += s.b;
712
+ }
713
+ const keys = mentions.reduce((sum, m) => sum + list(m.properties.block_key).length, 0);
714
+ const per = (x: number) => (mentions.length ? Math.round((x / mentions.length) * 100) / 100 : 0);
715
+ return {
716
+ mentions: mentions.length,
717
+ things: things.length,
718
+ keys,
719
+ keysPerMention: per(keys),
720
+ factsPerMention: per(facts),
721
+ bytesPerMention: per(bytes),
722
+ keyFactsShare: facts ? Math.round((keyFacts / facts) * 1000) / 1000 : 0,
723
+ keyBytesShare: bytes ? Math.round((keyBytes / bytes) * 1000) / 1000 : 0,
724
+ thingFacts,
725
+ thingBytes,
726
+ };
727
+ }