@suss/datalog 0.31.0 → 0.31.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/dist/index.cjs CHANGED
@@ -20,8 +20,11 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ BudgetExhausted: () => BudgetExhausted,
23
24
  Database: () => Database,
24
25
  Witness: () => Witness,
26
+ chargeAbandoned: () => chargeAbandoned,
27
+ chargeQuestion: () => chargeQuestion,
25
28
  clearRelations: () => clearRelations,
26
29
  confidence: () => confidence,
27
30
  confidenceWith: () => confidenceWith,
@@ -34,6 +37,7 @@ __export(index_exports, {
34
37
  profileEvaluation: () => profileEvaluation,
35
38
  profileEvaluationAsync: () => profileEvaluationAsync,
36
39
  proofOf: () => proofOf,
40
+ rowBudget: () => rowBudget,
37
41
  rule: () => rule,
38
42
  ruleLabel: () => ruleLabel,
39
43
  stratify: () => stratify,
@@ -231,21 +235,43 @@ function bodyAdornments(r, headAdornment, derives) {
231
235
  var open = [];
232
236
  var isProfiling = () => open.length > 0;
233
237
  var ruleKey = (head, body) => `${head} <- ${body.join(", ")}`;
234
- function addCost(into, key, head, body, ms, derived) {
238
+ function addCost(into, key, head, body, ms, derived, examined) {
235
239
  const existing = into.get(key);
236
240
  if (existing === void 0) {
237
- into.set(key, { head, body, ms, derived, attempts: 1 });
241
+ into.set(key, { head, body, ms, derived, examined, attempts: 1 });
238
242
  return;
239
243
  }
240
244
  existing.ms += ms;
241
245
  existing.derived += derived;
246
+ existing.examined += examined;
242
247
  existing.attempts += 1;
243
248
  }
244
- function chargeRule(ruleSet, head, body, ms, derived) {
249
+ function chargeRule(ruleSet, head, body, ms, derived, examined) {
245
250
  const key = ruleKey(head, body);
246
251
  for (const collector of open) {
247
- addCost(collector.rules, key, head, body, ms, derived);
248
- addCost(setIn(collector, ruleSet).rules, key, head, body, ms, derived);
252
+ addCost(collector.rules, key, head, body, ms, derived, examined);
253
+ addCost(
254
+ setIn(collector, ruleSet).rules,
255
+ key,
256
+ head,
257
+ body,
258
+ ms,
259
+ derived,
260
+ examined
261
+ );
262
+ }
263
+ }
264
+ function chargeAbandoned(question, examined) {
265
+ for (const collector of open) {
266
+ collector.abandoned.push({ question, examined });
267
+ }
268
+ }
269
+ function chargeQuestion(outcome) {
270
+ for (const collector of open) {
271
+ collector.questions.asked += 1;
272
+ if (outcome !== void 0) {
273
+ collector.questions[outcome] += 1;
274
+ }
249
275
  }
250
276
  }
251
277
  function chargeEvaluation(ruleSet) {
@@ -254,6 +280,13 @@ function chargeEvaluation(ruleSet) {
254
280
  setIn(collector, ruleSet).evaluations += 1;
255
281
  }
256
282
  }
283
+ function chargeEvaluationRows(examined) {
284
+ for (const collector of open) {
285
+ if (examined > collector.largestEvaluation) {
286
+ collector.largestEvaluation = examined;
287
+ }
288
+ }
289
+ }
257
290
  function chargeRound(ruleSet) {
258
291
  for (const collector of open) {
259
292
  collector.rounds += 1;
@@ -313,7 +346,10 @@ function openScope() {
313
346
  rules: /* @__PURE__ */ new Map(),
314
347
  relations: /* @__PURE__ */ new Map(),
315
348
  derivedRelations: /* @__PURE__ */ new Set(),
316
- ruleSets: /* @__PURE__ */ new Map()
349
+ ruleSets: /* @__PURE__ */ new Map(),
350
+ abandoned: [],
351
+ largestEvaluation: 0,
352
+ questions: { asked: 0, abandoned: 0, skipped: 0 }
317
353
  };
318
354
  open.push(mine);
319
355
  return mine;
@@ -331,6 +367,13 @@ var totalMs = (rules) => {
331
367
  }
332
368
  return ms;
333
369
  };
370
+ var totalExamined = (rules) => {
371
+ let rows = 0;
372
+ for (const cost of rules) {
373
+ rows += cost.examined;
374
+ }
375
+ return rows;
376
+ };
334
377
  var byCost = (a, b) => b.ms - a.ms;
335
378
  function summarise(mine) {
336
379
  return {
@@ -338,6 +381,10 @@ function summarise(mine) {
338
381
  datalogMs: totalMs(mine.rules.values()),
339
382
  rounds: mine.rounds,
340
383
  evaluations: mine.evaluations,
384
+ examined: totalExamined(mine.rules.values()),
385
+ largestEvaluation: mine.largestEvaluation,
386
+ abandoned: [...mine.abandoned],
387
+ questions: { ...mine.questions },
341
388
  relations: [...mine.relations].map(([relation, tuples]) => ({
342
389
  relation,
343
390
  tuples,
@@ -358,14 +405,26 @@ function ruleLines(rules, datalogMs) {
358
405
  return rules.slice(0, 20).map((r) => {
359
406
  const ms = `${r.ms.toFixed(0)}ms`.padStart(8);
360
407
  const derived = String(r.derived).padStart(7);
361
- return ` ${ms} ${share(r.ms, datalogMs)} ${derived} tuples ${r.attempts} attempts ${ruleKey(r.head, r.body)}`;
408
+ const examined = String(r.examined).padStart(9);
409
+ return ` ${ms} ${share(r.ms, datalogMs)} ${derived} tuples ${examined} rows read ${r.attempts} attempts ${ruleKey(r.head, r.body)}`;
362
410
  });
363
411
  }
364
412
  function formatProfile(profile) {
365
413
  const lines = [];
366
414
  lines.push(
367
- `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`
415
+ `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)`
368
416
  );
417
+ const { asked, abandoned, skipped } = profile.questions;
418
+ if (asked > 0) {
419
+ lines.push(
420
+ ` ${asked} questions under a site: ${abandoned} given up part way, ${skipped} never put once the run's rows were spent`
421
+ );
422
+ }
423
+ for (const given of profile.abandoned) {
424
+ lines.push(
425
+ ` gave up on ${given.question} after reading ${given.examined} rows`
426
+ );
427
+ }
369
428
  const derivedTuples = profile.relations.filter((r) => r.derived).reduce((sum, r) => sum + r.tuples, 0);
370
429
  lines.push(" tuples by relation (d = derived by a rule):");
371
430
  for (const { relation, tuples, derived } of profile.relations.slice(0, 20)) {
@@ -838,7 +897,22 @@ function planBodyOrder(r, deltaAt) {
838
897
  }
839
898
  return order;
840
899
  }
841
- function evaluateRule(db, deltas, r, deltaIndex) {
900
+ var rowBudget = (limit) => ({
901
+ examined: 0,
902
+ limit
903
+ });
904
+ var OUT_OF_BUDGET = /* @__PURE__ */ Symbol("datalog row budget exhausted");
905
+ var BudgetExhausted = class extends Error {
906
+ constructor(ruleSet, examined) {
907
+ super(`gave up after reading ${examined} rows without settling`);
908
+ this.ruleSet = ruleSet;
909
+ this.examined = examined;
910
+ this.name = "BudgetExhausted";
911
+ }
912
+ ruleSet;
913
+ examined;
914
+ };
915
+ function evaluateRule(db, deltas, r, deltaIndex, budget) {
842
916
  const results = [];
843
917
  const { deltaAt, order } = bodyPlan(r, deltaIndex);
844
918
  const step = (orderIndex, bindings) => {
@@ -856,6 +930,10 @@ function evaluateRule(db, deltas, r, deltaIndex) {
856
930
  }
857
931
  const source = literalIndex === deltaAt ? deltas.get(literal.relation) ?? [] : boundSource(db, literal, bindings);
858
932
  for (const tuple of source) {
933
+ budget.examined++;
934
+ if (budget.examined > budget.limit) {
935
+ throw OUT_OF_BUDGET;
936
+ }
859
937
  const next = unify(literal, tuple, bindings);
860
938
  if (next !== NO_MATCH) {
861
939
  step(orderIndex + 1, next);
@@ -884,7 +962,7 @@ function boundSource(db, literal, bindings) {
884
962
  return narrowest ?? db.facts(literal.relation);
885
963
  }
886
964
  var EMPTY_TAGS = [];
887
- function evaluateRuleTagged(db, deltas, r, deltaIndex, algebra) {
965
+ function evaluateRuleTagged(db, deltas, r, deltaIndex, algebra, budget) {
888
966
  const results = [];
889
967
  const bodyTags = [];
890
968
  const bodyMatches = [];
@@ -922,6 +1000,7 @@ function evaluateRuleTagged(db, deltas, r, deltaIndex, algebra) {
922
1000
  }
923
1001
  const source = positiveIndex === deltaIndex ? deltas.get(literal.relation) ?? [] : boundSource(db, literal, bindings);
924
1002
  for (const tuple of source) {
1003
+ budget.examined++;
925
1004
  const next = unify(literal, tuple, bindings);
926
1005
  if (next !== NO_MATCH) {
927
1006
  if (readsTags) {
@@ -1005,15 +1084,18 @@ function currentMarks(db) {
1005
1084
  }
1006
1085
  return marks;
1007
1086
  }
1008
- function evaluate(db, rules, algebra) {
1087
+ function evaluate(db, rules, algebra, budget) {
1009
1088
  if (algebra !== void 0 && isDemandRewritten(rules)) {
1010
1089
  throw new Error(
1011
1090
  "cannot evaluate demand-rewritten rules with a tag algebra: tags would follow the demand-transformed rules rather than the ones the caller wrote"
1012
1091
  );
1013
1092
  }
1093
+ if (algebra !== void 0 && budget !== void 0) {
1094
+ throw new Error("cannot evaluate with both a tag algebra and a row budget");
1095
+ }
1014
1096
  deriving.set(db, (deriving.get(db) ?? 0) + 1);
1015
1097
  try {
1016
- return runRules(db, rules, algebra);
1098
+ return runRules(db, rules, algebra, budget);
1017
1099
  } finally {
1018
1100
  const depth = (deriving.get(db) ?? 1) - 1;
1019
1101
  if (depth === 0) {
@@ -1063,13 +1145,19 @@ function storedMergeFor(algebra) {
1063
1145
  return stored === void 0 && merged === algebra.asserted ? void 0 : merged;
1064
1146
  };
1065
1147
  }
1066
- function runRules(db, rules, algebra) {
1148
+ function runRules(db, rules, algebra, given) {
1067
1149
  const {
1068
1150
  signature,
1069
1151
  name: ruleSetName,
1070
1152
  derivedRelations,
1071
1153
  strata
1072
1154
  } = shapeOf(rules);
1155
+ const budget = given ?? {
1156
+ examined: 0,
1157
+ limit: Number.POSITIVE_INFINITY
1158
+ };
1159
+ const startedAt = budget.examined;
1160
+ const addedHere = given === void 0 ? null : [];
1073
1161
  chargeEvaluation(ruleSetName);
1074
1162
  const states = statesFor(db);
1075
1163
  const state = states.get(signature) ?? {
@@ -1091,10 +1179,11 @@ function runRules(db, rules, algebra) {
1091
1179
  derived.set(relation, ledger);
1092
1180
  }
1093
1181
  ledger.set(keyOf2(tuple), tuple);
1182
+ addedHere?.push([relation, tuple]);
1094
1183
  };
1095
1184
  const mergeStored = algebra === void 0 ? void 0 : storedMergeFor(algebra);
1096
1185
  const marks = canResume(rules, state) ? state.marks : void 0;
1097
- for (const stratum of strata) {
1186
+ const runStratum = (stratum) => {
1098
1187
  let delta = /* @__PURE__ */ new Map();
1099
1188
  const derivedHere = new Set(stratum.map((r) => r.head.relation));
1100
1189
  const record = (relation, tuple) => {
@@ -1120,13 +1209,14 @@ function runRules(db, rules, algebra) {
1120
1209
  seed,
1121
1210
  r,
1122
1211
  deltaIndex,
1123
- algebra
1212
+ algebra,
1213
+ budget
1124
1214
  )) {
1125
1215
  recordTagged(r.head.relation, found.tuple, found.tag);
1126
1216
  }
1127
1217
  return;
1128
1218
  }
1129
- for (const tuple of evaluateRule(db, seed, r, deltaIndex)) {
1219
+ for (const tuple of evaluateRule(db, seed, r, deltaIndex, budget)) {
1130
1220
  record(r.head.relation, tuple);
1131
1221
  }
1132
1222
  };
@@ -1135,15 +1225,17 @@ function runRules(db, rules, algebra) {
1135
1225
  deriveInto(r, seed, deltaIndex);
1136
1226
  return;
1137
1227
  }
1138
- const startedAt = performance.now();
1228
+ const startedAt2 = performance.now();
1139
1229
  const before = db.size(r.head.relation);
1230
+ const readBefore = budget.examined;
1140
1231
  deriveInto(r, seed, deltaIndex);
1141
1232
  chargeRule(
1142
1233
  ruleSetName,
1143
1234
  r.head.relation,
1144
1235
  r.body.map(literalName),
1145
- performance.now() - startedAt,
1146
- db.size(r.head.relation) - before
1236
+ performance.now() - startedAt2,
1237
+ db.size(r.head.relation) - before,
1238
+ budget.examined - readBefore
1147
1239
  );
1148
1240
  };
1149
1241
  const applyDelta = (seed, derivedOnly) => {
@@ -1188,7 +1280,24 @@ function runRules(db, rules, algebra) {
1188
1280
  applyDelta(lastDelta, true);
1189
1281
  chargeRound(ruleSetName);
1190
1282
  }
1283
+ };
1284
+ const readHere = () => budget.examined - startedAt;
1285
+ try {
1286
+ for (const stratum of strata) {
1287
+ runStratum(stratum);
1288
+ }
1289
+ } catch (error) {
1290
+ if (error !== OUT_OF_BUDGET) {
1291
+ throw error;
1292
+ }
1293
+ for (const [relation, tuples] of byRelation(addedHere ?? [])) {
1294
+ db.retract(relation, tuples);
1295
+ }
1296
+ state.marks = null;
1297
+ chargeEvaluationRows(readHere());
1298
+ throw new BudgetExhausted(ruleSetName, readHere());
1191
1299
  }
1300
+ chargeEvaluationRows(readHere());
1192
1301
  state.marks = currentMarks(db);
1193
1302
  if (isProfiling()) {
1194
1303
  chargeRelationSizes(
@@ -1198,6 +1307,13 @@ function runRules(db, rules, algebra) {
1198
1307
  }
1199
1308
  return db;
1200
1309
  }
1310
+ function byRelation(added) {
1311
+ const buckets = /* @__PURE__ */ new Map();
1312
+ for (const [relation, tuple] of added) {
1313
+ addTo(buckets, relation, tuple);
1314
+ }
1315
+ return buckets;
1316
+ }
1201
1317
  function addTo(buckets, relation, tuple) {
1202
1318
  const bucket = buckets.get(relation);
1203
1319
  if (bucket === void 0) {
@@ -1208,8 +1324,11 @@ function addTo(buckets, relation, tuple) {
1208
1324
  }
1209
1325
  // Annotate the CommonJS export names for ESM import in node:
1210
1326
  0 && (module.exports = {
1327
+ BudgetExhausted,
1211
1328
  Database,
1212
1329
  Witness,
1330
+ chargeAbandoned,
1331
+ chargeQuestion,
1213
1332
  clearRelations,
1214
1333
  confidence,
1215
1334
  confidenceWith,
@@ -1222,6 +1341,7 @@ function addTo(buckets, relation, tuple) {
1222
1341
  profileEvaluation,
1223
1342
  profileEvaluationAsync,
1224
1343
  proofOf,
1344
+ rowBudget,
1225
1345
  rule,
1226
1346
  ruleLabel,
1227
1347
  stratify,
package/dist/index.d.cts CHANGED
@@ -90,9 +90,32 @@ interface RuleCost {
90
90
  ms: number;
91
91
  /** Tuples this rule added that no earlier derivation had reached. */
92
92
  derived: number;
93
+ /**
94
+ * Rows the join read for this rule. A rule that examines a hundred
95
+ * thousand rows to derive a dozen tuples is doing a walk somebody
96
+ * meant to be a lookup, and `derived` alone never shows that.
97
+ */
98
+ examined: number;
93
99
  /** How many times the rule was evaluated, over all rounds and deltas. */
94
100
  attempts: number;
95
101
  }
102
+ /** A question the engine gave up on, and how much it had read by then. */
103
+ interface AbandonedQuestion {
104
+ /** What the caller was asking, in whatever words the caller uses. */
105
+ question: string;
106
+ examined: number;
107
+ }
108
+ /**
109
+ * How a caller that asks one question at a time got on. `skipped` are
110
+ * the ones it never put, because what it had already spent was gone.
111
+ */
112
+ interface QuestionTally {
113
+ asked: number;
114
+ abandoned: number;
115
+ skipped: number;
116
+ }
117
+ /** How one question ended, for the tally. */
118
+ type QuestionOutcome = keyof Omit<QuestionTally, "asked">;
96
119
  /**
97
120
  * How big a relation grew. `derived` separates the relations rules
98
121
  * concluded from the base facts an adapter emitted, because "derive fewer
@@ -130,6 +153,18 @@ interface EvaluationProfile {
130
153
  * corpus size is worth more attention than the rule table.
131
154
  */
132
155
  evaluations: number;
156
+ /** Rows every join read, summed over every rule set. */
157
+ examined: number;
158
+ /**
159
+ * The most rows any one `evaluate` call read. A caller that asks a
160
+ * question at a time sizes a per-question budget off this, where the
161
+ * total only says how many questions it asked.
162
+ */
163
+ largestEvaluation: number;
164
+ /** Every question abandoned on its budget, in the order they were given up. */
165
+ abandoned: AbandonedQuestion[];
166
+ /** How many questions a one-at-a-time caller asked, and how they went. */
167
+ questions: QuestionTally;
133
168
  /** Final tuple count per relation, largest first. */
134
169
  relations: RelationSize[];
135
170
  /** Per-rule cost across every rule set, most expensive first. */
@@ -137,6 +172,17 @@ interface EvaluationProfile {
137
172
  /** The same time, split by the rule set that spent it. */
138
173
  ruleSets: RuleSetCost[];
139
174
  }
175
+ /**
176
+ * Note that a question was given up on. A caller reading this in a
177
+ * profile is looking at the reason an answer came back empty, so the
178
+ * question is recorded in the caller's own words.
179
+ */
180
+ declare function chargeAbandoned(question: string, examined: number): void;
181
+ /**
182
+ * Note that a caller put one question. `outcome` says whether it was
183
+ * given up part way or never put at all; leave it out when it answered.
184
+ */
185
+ declare function chargeQuestion(outcome?: QuestionOutcome): void;
140
186
  /**
141
187
  * Run `fn` with evaluation profiling on, and hand back what it returned
142
188
  * alongside the numbers. Nested calls share the outer collector, so a
@@ -403,18 +449,54 @@ interface Derivation {
403
449
  * stratified, so this throws.
404
450
  */
405
451
  declare function stratify(rules: Rule[]): Rule[][];
452
+ /**
453
+ * How many rows the joins of one evaluation may read, and how many they
454
+ * have read so far. Counting rows rather than new tuples is what catches
455
+ * a question that walks: the walk finds the same conclusions over and
456
+ * over, so nothing new arrives while the reading runs away.
457
+ *
458
+ * The caller builds one with `rowBudget` and reads `examined` back after
459
+ * the call, which is how it learns what a question cost whether or not
460
+ * the question settled. An evaluation with no budget gets a limit of
461
+ * Infinity, so the hot loop does the same increment and the same
462
+ * compare either way.
463
+ */
464
+ interface RowBudget {
465
+ examined: number;
466
+ limit: number;
467
+ }
468
+ /** A budget for one evaluation, ready to hand to `evaluate`. */
469
+ declare const rowBudget: (limit: number) => RowBudget;
470
+ /**
471
+ * What `evaluate` throws when a row budget runs out.
472
+ *
473
+ * By the time this reaches a caller the database is back where it
474
+ * started: every fact that evaluation derived has been retracted, and
475
+ * the rule set will start over from the base facts next time rather
476
+ * than from a delta that was never worked through. The caller's own
477
+ * facts, the question it asked among them, are still there for it to
478
+ * take back.
479
+ */
480
+ declare class BudgetExhausted extends Error {
481
+ readonly ruleSet: string;
482
+ readonly examined: number;
483
+ constructor(ruleSet: string, examined: number);
484
+ }
406
485
  /**
407
486
  * Evaluate `rules` over `db` to fixpoint, adding derived facts in
408
487
  * place. Rules are stratified first; within a stratum, semi-naïve
409
- * iteration joins each rule against the last round's delta so work is
410
- * proportional to new facts, not all facts.
488
+ * iteration joins each rule against the last round's delta, so work
489
+ * is proportional to new facts rather than to all facts.
411
490
  *
412
491
  * With an `algebra`, every derivation also tags its conclusion (see
413
- * `TagAlgebra`). Supply the same algebra on every evaluation of a rule
414
- * set over a database: a resumed run derives only from the new facts,
415
- * so it tags only what those reach.
492
+ * `TagAlgebra`). Supply the same algebra every time over one
493
+ * database: a resumed run tags only what the new facts reach.
494
+ *
495
+ * With a `budget`, evaluation gives up once its joins have read that
496
+ * many rows and throws `BudgetExhausted`. Either way the budget says
497
+ * afterwards what the evaluation cost.
416
498
  */
417
- declare function evaluate<Tag = never>(db: Database, rules: Rule[], algebra?: TagAlgebra<Tag>): Database;
499
+ declare function evaluate<Tag = never>(db: Database, rules: Rule[], algebra?: TagAlgebra<Tag>, budget?: RowBudget): Database;
418
500
  /**
419
501
  * Empty these relations, and leave `rules` able to carry on from where
420
502
  * it got to.
@@ -438,4 +520,4 @@ declare function evaluate<Tag = never>(db: Database, rules: Rule[], algebra?: Ta
438
520
  */
439
521
  declare function clearRelations(db: Database, rules: Rule[], relations: readonly string[]): void;
440
522
 
441
- export { type AddOutcome, type Atom, type BodyMatch, type ConfidenceLevel, Database, type Demand, type Derivation, type EvaluationProfile, type Literal, type OnDemandRules, type Proof, type ProofDerived, type ProofOptions, type Rule, type RuleCost, type TagAlgebra, type Term, type Tuple, Witness, type WitnessTag, clearRelations, confidence, confidenceWith, constant, deriveOnDemand, evaluate, formatProfile, lit, notLit, profileEvaluation, profileEvaluationAsync, proofOf, rule, ruleLabel, stratify, tupleKey, tupleKeyParts, variable, witnesses };
523
+ export { type AbandonedQuestion, type AddOutcome, type Atom, type BodyMatch, BudgetExhausted, type ConfidenceLevel, Database, type Demand, type Derivation, type EvaluationProfile, type Literal, type OnDemandRules, type Proof, type ProofDerived, type ProofOptions, type QuestionOutcome, type QuestionTally, type RowBudget, type Rule, type RuleCost, type TagAlgebra, type Term, type Tuple, Witness, type WitnessTag, chargeAbandoned, chargeQuestion, clearRelations, confidence, confidenceWith, constant, deriveOnDemand, evaluate, formatProfile, lit, notLit, profileEvaluation, profileEvaluationAsync, proofOf, rowBudget, rule, ruleLabel, stratify, tupleKey, tupleKeyParts, variable, witnesses };
package/dist/index.d.ts CHANGED
@@ -90,9 +90,32 @@ interface RuleCost {
90
90
  ms: number;
91
91
  /** Tuples this rule added that no earlier derivation had reached. */
92
92
  derived: number;
93
+ /**
94
+ * Rows the join read for this rule. A rule that examines a hundred
95
+ * thousand rows to derive a dozen tuples is doing a walk somebody
96
+ * meant to be a lookup, and `derived` alone never shows that.
97
+ */
98
+ examined: number;
93
99
  /** How many times the rule was evaluated, over all rounds and deltas. */
94
100
  attempts: number;
95
101
  }
102
+ /** A question the engine gave up on, and how much it had read by then. */
103
+ interface AbandonedQuestion {
104
+ /** What the caller was asking, in whatever words the caller uses. */
105
+ question: string;
106
+ examined: number;
107
+ }
108
+ /**
109
+ * How a caller that asks one question at a time got on. `skipped` are
110
+ * the ones it never put, because what it had already spent was gone.
111
+ */
112
+ interface QuestionTally {
113
+ asked: number;
114
+ abandoned: number;
115
+ skipped: number;
116
+ }
117
+ /** How one question ended, for the tally. */
118
+ type QuestionOutcome = keyof Omit<QuestionTally, "asked">;
96
119
  /**
97
120
  * How big a relation grew. `derived` separates the relations rules
98
121
  * concluded from the base facts an adapter emitted, because "derive fewer
@@ -130,6 +153,18 @@ interface EvaluationProfile {
130
153
  * corpus size is worth more attention than the rule table.
131
154
  */
132
155
  evaluations: number;
156
+ /** Rows every join read, summed over every rule set. */
157
+ examined: number;
158
+ /**
159
+ * The most rows any one `evaluate` call read. A caller that asks a
160
+ * question at a time sizes a per-question budget off this, where the
161
+ * total only says how many questions it asked.
162
+ */
163
+ largestEvaluation: number;
164
+ /** Every question abandoned on its budget, in the order they were given up. */
165
+ abandoned: AbandonedQuestion[];
166
+ /** How many questions a one-at-a-time caller asked, and how they went. */
167
+ questions: QuestionTally;
133
168
  /** Final tuple count per relation, largest first. */
134
169
  relations: RelationSize[];
135
170
  /** Per-rule cost across every rule set, most expensive first. */
@@ -137,6 +172,17 @@ interface EvaluationProfile {
137
172
  /** The same time, split by the rule set that spent it. */
138
173
  ruleSets: RuleSetCost[];
139
174
  }
175
+ /**
176
+ * Note that a question was given up on. A caller reading this in a
177
+ * profile is looking at the reason an answer came back empty, so the
178
+ * question is recorded in the caller's own words.
179
+ */
180
+ declare function chargeAbandoned(question: string, examined: number): void;
181
+ /**
182
+ * Note that a caller put one question. `outcome` says whether it was
183
+ * given up part way or never put at all; leave it out when it answered.
184
+ */
185
+ declare function chargeQuestion(outcome?: QuestionOutcome): void;
140
186
  /**
141
187
  * Run `fn` with evaluation profiling on, and hand back what it returned
142
188
  * alongside the numbers. Nested calls share the outer collector, so a
@@ -403,18 +449,54 @@ interface Derivation {
403
449
  * stratified, so this throws.
404
450
  */
405
451
  declare function stratify(rules: Rule[]): Rule[][];
452
+ /**
453
+ * How many rows the joins of one evaluation may read, and how many they
454
+ * have read so far. Counting rows rather than new tuples is what catches
455
+ * a question that walks: the walk finds the same conclusions over and
456
+ * over, so nothing new arrives while the reading runs away.
457
+ *
458
+ * The caller builds one with `rowBudget` and reads `examined` back after
459
+ * the call, which is how it learns what a question cost whether or not
460
+ * the question settled. An evaluation with no budget gets a limit of
461
+ * Infinity, so the hot loop does the same increment and the same
462
+ * compare either way.
463
+ */
464
+ interface RowBudget {
465
+ examined: number;
466
+ limit: number;
467
+ }
468
+ /** A budget for one evaluation, ready to hand to `evaluate`. */
469
+ declare const rowBudget: (limit: number) => RowBudget;
470
+ /**
471
+ * What `evaluate` throws when a row budget runs out.
472
+ *
473
+ * By the time this reaches a caller the database is back where it
474
+ * started: every fact that evaluation derived has been retracted, and
475
+ * the rule set will start over from the base facts next time rather
476
+ * than from a delta that was never worked through. The caller's own
477
+ * facts, the question it asked among them, are still there for it to
478
+ * take back.
479
+ */
480
+ declare class BudgetExhausted extends Error {
481
+ readonly ruleSet: string;
482
+ readonly examined: number;
483
+ constructor(ruleSet: string, examined: number);
484
+ }
406
485
  /**
407
486
  * Evaluate `rules` over `db` to fixpoint, adding derived facts in
408
487
  * place. Rules are stratified first; within a stratum, semi-naïve
409
- * iteration joins each rule against the last round's delta so work is
410
- * proportional to new facts, not all facts.
488
+ * iteration joins each rule against the last round's delta, so work
489
+ * is proportional to new facts rather than to all facts.
411
490
  *
412
491
  * With an `algebra`, every derivation also tags its conclusion (see
413
- * `TagAlgebra`). Supply the same algebra on every evaluation of a rule
414
- * set over a database: a resumed run derives only from the new facts,
415
- * so it tags only what those reach.
492
+ * `TagAlgebra`). Supply the same algebra every time over one
493
+ * database: a resumed run tags only what the new facts reach.
494
+ *
495
+ * With a `budget`, evaluation gives up once its joins have read that
496
+ * many rows and throws `BudgetExhausted`. Either way the budget says
497
+ * afterwards what the evaluation cost.
416
498
  */
417
- declare function evaluate<Tag = never>(db: Database, rules: Rule[], algebra?: TagAlgebra<Tag>): Database;
499
+ declare function evaluate<Tag = never>(db: Database, rules: Rule[], algebra?: TagAlgebra<Tag>, budget?: RowBudget): Database;
418
500
  /**
419
501
  * Empty these relations, and leave `rules` able to carry on from where
420
502
  * it got to.
@@ -438,4 +520,4 @@ declare function evaluate<Tag = never>(db: Database, rules: Rule[], algebra?: Ta
438
520
  */
439
521
  declare function clearRelations(db: Database, rules: Rule[], relations: readonly string[]): void;
440
522
 
441
- export { type AddOutcome, type Atom, type BodyMatch, type ConfidenceLevel, Database, type Demand, type Derivation, type EvaluationProfile, type Literal, type OnDemandRules, type Proof, type ProofDerived, type ProofOptions, type Rule, type RuleCost, type TagAlgebra, type Term, type Tuple, Witness, type WitnessTag, clearRelations, confidence, confidenceWith, constant, deriveOnDemand, evaluate, formatProfile, lit, notLit, profileEvaluation, profileEvaluationAsync, proofOf, rule, ruleLabel, stratify, tupleKey, tupleKeyParts, variable, witnesses };
523
+ export { type AbandonedQuestion, type AddOutcome, type Atom, type BodyMatch, BudgetExhausted, type ConfidenceLevel, Database, type Demand, type Derivation, type EvaluationProfile, type Literal, type OnDemandRules, type Proof, type ProofDerived, type ProofOptions, type QuestionOutcome, type QuestionTally, type RowBudget, type Rule, type RuleCost, type TagAlgebra, type Term, type Tuple, Witness, type WitnessTag, chargeAbandoned, chargeQuestion, clearRelations, confidence, confidenceWith, constant, deriveOnDemand, evaluate, formatProfile, lit, notLit, profileEvaluation, profileEvaluationAsync, proofOf, rowBudget, rule, ruleLabel, stratify, tupleKey, tupleKeyParts, variable, witnesses };
package/dist/index.js CHANGED
@@ -185,21 +185,43 @@ function bodyAdornments(r, headAdornment, derives) {
185
185
  var open = [];
186
186
  var isProfiling = () => open.length > 0;
187
187
  var ruleKey = (head, body) => `${head} <- ${body.join(", ")}`;
188
- function addCost(into, key, head, body, ms, derived) {
188
+ function addCost(into, key, head, body, ms, derived, examined) {
189
189
  const existing = into.get(key);
190
190
  if (existing === void 0) {
191
- into.set(key, { head, body, ms, derived, attempts: 1 });
191
+ into.set(key, { head, body, ms, derived, examined, attempts: 1 });
192
192
  return;
193
193
  }
194
194
  existing.ms += ms;
195
195
  existing.derived += derived;
196
+ existing.examined += examined;
196
197
  existing.attempts += 1;
197
198
  }
198
- function chargeRule(ruleSet, head, body, ms, derived) {
199
+ function chargeRule(ruleSet, head, body, ms, derived, examined) {
199
200
  const key = ruleKey(head, body);
200
201
  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);
202
+ addCost(collector.rules, key, head, body, ms, derived, examined);
203
+ addCost(
204
+ setIn(collector, ruleSet).rules,
205
+ key,
206
+ head,
207
+ body,
208
+ ms,
209
+ derived,
210
+ examined
211
+ );
212
+ }
213
+ }
214
+ function chargeAbandoned(question, examined) {
215
+ for (const collector of open) {
216
+ collector.abandoned.push({ question, examined });
217
+ }
218
+ }
219
+ function chargeQuestion(outcome) {
220
+ for (const collector of open) {
221
+ collector.questions.asked += 1;
222
+ if (outcome !== void 0) {
223
+ collector.questions[outcome] += 1;
224
+ }
203
225
  }
204
226
  }
205
227
  function chargeEvaluation(ruleSet) {
@@ -208,6 +230,13 @@ function chargeEvaluation(ruleSet) {
208
230
  setIn(collector, ruleSet).evaluations += 1;
209
231
  }
210
232
  }
233
+ function chargeEvaluationRows(examined) {
234
+ for (const collector of open) {
235
+ if (examined > collector.largestEvaluation) {
236
+ collector.largestEvaluation = examined;
237
+ }
238
+ }
239
+ }
211
240
  function chargeRound(ruleSet) {
212
241
  for (const collector of open) {
213
242
  collector.rounds += 1;
@@ -267,7 +296,10 @@ function openScope() {
267
296
  rules: /* @__PURE__ */ new Map(),
268
297
  relations: /* @__PURE__ */ new Map(),
269
298
  derivedRelations: /* @__PURE__ */ new Set(),
270
- ruleSets: /* @__PURE__ */ new Map()
299
+ ruleSets: /* @__PURE__ */ new Map(),
300
+ abandoned: [],
301
+ largestEvaluation: 0,
302
+ questions: { asked: 0, abandoned: 0, skipped: 0 }
271
303
  };
272
304
  open.push(mine);
273
305
  return mine;
@@ -285,6 +317,13 @@ var totalMs = (rules) => {
285
317
  }
286
318
  return ms;
287
319
  };
320
+ var totalExamined = (rules) => {
321
+ let rows = 0;
322
+ for (const cost of rules) {
323
+ rows += cost.examined;
324
+ }
325
+ return rows;
326
+ };
288
327
  var byCost = (a, b) => b.ms - a.ms;
289
328
  function summarise(mine) {
290
329
  return {
@@ -292,6 +331,10 @@ function summarise(mine) {
292
331
  datalogMs: totalMs(mine.rules.values()),
293
332
  rounds: mine.rounds,
294
333
  evaluations: mine.evaluations,
334
+ examined: totalExamined(mine.rules.values()),
335
+ largestEvaluation: mine.largestEvaluation,
336
+ abandoned: [...mine.abandoned],
337
+ questions: { ...mine.questions },
295
338
  relations: [...mine.relations].map(([relation, tuples]) => ({
296
339
  relation,
297
340
  tuples,
@@ -312,14 +355,26 @@ function ruleLines(rules, datalogMs) {
312
355
  return rules.slice(0, 20).map((r) => {
313
356
  const ms = `${r.ms.toFixed(0)}ms`.padStart(8);
314
357
  const derived = String(r.derived).padStart(7);
315
- return ` ${ms} ${share(r.ms, datalogMs)} ${derived} tuples ${r.attempts} attempts ${ruleKey(r.head, r.body)}`;
358
+ const examined = String(r.examined).padStart(9);
359
+ return ` ${ms} ${share(r.ms, datalogMs)} ${derived} tuples ${examined} rows read ${r.attempts} attempts ${ruleKey(r.head, r.body)}`;
316
360
  });
317
361
  }
318
362
  function formatProfile(profile) {
319
363
  const lines = [];
320
364
  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`
365
+ `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
366
  );
367
+ const { asked, abandoned, skipped } = profile.questions;
368
+ if (asked > 0) {
369
+ lines.push(
370
+ ` ${asked} questions under a site: ${abandoned} given up part way, ${skipped} never put once the run's rows were spent`
371
+ );
372
+ }
373
+ for (const given of profile.abandoned) {
374
+ lines.push(
375
+ ` gave up on ${given.question} after reading ${given.examined} rows`
376
+ );
377
+ }
323
378
  const derivedTuples = profile.relations.filter((r) => r.derived).reduce((sum, r) => sum + r.tuples, 0);
324
379
  lines.push(" tuples by relation (d = derived by a rule):");
325
380
  for (const { relation, tuples, derived } of profile.relations.slice(0, 20)) {
@@ -792,7 +847,22 @@ function planBodyOrder(r, deltaAt) {
792
847
  }
793
848
  return order;
794
849
  }
795
- function evaluateRule(db, deltas, r, deltaIndex) {
850
+ var rowBudget = (limit) => ({
851
+ examined: 0,
852
+ limit
853
+ });
854
+ var OUT_OF_BUDGET = /* @__PURE__ */ Symbol("datalog row budget exhausted");
855
+ var BudgetExhausted = class extends Error {
856
+ constructor(ruleSet, examined) {
857
+ super(`gave up after reading ${examined} rows without settling`);
858
+ this.ruleSet = ruleSet;
859
+ this.examined = examined;
860
+ this.name = "BudgetExhausted";
861
+ }
862
+ ruleSet;
863
+ examined;
864
+ };
865
+ function evaluateRule(db, deltas, r, deltaIndex, budget) {
796
866
  const results = [];
797
867
  const { deltaAt, order } = bodyPlan(r, deltaIndex);
798
868
  const step = (orderIndex, bindings) => {
@@ -810,6 +880,10 @@ function evaluateRule(db, deltas, r, deltaIndex) {
810
880
  }
811
881
  const source = literalIndex === deltaAt ? deltas.get(literal.relation) ?? [] : boundSource(db, literal, bindings);
812
882
  for (const tuple of source) {
883
+ budget.examined++;
884
+ if (budget.examined > budget.limit) {
885
+ throw OUT_OF_BUDGET;
886
+ }
813
887
  const next = unify(literal, tuple, bindings);
814
888
  if (next !== NO_MATCH) {
815
889
  step(orderIndex + 1, next);
@@ -838,7 +912,7 @@ function boundSource(db, literal, bindings) {
838
912
  return narrowest ?? db.facts(literal.relation);
839
913
  }
840
914
  var EMPTY_TAGS = [];
841
- function evaluateRuleTagged(db, deltas, r, deltaIndex, algebra) {
915
+ function evaluateRuleTagged(db, deltas, r, deltaIndex, algebra, budget) {
842
916
  const results = [];
843
917
  const bodyTags = [];
844
918
  const bodyMatches = [];
@@ -876,6 +950,7 @@ function evaluateRuleTagged(db, deltas, r, deltaIndex, algebra) {
876
950
  }
877
951
  const source = positiveIndex === deltaIndex ? deltas.get(literal.relation) ?? [] : boundSource(db, literal, bindings);
878
952
  for (const tuple of source) {
953
+ budget.examined++;
879
954
  const next = unify(literal, tuple, bindings);
880
955
  if (next !== NO_MATCH) {
881
956
  if (readsTags) {
@@ -959,15 +1034,18 @@ function currentMarks(db) {
959
1034
  }
960
1035
  return marks;
961
1036
  }
962
- function evaluate(db, rules, algebra) {
1037
+ function evaluate(db, rules, algebra, budget) {
963
1038
  if (algebra !== void 0 && isDemandRewritten(rules)) {
964
1039
  throw new Error(
965
1040
  "cannot evaluate demand-rewritten rules with a tag algebra: tags would follow the demand-transformed rules rather than the ones the caller wrote"
966
1041
  );
967
1042
  }
1043
+ if (algebra !== void 0 && budget !== void 0) {
1044
+ throw new Error("cannot evaluate with both a tag algebra and a row budget");
1045
+ }
968
1046
  deriving.set(db, (deriving.get(db) ?? 0) + 1);
969
1047
  try {
970
- return runRules(db, rules, algebra);
1048
+ return runRules(db, rules, algebra, budget);
971
1049
  } finally {
972
1050
  const depth = (deriving.get(db) ?? 1) - 1;
973
1051
  if (depth === 0) {
@@ -1017,13 +1095,19 @@ function storedMergeFor(algebra) {
1017
1095
  return stored === void 0 && merged === algebra.asserted ? void 0 : merged;
1018
1096
  };
1019
1097
  }
1020
- function runRules(db, rules, algebra) {
1098
+ function runRules(db, rules, algebra, given) {
1021
1099
  const {
1022
1100
  signature,
1023
1101
  name: ruleSetName,
1024
1102
  derivedRelations,
1025
1103
  strata
1026
1104
  } = shapeOf(rules);
1105
+ const budget = given ?? {
1106
+ examined: 0,
1107
+ limit: Number.POSITIVE_INFINITY
1108
+ };
1109
+ const startedAt = budget.examined;
1110
+ const addedHere = given === void 0 ? null : [];
1027
1111
  chargeEvaluation(ruleSetName);
1028
1112
  const states = statesFor(db);
1029
1113
  const state = states.get(signature) ?? {
@@ -1045,10 +1129,11 @@ function runRules(db, rules, algebra) {
1045
1129
  derived.set(relation, ledger);
1046
1130
  }
1047
1131
  ledger.set(keyOf2(tuple), tuple);
1132
+ addedHere?.push([relation, tuple]);
1048
1133
  };
1049
1134
  const mergeStored = algebra === void 0 ? void 0 : storedMergeFor(algebra);
1050
1135
  const marks = canResume(rules, state) ? state.marks : void 0;
1051
- for (const stratum of strata) {
1136
+ const runStratum = (stratum) => {
1052
1137
  let delta = /* @__PURE__ */ new Map();
1053
1138
  const derivedHere = new Set(stratum.map((r) => r.head.relation));
1054
1139
  const record = (relation, tuple) => {
@@ -1074,13 +1159,14 @@ function runRules(db, rules, algebra) {
1074
1159
  seed,
1075
1160
  r,
1076
1161
  deltaIndex,
1077
- algebra
1162
+ algebra,
1163
+ budget
1078
1164
  )) {
1079
1165
  recordTagged(r.head.relation, found.tuple, found.tag);
1080
1166
  }
1081
1167
  return;
1082
1168
  }
1083
- for (const tuple of evaluateRule(db, seed, r, deltaIndex)) {
1169
+ for (const tuple of evaluateRule(db, seed, r, deltaIndex, budget)) {
1084
1170
  record(r.head.relation, tuple);
1085
1171
  }
1086
1172
  };
@@ -1089,15 +1175,17 @@ function runRules(db, rules, algebra) {
1089
1175
  deriveInto(r, seed, deltaIndex);
1090
1176
  return;
1091
1177
  }
1092
- const startedAt = performance.now();
1178
+ const startedAt2 = performance.now();
1093
1179
  const before = db.size(r.head.relation);
1180
+ const readBefore = budget.examined;
1094
1181
  deriveInto(r, seed, deltaIndex);
1095
1182
  chargeRule(
1096
1183
  ruleSetName,
1097
1184
  r.head.relation,
1098
1185
  r.body.map(literalName),
1099
- performance.now() - startedAt,
1100
- db.size(r.head.relation) - before
1186
+ performance.now() - startedAt2,
1187
+ db.size(r.head.relation) - before,
1188
+ budget.examined - readBefore
1101
1189
  );
1102
1190
  };
1103
1191
  const applyDelta = (seed, derivedOnly) => {
@@ -1142,7 +1230,24 @@ function runRules(db, rules, algebra) {
1142
1230
  applyDelta(lastDelta, true);
1143
1231
  chargeRound(ruleSetName);
1144
1232
  }
1233
+ };
1234
+ const readHere = () => budget.examined - startedAt;
1235
+ try {
1236
+ for (const stratum of strata) {
1237
+ runStratum(stratum);
1238
+ }
1239
+ } catch (error) {
1240
+ if (error !== OUT_OF_BUDGET) {
1241
+ throw error;
1242
+ }
1243
+ for (const [relation, tuples] of byRelation(addedHere ?? [])) {
1244
+ db.retract(relation, tuples);
1245
+ }
1246
+ state.marks = null;
1247
+ chargeEvaluationRows(readHere());
1248
+ throw new BudgetExhausted(ruleSetName, readHere());
1145
1249
  }
1250
+ chargeEvaluationRows(readHere());
1146
1251
  state.marks = currentMarks(db);
1147
1252
  if (isProfiling()) {
1148
1253
  chargeRelationSizes(
@@ -1152,6 +1257,13 @@ function runRules(db, rules, algebra) {
1152
1257
  }
1153
1258
  return db;
1154
1259
  }
1260
+ function byRelation(added) {
1261
+ const buckets = /* @__PURE__ */ new Map();
1262
+ for (const [relation, tuple] of added) {
1263
+ addTo(buckets, relation, tuple);
1264
+ }
1265
+ return buckets;
1266
+ }
1155
1267
  function addTo(buckets, relation, tuple) {
1156
1268
  const bucket = buckets.get(relation);
1157
1269
  if (bucket === void 0) {
@@ -1161,8 +1273,11 @@ function addTo(buckets, relation, tuple) {
1161
1273
  }
1162
1274
  }
1163
1275
  export {
1276
+ BudgetExhausted,
1164
1277
  Database,
1165
1278
  Witness,
1279
+ chargeAbandoned,
1280
+ chargeQuestion,
1166
1281
  clearRelations,
1167
1282
  confidence,
1168
1283
  confidenceWith,
@@ -1175,6 +1290,7 @@ export {
1175
1290
  profileEvaluation,
1176
1291
  profileEvaluationAsync,
1177
1292
  proofOf,
1293
+ rowBudget,
1178
1294
  rule,
1179
1295
  ruleLabel,
1180
1296
  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.1",
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": [