@factoidal/core 0.3.0 → 0.5.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.
Files changed (126) hide show
  1. package/CHANGELOG.md +167 -0
  2. package/NOTICE +31 -0
  3. package/README.md +70 -8
  4. package/bin/factoidal.mjs +250 -14
  5. package/bin/pack-host.mjs +341 -0
  6. package/bin/pack-worker.mjs +31 -0
  7. package/bin/pack.mjs +174 -0
  8. package/bin/store.mjs +32 -0
  9. package/l4-assets/l4factoidal.js +61 -1
  10. package/l4-assets/l4factoidal.mjs +1 -1
  11. package/l4-assets/l4factoidal.wasm +0 -0
  12. package/l4-assets/version.json +4 -4
  13. package/package.json +19 -2
  14. package/sample-store/CURRENT +1 -0
  15. package/sample-store/gen-1/manifest.sbm2 +0 -0
  16. package/sample-store/gen-1/manifest.tsv +14 -0
  17. package/sample-store/gen-1/predicate-0.ibk3 +0 -0
  18. package/sample-store/gen-1/predicate-0.ibk3.merkle +1 -0
  19. package/sample-store/gen-1/predicate-0.ibk3.oli2 +0 -0
  20. package/sample-store/gen-1/predicate-0.ibk3.oli2.merkle +1 -0
  21. package/sample-store/gen-1/predicate-0.ibk3.sri2 +0 -0
  22. package/sample-store/gen-1/predicate-0.ibk3.sri2.merkle +1 -0
  23. package/sample-store/gen-1/predicate-0.ibk3.tli1 +0 -0
  24. package/sample-store/gen-1/predicate-0.ibk3.tli1.merkle +0 -0
  25. package/sample-store/gen-1/predicate-1.ibk3 +0 -0
  26. package/sample-store/gen-1/predicate-1.ibk3.merkle +0 -0
  27. package/sample-store/gen-1/predicate-1.ibk3.oli2 +0 -0
  28. package/sample-store/gen-1/predicate-1.ibk3.oli2.merkle +0 -0
  29. package/sample-store/gen-1/predicate-1.ibk3.sri2 +0 -0
  30. package/sample-store/gen-1/predicate-1.ibk3.sri2.merkle +1 -0
  31. package/sample-store/gen-1/predicate-1.ibk3.tli1 +0 -0
  32. package/sample-store/gen-1/predicate-1.ibk3.tli1.merkle +1 -0
  33. package/sample-store/gen-1/predicate-10.ibk3 +0 -0
  34. package/sample-store/gen-1/predicate-10.ibk3.merkle +1 -0
  35. package/sample-store/gen-1/predicate-10.ibk3.oli2 +0 -0
  36. package/sample-store/gen-1/predicate-10.ibk3.oli2.merkle +0 -0
  37. package/sample-store/gen-1/predicate-10.ibk3.sri2 +0 -0
  38. package/sample-store/gen-1/predicate-10.ibk3.sri2.merkle +1 -0
  39. package/sample-store/gen-1/predicate-10.ibk3.tli1 +0 -0
  40. package/sample-store/gen-1/predicate-10.ibk3.tli1.merkle +1 -0
  41. package/sample-store/gen-1/predicate-11.ibk3 +0 -0
  42. package/sample-store/gen-1/predicate-11.ibk3.merkle +0 -0
  43. package/sample-store/gen-1/predicate-11.ibk3.oli2 +0 -0
  44. package/sample-store/gen-1/predicate-11.ibk3.oli2.merkle +1 -0
  45. package/sample-store/gen-1/predicate-11.ibk3.sri2 +0 -0
  46. package/sample-store/gen-1/predicate-11.ibk3.sri2.merkle +1 -0
  47. package/sample-store/gen-1/predicate-11.ibk3.tli1 +0 -0
  48. package/sample-store/gen-1/predicate-11.ibk3.tli1.merkle +1 -0
  49. package/sample-store/gen-1/predicate-12.ibk3 +0 -0
  50. package/sample-store/gen-1/predicate-12.ibk3.merkle +1 -0
  51. package/sample-store/gen-1/predicate-12.ibk3.oli2 +0 -0
  52. package/sample-store/gen-1/predicate-12.ibk3.oli2.merkle +1 -0
  53. package/sample-store/gen-1/predicate-12.ibk3.sri2 +0 -0
  54. package/sample-store/gen-1/predicate-12.ibk3.sri2.merkle +1 -0
  55. package/sample-store/gen-1/predicate-12.ibk3.tli1 +0 -0
  56. package/sample-store/gen-1/predicate-12.ibk3.tli1.merkle +1 -0
  57. package/sample-store/gen-1/predicate-2.ibk3 +0 -0
  58. package/sample-store/gen-1/predicate-2.ibk3.merkle +1 -0
  59. package/sample-store/gen-1/predicate-2.ibk3.oli2 +0 -0
  60. package/sample-store/gen-1/predicate-2.ibk3.oli2.merkle +1 -0
  61. package/sample-store/gen-1/predicate-2.ibk3.sri2 +0 -0
  62. package/sample-store/gen-1/predicate-2.ibk3.sri2.merkle +0 -0
  63. package/sample-store/gen-1/predicate-2.ibk3.tli1 +0 -0
  64. package/sample-store/gen-1/predicate-2.ibk3.tli1.merkle +0 -0
  65. package/sample-store/gen-1/predicate-3.ibk3 +0 -0
  66. package/sample-store/gen-1/predicate-3.ibk3.merkle +1 -0
  67. package/sample-store/gen-1/predicate-3.ibk3.oli2 +0 -0
  68. package/sample-store/gen-1/predicate-3.ibk3.oli2.merkle +1 -0
  69. package/sample-store/gen-1/predicate-3.ibk3.sri2 +0 -0
  70. package/sample-store/gen-1/predicate-3.ibk3.sri2.merkle +1 -0
  71. package/sample-store/gen-1/predicate-3.ibk3.tli1 +0 -0
  72. package/sample-store/gen-1/predicate-3.ibk3.tli1.merkle +1 -0
  73. package/sample-store/gen-1/predicate-4.ibk3 +0 -0
  74. package/sample-store/gen-1/predicate-4.ibk3.merkle +0 -0
  75. package/sample-store/gen-1/predicate-4.ibk3.oli2 +0 -0
  76. package/sample-store/gen-1/predicate-4.ibk3.oli2.merkle +1 -0
  77. package/sample-store/gen-1/predicate-4.ibk3.sri2 +0 -0
  78. package/sample-store/gen-1/predicate-4.ibk3.sri2.merkle +1 -0
  79. package/sample-store/gen-1/predicate-4.ibk3.tli1 +0 -0
  80. package/sample-store/gen-1/predicate-4.ibk3.tli1.merkle +2 -0
  81. package/sample-store/gen-1/predicate-5.ibk3 +0 -0
  82. package/sample-store/gen-1/predicate-5.ibk3.merkle +1 -0
  83. package/sample-store/gen-1/predicate-5.ibk3.oli2 +0 -0
  84. package/sample-store/gen-1/predicate-5.ibk3.oli2.merkle +1 -0
  85. package/sample-store/gen-1/predicate-5.ibk3.sri2 +0 -0
  86. package/sample-store/gen-1/predicate-5.ibk3.sri2.merkle +1 -0
  87. package/sample-store/gen-1/predicate-5.ibk3.tli1 +0 -0
  88. package/sample-store/gen-1/predicate-5.ibk3.tli1.merkle +1 -0
  89. package/sample-store/gen-1/predicate-6.ibk3 +0 -0
  90. package/sample-store/gen-1/predicate-6.ibk3.merkle +1 -0
  91. package/sample-store/gen-1/predicate-6.ibk3.oli2 +0 -0
  92. package/sample-store/gen-1/predicate-6.ibk3.oli2.merkle +1 -0
  93. package/sample-store/gen-1/predicate-6.ibk3.sri2 +0 -0
  94. package/sample-store/gen-1/predicate-6.ibk3.sri2.merkle +1 -0
  95. package/sample-store/gen-1/predicate-6.ibk3.tli1 +0 -0
  96. package/sample-store/gen-1/predicate-6.ibk3.tli1.merkle +0 -0
  97. package/sample-store/gen-1/predicate-7.ibk3 +0 -0
  98. package/sample-store/gen-1/predicate-7.ibk3.merkle +0 -0
  99. package/sample-store/gen-1/predicate-7.ibk3.oli2 +0 -0
  100. package/sample-store/gen-1/predicate-7.ibk3.oli2.merkle +2 -0
  101. package/sample-store/gen-1/predicate-7.ibk3.sri2 +0 -0
  102. package/sample-store/gen-1/predicate-7.ibk3.sri2.merkle +1 -0
  103. package/sample-store/gen-1/predicate-7.ibk3.tli1 +0 -0
  104. package/sample-store/gen-1/predicate-7.ibk3.tli1.merkle +1 -0
  105. package/sample-store/gen-1/predicate-8.ibk3 +0 -0
  106. package/sample-store/gen-1/predicate-8.ibk3.merkle +1 -0
  107. package/sample-store/gen-1/predicate-8.ibk3.oli2 +0 -0
  108. package/sample-store/gen-1/predicate-8.ibk3.oli2.merkle +1 -0
  109. package/sample-store/gen-1/predicate-8.ibk3.sri2 +0 -0
  110. package/sample-store/gen-1/predicate-8.ibk3.sri2.merkle +1 -0
  111. package/sample-store/gen-1/predicate-8.ibk3.tli1 +0 -0
  112. package/sample-store/gen-1/predicate-8.ibk3.tli1.merkle +1 -0
  113. package/sample-store/gen-1/predicate-9.ibk3 +0 -0
  114. package/sample-store/gen-1/predicate-9.ibk3.merkle +1 -0
  115. package/sample-store/gen-1/predicate-9.ibk3.oli2 +0 -0
  116. package/sample-store/gen-1/predicate-9.ibk3.oli2.merkle +1 -0
  117. package/sample-store/gen-1/predicate-9.ibk3.sri2 +0 -0
  118. package/sample-store/gen-1/predicate-9.ibk3.sri2.merkle +3 -0
  119. package/sample-store/gen-1/predicate-9.ibk3.tli1 +0 -0
  120. package/sample-store/gen-1/predicate-9.ibk3.tli1.merkle +2 -0
  121. package/sample-store.d.ts +16 -0
  122. package/sample-store.mjs +37 -0
  123. package/store-host/deno.mjs +63 -0
  124. package/store-host/index.mjs +39 -0
  125. package/store-host/node.mjs +62 -1
  126. package/version.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,172 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 — 2026-09-04
