@littlebigbrain/create 0.0.0-stage → 0.2.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 (52) hide show
  1. package/LICENSE +55 -0
  2. package/README.md +73 -2
  3. package/bin/create-littlebigbrain.mjs +9 -0
  4. package/lib/create.mjs +244 -0
  5. package/package.json +46 -4
  6. package/project/setup.ts +18 -0
  7. package/project/try.ts +30 -0
  8. package/project/worker.ts +23 -0
  9. package/templates/_shared/testing.ts +521 -0
  10. package/templates/documents-link/README.md +146 -0
  11. package/templates/documents-link/link.ts +580 -0
  12. package/templates/documents-link/samples/ausstattungsliste.pdf +76 -0
  13. package/templates/documents-link/samples/baubeschreibung.pdf +54 -0
  14. package/templates/documents-link/samples/expected.json +404 -0
  15. package/templates/documents-link/samples/pages/ausstattungsliste.json +1150 -0
  16. package/templates/documents-link/samples/pages/baubeschreibung.json +802 -0
  17. package/templates/documents-link/samples/pages/raumblaetter.json +1580 -0
  18. package/templates/documents-link/samples/raumblaetter.pdf +109 -0
  19. package/templates/documents-link/samples.ts +31 -0
  20. package/templates/documents-link/workflow.test.ts +497 -0
  21. package/templates/documents-link/workflow.ts +467 -0
  22. package/templates/documents-map/README.md +212 -0
  23. package/templates/documents-map/map.ts +1135 -0
  24. package/templates/documents-map/samples/ausstattungsliste.pdf +76 -0
  25. package/templates/documents-map/samples/baubeschreibung.pdf +54 -0
  26. package/templates/documents-map/samples/expected.json +50 -0
  27. package/templates/documents-map/samples/pages/raumblaetter.json +1580 -0
  28. package/templates/documents-map/samples/raumblaetter.pdf +109 -0
  29. package/templates/documents-map/samples/trace.json +613 -0
  30. package/templates/documents-map/samples.ts +182 -0
  31. package/templates/documents-map/workflow.test.ts +770 -0
  32. package/templates/documents-map/workflow.ts +1199 -0
  33. package/templates/ontology-fit/README.md +81 -0
  34. package/templates/ontology-fit/samples/expected.md +88 -0
  35. package/templates/ontology-fit/samples/texts.json +22 -0
  36. package/templates/ontology-fit/samples.ts +40 -0
  37. package/templates/ontology-fit/workflow.test.ts +270 -0
  38. package/templates/ontology-fit/workflow.ts +416 -0
  39. package/templates/review/README.md +158 -0
  40. package/templates/review/decision.ts +483 -0
  41. package/templates/review/samples/expected.json +37 -0
  42. package/templates/review/samples/proposals.json +35 -0
  43. package/templates/review/samples.ts +154 -0
  44. package/templates/review/workflow.test.ts +438 -0
  45. package/templates/review/workflow.ts +459 -0
  46. package/templates/values-extract/README.md +81 -0
  47. package/templates/values-extract/samples/expected.json +148 -0
  48. package/templates/values-extract/samples/records.json +26 -0
  49. package/templates/values-extract/samples.ts +27 -0
  50. package/templates/values-extract/workflow.test.ts +279 -0
  51. package/templates/values-extract/workflow.ts +419 -0
  52. package/templates/versions.json +3 -0
