Skip to content

Commit 7b2c32c

Browse files
Eric Prud'hommeauxclaude
andcommitted
+ sparql-to-a3.js: section-4 bottom-up compilation mode
compileText(..., {mode:'bottomup'}) emits the SPARQL-faithful form of doc/sparql-to-algae3.md section 4: every group/union-branch/MINUS operand becomes scope(...) share(its SPARQL in-scope variables); group filters join after all operands; OPTIONAL-immediate filters are absorbed outside the scope at the LeftJoin (rule 3), where outer names stay visible; out-of-scope expression variables are renamed apart to fresh ?v_N (rule 2). FILTER NOT EXISTS compiles to ! with a comment that its bottom-up reading is Minus. The explorer's Compile button gains a section-3/section-4 selector. Differential-verified: all 7 evaluable .rq examples compiled in bottom-up mode match their goldens - nested-optional reproduces SPARQL's {v=1,w=9} (the from-sparql-bottomup golden, ?v renamed ?v_1 at the absorbed inner LeftJoin) and negation-scoped matches its bottom-up rendering; the well-designed remainder agree across modes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 4713eb2 commit 7b2c32c

2 files changed

Lines changed: 133 additions & 24 deletions

File tree

‎SPARQL-explorer.html‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,11 @@ <h2>Algae 3 pipeline (evaluated)</h2>
8484
</div>
8585
<div>
8686
<h2>Source SPARQL
87-
<button onclick="compileSparql()" style="margin-left:8px">Compile &rarr; a3</button>
87+
<select id="cmode" style="margin-left:8px;font-size:11px">
88+
<option value="topdown">&sect;3 top-down</option>
89+
<option value="bottomup">&sect;4 bottom-up (SPARQL-faithful)</option>
90+
</select>
91+
<button onclick="compileSparql()">Compile &rarr; a3</button>
8892
<span id="cstat" style="font-weight:normal;font-size:11px;color:#888"></span></h2>
8993
<textarea id="sparql" rows="14" placeholder="# paste raw SPARQL (.rq) here and Compile - or reference only"></textarea>
9094
</div>
@@ -231,8 +235,11 @@ <h2>Result set <span id="rowcount" style="font-weight:normal;font-size:11px;colo
231235
import("https://esm.sh/sparqljs?bundle").then(m => m.default ?? m),
232236
import("./sparql-to-a3.js"),
233237
]);
234-
$("script").value = s2a3.compileText(q, SparqlJs.Parser);
235-
$("cstat").textContent = "compiled (top-down structural translation)";
238+
const m = $("cmode").value;
239+
$("script").value = s2a3.compileText(q, SparqlJs.Parser, null, { mode: m });
240+
$("cstat").textContent = "compiled (" + (m === "bottomup"
241+
? "sec. 4: scoped groups, fresh vars, absorbed OPTIONAL filters"
242+
: "sec. 3: top-down structural") + ")";
236243
} catch (e) {
237244
$("cstat").textContent = "compile error: " + e.message;
238245
}

‎sparql-to-a3.js‎

