@factoidal/core 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/nodes.mjs ADDED
@@ -0,0 +1,215 @@
1
+ // The node-composition API (https://github.com/danbri/factoidal/issues/1002,
2
+ // step 2): registered Lean stages as nodes, each carrying its contract name
3
+ // and its proof status, with evidence flowing on the wires.
4
+ //
5
+ // The stage list is the engine's (op stageList, formal/lean4/Wasm/Ops/
6
+ // StageList.lean), read from the Lean registry
7
+ // L4Factoidal.Dataflow.registry: name, interface type, wire types, status,
8
+ // contract, named assumptions, and whether the stage may run at all
9
+ // (`runnable`, false for status `missing`). This file holds no copy of it.
10
+ //
11
+ // What this file adds is the binding of a stage NAME to the package
12
+ // function that runs it (RUNNERS below; each is the function that
13
+ // foundation/entrypoints.toml maps to that stage), and the wiring: a value
14
+ // on a wire is `{ graph, evidence }`, and each node appends one evidence
15
+ // record `{ stage, status, contract, assumptions, checked, artefact }` (plus
16
+ // `unchecked` and `reason` for a step run under run()'s `unchecked` opt-in;
17
+ // the record logic is in evidence.mjs). The result of
18
+ // a run also reports the union of the named assumptions of the nodes it
19
+ // passed: the premise list of the pipeline's soundness statement
20
+ // (Dataflow/EndToEnd.lean) as the user sees it.
21
+ //
22
+ // ES module, no Node built-ins: the browser loads this file as it is.
23
+
24
+ import { FactoidalError } from './errors.mjs';
25
+ import { refusalFor, makeRecord, summarize, unionAssumptions } from './evidence.mjs';
26
+
27
+ /**
28
+ * @param {object} deps the package functions and the engine entry
29
+ * @returns {{stages: Function, node: Function, pipe: Function, run: Function}}
30
+ */
31
+ export function buildNodes(deps) {
32
+ const { entry, entryResult, requireEntryFn, Dataset,
33
+ parse, closure, shaclValidate, query, serialize, canonicalize, explain } = deps;
34
+
35
+ const reader = (format) => async (wire, params) => {
36
+ if (typeof wire.graph !== 'string') {
37
+ throw new TypeError(`node: a reader takes text on its wire (got ${typeof wire.graph})`);
38
+ }
39
+ const ds = await parse(wire.graph, { format, ...params });
40
+ return { graph: ds, artefact: { format } };
41
+ };
42
+ const reasoner = (regime) => async (wire, params) => ({
43
+ graph: await closure(wire.graph, regime, params),
44
+ artefact: { regime },
45
+ });
46
+ const writer = (format) => async (wire, params) => ({
47
+ artefact: { format, text: await serialize(wire.graph, { ...params, format }) },
48
+ });
49
+
50
+ // Stage name (stageList `name`) -> the package function that runs it.
51
+ // A runner answers `{ graph?, artefact }`; no `graph` passes the input
52
+ // graph on unchanged.
53
+ const RUNNERS = {
54
+ ntriplesDocumentReader: reader('ntriples'),
55
+ nquadsDocumentReader: reader('nquads'),
56
+ turtleDocumentReader: reader('turtle'),
57
+ trigDocumentReader: reader('trig'),
58
+ rdfxmlDocumentReader: reader('rdfxml'),
59
+ jsonldDocumentReader: reader('jsonld'),
60
+ rdfsReasoner: reasoner('rdfs'),
61
+ owlRlReasoner: reasoner('owl-rl'),
62
+ rhoDfReasoner: reasoner('rho-df'),
63
+ shaclValidator: async (wire, params) => {
64
+ const { shapes, ...opts } = params;
65
+ if (shapes === undefined) throw new TypeError('node shaclValidator: params.shapes is required');
66
+ const r = await shaclValidate(wire.graph, shapes, opts);
67
+ return { artefact: { conforms: r.conforms, report: r.report } };
68
+ },
69
+ shippedEvaluator: async (wire, params) => {
70
+ const { query: sparql, ...opts } = params;
71
+ if (typeof sparql !== 'string') throw new TypeError('node shippedEvaluator: params.query must be a string');
72
+ const answer = await query(wire.graph, sparql, opts);
73
+ return answer instanceof Dataset ? { graph: answer, artefact: { form: 'graph' } }
74
+ : { artefact: { answer } };
75
+ },
76
+ ntriplesDocumentWriter: writer('ntriples'),
77
+ nquadsDocumentWriter: writer('nquads'),
78
+ turtleDocumentWriter: writer('turtle'),
79
+ trigDocumentWriter: writer('trig'),
80
+ rdfxmlDocumentWriter: writer('rdfxml'),
81
+ rdfcStage: async (wire, params) => ({
82
+ artefact: { canonical: await canonicalize(wire.graph, params) },
83
+ }),
84
+ rdfsExplainGraphStage: async (wire, params) => {
85
+ const { description, ...opts } = params;
86
+ if (description === undefined) {
87
+ throw new TypeError('node rdfsExplainGraphStage: params.description is required');
88
+ }
89
+ return { artefact: await explain(wire.graph, description, { ...opts, profile: 'rdfs' }) };
90
+ },
91
+ };
92
+
93
+ let stageCache = null;
94
+
95
+ /**
96
+ * The engine's stage list (op stageList), one element per registry entry.
97
+ * @returns {Promise<object[]>}
98
+ */
99
+ async function stages() {
100
+ if (!stageCache) {
101
+ stageCache = (async () => {
102
+ const e = await entry();
103
+ requireEntryFn(e, 'stageList', 'stages');
104
+ const r = entryResult(e.stageList(), 'stages');
105
+ return Object.freeze(r.stages.map((s) => Object.freeze({
106
+ ...s,
107
+ theorems: Object.freeze([...s.theorems]),
108
+ assumptions: Object.freeze([...s.assumptions]),
109
+ tests: Object.freeze([...s.tests]),
110
+ bound: Object.prototype.hasOwnProperty.call(RUNNERS, s.name),
111
+ })));
112
+ })();
113
+ stageCache.catch(() => { stageCache = null; });
114
+ }
115
+ return stageCache;
116
+ }
117
+
118
+ const NODE = Symbol('factoidal.node');
119
+
120
+ /**
121
+ * A node for the registered stage `name`, with its parameters (the
122
+ * second argument of the package function it calls, e.g. `{shapes}`,
123
+ * `{query}`, `{description}`, reader options).
124
+ * @param {string} name a stageList `name`
125
+ * @param {object} [params]
126
+ * @returns {Promise<object>}
127
+ */
128
+ async function node(name, params) {
129
+ if (typeof name !== 'string') throw new TypeError('node: name must be a string');
130
+ if (params !== undefined && (params === null || typeof params !== 'object')) {
131
+ throw new TypeError('node: params must be an object');
132
+ }
133
+ const info = (await stages()).find((s) => s.name === name);
134
+ if (!info) {
135
+ throw new FactoidalError(`node: no registered stage named '${name}' (see stages())`,
136
+ { code: 'option.invalid-value' });
137
+ }
138
+ return Object.freeze({
139
+ [NODE]: true,
140
+ stage: info.name,
141
+ kind: info.kind,
142
+ status: info.status,
143
+ contract: info.contract,
144
+ assumptions: info.assumptions,
145
+ wireIn: info.wireIn,
146
+ wireOut: info.wireOut,
147
+ runnable: info.runnable,
148
+ bound: info.bound,
149
+ params: Object.freeze({ ...(params || {}) }),
150
+ });
151
+ }
152
+
153
+ function nodesOf(items, who) {
154
+ const out = [];
155
+ for (const n of items) {
156
+ if (n && n[NODE]) out.push(n);
157
+ else if (n && Array.isArray(n.nodes)) out.push(...nodesOf(n.nodes, who));
158
+ else throw new TypeError(`${who}: expected nodes from node() or pipelines from pipe()`);
159
+ }
160
+ return out;
161
+ }
162
+
163
+ /**
164
+ * A pipeline: the nodes in order (pipelines are flattened).
165
+ * `assumptions` is the union of the nodes' named assumptions.
166
+ * @returns {{nodes: object[], assumptions: string[]}}
167
+ */
168
+ function pipe(...items) {
169
+ const nodes = nodesOf(items, 'pipe');
170
+ if (nodes.length === 0) throw new TypeError('pipe: no nodes');
171
+ return Object.freeze({ nodes: Object.freeze(nodes), assumptions: Object.freeze(unionAssumptions(nodes)) });
172
+ }
173
+
174
+ const isWire = (v) => v !== null && typeof v === 'object' && !(v instanceof Dataset) &&
175
+ Object.prototype.hasOwnProperty.call(v, 'graph') && Array.isArray(v.evidence);
176
+
177
+ /**
178
+ * Run a node or a pipeline. `input` is a wire value `{graph, evidence}`
179
+ * or the graph itself (text for a reader, else a Dataset or data input).
180
+ * `options.unchecked: true` lets stages that are not `proved` run,
181
+ * `missing` ones too when the package has a runner for them; each such
182
+ * step's record says so (evidence.mjs). Without it, a `missing` stage
183
+ * refuses before any node runs.
184
+ * @returns {Promise<{graph: any, evidence: object[], assumptions: string[],
185
+ * proved: boolean, uncheckedSteps: string[]}>}
186
+ */
187
+ async function run(pipeline, input, options) {
188
+ if (options !== undefined && (options === null || typeof options !== 'object')) {
189
+ throw new TypeError('run: options must be an object');
190
+ }
191
+ const unchecked = !!(options && options.unchecked);
192
+ const nodes = nodesOf([pipeline], 'run');
193
+ for (const n of nodes) {
194
+ const why = refusalFor(n, unchecked, Object.prototype.hasOwnProperty.call(RUNNERS, n.stage));
195
+ if (why) throw why;
196
+ }
197
+ let wire = isWire(input) ? { graph: input.graph, evidence: [...input.evidence] }
198
+ : { graph: input, evidence: [] };
199
+ let owned = null; // a Dataset an earlier node of this run made
200
+ for (const n of nodes) {
201
+ const out = await RUNNERS[n.stage](wire, n.params);
202
+ const record = makeRecord(n, out.artefact, unchecked);
203
+ let graph = wire.graph;
204
+ if (out.graph !== undefined && out.graph !== wire.graph) {
205
+ if (owned && owned === wire.graph && typeof owned.dispose === 'function') owned.dispose();
206
+ graph = out.graph;
207
+ owned = out.graph instanceof Dataset ? out.graph : null;
208
+ }
209
+ wire = { graph, evidence: [...wire.evidence, record] };
210
+ }
211
+ return { graph: wire.graph, evidence: wire.evidence, ...summarize(wire.evidence) };
212
+ }
213
+
214
+ return { stages, node, pipe, run };
215
+ }
@@ -0,0 +1,61 @@
1
+ // The Node reader of an SBM11 generation directory for lib/sbm11.mjs: the
2
+ // source is a directory path, and a file of the generation is read with
3
+ // fs.readSync at an offset (https://github.com/danbri/factoidal/issues/1009
4
+ // KR1). It moves bytes only (iron rule 7); the engine checks every length.
5
+ //
6
+ // One open file descriptor per file, kept while the reader lives: a query
7
+ // makes many small reads of the same few files.
8
+
9
+ import { closeSync, fstatSync, openSync, readSync, statSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+
12
+ export function nodeDirectoryReader (dir) {
13
+ const fds = new Map()
14
+ function fdOf (file) {
15
+ let fd = fds.get(file)
16
+ if (fd === undefined) {
17
+ fd = openSync(join(dir, file), 'r')
18
+ fds.set(file, fd)
19
+ }
20
+ return fd
21
+ }
22
+ return {
23
+ readAt (file, offset, length) {
24
+ const out = new Uint8Array(length)
25
+ const fd = fdOf(file)
26
+ let done = 0
27
+ while (done < length) {
28
+ const n = readSync(fd, out, done, length - done, offset + done)
29
+ if (n === 0) break
30
+ done += n
31
+ }
32
+ return done === length ? out : null
33
+ },
34
+ size (file) {
35
+ try {
36
+ const fd = fds.get(file)
37
+ const st = fd === undefined ? statSync(join(dir, file)) : fstatSync(fd)
38
+ return st.isFile() ? st.size : null
39
+ } catch {
40
+ return null
41
+ }
42
+ },
43
+ close () {
44
+ for (const fd of fds.values()) closeSync(fd)
45
+ fds.clear()
46
+ }
47
+ }
48
+ }
49
+
50
+ /** A default-reader factory: one reader per directory, kept. */
51
+ export function nodeDefaultReaderFactory () {
52
+ const byDir = new Map()
53
+ return (source) => {
54
+ let r = byDir.get(source)
55
+ if (!r) {
56
+ r = nodeDirectoryReader(source)
57
+ byDir.set(source, r)
58
+ }
59
+ return r
60
+ }
61
+ }
package/lib/sbm11.mjs ADDED
@@ -0,0 +1,148 @@
1
+ // SPARQL over an SBM11 generation, in Node and in the browser, through the
2
+ // wasm ops sbm11Open / sbm11Query / sbm11Close
3
+ // (formal/lean4/Wasm/Ops/Sbm11.lean; https://github.com/danbri/factoidal/issues/1009 KR1).
4
+ //
5
+ // WHAT THIS FILE IS ALLOWED TO DO
6
+ // Move bytes. The engine asks for (source, file, offset, length) through the
7
+ // bridge globalThis.__factoidalReadAt, and for the size of a file through
8
+ // globalThis.__factoidalFileSize (the EM_JS bodies in
9
+ // formal/lean4/ffi/l4_hostread.c call them synchronously). This file finds
10
+ // the reader that serves `source` and answers. It never reads a manifest,
11
+ // checks a length or a digest, or decides which file a query needs: the
12
+ // engine names every file and checks every answer against the manifest
13
+ // (formal/lean4/L4Factoidal/Storage/Sbm11/HostSession.lean). Iron rule 7.
14
+ //
15
+ // READERS
16
+ // A reader is { readAt(file, offset, length) -> Uint8Array, size(file) ->
17
+ // number | null }, both synchronous: the engine is inside WebAssembly and
18
+ // cannot wait for a promise. Node: lib/sbm11-node.mjs reads a directory with
19
+ // fs. A browser worker: a reader over OPFS FileSystemSyncAccessHandle
20
+ // (stage 4 of https://github.com/danbri/factoidal/issues/1007). A caller
21
+ // registers a reader under a source name; a source with no registered reader
22
+ // goes to the default reader (Node: the directory of that name).
23
+
24
+ const readers = new Map()
25
+ let defaultReader = null
26
+ let bridgeInstalled = false
27
+
28
+ function readerFor (source) {
29
+ const r = readers.get(source)
30
+ if (r) return r
31
+ if (defaultReader) return defaultReader(source)
32
+ return null
33
+ }
34
+
35
+ function installBridge () {
36
+ if (bridgeInstalled) return
37
+ bridgeInstalled = true
38
+ // A thrown error and a wrong length both reach Lean as "the host gave no
39
+ // bytes"; the engine then refuses the read with the file name.
40
+ globalThis.__factoidalReadAt = (source, file, offset, length) => {
41
+ const r = readerFor(source)
42
+ if (!r) return null
43
+ return r.readAt(file, offset, length)
44
+ }
45
+ globalThis.__factoidalFileSize = (source, file) => {
46
+ const r = readerFor(source)
47
+ if (!r) return null
48
+ return r.size(file)
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Serve `source` with `reader` ({ readAt(file, offset, length), size(file) },
54
+ * both synchronous). `null` removes the registration.
55
+ */
56
+ export function registerSbm11Source (source, reader) {
57
+ installBridge()
58
+ if (reader === null) { readers.delete(String(source)); return }
59
+ if (!reader || typeof reader.readAt !== 'function' || typeof reader.size !== 'function') {
60
+ throw new TypeError('registerSbm11Source: reader needs readAt(file, offset, length) and size(file)')
61
+ }
62
+ readers.set(String(source), reader)
63
+ }
64
+
65
+ /** The reader for a source with no registration: `(source) => reader | null`. */
66
+ export function setDefaultSbm11Reader (factory) {
67
+ installBridge()
68
+ defaultReader = factory
69
+ }
70
+
71
+ function envelope (raw) {
72
+ const j = typeof raw === 'string' ? JSON.parse(raw) : raw
73
+ if (!j || j.ok !== true) {
74
+ const e = new Error((j && j.error) || 'sbm11: unknown error')
75
+ e.code = 'SBM11'
76
+ throw e
77
+ }
78
+ return j
79
+ }
80
+
81
+ /**
82
+ * The SBM11 functions over an engine loader: `loadEngine()` resolves to the
83
+ * engine handle (`call(op, args)`).
84
+ */
85
+ export function sbm11Api (loadEngine) {
86
+ /**
87
+ * Open an SBM11 generation (a generation directory, or a collection root
88
+ * whose CURRENT file names one). Returns { generation, terms, query(sparql,
89
+ * options), close() }. `query` answers { path, rows, format, result, json }:
90
+ * `result` is the text `l4sbm11 query` writes (SPARQL JSON for SELECT and
91
+ * ASK, N-Triples for CONSTRUCT and DESCRIBE), `json` its parse for
92
+ * SELECT and ASK.
93
+ */
94
+ async function openSbm11 (source, options = {}) {
95
+ installBridge()
96
+ if (options.reader) registerSbm11Source(source, options.reader)
97
+ const eng = await loadEngine()
98
+ const opened = envelope(eng.call('sbm11Open', [String(source)]))
99
+ const handle = opened.handle
100
+ let open = true
101
+ return {
102
+ handle,
103
+ generation: opened.generation,
104
+ terms: Number(opened.terms),
105
+ query (sparql, qopts = {}) {
106
+ if (!open) throw new Error('sbm11: the store is closed')
107
+ const o = {}
108
+ if (qopts.base != null) o.base = String(qopts.base)
109
+ if (qopts.sparql12) o.sparql12 = true
110
+ if (qopts.reference) o.reference = true
111
+ const args = [handle, String(sparql), JSON.stringify(o)]
112
+ // The byte-region entry returns the result bytes outside the
113
+ // envelope; a large answer is then not escaped into a JSON string
114
+ // and parsed again (Wasm/Ops/Sbm11.lean sbm11QueryBlob).
115
+ let j, bytes
116
+ if (typeof eng.callBlobIO === 'function') {
117
+ const answer = eng.callBlobIO('sbm11Query', args, new Uint8Array(0))
118
+ j = envelope(answer.envelope)
119
+ bytes = answer.bytes
120
+ } else {
121
+ j = envelope(eng.call('sbm11Query', args))
122
+ }
123
+ let text = bytes === undefined ? j.result : undefined
124
+ return {
125
+ path: j.path,
126
+ rows: Number(j.rows),
127
+ format: j.format,
128
+ bytesRead: Number(j.bytesRead),
129
+ reads: Number(j.reads),
130
+ /** the answer as bytes (UTF-8) */
131
+ get bytes () { return bytes ?? new TextEncoder().encode(text) },
132
+ /** the answer as text */
133
+ get result () {
134
+ if (text === undefined) text = new TextDecoder().decode(bytes)
135
+ return text
136
+ },
137
+ get json () { return j.format === 'srj' ? JSON.parse(this.result) : undefined }
138
+ }
139
+ },
140
+ close () {
141
+ if (!open) return
142
+ open = false
143
+ envelope(eng.call('sbm11Close', [handle]))
144
+ }
145
+ }
146
+ }
147
+ return { openSbm11 }
148
+ }
package/lib/version.mjs CHANGED
@@ -2,4 +2,4 @@
2
2
  // needs import attributes, which not every runtime this package targets
3
3
  // reads). test/dts-drift.test.js checks that it equals package.json's
4
4
  // `version`.
5
- export const version = '0.12.0';
5
+ export const version = '0.14.0';