@littlebigbrain/create 0.2.0 → 0.4.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 (55) hide show
  1. package/README.md +7 -2
  2. package/lib/create.mjs +28 -4
  3. package/package.json +1 -1
  4. package/templates/_shared/testing.ts +185 -5
  5. package/templates/link/README.md +203 -0
  6. package/templates/link/candidates.ts +432 -0
  7. package/templates/link/decide.test.ts +368 -0
  8. package/templates/link/decide.ts +340 -0
  9. package/templates/link/fixture.ts +727 -0
  10. package/templates/link/grade-run.ts +53 -0
  11. package/templates/link/grade.test.ts +113 -0
  12. package/templates/link/grade.ts +335 -0
  13. package/templates/link/items.ts +333 -0
  14. package/templates/link/names.test.ts +260 -0
  15. package/templates/link/names.ts +590 -0
  16. package/templates/link/reads.ts +302 -0
  17. package/templates/link/samples/gold.json +108 -0
  18. package/templates/link/samples/records.json +48 -0
  19. package/templates/link/samples.ts +64 -0
  20. package/templates/link/settings.ts +162 -0
  21. package/templates/link/setup.ts +18 -0
  22. package/templates/link/source-turn.ts +894 -0
  23. package/templates/link/sources/records.ts +429 -0
  24. package/templates/link/things-turn.ts +984 -0
  25. package/templates/link/things.test.ts +204 -0
  26. package/templates/link/things.ts +426 -0
  27. package/templates/link/try.ts +28 -0
  28. package/templates/link/worker.ts +24 -0
  29. package/templates/link/workflow.test.ts +484 -0
  30. package/templates/link/workflow.ts +202 -0
  31. package/templates/rows/README.md +150 -0
  32. package/templates/rows/csv.ts +260 -0
  33. package/templates/rows/samples/expected.json +150 -0
  34. package/templates/rows/samples/properties.json +11 -0
  35. package/templates/rows/samples/q3-customers.csv +10 -0
  36. package/templates/rows/samples/q3-orders.tsv +5 -0
  37. package/templates/rows/samples/reports.json +5 -0
  38. package/templates/rows/samples.ts +56 -0
  39. package/templates/rows/setup.ts +18 -0
  40. package/templates/rows/try.ts +32 -0
  41. package/templates/rows/worker.ts +24 -0
  42. package/templates/rows/workflow.test.ts +541 -0
  43. package/templates/rows/workflow.ts +657 -0
  44. package/templates/sections/README.md +118 -0
  45. package/templates/sections/markdown.ts +121 -0
  46. package/templates/sections/samples/expected.json +82 -0
  47. package/templates/sections/samples/guides.json +4 -0
  48. package/templates/sections/samples/support-handbook.md +34 -0
  49. package/templates/sections/samples.ts +41 -0
  50. package/templates/sections/setup.ts +18 -0
  51. package/templates/sections/try.ts +32 -0
  52. package/templates/sections/worker.ts +24 -0
  53. package/templates/sections/workflow.test.ts +265 -0
  54. package/templates/sections/workflow.ts +433 -0
  55. package/templates/versions.json +1 -1
