Every other page in this series runs one engine: the F* specification,
extracted to OCaml, compiled to JavaScript. This page runs two. The
second is the Lean 4 port
— separate source, separate proof assistant, separate compiler —
compiled Lean → C → wasm32, with Lean's runtime and core library
rebuilt for wasm32
(skills/lean4-wasm-export).
Both engines below parse the same Turtle text and answer the same
SPARQL text, through typed wrappers with the same shape on each side:
fn.l4Parse/fn.l4Query for Lean, fn.parse/fn.query for F*.
The Lean wrappers hold the parsed data as a HANDLE inside the wasm
module rather than as N-Quads text passed back and forth
(issue #585) —
fn.l4Parse calls datasetOpen once in
Wasm/Ops/Handles.lean
and every later fn.l4Query call carries only that handle. When the
two engines return the same rows, the specification is doing the
work; when they disagree, one of them has a bug, and it shows here
rather than in a conformance report.
fn.loadL4() instantiates the module (once per page) and
fn.l4Version() asks the Lean code to identify itself. The string comes
out of Lean's String type, across the C boundary, into JavaScript.
leanVersion = fn.l4Version()
Four triples about two people, and a SPARQL query that joins ?s :name ?n with ?s :age ?a — the same subject variable in both patterns, so
an answer row must satisfy them together.
EX = "http://example.org/"
PEOPLE_TTL = `
@prefix : <${EX}> .
:alice :name "Alice" ; :age 30 .
:bob :name "Bob" ; :age 24 .
`
NAME_AGE_QUERY = `
# People with both a name and an age, joined on the shared subject.
SELECT ?s ?n ?a WHERE {
?s <${EX}name> ?n .
?s <${EX}age> ?a .
}
`
fn.l4Parse parses the Turtle into a dataset handle; fn.l4Query runs
the SPARQL SELECT against that handle. Same two-step shape as the F*
cells below — parse once into a dataset, query the dataset.
leanDataset = fn.l4Parse(PEOPLE_TTL)
leanNameAge = {
const rows = await fn.l4Query(leanDataset, NAME_AGE_QUERY);
return rows.map((m) => Object.fromEntries([...m].map(([k, t]) => [k, t.value])));
}
fn.parse and fn.query are the F*-derived engine every other page
in this series runs — the same wrapper the F* cells on
post 38 use.
fstarDataset = fn.parse(PEOPLE_TTL)
fstarNameAge = {
const res = await fn.query(fstarDataset, NAME_AGE_QUERY);
return res.map((m) => Object.fromEntries([...m].map(([k, t]) => [k, t.value])));
}
A BGP's answer is a set: the comparison sorts each row's own keys, then sorts the row list, so neither key order nor row order can produce a false disagreement.
agreement = {
const key = (r) => Object.keys(r).sort().map((k) => `${k}=${r[k]}`).join("|");
const lean = leanNameAge.map(key).sort();
const fstar = fstarNameAge.map(key).sort();
return {
agree: JSON.stringify(lean) === JSON.stringify(fstar),
leanRows: lean.length,
fstarRows: fstar.length,
};
}
?a above is an xsd:integer, not a string — SPARQL 1.1 §17.3
defines > over numeric operands, not over their lexical form. Both
engines run the identical ASK against the identical data.
ASK_QUERY = `
# Is there anyone older than 25? FILTER keeps only ?a values that
# satisfy the numeric comparison.
ASK { ?s <${EX}age> ?a . FILTER(?a > 25) }`
leanAsk = fn.l4Query(leanDataset, ASK_QUERY)
fstarAsk = fn.query(fstarDataset, ASK_QUERY)
askAgreement = ({ agree: leanAsk === fstarAsk, lean: leanAsk, fstar: fstarAsk })
That agreement is not proof of anything on its own — both engines could
misread the same clause of the SPARQL Query Language recommendation in
the same way. What it does buy is independence: the Lean evaluator was
written from the specification text, not from the F* source, so a
shared bug now needs the same misreading to have happened twice. Where
the F* tree carries theorems about its evaluator, the Lean tree
carries its own
(Invariants.lean
proves BGP monotonicity and the merge/lookup characterisation, with
#print axioms in the build log to show nothing was assumed).
The Lean engine parses N-Triples/N-Quads, Turtle, TriG and RDF/XML,
parses and evaluates SPARQL query and update, runs the RDFS and OWL 2
RL closures, and canonicalizes with RDFC-1.0 — all through the wasm
module's dispatch entry, reached above through the typed
fn.l4Parse/fn.l4Query wrappers over datasetOpen/datasetQuery.
Post 38 drives the same dispatch
surface directly, one call at a time through fn.l4Call, ending in
its own cross-engine agreement check.