@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.
- package/README.md +3 -1
- package/lib/create.mjs +26 -4
- package/package.json +1 -1
- package/templates/_shared/testing.ts +17 -4
- package/templates/link/README.md +203 -0
- package/templates/link/candidates.ts +432 -0
- package/templates/link/decide.test.ts +368 -0
- package/templates/link/decide.ts +340 -0
- package/templates/link/fixture.ts +727 -0
- package/templates/link/grade-run.ts +53 -0
- package/templates/link/grade.test.ts +113 -0
- package/templates/link/grade.ts +335 -0
- package/templates/link/items.ts +333 -0
- package/templates/link/names.test.ts +260 -0
- package/templates/link/names.ts +590 -0
- package/templates/link/reads.ts +302 -0
- package/templates/link/samples/gold.json +108 -0
- package/templates/link/samples/records.json +48 -0
- package/templates/link/samples.ts +64 -0
- package/templates/link/settings.ts +162 -0
- package/templates/link/setup.ts +18 -0
- package/templates/link/source-turn.ts +894 -0
- package/templates/link/sources/records.ts +429 -0
- package/templates/link/things-turn.ts +984 -0
- package/templates/link/things.test.ts +204 -0
- package/templates/link/things.ts +426 -0
- package/templates/link/try.ts +28 -0
- package/templates/link/worker.ts +24 -0
- package/templates/link/workflow.test.ts +484 -0
- package/templates/link/workflow.ts +202 -0
- package/templates/versions.json +1 -1
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// The review items of the link workflow (general linking 5.5): one link
|
|
2
|
+
// item per thing, one no-link item per thing of the best candidate, and one
|
|
3
|
+
// identity item per group of records. Pure functions.
|
|
4
|
+
//
|
|
5
|
+
// Every item has the origin `starter:<name>` with the label `link`, the
|
|
6
|
+
// producer the inbox labels and patterns read
|
|
7
|
+
// (`lbb-server/src/calibration/labels.rs`). A key names the kind and the
|
|
8
|
+
// thing, and ends in a hash of the rows: new rows file a new item, and the
|
|
9
|
+
// workflow sets the item it replaces to `superseded`.
|
|
10
|
+
import { ENTITY_ID_KEY_SENTINEL, type ReviewRequest, type Schemas } from "@littlebigbrain/client";
|
|
11
|
+
import { shortId, type Decision } from "./decide.js";
|
|
12
|
+
import { hash, idOf, type IdentityProposal } from "./things.js";
|
|
13
|
+
|
|
14
|
+
/** The producer id of the items: the catalog id of the workflow (general linking 12, decision 5). */
|
|
15
|
+
export const PRODUCER = "link";
|
|
16
|
+
|
|
17
|
+
/** Rows of one item, at most: its facts stay within the 200 rows and 64 KiB of a suggestion. */
|
|
18
|
+
export const ITEM_ROWS = 50;
|
|
19
|
+
|
|
20
|
+
/** A mention as an item shows it. */
|
|
21
|
+
export interface MentionView {
|
|
22
|
+
iri: string;
|
|
23
|
+
span: string;
|
|
24
|
+
/** The sentence of a span of text; the field's value for a self mention. */
|
|
25
|
+
line: string;
|
|
26
|
+
source: string;
|
|
27
|
+
sourceName: string;
|
|
28
|
+
sourceClass: string;
|
|
29
|
+
field: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** What an item is about: a thing, or a lone mention. */
|
|
33
|
+
export interface TargetView {
|
|
34
|
+
/** The thing's IRI, or the lone mention's. */
|
|
35
|
+
iri: string;
|
|
36
|
+
/** The mention the rows pair with: the thing's anchor, or the lone mention. */
|
|
37
|
+
anchor: MentionView;
|
|
38
|
+
label: string;
|
|
39
|
+
kind: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A decision below the gate, with the mention and the thing it may name. */
|
|
43
|
+
export interface Row {
|
|
44
|
+
decision: Decision;
|
|
45
|
+
mention: MentionView;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The commit reference of a mention: its IRI, by id, with its span as the name. */
|
|
49
|
+
export const mentionRef = (m: { iri: string; span: string }): Schemas["NamedEntityInput"] => ({
|
|
50
|
+
type: "Mention",
|
|
51
|
+
name: m.span.slice(0, 200) || idOf(m.iri),
|
|
52
|
+
key: `${ENTITY_ID_KEY_SENTINEL}${idOf(m.iri)}`,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/** The origin of every item. */
|
|
56
|
+
export const origin = (name: string): Schemas["SuggestionOrigin"] => ({
|
|
57
|
+
kind: "agent",
|
|
58
|
+
id: `starter:${name}`,
|
|
59
|
+
label: PRODUCER,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const where = (m: MentionView) => `${m.sourceClass} "${m.sourceName.slice(0, 80)}"`;
|
|
63
|
+
|
|
64
|
+
/** The `SAME_THING_AS` fact of a row, with the row's line as its evidence. */
|
|
65
|
+
export function rowFact(row: Row, target: TargetView, confidence: number): Schemas["TripletInput"] {
|
|
66
|
+
return {
|
|
67
|
+
source: mentionRef(row.mention),
|
|
68
|
+
relation: "SAME_THING_AS",
|
|
69
|
+
target: mentionRef(target.anchor),
|
|
70
|
+
confidence,
|
|
71
|
+
evidence: {
|
|
72
|
+
text: row.mention.line.slice(0, 1_000),
|
|
73
|
+
source_id: row.mention.sourceName.slice(0, 200),
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const sample = (row: Row) => ({
|
|
79
|
+
mention: row.mention.span,
|
|
80
|
+
source: where(row.mention),
|
|
81
|
+
field: row.mention.field,
|
|
82
|
+
line: row.mention.line.slice(0, 300),
|
|
83
|
+
confidence: row.decision.confidence,
|
|
84
|
+
found: row.decision.found,
|
|
85
|
+
...(row.decision.options
|
|
86
|
+
? { best_probability: Math.max(0, ...Object.values(row.decision.options)) }
|
|
87
|
+
: {}),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/** The key part of a set of rows: a hash of each row's mention, target and value. */
|
|
91
|
+
const rowsHash = (rows: Row[]) =>
|
|
92
|
+
hash(
|
|
93
|
+
...rows
|
|
94
|
+
.map(
|
|
95
|
+
(r) =>
|
|
96
|
+
`${r.mention.iri}|${r.decision.target}|${r.decision.confidence}|${r.decision.outcome}`,
|
|
97
|
+
)
|
|
98
|
+
.sort(),
|
|
99
|
+
).slice(0, 12);
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The link and no-link items of one thing: the rows below the gate that
|
|
103
|
+
* may name it, across every source, one item per 100 rows. Accepting an
|
|
104
|
+
* item writes the kept rows as `SAME_THING_AS` facts; a row left out, or a
|
|
105
|
+
* dismissed item, keeps its mention apart from the thing.
|
|
106
|
+
*/
|
|
107
|
+
export function thingItems(
|
|
108
|
+
name: string,
|
|
109
|
+
gate: number,
|
|
110
|
+
target: TargetView,
|
|
111
|
+
rows: Row[],
|
|
112
|
+
): ReviewRequest[] {
|
|
113
|
+
const out: ReviewRequest[] = [];
|
|
114
|
+
for (const kind of ["link", "nolink"] as const) {
|
|
115
|
+
const list = rows
|
|
116
|
+
.filter((r) =>
|
|
117
|
+
kind === "link" ? r.decision.outcome === "review" : r.decision.outcome === "nolink",
|
|
118
|
+
)
|
|
119
|
+
.sort(
|
|
120
|
+
(a, b) =>
|
|
121
|
+
a.decision.confidence - b.decision.confidence || (a.mention.iri < b.mention.iri ? -1 : 1),
|
|
122
|
+
);
|
|
123
|
+
for (let n = 0; n * ITEM_ROWS < list.length; n++) {
|
|
124
|
+
const part = list.slice(n * ITEM_ROWS, (n + 1) * ITEM_ROWS);
|
|
125
|
+
const sources = [...new Set(part.map((r) => r.mention.sourceClass))].sort();
|
|
126
|
+
const found = [...new Set(part.map((r) => r.decision.found ?? "none"))].sort();
|
|
127
|
+
// A no-link row offers the best candidate at its probability; its gate compared the no-link answer.
|
|
128
|
+
const triplets = part.map((r) =>
|
|
129
|
+
rowFact(r, target, kind === "link" ? r.decision.confidence : bestProbability(r)),
|
|
130
|
+
);
|
|
131
|
+
const lowest = Math.min(...part.map((r) => r.decision.confidence));
|
|
132
|
+
out.push({
|
|
133
|
+
key: `starter:${name}:${kind}:${idOf(target.iri)}:${rowsHash(part)}${n ? `:${n}` : ""}`,
|
|
134
|
+
title: (kind === "link"
|
|
135
|
+
? `${part.length} ${part.length === 1 ? "mention" : "mentions"} may name ${target.label} (${target.kind})`
|
|
136
|
+
: `${part.length} ${part.length === 1 ? "mention" : "mentions"} may name ${target.label} (${target.kind}): the model chose no thing`
|
|
137
|
+
).slice(0, 200),
|
|
138
|
+
rationale: (kind === "link"
|
|
139
|
+
? `The link of each row is below the gate ${gate} (lowest ${lowest}). Keep the rows that name ${target.label}; a row left out stays apart from it.`
|
|
140
|
+
: `For each row the decision model chose no thing, below the gate ${gate}. Keep the rows that name ${target.label} after all.`
|
|
141
|
+
).slice(0, 4_000),
|
|
142
|
+
origin: origin(name),
|
|
143
|
+
facts: {
|
|
144
|
+
triplets,
|
|
145
|
+
summary: `${triplets.length} SAME_THING_AS links to ${target.label}`.slice(0, 500),
|
|
146
|
+
},
|
|
147
|
+
mapping: {
|
|
148
|
+
link: {
|
|
149
|
+
answer: kind === "link" ? "link" : "no_entry",
|
|
150
|
+
thing: target.iri,
|
|
151
|
+
kind: target.kind,
|
|
152
|
+
target: target.anchor.iri,
|
|
153
|
+
gate,
|
|
154
|
+
sources,
|
|
155
|
+
found,
|
|
156
|
+
...(kind === "nolink" ? { gate_values: part.map((r) => r.decision.confidence) } : {}),
|
|
157
|
+
rows: part.map((r) => ({
|
|
158
|
+
mention: r.mention.iri,
|
|
159
|
+
source: r.mention.source,
|
|
160
|
+
class: r.mention.sourceClass,
|
|
161
|
+
field: r.mention.field,
|
|
162
|
+
value: r.decision.confidence,
|
|
163
|
+
found: r.decision.found,
|
|
164
|
+
...(r.decision.options ? { options: r.decision.options } : {}),
|
|
165
|
+
})),
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
evidence: { records: part.length, samples: part.slice(0, 20).map(sample) },
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const bestProbability = (row: Row) =>
|
|
176
|
+
row.decision.options?.[shortId(row.decision.target ?? "")] ??
|
|
177
|
+
Math.max(0, ...Object.values(row.decision.options ?? {}));
|
|
178
|
+
|
|
179
|
+
/** A record as an identity item shows it. */
|
|
180
|
+
export interface RecordView {
|
|
181
|
+
iri: string;
|
|
182
|
+
name: string;
|
|
183
|
+
class: string;
|
|
184
|
+
/** Its first self mention: the identity's fact pairs these. */
|
|
185
|
+
self?: { iri: string; span: string };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The identity item of one group of records: 2 to 10 records side by side,
|
|
190
|
+
* with what matched. A person always accepts an identity (decision 2):
|
|
191
|
+
* `identityGate` only orders and groups the items (`mapping.identity.above_gate`).
|
|
192
|
+
* Accepting it writes `SAME_AS` between the records, as an identity of an
|
|
193
|
+
* integration does, and a `SAME_THING_AS` fact between their self mentions,
|
|
194
|
+
* so the link workflow sees the accept.
|
|
195
|
+
*/
|
|
196
|
+
export function identityItem(
|
|
197
|
+
name: string,
|
|
198
|
+
identityGate: number,
|
|
199
|
+
proposal: IdentityProposal,
|
|
200
|
+
records: Map<string, RecordView>,
|
|
201
|
+
): ReviewRequest | null {
|
|
202
|
+
const views = proposal.records.map((r) => records.get(r)).filter((r): r is RecordView => !!r);
|
|
203
|
+
if (views.length < 2) return null;
|
|
204
|
+
const members = views.map((r) => ({
|
|
205
|
+
type: r.class,
|
|
206
|
+
name: r.name.slice(0, 200) || idOf(r.iri),
|
|
207
|
+
key: `${ENTITY_ID_KEY_SENTINEL}${idOf(r.iri)}`,
|
|
208
|
+
source: r.class,
|
|
209
|
+
}));
|
|
210
|
+
const reason = [...new Set(proposal.pairs.map((p) => p.reason))].join("; ").slice(0, 200);
|
|
211
|
+
const first = views[0]!;
|
|
212
|
+
const triplets = views
|
|
213
|
+
.slice(1)
|
|
214
|
+
.filter((r) => r.self && first.self)
|
|
215
|
+
.map((r) => ({
|
|
216
|
+
source: mentionRef(r.self!),
|
|
217
|
+
relation: "SAME_THING_AS",
|
|
218
|
+
target: mentionRef(first.self!),
|
|
219
|
+
confidence: proposal.confidence,
|
|
220
|
+
evidence: { text: reason || "an identity of records", source_id: r.name.slice(0, 200) },
|
|
221
|
+
}));
|
|
222
|
+
const kind = [...new Set(views.map((r) => r.class))].sort().join(", ");
|
|
223
|
+
return {
|
|
224
|
+
key: `starter:${name}:identity:${hash(...proposal.records).slice(0, 24)}`,
|
|
225
|
+
title: `${views.length} records may be one: ${views.map((r) => r.name).join(", ")}`.slice(
|
|
226
|
+
0,
|
|
227
|
+
200,
|
|
228
|
+
),
|
|
229
|
+
rationale:
|
|
230
|
+
`Their self mentions match (${reason}). A person accepts every identity; the identity gate ${identityGate} only orders the items.`.slice(
|
|
231
|
+
0,
|
|
232
|
+
4_000,
|
|
233
|
+
),
|
|
234
|
+
origin: origin(name),
|
|
235
|
+
identities: [
|
|
236
|
+
{ members, reason: reason || "matching names and keys", confidence: proposal.confidence },
|
|
237
|
+
],
|
|
238
|
+
...(triplets.length
|
|
239
|
+
? {
|
|
240
|
+
facts: {
|
|
241
|
+
triplets,
|
|
242
|
+
summary: `${triplets.length} SAME_THING_AS links between the records' names`,
|
|
243
|
+
},
|
|
244
|
+
}
|
|
245
|
+
: {}),
|
|
246
|
+
mapping: {
|
|
247
|
+
identity: {
|
|
248
|
+
gate: identityGate,
|
|
249
|
+
above_gate: proposal.confidence >= identityGate,
|
|
250
|
+
classes: kind,
|
|
251
|
+
selves: Object.fromEntries(views.filter((r) => r.self).map((r) => [r.iri, r.self!.iri])),
|
|
252
|
+
pairs: proposal.pairs.map((p) => ({
|
|
253
|
+
a: p.a,
|
|
254
|
+
b: p.b,
|
|
255
|
+
confidence: p.confidence,
|
|
256
|
+
reason: p.reason,
|
|
257
|
+
})),
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
evidence: {
|
|
261
|
+
records: views.length,
|
|
262
|
+
samples: proposal.pairs.slice(0, 20).map((p) => ({
|
|
263
|
+
a: records.get(p.a)?.name ?? p.a,
|
|
264
|
+
b: records.get(p.b)?.name ?? p.b,
|
|
265
|
+
confidence: p.confidence,
|
|
266
|
+
reason: p.reason,
|
|
267
|
+
})),
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The kind of an item by its key: `link`, `nolink` or `identity`. */
|
|
273
|
+
export function itemKind(key: string): "link" | "nolink" | "identity" | null {
|
|
274
|
+
const match = key.match(/^starter:[^:]+:(link|nolink|identity):/);
|
|
275
|
+
return (match?.[1] as "link" | "nolink" | "identity" | undefined) ?? null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The IRI a mention reference of an item names. */
|
|
279
|
+
export const iriOfRef = (ref: { key?: string | null }) =>
|
|
280
|
+
ref.key?.startsWith(ENTITY_ID_KEY_SENTINEL)
|
|
281
|
+
? `https://littlebigbrain.com/e/${ref.key.slice(ENTITY_ID_KEY_SENTINEL.length)}`
|
|
282
|
+
: null;
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The rows of a link or no-link item the workflow filed, read back: its
|
|
286
|
+
* facts and the rows of its mapping. A row the item does not describe in
|
|
287
|
+
* full is left out.
|
|
288
|
+
*/
|
|
289
|
+
export function rowsOfItem(item: Schemas["OntologyChangeSuggestion"]): Row[] {
|
|
290
|
+
const kind = itemKind(item.key);
|
|
291
|
+
if (kind !== "link" && kind !== "nolink") return [];
|
|
292
|
+
const facts = item.proposed_facts ?? item.facts;
|
|
293
|
+
const mapping = (
|
|
294
|
+
item.mapping as { link?: { rows?: Record<string, unknown>[]; thing?: string } } | null
|
|
295
|
+
)?.link;
|
|
296
|
+
const described = new Map((mapping?.rows ?? []).map((r) => [String(r.mention), r]));
|
|
297
|
+
const out: Row[] = [];
|
|
298
|
+
for (const t of facts?.triplets ?? []) {
|
|
299
|
+
const mention = iriOfRef(t.source);
|
|
300
|
+
const target = iriOfRef(t.target);
|
|
301
|
+
const row = mention ? described.get(mention) : undefined;
|
|
302
|
+
if (!mention || !target || !row) continue;
|
|
303
|
+
const evidence = (t.evidence ?? null) as {
|
|
304
|
+
text?: string | null;
|
|
305
|
+
source_id?: string | null;
|
|
306
|
+
} | null;
|
|
307
|
+
out.push({
|
|
308
|
+
decision: {
|
|
309
|
+
mention,
|
|
310
|
+
outcome: kind === "link" ? "review" : "nolink",
|
|
311
|
+
target,
|
|
312
|
+
thing: mapping?.thing ?? null,
|
|
313
|
+
confidence: Number(row.value ?? t.confidence ?? 0),
|
|
314
|
+
by: "model",
|
|
315
|
+
found: typeof row.found === "string" ? row.found : null,
|
|
316
|
+
kind: null,
|
|
317
|
+
...(row.options && typeof row.options === "object"
|
|
318
|
+
? { options: row.options as Record<string, number> }
|
|
319
|
+
: {}),
|
|
320
|
+
},
|
|
321
|
+
mention: {
|
|
322
|
+
iri: mention,
|
|
323
|
+
span: t.source.name,
|
|
324
|
+
line: evidence?.text ?? t.source.name,
|
|
325
|
+
source: String(row.source ?? ""),
|
|
326
|
+
sourceName: evidence?.source_id ?? "",
|
|
327
|
+
sourceClass: String(row.class ?? ""),
|
|
328
|
+
field: String(row.field ?? ""),
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// The normal forms and block keys of the link template (general linking 4.1,
|
|
2
|
+
// 4.2), and how mentions are found in record fields (3.2, 3.3). Every name
|
|
3
|
+
// is invented.
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import {
|
|
7
|
+
blockKeys,
|
|
8
|
+
codeForm,
|
|
9
|
+
cologne,
|
|
10
|
+
emailForm,
|
|
11
|
+
fold,
|
|
12
|
+
initialForms,
|
|
13
|
+
jaroWinkler,
|
|
14
|
+
keptKeys,
|
|
15
|
+
keyKind,
|
|
16
|
+
keyWeight,
|
|
17
|
+
levenshteinRatio,
|
|
18
|
+
nameSimilarity,
|
|
19
|
+
nameWords,
|
|
20
|
+
normalName,
|
|
21
|
+
phoneForm,
|
|
22
|
+
umlautFold,
|
|
23
|
+
webForm,
|
|
24
|
+
} from "./names.js";
|
|
25
|
+
import {
|
|
26
|
+
checkExtracted,
|
|
27
|
+
nameShaped,
|
|
28
|
+
selfMentions,
|
|
29
|
+
textMentions,
|
|
30
|
+
textSpans,
|
|
31
|
+
windowOf,
|
|
32
|
+
} from "./sources/records.js";
|
|
33
|
+
|
|
34
|
+
test("the normal forms fold case, width, diacritics, titles, legal forms and word order", () => {
|
|
35
|
+
assert.equal(fold("MIRA"), "mira");
|
|
36
|
+
assert.equal(fold("Müller"), "muller");
|
|
37
|
+
assert.equal(umlautFold("Müller"), "mueller");
|
|
38
|
+
assert.equal(umlautFold("Mira"), null);
|
|
39
|
+
assert.equal(fold("Søren Łukasz Æsir straße"), "soren lukasz aesir strasse");
|
|
40
|
+
assert.equal(fold("Mira"), "mira", "full width letters fold");
|
|
41
|
+
assert.equal(normalName("Northbay Water GmbH"), "northbay water");
|
|
42
|
+
assert.equal(normalName("Dr. Mira Ruiz"), "mira ruiz");
|
|
43
|
+
assert.equal(normalName("Tannenholt-Ruiz, Mira"), "mira ruiz tannenholt");
|
|
44
|
+
assert.equal(normalName("Mira Tannenholt-Ruiz"), "mira ruiz tannenholt");
|
|
45
|
+
assert.equal(normalName("Bob Haldane"), "haldane robert", "a nickname reads as its common name");
|
|
46
|
+
assert.equal(normalName("O'Neil"), "oneil");
|
|
47
|
+
// A name that is only a title or a legal form keeps it.
|
|
48
|
+
assert.equal(normalName("GmbH"), "gmbh");
|
|
49
|
+
assert.deepEqual(
|
|
50
|
+
nameWords("M. Tannenholt-Ruiz").map((w) => [w.parts, w.initial]),
|
|
51
|
+
[
|
|
52
|
+
[["m"], true],
|
|
53
|
+
[["tannenholt", "ruiz"], false],
|
|
54
|
+
],
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("the initials forms pair a full word with the first letter of the other", () => {
|
|
59
|
+
assert.deepEqual(initialForms(nameWords("M. Tannenholt")), ["tannenholt m"]);
|
|
60
|
+
assert.deepEqual(initialForms(nameWords("Mira Tannenholt")), ["mira t", "tannenholt m"]);
|
|
61
|
+
assert.deepEqual(initialForms(nameWords("Mira Tannenholt-Ruiz")).sort(), [
|
|
62
|
+
"mira t",
|
|
63
|
+
"ruiz m",
|
|
64
|
+
"tannenholt m",
|
|
65
|
+
]);
|
|
66
|
+
assert.deepEqual(initialForms(nameWords("Mira")), []);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("keys, addresses and codes take the resolver's and link version 7's forms", () => {
|
|
70
|
+
assert.equal(
|
|
71
|
+
emailForm(" Mira.Tannenholt@Northbay.example "),
|
|
72
|
+
"mira.tannenholt@northbay.example",
|
|
73
|
+
);
|
|
74
|
+
assert.equal(emailForm("not an address"), null);
|
|
75
|
+
assert.equal(webForm("https://www.Northbay.example/"), "northbay.example");
|
|
76
|
+
assert.equal(webForm("northbay.example/contact?x=1"), "northbay.example/contact");
|
|
77
|
+
assert.equal(phoneForm("+49 (30) 1234-5678"), "012345678", "the last 9 digits");
|
|
78
|
+
assert.equal(phoneForm("12 34"), null);
|
|
79
|
+
assert.equal(codeForm("P 38"), "p38");
|
|
80
|
+
assert.equal(codeForm("P.38"), "p38");
|
|
81
|
+
assert.equal(codeForm("S 4.1"), "s4.1");
|
|
82
|
+
assert.equal(codeForm("Mira"), null, "a code holds a digit");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("Cologne phonetics and the similarities give their published values", () => {
|
|
86
|
+
assert.equal(cologne("Wikipedia"), "3412");
|
|
87
|
+
assert.equal(cologne("Müller-Lüdenscheidt"), "65752682");
|
|
88
|
+
assert.equal(cologne("Müller"), cologne("Mueller"));
|
|
89
|
+
assert.equal(cologne("Ramaswamy"), cologne("Ramaswamey"), "a typo of one vowel keeps the sound");
|
|
90
|
+
assert.equal(Math.round(jaroWinkler("martha", "marhta") * 10_000), 9611);
|
|
91
|
+
assert.equal(Math.round(jaroWinkler("dixon", "dicksonx") * 10_000), 8133);
|
|
92
|
+
assert.equal(Math.round(levenshteinRatio("kitten", "sitting") * 10_000), 5714);
|
|
93
|
+
assert.equal(nameSimilarity("M. Tannenholt", "Mira Tannenholt-Ruiz"), 0.9);
|
|
94
|
+
assert.equal(nameSimilarity("Mimi T.", "M. Tannenholt"), 0.8);
|
|
95
|
+
assert.equal(
|
|
96
|
+
nameSimilarity("F. Okafor", "Dana Okafor"),
|
|
97
|
+
0.5,
|
|
98
|
+
"an initial of another letter does not agree",
|
|
99
|
+
);
|
|
100
|
+
assert.equal(nameSimilarity("Bob Haldane", "Robert Haldane"), 0.975);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("the block keys of the running example (general linking 4.6)", () => {
|
|
104
|
+
const mira = blockKeys("Mira Tannenholt-Ruiz", "name");
|
|
105
|
+
for (const key of [
|
|
106
|
+
"name:mira ruiz tannenholt",
|
|
107
|
+
"name:initial:tannenholt m",
|
|
108
|
+
"name:initial:ruiz m",
|
|
109
|
+
"name:initial:mira t",
|
|
110
|
+
])
|
|
111
|
+
assert.ok(mira.includes(key), key);
|
|
112
|
+
const lead = blockKeys("Mira Ruiz", "name");
|
|
113
|
+
assert.ok(lead.includes("name:mira ruiz") && lead.includes("name:initial:ruiz m"));
|
|
114
|
+
assert.deepEqual(blockKeys("M. Tannenholt", "name"), [
|
|
115
|
+
"name:initial:tannenholt m",
|
|
116
|
+
"name:part:tannenholt",
|
|
117
|
+
"name:sound:26652",
|
|
118
|
+
]);
|
|
119
|
+
const mimi = blockKeys("Mimi T.", "name");
|
|
120
|
+
assert.ok(mimi.includes("name:initial:mimi t") && mimi.includes("name:part:mimi"));
|
|
121
|
+
assert.ok(
|
|
122
|
+
!mimi.some((k) => k.startsWith("name:initial:tannenholt")),
|
|
123
|
+
"Mimi T. shares no key with Mira",
|
|
124
|
+
);
|
|
125
|
+
assert.deepEqual(blockKeys("mira.tannenholt@northbay.example", "email"), [
|
|
126
|
+
"email:mira.tannenholt@northbay.example",
|
|
127
|
+
"domain:northbay.example",
|
|
128
|
+
]);
|
|
129
|
+
assert.deepEqual(
|
|
130
|
+
blockKeys("someone@gmail.com", "email"),
|
|
131
|
+
["email:someone@gmail.com"],
|
|
132
|
+
"no domain of free mail",
|
|
133
|
+
);
|
|
134
|
+
assert.deepEqual(blockKeys("P 38", "code"), ["code:p38"]);
|
|
135
|
+
assert.deepEqual(
|
|
136
|
+
blockKeys("P38", "text"),
|
|
137
|
+
["code:p38"],
|
|
138
|
+
"a span of text written as a code is a code",
|
|
139
|
+
);
|
|
140
|
+
assert.ok(
|
|
141
|
+
blockKeys("Jürgen Kessler", "name").includes("name:juergen kessler"),
|
|
142
|
+
"the umlaut form",
|
|
143
|
+
);
|
|
144
|
+
assert.ok(blockKeys("Juergen Kessler", "name").includes("name:juergen kessler"));
|
|
145
|
+
assert.ok(blockKeys("Bob Haldane", "name").includes("name:haldane robert"), "the nickname form");
|
|
146
|
+
assert.ok(blockKeys("Mira", "name", ["Mimi T."]).includes("alias:mimi t"));
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("a key's kind and weight come from its prefix; the kept keys put strong ones first", () => {
|
|
150
|
+
assert.equal(keyKind("name:initial:tannenholt m"), "name:initial");
|
|
151
|
+
assert.equal(keyKind("name:mira ruiz"), "name");
|
|
152
|
+
assert.equal(keyKind("email:a@b.example"), "email");
|
|
153
|
+
assert.equal(keyKind("nothing"), null);
|
|
154
|
+
assert.equal(keyWeight("code:p38"), "strong");
|
|
155
|
+
assert.equal(keyWeight("name:initial:mira t"), "medium");
|
|
156
|
+
assert.equal(keyWeight("name:sound:67"), "weak");
|
|
157
|
+
const keys = keptKeys(["name:part:x", "email:a@b.example", "name:a b"]);
|
|
158
|
+
assert.deepEqual(keys, ["email:a@b.example", "name:a b", "name:part:x"]);
|
|
159
|
+
assert.equal(keptKeys(Array.from({ length: 30 }, (_, n) => `name:part:w${n}`)).length, 16);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("the gazetteer looks up spans of capitalized words, and keeps the spans written like a name", () => {
|
|
163
|
+
const spans = textSpans("Call with Mimi T.: she wants four filters.").map((s) => s.span);
|
|
164
|
+
assert.ok(spans.includes("Mimi T."));
|
|
165
|
+
assert.ok(
|
|
166
|
+
!spans.some((s) => s.includes("she")),
|
|
167
|
+
"a span stops at a colon and at a lower-case word",
|
|
168
|
+
);
|
|
169
|
+
assert.ok(textSpans("Wren, Tobias - annual renewal").some((s) => s.span === "Wren, Tobias"));
|
|
170
|
+
assert.ok(
|
|
171
|
+
!textSpans("Bob Haldane asked for the list").some((s) => s.span === "Bob Haldane asked"),
|
|
172
|
+
);
|
|
173
|
+
assert.ok(nameShaped("M. Tannenholt"));
|
|
174
|
+
assert.ok(nameShaped("Lena van Dijk"), "a name particle may stand between the capitalized words");
|
|
175
|
+
assert.ok(!nameShaped("van Dijk"), "but not first");
|
|
176
|
+
assert.ok(nameShaped("k.marsh@fernway.example"));
|
|
177
|
+
assert.ok(!nameShaped("Call"));
|
|
178
|
+
assert.ok(
|
|
179
|
+
!nameShaped("Met Kim Oak") && nameShaped("Kim Oak"),
|
|
180
|
+
"a sentence's first word starts no name",
|
|
181
|
+
);
|
|
182
|
+
assert.ok(!nameShaped("PX-200"), "a code alone is kept only when the gazetteer finds it");
|
|
183
|
+
assert.ok(!nameShaped("2026-09-30"), "a date is no phone number");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("a text value gives each name once, the longest span first, with its sentence", () => {
|
|
187
|
+
const text =
|
|
188
|
+
"Northbay Water confirmed. Tobias Wren signed the renewal; Robert Haldane is in copy.";
|
|
189
|
+
const found = textMentions("body", 0, text, new Set(["name:northbay water"]));
|
|
190
|
+
assert.deepEqual(
|
|
191
|
+
found.map((m) => [m.span, m.found, m.offset]),
|
|
192
|
+
[
|
|
193
|
+
["Northbay Water", "gazetteer", 0],
|
|
194
|
+
["Tobias Wren", "shape", 26],
|
|
195
|
+
["Robert Haldane", "shape", 58],
|
|
196
|
+
],
|
|
197
|
+
);
|
|
198
|
+
assert.equal(found[1]!.window, "Tobias Wren signed the renewal; Robert Haldane is in copy.");
|
|
199
|
+
assert.equal(textMentions("body", 0, "Ann Lee met Ann Lee.", new Set())[1]!.occurrence, 1);
|
|
200
|
+
assert.equal(
|
|
201
|
+
textMentions("body", 0, "A B. ".repeat(10) + "Tom Ash and Kim Oak.", new Set(), 1).length,
|
|
202
|
+
1,
|
|
203
|
+
);
|
|
204
|
+
const long = "x".repeat(400) + " Mira Ruiz " + "y".repeat(400);
|
|
205
|
+
assert.ok(windowOf(long, 401, 9).length <= 300);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("a record's names and keys are its self mentions; given and family names join", () => {
|
|
209
|
+
const record = {
|
|
210
|
+
iri: "https://littlebigbrain.com/e/aa",
|
|
211
|
+
class: "Lead",
|
|
212
|
+
name: "Mira Ruiz",
|
|
213
|
+
values: {
|
|
214
|
+
first_name: ["Mira"],
|
|
215
|
+
last_name: ["Ruiz"],
|
|
216
|
+
email: ["mira@northbay.example", "not an address"],
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
const found = selfMentions(record, {
|
|
220
|
+
first_name: "name:given",
|
|
221
|
+
last_name: "name:family",
|
|
222
|
+
email: "key:email",
|
|
223
|
+
company_name: "context",
|
|
224
|
+
});
|
|
225
|
+
assert.deepEqual(
|
|
226
|
+
found.map((m) => [m.field, m.kind, m.span, m.role]),
|
|
227
|
+
[
|
|
228
|
+
["email", "email", "mira@northbay.example", "self"],
|
|
229
|
+
["first_name+last_name", "name", "Mira Ruiz", "self"],
|
|
230
|
+
],
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("the reasoning model's spans are kept only where the text holds them", () => {
|
|
235
|
+
const text = "Met Kim Oak and Kim Oak again.";
|
|
236
|
+
const taken = textMentions("body", 0, text, new Set());
|
|
237
|
+
const extra = checkExtracted(
|
|
238
|
+
{
|
|
239
|
+
spans: [
|
|
240
|
+
{ span: "Kim Oak", kind: "person" },
|
|
241
|
+
{ span: "Nobody Here", kind: "person" },
|
|
242
|
+
{ span: "again", kind: "code" },
|
|
243
|
+
],
|
|
244
|
+
},
|
|
245
|
+
"body",
|
|
246
|
+
0,
|
|
247
|
+
text,
|
|
248
|
+
taken,
|
|
249
|
+
);
|
|
250
|
+
assert.deepEqual(
|
|
251
|
+
taken.map((m) => m.offset),
|
|
252
|
+
[4, 16],
|
|
253
|
+
);
|
|
254
|
+
assert.deepEqual(
|
|
255
|
+
extra.map((m) => [m.span, m.offset]),
|
|
256
|
+
[["again", 24]],
|
|
257
|
+
"taken places stay taken; a missing span goes",
|
|
258
|
+
);
|
|
259
|
+
assert.deepEqual(checkExtracted({ spans: "no list" }, "body", 0, text, []), []);
|
|
260
|
+
});
|