@kgdiff/formats 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -2
  3. package/dist/detect.d.ts +47 -0
  4. package/dist/detect.d.ts.map +1 -0
  5. package/dist/detect.js +425 -0
  6. package/dist/detect.js.map +1 -0
  7. package/dist/index.d.ts +203 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +114 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/iri.d.ts +13 -0
  12. package/dist/iri.d.ts.map +1 -0
  13. package/dist/iri.js +91 -0
  14. package/dist/iri.js.map +1 -0
  15. package/dist/json.d.ts +30 -0
  16. package/dist/json.d.ts.map +1 -0
  17. package/dist/json.js +749 -0
  18. package/dist/json.js.map +1 -0
  19. package/dist/jsonld.d.ts +16 -0
  20. package/dist/jsonld.d.ts.map +1 -0
  21. package/dist/jsonld.js +1249 -0
  22. package/dist/jsonld.js.map +1 -0
  23. package/dist/obo.d.ts +25 -0
  24. package/dist/obo.d.ts.map +1 -0
  25. package/dist/obo.js +478 -0
  26. package/dist/obo.js.map +1 -0
  27. package/dist/prefixes.d.ts +49 -0
  28. package/dist/prefixes.d.ts.map +1 -0
  29. package/dist/prefixes.js +177 -0
  30. package/dist/prefixes.js.map +1 -0
  31. package/dist/rdf.d.ts +46 -0
  32. package/dist/rdf.d.ts.map +1 -0
  33. package/dist/rdf.js +1047 -0
  34. package/dist/rdf.js.map +1 -0
  35. package/dist/rdfxml.d.ts +8 -0
  36. package/dist/rdfxml.d.ts.map +1 -0
  37. package/dist/rdfxml.js +529 -0
  38. package/dist/rdfxml.js.map +1 -0
  39. package/dist/shared.d.ts +117 -0
  40. package/dist/shared.d.ts.map +1 -0
  41. package/dist/shared.js +355 -0
  42. package/dist/shared.js.map +1 -0
  43. package/dist/xml.d.ts +50 -0
  44. package/dist/xml.d.ts.map +1 -0
  45. package/dist/xml.js +508 -0
  46. package/dist/xml.js.map +1 -0
  47. package/package.json +55 -3
  48. package/src/detect.ts +435 -0
  49. package/src/index.ts +296 -0
  50. package/src/iri.ts +86 -0
  51. package/src/json.ts +835 -0
  52. package/src/jsonld.ts +1376 -0
  53. package/src/obo.ts +557 -0
  54. package/src/prefixes.ts +195 -0
  55. package/src/rdf.ts +1181 -0
  56. package/src/rdfxml.ts +633 -0
  57. package/src/shared.ts +408 -0
  58. package/src/xml.ts +550 -0
