@factoidal/core 0.6.0 → 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,64 @@
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
+
3
62
  ## 0.6.0 — 2026-09-05
4
63
 
5
64
  **A full-text index, a geometry index, extension functions from
package/README.md CHANGED
@@ -942,6 +942,42 @@ would drop answers. A non-overlapping pair of boxes proves geometries
942
942
  disjoint, but that saves work inside a scan rather than reducing the
943
943
  candidate set.
944
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.
980
+
945
981
  ## Limits (deliberate, documented)
946
982
 
947
983
  - **In-memory only.** ~1.2 KB RAM per quad (measured); 1M quads ≈
package/bin/factoidal.mjs CHANGED
@@ -45,7 +45,7 @@ const EXIT_NOT_WIRED = 3
45
45
  // a time, so this is the window that catches one feed per report.
46
46
  const FEED_PROGRESS = 65536
47
47
 
48
- const PACK_LAYOUTS = ['ibk3', 'ibk4']
48
+ const PACK_LAYOUTS = ['ibk3', 'ibk4', 'ibk5']
49
49
  const PACK_SYNTAXES = ['turtle', 'trig', 'nquads', 'ntriples']
50
50
  const PACK_SUFFIXES = [
51
51
  ['.ttl', 'turtle'], ['.turtle', 'turtle'],
@@ -230,9 +230,15 @@ INPUT is an RDF file. OUTPUT is the generation directory to create; it is
230
230
  normally STORE/gen-N. Packing does not activate: run activate next.
231
231
 
232
232
  options:
233
- --layout LAYOUT ibk3 (triples, default) or ibk4 (quads)
233
+ --layout LAYOUT ibk3 (triples, default), ibk4 (quads, wire version 9)
234
+ or ibk5 (quads, wire version 10: RDF 1.2 terms,
235
+ out-of-line literals, zone maps)
234
236
  --syntax SYNTAX turtle, trig or nquads; default from the file extension
235
237
  --chunk-bytes N Merkle chunk size; default is the engine's
238
+ --batch-bytes N ibk4 only: source bytes between publication batches;
239
+ default is the engine's. Blocks are published during
240
+ the ingest pass, so this bounds the packer's memory.
241
+ Match l4block-shard-pack --batch-bytes for its blocks.
236
242
  --json emit one JSON object
237
243
  --no-worker pack in this process instead of on a worker thread
238
244
 
@@ -470,10 +476,27 @@ async function commandInspect (positional, options) {
470
476
  out(`term registry ${envelope.termRegistryVersion === '' ? '(none recorded)' : envelope.termRegistryVersion}`)
471
477
  out(`fixed-chunk Merkle commitment ${envelope.rangeCommitted ? 'yes' : 'no'}`)
472
478
  out(`${envelope.entries.length} ${envelope.entries.length === 1 ? 'entry' : 'entries'}, ${envelope.totalBytes} bytes, ${plural(envelope.totalRows, 'row')}`)
479
+ // Wire version 10: the manifest blob table. Each `blob-<hex>.lit` holds the
480
+ // lexical form of one literal above the 65,536-byte inline ceiling, and is
481
+ // shared by every block whose dictionary names it.
482
+ const blobs = envelope.blobs ?? []
483
+ if (blobs.length > 0) {
484
+ let blobBytes = 0
485
+ for (const blob of blobs) blobBytes += blob.bytes
486
+ out(`${plural(blobs.length, 'out-of-line literal')}, ${blobBytes} bytes`)
487
+ }
473
488
  out(`generation directory holds ${files.length} files, ${directoryBytes} bytes`)
474
489
  out('')
490
+ // The zone maps are per entry, so the columns appear only for a manifest
491
+ // that carries them. A bound is the first 64 bytes of the smallest (or
492
+ // largest) term key of the block; the first eight are enough to see that
493
+ // two blocks hold different ranges.
494
+ const zoned = envelope.entries.some((entry) => entry.subjectZone !== undefined)
495
+ const zoneCell = (zone) =>
496
+ zone === undefined ? '-' : `${zone.min.slice(0, 16)}..${zone.max.slice(0, 16)}`
475
497
  printTable(
476
- ['#', 'rows', 'bytes', 'kind', 'graphs', 'predicate'],
498
+ ['#', 'rows', 'bytes', 'kind', 'graphs', 'predicate']
499
+ .concat(zoned ? ['blobs', 'subject zone'] : []),
477
500
  envelope.entries.map((entry) => [
478
501
  String(entry.ordinal),
479
502
  String(entry.rows),
@@ -481,7 +504,9 @@ async function commandInspect (positional, options) {
481
504
  entry.blockKind,
482
505
  entry.graphs.length === 0 ? '-' : entry.graphs.map(graphLabel).join(' '),
483
506
  entry.predicate
484
- ]))
507
+ ].concat(zoned
508
+ ? [String((entry.blobRefs ?? []).length), zoneCell(entry.subjectZone)]
509
+ : [])))
485
510
  return EXIT_OK
