@factoidal/core 0.5.0 → 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 CHANGED
@@ -1,5 +1,188 @@
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
+
135
+ ## 0.5.1 — 2026-09-04
136
+
137
+ **A store can be opened once and queried many times.** `storeQuery` is
138
+ stateless: it re-reads, re-verifies and re-decodes the block on every
139
+ call, so a chat bot or a server paid the full cost for every question.
140
+ The new handle pays it once.
141
+
142
+ Measured on a 141-graph store, `skos:prefLabel` block of 5,571,302 bytes
143
+ and 45,806 rows, a DIFFERENT search string every query, load 10:
144
+
145
+ | | stateless | handle |
146
+ |---|---|---|
147
+ | first query (open + query) | 1,376 ms | 1,363 ms |
148
+ | second query, different string | 1,376 ms | **95 ms** |
149
+ | tenth query, all different | 1,382 ms | 103 ms |
150
+ | ten queries, total | 13,962 ms | **2,277 ms** |
151
+
152
+ ```js
153
+ import { openStoreHandle } from '@factoidal/core/store'
154
+ import { loadEngine } from '@factoidal/core/engine'
155
+ const engine = await loadEngine()
156
+ const store = await openStoreHandle(engine, '/path/to/store')
157
+ const a = store.query('PREFIX skos: … SELECT … ') // 95 ms
158
+ const b = store.query('PREFIX skos: … SELECT … ') // 95 ms
159
+ store.close()
160
+ ```
161
+
162
+ **What it buys**: the per-query SHA-256 verification, block decode,
163
+ dataset build and index build all happen once. **What it does not buy**:
164
+ `CONTAINS` still scans every retained row, so cost stays proportional to
165
+ row count. This is not a search fix. There is still no inverted index.
166
+
167
+ ⚠️ **Memory.** A held-open handle is about 170 MiB resident, of which
168
+ roughly 94 MiB is the decoded form of that 5.5 MB packed block — about
169
+ 17 times the packed size. Query evaluation peaks at 346 MiB. A process
170
+ holding several stores should watch this; `storeHandleList` reports each
171
+ handle's retained bytes and rows.
172
+
173
+ **For a long-lived process**: several stores may be open at once, keyed
174
+ independently. `storeOpen` REFUSES at its cap and never evicts another
175
+ caller's handle — an eviction policy is a host decision and is not
176
+ implemented. The WebAssembly module is single-threaded, so a host must
177
+ queue overlapping calls; use one module instance per worker thread for
178
+ concurrency. A server would still need a re-open path when a generation
179
+ is replaced on disk, and delta-log overlay support: this path serves the
180
+ manifest's committed artifacts only.
181
+
182
+ Handle answers are gated against the stateless path by comparing ROWS,
183
+ not row counts, in `tools/wasm-store-query-smoke.sh` and
184
+ `Wasm/native-smoke.sh` (85 pass, 0 fail, out of 85; was 79).
185
+
3
186
  ## 0.5.0 — 2026-09-04
4
187
 
5
188
  **Queries against a persisted store are about six times faster.** Measured
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:
@@ -488,6 +527,183 @@ store, `SELECT (COUNT(*) AS ?n) WHERE { ?s ?p ?o }`, whole process
488
527
  including start-up: 220 ms through this command, 33 ms through the
489
528
  native `l4block-id-v3-query`.
490
529
 
