@factoidal/core 0.5.1 → 0.6.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/CHANGELOG.md +132 -0
- package/README.md +180 -16
- package/bin/ext.mjs +198 -0
- package/bin/factoidal.mjs +62 -4
- package/bin/pack-host.mjs +19 -1
- package/bin/store-worker-host.mjs +397 -0
- package/bin/store-worker.mjs +100 -0
- package/bin/store.mjs +29 -9
- package/l4-assets/l4factoidal.js +1 -1
- package/l4-assets/l4factoidal.mjs +1 -1
- package/l4-assets/l4factoidal.wasm +0 -0
- package/l4-assets/version.json +4 -4
- package/package.json +4 -1
- package/version.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,137 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0 — 2026-09-05
|
|
4
|
+
|
|
5
|
+
**A full-text index, a geometry index, extension functions from
|
|
6
|
+
JavaScript, and a planner that no longer reads the whole store for a
|
|
7
|
+
`REGEX`.** Measured end to end on the largest corpus we have: 7,315,251
|
|
8
|
+
quads over 204 named graphs, 3,286 blocks, a 1.0 GB generation, queried
|
|
9
|
+
through a store handle on plain `node` with no flags.
|
|
10
|
+
|
|
11
|
+
| search | rows | time |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| `water` | 5 | 645 ms |
|
|
14
|
+
| `glacier` | **0** | 670 ms |
|
|
15
|
+
| `bicycle` | 2 | 617 ms |
|
|
16
|
+
|
|
17
|
+
A miss costs what a hit costs, because both are index lookups.
|
|
18
|
+
|
|
19
|
+
### The planner stopped reading the whole store
|
|
20
|
+
|
|
21
|
+
`REGEX`, `REPLACE`, `IRI()`, `NOW()`, the digest functions, aggregates in
|
|
22
|
+
a filter position, the triple-term accessors and every SPARQL 1.1
|
|
23
|
+
section 17.6 extension call each made the planner abandon predicate
|
|
24
|
+
selection and take every block in the manifest. On a 119-block store a
|
|
25
|
+
`REGEX` filter selected 119 artifacts where the same query with
|
|
26
|
+
`CONTAINS` selected 1.
|
|
27
|
+
|
|
28
|
+
The test was `Expr.backendLocal`, which answers a different question: may
|
|
29
|
+
the backend evaluate this expression, or must it materialise and
|
|
30
|
+
delegate. The right test is `Expr.existsFree` — `Expr.existsPat` and
|
|
31
|
+
`Expr.notExistsPat` are the only constructors carrying a pattern, so an
|
|
32
|
+
exists-free expression reads no triple whatever functions it calls. A
|
|
33
|
+
FILTER may narrow a plan or leave it alone; it must never widen it.
|
|
34
|
+
`EXISTS` and `NOT EXISTS` still widen, correctly.
|
|
35
|
+
|
|
36
|
+
### LGI1, a character-gram literal index
|
|
37
|
+
|
|
38
|
+
The packer writes a `.lgi1` sidecar beside each block and the planner
|
|
39
|
+
uses it where the query shape allows. It holds character 3-grams of the
|
|
40
|
+
case-folded lexical form and is a CANDIDATE FILTER, never a decider: it
|
|
41
|
+
answers a superset and the engine re-evaluates the original `FILTER`, so
|
|
42
|
+
the rows are the scan's rows. Tokens would not do, because `CONTAINS` is
|
|
43
|
+
a substring test and "underwater" contains "water" without being the
|
|
44
|
+
token "water".
|
|
45
|
+
|
|
46
|
+
Falls back to a scan, silently and correctly: a needle under 3
|
|
47
|
+
characters, a variable needle, `REGEX`, `UCASE`, `!CONTAINS`, `CONTAINS`
|
|
48
|
+
under `||`, a filter on a variable not bound in object position, and any
|
|
49
|
+
generation without the sidecar. Costs about 55% of the block bytes.
|
|
50
|
+
|
|
51
|
+
### GBI1, a geometry bounding-box index
|
|
52
|
+
|
|
53
|
+
The same construction for `geof:`. Five of the six topological functions
|
|
54
|
+
are filtered by a box — `sfIntersects`, `sfWithin`, `sfContains`,
|
|
55
|
+
`sfTouches`, `sfEquals` — at 74 to 94 times a scan, with a miss at 82
|
|
56
|
+
times. **`sfDisjoint` is refused** and falls back: it accepts exactly the
|
|
57
|
+
rows a box can exclude, so a box test inverts and would drop answers.
|
|
58
|
+
|
|
59
|
+
### Extension functions, from JavaScript, on the Lean engine
|
|
60
|
+
|
|
61
|
+
`registerExtensionFunction` reaches the Lean engine and the persisted
|
|
62
|
+
store, not only the in-memory F* path. Section 17.6 semantics are gated:
|
|
63
|
+
an unregistered IRI is unbound in SELECT and drops the row in FILTER, a
|
|
64
|
+
registration never overrides a built-in family, and `geof:` still
|
|
65
|
+
answers from the built-in table. Synchronous only; async is designed and
|
|
66
|
+
deferred.
|
|
67
|
+
|
|
68
|
+
### A handle is bounded by retained bytes
|
|
69
|
+
|
|
70
|
+
`storeOpen` was capped at 64 artifacts, inherited from the stateless path
|
|
71
|
+
where every query re-read and re-decoded its blocks. A handle pays that
|
|
72
|
+
once, so a count was the wrong shape — 257 small blocks may cost less
|
|
73
|
+
than 4 large ones. The cap is now 134,217,728 retained bytes, derived
|
|
74
|
+
from a measured 16.2 bytes resident per retained byte against half the
|
|
75
|
+
wasm32 address space, and the check runs BEFORE the read rather than
|
|
76
|
+
after hashing and decoding the whole set. A corpus-wide handle over 257
|
|
77
|
+
blocks and 741,179 rows now opens.
|
|
78
|
+
|
|
79
|
+
### Named graphs pack at scale
|
|
80
|
+
|
|
81
|
+
Blocks cut at graph boundaries, so `GRAPH <iri>` with a constant
|
|
82
|
+
predicate reads one block and the graph filter is exact rather than
|
|
83
|
+
conservative. Packer peak memory fell 32.6% and dictionary duplication
|
|
84
|
+
is 2.57%. No new wire version was needed: SBM7 already admitted several
|
|
85
|
+
entries per predicate.
|
|
86
|
+
|
|
87
|
+
**A large store no longer needs `node --stack-size`.** Several engine
|
|
88
|
+
paths recurse once per manifest entry and once per row, and on a
|
|
89
|
+
7,315,251-quad collection of 3,286 blocks `storeQueryPlan` alone
|
|
90
|
+
overflowed the default call stack of Node, before an artifact byte was
|
|
91
|
+
read ([issue 653](https://github.com/danbri/factoidal/issues/653)).
|
|
92
|
+
|
|
93
|
+
- New `@factoidal/core/store-worker`: `openStoreHandleOnWorker` holds the
|
|
94
|
+
engine and the handle on a `worker_threads` thread with a raised stack.
|
|
95
|
+
A handle is state inside the wasm instance and an instance does not
|
|
96
|
+
cross a thread boundary, so the handle lives there and `query()` and
|
|
97
|
+
`close()` are messages to it. One thread per session, many handles per
|
|
98
|
+
thread. Every call is asynchronous; the synchronous `openStoreHandle`
|
|
99
|
+
is unchanged.
|
|
100
|
+
- `factoidal query` still runs IN PROCESS, so a one-shot query pays
|
|
101
|
+
nothing for a worker it would drop. It runs again on a worker only when
|
|
102
|
+
the runtime runs out of frames — under Deno by re-executing itself with
|
|
103
|
+
a raised V8 stack, which needs `--allow-run` and `--allow-env`.
|
|
104
|
+
`--no-worker` turns the retry off.
|
|
105
|
+
- Measured: the worker costs about 110 ms once (thread start plus a
|
|
106
|
+
second engine load) and under a millisecond per query after that.
|
|
107
|
+
Deno's `worker_threads` shim does not raise the stack (10,835 frames
|
|
108
|
+
by default, 13,837 at `stackSizeMb` 64, against Node's 41,195 and
|
|
109
|
+
696,555), so a Deno library caller gets an in-process handle behind the
|
|
110
|
+
same interface and starts its process with
|
|
111
|
+
`--v8-flags=--stack-size=65536`.
|
|
112
|
+
|
|
113
|
+
### Fixed
|
|
114
|
+
|
|
115
|
+
- `openStoreHandle` with no options failed on any IBK3 generation with
|
|
116
|
+
`artifact 'predicate-0.ibk3.sri2' is not declared by this manifest`.
|
|
117
|
+
Two engine operations disagreed: `storeManifestInspect` reports
|
|
118
|
+
`subjectIndex`, `termIndex` and `objectIndex`, while the handle's
|
|
119
|
+
admission accepted only the block, `literalIndex` and `geoIndex`. One
|
|
120
|
+
list now serves both.
|
|
121
|
+
- The README named GeoSPARQL functions that do not exist and said a text
|
|
122
|
+
index was "separate work". Both corrected, with the shapes that fall
|
|
123
|
+
back to a scan listed.
|
|
124
|
+
|
|
125
|
+
### Known limits, measured
|
|
126
|
+
|
|
127
|
+
- Ingest is about 2,180 quads per second and peak memory is 2.07 times
|
|
128
|
+
the source, so a 142 GB corpus is not importable at that ratio. Making
|
|
129
|
+
memory flat is a wire change and is not done.
|
|
130
|
+
- The quad packer streams N-Quads only; Turtle and TriG are buffered.
|
|
131
|
+
- `ORDER BY ... LIMIT n` still overflows above about 14,576 materialised
|
|
132
|
+
rows. https://github.com/danbri/factoidal/issues/653
|
|
133
|
+
- The manifest is at version 9. Older generations still read.
|
|
134
|
+
|
|
3
135
|
## 0.5.1 — 2026-09-04
|
|
4
136
|
|
|
5
137
|
**A store can be opened once and queried many times.** `storeQuery` is
|
package/README.md
CHANGED
|
@@ -245,6 +245,45 @@ error. Async functions run over the synchronous verified engine
|
|
|
245
245
|
through a bounded, memoised re-evaluation loop — within one query
|
|
246
246
|
every call with the same arguments sees one stable answer.
|
|
247
247
|
|
|
248
|
+
### The same functions against the Lean engine and a persisted store
|
|
249
|
+
|
|
250
|
+
The registration above serves the F\* engine's in-memory `query()`. The
|
|
251
|
+
Lean engine has its own registry, and it reaches every Lean query path,
|
|
252
|
+
including a store handle — so a registered function can filter rows read
|
|
253
|
+
off disk:
|
|
254
|
+
|
|
255
|
+
```js
|
|
256
|
+
import { loadEngine } from '@factoidal/core/bin/engine.mjs'
|
|
257
|
+
import { openStore, openStoreHandle } from '@factoidal/core/bin/store.mjs'
|
|
258
|
+
import { registerExtensionFunction, withExtensionFunctions }
|
|
259
|
+
from '@factoidal/core/bin/ext.mjs'
|
|
260
|
+
|
|
261
|
+
const engine = await loadEngine()
|
|
262
|
+
const handle = openStoreHandle(engine, openStore('/path/to/store'))
|
|
263
|
+
|
|
264
|
+
registerExtensionFunction(engine, 'http://example.org/fn/endsWithZed',
|
|
265
|
+
([label]) => label.value.endsWith('z'))
|
|
266
|
+
|
|
267
|
+
const answer = handle.query(`
|
|
268
|
+
PREFIX ex: <http://example.org/fn/>
|
|
269
|
+
PREFIX skos: <http://www.w3.org/2004/02/skos/core#>
|
|
270
|
+
SELECT ?c ?l WHERE { ?c skos:prefLabel ?l FILTER(ex:endsWithZed(?l)) }`)
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
The argument and result encoding, the §17.6 error rules and the
|
|
274
|
+
async re-evaluation loop are the same as above, so one function serves
|
|
275
|
+
both engines. Two differences to plan around:
|
|
276
|
+
|
|
277
|
+
- **Registration is per engine instance**, not per handle or per query.
|
|
278
|
+
A server that answers for more than one caller uses
|
|
279
|
+
`withExtensionFunctions(engine, map, body)`, which registers, runs and
|
|
280
|
+
clears in a `finally`, or loads one engine per caller.
|
|
281
|
+
- **Every call crosses into JavaScript**, so a function in a FILTER runs
|
|
282
|
+
once per row. Measure before putting one on a large scan.
|
|
283
|
+
|
|
284
|
+
Design, with the determinism and scope rules in full:
|
|
285
|
+
[`docs/designissues/2026-09-04-lean-extension-functions.md`](../../docs/designissues/2026-09-04-lean-extension-functions.md).
|
|
286
|
+
|
|
248
287
|
## Functional API (fn)
|
|
249
288
|
|
|
250
289
|
`@factoidal/core/fn` is a strictly functional variant of the API above:
|
|
@@ -513,7 +552,7 @@ const docs = openStoreHandle(engine, openStore('./docs-store'))
|
|
|
513
552
|
|
|
514
553
|
console.log(listStoreHandles(engine))
|
|
515
554
|
// { ok: true, handles: [ {handle:'s1', bytes:…, rows:…}, {handle:'s2', …} ],
|
|
516
|
-
// bytes: …, rows: …, handleCap: 8, bytesCap:
|
|
555
|
+
// bytes: …, rows: …, handleCap: 8, bytesCap: 134217728 }
|
|
517
556
|
|
|
518
557
|
const PREFIX = 'PREFIX skos: <http://www.w3.org/2004/02/skos/core#>'
|
|
519
558
|
|
|
@@ -559,23 +598,112 @@ caller that holds only the handle id.
|
|
|
559
598
|
| tenth query, all different | 1382 ms | 103 ms |
|
|
560
599
|
| ten queries, total | 13962 ms | 2277 ms |
|
|
561
600
|
|
|
562
|
-
**What a handle buys
|
|
563
|
-
|
|
564
|
-
sub-linear
|
|
565
|
-
|
|
601
|
+
**What a handle buys.** It removes the per-query digest check, block
|
|
602
|
+
decode and index build. With the LGI1 literal index (below) a search is
|
|
603
|
+
also sub-linear rather than a scan, so a handle plus the index is what
|
|
604
|
+
makes repeated search on a large store interactive.
|
|
566
605
|
|
|
567
606
|
**Residency.** Retaining a decoded block costs memory. Measured on the
|
|
568
607
|
same store: 76 MiB resident with the engine loaded and no handle, 170 MiB
|
|
569
608
|
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).
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
609
|
+
evaluation peaks higher again (346 MiB during the queries above). At
|
|
610
|
+
corpus scale the marginal figure is smaller, because that one is carrying
|
|
611
|
+
the fixed cost of the process: measured 2026-09-05 on a 7,315,251-quad
|
|
612
|
+
store, one handle over 257 blocks and 103,341,302 retained artifact bytes
|
|
613
|
+
peaked at 1,675,345,920 bytes resident — 16.2 bytes resident per retained
|
|
614
|
+
byte.
|
|
615
|
+
|
|
616
|
+
The caps are on ARTIFACT bytes, which is what the manifest declares: 8
|
|
617
|
+
open handles, and 134217728 (128 MiB) retained artifact bytes across all
|
|
618
|
+
of them. There is NO cap on the number of artifacts a handle retains; a
|
|
619
|
+
count bounds nothing that the bytes do not
|
|
620
|
+
(https://github.com/danbri/factoidal/issues/657). 128 MiB is half the
|
|
621
|
+
wasm32 address space divided by that measured multiplier, and it admits
|
|
622
|
+
the 257-block corpus-wide set above. A cap is a refusal naming the cap
|
|
623
|
+
and the value that tripped it; no handle is ever evicted to make room for
|
|
574
624
|
another. `listStoreHandles` is how a server sees its own residency.
|
|
575
625
|
|
|
576
626
|
**One call at a time.** The WebAssembly module is single-threaded. Two
|
|
577
627
|
`query()` calls cannot overlap; a server queues them.
|
|
578
628
|
|
|
629
|
+
### A handle on a large store: the worker route
|
|
630
|
+
|
|
631
|
+
Several engine paths recurse once per manifest entry and once per row.
|
|
632
|
+
Against a large collection that exceeds the default call stack of Node
|
|
633
|
+
and of Deno, and the failure is `Maximum call stack size exceeded`.
|
|
634
|
+
**Measured 2026-09-05**, macOS arm64, Node 22.22.2, on a 7,315,251-quad
|
|
635
|
+
collection of 3,286 blocks in 204 graphs: `storeQueryPlan` alone
|
|
636
|
+
overflows on plain `node`, before one artifact byte is read.
|
|
637
|
+
`node --stack-size=60000` clears the plan, the open and every query.
|
|
638
|
+
|
|
639
|
+
`openStoreHandleOnWorker` removes the flag. It holds the engine and the
|
|
640
|
+
handle on a `worker_threads` thread with `resourceLimits.stackSizeMb`,
|
|
641
|
+
the route `factoidal pack` already takes
|
|
642
|
+
([issue 649](https://github.com/danbri/factoidal/issues/649)). A handle
|
|
643
|
+
is state inside the wasm instance and an instance does not cross a
|
|
644
|
+
thread boundary, so the handle lives where the raised stack is, and
|
|
645
|
+
`query()` and `close()` are messages to it.
|
|
646
|
+
|
|
647
|
+
```js
|
|
648
|
+
import { openStoreHandleOnWorker, closeSharedStoreWorkerSession }
|
|
649
|
+
from '@factoidal/core/store-worker'
|
|
650
|
+
|
|
651
|
+
// No runtime flag. One worker thread, shared by every handle opened
|
|
652
|
+
// this way, so a caller that opens several stores pays for one thread
|
|
653
|
+
// and one copy of the module.
|
|
654
|
+
const handle = await openStoreHandleOnWorker('/path/to/store', {
|
|
655
|
+
sparql: 'SELECT ?c ?l WHERE { GRAPH <urn:g> { ?c ?p ?l } }'
|
|
656
|
+
})
|
|
657
|
+
const answer = await handle.query(`${PREFIX}
|
|
658
|
+
SELECT ?c ?l WHERE { GRAPH <urn:g> { ?c skos:prefLabel ?l }
|
|
659
|
+
FILTER(CONTAINS(LCASE(STR(?l)), "volcan")) } LIMIT 8`)
|
|
660
|
+
await handle.close()
|
|
661
|
+
await closeSharedStoreWorkerSession()
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
| option | effect |
|
|
665
|
+
|---|---|
|
|
666
|
+
| `{sparql}`, `{keys}` | the same artifact choice `openStoreHandle` takes |
|
|
667
|
+
| `{generation}` | open a generation that has not been activated |
|
|
668
|
+
| `{session}` | open into a session you started with `openStoreWorkerSession()` |
|
|
669
|
+
| `{ownWorker: true}` | give this handle its own thread and its own copy of the module |
|
|
670
|
+
| `{worker: false}` | open in this process, with no thread |
|
|
671
|
+
|
|
672
|
+
What it costs, all **measured 2026-09-05** on the same machine, against
|
|
673
|
+
the bundled sample store so the overhead is not lost in the query:
|
|
674
|
+
|
|
675
|
+
| | in process | on a worker |
|
|
676
|
+
|---|---|---|
|
|
677
|
+
| one-shot query (thread start + engine load + query) | 163 ms | 220 ms |
|
|
678
|
+
| handle open (thread start + engine load + open) | 124 ms | 236 ms |
|
|
679
|
+
| every query after the open | 1 ms | 1 ms |
|
|
680
|
+
|
|
681
|
+
So the thread and its second copy of the engine cost about 110 ms once,
|
|
682
|
+
and the message round trip is under a millisecond. Three further costs:
|
|
683
|
+
every call is asynchronous where the in-process handle is synchronous;
|
|
684
|
+
an extension function registered on the main thread's engine
|
|
685
|
+
(`bin/ext.mjs`) is not visible to the worker's engine; and the worker
|
|
686
|
+
keeps the process alive until `close()`.
|
|
687
|
+
|
|
688
|
+
**Deno takes a different route.** Deno's `node:worker_threads` shim
|
|
689
|
+
accepts `resourceLimits.stackSizeMb` and raises almost nothing with it —
|
|
690
|
+
measured by counting frames to the overflow inside the worker, Node
|
|
691
|
+
reaches 41,195 frames by default and 696,555 at `stackSizeMb` 64, where
|
|
692
|
+
Deno reaches 10,835 and 13,837. `openStoreHandleOnWorker` therefore
|
|
693
|
+
gives a Deno caller an in-process handle behind the same asynchronous
|
|
694
|
+
interface, and the process supplies the stack:
|
|
695
|
+
|
|
696
|
+
```
|
|
697
|
+
deno run --allow-read --v8-flags=--stack-size=65536 your-program.mjs
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
**A one-shot `factoidal query` pays none of this.** It builds no handle,
|
|
701
|
+
so it runs in process and only retries on a worker if the runtime runs
|
|
702
|
+
out of frames; under Deno it re-executes itself once with a raised V8
|
|
703
|
+
stack, which needs `--allow-run` and `--allow-env`. `--no-worker` turns
|
|
704
|
+
the retry off. See
|
|
705
|
+
[issue 653](https://github.com/danbri/factoidal/issues/653).
|
|
706
|
+
|
|
579
707
|
## API (draft)
|
|
580
708
|
|
|
581
709
|
The `factoidal` CLI (`bin/factoidal-cli/factoidal_cli.ml`, built to
|
|
@@ -768,15 +896,51 @@ handling beyond what the WKT literal carries; no GML literals. Geometry
|
|
|
768
896
|
comes from a WKT parser, so a shapefile, GeoJSON or GML source must be
|
|
769
897
|
converted to `geo:wktLiteral` before it is loaded.
|
|
770
898
|
|
|
771
|
-
### Full text: SPARQL's own functions,
|
|
899
|
+
### Full text: SPARQL's own functions, over a character-gram index
|
|
772
900
|
|
|
773
901
|
`CONTAINS`, `STRSTARTS`, `STRENDS` and `REGEX` (SPARQL 1.1 §17.4.3) are
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
902
|
+
the way to search text. There is no `text:query`-style extension and you
|
|
903
|
+
do not ask for the index: where the query shape allows it, the planner
|
|
904
|
+
uses the **LGI1** literal index the packer writes beside each block.
|
|
905
|
+
|
|
906
|
+
LGI1 holds character 3-grams of the case-folded lexical form, and it is a
|
|
907
|
+
CANDIDATE FILTER rather than a decider — it answers a superset and the
|
|
908
|
+
engine re-evaluates your original `FILTER` on those rows, so the answer
|
|
909
|
+
is exactly the answer a scan gives. Tokens would not do: `CONTAINS` is a
|
|
910
|
+
substring test, and "underwater" contains "water" without being the
|
|
911
|
+
token "water".
|
|
912
|
+
|
|
913
|
+
Measured 2026-09-05, full skosdex corpus (7,315,251 quads, 3,286 blocks,
|
|
914
|
+
204 named graphs, 1.0 GB), through a store handle on plain `node`:
|
|
915
|
+
|
|
916
|
+
| search | rows | time |
|
|
917
|
+
|---|---|---|
|
|
918
|
+
| `water` | 5 | 645 ms |
|
|
919
|
+
| `glacier` | 0 | 670 ms |
|
|
920
|
+
| `bicycle` | 2 | 617 ms |
|
|
921
|
+
|
|
922
|
+
A miss costs what a hit costs, because both are index lookups rather than
|
|
923
|
+
scans. On a 45,806-row block the same search was about 180 ms of scan
|
|
924
|
+
before the index and 1.8 ms after it.
|
|
925
|
+
|
|
926
|
+
**It falls back to a scan**, silently and correctly, for: a needle under
|
|
927
|
+
3 characters, a variable needle, `REGEX`, `UCASE`, `!CONTAINS`,
|
|
928
|
+
`CONTAINS` under `||`, a filter on a variable not bound in object
|
|
929
|
+
position, and any block whose generation has no `.lgi1` sidecar. The
|
|
930
|
+
index costs about 55% of the block bytes.
|
|
931
|
+
|
|
932
|
+
### Geometry: the GBI1 bounding-box index
|
|
933
|
+
|
|
934
|
+
The same construction for `geof:`. Each `geo:wktLiteral` object's
|
|
935
|
+
bounding box is indexed, and five of the six topological functions are
|
|
936
|
+
filtered by it — `sfIntersects`, `sfWithin`, `sfContains`, `sfTouches`,
|
|
937
|
+
`sfEquals`. Measured 74x to 94x against a scan, with a miss at 82x.
|
|
938
|
+
|
|
939
|
+
**`sfDisjoint` is refused and falls back to a scan**, deliberately: it
|
|
940
|
+
accepts exactly the rows a box can exclude, so a box test inverts and
|
|
941
|
+
would drop answers. A non-overlapping pair of boxes proves geometries
|
|
942
|
+
disjoint, but that saves work inside a scan rather than reducing the
|
|
943
|
+
candidate set.
|
|
780
944
|
|
|
781
945
|
## Limits (deliberate, documented)
|
|
782
946
|
|
package/bin/ext.mjs
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Caller-registered SPARQL 1.1 §17.6 extension functions for the Lean
|
|
2
|
+
// engine. https://github.com/danbri/factoidal/issues/463 (the F* side),
|
|
3
|
+
// docs/designissues/2026-09-04-lean-extension-functions.md (this side).
|
|
4
|
+
//
|
|
5
|
+
// WHAT THIS FILE IS ALLOWED TO DO
|
|
6
|
+
// Hold the caller's functions, carry values across the boundary, and
|
|
7
|
+
// memoise. It makes NO semantic decision: which IRIs may reach a
|
|
8
|
+
// registered function, what an absent answer means, and how a §17.6
|
|
9
|
+
// error behaves are all decided in Lean (Wasm/Ops/ExtFns.lean and the
|
|
10
|
+
// evaluator). A JavaScript answer this file cannot encode becomes the
|
|
11
|
+
// empty string, which Lean reads as the §17.6 error — the same outcome
|
|
12
|
+
// as a function that is not registered at all.
|
|
13
|
+
//
|
|
14
|
+
// THE BRIDGE
|
|
15
|
+
// One function, globalThis.__factoidalExtCall(iri, argsJson), which the
|
|
16
|
+
// EM_JS body in formal/lean4/ffi/l4_ext.c calls SYNCHRONOUSLY from
|
|
17
|
+
// inside the evaluator. `argsJson` is a JSON array of SPARQL Query
|
|
18
|
+
// Results JSON binding-value objects; the answer is one such object as
|
|
19
|
+
// a JSON string, or the empty string for "no value".
|
|
20
|
+
//
|
|
21
|
+
// ASYNC
|
|
22
|
+
// The evaluator is synchronous and inside WebAssembly, so it cannot wait
|
|
23
|
+
// for a promise. The F* engine's answer is reused verbatim
|
|
24
|
+
// (npm/factoidal/browser.js, withExtensionRounds): the bridge starts the
|
|
25
|
+
// promise, records it, and answers "no value" for this round; the host
|
|
26
|
+
// awaits every pending promise, writes the answers into the memo table,
|
|
27
|
+
// and runs the WHOLE query again, until a round adds no new pending
|
|
28
|
+
// call.
|
|
29
|
+
//
|
|
30
|
+
// DETERMINISM
|
|
31
|
+
// The memo table is keyed on iri + ' ' + argsJson and lives for one
|
|
32
|
+
// top-level query, so the same call answers the same way however many
|
|
33
|
+
// times the physical plan evaluates the expression, and across
|
|
34
|
+
// re-evaluation rounds.
|
|
35
|
+
|
|
36
|
+
const XSD = 'http://www.w3.org/2001/XMLSchema#'
|
|
37
|
+
|
|
38
|
+
/** How many evaluation rounds an async resolution may take. */
|
|
39
|
+
export const EXT_MAX_ROUNDS = 25
|
|
40
|
+
|
|
41
|
+
const extFunctions = new Map()
|
|
42
|
+
const extRegistered = new Set()
|
|
43
|
+
let extCache = new Map()
|
|
44
|
+
let extPending = []
|
|
45
|
+
let bridgeInstalled = false
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Encode one JavaScript answer as an SRJ binding-value object, as a JSON
|
|
49
|
+
* string. `''` means "no value" — the §17.6 error.
|
|
50
|
+
*/
|
|
51
|
+
function encodeAnswer (out) {
|
|
52
|
+
if (out === null || out === undefined) return ''
|
|
53
|
+
if (typeof out === 'boolean') {
|
|
54
|
+
return JSON.stringify({ type: 'literal', value: out ? 'true' : 'false', datatype: XSD + 'boolean' })
|
|
55
|
+
}
|
|
56
|
+
if (typeof out === 'number') {
|
|
57
|
+
if (!Number.isFinite(out)) return ''
|
|
58
|
+
return Number.isInteger(out)
|
|
59
|
+
? JSON.stringify({ type: 'literal', value: String(out), datatype: XSD + 'integer' })
|
|
60
|
+
: JSON.stringify({ type: 'literal', value: String(out), datatype: XSD + 'double' })
|
|
61
|
+
}
|
|
62
|
+
if (typeof out === 'string') {
|
|
63
|
+
return JSON.stringify({ type: 'literal', value: out })
|
|
64
|
+
}
|
|
65
|
+
if (typeof out === 'object' && typeof out.type === 'string') {
|
|
66
|
+
// Already an SRJ binding-value object (or an RDF/JS-shaped term the
|
|
67
|
+
// caller built): pass it through and let the Lean decoder judge it.
|
|
68
|
+
return JSON.stringify(out)
|
|
69
|
+
}
|
|
70
|
+
return ''
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The synchronous bridge the wasm module calls. */
|
|
74
|
+
function extBridge (iri, argsJson) {
|
|
75
|
+
const key = iri + ' ' + argsJson
|
|
76
|
+
if (extCache.has(key)) return extCache.get(key)
|
|
77
|
+
const fn = extFunctions.get(iri)
|
|
78
|
+
if (typeof fn !== 'function') return ''
|
|
79
|
+
let out
|
|
80
|
+
try {
|
|
81
|
+
out = fn(JSON.parse(argsJson))
|
|
82
|
+
} catch (_error) {
|
|
83
|
+
extCache.set(key, '')
|
|
84
|
+
return ''
|
|
85
|
+
}
|
|
86
|
+
if (out !== null && typeof out === 'object' && typeof out.then === 'function') {
|
|
87
|
+
extPending.push({ key, promise: out })
|
|
88
|
+
return ''
|
|
89
|
+
}
|
|
90
|
+
const encoded = encodeAnswer(out)
|
|
91
|
+
extCache.set(key, encoded)
|
|
92
|
+
return encoded
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function installBridge () {
|
|
96
|
+
if (bridgeInstalled) return
|
|
97
|
+
globalThis.__factoidalExtCall = extBridge
|
|
98
|
+
bridgeInstalled = true
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Register one function under an absolute IRI.
|
|
103
|
+
*
|
|
104
|
+
* `fn` receives the evaluated arguments as an array of SRJ
|
|
105
|
+
* binding-value objects and returns a term object, a JavaScript
|
|
106
|
+
* string/number/boolean, `null`/`undefined` (the §17.6 error), or a
|
|
107
|
+
* promise of any of those.
|
|
108
|
+
*
|
|
109
|
+
* Scope: per loaded engine (one wasm module instance). A server that
|
|
110
|
+
* serves more than one caller uses `withExtensionFunctions` instead, or
|
|
111
|
+
* loads one engine per caller.
|
|
112
|
+
*/
|
|
113
|
+
export function registerExtensionFunction (engine, iri, fn) {
|
|
114
|
+
if (typeof iri !== 'string' || !/^[A-Za-z][A-Za-z0-9+.-]*:/.test(iri)) {
|
|
115
|
+
throw new TypeError('registerExtensionFunction: iri must be an absolute IRI string')
|
|
116
|
+
}
|
|
117
|
+
if (typeof fn !== 'function') {
|
|
118
|
+
throw new TypeError('registerExtensionFunction: fn must be a function')
|
|
119
|
+
}
|
|
120
|
+
installBridge()
|
|
121
|
+
extFunctions.set(iri, fn)
|
|
122
|
+
engine.call('extRegister', [iri])
|
|
123
|
+
extRegistered.add(iri)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Remove one registered function. */
|
|
127
|
+
export function unregisterExtensionFunction (engine, iri) {
|
|
128
|
+
extFunctions.delete(iri)
|
|
129
|
+
extRegistered.delete(iri)
|
|
130
|
+
engine.call('extUnregister', [iri])
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Return the engine to the built-in table (`geof:` and the built-ins). */
|
|
134
|
+
export function clearExtensionFunctions (engine) {
|
|
135
|
+
extFunctions.clear()
|
|
136
|
+
extRegistered.clear()
|
|
137
|
+
engine.call('extClear', [])
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The IRIs the engine will ask the host about. */
|
|
141
|
+
export function listExtensionFunctions (engine) {
|
|
142
|
+
return engine.call('extList', []).iris
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Run `body` with a fresh per-query memo table, resolving async answers
|
|
147
|
+
* by re-evaluation. `body` is called once per round and must be the
|
|
148
|
+
* WHOLE query, because a later round re-evaluates it with more answers
|
|
149
|
+
* known.
|
|
150
|
+
*/
|
|
151
|
+
export async function withExtensionRounds (body) {
|
|
152
|
+
extCache = new Map()
|
|
153
|
+
for (let round = 0; ; round += 1) {
|
|
154
|
+
extPending = []
|
|
155
|
+
const result = body()
|
|
156
|
+
if (extPending.length === 0) return result
|
|
157
|
+
if (round >= EXT_MAX_ROUNDS) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
'extension functions: async resolution did not converge within ' +
|
|
160
|
+
`${EXT_MAX_ROUNDS} evaluation rounds`)
|
|
161
|
+
}
|
|
162
|
+
const pending = extPending
|
|
163
|
+
extPending = []
|
|
164
|
+
await Promise.all(pending.map(async ({ key, promise }) => {
|
|
165
|
+
try {
|
|
166
|
+
extCache.set(key, encodeAnswer(await promise))
|
|
167
|
+
} catch (_error) {
|
|
168
|
+
extCache.set(key, '')
|
|
169
|
+
}
|
|
170
|
+
}))
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Run one query with a fresh memo table. Use this for a synchronous
|
|
176
|
+
* function set; use `withExtensionRounds` when any function is async.
|
|
177
|
+
*/
|
|
178
|
+
export function withFreshMemo (body) {
|
|
179
|
+
extCache = new Map()
|
|
180
|
+
extPending = []
|
|
181
|
+
return body()
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Register `map` (IRI -> function), run `body`, and clear in a
|
|
186
|
+
* `finally`. This is the scope a long-lived server wants: one caller's
|
|
187
|
+
* registrations are never visible to the next.
|
|
188
|
+
*/
|
|
189
|
+
export async function withExtensionFunctions (engine, map, body) {
|
|
190
|
+
for (const [iri, fn] of Object.entries(map)) {
|
|
191
|
+
registerExtensionFunction(engine, iri, fn)
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
return await withExtensionRounds(body)
|
|
195
|
+
} finally {
|
|
196
|
+
clearExtensionFunctions(engine)
|
|
197
|
+
}
|
|
198
|
+
}
|
package/bin/factoidal.mjs
CHANGED
|
@@ -28,6 +28,9 @@ import { loadEngine } from './engine.mjs'
|
|
|
28
28
|
import { sampleStoreFacts, sampleStorePath } from '../sample-store.mjs'
|
|
29
29
|
import { PackError, packSupported, verifyGeneration } from './pack.mjs'
|
|
30
30
|
import { denoReexec, isStackOverflow, runPack } from './pack-host.mjs'
|
|
31
|
+
import {
|
|
32
|
+
planQueryOnWorker, queryStoreOnWorker, workerRouteAvailable
|
|
33
|
+
} from './store-worker-host.mjs'
|
|
31
34
|
import {
|
|
32
35
|
STACK_REMEDY, StoreOperationError, inspectManifest, openStore, planQuery,
|
|
33
36
|
queryStore, stackLimitAdvice, turtleOfNQuads
|
|
@@ -179,6 +182,21 @@ options:
|
|
|
179
182
|
--generation NAME query this generation instead of the activated one
|
|
180
183
|
--json shorthand for --format json
|
|
181
184
|
--quiet print the result only, no plan line on stderr
|
|
185
|
+
--no-worker never retry on a worker thread (see below)
|
|
186
|
+
|
|
187
|
+
Several engine paths recurse once per manifest entry or once per row, and
|
|
188
|
+
against a large collection that exceeds the runtime's default call stack.
|
|
189
|
+
A one-shot query builds no store handle, so it runs IN PROCESS and pays
|
|
190
|
+
nothing for a worker it would drop; only when the runtime runs out of
|
|
191
|
+
frames does it run again on a worker thread with a raised stack, or under
|
|
192
|
+
Deno re-execute itself once with a raised V8 stack, which needs
|
|
193
|
+
--allow-run and --allow-env (https://github.com/danbri/factoidal/issues/653).
|
|
194
|
+
--no-worker turns the retry off, and the frame budget is then reported.
|
|
195
|
+
|
|
196
|
+
A process that asks MANY questions about one store should hold a store
|
|
197
|
+
handle instead: openStoreHandleOnWorker in bin/store-worker-host.mjs
|
|
198
|
+
verifies and decodes once, on a thread with the raised stack, and answers
|
|
199
|
+
every later query from what it retained.
|
|
182
200
|
|
|
183
201
|
formats:
|
|
184
202
|
table a human display of the engine's SPARQL Query Results JSON;
|
|
@@ -532,6 +550,36 @@ function reportStoreFailure (error) {
|
|
|
532
550
|
return EXIT_FAILURE
|
|
533
551
|
}
|
|
534
552
|
|
|
553
|
+
/**
|
|
554
|
+
* Run one store call again on a bigger call stack, after the in-process
|
|
555
|
+
* attempt ran out of frames.
|
|
556
|
+
*
|
|
557
|
+
* A one-shot query builds no handle and drops what it loads, so it runs
|
|
558
|
+
* IN PROCESS first and pays nothing for a worker it would throw away.
|
|
559
|
+
* This is the retry, and it is reached only by the overflow.
|
|
560
|
+
*
|
|
561
|
+
* Node takes the worker route; Deno has no `worker_threads` route with a
|
|
562
|
+
* stack size, so it re-executes this command once with a raised V8 stack
|
|
563
|
+
* (`denoReexec`), which needs --allow-run and --allow-env.
|
|
564
|
+
*
|
|
565
|
+
* @returns the retried answer, or a number to exit with (the Deno child's
|
|
566
|
+
* code), or null when no bigger stack is available here
|
|
567
|
+
*/
|
|
568
|
+
async function onBiggerStack (options, quiet, retry) {
|
|
569
|
+
const host = { worker: options['no-worker'] !== true }
|
|
570
|
+
if (host.worker === false) return null
|
|
571
|
+
if (isDeno) {
|
|
572
|
+
const code = await denoReexec(host)
|
|
573
|
+
return typeof code === 'number' ? code : null
|
|
574
|
+
}
|
|
575
|
+
if (!workerRouteAvailable()) return null
|
|
576
|
+
if (!quiet) {
|
|
577
|
+
err('The runtime ran out of call stack; running again on a worker thread ' +
|
|
578
|
+
'with a bigger one.')
|
|
579
|
+
}
|
|
580
|
+
return await retry()
|
|
581
|
+
}
|
|
582
|
+
|
|
535
583
|
async function commandQuery (positional, options) {
|
|
536
584
|
if (positional.length < 1) throw new UsageError('query needs a STORE')
|
|
537
585
|
if (typeof options.base === 'string') {
|
|
@@ -554,8 +602,13 @@ async function commandQuery (positional, options) {
|
|
|
554
602
|
try {
|
|
555
603
|
plan = planQuery(engine, store, sparql)
|
|
556
604
|
} catch (error) {
|
|
557
|
-
if (error instanceof StoreOperationError)
|
|
558
|
-
|
|
605
|
+
if (!(error instanceof StoreOperationError)) throw error
|
|
606
|
+
if (!error.stackLimit) return reportStoreFailure(error)
|
|
607
|
+
const retried = await onBiggerStack(options, quiet,
|
|
608
|
+
() => planQueryOnWorker(root, named, sparql))
|
|
609
|
+
if (retried === null) return reportStoreFailure(error)
|
|
610
|
+
if (typeof retried === 'number') return retried
|
|
611
|
+
plan = retried
|
|
559
612
|
}
|
|
560
613
|
if (format === 'json' || options.json === true) {
|
|
561
614
|
out(JSON.stringify(plan, null, 2))
|
|
@@ -572,8 +625,13 @@ async function commandQuery (positional, options) {
|
|
|
572
625
|
try {
|
|
573
626
|
answer = queryStore(engine, store, sparql)
|
|
574
627
|
} catch (error) {
|
|
575
|
-
if (error instanceof StoreOperationError)
|
|
576
|
-
|
|
628
|
+
if (!(error instanceof StoreOperationError)) throw error
|
|
629
|
+
if (!error.stackLimit) return reportStoreFailure(error)
|
|
630
|
+
const retried = await onBiggerStack(options, quiet,
|
|
631
|
+
() => queryStoreOnWorker(root, named, sparql))
|
|
632
|
+
if (retried === null) return reportStoreFailure(error)
|
|
633
|
+
if (typeof retried === 'number') return retried
|
|
634
|
+
answer = retried
|
|
577
635
|
}
|
|
578
636
|
const { plan, result, blobBytes } = answer
|
|
579
637
|
if (!quiet) {
|