@suss/datalog 0.3.2 → 0.5.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/README.md +64 -63
- package/dist/index.cjs +60 -14
- package/dist/index.d.cts +41 -18
- package/dist/index.d.ts +41 -18
- package/dist/index.js +58 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
A small semi-naïve Datalog evaluator with stratified negation. This is
|
|
4
4
|
the rules engine behind suss's derived program facts.
|
|
5
5
|
|
|
6
|
-
## Why suss
|
|
6
|
+
## Why suss ships a Datalog engine
|
|
7
7
|
|
|
8
|
-
Extraction keeps
|
|
9
|
-
functions are reachable from an entry point, what a bare `throw
|
|
10
|
-
re-throw can actually raise (the union of
|
|
11
|
-
block throws, transitively), how a wrapper
|
|
12
|
-
underlying route.
|
|
13
|
-
a few
|
|
8
|
+
Extraction keeps running into problems that are naturally *fixpoints*:
|
|
9
|
+
which functions are reachable from an entry point, what a bare `throw
|
|
10
|
+
err` re-throw can actually raise (the union of everything the `try`
|
|
11
|
+
block throws, transitively), how a wrapper of a wrapper resolves to its
|
|
12
|
+
underlying route. Write each of those as rules over base facts and it
|
|
13
|
+
comes to a few lines you can check by reading them:
|
|
14
14
|
|
|
15
15
|
```ts
|
|
16
16
|
import { Database, evaluate, lit, rule, variable as v } from "@suss/datalog";
|
|
@@ -32,16 +32,16 @@ evaluate(db, [
|
|
|
32
32
|
db.facts("reachable"); // [["main"], ["helper"], ["util"]]
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
Termination and soundness are the engine's job,
|
|
36
|
-
(`notLit`) is *stratified*: a rule set with a negation cycle is
|
|
37
|
-
error at evaluation time,
|
|
38
|
-
|
|
35
|
+
Termination and soundness are the engine's job, and we prove them once.
|
|
36
|
+
Negation (`notLit`) is *stratified*: a rule set with a negation cycle is
|
|
37
|
+
a hard error at evaluation time, and that is what lets you check a rule
|
|
38
|
+
on its own without thinking about the engine.
|
|
39
39
|
|
|
40
|
-
Because rules are plain data
|
|
41
|
-
same rule set can later run on a faster external engine
|
|
42
|
-
longer
|
|
43
|
-
`handles`,
|
|
44
|
-
has to emit the same facts.
|
|
40
|
+
Because rules are plain data, with no DSL strings and no embedded code,
|
|
41
|
+
the same rule set can later run on a faster external engine. Over the
|
|
42
|
+
longer term, an analysis written against a set of facts (`calls`,
|
|
43
|
+
`throws`, `handles`, and so on) does not depend on the language: a
|
|
44
|
+
second language adapter only has to emit the same facts.
|
|
45
45
|
|
|
46
46
|
## Design constraints
|
|
47
47
|
|
|
@@ -54,35 +54,36 @@ has to emit the same facts.
|
|
|
54
54
|
|
|
55
55
|
## What it does to stay quick
|
|
56
56
|
|
|
57
|
-
Semi-naïve iteration keeps
|
|
58
|
-
facts rather than to everything known.
|
|
57
|
+
Semi-naïve iteration keeps the work in each round proportional to the
|
|
58
|
+
facts newly derived rather than to everything already known.
|
|
59
59
|
|
|
60
60
|
Joins narrow before they scan. Once a literal has any term fixed, either
|
|
61
61
|
written as a constant or bound by an earlier literal, the join looks the
|
|
62
|
-
value up in a per-column index instead of walking the relation. A
|
|
63
|
-
gets indexed the first time something asks for it and stays
|
|
64
|
-
after that, so a relation nobody joins on that way
|
|
62
|
+
value up in a per-column index instead of walking the whole relation. A
|
|
63
|
+
column gets indexed the first time something asks for it and stays up to
|
|
64
|
+
date after that, so a relation nobody joins on that way never gets an
|
|
65
|
+
index at all.
|
|
65
66
|
|
|
66
67
|
`evaluate` picks up where it left off. Call it again with the same rules
|
|
67
|
-
after adding facts and it
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
so everything derived
|
|
68
|
+
after adding facts and it starts from the facts you added rather than
|
|
69
|
+
redoing the whole fixpoint. Callers that go back and forth between
|
|
70
|
+
adding facts and asking questions get this for free. Positive rules are
|
|
71
|
+
monotone, so everything derived earlier still holds.
|
|
71
72
|
|
|
72
73
|
Negated rules work differently. A new fact can make a negated literal
|
|
73
|
-
stop matching, and the conclusion that rested on it has to go. So
|
|
74
|
-
re-run with negated rules
|
|
75
|
-
works the answer out again from the base facts.
|
|
76
|
-
database
|
|
74
|
+
stop matching, and the conclusion that rested on it has to go. So when
|
|
75
|
+
you re-run with negated rules, the engine throws away what the previous
|
|
76
|
+
pass derived and works the answer out again from the base facts. Either
|
|
77
|
+
way, the database ends up with the answer for the facts it has now.
|
|
77
78
|
|
|
78
79
|
## Deriving only what somebody asked for
|
|
79
80
|
|
|
80
81
|
A rule set written for a whole program derives every conclusion its
|
|
81
|
-
facts support, and a caller
|
|
82
|
-
them. `deriveOnDemand` rewrites the rules so
|
|
83
|
-
on
|
|
82
|
+
facts support, and a caller who asks about one value reads only a
|
|
83
|
+
handful of them. `deriveOnDemand` rewrites the rules so that conclusions
|
|
84
|
+
nobody is waiting on never get derived.
|
|
84
85
|
|
|
85
|
-
|
|
86
|
+
List the relations that have to come out whole, and give each of them a
|
|
86
87
|
rule that starts at a base relation you assert:
|
|
87
88
|
|
|
88
89
|
```ts
|
|
@@ -112,28 +113,28 @@ db.facts("answer"); // [["a", "b"], ["a", "c"]]
|
|
|
112
113
|
db.facts("reaches"); // the chain from a, and nothing from m
|
|
113
114
|
```
|
|
114
115
|
|
|
115
|
-
The rewrite is magic sets. Each derived relation
|
|
116
|
-
relation
|
|
116
|
+
The rewrite is magic sets. Each derived relation gets a companion
|
|
117
|
+
relation that records which of its rows something is waiting on, that
|
|
117
118
|
companion becomes a literal in the rule body, and demand travels down
|
|
118
|
-
each body the way the join binds variables. A relation nothing asks
|
|
119
|
-
is not derived at all, so read back
|
|
119
|
+
each body the same way the join binds variables. A relation nothing asks
|
|
120
|
+
for is not derived at all, so only read back the relations you listed.
|
|
120
121
|
|
|
121
|
-
|
|
122
|
-
other, at a few tuples per value asked about, so a caller
|
|
123
|
-
most of a program
|
|
124
|
-
|
|
125
|
-
the one a negated literal was written against, which
|
|
126
|
-
p(x)` match where it
|
|
122
|
+
This costs you two things. The companion relations are stored like any
|
|
123
|
+
other, at a few tuples per value asked about, so a caller who asks about
|
|
124
|
+
most of a program ends up deriving more rather than less. And the
|
|
125
|
+
rewrite refuses negation: a relation derived only where somebody asked
|
|
126
|
+
is smaller than the one a negated literal was written against, which
|
|
127
|
+
would make `not p(x)` match where it should not.
|
|
127
128
|
|
|
128
129
|
### Taking a question back
|
|
129
130
|
|
|
130
131
|
Demand is a fact, and a fact stays until somebody removes it. A caller
|
|
131
|
-
that asks a thousand questions of one database derives
|
|
132
|
-
every time new facts arrive, so the last question costs a
|
|
133
|
-
questions.
|
|
132
|
+
that asks a thousand questions of one database re-derives all thousand
|
|
133
|
+
every time new facts arrive, so the last question costs as much as a
|
|
134
|
+
thousand questions.
|
|
134
135
|
|
|
135
|
-
`program.demandDriven`
|
|
136
|
-
`clearRelations` empties them once
|
|
136
|
+
`program.demandDriven` lists the relations the rewrite restricts, and
|
|
137
|
+
`clearRelations` empties them once you have read an answer:
|
|
137
138
|
|
|
138
139
|
```ts
|
|
139
140
|
clearRelations(db, program.rules, [...program.demandDriven, "asked"]);
|
|
@@ -144,19 +145,19 @@ db.facts("reaches"); // the chain from m, and nothing from a
|
|
|
144
145
|
db.facts("answer"); // a's answers, which nothing took away, plus m's
|
|
145
146
|
```
|
|
146
147
|
|
|
147
|
-
`retract` would send the next run back to the base facts,
|
|
148
|
+
`retract` would send the next run back to the base facts, because a fact
|
|
148
149
|
leaving the database can take away a conclusion drawn anywhere.
|
|
149
|
-
`clearRelations`
|
|
150
|
-
relations is saying nothing outside them was derived
|
|
151
|
-
|
|
152
|
-
derived under a demand fact, and only the relations
|
|
153
|
-
complete read from them. Those keep what they
|
|
154
|
-
you
|
|
155
|
-
|
|
156
|
-
Keep the facts you add and the facts rules derive in separate
|
|
157
|
-
Taking a conclusion back cannot tell one from the other, and
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
Extraction-scale fact sets
|
|
161
|
-
rule data model is the
|
|
162
|
-
part.
|
|
150
|
+
`clearRelations` lets the next run resume instead, because a caller who
|
|
151
|
+
passes these relations is saying that nothing outside them was derived
|
|
152
|
+
from them. That is true for the relations `deriveOnDemand` restricts:
|
|
153
|
+
every one of them is derived under a demand fact, and only the relations
|
|
154
|
+
you listed as complete read from them. Those keep what they already
|
|
155
|
+
contain, which is the answers you have read.
|
|
156
|
+
|
|
157
|
+
Keep the facts you add and the facts the rules derive in separate
|
|
158
|
+
relations. Taking a conclusion back cannot tell one from the other, and
|
|
159
|
+
splitting them is how people normally write Datalog anyway.
|
|
160
|
+
|
|
161
|
+
Extraction-scale fact sets run to thousands of tuples. If that changes,
|
|
162
|
+
the rule data model is the part that stays and this evaluator is the
|
|
163
|
+
part you replace.
|
package/dist/index.cjs
CHANGED
|
@@ -32,6 +32,8 @@ __export(index_exports, {
|
|
|
32
32
|
profileEvaluationAsync: () => profileEvaluationAsync,
|
|
33
33
|
rule: () => rule,
|
|
34
34
|
stratify: () => stratify,
|
|
35
|
+
tupleKey: () => tupleKey,
|
|
36
|
+
tupleKeyParts: () => tupleKeyParts,
|
|
35
37
|
variable: () => variable
|
|
36
38
|
});
|
|
37
39
|
module.exports = __toCommonJS(index_exports);
|
|
@@ -195,6 +197,38 @@ function formatProfile(profile) {
|
|
|
195
197
|
return lines.join("\n");
|
|
196
198
|
}
|
|
197
199
|
|
|
200
|
+
// src/tupleKey.ts
|
|
201
|
+
var atomKey = (a) => typeof a === "number" ? `n${a}:` : `s${a.length}:${a}`;
|
|
202
|
+
var tupleKey = (parts) => parts.map(atomKey).join("");
|
|
203
|
+
function tupleKeyParts(key) {
|
|
204
|
+
const parts = [];
|
|
205
|
+
let at = 0;
|
|
206
|
+
while (at < key.length) {
|
|
207
|
+
const colon = key.indexOf(":", at);
|
|
208
|
+
if (colon === -1) {
|
|
209
|
+
throw new Error(`not a tuple key: ${key}`);
|
|
210
|
+
}
|
|
211
|
+
const kind = key[at];
|
|
212
|
+
const header = key.slice(at + 1, colon);
|
|
213
|
+
if (kind === "n") {
|
|
214
|
+
parts.push(header);
|
|
215
|
+
at = colon + 1;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const length = Number(header);
|
|
219
|
+
if (kind !== "s" || !Number.isInteger(length) || length < 0) {
|
|
220
|
+
throw new Error(`not a tuple key: ${key}`);
|
|
221
|
+
}
|
|
222
|
+
const end = colon + 1 + length;
|
|
223
|
+
if (end > key.length) {
|
|
224
|
+
throw new Error(`not a tuple key: ${key}`);
|
|
225
|
+
}
|
|
226
|
+
parts.push(key.slice(colon + 1, end));
|
|
227
|
+
at = end;
|
|
228
|
+
}
|
|
229
|
+
return parts;
|
|
230
|
+
}
|
|
231
|
+
|
|
198
232
|
// src/onDemand.ts
|
|
199
233
|
var adornmentKey = (adornment) => adornment.map((bound) => bound ? "b" : "f").join("");
|
|
200
234
|
var anyBound = (adornment) => adornment.some(Boolean);
|
|
@@ -350,8 +384,7 @@ var notLit = (relation, ...terms) => ({
|
|
|
350
384
|
negated: true
|
|
351
385
|
});
|
|
352
386
|
var rule = (relation, terms, body) => ({ head: { relation, terms }, body });
|
|
353
|
-
var
|
|
354
|
-
var keyOf2 = (tuple) => tuple.map(atomKey).join("");
|
|
387
|
+
var keyOf2 = (tuple) => tupleKey(tuple);
|
|
355
388
|
function addToBucket(index, key, tuple) {
|
|
356
389
|
const bucket = index.get(key);
|
|
357
390
|
if (bucket === void 0) {
|
|
@@ -390,7 +423,7 @@ var Database = class {
|
|
|
390
423
|
for (const [column, index] of relation.indexes) {
|
|
391
424
|
const value = tuple[column];
|
|
392
425
|
if (value !== void 0) {
|
|
393
|
-
addToBucket(index,
|
|
426
|
+
addToBucket(index, value, tuple);
|
|
394
427
|
}
|
|
395
428
|
}
|
|
396
429
|
return true;
|
|
@@ -402,13 +435,13 @@ var Database = class {
|
|
|
402
435
|
return this.store.get(relationName)?.tuples ?? [];
|
|
403
436
|
}
|
|
404
437
|
/**
|
|
405
|
-
* The facts
|
|
406
|
-
*
|
|
407
|
-
*
|
|
438
|
+
* The facts with `value` at `column`. Once one of a literal's terms
|
|
439
|
+
* is already bound, the join uses this instead of scanning the whole
|
|
440
|
+
* relation.
|
|
408
441
|
*
|
|
409
442
|
* A column is indexed the first time somebody asks for it and stays
|
|
410
|
-
* up to date after that, so a relation nobody joins on this way
|
|
411
|
-
*
|
|
443
|
+
* up to date after that, so a relation nobody joins on this way never
|
|
444
|
+
* gets an index.
|
|
412
445
|
*/
|
|
413
446
|
lookup(relationName, column, value) {
|
|
414
447
|
const relation = this.store.get(relationName);
|
|
@@ -421,19 +454,19 @@ var Database = class {
|
|
|
421
454
|
for (const tuple of relation.tuples) {
|
|
422
455
|
const at = tuple[column];
|
|
423
456
|
if (at !== void 0) {
|
|
424
|
-
addToBucket(index,
|
|
457
|
+
addToBucket(index, at, tuple);
|
|
425
458
|
}
|
|
426
459
|
}
|
|
427
460
|
relation.indexes.set(column, index);
|
|
428
461
|
}
|
|
429
|
-
return index.get(
|
|
462
|
+
return index.get(value) ?? [];
|
|
430
463
|
}
|
|
431
464
|
/**
|
|
432
465
|
* Remove facts from a relation. Returns how many were actually there.
|
|
433
466
|
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
436
|
-
*
|
|
467
|
+
* When a rule set uses negation, a new fact can invalidate an old
|
|
468
|
+
* conclusion, so evaluation calls this to remove what it derived last
|
|
469
|
+
* time. Callers rarely need it.
|
|
437
470
|
*/
|
|
438
471
|
retract(relationName, tuples) {
|
|
439
472
|
const relation = this.store.get(relationName);
|
|
@@ -537,7 +570,7 @@ function groundNegated(literal, bindings) {
|
|
|
537
570
|
const bound = bindings.get(term.name);
|
|
538
571
|
if (bound === void 0) {
|
|
539
572
|
throw new Error(
|
|
540
|
-
`unbound variable "${term.name}" in negated literal "${literal.relation}"
|
|
573
|
+
`unbound variable "${term.name}" in negated literal "${literal.relation}": negated literals may only use variables bound by earlier positive literals`
|
|
541
574
|
);
|
|
542
575
|
}
|
|
543
576
|
return bound;
|
|
@@ -690,6 +723,11 @@ function shapeOf(rules) {
|
|
|
690
723
|
shapes.set(rules, shape);
|
|
691
724
|
return shape;
|
|
692
725
|
}
|
|
726
|
+
function couldProduce(db, rule2) {
|
|
727
|
+
return rule2.body.every(
|
|
728
|
+
(literal) => literal.negated || db.size(literal.relation) > 0
|
|
729
|
+
);
|
|
730
|
+
}
|
|
693
731
|
function runRules(db, rules) {
|
|
694
732
|
const {
|
|
695
733
|
signature,
|
|
@@ -748,6 +786,9 @@ function runRules(db, rules) {
|
|
|
748
786
|
};
|
|
749
787
|
const applyDelta = (seed, derivedOnly) => {
|
|
750
788
|
for (const r of stratum) {
|
|
789
|
+
if (!couldProduce(db, r)) {
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
751
792
|
const positives = r.body.filter((l) => !l.negated);
|
|
752
793
|
for (let i = 0; i < positives.length; i++) {
|
|
753
794
|
const literal = positives[i];
|
|
@@ -763,6 +804,9 @@ function runRules(db, rules) {
|
|
|
763
804
|
};
|
|
764
805
|
if (marks === void 0 || marks === null) {
|
|
765
806
|
for (const r of stratum) {
|
|
807
|
+
if (!couldProduce(db, r)) {
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
766
810
|
const all = /* @__PURE__ */ new Map();
|
|
767
811
|
for (const l of r.body) {
|
|
768
812
|
if (!l.negated) {
|
|
@@ -814,5 +858,7 @@ function addTo(buckets, relation, tuple) {
|
|
|
814
858
|
profileEvaluationAsync,
|
|
815
859
|
rule,
|
|
816
860
|
stratify,
|
|
861
|
+
tupleKey,
|
|
862
|
+
tupleKeyParts,
|
|
817
863
|
variable
|
|
818
864
|
});
|
package/dist/index.d.cts
CHANGED
|
@@ -12,7 +12,7 @@ interface OnDemandRules {
|
|
|
12
12
|
demandDriven: string[];
|
|
13
13
|
}
|
|
14
14
|
/**
|
|
15
|
-
* Rewrite `rules` so the relations
|
|
15
|
+
* Rewrite `rules` so the relations listed in `complete` still come out
|
|
16
16
|
* whole and everything else is derived only where those relations reach.
|
|
17
17
|
*
|
|
18
18
|
* Demand enters as ordinary facts. Give a complete relation a rule whose
|
|
@@ -21,7 +21,7 @@ interface OnDemandRules {
|
|
|
21
21
|
* fact into demand for one value's chain.
|
|
22
22
|
*
|
|
23
23
|
* Relations no complete relation reaches are dropped, so a caller reading
|
|
24
|
-
* a relation it did not
|
|
24
|
+
* a relation it did not list gets nothing. Negation is rejected: a
|
|
25
25
|
* demand-restricted relation is smaller than the one a negated literal
|
|
26
26
|
* was written against, and a smaller relation makes `not p(x)` true where
|
|
27
27
|
* it was false.
|
|
@@ -98,8 +98,8 @@ declare function profileEvaluation<T>(fn: () => T): {
|
|
|
98
98
|
/**
|
|
99
99
|
* The async twin, for callers whose extraction is a promise. The scope
|
|
100
100
|
* stays open across every await inside `fn`, so anything else that
|
|
101
|
-
* evaluates rules on the same thread meanwhile is charged here too.
|
|
102
|
-
*
|
|
101
|
+
* evaluates rules on the same thread meanwhile is charged here too. This
|
|
102
|
+
* is built for the case of one extraction at a time.
|
|
103
103
|
*/
|
|
104
104
|
declare function profileEvaluationAsync<T>(fn: () => Promise<T>): Promise<{
|
|
105
105
|
result: T;
|
|
@@ -108,6 +108,28 @@ declare function profileEvaluationAsync<T>(fn: () => Promise<T>): Promise<{
|
|
|
108
108
|
/** Render a profile as the table a human reads in a terminal. */
|
|
109
109
|
declare function formatProfile(profile: EvaluationProfile): string;
|
|
110
110
|
|
|
111
|
+
declare const tupleKey: (parts: readonly Atom[]) => string;
|
|
112
|
+
/**
|
|
113
|
+
* Throws on a string `tupleKey` did not produce. A key that does not
|
|
114
|
+
* parse means a caller mixed two encodings, and parsing it leniently
|
|
115
|
+
* would make every result after it quietly wrong.
|
|
116
|
+
*/
|
|
117
|
+
declare function tupleKeyParts(key: string): string[];
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @suss/datalog, a small semi-naïve Datalog evaluator with stratified
|
|
121
|
+
* negation, and the rules engine behind suss's derived program facts:
|
|
122
|
+
* reachable closures, wrapper resolution, throw propagation, origin
|
|
123
|
+
* chains. Rules are plain data (`Rule`) rather than code, so the same
|
|
124
|
+
* rules can later run on a faster engine, or be read on their own.
|
|
125
|
+
*
|
|
126
|
+
* It is pure TypeScript with no dependencies, because the npm-shipped
|
|
127
|
+
* CLI cannot require a native binary. Negation is stratified, so a
|
|
128
|
+
* negation cycle throws at evaluation time rather than quietly giving a
|
|
129
|
+
* wrong answer. Joins are nested-loop with hash indexing on bound
|
|
130
|
+
* positions, which suits the thousands of tuples extraction produces.
|
|
131
|
+
*/
|
|
132
|
+
|
|
111
133
|
/** Tuple values. Callers intern richer identities (AST nodes, summaries) to atoms. */
|
|
112
134
|
type Atom = string | number;
|
|
113
135
|
type Tuple = readonly Atom[];
|
|
@@ -119,7 +141,7 @@ type Term = {
|
|
|
119
141
|
type: "constant";
|
|
120
142
|
value: Atom;
|
|
121
143
|
};
|
|
122
|
-
/** Shorthand constructors
|
|
144
|
+
/** Shorthand constructors, so rules stay readable without a parser. */
|
|
123
145
|
declare const variable: (name: string) => Term;
|
|
124
146
|
declare const constant: (value: Atom) => Term;
|
|
125
147
|
interface Literal {
|
|
@@ -152,21 +174,21 @@ declare class Database {
|
|
|
152
174
|
has(relationName: string, tuple: Tuple): boolean;
|
|
153
175
|
facts(relationName: string): readonly Tuple[];
|
|
154
176
|
/**
|
|
155
|
-
* The facts
|
|
156
|
-
*
|
|
157
|
-
*
|
|
177
|
+
* The facts with `value` at `column`. Once one of a literal's terms
|
|
178
|
+
* is already bound, the join uses this instead of scanning the whole
|
|
179
|
+
* relation.
|
|
158
180
|
*
|
|
159
181
|
* A column is indexed the first time somebody asks for it and stays
|
|
160
|
-
* up to date after that, so a relation nobody joins on this way
|
|
161
|
-
*
|
|
182
|
+
* up to date after that, so a relation nobody joins on this way never
|
|
183
|
+
* gets an index.
|
|
162
184
|
*/
|
|
163
185
|
lookup(relationName: string, column: number, value: Atom): readonly Tuple[];
|
|
164
186
|
/**
|
|
165
187
|
* Remove facts from a relation. Returns how many were actually there.
|
|
166
188
|
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
189
|
+
* When a rule set uses negation, a new fact can invalidate an old
|
|
190
|
+
* conclusion, so evaluation calls this to remove what it derived last
|
|
191
|
+
* time. Callers rarely need it.
|
|
170
192
|
*/
|
|
171
193
|
retract(relationName: string, tuples: Iterable<Tuple>): number;
|
|
172
194
|
size(relationName: string): number;
|
|
@@ -175,8 +197,9 @@ declare class Database {
|
|
|
175
197
|
/**
|
|
176
198
|
* Assign each derived relation a stratum such that positive
|
|
177
199
|
* dependencies never decrease the stratum and negative dependencies
|
|
178
|
-
* strictly increase it. Iterates to fixpoint
|
|
179
|
-
* relation count means a negation cycle
|
|
200
|
+
* strictly increase it. Iterates to fixpoint. A stratum above the
|
|
201
|
+
* relation count means there is a negation cycle, which cannot be
|
|
202
|
+
* stratified, so this throws.
|
|
180
203
|
*/
|
|
181
204
|
declare function stratify(rules: Rule[]): Rule[][];
|
|
182
205
|
/**
|
|
@@ -201,12 +224,12 @@ declare function evaluate(db: Database, rules: Rule[]): Database;
|
|
|
201
224
|
* questions. Every relation `deriveOnDemand` restricts is derived under
|
|
202
225
|
* a demand fact, so clearing the demand together with everything under
|
|
203
226
|
* it costs one question's worth of derivation rather than every
|
|
204
|
-
* question asked so far. The relations
|
|
205
|
-
*
|
|
227
|
+
* question asked so far. The relations listed as complete keep their
|
|
228
|
+
* contents, which are the answers a caller has already read.
|
|
206
229
|
*
|
|
207
230
|
* Any other rule set over the same database does start over, since a
|
|
208
231
|
* relation it derived from may be one of these.
|
|
209
232
|
*/
|
|
210
233
|
declare function clearRelations(db: Database, rules: Rule[], relations: readonly string[]): void;
|
|
211
234
|
|
|
212
|
-
export { type Atom, Database, type EvaluationProfile, type Literal, type OnDemandRules, type Rule, type RuleCost, type Term, type Tuple, clearRelations, constant, deriveOnDemand, evaluate, formatProfile, lit, notLit, profileEvaluation, profileEvaluationAsync, rule, stratify, variable };
|
|
235
|
+
export { type Atom, Database, type EvaluationProfile, type Literal, type OnDemandRules, type Rule, type RuleCost, type Term, type Tuple, clearRelations, constant, deriveOnDemand, evaluate, formatProfile, lit, notLit, profileEvaluation, profileEvaluationAsync, rule, stratify, tupleKey, tupleKeyParts, variable };
|
package/dist/index.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ interface OnDemandRules {
|
|
|
12
12
|
demandDriven: string[];
|
|
13
13
|
}
|
|
14
14
|
/**
|
|
15
|
-
* Rewrite `rules` so the relations
|
|
15
|
+
* Rewrite `rules` so the relations listed in `complete` still come out
|
|
16
16
|
* whole and everything else is derived only where those relations reach.
|
|
17
17
|
*
|
|
18
18
|
* Demand enters as ordinary facts. Give a complete relation a rule whose
|
|
@@ -21,7 +21,7 @@ interface OnDemandRules {
|
|
|
21
21
|
* fact into demand for one value's chain.
|
|
22
22
|
*
|
|
23
23
|
* Relations no complete relation reaches are dropped, so a caller reading
|
|
24
|
-
* a relation it did not
|
|
24
|
+
* a relation it did not list gets nothing. Negation is rejected: a
|
|
25
25
|
* demand-restricted relation is smaller than the one a negated literal
|
|
26
26
|
* was written against, and a smaller relation makes `not p(x)` true where
|
|
27
27
|
* it was false.
|
|
@@ -98,8 +98,8 @@ declare function profileEvaluation<T>(fn: () => T): {
|
|
|
98
98
|
/**
|
|
99
99
|
* The async twin, for callers whose extraction is a promise. The scope
|
|
100
100
|
* stays open across every await inside `fn`, so anything else that
|
|
101
|
-
* evaluates rules on the same thread meanwhile is charged here too.
|
|
102
|
-
*
|
|
101
|
+
* evaluates rules on the same thread meanwhile is charged here too. This
|
|
102
|
+
* is built for the case of one extraction at a time.
|
|
103
103
|
*/
|
|
104
104
|
declare function profileEvaluationAsync<T>(fn: () => Promise<T>): Promise<{
|
|
105
105
|
result: T;
|
|
@@ -108,6 +108,28 @@ declare function profileEvaluationAsync<T>(fn: () => Promise<T>): Promise<{
|
|
|
108
108
|
/** Render a profile as the table a human reads in a terminal. */
|
|
109
109
|
declare function formatProfile(profile: EvaluationProfile): string;
|
|
110
110
|
|
|
111
|
+
declare const tupleKey: (parts: readonly Atom[]) => string;
|
|
112
|
+
/**
|
|
113
|
+
* Throws on a string `tupleKey` did not produce. A key that does not
|
|
114
|
+
* parse means a caller mixed two encodings, and parsing it leniently
|
|
115
|
+
* would make every result after it quietly wrong.
|
|
116
|
+
*/
|
|
117
|
+
declare function tupleKeyParts(key: string): string[];
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @suss/datalog, a small semi-naïve Datalog evaluator with stratified
|
|
121
|
+
* negation, and the rules engine behind suss's derived program facts:
|
|
122
|
+
* reachable closures, wrapper resolution, throw propagation, origin
|
|
123
|
+
* chains. Rules are plain data (`Rule`) rather than code, so the same
|
|
124
|
+
* rules can later run on a faster engine, or be read on their own.
|
|
125
|
+
*
|
|
126
|
+
* It is pure TypeScript with no dependencies, because the npm-shipped
|
|
127
|
+
* CLI cannot require a native binary. Negation is stratified, so a
|
|
128
|
+
* negation cycle throws at evaluation time rather than quietly giving a
|
|
129
|
+
* wrong answer. Joins are nested-loop with hash indexing on bound
|
|
130
|
+
* positions, which suits the thousands of tuples extraction produces.
|
|
131
|
+
*/
|
|
132
|
+
|
|
111
133
|
/** Tuple values. Callers intern richer identities (AST nodes, summaries) to atoms. */
|
|
112
134
|
type Atom = string | number;
|
|
113
135
|
type Tuple = readonly Atom[];
|
|
@@ -119,7 +141,7 @@ type Term = {
|
|
|
119
141
|
type: "constant";
|
|
120
142
|
value: Atom;
|
|
121
143
|
};
|
|
122
|
-
/** Shorthand constructors
|
|
144
|
+
/** Shorthand constructors, so rules stay readable without a parser. */
|
|
123
145
|
declare const variable: (name: string) => Term;
|
|
124
146
|
declare const constant: (value: Atom) => Term;
|
|
125
147
|
interface Literal {
|
|
@@ -152,21 +174,21 @@ declare class Database {
|
|
|
152
174
|
has(relationName: string, tuple: Tuple): boolean;
|
|
153
175
|
facts(relationName: string): readonly Tuple[];
|
|
154
176
|
/**
|
|
155
|
-
* The facts
|
|
156
|
-
*
|
|
157
|
-
*
|
|
177
|
+
* The facts with `value` at `column`. Once one of a literal's terms
|
|
178
|
+
* is already bound, the join uses this instead of scanning the whole
|
|
179
|
+
* relation.
|
|
158
180
|
*
|
|
159
181
|
* A column is indexed the first time somebody asks for it and stays
|
|
160
|
-
* up to date after that, so a relation nobody joins on this way
|
|
161
|
-
*
|
|
182
|
+
* up to date after that, so a relation nobody joins on this way never
|
|
183
|
+
* gets an index.
|
|
162
184
|
*/
|
|
163
185
|
lookup(relationName: string, column: number, value: Atom): readonly Tuple[];
|
|
164
186
|
/**
|
|
165
187
|
* Remove facts from a relation. Returns how many were actually there.
|
|
166
188
|
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
189
|
+
* When a rule set uses negation, a new fact can invalidate an old
|
|
190
|
+
* conclusion, so evaluation calls this to remove what it derived last
|
|
191
|
+
* time. Callers rarely need it.
|
|
170
192
|
*/
|
|
171
193
|
retract(relationName: string, tuples: Iterable<Tuple>): number;
|
|
172
194
|
size(relationName: string): number;
|
|
@@ -175,8 +197,9 @@ declare class Database {
|
|
|
175
197
|
/**
|
|
176
198
|
* Assign each derived relation a stratum such that positive
|
|
177
199
|
* dependencies never decrease the stratum and negative dependencies
|
|
178
|
-
* strictly increase it. Iterates to fixpoint
|
|
179
|
-
* relation count means a negation cycle
|
|
200
|
+
* strictly increase it. Iterates to fixpoint. A stratum above the
|
|
201
|
+
* relation count means there is a negation cycle, which cannot be
|
|
202
|
+
* stratified, so this throws.
|
|
180
203
|
*/
|
|
181
204
|
declare function stratify(rules: Rule[]): Rule[][];
|
|
182
205
|
/**
|
|
@@ -201,12 +224,12 @@ declare function evaluate(db: Database, rules: Rule[]): Database;
|
|
|
201
224
|
* questions. Every relation `deriveOnDemand` restricts is derived under
|
|
202
225
|
* a demand fact, so clearing the demand together with everything under
|
|
203
226
|
* it costs one question's worth of derivation rather than every
|
|
204
|
-
* question asked so far. The relations
|
|
205
|
-
*
|
|
227
|
+
* question asked so far. The relations listed as complete keep their
|
|
228
|
+
* contents, which are the answers a caller has already read.
|
|
206
229
|
*
|
|
207
230
|
* Any other rule set over the same database does start over, since a
|
|
208
231
|
* relation it derived from may be one of these.
|
|
209
232
|
*/
|
|
210
233
|
declare function clearRelations(db: Database, rules: Rule[], relations: readonly string[]): void;
|
|
211
234
|
|
|
212
|
-
export { type Atom, Database, type EvaluationProfile, type Literal, type OnDemandRules, type Rule, type RuleCost, type Term, type Tuple, clearRelations, constant, deriveOnDemand, evaluate, formatProfile, lit, notLit, profileEvaluation, profileEvaluationAsync, rule, stratify, variable };
|
|
235
|
+
export { type Atom, Database, type EvaluationProfile, type Literal, type OnDemandRules, type Rule, type RuleCost, type Term, type Tuple, clearRelations, constant, deriveOnDemand, evaluate, formatProfile, lit, notLit, profileEvaluation, profileEvaluationAsync, rule, stratify, tupleKey, tupleKeyParts, variable };
|
package/dist/index.js
CHANGED
|
@@ -157,6 +157,38 @@ function formatProfile(profile) {
|
|
|
157
157
|
return lines.join("\n");
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
// src/tupleKey.ts
|
|
161
|
+
var atomKey = (a) => typeof a === "number" ? `n${a}:` : `s${a.length}:${a}`;
|
|
162
|
+
var tupleKey = (parts) => parts.map(atomKey).join("");
|
|
163
|
+
function tupleKeyParts(key) {
|
|
164
|
+
const parts = [];
|
|
165
|
+
let at = 0;
|
|
166
|
+
while (at < key.length) {
|
|
167
|
+
const colon = key.indexOf(":", at);
|
|
168
|
+
if (colon === -1) {
|
|
169
|
+
throw new Error(`not a tuple key: ${key}`);
|
|
170
|
+
}
|
|
171
|
+
const kind = key[at];
|
|
172
|
+
const header = key.slice(at + 1, colon);
|
|
173
|
+
if (kind === "n") {
|
|
174
|
+
parts.push(header);
|
|
175
|
+
at = colon + 1;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const length = Number(header);
|
|
179
|
+
if (kind !== "s" || !Number.isInteger(length) || length < 0) {
|
|
180
|
+
throw new Error(`not a tuple key: ${key}`);
|
|
181
|
+
}
|
|
182
|
+
const end = colon + 1 + length;
|
|
183
|
+
if (end > key.length) {
|
|
184
|
+
throw new Error(`not a tuple key: ${key}`);
|
|
185
|
+
}
|
|
186
|
+
parts.push(key.slice(colon + 1, end));
|
|
187
|
+
at = end;
|
|
188
|
+
}
|
|
189
|
+
return parts;
|
|
190
|
+
}
|
|
191
|
+
|
|
160
192
|
// src/onDemand.ts
|
|
161
193
|
var adornmentKey = (adornment) => adornment.map((bound) => bound ? "b" : "f").join("");
|
|
162
194
|
var anyBound = (adornment) => adornment.some(Boolean);
|
|
@@ -312,8 +344,7 @@ var notLit = (relation, ...terms) => ({
|
|
|
312
344
|
negated: true
|
|
313
345
|
});
|
|
314
346
|
var rule = (relation, terms, body) => ({ head: { relation, terms }, body });
|
|
315
|
-
var
|
|
316
|
-
var keyOf2 = (tuple) => tuple.map(atomKey).join("");
|
|
347
|
+
var keyOf2 = (tuple) => tupleKey(tuple);
|
|
317
348
|
function addToBucket(index, key, tuple) {
|
|
318
349
|
const bucket = index.get(key);
|
|
319
350
|
if (bucket === void 0) {
|
|
@@ -352,7 +383,7 @@ var Database = class {
|
|
|
352
383
|
for (const [column, index] of relation.indexes) {
|
|
353
384
|
const value = tuple[column];
|
|
354
385
|
if (value !== void 0) {
|
|
355
|
-
addToBucket(index,
|
|
386
|
+
addToBucket(index, value, tuple);
|
|
356
387
|
}
|
|
357
388
|
}
|
|
358
389
|
return true;
|
|
@@ -364,13 +395,13 @@ var Database = class {
|
|
|
364
395
|
return this.store.get(relationName)?.tuples ?? [];
|
|
365
396
|
}
|
|
366
397
|
/**
|
|
367
|
-
* The facts
|
|
368
|
-
*
|
|
369
|
-
*
|
|
398
|
+
* The facts with `value` at `column`. Once one of a literal's terms
|
|
399
|
+
* is already bound, the join uses this instead of scanning the whole
|
|
400
|
+
* relation.
|
|
370
401
|
*
|
|
371
402
|
* A column is indexed the first time somebody asks for it and stays
|
|
372
|
-
* up to date after that, so a relation nobody joins on this way
|
|
373
|
-
*
|
|
403
|
+
* up to date after that, so a relation nobody joins on this way never
|
|
404
|
+
* gets an index.
|
|
374
405
|
*/
|
|
375
406
|
lookup(relationName, column, value) {
|
|
376
407
|
const relation = this.store.get(relationName);
|
|
@@ -383,19 +414,19 @@ var Database = class {
|
|
|
383
414
|
for (const tuple of relation.tuples) {
|
|
384
415
|
const at = tuple[column];
|
|
385
416
|
if (at !== void 0) {
|
|
386
|
-
addToBucket(index,
|
|
417
|
+
addToBucket(index, at, tuple);
|
|
387
418
|
}
|
|
388
419
|
}
|
|
389
420
|
relation.indexes.set(column, index);
|
|
390
421
|
}
|
|
391
|
-
return index.get(
|
|
422
|
+
return index.get(value) ?? [];
|
|
392
423
|
}
|
|
393
424
|
/**
|
|
394
425
|
* Remove facts from a relation. Returns how many were actually there.
|
|
395
426
|
*
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
427
|
+
* When a rule set uses negation, a new fact can invalidate an old
|
|
428
|
+
* conclusion, so evaluation calls this to remove what it derived last
|
|
429
|
+
* time. Callers rarely need it.
|
|
399
430
|
*/
|
|
400
431
|
retract(relationName, tuples) {
|
|
401
432
|
const relation = this.store.get(relationName);
|
|
@@ -499,7 +530,7 @@ function groundNegated(literal, bindings) {
|
|
|
499
530
|
const bound = bindings.get(term.name);
|
|
500
531
|
if (bound === void 0) {
|
|
501
532
|
throw new Error(
|
|
502
|
-
`unbound variable "${term.name}" in negated literal "${literal.relation}"
|
|
533
|
+
`unbound variable "${term.name}" in negated literal "${literal.relation}": negated literals may only use variables bound by earlier positive literals`
|
|
503
534
|
);
|
|
504
535
|
}
|
|
505
536
|
return bound;
|
|
@@ -652,6 +683,11 @@ function shapeOf(rules) {
|
|
|
652
683
|
shapes.set(rules, shape);
|
|
653
684
|
return shape;
|
|
654
685
|
}
|
|
686
|
+
function couldProduce(db, rule2) {
|
|
687
|
+
return rule2.body.every(
|
|
688
|
+
(literal) => literal.negated || db.size(literal.relation) > 0
|
|
689
|
+
);
|
|
690
|
+
}
|
|
655
691
|
function runRules(db, rules) {
|
|
656
692
|
const {
|
|
657
693
|
signature,
|
|
@@ -710,6 +746,9 @@ function runRules(db, rules) {
|
|
|
710
746
|
};
|
|
711
747
|
const applyDelta = (seed, derivedOnly) => {
|
|
712
748
|
for (const r of stratum) {
|
|
749
|
+
if (!couldProduce(db, r)) {
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
713
752
|
const positives = r.body.filter((l) => !l.negated);
|
|
714
753
|
for (let i = 0; i < positives.length; i++) {
|
|
715
754
|
const literal = positives[i];
|
|
@@ -725,6 +764,9 @@ function runRules(db, rules) {
|
|
|
725
764
|
};
|
|
726
765
|
if (marks === void 0 || marks === null) {
|
|
727
766
|
for (const r of stratum) {
|
|
767
|
+
if (!couldProduce(db, r)) {
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
728
770
|
const all = /* @__PURE__ */ new Map();
|
|
729
771
|
for (const l of r.body) {
|
|
730
772
|
if (!l.negated) {
|
|
@@ -775,5 +817,7 @@ export {
|
|
|
775
817
|
profileEvaluationAsync,
|
|
776
818
|
rule,
|
|
777
819
|
stratify,
|
|
820
|
+
tupleKey,
|
|
821
|
+
tupleKeyParts,
|
|
778
822
|
variable
|
|
779
823
|
};
|
package/package.json
CHANGED