eyeprolog 1.3.8 → 1.3.9

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
@@ -208,7 +208,9 @@ Trealla/Scryer organization for predicates such as `member/2`, `memberchk/2`,
208
208
  `append/2-3`, `nth0/3-4`, `nth1/3-4`, `length/2`, `maplist/2-8`, and
209
209
  `foldl/4-6`. Its `length/2` remains relational: with both arguments variable,
210
210
  `length(Xs, N)` enumerates lists of increasing length together with `N = 0, 1,
211
- 2, ...`.
211
+ 2, ...`. Open-ended generation uses the normal memory guard with recovery
212
+ headroom, so an exhausted finite heap is reported as a catchable
213
+ `resource_error(memory)` instead of degenerating into quadratic list checks.
212
214
 
213
215
  `library(iso_ext)` is also accepted as a common interop module name.
214
216
  EyeProlog exports `call_nth/2` there, so Scryer-style source can explicitly use
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.3.8",
6
+ "version": "1.3.9",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
package/src/solver.js CHANGED
@@ -17,6 +17,12 @@ import { evaluatePositiveDatalog, relationForDatalogGroup, datalogCandidateIndex
17
17
 
18
18
  let freshCounter = 0;
19
19
  const DEFAULT_INNER_TABLE_SCOPE_LIMIT = 1024;
20
+ // Conservative live-storage estimate for one generated length/2 list cell
21
+ // (cons object, argument vector, fresh variable, and its generated name).
22
+ const GENERATED_LENGTH_CELL_RESERVE_BYTES = 256;
23
+ const MAX_GENERATED_LENGTH_RESERVE_STEPS = BigInt(
24
+ Math.floor(Number.MAX_SAFE_INTEGER / GENERATED_LENGTH_CELL_RESERVE_BYTES),
25
+ );
20
26
 
21
27
  function qualifyTerm(term, module) {
22
28
  if (!term || (term.type !== COMPOUND && term.type !== 'atom')) return term;
@@ -1239,13 +1245,17 @@ function* generatedLengthSolutions(solver, list, length, env) {
1239
1245
  let suffix = emptyList();
1240
1246
  for (let extra = 0n; ; extra++) {
1241
1247
  const next = env.clone();
1242
- solver.stats.unify_calls++;
1243
- if (unify(cursor, suffix, next)) {
1244
- const answer = bindGeneratedLength(solver, length, count + extra, next);
1245
- if (answer != null) yield answer;
1246
- }
1248
+ // cursor is a dereferenced plain variable and suffix is made only from
1249
+ // freshly generated variables, so this binding cannot create a cycle.
1250
+ // Binding directly avoids an O(extra) occurs-check over the complete
1251
+ // growing suffix for every answer; without this, unbounded length/2
1252
+ // generation becomes quadratic and can spend hours before reaching the
1253
+ // normal memory resource guard (issue #49).
1254
+ next.bind(cursor.name, suffix);
1255
+ const answer = bindGeneratedLength(solver, length, count + extra, next);
1256
+ if (answer != null) yield answer;
1247
1257
  suffix = cons(variable(`__length${id}_${extra}`), suffix);
1248
- lengthAllocationCheckpoint(solver, ++steps);
1258
+ generatedLengthAllocationCheckpoint(solver, extra + 1n);
1249
1259
  }
1250
1260
  }
1251
1261
 
@@ -1263,6 +1273,19 @@ function lengthAllocationCheckpoint(solver, steps) {
1263
1273
  if ((steps & 255n) === 0n) solver.checkMemoryLimit(true);
1264
1274
  }
1265
1275
 
1276
+ function generatedLengthAllocationCheckpoint(solver, steps) {
1277
+ if ((steps & 255n) !== 0n) return;
1278
+ // The open-ended generator retains its current list spine between answers.
1279
+ // Reserve room proportional to that live spine so the protected length/2
1280
+ // call raises resource_error(memory) before its caller's outer solver hits
1281
+ // the same heap limit. This makes the error catchable by catch/3.
1282
+ const estimatedSpineBytes = steps > MAX_GENERATED_LENGTH_RESERVE_STEPS
1283
+ ? Number.MAX_SAFE_INTEGER
1284
+ : Number(steps) * GENERATED_LENGTH_CELL_RESERVE_BYTES;
1285
+ solver.checkMemoryReservation(estimatedSpineBytes);
1286
+ solver.checkMemoryLimit(true);
1287
+ }
1288
+
1266
1289
  function pushFastPiFrames(stack, goal, rest, env, depth, active) {
1267
1290
  const values = goal.args.map((arg) => deref(arg, env));
1268
1291
  if ([0, 1, 2, 4].some((index) => values[index].type !== 'number')) return false;
@@ -3627,6 +3627,45 @@ answer(ok) :-
3627
3627
  }
3628
3628
  },
3629
3629
  },
3630
+ {
3631
+ name: 'unbounded length/2 reaches a catchable memory resource error (issue #49)',
3632
+ run: () => {
3633
+ const engineUrl = new URL('../src/index.js', import.meta.url).href;
3634
+ const script = `
3635
+ import { Program, Solver, Env, deref, variable, parseGoalText, getEyePrologRegistry } from ${JSON.stringify(engineUrl)};
3636
+ const program = Program.parse(${JSON.stringify(':- use_module(library(lists)).\n')}, { sourceMetadata: false });
3637
+ const solver = new Solver(program, { registry: getEyePrologRegistry() });
3638
+ const goal = parseGoalText('catch(length(L,N),error(E,_),true),L=N', {
3639
+ operatorDefinitions: [...program.operators.values()],
3640
+ });
3641
+ let answer = null;
3642
+ for (const env of solver.solve([goal], new Env(), 0)) {
3643
+ answer = env;
3644
+ break;
3645
+ }
3646
+ if (answer == null) throw new Error('issue #49 query produced no caught answer');
3647
+ const formal = deref(variable('E'), answer);
3648
+ if (formal?.name !== 'resource_error' || deref(formal.args?.[0], answer)?.name !== 'memory') {
3649
+ throw new Error('unexpected caught error: ' + JSON.stringify(formal));
3650
+ }
3651
+ const left = deref(variable('L'), answer);
3652
+ const right = deref(variable('N'), answer);
3653
+ if (left.type !== 'var' || right.type !== 'var' || left.name !== right.name) {
3654
+ throw new Error('recovery did not leave L=N');
3655
+ }
3656
+ process.stdout.write('caught');
3657
+ `;
3658
+ const result = spawnSync(process.execPath, [
3659
+ '--max-old-space-size=64',
3660
+ '--input-type=module',
3661
+ '--eval',
3662
+ script,
3663
+ ], { cwd: packageRoot, encoding: 'utf8', timeout: 5000 });
3664
+ if (result.error) throw result.error;
3665
+ assertEqual(result.status, 0, `issue #49 bounded-heap child status; stderr=${result.stderr}`);
3666
+ assertEqual(result.stdout, 'caught', 'issue #49 resource error is caught by catch/3');
3667
+ },
3668
+ },
3630
3669
  {
3631
3670
  name: 'discarded fixed-length lists stay compact under a bounded heap',
3632
3671
  run: () => {
@@ -1900,9 +1900,15 @@ unification, another list predicate, or answer readback inspects it. This is a
1900
1900
  storage optimization, not a distinct Prolog term or list semantics. Embedders
1901
1901
  that inspect the JavaScript term model can recognize this representation with
1902
1902
  `CompactListTerm`, `isCompactList`, and `compactListLength`, or construct one
1903
- with `compactVariableList`. The ordinary clauses remain the authoritative module
1904
- definition and are used unchanged by the ISO-only registry and whenever delays
1905
- or finite-domain constraints require their normal wake-up points.
1903
+ with `compactVariableList`. For open-ended `length(List, N)` generation, the
1904
+ bundled path binds each fresh generated spine directly instead of re-running an
1905
+ occurs-check over the whole growing list. It also reserves recovery headroom
1906
+ proportional to the retained spine, so a finite heap limit is raised inside the
1907
+ `length/2` search as a catchable `resource_error(memory)` rather than allowing
1908
+ an outer solver frame to encounter the limit first. The ordinary clauses remain
1909
+ the authoritative module definition and are used unchanged by the ISO-only
1910
+ registry and whenever delays or finite-domain constraints require their normal
1911
+ wake-up points.
1906
1912
 
1907
1913
  ### Implementation boundary
1908
1914