package/src/rdf.ts ADDED
@@ -0,0 +1,1181 @@
1
+ /**
2
+ * RDF → `GraphInput`, following docs/architecture.md §"Formats: mapping conventions". Every
3
+ * RDF syntax produces quads first — Turtle, N-Triples, N-Quads and TriG through n3
4
+ * (`readN3`), RDF/XML through `rdfxml.ts`, JSON-LD through `jsonld.ts` — and `mapRdf` maps
5
+ * them all the same way, so one graph diffs identically in any syntax.
6
+ *
7
+ * - Triples are a set: repeated triples count once, whatever the syntax.
8
+ * - Node ids: OBO PURLs become CURIEs (`GO:0008150`), other IRIs stay whole; blank nodes get
9
+ * content-hashed ids `_:b<hash>` (hash of their sorted outgoing triples, recursive,
10
+ * cycle-safe), so ids are stable across files and parser runs.
11
+ * - Labels: label predicates with language preference; the other label values become aliases.
12
+ * Without a label a named node is labelled with its compact IRI.
13
+ * - Identities (predicates, attr keys, types) are compacted with well-known and user prefixes
14
+ * only by default (`RdfOptions.compactWith`), so they do not depend on file declarations.
15
+ * - Literal-valued properties → attrs keyed by compact predicate (numbers / booleans typed,
16
+ * language-tagged text as `"text@lang"`, multi-values as sorted arrays).
17
+ * - IRI-valued properties → edges (compact predicate, readable label).
18
+ * - `rdf:type` → types; `owl:deprecated` → deprecated; `IAO:0100001` → replacedBy.
19
+ * - OWL: `C ⊑ ∃R.D` and `C ≡ A ⊓ ∃R.D` become edges; `owl:Ontology` becomes `meta`.
20
+ * - Lists: literal lists become attr arrays; lists of resources become (unordered) edges.
21
+ * - Statement annotations (`owl:Axiom`, `rdf:Statement`, RDF 1.2 `rdf:reifies`) are folded
22
+ * into the attrs of the annotated edge (or of the node, for literal-valued statements),
23
+ * unless `RdfOptions.foldAnnotations` is false.
24
+ */
25
+ import type { AttrValue, EdgeInput, GraphInput, GraphMeta, NodeInput } from '@kgdiff/core';
26
+ import type { Quad } from 'n3';
27
+ import { BlankNode, Literal, NamedNode, Parser } from 'n3';
28
+ import type { RdfOptions } from './index.js';
29
+ import {
30
+ nodeIdForIri,
31
+ OBO_IN_OWL_NS,
32
+ OBO_PURL,
33
+ OWL_NS,
34
+ PrefixTable,
35
+ RDF_NS,
36
+ RDFS_NS,
37
+ readableName,
38
+ SKOS_NS,
39
+ WELL_KNOWN_PREFIXES,
40
+ XSD_NS,
41
+ } from './prefixes.js';
42
+ import {
43
+ AttrBag,
44
+ compareStrings,
45
+ hash53,
46
+ invalidInput,
47
+ makeEdge,
48
+ makeMeta,
49
+ NodeBuilder,
50
+ plural,
51
+ setOwn,
52
+ sortEdges,
53
+ sortNodes,
54
+ typedValue,
55
+ type Warnings,
56
+ } from './shared.js';
57
+
58
+ /** RDF syntaxes read by n3. */
59
+ export type N3Format = 'turtle' | 'ntriples' | 'nquads' | 'trig';
60
+
61
+ /** The quads of one RDF document and the prefixes it declares (for display only). */
62
+ export interface RdfDocument {
63
+ readonly quads: readonly Quad[];
64
+ readonly prefixes: Readonly<Record<string, string>>;
65
+ }
66
+
67
+ export interface RdfParseOutput {
68
+ readonly graph: GraphInput;
69
+ readonly prefixes: Readonly<Record<string, string>>;
70
+ /** Labels the file gives the properties it uses, by compact predicate. */
71
+ readonly predicateLabels: Readonly<Record<string, string>>;
72
+ }
73
+
74
+ const FORMAT_NAMES: Readonly<Record<N3Format, string>> = {
75
+ turtle: 'Turtle',
76
+ ntriples: 'N-Triples',
77
+ nquads: 'N-Quads',
78
+ trig: 'TriG',
79
+ };
80
+
81
+ const RDF_TYPE = `${RDF_NS}type`;
82
+ const RDF_FIRST = `${RDF_NS}first`;
83
+ const RDF_REST = `${RDF_NS}rest`;
84
+ const RDF_NIL = `${RDF_NS}nil`;
85
+ const RDF_LIST = `${RDF_NS}List`;
86
+ const RDF_REIFIES = `${RDF_NS}reifies`;
87
+ const RDF_STATEMENT = `${RDF_NS}Statement`;
88
+ const RDF_SUBJECT = `${RDF_NS}subject`;
89
+ const RDF_PREDICATE = `${RDF_NS}predicate`;
90
+ const RDF_OBJECT = `${RDF_NS}object`;
91
+ const RDFS_SUBCLASS_OF = `${RDFS_NS}subClassOf`;
92
+ const OWL_ONTOLOGY = `${OWL_NS}Ontology`;
93
+ const OWL_IMPORTS = `${OWL_NS}imports`;
94
+ const OWL_VERSION_INFO = `${OWL_NS}versionInfo`;
95
+ const OWL_VERSION_IRI = `${OWL_NS}versionIRI`;
96
+ const OWL_DEPRECATED = `${OWL_NS}deprecated`;
97
+ const OWL_CLASS = `${OWL_NS}Class`;
98
+ const OWL_RESTRICTION = `${OWL_NS}Restriction`;
99
+ const OWL_ON_PROPERTY = `${OWL_NS}onProperty`;
100
+ const OWL_SOME_VALUES_FROM = `${OWL_NS}someValuesFrom`;
101
+ const OWL_EQUIVALENT_CLASS = `${OWL_NS}equivalentClass`;
102
+ const OWL_INTERSECTION_OF = `${OWL_NS}intersectionOf`;
103
+ const OWL_UNION_OF = `${OWL_NS}unionOf`;
104
+ const OWL_AXIOM = `${OWL_NS}Axiom`;
105
+ const OWL_ANNOTATED_SOURCE = `${OWL_NS}annotatedSource`;
106
+ const OWL_ANNOTATED_PROPERTY = `${OWL_NS}annotatedProperty`;
107
+ const OWL_ANNOTATED_TARGET = `${OWL_NS}annotatedTarget`;
108
+ const IAO_REPLACED_BY = `${OBO_PURL}IAO_0100001`;
109
+ const DC_TITLE = `${WELL_KNOWN_PREFIXES.dc}title`;
110
+ const DCTERMS_TITLE = `${WELL_KNOWN_PREFIXES.dcterms}title`;
111
+ const SCHEMA_HTTPS = 'https://schema.org/';
112
+ const SCHEMA_HTTP = 'http://schema.org/';
113
+
114
+ /** Default label predicates, in priority order (schema.org in both http and https forms). */
115
+ const DEFAULT_LABEL_PREDICATES: readonly string[] = [
116
+ `${RDFS_NS}label`,
117
+ `${SKOS_NS}prefLabel`,
118
+ `${SCHEMA_HTTPS}name`,
119
+ DCTERMS_TITLE,
120
+ `${WELL_KNOWN_PREFIXES.foaf}name`,
121
+ ];
122
+
123
+ /** Default alias predicates: SKOS alternative labels and the oboInOwl synonym family. */
124
+ const DEFAULT_ALIAS_PREDICATES: readonly string[] = [
125
+ `${SKOS_NS}altLabel`,
126
+ `${OBO_IN_OWL_NS}hasExactSynonym`,
127
+ `${OBO_IN_OWL_NS}hasRelatedSynonym`,
128
+ `${OBO_IN_OWL_NS}hasBroadSynonym`,
129
+ `${OBO_IN_OWL_NS}hasNarrowSynonym`,
130
+ `${OBO_IN_OWL_NS}hasSynonym`,
131
+ ];
132
+
133
+ type Resource = NamedNode | BlankNode;
134
+ type Value = NamedNode | BlankNode | Literal;
135
+
136
+ /** All triples of one subject, grouped by predicate IRI in document order. */
137
+ interface Subject {
138
+ readonly term: Resource;
139
+ readonly props: Map<string, Value[]>;
140
+ }
141
+
142
+ /** An RDF 1.2 triple term (`<<( s p o )>>`); not covered by @types/n3. */
143
+ interface TripleTerm {
144
+ readonly termType: 'Quad';
145
+ readonly subject: unknown;
146
+ readonly predicate: unknown;
147
+ readonly object: unknown;
148
+ }
149
+
150
+ function isTripleTerm(value: unknown): value is TripleTerm {
151
+ return (
152
+ typeof value === 'object' &&
153
+ value !== null &&
154
+ (value as { termType?: unknown }).termType === 'Quad' &&
155
+ 'subject' in value &&
156
+ 'predicate' in value &&
157
+ 'object' in value
158
+ );
159
+ }
160
+
161
+ /** A statement about a statement: owl:Axiom, rdf:Statement or an RDF 1.2 reifier. */
162
+ interface Reification {
163
+ readonly subject: Resource;
164
+ readonly predicate: string;
165
+ readonly object: Value;
166
+ readonly annotations: readonly (readonly [string, readonly Value[]])[];
167
+ }
168
+
169
+ interface LabelCandidate {
170
+ readonly tier: number;
171
+ readonly rank: number;
172
+ readonly value: string;
173
+ readonly language: string;
174
+ }
175
+
176
+ interface EdgeBuilder {
177
+ readonly source: string;
178
+ readonly predicate: string;
179
+ readonly target: string;
180
+ readonly label: string;
181
+ readonly attrs: AttrBag;
182
+ }
183
+
184
+ const keyOf = (term: Resource): string =>
185
+ term.termType === 'BlankNode' ? `_:${term.value}` : term.value;
186
+
187
+ /** Value lists up to this length are de-duplicated by a linear scan (see `contains`). */
188
+ const SCAN_LIMIT = 8;
189
+
190
+ const termKey = (term: Value): string => `${term.termType}\u0000${term.id}`;
191
+
192
+ const isBlankKey = (key: string): boolean => key.startsWith('_:');
193
+
194
+ /** Reads Turtle-family text with n3. Throws `KgdiffError('invalid-input')` with the line on syntax errors. */
195
+ export function readN3(text: string, format: N3Format, baseIRI: string | undefined): RdfDocument {
196
+ const declared: Record<string, string> = {};
197
+ const parser = new Parser({ format: FORMAT_NAMES[format], baseIRI });
198
+ try {
199
+ const quads = parser.parse(text, null, (prefix, iri) => {
200
+ setOwn(declared, prefix, iri.value);
201
+ });
202
+ return { quads, prefixes: declared };
203
+ } catch (error) {
204
+ throw syntaxError(error, format);
205
+ }
206
+ }
207
+
208
+ /** Maps the quads of an RDF document (any syntax) to a `GraphInput`. */
209
+ export function mapRdf(
210
+ document: RdfDocument,
211
+ options: RdfOptions | undefined,
212
+ warnings: Warnings,
213
+ ): RdfParseOutput {
214
+ const user = options?.prefixes ?? {};
215
+ const display = new PrefixTable([
216
+ ['well-known', WELL_KNOWN_PREFIXES],
217
+ ['declared', document.prefixes],
218
+ ['user', user],
219
+ ]);
220
+ // Identities must not depend on how a file happens to abbreviate IRIs (see `compactWith`).
221
+ const identity =
222
+ options?.compactWith === 'declared'
223
+ ? display
224
+ : new PrefixTable([
225
+ ['well-known', WELL_KNOWN_PREFIXES],
226
+ ['user', user],
227
+ ]);
228
+ const mapper = new RdfMapper(identity, display, options ?? {}, warnings);
229
+ mapper.ingest(document.quads);
230
+ const graph = mapper.build();
231
+ return { graph, prefixes: display.record, predicateLabels: mapper.propertyLabels() };
232
+ }
233
+
234
+ function syntaxError(error: unknown, format: N3Format) {
235
+ const message = error instanceof Error ? error.message : String(error);
236
+ const context =
237
+ error instanceof Error ? (error as Error & { context?: unknown }).context : undefined;
238
+ const line =
239
+ typeof context === 'object' &&
240
+ context !== null &&
241
+ 'line' in context &&
242
+ typeof context.line === 'number'
243
+ ? context.line
244
+ : undefined;
245
+ const hint =
246
+ format === 'turtle' && /\bgraph\b|\{/i.test(message)
247
+ ? ' Named graphs need TriG: use a .trig file or format "trig".'
248
+ : '';
249
+ return invalidInput(`Invalid ${FORMAT_NAMES[format]}: ${message}${hint}`, {
250
+ format,
251
+ ...(line !== undefined ? { line } : {}),
252
+ });
253
+ }
254
+
255
+ class RdfMapper {
256
+ private readonly subjects = new Map<string, Subject>();
257
+ /**
258
+ * Distinct (subject, predicate) pairs using each blank node as object: lists, restrictions,
259
+ * roots and context ids depend on it. `parents` keeps the first one.
260
+ */
261
+ private readonly referrers = new Map<string, Set<string>>();
262
+ private readonly parents = new Map<
263
+ string,
264
+ { readonly subject: string; readonly predicate: string }
265
+ >();
266
+ /** RDF 1.2 `rdf:reifies` triple terms by reifier key. */
267
+ private readonly reifies = new Map<string, TripleTerm>();
268
+ private readonly namedGraphs = new Set<string>();
269
+ private skippedTripleTerms = 0;
270
+ /** Term keys of long value lists, for `contains`. */
271
+ private readonly valueIndex = new WeakMap<readonly Value[], Set<string>>();
272
+
273
+ private readonly labelRanks = new Map<string, number>();
274
+ private readonly aliasPredicates: ReadonlySet<string>;
275
+ private readonly languages: readonly string[];
276
+ private readonly typeEdges: boolean;
277
+ private readonly owlRestrictions: boolean;
278
+ private readonly foldAnnotations: boolean;
279
+
280
+ private readonly ontologies = new Set<string>();
281
+ private readonly nodes = new Map<string, NodeBuilder>();
282
+ private readonly edges = new Map<string, EdgeBuilder>();
283
+ /** Compact predicate → IRI of every predicate used for an edge or attribute. */
284
+ private readonly usedPredicates = new Map<string, string>();
285
+
286
+ private readonly lists = new Map<string, readonly Value[] | null>();
287
+ private readonly existentials = new Map<string, { property: string; filler: string } | null>();
288
+ /** Content hash of every blank node, computed once on first use, and the ones on cycles. */
289
+ private blankHashes: ReadonlyMap<string, string> | undefined;
290
+ private readonly cyclicBlanks = new Set<string>();
291
+ /** Final `_:b…` ids (content- or context-based). */
292
+ private readonly blankIds = new Map<string, string>();
293
+ /** Blank nodes emitted as nodes of their own (`_:b…`), and the queue still to emit. */
294
+ private readonly emittedBlanks = new Set<string>();
295
+ private readonly blankQueue: string[] = [];
296
+ private blankCursor = 0;
297
+
298
+ /**
299
+ * @param table compacts identities (predicates, attr keys, types, fallback labels)
300
+ * @param display expands CURIEs in options, with the file's declared prefixes too
301
+ */
302
+ constructor(
303
+ private readonly table: PrefixTable,
304
+ display: PrefixTable,
305
+ options: RdfOptions,
306
+ private readonly warnings: Warnings,
307
+ ) {
308
+ const labelPredicates =
309
+ options.labelPredicates?.map((p) => display.expand(p)) ?? DEFAULT_LABEL_PREDICATES;
310
+ labelPredicates.forEach((iri, rank) => {
311
+ for (const variant of schemaVariants(iri)) {
312
+ if (!this.labelRanks.has(variant)) this.labelRanks.set(variant, rank);
313
+ }
314
+ });
315
+ const aliasPredicates =
316
+ options.aliasPredicates?.map((p) => display.expand(p)) ?? DEFAULT_ALIAS_PREDICATES;
317
+ this.aliasPredicates = new Set(aliasPredicates.flatMap(schemaVariants));
318
+ const languages = (options.languages ?? ['', 'en']).map((language) => language.toLowerCase());
319
+ this.languages = languages.includes('') ? languages : [...languages, ''];
320
+ this.typeEdges = options.typeEdges ?? false;
321
+ this.owlRestrictions = options.owlRestrictions ?? true;
322
+ this.foldAnnotations = options.foldAnnotations ?? true;
323
+ }
324
+
325
+ // ─── Ingestion ──────────────────────────────────────────────────────────
326
+
327
+ ingest(quads: readonly Quad[]): void {
328
+ for (const { subject, predicate, object, graph } of quads) {
329
+ if (graph.termType !== 'DefaultGraph') this.namedGraphs.add(graph.value);
330
+ // Subjects can be RDF 1.2 triple terms at runtime, which @types/n3 does not model.
331
+ if (
332
+ !(subject instanceof NamedNode || subject instanceof BlankNode) ||
333
+ !(predicate instanceof NamedNode)
334
+ ) {
335
+ this.skippedTripleTerms++;
336
+ continue;
337
+ }
338
+ const subjectKey = keyOf(subject);
339
+ if (object instanceof NamedNode || object instanceof BlankNode || object instanceof Literal) {
340
+ this.add(subject, subjectKey, predicate.value, object);
341
+ } else if (
342
+ isTripleTerm(object) &&
343
+ predicate.value === RDF_REIFIES &&
344
+ subject instanceof BlankNode
345
+ ) {
346
+ this.reifies.set(subjectKey, object);
347
+ this.subjectOf(subject, subjectKey);
348
+ } else {
349
+ this.skippedTripleTerms++;
350
+ }
351
+ }
352
+ }
353
+
354
+ private referenceCount(key: string): number {
355
+ return this.referrers.get(key)?.size ?? 0;
356
+ }
357
+
358
+ /**
359
+ * Whether `values` already holds `object`: RDF graphs are sets, and syntaxes differ in how
360
+ * often they repeat a triple (JSON-LD embeds a node wherever it is used). Long value lists
361
+ * get a key index instead of a linear scan; adding a missing key records it.
362
+ */
363
+ private contains(values: readonly Value[], object: Value): boolean {
364
+ if (values.length < SCAN_LIMIT) return values.some((value) => value.equals(object));
365
+ let index = this.valueIndex.get(values);
366
+ if (!index) {
367
+ index = new Set(values.map(termKey));
368
+ this.valueIndex.set(values, index);
369
+ }
370
+ const key = termKey(object);
371
+ if (index.has(key)) return true;
372
+ index.add(key);
373
+ return false;
374
+ }
375
+
376
+ private subjectOf(term: Resource, key: string): Subject {
377
+ let subject = this.subjects.get(key);
378
+ if (!subject) {
379
+ subject = { term, props: new Map() };
380
+ this.subjects.set(key, subject);
381
+ }
382
+ return subject;
383
+ }
384
+
385
+ private add(term: Resource, key: string, predicate: string, object: Value): void {
386
+ const props = this.subjectOf(term, key).props;
387
+ const values = props.get(predicate);
388
+ if (!values) props.set(predicate, [object]);
389
+ else if (this.contains(values, object)) return;
390
+ else values.push(object);
391
+ if (object.termType === 'BlankNode') {
392
+ const objectKey = `_:${object.value}`;
393
+ let referrers = this.referrers.get(objectKey);
394
+ if (!referrers) {
395
+ referrers = new Set();
396
+ this.referrers.set(objectKey, referrers);
397
+ this.parents.set(objectKey, { subject: key, predicate });
398
+ }
399
+ referrers.add(`${key}\u0000${predicate}`);
400
+ }
401
+ }
402
+
403
+ // ─── Build ──────────────────────────────────────────────────────────────
404
+
405
+ build(): GraphInput {
406
+ const reifications: [string, Reification][] = [];
407
+ const roots: string[] = [];
408
+ for (const [key, subject] of this.subjects) {
409
+ if (this.hasType(subject, OWL_ONTOLOGY)) this.ontologies.add(key);
410
+ }
411
+ for (const [key, subject] of this.subjects) {
412
+ if (this.ontologies.has(key)) continue;
413
+ if (!isBlankKey(key)) {
414
+ this.emitResource(subject, nodeIdForIri(key));
415
+ continue;
416
+ }
417
+ if (this.referenceCount(key) > 0) continue;
418
+ const reification = this.foldAnnotations ? this.reification(key, subject) : undefined;
419
+ if (reification) reifications.push([key, reification]);
420
+ else roots.push(key);
421
+ }
422
+ for (const key of roots) this.requestBlank(key);
423
+ for (const key of this.orphanBlanks()) this.requestBlank(key);
424
+ this.drainBlanks();
425
+ for (const [key, reification] of reifications) {
426
+ if (!this.foldReification(reification)) this.requestBlank(key);
427
+ }
428
+ this.drainBlanks();
429
+
430
+ if (this.namedGraphs.size > 0) {
431
+ this.warnings.add(
432
+ 'named-graphs',
433
+ `Merged ${plural(this.namedGraphs.size, 'named graph')} into one graph.`,
434
+ );
435
+ }
436
+ if (this.skippedTripleTerms > 0) {
437
+ this.warnings.add(
438
+ 'triple-terms',
439
+ `Skipped ${plural(this.skippedTripleTerms, 'triple')} with RDF 1.2 triple terms (not representable as edges).`,
440
+ );
441
+ }
442
+
443
+ const nodes: NodeInput[] = [];
444
+ for (const node of this.nodes.values()) nodes.push(node.build());
445
+ const edges: EdgeInput[] = [];
446
+ for (const edge of this.edges.values()) {
447
+ edges.push(
448
+ makeEdge({
449
+ source: edge.source,
450
+ target: edge.target,
451
+ predicate: edge.predicate,
452
+ label: edge.label,
453
+ attrs: edge.attrs.build(),
454
+ }),
455
+ );
456
+ }
457
+ const meta = this.ontologyMeta();
458
+ return { nodes: sortNodes(nodes), edges: sortEdges(edges), ...(meta ? { meta } : {}) };
459
+ }
460
+
461
+ /**
462
+ * Blank nodes that nothing outside their own cycle points to (`_:a :p _:b . _:b :p _:a .`).
463
+ * They have references, so they are not roots, yet no named node leads to them either.
464
+ */
465
+ private orphanBlanks(): string[] {
466
+ const reached = new Set<string>();
467
+ const queue: string[] = [];
468
+ const reach = (key: string) => {
469
+ if (!reached.has(key)) {
470
+ reached.add(key);
471
+ queue.push(key);
472
+ }
473
+ };
474
+ for (const key of this.subjects.keys()) {
475
+ if (!isBlankKey(key) || this.referenceCount(key) === 0) reach(key);
476
+ }
477
+ for (let key = queue.pop(); key !== undefined; key = queue.pop()) {
478
+ this.forEachBlankChild(key, reach);
479
+ }
480
+ return [...this.subjects.keys()].filter((key) => isBlankKey(key) && !reached.has(key));
481
+ }
482
+
483
+ private drainBlanks(): void {
484
+ // An index instead of shift(): the queue can hold every blank node of a large file.
485
+ for (; this.blankCursor < this.blankQueue.length; this.blankCursor++) {
486
+ const key = this.blankQueue[this.blankCursor] as string;
487
+ const subject = this.subjects.get(key);
488
+ const id = this.blankId(key);
489
+ if (subject) this.emitResource(subject, id);
490
+ else this.nodeBuilder(id);
491
+ }
492
+ }
493
+
494
+ private requestBlank(key: string): void {
495
+ if (this.emittedBlanks.has(key)) return;
496
+ this.emittedBlanks.add(key);
497
+ this.blankQueue.push(key);
498
+ }
499
+
500
+ private nodeBuilder(id: string): NodeBuilder {
501
+ let node = this.nodes.get(id);
502
+ if (!node) {
503
+ node = new NodeBuilder(id);
504
+ this.nodes.set(id, node);
505
+ }
506
+ return node;
507
+ }
508
+
509
+ private hasType(subject: Subject, type: string): boolean {
510
+ return (
511
+ subject.props
512
+ .get(RDF_TYPE)
513
+ ?.some((value) => value.value === type && value.termType === 'NamedNode') ?? false
514
+ );
515
+ }
516
+
517
+ // ─── Resources ──────────────────────────────────────────────────────────
518
+
519
+ private emitResource(subject: Subject, id: string): void {
520
+ const node = this.nodeBuilder(id);
521
+ if (node.label === undefined) {
522
+ node.label =
523
+ this.bestLabel(subject) ??
524
+ (subject.term.termType === 'NamedNode'
525
+ ? this.table.compact(subject.term.value)
526
+ : undefined);
527
+ }
528
+ for (const [predicate, values] of subject.props) {
529
+ if (predicate === RDF_TYPE) {
530
+ for (const value of values) {
531
+ if (value.termType === 'NamedNode') {
532
+ node.types.add(this.table.compact(value.value));
533
+ if (this.typeEdges) this.emitValue(id, predicate, value);
534
+ } else this.emitValue(id, predicate, value);
535
+ }
536
+ } else if (this.labelRanks.has(predicate) || this.aliasPredicates.has(predicate)) {
537
+ // Label values that were not chosen become aliases (makeNode drops the label itself).
538
+ for (const value of values) if (value.termType === 'Literal') node.aliases.add(value.value);
539
+ } else if (predicate === OWL_DEPRECATED) {
540
+ if (values.some((value) => value.termType === 'Literal' && isTrue(value.value)))
541
+ node.deprecated = true;
542
+ } else if (predicate === IAO_REPLACED_BY) {
543
+ for (const value of values) {
544
+ if (value.termType === 'NamedNode') node.replacedBy.add(nodeIdForIri(value.value));
545
+ else if (value.termType === 'Literal' && value.value.trim() !== '') {
546
+ node.replacedBy.add(nodeIdForIri(value.value.trim()));
547
+ }
548
+ }
549
+ } else {
550
+ for (const value of values) this.emitValue(id, predicate, value);
551
+ }
552
+ }
553
+ }
554
+
555
+ private emitValue(sourceId: string, predicate: string, value: Value): void {
556
+ if (value.termType === 'Literal') {
557
+ this.nodeBuilder(sourceId).attrs.add(this.attrKey(predicate), literalValue(value));
558
+ return;
559
+ }
560
+ if (value.termType === 'NamedNode') {
561
+ if (value.value === RDF_NIL)
562
+ this.nodeBuilder(sourceId).attrs.add(this.attrKey(predicate), []);
563
+ else if (this.ontologies.has(value.value)) {
564
+ // The ontology is metadata, not a node: references to it stay as plain values.
565
+ this.nodeBuilder(sourceId).attrs.add(this.attrKey(predicate), value.value);
566
+ } else this.addEdge(sourceId, predicate, nodeIdForIri(value.value));
567
+ return;
568
+ }
569
+ const key = `_:${value.value}`;
570
+ const items = this.listItems(key);
571
+ if (items) {
572
+ this.emitList(sourceId, predicate, items);
573
+ return;
574
+ }
575
+ if (predicate === RDFS_SUBCLASS_OF && this.owlRestrictions) {
576
+ const restriction = this.existential(key);
577
+ if (restriction) {
578
+ this.addEdge(sourceId, restriction.property, nodeIdForIri(restriction.filler));
579
+ return;
580
+ }
581
+ }
582
+ if (predicate === OWL_EQUIVALENT_CLASS) {
583
+ const members = this.classExpression(key);
584
+ if (members) {
585
+ for (const member of members) this.addEdge(sourceId, member.predicate, member.target);
586
+ return;
587
+ }
588
+ }
589
+ this.addEdge(sourceId, predicate, this.blankId(key));
590
+ this.requestBlank(key);
591
+ }
592
+
593
+ private emitList(sourceId: string, predicate: string, items: readonly Value[]): void {
594
+ if (items.every((item): item is Literal => item.termType === 'Literal')) {
595
+ this.nodeBuilder(sourceId).attrs.add(this.attrKey(predicate), items.map(literalValue));
596
+ return;
597
+ }
598
+ this.warnings.count(
599
+ 'rdf-list-resources',
600
+ (n) =>
601
+ `${plural(n, 'RDF list')} of resources mapped to unordered edges (item order is not kept).`,
602
+ );
603
+ for (const item of items) this.emitValue(sourceId, predicate, item);
604
+ }
605
+
606
+ private addEdge(source: string, predicate: string, target: string): EdgeBuilder {
607
+ const compact = this.usePredicate(predicate);
608
+ const key = `${source}\u0000${compact}\u0000${target}`;
609
+ let edge = this.edges.get(key);
610
+ if (!edge) {
611
+ // The label derives from the predicate alone: a property declaration appearing or
612
+ // disappearing must not relabel every edge. Declared labels go to `propertyLabels`.
613
+ edge = {
614
+ source,
615
+ predicate: compact,
616
+ target,
617
+ label: readableName(compact),
618
+ attrs: new AttrBag(),
619
+ };
620
+ this.edges.set(key, edge);
621
+ }
622
+ return edge;
623
+ }
624
+
625
+ /** Attribute key of a literal-valued property (its compact form). */
626
+ private attrKey(predicate: string): string {
627
+ return this.usePredicate(predicate);
628
+ }
629
+
630
+ /** Compact form of a predicate, remembered so its declared label can be reported. */
631
+ private usePredicate(predicate: string): string {
632
+ const compact = this.table.compact(predicate);
633
+ this.usedPredicates.set(compact, predicate);
634
+ return compact;
635
+ }
636
+
637
+ /**
638
+ * Labels the file declares for the properties used as edge predicates or attr keys
639
+ * (`BFO:0000050` → “part of”), by compact predicate, for display.
640
+ */
641
+ propertyLabels(): Record<string, string> {
642
+ const labels: Record<string, string> = {};
643
+ for (const compact of [...this.usedPredicates.keys()].sort(compareStrings)) {
644
+ const subject = this.subjects.get(this.usedPredicates.get(compact) ?? '');
645
+ const label = subject && this.bestLabel(subject);
646
+ if (label) setOwn(labels, compact, label);
647
+ }
648
+ return labels;
649
+ }
650
+
651
+ // ─── Labels ─────────────────────────────────────────────────────────────
652
+
653
+ /**
654
+ * Picks the label by language tier first (preferred languages in order, then untagged, then
655
+ * any), then by label predicate priority, then lexicographically: deterministic for any order.
656
+ */
657
+ private bestLabel(subject: Subject): string | undefined {
658
+ let best: LabelCandidate | undefined;
659
+ for (const [predicate, values] of subject.props) {
660
+ const rank = this.labelRanks.get(predicate);
661
+ if (rank === undefined) continue;
662
+ for (const value of values) {
663
+ if (value.termType !== 'Literal') continue;
664
+ const candidate = {
665
+ tier: this.languageTier(value.language),
666
+ rank,
667
+ value: value.value,
668
+ language: value.language,
669
+ };
670
+ if (!best || compareCandidates(candidate, best) < 0) best = candidate;
671
+ }
672
+ }
673
+ return best?.value;
674
+ }
675
+
676
+ private languageTier(language: string): number {
677
+ const tag = language.toLowerCase();
678
+ for (let i = 0; i < this.languages.length; i++) {
679
+ const preferred = this.languages[i];
680
+ if (preferred === tag) return 2 * i;
681
+ if (preferred !== '' && tag.startsWith(`${preferred}-`)) return 2 * i + 1;
682
+ }
683
+ return 2 * this.languages.length;
684
+ }
685
+
686
+ // ─── Blank node structures ──────────────────────────────────────────────
687
+
688
+ /** Items of a well-formed RDF list headed by `head`, or `undefined`. */
689
+ private listItems(head: string): readonly Value[] | undefined {
690
+ const cached = this.lists.get(head);
691
+ if (cached !== undefined) return cached ?? undefined;
692
+ const items: Value[] = [];
693
+ const seen = new Set<string>();
694
+ let key = head;
695
+ let result: readonly Value[] | null = null;
696
+ for (;;) {
697
+ const cell = this.subjects.get(key);
698
+ if (!cell || seen.has(key) || (key !== head && this.referenceCount(key) !== 1)) break;
699
+ seen.add(key);
700
+ const first = cell.props.get(RDF_FIRST);
701
+ const rest = cell.props.get(RDF_REST);
702
+ const types = cell.props.get(RDF_TYPE) ?? [];
703
+ const extra = cell.props.size - (first ? 1 : 0) - (rest ? 1 : 0) - (types.length > 0 ? 1 : 0);
704
+ if (first?.length !== 1 || rest?.length !== 1 || extra > 0) break;
705
+ if (!types.every((type) => type.termType === 'NamedNode' && type.value === RDF_LIST)) break;
706
+ const [item] = first;
707
+ const [next] = rest;
708
+ if (item === undefined || next === undefined) break;
709
+ items.push(item);
710
+ if (next.termType === 'NamedNode' && next.value === RDF_NIL) {
711
+ result = items;
712
+ break;
713
+ }
714
+ if (next.termType !== 'BlankNode') break;
715
+ key = `_:${next.value}`;
716
+ }
717
+ this.lists.set(head, result);
718
+ return result ?? undefined;
719
+ }
720
+
721
+ /** `[ a owl:Restriction ; owl:onProperty R ; owl:someValuesFrom D ]` with named R and D. */
722
+ private existential(key: string): { property: string; filler: string } | undefined {
723
+ const cached = this.existentials.get(key);
724
+ if (cached !== undefined) return cached ?? undefined;
725
+ let result: { property: string; filler: string } | null = null;
726
+ const subject = this.subjects.get(key);
727
+ if (subject) {
728
+ let property: string | undefined;
729
+ let filler: string | undefined;
730
+ let simple = true;
731
+ for (const [predicate, values] of subject.props) {
732
+ const [only] = values;
733
+ const named =
734
+ values.length === 1 && only?.termType === 'NamedNode' ? only.value : undefined;
735
+ if (predicate === RDF_TYPE) simple &&= values.every((v) => v.value === OWL_RESTRICTION);
736
+ else if (predicate === OWL_ON_PROPERTY && named !== undefined) property = named;
737
+ else if (predicate === OWL_SOME_VALUES_FROM && named !== undefined) filler = named;
738
+ else simple = false;
739
+ }
740
+ if (simple && property !== undefined && filler !== undefined) result = { property, filler };
741
+ }
742
+ this.existentials.set(key, result);
743
+ return result ?? undefined;
744
+ }
745
+
746
+ /**
747
+ * Members of `C owl:equivalentClass X` that are worth an edge: an existential X gives
748
+ * `C –R→ D`; an `owl:intersectionOf` / `owl:unionOf` list of named classes (and, for
749
+ * intersections, existentials) gives one edge per member. `undefined` when X has any other
750
+ * shape, which keeps it as a blank node of its own.
751
+ */
752
+ private classExpression(key: string): { predicate: string; target: string }[] | undefined {
753
+ const restriction = this.owlRestrictions ? this.existential(key) : undefined;
754
+ if (restriction)
755
+ return [{ predicate: restriction.property, target: nodeIdForIri(restriction.filler) }];
756
+ const subject = this.subjects.get(key);
757
+ if (!subject) return undefined;
758
+ let operator: string | undefined;
759
+ let head: string | undefined;
760
+ for (const [predicate, values] of subject.props) {
761
+ const [only] = values;
762
+ if (predicate === RDF_TYPE) {
763
+ if (!values.every((v) => v.value === OWL_CLASS)) return undefined;
764
+ } else if (
765
+ (predicate === OWL_INTERSECTION_OF || predicate === OWL_UNION_OF) &&
766
+ operator === undefined &&
767
+ values.length === 1 &&
768
+ only?.termType === 'BlankNode'
769
+ ) {
770
+ operator = predicate;
771
+ head = `_:${only.value}`;
772
+ } else return undefined;
773
+ }
774
+ const items = head === undefined ? undefined : this.listItems(head);
775
+ if (operator === undefined || !items) return undefined;
776
+ const members: { predicate: string; target: string }[] = [];
777
+ for (const item of items) {
778
+ if (item.termType === 'NamedNode') {
779
+ members.push({ predicate: operator, target: nodeIdForIri(item.value) });
780
+ continue;
781
+ }
782
+ const nested =
783
+ item.termType === 'BlankNode' && operator === OWL_INTERSECTION_OF && this.owlRestrictions
784
+ ? this.existential(`_:${item.value}`)
785
+ : undefined;
786
+ if (!nested) return undefined;
787
+ members.push({ predicate: nested.property, target: nodeIdForIri(nested.filler) });
788
+ }
789
+ return members;
790
+ }
791
+
792
+ // ─── Blank node identity ────────────────────────────────────────────────
793
+
794
+ /**
795
+ * Id of a blank node. Tree-shaped blank nodes (used by exactly one subject / predicate and
796
+ * not on a cycle) are skolemized by context: `_:b` + hash(parent id, predicate, content), so
797
+ * `alice :knows []` and `bob :knows []` stay two nodes. Shared, unreferenced and cyclic
798
+ * blank nodes are identified by content alone. Iterative, so parent chains of any length work.
799
+ */
800
+ private blankId(key: string): string {
801
+ const known = this.blankIds.get(key);
802
+ if (known !== undefined) return known;
803
+ const content = (blank: string) => {
804
+ this.blankHashes ??= this.hashBlanks();
805
+ return this.blankHashes.get(blank) ?? hash53('');
806
+ };
807
+ // Walk up the unique-parent chain until an ancestor's id is known or context-free.
808
+ const chain: string[] = [];
809
+ for (let cursor = key; !this.blankIds.has(cursor); ) {
810
+ const parent = this.treeParent(cursor);
811
+ if (!parent) {
812
+ this.blankIds.set(cursor, `_:b${content(cursor)}`);
813
+ break;
814
+ }
815
+ chain.push(cursor);
816
+ if (!isBlankKey(parent.subject)) break;
817
+ cursor = parent.subject;
818
+ }
819
+ for (let i = chain.length - 1; i >= 0; i--) {
820
+ const child = chain[i] as string;
821
+ const parent = this.treeParent(child);
822
+ if (!parent) continue;
823
+ const parentId = isBlankKey(parent.subject)
824
+ ? (this.blankIds.get(parent.subject) ?? '')
825
+ : nodeIdForIri(parent.subject);
826
+ const hash = hash53(`${parentId}\n<${parent.predicate}>\n${content(child)}`);
827
+ this.blankIds.set(child, `_:b${hash}`);
828
+ }
829
+ return this.blankIds.get(key) ?? `_:b${content(key)}`;
830
+ }
831
+
832
+ /** The single (subject, predicate) using a tree-shaped blank node, if it is one. */
833
+ private treeParent(
834
+ key: string,
835
+ ): { readonly subject: string; readonly predicate: string } | undefined {
836
+ this.blankHashes ??= this.hashBlanks();
837
+ if (this.referenceCount(key) !== 1 || this.cyclicBlanks.has(key)) return undefined;
838
+ return this.parents.get(key);
839
+ }
840
+
841
+ /**
842
+ * Content hashes for all blank nodes, independent of blank node labels and statement order.
843
+ *
844
+ * A blank node's hash covers its sorted outgoing triples, with blank objects replaced by
845
+ * their own hashes. Strongly connected components (Tarjan, iterative) are processed children
846
+ * first, so acyclic structures hash bottom-up in one pass. Blank nodes on a cycle are
847
+ * hashed by colour refinement (1-WL) within their component until the partition is stable.
848
+ * Structurally indistinguishable blank nodes therefore share an id. O(triples) for acyclic
849
+ * data; at most O(component size × component triples) for cycles.
850
+ */
851
+ private hashBlanks(): Map<string, string> {
852
+ const keys: string[] = [];
853
+ const indexOf = new Map<string, number>();
854
+ const add = (key: string): number => {
855
+ let index = indexOf.get(key);
856
+ if (index === undefined) {
857
+ index = keys.length;
858
+ indexOf.set(key, index);
859
+ keys.push(key);
860
+ }
861
+ return index;
862
+ };
863
+ for (const key of this.subjects.keys()) if (isBlankKey(key)) add(key);
864
+ const children: number[][] = [];
865
+ for (let i = 0; i < keys.length; i++) {
866
+ const out: number[] = [];
867
+ this.forEachBlankChild(keys[i] as string, (child) => out.push(add(child)));
868
+ children.push(out);
869
+ }
870
+
871
+ const hashes = new Map<string, string>();
872
+ for (const component of stronglyConnected(children)) {
873
+ const [first] = component;
874
+ if (first === undefined) continue;
875
+ const selfLoop = (children[first] ?? []).includes(first);
876
+ if (component.length === 1 && !selfLoop) {
877
+ const key = keys[first] as string;
878
+ const lines = this.canonicalLines(key, (child) => hashes.get(child) ?? '');
879
+ hashes.set(key, hash53(lines.join('\n')));
880
+ } else {
881
+ const members = component.map((index) => keys[index] as string);
882
+ for (const member of members) this.cyclicBlanks.add(member);
883
+ for (const [key, color] of this.refineCycle(members, hashes)) hashes.set(key, color);
884
+ }
885
+ }
886
+ return hashes;
887
+ }
888
+
889
+ /**
890
+ * Colour refinement for the blank nodes of one cycle: start from each node's own content
891
+ * (cycle members anonymous), then repeatedly fold in the members' colours until the number
892
+ * of distinct colours stops growing (at most one round per member).
893
+ */
894
+ private refineCycle(members: readonly string[], hashes: ReadonlyMap<string, string>) {
895
+ const inside = new Set(members);
896
+ const outside = (child: string) => hashes.get(child) ?? '';
897
+ const colorOf = (key: string, previous: ReadonlyMap<string, string> | undefined) => {
898
+ const lines = this.canonicalLines(key, (child) =>
899
+ inside.has(child) ? (previous?.get(child) ?? '~') : outside(child),
900
+ );
901
+ return hash53(`${previous?.get(key) ?? '~'}\n${lines.join('\n')}`);
902
+ };
903
+ let colors = new Map(members.map((key) => [key, colorOf(key, undefined)]));
904
+ for (let round = 0; round < members.length; round++) {
905
+ const previous = colors;
906
+ colors = new Map(members.map((key) => [key, colorOf(key, previous)]));
907
+ if (new Set(colors.values()).size === new Set(previous.values()).size) break;
908
+ }
909
+ return colors;
910
+ }
911
+
912
+ /** Calls `visit` for every blank node a blank node points to (triple terms included). */
913
+ private forEachBlankChild(key: string, visit: (child: string) => void): void {
914
+ const term = (value: unknown): void => {
915
+ if (value instanceof BlankNode) visit(`_:${value.value}`);
916
+ else if (isTripleTerm(value)) {
917
+ term(value.subject);
918
+ term(value.object);
919
+ }
920
+ };
921
+ for (const values of this.subjects.get(key)?.props.values() ?? []) {
922
+ for (const value of values) term(value);
923
+ }
924
+ term(this.reifies.get(key));
925
+ }
926
+
927
+ /** Sorted, de-duplicated canonical lines of a blank node; `blank` names blank objects. */
928
+ private canonicalLines(key: string, blank: (child: string) => string): string[] {
929
+ const term = (value: unknown): string => {
930
+ if (value instanceof BlankNode) return `_:${blank(`_:${value.value}`)}`;
931
+ if (value instanceof NamedNode) return `<${value.value}>`;
932
+ if (value instanceof Literal) {
933
+ return value.language
934
+ ? `${JSON.stringify(value.value)}@${value.language}`
935
+ : `${JSON.stringify(value.value)}^^<${value.datatype.value}>`;
936
+ }
937
+ return isTripleTerm(value)
938
+ ? `<<${term(value.subject)} ${term(value.predicate)} ${term(value.object)}>>`
939
+ : '?';
940
+ };
941
+ const lines: string[] = [];
942
+ for (const [predicate, values] of this.subjects.get(key)?.props ?? []) {
943
+ for (const value of values) lines.push(`<${predicate}> ${term(value)}`);
944
+ }
945
+ const reified = this.reifies.get(key);
946
+ if (reified) lines.push(`<${RDF_REIFIES}> ${term(reified)}`);
947
+ return [...new Set(lines)].sort(compareStrings);
948
+ }
949
+
950
+ // ─── Statement annotations ──────────────────────────────────────────────
951
+
952
+ /** Recognises an unreferenced blank node that annotates a statement. */
953
+ private reification(key: string, subject: Subject): Reification | undefined {
954
+ const parts = new Map<string, Value>();
955
+ const annotations: [string, Value[]][] = [];
956
+ for (const [predicate, values] of subject.props) {
957
+ switch (predicate) {
958
+ case OWL_ANNOTATED_SOURCE:
959
+ case OWL_ANNOTATED_PROPERTY:
960
+ case OWL_ANNOTATED_TARGET:
961
+ case RDF_SUBJECT:
962
+ case RDF_PREDICATE:
963
+ case RDF_OBJECT: {
964
+ const [only] = values;
965
+ if (values.length !== 1 || only === undefined) return undefined;
966
+ parts.set(predicate, only);
967
+ break;
968
+ }
969
+ case RDF_TYPE:
970
+ if (!values.every((v) => v.value === OWL_AXIOM || v.value === RDF_STATEMENT))
971
+ return undefined;
972
+ break;
973
+ default:
974
+ annotations.push([predicate, values]);
975
+ }
976
+ }
977
+ const triple = this.reifies.get(key);
978
+ let s: unknown;
979
+ let p: unknown;
980
+ let o: unknown;
981
+ if (parts.size === 3 && parts.has(OWL_ANNOTATED_SOURCE)) {
982
+ [s, p, o] = [
983
+ parts.get(OWL_ANNOTATED_SOURCE),
984
+ parts.get(OWL_ANNOTATED_PROPERTY),
985
+ parts.get(OWL_ANNOTATED_TARGET),
986
+ ];
987
+ } else if (parts.size === 3 && parts.has(RDF_SUBJECT)) {
988
+ [s, p, o] = [parts.get(RDF_SUBJECT), parts.get(RDF_PREDICATE), parts.get(RDF_OBJECT)];
989
+ } else if (parts.size === 0 && triple) {
990
+ [s, p, o] = [triple.subject, triple.predicate, triple.object];
991
+ } else return undefined;
992
+ if (!(s instanceof NamedNode || s instanceof BlankNode) || !(p instanceof NamedNode))
993
+ return undefined;
994
+ if (!(o instanceof NamedNode || o instanceof BlankNode || o instanceof Literal))
995
+ return undefined;
996
+ return { subject: s, predicate: p.value, object: o, annotations };
997
+ }
998
+
999
+ /**
1000
+ * Attaches a statement's annotations to the edge it describes (keyed by compact annotation
1001
+ * predicate), or — for an asserted literal-valued statement — to the node, keyed
1002
+ * `<property>/<annotation>`. Returns false when it cannot, so the annotation stays a node.
1003
+ */
1004
+ private foldReification(reification: Reification): boolean {
1005
+ const { subject, predicate, object } = reification;
1006
+ const sourceKey = keyOf(subject);
1007
+ let sourceId: string | undefined;
1008
+ if (subject.termType === 'NamedNode') sourceId = nodeIdForIri(subject.value);
1009
+ else if (this.emittedBlanks.has(sourceKey)) sourceId = this.blankId(sourceKey);
1010
+ if (sourceId === undefined) return false;
1011
+
1012
+ const values: [string, AttrValue][] = [];
1013
+ for (const [annotation, annotationValues] of reification.annotations) {
1014
+ const key = this.attrKey(annotation);
1015
+ for (const value of annotationValues) {
1016
+ if (value.termType === 'BlankNode') return false;
1017
+ values.push([
1018
+ key,
1019
+ value.termType === 'Literal' ? literalValue(value) : nodeIdForIri(value.value),
1020
+ ]);
1021
+ }
1022
+ }
1023
+
1024
+ const edge = this.annotatedEdge(sourceId, predicate, object);
1025
+ if (edge) {
1026
+ for (const [key, value] of values) edge.attrs.add(key, value);
1027
+ return true;
1028
+ }
1029
+ const asserted = this.subjects
1030
+ .get(sourceKey)
1031
+ ?.props.get(predicate)
1032
+ ?.some((value) => value.equals(object));
1033
+ const node = this.nodes.get(sourceId);
1034
+ if (object.termType !== 'Literal' || !asserted || !node) return false;
1035
+ const property = this.attrKey(predicate);
1036
+ for (const [key, value] of values) node.attrs.add(`${property}/${key}`, value);
1037
+ return true;
1038
+ }
1039
+
1040
+ private annotatedEdge(
1041
+ sourceId: string,
1042
+ predicate: string,
1043
+ object: Value,
1044
+ ): EdgeBuilder | undefined {
1045
+ const find = (p: string, target: string) =>
1046
+ this.edges.get(`${sourceId}\u0000${this.table.compact(p)}\u0000${target}`);
1047
+ if (object.termType === 'NamedNode') return find(predicate, nodeIdForIri(object.value));
1048
+ if (object.termType !== 'BlankNode') return undefined;
1049
+ const key = `_:${object.value}`;
1050
+ // OWL API repeats the annotated class expression as a fresh blank node: match its shape.
1051
+ const restriction = predicate === RDFS_SUBCLASS_OF ? this.existential(key) : undefined;
1052
+ if (restriction) return find(restriction.property, nodeIdForIri(restriction.filler));
1053
+ return this.emittedBlanks.has(key) ? find(predicate, this.blankId(key)) : undefined;
1054
+ }
1055
+
1056
+ // ─── Ontology header ────────────────────────────────────────────────────
1057
+
1058
+ private ontologyMeta(): GraphMeta | undefined {
1059
+ if (this.ontologies.size === 0) return undefined;
1060
+ const imported = new Set<string>();
1061
+ for (const key of this.ontologies) {
1062
+ for (const value of this.subjects.get(key)?.props.get(OWL_IMPORTS) ?? [])
1063
+ imported.add(value.value);
1064
+ }
1065
+ const candidates = [...this.ontologies].sort(
1066
+ (a, b) =>
1067
+ Number(imported.has(a)) - Number(imported.has(b)) ||
1068
+ Number(isBlankKey(a)) - Number(isBlankKey(b)) ||
1069
+ compareStrings(a, b),
1070
+ );
1071
+ if (candidates.length > 1) {
1072
+ this.warnings.add(
1073
+ 'multiple-ontologies',
1074
+ `Found ${candidates.length} owl:Ontology headers; using ${candidates[0]} for name and version.`,
1075
+ );
1076
+ }
1077
+ const key = candidates[0];
1078
+ const subject = key === undefined ? undefined : this.subjects.get(key);
1079
+ if (!subject || key === undefined) return undefined;
1080
+ const literal = (predicate: string) =>
1081
+ (subject.props.get(predicate) ?? [])
1082
+ .filter((value) => value.termType === 'Literal')
1083
+ .map((value) => value.value)
1084
+ .sort(compareStrings)[0];
1085
+ const iri = (predicate: string) =>
1086
+ subject.props.get(predicate)?.find((value) => value.termType === 'NamedNode')?.value;
1087
+ return makeMeta({
1088
+ name:
1089
+ this.bestLabel(subject) ??
1090
+ literal(DCTERMS_TITLE) ??
1091
+ literal(DC_TITLE) ??
1092
+ (isBlankKey(key) ? undefined : key),
1093
+ version: literal(OWL_VERSION_INFO) ?? iri(OWL_VERSION_IRI),
1094
+ });
1095
+ }
1096
+ }
1097
+
1098
+ // ─── Values ─────────────────────────────────────────────────────────────────
1099
+
1100
+ /** Attribute value of a literal: typed XSD numbers / booleans, `"text@lang"` for tagged text. */
1101
+ function literalValue(literal: Literal): AttrValue {
1102
+ if (literal.language) return `${literal.value}@${literal.language}`;
1103
+ const datatype = literal.datatype.value;
1104
+ return typedValue(
1105
+ literal.value,
1106
+ datatype.startsWith(XSD_NS) ? datatype.slice(XSD_NS.length) : undefined,
1107
+ );
1108
+ }
1109
+
1110
+ const isTrue = (lexical: string): boolean => {
1111
+ const value = lexical.trim();
1112
+ return value === 'true' || value === '1';
1113
+ };
1114
+
1115
+ function compareCandidates(a: LabelCandidate, b: LabelCandidate): number {
1116
+ return (
1117
+ a.tier - b.tier ||
1118
+ a.rank - b.rank ||
1119
+ compareStrings(a.value, b.value) ||
1120
+ compareStrings(a.language, b.language)
1121
+ );
1122
+ }
1123
+
1124
+ /** schema.org is published under both http and https; treat them as one vocabulary. */
1125
+ function schemaVariants(iri: string): string[] {
1126
+ if (iri.startsWith(SCHEMA_HTTPS)) return [iri, SCHEMA_HTTP + iri.slice(SCHEMA_HTTPS.length)];
1127
+ if (iri.startsWith(SCHEMA_HTTP)) return [iri, SCHEMA_HTTPS + iri.slice(SCHEMA_HTTP.length)];
1128
+ return [iri];
1129
+ }
1130
+
1131
+ /**
1132
+ * Strongly connected components of a directed graph given as adjacency lists (Tarjan,
1133
+ * iterative). Components come out in reverse topological order: every component after all
1134
+ * components it points to.
1135
+ */
1136
+ function stronglyConnected(adjacency: readonly (readonly number[])[]): number[][] {
1137
+ const n = adjacency.length;
1138
+ const index = new Int32Array(n).fill(-1);
1139
+ const low = new Int32Array(n);
1140
+ const onStack = new Uint8Array(n);
1141
+ const stack: number[] = [];
1142
+ const components: number[][] = [];
1143
+ let counter = 0;
1144
+ const open = (node: number, work: [number, number][]) => {
1145
+ index[node] = low[node] = counter++;
1146
+ stack.push(node);
1147
+ onStack[node] = 1;
1148
+ work.push([node, 0]);
1149
+ };
1150
+ for (let start = 0; start < n; start++) {
1151
+ if ((index[start] ?? 0) !== -1) continue;
1152
+ const work: [number, number][] = [];
1153
+ open(start, work);
1154
+ while (work.length > 0) {
1155
+ const frame = work[work.length - 1] as [number, number];
1156
+ const [node, position] = frame;
1157
+ const next = adjacency[node]?.[position];
1158
+ if (next !== undefined) {
1159
+ frame[1]++;
1160
+ if (index[next] === -1) open(next, work);
1161
+ else if (onStack[next]) low[node] = Math.min(low[node] ?? 0, index[next] ?? 0);
1162
+ continue;
1163
+ }
1164
+ work.pop();
1165
+ const parent = work[work.length - 1];
1166
+ if (parent) low[parent[0]] = Math.min(low[parent[0]] ?? 0, low[node] ?? 0);
1167
+ if (low[node] === index[node]) {
1168
+ const component: number[] = [];
1169
+ let member: number | undefined;
1170
+ do {
1171
+ member = stack.pop();
1172
+ if (member === undefined) break;
1173
+ onStack[member] = 0;
1174
+ component.push(member);
1175
+ } while (member !== node);
1176
+ components.push(component);
1177
+ }
1178
+ }
1179
+ }
1180
+ return components;
1181
+ }