@littlebigbrain/create 0.5.0 → 0.6.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.
@@ -1,18 +1,29 @@
1
1
  // ontology-fit: read the text of each new or changed instance of a class
2
- // and propose the ontology changes the text supports, as review items.
2
+ // and propose the ontology changes the text supports, as review items. The
3
+ // rules are those of LBB's fit from text (`ontology.fit@v1`, the fit sources
4
+ // of lbb-graph-query/src/fit_sources.rs); its eval (sdk/templates/_evals)
5
+ // runs both on the same recorded model answers.
3
6
  //
4
7
  // A triggered workflow of the starter workflow `workflow` watches a class and
5
8
  // its text field, and sends the new and changed instances to this workflow.
6
- // For each instance the workflow asks the reasoning model (`reason`) which
7
- // classes, properties and relations the text needs that the ontology lacks,
8
- // each with a sentence of the text as its quote. It keeps a proposal when the
9
- // quote is in the text and a dry run of the change (`evolve` with `dryRun`)
10
- // says the ontology does not have it yet. Each kept change becomes a review
11
- // item with the change: a person accepts it, and the ontology changes.
9
+ // For each instance the workflow:
12
10
  //
13
- // A change's key names it (`fit:<workflow>/add_class/mangel`), so the same
14
- // change from several texts is one review item.
11
+ // 1. asks the reasoning model (`reason`), one call per chunk of the text,
12
+ // which classes, properties and relations the text needs that the
13
+ // ontology lacks, each with a sentence of the text as its quote. The model
14
+ // reads the ontology and the changes already proposed and waiting for
15
+ // review, so it proposes them again by the same names;
16
+ // 2. checks each proposal without a model: its shape (with the ontology's
17
+ // name style), one per change, its quote in the text, and a dry run of the
18
+ // change (`evolve` with `dryRun`): a change the ontology has is
19
+ // `already_covered`;
20
+ // 3. asks the decision model (`decide`) how well each quote supports its
21
+ // change; a change below 0.5 is dropped (`weak_support`);
22
+ // 4. merges the kept changes into the review items: one per change, keyed by
23
+ // it (`fit:<workflow>/add_class/mangel`), with the best supported samples
24
+ // of every text that supports it.
15
25
  import {
26
+ isActivityRefused,
16
27
  LbbError,
17
28
  triggeredWorkflow,
18
29
  type LbbActivities,
@@ -25,7 +36,7 @@ export interface Settings {
25
36
  className: string;
26
37
  /** The property that holds each instance's text. */
27
38
  textField: string;
28
- /** One sentence about what the text is, for the model. */
39
+ /** One sentence about what the text is, for the models. */
29
40
  context: string;
30
41
  }
31
42
 
@@ -58,14 +69,41 @@ export function ontology(settings: Settings = defaults): Schemas["OntologyEvolve
58
69
  ];
59
70
  }
60
71
 
72
+ /** The counters of the workflow: those of LBB's fit sources. */
61
73
  export interface FitState {
62
- texts: number;
74
+ instances: number;
75
+ chunks: number;
76
+ failed_chunks: number;
63
77
  proposals: number;
78
+ kept: number;
64
79
  filed: number;
80
+ updated: number;
81
+ reopened: number;
82
+ quote_not_found: number;
83
+ invalid: number;
84
+ already_covered: number;
85
+ weak_support: number;
86
+ skipped: number;
65
87
  }
66
88
 
89
+ const ZERO: FitState = {
90
+ instances: 0,
91
+ chunks: 0,
92
+ failed_chunks: 0,
93
+ proposals: 0,
94
+ kept: 0,
95
+ filed: 0,
96
+ updated: 0,
97
+ reopened: 0,
98
+ quote_not_found: 0,
99
+ invalid: 0,
100
+ already_covered: 0,
101
+ weak_support: 0,
102
+ skipped: 0,
103
+ };
104
+
67
105
  /** One proposal as the model gives it: every field a string, empty when the kind does not use it. */