530
+ ### Holding a store open: verify once, answer many
531
+
532
+ `queryStore` is stateless. Every call transfers the artifacts again, and
533
+ the engine hashes, decodes and indexes them again. A process that answers
534
+ many questions against one generation — a chat bot, an MCP server, a
535
+ SPARQL endpoint — pays all of that for every question, and none of it
536
+ depends on the question.
537
+
538
+ A **store handle** holds that work. `openStoreHandle` verifies each
539
+ artifact against the SHA-256 the manifest commits, decodes each block and
540
+ indexes the rows, once. `handle.query()` then answers from what it
541
+ retained. Several stores can be open at the same time.
542
+
543
+ ```js
544
+ import { loadEngine } from '@factoidal/core/bin/engine.mjs'
545
+ import { openStore, openStoreHandle, listStoreHandles } from '@factoidal/core/store'
546
+
547
+ const engine = await loadEngine()
548
+
549
+ // Two stores, open at once, held for the life of the process.
550
+ const skos = openStoreHandle(engine, openStore('./skos-store'))
551
+ const docs = openStoreHandle(engine, openStore('./docs-store'))
552
+
553
+ console.log(listStoreHandles(engine))
554
+ // { ok: true, handles: [ {handle:'s1', bytes:…, rows:…}, {handle:'s2', …} ],
555
+ // bytes: …, rows: …, handleCap: 8, bytesCap: 134217728 }
556
+
557
+ const PREFIX = 'PREFIX skos: <http://www.w3.org/2004/02/skos/core#>'
558
+
559
+ function labelsMatching (handle, needle) {
560
+ const answer = handle.query(`${PREFIX}
561
+ SELECT ?c ?l WHERE {
562
+ GRAPH ?g { ?c skos:prefLabel ?l }
563
+ FILTER(CONTAINS(LCASE(STR(?l)), "${needle.toLowerCase()}"))
564
+ } LIMIT 10`)
565
+ return answer.srj.results.bindings
566
+ }
567
+
568
+ // Many questions, each a new search string. None of them re-reads a block.
569
+ for (const needle of ['water', 'forest', 'railway', 'volcano']) {
570
+ console.log(needle, labelsMatching(skos, needle).length)
571
+ }
572
+ console.log(docs.query(`${PREFIX} SELECT (COUNT(*) AS ?n) WHERE { ?s ?p ?o }`))
573
+
574
+ skos.close()
575
+ docs.close()
576
+ ```
577
+
578
+ `openStoreHandle(engine, store, options)` takes:
579
+
580
+ | option | effect |
581
+ |---|---|
582
+ | (none) | open every artifact the manifest declares |
583
+ | `{ sparql }` | open only the artifacts that query's plan names |
584
+ | `{ keys }` | open exactly these artifact keys |
585
+
586
+ `queryStoreHandle(engine, handle, sparql)`, `closeStoreHandle(engine,
587
+ handle)` and `listStoreHandles(engine)` are the same operations for a
588
+ caller that holds only the handle id.
589
+
590
+ **Measured 2026-09-04**, macOS arm64, Node 22, the 141-graph SKOS store,
591
+ `skos:prefLabel` block of 5,571,302 bytes and 45,806 rows, one
592
+ `CONTAINS` query per row, a DIFFERENT search string every time:
593
+
594
+ | | stateless `queryStore` | handle |
595
+ |---|---|---|
596
+ | first query (open + query) | 1376 ms | 1363 ms |
597
+ | second query, different search string | 1376 ms | 95 ms |
598
+ | tenth query, all different | 1382 ms | 103 ms |
599
+ | ten queries, total | 13962 ms | 2277 ms |
600
+
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.
605
+
606
+ **Residency.** Retaining a decoded block costs memory. Measured on the
607
+ same store: 76 MiB resident with the engine loaded and no handle, 170 MiB
608
+ with the handle open — about 94 MiB for a 5.5 MB packed block, and
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
624
+ another. `listStoreHandles` is how a server sees its own residency.
625
+
626
+ **One call at a time.** The WebAssembly module is single-threaded. Two
627
+ `query()` calls cannot overlap; a server queues them.
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
+
491
707
  ## API (draft)
492
708
 
493
709
  The `factoidal` CLI (`bin/factoidal-cli/factoidal_cli.ml`, built to
@@ -680,15 +896,51 @@ handling beyond what the WKT literal carries; no GML literals. Geometry
680
896
  comes from a WKT parser, so a shapefile, GeoJSON or GML source must be
681
897
  converted to `geo:wktLiteral` before it is loaded.
682
898
 
683
- ### Full text: SPARQL's own functions, no index
899
+ ### Full text: SPARQL's own functions, over a character-gram index
684
900
 
685
901
  `CONTAINS`, `STRSTARTS`, `STRENDS` and `REGEX` (SPARQL 1.1 §17.4.3) are
686
- implemented and are the way to search text. They are evaluated per row
687
- after a block is decoded **there is no inverted index and no
688
- `text:query`-style extension**. Measured 2026-09-04: a `CONTAINS` over
689
- 45,806 `skos:prefLabel` values in one block answers in about 6 seconds.
690
- That is fine for a vocabulary and will not scale to a large literal
691
- corpus.
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.
692
944
 
693
945
  ## Limits (deliberate, documented)
694
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
+ }