Persisted query ladder — 2026-09-02#

Owner goal, 2026-09-02, verbatim: "Performant standards-compliant formally verifiable e2e RDF/SPARQL MVP, showing snappy searches against increasingly large dataset converted to Shardborough formed rdf indexed data."

This note is the measurement record for that goal on the persisted path (l4block-shard-pack → l4block-shard-activate → l4block-id-v3-query, see skills/shardborough-storage). Every number is a single cold run on one laptop (Apple M-series, macOS, formal/lean4 native build) unless a column says otherwise; timings are wall-clock for the whole process, including manifest read, Merkle verification, decoding and result printing. Rows are counted by the CLI.

Rung 2: gene.ttl, 888,949 triples#

Step Result
Source examples/wikidata/subsets/lifesci-kgx/data/gene.ttl, 17,363,312 bytes
Pack (ibk3) 13 blocks, 105.8 s while a WASM build competed for CPU (2026-08-31 baseline: 72 s on the IBK2 publisher)
Generation on disk 50 MB (blocks, PTD1 dictionaries, SRI2/OLI2/TLI1 sidecars, Merkle leaves)
Largest predicate P684, 759,263 rows across five blocks (36,056 / 180,667 / 251,148 / 256,698 / 34,694)

Query workload#

The six competitive-bench queries (docs/test-results/competitive-bench.json) plus a bounded scan and an object-bound lookup.

# Query shape Rows Before (17:30) After planner fixes (19:40) Open mode after
q1 COUNT(*) over everything 1 12.5 s 6.3 s (quiet machine; same path) full manifest, 13 blocks
q2 COUNT of P684 (759,263 rows) 1 1.95 s 1.28 s (same path) per-predicate count
q3 subject point lookup, unbound predicate: wd:Q100085837 ?p ?o 3 10.0 s 0.23 s TLI1/SRI2 subject point, 13 entries
q4 two-pattern join P684/P682 14 0.17 s 0.11 s SRI2/TLI1 subject join
q5 GROUP BY ?p with counts 6 2.4 s 1.55 s (same path) per-predicate counts
q6 ?s P1057 ?o1 . OPTIONAL { ?s P688 ?o2 } FILTER(isIRI(?o1)) 25,083 31.8 s 17.6 s 2 blocks; LeftJoin on the reference evaluator
s1 ?s P684 ?o LIMIT 10 10 0.66 s 0.03 s bounded prefix scan
o1 ?s P682 wd:Q14860489 0 0.05 s 0.01 s OLI2 object scan

The "before" column was measured while a WASM build competed for CPU; the two columns are therefore comparable only where the open mode changed (q3, q6). Every query returns the same row count and the same preview rows in both columns. q3's bytes read fell from 24.9 MB to 0.24 MB logical; q6's from 24.9 MB to 2.7 MB.

Reference points from the same store: the plain join ?s P1057 ?o1 . ?s P688 ?o2 returns 9,117 rows in 2.4 s through the subject-join path (4 shards, 3.0 MB read); ?s P1057 ?o1 alone returns 25,058 rows in 0.54 s.

Diagnoses#

Rung 2.5: the whole life-science extract, 1,290,077 triples#

All twelve Turtle files of examples/wikidata/subsets/lifesci-kgx/data/ concatenated (31,603,120 bytes), through the same path, measured 22:25 with nothing else running.

Step Result
Pack (ibk3) 52 blocks over 26 predicates, 35.8 s (36,000 triples/s)
Activate 50,744,391 logical bytes verified, 45.5 s
Generation on disk 103 MB
# Query shape Time Path
q1 COUNT(*) over everything 10.3 s full read of 52 blocks (50.7 MB)
q3 subject point lookup, unbound predicate 0.20 s TLI1/SRI2 probe of all 52 blocks
q4 two-pattern join P684/P682 0.05 s subject join, 6 shards
q5 GROUP BY ?p with counts 0.66 s per-predicate counts, 52 blocks
q6 OPTIONAL + FILTER 0.36 s 2 predicates, 6 shards, hash LeftJoin
s1 ?s P684 ?o LIMIT 10 0.02 s bounded prefix scan

The selective paths scale with the number of blocks probed (q3: 13 to 52 blocks, 0.12 s to 0.20 s), not with the triple count. The two costs that scale with bytes are activation (one full verification and index recomputation per generation, 45 s for 51 MB) and the whole-store count.

Rung 3: the UK Parliament dump, 3,143,406 triples#