486
511
  }
487
512
 
@@ -764,7 +789,8 @@ async function commandPack (positional, options) {
764
789
  let answer
765
790
  try {
766
791
  answer = await runPack(
767
- { kind: 'pack', input, output, syntax, layout, base: packBase(input, options) },
792
+ { kind: 'pack', input, output, syntax, layout, base: packBase(input, options),
793
+ batchBytes: options['batch-bytes'] },
768
794
  quiet
769
795
  ? undefined
770
796
  : (progress) => {
package/bin/pack-host.mjs CHANGED
@@ -215,6 +215,7 @@ export async function packHere (task, onProgress) {
215
215
  syntax: task.syntax,
216
216
  layout: task.layout,
217
217
  base: task.base,
218
+ batchBytes: task.batchBytes,
218
219
  onProgress
219
220
  })
220
221
  return { report }
@@ -298,7 +299,7 @@ async function packInWorker (task, onProgress) {
298
299
  * already re-executed (see `denoReexec`), so this runs in process.
299
300
  *
300
301
  * @param {object} task {kind, ...}: a pack takes
301
- * {input, output, syntax, layout, base}, an activate takes
302
+ * {input, output, syntax, layout, base, batchBytes}, an activate takes
302
303
  * {root, generation}
303
304
  * @param {(progress: object) => void} [onProgress]
304
305
  * @param {object} [options] {worker: false} forces the in-process path
package/bin/pack.mjs CHANGED
@@ -93,16 +93,22 @@ function drain (engine, handle, output) {
93
93
  * @param {string} input the RDF file to read
94
94
  * @param {string} output the generation directory to fill; it must exist
95
95
  * @param {object} options
96
- * @param {string} options.layout `ibk3` or `ibk4`
96
+ * @param {string} options.layout `ibk3`, `ibk4` or `ibk5`
97
97
  * @param {string} options.syntax `turtle`, `trig` or `nquads`
98
98
  * @param {string} [options.base] the base IRI relative IRIs resolve against;
99
99
  * the empty string means no base
100
+ * @param {number|string} [options.batchBytes] source bytes per publication
101
+ * batch for the IBK4 streamed grammars; omitted means the engine's own
102
+ * default. Match the native packer's `--batch-bytes` to get its block set.
100
103
  * @param {(progress: object) => void} [options.onProgress]
101
104
  * @returns {object} the engine's own finish envelope, plus what was written
102
105
  */
103
106
  export function packFile (engine, input, output, options) {
104
107
  const begun = engine.call('packBegin',
105
- [options.syntax, options.layout, typeof options.base === 'string' ? options.base : ''])
108
+ [options.syntax, options.layout,
109
+ typeof options.base === 'string' ? options.base : '',
110
+ options.batchBytes === undefined || options.batchBytes === null
111
+ ? '' : String(options.batchBytes)])
106
112
  const handle = begun.handle
107
113
  if (typeof handle !== 'string') {
108
114
  throw new PackError('packBegin answered no handle')
package/bin/store.mjs CHANGED
@@ -3,7 +3,9 @@
3
3
  //
4
4
  // WHAT THIS FILE IS ALLOWED TO DO
5
5
  // Read CURRENT, read a manifest file by name, read the artifact files the
6
- // engine's plan named, concatenate their bytes, and hand them over. It
6
+ // engine's plan named -- blocks, index sidecars, and the `blob-<hex>.lit`
7
+ // out-of-line literals of wire version 10 -- concatenate their bytes, and
8
+ // hand them over. It
7
9
  // never parses a manifest, never verifies a digest, never decodes a block
8
10
  // and never decides which artifact answers a query. Every one of those is
9
11
  // a format decision and it lives in `formal/lean4/Wasm/Ops/Store.lean`
@@ -192,6 +194,22 @@ function capDecision (engine, store, sparql) {
192
194
  }
193
195
  }
194
196
 
197
+ /** Read the named artifacts and concatenate them into one region. */
198
+ function regionOf (store, keys) {
199
+ const chunks = keys.map((key) => readWhole(joinPath(store.generationDir, key)))
200
+ let total = 0
201
+ for (const chunk of chunks) total += chunk.length
202
+ const blob = new Uint8Array(total)
203
+ const artifacts = []
204
+ let offset = 0
205
+ for (let index = 0; index < chunks.length; index += 1) {
206
+ blob.set(chunks[index], offset)
207
+ artifacts.push({ key: keys[index], offset, len: chunks[index].length })
208
+ offset += chunks[index].length
209
+ }
210
+ return { blob, artifacts, total }
211
+ }
212
+
195
213
  /**
196
214
  * Evaluate one SPARQL query against one generation.
197
215
  *
@@ -210,17 +228,12 @@ export function queryStore (engine, store, sparql) {
210
228
  if (empty !== null) {
211
229
  return { plan, result: empty, blobBytes: 0, artifacts: [] }
212
230
  }
213
- const chunks = plan.keys.map((key) => readWhole(joinPath(store.generationDir, key)))
214
- let total = 0
215
- for (const chunk of chunks) total += chunk.length
216
- const blob = new Uint8Array(total)
217
- const artifacts = []
218
- let offset = 0
219
- for (let index = 0; index < chunks.length; index += 1) {
220
- blob.set(chunks[index], offset)
221
- artifacts.push({ key: plan.keys[index], offset, len: chunks[index].length })
222
- offset += chunks[index].length
223
- }
231
+ // `blobKeys` are the out-of-line literals the selected blocks name (wire
232
+ // version 10). A block holds only their byte extent and SHA-256, so a
233
+ // query that touches one needs its file as much as it needs the block.
234
+ // The engine chose these keys; this host reads the files and nothing more.
235
+ const keys = plan.keys.concat(plan.blobKeys ?? [])
236
+ const { blob, artifacts, total } = regionOf(store, keys)
224
237
  let result
225
238
  try {
226
239
  result = engine.callBlobIO('storeQuery',
@@ -274,30 +287,18 @@ export function turtleOfNQuads (engine, nquads) {
274
287
  * need no change here: the engine names them under `entry.sidecars`.
275
288
  */
276
289
  function manifestKeys (engine, store) {
290
+ const envelope = inspectManifest(engine, store)
277
291
  const keys = []
278
- for (const entry of inspectManifest(engine, store).entries) {
292
+ for (const entry of envelope.entries) {
279
293
  keys.push(entry.key)
280
294
  for (const key of Object.values(entry.sidecars ?? {})) keys.push(key)
281
295
  }
296
+ // The manifest blob table of wire version 10: one `blob-<hex>.lit` file per
297
+ // out-of-line literal, shared by every block whose dictionary names it.
298
+ for (const blob of envelope.blobs ?? []) keys.push(blob.key)
282
299
  return keys
283
300
  }
284
301
 
285
- /** Read the named artifacts and concatenate them into one region. */
286
- function regionOf (store, keys) {
287
- const chunks = keys.map((key) => readWhole(joinPath(store.generationDir, key)))
288
- let total = 0
289
- for (const chunk of chunks) total += chunk.length
290
- const blob = new Uint8Array(total)
291
- const artifacts = []
292
- let offset = 0
293
- for (let index = 0; index < chunks.length; index += 1) {
294
- blob.set(chunks[index], offset)
295
- artifacts.push({ key: keys[index], offset, len: chunks[index].length })
296
- offset += chunks[index].length
297
- }
298
- return { blob, artifacts }
299
- }
300
-
301
302
  /**
302
303
  * An open store held inside the engine. Hold this object for as long as the
303
304
  * process answers questions about the generation, then `close()` it.
@@ -316,6 +317,9 @@ export class StoreHandle {
316
317
  this.artifacts = envelope.artifacts
317
318
  this.bytes = envelope.bytes
318
319
  this.rows = envelope.rows
320
+ // Wire version 10 only: how many out-of-line literals the engine
321
+ // resolved when it opened the handle. Zero elsewhere.
322
+ this.blobs = envelope.blobs ?? 0
319
323
  this.closed = false
320
324
  }
321
325
 
@@ -366,7 +370,7 @@ export function openStoreHandle (engine, store, options = {}) {
366
370
  keys = options.keys
367
371
  } else if (typeof options.sparql === 'string') {
368
372
  const plan = planQuery(engine, store, options.sparql)
369
- keys = plan.keys.concat(plan.sidecarKeys ?? [])
373
+ keys = plan.keys.concat(plan.sidecarKeys ?? [], plan.blobKeys ?? [])
370
374
  } else {
371
375
  keys = manifestKeys(engine, store)
372
376
  }
@@ -37,7 +37,7 @@
37
37
  // bytes change.
38
38
 
39
39
  // Stamped by formal/lean4/Wasm/build-wasm.sh step 9 -- do not hand-edit.
40
- const WASM_VERSION = "0de098a8aad0";
40
+ const WASM_VERSION = "a7720d5d5630";
41
41
 
42
42
  import createModule from './l4factoidal.mjs';
43
43
 
Binary file
@@ -2,13 +2,13 @@
2
2
  "engine": "lean4",
3
3
  "note": "The Lean 4 (L4Factoidal) engine's own build provenance and claims -- distinct from ../version.json, which is the F* engine's. Do not merge the two: they come from different toolchains, different git SHAs in general, and carry different proof obligations (issue #618).",
4
4
  "version": "0.1.0",
5
- "gitSha": "178d7ce330ab01f4e5814801e9fda483d4478f34",
6
- "builtAt": "2026-09-05T13:03:17+00:00",
5
+ "gitSha": "e32b58ac2ec41dca4f86ef6e6b6f1a5cc6f46726",
6
+ "builtAt": "2026-09-05T20:46:27+00:00",
7
7
  "leanToolchain": "leanprover/lean4:v4.33.1",
8
8
  "emscripten": "emcc (Emscripten gcc/clang-like replacement + linker emulating GNU ld) 6.0.8-git",
9
9
  "abiVersion": "1",
10
- "wasmSha256": "0de098a8aad0255eb575b4af236592eacb628da70a0784d22267624d2eaa6507",
11
- "wasmBytes": 5606105,
10
+ "wasmSha256": "a7720d5d563014d875d26f63576993166c45b8f3eef7a6be9b75adfebd626500",
11
+ "wasmBytes": 5857701,
12
12
  "claims": {
13
13
  "source": "formal/lean4 (L4Factoidal): no sorry, no user axioms, no native_decide; W3C behaviour pinned by build-time #guard",
14
14
  "suitesAtBuildSha": "see docs/test-results and formal/lean4/PORT_NOTES.md at gitSha"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@factoidal/core",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Factoidal: a linked information system with graph data and the Web at its heart - RDF parsing, SPARQL 1.1 query, canonicalization and entailment for Node and the browser (JS + Wasm), grounded in F* and Lean 4. Parser and algebra spec verified in F*; on-disk backend has unverified OCaml-side optimization layers being migrated back to F*.",
5
5
  "keywords": [
6
6
  "sparql",