@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.
Files changed (61) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +58 -1
  3. package/dist/lib/Ast.mjs +72 -0
  4. package/dist/lib/Ast.mjs.map +1 -0
  5. package/dist/lib/Builtin.mjs +37 -0
  6. package/dist/lib/Builtin.mjs.map +1 -0
  7. package/dist/lib/Checker.mjs +3 -0
  8. package/dist/lib/Engine.mjs +3 -0
  9. package/dist/lib/Parser.mjs +2 -0
  10. package/dist/lib/chunk-Checker.mjs +414 -0
  11. package/dist/lib/chunk-Checker.mjs.map +1 -0
  12. package/dist/lib/chunk-Engine.mjs +488 -0
  13. package/dist/lib/chunk-Engine.mjs.map +1 -0
  14. package/dist/lib/chunk-Parser.mjs +366 -0
  15. package/dist/lib/chunk-Parser.mjs.map +1 -0
  16. package/dist/lib/chunk-rolldown-runtime.mjs +13 -0
  17. package/dist/lib/index.mjs +6 -0
  18. package/dist/types/src/Ast.d.ts +81 -0
  19. package/dist/types/src/Ast.d.ts.map +1 -0
  20. package/dist/types/src/Builtin.d.ts +35 -0
  21. package/dist/types/src/Builtin.d.ts.map +1 -0
  22. package/dist/types/src/Checker.d.ts +61 -0
  23. package/dist/types/src/Checker.d.ts.map +1 -0
  24. package/dist/types/src/Checker.test.d.ts +2 -0
  25. package/dist/types/src/Checker.test.d.ts.map +1 -0
  26. package/dist/types/src/Engine.d.ts +55 -0
  27. package/dist/types/src/Engine.d.ts.map +1 -0
  28. package/dist/types/src/Engine.test.d.ts +2 -0
  29. package/dist/types/src/Engine.test.d.ts.map +1 -0
  30. package/dist/types/src/Engine.workerd.test.d.ts +2 -0
  31. package/dist/types/src/Engine.workerd.test.d.ts.map +1 -0
  32. package/dist/types/src/Parser.d.ts +43 -0
  33. package/dist/types/src/Parser.d.ts.map +1 -0
  34. package/dist/types/src/Parser.test.d.ts +2 -0
  35. package/dist/types/src/Parser.test.d.ts.map +1 -0
  36. package/dist/types/src/index.d.ts +6 -0
  37. package/dist/types/src/index.d.ts.map +1 -0
  38. package/dist/types/src/internal/lexer.d.ts +46 -0
  39. package/dist/types/src/internal/lexer.d.ts.map +1 -0
  40. package/dist/types/src/internal/plan.d.ts +96 -0
  41. package/dist/types/src/internal/plan.d.ts.map +1 -0
  42. package/dist/types/src/internal/relation.d.ts +20 -0
  43. package/dist/types/src/internal/relation.d.ts.map +1 -0
  44. package/dist/types/src/internal/stratify.d.ts +28 -0
  45. package/dist/types/src/internal/stratify.d.ts.map +1 -0
  46. package/dist/types/tsconfig.tsbuildinfo +1 -0
  47. package/package.json +57 -5
  48. package/src/Ast.ts +136 -0
  49. package/src/Builtin.ts +73 -0
  50. package/src/Checker.test.ts +99 -0
  51. package/src/Checker.ts +156 -0
  52. package/src/Engine.test.ts +239 -0
  53. package/src/Engine.ts +614 -0
  54. package/src/Engine.workerd.test.ts +33 -0
  55. package/src/Parser.test.ts +84 -0
  56. package/src/Parser.ts +180 -0
  57. package/src/index.ts +9 -0
  58. package/src/internal/lexer.ts +154 -0
  59. package/src/internal/plan.ts +346 -0
  60. package/src/internal/relation.ts +95 -0
  61. package/src/internal/stratify.ts +114 -0
