@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 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
+ }
@@ -37,7 +37,7 @@
37
37
  // bytes change.
38
38
 
39
39
  // Stamped by formal/lean4/Wasm/build-wasm.sh step 9 -- do not hand-edit.
40
- const WASM_VERSION = "20551e4329bb";
40
+ const WASM_VERSION = "ffbb3a035705";
41
41
 
42
42
  import createModule from './l4factoidal.mjs';
43
43
 
Binary file
@@ -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": "4295b2917e700fb3ead2d74fad05c9d2f592b7d9",
6
- "builtAt": "2026-09-04T20:28:04+00:00",
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": "20551e4329bb3fa363e4c64b9f9d62cc1371a2c563161d938226c0d2e102c87a",
11
- "wasmBytes": 5384150,
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.0",
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
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.5.0",
2
+ "version": "0.5.1",
3
3
  "gitSha": "49f8ca4d70bf57c12fea445611e2b01b067bf6ba",
4
4
  "builtAt": "2026-08-26T21:45:40Z",
5
5
  "claims": {