@josd/eyecode 0.0.0-stage → 0.1.4

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/src/program.js ADDED
@@ -0,0 +1,278 @@
1
+ // A program: its clauses, validated, indexed and stratified.
2
+ import { PRIMITIVE_KEYS } from './builtins.js';
3
+ import {
4
+ Env, EyecodeError, Struct, Var, deref, flattenConjunction, freshTerm, identity, isCallable,
5
+ isGround, isTerm, key, keyArity, unify,
6
+ } from './terms.js';
7
+
8
+ export const CONTROL_KEYS = new Set(['and/2', 'or/2', 'not/1', 'call/1', 'once/1', 'findall/3']);
9
+ export const RESERVED_KEYS = new Set(['step/4', 'clause/2']);
10
+
11
+ // The statements that state a forward rule, and how an error names them.
12
+ const FORWARD_KINDS = { forward: 'a forward rule', query: 'query()', contradiction: 'contradiction()' };
13
+
14
+ export class Clause {
15
+ constructor(id, head, heads, body, forward, line) {
16
+ this.id = id;
17
+ this.head = head;
18
+ // Only a forward rule can have several heads, so only it stores them.
19
+ this.heads = heads;
20
+ this.body = body;
21
+ this.forward = forward;
22
+ this.line = line;
23
+ this.renaming = null; // how to rename it apart, worked out once it is used often
24
+ this.uses = 0;
25
+ }
26
+ }
27
+
28
+ // One clause as a program states it, before validation. kind is 'fact',
29
+ // 'backward', 'forward', 'query' or 'contradiction'; body is a list of goals.
30
+ export class Source {
31
+ constructor(kind, head, body, line = null, file = null) {
32
+ this.kind = kind;
33
+ this.head = head;
34
+ this.body = body;
35
+ this.line = line;
36
+ this.file = file;
37
+ }
38
+ }
39
+
40
+ function pushGoals(goal, out) {
41
+ if (goal instanceof Struct && goal.name === 'and' && goal.args.length === 2) {
42
+ out.push(...flattenConjunction(goal));
43
+ } else {
44
+ out.push(goal);
45
+ }
46
+ }
47
+
48
+ // options: without leaves out the clause with that number, counting from 1.
49
+ export class Program {
50
+ constructor(sources, without = null) {
51
+ this.sources = [...sources];
52
+ this.clauses = [];
53
+ this.groups = new Map();
54
+ this.forward = [];
55
+ let stratifying = false;
56
+ this.sources.forEach((source, index) => {
57
+ const ordinal = index + 1;
58
+ if (ordinal === without) return;
59
+ const forward = Object.hasOwn(FORWARD_KINDS, source.kind);
60
+ const body = [];
61
+ for (const goal of source.body) pushGoals(goal, body);
62
+ if (forward && !body.length) {
63
+ throw new EyecodeError(`line ${source.line}: ${FORWARD_KINDS[source.kind]} needs at least one goal`);
64
+ }
65
+ if (source.kind === 'fact' && body.length) throw new EyecodeError(`line ${source.line}: a fact has no body`);
66
+ const heads = forward ? flattenConjunction(source.head) : [source.head];
67
+ if (source.kind === 'forward' && heads.some((item) => item === 'true' || item === 'false')) {
68
+ // query() and contradiction() state these, and say so.
69
+ throw new EyecodeError(`line ${source.line}: implies() cannot conclude 'true' or 'false'; ` +
70
+ 'write query(Premise) or contradiction(Premise)');
71
+ }
72
+ for (const goal of body) {
73
+ if (!stratifying && mayNeedStratifying(goal)) stratifying = true;
74
+ }
75
+ for (const item of heads) {
76
+ if (!isCallable(item)) throw new EyecodeError(`line ${source.line}: a head must be an atom or a compound term`);
77
+ const itemKey = key(item);
78
+ if (RESERVED_KEYS.has(itemKey) ||
79
+ ((PRIMITIVE_KEYS.has(itemKey) || CONTROL_KEYS.has(itemKey)) &&
80
+ !(forward && (item === 'true' || item === 'false')))) {
81
+ throw new EyecodeError(`line ${source.line}: unsupported or reserved head ${itemKey}`);
82
+ }
83
+ }
84
+ const clause = new Clause(this.clauses.length + 1, source.head, forward ? heads : null, body, forward, source.line);
85
+ this.clauses.push(clause);
86
+ if (forward) {
87
+ this.forward.push(clause);
88
+ } else {
89
+ const headKey = key(source.head);
90
+ if (!this.groups.has(headKey)) this.groups.set(headKey, []);
91
+ this.groups.get(headKey).push(clause);
92
+ }
93
+ });
94
+ this.indexes = new Map();
95
+ for (const [groupKey, clauses] of this.groups) {
96
+ this.indexes.set(groupKey, positionIndexes(clauses, (clause) => clause.head, keyArity(groupKey)));
97
+ }
98
+ // Ground source facts are compared against forward conclusions by
99
+ // identity, so work those out once here.
100
+ this.groundFactKeys = new Set();
101
+ for (const clause of this.clauses) {
102
+ if (!clause.forward && !clause.body.length && isGround(clause.head)) this.groundFactKeys.add(identity(clause.head));
103
+ }
104
+ this.strata = stratify(this.clauses, stratifying);
105
+ }
106
+
107
+ candidates(goal, env, goalKey) {
108
+ const clauses = this.groups.get(goalKey);
109
+ if (clauses === undefined) return [];
110
+ const positions = this.indexes.get(goalKey);
111
+ if (!positions || !positions.length) return clauses;
112
+ let chosen = null;
113
+ let matches = null;
114
+ let count = clauses.length;
115
+ const args = goal.args;
116
+ positions.forEach((index, position) => {
117
+ if (index === null) return;
118
+ let argument = args[position];
119
+ if (argument instanceof Var) argument = deref(argument, env);
120
+ if (typeof argument !== 'string') return;
121
+ const bucket = index[0].get(argument) || [];
122
+ const size = bucket.length + index[1].length;
123
+ if (size < count) {
124
+ chosen = index;
125
+ matches = bucket;
126
+ count = size;
127
+ }
128
+ });
129
+ if (chosen === null) return clauses;
130
+ if (!chosen[1].length) return matches;
131
+ // Retain source order, including clauses with variable or structured heads.
132
+ return [...matches, ...chosen[1]].sort((a, b) => a.id - b.id);
133
+ }
134
+ }
135
+
136
+ // An index per argument position where enough entries carry an atom. Each
137
+ // index is [atoms, other]: a map from atom to entries, and the entries with
138
+ // anything else there. Indexing a position where nearly every head has a
139
+ // variable rules out nothing, so such a position gets null.
140
+ export function positionIndexes(entries, headOf, arity) {
141
+ const counts = new Array(arity).fill(0);
142
+ for (const entry of entries) {
143
+ const head = headOf(entry);
144
+ if (arity) {
145
+ head.args.forEach((arg, position) => {
146
+ if (typeof arg === 'string') counts[position]++;
147
+ });
148
+ }
149
+ }
150
+ const positions = counts.map((count) => (count * 2 < entries.length ? null : [new Map(), []]));
151
+ for (const entry of entries) {
152
+ const head = headOf(entry);
153
+ for (let position = 0; position < arity; position++) {
154
+ const index = positions[position];
155
+ if (index === null) continue;
156
+ const argument = head.args[position];
157
+ if (typeof argument !== 'string') {
158
+ index[1].push(entry);
159
+ } else {
160
+ if (!index[0].has(argument)) index[0].set(argument, []);
161
+ index[0].get(argument).push(entry);
162
+ }
163
+ }
164
+ }
165
+ return positions;
166
+ }
167
+
168
+ const CONTROL_NAMES = new Set(['and', 'or', 'not', 'call', 'once', 'findall']);
169
+
170
+ // Closed dependencies arise from absence and collection.
171
+ function dependencies(goal, closed = false, out = []) {
172
+ if (isTerm(goal, 'and', 2) || isTerm(goal, 'or', 2)) {
173
+ dependencies(goal.args[0], closed, out);
174
+ dependencies(goal.args[1], closed, out);
175
+ } else if (isTerm(goal, 'not', 1)) {
176
+ dependencies(goal.args[0], true, out);
177
+ } else if (isTerm(goal, 'findall', 3)) {
178
+ dependencies(goal.args[1], true, out);
179
+ } else if (isTerm(goal, 'once', 1) || isTerm(goal, 'call', 1)) {
180
+ dependencies(goal.args[0], closed, out);
181
+ } else if (goal instanceof Var || (isCallable(goal) && !PRIMITIVE_KEYS.has(key(goal)))) {
182
+ out.push([goal, closed]);
183
+ }
184
+ return out;
185
+ }
186
+
187
+ // Whether a goal could contribute a closed dependency or a dynamic call.
188
+ function mayNeedStratifying(goal) {
189
+ if (goal instanceof Var) return true;
190
+ if (!(goal instanceof Struct) || !CONTROL_NAMES.has(goal.name)) return false;
191
+ if (isTerm(goal, 'and', 2) || isTerm(goal, 'or', 2)) {
192
+ return mayNeedStratifying(goal.args[0]) || mayNeedStratifying(goal.args[1]);
193
+ }
194
+ if (isTerm(goal, 'not', 1) || isTerm(goal, 'findall', 3)) return true;
195
+ if (isTerm(goal, 'once', 1) || isTerm(goal, 'call', 1)) return mayNeedStratifying(goal.args[0]);
196
+ return false;
197
+ }
198
+
199
+ function headIndex(clauses) {
200
+ const index = new Map();
201
+ for (const clause of clauses) {
202
+ for (const head of clause.heads || [clause.head]) {
203
+ const headKey = key(head);
204
+ let entry = index.get(headKey);
205
+ if (entry === undefined) {
206
+ entry = { pairs: [], arity: keyArity(headKey), positions: null };
207
+ index.set(headKey, entry);
208
+ }
209
+ entry.pairs.push([clause, head]);
210
+ }
211
+ }
212
+ for (const entry of index.values()) entry.positions = positionIndexes(entry.pairs, (pair) => pair[1], entry.arity);
213
+ return index;
214
+ }
215
+
216
+ function headCandidates(entry, goal) {
217
+ if (entry === undefined) return [];
218
+ let chosen = entry.pairs;
219
+ entry.positions.forEach((bucket, position) => {
220
+ if (bucket === null || !(goal instanceof Struct) || typeof goal.args[position] !== 'string') return;
221
+ const matched = bucket[0].get(goal.args[position]) || [];
222
+ if (matched.length + bucket[1].length < chosen.length) chosen = [...matched, ...bucket[1]];
223
+ });
224
+ return chosen;
225
+ }
226
+
227
+ // Ranks per clause id; an absent rank is stratum 0. Stratification matches
228
+ // whole terms, not just predicate names, so two relations sharing a name can
229
+ // sit in different strata. Closed dependency cycles are rejected, as are
230
+ // dynamic calls reachable from forward rules.
231
+ function stratify(clauses, stratifying) {
232
+ if (!stratifying) return new Map();
233
+ const ranks = new Map(clauses.map((clause) => [clause.id, 0]));
234
+ const edges = [];
235
+ const outgoing = new Map();
236
+ const dynamic = new Set();
237
+ const index = headIndex(clauses);
238
+ const everyHead = [];
239
+ for (const clause of clauses) for (const head of clause.heads || [clause.head]) everyHead.push([clause, head]);
240
+ for (const clause of clauses) {
241
+ for (const goal of clause.body) {
242
+ for (const [depGoal, closed] of dependencies(goal)) {
243
+ const named = !(depGoal instanceof Var);
244
+ if (!named) dynamic.add(clause.id);
245
+ const candidates = named ? headCandidates(index.get(key(depGoal)), depGoal) : everyHead;
246
+ for (const [candidateClause, head] of candidates) {
247
+ if (unify(freshTerm(depGoal, 'dependency'), freshTerm(head, 'head'), new Env())) {
248
+ const edge = [clause.id, candidateClause.id, closed];
249
+ edges.push(edge);
250
+ if (!outgoing.has(clause.id)) outgoing.set(clause.id, []);
251
+ outgoing.get(clause.id).push(edge);
252
+ }
253
+ }
254
+ }
255
+ }
256
+ }
257
+ const reachable = new Set();
258
+ const pending = clauses.filter((clause) => clause.forward).map((clause) => clause.id);
259
+ while (pending.length) {
260
+ const clauseId = pending.pop();
261
+ if (reachable.has(clauseId)) continue;
262
+ reachable.add(clauseId);
263
+ if (dynamic.has(clauseId)) throw new EyecodeError('forward dependencies require statically named calls');
264
+ for (const edge of outgoing.get(clauseId) || []) pending.push(edge[1]);
265
+ }
266
+ for (let round = 0; round <= ranks.size; round++) {
267
+ let changed = false;
268
+ for (const [headId, targetId, closed] of edges) {
269
+ const rank = (ranks.get(targetId) || 0) + (closed ? 1 : 0);
270
+ if (rank > ranks.get(headId)) {
271
+ ranks.set(headId, rank);
272
+ changed = true;
273
+ }
274
+ }
275
+ if (!changed) return ranks;
276
+ }
277
+ throw new EyecodeError('unstratified negation or collection dependency');
278
+ }