68
- interface Raw {
106
+ export interface Raw {
69
107
  kind: string;
70
108
  name: string;
71
109
  parent: string;
@@ -77,17 +115,34 @@ interface Raw {
77
115
  reason: string;
78
116
  }
79
117
 
80
- /** A proposal as a change: its key, title and evolve operations, and the quote that supports it. */
118
+ type Kind = "add_class" | "add_property" | "add_relation" | "widen_relation";
119
+
120
+ /** A proposal as a change: its kind, name, key, title, anchor and evolve operations. */
81
121
  export interface Change {
122
+ kind: Kind;
123
+ name: string;
82
124
  key: string;
83
125
  title: string;
84
126
  anchor: Schemas["SuggestionAnchor"];
85
127
  change: Schemas["OntologyEvolveOp"][];
128
+ }
129
+
130
+ /** What one proposal became. */
131
+ export interface Proposal {
132
+ key: string;
133
+ title: string;
134
+ kind: Kind;
135
+ anchor?: Schemas["SuggestionAnchor"];
136
+ change?: Schemas["OntologyEvolveOp"][];
86
137
  quote: string;
87
138
  reason: string;
139
+ support?: number;
140
+ outcome: "kept" | "quote_not_found" | "invalid" | "already_covered" | "weak_support";
141
+ detail?: string;
88
142
  }
89
143
 
90
- const SYSTEM = `You read text from a knowledge graph and propose changes to its ontology. The ontology has classes, relations between classes, and properties. Propose only changes that the text supports and that the ontology does not have yet:
144
+ /** The system prompt of LBB's fit (`SYSTEM_PROMPT`). */
145
+ export const SYSTEM = `You read text from a knowledge graph and propose changes to its ontology. The ontology has classes, relations between classes, and properties. Propose only changes that the text supports and that the ontology does not have yet:
91
146
  - add_class: a new kind of thing the text talks about, with its parent class when one in the ontology fits (\`name\`, \`parent\`).
92
147
  - add_property: a new fact that instances of a class carry (\`name\`, \`class\`, \`value_type\`).
93
148
  - add_relation: a new link between two classes (\`name\`, \`domain\`, \`range\`).
@@ -96,11 +151,21 @@ Rules:
96
151
  - Propose kinds of things and links that many texts like this one hold. Do not propose one person, one date, one number, or one event as a class.
97
152
  - Each proposal quotes one sentence of the text in \`quote\`, copied exactly, character for character. A proposal without an exact quote is dropped.
98
153
  - Use the ontology's names for existing classes and relations. Name new classes as singular nouns, as the ontology names its classes. Name new relations as verbs and new properties as nouns, in the naming style the ontology states (UPPER_SNAKE_CASE relations and snake_case properties when it states none).
99
- - A list may follow of the changes already proposed and waiting for review. When the text supports one of them, propose it again with exactly the same names: it adds evidence. Never propose a synonym of a relation or property the ontology has or the list holds, and never the inverse of one.
154
+ - A list may follow of the changes already proposed from this source and waiting for review. When the text supports one of them, propose it again with exactly the same names: it adds evidence. Never propose a synonym of a relation or property the ontology has or the list holds (\`holds\` beside \`holds_certificate\`, \`uses\` beside \`uses_system\`), and never the inverse of one (\`contract_with\` beside \`has_contract\`). Never propose a change the rejected list names.
100
155
  - \`reason\` is one short sentence.
101
156
  - Leave the fields a kind does not use empty.
102
157
  - Propose at most 12 changes. An empty list is a good answer when the ontology covers the text.`;
103
158
 
159
+ const SCORE_INSTRUCTIONS =
160
+ "The state holds the ontology of a knowledge graph and the class whose text was read. A model proposed a change to the ontology and quoted a sentence of the text to support it. How well does the quote support the change?";
161
+ const SCORE_CRITERIA: Record<string, string> = {
162
+ already_covered: "The ontology already has a class, property or relation that holds this.",
163
+ not_supported: "The quote does not say what the change needs.",
164
+ supported:
165
+ "The quote states that this kind of thing, fact or link exists in the domain, so the ontology needs the change.",
166
+ weak: "The quote mentions it only in passing, or it fits one case and not a kind of thing.",
167
+ };
168
+
104
169
  const text = (description: string) => ({ type: "string", description });
105
170
  const SCHEMA = {
106
171
  type: "object",
@@ -148,244 +213,670 @@ const SCHEMA = {
148
213
  },
149
214
  };
150
215
 
151
- /** Texts one turn reads: one step each. */
152
- const TURN_INSTANCES = 48;
153
- const TEXT_CHARS = 40_000;
216
+ /** Texts one turn reads: two steps each, after the step that reads the inbox. */
217
+ const TURN_INSTANCES = 24;
218
+ /** Characters of an instance's text, and of one chunk the model reads, and their overlap. */
219
+ const TEXT_CHARS = 400_000;
220
+ const CHUNK_CHARS = 40_000;
221
+ const CHUNK_OVERLAP_CHARS = 1_000;
222
+ const PROPOSALS_PER_CHUNK = 12;
223
+ const SCORE_BATCH = 20;
224
+ const SUPPORT_MIN = 0.5;
225
+ const SAMPLES = 20;
226
+ const QUOTE_MIN_WORDS = 3;
227
+ const ONTOLOGY_MAX_CHARS = 24_000;
228
+ const ONTOLOGY_MAX_TERMS = 400;
229
+ const KEY_PART_CHARS = 48;
230
+ const PROPOSED_MAX = 200;
231
+ const REJECTED_MAX = 100;
232
+ const VALUE_TYPES = ["bool", "i64", "f64", "date_time", "keyword", "text", "bytes"];
154
233
  const PLAIN_IRI = /^[^\s<>"{}|\\^`]+$/;
234
+ const ENTITY_IRI = /^https:\/\/littlebigbrain\.com\/e\/[0-9a-fA-F]{32}$/;
235
+ const LABEL = "http://www.w3.org/2000/01/rdf-schema#label";
236
+ const P = "https://littlebigbrain.com/p/";
237
+ const CLASS_NS = "https://littlebigbrain.com/class/";
155
238
  const normalize = (value: string) => value.split(/\s+/).filter(Boolean).join(" ");
239
+ /** Cut at `max` characters with an ellipsis, as LBB cuts a title or a quote. */
240
+ const clip = (value: string, max: number) => {
241
+ const all = [...value];
242
+ return all.length <= max ? value : `${all.slice(0, max - 1).join("")}…`;
243
+ };
244
+ const same = (a: string, b: string) => a.toLowerCase() === b.toLowerCase();
245
+
246
+ /** The IRI a records graph gives a class name. */
247
+ export const classIri = (name: string) =>
248
+ CLASS_NS + encodeURIComponent(name.trim().toLowerCase().split(/\s+/).join(" "));
249
+
250
+ /** A class's name as LBB's fit gives it to the models: the words of its IRI's local name, in lower case. */
251
+ export function displayName(local: string): string {
252
+ let out = "";
253
+ let previousLower = false;
254
+ for (const c of local) {
255
+ if (c === "_" || c === "-" || c === ".") {
256
+ if (out && !out.endsWith(" ")) out += " ";
257
+ previousLower = false;
258
+ continue;
259
+ }
260
+ const upper = c !== c.toLowerCase() && c === c.toUpperCase();
261
+ if (upper && previousLower) out += " ";
262
+ previousLower = (c !== c.toUpperCase() && c === c.toLowerCase()) || /[0-9]/.test(c);
263
+ out += c.toLowerCase();
264
+ }
265
+ return out.trim() || local;
266
+ }
156
267
 
157
268
  /** A part of a key: lowercase ASCII letters and digits, every other run as one dash. */
158
269
  export function keyPart(value: string): string {
159
270
  const part = value
160
271
  .toLowerCase()
161
272
  .replace(/[^a-z0-9]+/g, "-")
162
- .replace(/^-+|-+$/g, "")
163
- .slice(0, 48)
164
- .replace(/-+$/, "");
165
- return part || "x";
273
+ .replace(/^-+|-+$/g, "");
274
+ return [...part].slice(0, KEY_PART_CHARS).join("").replace(/-+$/, "") || "x";
166
275
  }
167
276
 
168
- /** The ontology as the model reads it, and its names by lower case. */
277
+ type Style = "lower" | "upper";
278
+
279
+ /** The words of a name: `SENDS_INVOICE`, `sendsInvoice`, `sends invoice`, `HTTPServer`. */
280
+ function nameWords(name: string): string[] {
281
+ const chars = [...name];
282
+ const words: string[] = [];
283
+ let word = "";
284
+ const isUpper = (c?: string) => c !== undefined && c !== c.toLowerCase() && c === c.toUpperCase();
285
+ const isLower = (c?: string) => c !== undefined && c !== c.toUpperCase() && c === c.toLowerCase();
286
+ for (const [i, c] of chars.entries()) {
287
+ if (!/[\p{L}\p{N}]/u.test(c)) {
288
+ if (word) words.push(word);
289
+ word = "";
290
+ continue;
291
+ }
292
+ const previous = chars[i - 1];
293
+ const next = chars[i + 1];
294
+ const hump =
295
+ isUpper(c) &&
296
+ previous !== undefined &&
297
+ (isLower(previous) || /[0-9]/.test(previous) || (isUpper(previous) && isLower(next)));
298
+ if (hump && word) {
299
+ words.push(word);
300
+ word = "";
301
+ }
302
+ word += c;
303
+ }
304
+ if (word) words.push(word);
305
+ return words;
306
+ }
307
+
308
+ /** The style most names share, when at least 60% have it. */
309
+ function nameStyle(names: string[]): Style | null {
310
+ if (!names.length) return null;
311
+ const lower = names.filter((n) => /^[a-z][a-z0-9_]*$/.test(n)).length;
312
+ const upper = names.filter((n) => /^[A-Z][A-Z0-9_]*$/.test(n)).length;
313
+ if (lower * 10 >= names.length * 6) return "lower";
314
+ if (upper * 10 >= names.length * 6) return "upper";
315
+ return null;
316
+ }
317
+
318
+ const styled = (style: Style, name: string) => {
319
+ const words = nameWords(name).join("_");
320
+ return style === "lower" ? words.toLowerCase() : words.toUpperCase();
321
+ };
322
+
323
+ /** The ontology as LBB's fit reads it: a text for the models, its names by lower case, and its name styles. */
169
324
  export function describe(view: Schemas["OntologyView"]) {
170
325
  const types = (view.entity_type_defs ?? []).filter((t) => t.removed_version == null);
171
- const relations = (view.relation_defs ?? []).filter((r) => r.removed_version == null);
172
- const properties = view.property_defs ?? [];
173
- const byId = new Map(types.map((t) => [t.stable_id, t.name]));
326
+ const byId = new Map((view.entity_type_defs ?? []).map((t) => [t.stable_id, t.name]));
327
+ const classes = new Map<string, string>();
328
+ const relations = new Map<string, string>();
329
+ const properties = new Map<string, string>();
330
+ const lines = ["Classes (name: parent classes):"];
331
+ for (const t of types.slice(0, ONTOLOGY_MAX_TERMS)) {
332
+ classes.set(t.name.toLowerCase(), t.name);
333
+ const parents = (t.super_types ?? []).map((id) => byId.get(id) ?? id);
334
+ lines.push(parents.length ? `- ${t.name}: ${parents.join(", ")}` : `- ${t.name}`);
335
+ }
336
+ lines.push("Relations (name: domain -> range):");
174
337
  const side = (names: string[]) => (names.length ? names.join(" | ") : "any");
175
- const lines = [
176
- "Classes (name: parent classes):",
177
- ...types.map(
178
- (t) =>
179
- `- ${t.name}${t.super_types?.length ? `: ${t.super_types.map((id) => byId.get(id) ?? id).join(", ")}` : ""}`,
180
- ),
181
- "Relations (name: domain -> range):",
182
- ...relations.map((r) => `- ${r.name}: ${side(r.src_types)} -> ${side(r.dst_types)}`),
183
- "Properties (graph-wide fields, name: value type):",
184
- ...properties.map((p) => `- ${p.name}: ${p.value_type}`),
185
- ];
186
- const index = (names: string[]) => new Map(names.map((n) => [n.toLowerCase(), n]));
338
+ for (const r of (view.relation_defs ?? [])
339
+ .filter((r) => r.removed_version == null)
340
+ .slice(0, ONTOLOGY_MAX_TERMS)) {
341
+ relations.set(r.name.toLowerCase(), r.name);
342
+ lines.push(`- ${r.name}: ${side(r.src_types)} -> ${side(r.dst_types)}`);
343
+ }
344
+ lines.push("Properties (graph-wide fields, name: value type):");
345
+ for (const p of (view.property_defs ?? []).slice(0, ONTOLOGY_MAX_TERMS)) {
346
+ properties.set(p.name.toLowerCase(), p.name);
347
+ lines.push(`- ${p.name}: ${p.value_type}`);
348
+ }
349
+ // As LBB's maps keep them: in name order.
350
+ const sorted = (m: Map<string, string>) =>
351
+ [...m.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([, v]) => v);
352
+ const relationStyle = nameStyle(sorted(relations));
353
+ const propertyStyle = nameStyle(sorted(properties));
354
+ const describeStyle = (s: Style) =>
355
+ s === "lower" ? "lower snake_case (works_in)" : "upper snake_case (WORKS_IN)";
356
+ const naming = ["Naming: classes as the ontology names them."];
357
+ if (relationStyle)
358
+ naming.push(
359
+ `Relations are ${describeStyle(relationStyle)} like the ontology's; a new relation must be too.`,
360
+ );
361
+ if (propertyStyle)
362
+ naming.push(
363
+ `Properties are ${describeStyle(propertyStyle)} like the ontology's; a new property must be too.`,
364
+ );
365
+ lines.push(naming.join(" "));
366
+ let out = "";
367
+ for (const line of lines) {
368
+ if (
369
+ new TextEncoder().encode(out).length + new TextEncoder().encode(line).length + 1 >
370
+ ONTOLOGY_MAX_CHARS
371
+ ) {
372
+ out += "- … (more elements not shown)\n";
373
+ break;
374
+ }
375
+ out += `${line}\n`;
376
+ }
377
+ const relation = (name: string) => relations.get(name.trim().toLowerCase());
187
378
  return {
188
- text: lines.join("\n"),
189
- classes: index(types.map((t) => t.name)),
190
- relations: index(relations.map((r) => r.name)),
191
- properties: index(properties.map((p) => p.name)),
379
+ text: out,
380
+ classes,
381
+ relations,
382
+ properties,
383
+ relation,
384
+ className: (name: string) => classes.get(name.trim().toLowerCase()) ?? name.trim(),
385
+ relationName: (name: string) => {
386
+ const n = name.trim();
387
+ const s = relationStyle ? styled(relationStyle, n) : n;
388
+ return relation(n) ?? relation(s) ?? s;
389
+ },
390
+ propertyName: (name: string) => {
391
+ const n = name.trim();
392
+ const s = propertyStyle ? styled(propertyStyle, n) : n;
393
+ return properties.get(n.toLowerCase()) ?? properties.get(s.toLowerCase()) ?? s;
394
+ },
192
395
  };
193
396
  }
194
397
  type Ontology = ReturnType<typeof describe>;
195
398
 
196
- /** A raw proposal as a change with its key, or null when it has no shape. Adapted from fit_sources.rs. */
197
- export function shape(source: string, raw: Raw, onto: Ontology): Change | null {
198
- const name = raw.name.trim();
199
- if (!name || !["add_class", "add_property", "add_relation", "widen_relation"].includes(raw.kind))
200
- return null;
201
- const cls = (n: string) => onto.classes.get(n.trim().toLowerCase()) ?? n.trim();
202
- const declare = (names: string[]): Schemas["OntologyEvolveOp"][] =>
203
- [...new Set(names.filter((n) => n && !onto.classes.has(n.toLowerCase())))].map((n) => ({
204
- op: "add_entity_type",
205
- name: n,
206
- }));
207
- const base = { quote: raw.quote, reason: raw.reason.trim() };
399
+ /** An evolve operation with every field, as LBB writes and describes it. */
400
+ type Op = Schemas["OntologyEvolveOp"];
401
+ const addEntityType = (name: string): Op => ({ op: "add_entity_type", name });
402
+
403
+ /** A raw proposal as a change with its key, or why it has none (LBB's `shape_proposal`). */
404
+ export function shape(source: string, raw: Raw, onto: Ontology): Change | { error: string } {
405
+ const kinds: Kind[] = ["add_class", "add_property", "add_relation", "widen_relation"];
406
+ let kind = kinds.find((k) => k === raw.kind.trim());
407
+ if (!kind) return { error: `unknown kind \`${raw.kind}\`` };
408
+ const rawName = raw.name.trim();
409
+ if (!rawName) return { error: "the proposal has no name" };
410
+ const name =
411
+ kind === "add_relation" || kind === "widen_relation"
412
+ ? onto.relationName(rawName)
413
+ : kind === "add_property"
414
+ ? onto.propertyName(rawName)
415
+ : rawName;
208
416
  // A new relation with the name of an existing one widens it.
209
- const relation = onto.relations.get(name.toLowerCase());
210
- const kind = raw.kind === "add_relation" && relation ? "widen_relation" : raw.kind;
417
+ if (kind === "add_relation" && onto.relation(name)) kind = "widen_relation";
418
+ const declare = (cls: string, ops: Op[]) => {
419
+ const declared = ops.some(
420
+ (op) => op.op === "add_entity_type" && same((op as { name: string }).name, cls),
421
+ );
422
+ if (!onto.classes.has(cls.trim().toLowerCase()) && !declared) ops.push(addEntityType(cls));
423
+ };
211
424
  const prefix = `fit:${source}/${kind}`;
425
+ const done = (c: Change): Change => ({ ...c, title: clip(c.title, 200) });
212
426
  if (kind === "add_class") {
213
- const parent =
214
- raw.parent.trim() && raw.parent.trim().toLowerCase() !== name.toLowerCase()
215
- ? cls(raw.parent)
216
- : "";
217
- const change: Schemas["OntologyEvolveOp"][] = [
218
- { op: "add_entity_type", name: cls(name) },
219
- ...declare([parent]),
220
- ];
221
- if (parent)
222
- change.push({ op: "add_super_types", entity_type: cls(name), super_types: [parent] });
223
- return {
224
- ...base,
225
- key: `${prefix}/${keyPart(cls(name))}`,
226
- title: parent ? `Add class ${cls(name)} under ${parent}` : `Add class ${cls(name)}`,
227
- anchor: { kind: "class", name: cls(name) },
427
+ const cls = onto.className(name);
428
+ const parent = raw.parent.trim();
429
+ const change: Op[] = [addEntityType(cls)];
430
+ let title = `Add class ${cls}`;
431
+ if (parent && !same(parent, cls)) {
432
+ const p = onto.className(parent);
433
+ declare(p, change);
434
+ change.push({ op: "add_super_types", entity_type: cls, super_types: [p] });
435
+ title = `Add class ${cls} under ${p}`;
436
+ }
437
+ return done({
438
+ kind,
439
+ name: cls,
440
+ key: `${prefix}/${keyPart(cls)}`,
441
+ title,
442
+ anchor: { kind: "class", name: cls },
228
443
  change,
229
- };
444
+ });
230
445
  }
231
446
  if (kind === "add_property") {
232
- if (!raw.class.trim()) return null;
233
- const property = onto.properties.get(name.toLowerCase()) ?? name;
234
- const type = ["text", "keyword", "i64", "f64", "bool", "date_time"].includes(raw.value_type)
235
- ? raw.value_type
236
- : "text";
237
- return {
238
- ...base,
239
- key: `${prefix}/${keyPart(cls(raw.class))}/${keyPart(property)}`,
240
- title: `Add property ${property} to ${cls(raw.class)}`,
241
- anchor: { kind: "property", name: property },
242
- change: [{ op: "add_property", name: property, value_type: type }],
243
- };
447
+ if (!raw.class.trim()) return { error: "the property names no class" };
448
+ const cls = onto.className(raw.class);
449
+ const type = raw.value_type.trim().toLowerCase();
450
+ return done({
451
+ kind,
452
+ name,
453
+ key: `${prefix}/${keyPart(cls)}/${keyPart(name)}`,
454
+ title: `Add property ${name} to ${cls}`,
455
+ anchor: { kind: "property", name },
456
+ change: [
457
+ {
458
+ op: "add_property",
459
+ name,
460
+ value_type: VALUE_TYPES.includes(type) ? type : "text",
461
+ required: false,
462
+ allowed_values: [],
463
+ },
464
+ ],
465
+ });
244
466
  }
245
- const [domain, range] = [
246
- raw.domain.trim() ? cls(raw.domain) : "",
247
- raw.range.trim() ? cls(raw.range) : "",
248
- ];
249
467
  if (kind === "add_relation") {
250
- if (!domain || !range) return null;
251
- return {
252
- ...base,
468
+ if (!raw.domain.trim() || !raw.range.trim())
469
+ return { error: "the relation needs a domain and a range" };
470
+ const [domain, range] = [onto.className(raw.domain), onto.className(raw.range)];
471
+ const change: Op[] = [];
472
+ declare(domain, change);
473
+ declare(range, change);
474
+ change.push({
475
+ op: "add_relation",
476
+ name,
477
+ domain: [domain],
478
+ range: [range],
479
+ cardinality: null,
480
+ temporal_semantics: null,
481
+ reducer: null,
482
+ inverse_name: null,
483
+ transitive: false,
484
+ symmetric: false,
485
+ } as Op);
486
+ return done({
487
+ kind,
488
+ name,
253
489
  key: `${prefix}/${keyPart(domain)}/${keyPart(name)}/${keyPart(range)}`,
254
490
  title: `Add relation ${name} from ${domain} to ${range}`,
255
491
  anchor: { kind: "relation", name },
256
- change: [
257
- ...declare([domain, range]),
258
- { op: "add_relation", name, domain: [domain], range: [range] },
259
- ],
260
- };
492
+ change,
493
+ });
261
494
  }
262
- if (!domain && !range) return null;
263
- const widened = relation ?? name;
264
- const title =
265
- domain && range
266
- ? `Let ${widened} link ${domain} to ${range}`
267
- : domain
268
- ? `Let ${widened} start at ${domain}`
269
- : `Let ${widened} point to ${range}`;
270
- const widen: Schemas["OntologyEvolveOp"] = {
495
+ const relation = onto.relation(name) ?? name;
496
+ if (!raw.domain.trim() && !raw.range.trim()) return { error: "the widening adds no class" };
497
+ const domain = raw.domain.trim() ? onto.className(raw.domain) : "";
498
+ const range = raw.range.trim() ? onto.className(raw.range) : "";
499
+ const change: Op[] = [];
500
+ for (const cls of [domain, range].filter(Boolean)) declare(cls, change);
501
+ change.push({
271
502
  op: "widen_relation",
272
- relation: widened,
503
+ relation,
273
504
  add_domain: domain ? [domain] : [],
274
505
  add_range: range ? [range] : [],
275
- };
276
- return {
277
- ...base,
278
- key: `${prefix}/${keyPart(widened)}/${keyPart(domain || "-")}/${keyPart(range || "-")}`,
506
+ });
507
+ const title =
508
+ domain && range
509
+ ? `Let ${relation} link ${domain} to ${range}`
510
+ : domain
511
+ ? `Let ${relation} start at ${domain}`
512
+ : `Let ${relation} point to ${range}`;
513
+ return done({
514
+ kind,
515
+ name: relation,
516
+ key: `${prefix}/${keyPart(relation)}/${keyPart(domain || "-")}/${keyPart(range || "-")}`,
279
517
  title,
280
- anchor: { kind: "relation", name: widened },
281
- change: [...declare([domain, range]), widen],
282
- };
518
+ anchor: { kind: "relation", name: relation },
519
+ change,
520
+ });
521
+ }
522
+
523
+ /** An operation as LBB writes it in a change's description: every field, in its order. */
524
+ export function opJson(op: Op): string {
525
+ const o = op as Record<string, unknown>;
526
+ switch (op.op) {
527
+ case "add_entity_type":
528
+ return JSON.stringify({ op: o.op, name: o.name });
529
+ case "add_super_types":
530
+ return JSON.stringify({ op: o.op, entity_type: o.entity_type, super_types: o.super_types });
531
+ case "add_property":
532
+ return JSON.stringify({
533
+ op: o.op,
534
+ name: o.name,
535
+ value_type: o.value_type ?? null,
536
+ required: o.required ?? false,
537
+ allowed_values: o.allowed_values ?? [],
538
+ });
539
+ case "add_relation":
540
+ return JSON.stringify({
541
+ op: o.op,
542
+ name: o.name,
543
+ domain: o.domain ?? [],
544
+ range: o.range ?? [],
545
+ cardinality: o.cardinality ?? null,
546
+ temporal_semantics: o.temporal_semantics ?? null,
547
+ reducer: o.reducer ?? null,
548
+ inverse_name: o.inverse_name ?? null,
549
+ transitive: o.transitive ?? false,
550
+ symmetric: o.symmetric ?? false,
551
+ });
552
+ case "widen_relation":
553
+ return JSON.stringify({
554
+ op: o.op,
555
+ relation: o.relation,
556
+ add_domain: o.add_domain ?? [],
557
+ add_range: o.add_range ?? [],
558
+ });
559
+ default:
560
+ return JSON.stringify(op);
561
+ }
562
+ }
563
+
564
+ /** Why a quote does not hold, or null: it has at least three words and is in the text, whitespace normalized. */
565
+ export function quoteProblem(quote: string, value: string): string | null {
566
+ const q = normalize(quote);
567
+ if ((q ? q.split(" ").length : 0) < QUOTE_MIN_WORDS)
568
+ return `the quote has fewer than ${QUOTE_MIN_WORDS} words`;
569
+ return normalize(value).includes(q) ? null : "the quote is not in the text";
283
570
  }
284
571
 
285
572
  /** A quote must be in the text, whitespace normalized, with at least three words. */
286
- export const quoteInText = (quote: string, value: string) =>
287
- normalize(quote).split(" ").length >= 3 && normalize(value).includes(normalize(quote));
573
+ export const quoteInText = (quote: string, value: string) => quoteProblem(quote, value) === null;
574
+
575
+ /** The chunks of a text: at most `max` characters, cut after a sentence end or a space in the last tenth, `overlap` shared. */
576
+ export function chunkText(value: string, max = CHUNK_CHARS, overlap = CHUNK_OVERLAP_CHARS) {
577
+ const chars = [...value];
578
+ if (chars.length <= max) return [value];
579
+ overlap = Math.min(overlap, Math.floor(max / 2));
580
+ const space = (c: string) => /\s/.test(c);
581
+ const chunks: string[] = [];
582
+ let start = 0;
583
+ while (start < chars.length) {
584
+ let end = Math.min(start + max, chars.length);
585
+ if (end < chars.length) {
586
+ const floor = Math.max(start + Math.floor((max * 9) / 10), start + 1);
587
+ let cut: number | undefined;
588
+ for (let i = end - 1; i >= floor; i--)
589
+ if (/[.!?\n]/.test(chars[i - 1]!) && space(chars[i]!)) {
590
+ cut = i;
591
+ break;
592
+ }
593
+ if (cut === undefined)
594
+ for (let i = end - 1; i >= floor; i--)
595
+ if (space(chars[i]!)) {
596
+ cut = i;
597
+ break;
598
+ }
599
+ if (cut !== undefined) end = cut;
600
+ }
601
+ chunks.push(chars.slice(start, end).join(""));
602
+ if (end >= chars.length) break;
603
+ start = Math.max(end - overlap, start + 1);
604
+ }
605
+ return chunks;
606
+ }
607
+
608
+ /** The changes already proposed and the rejected names, as the reasoning model reads them. */
609
+ class Proposed {
610
+ constructor(
611
+ readonly open: string[] = [],
612
+ readonly rejected: string[] = [],
613
+ ) {}
614
+ text(): string {
615
+ let out = "";
616
+ if (this.open.length)
617
+ out += `Changes already proposed from this source and waiting for review (newest support first):\n${this.open.map((t) => `- ${t}\n`).join("")}`;
618
+ if (this.rejected.length)
619
+ out += `Already rejected, do not propose: ${this.rejected.join(", ")}\n`;
620
+ return out;
621
+ }
622
+ addOpen(title: string) {
623
+ const at = this.open.indexOf(title);
624
+ if (at >= 0) this.open.splice(at, 1);
625
+ this.open.unshift(title);
626
+ this.open.length = Math.min(this.open.length, PROPOSED_MAX);
627
+ }
628
+ }
629
+
630
+ /** What one instance's proposal step gave. */
631
+ interface Read {
632
+ missing?: boolean;
633
+ chunks: number;
634
+ failed_chunks: number;
635
+ proposals: Proposal[];
636
+ }
288
637
 
289
638
  /** The workflow, for a `WorkflowWorker`. `lbb` is `lbbActivities(client)`, or a mock in a test. */
290
639
  export function createWorkflow(lbb: LbbActivities, settings: Settings = defaults) {
291
- return triggeredWorkflow<FitState>({
640
+ const iri = classIri(settings.className);
641
+ const className = displayName(iri.slice(iri.lastIndexOf("/") + 1));
642
+ return triggeredWorkflow<FitState, { counters: FitState }>({
292
643
  name: workflowType.workflow_type,
293
644
  version: workflowType.version,
294
- initialState: { texts: 0, proposals: 0, filed: 0 },
645
+ initialState: ZERO,
295
646
  async onInstances(ctx, message, state) {
647
+ const source = message.workflow;
296
648
  const batch = message.instances.slice(0, TURN_INSTANCES);
297
649
  const texts = reader(message, batch);
298
650
  let onto: Promise<Ontology> | undefined;
299
- const kept: Change[][] = [];
300
- for (const [i, iri] of batch.entries()) {
301
- kept.push(
302
- await ctx.step(`propose-${i}`, async (s) => {
303
- s.progress("reading texts", { done: i, total: batch.length, unit: "instances" });
304
- const record = (await texts())[iri];
305
- if (!record?.text.trim()) return [];
306
- const ontology = await (onto ??= lbb.ontology().then(describe));
307
- // The changes the earlier texts of this turn proposed: the model proposes them again by the same names.
308
- const earlier = [...new Set(kept.flat().map((c) => c.title))];
309
- const system = [
310
- SYSTEM,
311
- `The ontology now:\n${ontology.text}`,
312
- ...(earlier.length
313
- ? [
314
- `Changes already proposed and waiting for review:\n${earlier.map((t) => `- ${t}`).join("\n")}`,
315
- ]
316
- : []),
317
- ].join("\n\n");
651
+ const ontology = () => (onto ??= lbb.ontology().then(describe));
652
+ // The changes this workflow proposed before: its open review items, newest first, and the
653
+ // names of the dismissed ones.
654
+ const inbox = await ctx.step("inbox", async () => {
655
+ const { suggestions } = await lbb.listReviews({ originId: `fit:${source}`, limit: 500 });
656
+ const open: string[] = [];
657
+ const rejected: string[] = [];
658
+ for (const item of suggestions) {
659
+ if (item.status === "open" && open.length < PROPOSED_MAX) open.push(item.title);
660
+ if (item.status === "dismissed" && rejected.length < REJECTED_MAX) {
661
+ const name = item.anchor?.name ?? item.title;
662
+ if (!rejected.includes(name)) rejected.push(name);
663
+ }
664
+ }
665
+ return { open, rejected };
666
+ });
667
+ const proposed = new Proposed([...inbox.open], [...inbox.rejected]);
668
+ const tally: FitState = { ...ZERO };
669
+ for (const [i, entity] of batch.entries()) {
670
+ const read = await ctx.step(`propose-${i}`, async (s): Promise<Read> => {
671
+ s.progress("reading texts", { done: i, total: batch.length, unit: "instances" });
672
+ const record = (await texts())[entity];
673
+ if (!record) return { missing: true, chunks: 0, failed_chunks: 0, proposals: [] };
674
+ const out: Read = { chunks: 0, failed_chunks: 0, proposals: [] };
675
+ if (!record.text.trim()) return out;
676
+ const onto = await ontology();
677
+ const listed = proposed.text();
678
+ const system = [
679
+ SYSTEM,
680
+ `The ontology now:\n${onto.text}`,
681
+ ...(listed ? [listed] : []),
682
+ ].join("\n\n");
683
+ const chunks = chunkText(record.text);
684
+ const raw: Raw[] = [];
685
+ for (const [n, chunk] of chunks.entries()) {
318
686
  const prompt = JSON.stringify({
319
- class: settings.className,
687
+ class: className,
688
+ class_iri: iri,
320
689
  context: settings.context,
321
690
  instance: record.label,
322
- text: record.text,
691
+ chunk: `${n + 1} of ${chunks.length}`,
692
+ text: chunk,
323
693
  });
324
- const answer = await lbb.reason<{ proposals: Raw[] }>(
325
- { system, prompt, schema: SCHEMA, max_tokens: 16_000 },
326
- { effectId: s.effectId, label: `${record.label.slice(0, 50)} fit` },
694
+ out.chunks += 1;
695
+ try {
696
+ const answer = await lbb.reason<{ proposals?: Raw[] }>(
697
+ { system, prompt, schema: SCHEMA, max_tokens: 16_000 },
698
+ {
699
+ effectId: n ? `${s.effectId}.${n}` : s.effectId,
700
+ label: `${record.label.slice(0, 50)} fit`,
701
+ },
702
+ );
703
+ raw.push(...(answer.value.proposals ?? []).slice(0, PROPOSALS_PER_CHUNK));
704
+ } catch (error) {
705
+ // No usable answer for the chunk: it counts as failed, and the instance goes on.
706
+ if (!isActivityRefused(error)) throw error;
707
+ out.failed_chunks += 1;
708
+ }
709
+ }
710
+ // The checks that need no model: the shape, one per change, the quote, the dry run.
711
+ const seen = new Set<string>();
712
+ const passed: { proposal: Proposal; change: Change }[] = [];
713
+ for (const r of raw) {
714
+ const shaped = shape(source, r, onto);
715
+ const base = {
716
+ quote: r.quote,
717
+ reason: clip(r.reason.trim(), 1_000),
718
+ };
719
+ if ("error" in shaped) {
720
+ out.proposals.push({
721
+ ...base,
722
+ key: "",
723
+ title: "",
724
+ kind: "add_class",
725
+ outcome: "invalid",
726
+ detail: shaped.error,
727
+ });
728
+ continue;
729
+ }
730
+ const proposal: Proposal = {
731
+ ...base,
732
+ key: shaped.key,
733
+ title: shaped.title,
734
+ kind: shaped.kind,
735
+ anchor: shaped.anchor,
736
+ change: shaped.change,
737
+ outcome: "invalid",
738
+ };
739
+ if (seen.has(shaped.key)) continue;
740
+ seen.add(shaped.key);
741
+ const problem = quoteProblem(r.quote, record.text);
742
+ if (problem) {
743
+ out.proposals.push({ ...proposal, outcome: "quote_not_found", detail: problem });
744
+ continue;
745
+ }
746
+ const preview = await lbb
747
+ .evolve(shaped.change, { dryRun: true })
748
+ .catch((error: unknown) => {
749
+ if (error instanceof LbbError && error.status === 400)
750
+ return { invalid: error.message.slice(0, 300) };
751
+ throw error;
752
+ });
753
+ if ("invalid" in preview)
754
+ out.proposals.push({ ...proposal, detail: String(preview.invalid) });
755
+ else if (preview.no_op) out.proposals.push({ ...proposal, outcome: "already_covered" });
756
+ else if (!preview.publishable)
757
+ out.proposals.push({
758
+ ...proposal,
759
+ ...(preview.messages?.[0] ? { detail: preview.messages[0] } : {}),
760
+ });
761
+ else {
762
+ passed.push({ proposal, change: shaped });
763
+ out.proposals.push(proposal);
764
+ }
765
+ }
766
+ // The support of the ones that passed: the decision model, 20 at a time.
767
+ const support: (number | undefined)[] = [];
768
+ for (let b = 0; b * SCORE_BATCH < passed.length; b++) {
769
+ const items = passed.slice(b * SCORE_BATCH, (b + 1) * SCORE_BATCH);
770
+ const { answers } = await lbb.decide(
771
+ {
772
+ state: { ontology: onto.text, class: className, context: settings.context },
773
+ questions: Object.fromEntries(
774
+ items.map(({ proposal, change }, n) => [
775
+ `p${n}`,
776
+ {
777
+ instructions: `${SCORE_INSTRUCTIONS}\nInstance: ${record.label}\nChange: ${change.title} (${change.change.map(opJson).join("; ")})\nQuote: "${proposal.quote}"`,
778
+ criteria: SCORE_CRITERIA,
779
+ },
780
+ ]),
781
+ ),
782
+ },
783
+ { effectId: `${s.effectId}.s${b}`, label: `${record.label.slice(0, 50)} support` },
327
784
  );
328
- const out: Change[] = [];
329
- for (const raw of (answer.value.proposals ?? []).slice(0, 12)) {
330
- const change = shape(message.workflow, raw, ontology);
331
- if (
332
- !change ||
333
- out.some((c) => c.key === change.key) ||
334
- !quoteInText(change.quote, record.text)
335
- )
336
- continue;
337
- // A dry run: a change the ontology has already is a no-op.
338
- const preview = await lbb
339
- .evolve(change.change, { dryRun: true })
340
- .catch((error: unknown) => {
341
- if (error instanceof LbbError && error.status === 400) return null;
342
- throw error;
343
- });
344
- if (preview && !preview.no_op && preview.publishable) out.push(change);
785
+ for (const n of items.keys()) {
786
+ const a = answers[`p${n}`];
787
+ support.push(
788
+ a?.probabilities?.supported ??
789
+ (a?.choice == null
790
+ ? undefined
791
+ : a.choice === "supported"
792
+ ? (a.confidence ?? 1)
793
+ : 0),
794
+ );
345
795
  }
346
- return out;
347
- }),
348
- );
349
- }
350
- const filed = await ctx.step("review", async (s) => {
351
- const records = await texts();
352
- const byKey = new Map<
353
- string,
354
- { change: Change; samples: { entity: string; label: string; quote: string }[] }
355
- >();
356
- for (const [i, changes] of kept.entries())
357
- for (const change of changes) {
358
- const group = byKey.get(change.key) ?? { change, samples: [] };
359
- group.samples.push({
360
- entity: batch[i]!,
361
- label: records[batch[i]!]?.label ?? "",
362
- quote: change.quote.slice(0, 600),
796
+ }
797
+ for (const [n, { proposal }] of passed.entries()) {
798
+ const p = support[n];
799
+ proposal.support = p;
800
+ if (p !== undefined && p >= SUPPORT_MIN) proposal.outcome = "kept";
801
+ else {
802
+ proposal.outcome = "weak_support";
803
+ if (p === undefined) proposal.detail = "the decision model did not answer";
804
+ }
805
+ }
806
+ return out;
807
+ });
808
+ // The kept changes into the review items, one per change.
809
+ const filed = await ctx.step(`file-${i}`, async (s) => {
810
+ s.progress("filing review items", { done: i, total: batch.length, unit: "instances" });
811
+ const result = { filed: 0, updated: 0, supported: [] as string[] };
812
+ const record = (await texts())[entity];
813
+ for (const p of read.proposals.filter((p) => p.outcome === "kept")) {
814
+ const existing = await lbb.reviewItem(p.key);
815
+ if (existing && ["accepted", "superseded", "dismissed"].includes(existing.status))
816
+ continue;
817
+ const previous = (existing?.evidence?.samples ?? []) as Record<string, unknown>[];
818
+ // The instances that support the change: the item's count, and this one when it is new.
819
+ const known = previous.some((sample) => sample.entity === entity);
820
+ const records = (existing?.evidence?.records ?? 0) + (known ? 0 : 1);
821
+ const sample = {
822
+ entity,
823
+ label: clip(record?.label ?? "", 120),
824
+ quote: clip(normalize(p.quote), 600),
825
+ support: Math.round((p.support ?? 0) * 100) / 100,
826
+ };
827
+ const samples = [...previous.filter((x) => x.entity !== entity), sample]
828
+ .sort(
829
+ (a, b) =>
830
+ Number(b.support ?? 0) - Number(a.support ?? 0) ||
831
+ (String(a.entity) < String(b.entity)
832
+ ? -1
833
+ : String(a.entity) > String(b.entity)
834
+ ? 1
835
+ : 0),
836
+ )
837
+ .slice(0, SAMPLES);
838
+ const title = existing?.title ?? p.title;
839
+ result.supported.push(title);
840
+ await lbb.review({
841
+ workflow: source,
842
+ key: p.key,
843
+ title,
844
+ rationale: clip(
845
+ `The text of ${records} instance${records === 1 ? "" : "s"} of ${className} supports this change. Model's reason: ${p.reason || "none given"}`,
846
+ 4_000,
847
+ ),
848
+ anchor: p.anchor!,
849
+ origin: { kind: "agent", id: `fit:${source}`, label: `Fit from text (${source})` },
850
+ change: existing?.change ?? p.change!,
851
+ mapping: { fit_source: source, kind: p.kind },
852
+ evidence: { records, samples, source_fields: [settings.textField] },
363
853
  });
364
- byKey.set(change.key, group);
854
+ if (existing) result.updated += 1;
855
+ else result.filed += 1;
365
856
  }
366
- for (const [n, { change, samples }] of [...byKey.values()].entries()) {
367
- s.progress("filing review items", { done: n, total: byKey.size, unit: "items" });
368
- await lbb.review({
369
- workflow: message.workflow,
370
- key: change.key,
371
- title: change.title.slice(0, 200),
372
- rationale: `${change.reason} "${change.quote.slice(0, 600)}"`.trim().slice(0, 4_000),
373
- anchor: change.anchor,
374
- change: change.change,
375
- evidence: { records: samples.length, samples: samples.slice(0, 20) },
376
- });
377
- }
378
- return byKey.size;
379
- });
380
- const next = {
381
- texts: state.texts + batch.length,
382
- proposals: state.proposals + kept.flat().length,
383
- filed: state.filed + filed,
384
- };
857
+ return result;
858
+ });
859
+ for (const title of filed.supported) proposed.addOpen(title);
860
+ if (read.missing) continue;
861
+ tally.instances += 1;
862
+ tally.chunks += read.chunks;
863
+ tally.failed_chunks += read.failed_chunks;
864
+ tally.proposals += read.proposals.length;
865
+ for (const p of read.proposals) tally[p.outcome] += 1;
866
+ tally.filed += filed.filed;
867
+ tally.updated += filed.updated;
868
+ }
869
+ const next = Object.fromEntries(
870
+ Object.keys(ZERO).map((k) => [
871
+ k,
872
+ (state[k as keyof FitState] ?? 0) + tally[k as keyof FitState],
873
+ ]),
874
+ ) as unknown as FitState;
875
+ const result = { counters: tally };
385
876
  const rest = message.instances.slice(TURN_INSTANCES);
386
877
  return rest.length
387
- ? ctx.continue(next, { ...message, instances: rest, removed: [] })
388
- : { state: next };
878
+ ? { ...ctx.continue(next, { ...message, instances: rest, removed: [] }), result }
879
+ : { state: next, result };
389
880
  },
390
881
  });
391
882
 
@@ -394,21 +885,31 @@ export function createWorkflow(lbb: LbbActivities, settings: Settings = defaults
394
885
  let read: Promise<Record<string, { label: string; text: string }>> | undefined;
395
886
  return () =>
396
887
  (read ??= (async () => {
888
+ const plain = iris.filter((x) => PLAIN_IRI.test(x) && ENTITY_IRI.test(x));
397
889
  const query = `# ontology-fit: texts
398
890
  SELECT ?s ?label ?text WHERE {
399
- VALUES ?s { ${iris
400
- .filter((iri) => PLAIN_IRI.test(iri))
401
- .map((iri) => `<${iri}>`)
402
- .join(" ")} }
403
- OPTIONAL { ?s <http://www.w3.org/2000/01/rdf-schema#label> ?label }
404
- OPTIONAL { ?s <https://littlebigbrain.com/p/${encodeURIComponent(settings.textField.toLowerCase())}> ?text }
891
+ VALUES ?s { ${plain.map((x) => `<${x}>`).join(" ")} }
892
+ ?s a ?class .
893
+ OPTIONAL { ?s <${LABEL}> ?label }
894
+ OPTIONAL { ?s <${P}${encodeURIComponent(settings.textField.toLowerCase())}> ?text }
405
895
  }`;
406
896
  const { rows } = await lbb.sparql({ query, as_of_commit_seq: message.as_of_commit_seq });
407
- const out: Record<string, { label: string; text: string }> = {};
897
+ const found = new Map<string, { labels: Set<string>; texts: Set<string> }>();
408
898
  for (const row of rows) {
409
- const record = (out[row.s!] ??= { label: row.label ?? row.s!, text: "" });
410
- if (row.text && !record.text.includes(row.text))
411
- record.text = `${record.text}\n${row.text}`.trim().slice(0, TEXT_CHARS);
899
+ const at = found.get(row.s!) ?? { labels: new Set(), texts: new Set() };
900
+ found.set(row.s!, at);
901
+ if (row.label && normalize(row.label)) at.labels.add(normalize(row.label));
902
+ if (row.text && normalize(row.text)) at.texts.add(normalize(row.text));
903
+ }
904
+ const out: Record<string, { label: string; text: string }> = {};
905
+ for (const [x, at] of found) {
906
+ // As LBB composes a text from its fields: `<field>: <values>`.
907
+ const values = [...at.texts].sort();
908
+ const value = values.length ? `${settings.textField}: ${values.join(", ")}` : "";
909
+ out[x] = {
910
+ label: [...at.labels].sort()[0] ?? x.slice(x.lastIndexOf("/") + 1),
911
+ text: [...value].slice(0, TEXT_CHARS).join(""),
912
+ };
412
913
  }
413
914
  return out;
414
915
  })());