@@ -0,0 +1,1135 @@
1
+ // The parts of documents-map that need no I/O: names, the ontology the
2
+ // questions offer, the subject of a mention, the decision model's questions,
3
+ // and the proposals. Adapted from the LBB-run `documents.map@v1`
4
+ // (database/crates/lbb-graph-query/src/documents/map.rs and map_store.rs).
5
+ import { createHash } from "node:crypto";
6
+ import {
7
+ ENTITY_IRI_NAMESPACE,
8
+ type DecideAnswer,
9
+ type DecideQuestion,
10
+ type DecideRequest,
11
+ type Schemas,
12
+ } from "@littlebigbrain/client";
13
+
14
+ // ---- the vocabulary ----------------------------------------------------------------
15
+
16
+ export const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
17
+ export const RDFS_LABEL = "http://www.w3.org/2000/01/rdf-schema#label";
18
+ export const OWL_SAME_AS = "http://www.w3.org/2002/07/owl#sameAs";
19
+ export const XSD_STRING = "http://www.w3.org/2001/XMLSchema#string";
20
+ /** The IRIs a records graph gives its classes, properties and relations. */
21
+ export const CLASS_NS = "https://littlebigbrain.com/class/";
22
+ export const PROPERTY_NS = "https://littlebigbrain.com/p/";
23
+ export const RELATION_NS = "https://littlebigbrain.com/r/";
24
+ /** The relation from an entry (or a mention that is a page's subject) to the instance it names. */
25
+ export const DENOTES = "DENOTES";
26
+
27
+ const segment = (name: string) =>
28
+ encodeURIComponent(name.trim().toLowerCase().split(/\s+/).join(" "));
29
+ /** The IRI a records graph gives a class name. */
30
+ export const classIri = (name: string) => CLASS_NS + segment(name);
31
+ /** The IRI a records graph gives a property. */
32
+ export const propertyIri = (name: string) => PROPERTY_NS + segment(name);
33
+ /** The IRI a records graph gives a relation. */
34
+ export const relationIri = (name: string) => RELATION_NS + segment(name);
35
+
36
+ /** The class of the proposals, and the properties a proposer writes on each. */
37
+ export const FACT_PROPOSAL = "FactProposal";
38
+ export const PROPOSAL_FIELDS: Record<string, string> = {
39
+ fact_subject: "keyword",
40
+ fact_predicate: "keyword",
41
+ fact_object: "text",
42
+ fact_datatype: "keyword",
43
+ fact_confidence: "f64",
44
+ fact_evidence: "text",
45
+ proposed_by: "keyword",
46
+ };
47
+
48
+ /** LBB's own trace: never offered as a class or a property. */
49
+ export const TRACE_CLASSES = ["Document", "DocEntry", "DocMention", FACT_PROPOSAL, "ValueSource"];
50
+ export const TRACE_RELATIONS = [
51
+ "DEFINED_IN",
52
+ "MENTIONED_IN",
53
+ "REFERS_TO",
54
+ "SAME_ITEM_AS",
55
+ "HAS_VALUE_SOURCE",
56
+ DENOTES,
57
+ ];
58
+
59
+ /** The fixed options of the questions. */
60
+ export const SAME_ITEM = "same_item";
61
+ export const NO_FACT = "no_fact";
62
+ export const MISSING_PROPERTY = "missing_property";
63
+ export const NEW_INSTANCE = "new_instance";
64
+ export const NO_INSTANCE = "none";
65
+ export const NO_CLASS = "no_class";
66
+ const FIXED = [SAME_ITEM, NO_FACT, MISSING_PROPERTY, NEW_INSTANCE, NO_INSTANCE, NO_CLASS];
67
+
68
+ /** Candidates of one grounding question, classes of one class question, properties of one typing question. */
69
+ export const GROUND_CANDIDATES = 10;
70
+ export const CLASS_OPTIONS = 12;
71
+ export const PROPERTY_OPTIONS = 12;
72
+ /** "No class fits" and "a property the ontology lacks" with at least this probability file a suggestion. */
73
+ export const LACK_SUGGEST_AT = 0.5;
74
+ /**
75
+ * A relation suggestion whose two classes an existing relation fits needs this many lines whose
76
+ * answer weighed that relation and chose "a property the ontology lacks", or one such line at
77
+ * `OVERLAP_ALONE_AT` or more.
78
+ */
79
+ export const OVERLAP_LINES = 2;
80
+ export const OVERLAP_ALONE_AT = 0.8;
81
+ /** The top of a page, as a share of its height: a page's head. */
82
+ export const HEAD_BAND = 0.3;
83
+ /** Lines whose middles are this close (a share of the page height) are on one row. */
84
+ const ROW_TOLERANCE = 0.006;
85
+ const DESCRIPTION_CHARS = 160;
86
+ const FACT_CHARS = 80;
87
+ const CONTEXT_CHARS = 400;
88
+ /** Questions of one decide request, and its characters (well under the route's 200,000). */
89
+ const REQUEST_QUESTIONS = 32;
90
+ const REQUEST_CHARS = 60_000;
91
+
92
+ export const TYPING_TASK =
93
+ "Each question is about one link that a document linker found: a span on a line of a document names an entry of another document. The subject is the instance the line belongs to (the room of a room sheet, or the entry of the table row); the object is the instance of the linked entry. The question asks which statement, in the terms of the ontology below, the line makes about the two.";
94
+ const TYPING_GUIDE =
95
+ " Choose a property by its meaning (its comment in the ontology), not by its name alone. Choose 'a property the ontology lacks' when the line relates the two but no property means that relation. Choose 'no fact' when the line does not relate the two, for example when the span is the subject's own number or the linked entry is not what the span names.";
96
+ export const GROUND_TASK =
97
+ "Each question is about one entry that a document defines: an identifier with its text. The candidates are instances of the graph, each with its class, label and facts. The question asks which instance of the graph the entry is.";
98
+ export const CLASS_TASK =
99
+ "Each question is about one entry that a document defines and that is a new instance of the graph. The question asks which class of the graph's ontology the entry is an instance of.";
100
+
101
+ // ---- names ---------------------------------------------------------------------------
102
+
103
+ /** The text after the last `#` or `/` of an IRI. */
104
+ export function localName(iri: string): string {
105
+ const trimmed = iri.replace(/[/#]+$/, "");
106
+ return trimmed.slice(Math.max(trimmed.lastIndexOf("#"), trimmed.lastIndexOf("/")) + 1);
107
+ }
108
+
109
+ /** A name as words: `hasEquipment` and `HAS_EQUIPMENT` read `has equipment`. */
110
+ export function humanize(name: string): string {
111
+ return name
112
+ .replace(/[_-]+/g, " ")
113
+ .replace(/(?<=[\p{Ll}\d])(?=\p{Lu})/gu, " ")
114
+ .toLowerCase()
115
+ .split(/\s+/)
116
+ .filter(Boolean)
117
+ .join(" ");
118
+ }
119
+
120
+ /** A class name as people read it: `SpecSection` reads `Spec section`. */
121
+ export function classLabel(name: string): string {
122
+ const words = humanize(name);
123
+ return words ? words[0]!.toUpperCase() + words.slice(1) : name;
124
+ }
125
+
126
+ /** Whitespace collapsed, cut at `chars` with an ellipsis. */
127
+ export function clip(text: string, chars: number): string {
128
+ const flat = text.split(/\s+/).filter(Boolean).join(" ");
129
+ const all = [...flat];
130
+ return all.length <= chars ? flat : `${all.slice(0, chars).join("")}…`;
131
+ }
132
+
133
+ /** The words of a text, lower case, at least three letters long. */
134
+ const words = (text: string) =>
135
+ new Set(
136
+ text
137
+ .split(/[^\p{L}\p{N}]+/u)
138
+ .filter((w) => [...w].length >= 3)
139
+ .map((w) => w.toLowerCase()),
140
+ );
141
+ const overlap = (a: Set<string>, text: string) => [...words(text)].filter((w) => a.has(w)).length;
142
+
143
+ /**
144
+ * The key an identifier matches by: no whitespace, lower case, and no dot
145
+ * between leading letters and a digit (`S.2.2`, `s 2.2` and `S2.2` match).
146
+ */
147
+ export function identifierKey(identifier: string): string {
148
+ const squashed = identifier.replace(/\s+/g, "").toLowerCase();
149
+ return squashed.replace(/^(\p{L}+)\.(?=\d)/u, "$1");
150
+ }
151
+
152
+ /**
153
+ * The form of a code: each run of digits as `9`, each run of letters as `a`,
154
+ * other characters as written; a space only between two runs of one kind
155
+ * (`S 1.1` → `a9.9`, `0.12` → `9.9`).
156
+ */
157
+ export function codeShape(text: string): string {
158
+ let out = "";
159
+ let space = false;
160
+ for (const c of text.trim()) {
161
+ if (/\s/.test(c)) {
162
+ space = true;
163
+ continue;
164
+ }
165
+ const kind = /\p{N}/u.test(c) ? "9" : /\p{L}/u.test(c) ? "a" : c;
166
+ const last = out.at(-1);
167
+ if (space && (kind === "9" || kind === "a") && last === kind) out += ` ${kind}`;
168
+ else if (!((kind === "9" || kind === "a") && last === kind)) out += kind;
169
+ space = false;
170
+ }
171
+ return out;
172
+ }
173
+
174
+ /** The ways an identifier may be written on an instance: as written, without spaces, with one space after its letters. */
175
+ export function identifierVariants(identifier: string): string[] {
176
+ const trimmed = identifier.split(/\s+/).filter(Boolean).join(" ");
177
+ const squashed = trimmed.replace(/\s+/g, "");
178
+ const out = [trimmed, squashed];
179
+ const letters = squashed.match(/^\p{L}+/u)?.[0].length ?? 0;
180
+ if (letters > 0 && letters < squashed.length)
181
+ out.push(`${squashed.slice(0, letters)} ${squashed.slice(letters).replace(/^\.+/, "")}`);
182
+ return [...new Set(out.filter(Boolean))];
183
+ }
184
+
185
+ /** The description of a page entry: its line without the span (the whole line when nothing is left). */
186
+ export function pageEntryDescription(span: string, line: string): string {
187
+ const rest = line.replace(span, " ").split(/\s+/).filter(Boolean).join(" ");
188
+ return rest || line.trim();
189
+ }
190
+
191
+ /** The label of a new instance: its identifier and the start of its description. */
192
+ export function newInstanceLabel(identifier: string, description: string): string {
193
+ const id = identifier.trim();
194
+ let rest = description.trim();
195
+ if (rest.startsWith(id)) rest = rest.slice(id.length).replace(/^[ :,-]+/, "");
196
+ rest = clip(rest, 80);
197
+ return !rest || identifierKey(rest) === identifierKey(id) ? id : `${id} ${rest}`;
198
+ }
199
+
200
+ /** The key two ontology names compare by: letters and digits, lower case (`HAS_EQUIPMENT` is `hasEquipment`). */
201
+ export const nameKey = (name: string) => name.replace(/[^\p{L}\p{N}]+/gu, "").toLowerCase();
202
+
203
+ /** `name`, or `<name><sep>2`, `<name><sep>3` … when `taken` (name keys) has it. */
204
+ function freeName(name: string, taken: Set<string>, sep = "_"): string {
205
+ if (!taken.has(nameKey(name))) return name;
206
+ for (let n = 2; ; n++) if (!taken.has(nameKey(`${name}${sep}${n}`))) return `${name}${sep}${n}`;
207
+ }
208
+
209
+ const FOLD: Record<string, string> = {
210
+ Ä: "AE",
211
+ Æ: "AE",
212
+ Ö: "OE",
213
+ Œ: "OE",
214
+ Ü: "UE",
215
+ ẞ: "SS",
216
+ À: "A",
217
+ Á: "A",
218
+ Â: "A",
219
+ Ã: "A",
220
+ Å: "A",
221
+ Ç: "C",
222
+ È: "E",
223
+ É: "E",
224
+ Ê: "E",
225
+ Ë: "E",
226
+ Ì: "I",
227
+ Í: "I",
228
+ Î: "I",
229
+ Ï: "I",
230
+ Ñ: "N",
231
+ Ò: "O",
232
+ Ó: "O",
233
+ Ô: "O",
234
+ Õ: "O",
235
+ Ø: "O",
236
+ Ù: "U",
237
+ Ú: "U",
238
+ Û: "U",
239
+ Ý: "Y",
240
+ Ÿ: "Y",
241
+ };
242
+
243
+ /** A word in upper case ASCII for a records name: `darüber` reads `DARUEBER`. */
244
+ function asciiUpper(word: string): string {
245
+ return [...word.toUpperCase()].map((c) => FOLD[c] ?? (/^[A-Z0-9]$/.test(c) ? c : "")).join("");
246
+ }
247
+
248
+ /** The words of a text, lower case, made of letters only and at least three long. */
249
+ export const nameWords = (text: string) =>
250
+ new Set(
251
+ text
252
+ .split(/[^\p{L}\p{N}]+/u)
253
+ .filter((w) => [...w].length >= 3 && /^\p{L}+$/u.test(w))
254
+ .map((w) => w.toLowerCase()),
255
+ );
256
+
257
+ /**
258
+ * The words next to a link's span on its line, which may say how the line relates the two ends:
259
+ * the two nearest words before the span, else after it, made of letters only, at least three
260
+ * long, and not in `skip` (the words of the two ends). `Sanitärobjekte siehe Baubeschreibung
261
+ * Kap. 3.2`, span `3.2`, skipping the object's words, gives `siehe`, `Kap`.
262
+ */
263
+ export function spanPhrase(line: string, span: string, skip: Set<string>): string[] {
264
+ const words = [...line.matchAll(/[\p{L}\p{N}]+/gu)].map((m) => ({
265
+ word: m[0],
266
+ from: m.index,
267
+ to: m.index + m[0].length,
268
+ }));
269
+ const usable = (w: string) =>
270
+ [...w].length >= 3 && /^\p{L}+$/u.test(w) && !skip.has(w.toLowerCase());
271
+ const at = span.trim() ? line.indexOf(span.trim()) : -1;
272
+ const [from, to] = at >= 0 ? [at, at + span.trim().length] : [line.length, line.length];
273
+ const before = words.filter((w) => w.to <= from && usable(w.word)).map((w) => w.word);
274
+ if (before.length) return before.slice(-2);
275
+ return words
276
+ .filter((w) => w.from >= to && usable(w.word))
277
+ .map((w) => w.word)
278
+ .slice(0, 2);
279
+ }
280
+
281
+ /**
282
+ * A relation name for a property the ontology lacks, from the words next to the spans of its
283
+ * lines: the phrase most lines share (the most probable line's first on a tie), in upper case
284
+ * ASCII (`SIEHE_KAP`), with a number when the ontology has that name. `undefined` when no line
285
+ * has such words.
286
+ */
287
+ export function relationNameFromLines(phrases: string[][], taken: Set<string>): string | undefined {
288
+ const counts = new Map<string, number>();
289
+ for (const phrase of phrases) {
290
+ const name = phrase.map(asciiUpper).filter(Boolean).join("_");
291
+ if (name) counts.set(name, (counts.get(name) ?? 0) + 1);
292
+ }
293
+ const most = Math.max(0, ...counts.values());
294
+ const name = [...counts].find(([, n]) => n === most)?.[0];
295
+ return name ? freeName(name, taken) : undefined;
296
+ }
297
+
298
+ /** A relation name when the lines have no words for one: `HAS_<CLASS>`, with a number when taken. */
299
+ export function lackingRelationName(objectClass: string, taken: Set<string>): string {
300
+ return freeName(`HAS_${humanize(objectClass).toUpperCase().replace(/ /g, "_")}`, taken);
301
+ }
302
+
303
+ /** A class name for entries no class fits: the longest word of the document's name and `Item`, with a number when taken. */
304
+ export function lackingClassName(document: string, taken: Set<string> = new Set()): string {
305
+ const stem = document.includes(".") ? document.slice(0, document.lastIndexOf(".")) : document;
306
+ const word =
307
+ stem
308
+ .split(/[^\p{L}]+/u)
309
+ .filter((w) => [...w].length >= 4)
310
+ .sort((a, b) => [...b].length - [...a].length)[0] ?? "Document";
311
+ return freeName(`${word[0]!.toUpperCase()}${word.slice(1)}Item`, taken, "");
312
+ }
313
+
314
+ /** A SHA-256 of the parts, as hex. */
315
+ export const hash = (...parts: string[]) =>
316
+ createHash("sha256").update(parts.join("\u0000")).digest("hex");
317
+
318
+ /**
319
+ * The IRI of the new instance an entry proposes: one per document and
320
+ * identifier key, so the same entry always proposes the same instance and
321
+ * finds it again once the review wrote it.
322
+ */
323
+ export const mintedIri = (sha256: string, identifier: string) =>
324
+ ENTITY_IRI_NAMESPACE + hash("lbb.map.instance", sha256, identifierKey(identifier)).slice(0, 32);
325
+
326
+ // ---- the ontology ----------------------------------------------------------------------
327
+
328
+ export interface MapClass {
329
+ iri: string;
330
+ /** The name an evolve operation takes. */
331
+ name: string;
332
+ /** Direct superclasses, by IRI. */
333
+ supers: string[];
334
+ }
335
+
336
+ export interface MapProperty {
337
+ iri: string;
338
+ name: string;
339
+ /** Class IRIs; empty: any class. */
340
+ domain: string[];
341
+ range: string[];
342
+ symmetric: boolean;
343
+ /** The inverse property, by IRI. */
344
+ inverse?: string;
345
+ }
346
+
347
+ /** One property option of a typing question. */
348
+ export interface PropertyOption {
349
+ /** The property's name, `<name>.reverse` for the other direction. */
350
+ key: string;
351
+ property: string;
352
+ /** The statement runs from the object to the subject. */
353
+ reverse: boolean;
354
+ label: string;
355
+ }
356
+
357
+ /** An option key from a name: ASCII letters, digits and `_`. */
358
+ const optionKey = (name: string) => name.replace(/[^A-Za-z0-9_]/g, "_").replace(/^_+|_+$/g, "");
359
+
360
+ /** Keys made unique (`name`, `name_2`, …), never one of the fixed options. */
361
+ function uniqueKeys(items: [string, string][]): Map<string, string> {
362
+ const taken = new Set(FIXED);
363
+ const out = new Map<string, string>();
364
+ for (const [iri, key] of items) {
365
+ let candidate = key;
366
+ for (let n = 2; taken.has(candidate); n++) candidate = `${key}_${n}`;
367
+ taken.add(candidate);
368
+ out.set(iri, candidate);
369
+ }
370
+ return out;
371
+ }
372
+
373
+ /** The classes and relations of the graph's records ontology that a mapping offers. */
374
+ export class MapOntology {
375
+ readonly classes: Map<string, MapClass>;
376
+
377
+ constructor(
378
+ classes: MapClass[],
379
+ readonly properties: MapProperty[],
380
+ ) {
381
+ this.classes = new Map(classes.map((c) => [c.iri, c]));
382
+ }
383
+
384
+ /** The ontology of `GET /v1/ontology`, without LBB's own trace. */
385
+ static fromView(view: Schemas["OntologyView"]): MapOntology {
386
+ const types = (view.entity_type_defs ?? []).filter((t) => t.removed_version == null);
387
+ const iriOf = new Map<string, string>();
388
+ for (const t of types) {
389
+ const iri = t.iri || classIri(t.name);
390
+ iriOf.set(t.name.toLowerCase(), iri);
391
+ if (t.stable_id) iriOf.set(t.stable_id.toLowerCase(), iri);
392
+ }
393
+ const trace = new Set(TRACE_CLASSES.map((c) => c.toLowerCase()));
394
+ const classes = types
395
+ .filter((t) => !trace.has(t.name.toLowerCase()))
396
+ .map((t) => ({
397
+ iri: iriOf.get(t.name.toLowerCase())!,
398
+ name: t.name,
399
+ supers: (t.super_types ?? []).flatMap((s) => iriOf.get(s.toLowerCase()) ?? []),
400
+ }));
401
+ const traceRelations = new Set(TRACE_RELATIONS.map((r) => r.toLowerCase()));
402
+ const properties = (view.relation_defs ?? [])
403
+ .filter((r) => r.removed_version == null && !traceRelations.has(r.name.toLowerCase()))
404
+ .map((r) => ({
405
+ iri: relationIri(r.name),
406
+ name: r.name,
407
+ domain: r.src_types.flatMap((n) => iriOf.get(n.toLowerCase()) ?? []),
408
+ range: r.dst_types.flatMap((n) => iriOf.get(n.toLowerCase()) ?? []),
409
+ symmetric: r.symmetric,
410
+ ...(r.inverse_name?.trim() ? { inverse: relationIri(r.inverse_name) } : {}),
411
+ }));
412
+ // An inverse is mutual.
413
+ for (const p of properties)
414
+ if (p.inverse) {
415
+ const other = properties.find((q) => q.iri === p.inverse);
416
+ if (other && !other.inverse) Object.assign(other, { inverse: p.iri });
417
+ }
418
+ return new MapOntology(classes, properties);
419
+ }
420
+
421
+ /** A class and every superclass of it. */
422
+ ancestors(iri: string): Set<string> {
423
+ const out = new Set<string>();
424
+ const queue = [iri];
425
+ while (queue.length) {
426
+ const next = queue.pop()!;
427
+ if (out.has(next)) continue;
428
+ out.add(next);
429
+ queue.push(...(this.classes.get(next)?.supers ?? []));
430
+ }
431
+ return out;
432
+ }
433
+
434
+ classLabel(iri: string): string {
435
+ return classLabel(this.classes.get(iri)?.name ?? localName(iri));
436
+ }
437
+
438
+ /** The key of each class in a class question: its name, made unique. */
439
+ classKeys(): Map<string, string> {
440
+ return uniqueKeys([...this.classes.values()].map((c) => [c.iri, optionKey(c.name) || "class"]));
441
+ }
442
+
443
+ /**
444
+ * The property options of a link from an instance of `subject` to one of
445
+ * `object`: each property whose domain fits one end and whose range fits
446
+ * the other (through superclasses; an empty domain or range fits any
447
+ * class), in both directions, the declared ones first, then those that
448
+ * share the most words with the line, at most 12. A symmetric property
449
+ * goes one way; the other direction of a property with an inverse is the
450
+ * inverse.
451
+ */
452
+ propertyOptions(subject: string, object: string, line: string): PropertyOption[] {
453
+ return this.fittingProperties(subject, object, line).slice(0, PROPERTY_OPTIONS);
454
+ }
455
+
456
+ /** The IRIs of every relation that fits a link from `subject` to `object`, either way round, before the cut to 12. */
457
+ propertiesBetween(subject: string, object: string): Set<string> {
458
+ return new Set(this.fittingProperties(subject, object, "").map((o) => o.property));
459
+ }
460
+
461
+ /** `propertyOptions` without the cut. */
462
+ private fittingProperties(subject: string, object: string, line: string): PropertyOption[] {
463
+ const [up, down] = [this.ancestors(subject), this.ancestors(object)];
464
+ const fits = (declared: string[], classes: Set<string>) =>
465
+ declared.length === 0 || declared.some((c) => classes.has(c));
466
+ const keys = uniqueKeys(this.properties.map((p) => [p.iri, optionKey(p.name) || "property"]));
467
+ const forward = new Set(
468
+ this.properties.filter((p) => fits(p.domain, up) && fits(p.range, down)).map((p) => p.iri),
469
+ );
470
+ const lineWords = words(line);
471
+ const found: { open: number; score: number; option: PropertyOption }[] = [];
472
+ for (const p of this.properties) {
473
+ const key = keys.get(p.iri)!;
474
+ const label = humanize(p.name);
475
+ const score = overlap(lineWords, `${label} ${p.name}`);
476
+ const open = p.domain.length && p.range.length ? 0 : 1;
477
+ if (forward.has(p.iri))
478
+ found.push({ open, score, option: { key, property: p.iri, reverse: false, label } });
479
+ const inverseOffered = p.inverse !== undefined && forward.has(p.inverse);
480
+ if (fits(p.domain, down) && fits(p.range, up) && !p.symmetric && !inverseOffered)
481
+ found.push({
482
+ open,
483
+ score,
484
+ option: { key: `${key}.reverse`, property: p.iri, reverse: true, label },
485
+ });
486
+ }
487
+ found.sort(
488
+ (a, b) =>
489
+ a.open - b.open ||
490
+ b.score - a.score ||
491
+ (a.option.key < b.option.key ? -1 : a.option.key > b.option.key ? 1 : 0),
492
+ );
493
+ return found.map((f) => f.option);
494
+ }
495
+
496
+ /** The part of the ontology a typing request's state shows: the classes and properties, by name. */
497
+ stateSubset(classes: Set<string>, properties: Set<string>): Record<string, unknown> {
498
+ const name = (iri: string) => this.classes.get(iri)?.name ?? localName(iri);
499
+ return {
500
+ classes: Object.fromEntries(
501
+ [...classes]
502
+ .filter((iri) => this.classes.has(iri))
503
+ .map((iri) => [name(iri), { label: this.classLabel(iri) }]),
504
+ ),
505
+ properties: Object.fromEntries(
506
+ this.properties
507
+ .filter((p) => properties.has(p.iri))
508
+ .map((p) => [
509
+ p.name,
510
+ {
511
+ label: humanize(p.name),
512
+ domain: p.domain.map(name),
513
+ range: p.range.map(name),
514
+ symmetric: p.symmetric,
515
+ },
516
+ ]),
517
+ ),
518
+ };
519
+ }
520
+ }
521
+
522
+ // ---- instances and entries ------------------------------------------------------------
523
+
524
+ /** An instance a question is about: one the graph holds, or a new one the mapping proposes. */
525
+ export interface MapInstance {
526
+ iri: string;
527
+ /** The class IRI, when the instance has a class of the ontology. */
528
+ class?: string;
529
+ classLabel: string;
530
+ identifier?: string;
531
+ label: string;
532
+ description?: string;
533
+ isNew: boolean;
534
+ }
535
+
536
+ /** How a question names an instance briefly: its identifier, else its label. */
537
+ export const short = (i: MapInstance) => i.identifier?.trim() || clip(i.label, 60);
538
+
539
+ /** How a question names an instance in full. */
540
+ export function long(i: MapInstance): string {
541
+ const cls = i.classLabel.toLowerCase();
542
+ if (i.identifier && i.description?.trim())
543
+ return `${cls} ${i.identifier} ('${clip(i.description, DESCRIPTION_CHARS)}')`;
544
+ if (i.identifier) return `${cls} ${i.identifier}`;
545
+ return `${cls} '${clip(i.label, DESCRIPTION_CHARS)}'`;
546
+ }
547
+
548
+ /** An instance in a request's state. */
549
+ export const instanceState = (i: MapInstance) => ({
550
+ class: i.classLabel,
551
+ ...(i.identifier ? { identifier: i.identifier } : {}),
552
+ name: clip(i.label, DESCRIPTION_CHARS),
553
+ ...(i.description?.trim() ? { description: clip(i.description, DESCRIPTION_CHARS) } : {}),
554
+ });
555
+
556
+ /** What a grounding question shows of an entry. */
557
+ export interface EntryView {
558
+ identifier: string;
559
+ description: string;
560
+ context: string;
561
+ document: string;
562
+ /** The page, from 1. */
563
+ page: number;
564
+ }
565
+
566
+ const entryState = (e: EntryView) => ({
567
+ identifier: e.identifier,
568
+ description: clip(e.description, DESCRIPTION_CHARS),
569
+ context: clip(e.context, CONTEXT_CHARS),
570
+ });
571
+
572
+ /** A candidate instance of a grounding question. */
573
+ export interface CandidateView {
574
+ iri: string;
575
+ class?: string;
576
+ classLabel: string;
577
+ label: string;
578
+ /** `[property name, value]`, a few. */
579
+ facts: [string, string][];
580
+ }
581
+
582
+ /** A decide question with its items in the request's state. */
583
+ export interface Member {
584
+ id: string;
585
+ question: DecideQuestion;
586
+ items: Record<string, unknown>;
587
+ }
588
+
589
+ /** The grounding question of one entry (`g<id>`): its candidates, a new instance, or none. */
590
+ export function groundQuestion(id: string, entry: EntryView, candidates: CandidateView[]): Member {
591
+ const criteria: Record<string, string> = {};
592
+ const listed: Record<string, unknown> = {};
593
+ candidates.forEach((c, n) => {
594
+ criteria[`c${n}`] = `${clip(c.label, 80)} (${c.classLabel})`;
595
+ listed[`c${n}`] = {
596
+ class: c.classLabel,
597
+ label: clip(c.label, DESCRIPTION_CHARS),
598
+ facts: Object.fromEntries(c.facts.map(([p, v]) => [p, clip(v, FACT_CHARS)])),
599
+ };
600
+ });
601
+ criteria[NEW_INSTANCE] =
602
+ "A new instance: the entry is a thing of the graph's domain, and no candidate is that thing.";
603
+ criteria[NO_INSTANCE] =
604
+ "None: the entry is not a thing the graph holds (a section of the text, a note, a heading).";
605
+ return {
606
+ id: `g${id}`,
607
+ question: {
608
+ instructions: `Which instance of the graph is the entry "${entry.identifier}" of ${entry.document}, page ${entry.page}? The state gives the entry's text and each candidate's class, label and facts. Choose a candidate only when the entry describes that same thing. Choose 'a new instance' when the entry is a thing of the graph's domain that no candidate is, and 'none' when it is not.`,
609
+ criteria,
610
+ },
611
+ items: { [`g${id}`]: { ...entryState(entry), candidates: listed } },
612
+ };
613
+ }
614
+
615
+ /** The class question of one new instance (`k<id>`): `options` are class IRIs. */
616
+ export function classQuestion(
617
+ id: string,
618
+ entry: EntryView,
619
+ options: string[],
620
+ ontology: MapOntology,
621
+ keys: Map<string, string>,
622
+ ): Member {
623
+ const criteria: Record<string, string> = {};
624
+ for (const iri of options.slice(0, CLASS_OPTIONS)) {
625
+ const key = keys.get(iri);
626
+ if (key && ontology.classes.has(iri)) criteria[key] = ontology.classLabel(iri);
627
+ }
628
+ criteria[NO_CLASS] = "No class of the ontology fits this entry: a class the ontology lacks.";
629
+ return {
630
+ id: `k${id}`,
631
+ question: {
632
+ instructions: `The entry "${entry.identifier}" of ${entry.document}, page ${entry.page}, is a new instance of the graph: "${clip(entry.description, DESCRIPTION_CHARS)}". Which class of the ontology is it an instance of? Choose a class by its meaning (its comment), not by its name alone. Choose 'no class fits' when the entry is a kind of thing none of these classes means.`,
633
+ criteria,
634
+ },
635
+ items: { [`k${id}`]: entryState(entry) },
636
+ };
637
+ }
638
+
639
+ /** The classes a class question offers: the candidates' classes first, then by the words they share with the entry. */
640
+ export function classOptions(
641
+ ontology: MapOntology,
642
+ candidateClasses: string[],
643
+ entryText: string,
644
+ ): string[] {
645
+ const out = [...new Set(candidateClasses.filter((c) => ontology.classes.has(c)))];
646
+ const entryWords = words(entryText);
647
+ const rest = [...ontology.classes.values()]
648
+ .filter((c) => !out.includes(c.iri))
649
+ .map((c) => ({ iri: c.iri, score: overlap(entryWords, `${c.name} ${classLabel(c.name)}`) }))
650
+ .sort((a, b) => b.score - a.score || (a.iri < b.iri ? -1 : 1));
651
+ return [...out, ...rest.map((c) => c.iri)].slice(0, CLASS_OPTIONS);
652
+ }
653
+
654
+ /** One link a typing question asks about. */
655
+ export interface TypingView {
656
+ document: string;
657
+ /** The page, from 1. */
658
+ page: number;
659
+ lineId: string;
660
+ lineText: string;
661
+ rowText: string;
662
+ span: string;
663
+ subject: MapInstance;
664
+ object: MapInstance;
665
+ }
666
+
667
+ /** The typing question of one link (`t<id>`): its property options, same item, no fact, a property the ontology lacks. */
668
+ export function typingQuestion(id: string, view: TypingView, options: PropertyOption[]): Member {
669
+ const [s, o] = [short(view.subject), short(view.object)];
670
+ const criteria: Record<string, string> = {};
671
+ for (const option of options) {
672
+ const [from, to] = option.reverse ? [o, s] : [s, o];
673
+ criteria[option.key] = `${from} ${option.label} ${to}.`;
674
+ }
675
+ criteria[SAME_ITEM] = `${s} and ${o} are the same item, described in two places.`;
676
+ criteria[NO_FACT] = `The line states no fact that relates ${s} and ${o}.`;
677
+ criteria[MISSING_PROPERTY] =
678
+ `The line states a fact that relates ${s} and ${o}, but none of the ontology's properties expresses it: a property the ontology lacks.`;
679
+ return {
680
+ id: `t${id}`,
681
+ question: {
682
+ instructions: `Line ${view.lineId} of ${view.document} page ${view.page} reads: "${view.lineText}". Its row reads: "${view.rowText}". The span "${view.span}" in this line names ${long(view.object)} (instance o${id}). The line belongs to ${long(view.subject)} (instance s${id}). Which statement does this line make about ${s} and ${o}?${TYPING_GUIDE}`,
683
+ criteria,
684
+ },
685
+ items: { [`s${id}`]: instanceState(view.subject), [`o${id}`]: instanceState(view.object) },
686
+ };
687
+ }
688
+
689
+ /**
690
+ * The decide requests of `members` around one state: at most 32 questions
691
+ * and 60,000 characters each. `key` names the state's field for the items.
692
+ */
693
+ export function packRequests(
694
+ state: Record<string, unknown>,
695
+ key: string,
696
+ members: Member[],
697
+ ): Member[][] {
698
+ const out: Member[][] = [];
699
+ let current: Member[] = [];
700
+ for (const member of members) {
701
+ const trial = [...current, member];
702
+ const size = JSON.stringify(buildRequest(state, key, trial)).length;
703
+ if (current.length && (trial.length > REQUEST_QUESTIONS || size > REQUEST_CHARS)) {
704
+ out.push(current);
705
+ current = [member];
706
+ } else current = trial;
707
+ }
708
+ if (current.length) out.push(current);
709
+ return out;
710
+ }
711
+
712
+ /** The decide request of `members`: the state with their items under `key`, and their questions. */
713
+ export function buildRequest(
714
+ state: Record<string, unknown>,
715
+ key: string,
716
+ members: Member[],
717
+ ): DecideRequest {
718
+ return {
719
+ state: { ...state, [key]: Object.assign({}, ...members.map((m) => m.items)) },
720
+ questions: Object.fromEntries(members.map((m) => [m.id, m.question])),
721
+ };
722
+ }
723
+
724
+ /** One answer, kept small: the choice, its probability, and the options of at least 0.001. */
725
+ export type Kept = [choice: string, p: number, options: Record<string, number>];
726
+
727
+ const round3 = (p: number) => Math.round(p * 1000) / 1000;
728
+
729
+ /** The answers of a decide call, kept small for a step's saved output. */
730
+ export function keep(answers: Record<string, DecideAnswer>): Record<string, Kept> {
731
+ const out: Record<string, Kept> = {};
732
+ for (const [id, a] of Object.entries(answers)) {
733
+ if (!a.choice) continue;
734
+ const p = a.probabilities?.[a.choice] ?? a.confidence ?? 0;
735
+ const options = Object.fromEntries(
736
+ Object.entries(a.probabilities ?? {})
737
+ .filter(([, q]) => q >= 0.001)
738
+ .map(([o, q]) => [o, round3(q)]),
739
+ );
740
+ out[id] = [a.choice, round3(Math.min(1, Math.max(0, p))), options];
741
+ }
742
+ return out;
743
+ }
744
+
745
+ // ---- the subject of a mention ------------------------------------------------------------
746
+
747
+ /** Something on a page, with its box `[x0, y0, x1, y1]` normalized to the page. */
748
+ export interface Placed {
749
+ page: number;
750
+ box: number[];
751
+ }
752
+
753
+ const middle = (b: number[]) => (b[1]! + b[3]!) / 2;
754
+ /** Whether `a` comes before or at `b` on one page: an earlier row, or the same row and not right of it. */
755
+ function atOrBefore(a: number[], b: number[]): boolean {
756
+ const [ya, yb] = [middle(a), middle(b)];
757
+ return Math.abs(ya - yb) <= ROW_TOLERANCE ? a[0]! <= b[0]! + 1e-9 : ya < yb;
758
+ }
759
+ const readingOrder = (a: Placed, b: Placed) =>
760
+ atOrBefore(a.box, b.box) && !atOrBefore(b.box, a.box)
761
+ ? -1
762
+ : atOrBefore(b.box, a.box) && !atOrBefore(a.box, b.box)
763
+ ? 1
764
+ : 0;
765
+
766
+ /** An entry the mention's own document defines. */
767
+ export interface OwnEntry extends Placed {
768
+ entry: string;
769
+ identifier: string;
770
+ }
771
+
772
+ /** A mention of the document, with the entry its link names (or null). */
773
+ export interface PageMention extends Placed {
774
+ span: string;
775
+ target: string | null;
776
+ }
777
+
778
+ /** The subject of a mention: an entry, or a page entry made from a mention of the page's head. */
779
+ export type Subject = { entry: string } | { mention: number } | null;
780
+
781
+ /**
782
+ * The subject of each mention's links, in the mention's own document.
783
+ *
784
+ * **A page head.** When the first mention of a page in reading order starts
785
+ * in the page's head (the top 30%), lies below no entry the document defines
786
+ * on the page, and is written unlike most of the page's other mentions (the
787
+ * room number `0.12` above the codes `T1`, `S 1.1`), it names the page's
788
+ * subject: the entry it links to, or else a page entry of its own. Every
789
+ * other mention of the page belongs to it, and a following page with neither
790
+ * a head nor an anchor continues it.
791
+ *
792
+ * **Anchors.** Otherwise the anchors of a page are its own entries written
793
+ * like its first one (the positions of a bill, not the codes its table also
794
+ * defines). A mention belongs to the last anchor at or before it. A mention
795
+ * above the first anchor belongs to it when the anchor starts in the head,
796
+ * else to the last anchor of the page before when that is written the same
797
+ * way (a position that runs onto the next page). A page with neither gives
798
+ * its mentions no subject.
799
+ */
800
+ export function subjects(own: OwnEntry[], mentions: PageMention[]): Subject[] {
801
+ const heads = new Map<number, { index: number; subject: Subject }>();
802
+ for (const page of new Set(mentions.map((m) => m.page))) {
803
+ const indices = mentions
804
+ .map((m, index) => ({ m, index }))
805
+ .filter(({ m }) => m.page === page)
806
+ .sort((a, b) => readingOrder(a.m, b.m) || a.index - b.index);
807
+ if (indices.length < 2) continue;
808
+ const { m: head, index } = indices[0]!;
809
+ if (head.box[1]! > HEAD_BAND) continue;
810
+ // An entry the page defines on the head's row or above it heads the page itself.
811
+ if (own.some((e) => e.page === page && middle(e.box) <= middle(head.box) + ROW_TOLERANCE))
812
+ continue;
813
+ const counts = new Map<string, number>();
814
+ for (const { m } of indices.slice(1))
815
+ counts.set(codeShape(m.span), (counts.get(codeShape(m.span)) ?? 0) + 1);
816
+ const most = [...counts].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))[0]![0];
817
+ if (codeShape(head.span) === most) continue;
818
+ heads.set(page, { index, subject: head.target ? { entry: head.target } : { mention: index } });
819
+ }
820
+ const anchors = new Map<number, { shape: string; list: OwnEntry[] }>();
821
+ for (const page of new Set(own.map((e) => e.page))) {
822
+ const entries = own
823
+ .filter((e) => e.page === page)
824
+ .sort((a, b) => readingOrder(a, b) || (a.identifier < b.identifier ? -1 : 1));
825
+ const shape = codeShape(entries[0]!.identifier);
826
+ anchors.set(page, { shape, list: entries.filter((e) => codeShape(e.identifier) === shape) });
827
+ }
828
+ return mentions.map((m, index) => {
829
+ const head = heads.get(m.page);
830
+ if (head) return head.index === index ? null : head.subject;
831
+ const page = anchors.get(m.page);
832
+ // Neither a head nor an anchor: the head of the page before continues onto it.
833
+ if (!page) return heads.get(m.page - 1)?.subject ?? null;
834
+ const found = page.list.filter((a) => atOrBefore(a.box, m.box)).at(-1);
835
+ if (found) return { entry: found.entry };
836
+ if (page.list[0]!.box[1]! <= HEAD_BAND) return { entry: page.list[0]!.entry };
837
+ const previous = anchors.get(m.page - 1);
838
+ return previous && previous.shape === page.shape
839
+ ? { entry: previous.list.at(-1)!.entry }
840
+ : null;
841
+ });
842
+ }
843
+
844
+ // ---- proposals -------------------------------------------------------------------------------
845
+
846
+ /** One proposed statement: IRIs, or a literal object with its datatype. */
847
+ export interface Statement {
848
+ subject: string;
849
+ predicate: string;
850
+ object: string;
851
+ datatype?: string;
852
+ }
853
+
854
+ /** The key of a proposal: from its statement and its proposer, so the same proposal again names the same entity. */
855
+ export const proposalKey = (s: Statement, proposedBy: string) =>
856
+ `proposal:${hash("fact_proposal_v1", s.subject, s.predicate, s.object, s.datatype ?? "", proposedBy).slice(0, 32)}`;
857
+
858
+ /** One proposal as a run assembles it. */
859
+ export interface Proposal {
860
+ statement: Statement;
861
+ /** `fact`, `same_item`, `instance` or `denotes`. */
862
+ kind: string;
863
+ /** The statement in words. */
864
+ name: string;
865
+ confidence: number;
866
+ /** The evidence of each source, the most confident first, at most 8. */
867
+ sources: { confidence: number; evidence: Record<string, unknown> }[];
868
+ }
869
+
870
+ /** Add a source to the proposal of its statement (a new proposal, or a source of one). */
871
+ export function addProposal(
872
+ proposals: Map<string, Proposal>,
873
+ proposedBy: string,
874
+ proposal: Omit<Proposal, "sources"> & { evidence: Record<string, unknown> },
875
+ ): void {
876
+ const key = proposalKey(proposal.statement, proposedBy);
877
+ const { evidence, ...rest } = proposal;
878
+ const source = { confidence: round3(rest.confidence), evidence };
879
+ const found = proposals.get(key);
880
+ if (!found) {
881
+ proposals.set(key, { ...rest, confidence: round3(rest.confidence), sources: [source] });
882
+ return;
883
+ }
884
+ found.confidence = Math.max(found.confidence, source.confidence);
885
+ found.sources = [...found.sources, source]
886
+ .sort((a, b) => b.confidence - a.confidence)
887
+ .slice(0, 8);
888
+ }
889
+
890
+ /** The row a proposer commits for one proposal: the `FactProposal` entity and its fields. */
891
+ export function proposalRow(
892
+ key: string,
893
+ proposal: Proposal,
894
+ proposedBy: string,
895
+ ): Schemas["EntityPropertiesInput"] {
896
+ const { statement: s } = proposal;
897
+ return {
898
+ type: FACT_PROPOSAL,
899
+ name: key,
900
+ key,
901
+ properties: {
902
+ fact_subject: s.subject,
903
+ fact_predicate: s.predicate,
904
+ fact_object: s.object,
905
+ ...(s.datatype ? { fact_datatype: s.datatype } : {}),
906
+ fact_confidence: proposal.confidence,
907
+ fact_evidence: evidenceText(proposal),
908
+ proposed_by: proposedBy,
909
+ },
910
+ };
911
+ }
912
+
913
+ /** A proposal's evidence, as JSON text. */
914
+ export const evidenceText = (p: Proposal) =>
915
+ JSON.stringify({ kind: p.kind, name: p.name, sources: p.sources.map((s) => s.evidence) });
916
+
917
+ // ---- ontology suggestions --------------------------------------------------------------------
918
+
919
+ /** An answer that the ontology lacks a class or a property. */
920
+ export interface Lack {
921
+ kind: "class" | "property";
922
+ subjectClass?: string;
923
+ objectClass?: string;
924
+ document: string;
925
+ /** The evidence sample: where it is (`document`, `page` from 1, `line`, `box`) and the answer. */
926
+ sample: Record<string, unknown>;
927
+ p: number;
928
+ /** The answer's probability per option key. */
929
+ options: Record<string, number>;
930
+ /** A property lack: the relation IRI of each property option its question offered, by key. */
931
+ offered?: Record<string, string>;
932
+ /** A property lack: the words next to the span (`spanPhrase`). */
933
+ phrase?: string[];
934
+ /** How the rationale quotes it: `p. 2, "line" (0.14 → 3.2, 0.59)`. */
935
+ quote: string;
936
+ /** A class lack: the entry as a title names it, and how its identifier is written. */
937
+ entry?: { name: string; shape: string };
938
+ }
939
+
940
+ /** A new instance whose class question chose a class: a class suggestion names those of its document written alike. */
941
+ export interface ClassedEntry {
942
+ document: string;
943
+ shape: string;
944
+ name: string;
945
+ classLabel: string;
946
+ p: number;
947
+ }
948
+
949
+ /** What an ontology suggestion of a pattern says, before it is filed. */
950
+ export interface SuggestionDraft {
951
+ title: string;
952
+ rationale: string;
953
+ change: Schemas["OntologyEvolveOp"][];
954
+ records: number;
955
+ samples: Record<string, unknown>[];
956
+ }
957
+
958
+ /** The name keys an ontology has: its classes' and its relations' names and labels. */
959
+ export function takenNames(ontology: MapOntology): Set<string> {
960
+ return new Set([
961
+ ...[...ontology.classes.values()].flatMap((c) => [
962
+ nameKey(c.name),
963
+ nameKey(classLabel(c.name)),
964
+ ]),
965
+ ...ontology.properties.flatMap((p) => [nameKey(p.name), nameKey(humanize(p.name))]),
966
+ ]);
967
+ }
968
+
969
+ /** An answer in words. */
970
+ export function answerWords(choice: string, property?: string): string {
971
+ if (choice === MISSING_PROPERTY) return "a property the ontology lacks";
972
+ if (choice === NO_FACT) return "no fact";
973
+ if (choice === SAME_ITEM) return "the same item";
974
+ if (choice === NO_CLASS) return "no class fits";
975
+ if (property && choice === "declared")
976
+ return `${humanize(localName(property))} (a declared mapping)`;
977
+ return property ? humanize(localName(property)) : choice;
978
+ }
979
+
980
+ const counted = (n: number, one: string, many: string) => (n === 1 ? `1 ${one}` : `${n} ${many}`);
981
+ const RATIONALE_LINES = 5;
982
+ const SUGGESTION_EXAMPLES = 10;
983
+
984
+ /** The quotes of the first lacks, and how many more there are. */
985
+ function quotes(members: Lack[]): string {
986
+ const shown = members.slice(0, RATIONALE_LINES).map((l) => l.quote);
987
+ const more = members.length - shown.length;
988
+ return more > 0 ? `${shown.join("; ")}; and ${more} more` : shown.join("; ");
989
+ }
990
+
991
+ /**
992
+ * The relation suggestion of the lines that relate an instance of `subject` to one of `object`
993
+ * in `document` in a way no relation states, or the lines as unmapped links when it is not filed.
994
+ * The existing relations that fit the two classes (either way round) overlap it: a line backs
995
+ * it only when its question offered each of them. With an overlap it is filed only when
996
+ * `OVERLAP_LINES` lines back it, or one at `OVERLAP_ALONE_AT` or more, and its rationale names
997
+ * each overlapping relation with the most a backing line's answer gave it. The name comes from
998
+ * the words next to the spans (`relationNameFromLines`), else `HAS_<CLASS>`.
999
+ */
1000
+ export function relationDraft(
1001
+ ontology: MapOntology,
1002
+ subject: string,
1003
+ object: string,
1004
+ document: string,
1005
+ members: Lack[],
1006
+ ): { draft: SuggestionDraft } | { unmapped: Record<string, unknown>[] } {
1007
+ const name = (iri: string) => ontology.classes.get(iri)?.name ?? localName(iri);
1008
+ const [subjectName, objectName] = [name(subject), name(object)];
1009
+ const overlap = ontology.propertiesBetween(subject, object);
1010
+ const overlapping = ontology.properties.filter((p) => overlap.has(p.iri));
1011
+ const weighed = (lack: Lack) =>
1012
+ [...overlap].every((iri) => Object.values(lack.offered ?? {}).includes(iri));
1013
+ const backing = members.filter(weighed);
1014
+ const enough =
1015
+ !overlap.size ||
1016
+ backing.length >= OVERLAP_LINES ||
1017
+ backing.some((l) => l.p >= OVERLAP_ALONE_AT);
1018
+ const relations = overlapping.map((p) => p.name).join(", ");
1019
+ if (!enough)
1020
+ return {
1021
+ unmapped: members.map((lack) => ({
1022
+ ...lack.sample,
1023
+ outcome: "lacks",
1024
+ reason: weighed(lack)
1025
+ ? `${relations} fits ${subjectName} → ${objectName}: a new relation between them needs ${OVERLAP_LINES} lines whose answer chose a property the ontology lacks, or one at ${OVERLAP_ALONE_AT} or more`
1026
+ : `its typing question did not offer ${relations}, which fits ${subjectName} → ${objectName}`,
1027
+ })),
1028
+ };
1029
+ const taken = takenNames(ontology);
1030
+ const fromLines = relationNameFromLines(
1031
+ backing.map((l) => l.phrase ?? []),
1032
+ taken,
1033
+ );
1034
+ const relation = fromLines ?? lackingRelationName(objectName, taken);
1035
+ const top = backing[0]?.p ?? 0;
1036
+ let rationale = `In ${document}, the decision model found that ${counted(backing.length, "line", "lines")} relate an instance of ${subjectName} to one of ${objectName} in a way no property of the ontology states (a property the ontology lacks, up to ${top.toFixed(2)}): ${quotes(backing)}.`;
1037
+ for (const property of overlapping) {
1038
+ const best = Math.max(
1039
+ 0,
1040
+ ...backing.flatMap((l) =>
1041
+ Object.entries(l.offered ?? {})
1042
+ .filter(([, iri]) => iri === property.iri)
1043
+ .map(([key]) => l.options[key] ?? 0),
1044
+ ),
1045
+ );
1046
+ rationale += ` The ontology has ${property.name} (${humanize(property.name)}), which fits ${subjectName} → ${objectName}. On these lines the model gave it ${best.toFixed(2)} at most: it found that ${property.name} does not state what the lines say.`;
1047
+ }
1048
+ rationale += fromLines
1049
+ ? ` Proposed name: ${relation}, from the words next to the span on the lines.`
1050
+ : ` Proposed name: ${relation}, from the class ${objectName}: the lines have no other words next to the span.`;
1051
+ rationale += " Edit the name, then accept: the next run maps these links again.";
1052
+ return {
1053
+ draft: {
1054
+ title: `Add a relation from ${subjectName} to ${objectName}: ${relation}`,
1055
+ rationale,
1056
+ change: [{ op: "add_relation", name: relation, domain: [subjectName], range: [objectName] }],
1057
+ records: backing.length,
1058
+ samples: backing.slice(0, SUGGESTION_EXAMPLES).map((l) => l.sample),
1059
+ },
1060
+ };
1061
+ }
1062
+
1063
+ /**
1064
+ * The class suggestion of the entries of `document` that are new instances no class fits. Its
1065
+ * title names the entries; its rationale names them, the classes their answers weighed (the most
1066
+ * each got), and the entries of the same document written the same way that got a class: a
1067
+ * person who finds they belong to the new class too dismisses their types in the review. These
1068
+ * entries propose nothing (no type, label, identifier or trace) until a class exists.
1069
+ */
1070
+ export function classDraft(
1071
+ ontology: MapOntology,
1072
+ document: string,
1073
+ members: Lack[],
1074
+ classed: ClassedEntry[],
1075
+ ): SuggestionDraft {
1076
+ const names = members.flatMap((l) => (l.entry ? [l.entry.name] : []));
1077
+ let title: string;
1078
+ if (names.length === 1) title = `Add a class for ${clip(names[0]!, 120)}`;
1079
+ else {
1080
+ const head = `Add a class for ${names.length} entries of ${document}: `;
1081
+ const listed: string[] = [];
1082
+ for (const n of names) {
1083
+ const short = clip(n, 40);
1084
+ const length =
1085
+ [...head].length +
1086
+ listed.reduce((sum, l) => sum + [...l].length + 2, 0) +
1087
+ [...short].length;
1088
+ if (length > 195) {
1089
+ listed.push("…");
1090
+ break;
1091
+ }
1092
+ listed.push(short);
1093
+ }
1094
+ title = head + listed.join(", ");
1095
+ }
1096
+ const labelOf = new Map(
1097
+ [...ontology.classKeys()].map(([iri, key]) => [key, ontology.classLabel(iri)]),
1098
+ );
1099
+ const weighed = new Map<string, number>();
1100
+ for (const lack of members)
1101
+ for (const [key, p] of Object.entries(lack.options)) {
1102
+ const label = labelOf.get(key);
1103
+ if (label) weighed.set(label, Math.max(weighed.get(label) ?? 0, p));
1104
+ }
1105
+ const top = members[0]?.p ?? 0;
1106
+ let rationale = `The decision model found that no class of the ontology fits ${counted(members.length, "entry", "entries")} of ${document} (no class fits, up to ${top.toFixed(2)}): ${quotes(members)}.`;
1107
+ const listed = [...weighed]
1108
+ .sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
1109
+ .slice(0, RATIONALE_LINES)
1110
+ .map(([label, p]) => `${label} ${p.toFixed(2)}`);
1111
+ if (listed.length)
1112
+ rationale += ` The classes the model weighed got at most: ${listed.join(", ")}.`;
1113
+ rationale +=
1114
+ " These entries propose no type, label, identifier or trace until the ontology has a class for them.";
1115
+ const shapes = new Set(members.flatMap((l) => (l.entry ? [l.entry.shape] : [])));
1116
+ const siblings = classed
1117
+ .filter((e) => e.document === document && shapes.has(e.shape))
1118
+ .map((e) => `${clip(e.name, 60)} (${e.classLabel}, ${e.p.toFixed(2)})`);
1119
+ if (siblings.length) {
1120
+ const shown = siblings.slice(0, RATIONALE_LINES);
1121
+ const more = siblings.length - shown.length;
1122
+ rationale += ` Other entries of ${document}, written the same way, got a class of the ontology: ${shown.join(", ")}${more > 0 ? `, and ${more} more` : ""}. When they belong to the new class too, dismiss their types in the review.`;
1123
+ }
1124
+ const cls = lackingClassName(document, takenNames(ontology));
1125
+ rationale += ` Proposed name: ${cls}, from the document's name. Edit the name, then accept: the next run maps them again.`;
1126
+ return {
1127
+ title,
1128
+ rationale,
1129
+ change: [{ op: "add_entity_type", name: cls }],
1130
+ records: members.length,
1131
+ samples: members.slice(0, SUGGESTION_EXAMPLES).map((l) => l.sample),
1132
+ };
1133
+ }
1134
+
1135
+ export { round3 };