eyeling 1.17.2 → 1.18.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/index.d.ts CHANGED
@@ -148,8 +148,23 @@ declare module 'eyeling' {
148
148
  noProofComments?: boolean;
149
149
  args?: string[];
150
150
  maxBuffer?: number;
151
+ builtinModules?: string | string[];
151
152
  }
152
153
 
154
+ export interface BuiltinRegistrationContext {
155
+ iri: string;
156
+ goal: EyelingTriple;
157
+ subst: Record<string, EyelingTerm>;
158
+ facts: any[];
159
+ backRules: EyelingRule[];
160
+ depth: number;
161
+ varGen: number[];
162
+ maxResults?: number;
163
+ api: any;
164
+ }
165
+
166
+ export type BuiltinHandler = (ctx: BuiltinRegistrationContext) => Array<Record<string, EyelingTerm>>;
167
+
153
168
  export interface ReasonStreamOptions {
154
169
  baseIri?: string | null;
155
170
  proof?: boolean;
@@ -157,6 +172,7 @@ declare module 'eyeling' {
157
172
  enforceHttps?: boolean;
158
173
  rdfjs?: boolean;
159
174
  dataFactory?: RdfJsDataFactory | null;
175
+ builtinModules?: string | string[];
160
176
  onDerived?: (item: { triple: string; quad?: RdfJsQuad; df: any }) => void;
161
177
  }
162
178
 
@@ -183,4 +199,9 @@ declare module 'eyeling' {
183
199
  ): AsyncIterable<RdfJsQuad>;
184
200
 
185
201
  export const rdfjs: RdfJsDataFactory;
202
+ export function registerBuiltin(iri: string, handler: BuiltinHandler): BuiltinHandler;
203
+ export function unregisterBuiltin(iri: string): boolean;
204
+ export function registerBuiltinModule(mod: any, origin?: string): boolean;
205
+ export function loadBuiltinModule(specifier: string, options?: { resolveFrom?: string }): string;
206
+ export function listBuiltinIris(): string[];
186
207
  }
package/index.js CHANGED
@@ -7,6 +7,7 @@ const cp = require('node:child_process');
7
7
 
8
8
  const bundleApi = require('./eyeling.js');
9
9
  const { dataFactory, normalizeReasonerInputSync } = require('./lib/rdfjs');
10
+ const engine = require('./lib/engine');
10
11
 
11
12
  function reason(opt = {}, input = '') {
12
13
  if (input == null) input = '';
@@ -42,6 +43,13 @@ function reason(opt = {}, input = '') {
42
43
 
43
44
  if (Array.isArray(opt.args)) args.push(...opt.args);
44
45
 
46
+ const builtinModules = Array.isArray(opt.builtinModules)
47
+ ? opt.builtinModules
48
+ : typeof opt.builtinModules === 'string' && opt.builtinModules
49
+ ? [opt.builtinModules]
50
+ : [];
51
+ for (const spec of builtinModules) args.push('--builtin', spec);
52
+
45
53
  const maxBuffer = Number.isFinite(opt.maxBuffer) ? opt.maxBuffer : 50 * 1024 * 1024;
46
54
 
47
55
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'eyeling-'));
@@ -77,6 +85,11 @@ module.exports = {
77
85
  reasonStream: bundleApi.reasonStream,
78
86
  reasonRdfJs: bundleApi.reasonRdfJs,
79
87
  rdfjs: dataFactory,
88
+ registerBuiltin: engine.registerBuiltin,
89
+ unregisterBuiltin: engine.unregisterBuiltin,
90
+ registerBuiltinModule: engine.registerBuiltinModule,
91
+ loadBuiltinModule: engine.loadBuiltinModule,
92
+ listBuiltinIris: engine.listBuiltinIris,
80
93
  };
81
94
 
82
95
  // small interop nicety for ESM default import
