@suss/datalog 0.23.0 → 0.24.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 (2) hide show
  1. package/README.md +8 -288
  2. 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
- ## Design constraints
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
- db.add("edge", ["a", "b"]); // high: read from source
209
- db.add("edge", ["b", "c"], "medium"); // a guess somebody should check
210
- evaluate(db, rules, confidence);
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
- A caller that loads its base facts lazily uses this to find out what to
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
+ ![coverage](../../.github/badges/coverage-datalog.svg)
333
55
 
334
- Extraction-scale fact sets run to thousands of tuples. If that changes,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suss/datalog",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "type": "module",
5
5
  "description": "A small semi-naive Datalog evaluator with stratified negation, the rules engine suss runs its whole-program analyses on.",
6
6
  "keywords": [