4
+
5
+ **Queries against a persisted store are about six times faster.** Measured
6
+ end to end the way a caller runs them — process start, engine load,
7
+ digest verification, block decode and scan — on a 141-graph store with a
8
+ 5,571,302-byte `skos:prefLabel` block of 45,806 rows, at the same machine
9
+ load:
10
+
11
+ | query | 0.4.0 | 0.5.0 |
12
+ |---|---|---|
13
+ | `CONTAINS` over labels, `LIMIT 8` | 12.03 s | **2.06 s** |
14
+ | the same for a second word | 12.18 s | **2.04 s** |
15
+ | the same for a third | 11.96 s | **2.02 s** |
16
+
17
+ Two causes, both fixed.
18
+
19
+ - **A quadratic byte copy in SHA-256.** `Crypto.processBlocks256` copied
20
+ the whole remaining message once per 64-byte block, so verifying an
21
+ artifact was quadratic in its size. Fitting `t = c*n^k` to the
22
+ admission step gave k = 2.08 before and **k = 0.98 after**. On the
23
+ three blocks of that store, admission went 1,179 / 2,150 / 11,069 ms to
24
+ 303 / 413 / 872 ms. Decode and evaluation were linear throughout.
25
+ - **`LIMIT` was not pushed down through `GRAPH`.** A `LIMIT 8` cost what
26
+ a full count cost, because `GRAPH ?g { ... }` fell through to the
27
+ reference evaluator over the whole materialised dataset. The push-down
28
+ now takes one `GRAPH` layer with a constant IRI or a variable.
29
+ `ORDER BY`, `OFFSET`, `DISTINCT`, `REDUCED`, `GROUP BY`, `HAVING`,
30
+ `VALUES` and aggregates still reject, two of them pinned by theorems.
31
+ - A `RangeError: Maximum call stack size exceeded` on `SELECT ... LIMIT 8`
32
+ is gone with it, because the query no longer materialises 45,806 rows
33
+ to return eight.
34
+
35
+ **A correctness fix found while measuring.** The pre-existing bare-BGP
36
+ `LIMIT` push-down could answer SHORT: a repeated variable (`?x ?p ?x`) or
37
+ an RDF-star triple term let the backend stop early on rows the match then
38
+ rejected. It now refuses both shapes.
39
+
40
+ **New exports.** `@factoidal/core/store`, `/pack` and `/engine`. A caller
41
+ can drive the store in process instead of spawning the command:
42
+
43
+ ```js
44
+ import { openStore, queryStore } from '@factoidal/core/store'
45
+ import { loadEngine } from '@factoidal/core/engine'
46
+ const engine = await loadEngine()
47
+ const store = openStore('/path/to/store', null)
48
+ const { result } = queryStore(engine, store, 'PREFIX skos: ... SELECT ...')
49
+ ```
50
+
51
+ **The engine carries a day of OWL work**: OWL RL 1,181 pass, 266 fail
52
+ (out of 1,457) and OWL DL about 1,326 pass, 121 fail (out of 1,457), both
53
+ against a conclusion check corrected to require one functional blank-node
54
+ mapping (RDF 1.1 Semantics §1.5 and the interpolation lemma). A false
55
+ clash was removed — the materialiser had minted one existential witness
56
+ for several obligations, so the engine denied three consistent
57
+ ontologies; ConsistencyTest went 758 pass, 4 fail to 761 pass, 1 fail.
58
+
59
+ **Named graphs now pack at scale.** The IBK4 quad path read the whole
60
+ source file and a 553 MB, 194-graph corpus could not be packed at all. It
61
+ streams now, and a quadratic term that only named graphs paid — a hash
62
+ map copied per quad in `addQuadFast` — is gone. Peak memory per source
63
+ byte fell from 37 and 20 to between 7.4 and 11.4; a 316,816,934-byte,
64
+ 194-graph input that used to fail now packs in 750 s at 2.34 GB. Byte
65
+ identity with the previous packer holds by theorem, not only by diff.
66
+
67
+ **Documentation corrected.** The GeoSPARQL section named functions that
68
+ do not exist. Six topological functions are implemented — `geof:sfEquals`,
69
+ `sfDisjoint`, `sfIntersects`, `sfTouches`, `sfWithin`, `sfContains` — and
70
+ they work against a persisted store, verified. There is no
71
+ `geof:distance`, `buffer`, `envelope`, `boundary`, `convexHull`, no
72
+ `relate` with a DE-9IM matrix, no CRS handling beyond the WKT literal and
73
+ no GML. Full text is SPARQL 1.1's own `CONTAINS` / `STRSTARTS` / `REGEX`,
74
+ evaluated per row after a block decodes: **there is no inverted index**.
75
+
76
+ Known limits, measured:
77
+
78
+ - A query is still O(rows) per search string, and nothing is retained
79
+ between queries: `storeQuery` re-reads, re-verifies and re-decodes the
80
+ block every call. A store handle that decodes once is the next step.
81
+ - `ORDER BY ... LIMIT n` still overflows the call stack above about
82
+ 14,576 materialised rows.
83
+ https://github.com/danbri/factoidal/issues/653
84
+ - `update` and `compact` still exit 3.
85
+ https://github.com/danbri/factoidal/issues/641
86
+ - A query plan is refused above 64 artifacts, 8,388,608 blob bytes or
87
+ 100,000 rows. https://github.com/danbri/factoidal/issues/648
88
+ - The two SHA-256 folds are checked equal by the FIPS 180-4 build-time
89
+ guards and the HACL* differential, not proved.
90
+
91
+ ## 0.4.0 — 2026-09-04
92
+
93
+ The package builds a store of its own. `pack` and `activate` join
94
+ `inspect` and `query`, so `npm install @factoidal/core` gives a complete
95
+ RDF store — import, activate, query — with no native binary on the
96
+ machine. https://github.com/danbri/factoidal/issues/641
97
+
98
+ - **`factoidal pack INPUT OUTPUT`** builds one immutable Shardborough
99
+ generation from a Turtle, TriG, N-Triples or N-Quads file, streaming
100
+ it in 65,536-byte chunks through the Lean engine's WebAssembly module.
101
+ The generation is BYTE-IDENTICAL to what the native
102
+ `l4block-shard-pack` writes: `diff -r` is empty for the `ibk3` triple
103
+ layout and for the `ibk4` quad layout, on inputs up to 888,949
104
+ triples. `--layout ibk3|ibk4`, `--syntax`, `--base`.
105
+ - **`factoidal activate STORE GENERATION`** verifies every artifact
106
+ against the SHA-256 the manifest commits, and every cross-artifact
107
+ relation, then replaces `CURRENT` atomically. A generation that fails
108
+ verification never becomes current.
109
+ - **`factoidal sample-store`** prints the path of a store this package
110
+ now carries, so a fresh install answers a SPARQL query with nothing
111
+ else to download: 4,434 triples in 13 predicate blocks, five IPTC
112
+ NewsCodes vocabularies under CC BY 4.0. Also exported as
113
+ `@factoidal/core/sample-store`. See NOTICE.
114
+ - The engine gained a raw byte path out of the module
115
+ (`l4_call_blob_io`), so artifact bytes cross the boundary with no
116
+ encoding. Hexadecimal doubled them; base64 was refused. Measured on
117
+ the read path: 242,416 bytes and 96 ms hexadecimal against 4,893 bytes
118
+ and 70 ms raw.
119
+ - The pack hashes with HACL* SHA-256, the same primitive the native
120
+ packer uses. It hashed with the pure Lean SHA-256 in development,
121
+ which cost 3.3 times: 104 s against 31 s on 888,949 triples. Now at
122
+ parity, 29.24 s against the native packer's 29.38 s.
123
+ - `packBegin` takes a base IRI, defaulted by the command to
124
+ `file://<input>` so relative IRIs resolve exactly as the native packer
125
+ resolves them. `--base ''` asks for no base, which makes a relative
126
+ IRI a parse error rather than a silently different term.
127
+ - `pack` on a syntax the streaming fold cannot read now says so by name
128
+ rather than raising an unhandled error.
129
+ - `store-host` gained `readChunk` (a short read means end of file, not
130
+ an error), `writeNew` (create and fsync, refusing an existing file so
131
+ a name collision in a generation is reported) and `makeDirectory`, on
132
+ Node and on Deno both.
133
+ - `factoidal activate` runs on the raised stack too. Verification decodes
134
+ the same blocks the pack encoded, so it recurses as deep; the worker was
135
+ given to `pack` alone at first, and a 112,742-row generation packed
136
+ successfully and then failed to activate with `Maximum call stack size
137
+ exceeded`, leaving a store that could be built and not opened. Found by
138
+ installing the tarball and running the command, which is why that step
139
+ is in the release procedure. `tests/store-host/cli.mjs` now gates
140
+ pack-then-activate on both runtimes.
141
+ - `factoidal pack` no longer needs a runtime flag. The pack fold recurses
142
+ deeper than either runtime's default call stack allows, so an input
143
+ above roughly 0.5 MB ended with `Maximum call stack size exceeded`
144
+ (https://github.com/danbri/factoidal/issues/649). Under Node the pack
145
+ now runs on a `worker_threads` worker with a 64 MiB stack; under Deno
146
+ the command re-executes itself once with
147
+ `--v8-flags=--stack-size=65536`, which needs `--allow-run` and
148
+ `--allow-env` in addition to `--allow-read` and `--allow-write`.
149
+ `gene.ttl`, 17,363,312 bytes and 888,949 triples, packs on the default
150
+ stack of both runtimes, byte-identical to `l4block-shard-pack`.
151
+ `--no-worker` forces the in-process path, which still reports the
152
+ frame budget and the flag that raises it rather than crashing. A
153
+ browser tab has a fixed frame budget and no flag, so this does not
154
+ make an in-page packer possible.
155
+
156
+ Known limits, measured:
157
+
158
+ - `update` and `compact` still exit 3. The delta-log operations are
159
+ stage 4 of https://github.com/danbri/factoidal/issues/641.
160
+ - The `ibk4` quad layout reads the whole source rather than streaming,
161
+ because a quad block commits a graph-set summary over the entire
162
+ input. The wasm packer refuses a quad file above 128 MiB.
163
+ https://github.com/danbri/factoidal/issues/650
164
+ - A query plan is refused above 64 artifacts, 8,388,608 blob bytes or
165
+ 100,000 rows. https://github.com/danbri/factoidal/issues/648
166
+ - Packing in a browser tab is limited to about 7,800 distinct terms in
167
+ one block, whatever the file size, and no host flag raises it.
168
+ https://github.com/danbri/factoidal/issues/647
169
+
3
170
  ## 0.3.0 — 2026-09-03
4
171
 
5
172
  - The `factoidal` command answers SPARQL against a persisted
package/NOTICE ADDED
@@ -0,0 +1,31 @@
1
+ @factoidal/core — third-party content notices
2
+ =============================================
3
+
4
+ The code in this package is Apache-2.0; see LICENSE.
5
+
6
+ sample-store/
7
+ -------------
8
+
9
+ The bundled sample Shardborough store holds five IPTC NewsCodes
10
+ vocabularies, converted from the publisher's RDF/XML and packed into
11
+ IBK3 predicate blocks:
12
+
13
+ spamfstat, videoqualifier, subjectqualifier, videocodec, spct
14
+
15
+ Publisher: International Press Telecommunications Council
16
+ https://iptc.org/
17
+ Licence: Creative Commons Attribution 4.0 International (CC BY 4.0)
18
+ https://creativecommons.org/licenses/by/4.0/
19
+ Obtained through: https://github.com/danbri/skosdex third_party/skos
20
+
21
+ The IRIs, labels and structure are the IPTC's. The block layout, the
22
+ dictionary, the indexes and the manifest are this package's encoding of
23
+ them. Redistribution of the vocabulary content stays under CC BY 4.0.
24
+
25
+ hacl-wasm/
26
+ ----------
27
+
28
+ HACL* verified cryptographic primitives, from
29
+ https://github.com/hacl-star/hacl-star, Apache-2.0 / MIT. See the
30
+ provenance recorded in the Factoidal repository under
31
+ skills/node-crypto-haclstar-vc-wasm-build/.
package/README.md CHANGED
@@ -380,7 +380,41 @@ SHA-256, evaluating the SPARQL).
380
380
  > This command is not the native F\* `factoidal` binary that the API
