@oml/owl 0.19.3 → 0.20.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 (46) hide show
  1. package/out/index.d.ts +3 -0
  2. package/out/index.js +3 -0
  3. package/out/index.js.map +1 -1
  4. package/out/owl/owl-abox.d.ts +16 -5
  5. package/out/owl/owl-abox.js +365 -188
  6. package/out/owl/owl-abox.js.map +1 -1
  7. package/out/owl/owl-imports.js +4 -2
  8. package/out/owl/owl-imports.js.map +1 -1
  9. package/out/owl/owl-interfaces.d.ts +26 -1
  10. package/out/owl/owl-mapper.d.ts +2 -0
  11. package/out/owl/owl-mapper.js +77 -38
  12. package/out/owl/owl-mapper.js.map +1 -1
  13. package/out/owl/owl-service.d.ts +4 -0
  14. package/out/owl/owl-service.js +139 -44
  15. package/out/owl/owl-service.js.map +1 -1
  16. package/out/owl/owl-shacl.d.ts +8 -9
  17. package/out/owl/owl-shacl.js +43 -117
  18. package/out/owl/owl-shacl.js.map +1 -1
  19. package/out/owl/owl-sparql-engine.d.ts +6 -0
  20. package/out/owl/owl-sparql-engine.js +11 -0
  21. package/out/owl/owl-sparql-engine.js.map +1 -0
  22. package/out/owl/owl-sparql-oxigraph.d.ts +34 -0
  23. package/out/owl/owl-sparql-oxigraph.js +436 -0
  24. package/out/owl/owl-sparql-oxigraph.js.map +1 -0
  25. package/out/owl/owl-sparql.d.ts +0 -44
  26. package/out/owl/owl-sparql.js +0 -320
  27. package/out/owl/owl-sparql.js.map +1 -1
  28. package/out/owl/owl-store-lean.d.ts +29 -0
  29. package/out/owl/owl-store-lean.js +445 -0
  30. package/out/owl/owl-store-lean.js.map +1 -0
  31. package/out/owl/owl-store.d.ts +11 -1
  32. package/out/owl/owl-store.js +80 -6
  33. package/out/owl/owl-store.js.map +1 -1
  34. package/package.json +3 -3
  35. package/src/index.ts +3 -0
  36. package/src/owl/owl-abox.ts +401 -200
  37. package/src/owl/owl-imports.ts +4 -2
  38. package/src/owl/owl-interfaces.ts +28 -1
  39. package/src/owl/owl-mapper.ts +79 -41
  40. package/src/owl/owl-service.ts +149 -49
  41. package/src/owl/owl-shacl.ts +55 -132
  42. package/src/owl/owl-sparql-engine.ts +34 -0
  43. package/src/owl/owl-sparql-oxigraph.ts +527 -0
  44. package/src/owl/owl-sparql.ts +3 -366
  45. package/src/owl/owl-store-lean.ts +438 -0
  46. package/src/owl/owl-store.ts +86 -7
