@dxos/datalog 0.0.1 → 0.13.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/LICENSE +105 -0
- package/README.md +58 -1
- package/dist/lib/Ast.mjs +72 -0
- package/dist/lib/Ast.mjs.map +1 -0
- package/dist/lib/Builtin.mjs +37 -0
- package/dist/lib/Builtin.mjs.map +1 -0
- package/dist/lib/Checker.mjs +3 -0
- package/dist/lib/Engine.mjs +3 -0
- package/dist/lib/Parser.mjs +2 -0
- package/dist/lib/chunk-Checker.mjs +414 -0
- package/dist/lib/chunk-Checker.mjs.map +1 -0
- package/dist/lib/chunk-Engine.mjs +488 -0
- package/dist/lib/chunk-Engine.mjs.map +1 -0
- package/dist/lib/chunk-Parser.mjs +366 -0
- package/dist/lib/chunk-Parser.mjs.map +1 -0
- package/dist/lib/chunk-rolldown-runtime.mjs +13 -0
- package/dist/lib/index.mjs +6 -0
- package/dist/types/src/Ast.d.ts +81 -0
- package/dist/types/src/Ast.d.ts.map +1 -0
- package/dist/types/src/Builtin.d.ts +35 -0
- package/dist/types/src/Builtin.d.ts.map +1 -0
- package/dist/types/src/Checker.d.ts +61 -0
- package/dist/types/src/Checker.d.ts.map +1 -0
- package/dist/types/src/Checker.test.d.ts +2 -0
- package/dist/types/src/Checker.test.d.ts.map +1 -0
- package/dist/types/src/Engine.d.ts +55 -0
- package/dist/types/src/Engine.d.ts.map +1 -0
- package/dist/types/src/Engine.test.d.ts +2 -0
- package/dist/types/src/Engine.test.d.ts.map +1 -0
- package/dist/types/src/Engine.workerd.test.d.ts +2 -0
- package/dist/types/src/Engine.workerd.test.d.ts.map +1 -0
- package/dist/types/src/Parser.d.ts +43 -0
- package/dist/types/src/Parser.d.ts.map +1 -0
- package/dist/types/src/Parser.test.d.ts +2 -0
- package/dist/types/src/Parser.test.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +6 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/internal/lexer.d.ts +46 -0
- package/dist/types/src/internal/lexer.d.ts.map +1 -0
- package/dist/types/src/internal/plan.d.ts +96 -0
- package/dist/types/src/internal/plan.d.ts.map +1 -0
- package/dist/types/src/internal/relation.d.ts +20 -0
- package/dist/types/src/internal/relation.d.ts.map +1 -0
- package/dist/types/src/internal/stratify.d.ts +28 -0
- package/dist/types/src/internal/stratify.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +57 -5
- package/src/Ast.ts +136 -0
- package/src/Builtin.ts +73 -0
- package/src/Checker.test.ts +99 -0
- package/src/Checker.ts +156 -0
- package/src/Engine.test.ts +239 -0
- package/src/Engine.ts +614 -0
- package/src/Engine.workerd.test.ts +33 -0
- package/src/Parser.test.ts +84 -0
- package/src/Parser.ts +180 -0
- package/src/index.ts +9 -0
- package/src/internal/lexer.ts +154 -0
- package/src/internal/plan.ts +346 -0
- package/src/internal/relation.ts +95 -0
- package/src/internal/stratify.ts +114 -0
package/src/Engine.ts
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
// @import-as-namespace
|
|
6
|
+
|
|
7
|
+
import * as Ast from './Ast.ts';
|
|
8
|
+
import type * as Builtin from './Builtin.ts';
|
|
9
|
+
import * as Checker from './Checker.ts';
|
|
10
|
+
import { type Plan, type Ref, Slots, type Step, planBody } from './internal/plan.ts';
|
|
11
|
+
import { Relation, compareValues, tupleKey, valueKey } from './internal/relation.ts';
|
|
12
|
+
import { dependencies, stratify } from './internal/stratify.ts';
|
|
13
|
+
|
|
14
|
+
/** A tuple of a named relation. */
|
|
15
|
+
export type Entry = { readonly relation: string; readonly tuple: Ast.Tuple };
|
|
16
|
+
|
|
17
|
+
/** Tuples that entered or left each relation during one update. */
|
|
18
|
+
export type Changes = {
|
|
19
|
+
readonly added: ReadonlyMap<string, ReadonlyArray<Ast.Tuple>>;
|
|
20
|
+
readonly removed: ReadonlyMap<string, ReadonlyArray<Ast.Tuple>>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** One way a tuple was derived: the rule and the relation tuples its positive literals matched. */
|
|
24
|
+
export type Derivation = { readonly rule: number; readonly premises: ReadonlyArray<Entry> };
|
|
25
|
+
|
|
26
|
+
export type Options = {
|
|
27
|
+
readonly program: Ast.Program;
|
|
28
|
+
readonly builtins?: Builtin.Registry;
|
|
29
|
+
/** Base relations and their arities; enables the `undefined` check. */
|
|
30
|
+
readonly relations?: Readonly<Record<string, number>>;
|
|
31
|
+
/** Record one derivation per derived tuple (default true). */
|
|
32
|
+
readonly provenance?: boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type Update = {
|
|
36
|
+
readonly insert?: Iterable<Entry>;
|
|
37
|
+
readonly retract?: Iterable<Entry>;
|
|
38
|
+
/** Re-evaluate rules that use volatile built-ins (a clock tick). */
|
|
39
|
+
readonly refresh?: boolean;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type CompiledRule = {
|
|
43
|
+
readonly index: number;
|
|
44
|
+
readonly relation: string;
|
|
45
|
+
readonly head: ReadonlyArray<Ref>;
|
|
46
|
+
readonly slots: number;
|
|
47
|
+
readonly naive: ReadonlyArray<Step>;
|
|
48
|
+
/** Plans that scan the given positive literal first, for semi-naive deltas. */
|
|
49
|
+
readonly delta: ReadonlyMap<number, { readonly relation: string; readonly steps: ReadonlyArray<Step> }>;
|
|
50
|
+
readonly generatorOnly: boolean;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
type CompiledStratum = {
|
|
54
|
+
readonly predicates: ReadonlySet<string>;
|
|
55
|
+
readonly rules: ReadonlyArray<CompiledRule>;
|
|
56
|
+
readonly positive: ReadonlySet<string>;
|
|
57
|
+
readonly nonMonotone: ReadonlySet<string>;
|
|
58
|
+
readonly volatile: boolean;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
type Accumulator = { added: Map<string, Ast.Tuple[]>; removed: Map<string, Ast.Tuple[]> };
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Stratified semi-naive evaluator that keeps its model between updates: insertions propagate
|
|
65
|
+
* incrementally, and a stratum is recomputed (and diffed) only when something it reads
|
|
66
|
+
* non-monotonically changed.
|
|
67
|
+
*/
|
|
68
|
+
export class Engine {
|
|
69
|
+
readonly #builtins: Builtin.Registry;
|
|
70
|
+
readonly #provenance: boolean;
|
|
71
|
+
readonly #strata: ReadonlyArray<CompiledStratum>;
|
|
72
|
+
readonly #idb: ReadonlySet<string>;
|
|
73
|
+
readonly #arities = new Map<string, number>();
|
|
74
|
+
readonly #relations = new Map<string, Relation>();
|
|
75
|
+
readonly #base = new Map<string, Relation>();
|
|
76
|
+
readonly #derivations = new Map<string, Map<string, Derivation>>();
|
|
77
|
+
|
|
78
|
+
/** @throws Checker.CheckError if the program has diagnostics. */
|
|
79
|
+
constructor(options: Options) {
|
|
80
|
+
this.#builtins = options.builtins ?? new Map();
|
|
81
|
+
this.#provenance = options.provenance ?? true;
|
|
82
|
+
const diagnostics = Checker.check(options.program, { builtins: this.#builtins, relations: options.relations });
|
|
83
|
+
if (diagnostics.length > 0) {
|
|
84
|
+
throw new Checker.CheckError(diagnostics);
|
|
85
|
+
}
|
|
86
|
+
const stratification = stratify(options.program.rules, this.#builtins);
|
|
87
|
+
if (!stratification.ok) {
|
|
88
|
+
throw new Error('Unreachable: checked programs are stratifiable.');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const [name, arity] of Object.entries(options.relations ?? {})) {
|
|
92
|
+
this.#arities.set(name, arity);
|
|
93
|
+
}
|
|
94
|
+
const rules = options.program.rules;
|
|
95
|
+
const visit = (literal: Ast.Literal) => {
|
|
96
|
+
if (literal.type === 'atom' && !this.#builtins.has(literal.atom.predicate)) {
|
|
97
|
+
this.#arities.set(literal.atom.predicate, literal.atom.terms.length);
|
|
98
|
+
} else if (literal.type === 'aggregate') {
|
|
99
|
+
literal.body.forEach(visit);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
for (const rule of rules) {
|
|
103
|
+
this.#arities.set(rule.head.predicate, rule.head.terms.length);
|
|
104
|
+
rule.body.forEach(visit);
|
|
105
|
+
}
|
|
106
|
+
this.#idb = new Set(stratification.strata.flatMap((stratum) => [...stratum.predicates]));
|
|
107
|
+
this.#strata = stratification.strata.map((stratum) => {
|
|
108
|
+
const compiled = stratum.rules.map((index) => this.#compileRule(index, rules[index]));
|
|
109
|
+
const positive = new Set<string>();
|
|
110
|
+
const nonMonotone = new Set<string>();
|
|
111
|
+
for (const index of stratum.rules) {
|
|
112
|
+
for (const literal of rules[index].body) {
|
|
113
|
+
for (const dependency of dependencies(literal, this.#builtins)) {
|
|
114
|
+
if (!stratum.predicates.has(dependency.predicate)) {
|
|
115
|
+
(dependency.negative ? nonMonotone : positive).add(dependency.predicate);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
predicates: stratum.predicates,
|
|
122
|
+
rules: compiled,
|
|
123
|
+
positive,
|
|
124
|
+
nonMonotone,
|
|
125
|
+
volatile: stratum.rules.some((index) => rules[index].body.some((literal) => this.#isVolatile(literal))),
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const accumulator: Accumulator = { added: new Map(), removed: new Map() };
|
|
130
|
+
for (const rule of rules.filter((rule) => rule.body.length === 0)) {
|
|
131
|
+
const tuple = rule.head.terms.flatMap((term) => (term.type === 'constant' ? [term.value] : []));
|
|
132
|
+
this.#addBase(rule.head.predicate, tuple, accumulator);
|
|
133
|
+
}
|
|
134
|
+
for (const stratum of this.#strata) {
|
|
135
|
+
this.#recompute(stratum, accumulator);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Applies retractions, then insertions, then propagates; returns what changed. */
|
|
140
|
+
update(update: Update): Changes {
|
|
141
|
+
const accumulator: Accumulator = { added: new Map(), removed: new Map() };
|
|
142
|
+
const dirty = new Set<string>();
|
|
143
|
+
for (const { relation, tuple } of update.retract ?? []) {
|
|
144
|
+
const key = tupleKey(tuple);
|
|
145
|
+
if (this.#base.get(relation)?.delete(key)) {
|
|
146
|
+
if (this.#idb.has(relation)) {
|
|
147
|
+
dirty.add(relation);
|
|
148
|
+
} else if (this.#relation(relation).delete(key)) {
|
|
149
|
+
push(accumulator.removed, relation, tuple);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
for (const { relation, tuple } of update.insert ?? []) {
|
|
154
|
+
if (this.#addBase(relation, tuple, accumulator) && this.#idb.has(relation)) {
|
|
155
|
+
dirty.add(relation);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
for (const stratum of this.#strata) {
|
|
160
|
+
const changed = (relation: string) =>
|
|
161
|
+
(accumulator.added.get(relation)?.length ?? 0) + (accumulator.removed.get(relation)?.length ?? 0) > 0;
|
|
162
|
+
const full =
|
|
163
|
+
(update.refresh && stratum.volatile) ||
|
|
164
|
+
[...stratum.predicates].some((predicate) => dirty.has(predicate)) ||
|
|
165
|
+
[...stratum.nonMonotone].some(changed) ||
|
|
166
|
+
[...stratum.positive].some((relation) => (accumulator.removed.get(relation)?.length ?? 0) > 0);
|
|
167
|
+
if (full) {
|
|
168
|
+
this.#recompute(stratum, accumulator);
|
|
169
|
+
} else {
|
|
170
|
+
this.#propagate(stratum, accumulator);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return accumulator;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Inserts tuples into one relation. */
|
|
177
|
+
insert(relation: string, ...tuples: ReadonlyArray<Ast.Tuple>): Changes {
|
|
178
|
+
return this.update({ insert: tuples.map((tuple) => ({ relation, tuple })) });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Tuples of `relation`, optionally restricted to those matching the defined positions of `pattern`. */
|
|
182
|
+
query(relation: string, pattern: ReadonlyArray<Ast.Value | undefined> = []): Ast.Tuple[] {
|
|
183
|
+
const columns: number[] = [];
|
|
184
|
+
const values: Ast.Value[] = [];
|
|
185
|
+
pattern.forEach((value, column) => {
|
|
186
|
+
if (value !== undefined) {
|
|
187
|
+
columns.push(column);
|
|
188
|
+
values.push(value);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
return [...(this.#relations.get(relation)?.lookup(columns, values) ?? [])];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
has(relation: string, tuple: Ast.Tuple): boolean {
|
|
195
|
+
return this.#relations.get(relation)?.has(tupleKey(tuple)) ?? false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The recorded derivation of a tuple; undefined for base tuples and absent ones. */
|
|
199
|
+
derivation(relation: string, tuple: Ast.Tuple): Derivation | undefined {
|
|
200
|
+
return this.#derivations.get(relation)?.get(tupleKey(tuple));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The base tuples one derivation of `tuple` rests on (the tuple itself if it is a base tuple). */
|
|
204
|
+
provenance(relation: string, tuple: Ast.Tuple): Entry[] {
|
|
205
|
+
const result: Entry[] = [];
|
|
206
|
+
const visited = new Set<string>();
|
|
207
|
+
const visit = (entry: Entry) => {
|
|
208
|
+
const key = `${entry.relation}\u0002${tupleKey(entry.tuple)}`;
|
|
209
|
+
if (visited.has(key)) {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
visited.add(key);
|
|
213
|
+
const derivation = this.derivation(entry.relation, entry.tuple);
|
|
214
|
+
if (derivation) {
|
|
215
|
+
derivation.premises.forEach(visit);
|
|
216
|
+
} else if (this.#base.get(entry.relation)?.has(tupleKey(entry.tuple))) {
|
|
217
|
+
result.push(entry);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
if (this.has(relation, tuple)) {
|
|
221
|
+
visit({ relation, tuple });
|
|
222
|
+
}
|
|
223
|
+
return result;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
#compileRule(index: number, rule: Ast.Rule): CompiledRule {
|
|
227
|
+
const slots = new Slots();
|
|
228
|
+
const outer = new Set(Ast.variablesOf(rule.head.terms));
|
|
229
|
+
const naive = planBody({ body: rule.body, builtins: this.#builtins, slots, outer });
|
|
230
|
+
const delta = new Map<number, { relation: string; steps: ReadonlyArray<Step> }>();
|
|
231
|
+
rule.body.forEach((literal, literalIndex) => {
|
|
232
|
+
if (literal.type === 'atom' && !literal.negated && !this.#builtins.has(literal.atom.predicate)) {
|
|
233
|
+
const plan: Plan = planBody({ body: rule.body, builtins: this.#builtins, slots, outer, first: literalIndex });
|
|
234
|
+
delta.set(literalIndex, { relation: literal.atom.predicate, steps: plan.steps });
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
return {
|
|
238
|
+
index,
|
|
239
|
+
relation: rule.head.predicate,
|
|
240
|
+
head: rule.head.terms.map((term) => slots.ref(term)),
|
|
241
|
+
slots: slots.size,
|
|
242
|
+
naive: naive.steps,
|
|
243
|
+
delta,
|
|
244
|
+
generatorOnly: delta.size === 0,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
#isVolatile(literal: Ast.Literal): boolean {
|
|
249
|
+
switch (literal.type) {
|
|
250
|
+
case 'atom':
|
|
251
|
+
return this.#builtins.get(literal.atom.predicate)?.volatile === true;
|
|
252
|
+
case 'comparison':
|
|
253
|
+
return false;
|
|
254
|
+
case 'aggregate':
|
|
255
|
+
return literal.body.some((inner) => this.#isVolatile(inner));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
#relation(name: string): Relation {
|
|
260
|
+
let relation = this.#relations.get(name);
|
|
261
|
+
if (!relation) {
|
|
262
|
+
relation = new Relation();
|
|
263
|
+
this.#relations.set(name, relation);
|
|
264
|
+
}
|
|
265
|
+
return relation;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
#addBase(relation: string, tuple: Ast.Tuple, accumulator: Accumulator): boolean {
|
|
269
|
+
const arity = this.#arities.get(relation);
|
|
270
|
+
if (arity === undefined) {
|
|
271
|
+
this.#arities.set(relation, tuple.length);
|
|
272
|
+
} else if (arity !== tuple.length) {
|
|
273
|
+
throw new Error(`Arity mismatch: ${relation}/${tuple.length} inserted where ${relation}/${arity} is expected`);
|
|
274
|
+
}
|
|
275
|
+
let base = this.#base.get(relation);
|
|
276
|
+
if (!base) {
|
|
277
|
+
base = new Relation();
|
|
278
|
+
this.#base.set(relation, base);
|
|
279
|
+
}
|
|
280
|
+
const key = tupleKey(tuple);
|
|
281
|
+
if (!base.add(tuple, key)) {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
if (!this.#idb.has(relation) && this.#relation(relation).add(tuple, key)) {
|
|
285
|
+
push(accumulator.added, relation, tuple);
|
|
286
|
+
}
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Re-derives a stratum from scratch and records the difference from its previous contents. */
|
|
291
|
+
#recompute(stratum: CompiledStratum, accumulator: Accumulator) {
|
|
292
|
+
const previous = new Map<string, Relation>();
|
|
293
|
+
for (const predicate of stratum.predicates) {
|
|
294
|
+
previous.set(predicate, this.#relation(predicate));
|
|
295
|
+
const fresh = new Relation();
|
|
296
|
+
for (const [key, tuple] of this.#base.get(predicate)?.entries() ?? []) {
|
|
297
|
+
fresh.add(tuple, key);
|
|
298
|
+
}
|
|
299
|
+
this.#relations.set(predicate, fresh);
|
|
300
|
+
this.#derivations.delete(predicate);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const delta = this.#commit(
|
|
304
|
+
stratum.rules.flatMap((rule) => this.#run(rule, rule.naive)),
|
|
305
|
+
undefined,
|
|
306
|
+
);
|
|
307
|
+
this.#iterate(stratum, delta);
|
|
308
|
+
|
|
309
|
+
for (const predicate of stratum.predicates) {
|
|
310
|
+
const before = previous.get(predicate) ?? new Relation();
|
|
311
|
+
const after = this.#relation(predicate);
|
|
312
|
+
for (const [key, tuple] of after.entries()) {
|
|
313
|
+
if (!before.has(key)) {
|
|
314
|
+
push(accumulator.added, predicate, tuple);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
for (const [key, tuple] of before.entries()) {
|
|
318
|
+
if (!after.has(key)) {
|
|
319
|
+
push(accumulator.removed, predicate, tuple);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Semi-naive propagation of tuples added to relations below the stratum. */
|
|
326
|
+
#propagate(stratum: CompiledStratum, accumulator: Accumulator) {
|
|
327
|
+
const derived: Emitted[] = [];
|
|
328
|
+
for (const rule of stratum.rules) {
|
|
329
|
+
if (rule.generatorOnly) {
|
|
330
|
+
derived.push(...this.#run(rule, rule.naive));
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
for (const { relation, steps } of rule.delta.values()) {
|
|
334
|
+
const tuples = accumulator.added.get(relation);
|
|
335
|
+
if (tuples && tuples.length > 0 && !stratum.predicates.has(relation)) {
|
|
336
|
+
derived.push(...this.#run(rule, steps, tuples));
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const delta = this.#commit(derived, accumulator);
|
|
341
|
+
this.#iterate(stratum, delta, accumulator);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Runs recursive rules of the stratum to fixpoint from `delta`. */
|
|
345
|
+
#iterate(stratum: CompiledStratum, initial: Map<string, Ast.Tuple[]>, accumulator?: Accumulator) {
|
|
346
|
+
let delta = initial;
|
|
347
|
+
while (delta.size > 0) {
|
|
348
|
+
const derived: Emitted[] = [];
|
|
349
|
+
for (const rule of stratum.rules) {
|
|
350
|
+
for (const { relation, steps } of rule.delta.values()) {
|
|
351
|
+
const tuples = delta.get(relation);
|
|
352
|
+
if (tuples && stratum.predicates.has(relation)) {
|
|
353
|
+
derived.push(...this.#run(rule, steps, tuples));
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
delta = this.#commit(derived, accumulator);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Adds derived tuples; returns the new ones by relation. */
|
|
362
|
+
#commit(derived: ReadonlyArray<Emitted>, accumulator: Accumulator | undefined): Map<string, Ast.Tuple[]> {
|
|
363
|
+
const delta = new Map<string, Ast.Tuple[]>();
|
|
364
|
+
for (const { relation, tuple, derivation } of derived) {
|
|
365
|
+
const key = tupleKey(tuple);
|
|
366
|
+
if (this.#relation(relation).add(tuple, key)) {
|
|
367
|
+
push(delta, relation, tuple);
|
|
368
|
+
if (accumulator) {
|
|
369
|
+
push(accumulator.added, relation, tuple);
|
|
370
|
+
}
|
|
371
|
+
if (derivation) {
|
|
372
|
+
let derivations = this.#derivations.get(relation);
|
|
373
|
+
if (!derivations) {
|
|
374
|
+
derivations = new Map();
|
|
375
|
+
this.#derivations.set(relation, derivations);
|
|
376
|
+
}
|
|
377
|
+
derivations.set(key, derivation);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return delta;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Evaluates a rule plan; when `delta` is given, the plan's first scan reads it instead of the relation. */
|
|
385
|
+
#run(rule: CompiledRule, steps: ReadonlyArray<Step>, delta?: ReadonlyArray<Ast.Tuple>): Emitted[] {
|
|
386
|
+
const emitted: Emitted[] = [];
|
|
387
|
+
const binding: Array<Ast.Value | undefined> = new Array(rule.slots).fill(undefined);
|
|
388
|
+
const premises: Entry[] = [];
|
|
389
|
+
this.#solve(steps, 0, binding, premises, delta, () => {
|
|
390
|
+
const tuple = rule.head.map((ref) => resolveBound(ref, binding));
|
|
391
|
+
emitted.push({
|
|
392
|
+
relation: rule.relation,
|
|
393
|
+
tuple,
|
|
394
|
+
derivation: this.#provenance ? { rule: rule.index, premises: [...premises] } : undefined,
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
return emitted;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
#solve(
|
|
401
|
+
steps: ReadonlyArray<Step>,
|
|
402
|
+
position: number,
|
|
403
|
+
binding: Array<Ast.Value | undefined>,
|
|
404
|
+
premises: Entry[],
|
|
405
|
+
delta: ReadonlyArray<Ast.Tuple> | undefined,
|
|
406
|
+
emit: () => void,
|
|
407
|
+
): void {
|
|
408
|
+
if (position === steps.length) {
|
|
409
|
+
emit();
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const step = steps[position];
|
|
413
|
+
const next = () => this.#solve(steps, position + 1, binding, premises, delta, emit);
|
|
414
|
+
switch (step.kind) {
|
|
415
|
+
case 'scan': {
|
|
416
|
+
const values = step.bound.map(({ ref }) => resolveBound(ref, binding));
|
|
417
|
+
const tuples =
|
|
418
|
+
position === 0 && delta
|
|
419
|
+
? delta.filter((tuple) => step.bound.every(({ column }, at) => tuple[column] === values[at]))
|
|
420
|
+
: this.#relation(step.relation).lookup(
|
|
421
|
+
step.bound.map(({ column }) => column),
|
|
422
|
+
values,
|
|
423
|
+
);
|
|
424
|
+
for (const tuple of tuples) {
|
|
425
|
+
const assigned: number[] = [];
|
|
426
|
+
let matches = true;
|
|
427
|
+
for (const { column, slot } of step.binds) {
|
|
428
|
+
const current = binding[slot];
|
|
429
|
+
if (current === undefined) {
|
|
430
|
+
binding[slot] = tuple[column];
|
|
431
|
+
assigned.push(slot);
|
|
432
|
+
} else if (current !== tuple[column]) {
|
|
433
|
+
matches = false;
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if (matches) {
|
|
438
|
+
premises.push({ relation: step.relation, tuple });
|
|
439
|
+
next();
|
|
440
|
+
premises.pop();
|
|
441
|
+
}
|
|
442
|
+
for (const slot of assigned) {
|
|
443
|
+
binding[slot] = undefined;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
case 'negation': {
|
|
449
|
+
const matches = this.#relation(step.relation).lookup(
|
|
450
|
+
step.bound.map(({ column }) => column),
|
|
451
|
+
step.bound.map(({ ref }) => resolveBound(ref, binding)),
|
|
452
|
+
);
|
|
453
|
+
if (isEmpty(matches)) {
|
|
454
|
+
next();
|
|
455
|
+
}
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
case 'compare': {
|
|
459
|
+
if (step.assign) {
|
|
460
|
+
binding[step.assign.slot] = resolve(step.assign.from, binding);
|
|
461
|
+
next();
|
|
462
|
+
binding[step.assign.slot] = undefined;
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const left = resolve(step.left, binding);
|
|
466
|
+
const right = resolve(step.right, binding);
|
|
467
|
+
if (left !== undefined && right !== undefined && compare(step.operator, left, right)) {
|
|
468
|
+
next();
|
|
469
|
+
}
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
case 'builtin': {
|
|
473
|
+
const args = step.args.map((ref, index) => (step.free[index] ? undefined : resolve(ref, binding)));
|
|
474
|
+
const results = step.builtin.evaluate(args);
|
|
475
|
+
if (step.negated) {
|
|
476
|
+
if (isEmpty(results)) {
|
|
477
|
+
next();
|
|
478
|
+
}
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
for (const result of results) {
|
|
482
|
+
if (result.length !== step.args.length) {
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
const assigned: number[] = [];
|
|
486
|
+
let matches = true;
|
|
487
|
+
for (let index = 0; index < step.args.length && matches; index++) {
|
|
488
|
+
const ref = step.args[index];
|
|
489
|
+
if (ref.kind === 'wild') {
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
if (ref.kind === 'slot' && binding[ref.slot] === undefined) {
|
|
493
|
+
binding[ref.slot] = result[index];
|
|
494
|
+
assigned.push(ref.slot);
|
|
495
|
+
} else {
|
|
496
|
+
matches = resolve(ref, binding) === result[index];
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (matches) {
|
|
500
|
+
next();
|
|
501
|
+
}
|
|
502
|
+
for (const slot of assigned) {
|
|
503
|
+
binding[slot] = undefined;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
case 'aggregate': {
|
|
509
|
+
const seen = new Set<string>();
|
|
510
|
+
const targets: Ast.Value[] = [];
|
|
511
|
+
const matched: Entry[] = [];
|
|
512
|
+
this.#solve(step.steps, 0, binding, matched, undefined, () => {
|
|
513
|
+
// Matched tuples join the key so anonymous columns still count as distinct rows.
|
|
514
|
+
const key = [
|
|
515
|
+
...step.local.map((slot) => valueKey(resolveBound({ kind: 'slot', slot }, binding))),
|
|
516
|
+
...matched.map(({ relation, tuple }) => `${relation}\u0002${tupleKey(tuple)}`),
|
|
517
|
+
].join('\u0001');
|
|
518
|
+
if (!seen.has(key)) {
|
|
519
|
+
seen.add(key);
|
|
520
|
+
const target = step.target ? resolve(step.target, binding) : undefined;
|
|
521
|
+
if (target !== undefined) {
|
|
522
|
+
targets.push(target);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
const value = aggregate(step.function, seen.size, targets);
|
|
527
|
+
if (value === undefined) {
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
if (step.assignResult && step.result.kind === 'slot') {
|
|
531
|
+
binding[step.result.slot] = value;
|
|
532
|
+
next();
|
|
533
|
+
binding[step.result.slot] = undefined;
|
|
534
|
+
} else if (step.result.kind === 'wild' || resolve(step.result, binding) === value) {
|
|
535
|
+
next();
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Creates an engine and evaluates the program's facts. */
|
|
543
|
+
export const make = (options: Options): Engine => new Engine(options);
|
|
544
|
+
|
|
545
|
+
type Emitted = { readonly relation: string; readonly tuple: Ast.Tuple; readonly derivation?: Derivation };
|
|
546
|
+
|
|
547
|
+
const push = (map: Map<string, Ast.Tuple[]>, relation: string, tuple: Ast.Tuple) => {
|
|
548
|
+
const list = map.get(relation);
|
|
549
|
+
if (list) {
|
|
550
|
+
list.push(tuple);
|
|
551
|
+
} else {
|
|
552
|
+
map.set(relation, [tuple]);
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
const resolve = (ref: Ref, binding: ReadonlyArray<Ast.Value | undefined>): Ast.Value | undefined => {
|
|
557
|
+
switch (ref.kind) {
|
|
558
|
+
case 'const':
|
|
559
|
+
return ref.value;
|
|
560
|
+
case 'slot':
|
|
561
|
+
return binding[ref.slot];
|
|
562
|
+
case 'wild':
|
|
563
|
+
return undefined;
|
|
564
|
+
}
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
/** Planned steps only read slots an earlier step bound. */
|
|
568
|
+
const resolveBound = (ref: Ref, binding: ReadonlyArray<Ast.Value | undefined>): Ast.Value => {
|
|
569
|
+
const value = resolve(ref, binding);
|
|
570
|
+
if (value === undefined) {
|
|
571
|
+
throw new Error('Unbound variable in planned step.');
|
|
572
|
+
}
|
|
573
|
+
return value;
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
const isEmpty = (iterable: Iterable<unknown>): boolean => iterable[Symbol.iterator]().next().done === true;
|
|
577
|
+
|
|
578
|
+
const compare = (operator: Ast.ComparisonOperator, left: Ast.Value, right: Ast.Value): boolean => {
|
|
579
|
+
switch (operator) {
|
|
580
|
+
case '=':
|
|
581
|
+
return left === right;
|
|
582
|
+
case '!=':
|
|
583
|
+
return left !== right;
|
|
584
|
+
case '<':
|
|
585
|
+
return compareValues(left, right) < 0;
|
|
586
|
+
case '<=':
|
|
587
|
+
return compareValues(left, right) <= 0;
|
|
588
|
+
case '>':
|
|
589
|
+
return compareValues(left, right) > 0;
|
|
590
|
+
case '>=':
|
|
591
|
+
return compareValues(left, right) >= 0;
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
const aggregate = (
|
|
596
|
+
fn: Ast.AggregateFunction,
|
|
597
|
+
count: number,
|
|
598
|
+
targets: ReadonlyArray<Ast.Value>,
|
|
599
|
+
): Ast.Value | undefined => {
|
|
600
|
+
switch (fn) {
|
|
601
|
+
case 'count':
|
|
602
|
+
return count;
|
|
603
|
+
case 'sum':
|
|
604
|
+
return targets.reduce<number>((sum, value) => sum + (typeof value === 'number' ? value : Number(value)), 0);
|
|
605
|
+
case 'min':
|
|
606
|
+
return targets.length === 0
|
|
607
|
+
? undefined
|
|
608
|
+
: targets.reduce((min, value) => (compareValues(value, min) < 0 ? value : min));
|
|
609
|
+
case 'max':
|
|
610
|
+
return targets.length === 0
|
|
611
|
+
? undefined
|
|
612
|
+
: targets.reduce((max, value) => (compareValues(value, max) > 0 ? value : max));
|
|
613
|
+
}
|
|
614
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { describe, test } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import * as Builtin from './Builtin.ts';
|
|
8
|
+
import * as Engine from './Engine.ts';
|
|
9
|
+
import * as Parser from './Parser.ts';
|
|
10
|
+
|
|
11
|
+
describe('Engine in workerd', () => {
|
|
12
|
+
test('runs inside the Cloudflare Workers runtime', ({ expect }) => {
|
|
13
|
+
expect(globalThis).toHaveProperty('navigator.userAgent', 'Cloudflare-Workers');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('evaluates a stratified program incrementally', ({ expect }) => {
|
|
17
|
+
const engine = Engine.make({
|
|
18
|
+
builtins: Builtin.registry(Builtin.predicate('positive', 1, ([value]) => Number(value) > 0)),
|
|
19
|
+
program: Parser.parse(`
|
|
20
|
+
path(X, Y) :- edge(X, Y).
|
|
21
|
+
path(X, Z) :- path(X, Y), edge(Y, Z).
|
|
22
|
+
weighted(X, N) :- node(X), N = count : { edge(X, _) }, positive(N).
|
|
23
|
+
isolated(X) :- node(X), not path(X, _).
|
|
24
|
+
`),
|
|
25
|
+
});
|
|
26
|
+
engine.insert('node', ['a'], ['b'], ['c']);
|
|
27
|
+
expect(engine.query('isolated')).toHaveLength(3);
|
|
28
|
+
const changes = engine.insert('edge', ['a', 'b'], ['b', 'c']);
|
|
29
|
+
expect(changes.added.get('path')).toHaveLength(3);
|
|
30
|
+
expect(engine.query('isolated')).toEqual([['c']]);
|
|
31
|
+
expect(engine.provenance('path', ['a', 'c'])).toHaveLength(2);
|
|
32
|
+
});
|
|
33
|
+
});
|