@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 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: 67108864 }
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 and what it does not.** It removes the per-query
563
- digest check, block decode and index build. It does NOT make search
564
- sub-linear: `CONTAINS` still scans every retained row, so a query still
565
- costs time proportional to the row count. A text index is separate work.
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). The
571
- caps are on ARTIFACT bytes, which is what the manifest declares: 8 open
572
- handles, and 67108864 retained artifact bytes across all of them. A cap
573
- is a refusal naming the cap; no handle is ever evicted to make room for
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, no index
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
- implemented and are the way to search text. They are evaluated per row
775
- after a block is decoded — **there is no inverted index and no
776
- `text:query`-style extension**. Measured 2026-09-04: a `CONTAINS` over
777
- 45,806 `skos:prefLabel` values in one block answers in about 6 seconds.
778
- That is fine for a vocabulary and will not scale to a large literal
779
- 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.
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
+ }