@littlebigbrain/create 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,984 @@
1
+ // A turn of the things identity (general linking 5.4, 8.1, 8.2): the decided
2
+ // pairs of one `decided` message, grouped into things. Its steps:
3
+ //
4
+ // 1. `harvest`: the review items it filed that a person decided since: a
5
+ // kept row is a must-link, a row left out or a dismissed item a
6
+ // cannot-link (2.4, rule 2);
7
+ // 2. `group`: the things the message touches, read from the graph; a second
8
+ // look at the mentions left alone; the grouping with its constraints;
9
+ // the anchors and IRIs, merges and splits; what changed;
10
+ // 3. `mentions-<n>` and `things-<n>`: the owned writes of what changed;
11
+ // 4. `file`: the link, no-link and identity items.
12
+ //
13
+ // The graph holds the state: each mention keeps the targets of its machine
14
+ // links (`linked_to`) and a person's constraints (`must_link`,
15
+ // `cannot_link`). A `SAME_THING_AS` that neither names is a row a person
16
+ // kept in an accept, so it becomes a must-link.
17
+ import {
18
+ ENTITY_ID_KEY_SENTINEL,
19
+ LbbError,
20
+ entityRef,
21
+ isActivityRefused,
22
+ splitInHalves,
23
+ type DecideAnswer,
24
+ type DecideRequest,
25
+ type LbbActivities,
26
+ type OwnedWrite,
27
+ type Schemas,
28
+ type WorkflowContext,
29
+ type WorkflowStepContext,
30
+ } from "@littlebigbrain/client";
31
+ import {
32
+ BOUNDS,
33
+ candidatesFor,
34
+ keyCountsQuery,
35
+ keyHitsQuery,
36
+ parseCounts,
37
+ parseHits,
38
+ stopKeys,
39
+ } from "./candidates.js";
40
+ import {
41
+ decideRequest,
42
+ effectIdOf,
43
+ linkRule,
44
+ packRequests,
45
+ rulesDecisions,
46
+ sourceState,
47
+ type Asked,
48
+ type Decision,
49
+ } from "./decide.js";
50
+ import {
51
+ identityItem,
52
+ iriOfRef,
53
+ itemKind,
54
+ rowsOfItem,
55
+ thingItems,
56
+ type MentionView,
57
+ type RecordView,
58
+ type Row,
59
+ type TargetView,
60
+ } from "./items.js";
61
+ import { keyWeight } from "./names.js";
62
+ import {
63
+ LINK_FIELDS,
64
+ MEMBER_ROWS,
65
+ THING_FIELDS,
66
+ mentionFactsQuery,
67
+ parseMentionFacts,
68
+ parseRecords,
69
+ parseSameAs,
70
+ parseSourceMentions,
71
+ parseThingFacts,
72
+ parseThingMembers,
73
+ parts,
74
+ recordsQuery,
75
+ sameAsQuery,
76
+ sourceMentionsQuery,
77
+ thingFactsQuery,
78
+ thingMembersQuery,
79
+ type MentionFacts,
80
+ } from "./reads.js";
81
+ import { idIn, iriOf, type DecidedMessage, type LinkPorts, type Settings } from "./settings.js";
82
+ import {
83
+ group,
84
+ nameThings,
85
+ type IdentityPair,
86
+ type IdentityProposal,
87
+ type Member,
88
+ type Pair,
89
+ type PreviousThing,
90
+ } from "./things.js";
91
+
92
+ /** What the things identity has done, and the review items it waits on. */
93
+ export interface ThingsState {
94
+ turns: number;
95
+ things: number;
96
+ merged: number;
97
+ written: number;
98
+ review: number;
99
+ identities: number;
100
+ secondLooks: number;
101
+ blockedByPerson: number;
102
+ blockedByRecords: number;
103
+ tooLarge: number;
104
+ bounds: Record<string, number>;
105
+ /**
106
+ * The open review items, by `<kind>:<thing or mention IRI>` or
107
+ * `identity:<key>`: what the harvest reads. An identity keeps the short
108
+ * ids of its records.
109
+ */
110
+ open: Record<string, { id: string; key: string; records?: string[] }>;
111
+ }
112
+
113
+ export const emptyThings = (): ThingsState => ({
114
+ turns: 0,
115
+ things: 0,
116
+ merged: 0,
117
+ written: 0,
118
+ review: 0,
119
+ identities: 0,
120
+ secondLooks: 0,
121
+ blockedByPerson: 0,
122
+ blockedByRecords: 0,
123
+ tooLarge: 0,
124
+ bounds: {},
125
+ open: {},
126
+ });
127
+
128
+ /** Review items one harvest reads, at most. */
129
+ export const HARVEST = 40;
130
+ /** Mentions one turn reads to group, at most. */
131
+ export const MAX_MEMBERS = 2_000;
132
+ /** Mentions one turn writes, at most; the rest is written in a continued turn. */
133
+ export const PLAN_MEMBERS = 250;
134
+ /** Review rows one turn files, at most. */
135
+ export const PLAN_ROWS = 80;
136
+ /** Mentions one second look reads again, at most. */
137
+ export const SECOND_LOOK = 32;
138
+ /** Open items the state keeps, at most. */
139
+ export const MAX_OPEN = 500;
140
+ /** Entities of one owned write. */
141
+ const WRITE_PART = 900;
142
+
143
+ const strong = { consistency: "strong" as const };
144
+ /** The kinds of a span that is a key, no name. */
145
+ const KEY_KINDS_OF_SPANS = new Set(["email", "web", "phone", "handle", "code"]);
146
+
147
+ /** A changed mention: its thing, its links and its constraints, as it is written. */
148
+ interface MentionPlan {
149
+ iri: string;
150
+ span: string;
151
+ thing: string | null;
152
+ thingLabel: string;
153
+ links: {
154
+ target: string;
155
+ span: string;
156
+ confidence: number;
157
+ evidence?: { text: string; source_id: string };
158
+ }[];
159
+ linked: string[];
160
+ must: string[];
161
+ cannot: string[];
162
+ }
163
+
164
+ interface ThingPlan {
165
+ iri: string;
166
+ label: string;
167
+ kind: string;
168
+ anchorKey: string;
169
+ aliases: string[];
170
+ records: { iri: string; class: string; name: string }[];
171
+ }
172
+
173
+ /** What the `group` step saves for the steps after it. */
174
+ interface Plan {
175
+ mentions: MentionPlan[];
176
+ things: ThingPlan[];
177
+ merged: { from: string; label: string; into: string; intoLabel: string }[];
178
+ gone: string[];
179
+ rows: { target: TargetView; rows: Row[] }[];
180
+ identities: { proposal: IdentityProposal; records: RecordView[] }[];
181
+ /** Open identity items whose records are now in a group of more than 10: they go. */
182
+ supersede: string[];
183
+ /** The mentions of each row, and the thing each is in now. */
184
+ thingOf: Record<string, string | null>;
185
+ counts: {
186
+ things: number;
187
+ merged: number;
188
+ secondLooks: number;
189
+ blockedByPerson: number;
190
+ blockedByRecords: number;
191
+ tooLarge: number;
192
+ };
193
+ bounds: Record<string, number>;
194
+ more: boolean;
195
+ }
196
+
197
+ /** One turn of the things identity. */
198
+ export async function thingsTurn(
199
+ ctx: WorkflowContext,
200
+ lbb: LbbActivities,
201
+ ports: LinkPorts,
202
+ settings: Settings,
203
+ message: DecidedMessage & { must?: [string, string][]; cannot?: [string, string][] },
204
+ state: ThingsState,
205
+ ): Promise<{ state: ThingsState; next?: DecidedMessage }> {
206
+ const gate = settings.modelsReadRecords ? settings.gate : 1;
207
+ const harvested = await ctx.step("harvest", (s) => harvest(s, ports, state.open));
208
+ const constraints = {
209
+ must: [...(message.must ?? []), ...harvested.must],
210
+ cannot: [...(message.cannot ?? []), ...harvested.cannot],
211
+ };
212
+ const plan = await ctx.step("group", (s) =>
213
+ groupStep(s, lbb, settings, gate, message, constraints, state.open),
214
+ );
215
+
216
+ for (let n = 0; n * WRITE_PART < plan.mentions.length; n++)
217
+ await lbb.writeOwned(
218
+ ctx,
219
+ `mentions-${n}`,
220
+ (s) => {
221
+ s.progress("writing things", {
222
+ done: n * WRITE_PART,
223
+ total: plan.mentions.length,
224
+ unit: "mentions",
225
+ });
226
+ return mentionsWrite(plan.mentions.slice(n * WRITE_PART, (n + 1) * WRITE_PART));
227
+ },
228
+ { graph: message.graph },
229
+ );
230
+ const thingCount = plan.things.length + plan.merged.length + plan.gone.length;
231
+ for (let n = 0; n * WRITE_PART < thingCount; n++)
232
+ await lbb.writeOwned(
233
+ ctx,
234
+ `things-${n}`,
235
+ (s) => {
236
+ s.progress("writing things", { done: n * WRITE_PART, total: thingCount, unit: "items" });
237
+ return thingsWrite(plan, n);
238
+ },
239
+ { graph: message.graph },
240
+ );
241
+
242
+ const open = { ...state.open };
243
+ for (const key of harvested.closed) delete open[key];
244
+ const filed = await ctx.step("file", (s) => fileItems(s, lbb, ports, settings, gate, plan, open));
245
+
246
+ const next: ThingsState = {
247
+ turns: state.turns + 1,
248
+ things: state.things + plan.counts.things,
249
+ merged: state.merged + plan.counts.merged,
250
+ written: state.written + plan.mentions.length,
251
+ review: state.review + filed.rows,
252
+ identities: state.identities + filed.identities,
253
+ secondLooks: state.secondLooks + plan.counts.secondLooks,
254
+ blockedByPerson: state.blockedByPerson + plan.counts.blockedByPerson,
255
+ blockedByRecords: state.blockedByRecords + plan.counts.blockedByRecords,
256
+ tooLarge: state.tooLarge + plan.counts.tooLarge,
257
+ bounds: { ...state.bounds },
258
+ open: filed.open,
259
+ };
260
+ for (const [bound, n] of Object.entries(plan.bounds))
261
+ next.bounds[bound] = (next.bounds[bound] ?? 0) + n;
262
+ if (plan.more)
263
+ return {
264
+ state: next,
265
+ next: { ...message, round: (message.round ?? 0) + 1, ...constraints } as DecidedMessage,
266
+ };
267
+ return { state: next };
268
+ }
269
+
270
+ // ---- harvest -----------------------------------------------------------------------
271
+
272
+ /** The decisions a person made on the open items: must-links and cannot-links, and the items that closed. */
273
+ async function harvest(
274
+ s: WorkflowStepContext,
275
+ ports: LinkPorts,
276
+ open: ThingsState["open"],
277
+ ): Promise<{ must: [string, string][]; cannot: [string, string][]; closed: string[] }> {
278
+ const out = {
279
+ must: [] as [string, string][],
280
+ cannot: [] as [string, string][],
281
+ closed: [] as string[],
282
+ };
283
+ const keys = Object.keys(open).slice(0, HARVEST);
284
+ for (const [k, key] of keys.entries()) {
285
+ s.progress("reading review decisions", { done: k, total: keys.length, unit: "items" });
286
+ let item: Schemas["OntologyChangeSuggestion"];
287
+ try {
288
+ item = await ports.suggestion(open[key]!.id);
289
+ } catch (error) {
290
+ if (error instanceof LbbError && error.status === 404) {
291
+ out.closed.push(key);
292
+ continue;
293
+ }
294
+ throw error;
295
+ }
296
+ if (item.status === "open") continue;
297
+ out.closed.push(key);
298
+ if (item.status === "superseded") continue;
299
+ const accepted = item.status === "accepted";
300
+ if (itemKind(item.key) === "identity") {
301
+ const selves = ((item.mapping as { identity?: { selves?: Record<string, string> } } | null)
302
+ ?.identity?.selves ?? {}) as Record<string, string>;
303
+ const recordOf = (key: string) =>
304
+ key.startsWith(ENTITY_ID_KEY_SENTINEL)
305
+ ? iriOf(key.slice(ENTITY_ID_KEY_SENTINEL.length))
306
+ : null;
307
+ for (const identity of item.proposed_identities ?? item.identities ?? []) {
308
+ const kept = new Set(
309
+ accepted
310
+ ? (item.identities ?? [])
311
+ .flatMap((i) => i.members.map((m) => m.key))
312
+ .filter((key) => identity.members.some((m) => m.key === key))
313
+ : [],
314
+ );
315
+ const first = identity.members.find((m) => kept.has(m.key)) ?? identity.members[0];
316
+ if (!first) continue;
317
+ const firstSelf = selves[recordOf(first.key) ?? ""];
318
+ for (const member of identity.members) {
319
+ if (member === first || kept.has(member.key)) continue;
320
+ const self = selves[recordOf(member.key) ?? ""];
321
+ if (self && firstSelf) out.cannot.push([self, firstSelf]);
322
+ }
323
+ }
324
+ continue;
325
+ }
326
+ const proposed = (item.proposed_facts ?? item.facts)?.triplets ?? [];
327
+ const kept = accepted ? (item.facts?.triplets ?? []) : [];
328
+ const same = (a: Schemas["TripletInput"], b: Schemas["TripletInput"]) =>
329
+ a.source.key === b.source.key && a.target.key === b.target.key;
330
+ for (const row of proposed) {
331
+ const pair = [iriOfRef(row.source), iriOfRef(row.target)];
332
+ if (!pair[0] || !pair[1]) continue;
333
+ (kept.some((k) => same(k, row)) ? out.must : out.cannot).push(pair as [string, string]);
334
+ }
335
+ }
336
+ return out;
337
+ }
338
+
339
+ // ---- group --------------------------------------------------------------------------
340
+
341
+ /** The touched mentions and everything their things hold, read until no new mention comes, within the bound. */
342
+ async function readComponent(
343
+ lbb: LbbActivities,
344
+ start: Set<string>,
345
+ things: Set<string>,
346
+ bound: (name: string) => void,
347
+ ) {
348
+ const facts = new Map<string, MentionFacts>();
349
+ const membersOf = new Map<string, Set<string>>();
350
+ const readThings = new Set<string>();
351
+ let frontier = new Set(start);
352
+ const pendingThings = new Set(things);
353
+ for (let round = 0; round < 4 && (frontier.size || pendingThings.size); round++) {
354
+ const next = new Set<string>();
355
+ for (const part of parts([...frontier]))
356
+ for (const [iri, f] of parseMentionFacts(
357
+ (await lbb.sparql(mentionFactsQuery(part), strong)).rows,
358
+ ))
359
+ facts.set(iri, f);
360
+ for (const iri of frontier) {
361
+ const f = facts.get(iri);
362
+ if (!f) continue;
363
+ if (f.thing && !readThings.has(f.thing)) pendingThings.add(f.thing);
364
+ for (const t of [...f.same.map((x) => x.target), ...f.must, ...f.linkedTo])
365
+ if (!facts.has(t)) next.add(t);
366
+ }
367
+ const newThings = [...pendingThings].filter((t) => !readThings.has(t));
368
+ pendingThings.clear();
369
+ for (const part of parts(newThings)) {
370
+ const rows = (await lbb.sparql(thingMembersQuery(part), strong)).rows;
371
+ if (rows.length >= MEMBER_ROWS) bound("thing_members");
372
+ for (const [t, members] of parseThingMembers(rows)) membersOf.set(t, members);
373
+ }
374
+ for (const t of newThings) {
375
+ readThings.add(t);
376
+ for (const x of membersOf.get(t) ?? []) if (!facts.has(x)) next.add(x);
377
+ }
378
+ for (const x of [...next]) if (facts.has(x)) next.delete(x);
379
+ if (facts.size + next.size > MAX_MEMBERS) {
380
+ bound("members");
381
+ break;
382
+ }
383
+ frontier = next;
384
+ }
385
+ return { facts, membersOf, things: readThings };
386
+ }
387
+
388
+ async function groupStep(
389
+ s: WorkflowStepContext,
390
+ lbb: LbbActivities,
391
+ settings: Settings,
392
+ gate: number,
393
+ message: DecidedMessage,
394
+ constraints: { must: [string, string][]; cannot: [string, string][] },
395
+ open: ThingsState["open"],
396
+ ): Promise<Plan> {
397
+ const bounds: Record<string, number> = {};
398
+ const bound = (name: string) => (bounds[name] = (bounds[name] ?? 0) + 1);
399
+ const decisions: Decision[] = message.decisions.map((d) => ({
400
+ ...d,
401
+ mention: iriOf(d.mention),
402
+ target: d.target ? iriOf(d.target) : null,
403
+ thing: d.thing ? iriOf(d.thing) : null,
404
+ }));
405
+ s.progress("grouping things", { done: 0, total: decisions.length, unit: "links" });
406
+ const removed = new Set(message.removed.map(iriOf));
407
+ const start = new Set<string>([
408
+ ...message.mentions.map(iriOf),
409
+ ...decisions.flatMap((d) => [d.mention, ...(d.target ? [d.target] : [])]),
410
+ ...constraints.must.flat(),
411
+ ...constraints.cannot.flat(),
412
+ ]);
413
+ // An identity names records: their mentions join the read.
414
+ const identityRecords = [...new Set(message.identities.flatMap((p) => [iriOf(p.a), iriOf(p.b)]))];
415
+ for (const part of parts(identityRecords))
416
+ for (const list of parseSourceMentions(
417
+ (await lbb.sparql(sourceMentionsQuery(part), strong)).rows,
418
+ ).values())
419
+ for (const w of list) start.add(w.mention);
420
+ for (const r of removed) start.delete(r);
421
+ const component = await readComponent(lbb, start, new Set(message.things.map(iriOf)), bound);
422
+ const facts = component.facts;
423
+
424
+ // The second look: mentions left alone that another mention of the same time may name.
425
+ const second = await secondLook(s, lbb, settings, gate, decisions, facts);
426
+ for (const d of second)
427
+ for (const t of [d.target]) if (t && !facts.has(t)) bound("second_look_target");
428
+ const all = [...decisions, ...second.filter((d) => d.target && facts.has(d.target))];
429
+
430
+ const sources = [...new Set([...facts.values()].map((f) => f.source))];
431
+ const records = [
432
+ ...new Set([...facts.values()].filter((f) => f.role === "self").map((f) => f.source)),
433
+ ];
434
+ const recordFacts = new Map<string, { name: string; class: string }>();
435
+ for (const part of parts(sources))
436
+ for (const [iri, r] of parseRecords(
437
+ (await lbb.sparql(recordsQuery(part), strong)).rows,
438
+ Object.keys(settings.fields),
439
+ ))
440
+ recordFacts.set(iri, r);
441
+ const thingFacts = new Map<
442
+ string,
443
+ { anchorKey: string; label: string; mergedInto: string | null }
444
+ >();
445
+ for (const part of parts([...component.things]))
446
+ for (const [iri, t] of parseThingFacts((await lbb.sparql(thingFactsQuery(part), strong)).rows))
447
+ thingFacts.set(iri, t);
448
+ const sameAs: [string, string][] = [];
449
+ for (const part of parts(records))
450
+ sameAs.push(...parseSameAs((await lbb.sparql(sameAsQuery(part), strong)).rows));
451
+
452
+ // The machine links: a mention this message decided gets the links of its decisions; the others keep theirs.
453
+ const decided = new Set(all.map((d) => d.mention));
454
+ const linked = new Map<string, Map<string, { confidence: number; by: Pair["by"] }>>();
455
+ for (const f of facts.values()) {
456
+ if (decided.has(f.iri)) continue;
457
+ const entry = new Map<string, { confidence: number; by: Pair["by"] }>();
458
+ for (const t of f.linkedTo)
459
+ entry.set(t, {
460
+ confidence: f.same.find((x) => x.target === t)?.confidence ?? 1,
461
+ by: "model",
462
+ });
463
+ linked.set(f.iri, entry);
464
+ }
465
+ // A refers mention has one answer: its latest decision replaces its link. A self mention's
466
+ // decisions only add: what its own thing holds is no candidate of it, so it is not asked again.
467
+ for (const d of all) {
468
+ let entry = linked.get(d.mention);
469
+ if (!entry) {
470
+ entry = new Map();
471
+ const f = facts.get(d.mention);
472
+ if (f?.role === "self")
473
+ for (const t of f.linkedTo)
474
+ entry.set(t, {
475
+ confidence: f.same.find((x) => x.target === t)?.confidence ?? 1,
476
+ by: "model",
477
+ });
478
+ linked.set(d.mention, entry);
479
+ }
480
+ if (d.outcome === "link" && d.target)
481
+ entry.set(d.target, { confidence: d.confidence, by: d.by });
482
+ }
483
+ // A person's constraints: the stored ones, the edges an accept wrote, the harvested ones (newest wins).
484
+ const must = new Map<string, Set<string>>();
485
+ const cannot = new Map<string, Set<string>>();
486
+ const add = (map: Map<string, Set<string>>, a: string, b: string) =>
487
+ (map.get(a) ?? map.set(a, new Set()).get(a)!).add(b);
488
+ const drop = (map: Map<string, Set<string>>, a: string, b: string) => {
489
+ map.get(a)?.delete(b);
490
+ map.get(b)?.delete(a);
491
+ };
492
+ for (const f of facts.values()) {
493
+ for (const t of f.must) add(must, f.iri, t);
494
+ for (const t of f.cannot) add(cannot, f.iri, t);
495
+ for (const { target } of f.same)
496
+ if (!f.linkedTo.includes(target) && !f.must.includes(target)) add(must, f.iri, target);
497
+ }
498
+ for (const [a, b] of constraints.cannot) {
499
+ drop(must, a, b);
500
+ add(cannot, a, b);
501
+ add(cannot, b, a);
502
+ }
503
+ for (const [a, b] of constraints.must) {
504
+ drop(cannot, a, b);
505
+ add(must, a, b);
506
+ }
507
+ // A cannot-link also drops the machine link between the two.
508
+ for (const [a, set] of cannot) for (const b of set) linked.get(a)?.delete(b);
509
+
510
+ const members = new Map<string, Member>();
511
+ for (const f of facts.values())
512
+ members.set(f.iri, {
513
+ iri: f.iri,
514
+ role: f.role,
515
+ record: f.role === "self" ? f.source : null,
516
+ span: f.span,
517
+ kind: f.role === "self" ? recordFacts.get(f.source)?.class || "record" : f.kind || "name",
518
+ keyed: KEY_KINDS_OF_SPANS.has(f.kind),
519
+ });
520
+ const pairs: Pair[] = [];
521
+ for (const [a, entry] of linked)
522
+ for (const [b, x] of entry) pairs.push({ a, b, confidence: x.confidence, by: x.by });
523
+ for (const [a, set] of must)
524
+ for (const b of set) pairs.push({ a, b, confidence: 1, by: "person" });
525
+ const cannotPairs: [string, string][] = [];
526
+ for (const [a, set] of cannot) for (const b of set) if (a < b) cannotPairs.push([a, b]);
527
+ const identities: IdentityPair[] = message.identities.map((p) => ({
528
+ ...p,
529
+ a: iriOf(p.a),
530
+ b: iriOf(p.b),
531
+ }));
532
+ const grouping = group({
533
+ members: [...members.values()],
534
+ pairs,
535
+ cannot: cannotPairs,
536
+ sameAs,
537
+ identities,
538
+ });
539
+
540
+ const previous: PreviousThing[] = [...component.things]
541
+ .filter((t) => !thingFacts.get(t)?.mergedInto)
542
+ .map((t) => ({
543
+ iri: t,
544
+ anchorKey: thingFacts.get(t)?.anchorKey ?? "",
545
+ members: [...(component.membersOf.get(t) ?? [])],
546
+ }));
547
+ const naming = nameThings(message.graph, grouping.groups, members, previous);
548
+ const thingOf = new Map<string, (typeof naming.things)[number]>();
549
+ for (const t of naming.things) for (const m of t.members) thingOf.set(m, t);
550
+
551
+ // What changed, per mention.
552
+ const sameSet = (a: Iterable<string>, b: Iterable<string>) => {
553
+ const [x, y] = [new Set(a), new Set(b)];
554
+ return x.size === y.size && [...x].every((v) => y.has(v));
555
+ };
556
+ const nameOf = (iri: string) => recordFacts.get(iri)?.name ?? "";
557
+ const plans: MentionPlan[] = [];
558
+ for (const f of facts.values()) {
559
+ const thing = thingOf.get(f.iri) ?? null;
560
+ const links = linked.get(f.iri) ?? new Map();
561
+ const mustOf = [...(must.get(f.iri) ?? [])].filter((t) => facts.has(t));
562
+ const cannotOf = [...(cannot.get(f.iri) ?? [])];
563
+ const targets = [...new Set([...links.keys(), ...mustOf])].filter((t) => facts.has(t));
564
+ const changed =
565
+ (thing?.iri ?? null) !== f.thing ||
566
+ !sameSet(links.keys(), f.linkedTo) ||
567
+ !sameSet(mustOf, f.must) ||
568
+ !sameSet(cannotOf, f.cannot) ||
569
+ !sameSet(
570
+ targets,
571
+ f.same.map((x) => x.target),
572
+ );
573
+ if (!changed) continue;
574
+ plans.push({
575
+ iri: f.iri,
576
+ span: f.span,
577
+ thing: thing?.iri ?? null,
578
+ thingLabel: thing?.label ?? "",
579
+ links: targets.map((t) => {
580
+ const known = f.same.some((x) => x.target === t);
581
+ return {
582
+ target: t,
583
+ span: facts.get(t)!.span,
584
+ confidence: links.get(t)?.confidence ?? 1,
585
+ ...(known
586
+ ? {}
587
+ : {
588
+ evidence: {
589
+ text: (f.window ?? f.span).slice(0, 1_000),
590
+ source_id: nameOf(f.source).slice(0, 200) || "record",
591
+ },
592
+ }),
593
+ };
594
+ }),
595
+ linked: [...links.keys()].filter((t) => facts.has(t)),
596
+ must: mustOf,
597
+ cannot: cannotOf,
598
+ });
599
+ }
600
+ const more = plans.length > PLAN_MEMBERS;
601
+ if (more) bound("plan_members");
602
+
603
+ const view = (iri: string): MentionView => {
604
+ const f = facts.get(iri)!;
605
+ return {
606
+ iri,
607
+ span: f.span,
608
+ line: (f.window ?? f.span).slice(0, 300),
609
+ source: f.source,
610
+ sourceName: nameOf(f.source).slice(0, 80),
611
+ sourceClass: recordFacts.get(f.source)?.class ?? "",
612
+ field: f.field,
613
+ };
614
+ };
615
+ // The review rows, by the thing (or lone mention) their best candidate is in now.
616
+ const byTarget = new Map<string, { target: TargetView; rows: Row[] }>();
617
+ let rows = 0;
618
+ for (const d of all) {
619
+ if ((d.outcome !== "review" && d.outcome !== "nolink") || !d.target) continue;
620
+ if (!facts.has(d.mention) || !facts.has(d.target)) continue;
621
+ const thing = thingOf.get(d.target);
622
+ if (thing && thingOf.get(d.mention) === thing) continue;
623
+ if (rows >= PLAN_ROWS) {
624
+ bound("plan_rows");
625
+ break;
626
+ }
627
+ const key = thing?.iri ?? d.target;
628
+ const target: TargetView = thing
629
+ ? { iri: thing.iri, anchor: view(thing.anchor), label: thing.label, kind: thing.kind }
630
+ : {
631
+ iri: d.target,
632
+ anchor: view(d.target),
633
+ label: facts.get(d.target)!.span,
634
+ kind: members.get(d.target)!.kind,
635
+ };
636
+ const entry = byTarget.get(key) ?? { target, rows: [] };
637
+ entry.rows.push({ decision: { ...d, thing: thing?.iri ?? null }, mention: view(d.mention) });
638
+ byTarget.set(key, entry);
639
+ rows += 1;
640
+ }
641
+ const recordView = (iri: string): RecordView => {
642
+ const self = [...facts.values()]
643
+ .filter((f) => f.role === "self" && f.source === iri)
644
+ .sort(
645
+ (a, b) => Number(a.kind !== "name") - Number(b.kind !== "name") || (a.iri < b.iri ? -1 : 1),
646
+ )[0];
647
+ return {
648
+ iri,
649
+ name: nameOf(iri) || self?.span || "",
650
+ class: recordFacts.get(iri)?.class || "record",
651
+ ...(self ? { self: { iri: self.iri, span: self.span } } : {}),
652
+ };
653
+ };
654
+ const rowMentions = [...byTarget.values()].flatMap((e) => e.rows.map((r) => r.mention.iri));
655
+ // An identity proposed when fewer records shared its keys: the group has grown past 10.
656
+ const short = (iri: string) => idIn(iri).slice(0, 12);
657
+ const crowded = new Set(grouping.tooLargeGroups.flat().map(short));
658
+ const supersede = Object.values(open)
659
+ .filter((entry) => entry.records?.some((r) => crowded.has(r)))
660
+ .map((entry) => entry.id);
661
+ return {
662
+ mentions: plans.slice(0, PLAN_MEMBERS),
663
+ things: naming.things.map((t) => ({
664
+ iri: t.iri,
665
+ label: t.label.slice(0, 200),
666
+ kind: t.kind,
667
+ anchorKey: t.anchorKey,
668
+ aliases: t.aliases,
669
+ records: t.records.map((r) => ({
670
+ iri: r,
671
+ class: recordFacts.get(r)?.class || "record",
672
+ name: nameOf(r),
673
+ })),
674
+ })),
675
+ merged: naming.merged.map((m) => ({
676
+ from: m.from,
677
+ label: thingFacts.get(m.from)?.label ?? "",
678
+ into: m.into,
679
+ intoLabel: naming.things.find((t) => t.iri === m.into)!.label.slice(0, 200),
680
+ })),
681
+ gone: naming.gone,
682
+ rows: [...byTarget.values()],
683
+ identities: grouping.proposals.map((proposal) => ({
684
+ proposal,
685
+ records: proposal.records.map(recordView),
686
+ })),
687
+ supersede,
688
+ thingOf: Object.fromEntries(rowMentions.map((m) => [m, thingOf.get(m)?.iri ?? null])),
689
+ counts: {
690
+ things: naming.things.length,
691
+ merged: naming.merged.length,
692
+ secondLooks: second.length,
693
+ blockedByPerson: grouping.blockedByPerson,
694
+ blockedByRecords: grouping.blockedByRecords,
695
+ tooLarge: grouping.tooLarge,
696
+ },
697
+ bounds,
698
+ more,
699
+ };
700
+ }
701
+
702
+ /**
703
+ * The second look (5.4): two turns that ran at once can each leave a new
704
+ * mention alone that names the other's. For each mention this message left
705
+ * alone, the mentions alone that share a medium or strong key with it and
706
+ * that its turn did not compare it with are decided again, once.
707
+ */
708
+ async function secondLook(
709
+ s: WorkflowStepContext,
710
+ lbb: LbbActivities,
711
+ settings: Settings,
712
+ gate: number,
713
+ decisions: Decision[],
714
+ facts: Map<string, MentionFacts>,
715
+ ): Promise<Decision[]> {
716
+ const alone = decisions
717
+ .filter(
718
+ (d) =>
719
+ (d.outcome === "none" || d.outcome === "nolink") && facts.get(d.mention)?.role === "refers",
720
+ )
721
+ .slice(0, SECOND_LOOK);
722
+ if (!alone.length) return [];
723
+ const keysOf = (f: MentionFacts) => f.keys.filter((k) => keyWeight(k) !== "weak");
724
+ const keys = [...new Set(alone.flatMap((d) => keysOf(facts.get(d.mention)!)))];
725
+ if (!keys.length) return [];
726
+ const counts = new Map<string, number>();
727
+ for (const part of parts(keys, BOUNDS.keysPerRead))
728
+ for (const [k, n] of parseCounts((await lbb.sparql(keyCountsQuery(part), strong)).rows))
729
+ counts.set(k, n);
730
+ const stop = new Set(stopKeys(counts, BOUNDS.stopKey));
731
+ const wanted = keys.filter((k) => !stop.has(k) && (counts.get(k) ?? 0) > 1);
732
+ if (!wanted.length) return [];
733
+ const hits = [];
734
+ for (const part of parts(wanted, BOUNDS.keysPerRead))
735
+ hits.push(...parseHits((await lbb.sparql(keyHitsQuery(part), strong)).rows));
736
+ const out: Decision[] = [];
737
+ const asked: { asked: Asked; mention: string }[] = [];
738
+ for (const d of alone) {
739
+ const f = facts.get(d.mention)!;
740
+ const seen = new Set(d.seen ?? []);
741
+ const fresh = hits.filter(
742
+ (h) =>
743
+ !h.thing &&
744
+ h.mention !== f.iri &&
745
+ h.role === "refers" &&
746
+ !seen.has(idIn(h.mention).slice(0, 12)),
747
+ );
748
+ const candidates = candidatesFor(
749
+ {
750
+ iri: f.iri,
751
+ source: f.source,
752
+ role: "refers",
753
+ span: f.span,
754
+ keys: keysOf(f),
755
+ cannot: f.cannot,
756
+ },
757
+ fresh,
758
+ stop,
759
+ );
760
+ if (!candidates.length) continue;
761
+ if (!settings.modelsReadRecords) {
762
+ out.push(
763
+ ...rulesDecisions({ iri: f.iri, role: "refers", record: null }, candidates).decisions,
764
+ );
765
+ continue;
766
+ }
767
+ asked.push({
768
+ mention: f.iri,
769
+ asked: {
770
+ position: asked.length,
771
+ span: f.span,
772
+ context: `"${f.window ?? f.span}".`,
773
+ kind: false,
774
+ candidates,
775
+ },
776
+ });
777
+ }
778
+ if (asked.length) {
779
+ s.progress("second look", { done: 0, total: asked.length, unit: "mentions" });
780
+ const state = sourceState("A second look: mentions written at the same time", []);
781
+ const answers: Record<string, DecideAnswer> = {};
782
+ const ask = async (group: Asked[]): Promise<void> => {
783
+ const request: DecideRequest = decideRequest(state, group);
784
+ try {
785
+ Object.assign(
786
+ answers,
787
+ (await lbb.decide(request, { effectId: effectIdOf(request), label: "link second look" }))
788
+ .answers,
789
+ );
790
+ } catch (error) {
791
+ if (!isActivityRefused(error)) throw error;
792
+ const halves = splitInHalves(group);
793
+ if (halves) for (const half of halves) await ask(half);
794
+ }
795
+ };
796
+ for (const group of packRequests(
797
+ state,
798
+ asked.map((a) => a.asked),
799
+ ))
800
+ await ask(group);
801
+ for (const { mention, asked: a } of asked)
802
+ out.push(linkRule(mention, a, undefined, answers[`m${a.position}-names`], gate));
803
+ }
804
+ return out.filter((d) => d.outcome !== "none");
805
+ }
806
+
807
+ // ---- writes ---------------------------------------------------------------------------
808
+
809
+ const mentionRef = (iri: string, span: string) => ({
810
+ type: "Mention",
811
+ name: span.slice(0, 200) || idIn(iri),
812
+ key: `${ENTITY_ID_KEY_SENTINEL}${idIn(iri)}`,
813
+ });
814
+ const thingRef = (iri: string, label: string) => ({
815
+ type: "LinkedThing",
816
+ name: label.slice(0, 200) || idIn(iri),
817
+ key: `${ENTITY_ID_KEY_SENTINEL}${idIn(iri)}`,
818
+ });
819
+
820
+ /** The owned write of changed mentions: their thing, their links and their constraints. */
821
+ function mentionsWrite(plans: MentionPlan[]): OwnedWrite {
822
+ return {
823
+ subjects: plans.map((p) => mentionRef(p.iri, p.span)),
824
+ relations: ["SAME_THING_AS", "REFERS_TO"],
825
+ properties: LINK_FIELDS,
826
+ entity_properties: plans
827
+ .filter((p) => p.linked.length || p.must.length || p.cannot.length)
828
+ .map((p) => ({
829
+ ...mentionRef(p.iri, p.span),
830
+ properties: {
831
+ ...(p.linked.length ? { linked_to: p.linked } : {}),
832
+ ...(p.must.length ? { must_link: p.must } : {}),
833
+ ...(p.cannot.length ? { cannot_link: p.cannot } : {}),
834
+ },
835
+ })),
836
+ triplets: plans.flatMap((p) => [
837
+ ...p.links.map((l) => ({
838
+ source: mentionRef(p.iri, p.span),
839
+ relation: "SAME_THING_AS",
840
+ target: mentionRef(l.target, l.span),
841
+ confidence: l.confidence,
842
+ ...(l.evidence ? { evidence: l.evidence } : {}),
843
+ })),
844
+ ...(p.thing
845
+ ? [
846
+ {
847
+ source: mentionRef(p.iri, p.span),
848
+ relation: "REFERS_TO",
849
+ target: thingRef(p.thing, p.thingLabel),
850
+ },
851
+ ]
852
+ : []),
853
+ ]),
854
+ };
855
+ }
856
+
857
+ /** Part `n` of the owned write of the things: the live ones, the merged ones with their pointer, the gone ones retracted. */
858
+ function thingsWrite(plan: Plan, n: number): OwnedWrite {
859
+ const all = [
860
+ ...plan.things.map((t) => ({ kind: "live" as const, t })),
861
+ ...plan.merged.map((m) => ({ kind: "merged" as const, m })),
862
+ ...plan.gone.map((g) => ({ kind: "gone" as const, g })),
863
+ ].slice(n * WRITE_PART, (n + 1) * WRITE_PART);
864
+ const live = all.flatMap((x) => (x.kind === "live" ? [x.t] : []));
865
+ const merged = all.flatMap((x) => (x.kind === "merged" ? [x.m] : []));
866
+ const gone = all.flatMap((x) => (x.kind === "gone" ? [x.g] : []));
867
+ return {
868
+ subjects: [
869
+ ...live.map((t) => thingRef(t.iri, t.label)),
870
+ ...merged.map((m) => thingRef(m.from, m.label)),
871
+ ],
872
+ relations: ["HAS_RECORD", "MERGED_INTO"],
873
+ properties: THING_FIELDS,
874
+ retract_entities: gone.map((g) => thingRef(g, "")),
875
+ entity_properties: live.map((t) => ({
876
+ ...thingRef(t.iri, t.label),
877
+ properties: {
878
+ anchor_key: t.anchorKey,
879
+ kind: t.kind,
880
+ ...(t.aliases.length ? { alias: t.aliases } : {}),
881
+ },
882
+ })),
883
+ triplets: [
884
+ ...live.flatMap((t) =>
885
+ t.records.map((r) => ({
886
+ source: thingRef(t.iri, t.label),
887
+ relation: "HAS_RECORD",
888
+ target: entityRef(r.iri, r.class, r.name || undefined),
889
+ })),
890
+ ),
891
+ ...merged.map((m) => ({
892
+ source: thingRef(m.from, m.label),
893
+ relation: "MERGED_INTO",
894
+ target: thingRef(m.into, m.intoLabel),
895
+ })),
896
+ ],
897
+ };
898
+ }
899
+
900
+ // ---- review items -------------------------------------------------------------------
901
+
902
+ /** File the items of the plan: per thing its link and no-link items with the rows still open, and the identities. */
903
+ async function fileItems(
904
+ s: WorkflowStepContext,
905
+ lbb: LbbActivities,
906
+ ports: LinkPorts,
907
+ settings: Settings,
908
+ gate: number,
909
+ plan: Plan,
910
+ open: ThingsState["open"],
911
+ ): Promise<{ open: ThingsState["open"]; rows: number; identities: number }> {
912
+ const next = { ...open };
913
+ let rows = 0;
914
+ let identities = 0;
915
+ const total = plan.rows.length * 2 + plan.identities.length;
916
+ let done = 0;
917
+ for (const { target, rows: fresh } of plan.rows) {
918
+ for (const kind of ["link", "nolink"] as const) {
919
+ s.progress("filing review items", { done: done++, total, unit: "items" });
920
+ const outcome = kind === "link" ? "review" : "nolink";
921
+ const now = fresh.filter((r) => r.decision.outcome === outcome);
922
+ const openKey = `${kind}:${target.iri}`;
923
+ const before = next[openKey];
924
+ let kept: Row[] = [];
925
+ if (before) {
926
+ const item = await ports.suggestion(before.id).catch((error: unknown) => {
927
+ if (error instanceof LbbError && error.status === 404) return null;
928
+ throw error;
929
+ });
930
+ if (item?.status === "open")
931
+ kept = rowsOfItem(item).filter(
932
+ (r) =>
933
+ !fresh.some((f) => f.mention.iri === r.mention.iri) &&
934
+ (plan.thingOf[r.mention.iri] ?? null) !== target.iri,
935
+ );
936
+ else delete next[openKey];
937
+ }
938
+ const all = [...now, ...kept];
939
+ if (!all.length) continue;
940
+ const requests = thingItems(settings.name, gate, target, all);
941
+ for (const request of requests) {
942
+ const filed = await lbb.review(request);
943
+ if (request === requests[0]) next[openKey] = { id: filed.suggestion_id, key: request.key! };
944
+ }
945
+ if (before && next[openKey]?.key !== before.key && open[openKey])
946
+ await ports
947
+ .supersede(before.id, `The rows changed: the item ${requests[0]!.key} holds them now.`)
948
+ .catch((error: unknown) => {
949
+ if (!(error instanceof LbbError && (error.status === 404 || error.status === 409)))
950
+ throw error;
951
+ });
952
+ rows += now.length;
953
+ }
954
+ }
955
+ for (const { proposal, records } of plan.identities) {
956
+ s.progress("filing review items", { done: done++, total, unit: "items" });
957
+ const request = identityItem(
958
+ settings.name,
959
+ settings.identityGate,
960
+ proposal,
961
+ new Map(records.map((r) => [r.iri, r])),
962
+ );
963
+ if (!request) continue;
964
+ const filed = await lbb.review(request);
965
+ next[`identity:${request.key}`] = {
966
+ id: filed.suggestion_id,
967
+ key: request.key!,
968
+ records: proposal.records.map((r) => idIn(r).slice(0, 12)),
969
+ };
970
+ identities += 1;
971
+ }
972
+ for (const id of plan.supersede) {
973
+ await ports
974
+ .supersede(id, "More than 10 records share these keys now: no identity of them is proposed.")
975
+ .catch((error: unknown) => {
976
+ if (!(error instanceof LbbError && (error.status === 404 || error.status === 409)))
977
+ throw error;
978
+ });
979
+ for (const [key, entry] of Object.entries(next)) if (entry.id === id) delete next[key];
980
+ }
981
+ const keys = Object.keys(next);
982
+ for (const key of keys.slice(0, Math.max(0, keys.length - MAX_OPEN))) delete next[key];
983
+ return { open: next, rows, identities };
984
+ }