Lines changed: 123 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ const FUNS = { // SPARQL builtin -> a3 call name (manifesty.yaml map)
6060
const AGGS = new Set(["count", "sum", "min", "max", "avg", "sample", "group_concat"]);
6161

6262
function expr(e, ctx) {
63+
if (e.termType === "Variable" && ctx?.rename && !ctx.inScope.has("?" + e.value))
64+
return ctx.fresh(e.value); // rule 2: out-of-scope -> renamed apart
6365
if (e.termType != null) return term(e);
6466
if (e.type === "aggregate") {
6567
const name = e.aggregation.toLowerCase();
@@ -117,62 +119,98 @@ function bgp(triples, ind) {
117119
return lines.join(" .\n" + ind);
118120
}
119121

120-
/** compile a sparqljs pattern ARRAY (one group's contents) */
121-
function group(patterns, ind) {
122+
/** wrap a group's compilation in scope(...) share(its in-scope vars) */
123+
function scoped(patterns, ind, ctx) {
124+
const V = [...inScope(patterns)];
125+
const inner = { ...ctx, inScope: inScope(patterns, new Set(ctx.inScope)) };
126+
return "scope ( " + group(patterns, ind + " ", inner) + " )"
127+
+ " share (" + V.join(" ") + ")";
128+
}
129+
130+
/** compile a sparqljs pattern ARRAY (one group's contents).
131+
* ctx = {mode:"topdown"|"bottomup", inScope:Set, fresh, rename:bool} */
132+
function group(patterns, ind, ctx = { mode: "topdown" }) {
133+
const bu = ctx.mode === "bottomup";
134+
const ectx = bu ? { ...ctx, rename: true,
135+
inScope: inScope(patterns, new Set(ctx.inScope)) } : undefined;
136+
const filters = []; // bottom-up: group filters apply after all joins
122137
const parts = []; // each a conjunct string
123138
for (const p of patterns) {
124139
switch (p.type) {
125140
case "bgp":
126141
if (p.triples.length) parts.push(bgp(p.triples, ind));
127142
break;
128143
case "optional":
129-
parts.push("~( " + group(p.patterns, ind + " ") + " )");
144+
if (bu) {
145+
/* rule 3: absorb the optional group's IMMEDIATE filters - they sit
146+
* outside the scope, at the LeftJoin, where outer names stay visible */
147+
const direct = p.patterns.filter(x => x.type === "filter"
148+
&& x.expression?.operator?.toLowerCase() !== "notexists");
149+
const rest = p.patterns.filter(x => !direct.includes(x));
150+
const joinScope = inScope(rest, new Set(ectx.inScope));
151+
const jctx = { ...ctx, rename: true, inScope: joinScope };
152+
let o = scoped(rest, ind + " ", ctx);
153+
for (const f of direct)
154+
o += " .\n" + ind + " {" + expr(f.expression, jctx) + "}";
155+
parts.push(direct.length ? "~( " + o + " )" : "~ " + o);
156+
} else
157+
parts.push("~( " + group(p.patterns, ind + " ", ctx) + " )");
130158
break;
131159
case "minus": {
132160
/* MINUS binds to the group built so far: (acc |! (P)) */
133161
const acc = parts.length ? parts.join(" .\n" + ind) : "";
134-
const rhs = "( " + group(p.patterns, ind + " ") + " )";
162+
const rhs = bu ? "( " + scoped(p.patterns, ind + " ", ctx) + " )"
163+
: "( " + group(p.patterns, ind + " ", ctx) + " )";
135164
parts.length = 0;
136165
parts.push(acc ? "( " + acc + "\n" + ind + " |! " + rhs + " )" : "|! " + rhs);
137166
break;
138167
}
139168
case "union":
140-
parts.push("( " + p.patterns.map(b =>
141-
group(b.type === "group" ? b.patterns : [b], ind + " ")).join("\n" + ind + "|&\n" + ind + " ") + " )");
169+
parts.push("( " + p.patterns.map(b => {
170+
const ps = b.type === "group" ? b.patterns : [b];
171+
return bu ? scoped(ps, ind + " ", ctx) : group(ps, ind + " ", ctx);
172+
}).join("\n" + ind + "|&\n" + ind + " ") + " )");
142173
break;
143174
case "group":
144-
parts.push("( " + group(p.patterns, ind + " ") + " )");
175+
parts.push(bu ? scoped(p.patterns, ind + " ", ctx)
176+
: "( " + group(p.patterns, ind + " ", ctx) + " )");
145177
break;
146178
case "graph":
147-
parts.push("in " + term(p.name) + " ( " + group(p.patterns, ind + " ") + " )");
179+
parts.push("in " + term(p.name) + " ( "
180+
+ (bu ? scoped(p.patterns, ind + " ", ctx) : group(p.patterns, ind + " ", ctx)) + " )");
148181
break;
149182
case "filter": {
150183
const op = p.expression?.operator?.toLowerCase();
151184
if (op === "notexists") {
152185
const arg = p.expression.args[0];
153-
parts.push("!( " + group(arg.patterns ?? [arg], ind + " ") + " )");
186+
/* NB in bottom-up mode ! evaluates as Minus; SPARQL NOT EXISTS's
187+
* substitution semantics are the top-down reading */
188+
parts.push("!( " + group(arg.patterns ?? [arg], ind + " ", ctx) + " )");
154189
}
155190
else if (op === "exists")
156191
throw new Error("unsupported: FILTER EXISTS (use the pattern directly)");
192+
else if (bu)
193+
filters.push("{" + expr(p.expression, ectx) + "}"); // group filters join last
157194
else
158195
parts.push("{" + expr(p.expression) + "}");
159196
break;
160197
}
161198
case "bind":
162-
parts.push("let (" + term(p.variable) + " " + expr(p.expression) + ")");
199+
parts.push("let (" + term(p.variable) + " " + expr(p.expression, ectx) + ")");
163200
break;
164201
case "values":
165202
parts.push(valuesBlock(p.values, ind));
166203
break;
167204
case "query":
168-
parts.push(subselect(p, ind));
205+
parts.push(subselect(p, ind, ctx));
169206
break;
170207
case "service":
171208
throw new Error("unsupported: SERVICE");
172209
default:
173210
throw new Error("unsupported pattern: " + p.type);
174211
}
175212
}
213+
parts.push(...filters);
176214
return parts.join(" .\n" + ind);
177215
}
178216

@@ -183,6 +221,58 @@ function valuesBlock(values, ind) {
183221
return "bindings (" + vars.join(" ") + ") { " + rows.join(" ") + " }";
184222
}
185223

224+
/* ── section 4 (bottom-up) machinery ────────────────────────────────────── */
225+
226+
/** SPARQL in-scope variables of a pattern list (18.2.1-ish) */
227+
function inScope(patterns, into = new Set()) {
228+
for (const p of patterns ?? []) {
229+
switch (p.type) {
230+
case "bgp":
231+
for (const t of p.triples)
232+
for (const k of ["subject", "predicate", "object"])
233+
if (t[k].termType === "Variable") into.add("?" + t[k].value);
234+
break;
235+
case "bind": into.add(term(p.variable)); break;
236+
case "values": for (const v of Object.keys(p.values[0] ?? {})) into.add(v); break;
237+
case "graph":
238+
if (p.name.termType === "Variable") into.add(term(p.name));
239+
inScope(p.patterns, into); break;
240+
case "optional": case "group": case "union":
241+
inScope(p.patterns, into); break;
242+
case "query":
243+
for (const v of p.variables ?? [])
244+
if (v.termType === "Variable") into.add(term(v));
245+
else if (v.variable) into.add(term(v.variable));
246+
break;
247+
case "minus": case "filter": break; // not in-scope contributors
248+
}
249+
}
250+
return into;
251+
}
252+
253+
/** fresh-name allocator over the whole query's variables (?v -> ?v_1) */
254+
function freshener(allVars) {
255+
const made = new Map();
256+
return name => {
257+
if (made.has(name)) return made.get(name);
258+
let n = 1;
259+
while (allVars.has("?" + name + "_" + n)) ++n;
260+
const f = "?" + name + "_" + n;
261+
allVars.add(f);
262+
made.set(name, f);
263+
return f;
264+
};
265+
}
266+
267+
function allQueryVars(node, into = new Set()) {
268+
if (!node || typeof node !== "object") return into;
269+
if (node.termType === "Variable") into.add("?" + node.value);
270+
for (const v of Object.values(node))
271+
if (Array.isArray(v)) v.forEach(x => allQueryVars(x, into));
272+
else if (v && typeof v === "object") allQueryVars(v, into);
273+
return into;
274+
}
275+
186276
/* projections + solution modifiers shared by SELECT and subselect */
187277
function collectClause(q, ind) {
188278
let proj;
@@ -211,14 +301,15 @@ function collectClause(q, ind) {
211301
}
212302

213303
/** a nested SELECT becomes a scoped sub-pipeline sharing its projection */
214-
function subselect(q, ind) {
304+
function subselect(q, ind, ctx = { mode: "topdown" }) {
215305
if (q.queryType !== "SELECT") throw new Error("unsupported nested query: " + q.queryType);
216306
const inner = ind + " ";
217307
const shared = q.variables.some(v => v.termType === "Wildcard")
218308
? [...collectVars(q.where)]
219309
: q.variables.map(v => v.termType === "Variable" ? term(v) : term(v.variable));
220310
const { collect, having } = collectClause(q, inner);
221-
let body = "ask ( " + group(q.where, inner) + " )\n" + inner + collect;
311+
const sctx = { ...ctx, inScope: new Set() }; // subselect isolates
312+
let body = "ask ( " + group(q.where, inner, sctx) + " )\n" + inner + collect;
222313
for (const h of having) body += "\n" + inner + h;
223314
return "scope ( " + body + "\n" + ind + " ) share (" + shared.join(" ") + ")";
224315
}
@@ -243,10 +334,21 @@ function collectVars(patterns, into = new Set()) {
243334

244335
/* ── whole queries ──────────────────────────────────────────────────────── */
245336

246-
export function compile(ast) {
337+
export function compile(ast, opts = {}) {
338+
const mode = opts.mode === "bottomup" ? "bottomup" : "topdown";
339+
const ctx = { mode, inScope: new Set(),
340+
fresh: freshener(allQueryVars(ast)) };
247341
const out = [];
248-
out.push("# compiled from SPARQL (sparql-to-algae3.md section 3, top-down)");
249-
out.push("require <http://www.w3.org/ns/algae3#eval-topdown>");
342+
if (mode === "bottomup") {
343+
out.push("# compiled from SPARQL (sparql-to-algae3.md section 4, bottom-up:");
344+
out.push("# groups scoped to their in-scope variables; out-of-scope");
345+
out.push("# expression variables renamed apart; OPTIONAL-immediate");
346+
out.push("# filters absorbed at the LeftJoin)");
347+
out.push("require <http://www.w3.org/ns/algae3#eval-bottomup>");
348+
} else {
349+
out.push("# compiled from SPARQL (sparql-to-algae3.md section 3, top-down)");
350+
out.push("require <http://www.w3.org/ns/algae3#eval-topdown>");
351+
}
250352
for (const [pfx, iri] of Object.entries(ast.prefixes ?? {}))
251353
out.push("prefix " + pfx + ": <" + iri + ">");
252354
if (ast.base) out.push("base <" + ast.base + ">");
@@ -258,16 +360,16 @@ export function compile(ast) {
258360
switch (ast.queryType) {
259361
case "SELECT": {
260362
const { collect, having } = collectClause(ast, "");
261-
out.push("ask ( " + group(ast.where, ind) + " )");
363+
out.push("ask ( " + group(ast.where, ind, ctx) + " )");
262364
out.push(collect);
263365
for (const h of having) out.push(h);
264366
break;
265367
}
266368
case "ASK":
267-
out.push("test ( " + group(ast.where, ind) + " )");
369+
out.push("test ( " + group(ast.where, ind, ctx) + " )");
268370
break;
269371
case "CONSTRUCT":
270-
out.push("ask ( " + group(ast.where, ind) + " )");
372+
out.push("ask ( " + group(ast.where, ind, ctx) + " )");
271373
out.push("assert ( " + bgp(ast.template ?? [], ind) + " )");
272374
break;
273375
default:
@@ -277,7 +379,7 @@ export function compile(ast) {
277379
}
278380

279381
/** parse text with a sparqljs Parser instance and compile */
280-
export function compileText(sparqlText, SparqlParser, baseIRI) {
382+
export function compileText(sparqlText, SparqlParser, baseIRI, opts) {
281383
const parser = new SparqlParser(baseIRI ? { baseIRI } : {});
282-
return compile(parser.parse(sparqlText));
384+
return compile(parser.parse(sparqlText), opts ?? {});
283385
}

0 commit comments

Comments
 (0)