@factoidal/core 0.5.1 → 0.7.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 +191 -0
- package/README.md +216 -16
- package/bin/ext.mjs +198 -0
- package/bin/factoidal.mjs +93 -9
- package/bin/pack-host.mjs +21 -2
- package/bin/pack.mjs +8 -2
- package/bin/store-worker-host.mjs +397 -0
- package/bin/store-worker.mjs +100 -0
- package/bin/store.mjs +60 -36
- 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,196 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.0 — 2026-09-05
|
|
4
|
+
|
|
5
|
+
**Wire version 10.** The command and `bin/store.mjs` read a generation
|
|
6
|
+
packed at `--layout ibk5`: out-of-line literals, RDF 1.2 triple terms and
|
|
7
|
+
directional literals, and per-entry zone maps.
|
|
8
|
+
|
|
9
|
+
### The packer
|
|
10
|
+
|
|
11
|
+
`factoidal pack --layout ibk5` writes wire version 10. Blocks are now
|
|
12
|
+
published DURING the ingest pass, every `--batch-bytes` of source (default
|
|
13
|
+
64 MiB in the module, 256 MiB in the native tool), so peak memory is bounded
|
|
14
|
+
by a batch and not by the source: the native packer's peak on the
|
|
15
|
+
1,543,478,120-byte skosdex corpus fell from 5,951,730,560 to 396,869,632
|
|
16
|
+
bytes, and the module's 128 MiB source cap is gone for N-Quads, Turtle and
|
|
17
|
+
N-Triples (TriG still buffers). Rows are bucketed by predicate AND graph, so
|
|
18
|
+
an N-Quads file whose graphs interleave line by line packs to the same
|
|
19
|
+
block set as the same statements in graph order (measured: 11,636 blocks
|
|
20
|
+
became 483). The literal search index is LGI2, whose LEB128 gaps are 23% of
|
|
21
|
+
a `skos:prefLabel` block against LGI1's 54%; a version-10 generation of the
|
|
22
|
+
210 MB skosdex prefix is 14.5% smaller than version 9.
|
|
23
|
+
|
|
24
|
+
### Out-of-line literals
|
|
25
|
+
|
|
26
|
+
A literal above 65,536 UTF-8 bytes is stored as one `blob-<sha256
|
|
27
|
+
hex>.lit` file beside the block, committed by the manifest blob table.
|
|
28
|
+
The block holds only the byte extent and the digest, so a host must fetch
|
|
29
|
+
the blob as well as the block:
|
|
30
|
+
|
|
31
|
+
- `storeQueryPlan` lists them under `blobKeys`, after `keys` and
|
|
32
|
+
`sidecarKeys`, and counts their bytes in `bytes`;
|
|
33
|
+
- `queryStore` and `openStoreHandle` fetch them without a flag;
|
|
34
|
+
- a blob that is absent or whose bytes hash differently REFUSES the
|
|
35
|
+
query by name. Nothing is answered with a shortened literal.
|
|
36
|
+
|
|
37
|
+
A store handle resolves every blob ONCE, at `storeOpen`, and the open
|
|
38
|
+
envelope reports how many. The two candidate-filter indexes keep an
|
|
39
|
+
out-of-line literal in every candidate set, so `CONTAINS` over a
|
|
40
|
+
70,000-byte literal and `geof:sfIntersects` over a polygon above the
|
|
41
|
+
ceiling both answer their rows.
|
|
42
|
+
|
|
43
|
+
### RDF 1.2 terms
|
|
44
|
+
|
|
45
|
+
A triple term and a directional language literal are stored and read
|
|
46
|
+
back. Wire version 9 refuses both.
|
|
47
|
+
|
|
48
|
+
### Zone maps
|
|
49
|
+
|
|
50
|
+
Each manifest entry carries the first 64 bytes of the smallest and the
|
|
51
|
+
largest subject key and object key of its block. A query with a constant
|
|
52
|
+
subject or object skips every block whose range cannot hold it, from the
|
|
53
|
+
manifest alone. `storeQueryPlan` reports the count as `zoneExcluded`, and
|
|
54
|
+
`factoidal inspect` prints the bounds beside each entry.
|
|
55
|
+
|
|
56
|
+
### Unchanged
|
|
57
|
+
|
|
58
|
+
Wire version 9 and earlier answer exactly what they answered before: no
|
|
59
|
+
manifest below version 10 carries a blob table or a zone map, so
|
|
60
|
+
`blobKeys` is empty and `zoneExcluded` is zero for them.
|
|
61
|
+
|
|
62
|
+
## 0.6.0 — 2026-09-05
|
|
63
|
+
|
|
64
|
+
**A full-text index, a geometry index, extension functions from
|
|
65
|
+
JavaScript, and a planner that no longer reads the whole store for a
|
|
66
|
+
`REGEX`.** Measured end to end on the largest corpus we have: 7,315,251
|
|
67
|
+
quads over 204 named graphs, 3,286 blocks, a 1.0 GB generation, queried
|
|
68
|
+
through a store handle on plain `node` with no flags.
|
|
69
|
+
|
|
70
|
+
| search | rows | time |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| `water` | 5 | 645 ms |
|
|
73
|
+
| `glacier` | **0** | 670 ms |
|
|
74
|
+
| `bicycle` | 2 | 617 ms |
|
|
75
|
+
|
|
76
|
+
A miss costs what a hit costs, because both are index lookups.
|
|
77
|
+
|
|
78
|
+
### The planner stopped reading the whole store
|
|
79
|
+
|
|
80
|
+
`REGEX`, `REPLACE`, `IRI()`, `NOW()`, the digest functions, aggregates in
|
|
81
|
+
a filter position, the triple-term accessors and every SPARQL 1.1
|
|
82
|
+
section 17.6 extension call each made the planner abandon predicate
|
|
83
|
+
selection and take every block in the manifest. On a 119-block store a
|
|
84
|
+
`REGEX` filter selected 119 artifacts where the same query with
|
|
85
|
+
`CONTAINS` selected 1.
|
|
86
|
+
|
|
87
|
+
The test was `Expr.backendLocal`, which answers a different question: may
|
|
88
|
+
the backend evaluate this expression, or must it materialise and
|
|
89
|
+
delegate. The right test is `Expr.existsFree` — `Expr.existsPat` and
|
|
90
|
+
`Expr.notExistsPat` are the only constructors carrying a pattern, so an
|
|
91
|
+
exists-free expression reads no triple whatever functions it calls. A
|
|
92
|
+
FILTER may narrow a plan or leave it alone; it must never widen it.
|
|
93
|
+
`EXISTS` and `NOT EXISTS` still widen, correctly.
|
|
94
|
+
|
|
95
|
+
### LGI1, a character-gram literal index
|
|
96
|
+
|
|
97
|
+
The packer writes a `.lgi1` sidecar beside each block and the planner
|
|
98
|
+
uses it where the query shape allows. It holds character 3-grams of the
|
|
99
|
+
case-folded lexical form and is a CANDIDATE FILTER, never a decider: it
|
|
100
|
+
answers a superset and the engine re-evaluates the original `FILTER`, so
|
|
101
|
+
the rows are the scan's rows. Tokens would not do, because `CONTAINS` is
|
|
102
|
+
a substring test and "underwater" contains "water" without being the
|
|
103
|
+
token "water".
|
|
104
|
+
|
|
105
|
+
Falls back to a scan, silently and correctly: a needle under 3
|
|
106
|
+
characters, a variable needle, `REGEX`, `UCASE`, `!CONTAINS`, `CONTAINS`
|
|
107
|
+
under `||`, a filter on a variable not bound in object position, and any
|
|
108
|
+
generation without the sidecar. Costs about 55% of the block bytes.
|
|
109
|
+
|
|
110
|
+
### GBI1, a geometry bounding-box index
|
|
111
|
+
|
|
112
|
+
The same construction for `geof:`. Five of the six topological functions
|
|
113
|
+
are filtered by a box — `sfIntersects`, `sfWithin`, `sfContains`,
|
|
114
|
+
`sfTouches`, `sfEquals` — at 74 to 94 times a scan, with a miss at 82
|
|
115
|
+
times. **`sfDisjoint` is refused** and falls back: it accepts exactly the
|
|
116
|
+
rows a box can exclude, so a box test inverts and would drop answers.
|
|
117
|
+
|
|
118
|
+
### Extension functions, from JavaScript, on the Lean engine
|
|
119
|
+
|
|
120
|
+
`registerExtensionFunction` reaches the Lean engine and the persisted
|
|
121
|
+
store, not only the in-memory F* path. Section 17.6 semantics are gated:
|
|
122
|
+
an unregistered IRI is unbound in SELECT and drops the row in FILTER, a
|
|
123
|
+
registration never overrides a built-in family, and `geof:` still
|
|
124
|
+
answers from the built-in table. Synchronous only; async is designed and
|
|
125
|
+
deferred.
|
|
126
|
+
|
|
127
|
+
### A handle is bounded by retained bytes
|
|
128
|
+
|
|
129
|
+
`storeOpen` was capped at 64 artifacts, inherited from the stateless path
|
|
130
|
+
where every query re-read and re-decoded its blocks. A handle pays that
|
|
131
|
+
once, so a count was the wrong shape — 257 small blocks may cost less
|
|
132
|
+
than 4 large ones. The cap is now 134,217,728 retained bytes, derived
|
|
133
|
+
from a measured 16.2 bytes resident per retained byte against half the
|
|
134
|
+
wasm32 address space, and the check runs BEFORE the read rather than
|
|
135
|
+
after hashing and decoding the whole set. A corpus-wide handle over 257
|
|
136
|
+
blocks and 741,179 rows now opens.
|
|
137
|
+
|
|
138
|
+
### Named graphs pack at scale
|
|
139
|
+
|
|
140
|
+
Blocks cut at graph boundaries, so `GRAPH <iri>` with a constant
|
|
141
|
+
predicate reads one block and the graph filter is exact rather than
|
|
142
|
+
conservative. Packer peak memory fell 32.6% and dictionary duplication
|
|
143
|
+
is 2.57%. No new wire version was needed: SBM7 already admitted several
|
|
144
|
+
entries per predicate.
|
|
145
|
+
|
|
146
|
+
**A large store no longer needs `node --stack-size`.** Several engine
|
|
147
|
+
paths recurse once per manifest entry and once per row, and on a
|
|
148
|
+
7,315,251-quad collection of 3,286 blocks `storeQueryPlan` alone
|
|
149
|
+
overflowed the default call stack of Node, before an artifact byte was
|
|
150
|
+
read ([issue 653](https://github.com/danbri/factoidal/issues/653)).
|
|
151
|
+
|
|
152
|
+
- New `@factoidal/core/store-worker`: `openStoreHandleOnWorker` holds the
|
|
153
|
+
engine and the handle on a `worker_threads` thread with a raised stack.
|
|
154
|
+
A handle is state inside the wasm instance and an instance does not
|
|
155
|
+
cross a thread boundary, so the handle lives there and `query()` and
|
|
156
|
+
`close()` are messages to it. One thread per session, many handles per
|
|
157
|
+
thread. Every call is asynchronous; the synchronous `openStoreHandle`
|
|
158
|
+
is unchanged.
|
|
159
|
+
- `factoidal query` still runs IN PROCESS, so a one-shot query pays
|
|
160
|
+
nothing for a worker it would drop. It runs again on a worker only when
|
|
161
|
+
the runtime runs out of frames — under Deno by re-executing itself with
|
|
162
|
+
a raised V8 stack, which needs `--allow-run` and `--allow-env`.
|
|
163
|
+
`--no-worker` turns the retry off.
|
|
164
|
+
- Measured: the worker costs about 110 ms once (thread start plus a
|
|
165
|
+
second engine load) and under a millisecond per query after that.
|
|
166
|
+
Deno's `worker_threads` shim does not raise the stack (10,835 frames
|
|
167
|
+
by default, 13,837 at `stackSizeMb` 64, against Node's 41,195 and
|
|
168
|
+
696,555), so a Deno library caller gets an in-process handle behind the
|
|
169
|
+
same interface and starts its process with
|
|
170
|
+
`--v8-flags=--stack-size=65536`.
|
|
171
|
+
|
|
172
|
+
### Fixed
|
|
173
|
+
|
|
174
|
+
- `openStoreHandle` with no options failed on any IBK3 generation with
|
|
175
|
+
`artifact 'predicate-0.ibk3.sri2' is not declared by this manifest`.
|
|
176
|
+
Two engine operations disagreed: `storeManifestInspect` reports
|
|
177
|
+
`subjectIndex`, `termIndex` and `objectIndex`, while the handle's
|
|
178
|
+
admission accepted only the block, `literalIndex` and `geoIndex`. One
|
|
179
|
+
list now serves both.
|
|
180
|
+
- The README named GeoSPARQL functions that do not exist and said a text
|
|
181
|
+
index was "separate work". Both corrected, with the shapes that fall
|
|
182
|
+
back to a scan listed.
|
|
183
|
+
|
|
184
|
+
### Known limits, measured
|
|
185
|
+
|
|
186
|
+
- Ingest is about 2,180 quads per second and peak memory is 2.07 times
|
|
187
|
+
the source, so a 142 GB corpus is not importable at that ratio. Making
|
|
188
|
+
memory flat is a wire change and is not done.
|
|
189
|
+
- The quad packer streams N-Quads only; Turtle and TriG are buffered.
|
|
190
|
+
- `ORDER BY ... LIMIT n` still overflows above about 14,576 materialised
|
|
191
|
+
rows. https://github.com/danbri/factoidal/issues/653
|
|
192
|
+
- The manifest is at version 9. Older generations still read.
|
|
193
|
+
|
|
3
194
|
## 0.5.1 — 2026-09-04
|
|
4
195
|
|
|
5
196
|
**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,87 @@ 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.
|
|
944
|
+
|
|
945
|
+
### Wire version 10: large literals, RDF 1.2 terms, zone maps
|
|
946
|
+
|
|
947
|
+
A generation packed at wire version 10 (`--layout ibk5`) carries three
|
|
948
|
+
things earlier versions do not. Everything below is read by the command
|
|
949
|
+
and by `bin/store.mjs` without a flag; wire versions 9 and earlier are
|
|
950
|
+
read exactly as before.
|
|
951
|
+
|
|
952
|
+
**Out-of-line literals.** A literal whose lexical form is at most 65,536
|
|
953
|
+
UTF-8 bytes is stored inside the block. A longer one is stored as one
|
|
954
|
+
file beside it, `blob-<sha256 hex>.lit`, holding exactly those bytes; the
|
|
955
|
+
block keeps only the byte length and the SHA-256, and the manifest blob
|
|
956
|
+
table commits the file. The name is the content address, so the same
|
|
957
|
+
literal in twenty blocks is one file. `factoidal inspect` prints how many
|
|
958
|
+
such files a generation holds and their total size, and the plan a query
|
|
959
|
+
produces lists them under `blobKeys` beside `keys` and `sidecarKeys` — a
|
|
960
|
+
host reading a store itself must fetch all three. A blob that is missing
|
|
961
|
+
or whose bytes hash differently REFUSES the query; it is never answered
|
|
962
|
+
with a shortened literal.
|
|
963
|
+
|
|
964
|
+
Above 4,294,967,295 bytes the packer refuses the literal and names its
|
|
965
|
+
subject and predicate. That ceiling, and every other one, is stated in
|
|
966
|
+
`docs/designissues/2026-09-05-wire-version-10-scale.md` section 2.
|
|
967
|
+
|
|
968
|
+
**RDF 1.2 terms.** A triple term (`<<( :a :p :b )>>`) and a directional
|
|
969
|
+
language literal (`"..."@ar--rtl`) are stored and read back. Wire version
|
|
970
|
+
9 refuses both.
|
|
971
|
+
|
|
972
|
+
**Zone maps.** Each manifest entry carries the smallest and the largest
|
|
973
|
+
subject key and object key of its block, truncated to 64 bytes. A query
|
|
974
|
+
with a constant subject or object skips every block whose range cannot
|
|
975
|
+
hold it, from the manifest alone, with no block read. The plan reports
|
|
976
|
+
how many entries were dropped that way as `zoneExcluded`. Selectivity
|
|
977
|
+
depends on the source order: a subject-grouped or graph-grouped file
|
|
978
|
+
gives disjoint ranges per block, a shuffled one gives overlapping ranges
|
|
979
|
+
and a scan, which is correct and no worse than wire version 9.
|
|
780
980
|
|
|
781
981
|
## Limits (deliberate, documented)
|
|
782
982
|
|
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
|
+
}
|