@@ -0,0 +1,346 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import type * as Ast from '../Ast.ts';
6
+ import * as Builtin from '../Builtin.ts';
7
+
8
+ /** A term resolved against a rule's variable slots; `wild` is an anonymous variable. */
9
+ export type Ref =
10
+ | { readonly kind: 'const'; readonly value: Ast.Value }
11
+ | { readonly kind: 'slot'; readonly slot: number }
12
+ | { readonly kind: 'wild' };
13
+
14
+ export type ScanStep = {
15
+ readonly kind: 'scan';
16
+ readonly literal: number;
17
+ readonly relation: string;
18
+ /** Columns whose value is known before the scan, with the value source. */
19
+ readonly bound: ReadonlyArray<{ readonly column: number; readonly ref: Ref }>;
20
+ /** Columns that bind a slot, or check it when the variable repeats within the atom. */
21
+ readonly binds: ReadonlyArray<{ readonly column: number; readonly slot: number }>;
22
+ };
23
+
24
+ export type NegationStep = {
25
+ readonly kind: 'negation';
26
+ readonly relation: string;
27
+ readonly bound: ReadonlyArray<{ readonly column: number; readonly ref: Ref }>;
28
+ };
29
+
30
+ export type CompareStep = {
31
+ readonly kind: 'compare';
32
+ readonly operator: Ast.ComparisonOperator;
33
+ readonly left: Ref;
34
+ readonly right: Ref;
35
+ /** Set for `X = term` with `X` unbound: assigns instead of comparing. */
36
+ readonly assign?: { readonly slot: number; readonly from: Ref };
37
+ };
38
+
39
+ export type BuiltinStep = {
40
+ readonly kind: 'builtin';
41
+ readonly builtin: Builtin.Builtin;
42
+ readonly args: ReadonlyArray<Ref>;
43
+ /** Arguments unbound at call time, passed as `undefined` and bound from the results. */
44
+ readonly free: ReadonlyArray<boolean>;
45
+ readonly negated: boolean;
46
+ };
47
+
48
+ export type AggregateStep = {
49
+ readonly kind: 'aggregate';
50
+ readonly function: Ast.AggregateFunction;
51
+ readonly steps: ReadonlyArray<Step>;
52
+ /** Slots local to the aggregate body; distinct assignments of these are aggregated. */
53
+ readonly local: ReadonlyArray<number>;
54
+ readonly target?: Ref;
55
+ readonly result: Ref;
56
+ readonly assignResult: boolean;
57
+ };
58
+
59
+ export type Step = ScanStep | NegationStep | CompareStep | BuiltinStep | AggregateStep;
60
+
61
+ export type Unresolved = { readonly literal: number; readonly reason: string };
62
+
63
+ export type Plan = {
64
+ readonly steps: ReadonlyArray<Step>;
65
+ readonly unresolved: ReadonlyArray<Unresolved>;
66
+ readonly bound: ReadonlySet<string>;
67
+ };
68
+
69
+ /** Assigns a slot per named variable of a rule. */
70
+ export class Slots {
71
+ readonly #slots = new Map<string, number>();
72
+
73
+ get size(): number {
74
+ return this.#slots.size;
75
+ }
76
+
77
+ slot(name: string): number {
78
+ let slot = this.#slots.get(name);
79
+ if (slot === undefined) {
80
+ slot = this.#slots.size;
81
+ this.#slots.set(name, slot);
82
+ }
83
+ return slot;
84
+ }
85
+
86
+ ref(term: Ast.Term): Ref {
87
+ if (term.type === 'constant') {
88
+ return { kind: 'const', value: term.value };
89
+ }
90
+ return term.anonymous ? { kind: 'wild' } : { kind: 'slot', slot: this.slot(term.name) };
91
+ }
92
+ }
93
+
94
+ const isBound = (term: Ast.Term, bound: ReadonlySet<string>) =>
95
+ term.type === 'constant' || (!term.anonymous && bound.has(term.name));
96
+
97
+ const unboundNames = (terms: ReadonlyArray<Ast.Term>, bound: ReadonlySet<string>) =>
98
+ terms.flatMap((term) => (term.type === 'variable' && !term.anonymous && !bound.has(term.name) ? [term.name] : []));
99
+
100
+ const variableNames = (literal: Ast.Literal): string[] => {
101
+ switch (literal.type) {
102
+ case 'atom':
103
+ return literal.atom.terms.flatMap((term) => (term.type === 'variable' && !term.anonymous ? [term.name] : []));
104
+ case 'comparison':
105
+ return [literal.left, literal.right].flatMap((term) =>
106
+ term.type === 'variable' && !term.anonymous ? [term.name] : [],
107
+ );
108
+ case 'aggregate':
109
+ return [
110
+ ...[literal.result, ...(literal.target ? [literal.target] : [])].flatMap((term) =>
111
+ term.type === 'variable' && !term.anonymous ? [term.name] : [],
112
+ ),
113
+ ...literal.body.flatMap(variableNames),
114
+ ];
115
+ }
116
+ };
117
+
118
+ /**
119
+ * Orders a rule body so every literal runs once its inputs are bound: filters as early as
120
+ * possible, then the relation scan with the most bound columns, then generating built-ins.
121
+ * @param outer Variables used outside the body (the head, or the enclosing rule of an aggregate).
122
+ * @param first Literal to scan first (the semi-naive delta).
123
+ */
124
+ export const planBody = (options: {
125
+ body: ReadonlyArray<Ast.Literal>;
126
+ builtins: Builtin.Registry;
127
+ slots: Slots;
128
+ outer: ReadonlySet<string>;
129
+ bound?: ReadonlySet<string>;
130
+ first?: number;
131
+ }): Plan => {
132
+ const { body, builtins, slots, outer, first } = options;
133
+ const bound = new Set(options.bound ?? []);
134
+ const steps: Step[] = [];
135
+ const remaining = new Set(body.keys());
136
+ const unresolved: Unresolved[] = [];
137
+
138
+ const scan = (index: number, literal: Ast.AtomLiteral) => {
139
+ const boundColumns: { column: number; ref: Ref }[] = [];
140
+ const binds: { column: number; slot: number }[] = [];
141
+ literal.atom.terms.forEach((term, column) => {
142
+ if (isBound(term, bound)) {
143
+ boundColumns.push({ column, ref: slots.ref(term) });
144
+ } else if (term.type === 'variable' && !term.anonymous) {
145
+ binds.push({ column, slot: slots.slot(term.name) });
146
+ }
147
+ });
148
+ for (const name of unboundNames(literal.atom.terms, bound)) {
149
+ bound.add(name);
150
+ }
151
+ steps.push({ kind: 'scan', literal: index, relation: literal.atom.predicate, bound: boundColumns, binds });
152
+ };
153
+
154
+ /** Variables any other literal or the outer scope uses, which an aggregate must correlate on. */
155
+ const correlated = (index: number, literal: Ast.AggregateLiteral): string[] => {
156
+ const elsewhere = new Set([...outer, ...body.flatMap((other, at) => (at === index ? [] : variableNames(other)))]);
157
+ const inner = new Set(literal.body.flatMap(variableNames));
158
+ return [...inner].filter((name) => elsewhere.has(name));
159
+ };
160
+
161
+ /** Schedules `index` if it is a filter (or, with `generate`, a generator) whose inputs are bound. */
162
+ const trySchedule = (index: number, generate: boolean): boolean => {
163
+ const literal = body[index];
164
+ switch (literal.type) {
165
+ case 'atom': {
166
+ const builtin = builtins.get(literal.atom.predicate);
167
+ if (!builtin) {
168
+ if (!literal.negated) {
169
+ return false;
170
+ }
171
+ if (unboundNames(literal.atom.terms, bound).length > 0) {
172
+ return false;
173
+ }
174
+ steps.push({
175
+ kind: 'negation',
176
+ relation: literal.atom.predicate,
177
+ bound: literal.atom.terms.flatMap((term, column) =>
178
+ term.type === 'variable' && term.anonymous ? [] : [{ column, ref: slots.ref(term) }],
179
+ ),
180
+ });
181
+ return true;
182
+ }
183
+ const boundArgs = literal.atom.terms.map((term) => isBound(term, bound));
184
+ const allBound = boundArgs.every(Boolean);
185
+ if (!allBound && (!generate || literal.negated || !Builtin.acceptsBinding(builtin, boundArgs))) {
186
+ return false;
187
+ }
188
+ steps.push({
189
+ kind: 'builtin',
190
+ builtin,
191
+ args: literal.atom.terms.map((term) => slots.ref(term)),
192
+ free: boundArgs.map((value) => !value),
193
+ negated: literal.negated,
194
+ });
195
+ for (const name of unboundNames(literal.atom.terms, bound)) {
196
+ bound.add(name);
197
+ }
198
+ return true;
199
+ }
200
+ case 'comparison': {
201
+ const left = isBound(literal.left, bound);
202
+ const right = isBound(literal.right, bound);
203
+ if (left && right) {
204
+ steps.push({
205
+ kind: 'compare',
206
+ operator: literal.operator,
207
+ left: slots.ref(literal.left),
208
+ right: slots.ref(literal.right),
209
+ });
210
+ return true;
211
+ }
212
+ if (!generate || literal.operator !== '=' || left === right) {
213
+ return false;
214
+ }
215
+ const [target, source] = left ? [literal.right, literal.left] : [literal.left, literal.right];
216
+ if (target.type !== 'variable' || target.anonymous) {
217
+ return false;
218
+ }
219
+ steps.push({
220
+ kind: 'compare',
221
+ operator: '=',
222
+ left: slots.ref(literal.left),
223
+ right: slots.ref(literal.right),
224
+ assign: { slot: slots.slot(target.name), from: slots.ref(source) },
225
+ });
226
+ bound.add(target.name);
227
+ return true;
228
+ }
229
+ case 'aggregate': {
230
+ const correlation = correlated(index, literal);
231
+ if (correlation.some((name) => !bound.has(name))) {
232
+ return false;
233
+ }
234
+ const resultBound = isBound(literal.result, bound);
235
+ if (!resultBound && !generate) {
236
+ return false;
237
+ }
238
+ const inner = planBody({
239
+ body: literal.body,
240
+ builtins,
241
+ slots,
242
+ outer: new Set(correlation),
243
+ bound: new Set(correlation),
244
+ });
245
+ if (inner.unresolved.length > 0) {
246
+ unresolved.push(...inner.unresolved.map(({ reason }) => ({ literal: index, reason })));
247
+ return true;
248
+ }
249
+ if (literal.target && !isBound(literal.target, inner.bound)) {
250
+ unresolved.push({ literal: index, reason: `aggregate target is not bound by the aggregate body` });
251
+ return true;
252
+ }
253
+ const local = [...inner.bound].filter((name) => !correlation.includes(name)).map((name) => slots.slot(name));
254
+ steps.push({
255
+ kind: 'aggregate',
256
+ function: literal.function,
257
+ steps: inner.steps,
258
+ local,
259
+ target: literal.target ? slots.ref(literal.target) : undefined,
260
+ result: slots.ref(literal.result),
261
+ assignResult: !resultBound,
262
+ });
263
+ if (literal.result.type === 'variable' && !literal.result.anonymous) {
264
+ bound.add(literal.result.name);
265
+ }
266
+ return true;
267
+ }
268
+ }
269
+ };
270
+
271
+ if (first !== undefined) {
272
+ const literal = body[first];
273
+ if (literal.type === 'atom' && !literal.negated && !builtins.has(literal.atom.predicate)) {
274
+ remaining.delete(first);
275
+ scan(first, literal);
276
+ }
277
+ }
278
+
279
+ while (remaining.size > 0) {
280
+ let progressed = false;
281
+ for (const index of remaining) {
282
+ if (trySchedule(index, false)) {
283
+ remaining.delete(index);
284
+ progressed = true;
285
+ }
286
+ }
287
+ if (progressed) {
288
+ continue;
289
+ }
290
+
291
+ let best: number | undefined;
292
+ let bestBound = -1;
293
+ for (const index of remaining) {
294
+ const literal = body[index];
295
+ if (literal.type === 'atom' && !literal.negated && !builtins.has(literal.atom.predicate)) {
296
+ const boundCount = literal.atom.terms.filter((term) => isBound(term, bound)).length;
297
+ if (boundCount > bestBound) {
298
+ best = index;
299
+ bestBound = boundCount;
300
+ }
301
+ }
302
+ }
303
+ if (best !== undefined) {
304
+ const literal = body[best];
305
+ if (literal.type === 'atom') {
306
+ remaining.delete(best);
307
+ scan(best, literal);
308
+ }
309
+ continue;
310
+ }
311
+
312
+ for (const index of remaining) {
313
+ if (trySchedule(index, true)) {
314
+ remaining.delete(index);
315
+ progressed = true;
316
+ break;
317
+ }
318
+ }
319
+ if (!progressed) {
320
+ break;
321
+ }
322
+ }
323
+
324
+ for (const index of remaining) {
325
+ unresolved.push({ literal: index, reason: describeUnresolved(body[index], bound, builtins) });
326
+ }
327
+ return { steps, unresolved, bound };
328
+ };
329
+
330
+ const describeUnresolved = (literal: Ast.Literal, bound: ReadonlySet<string>, builtins: Builtin.Registry): string => {
331
+ switch (literal.type) {
332
+ case 'atom': {
333
+ const names = unboundNames(literal.atom.terms, bound).join(', ');
334
+ if (builtins.has(literal.atom.predicate)) {
335
+ return literal.negated
336
+ ? `negated built-in ${literal.atom.predicate} needs every argument bound (${names} unbound)`
337
+ : `no binding mode of built-in ${literal.atom.predicate}/${literal.atom.terms.length} accepts ${names} unbound`;
338
+ }
339
+ return `variable ${names} under negation is not bound by a positive literal`;
340
+ }
341
+ case 'comparison':
342
+ return `variable ${unboundNames([literal.left, literal.right], bound).join(', ')} in comparison is not bound`;
343
+ case 'aggregate':
344
+ return `aggregate depends on unbound variables`;
345
+ }
346
+ };
@@ -0,0 +1,95 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import type * as Ast from '../Ast.ts';
6
+
7
+ /** Collision-free key: the type tag keeps `1` and `"1"` apart. */
8
+ export const valueKey = (value: Ast.Value): string => (typeof value === 'number' ? `n${value}` : `s${value}`);
9
+
10
+ export const tupleKey = (tuple: Ast.Tuple): string => tuple.map(valueKey).join('\u0001');
11
+
12
+ /** Total order over values: numbers before strings, numbers numerically, strings by code unit. */
13
+ export const compareValues = (left: Ast.Value, right: Ast.Value): number => {
14
+ if (typeof left === 'number' && typeof right === 'number') {
15
+ return left - right;
16
+ }
17
+ if (typeof left === 'number') {
18
+ return -1;
19
+ }
20
+ if (typeof right === 'number') {
21
+ return 1;
22
+ }
23
+ return left < right ? -1 : left > right ? 1 : 0;
24
+ };
25
+
26
+ /** A set of tuples with lazily built hash indexes over bound column sets. */
27
+ export class Relation {
28
+ readonly #tuples = new Map<string, Ast.Tuple>();
29
+ readonly #indexes = new Map<string, Index>();
30
+
31
+ get size(): number {
32
+ return this.#tuples.size;
33
+ }
34
+
35
+ values(): IterableIterator<Ast.Tuple> {
36
+ return this.#tuples.values();
37
+ }
38
+
39
+ entries(): IterableIterator<[string, Ast.Tuple]> {
40
+ return this.#tuples.entries();
41
+ }
42
+
43
+ has(key: string): boolean {
44
+ return this.#tuples.has(key);
45
+ }
46
+
47
+ /** Adds a tuple; returns false if it was already present. */
48
+ add(tuple: Ast.Tuple, key = tupleKey(tuple)): boolean {
49
+ if (this.#tuples.has(key)) {
50
+ return false;
51
+ }
52
+ this.#tuples.set(key, tuple);
53
+ for (const index of this.#indexes.values()) {
54
+ insert(index, tuple);
55
+ }
56
+ return true;
57
+ }
58
+
59
+ delete(key: string): boolean {
60
+ const deleted = this.#tuples.delete(key);
61
+ if (deleted) {
62
+ this.#indexes.clear();
63
+ }
64
+ return deleted;
65
+ }
66
+
67
+ /** Tuples whose `columns` equal `values`; scans everything when no column is bound. */
68
+ lookup(columns: ReadonlyArray<number>, values: ReadonlyArray<Ast.Value>): Iterable<Ast.Tuple> {
69
+ if (columns.length === 0) {
70
+ return this.#tuples.values();
71
+ }
72
+ const columnsKey = columns.join(',');
73
+ let index = this.#indexes.get(columnsKey);
74
+ if (!index) {
75
+ index = { columns, buckets: new Map() };
76
+ for (const tuple of this.#tuples.values()) {
77
+ insert(index, tuple);
78
+ }
79
+ this.#indexes.set(columnsKey, index);
80
+ }
81
+ return index.buckets.get(values.map(valueKey).join('\u0001')) ?? [];
82
+ }
83
+ }
84
+
85
+ type Index = { readonly columns: ReadonlyArray<number>; readonly buckets: Map<string, Ast.Tuple[]> };
86
+
87
+ const insert = (index: Index, tuple: Ast.Tuple) => {
88
+ const key = index.columns.map((column) => valueKey(tuple[column])).join('\u0001');
89
+ const bucket = index.buckets.get(key);
90
+ if (bucket) {
91
+ bucket.push(tuple);
92
+ } else {
93
+ index.buckets.set(key, [tuple]);
94
+ }
95
+ };
@@ -0,0 +1,114 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import type * as Ast from '../Ast.ts';
6
+ import type * as Builtin from '../Builtin.ts';
7
+
8
+ export type Dependency = { readonly predicate: string; readonly negative: boolean };
9
+
10
+ export type Stratum = {
11
+ /** Predicates defined by the stratum's rules (one strongly connected component). */
12
+ readonly predicates: ReadonlySet<string>;
13
+ /** Indexes into the program's rules. */
14
+ readonly rules: ReadonlyArray<number>;
15
+ readonly recursive: boolean;
16
+ };
17
+
18
+ export type Stratification =
19
+ | { readonly ok: true; readonly strata: ReadonlyArray<Stratum> }
20
+ | { readonly ok: false; readonly cycles: ReadonlyArray<ReadonlyArray<string>> };
21
+
22
+ /** Relations a literal reads; negation and aggregation read them non-monotonically. */
23
+ export const dependencies = (literal: Ast.Literal, builtins: Builtin.Registry): Dependency[] => {
24
+ switch (literal.type) {
25
+ case 'atom':
26
+ return builtins.has(literal.atom.predicate)
27
+ ? []
28
+ : [{ predicate: literal.atom.predicate, negative: literal.negated }];
29
+ case 'comparison':
30
+ return [];
31
+ case 'aggregate':
32
+ return literal.body.flatMap((inner) =>
33
+ dependencies(inner, builtins).map(({ predicate }) => ({ predicate, negative: true })),
34
+ );
35
+ }
36
+ };
37
+
38
+ /**
39
+ * Splits rules into strata: one per strongly connected component of the predicate graph, in
40
+ * dependency order; fails when negation or aggregation occurs inside a component.
41
+ */
42
+ export const stratify = (rules: ReadonlyArray<Ast.Rule>, builtins: Builtin.Registry): Stratification => {
43
+ const defined = new Set(rules.filter((rule) => rule.body.length > 0).map((rule) => rule.head.predicate));
44
+ const edges = new Map<string, Dependency[]>();
45
+ for (const predicate of defined) {
46
+ edges.set(predicate, []);
47
+ }
48
+ for (const rule of rules) {
49
+ for (const literal of rule.body) {
50
+ for (const dependency of dependencies(literal, builtins)) {
51
+ if (defined.has(dependency.predicate)) {
52
+ edges.get(rule.head.predicate)?.push(dependency);
53
+ }
54
+ }
55
+ }
56
+ }
57
+
58
+ // Tarjan's algorithm emits each component after every component it depends on.
59
+ const components: string[][] = [];
60
+ const index = new Map<string, number>();
61
+ const lowLink = new Map<string, number>();
62
+ const stack: string[] = [];
63
+ const onStack = new Set<string>();
64
+ const visit = (predicate: string) => {
65
+ index.set(predicate, index.size);
66
+ lowLink.set(predicate, index.get(predicate) ?? 0);
67
+ stack.push(predicate);
68
+ onStack.add(predicate);
69
+ for (const { predicate: next } of edges.get(predicate) ?? []) {
70
+ if (!index.has(next)) {
71
+ visit(next);
72
+ lowLink.set(predicate, Math.min(lowLink.get(predicate) ?? 0, lowLink.get(next) ?? 0));
73
+ } else if (onStack.has(next)) {
74
+ lowLink.set(predicate, Math.min(lowLink.get(predicate) ?? 0, index.get(next) ?? 0));
75
+ }
76
+ }
77
+ if (lowLink.get(predicate) === index.get(predicate)) {
78
+ const component: string[] = [];
79
+ let member: string | undefined;
80
+ do {
81
+ member = stack.pop();
82
+ if (member !== undefined) {
83
+ onStack.delete(member);
84
+ component.push(member);
85
+ }
86
+ } while (member !== undefined && member !== predicate);
87
+ components.push(component);
88
+ }
89
+ };
90
+ for (const predicate of defined) {
91
+ if (!index.has(predicate)) {
92
+ visit(predicate);
93
+ }
94
+ }
95
+
96
+ const cycles: string[][] = [];
97
+ const strata: Stratum[] = components.map((component) => {
98
+ const members = new Set(component);
99
+ const internal = component.flatMap((predicate) =>
100
+ (edges.get(predicate) ?? []).filter((dependency) => members.has(dependency.predicate)),
101
+ );
102
+ if (internal.some((dependency) => dependency.negative)) {
103
+ cycles.push(component.sort());
104
+ }
105
+ return {
106
+ predicates: members,
107
+ rules: rules.flatMap((rule, ruleIndex) =>
108
+ rule.body.length > 0 && members.has(rule.head.predicate) ? [ruleIndex] : [],
109
+ ),
110
+ recursive: internal.length > 0,
111
+ };
112
+ });
113
+ return cycles.length > 0 ? { ok: false, cycles } : { ok: true, strata };
114
+ };