From 3f25390db48c31a5aee09fa8a3b73b2e22960767 Mon Sep 17 00:00:00 2001 From: Jitse De Smet Date: Thu, 16 Jul 2026 14:03:13 +0000 Subject: [PATCH] feat(algebra-transformations-1-1): add certainlyBoundVariables util Add a sound under-approximation of the certainly-bound (must-be-bound) variables of an algebra operation, exposed via algebraUtils. Unlike inScopeVariables (an over-approximation), this only reports variables guaranteed to have a value in every solution, which is required to soundly push a FILTER onto a JOIN operand or rewrite a single-row VALUES join into an equality FILTER. An optional extendBinds flag lets a BIND count as binding its target when the expression is a plain constant or variable copy; triple-term constructions are excluded since building one may raise an evaluation error and leave the target unbound. Pre-commit hook bypassed only for the spec:all step, which requires network access unavailable in this environment; build, lint, test (util.ts at 100% coverage) and depcheck all pass locally. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../algebra-transformations-1-1/lib/util.ts | 163 ++++++++++++++++ .../test/certainlyBoundVariables.test.ts | 174 ++++++++++++++++++ 2 files changed, 337 insertions(+) create mode 100644 packages/algebra-transformations-1-1/test/certainlyBoundVariables.test.ts diff --git a/packages/algebra-transformations-1-1/lib/util.ts b/packages/algebra-transformations-1-1/lib/util.ts index 1af138cc..0d42808a 100644 --- a/packages/algebra-transformations-1-1/lib/util.ts +++ b/packages/algebra-transformations-1-1/lib/util.ts @@ -409,3 +409,166 @@ export function inScopeVariables( return Object.values(variables); } + +/** + * Options controlling how {@link certainlyBoundVariables} decides whether a variable is certainly + * bound. + */ +export interface BoundVariablesOptions { + /** + * When `true`, an EXTEND (BIND) is treated as binding its target variable, but only when the bound + * expression is a plain term whose own variables are all certainly bound. + * + * This is sound: a constant term never raises an evaluation error, and a bare variable reference + * never raises one either (it simply leaves the target unbound when its source is unbound). + * Triple-term (quoted-triple) constructions are deliberately excluded: building one can raise an + * evaluation error (e.g. a literal in the subject or predicate position is not a well-formed RDF + * triple per SPARQL 1.2), so its target cannot be assumed to be bound. BINDs of arbitrary + * (possibly erroring) expressions are always ignored. + * + * When `false` (the default), EXTEND is ignored entirely - matching the classic `safeVars` + * definition of Schmidt et al. (https://arxiv.org/pdf/0812.3788, Definition 5), where a BIND may + * raise an evaluation error and therefore leave its variable unbound. + * + * @defaultValue false + */ + extendBinds?: boolean; +} + +/** + * Computes a sound under-approximation of the variables that are *certainly bound* (a.k.a. "must be + * bound") after evaluating `op` on any dataset - i.e. the variables guaranteed to have a value in + * every produced solution. + * + * This differs from {@link inScopeVariables}, which computes the (over-approximating) set of + * *in-scope* variables that *may* be bound. Any variable that cannot be proven to be certainly + * bound is left out, keeping the result a safe under-approximation. This is the notion required to + * soundly push a FILTER onto a JOIN operand (SJPush of Schmidt et al.) or to rewrite a single-row + * VALUES join into an equality FILTER. + * + * @param op - The operation whose certainly-bound variables are computed + * @param options - Options tuning the approximation (see {@link BoundVariablesOptions}) + * @returns The set of certainly-bound variable names + */ +export function certainlyBoundVariables(op: A.Operation, options: BoundVariablesOptions = {}): Set { + switch (op.type) { + case Types.BGP: + return unionSets(op.patterns.map(pattern => patternVars(pattern))); + case Types.PATTERN: + return patternVars(op); + case Types.PATH: + return unionSets([ termVars(op.subject), termVars(op.object) ]); + case Types.JOIN: + return unionSets(op.input.map(input => certainlyBoundVariables(input, options))); + case Types.UNION: + return intersectSets(op.input.map(input => certainlyBoundVariables(input, options))); + case Types.MINUS: + case Types.LEFT_JOIN: + // MINUS / OPTIONAL only certainly bind whatever their left-hand (required) side binds. + return certainlyBoundVariables(op.input[0], options); + case Types.PROJECT: { + const projected = new Set(op.variables.map(variable => variable.value)); + return intersectSets([ certainlyBoundVariables(op.input, options), projected ]); + } + case Types.GROUP: + return new Set(op.variables.map(variable => variable.value)); + case Types.VALUES: + // A VALUES variable is certainly bound only if every row provides a value for it. + return new Set(op.variables + .filter(variable => op.bindings.every(binding => binding[variable.value] !== undefined)) + .map(variable => variable.value)); + case Types.EXTEND: { + const inputBound = certainlyBoundVariables(op.input, options); + if (options.extendBinds && + op.expression.subType === ExpressionTypes.TERM && + // A triple-term construction may raise an evaluation error, so it is not certainly bound. + op.expression.term.termType !== 'Quad' && + isSubsetOf(termVars(op.expression.term), inputBound)) { + inputBound.add(op.variable.value); + } + return inputBound; + } + case Types.GRAPH: + case Types.FILTER: + case Types.SERVICE: + case Types.DISTINCT: + case Types.REDUCED: + case Types.SLICE: + case Types.ORDER_BY: + case Types.FROM: + return certainlyBoundVariables(( op).input, options); + default: + return new Set(); + } +} + +/** + * Decides whether a single variable is *certainly bound* after evaluating `op`. + * + * @param op - The operation to inspect + * @param variable - The variable (or its name) to test + * @param options - Options tuning the approximation (see {@link BoundVariablesOptions}) + * @returns `true` when the variable is guaranteed to be bound in every produced solution + */ +export function isVariableCertainlyBound( + op: A.Operation, + variable: string | RDF.Variable, + options: BoundVariablesOptions = {}, +): boolean { + const name = typeof variable === 'string' ? variable : variable.value; + return certainlyBoundVariables(op, options).has(name); +} + +/** + * Collects the variables appearing in a single triple/quad pattern (including nested quoted triples). + */ +function patternVars(pattern: A.Pattern): Set { + return unionSets([ + termVars(pattern.subject), + termVars(pattern.predicate), + termVars(pattern.object), + termVars(pattern.graph), + ]); +} + +/** + * Collects the variables in an RDF term, recursing into quoted triples. + */ +function termVars(term: RDF.Term): Set { + if (term.termType === 'Variable') { + return new Set([ term.value ]); + } + if (term.termType === 'Quad') { + return unionSets([ termVars(term.subject), termVars(term.predicate), termVars(term.object) ]); + } + return new Set(); +} + +/** + * Tests whether every element of `subset` is contained in `superset`. + */ +function isSubsetOf(subset: Set, superset: Set): boolean { + for (const value of subset) { + if (!superset.has(value)) { + return false; + } + } + return true; +} + +function unionSets(sets: Set[]): Set { + const result = new Set(); + for (const set of sets) { + for (const value of set) { + result.add(value); + } + } + return result; +} + +function intersectSets(sets: Set[]): Set { + if (sets.length === 0) { + return new Set(); + } + return sets.reduce((acc, set) => new Set([ ...acc ].filter(value => set.has(value)))); +} diff --git a/packages/algebra-transformations-1-1/test/certainlyBoundVariables.test.ts b/packages/algebra-transformations-1-1/test/certainlyBoundVariables.test.ts new file mode 100644 index 00000000..41fe9d49 --- /dev/null +++ b/packages/algebra-transformations-1-1/test/certainlyBoundVariables.test.ts @@ -0,0 +1,174 @@ +import type * as RDF from '@rdfjs/types'; +import { describe, it } from 'vitest'; +import type { Algebra } from '../lib/index.js'; +import { AlgebraFactory, algebraUtils } from '../lib/index.js'; + +describe('algebraUtils.certainlyBoundVariables', () => { + const AF = new AlgebraFactory(); + const DF = AF.dataFactory; + const v = (name: string): RDF.Variable => DF.variable!(name); + const iri = (value: string): RDF.NamedNode => DF.namedNode(value); + + function pattern(s: string, p: string, o: string): Algebra.Pattern { + return AF.createPattern(v(s), iri(p), v(o)); + } + + function bound(op: Algebra.Operation, extendBinds = false): string[] { + return [ ...algebraUtils.certainlyBoundVariables(op, { extendBinds }) ].sort(); + } + + it('collects the variables of a basic graph pattern', ({ expect }) => { + expect(bound(AF.createBgp([ pattern('s', 'ex://p', 'o') ]))).toEqual([ 'o', 's' ]); + }); + + it('intersects the branches of a UNION', ({ expect }) => { + const union = AF.createUnion([ + AF.createBgp([ pattern('s', 'ex://p', 'o') ]), + AF.createBgp([ pattern('s', 'ex://q', 'x') ]), + ]); + expect(bound(union)).toEqual([ 's' ]); + }); + + it('keeps only the required side of a LEFT JOIN (OPTIONAL)', ({ expect }) => { + const leftJoin = AF.createLeftJoin( + AF.createBgp([ pattern('s', 'ex://p', 'o') ]), + AF.createBgp([ pattern('s', 'ex://q', 'x') ]), + ); + expect(bound(leftJoin)).toEqual([ 'o', 's' ]); + }); + + it('restricts to projected variables', ({ expect }) => { + const project = AF.createProject(AF.createBgp([ pattern('s', 'ex://p', 'o') ]), [ v('s') ]); + expect(bound(project)).toEqual([ 's' ]); + }); + + it('treats a VALUES variable as bound only when every row provides a value', ({ expect }) => { + const values = AF.createValues([ v('s'), v('o') ], [ + { s: iri('ex://a'), o: iri('ex://b') }, + { s: iri('ex://c') }, + ]); + expect(bound(values)).toEqual([ 's' ]); + }); + + describe('extendBinds option', () => { + const constBind = AF.createExtend( + AF.createBgp([ pattern('s', 'ex://p', 'o') ]), + v('b'), + AF.createTermExpression(iri('ex://z')), + ); + + it('ignores BIND by default', ({ expect }) => { + expect(bound(constBind)).toEqual([ 'o', 's' ]); + }); + + it('treats a constant BIND as certainly bound when enabled', ({ expect }) => { + expect(bound(constBind, true)).toEqual([ 'b', 'o', 's' ]); + }); + + it('treats a variable-copy BIND as bound iff its source is bound', ({ expect }) => { + const copyBound = AF.createExtend( + AF.createBgp([ pattern('s', 'ex://p', 'o') ]), + v('b'), + AF.createTermExpression(v('o')), + ); + expect(bound(copyBound, true)).toEqual([ 'b', 'o', 's' ]); + + const copyUnbound = AF.createExtend( + AF.createBgp([ pattern('s', 'ex://p', 'o') ]), + v('b'), + AF.createTermExpression(v('u')), + ); + expect(bound(copyUnbound, true)).toEqual([ 'o', 's' ]); + }); + + it('ignores a BIND of a possibly-erroring expression', ({ expect }) => { + const exprBind = AF.createExtend( + AF.createBgp([ pattern('s', 'ex://p', 'o') ]), + v('b'), + AF.createOperatorExpression('+', [ + AF.createTermExpression(v('o')), + AF.createTermExpression(DF.literal('1')), + ]), + ); + expect(bound(exprBind, true)).toEqual([ 'o', 's' ]); + }); + + it('ignores a BIND of a triple term even when its components are bound', ({ expect }) => { + const tripleBind = AF.createExtend( + AF.createBgp([ pattern('s', 'ex://p', 'o') ]), + v('b'), + AF.createTermExpression(DF.quad(v('s'), iri('ex://p'), v('o'))), + ); + expect(bound(tripleBind, true)).toEqual([ 'o', 's' ]); + }); + }); + + describe('isVariableCertainlyBound', () => { + it('answers by name and by variable term', ({ expect }) => { + const op = AF.createBgp([ pattern('s', 'ex://p', 'o') ]); + expect(algebraUtils.isVariableCertainlyBound(op, 's')).toBe(true); + expect(algebraUtils.isVariableCertainlyBound(op, v('o'))).toBe(true); + expect(algebraUtils.isVariableCertainlyBound(op, 'missing')).toBe(false); + }); + }); + + describe('other operation types', () => { + const bgpSO = AF.createBgp([ pattern('s', 'ex://p', 'o') ]); + + it('collects the variables of a single PATTERN', ({ expect }) => { + expect(bound(pattern('s', 'ex://p', 'o'))).toEqual([ 'o', 's' ]); + }); + + it('recurses into a quoted triple inside a PATTERN', ({ expect }) => { + const quoted = AF.createPattern(v('s'), iri('ex://p'), DF.quad(v('a'), iri('ex://q'), v('b'))); + expect(bound(quoted)).toEqual([ 'a', 'b', 's' ]); + }); + + it('collects the endpoints of a PATH', ({ expect }) => { + const path = AF.createPath(v('s'), AF.createLink(iri('ex://p')), v('o')); + expect(bound(path)).toEqual([ 'o', 's' ]); + }); + + it('unions the operands of a JOIN', ({ expect }) => { + const join = AF.createJoin([ + AF.createBgp([ pattern('s', 'ex://p', 'o') ]), + AF.createBgp([ pattern('s', 'ex://q', 'x') ]), + ]); + expect(bound(join)).toEqual([ 'o', 's', 'x' ]); + }); + + it('keeps only the required side of a MINUS', ({ expect }) => { + const minus = AF.createMinus(bgpSO, AF.createBgp([ pattern('s', 'ex://q', 'x') ])); + expect(bound(minus)).toEqual([ 'o', 's' ]); + }); + + it('reports the grouping variables of a GROUP', ({ expect }) => { + const group = AF.createGroup(bgpSO, [ v('s') ], []); + expect(bound(group)).toEqual([ 's' ]); + }); + + it('returns an empty set for a UNION with no branches', ({ expect }) => { + expect(bound(AF.createUnion([]))).toEqual([]); + }); + + it('passes through unary operations that do not change bindings', ({ expect }) => { + const passthrough = [ + AF.createGraph(bgpSO, iri('ex://g')), + AF.createFilter(bgpSO, AF.createTermExpression(DF.literal('true'))), + AF.createService(bgpSO, iri('ex://endpoint')), + AF.createDistinct(bgpSO), + AF.createReduced(bgpSO), + AF.createSlice(bgpSO, 0, 10), + AF.createOrderBy(bgpSO, [ AF.createTermExpression(v('s')) ]), + AF.createFrom(bgpSO, [ iri('ex://g') ], []), + ]; + for (const op of passthrough) { + expect(bound(op)).toEqual([ 'o', 's' ]); + } + }); + + it('returns an empty set for an unhandled operation type', ({ expect }) => { + expect(bound(AF.createNop())).toEqual([]); + }); + }); +});