third_party/data/ukparliament/ukparliament-rdf-2019-07-27.trig, 346,861,556 bytes, 5,325,830 lines, one unlabelled graph block (the TriG default graph). Converted to Turtle by dropping the two brace lines; packed with the same command as the rungs above. The content is dominated by e-petition signature counts (four predicates hold 2.2 million of the 3.1 million triples); the procedure-browser vocabulary the sample queries use is almost absent (29 :name triples, 405 typed entities), so the sample queries answer zero or few rows, correctly: the F* engine's recorded bench shows no rows for the same queries.

Step Result
Pack (ibk3), first run 309 blocks over 232 predicates, 6,134 s (512 triples/s; 70× slower per triple than rung 2.5)
Activate, first run 356,214,197 logical bytes verified, 4,554 s (78 KB/s)
Generation on disk 716 MB
Pack, after the scanner fix (2026-09-03, commit 0a3d30671) same 309 blocks, 254 s (12,400 triples/s)
Activate, after the scanner fix and decode-once activation (389b47f1a) 356,111,955 logical bytes verified, 165 s idle (2.2 MB/s)
Activate, after the TLI1 key and decoder changes (7be6b9f17, f5f0c9dee) same bytes, 152 s with two builds running on the machine
Activate, after the IBK3 and PTD1 decoders by byte-array index (2228793cd) same bytes, 104 s and 104 s (alternating runs against the pre-change binary: 1,931 s and 584 s)

Measurement caveat for this rung (2026-09-03): on the 16 GB MacBook the same binary on the same store gave 30 s and 985 s in two alternating runs (1,138,990 triples), and one run of the full-store activation took 1,017 s after a run of 152 s. The stalls coincide with memory pressure (the reference parse of the 134 MB region alone reaches 5.39 GB resident; the list-based decoders hold about sixteen bytes per artifact byte) and are not reproducible with the byte-array decoders, which were stable at 104 s. Report such numbers only from alternating runs on an idle machine, and say which runs were discarded and why.

Query (third_party/data/ukparliament/sparql/main/) Time Rows Path
enabling-legislation listing (5 OPTIONALs) 0.05 s 0 1 shard, 8 predicates named
enabling-legislation first-letter counts (BIND, GROUP BY) 1,125 s (39 s warm) → 0 s after cf36c8e90 0 full manifest, 309 blocks → 1 shard
legislatures, organisations, procedures, step collections, steps by type (6 queries) 0.03 to 0.05 s 0 or 1 1 to 2 shards
work packages current, count (MINUS with a property path) 85 s (46 s warm) → 0 s after cf36c8e90 1 full manifest → 5 shards
work packages current, listing 43 s 0 full manifest

What this rung teaches:

Findings from the first, failed attempt (2026-09-02, late):

Where the 6,134 s went (2026-09-02, late): the statement scanner#

Method. A size ladder of UK Parliament slices with no literal over 10 KB (the first such literal is at line 1,702,684 of 5,360,986), each packed and activated on an idle machine with the Turtle-fixed binary:

Lines Triples Blocks Pack Activate Pack per triple
20,019 10,305 18 0.40 s 0.46 s 38 µs
40,019 20,305 18 0.74 s 0.87 s 37 µs
80,019 40,305 22 1.39 s 1.59 s 34 µs
160,019 92,265 30 2.85 s 3.22 s 31 µs
320,019 370,355 47 12.75 s 15.87 s 34 µs

Linear in triples, and the block count (18 to 47) does not show. The gene store on the same idle machine: 888,949 triples, 13 blocks, pack 11.5 s, activate 13.4 s (13 µs per triple). So neither predicate count nor block count explains the full dump's 1,950 µs per triple.

The remaining candidate was the region of large literals: 345 lines over 100 KB, 105 MB of the 341 MB file, all inside lines 1,702,684 to 1,706,891. Cut as one 134 MB Turtle file (4,211 lines, 3,560 triples), it packed in 334 s with the committed binary — 100 s more than the whole linear ladder above put together.

Cause. Syntax/TurtleStatementScan.lean decides at every line end in normal mode whether the current candidate is a no-dot directive (PREFIX, BASE, VERSION), and did so by reversing the whole accumulated candidate (dropWs currentRev.reverse) to read its first word. That is O(lines × characters) per statement group: the polygon group has 4,211 lines and 134 MB, about 280 G list steps.

Fix. StatementScan now carries head, the first seven characters after leading whitespace, maintained per character by pushHead; the directive test reads it. The old form is kept as directiveHeadSpec, and Syntax/TurtleStatementScanTheorems.lean proves head = directiveHeadSpec currentRev for every run of the scanner from init (axioms: propext, Classical.choice, Quot.sound only).