@@ -0,0 +1,432 @@
1
+ // The candidates of a mention (general linking 4.3) and their bounds (4.4).
2
+ // The queries are built here and run by the workflow; the scoring is pure.
3
+ //
4
+ // Block keys are graph facts: `block_key` values on each `Mention`. One read
5
+ // counts the mentions per key, so a key that more than 200 mentions hold is
6
+ // a stop key and finds nothing. A second read returns the mentions of the
7
+ // other keys, with the thing each one belongs to. The neighbours are the
8
+ // things of the records within two hops of the mention's source.
9
+ import { KEY_KINDS, keyKind, keyWeight, nameSimilarity, type KeyKind } from "./names.js";
10
+
11
+ /** The bounds of the candidate sources and of a mention (4.4). */
12
+ export const BOUNDS = {
13
+ /** Candidates with an equal strong key, per mention. */
14
+ strong: 10,
15
+ /** Candidates that share a medium or weak key, per mention. */
16
+ other: 10,
17
+ /** Candidates among the neighbours, per mention. */
18
+ neighbours: 10,
19
+ /** Records the neighbours read reads, per source turn. */
20
+ neighbourRecords: 50,
21
+ /** Hops from a mention's source to its neighbours. */
22
+ hops: 2,
23
+ /** Candidates of one mention, from every source. */
24
+ total: 30,
25
+ /** A key that more mentions hold is a stop key. */
26
+ stopKey: 200,
27
+ /** Mentions one text value gives. */
28
+ textMentions: 100,
29
+ /** Rows one key read returns. */
30
+ keyRows: 4_000,
31
+ /** Keys one read asks about. */
32
+ keysPerRead: 500,
33
+ };
34
+
35
+ /** The lowest score a candidate found by a medium or weak key keeps. */
36
+ export const MIN_SCORE = 0.55;
37
+ /** The lowest name similarity a neighbour keeps. */
38
+ export const MIN_NEIGHBOUR_SIMILARITY = 0.6;
39
+
40
+ export const P =
41
+ "PREFIX p: <https://littlebigbrain.com/p/>\nPREFIX r: <https://littlebigbrain.com/r/>\nPREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>\n";
42
+ /** A record's display name, and an entity's name, in the published RDF. */
43
+ export const RDFS_LABEL = "http://www.w3.org/2000/01/rdf-schema#label";
44
+ export const RELATION_IRI = "https://littlebigbrain.com/r/";
45
+ export const PROPERTY_IRI = "https://littlebigbrain.com/p/";
46
+ export const CLASS_IRI = "https://littlebigbrain.com/class/";
47
+
48
+ /** The local part of a class, property or relation IRI, normalized and escaped as the server mints it. */
49
+ export function iriSegment(name: string): string {
50
+ const normalized = name.trim().toLowerCase().split(/\s+/).join(" ");
51
+ let out = "";
52
+ for (const byte of new TextEncoder().encode(normalized)) {
53
+ const char = String.fromCharCode(byte);
54
+ out += /[A-Za-z0-9\-_.~]/.test(char)
55
+ ? char
56
+ : `%${byte.toString(16).toUpperCase().padStart(2, "0")}`;
57
+ }
58
+ return out;
59
+ }
60
+
61
+ export const classIri = (name: string) => `${CLASS_IRI}${iriSegment(name)}`;
62
+ export const propertyIri = (name: string) => `${PROPERTY_IRI}${iriSegment(name)}`;
63
+ export const relationIri = (name: string) => `${RELATION_IRI}${iriSegment(name)}`;
64
+
65
+ /** IRIs as a VALUES list; an IRI a query cannot hold is left out. */
66
+ export const iris = (list: string[]) =>
67
+ list
68
+ .filter((iri) => /^[^\s<>"{}|\\^`]+$/.test(iri))
69
+ .map((iri) => `<${iri}>`)
70
+ .join(" ");
71
+
72
+ /** Strings as a VALUES list of literals. */
73
+ export const literals = (list: string[]) => list.map((s) => JSON.stringify(s)).join(" ");
74
+
75
+ /** The relations of the link vocabulary: a neighbour is never found through them. */
76
+ export const LINK_RELATIONS = [
77
+ "mentioned_in",
78
+ "refers_to",
79
+ "same_thing_as",
80
+ "has_record",
81
+ "merged_into",
82
+ ];
83
+
84
+ // ---- the reads ------------------------------------------------------------------
85
+
86
+ /** How many mentions hold each key. */
87
+ export function keyCountsQuery(keys: string[]): string {
88
+ return `# link: key counts\n${P}SELECT ?key (COUNT(?m) AS ?n) WHERE {
89
+ VALUES ?key { ${literals(keys)} }
90
+ ?m p:block_key ?key .
91
+ } GROUP BY ?key`;
92
+ }
93
+
94
+ export function parseCounts(rows: Record<string, string>[]): Map<string, number> {
95
+ return new Map(rows.map((r) => [r.key!, Number(r.n ?? 0)]));
96
+ }
97
+
98
+ /** The keys that more than `limit` mentions hold. */
99
+ export function stopKeys(counts: Map<string, number>, limit = BOUNDS.stopKey): string[] {
100
+ return [...counts]
101
+ .filter(([, n]) => n > limit)
102
+ .map(([key]) => key)
103
+ .sort();
104
+ }
105
+
106
+ /** The mentions that hold the keys, with what they belong to. */
107
+ export function keyHitsQuery(keys: string[]): string {
108
+ return `# link: keys\n${P}SELECT ?key ?m ?role ?span ?src ?kind ?window ?thing ?label WHERE {
109
+ VALUES ?key { ${literals(keys)} }
110
+ ?m p:block_key ?key ; p:role ?role ; p:span ?span ; r:mentioned_in ?src .
111
+ OPTIONAL { ?m p:kind ?kind } OPTIONAL { ?m p:window ?window }
112
+ OPTIONAL { ?m r:refers_to ?thing . OPTIONAL { ?thing rdfs:label ?label } }
113
+ } LIMIT ${BOUNDS.keyRows}`;
114
+ }
115
+
116
+ /** A mention that holds a looked-up key. */
117
+ export interface KeyHit {
118
+ key: string;
119
+ mention: string;
120
+ role: string;
121
+ span: string;
122
+ source: string;
123
+ kind: string | null;
124
+ window: string | null;
125
+ thing: string | null;
126
+ label: string | null;
127
+ }
128
+
129
+ export function parseHits(rows: Record<string, string>[]): KeyHit[] {
130
+ return rows
131
+ .filter((r) => r.key && r.m && r.src)
132
+ .map((r) => ({
133
+ key: r.key!,
134
+ mention: r.m!,
135
+ role: r.role ?? "refers",
136
+ span: r.span ?? "",
137
+ source: r.src!,
138
+ kind: r.kind ?? null,
139
+ window: r.window ?? null,
140
+ thing: r.thing ?? null,
141
+ label: r.label ?? null,
142
+ }));
143
+ }
144
+
145
+ /** One hop of relations from records, both ways, without the link vocabulary. */
146
+ export function hopQuery(records: string[]): string {
147
+ const skip = LINK_RELATIONS.map((r) => `r:${r}`).join(", ");
148
+ return `# link: hop\n${P}SELECT ?a ?r ?b ?dir ?name ?class WHERE {
149
+ VALUES ?a { ${iris(records)} }
150
+ { ?a ?r ?b BIND("out" AS ?dir) } UNION { ?b ?r ?a BIND("in" AS ?dir) }
151
+ FILTER(STRSTARTS(STR(?r), "${RELATION_IRI}") && ?r NOT IN (${skip}))
152
+ OPTIONAL { ?b rdfs:label ?name } OPTIONAL { ?b a ?class }
153
+ } LIMIT 500`;
154
+ }
155
+
156
+ /** A relation from a record to a neighbour. */
157
+ export interface Hop {
158
+ from: string;
159
+ relation: string;
160
+ to: string;
161
+ dir: "out" | "in";
162
+ name: string;
163
+ class: string;
164
+ }
165
+
166
+ const local = (iri: string, base: string) =>
167
+ iri.startsWith(base) ? decodeURIComponent(iri.slice(base.length)) : iri;
168
+
169
+ export function parseHops(rows: Record<string, string>[]): Hop[] {
170
+ const seen = new Set<string>();
171
+ const out: Hop[] = [];
172
+ for (const r of rows) {
173
+ if (!r.a || !r.r || !r.b) continue;
174
+ const key = `${r.a}\u0000${r.r}\u0000${r.b}\u0000${r.dir}`;
175
+ if (seen.has(key)) continue;
176
+ seen.add(key);
177
+ out.push({
178
+ from: r.a,
179
+ relation: local(r.r, RELATION_IRI).toUpperCase(),
180
+ to: r.b,
181
+ dir: r.dir === "in" ? "in" : "out",
182
+ name: r.name ?? "",
183
+ class: r.class ? local(r.class, CLASS_IRI) : "",
184
+ });
185
+ }
186
+ return out;
187
+ }
188
+
189
+ /** The mentions of the neighbour records, with the things they belong to. */
190
+ export function neighbourMentionsQuery(records: string[]): string {
191
+ return `# link: neighbour mentions\n${P}SELECT ?n ?m ?role ?span ?thing ?label WHERE {
192
+ VALUES ?n { ${iris(records)} }
193
+ ?m r:mentioned_in ?n ; p:role ?role ; p:span ?span .
194
+ OPTIONAL { ?m r:refers_to ?thing . OPTIONAL { ?thing rdfs:label ?label } }
195
+ } LIMIT 2000`;
196
+ }
197
+
198
+ /** A mention of a neighbour record, and how the neighbour is reached from the source. */
199
+ export interface NeighbourMention {
200
+ record: string;
201
+ mention: string;
202
+ role: string;
203
+ span: string;
204
+ thing: string | null;
205
+ label: string | null;
206
+ }
207
+
208
+ export function parseNeighbourMentions(rows: Record<string, string>[]): NeighbourMention[] {
209
+ return rows
210
+ .filter((r) => r.n && r.m)
211
+ .map((r) => ({
212
+ record: r.n!,
213
+ mention: r.m!,
214
+ role: r.role ?? "refers",
215
+ span: r.span ?? "",
216
+ thing: r.thing ?? null,
217
+ label: r.label ?? null,
218
+ }));
219
+ }
220
+
221
+ /**
222
+ * The records within `hops` of each source, and how each is reached: a
223
+ * breadth-first walk over `hops` (one read per hop), at most `limit`
224
+ * records. A neighbour's path reads `ABOUT → Deal "…" → INVOLVES`.
225
+ */
226
+ export function neighbourPaths(
227
+ sources: string[],
228
+ hopsByLevel: Hop[][],
229
+ limit = BOUNDS.neighbourRecords,
230
+ ): Map<string, Map<string, string>> {
231
+ const out = new Map<string, Map<string, string>>();
232
+ let total = 0;
233
+ for (const source of sources) {
234
+ const reached = new Map<string, string>([[source, ""]]);
235
+ let frontier = [source];
236
+ for (const level of hopsByLevel) {
237
+ const next: string[] = [];
238
+ for (const hop of level) {
239
+ if (!frontier.includes(hop.from) || reached.has(hop.to) || total >= limit) continue;
240
+ const before = reached.get(hop.from)!;
241
+ const step = `${hop.relation}${hop.dir === "in" ? " (from)" : ""} → ${hop.class || "record"} "${hop.name.slice(0, 60)}"`;
242
+ reached.set(hop.to, before ? `${before} → ${step}` : step);
243
+ next.push(hop.to);
244
+ total += 1;
245
+ }
246
+ frontier = next;
247
+ }
248
+ reached.delete(source);
249
+ out.set(source, reached);
250
+ }
251
+ return out;
252
+ }
253
+
254
+ // ---- candidates -------------------------------------------------------------------
255
+
256
+ /** A mention whose candidates are wanted. */
257
+ export interface MentionInput {
258
+ iri: string;
259
+ source: string;
260
+ role: "self" | "refers";
261
+ span: string;
262
+ keys: string[];
263
+ /** Mentions a person keeps apart from this one. */
264
+ cannot: string[];
265
+ /** The thing the mention is in now: for a self mention, what it holds is no candidate (it is linked already). */
266
+ thing?: string | null;
267
+ }
268
+
269
+ /** One candidate of a mention: a thing, or a lone mention. */
270
+ export interface Candidate {
271
+ /** The mention a link pairs with: the one that matched, or a member of the neighbour thing. */
272
+ target: string;
273
+ /** The thing the target belongs to; null for a lone mention. */
274
+ thing: string | null;
275
+ label: string;
276
+ /** Up to three spans of the candidate's members that the reads returned. */
277
+ spans: string[];
278
+ /** The records whose self mentions are among those members. */
279
+ records: string[];
280
+ score: number;
281
+ /** The key kind that found it, or `neighbour`. */
282
+ found: KeyKind | "neighbour";
283
+ strong: boolean;
284
+ /** For a neighbour: how it is reached from the mention's source. */
285
+ via?: string;
286
+ /** For a lone mention of text: its sentence. */
287
+ window?: string;
288
+ }
289
+
290
+ /** Group key of a hit: its thing, else the mention itself. */
291
+ const groupOf = (thing: string | null, mention: string) => thing ?? mention;
292
+
293
+ /**
294
+ * The candidates of a mention: the things and lone mentions with an equal
295
+ * strong key (up to 10), the ones that share a medium or weak key, scored
296
+ * by the key weights and the name similarity (up to 10), and the things of
297
+ * the neighbours that are named alike (up to 10); at most 30, the strong
298
+ * ones first. The mention itself, the other self mentions of its record,
299
+ * and what a person keeps apart from it are left out.
300
+ */
301
+ export function candidatesFor(
302
+ m: MentionInput,
303
+ hits: KeyHit[],
304
+ stop: Set<string>,
305
+ neighbours: { mention: NeighbourMention; via: string }[] = [],
306
+ ): Candidate[] {
307
+ const own = new Set(m.keys.filter((k) => !stop.has(k)));
308
+ const banned = new Set(m.cannot);
309
+ const groups = new Map<
310
+ string,
311
+ {
312
+ target: string;
313
+ thing: string | null;
314
+ label: string;
315
+ spans: Set<string>;
316
+ records: Set<string>;
317
+ keys: Set<string>;
318
+ window?: string;
319
+ via?: string;
320
+ }
321
+ >();
322
+ const entry = (thing: string | null, mention: string, label: string) => {
323
+ const key = groupOf(thing, mention);
324
+ let g = groups.get(key);
325
+ if (!g) {
326
+ g = { target: mention, thing, label, spans: new Set(), records: new Set(), keys: new Set() };
327
+ groups.set(key, g);
328
+ }
329
+ return g;
330
+ };
331
+ const skip = (mention: string, source: string, role: string) =>
332
+ mention === m.iri ||
333
+ banned.has(mention) ||
334
+ (m.role === "self" && role === "self" && source === m.source);
335
+ for (const hit of hits) {
336
+ if (!own.has(hit.key) || skip(hit.mention, hit.source, hit.role)) continue;
337
+ if (hit.thing && (banned.has(hit.thing) || (m.role === "self" && hit.thing === m.thing)))
338
+ continue;
339
+ const g = entry(hit.thing, hit.mention, hit.label ?? hit.span);
340
+ g.keys.add(hit.key);
341
+ g.spans.add(hit.span);
342
+ if (hit.role === "self") g.records.add(hit.source);
343
+ if (!hit.thing && hit.window) g.window = hit.window;
344
+ // A strong key names its target: the member that holds it.
345
+ if ([...g.keys].some((k) => keyWeight(k) === "strong") && keyWeight(hit.key) === "strong")
346
+ g.target = hit.mention;
347
+ }
348
+ const scored: Candidate[] = [];
349
+ for (const g of groups.values()) {
350
+ const strongKey = [...g.keys].find((k) => keyWeight(k) === "strong");
351
+ const similarity = Math.max(0, ...[...g.spans].map((s) => nameSimilarity(m.span, s)));
352
+ const keyScore = Math.min(
353
+ 1,
354
+ [...g.keys].reduce((sum, k) => sum + KEY_KINDS[keyKind(k)!].value, 0),
355
+ );
356
+ const best = [...g.keys].sort(
357
+ (a, b) => KEY_KINDS[keyKind(b)!].value - KEY_KINDS[keyKind(a)!].value,
358
+ )[0]!;
359
+ scored.push({
360
+ target: g.target,
361
+ thing: g.thing,
362
+ label: g.label,
363
+ spans: [...g.spans].slice(0, 3),
364
+ records: [...g.records].sort(),
365
+ score: strongKey ? 1 : round(0.5 * keyScore + 0.5 * similarity),
366
+ found: keyKind(strongKey ?? best)!,
367
+ strong: Boolean(strongKey),
368
+ ...(g.window ? { window: g.window } : {}),
369
+ });
370
+ }
371
+ const strong = scored
372
+ .filter((c) => c.strong)
373
+ .sort(byScore)
374
+ .slice(0, BOUNDS.strong);
375
+ const other = scored
376
+ .filter((c) => !c.strong && c.score >= MIN_SCORE)
377
+ .sort(byScore)
378
+ .slice(0, BOUNDS.other);
379
+
380
+ // The neighbours: the things (and lone mentions) of the records near the source.
381
+ const near = new Map<string, Candidate>();
382
+ for (const { mention: n, via } of neighbours) {
383
+ if (
384
+ skip(n.mention, n.record, n.role) ||
385
+ (n.thing && (banned.has(n.thing) || (m.role === "self" && n.thing === m.thing)))
386
+ )
387
+ continue;
388
+ if (n.record === m.source) continue;
389
+ const key = groupOf(n.thing, n.mention);
390
+ const similarity = nameSimilarity(m.span, n.span);
391
+ if (similarity < MIN_NEIGHBOUR_SIMILARITY) continue;
392
+ const before = near.get(key);
393
+ const score = round(0.3 + 0.5 * similarity);
394
+ if (before && before.score >= score) continue;
395
+ near.set(key, {
396
+ target: n.mention,
397
+ thing: n.thing,
398
+ label: n.label ?? n.span,
399
+ spans: [n.span],
400
+ records: n.role === "self" ? [n.record] : [],
401
+ score,
402
+ found: "neighbour",
403
+ strong: false,
404
+ via: n.role === "self" ? via : `named in ${via}`,
405
+ });
406
+ }
407
+ const out: Candidate[] = [];
408
+ const taken = new Map<string, Candidate>();
409
+ for (const c of [...strong, ...other]) {
410
+ out.push(c);
411
+ taken.set(groupOf(c.thing, c.target), c);
412
+ }
413
+ for (const c of [...near.values()].sort(byScore).slice(0, BOUNDS.neighbours)) {
414
+ const key = groupOf(c.thing, c.target);
415
+ const found = taken.get(key);
416
+ if (found) {
417
+ // Found by a key and near the source: both count.
418
+ found.via ??= c.via;
419
+ if (!found.strong) found.score = round(Math.min(1, found.score + 0.05));
420
+ continue;
421
+ }
422
+ out.push(c);
423
+ }
424
+ return [...out.filter((c) => c.strong), ...out.filter((c) => !c.strong).sort(byScore)].slice(
425
+ 0,
426
+ BOUNDS.total,
427
+ );
428
+ }
429
+
430
+ const round = (x: number) => Math.round(x * 1000) / 1000;
431
+ const byScore = (a: Candidate, b: Candidate) =>
432
+ b.score - a.score || (a.target < b.target ? -1 : a.target > b.target ? 1 : 0);