@suss/datalog 0.23.0 → 0.24.1
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 +8 -288
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# @suss/datalog
|
|
2
2
|
|
|
3
|
+
Part of [suss](https://github.com/nimbuscloud-ai/suss), which reads both sides of every call in a repository and says where the two disagree.
|
|
4
|
+
|
|
3
5
|
A small semi-naïve Datalog evaluator with stratified negation. This is
|
|
4
6
|
the rules engine behind suss's derived program facts.
|
|
5
7
|
|
|
@@ -43,294 +45,12 @@ longer term, an analysis written against a set of facts (`calls`,
|
|
|
43
45
|
`throws`, `handles`, and so on) does not depend on the language: a
|
|
44
46
|
second language adapter only has to emit the same facts.
|
|
45
47
|
|
|
46
|
-
##
|
|
47
|
-
|
|
48
|
-
1. **Pure TypeScript, zero dependencies.** The npm-shipped CLI cannot
|
|
49
|
-
require a native binary.
|
|
50
|
-
2. **Rules are data.** `Rule` / `Literal` / `Term` objects, built with
|
|
51
|
-
`rule` / `lit` / `notLit` / `variable` / `constant`.
|
|
52
|
-
3. **Sound negation only.** Stratification is checked; negated literals
|
|
53
|
-
must ground all variables from earlier positive literals.
|
|
54
|
-
|
|
55
|
-
## What it does to stay quick
|
|
56
|
-
|
|
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
|
-
|
|
60
|
-
Joins narrow before they scan. Once a literal has any term fixed, either
|
|
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 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.
|
|
66
|
-
|
|
67
|
-
The body is not always walked in the order it was written. A round draws
|
|
68
|
-
one literal from the facts that arrived last round, and that list is
|
|
69
|
-
usually short, so it goes first and everything after it starts with some
|
|
70
|
-
variables already bound. Take `chain(x, z) :- chain(x, y), binds(y, z)`
|
|
71
|
-
in the round where new `binds` facts arrive. Written order asks for
|
|
72
|
-
`chain(x, y)` with nothing bound, which scans every chain fact and tries
|
|
73
|
-
each one against every new binding. Leading with the new bindings binds
|
|
74
|
-
`y`, and `chain` then comes off its index on that column. On suss's own
|
|
75
|
-
sources that one reordering took the rule from 2.4 seconds to 55
|
|
76
|
-
milliseconds.
|
|
77
|
-
|
|
78
|
-
Once the delta leads, the rest of the body follows greedily: the next
|
|
79
|
-
literal is one sharing a variable that is already bound, so it too comes
|
|
80
|
-
off an index rather than scanning. A negated literal is a filter, so it
|
|
81
|
-
is placed as soon as its variables are bound. A join produces the same
|
|
82
|
-
rows whatever order it walks in, so this changes what a round costs and
|
|
83
|
-
not what it derives.
|
|
84
|
-
|
|
85
|
-
`evaluate` picks up where it left off. Call it again with the same rules
|
|
86
|
-
after adding facts and it starts from the facts you added rather than
|
|
87
|
-
redoing the whole fixpoint. Callers that go back and forth between
|
|
88
|
-
adding facts and asking questions get this for free. Positive rules are
|
|
89
|
-
monotone, so everything derived earlier still holds.
|
|
90
|
-
|
|
91
|
-
Negated rules work differently. A new fact can make a negated literal
|
|
92
|
-
stop matching, and the conclusion that rested on it has to go. So when
|
|
93
|
-
you re-run with negated rules, the engine throws away what the previous
|
|
94
|
-
pass derived and works the answer out again from the base facts. Either
|
|
95
|
-
way, the database ends up with the answer for the facts it has now.
|
|
96
|
-
|
|
97
|
-
## Tagging derivations
|
|
98
|
-
|
|
99
|
-
A fact is normally there or not there. Pass `evaluate` a `TagAlgebra`
|
|
100
|
-
and every derived fact also gets a tag: `combine` turns the body's
|
|
101
|
-
tags into the head's, and `merge` decides what to keep when the same
|
|
102
|
-
fact is derived a second time. This is a provenance semiring in the
|
|
103
|
-
sense of Green, Karvounarakis, and Tannen (2007); Soufflé's provenance
|
|
104
|
-
mode stores a minimal proof height with the same two operations. Rules
|
|
105
|
-
never mention tags, and a call without an algebra runs as before.
|
|
106
|
-
|
|
107
|
-
```ts
|
|
108
|
-
const cost: TagAlgebra<number> = {
|
|
109
|
-
asserted: 0, // what an untagged fact contributes
|
|
110
|
-
absent: 0, // what a matched negated literal contributes
|
|
111
|
-
combine: (tags) => tags.reduce((sum, tag) => sum + tag, 1),
|
|
112
|
-
merge: (stored, incoming) => (incoming < stored ? incoming : stored),
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
evaluate(db, rules, cost);
|
|
116
|
-
db.tagOf("reaches", ["a", "c"]); // the cheapest derivation's cost
|
|
117
|
-
```
|
|
118
|
-
|
|
119
|
-
`combine` is also handed the derivation itself: the `Rule` that fired,
|
|
120
|
-
and one entry per body literal saying which fact it matched, or which
|
|
121
|
-
grounded tuple a negated literal checked and did not find. An algebra
|
|
122
|
-
that only folds tags, like the cost above, ignores it; the witness
|
|
123
|
-
algebra below is built out of it.
|
|
124
|
-
|
|
125
|
-
Tags are stored beside the relation, keyed the same way as the facts,
|
|
126
|
-
never in the tuple. A tuple is its own identity: were the tag part of it, an
|
|
127
|
-
improved tag would read as a new fact and the fixpoint would never
|
|
128
|
-
close. `Database.add` reports "added", "improved", or "unchanged", and
|
|
129
|
-
an improved fact re-enters the delta so conclusions built on it
|
|
130
|
-
recompute with the better tag.
|
|
131
|
-
|
|
132
|
-
The contract on `merge`:
|
|
133
|
-
|
|
134
|
-
- Return the stored tag itself, the same value by `===`, when the new
|
|
135
|
-
derivation does not improve on it. Anything else is stored and
|
|
136
|
-
re-derives downstream.
|
|
137
|
-
- For recursive rule sets, merge must be a bounded meet: repeated
|
|
138
|
-
merges must stop improving after finitely many steps, the way `min`
|
|
139
|
-
over numbers does. The engine does not check this; an algebra that
|
|
140
|
-
keeps improving keeps evaluating.
|
|
141
|
-
- `undefined` is not a valid tag. The store uses it to mean "no tag",
|
|
142
|
-
and `tagOf` returns it for untagged facts.
|
|
143
|
-
|
|
144
|
-
Two caveats. Supply the same algebra on every evaluation of a rule set
|
|
145
|
-
over a database: a resumed run derives only from facts added since, so
|
|
146
|
-
it tags only what those reach. And rules rewritten by `deriveOnDemand`
|
|
147
|
-
refuse an algebra: tags would follow the demand-transformed rules
|
|
148
|
-
rather than the ones you wrote, so `evaluate` throws instead.
|
|
149
|
-
|
|
150
|
-
## Witnesses, and proofs on demand
|
|
151
|
-
|
|
152
|
-
The `witnesses` algebra keeps one derivation per derived fact: the rule
|
|
153
|
-
that fired and one entry per body literal. Its merge keeps whatever is
|
|
154
|
-
already stored, so no fact ever re-enters the delta, the fixpoint
|
|
155
|
-
behaves exactly as it does untagged, and the tag stays one small object
|
|
156
|
-
per fact. `proofOf` then rebuilds the tree for one fact by walking
|
|
157
|
-
those witnesses backward, without re-running any rule:
|
|
158
|
-
|
|
159
|
-
```ts
|
|
160
|
-
import { evaluate, proofOf, ruleLabel, witnesses } from "@suss/datalog";
|
|
161
|
-
|
|
162
|
-
evaluate(db, rules, witnesses);
|
|
163
|
-
const proof = proofOf(db, "reachable", ["util"]);
|
|
164
|
-
// {
|
|
165
|
-
// kind: "derived",
|
|
166
|
-
// rule: <the Rule that fired>,
|
|
167
|
-
// premises: [
|
|
168
|
-
// { kind: "derived", ... }, // walked further down
|
|
169
|
-
// { kind: "fact", relation: "calls", tuple: ["helper", "util"] },
|
|
170
|
-
// ],
|
|
171
|
-
// }
|
|
172
|
-
```
|
|
173
|
-
|
|
174
|
-
A proof node is one of four kinds. `derived` has the rule and one
|
|
175
|
-
premise per body literal, in rule-body order. `fact` is a leaf with no
|
|
176
|
-
witness: the caller asserted it, or it was derived without the
|
|
177
|
-
algebra. `absence` is a leaf for a tuple missing from the database, at
|
|
178
|
-
the root when you ask about a fact that was never derived, and under a
|
|
179
|
-
derivation where a negated literal relied on the tuple being missing;
|
|
180
|
-
that absence is recorded at derivation time, so a proof can say "and no
|
|
181
|
-
handles(x) existed" without checking anything again. `truncated` is
|
|
182
|
-
where the walk stopped, at the depth cap (`maxDepth`, 128 unless you
|
|
183
|
-
say otherwise) or at a cycle.
|
|
184
|
-
|
|
185
|
-
First-wins gives you *a* proof, not the shortest one. When nine
|
|
186
|
-
derivations reach the same fact, the one stored is the one evaluation
|
|
187
|
-
found first. A different merge, say keeping the witness with the
|
|
188
|
-
smaller proof height, fits the same algebra interface without touching
|
|
189
|
-
the walk.
|
|
190
|
-
|
|
191
|
-
A proof shows each rule as `ruleLabel` renders it: the `name` you
|
|
192
|
-
passed as `rule`'s fourth argument, or the head and body relations, as
|
|
193
|
-
in `path :- path, !blocked`. The witness keeps the `Rule` object
|
|
194
|
-
itself, so two rules that render alike stay distinct.
|
|
195
|
-
|
|
196
|
-
## Confidence levels
|
|
197
|
-
|
|
198
|
-
The `confidence` algebra propagates how sure the run is of each fact,
|
|
199
|
-
as one of `"high"`, `"medium"`, or `"low"`. A rule firing takes the
|
|
200
|
-
minimum level across its body, so a conclusion is only as sure as its
|
|
201
|
-
weakest premise, and a fact derived twice keeps the better level. An
|
|
202
|
-
asserted fact without a tag counts as high; to say less, assert it
|
|
203
|
-
with a level:
|
|
204
|
-
|
|
205
|
-
```ts
|
|
206
|
-
import { confidence, evaluate } from "@suss/datalog";
|
|
48
|
+
## More
|
|
207
49
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
db.tagOf("path", ["a", "c"]); // "medium"
|
|
212
|
-
```
|
|
213
|
-
|
|
214
|
-
A rule can be a heuristic itself, and then its conclusions should not
|
|
215
|
-
outrank it. `confidenceWith` takes a level per rule and folds it into
|
|
216
|
-
the minimum; rules the callback returns `undefined` for count as
|
|
217
|
-
exact. Both operations are idempotent, so ten medium steps come out
|
|
218
|
-
medium, and a matched negation counts as high, because negation here
|
|
219
|
-
is exact over the database as computed.
|
|
220
|
-
|
|
221
|
-
## Deriving only what somebody asked for
|
|
222
|
-
|
|
223
|
-
A rule set written for a whole program derives every conclusion its
|
|
224
|
-
facts support, and a caller who asks about one value reads only a
|
|
225
|
-
handful of them. `deriveOnDemand` rewrites the rules so that conclusions
|
|
226
|
-
nobody is waiting on never get derived.
|
|
227
|
-
|
|
228
|
-
List the relations that have to come out whole, and give each of them a
|
|
229
|
-
rule that starts at a base relation you assert:
|
|
230
|
-
|
|
231
|
-
```ts
|
|
232
|
-
const rules = [
|
|
233
|
-
rule("reaches", [v("x"), v("y")], [lit("edge", v("x"), v("y"))]),
|
|
234
|
-
rule(
|
|
235
|
-
"reaches",
|
|
236
|
-
[v("x"), v("z")],
|
|
237
|
-
[lit("edge", v("x"), v("y")), lit("reaches", v("y"), v("z"))],
|
|
238
|
-
),
|
|
239
|
-
rule(
|
|
240
|
-
"answer",
|
|
241
|
-
[v("x"), v("y")],
|
|
242
|
-
[lit("asked", v("x")), lit("reaches", v("x"), v("y"))],
|
|
243
|
-
),
|
|
244
|
-
];
|
|
245
|
-
|
|
246
|
-
const db = new Database();
|
|
247
|
-
db.add("edge", ["a", "b"]);
|
|
248
|
-
db.add("edge", ["b", "c"]);
|
|
249
|
-
db.add("edge", ["m", "n"]);
|
|
250
|
-
db.add("asked", ["a"]);
|
|
251
|
-
|
|
252
|
-
const program = deriveOnDemand(rules, ["answer"]);
|
|
253
|
-
evaluate(db, program.rules);
|
|
254
|
-
db.facts("answer"); // [["a", "b"], ["a", "c"]]
|
|
255
|
-
db.facts("reaches"); // the chain from a, and nothing from m
|
|
256
|
-
```
|
|
257
|
-
|
|
258
|
-
The rewrite is magic sets. Each derived relation gets a companion
|
|
259
|
-
relation that records which of its rows something is waiting on, that
|
|
260
|
-
companion becomes a literal in the rule body, and demand travels down
|
|
261
|
-
each body the same way the join binds variables. A relation nothing asks
|
|
262
|
-
for is not derived at all, so only read back the relations you listed.
|
|
263
|
-
|
|
264
|
-
The rewrite puts each body in the order demand should travel, which is
|
|
265
|
-
not always written order. At each position it takes the literal with the
|
|
266
|
-
most columns already fixed, by a constant or by a variable the head or
|
|
267
|
-
an earlier literal bound, and keeps written order between equals. Take
|
|
268
|
-
`moduleExport(m, n, v) <- reExports(m, n, m2, n2), moduleExport(m2, n2,
|
|
269
|
-
v)` asked with `v` bound. In written order the re-export comes first and
|
|
270
|
-
binds `m2` and `n2` off every re-export in the program, so the recursive
|
|
271
|
-
call wants `moduleExport` with all three columns bound: one demand fact
|
|
272
|
-
per re-export per value asked about. With `moduleExport(m2, n2, v)`
|
|
273
|
-
first, the demand stays on `v` and the re-exports come off an index on
|
|
274
|
-
`m2`. A body the head binds nothing in starts with its written first
|
|
275
|
-
literal.
|
|
276
|
-
|
|
277
|
-
This costs you two things. The companion relations are stored like any
|
|
278
|
-
other, at a few tuples per value asked about, so a caller who asks about
|
|
279
|
-
most of a program ends up deriving more rather than less. And the
|
|
280
|
-
rewrite refuses negation: a relation derived only where somebody asked
|
|
281
|
-
is smaller than the one a negated literal was written against, which
|
|
282
|
-
would make `not p(x)` match where it should not.
|
|
283
|
-
|
|
284
|
-
### Taking a question back
|
|
285
|
-
|
|
286
|
-
Demand is a fact, and a fact stays until somebody removes it. A caller
|
|
287
|
-
that asks a thousand questions of one database re-derives all thousand
|
|
288
|
-
every time new facts arrive, so the last question costs as much as a
|
|
289
|
-
thousand questions.
|
|
290
|
-
|
|
291
|
-
`program.demandDriven` lists the relations the rewrite restricts, and
|
|
292
|
-
`clearRelations` empties them once you have read an answer:
|
|
293
|
-
|
|
294
|
-
```ts
|
|
295
|
-
clearRelations(db, program.rules, [...program.demandDriven, "asked"]);
|
|
296
|
-
|
|
297
|
-
db.add("asked", ["m"]);
|
|
298
|
-
evaluate(db, program.rules);
|
|
299
|
-
db.facts("reaches"); // the chain from m, and nothing from a
|
|
300
|
-
db.facts("answer"); // a's answers, which nothing took away, plus m's
|
|
301
|
-
```
|
|
302
|
-
|
|
303
|
-
`retract` would send the next run back to the base facts, because a fact
|
|
304
|
-
leaving the database can take away a conclusion drawn anywhere.
|
|
305
|
-
`clearRelations` lets the next run resume instead, because a caller who
|
|
306
|
-
passes these relations is saying that nothing outside them was derived
|
|
307
|
-
from them. That is true for the relations `deriveOnDemand` restricts:
|
|
308
|
-
every one of them is derived under a demand fact, and only the relations
|
|
309
|
-
you listed as complete read from them. Those keep what they already
|
|
310
|
-
contain, which is the answers you have read.
|
|
311
|
-
|
|
312
|
-
Keep the facts you add and the facts the rules derive in separate
|
|
313
|
-
relations. Taking a conclusion back cannot tell one from the other, and
|
|
314
|
-
splitting them is how people normally write Datalog anyway.
|
|
315
|
-
|
|
316
|
-
### Reading what the rules are waiting on
|
|
317
|
-
|
|
318
|
-
Demand is also something a caller can read. `program.demands` lists
|
|
319
|
-
each demand relation with the relation it is for and which of that
|
|
320
|
-
relation's columns a demand row fixes, in column order:
|
|
321
|
-
|
|
322
|
-
```ts
|
|
323
|
-
program.demands;
|
|
324
|
-
// [{ relation: "reaches", bound: [true, false], demand: "wanted:reaches" }]
|
|
325
|
-
db.facts("wanted:reaches"); // [["a"], ["b"], ["c"]]
|
|
326
|
-
```
|
|
50
|
+
- [How the engine evaluates](./DESIGN.md)
|
|
51
|
+
- [Documentation](https://nimbuscloud-ai.github.io/suss/)
|
|
52
|
+
- [Source and issues](https://github.com/nimbuscloud-ai/suss)
|
|
327
53
|
|
|
328
|
-
|
|
329
|
-
load next. Evaluate, read the demand rows on the relation whose base
|
|
330
|
-
facts come from somewhere else, fetch those, evaluate again, and stop
|
|
331
|
-
when no demand row is new. The rules then decide what gets loaded, and
|
|
332
|
-
the caller never has to guess how far a chain runs.
|
|
54
|
+

|
|
333
55
|
|
|
334
|
-
|
|
335
|
-
the rule data model is the part that stays and this evaluator is the
|
|
336
|
-
part you replace.
|
|
56
|
+
Apache 2.0. See [LICENSE](../../LICENSE).
|
package/package.json
CHANGED