@suss/datalog 0.31.0 → 0.31.2

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/dist/index.js CHANGED
@@ -27,6 +27,7 @@ function deriveOnDemand(rules, complete) {
27
27
  }
28
28
  }
29
29
  const derives = (relation) => rulesByHead.has(relation);
30
+ const labels = labelColumns(rules, derives);
30
31
  const asked = /* @__PURE__ */ new Map();
31
32
  const pending = [];
32
33
  const want = (adorned) => {
@@ -48,7 +49,7 @@ function deriveOnDemand(rules, complete) {
48
49
  while (pending.length > 0) {
49
50
  const adorned = pending.pop();
50
51
  for (const written of rulesByHead.get(adorned.relation) ?? []) {
51
- const r = boundFirst(written, adorned.adornment);
52
+ const r = boundFirst(written, adorned.adornment, labels);
52
53
  const adornments = bodyAdornments(r, adorned.adornment, derives);
53
54
  r.body.forEach((literal, at) => {
54
55
  const adornment = adornments[at];
@@ -91,7 +92,7 @@ function deriveOnDemand(rules, complete) {
91
92
  });
92
93
  }
93
94
  for (const written of rulesByHead.get(adorned.relation) ?? []) {
94
- const r = boundFirst(written, adorned.adornment);
95
+ const r = boundFirst(written, adorned.adornment, labels);
95
96
  const guard = anyBound(adorned.adornment) ? [
96
97
  positiveLiteral(
97
98
  demandOn(adorned.relation, adorned.adornment),
@@ -132,7 +133,30 @@ function deriveOnDemand(rules, complete) {
132
133
  return { rules: rewritten, demandDriven: [...demandDriven], demands };
133
134
  }
134
135
  var sameLiteral = (premises, conclusion) => premises.length === 1 && JSON.stringify(premises[0]) === JSON.stringify(conclusion);
135
- function boundFirst(r, headAdornment) {
136
+ function labelColumns(rules, derives) {
137
+ const labels = /* @__PURE__ */ new Map();
138
+ const note = ({
139
+ relation,
140
+ terms
141
+ }) => {
142
+ if (!derives(relation)) {
143
+ return;
144
+ }
145
+ terms.forEach((term, column) => {
146
+ if (term.type === "constant") {
147
+ const columns = labels.get(relation) ?? /* @__PURE__ */ new Set();
148
+ columns.add(column);
149
+ labels.set(relation, columns);
150
+ }
151
+ });
152
+ };
153
+ for (const r of rules) {
154
+ note(r.head);
155
+ r.body.forEach(note);
156
+ }
157
+ return labels;
158
+ }
159
+ function boundFirst(r, headAdornment, labels) {
136
160
  const bound = /* @__PURE__ */ new Set();
137
161
  r.head.terms.forEach((term, column) => {
138
162
  if (headAdornment[column] && term.type === "variable") {
@@ -140,7 +164,7 @@ function boundFirst(r, headAdornment) {
140
164
  }
141
165
  });
142
166
  const boundColumns = (literal) => literal.terms.filter(
143
- (term) => term.type === "constant" || bound.has(term.name)
167
+ (term, column) => !labels.get(literal.relation)?.has(column) && (term.type === "constant" || bound.has(term.name))
144
168
  ).length;
145
169
  const remaining = [...r.body];
146
170
  const body = [];
@@ -185,21 +209,43 @@ function bodyAdornments(r, headAdornment, derives) {
185
209
  var open = [];
186
210
  var isProfiling = () => open.length > 0;
187
211
  var ruleKey = (head, body) => `${head} <- ${body.join(", ")}`;
188
- function addCost(into, key, head, body, ms, derived) {
212
+ function addCost(into, key, head, body, ms, derived, examined) {
189
213
  const existing = into.get(key);
190
214
  if (existing === void 0) {
191
- into.set(key, { head, body, ms, derived, attempts: 1 });
215
+ into.set(key, { head, body, ms, derived, examined, attempts: 1 });
192
216
  return;
193
217
  }
194
218
  existing.ms += ms;
195
219
  existing.derived += derived;
220
+ existing.examined += examined;
196
221
  existing.attempts += 1;
197
222
  }
198
- function chargeRule(ruleSet, head, body, ms, derived) {
223
+ function chargeRule(ruleSet, head, body, ms, derived, examined) {
199
224
  const key = ruleKey(head, body);
200
225
  for (const collector of open) {
201
- addCost(collector.rules, key, head, body, ms, derived);
202
- addCost(setIn(collector, ruleSet).rules, key, head, body, ms, derived);
226
+ addCost(collector.rules, key, head, body, ms, derived, examined);
227
+ addCost(
228
+ setIn(collector, ruleSet).rules,
229
+ key,
230
+ head,
231
+ body,
232
+ ms,
233
+ derived,
234
+ examined
235
+ );
236
+ }
237
+ }
238
+ function chargeAbandoned(question, examined) {
239
+ for (const collector of open) {
240
+ collector.abandoned.push({ question, examined });
241
+ }
242
+ }
243
+ function chargeQuestion(outcome) {
244
+ for (const collector of open) {
245
+ collector.questions.asked += 1;
246
+ if (outcome !== void 0) {
247
+ collector.questions[outcome] += 1;
248
+ }
203
249
  }
204
250
  }
205
251
  function chargeEvaluation(ruleSet) {
@@ -208,6 +254,13 @@ function chargeEvaluation(ruleSet) {
208
254
  setIn(collector, ruleSet).evaluations += 1;
209
255
  }
210
256
  }
257
+ function chargeEvaluationRows(examined) {
258
+ for (const collector of open) {
259
+ if (examined > collector.largestEvaluation) {
260
+ collector.largestEvaluation = examined;
261
+ }
262
+ }
263
+ }
211
264
  function chargeRound(ruleSet) {
212
265
  for (const collector of open) {
213
266
  collector.rounds += 1;
@@ -267,7 +320,10 @@ function openScope() {
267
320
  rules: /* @__PURE__ */ new Map(),
268
321
  relations: /* @__PURE__ */ new Map(),
269
322
  derivedRelations: /* @__PURE__ */ new Set(),
270
- ruleSets: /* @__PURE__ */ new Map()
323
+ ruleSets: /* @__PURE__ */ new Map(),
324
+ abandoned: [],
325
+ largestEvaluation: 0,
326
+ questions: { asked: 0, abandoned: 0, skipped: 0 }
271
327
  };
272
328
  open.push(mine);
273
329
  return mine;
@@ -285,6 +341,13 @@ var totalMs = (rules) => {
285
341
  }
286
342
  return ms;
287
343
  };
344
+ var totalExamined = (rules) => {
345
+ let rows = 0;
346
+ for (const cost of rules) {
347
+ rows += cost.examined;
348
+ }
349
+ return rows;
350
+ };
288
351
  var byCost = (a, b) => b.ms - a.ms;
289
352
  function summarise(mine) {
290
353
  return {
@@ -292,6 +355,10 @@ function summarise(mine) {
292
355
  datalogMs: totalMs(mine.rules.values()),
293
356
  rounds: mine.rounds,
294
357
  evaluations: mine.evaluations,
358
+ examined: totalExamined(mine.rules.values()),
359
+ largestEvaluation: mine.largestEvaluation,
360
+ abandoned: [...mine.abandoned],
361
+ questions: { ...mine.questions },
295
362
  relations: [...mine.relations].map(([relation, tuples]) => ({
296
363
  relation,
297
364
  tuples,
@@ -312,14 +379,26 @@ function ruleLines(rules, datalogMs) {
312
379
  return rules.slice(0, 20).map((r) => {
313
380
  const ms = `${r.ms.toFixed(0)}ms`.padStart(8);
314
381
  const derived = String(r.derived).padStart(7);
315
- return ` ${ms} ${share(r.ms, datalogMs)} ${derived} tuples ${r.attempts} attempts ${ruleKey(r.head, r.body)}`;
382
+ const examined = String(r.examined).padStart(9);
383
+ return ` ${ms} ${share(r.ms, datalogMs)} ${derived} tuples ${examined} rows read ${r.attempts} attempts ${ruleKey(r.head, r.body)}`;
316
384
  });
317
385
  }
318
386
  function formatProfile(profile) {
319
387
  const lines = [];
320
388
  lines.push(
321
- `datalog: ${profile.datalogMs.toFixed(0)}ms (${share(profile.datalogMs, profile.wallMs).trim()} of ${profile.wallMs.toFixed(0)}ms wall), ${profile.evaluations} evaluations, ${profile.rounds} rounds`
389
+ `datalog: ${profile.datalogMs.toFixed(0)}ms (${share(profile.datalogMs, profile.wallMs).trim()} of ${profile.wallMs.toFixed(0)}ms wall), ${profile.evaluations} evaluations, ${profile.rounds} rounds, ${profile.examined} rows read (${profile.largestEvaluation} in the biggest evaluation)`
322
390
  );
391
+ const { asked, abandoned, skipped } = profile.questions;
392
+ if (asked > 0) {
393
+ lines.push(
394
+ ` ${asked} questions under a site: ${abandoned} given up part way, ${skipped} never put once the run's rows were spent`
395
+ );
396
+ }
397
+ for (const given of profile.abandoned) {
398
+ lines.push(
399
+ ` gave up on ${given.question} after reading ${given.examined} rows`
400
+ );
401
+ }
323
402
  const derivedTuples = profile.relations.filter((r) => r.derived).reduce((sum, r) => sum + r.tuples, 0);
324
403
  lines.push(" tuples by relation (d = derived by a rule):");
325
404
  for (const { relation, tuples, derived } of profile.relations.slice(0, 20)) {
@@ -733,93 +812,109 @@ function headTuple(head, bindings) {
733
812
  return bound;
734
813
  });
735
814
  }
736
- var variablesOf = (literal) => literal.terms.filter((term) => term.type === "variable").map((term) => term.name);
737
- var plans = /* @__PURE__ */ new WeakMap();
738
- function bodyPlan(r, deltaIndex) {
739
- let byDelta = plans.get(r);
740
- if (byDelta === void 0) {
741
- byDelta = /* @__PURE__ */ new Map();
742
- plans.set(r, byDelta);
743
- }
744
- const known = byDelta.get(deltaIndex);
745
- if (known !== void 0) {
746
- return known;
747
- }
748
- const positives = r.body.map((literal, index) => literal.negated ? -1 : index).filter((index) => index !== -1);
749
- const deltaAt = positives[deltaIndex] ?? -1;
750
- const computed = { deltaAt, order: planBodyOrder(r, deltaAt) };
751
- byDelta.set(deltaIndex, computed);
752
- return computed;
815
+ var positiveLiterals = /* @__PURE__ */ new WeakMap();
816
+ function deltaLiteral(r, deltaIndex) {
817
+ let positives = positiveLiterals.get(r);
818
+ if (positives === void 0) {
819
+ positives = r.body.map((literal, index) => literal.negated ? -1 : index).filter((index) => index !== -1);
820
+ positiveLiterals.set(r, positives);
821
+ }
822
+ return positives[deltaIndex] ?? -1;
753
823
  }
754
- function planBodyOrder(r, deltaAt) {
755
- const natural = r.body.map((_, i) => i);
756
- if (deltaAt <= 0) {
757
- return natural;
758
- }
759
- const taken = r.body.map(() => false);
760
- const bound = /* @__PURE__ */ new Set();
761
- const order = [];
762
- const take = (index) => {
763
- taken[index] = true;
764
- order.push(index);
765
- for (const name of variablesOf(r.body[index])) {
766
- bound.add(name);
767
- }
768
- };
769
- const firstWhere = (want) => natural.find((index) => !taken[index] && want(index)) ?? -1;
770
- const nextLiteral = () => {
771
- const ready = firstWhere(
772
- (i) => r.body[i].negated && variablesOf(r.body[i]).every((name) => bound.has(name))
773
- );
774
- if (ready !== -1) {
775
- return ready;
776
- }
777
- const joined = firstWhere(
778
- (i) => !r.body[i].negated && variablesOf(r.body[i]).some((name) => bound.has(name))
779
- );
780
- if (joined !== -1) {
781
- return joined;
782
- }
783
- const disconnected = firstWhere((i) => !r.body[i].negated);
784
- if (disconnected !== -1) {
785
- return disconnected;
824
+ var allBound = (literal, bindings) => literal.terms.every(
825
+ (term) => term.type === "constant" || boundValue(bindings, term.name) !== void 0
826
+ );
827
+ var rowBudget = (limit) => ({
828
+ examined: 0,
829
+ limit
830
+ });
831
+ var OUT_OF_BUDGET = /* @__PURE__ */ Symbol("datalog row budget exhausted");
832
+ var BudgetExhausted = class extends Error {
833
+ constructor(ruleSet, examined) {
834
+ super(`gave up after reading ${examined} rows without settling`);
835
+ this.ruleSet = ruleSet;
836
+ this.examined = examined;
837
+ this.name = "BudgetExhausted";
838
+ }
839
+ ruleSet;
840
+ examined;
841
+ };
842
+ function evaluateRule(db, deltas, r, deltaIndex, budget) {
843
+ const results = [];
844
+ const body = r.body;
845
+ const deltaAt = deltaLiteral(r, deltaIndex);
846
+ const whole = 2 ** body.length - 1;
847
+ const walk2 = (index, source, taken, bindings) => {
848
+ const literal = body[index];
849
+ for (const tuple of source) {
850
+ budget.examined++;
851
+ if (budget.examined > budget.limit) {
852
+ throw OUT_OF_BUDGET;
853
+ }
854
+ const next = unify(literal, tuple, bindings);
855
+ if (next !== NO_MATCH) {
856
+ step(taken | 1 << index, next);
857
+ }
786
858
  }
787
- return firstWhere(() => true);
788
859
  };
789
- take(deltaAt);
790
- while (order.length < r.body.length) {
791
- take(nextLiteral());
792
- }
793
- return order;
794
- }
795
- function evaluateRule(db, deltas, r, deltaIndex) {
796
- const results = [];
797
- const { deltaAt, order } = bodyPlan(r, deltaIndex);
798
- const step = (orderIndex, bindings) => {
799
- if (orderIndex === order.length) {
860
+ const step = (taken, bindings) => {
861
+ if (taken === whole) {
800
862
  results.push(headTuple(r.head, bindings));
801
863
  return;
802
864
  }
803
- const literalIndex = order[orderIndex];
804
- const literal = r.body[literalIndex];
805
- if (literal.negated) {
806
- if (!db.has(literal.relation, groundNegated(literal, bindings))) {
807
- step(orderIndex + 1, bindings);
865
+ let pick = -1;
866
+ let narrowest = null;
867
+ for (let index = 0; index < body.length; index++) {
868
+ if (taken & 1 << index) {
869
+ continue;
870
+ }
871
+ const literal = body[index];
872
+ if (literal.negated) {
873
+ if (allBound(literal, bindings)) {
874
+ if (!db.has(literal.relation, groundNegated(literal, bindings))) {
875
+ step(taken | 1 << index, bindings);
876
+ }
877
+ return;
878
+ }
879
+ continue;
880
+ }
881
+ const source = narrowedSource(db, literal, bindings);
882
+ if (source === null) {
883
+ continue;
884
+ }
885
+ if (source.length === 0) {
886
+ return;
887
+ }
888
+ if (narrowest === null || source.length < narrowest.length) {
889
+ pick = index;
890
+ narrowest = source;
808
891
  }
892
+ }
893
+ if (narrowest !== null) {
894
+ walk2(pick, narrowest, taken, bindings);
809
895
  return;
810
896
  }
811
- const source = literalIndex === deltaAt ? deltas.get(literal.relation) ?? [] : boundSource(db, literal, bindings);
812
- for (const tuple of source) {
813
- const next = unify(literal, tuple, bindings);
814
- if (next !== NO_MATCH) {
815
- step(orderIndex + 1, next);
897
+ for (let index = 0; index < body.length; index++) {
898
+ if (!(taken & 1 << index) && !body[index].negated) {
899
+ walk2(index, db.facts(body[index].relation), taken, bindings);
900
+ return;
901
+ }
902
+ }
903
+ for (let index = 0; index < body.length; index++) {
904
+ if (!(taken & 1 << index)) {
905
+ groundNegated(body[index], bindings);
816
906
  }
817
907
  }
818
908
  };
819
- step(0, null);
909
+ if (deltaAt === -1) {
910
+ step(0, null);
911
+ } else {
912
+ walk2(deltaAt, deltas.get(body[deltaAt].relation) ?? [], 0, null);
913
+ }
820
914
  return results;
821
915
  }
822
- function boundSource(db, literal, bindings) {
916
+ var boundSource = (db, literal, bindings) => narrowedSource(db, literal, bindings) ?? db.facts(literal.relation);
917
+ function narrowedSource(db, literal, bindings) {
823
918
  let narrowest = null;
824
919
  for (let column = 0; column < literal.terms.length; column++) {
825
920
  const term = literal.terms[column];
@@ -835,10 +930,10 @@ function boundSource(db, literal, bindings) {
835
930
  narrowest = bucket;
836
931
  }
837
932
  }
838
- return narrowest ?? db.facts(literal.relation);
933
+ return narrowest;
839
934
  }
840
935
  var EMPTY_TAGS = [];
841
- function evaluateRuleTagged(db, deltas, r, deltaIndex, algebra) {
936
+ function evaluateRuleTagged(db, deltas, r, deltaIndex, algebra, budget) {
842
937
  const results = [];
843
938
  const bodyTags = [];
844
939
  const bodyMatches = [];
@@ -876,6 +971,7 @@ function evaluateRuleTagged(db, deltas, r, deltaIndex, algebra) {
876
971
  }
877
972
  const source = positiveIndex === deltaIndex ? deltas.get(literal.relation) ?? [] : boundSource(db, literal, bindings);
878
973
  for (const tuple of source) {
974
+ budget.examined++;
879
975
  const next = unify(literal, tuple, bindings);
880
976
  if (next !== NO_MATCH) {
881
977
  if (readsTags) {
@@ -959,15 +1055,18 @@ function currentMarks(db) {
959
1055
  }
960
1056
  return marks;
961
1057
  }
962
- function evaluate(db, rules, algebra) {
1058
+ function evaluate(db, rules, algebra, budget) {
963
1059
  if (algebra !== void 0 && isDemandRewritten(rules)) {
964
1060
  throw new Error(
965
1061
  "cannot evaluate demand-rewritten rules with a tag algebra: tags would follow the demand-transformed rules rather than the ones the caller wrote"
966
1062
  );
967
1063
  }
1064
+ if (algebra !== void 0 && budget !== void 0) {
1065
+ throw new Error("cannot evaluate with both a tag algebra and a row budget");
1066
+ }
968
1067
  deriving.set(db, (deriving.get(db) ?? 0) + 1);
969
1068
  try {
970
- return runRules(db, rules, algebra);
1069
+ return runRules(db, rules, algebra, budget);
971
1070
  } finally {
972
1071
  const depth = (deriving.get(db) ?? 1) - 1;
973
1072
  if (depth === 0) {
@@ -1017,13 +1116,19 @@ function storedMergeFor(algebra) {
1017
1116
  return stored === void 0 && merged === algebra.asserted ? void 0 : merged;
1018
1117
  };
1019
1118
  }
1020
- function runRules(db, rules, algebra) {
1119
+ function runRules(db, rules, algebra, given) {
1021
1120
  const {
1022
1121
  signature,
1023
1122
  name: ruleSetName,
1024
1123
  derivedRelations,
1025
1124
  strata
1026
1125
  } = shapeOf(rules);
1126
+ const budget = given ?? {
1127
+ examined: 0,
1128
+ limit: Number.POSITIVE_INFINITY
1129
+ };
1130
+ const startedAt = budget.examined;
1131
+ const addedHere = given === void 0 ? null : [];
1027
1132
  chargeEvaluation(ruleSetName);
1028
1133
  const states = statesFor(db);
1029
1134
  const state = states.get(signature) ?? {
@@ -1045,10 +1150,11 @@ function runRules(db, rules, algebra) {
1045
1150
  derived.set(relation, ledger);
1046
1151
  }
1047
1152
  ledger.set(keyOf2(tuple), tuple);
1153
+ addedHere?.push([relation, tuple]);
1048
1154
  };
1049
1155
  const mergeStored = algebra === void 0 ? void 0 : storedMergeFor(algebra);
1050
1156
  const marks = canResume(rules, state) ? state.marks : void 0;
1051
- for (const stratum of strata) {
1157
+ const runStratum = (stratum) => {
1052
1158
  let delta = /* @__PURE__ */ new Map();
1053
1159
  const derivedHere = new Set(stratum.map((r) => r.head.relation));
1054
1160
  const record = (relation, tuple) => {
@@ -1074,13 +1180,14 @@ function runRules(db, rules, algebra) {
1074
1180
  seed,
1075
1181
  r,
1076
1182
  deltaIndex,
1077
- algebra
1183
+ algebra,
1184
+ budget
1078
1185
  )) {
1079
1186
  recordTagged(r.head.relation, found.tuple, found.tag);
1080
1187
  }
1081
1188
  return;
1082
1189
  }
1083
- for (const tuple of evaluateRule(db, seed, r, deltaIndex)) {
1190
+ for (const tuple of evaluateRule(db, seed, r, deltaIndex, budget)) {
1084
1191
  record(r.head.relation, tuple);
1085
1192
  }
1086
1193
  };
@@ -1089,15 +1196,17 @@ function runRules(db, rules, algebra) {
1089
1196
  deriveInto(r, seed, deltaIndex);
1090
1197
  return;
1091
1198
  }
1092
- const startedAt = performance.now();
1199
+ const startedAt2 = performance.now();
1093
1200
  const before = db.size(r.head.relation);
1201
+ const readBefore = budget.examined;
1094
1202
  deriveInto(r, seed, deltaIndex);
1095
1203
  chargeRule(
1096
1204
  ruleSetName,
1097
1205
  r.head.relation,
1098
1206
  r.body.map(literalName),
1099
- performance.now() - startedAt,
1100
- db.size(r.head.relation) - before
1207
+ performance.now() - startedAt2,
1208
+ db.size(r.head.relation) - before,
1209
+ budget.examined - readBefore
1101
1210
  );
1102
1211
  };
1103
1212
  const applyDelta = (seed, derivedOnly) => {
@@ -1142,7 +1251,24 @@ function runRules(db, rules, algebra) {
1142
1251
  applyDelta(lastDelta, true);
1143
1252
  chargeRound(ruleSetName);
1144
1253
  }
1254
+ };
1255
+ const readHere = () => budget.examined - startedAt;
1256
+ try {
1257
+ for (const stratum of strata) {
1258
+ runStratum(stratum);
1259
+ }
1260
+ } catch (error) {
1261
+ if (error !== OUT_OF_BUDGET) {
1262
+ throw error;
1263
+ }
1264
+ for (const [relation, tuples] of byRelation(addedHere ?? [])) {
1265
+ db.retract(relation, tuples);
1266
+ }
1267
+ state.marks = null;
1268
+ chargeEvaluationRows(readHere());
1269
+ throw new BudgetExhausted(ruleSetName, readHere());
1145
1270
  }
1271
+ chargeEvaluationRows(readHere());
1146
1272
  state.marks = currentMarks(db);
1147
1273
  if (isProfiling()) {
1148
1274
  chargeRelationSizes(
@@ -1152,6 +1278,13 @@ function runRules(db, rules, algebra) {
1152
1278
  }
1153
1279
  return db;
1154
1280
  }
1281
+ function byRelation(added) {
1282
+ const buckets = /* @__PURE__ */ new Map();
1283
+ for (const [relation, tuple] of added) {
1284
+ addTo(buckets, relation, tuple);
1285
+ }
1286
+ return buckets;
1287
+ }
1155
1288
  function addTo(buckets, relation, tuple) {
1156
1289
  const bucket = buckets.get(relation);
1157
1290
  if (bucket === void 0) {
@@ -1161,8 +1294,11 @@ function addTo(buckets, relation, tuple) {
1161
1294
  }
1162
1295
  }
1163
1296
  export {
1297
+ BudgetExhausted,
1164
1298
  Database,
1165
1299
  Witness,
1300
+ chargeAbandoned,
1301
+ chargeQuestion,
1166
1302
  clearRelations,
1167
1303
  confidence,
1168
1304
  confidenceWith,
@@ -1175,6 +1311,7 @@ export {
1175
1311
  profileEvaluation,
1176
1312
  profileEvaluationAsync,
1177
1313
  proofOf,
1314
+ rowBudget,
1178
1315
  rule,
1179
1316
  ruleLabel,
1180
1317
  stratify,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suss/datalog",
3
- "version": "0.31.0",
3
+ "version": "0.31.2",
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": [