381
381
  > table below refers to. That one is `bin/<platform>/factoidal` in the
382
382
  > repository and takes subcommands such as `shex` and `compact`. This
383
- > one takes `version`, `inspect` and `query`.
383
+ > one takes `version`, `sample-store`, `inspect` and `query`.
384
+
385
+ ### First query, with nothing else to download
386
+
387
+ The package carries an activated store, so a fresh install answers a
388
+ SPARQL query at once:
389
+
390
+ ```console
391
+ $ npm install @factoidal/core
392
+ $ npx factoidal query "$(npx factoidal sample-store)" \
393
+ 'SELECT ?c ?l
394
+ WHERE { ?c <http://www.w3.org/2004/02/skos/core#inScheme>
395
+ <http://cv.iptc.org/newscodes/videocodec/> ;
396
+ <http://www.w3.org/2004/02/skos/core#prefLabel> ?l .
397
+ FILTER(langMatches(lang(?l), "en")) }
398
+ LIMIT 4'
399
+ c l
400
+ <http://cv.iptc.org/newscodes/videocodec/c001> "Analogue Black and White"@en-gb
401
+ <http://cv.iptc.org/newscodes/videocodec/c002> "PAL"@en-gb
402
+ <http://cv.iptc.org/newscodes/videocodec/c003> "NTSC"@en-gb
403
+ <http://cv.iptc.org/newscodes/videocodec/c004> "SECAM"@en-gb
404
+ ```
405
+
406
+ `factoidal sample-store` prints the path; `--json` adds what was
407
+ recorded when the store was packed. From JavaScript:
408
+
409
+ ```js
410
+ import { sampleStorePath, sampleStoreFacts } from '@factoidal/core/sample-store'
411
+ ```
412
+
413
+ The store holds 4,434 triples in 13 predicate blocks: five IPTC
414
+ NewsCodes vocabularies, published by the IPTC under CC BY 4.0 and taken
415
+ from [danbri/skosdex](https://github.com/danbri/skosdex). See `NOTICE`.
416
+
417
+ ### Any other store
384
418
 
385
419
  ```console
386
420
  $ factoidal inspect ./mystore
@@ -619,14 +653,42 @@ value transforms:
619
653
  API; the `_*` functions (e.g. `_deltaLogCorruptLastForTest`) are
620
654
  test-only and intentionally left untyped.
621
655
 
622
- ### GeoSPARQL
656
+ ### GeoSPARQL — six topological functions
657
+
658
+ The `geof:` functions below are built into the SPARQL engine and need no
659
+ import. They work through `query()` / `fn.query()` AND against a
660
+ persisted store through `factoidal query`, because both paths evaluate
661
+ in the same environment.
662
+
663
+ geof:sfEquals geof:sfDisjoint geof:sfIntersects
664
+ geof:sfTouches geof:sfWithin geof:sfContains
665
+
666
+ ```sparql
667
+ PREFIX geof: <http://www.opengis.net/def/function/geosparql/>
668
+ PREFIX geo: <http://www.opengis.net/ont/geosparql#>
669
+ SELECT ?a WHERE {
670
+ ?a :footprint ?w
671
+ FILTER(geof:sfWithin(?w, "POLYGON((0 0,0 2,2 2,2 0,0 0))"^^geo:wktLiteral))
672
+ }
673
+ ```
623
674
 
624
- There is no separate GeoSPARQL function: the `geof:` functions
625
- (`geof:sfWithin`, `geof:sfDisjoint`, `geof:distance`, `geof:envelope`,
626
- …) are built into the SPARQL engine and work through ordinary
627
- `query()` / `fn.query()` e.g.
628
- `query(data, 'PREFIX geof: <http://www.opengis.net/def/function/geosparql/> SELECT ?a ?b WHERE { FILTER(geof:sfWithin(?a, ?b)) }')`.
629
- Nothing to import; nothing "missing".
675
+ **What is NOT there**, stated so nobody plans around it: no
676
+ `geof:distance`, `geof:buffer`, `geof:envelope`, `geof:boundary`,
677
+ `geof:convexHull` or any other non-topological measure; no
678
+ `geof:relate` with a DE-9IM matrix; no coordinate reference system
679
+ handling beyond what the WKT literal carries; no GML literals. Geometry
680
+ comes from a WKT parser, so a shapefile, GeoJSON or GML source must be
681
+ converted to `geo:wktLiteral` before it is loaded.
682
+
683
+ ### Full text: SPARQL's own functions, no index
684
+
685
+ `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.
630
692
 
631
693
  ## Limits (deliberate, documented)
632
694
 
package/bin/factoidal.mjs CHANGED
@@ -20,13 +20,17 @@
20
20
  // `--format turtle` all print documents the engine produced.
21
21
 
22
22
  import {
23
- StoreHostError, listGeneration, readWhole, runtime
23
+ StoreHostError, atomicReplace, listGeneration, makeDirectory, readWhole,
24
+ runtime
24
25
  } from '../store-host/index.mjs'
25
26
  import { fileUrlToPath, joinPath } from '../store-host/paths.mjs'
26
27
  import { loadEngine } from './engine.mjs'
28
+ import { sampleStoreFacts, sampleStorePath } from '../sample-store.mjs'
29
+ import { PackError, packSupported, verifyGeneration } from './pack.mjs'
30
+ import { denoReexec, isStackOverflow, runPack } from './pack-host.mjs'
27
31
  import {
28
- StoreOperationError, inspectManifest, openStore, planQuery, queryStore,
29
- turtleOfNQuads
32
+ STACK_REMEDY, StoreOperationError, inspectManifest, openStore, planQuery,
33
+ queryStore, stackLimitAdvice, turtleOfNQuads
30
34
  } from './store.mjs'
31
35
 
32
36
  const EXIT_OK = 0
@@ -34,6 +38,41 @@ const EXIT_FAILURE = 1
34
38
  const EXIT_USAGE = 2
35
39
  const EXIT_NOT_WIRED = 3
36
40
 
41
+ // Progress is reported about every 16 MiB; the packer feeds 65,536 bytes
42
+ // a time, so this is the window that catches one feed per report.
43
+ const FEED_PROGRESS = 65536
44
+
45
+ const PACK_LAYOUTS = ['ibk3', 'ibk4']
46
+ const PACK_SYNTAXES = ['turtle', 'trig', 'nquads', 'ntriples']
47
+ const PACK_SUFFIXES = [
48
+ ['.ttl', 'turtle'], ['.turtle', 'turtle'],
49
+ ['.trig', 'trig'],
50
+ ['.nq', 'nquads'], ['.nquads', 'nquads'],
51
+ ['.nt', 'ntriples'], ['.ntriples', 'ntriples']
52
+ ]
53
+
54
+ // Suffixes the engine parses elsewhere but the packer's streaming fold does
55
+ // not read. Naming them is better than "cannot tell the syntax from its
56
+ // name", which sends the reader looking for a --syntax value that does not
57
+ // exist.
58
+ const PACK_UNSUPPORTED_SUFFIXES = [
59
+ ['.rdf', 'RDF/XML'], ['.owl', 'RDF/XML'], ['.xml', 'RDF/XML'],
60
+ ['.jsonld', 'JSON-LD'], ['.json', 'JSON-LD'], ['.n3', 'Notation3'],
61
+ ['.csv', 'CSV'], ['.tsv', 'TSV'], ['.hdt', 'HDT']
62
+ ]
63
+
64
+ /** The parent of a path, and its last component. The `activate` hint
65
+ * printed after a pack needs both; neither is a format decision. */
66
+ function dirOf (path) {
67
+ const cut = path.replace(/[/\\]+$/, '').lastIndexOf('/')
68
+ return cut <= 0 ? '.' : path.slice(0, cut)
69
+ }
70
+ function nameOf (path) {
71
+ const trimmed = path.replace(/[/\\]+$/, '')
72
+ const cut = trimmed.lastIndexOf('/')
73
+ return cut < 0 ? trimmed : trimmed.slice(cut + 1)
74
+ }
75
+
37
76
  const ISSUE = 'https://github.com/danbri/factoidal/issues/641'
38
77
 
39
78
  const isDeno = typeof globalThis.Deno !== 'undefined'
@@ -60,6 +99,7 @@ usage: factoidal <command> [options]
60
99
 
61
100
  commands:
62
101
  version print the package and engine versions
102
+ sample-store print the path of the bundled sample store
63
103
  inspect STORE report what the activated manifest commits
64
104
  query STORE [QUERY] evaluate a SPARQL query against a store
65
105
  pack INPUT OUTPUT build one immutable generation from an RDF file
@@ -75,7 +115,10 @@ global options:
75
115
  exit codes:
76
116
  0 success 1 failure 2 usage error 3 not yet wired (${ISSUE})
77
117
 
78
- STORE is a collection root: the directory that holds CURRENT.`
118
+ STORE is a collection root: the directory that holds CURRENT. This package
119
+ carries one, so the first query needs no other download:
120
+
121
+ factoidal query "$(factoidal sample-store)" 'SELECT * WHERE { ?s ?p ?o } LIMIT 5'`
79
122
 
80
123
  const COMMAND_USAGE = {
81
124
  version: `factoidal version - print the package and engine versions
@@ -85,6 +128,21 @@ usage: factoidal version [--json]
85
128
  Prints the npm package version, the Lean engine's WebAssembly digest as
86
129
  recorded by its build, and which host-I/O implementation is loaded.`,
87
130
 
131
+ 'sample-store': `factoidal sample-store - print the bundled store's path
132
+
133
+ usage: factoidal sample-store [--json]
134
+
135
+ Prints the collection root of the Shardborough store this package
136
+ carries, so a fresh install can query something at once:
137
+
138
+ factoidal inspect "$(factoidal sample-store)"
139
+ factoidal query "$(factoidal sample-store)" \\
140
+ 'SELECT (COUNT(*) AS ?n) WHERE { ?s ?p ?o }'
141
+
142
+ The store holds five IPTC NewsCodes vocabularies (CC BY 4.0; see NOTICE)
143
+ packed into IBK3 predicate blocks. --json adds what was recorded when it
144
+ was packed.`,
145
+
88
146
  inspect: `factoidal inspect - report what a store's manifest commits
89
147
 
90
148
  usage: factoidal inspect STORE [--json] [--generation NAME]
@@ -157,7 +215,14 @@ options:
157
215
  --layout LAYOUT ibk3 (triples, default) or ibk4 (quads)
158
216
  --syntax SYNTAX turtle, trig or nquads; default from the file extension
159
217
  --chunk-bytes N Merkle chunk size; default is the engine's
160
- --json emit one JSON object`,
218
+ --json emit one JSON object
219
+ --no-worker pack in this process instead of on a worker thread
220
+
221
+ The pack fold recurses deeper than either runtime's default call stack
222
+ allows, so it runs on a worker thread with a raised stack under Node, and
223
+ under Deno the command re-executes itself once with a raised V8 stack
224
+ (https://github.com/danbri/factoidal/issues/649). --no-worker turns both
225
+ off; a pack above about half a megabyte of input then overflows.`,
161
226
 
162
227
  activate: `factoidal activate - make one generation the activated generation
163
228
 
@@ -239,9 +304,10 @@ class UsageError extends Error {}
239
304
 
240
305
  const VALUE_OPTIONS = {
241
306
  version: new Set([]),
307
+ 'sample-store': new Set([]),
242
308
  inspect: new Set(['generation']),
243
309
  query: new Set(['query', 'file', 'format', 'limit', 'base', 'generation']),
244
- pack: new Set(['layout', 'syntax', 'chunk-bytes']),
310
+ pack: new Set(['layout', 'syntax', 'chunk-bytes', 'base']),
245
311
  activate: new Set([]),
246
312
  update: new Set(['update', 'file']),
247
313
  compact: new Set([])
@@ -297,6 +363,16 @@ function commandVersion (options) {
297
363
  return EXIT_OK
298
364
  }
299
365
 
366
+ function commandSampleStore (options) {
367
+ const path = sampleStorePath()
368
+ if (options.json === true) {
369
+ out(JSON.stringify({ path, ...sampleStoreFacts }, null, 2))
370
+ return EXIT_OK
371
+ }
372
+ out(path)
373
+ return EXIT_OK
374
+ }
375
+
300
376
  // ------------------------------------------------------------ rendering
301
377
 
302
378
  /**
@@ -448,10 +524,7 @@ function reportStoreFailure (error) {
448
524
  err(`This query needs more of the store than one WebAssembly call may read: ${error.capValue} against a cap of ${error.capLimit}.`)
449
525
  err('Narrow the query - bind a predicate, or restrict the graph - or use the native l4block-* tools.')
450
526
  } else if (error.stackLimit) {
451
- err('The runtime ran out of call stack inside the engine, not the store.')
452
- err('Some evaluator paths recurse once per row, and a few thousand rows can')
453
- err("exceed Node's default WebAssembly frame budget. Raise it with")
454
- err('node --stack-size=4000, add a LIMIT, or run the query under Deno.')
527
+ for (const line of stackLimitAdvice(STACK_REMEDY.query)) err(line)
455
528
  } else if (error.digestKey !== null) {
456
529
  err(`The bytes of '${error.digestKey}' in the generation directory are not the bytes the manifest commits.`)
457
530
  err('The generation is damaged or was edited after it was packed; repack or restore it.')
@@ -561,14 +634,176 @@ function renderQueryResult (engine, result, format, limit, quiet) {
561
634
  return EXIT_FAILURE
562
635
  }
563
636
 
564
- function commandPack (positional, _options) {
637
+ /** The syntax tag for an input, from --syntax or from the file name. The
638
+ * engine is what actually decides how to read the bytes; this only picks
639
+ * which of its parsers to name. */
640
+ function packSyntax (input, options) {
641
+ if (typeof options.syntax === 'string') {
642
+ const syntax = options.syntax.toLowerCase()
643
+ if (PACK_SYNTAXES.indexOf(syntax) >= 0) return syntax
644
+ throw new UsageError(`--syntax ${options.syntax} is not one of ${PACK_SYNTAXES.join(', ')}`)
645
+ }
646
+ const lower = input.toLowerCase()
647
+ for (const [suffix, syntax] of PACK_SUFFIXES) {
648
+ if (lower.endsWith(suffix)) return syntax
649
+ }
650
+ for (const [suffix, name] of PACK_UNSUPPORTED_SUFFIXES) {
651
+ if (lower.endsWith(suffix)) {
652
+ throw new UsageError(
653
+ `pack does not read ${name}. The packer's streaming fold reads ` +
654
+ `${PACK_SYNTAXES.join(', ')} only. Convert the file first, for ` +
655
+ "example with: factoidal parse FILE --out nquads")
656
+ }
657
+ }
658
+ throw new UsageError(
659
+ `cannot tell the syntax of ${input} from its name; give --syntax ` +
660
+ `(${PACK_SYNTAXES.join(', ')})`)
661
+ }
662
+
663
+ /**
664
+ * The base IRI relative IRIs in the source resolve against.
665
+ *
666
+ * The native packer uses `file://<input>`, so this matches it by default
667
+ * and byte-identical output needs no flag. `--base` overrides it, and
668
+ * `--base ''` asks for no base, which turns a relative IRI into a parse
669
+ * error rather than a silently different term.
670
+ */
671
+ function packBase (input, options) {
672
+ if (typeof options.base === 'string') return options.base
673
+ const absolute = input.startsWith('/') ? input : joinPath(currentDirectory(), input)
674
+ return 'file://' + absolute
675
+ }
676
+
677
+ /** The process's working directory, on Node and on Deno. */
678
+ function currentDirectory () {
679
+ if (isDeno) return globalThis.Deno.cwd()
680
+ return process.cwd()
681
+ }
682
+
683
+ function packLayout (options) {
684
+ if (typeof options.layout !== 'string') return 'ibk3'
685
+ const layout = options.layout.toLowerCase()
686
+ if (PACK_LAYOUTS.indexOf(layout) >= 0) return layout
687
+ throw new UsageError(`--layout ${options.layout} is not one of ${PACK_LAYOUTS.join(', ')}`)
688
+ }
689
+
690
+ async function commandPack (positional, options) {
565
691
  if (positional.length !== 2) throw new UsageError('pack needs INPUT and OUTPUT')
566
- return notWired('pack', 'The streaming pack operations are stage 3 of the milestone.')
692
+ const [input, output] = positional
693
+ const syntax = packSyntax(input, options)
694
+ const layout = packLayout(options)
695
+ // The pack fold needs a bigger call stack than either runtime gives by
696
+ // default (https://github.com/danbri/factoidal/issues/649). Under Node
697
+ // the work runs on a worker thread with a raised stack; under Deno the
698
+ // command re-executes itself once with --v8-flags=--stack-size, and
699
+ // this is where that happens, before any file is opened. --no-worker
700
+ // keeps the in-process path testable.
701
+ const host = { worker: options['no-worker'] !== true }
702
+ const reexec = await denoReexec(host)
703
+ if (reexec !== null) return reexec
704
+ makeDirectory(output)
705
+ const quiet = options.quiet === true
706
+ let answer
707
+ try {
708
+ answer = await runPack(
709
+ { kind: 'pack', input, output, syntax, layout, base: packBase(input, options) },
710
+ quiet
711
+ ? undefined
712
+ : (progress) => {
713
+ if (progress.bytesRead % (16 * 1024 * 1024) < FEED_PROGRESS) {
714
+ err(`${progress.pass}: ${progress.bytesRead} bytes read, ${progress.artifacts} artifacts written`)
715
+ }
716
+ },
717
+ host)
718
+ } catch (error) {
719
+ if (error instanceof PackError || error instanceof StoreHostError) {
720
+ err(`factoidal pack: ${error.message}`)
721
+ return EXIT_FAILURE
722
+ }
723
+ // Everything the engine refuses -- an unknown grammar tag, a parse
724
+ // error, a cap -- arrives as a plain Error carrying the engine's own
725
+ // words. A stack trace here would hide them.
726
+ if (error instanceof Error && typeof error.message === 'string') {
727
+ err(`factoidal pack: ${error.message.replace(/^l4factoidal:\s*/, '')}`)
728
+ // The raised stack was refused, unavailable, or still not enough.
729
+ if (isStackOverflow(error)) {
730
+ for (const line of stackLimitAdvice(STACK_REMEDY.pack)) err(line)
731
+ }
732
+ return EXIT_FAILURE
733
+ }
734
+ throw error
735
+ }
736
+ if (answer.notWired === true) {
737
+ return notWired('pack',
738
+ 'This install carries an engine built before the streaming pack ' +
739
+ 'operations. Update @factoidal/core, or set FACTOIDAL_L4_ASSETS to ' +
740
+ 'a newer build.')
741
+ }
742
+ const report = answer.report
743
+ if (options.json === true) {
744
+ out(JSON.stringify(report, null, 2))
745
+ return EXIT_OK
746
+ }
747
+ out(`packed ${report.bytesRead} bytes of ${syntax} into ${output}`)
748
+ out(`${report.written.length} artifacts, ${report.bytesWritten} bytes, layout ${layout}`)
749
+ if (typeof report.rows === 'number') out(`${plural(report.rows, 'row')}`)
750
+ out(`activate it with: factoidal activate ${dirOf(output)} ${nameOf(output)}`)
751
+ return EXIT_OK
567
752
  }
568
753
 
569
- function commandActivate (positional, _options) {
754
+ async function commandActivate (positional, options) {
570
755
  if (positional.length !== 2) throw new UsageError('activate needs STORE and GENERATION')
571
- return notWired('activate', 'Activation must verify every artifact before it replaces CURRENT.')
756
+ const [root, generation] = positional
757
+ // Verification decodes the same blocks the pack encoded, so it recurses
758
+ // as deep and needs the same raised stack. Measured 2026-09-04: a
759
+ // 112,742-row generation packed successfully and then failed to
760
+ // activate with `Maximum call stack size exceeded`, leaving a store
761
+ // that could be built and not opened
762
+ // (https://github.com/danbri/factoidal/issues/649).
763
+ const host = { worker: options['no-worker'] !== true }
764
+ const reexec = await denoReexec(host)
765
+ if (reexec !== null) return reexec
766
+ let answer
767
+ try {
768
+ answer = await runPack({ kind: 'activate', root, generation }, undefined, host)
769
+ } catch (error) {
770
+ if (error instanceof PackError || error instanceof StoreHostError) {
771
+ err(`factoidal activate: ${error.code ? error.code + ': ' : ''}${error.message}`)
772
+ return EXIT_FAILURE
773
+ }
774
+ if (error instanceof Error && typeof error.message === 'string') {
775
+ err(`factoidal activate: ${error.message.replace(/^l4factoidal:\s*/, '')}`)
776
+ if (isStackOverflow(error)) {
777
+ for (const line of stackLimitAdvice(STACK_REMEDY.pack)) err(line)
778
+ }
779
+ return EXIT_FAILURE
780
+ }
781
+ throw error
782
+ }
783
+ if (answer.notWired === true) {
784
+ return notWired('activate',
785
+ 'This install carries an engine built before the activation ' +
786
+ 'verification operation. Update @factoidal/core.')
787
+ }
788
+ const verdict = answer.report
789
+ if (verdict.ok !== true) {
790
+ err(`factoidal activate: ${verdict.error}`)
791
+ err('The generation is NOT activated; CURRENT is unchanged.')
792
+ return EXIT_FAILURE
793
+ }
794
+ // Only now does the pointer move, and it moves atomically.
795
+ const pointer = new TextEncoder().encode(generation)
796
+ const synced = atomicReplace(joinPath(root, 'CURRENT'), pointer)
797
+ if (options.json === true) {
798
+ out(JSON.stringify({ ...verdict, generation, directorySynced: synced }, null, 2))
799
+ return EXIT_OK
800
+ }
801
+ out(`activated ${generation}: ${verdict.artifacts} artifacts verified, ${verdict.bytes} bytes`)
802
+ if (!synced) {
803
+ err('CURRENT was replaced, but the directory entry was not synced; a ' +
804
+ 'crash now could lose the pointer update.')
805
+ }
806
+ return EXIT_OK
572
807
  }
573
808
 
574
809
  function commandUpdate (positional, options) {
@@ -589,6 +824,7 @@ function commandCompact (positional, _options) {
589
824
 
590
825
  const COMMANDS = {
591
826
  version: (positional, options) => commandVersion(options),
827
+ 'sample-store': (positional, options) => commandSampleStore(options),
592
828
  inspect: commandInspect,
593
829
  query: commandQuery,
594
830
  pack: commandPack,