@factoidal/core 0.5.0 → 0.5.1
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/CHANGELOG.md +51 -0
- package/README.md +88 -0
- package/bin/store.mjs +157 -0
- package/l4-assets/l4factoidal.js +1 -1
- package/l4-assets/l4factoidal.wasm +0 -0
- package/l4-assets/version.json +4 -4
- package/package.json +1 -1
- package/version.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,56 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.1 — 2026-09-04
|
|
4
|
+
|
|
5
|
+
**A store can be opened once and queried many times.** `storeQuery` is
|
|
6
|
+
stateless: it re-reads, re-verifies and re-decodes the block on every
|
|
7
|
+
call, so a chat bot or a server paid the full cost for every question.
|
|
8
|
+
The new handle pays it once.
|
|
9
|
+
|
|
10
|
+
Measured on a 141-graph store, `skos:prefLabel` block of 5,571,302 bytes
|
|
11
|
+
and 45,806 rows, a DIFFERENT search string every query, load 10:
|
|
12
|
+
|
|
13
|
+
| | stateless | handle |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| first query (open + query) | 1,376 ms | 1,363 ms |
|
|
16
|
+
| second query, different string | 1,376 ms | **95 ms** |
|
|
17
|
+
| tenth query, all different | 1,382 ms | 103 ms |
|
|
18
|
+
| ten queries, total | 13,962 ms | **2,277 ms** |
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
import { openStoreHandle } from '@factoidal/core/store'
|
|
22
|
+
import { loadEngine } from '@factoidal/core/engine'
|
|
23
|
+
const engine = await loadEngine()
|
|
24
|
+
const store = await openStoreHandle(engine, '/path/to/store')
|
|
25
|
+
const a = store.query('PREFIX skos: … SELECT … ') // 95 ms
|
|
26
|
+
const b = store.query('PREFIX skos: … SELECT … ') // 95 ms
|
|
27
|
+
store.close()
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**What it buys**: the per-query SHA-256 verification, block decode,
|
|
31
|
+
dataset build and index build all happen once. **What it does not buy**:
|
|
32
|
+
`CONTAINS` still scans every retained row, so cost stays proportional to
|
|
33
|
+
row count. This is not a search fix. There is still no inverted index.
|
|
34
|
+
|
|
35
|
+
⚠️ **Memory.** A held-open handle is about 170 MiB resident, of which
|
|
36
|
+
roughly 94 MiB is the decoded form of that 5.5 MB packed block — about
|
|
37
|
+
17 times the packed size. Query evaluation peaks at 346 MiB. A process
|
|
38
|
+
holding several stores should watch this; `storeHandleList` reports each
|
|
39
|
+
handle's retained bytes and rows.
|
|
40
|
+
|
|
41
|
+
**For a long-lived process**: several stores may be open at once, keyed
|
|
42
|
+
independently. `storeOpen` REFUSES at its cap and never evicts another
|
|
43
|
+
caller's handle — an eviction policy is a host decision and is not
|
|
44
|
+
implemented. The WebAssembly module is single-threaded, so a host must
|
|
45
|
+
queue overlapping calls; use one module instance per worker thread for
|
|
46
|
+
concurrency. A server would still need a re-open path when a generation
|
|
47
|
+
is replaced on disk, and delta-log overlay support: this path serves the
|
|
48
|
+
manifest's committed artifacts only.
|
|
49
|
+
|
|
50
|
+
Handle answers are gated against the stateless path by comparing ROWS,
|
|
51
|
+
not row counts, in `tools/wasm-store-query-smoke.sh` and
|
|
52
|
+
`Wasm/native-smoke.sh` (85 pass, 0 fail, out of 85; was 79).
|
|
53
|
+
|
|
3
54
|
## 0.5.0 — 2026-09-04
|
|
4
55
|
|
|
5
56
|
**Queries against a persisted store are about six times faster.** Measured
|
package/README.md
CHANGED
|
@@ -488,6 +488,94 @@ store, `SELECT (COUNT(*) AS ?n) WHERE { ?s ?p ?o }`, whole process
|
|
|
488
488
|
including start-up: 220 ms through this command, 33 ms through the
|
|
489
489
|
native `l4block-id-v3-query`.
|
|
490
490
|
|
|
491
|
+
### Holding a store open: verify once, answer many
|
|
492
|
+
|
|
493
|
+
`queryStore` is stateless. Every call transfers the artifacts again, and
|
|
494
|
+
the engine hashes, decodes and indexes them again. A process that answers
|
|
495
|
+
many questions against one generation — a chat bot, an MCP server, a
|
|
496
|
+
SPARQL endpoint — pays all of that for every question, and none of it
|
|
497
|
+
depends on the question.
|
|
498
|
+
|
|
499
|
+
A **store handle** holds that work. `openStoreHandle` verifies each
|
|
500
|
+
artifact against the SHA-256 the manifest commits, decodes each block and
|
|
501
|
+
indexes the rows, once. `handle.query()` then answers from what it
|
|
502
|
+
retained. Several stores can be open at the same time.
|
|
503
|
+
|
|
504
|
+
```js
|
|
505
|
+
import { loadEngine } from '@factoidal/core/bin/engine.mjs'
|
|
506
|
+
import { openStore, openStoreHandle, listStoreHandles } from '@factoidal/core/store'
|
|
507
|
+
|
|
508
|
+
const engine = await loadEngine()
|
|
509
|
+
|
|
510
|
+
// Two stores, open at once, held for the life of the process.
|
|
511
|
+
const skos = openStoreHandle(engine, openStore('./skos-store'))
|
|
512
|
+
const docs = openStoreHandle(engine, openStore('./docs-store'))
|
|
513
|
+
|
|
514
|
+
console.log(listStoreHandles(engine))
|
|
515
|
+
// { ok: true, handles: [ {handle:'s1', bytes:…, rows:…}, {handle:'s2', …} ],
|
|
516
|
+
// bytes: …, rows: …, handleCap: 8, bytesCap: 67108864 }
|
|
517
|
+
|
|
518
|
+
const PREFIX = 'PREFIX skos: <http://www.w3.org/2004/02/skos/core#>'
|
|
519
|
+
|
|
520
|
+
function labelsMatching (handle, needle) {
|
|
521
|
+
const answer = handle.query(`${PREFIX}
|
|
522
|
+
SELECT ?c ?l WHERE {
|
|
523
|
+
GRAPH ?g { ?c skos:prefLabel ?l }
|
|
524
|
+
FILTER(CONTAINS(LCASE(STR(?l)), "${needle.toLowerCase()}"))
|
|
525
|
+
} LIMIT 10`)
|
|
526
|
+
return answer.srj.results.bindings
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Many questions, each a new search string. None of them re-reads a block.
|
|
530
|
+
for (const needle of ['water', 'forest', 'railway', 'volcano']) {
|
|
531
|
+
console.log(needle, labelsMatching(skos, needle).length)
|
|
532
|
+
}
|
|
533
|
+
console.log(docs.query(`${PREFIX} SELECT (COUNT(*) AS ?n) WHERE { ?s ?p ?o }`))
|
|
534
|
+
|
|
535
|
+
skos.close()
|
|
536
|
+
docs.close()
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
`openStoreHandle(engine, store, options)` takes:
|
|
540
|
+
|
|
541
|
+
| option | effect |
|
|
542
|
+
|---|---|
|
|
543
|
+
| (none) | open every artifact the manifest declares |
|
|
544
|
+
| `{ sparql }` | open only the artifacts that query's plan names |
|
|
545
|
+
| `{ keys }` | open exactly these artifact keys |
|
|
546
|
+
|
|
547
|
+
`queryStoreHandle(engine, handle, sparql)`, `closeStoreHandle(engine,
|
|
548
|
+
handle)` and `listStoreHandles(engine)` are the same operations for a
|
|
549
|
+
caller that holds only the handle id.
|
|
550
|
+
|
|
551
|
+
**Measured 2026-09-04**, macOS arm64, Node 22, the 141-graph SKOS store,
|
|
552
|
+
`skos:prefLabel` block of 5,571,302 bytes and 45,806 rows, one
|
|
553
|
+
`CONTAINS` query per row, a DIFFERENT search string every time:
|
|
554
|
+
|
|
555
|
+
| | stateless `queryStore` | handle |
|
|
556
|
+
|---|---|---|
|
|
557
|
+
| first query (open + query) | 1376 ms | 1363 ms |
|
|
558
|
+
| second query, different search string | 1376 ms | 95 ms |
|
|
559
|
+
| tenth query, all different | 1382 ms | 103 ms |
|
|
560
|
+
| ten queries, total | 13962 ms | 2277 ms |
|
|
561
|
+
|
|
562
|
+
**What a handle buys and what it does not.** It removes the per-query
|
|
563
|
+
digest check, block decode and index build. It does NOT make search
|
|
564
|
+
sub-linear: `CONTAINS` still scans every retained row, so a query still
|
|
565
|
+
costs time proportional to the row count. A text index is separate work.
|
|
566
|
+
|
|
567
|
+
**Residency.** Retaining a decoded block costs memory. Measured on the
|
|
568
|
+
same store: 76 MiB resident with the engine loaded and no handle, 170 MiB
|
|
569
|
+
with the handle open — about 94 MiB for a 5.5 MB packed block, and
|
|
570
|
+
evaluation peaks higher again (346 MiB during the queries above). The
|
|
571
|
+
caps are on ARTIFACT bytes, which is what the manifest declares: 8 open
|
|
572
|
+
handles, and 67108864 retained artifact bytes across all of them. A cap
|
|
573
|
+
is a refusal naming the cap; no handle is ever evicted to make room for
|
|
574
|
+
another. `listStoreHandles` is how a server sees its own residency.
|
|
575
|
+
|
|
576
|
+
**One call at a time.** The WebAssembly module is single-threaded. Two
|
|
577
|
+
`query()` calls cannot overlap; a server queues them.
|
|
578
|
+
|
|
491
579
|
## API (draft)
|
|
492
580
|
|
|
493
581
|
The `factoidal` CLI (`bin/factoidal-cli/factoidal_cli.ml`, built to
|
package/bin/store.mjs
CHANGED
|
@@ -236,3 +236,160 @@ export function turtleOfNQuads (engine, nquads) {
|
|
|
236
236
|
throw asStoreError(error)
|
|
237
237
|
}
|
|
238
238
|
}
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------- handles
|
|
241
|
+
//
|
|
242
|
+
// `queryStore` above is stateless: every call transfers the artifacts again,
|
|
243
|
+
// and the engine hashes, decodes and indexes them again. A process that
|
|
244
|
+
// answers many questions against one generation — a chat bot, an MCP server,
|
|
245
|
+
// a SPARQL endpoint — pays that per question, and none of it depends on the
|
|
246
|
+
// question.
|
|
247
|
+
//
|
|
248
|
+
// A handle holds the verified, decoded, indexed form inside the engine.
|
|
249
|
+
// `storeOpen` verifies every artifact against the SHA-256 the manifest
|
|
250
|
+
// commits, once; `storeHandleQuery` then answers from what it retained. The
|
|
251
|
+
// engine refuses a query whose plan needs a block the handle does not retain,
|
|
252
|
+
// so a handle answer is never a partial generation.
|
|
253
|
+
//
|
|
254
|
+
// What a handle does NOT change: a query still scans every retained row, so
|
|
255
|
+
// its cost is still proportional to the row count. A handle removes the
|
|
256
|
+
// per-query decode; it is not a text index.
|
|
257
|
+
//
|
|
258
|
+
// The stateless path above stays exactly as it was. A one-shot CLI query
|
|
259
|
+
// should not pay to build a handle it will drop.
|
|
260
|
+
|
|
261
|
+
/** The artifact keys a manifest declares, in manifest order. */
|
|
262
|
+
function manifestKeys (engine, store) {
|
|
263
|
+
return inspectManifest(engine, store).entries.map((entry) => entry.key)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Read the named artifacts and concatenate them into one region. */
|
|
267
|
+
function regionOf (store, keys) {
|
|
268
|
+
const chunks = keys.map((key) => readWhole(joinPath(store.generationDir, key)))
|
|
269
|
+
let total = 0
|
|
270
|
+
for (const chunk of chunks) total += chunk.length
|
|
271
|
+
const blob = new Uint8Array(total)
|
|
272
|
+
const artifacts = []
|
|
273
|
+
let offset = 0
|
|
274
|
+
for (let index = 0; index < chunks.length; index += 1) {
|
|
275
|
+
blob.set(chunks[index], offset)
|
|
276
|
+
artifacts.push({ key: keys[index], offset, len: chunks[index].length })
|
|
277
|
+
offset += chunks[index].length
|
|
278
|
+
}
|
|
279
|
+
return { blob, artifacts }
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* An open store held inside the engine. Hold this object for as long as the
|
|
284
|
+
* process answers questions about the generation, then `close()` it.
|
|
285
|
+
*
|
|
286
|
+
* The wasm module is single-threaded: two `query()` calls cannot overlap. A
|
|
287
|
+
* server awaits one before it starts the next.
|
|
288
|
+
*/
|
|
289
|
+
export class StoreHandle {
|
|
290
|
+
constructor (engine, store, envelope) {
|
|
291
|
+
this.engine = engine
|
|
292
|
+
this.store = store
|
|
293
|
+
this.handle = envelope.handle
|
|
294
|
+
this.identity = envelope.identity
|
|
295
|
+
this.layout = envelope.layout
|
|
296
|
+
this.wireVersion = envelope.wireVersion
|
|
297
|
+
this.artifacts = envelope.artifacts
|
|
298
|
+
this.bytes = envelope.bytes
|
|
299
|
+
this.rows = envelope.rows
|
|
300
|
+
this.closed = false
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* One SPARQL query against the retained blocks. The envelope is the one
|
|
305
|
+
* `queryStore` answers with — `kind`, `srj`/`boolean`/`nquads`, plus
|
|
306
|
+
* `shards` and `mode` — so a caller handles both paths alike.
|
|
307
|
+
*/
|
|
308
|
+
query (sparql) {
|
|
309
|
+
if (this.closed) {
|
|
310
|
+
throw new StoreOperationError(`store handle ${this.handle} is closed`)
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
return this.engine.call('storeHandleQuery', [this.handle, sparql])
|
|
314
|
+
} catch (error) {
|
|
315
|
+
throw asStoreError(error)
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Drop the handle and everything it retained. Idempotent on this object. */
|
|
320
|
+
close () {
|
|
321
|
+
if (this.closed) return
|
|
322
|
+
this.closed = true
|
|
323
|
+
try {
|
|
324
|
+
this.engine.call('storeHandleClose', [this.handle])
|
|
325
|
+
} catch (error) {
|
|
326
|
+
throw asStoreError(error)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* `storeOpen` — verify, decode and index a generation once.
|
|
333
|
+
*
|
|
334
|
+
* With no options every artifact the manifest declares is opened, so any
|
|
335
|
+
* query the generation can answer works. With `sparql` only the artifacts
|
|
336
|
+
* that query's plan names are opened, which is what a process asking one
|
|
337
|
+
* query shape wants. With `keys` exactly those artifacts are opened.
|
|
338
|
+
*
|
|
339
|
+
* @param {object} engine the loaded engine (bin/engine.mjs)
|
|
340
|
+
* @param {object} store the result of `openStore`
|
|
341
|
+
* @param {{keys?: string[], sparql?: string}} options
|
|
342
|
+
* @returns {StoreHandle}
|
|
343
|
+
*/
|
|
344
|
+
export function openStoreHandle (engine, store, options = {}) {
|
|
345
|
+
let keys
|
|
346
|
+
if (Array.isArray(options.keys)) {
|
|
347
|
+
keys = options.keys
|
|
348
|
+
} else if (typeof options.sparql === 'string') {
|
|
349
|
+
keys = planQuery(engine, store, options.sparql).keys
|
|
350
|
+
} else {
|
|
351
|
+
keys = manifestKeys(engine, store)
|
|
352
|
+
}
|
|
353
|
+
const { blob, artifacts } = regionOf(store, keys)
|
|
354
|
+
let envelope
|
|
355
|
+
try {
|
|
356
|
+
envelope = engine.callBlobIO('storeOpen',
|
|
357
|
+
[store.manifestHex, JSON.stringify(artifacts)], blob).envelope
|
|
358
|
+
} catch (error) {
|
|
359
|
+
throw asStoreError(error)
|
|
360
|
+
}
|
|
361
|
+
return new StoreHandle(engine, store, envelope)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** `storeHandleQuery` — the loose form, for a caller holding only the id. */
|
|
365
|
+
export function queryStoreHandle (engine, handle, sparql) {
|
|
366
|
+
const id = typeof handle === 'string' ? handle : handle.handle
|
|
367
|
+
try {
|
|
368
|
+
return engine.call('storeHandleQuery', [id, sparql])
|
|
369
|
+
} catch (error) {
|
|
370
|
+
throw asStoreError(error)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** `storeHandleClose` — the loose form. */
|
|
375
|
+
export function closeStoreHandle (engine, handle) {
|
|
376
|
+
if (handle instanceof StoreHandle) return handle.close()
|
|
377
|
+
try {
|
|
378
|
+
return engine.call('storeHandleClose', [handle])
|
|
379
|
+
} catch (error) {
|
|
380
|
+
throw asStoreError(error)
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* `storeHandleList` — what this process holds open, with the retained bytes
|
|
386
|
+
* and rows and the two residency caps. A server that cannot see its own
|
|
387
|
+
* residency cannot be operated.
|
|
388
|
+
*/
|
|
389
|
+
export function listStoreHandles (engine) {
|
|
390
|
+
try {
|
|
391
|
+
return engine.call('storeHandleList', [])
|
|
392
|
+
} catch (error) {
|
|
393
|
+
throw asStoreError(error)
|
|
394
|
+
}
|
|
395
|
+
}
|
package/l4-assets/l4factoidal.js
CHANGED
|
Binary file
|
package/l4-assets/version.json
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
"engine": "lean4",
|
|
3
3
|
"note": "The Lean 4 (L4Factoidal) engine's own build provenance and claims -- distinct from ../version.json, which is the F* engine's. Do not merge the two: they come from different toolchains, different git SHAs in general, and carry different proof obligations (issue #618).",
|
|
4
4
|
"version": "0.1.0",
|
|
5
|
-
"gitSha": "
|
|
6
|
-
"builtAt": "2026-09-04T20:
|
|
5
|
+
"gitSha": "6613042b8acf3be78541f862f3ad0caf057cbab3",
|
|
6
|
+
"builtAt": "2026-09-04T20:56:42+00:00",
|
|
7
7
|
"leanToolchain": "leanprover/lean4:v4.33.1",
|
|
8
8
|
"emscripten": "emcc (Emscripten gcc/clang-like replacement + linker emulating GNU ld) 6.0.8-git",
|
|
9
9
|
"abiVersion": "1",
|
|
10
|
-
"wasmSha256": "
|
|
11
|
-
"wasmBytes":
|
|
10
|
+
"wasmSha256": "ffbb3a0357051f78c2e9135ae629fc04a46037d540501f7fadd3d3e0e96c96b4",
|
|
11
|
+
"wasmBytes": 5412682,
|
|
12
12
|
"claims": {
|
|
13
13
|
"source": "formal/lean4 (L4Factoidal): no sorry, no user axioms, no native_decide; W3C behaviour pinned by build-time #guard",
|
|
14
14
|
"suitesAtBuildSha": "see docs/test-results and formal/lean4/PORT_NOTES.md at gitSha"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@factoidal/core",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Factoidal: a linked information system with graph data and the Web at its heart - RDF parsing, SPARQL 1.1 query, canonicalization and entailment for Node and the browser (JS + Wasm), grounded in F* and Lean 4. Parser and algebra spec verified in F*; on-disk backend has unverified OCaml-side optimization layers being migrated back to F*.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"sparql",
|
package/version.json
CHANGED