@@ -0,0 +1,465 @@
1
+ 'use strict';
2
+
3
+ module.exports = function registerSudokuBuiltins(api) {
4
+ const { registerBuiltin, internLiteral, termToJsString, unifyTerm, terms } = api;
5
+ const { Var } = terms;
6
+
7
+ const SUDOKU_NS = 'http://example.org/sudoku-builtin#';
8
+ const __sudokuReportCache = new Map();
9
+ const __SUDOKU_ALL = 0x1ff;
10
+
11
+ function makeStringLiteral(str) {
12
+ return internLiteral(JSON.stringify(str));
13
+ }
14
+
15
+ function digitMask(v) {
16
+ return 1 << (v - 1);
17
+ }
18
+
19
+ function boxIndex(r, c) {
20
+ return Math.floor(r / 3) * 3 + Math.floor(c / 3);
21
+ }
22
+
23
+ function popcount(mask) {
24
+ let n = 0;
25
+ while (mask) {
26
+ mask &= mask - 1;
27
+ n += 1;
28
+ }
29
+ return n;
30
+ }
31
+
32
+ function maskToDigits(mask) {
33
+ const out = [];
34
+ for (let d = 1; d <= 9; d += 1) if (mask & digitMask(d)) out.push(d);
35
+ return out;
36
+ }
37
+
38
+ function formatBoard(cells) {
39
+ let out = '';
40
+ for (let r = 0; r < 9; r += 1) {
41
+ if (r > 0 && r % 3 === 0) out += '\n';
42
+ for (let c = 0; c < 9; c += 1) {
43
+ if (c > 0 && c % 3 === 0) out += '| ';
44
+ const v = cells[r * 9 + c];
45
+ out += v === 0 ? '. ' : `${String(v)} `;
46
+ }
47
+ out += '\n';
48
+ }
49
+ return out;
50
+ }
51
+
52
+ function parsePuzzle(input) {
53
+ const filtered = [];
54
+ for (const ch of input) {
55
+ if (/\s/.test(ch) || ch === '|' || ch === '+') continue;
56
+ filtered.push(ch);
57
+ }
58
+ if (filtered.length !== 81) {
59
+ return { error: `Expected exactly 81 cells after removing whitespace, but found ${filtered.length}.` };
60
+ }
61
+ const cells = new Array(81).fill(0);
62
+ for (let i = 0; i < 81; i += 1) {
63
+ const ch = filtered[i];
64
+ if (ch >= '1' && ch <= '9') cells[i] = ch.charCodeAt(0) - 48;
65
+ else if (ch === '0' || ch === '.' || ch === '_') cells[i] = 0;
66
+ else return { error: `Unexpected character '${ch}' at position ${i + 1}.` };
67
+ }
68
+ return { cells };
69
+ }
70
+
71
+ function attachMethods(state) {
72
+ state.place = function place(idx, value) {
73
+ if (this.cells[idx] !== 0) return this.cells[idx] === value;
74
+ const row = Math.floor(idx / 9);
75
+ const col = idx % 9;
76
+ const bx = boxIndex(row, col);
77
+ const bit = digitMask(value);
78
+ if (((this.rowUsed[row] | this.colUsed[col] | this.boxUsed[bx]) & bit) !== 0) return false;
79
+ this.cells[idx] = value;
80
+ this.rowUsed[row] |= bit;
81
+ this.colUsed[col] |= bit;
82
+ this.boxUsed[bx] |= bit;
83
+ return true;
84
+ };
85
+
86
+ state.candidates = function candidates(idx) {
87
+ const row = Math.floor(idx / 9);
88
+ const col = idx % 9;
89
+ const bx = boxIndex(row, col);
90
+ return __SUDOKU_ALL & ~(this.rowUsed[row] | this.colUsed[col] | this.boxUsed[bx]);
91
+ };
92
+
93
+ state.clone = function clone() {
94
+ return attachMethods({
95
+ cells: this.cells.slice(),
96
+ rowUsed: this.rowUsed.slice(),
97
+ colUsed: this.colUsed.slice(),
98
+ boxUsed: this.boxUsed.slice(),
99
+ moves: this.moves.slice(),
100
+ });
101
+ };
102
+
103
+ return state;
104
+ }
105
+
106
+ function stateFromPuzzle(cells) {
107
+ const state = attachMethods({
108
+ cells: new Array(81).fill(0),
109
+ rowUsed: new Array(9).fill(0),
110
+ colUsed: new Array(9).fill(0),
111
+ boxUsed: new Array(9).fill(0),
112
+ moves: [],
113
+ });
114
+
115
+ for (let idx = 0; idx < 81; idx += 1) {
116
+ const value = cells[idx];
117
+ if (value === 0) continue;
118
+ if (value < 1 || value > 9) {
119
+ return { error: `Cell ${idx + 1} contains ${value}, but only digits 1-9 or 0/. are allowed.` };
120
+ }
121
+ if (!state.place(idx, value)) {
122
+ const row = Math.floor(idx / 9) + 1;
123
+ const col = (idx % 9) + 1;
124
+ return { error: `The given clues already conflict at row ${row}, column ${col}.` };
125
+ }
126
+ }
127
+
128
+ return { state };
129
+ }
130
+
131
+ function summarizeMoves(moves, limit) {
132
+ if (!moves.length) return 'no placements were needed';
133
+ const parts = [];
134
+ for (const mv of moves.slice(0, limit)) {
135
+ const row = Math.floor(mv.index / 9) + 1;
136
+ const col = (mv.index % 9) + 1;
137
+ const mode = mv.forced ? 'forced' : 'guess';
138
+ parts.push(`r${row}c${col}=${mv.value}: ${mode}`);
139
+ }
140
+ if (moves.length > limit) parts.push(`… and ${moves.length - limit} more placements`);
141
+ return parts.join(', ');
142
+ }
143
+
144
+ function unitIsComplete(values) {
145
+ let seen = 0;
146
+ for (const v of values) {
147
+ if (v < 1 || v > 9) return false;
148
+ const bit = digitMask(v);
149
+ if (seen & bit) return false;
150
+ seen |= bit;
151
+ }
152
+ return seen === __SUDOKU_ALL;
153
+ }
154
+
155
+ function replayMovesAreLegal(puzzleCells, moves) {
156
+ const init = stateFromPuzzle(puzzleCells);
157
+ if (init.error) return false;
158
+ const state = init.state;
159
+ for (const mv of moves) {
160
+ if (state.cells[mv.index] !== 0) return false;
161
+ const maskNow = state.candidates(mv.index);
162
+ if (maskNow !== mv.candidatesMask) return false;
163
+ if ((maskNow & digitMask(mv.value)) === 0) return false;
164
+ if (mv.forced && popcount(maskNow) !== 1) return false;
165
+ if (!state.place(mv.index, mv.value)) return false;
166
+ }
167
+ return true;
168
+ }
169
+
170
+ function propagateSingles(state, stats) {
171
+ for (;;) {
172
+ let progress = false;
173
+ for (let idx = 0; idx < 81; idx += 1) {
174
+ if (state.cells[idx] !== 0) continue;
175
+ const mask = state.candidates(idx);
176
+ const count = popcount(mask);
177
+ if (count === 0) return false;
178
+ if (count === 1) {
179
+ const digit = maskToDigits(mask)[0];
180
+ state.moves.push({ index: idx, value: digit, candidatesMask: mask, forced: true });
181
+ if (!state.place(idx, digit)) return false;
182
+ stats.forcedMoves += 1;
183
+ progress = true;
184
+ }
185
+ }
186
+ if (!progress) return true;
187
+ }
188
+ }
189
+
190
+ function selectUnfilledCell(state) {
191
+ let best = null;
192
+ for (let idx = 0; idx < 81; idx += 1) {
193
+ if (state.cells[idx] !== 0) continue;
194
+ const mask = state.candidates(idx);
195
+ const count = popcount(mask);
196
+ if (best === null || count < best.count) best = { idx, mask, count };
197
+ if (count === 2) break;
198
+ }
199
+ return best;
200
+ }
201
+
202
+ function solve(state, stats, depth) {
203
+ stats.recursiveNodes += 1;
204
+ if (depth > stats.maxDepth) stats.maxDepth = depth;
205
+ const current = state.clone();
206
+ if (!propagateSingles(current, stats)) {
207
+ stats.backtracks += 1;
208
+ return null;
209
+ }
210
+ const best = selectUnfilledCell(current);
211
+ if (!best) return current;
212
+ for (const digit of maskToDigits(best.mask)) {
213
+ const next = current.clone();
214
+ const candidatesMask = next.candidates(best.idx);
215
+ next.moves.push({ index: best.idx, value: digit, candidatesMask, forced: false });
216
+ stats.guessedMoves += 1;
217
+ if (!next.place(best.idx, digit)) continue;
218
+ const solved = solve(next, stats, depth + 1);
219
+ if (solved) return solved;
220
+ }
221
+ stats.backtracks += 1;
222
+ return null;
223
+ }
224
+
225
+ function countSolutions(state, limit, countRef) {
226
+ if (countRef.count >= limit) return;
227
+ const current = state.clone();
228
+ const dummy = {
229
+ givens: 0,
230
+ blanks: 0,
231
+ forcedMoves: 0,
232
+ guessedMoves: 0,
233
+ recursiveNodes: 0,
234
+ backtracks: 0,
235
+ maxDepth: 0,
236
+ };
237
+ if (!propagateSingles(current, dummy)) return;
238
+ const best = selectUnfilledCell(current);
239
+ if (!best) {
240
+ countRef.count += 1;
241
+ return;
242
+ }
243
+ for (const digit of maskToDigits(best.mask)) {
244
+ if (countRef.count >= limit) return;
245
+ const next = current.clone();
246
+ if (next.place(best.idx, digit)) countSolutions(next, limit, countRef);
247
+ }
248
+ }
249
+
250
+ function computeReport(term) {
251
+ const raw = termToJsString(term);
252
+ if (raw === null) return null;
253
+ if (__sudokuReportCache.has(raw)) return __sudokuReportCache.get(raw);
254
+
255
+ const parsed = parsePuzzle(raw);
256
+ if (parsed.error) {
257
+ const rep = { status: 'invalid-input', error: parsed.error, raw, normalized: null };
258
+ __sudokuReportCache.set(raw, rep);
259
+ return rep;
260
+ }
261
+
262
+ const normalized = parsed.cells.join('');
263
+ const init = stateFromPuzzle(parsed.cells);
264
+ if (init.error) {
265
+ const rep = {
266
+ status: 'illegal-clues',
267
+ error: init.error,
268
+ raw,
269
+ normalized,
270
+ givens: parsed.cells.filter((v) => v !== 0).length,
271
+ blanks: parsed.cells.filter((v) => v === 0).length,
272
+ puzzleText: formatBoard(parsed.cells),
273
+ };
274
+ __sudokuReportCache.set(raw, rep);
275
+ return rep;
276
+ }
277
+
278
+ const initial = init.state;
279
+ const stats = {
280
+ givens: parsed.cells.filter((v) => v !== 0).length,
281
+ blanks: parsed.cells.filter((v) => v === 0).length,
282
+ forcedMoves: 0,
283
+ guessedMoves: 0,
284
+ recursiveNodes: 0,
285
+ backtracks: 0,
286
+ maxDepth: 0,
287
+ };
288
+
289
+ const solved = solve(initial, stats, 0);
290
+ if (!solved) {
291
+ const rep = {
292
+ status: 'unsatisfiable',
293
+ raw,
294
+ normalized,
295
+ givens: stats.givens,
296
+ blanks: stats.blanks,
297
+ recursiveNodes: stats.recursiveNodes,
298
+ backtracks: stats.backtracks,
299
+ puzzleText: formatBoard(parsed.cells),
300
+ };
301
+ __sudokuReportCache.set(raw, rep);
302
+ return rep;
303
+ }
304
+
305
+ const countRef = { count: 0 };
306
+ countSolutions(initial, 2, countRef);
307
+
308
+ const givensPreserved = parsed.cells.every((v, i) => v === 0 || v === solved.cells[i]);
309
+ const noBlanks = solved.cells.every((v) => v >= 1 && v <= 9);
310
+ const rowsComplete = Array.from({ length: 9 }, (_, r) =>
311
+ unitIsComplete(solved.cells.slice(r * 9, r * 9 + 9)),
312
+ ).every(Boolean);
313
+ const colsComplete = Array.from({ length: 9 }, (_, c) =>
314
+ unitIsComplete(Array.from({ length: 9 }, (_, r) => solved.cells[r * 9 + c])),
315
+ ).every(Boolean);
316
+ const boxesComplete = Array.from({ length: 9 }, (_, b) => {
317
+ const br = Math.floor(b / 3) * 3;
318
+ const bc = (b % 3) * 3;
319
+ const vals = [];
320
+ for (let dr = 0; dr < 3; dr += 1) {
321
+ for (let dc = 0; dc < 3; dc += 1) vals.push(solved.cells[(br + dr) * 9 + (bc + dc)]);
322
+ }
323
+ return unitIsComplete(vals);
324
+ }).every(Boolean);
325
+ const replayLegal = replayMovesAreLegal(parsed.cells, solved.moves);
326
+ const proofPathGuessCount = solved.moves.filter((m) => !m.forced).length;
327
+ const storyConsistent =
328
+ stats.recursiveNodes >= 1 &&
329
+ stats.maxDepth <= stats.blanks &&
330
+ solved.moves.length === stats.blanks &&
331
+ proofPathGuessCount <= stats.guessedMoves;
332
+
333
+ const rep = {
334
+ status: 'ok',
335
+ raw,
336
+ normalized,
337
+ givens: stats.givens,
338
+ blanks: stats.blanks,
339
+ forcedMoves: stats.forcedMoves,
340
+ guessedMoves: stats.guessedMoves,
341
+ recursiveNodes: stats.recursiveNodes,
342
+ backtracks: stats.backtracks,
343
+ maxDepth: stats.maxDepth,
344
+ unique: countRef.count === 1,
345
+ solution: solved.cells.join(''),
346
+ puzzleText: formatBoard(parsed.cells),
347
+ solutionText: formatBoard(solved.cells),
348
+ moveSummary: summarizeMoves(solved.moves, 8),
349
+ moveCount: solved.moves.length,
350
+ givensPreserved,
351
+ noBlanks,
352
+ rowsComplete,
353
+ colsComplete,
354
+ boxesComplete,
355
+ replayLegal,
356
+ storyConsistent,
357
+ };
358
+
359
+ __sudokuReportCache.set(raw, rep);
360
+ return rep;
361
+ }
362
+
363
+ function reportFieldAsTerm(report, field) {
364
+ if (!report) return null;
365
+ if (field === 'status') return makeStringLiteral(report.status);
366
+ if (field === 'error') return report.error ? makeStringLiteral(report.error) : null;
367
+ if (field === 'normalizedPuzzle') return report.normalized ? makeStringLiteral(report.normalized) : null;
368
+ if (field === 'solution') return report.solution ? makeStringLiteral(report.solution) : null;
369
+ if (field === 'puzzleText') return report.puzzleText ? makeStringLiteral(report.puzzleText) : null;
370
+ if (field === 'solutionText') return report.solutionText ? makeStringLiteral(report.solutionText) : null;
371
+ if (field === 'moveSummary') return report.moveSummary ? makeStringLiteral(report.moveSummary) : null;
372
+ if (field === 'givensPreservedText')
373
+ return report.givensPreserved === undefined ? null : makeStringLiteral(report.givensPreserved ? 'OK' : 'failed');
374
+ if (field === 'noBlanksText')
375
+ return report.noBlanks === undefined ? null : makeStringLiteral(report.noBlanks ? 'OK' : 'failed');
376
+ if (field === 'rowsCompleteText')
377
+ return report.rowsComplete === undefined ? null : makeStringLiteral(report.rowsComplete ? 'OK' : 'failed');
378
+ if (field === 'colsCompleteText')
379
+ return report.colsComplete === undefined ? null : makeStringLiteral(report.colsComplete ? 'OK' : 'failed');
380
+ if (field === 'boxesCompleteText')
381
+ return report.boxesComplete === undefined ? null : makeStringLiteral(report.boxesComplete ? 'OK' : 'failed');
382
+ if (field === 'replayLegalText')
383
+ return report.replayLegal === undefined ? null : makeStringLiteral(report.replayLegal ? 'OK' : 'failed');
384
+ if (field === 'storyConsistentText')
385
+ return report.storyConsistent === undefined ? null : makeStringLiteral(report.storyConsistent ? 'OK' : 'failed');
386
+
387
+ const boolFields = [
388
+ 'unique',
389
+ 'givensPreserved',
390
+ 'noBlanks',
391
+ 'rowsComplete',
392
+ 'colsComplete',
393
+ 'boxesComplete',
394
+ 'replayLegal',
395
+ 'storyConsistent',
396
+ ];
397
+ if (boolFields.includes(field))
398
+ return report[field] === undefined ? null : internLiteral(report[field] ? 'true' : 'false');
399
+
400
+ const numberFields = [
401
+ 'givens',
402
+ 'blanks',
403
+ 'forcedMoves',
404
+ 'guessedMoves',
405
+ 'recursiveNodes',
406
+ 'backtracks',
407
+ 'maxDepth',
408
+ 'moveCount',
409
+ ];
410
+ if (numberFields.includes(field)) return report[field] === undefined ? null : internLiteral(String(report[field]));
411
+
412
+ return null;
413
+ }
414
+
415
+ function evalSudokuField(goal, subst, field) {
416
+ const report = computeReport(goal.s);
417
+ if (!report) return [];
418
+ const term = reportFieldAsTerm(report, field);
419
+ if (!term) return [];
420
+ if (goal.o instanceof Var) {
421
+ const s2 = { ...subst };
422
+ s2[goal.o.name] = term;
423
+ return [s2];
424
+ }
425
+ const s2 = unifyTerm(goal.o, term, subst);
426
+ return s2 !== null ? [s2] : [];
427
+ }
428
+
429
+ const fields = [
430
+ 'status',
431
+ 'error',
432
+ 'normalizedPuzzle',
433
+ 'solution',
434
+ 'givens',
435
+ 'blanks',
436
+ 'forcedMoves',
437
+ 'guessedMoves',
438
+ 'recursiveNodes',
439
+ 'backtracks',
440
+ 'maxDepth',
441
+ 'unique',
442
+ 'givensPreserved',
443
+ 'noBlanks',
444
+ 'rowsComplete',
445
+ 'colsComplete',
446
+ 'boxesComplete',
447
+ 'replayLegal',
448
+ 'storyConsistent',
449
+ 'givensPreservedText',
450
+ 'noBlanksText',
451
+ 'rowsCompleteText',
452
+ 'colsCompleteText',
453
+ 'boxesCompleteText',
454
+ 'replayLegalText',
455
+ 'storyConsistentText',
456
+ 'moveSummary',
457
+ 'puzzleText',
458
+ 'solutionText',
459
+ 'moveCount',
460
+ ];
461
+
462
+ for (const field of fields) {
463
+ registerBuiltin(SUDOKU_NS + field, ({ goal, subst }) => evalSudokuField(goal, subst, field));
464
+ }
465
+ };