@suss/datalog 0.3.1 → 0.4.0

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