@@ -0,0 +1,527 @@
1
+ // Copyright (c) 2026 Modelware. All rights reserved.
2
+
3
+ // Experimental Oxigraph-backed SPARQL engine, gated behind OML_SPARQL_ENGINE=oxigraph.
4
+ // Rationale (benchmarked 2026-06): Comunica's per-pattern actor mediation collapses on
5
+ // cross-graph queries (`GRAPH ?g` over a large import closure) — 565s vs ~20ms on Oxigraph.
6
+ // We keep N3 as the reasoning store (native chainers; Oxigraph match()/add() are far slower
7
+ // per-quad across the WASM boundary) and MIRROR reasoned quads into a single global Oxigraph
8
+ // store for querying. The mirror syncs per dirty model via fast bulk N-Triples load(); query
9
+ // scoping to the import closure uses Oxigraph's default_graph / named_graphs options.
10
+ //
11
+ // Node-only: oxigraph is dynamically imported so the browser/worker bundle (which never sets
12
+ // the flag) does not pull in the WASM module. Comunica remains the default everywhere.
13
+
14
+ import type * as RDF from '@rdfjs/types';
15
+ import type { NamedNode, Quad, Store } from 'n3';
16
+ import type {
17
+ OwlABoxEntailmentState,
18
+ OwlAskResult,
19
+ OwlConstructResult,
20
+ OwlImportGraph,
21
+ OwlInferenceRunner,
22
+ OwlQueryResult,
23
+ OwlQueryRow,
24
+ OwlQueryTerm,
25
+ OwlSparqlEngine,
26
+ OwlStore,
27
+ } from './owl-interfaces.js';
28
+
29
+ // Minimal structural typing over the `oxigraph` module surface we use, so this file
30
+ // type-checks without a hard static import of the WASM package.
31
+ type OxTerm = { termType: string; value: string; datatype?: { value: string }; language?: string };
32
+ type OxQuad = unknown;
33
+ type OxStore = {
34
+ load(data: string, options: { format: string; to_graph_name?: unknown; base_iri?: string }): void;
35
+ query(query: string, options?: Record<string, unknown>): unknown;
36
+ update(update: string): void;
37
+ add(quad: OxQuad): void;
38
+ delete(quad: OxQuad): void;
39
+ };
40
+ type OxModule = {
41
+ Store: new () => OxStore;
42
+ namedNode(value: string): unknown;
43
+ quad(subject: unknown, predicate: unknown, object: unknown, graph?: unknown): OxQuad;
44
+ fromTerm(term: RDF.Term): unknown;
45
+ };
46
+
47
+ const XSD_STRING = 'http://www.w3.org/2001/XMLSchema#string';
48
+ const RDF_LANG_STRING = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#langString';
49
+
50
+ interface ClosureContext {
51
+ store: OxStore;
52
+ missingModels: string[];
53
+ /** Base IRI for the query, so relative IRIs (e.g. IRI("/edge/..") in diagram CONSTRUCTs) resolve. */
54
+ baseIri?: string;
55
+ }
56
+
57
+ export class OxigraphSparqlService implements OwlSparqlEngine {
58
+ private oxModule?: OxModule;
59
+ private oxInit?: Promise<void>;
60
+
61
+ // Per-query-context Oxigraph store, keyed by the queried model URI. Each store holds only
62
+ // that model's import closure under ontology-IRI graph names, so queries can use
63
+ // use_default_graph_as_union (fast) instead of a huge explicit graph list (which Oxigraph
64
+ // evaluates ~200x slower: 24ms vs 5s for this workspace's closure).
65
+ //
66
+ // `mirrored` records, per closure model, the generation of its data currently in the store and
67
+ // the ontology IRI its graphs live under. On the next query the store is re-synced against the
68
+ // global `modelGen`: only models whose generation advanced (edited/re-chained) are re-loaded,
69
+ // and models that left the closure are cleared — so an edit patches a few graphs instead of
70
+ // rebuilding the whole mirror.
71
+ private readonly contextStores = new Map<string, { store: OxStore; mirrored: Map<string, { gen: number; ontologyIri: string }> }>();
72
+
73
+ private ontologyIriResolver: (modelUri: string) => string;
74
+
75
+ constructor(
76
+ private readonly reasoningStore: OwlStore,
77
+ private readonly importGraph: OwlImportGraph,
78
+ private readonly aboxEntailmentCache: OwlABoxEntailmentState,
79
+ private readonly reasoningService: OwlInferenceRunner,
80
+ resolveOntologyIriForModelUri?: (modelUri: string) => string,
81
+ ) {
82
+ this.ontologyIriResolver = resolveOntologyIriForModelUri ?? ((modelUri) => modelUri);
83
+ }
84
+
85
+ setOntologyIriResolver(resolver: (modelUri: string) => string): void {
86
+ this.ontologyIriResolver = resolver;
87
+ }
88
+
89
+ invalidateFilteredStore(_modelUri: string): void {
90
+ // No-op: the mirror re-syncs against the reasoning store's per-model data generation on
91
+ // every query (see syncStore), so a cached store never needs to be dropped or explicitly
92
+ // flagged when a closure member changes — only the models whose data actually changed are
93
+ // re-loaded. (Kept on the interface for the Comunica engine, which caches differently.)
94
+ }
95
+
96
+ removeFilteredStore(modelUri: string): void {
97
+ // A query context that no longer exists — drop its cached store.
98
+ this.contextStores.delete(modelUri);
99
+ }
100
+
101
+ private targetGraphIris(ontologyIri: string): string[] {
102
+ return [ontologyIri, `${ontologyIri}__entailments`, `${ontologyIri}__shacl_rules`];
103
+ }
104
+
105
+ async query(modelUri: string, sparql: string): Promise<OwlQueryResult> {
106
+ try {
107
+ const { context, warnings } = await this.prepare(modelUri);
108
+ const result = this.execute(sparql, context);
109
+ const rows: OwlQueryRow[] = [];
110
+ for (const binding of result as Iterable<Map<string, OxTerm>>) {
111
+ const row: OwlQueryRow = new Map();
112
+ for (const [variable, term] of binding) {
113
+ if (!isQueryValueTerm(term)) {
114
+ continue;
115
+ }
116
+ row.set(variable, toQueryTerm(term));
117
+ }
118
+ rows.push(row);
119
+ }
120
+ return { success: true, rows, warnings };
121
+ } catch (error) {
122
+ return { success: false, rows: [], warnings: this.safeMissingWarnings(modelUri), error: messageOf(error) };
123
+ }
124
+ }
125
+
126
+ async construct(modelUri: string, sparql: string): Promise<OwlConstructResult> {
127
+ try {
128
+ const { context, warnings } = await this.prepare(modelUri);
129
+ const result = this.execute(sparql, context) as Iterable<RDF.Quad>;
130
+ const seen = new Set<string>();
131
+ const quads: RDF.Quad[] = [];
132
+ for (const quad of result) {
133
+ const key = `${quad.subject.value}\x00${quad.predicate.value}\x00${quad.object.value}`;
134
+ if (!seen.has(key)) {
135
+ seen.add(key);
136
+ quads.push(quad);
137
+ }
138
+ }
139
+ return { success: true, quads, warnings };
140
+ } catch (error) {
141
+ return { success: false, quads: [], warnings: [], error: messageOf(error) };
142
+ }
143
+ }
144
+
145
+ async ask(modelUri: string, sparql: string): Promise<OwlAskResult> {
146
+ try {
147
+ const { context, warnings } = await this.prepare(modelUri);
148
+ const result = this.execute(sparql, context) as boolean;
149
+ return { success: true, result: Boolean(result), warnings };
150
+ } catch (error) {
151
+ return { success: false, result: false, warnings: [], error: messageOf(error) };
152
+ }
153
+ }
154
+
155
+ async withMaterializedQuads<T>(modelUri: string, quads: RDF.Quad[], action: () => Promise<T>): Promise<T> {
156
+ // Oxigraph queries read the cached mirror, not the live N3 store, so attaching the quads to
157
+ // the N3 store (as Comunica does) would be invisible here. Instead apply them as a delta
158
+ // directly to the cached context store's shaclRules graph, then remove them afterwards —
159
+ // far cheaper than invalidating and rebuilding the whole closure mirror per SHACL rule.
160
+ if (quads.length === 0) {
161
+ return action();
162
+ }
163
+ await this.ensureModule();
164
+ // Build/refresh the context first so the delta lands on the store the query will read,
165
+ // and so prepare() inside `action` is a cache hit that does not rebuild it without the delta.
166
+ this.ensureFreshEntailments(modelUri);
167
+ const mod = this.oxModule!;
168
+ let targetGraph: unknown;
169
+ let store: OxStore;
170
+ try {
171
+ store = this.getOrBuildContext(modelUri).store;
172
+ targetGraph = mod.namedNode(`${this.toOntologyGraphIri(this.resolveOntologyIri(modelUri))}__shacl_rules`);
173
+ } catch {
174
+ return action();
175
+ }
176
+ const oxQuads = quads.map((q) => mod.quad(mod.fromTerm(q.subject), mod.fromTerm(q.predicate), mod.fromTerm(q.object), targetGraph));
177
+ for (const q of oxQuads) {
178
+ store.add(q);
179
+ }
180
+ try {
181
+ return await action();
182
+ } finally {
183
+ for (const q of oxQuads) {
184
+ store.delete(q);
185
+ }
186
+ }
187
+ }
188
+
189
+ // --- internals -------------------------------------------------------------
190
+
191
+ private async ensureModule(): Promise<OxModule> {
192
+ if (this.oxModule) {
193
+ return this.oxModule;
194
+ }
195
+ if (!this.oxInit) {
196
+ this.oxInit = (async () => {
197
+ const mod = (await import('oxigraph')) as unknown as OxModule;
198
+ // In the browser worker the web build must be wasm-initialized before use; the worker
199
+ // entry kicks that off and exposes the promise. Await it here (first query). In Node
200
+ // the build auto-inits and no promise is present.
201
+ const ready = (globalThis as { __omlOxigraphInit?: Promise<unknown> }).__omlOxigraphInit;
202
+ if (ready) {
203
+ await ready;
204
+ }
205
+ this.oxModule = mod;
206
+ })();
207
+ }
208
+ await this.oxInit;
209
+ return this.oxModule!;
210
+ }
211
+
212
+ private async prepare(modelUri: string): Promise<{ context: ClosureContext; warnings: string[] }> {
213
+ this.ensureFreshEntailments(modelUri);
214
+ await this.ensureModule();
215
+ const context = this.getOrBuildContext(modelUri);
216
+ const warnings = this.composeWarnings(modelUri, context.missingModels);
217
+ return { context, warnings };
218
+ }
219
+
220
+ private execute(sparql: string, context: ClosureContext): unknown {
221
+ // The context store holds only the closure, so unioning all its graphs reproduces the
222
+ // unionDefaultGraph semantics while keeping GRAPH ?g scoped to the closure.
223
+ const options: Record<string, unknown> = { use_default_graph_as_union: true };
224
+ if (context.baseIri) {
225
+ options.base_iri = context.baseIri;
226
+ }
227
+ return context.store.query(sparql, options);
228
+ }
229
+
230
+ // Build (or reuse a cached) Oxigraph store containing the queried model's full import
231
+ // closure, with each model's reasoned graphs remapped to ontology-IRI graph names.
232
+ private getOrBuildContext(modelUri: string): ClosureContext {
233
+ const cached = this.contextStores.get(modelUri);
234
+ if (cached) {
235
+ const missingModels = this.syncStore(modelUri, cached);
236
+ return { store: cached.store, missingModels, baseIri: this.resolveBaseIri(modelUri) };
237
+ }
238
+ const mod = this.oxModule!;
239
+ const store = new mod.Store();
240
+ const n3store: Store = this.reasoningStore.getStore();
241
+ const allUris = [modelUri, ...this.importGraph.dependenciesOf(modelUri)];
242
+ const seen = new Set<string>();
243
+ const missingModels: string[] = [];
244
+ const mirrored = new Map<string, { gen: number; ontologyIri: string }>();
245
+ // Serialize the closure as N-Quads (graph as the 4th term) and bulk-load in batches.
246
+ // Per-graph load() calls cross the WASM boundary thousands of times for a large closure;
247
+ // batching collapses that to a handful of parse passes while keeping the in-flight buffer
248
+ // bounded (a single all-at-once buffer OOMs the Node heap on large workspaces).
249
+ const CHUNK_LINES = 100_000;
250
+ let buffer: string[] = [];
251
+ const flush = (): void => {
252
+ if (buffer.length === 0) {
253
+ return;
254
+ }
255
+ store.load(buffer.join('\n'), { format: 'application/n-quads' });
256
+ buffer = [];
257
+ };
258
+ for (const uri of allUris) {
259
+ if (seen.has(uri)) {
260
+ continue;
261
+ }
262
+ seen.add(uri);
263
+ let sourceGraphs: { own: NamedNode; entailments: NamedNode; shaclRules: NamedNode };
264
+ try {
265
+ sourceGraphs = this.reasoningStore.graphs(uri);
266
+ } catch {
267
+ missingModels.push(uri);
268
+ continue;
269
+ }
270
+ const ontologyIri = this.toOntologyGraphIri(this.resolveOntologyIri(uri));
271
+ mirrored.set(uri, { gen: this.reasoningStore.getModelDataGen(uri), ontologyIri });
272
+ const targets: Array<[NamedNode, string]> = [
273
+ [sourceGraphs.own, ontologyIri],
274
+ [sourceGraphs.entailments, `${ontologyIri}__entailments`],
275
+ [sourceGraphs.shaclRules, `${ontologyIri}__shacl_rules`],
276
+ ];
277
+ for (const [sourceGraph, targetIri] of targets) {
278
+ const quads = n3store.getQuads(null, null, null, sourceGraph);
279
+ if (quads.length === 0) {
280
+ continue;
281
+ }
282
+ const graphNT = `<${escapeIri(targetIri)}>`;
283
+ for (const quad of quads) {
284
+ buffer.push(quadToNQuad(quad, graphNT));
285
+ }
286
+ if (buffer.length >= CHUNK_LINES) {
287
+ flush();
288
+ }
289
+ }
290
+ }
291
+ flush();
292
+ this.contextStores.set(modelUri, { store, mirrored });
293
+ return { store, missingModels, baseIri: this.resolveBaseIri(modelUri) };
294
+ }
295
+
296
+ // Re-sync a cached store against the current closure + per-model generations: re-load models
297
+ // whose data advanced, clear models that left the closure, leave everything else untouched.
298
+ private syncStore(
299
+ modelUri: string,
300
+ entry: { store: OxStore; mirrored: Map<string, { gen: number; ontologyIri: string }> },
301
+ ): string[] {
302
+ const closure = new Set([modelUri, ...this.importGraph.dependenciesOf(modelUri)]);
303
+ const toCheck = new Set<string>([...closure, ...entry.mirrored.keys()]);
304
+ const missingModels: string[] = [];
305
+ for (const uri of toCheck) {
306
+ const have = entry.mirrored.get(uri);
307
+ if (!closure.has(uri)) {
308
+ if (have) {
309
+ this.clearModelInStore(entry, uri);
310
+ }
311
+ continue;
312
+ }
313
+ const curGen = this.reasoningStore.getModelDataGen(uri);
314
+ if (!have || have.gen !== curGen) {
315
+ if (!this.patchModelInStore(entry, uri)) {
316
+ missingModels.push(uri);
317
+ }
318
+ }
319
+ }
320
+ return missingModels;
321
+ }
322
+
323
+ // Replace one model's graphs in a cached store with its current reasoned quads. Returns false
324
+ // if the model is no longer loaded/resolvable (its graphs are then cleared).
325
+ private patchModelInStore(
326
+ entry: { store: OxStore; mirrored: Map<string, { gen: number; ontologyIri: string }> },
327
+ uri: string,
328
+ ): boolean {
329
+ const curGen = this.reasoningStore.getModelDataGen(uri);
330
+ const prev = entry.mirrored.get(uri);
331
+ const graphsToClear = new Set<string>(prev ? this.targetGraphIris(prev.ontologyIri) : []);
332
+ let sourceGraphs: { own: NamedNode; entailments: NamedNode; shaclRules: NamedNode };
333
+ let ontologyIri: string;
334
+ try {
335
+ sourceGraphs = this.reasoningStore.graphs(uri);
336
+ ontologyIri = this.toOntologyGraphIri(this.resolveOntologyIri(uri));
337
+ } catch {
338
+ // Model no longer loaded/resolvable — clear whatever was mirrored and forget it.
339
+ for (const graph of graphsToClear) {
340
+ entry.store.update(`CLEAR SILENT GRAPH <${escapeIri(graph)}>`);
341
+ }
342
+ entry.mirrored.delete(uri);
343
+ return false;
344
+ }
345
+ for (const graph of this.targetGraphIris(ontologyIri)) {
346
+ graphsToClear.add(graph);
347
+ }
348
+ for (const graph of graphsToClear) {
349
+ entry.store.update(`CLEAR SILENT GRAPH <${escapeIri(graph)}>`);
350
+ }
351
+ const n3store: Store = this.reasoningStore.getStore();
352
+ const targets: Array<[NamedNode, string]> = [
353
+ [sourceGraphs.own, ontologyIri],
354
+ [sourceGraphs.entailments, `${ontologyIri}__entailments`],
355
+ [sourceGraphs.shaclRules, `${ontologyIri}__shacl_rules`],
356
+ ];
357
+ const buffer: string[] = [];
358
+ for (const [sourceGraph, targetIri] of targets) {
359
+ const quads = n3store.getQuads(null, null, null, sourceGraph);
360
+ const graphNT = `<${escapeIri(targetIri)}>`;
361
+ for (const quad of quads) {
362
+ buffer.push(quadToNQuad(quad, graphNT));
363
+ }
364
+ }
365
+ if (buffer.length > 0) {
366
+ entry.store.load(buffer.join('\n'), { format: 'application/n-quads' });
367
+ }
368
+ entry.mirrored.set(uri, { gen: curGen, ontologyIri });
369
+ return true;
370
+ }
371
+
372
+ private clearModelInStore(
373
+ entry: { store: OxStore; mirrored: Map<string, { gen: number; ontologyIri: string }> },
374
+ uri: string,
375
+ ): void {
376
+ const have = entry.mirrored.get(uri);
377
+ if (have) {
378
+ for (const graph of this.targetGraphIris(have.ontologyIri)) {
379
+ entry.store.update(`CLEAR SILENT GRAPH <${escapeIri(graph)}>`);
380
+ }
381
+ }
382
+ entry.mirrored.delete(uri);
383
+ }
384
+
385
+ private resolveBaseIri(modelUri: string): string | undefined {
386
+ // Mirror the Comunica path: relative IRIs in queries resolve against the model's own graph IRI.
387
+ try {
388
+ return this.reasoningStore.graphs(modelUri).own.value;
389
+ } catch {
390
+ return modelUri;
391
+ }
392
+ }
393
+
394
+ private resolveOntologyIri(modelUri: string): string {
395
+ const resolved = this.ontologyIriResolver(modelUri);
396
+ if (!resolved || resolved === modelUri) {
397
+ throw new Error(`Missing ontology IRI mapping for model '${modelUri}'.`);
398
+ }
399
+ return resolved;
400
+ }
401
+
402
+ private toOntologyGraphIri(iri: string): string {
403
+ return iri.endsWith('#') ? iri.slice(0, -1) : iri;
404
+ }
405
+
406
+ private ensureFreshEntailments(modelUri: string): void {
407
+ const closure = [modelUri, ...this.importGraph.dependenciesOf(modelUri)];
408
+ // Mirror runInference's own chaining condition: it only (re)chains a model that is dirty,
409
+ // or the query root when its entailments are absent. A non-root dependency that stays
410
+ // 'absent' (runInference never chains it, so its entailment graph is genuinely empty) must
411
+ // NOT be treated as stale here — otherwise every query re-runs inference and invalidates
412
+ // the whole mirror, even though nothing changed.
413
+ const stale = closure.filter((uri) =>
414
+ this.aboxEntailmentCache.isDirty(uri)
415
+ || (uri === modelUri && this.aboxEntailmentCache.isAbsent(uri)));
416
+ if (stale.length === 0) {
417
+ return;
418
+ }
419
+ // runInference re-chains the stale models, mutating their entailment graphs in the reasoning
420
+ // store — which bumps those models' data generations. getOrBuildContext's syncStore then
421
+ // re-loads exactly those models into cached mirrors; no explicit invalidation needed here.
422
+ this.reasoningService.runInference(modelUri);
423
+ }
424
+
425
+ private composeWarnings(modelUri: string, missingModels: string[]): string[] {
426
+ const closureWarnings = [modelUri, ...this.importGraph.dependenciesOf(modelUri)]
427
+ .flatMap((uri) => this.aboxEntailmentCache.getValidationWarnings(uri));
428
+ return [...new Set([...missingModels.map((uri) => `Model not loaded: ${uri}`), ...closureWarnings])];
429
+ }
430
+
431
+ private safeMissingWarnings(modelUri: string): string[] {
432
+ try {
433
+ return this.composeWarnings(modelUri, []);
434
+ } catch {
435
+ return [];
436
+ }
437
+ }
438
+ }
439
+
440
+ function isQueryValueTerm(term: OxTerm): boolean {
441
+ return term.termType === 'NamedNode' || term.termType === 'BlankNode' || term.termType === 'Literal';
442
+ }
443
+
444
+ function toQueryTerm(term: OxTerm): OwlQueryTerm {
445
+ if (term.termType === 'NamedNode') {
446
+ return { termType: 'NamedNode', value: term.value };
447
+ }
448
+ if (term.termType === 'BlankNode') {
449
+ return { termType: 'BlankNode', value: term.value };
450
+ }
451
+ return {
452
+ termType: 'Literal',
453
+ value: term.value,
454
+ datatype: term.datatype?.value,
455
+ language: term.language,
456
+ };
457
+ }
458
+
459
+ function messageOf(error: unknown): string {
460
+ return error instanceof Error ? error.message : String(error);
461
+ }
462
+
463
+ // --- N-Triples serialization (fast bulk-load path into Oxigraph) -----------------
464
+ // NOTE: blank nodes are serialized per-graph; a blank node shared across a model's own
465
+ // and entailment graphs would split into distinct nodes on reload. OML reasoning rarely
466
+ // shares blank nodes across these graphs, and the experimental flag documents this limit.
467
+
468
+ // N-Quads line: subject predicate object graph . — graphNT is the pre-serialized graph IRI.
469
+ function quadToNQuad(quad: Quad, graphNT: string): string {
470
+ return `${termToNT(quad.subject)} ${termToNT(quad.predicate)} ${termToNT(quad.object)} ${graphNT} .`;
471
+ }
472
+
473
+ function termToNT(term: RDF.Term): string {
474
+ switch (term.termType) {
475
+ case 'NamedNode':
476
+ return `<${escapeIri(term.value)}>`;
477
+ case 'BlankNode':
478
+ return `_:${term.value}`;
479
+ case 'Literal': {
480
+ const literal = term as RDF.Literal;
481
+ const lex = `"${escapeLiteral(literal.value)}"`;
482
+ if (literal.language) {
483
+ return `${lex}@${literal.language}`;
484
+ }
485
+ const dt = literal.datatype?.value;
486
+ if (dt && dt !== XSD_STRING && dt !== RDF_LANG_STRING) {
487
+ return `${lex}^^<${escapeIri(dt)}>`;
488
+ }
489
+ return lex;
490
+ }
491
+ default:
492
+ throw new Error(`Unsupported term type for N-Triples serialization: ${term.termType}`);
493
+ }
494
+ }
495
+
496
+ const IRI_DELIMITERS = new Set(['<', '>', '"', '{', '}', '|', '^', '`', '\\']);
497
+ // Control chars (<= 0x20) or the IRIREF delimiters that are illegal unescaped.
498
+ const IRI_NEEDS_ESCAPE = /[\u0000-\u0020<>"{}|^\u0060\\]/;
499
+
500
+ function escapeIri(value: string): string {
501
+ // Fast path: the overwhelming majority of OML IRIs contain no character needing escaping.
502
+ // Serializing the full closure runs this millions of times, so skip the char loop when clean.
503
+ if (!IRI_NEEDS_ESCAPE.test(value)) {
504
+ return value;
505
+ }
506
+ // N-Triples IRIREF: \uXXXX-escape control chars, space, and the delimiters illegal
507
+ // unescaped. \u escapes round-trip to the same code point, preserving the IRI value.
508
+ let out = '';
509
+ for (const ch of value) {
510
+ const code = ch.codePointAt(0) ?? 0;
511
+ if (code <= 0x20 || IRI_DELIMITERS.has(ch)) {
512
+ out += `\\u${code.toString(16).toUpperCase().padStart(4, '0')}`;
513
+ } else {
514
+ out += ch;
515
+ }
516
+ }
517
+ return out;
518
+ }
519
+
520
+ function escapeLiteral(value: string): string {
521
+ return value
522
+ .replace(/\\/g, '\\\\')
523
+ .replace(/"/g, '\\"')
524
+ .replace(/\n/g, '\\n')
525
+ .replace(/\r/g, '\\r')
526
+ .replace(/\t/g, '\\t');
527
+ }