@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,204 @@
1
+ // Things (general linking 2.4, 5.4): the grouping with must-links and
2
+ // cannot-links, the identity proposals, the anchors, the deterministic IRIs,
3
+ // and the merges and splits from one grouping to the next.
4
+ import { test } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { ENTITY_IRI_NAMESPACE } from "@littlebigbrain/client";
7
+ import {
8
+ anchorKeyOf,
9
+ anchorOf,
10
+ group,
11
+ mentionIri,
12
+ nameThings,
13
+ thingIri,
14
+ type Member,
15
+ type Pair,
16
+ } from "./things.js";
17
+
18
+ const e = (n: number | string) => `${ENTITY_IRI_NAMESPACE}${String(n).padStart(32, "0")}`;
19
+ const self = (n: number, record: number, span: string, keyed = false): Member => ({
20
+ iri: e(n),
21
+ role: "self",
22
+ record: e(`r${record}`.replace("r", "9")),
23
+ span,
24
+ kind: "Person",
25
+ keyed,
26
+ });
27
+ const refers = (n: number, span: string): Member => ({
28
+ iri: e(n),
29
+ role: "refers",
30
+ record: null,
31
+ span,
32
+ kind: "person",
33
+ });
34
+ const pair = (a: number, b: number, confidence: number, by: Pair["by"] = "model"): Pair => ({
35
+ a: e(a),
36
+ b: e(b),
37
+ confidence,
38
+ by,
39
+ });
40
+
41
+ test("the IRIs of mentions and things are hashes: pinned", () => {
42
+ const source = "https://littlebigbrain.com/e/0123456789abcdef0123456789abcdef";
43
+ assert.equal(
44
+ mentionIri("samples", source, "name", "Mira Tannenholt-Ruiz", 0),
45
+ "https://littlebigbrain.com/e/2b8a3464c3a38d435f59f67efdc1b28b",
46
+ );
47
+ assert.equal(
48
+ mentionIri("samples", source, "body", "Mimi T.", 1),
49
+ "https://littlebigbrain.com/e/baa1d4333f2ef9b8f8724c9399ef988c",
50
+ );
51
+ assert.equal(
52
+ thingIri("samples", "record:0123456789abcdef0123456789abcdef"),
53
+ "https://littlebigbrain.com/e/bb0279d177d2971f056cf6d82a726bf2",
54
+ );
55
+ assert.equal(
56
+ thingIri("samples", "mention:fedcba9876543210fedcba9876543210"),
57
+ "https://littlebigbrain.com/e/6632612e562605c43a8309e54eee34dd",
58
+ );
59
+ assert.notEqual(
60
+ thingIri("other", "record:0123456789abcdef0123456789abcdef"),
61
+ thingIri("samples", "record:0123456789abcdef0123456789abcdef"),
62
+ );
63
+ });
64
+
65
+ test("a record's self mentions are one thing, anchored by the record, named by its name", () => {
66
+ const members = [self(2, 1, "mira@northbay.example", true), self(1, 1, "Mira Ruiz")];
67
+ const { groups } = group({ members, pairs: [], cannot: [], sameAs: [] });
68
+ assert.deepEqual(groups, [[e(1), e(2)]]);
69
+ const anchor = anchorOf(members);
70
+ assert.equal(anchor.iri, e(1), "the name before the key");
71
+ assert.equal(anchorKeyOf(anchor), `record:${e(91).slice(-32)}`);
72
+ const naming = nameThings("samples", groups, new Map(members.map((m) => [m.iri, m])));
73
+ assert.equal(naming.things[0]!.label, "Mira Ruiz");
74
+ assert.equal(naming.things[0]!.iri, thingIri("samples", anchorKeyOf(anchor)));
75
+ });
76
+
77
+ test("the things of two records stay apart without an accepted identity, and the pair becomes a proposal", () => {
78
+ const members = [
79
+ self(1, 1, "Mira Ruiz"),
80
+ self(2, 2, "Mira Tannenholt-Ruiz"),
81
+ refers(3, "M. Tannenholt"),
82
+ ];
83
+ const pairs = [pair(3, 2, 0.9), pair(3, 1, 0.85)];
84
+ const apart = group({ members, pairs, cannot: [], sameAs: [] });
85
+ assert.equal(apart.groups.length, 2);
86
+ assert.equal(apart.blockedByRecords, 1);
87
+ assert.deepEqual(
88
+ apart.proposals.map((p) => p.records),
89
+ [[e(91), e(92)]],
90
+ );
91
+ // An accepted identity joins them; nothing is proposed.
92
+ const joined = group({ members, pairs, cannot: [], sameAs: [[e(91), e(92)]] });
93
+ assert.deepEqual(joined.groups, [[e(1), e(2), e(3)]]);
94
+ assert.deepEqual(joined.proposals, []);
95
+ });
96
+
97
+ test("a cannot-link keeps two mentions apart, also through other pairs; a person's pairs join first", () => {
98
+ const members = [refers(1, "Lena Fair"), refers(2, "L. Fair"), refers(3, "Lena Fairweather")];
99
+ const pairs = [pair(1, 2, 0.95), pair(2, 3, 0.9)];
100
+ const kept = group({ members, pairs, cannot: [[e(1), e(3)]], sameAs: [] });
101
+ assert.deepEqual(
102
+ kept.groups,
103
+ [[e(1), e(2)], [e(3)]],
104
+ "the higher pair joins, the lower one is blocked",
105
+ );
106
+ assert.equal(kept.blockedByPerson, 1);
107
+ // A person's must-link joins before the model's higher pair.
108
+ const person = group({
109
+ members,
110
+ pairs: [...pairs, pair(2, 3, 0.5, "person")],
111
+ cannot: [[e(1), e(3)]],
112
+ sameAs: [],
113
+ });
114
+ assert.deepEqual(person.groups, [[e(1)], [e(2), e(3)]]);
115
+ });
116
+
117
+ test("an identity of more than 10 records is not proposed; a dismissed identity is not proposed again", () => {
118
+ const members = Array.from({ length: 12 }, (_, n) => self(n + 1, n + 1, "Alex Brandt"));
119
+ const identities = members.slice(1).map((m) => ({
120
+ a: members[0]!.record!,
121
+ b: m.record!,
122
+ confidence: 1,
123
+ reason: "a team address",
124
+ }));
125
+ const big = group({ members, pairs: [], cannot: [], sameAs: [], identities });
126
+ assert.equal(big.tooLarge, 1);
127
+ assert.deepEqual(big.proposals, []);
128
+ const two = members.slice(0, 2);
129
+ const dismissed = group({
130
+ members: two,
131
+ pairs: [],
132
+ cannot: [[two[0]!.iri, two[1]!.iri]],
133
+ sameAs: [],
134
+ identities: [identities[0]!],
135
+ });
136
+ assert.deepEqual(dismissed.proposals, []);
137
+ });
138
+
139
+ test("a thing of mentions alone takes the smallest key; a record that joins becomes its anchor, and the old IRI points to it", () => {
140
+ const notes = [refers(5, "Bob Haldane"), refers(4, "Bob Haldane")];
141
+ const first = group({ members: notes, pairs: [pair(5, 4, 0.96)], cannot: [], sameAs: [] });
142
+ const map1 = new Map(notes.map((m) => [m.iri, m]));
143
+ const before = nameThings("samples", first.groups, map1);
144
+ assert.equal(before.things[0]!.anchorKey, `mention:${e(4).slice(-32)}`);
145
+
146
+ const robert = self(1, 7, "Robert Haldane");
147
+ const all = [...notes, robert];
148
+ const second = group({
149
+ members: all,
150
+ pairs: [pair(5, 4, 0.96), pair(1, 4, 0.9)],
151
+ cannot: [],
152
+ sameAs: [],
153
+ });
154
+ const after = nameThings("samples", second.groups, new Map(all.map((m) => [m.iri, m])), [
155
+ { iri: before.things[0]!.iri, anchorKey: before.things[0]!.anchorKey, members: [e(4), e(5)] },
156
+ ]);
157
+ assert.equal(after.things.length, 1);
158
+ assert.equal(after.things[0]!.anchorKey, `record:${e(97).slice(-32)}`);
159
+ assert.deepEqual(after.merged, [{ from: before.things[0]!.iri, into: after.things[0]!.iri }]);
160
+ assert.deepEqual(after.things[0]!.aliases, [], "a nickname of the label is no alias");
161
+ });
162
+
163
+ test("a split keeps the anchor's part at its IRI, and the other part gets the IRI of its own anchor", () => {
164
+ const members = [self(1, 7, "Robert Haldane"), refers(4, "Bob Haldane"), refers(5, "R. Haldane")];
165
+ const map = new Map(members.map((m) => [m.iri, m]));
166
+ const together = nameThings(
167
+ "samples",
168
+ group({ members, pairs: [pair(4, 1, 0.9), pair(5, 4, 0.9)], cannot: [], sameAs: [] }).groups,
169
+ map,
170
+ );
171
+ assert.equal(together.things.length, 1);
172
+ const recordThing = together.things[0]!.iri;
173
+ // A person keeps "Bob Haldane" apart from the record: the pair to it goes.
174
+ const split = group({
175
+ members,
176
+ pairs: [pair(4, 1, 0.9), pair(5, 4, 0.9)],
177
+ cannot: [[e(4), e(1)]],
178
+ sameAs: [],
179
+ });
180
+ const named = nameThings("samples", split.groups, map, [
181
+ {
182
+ iri: recordThing,
183
+ anchorKey: together.things[0]!.anchorKey,
184
+ members: members.map((m) => m.iri),
185
+ },
186
+ ]);
187
+ const byIri = new Map(named.things.map((t) => [t.iri, t]));
188
+ assert.deepEqual(byIri.get(recordThing)?.members, [e(1)], "the record keeps its IRI");
189
+ const other = named.things.find((t) => t.iri !== recordThing)!;
190
+ assert.deepEqual(other.members, [e(4), e(5)]);
191
+ assert.equal(other.iri, thingIri("samples", `mention:${e(4).slice(-32)}`));
192
+ assert.deepEqual(named.merged, []);
193
+ assert.deepEqual(named.gone, []);
194
+ });
195
+
196
+ test("a mention alone is no thing; a thing whose members are all gone is gone", () => {
197
+ const lone = refers(3, "Kim Oak");
198
+ const naming = nameThings("samples", [[lone.iri]], new Map([[lone.iri, lone]]), [
199
+ { iri: thingIri("samples", "mention:x"), anchorKey: "mention:x", members: [e(8)] },
200
+ ]);
201
+ assert.deepEqual(naming.things, []);
202
+ assert.deepEqual(naming.lone, [lone.iri]);
203
+ assert.deepEqual(naming.gone, [thingIri("samples", "mention:x")]);
204
+ });
@@ -0,0 +1,426 @@
1
+ // Things: the groups of mentions that name the same real thing (general
2
+ // linking 2.1 to 2.4, 5.4). Pure functions: the grouping with must-links and
3
+ // cannot-links, the identity proposals, the anchors, the IRIs, and the merges
4
+ // and splits from one grouping to the next.
5
+ //
6
+ // The rules that keep a thing stable:
7
+ //
8
+ // - A person's decision is a constraint: a kept link is a must-link, a link
9
+ // left out or dismissed is a cannot-link. No model answer joins what a
10
+ // cannot-link keeps apart.
11
+ // - The things of two records are never joined without an accepted identity
12
+ // (`SAME_AS`). A pair that would join them becomes an identity proposal.
13
+ // - The anchor names the thing: a record's self mention first, then a
14
+ // definition, then a page's self mention, then any other mention; inside
15
+ // a rank the smallest stable key. The thing's IRI is a hash of the graph
16
+ // and the anchor's key, so the same inputs give the same IRIs.
17
+ // - A thing whose anchor changed, or that merged into another, keeps one
18
+ // statement: `MERGED_INTO` the thing that holds its members now.
19
+ import { createHash } from "node:crypto";
20
+ import { ENTITY_IRI_NAMESPACE } from "@littlebigbrain/client";
21
+ import { normalName } from "./names.js";
22
+
23
+ /** A SHA-256 of the parts, as hex. */
24
+ export const hash = (...parts: string[]) =>
25
+ createHash("sha256").update(parts.join("\u0000")).digest("hex");
26
+
27
+ /** The 32-hex id of an entity IRI. */
28
+ export const idOf = (iri: string) =>
29
+ iri.startsWith(ENTITY_IRI_NAMESPACE)
30
+ ? iri.slice(ENTITY_IRI_NAMESPACE.length)
31
+ : hash(iri).slice(0, 32);
32
+
33
+ /**
34
+ * The IRI of a mention: a hash of the graph, its source, its field, its
35
+ * span and which equal span of the field it is. A mention keeps its IRI
36
+ * while that text stays, also when text before it changes.
37
+ */
38
+ export function mentionIri(
39
+ graph: string,
40
+ source: string,
41
+ field: string,
42
+ span: string,
43
+ occurrence: number,
44
+ ): string {
45
+ return (
46
+ ENTITY_IRI_NAMESPACE +
47
+ hash("lbb.link.mention.v1", graph, idOf(source), field, span, String(occurrence)).slice(0, 32)
48
+ );
49
+ }
50
+
51
+ /** The IRI of a thing: a hash of the graph and its anchor's key. */
52
+ export function thingIri(graph: string, anchorKey: string): string {
53
+ return ENTITY_IRI_NAMESPACE + hash("lbb.link.thing.v1", graph, anchorKey).slice(0, 32);
54
+ }
55
+
56
+ /** A mention as the grouping sees it. */
57
+ export interface Member {
58
+ iri: string;
59
+ role: "self" | "refers" | "defines";
60
+ /** The record whose self mention this is. */
61
+ record: string | null;
62
+ span: string;
63
+ /** What the thing is called by its kind: the record's class, or the mention's kind. */
64
+ kind: string;
65
+ /** The span is a key (an email, a web address, a phone number, a handle, a code), no name. */
66
+ keyed?: boolean;
67
+ }
68
+
69
+ /** The rank of a member as an anchor: a record's self mention, a definition, a page's self mention, any other. */
70
+ export const rankOf = (m: Member): number =>
71
+ m.role === "self" && m.record ? 0 : m.role === "defines" ? 1 : m.role === "self" ? 2 : 3;
72
+
73
+ /** The stable key a member anchors a thing by: its record's id, else its own id. */
74
+ export const anchorKeyOf = (m: Member): string =>
75
+ rankOf(m) === 0 ? `record:${idOf(m.record!)}` : `mention:${idOf(m.iri)}`;
76
+
77
+ /** Who made a pair. A person's pairs and the identities join first, then the others by confidence. */
78
+ export type PairBy = "person" | "strong" | "model" | "rule";
79
+
80
+ /** A decided or kept pair: the two mentions name the same thing. */
81
+ export interface Pair {
82
+ a: string;
83
+ b: string;
84
+ confidence: number;
85
+ by: PairBy;
86
+ }
87
+
88
+ /** Two records a decision proposes as one identity, with why. */
89
+ export interface IdentityPair {
90
+ a: string;
91
+ b: string;
92
+ confidence: number;
93
+ reason: string;
94
+ }
95
+
96
+ export interface GroupInput {
97
+ members: Member[];
98
+ pairs: Pair[];
99
+ /** Pairs of mentions a person keeps apart. */
100
+ cannot: [string, string][];
101
+ /** Pairs of records a person accepted as one identity (`SAME_AS`). */
102
+ sameAs: [string, string][];
103
+ /** Identities the decisions propose between records. */
104
+ identities?: IdentityPair[];
105
+ }
106
+
107
+ /** Records proposed as one identity: 2 to 10 of them, with the pairs that matched. */
108
+ export interface IdentityProposal {
109
+ records: string[];
110
+ confidence: number;
111
+ pairs: IdentityPair[];
112
+ }
113
+
114
+ export interface Grouping {
115
+ /** The groups of mention IRIs, each sorted, ordered by their first member. */
116
+ groups: string[][];
117
+ proposals: IdentityProposal[];
118
+ /** Pairs a cannot-link kept apart. */
119
+ blockedByPerson: number;
120
+ /** Pairs that would have joined the things of two records without an accepted identity. */
121
+ blockedByRecords: number;
122
+ /** Identity groups of more than 10 records: not proposed. */
123
+ tooLarge: number;
124
+ /** The records of each identity group of more than 10. */
125
+ tooLargeGroups: string[][];
126
+ }
127
+
128
+ /** Records of one identity, at most: the server's limit. */
129
+ export const MAX_IDENTITY = 10;
130
+
131
+ const ORDER: Record<PairBy, number> = { person: 0, strong: 1, model: 2, rule: 3 };
132
+
133
+ /**
134
+ * Group the members by their pairs. The self mentions of one record join
135
+ * first, then the records of each accepted identity, then a person's pairs,
136
+ * then the other pairs from the highest confidence down. A join that a
137
+ * cannot-link forbids is skipped. A join of two groups that both hold a
138
+ * record and no accepted identity is skipped and proposed as an identity.
139
+ */
140
+ export function group(input: GroupInput): Grouping {
141
+ const members = new Map(input.members.map((m) => [m.iri, m]));
142
+ const parent = new Map<string, string>();
143
+ const sets = new Map<string, { members: Set<string>; records: Set<string> }>();
144
+ for (const m of members.values()) {
145
+ parent.set(m.iri, m.iri);
146
+ sets.set(m.iri, { members: new Set([m.iri]), records: new Set(m.record ? [m.record] : []) });
147
+ }
148
+ const find = (x: string): string => {
149
+ let root = x;
150
+ while (parent.get(root) !== root) root = parent.get(root)!;
151
+ for (let at = x; parent.get(at) !== root;) {
152
+ const next = parent.get(at)!;
153
+ parent.set(at, root);
154
+ at = next;
155
+ }
156
+ return root;
157
+ };
158
+ const cannot = new Map<string, Set<string>>();
159
+ for (const [a, b] of input.cannot) {
160
+ if (a === b) continue;
161
+ (cannot.get(a) ?? cannot.set(a, new Set()).get(a)!).add(b);
162
+ (cannot.get(b) ?? cannot.set(b, new Set()).get(b)!).add(a);
163
+ }
164
+ const kept = (ra: string, rb: string) => {
165
+ const [small, large] = [sets.get(ra)!, sets.get(rb)!].sort(
166
+ (x, y) => x.members.size - y.members.size,
167
+ );
168
+ for (const m of small!.members)
169
+ for (const other of cannot.get(m) ?? []) if (large!.members.has(other)) return true;
170
+ return false;
171
+ };
172
+ const union = (ra: string, rb: string) => {
173
+ const [keep, drop] = ra < rb ? [ra, rb] : [rb, ra];
174
+ parent.set(drop, keep);
175
+ const into = sets.get(keep)!;
176
+ for (const m of sets.get(drop)!.members) into.members.add(m);
177
+ for (const r of sets.get(drop)!.records) into.records.add(r);
178
+ sets.delete(drop);
179
+ };
180
+ const out: Grouping = {
181
+ groups: [],
182
+ proposals: [],
183
+ blockedByPerson: 0,
184
+ blockedByRecords: 0,
185
+ tooLarge: 0,
186
+ tooLargeGroups: [],
187
+ };
188
+ const proposed: IdentityPair[] = [...(input.identities ?? [])];
189
+
190
+ // The self mentions of one record name the record.
191
+ const selfOf = new Map<string, string>();
192
+ for (const m of [...members.values()].sort((x, y) => (x.iri < y.iri ? -1 : 1))) {
193
+ if (!m.record || m.role !== "self") continue;
194
+ const first = selfOf.get(m.record);
195
+ if (first === undefined) selfOf.set(m.record, m.iri);
196
+ else if (find(first) !== find(m.iri)) union(find(first), find(m.iri));
197
+ }
198
+ // An accepted identity joins its records.
199
+ for (const [a, b] of [...input.sameAs].sort()) {
200
+ const [x, y] = [selfOf.get(a), selfOf.get(b)];
201
+ if (x && y && find(x) !== find(y)) union(find(x), find(y));
202
+ }
203
+ const pairs = input.pairs
204
+ .filter((p) => p.a !== p.b && members.has(p.a) && members.has(p.b))
205
+ .sort(
206
+ (x, y) =>
207
+ (x.by === "person" ? 0 : 1) - (y.by === "person" ? 0 : 1) ||
208
+ y.confidence - x.confidence ||
209
+ ORDER[x.by] - ORDER[y.by] ||
210
+ (x.a + x.b < y.a + y.b ? -1 : x.a + x.b > y.a + y.b ? 1 : 0),
211
+ );
212
+ for (const pair of pairs) {
213
+ const [ra, rb] = [find(pair.a), find(pair.b)];
214
+ if (ra === rb) continue;
215
+ if (kept(ra, rb)) {
216
+ out.blockedByPerson += 1;
217
+ continue;
218
+ }
219
+ const [x, y] = [sets.get(ra)!.records, sets.get(rb)!.records];
220
+ if (x.size && y.size) {
221
+ out.blockedByRecords += 1;
222
+ const [first, second] = [[...x].sort()[0]!, [...y].sort()[0]!];
223
+ proposed.push({
224
+ a: first,
225
+ b: second,
226
+ confidence: pair.confidence,
227
+ reason: `a mention links ${members.get(pair.a)!.span} and ${members.get(pair.b)!.span}`,
228
+ });
229
+ continue;
230
+ }
231
+ union(ra, rb);
232
+ }
233
+
234
+ out.groups = [...sets.values()]
235
+ .map((s) => [...s.members].sort())
236
+ .sort((a, b) => (a[0]! < b[0]! ? -1 : 1));
237
+ out.proposals = proposals(proposed, find, selfOf, cannot, sets, out);
238
+ return out;
239
+ }
240
+
241
+ /** The identity proposals of the pairs: components over records, without the pairs already joined or kept apart. */
242
+ function proposals(
243
+ pairs: IdentityPair[],
244
+ find: (x: string) => string,
245
+ selfOf: Map<string, string>,
246
+ cannot: Map<string, Set<string>>,
247
+ sets: Map<string, { members: Set<string>; records: Set<string> }>,
248
+ out: Grouping,
249
+ ): IdentityProposal[] {
250
+ const groupOf = (record: string) => {
251
+ const self = selfOf.get(record);
252
+ return self ? find(self) : `record:${record}`;
253
+ };
254
+ const recordsOf = (record: string) => {
255
+ const self = selfOf.get(record);
256
+ return self ? [...sets.get(find(self))!.records] : [record];
257
+ };
258
+ const apart = (a: string, b: string) => {
259
+ const left = sets.get(groupOf(a))?.members ?? new Set<string>();
260
+ const right = sets.get(groupOf(b))?.members ?? new Set<string>();
261
+ for (const m of left)
262
+ for (const other of cannot.get(m) ?? []) if (right.has(other)) return true;
263
+ return false;
264
+ };
265
+ const parent = new Map<string, string>();
266
+ const find2 = (x: string): string => {
267
+ if (!parent.has(x)) parent.set(x, x);
268
+ let root = x;
269
+ while (parent.get(root) !== root) root = parent.get(root)!;
270
+ parent.set(x, root);
271
+ return root;
272
+ };
273
+ const kept: IdentityPair[] = [];
274
+ for (const pair of pairs) {
275
+ if (pair.a === pair.b || groupOf(pair.a) === groupOf(pair.b) || apart(pair.a, pair.b)) continue;
276
+ kept.push(pair);
277
+ const [x, y] = [find2(groupOf(pair.a)), find2(groupOf(pair.b))];
278
+ if (x !== y) parent.set(x < y ? y : x, x < y ? x : y);
279
+ }
280
+ const components = new Map<string, { groups: Set<string>; pairs: IdentityPair[] }>();
281
+ for (const pair of kept) {
282
+ const root = find2(groupOf(pair.a));
283
+ const entry = components.get(root) ?? { groups: new Set<string>(), pairs: [] };
284
+ entry.groups.add(groupOf(pair.a)).add(groupOf(pair.b));
285
+ entry.pairs.push(pair);
286
+ components.set(root, entry);
287
+ }
288
+ const result: IdentityProposal[] = [];
289
+ for (const { pairs: matched } of components.values()) {
290
+ const records = [
291
+ ...new Set(matched.flatMap((p) => [...recordsOf(p.a), ...recordsOf(p.b)])),
292
+ ].sort();
293
+ if (records.length > MAX_IDENTITY) {
294
+ out.tooLarge += 1;
295
+ out.tooLargeGroups.push(records);
296
+ continue;
297
+ }
298
+ if (records.length < 2) continue;
299
+ const best = new Map<string, IdentityPair>();
300
+ for (const p of matched) {
301
+ const key = [p.a, p.b].sort().join("\u0000");
302
+ if ((best.get(key)?.confidence ?? -1) < p.confidence) best.set(key, p);
303
+ }
304
+ const unique = [...best.values()].sort((x, y) => y.confidence - x.confidence);
305
+ result.push({
306
+ records,
307
+ confidence: Math.min(...unique.map((p) => p.confidence)),
308
+ pairs: unique,
309
+ });
310
+ }
311
+ return result.sort((a, b) => (a.records[0]! < b.records[0]! ? -1 : 1));
312
+ }
313
+
314
+ // ---- naming ----------------------------------------------------------------------
315
+
316
+ /** A thing as the graph held it before this grouping. */
317
+ export interface PreviousThing {
318
+ iri: string;
319
+ anchorKey: string;
320
+ members: string[];
321
+ }
322
+
323
+ /** A thing as this grouping names it. */
324
+ export interface NamedThing {
325
+ iri: string;
326
+ anchorKey: string;
327
+ anchor: string;
328
+ label: string;
329
+ kind: string;
330
+ members: string[];
331
+ records: string[];
332
+ /** The other names its members use, in their normal form. */
333
+ aliases: string[];
334
+ }
335
+
336
+ export interface Naming {
337
+ things: NamedThing[];
338
+ /** Things of before whose members another thing holds now: `from` keeps `MERGED_INTO` `into`. */
339
+ merged: { from: string; into: string }[];
340
+ /** Things of before whose members no thing holds now. */
341
+ gone: string[];
342
+ /** Mentions in no thing: alone, and anchoring nothing. */
343
+ lone: string[];
344
+ }
345
+
346
+ /** The aliases a thing keeps, at most. */
347
+ export const MAX_ALIASES = 16;
348
+
349
+ /** The anchor of a group: the best rank, then the smallest key. */
350
+ export function anchorOf(members: Member[]): Member {
351
+ return [...members].sort(
352
+ (a, b) =>
353
+ rankOf(a) - rankOf(b) ||
354
+ (anchorKeyOf(a) < anchorKeyOf(b) ? -1 : anchorKeyOf(a) > anchorKeyOf(b) ? 1 : 0) ||
355
+ // The self mentions of one record share a key: a name before a key.
356
+ Number(Boolean(a.keyed)) - Number(Boolean(b.keyed)) ||
357
+ (a.iri < b.iri ? -1 : 1),
358
+ )[0]!;
359
+ }
360
+
361
+ /**
362
+ * Name the groups. A group of two or more members, or with a record's self
363
+ * mention or a definition, is a thing with the IRI of its anchor; the other
364
+ * groups are lone mentions. A thing of before that this grouping does not
365
+ * name again points to the thing that holds its anchor now, else to the
366
+ * one that holds most of its members, else it is gone.
367
+ */
368
+ export function nameThings(
369
+ graph: string,
370
+ groups: string[][],
371
+ members: Map<string, Member>,
372
+ previous: PreviousThing[] = [],
373
+ ): Naming {
374
+ const naming: Naming = { things: [], merged: [], gone: [], lone: [] };
375
+ for (const iris of groups) {
376
+ const list = iris.map((iri) => members.get(iri)!).filter(Boolean);
377
+ if (!list.length) continue;
378
+ const anchor = anchorOf(list);
379
+ if (list.length < 2 && rankOf(anchor) > 1) {
380
+ naming.lone.push(anchor.iri);
381
+ continue;
382
+ }
383
+ const anchorKey = anchorKeyOf(anchor);
384
+ // The label is the anchor's name; an anchor that is a key (a lead known by its email) takes the best name of its thing.
385
+ const names = list.filter((m) => !m.keyed);
386
+ const label = anchor.keyed && names.length ? anchorOf(names).span : anchor.span;
387
+ const aliases = [
388
+ ...new Set(
389
+ list
390
+ .filter((m) => !m.keyed)
391
+ .map((m) => normalName(m.span))
392
+ .filter((name) => name && name !== normalName(label)),
393
+ ),
394
+ ]
395
+ .sort()
396
+ .slice(0, MAX_ALIASES);
397
+ naming.things.push({
398
+ iri: thingIri(graph, anchorKey),
399
+ anchorKey,
400
+ anchor: anchor.iri,
401
+ label,
402
+ kind: anchor.kind,
403
+ members: [...iris],
404
+ records: [...new Set(list.map((m) => m.record).filter((r): r is string => !!r))].sort(),
405
+ aliases,
406
+ });
407
+ }
408
+ const live = new Map(naming.things.map((t) => [t.iri, t]));
409
+ for (const before of previous) {
410
+ if (live.has(before.iri)) continue;
411
+ const holding = naming.things.find((t) =>
412
+ t.members.some((m) => {
413
+ const member = members.get(m);
414
+ return member !== undefined && anchorKeyOf(member) === before.anchorKey;
415
+ }),
416
+ );
417
+ const most = [...naming.things]
418
+ .map((t) => ({ t, n: t.members.filter((m) => before.members.includes(m)).length }))
419
+ .filter((x) => x.n > 0)
420
+ .sort((a, b) => b.n - a.n || (a.t.iri < b.t.iri ? -1 : 1))[0]?.t;
421
+ const into = holding ?? most;
422
+ if (into) naming.merged.push({ from: before.iri, into: into.iri });
423
+ else naming.gone.push(before.iri);
424
+ }
425
+ return naming;
426
+ }
@@ -0,0 +1,28 @@
1
+ // Try the workflow on its samples: create a graph (default try-link), put the
2
+ // invented CRM on it, and create the workflow there. Then serve it with
3
+ // npm start and the same LBB_GRAPH, and grade the run with npm run grade.
4
+ //
5
+ // LBB_URL=... LBB_API_KEY=... LBB_GRAPH=try-link npm run try
6
+ import { fileURLToPath } from "node:url";
7
+ import { LbbClient, LbbError, lbbActivities } from "@littlebigbrain/client";
8
+ import { loadSamples } from "./samples.js";
9
+ import { setup } from "./workflow.js";
10
+
11
+ const graph = process.env.LBB_GRAPH ?? "try-link";
12
+ const client = new LbbClient({
13
+ baseUrl: process.env.LBB_URL ?? "http://127.0.0.1:7400",
14
+ apiKey: process.env.LBB_API_KEY,
15
+ graph,
16
+ });
17
+ try {
18
+ await client.graph(graph).create();
19
+ console.log(`Created the graph ${graph}`);
20
+ } catch (error) {
21
+ // 409: the graph exists already.
22
+ if (!(error instanceof LbbError && error.status === 409)) throw error;
23
+ }
24
+ await loadSamples(client, fileURLToPath(new URL("../samples/", import.meta.url)));
25
+ const names = await setup(client, lbbActivities(client));
26
+ console.log(`Created ${names.join(", ")} on the graph ${graph}.`);
27
+ console.log(`Serve them with LBB_GRAPH=${graph} npm start, then grade the run with:`);
28
+ console.log(` LBB_GRAPH=${graph} npx tsx src/grade-run.ts --gold samples/gold.json`);
@@ -0,0 +1,24 @@
1
+ // Serve the workflow: the worker claims the turns of the source shards and
2
+ // of the things identity and runs them, one at a time. Run more workers for
3
+ // more at once.
4
+ //
5
+ // LBB_URL=... LBB_API_KEY=... LBB_GRAPH=... npm start
6
+ import { LbbClient, WorkflowWorker, lbbActivities } from "@littlebigbrain/client";
7
+ import { createWorkflow, linkPorts } from "./workflow.js";
8
+
9
+ const graph = process.env.LBB_GRAPH ?? "main";
10
+ const client = new LbbClient({
11
+ baseUrl: process.env.LBB_URL ?? "http://127.0.0.1:7400",
12
+ apiKey: process.env.LBB_API_KEY,
13
+ graph,
14
+ });
15
+ const workflow = createWorkflow(lbbActivities(client), linkPorts(client));
16
+ const stop = new AbortController();
17
+ process.once("SIGINT", () => stop.abort());
18
+ process.once("SIGTERM", () => stop.abort());
19
+ console.log(
20
+ `Serving ${workflow.name}@${workflow.version} for the graph ${graph}. Stop with Ctrl-C.`,
21
+ );
22
+ await new WorkflowWorker(client, [workflow], {
23
+ worker: process.env.LBB_WORKER_ID ?? `${workflow.name}-1`,
24
+ }).run({ signal: stop.signal });