After, same region: pack 81 s, activate 109 s with the three-pass activation and 62 s with the decode-once activation of commit 389b47f1a (both measured with another pack running on the machine). The whole dump: pack 254 s (was 6,134 s), activation 165 s idle (was 4,554 s). The remaining 81 s is the reference parse (31 s for this file: 19 s user, 9 s system reading 134 MB as a character list) plus the term codec, PTD1 pages, TLI1 keys and Merkle leaves over 105 MB of literal bytes; the activate is four full decodes per block (ShardActivate.verify* each decode the primary again) over the same bytes. Those are the next two items for this rung, with the large-literal policy question above.

Stage profile after the fixes (2026-09-03, temporary IO.monoMsNow timers, two runs each; the 134 MB region, 3,560 triples, 28 blocks):

Pack stage Seconds Share
pre-pass SHA-256 7.0 to 8.0 9 to 10%
chunk decode + scanner feed 10.5 to 11.3 13 to 14%
grammar (consumeCandidates) 4.5 to 4.8 5 to 6%
IBK3 encode 20.9 to 21.0 26%
SRI2/TLI1/OLI2 sidecar build 18.1 to 19.0 23%
Merkle leaf hashing + writes 13.0 to 13.6 16 to 17%
total real 81 to 82
Activate stage Seconds
full SHA-256 + Merkle rebuild 13.2 to 14.8
index sidecars (one decode, three comparisons) 43.9 to 46.1
paged materialize 9.4 to 9.6
total real 66.5 to 70.7

On the 370,355-triple slice without large literals the shares are similar but the absolute index-sidecar cost is 9 s, so both largest stages track literal bytes, not triple count. The two lines that do the work: IBK3 encode? converts the PTD1 dictionary ByteArray to a List UInt8 and back with ++ (IndexedBlockWireV3.lean, dictionary.data.toList then byteArrayOfList); TLI1 entriesOf builds a List UInt8 key per term and sorts with a cons-cell comparator (TermLocalIndex.lean, lessBytes). Both were dispatched as bounded changes with byte-identity and theorem gates. Outcome: the IBK3 encoder change (ce7db9def) saved the predicted 20 s. The TLI1 change (ByteArray keys, mergeSort with entriesOf_eq_spec) saved 2 to 3 s, because a sampler run (/usr/bin/sample) showed the 46 s index-sidecar stage is 17 s primary IBK3 decode plus 16 s TermLocalIndexWire.decode? (which re-serializes every term to check its stored key and walks the file as a List UInt8); entriesOf was 2.2 s and its sort 0.1 s. The code-reading attribution to the sort was wrong (anti-pattern 28: state the method next to the result; a sampler sees what a reading cannot). Next bounded item: the two decoders over ByteArray indices with the list forms kept as the specification. The reference parser on the same file: 22.9 s user, 21.7 s system, 5.39 GB maximum resident set (the List Char representation; design record section 2.4).

Gates for the scanner change: all 378 artifacts of the 320,019-line slice byte-identical between the old and new scanner; W3C RDF 1.1 Turtle 313 and TriG 356, RDF 1.2 Turtle 67 + 29 and TriG 35 + 25, all 0 fail; native-smoke 63 pass (out of 63); lake build 922 jobs. The scanner is used only by Harness/PredicateShardPack.lean, so the WASM mirrors are unchanged.

Status#

Milestone table, 2026-09-02 evening#

Single cold runs on the same store, everything of the day landed; a WASM build was competing for CPU during these runs, so the quiet numbers are a little lower (q1 measures 3.94 s quiet).

# Query shape Rows Morning Evening Path
q1 COUNT(*) over everything 1 12.5 s 7.5 s (3.9 s quiet) full read, 13 blocks, HACL* Merkle verification
q2 COUNT of P684 (759,263 rows) 1 1.95 s 1.3 s per-predicate count
q3 subject point lookup, unbound predicate 3 10.0 s 0.12 s TLI1/SRI2 subject point
q4 two-pattern join P684/P682 14 0.17 s 0.07 s SRI2/TLI1 subject join, hash join
q5 GROUP BY ?p with counts 6 2.4 s 0.62 s per-predicate counts
q6 ?s P1057 ?o1 . OPTIONAL { ?s P688 ?o2 } FILTER(isIRI(?o1)) 25,083 31.8 s 0.86 s 2 blocks, hash LeftJoin on the backend arm
s1 ?s P684 ?o LIMIT 10 10 0.66 s 0.03 s bounded prefix scan
o1 ?s P682 wd:Q14860489 0 0.05 s 0.01 s OLI2 object scan

Every row count and preview is unchanged from the morning. What backs the numbers: the codecs' round-trip theorems (spec section 10.1), the hash join and hash LeftJoin equalities, the backend-arm theorems, the encoder admission equal to the decoder's, and the HACL* SHA-256 differential probe in CI. What remains outside a theorem: the planner's choice of blocks (argued in docstrings, checked by the census and the row-count comparisons) and the extern hasher's agreement with the specification (checked by the probe).