eyeprolog 1.2.22 → 1.2.24

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/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.2.22",
6
+ "version": "1.2.24",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
package/src/iso.js CHANGED
@@ -5,7 +5,8 @@ import {
5
5
  isDecimalInteger, listFromItems, numberTerm, numberTextFromDouble,
6
6
  properListItems, termIsGround, termToString, unify, variable, variantTerms,
7
7
  } from './term.js';
8
- import { createParserOperatorState, parseClauses, parseGoalText } from './parser.js';
8
+ import { sameNumberValue } from './number-value.js';
9
+ import { createParserOperatorState, parseClauses, parseGoalText, parseNumberTokenText } from './parser.js';
9
10
  import { formatTermForWrite } from './write.js';
10
11
  import { emptyTerminalSequence, expandDcgBody, isListOrPartialList, validateDcgEmbeddedGoals } from './dcg.js';
11
12
 
@@ -220,7 +221,8 @@ function subsumesTerm(general, specific, env) {
220
221
  }
221
222
  continue;
222
223
  }
223
- if (left.type !== right.type || left.name !== right.name || left.arity !== right.arity) return false;
224
+ if (left.type !== right.type || left.arity !== right.arity) return false;
225
+ if (left.type === NUMBER ? !sameNumberValue(left.name, right.name) : left.name !== right.name) return false;
224
226
  for (let i = left.arity - 1; i >= 0; i--) pending.push([left.args[i], right.args[i]]);
225
227
  }
226
228
  return true;
@@ -239,7 +241,8 @@ function* nonIdentity({ goal, env }) {
239
241
  function identical(left, right, env) {
240
242
  left = deref(left, env);
241
243
  right = deref(right, env);
242
- if (left.type !== right.type || left.name !== right.name || left.arity !== right.arity) return false;
244
+ if (left.type !== right.type || left.arity !== right.arity) return false;
245
+ if (left.type === NUMBER ? !sameNumberValue(left.name, right.name) : left.name !== right.name) return false;
243
246
  if (left.type === VAR) return left.name === right.name;
244
247
  for (let i = 0; i < left.arity; i++) if (!identical(left.args[i], right.args[i], env)) return false;
245
248
  return true;
@@ -1549,9 +1552,7 @@ function quotedNumberSign(text, start) {
1549
1552
  }
1550
1553
 
1551
1554
  function parseIsoNumber(text) {
1552
- // number_chars/2 accepts leading layout, but its character list must end
1553
- // with the numeric token itself rather than trailing layout text.
1554
- if (text.length === 0 || /[\u0009-\u000d\u0020]$/.test(text)) return null;
1555
+ if (text.length === 0) return null;
1555
1556
  let position = skipNumberLayout(text, 0);
1556
1557
  let sign = '';
1557
1558
 
@@ -1587,10 +1588,7 @@ function parseIsoNumber(text) {
1587
1588
  // ISO floating-point syntax requires a decimal fraction before an exponent.
1588
1589
  if (/^-?\d+[eE][+-]?\d+$/.test(numericText)) return null;
1589
1590
  try {
1590
- const parsed = parseGoalText(`number_chars_value(${numericText})`);
1591
- if (parsed.type !== COMPOUND || parsed.name !== 'number_chars_value' ||
1592
- parsed.arity !== 1 || parsed.args[0].type !== NUMBER) return null;
1593
- const value = parsed.args[0];
1591
+ const value = parseNumberTokenText(numericText);
1594
1592
  if (isDecimalInteger(value.name)) return numberTerm(BigInt(value.name).toString());
1595
1593
  const finite = Number(value.name);
1596
1594
  if (!Number.isFinite(finite)) return null;
@@ -1601,14 +1599,18 @@ function parseIsoNumber(text) {
1601
1599
  }
1602
1600
 
1603
1601
  function sameNumber(left, right) {
1604
- const leftInteger = isDecimalInteger(left.name);
1605
- const rightInteger = isDecimalInteger(right.name);
1606
- if (leftInteger || rightInteger) {
1607
- return leftInteger && rightInteger && BigInt(left.name) === BigInt(right.name);
1602
+ return sameNumberValue(left.name, right.name);
1603
+ }
1604
+
1605
+ function canonicalNumberText(value) {
1606
+ if (isDecimalInteger(value.name)) return BigInt(value.name).toString();
1607
+ const finite = Number(value.name);
1608
+ if (Number.isFinite(finite) && /^-?\d+\.\d+(?:[eE][+-]?\d+)?$/.test(value.name)) {
1609
+ return value.name;
1608
1610
  }
1609
- const leftValue = Number(left.name);
1610
- const rightValue = Number(right.name);
1611
- return Number.isFinite(leftValue) && Number.isFinite(rightValue) && leftValue === rightValue;
1611
+ const text = numberTextFromDouble(finite);
1612
+ if (text == null) throw new PrologError('representation_error(max_float)');
1613
+ return text;
1612
1614
  }
1613
1615
 
1614
1616
  function numberListText(list, env, kind, valueIsBound) {
@@ -1650,7 +1652,7 @@ function numberListBuiltin(kind) {
1650
1652
  if (sameNumber(value, parsed)) yield next;
1651
1653
  return;
1652
1654
  }
1653
- const items = characters(value.name).map((ch) =>
1655
+ const items = characters(canonicalNumberText(value)).map((ch) =>
1654
1656
  kind === 'chars' ? atom(ch) : numberTerm(ch.codePointAt(0)));
1655
1657
  if (unify(goal.args[1], listFromItems(items), next)) yield next;
1656
1658
  return;
@@ -0,0 +1,28 @@
1
+ // Numeric value identity shared by term semantics and scalar indexes. Keep
2
+ // integer and float terms distinct while ignoring insignificant spelling
3
+ // differences within either ISO numeric type.
4
+ const decimalInteger = (text) => /^-?\d+$/.test(text ?? '');
5
+
6
+ const finiteFloat = (text) => {
7
+ if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(text ?? '')) return null;
8
+ const value = Number(text);
9
+ return Number.isFinite(value) ? value : null;
10
+ };
11
+
12
+ export function sameNumberValue(left, right) {
13
+ const leftInteger = decimalInteger(left);
14
+ const rightInteger = decimalInteger(right);
15
+ if (leftInteger || rightInteger) {
16
+ return leftInteger && rightInteger && BigInt(left) === BigInt(right);
17
+ }
18
+ const leftValue = finiteFloat(left);
19
+ const rightValue = finiteFloat(right);
20
+ return leftValue != null && rightValue != null && leftValue === rightValue;
21
+ }
22
+
23
+ export function numberValueKey(text) {
24
+ if (decimalInteger(text)) return `integer:${BigInt(text)}`;
25
+ const value = finiteFloat(text);
26
+ if (value == null) return `invalid:${text}`;
27
+ return `float:${Object.is(value, -0) ? 0 : value}`;
28
+ }
package/src/parser.js CHANGED
@@ -433,6 +433,18 @@ class Parser {
433
433
  this.take();
434
434
  this.take();
435
435
  let value = this.take();
436
+ if (value) {
437
+ const firstCode = value.charCodeAt(0);
438
+ if (firstCode >= 0xd800 && firstCode <= 0xdbff) {
439
+ const secondCode = this.peek().charCodeAt(0);
440
+ if (secondCode < 0xdc00 || secondCode > 0xdfff) {
441
+ throw new Error(`parse line ${line}: bad character code constant`);
442
+ }
443
+ value += this.take();
444
+ } else if (firstCode >= 0xdc00 && firstCode <= 0xdfff) {
445
+ throw new Error(`parse line ${line}: bad character code constant`);
446
+ }
447
+ }
436
448
  if (!value || (value !== ' ' && isWhitespaceCode(value.charCodeAt(0)))) {
437
449
  throw new Error(`parse line ${line}: bad character code constant`);
438
450
  }
@@ -1294,6 +1306,22 @@ export function parseProgramText(source, options = {}) {
1294
1306
  return parseClauses(source, options);
1295
1307
  }
1296
1308
 
1309
+ export function parseNumberTokenText(text) {
1310
+ const source = String(text ?? '');
1311
+ const parser = new Parser(source, {
1312
+ includeDefaultOperators: false,
1313
+ sourceMetadata: false,
1314
+ });
1315
+ // Inspect the tokenizer position before requesting another token: advancing
1316
+ // would skip trailing layout and make `3 ` or `3/**/` look complete. A
1317
+ // literal space consumed by the character-code token `0' ` is already part
1318
+ // of parser.pos and is therefore correctly accepted.
1319
+ if (parser.token.type !== TOK.NUMBER || parser.pos !== source.length) {
1320
+ throw new Error('not exactly one number token');
1321
+ }
1322
+ return numberTerm(parser.token.text);
1323
+ }
1324
+
1297
1325
  export function parseGoalText(text, options = {}) {
1298
1326
  const clauses = parseClauses(`zz_goal((${text})).`, options);
1299
1327
  const head = clauses[0]?.head;
package/src/program.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // Program representation and clause indexing.
2
2
  // Indexes are deliberately conservative: they speed up common scalar arguments but never replace unification as the final check.
3
3
  import { ATOM, COMPOUND, VAR, Env, atom, compound, deref, flattenConjunction, isScalar, numberTerm, properListItems, termToString, variable } from './term.js';
4
+ import { numberValueKey } from './number-value.js';
4
5
  import { formatTermForWrite } from './write.js';
5
6
  import {
6
7
  ISO_OPERATOR_DEFINITIONS,
@@ -158,6 +159,9 @@ export class Program {
158
159
  scalarFactsOnly: true,
159
160
  dynamic: this.dynamicPredicates.has(modulePredicateKey(module, name, arity)),
160
161
  negationStratum: null,
162
+ hasCut: false,
163
+ cutReachable: null,
164
+ bundledLibrary: false,
161
165
  };
162
166
  return group;
163
167
  }
@@ -185,6 +189,7 @@ export class Program {
185
189
  group.rejectedDemandIndexes.clear();
186
190
  }
187
191
  group.clauses.push(clause);
192
+ if (clauseHasCut(clause)) group.hasCut = true;
188
193
  const clausePosition = group.clauses.length - 1;
189
194
  for (let i = 0; i < head.arity; i++) indexOne(group.argIndexes[i], head.args[i], clause, group.clauses, clausePosition);
190
195
  }
@@ -291,6 +296,7 @@ export class Program {
291
296
  const groups = [...this.groups.values()];
292
297
  const indexByGroup = new Map(groups.map((group, i) => [group, i]));
293
298
  const deps = groups.map(() => new Set());
299
+ const cutDeps = groups.map(() => new Set());
294
300
  const negativeEdges = [];
295
301
  for (const group of groups) {
296
302
  const groupIndex = indexByGroup.get(group);
@@ -298,11 +304,18 @@ export class Program {
298
304
  if (isCompactBinaryClause(clause)) {
299
305
  if (clause.bodyName != null) {
300
306
  const dep = this.findGroup(clause.bodyName, 2, group.module);
301
- if (dep) deps[groupIndex].add(indexByGroup.get(dep));
307
+ if (dep) {
308
+ deps[groupIndex].add(indexByGroup.get(dep));
309
+ cutDeps[groupIndex].add(indexByGroup.get(dep));
310
+ }
302
311
  }
303
312
  continue;
304
313
  }
305
314
  for (const goal of clause.body) {
315
+ for (const dependency of collectGoalDependencies(goal, false, true)) {
316
+ const dep = this.findGroup(dependency.name, dependency.arity, dependency.module ?? group.module);
317
+ if (dep) cutDeps[groupIndex].add(indexByGroup.get(dep));
318
+ }
306
319
  const directKey = directGoalDependencyKey(goal);
307
320
  if (directKey) {
308
321
  const dep = this.findGroup(goal.name, goal.arity, goal.module ?? group.module);
@@ -324,6 +337,8 @@ export class Program {
324
337
  const start = indexByGroup.get(group);
325
338
  const standardLibraryModule = group.module !== 'user' &&
326
339
  this.modules.get(group.module)?.filename?.startsWith('src/lib/');
340
+ group.bundledLibrary = standardLibraryModule === true;
341
+ group.cutReachable = [...reachableIndexes(start, cutDeps)].some((index) => groups[index].hasCut);
327
342
  const seen = new Set();
328
343
  const stack = [start];
329
344
  let recursive = false;
@@ -532,6 +547,7 @@ class ProgramBuilder {
532
547
  group.clauses.push(clause);
533
548
  clause.groundHead = termHasNoVariables(head);
534
549
  clause.scalarHead = head.type === COMPOUND && head.args.every(isScalar);
550
+ if (clauseHasCut(clause)) group.hasCut = true;
535
551
  if (clause.body.length !== 0 || !clause.scalarHead) group.scalarFactsOnly = false;
536
552
  for (let i = 0; i < head.arity; i++) {
537
553
  indexOne(group.argIndexes[i], head.args[i], clause, group.clauses, clausePosition);
@@ -1113,33 +1129,41 @@ function directGoalDependencyKey(goal) {
1113
1129
  return `${goal.name}/${goal.arity}`;
1114
1130
  }
1115
1131
 
1116
- function collectGoalDependencies(goal, negated) {
1132
+ function collectGoalDependencies(goal, negated, traverseConditionals = false) {
1117
1133
  if (goal.type === ATOM) return [{ key: `${goal.name}/0`, name: goal.name, arity: 0, module: goal.module, negative: negated }];
1118
1134
  if (goal.type !== COMPOUND) return [];
1119
1135
  if (goal.name === ',' && goal.arity === 2) {
1120
1136
  return [
1121
- ...collectGoalDependencies(goal.args[0], negated),
1122
- ...collectGoalDependencies(goal.args[1], negated),
1137
+ ...collectGoalDependencies(goal.args[0], negated, traverseConditionals),
1138
+ ...collectGoalDependencies(goal.args[1], negated, traverseConditionals),
1139
+ ];
1140
+ }
1141
+ if (traverseConditionals && (goal.name === ';' || goal.name === '->') && goal.arity === 2) {
1142
+ return [
1143
+ ...collectGoalDependencies(goal.args[0], negated, true),
1144
+ ...collectGoalDependencies(goal.args[1], negated, true),
1123
1145
  ];
1124
1146
  }
1125
1147
  if ((goal.name === '\\+' || goal.name === 'not') && goal.arity === 1) {
1126
- return collectGoalDependencies(goal.args[0], !negated);
1148
+ return collectGoalDependencies(goal.args[0], !negated, traverseConditionals);
1127
1149
  }
1128
1150
  if (goal.name === 'once' && goal.arity === 1) {
1129
- return collectGoalDependencies(goal.args[0], negated);
1151
+ return collectGoalDependencies(goal.args[0], negated, traverseConditionals);
1130
1152
  }
1131
1153
  if (goal.name === 'forall' && goal.arity === 2) {
1132
1154
  return [
1133
- ...collectGoalDependencies(goal.args[0], negated),
1134
- ...collectGoalDependencies(goal.args[1], negated),
1155
+ ...collectGoalDependencies(goal.args[0], negated, traverseConditionals),
1156
+ ...collectGoalDependencies(goal.args[1], negated, traverseConditionals),
1135
1157
  ];
1136
1158
  }
1137
1159
  if ((goal.name === 'findall' || goal.name === 'sumall') && goal.arity === 3) {
1138
- return collectGoalDependencies(goal.args[1], negated);
1160
+ return collectGoalDependencies(goal.args[1], negated, traverseConditionals);
1161
+ }
1162
+ if (goal.name === 'countall' && goal.arity === 2) {
1163
+ return collectGoalDependencies(goal.args[0], negated, traverseConditionals);
1139
1164
  }
1140
- if (goal.name === 'countall' && goal.arity === 2) return collectGoalDependencies(goal.args[0], negated);
1141
1165
  if ((goal.name === 'aggregate_min' || goal.name === 'aggregate_max') && goal.arity === 5) {
1142
- return collectGoalDependencies(goal.args[2], negated);
1166
+ return collectGoalDependencies(goal.args[2], negated, traverseConditionals);
1143
1167
  }
1144
1168
  return [{ key: `${goal.name}/${goal.arity}`, name: goal.name, arity: goal.arity, module: goal.module, negative: negated }];
1145
1169
  }
@@ -1238,15 +1262,19 @@ function scalarBuckets(index, term) {
1238
1262
  }
1239
1263
 
1240
1264
  function argumentBucket(index, term) {
1241
- return scalarBuckets(index, term).get(term.name) ?? null;
1265
+ return scalarBuckets(index, term).get(scalarBucketKey(term.type, term.name)) ?? null;
1242
1266
  }
1243
1267
 
1244
1268
  function addArgumentBucket(index, term, clause) {
1245
- addClauseBucket(scalarBuckets(index, term), term.name, clause);
1269
+ addClauseBucket(scalarBuckets(index, term), scalarBucketKey(term.type, term.name), clause);
1246
1270
  }
1247
1271
 
1248
1272
  function scalarIndexKey(term) {
1249
- return `${term.type}\u0000${term.name}`;
1273
+ return `${term.type}\u0000${scalarBucketKey(term.type, term.name)}`;
1274
+ }
1275
+
1276
+ function scalarBucketKey(type, name) {
1277
+ return type === 'number' ? numberValueKey(name) : name;
1250
1278
  }
1251
1279
 
1252
1280
  function addClauseBucket(buckets, key, clause) {
@@ -1276,7 +1304,7 @@ function indexCompactOne(index, type, name, clause, clauses = null, clausePositi
1276
1304
  index.sawScalar = true;
1277
1305
  if (clauses && clausePosition > 0) index.fallback = clauses.slice(0, clausePosition);
1278
1306
  }
1279
- addClauseBucket(compactScalarBuckets(index, type), name, clause);
1307
+ addClauseBucket(compactScalarBuckets(index, type), scalarBucketKey(type, name), clause);
1280
1308
  } else if (index.sawScalar) {
1281
1309
  index.fallback.push(clause);
1282
1310
  }
@@ -1303,6 +1331,7 @@ function rebuildGroupIndexes(group) {
1303
1331
  group.demandIndexes.clear();
1304
1332
  group.rejectedDemandIndexes.clear();
1305
1333
  group.scalarFactsOnly = true;
1334
+ group.hasCut = false;
1306
1335
  for (let clausePosition = 0; clausePosition < group.clauses.length; clausePosition++) {
1307
1336
  const clause = group.clauses[clausePosition];
1308
1337
  if (isCompactBinaryClause(clause)) {
@@ -1315,6 +1344,7 @@ function rebuildGroupIndexes(group) {
1315
1344
  }
1316
1345
  clause.groundHead = termHasNoVariables(clause.head);
1317
1346
  clause.scalarHead = clause.head.type === COMPOUND && clause.head.args.every(isScalar);
1347
+ if (clauseHasCut(clause)) group.hasCut = true;
1318
1348
  if (clause.body.length !== 0 || !clause.scalarHead) group.scalarFactsOnly = false;
1319
1349
  for (let i = 0; i < group.arity; i++) indexOne(group.argIndexes[i], clause.head.args[i], clause, group.clauses, clausePosition);
1320
1350
  }
package/src/solver.js CHANGED
@@ -1,9 +1,11 @@
1
1
  // Depth-first EyeProlog solver with builtin dispatch, memoization, and guarded recursion handling.
2
2
  // Most semantic decisions still flow through unification; optimizations only select candidates earlier.
3
3
  import {
4
- COMPOUND, Env, compound, copyResolved, deref, emptyList, flattenConjunction, freshTerm,
5
- numberTerm, numberTextFromDouble, termIsGround, termToString, unify, variantTerms,
4
+ COMPOUND, NUMBER, VAR, Env, compound, cons, copyResolved, deref, emptyList,
5
+ flattenConjunction, freshTerm, isCons, isDecimalInteger, isEmptyList,
6
+ numberTerm, numberTextFromDouble, termIsGround, termToString, unify, variable, variantTerms,
6
7
  } from './term.js';
8
+ import { sameNumberValue } from './number-value.js';
7
9
  import { PrologError, getStrictIsoRegistry } from './iso.js';
8
10
  import { getEyePrologRegistry } from './standard-library.js';
9
11
  import { selectClauseCandidates, selectClauseCandidatesForValues, selectGroundClauseCandidates } from './program.js';
@@ -366,6 +368,23 @@ export class Solver {
366
368
  }
367
369
  qualifyMetaArguments(goal, group);
368
370
 
371
+ const lengthIterator = prologueLengthIterator(this, group, goal, env);
372
+ if (lengthIterator != null) {
373
+ const firstResult = lengthIterator.next();
374
+ if (firstResult.done) break;
375
+ stack.push({
376
+ kind: 'resumeBuiltin',
377
+ iterator: lengthIterator,
378
+ goals: rest,
379
+ depth: depth + 1,
380
+ active,
381
+ });
382
+ goals = rest;
383
+ env = firstResult.value;
384
+ depth++;
385
+ continue;
386
+ }
387
+
369
388
  if (group.tabled) {
370
389
  const key = memoKey(goal, env, group);
371
390
  if (key.hasBound) {
@@ -417,8 +436,8 @@ export class Solver {
417
436
  return activeVariantIn(goal, env, this.active);
418
437
  }
419
438
 
420
- checkMemoryLimit() {
421
- if (this.inferences < this.nextMemoryCheck) return;
439
+ checkMemoryLimit(force = false) {
440
+ if (!force && this.inferences < this.nextMemoryCheck) return;
422
441
  this.nextMemoryCheck = this.inferences + 256;
423
442
  if (!Number.isFinite(this.maxMemoryBytes)) return;
424
443
  const used = usedHeapSize();
@@ -476,6 +495,12 @@ export class Solver {
476
495
  if (!unify(goal, freshHead, next)) continue;
477
496
  if (freshBody.length === 0) {
478
497
  yield* this.solve(rest, next, depth + 1);
498
+ } else if (!groupNeedsActiveFrame(group)) {
499
+ for (const bodyEnv of this.solve(freshBody, next, depth + 1)) {
500
+ if (this.solutionsSeen > 0) this.solutionsSeen--;
501
+ yield* this.solve(rest, bodyEnv, depth + 1);
502
+ if (this.solutionsSeen >= this.solutionLimit) break;
503
+ }
479
504
  } else {
480
505
  yield* this.solveRuleBodyThenRest(goal, env, freshBody, rest, next, depth);
481
506
  }
@@ -612,7 +637,11 @@ function pushUserGoalUncachedFrames(stack, solver, group, goal, rest, env, depth
612
637
  const candidates = selectClauseCandidates(group, goal, env);
613
638
  const frames = [];
614
639
  const invocation = { goal, env };
615
- const guarded = !group.linearNumeric;
640
+ // Active frames serve two purposes: they delimit cut and detect variants in
641
+ // recursive user predicates. Cut-free, non-recursive library helpers need
642
+ // neither. Copying their full active stack at every recursive step made
643
+ // otherwise linear relations such as length/2 retain O(depth^2) references.
644
+ const guarded = groupNeedsActiveFrame(group);
616
645
  const release = guarded ? [{ kind: 'releaseActive' }] : [];
617
646
  const nextActive = guarded ? [...active, invocation] : active;
618
647
  for (const pass of [candidates.primary, candidates.fallback]) {
@@ -662,6 +691,141 @@ function pushUserGoalUncachedFrames(stack, solver, group, goal, rest, env, depth
662
691
  for (let i = frames.length - 1; i >= 0; i--) stack.push(frames[i]);
663
692
  }
664
693
 
694
+ function groupNeedsActiveFrame(group) {
695
+ // User code may observe the surrounding control context through later cuts,
696
+ // so only apply this planning shortcut to the fixed bundled-library graph.
697
+ if (group.bundledLibrary !== true) return true;
698
+ // A frame is also required above a cut-bearing callee. The disjunction
699
+ // builtin uses the caller marker to distinguish a callee-local cut from a
700
+ // cut in its own branch. null means dependency analysis was intentionally
701
+ // disabled (strict mode or a newly mutated group), so remain conservative.
702
+ return group.cutReachable !== false || (group.recursive && !group.linearNumeric);
703
+ }
704
+
705
+ function prologueLengthIterator(solver, group, goal, env) {
706
+ if (solver.registry.eyePrologLibrary !== true ||
707
+ group.module !== 'prologue' || group.name !== 'length' || group.arity !== 2 ||
708
+ group.bundledLibrary !== true || group.clauses.length !== 2) {
709
+ return null;
710
+ }
711
+
712
+ // Delayed and constrained variables need the ordinary solver's wake-up
713
+ // points. The fast path is deliberately limited to plain finite-tree terms.
714
+ if (env._clpz != null) return null;
715
+ const length = deref(goal.args[1], env);
716
+ if (length.type === VAR && env._delays?.has(length.name)) return null;
717
+
718
+ let cursor = deref(goal.args[0], env);
719
+ while (isCons(cursor)) {
720
+ cursor = deref(cursor.args[1], env);
721
+ }
722
+ if (cursor.type === VAR) {
723
+ if (env._delays?.has(cursor.name)) return null;
724
+ if (length.type === VAR && cursor.name === length.name) return null;
725
+ }
726
+ return prologueLengthSolutions(solver, goal, env);
727
+ }
728
+
729
+ function* prologueLengthSolutions(solver, goal, env) {
730
+ const requestedLength = deref(goal.args[1], env);
731
+ if (requestedLength.type !== VAR) {
732
+ if (requestedLength.type !== NUMBER || !isDecimalInteger(requestedLength.name)) {
733
+ throw new PrologError('type_error(integer)', requestedLength);
734
+ }
735
+ const length = BigInt(requestedLength.name);
736
+ if (length < 0n) throw new PrologError('domain_error(not_less_than_zero)', requestedLength);
737
+ yield* fixedLengthSolutions(solver, goal.args[0], length, env);
738
+ return;
739
+ }
740
+
741
+ yield* generatedLengthSolutions(solver, goal.args[0], goal.args[1], env);
742
+ }
743
+
744
+ function* fixedLengthSolutions(solver, list, length, env) {
745
+ let cursor = deref(list, env);
746
+ let remaining = length;
747
+ let steps = 0n;
748
+ while (isCons(cursor)) {
749
+ if (remaining === 0n) return;
750
+ remaining--;
751
+ cursor = deref(cursor.args[1], env);
752
+ lengthAllocationCheckpoint(solver, ++steps);
753
+ }
754
+ if (isEmptyList(cursor)) {
755
+ if (remaining === 0n) yield env;
756
+ return;
757
+ }
758
+ if (cursor.type !== VAR) return;
759
+
760
+ // A source-level anonymous variable occurs nowhere else, so materializing
761
+ // its list cannot affect any subsequent goal or answer substitution.
762
+ if (isAnonymousVariable(cursor)) {
763
+ yield env;
764
+ return;
765
+ }
766
+
767
+ const id = nextFreshId();
768
+ let suffix = emptyList();
769
+ for (let index = 0n; index < remaining; index++) {
770
+ suffix = cons(variable(`__length${id}_${index}`), suffix);
771
+ lengthAllocationCheckpoint(solver, ++steps);
772
+ }
773
+ const next = env.clone();
774
+ solver.stats.unify_calls++;
775
+ if (unify(cursor, suffix, next)) yield next;
776
+ }
777
+
778
+ function* generatedLengthSolutions(solver, list, length, env) {
779
+ let cursor = deref(list, env);
780
+ let count = 0n;
781
+ let steps = 0n;
782
+ while (isCons(cursor)) {
783
+ count++;
784
+ cursor = deref(cursor.args[1], env);
785
+ lengthAllocationCheckpoint(solver, ++steps);
786
+ }
787
+ if (isEmptyList(cursor)) {
788
+ const next = bindGeneratedLength(solver, length, count, env);
789
+ if (next != null) yield next;
790
+ return;
791
+ }
792
+ if (cursor.type !== VAR) return;
793
+
794
+ if (isAnonymousVariable(cursor)) {
795
+ for (let value = count; ; value++) {
796
+ const next = bindGeneratedLength(solver, length, value, env);
797
+ if (next != null) yield next;
798
+ }
799
+ }
800
+
801
+ const id = nextFreshId();
802
+ let suffix = emptyList();
803
+ for (let extra = 0n; ; extra++) {
804
+ const next = env.clone();
805
+ solver.stats.unify_calls++;
806
+ if (unify(cursor, suffix, next)) {
807
+ const answer = bindGeneratedLength(solver, length, count + extra, next);
808
+ if (answer != null) yield answer;
809
+ }
810
+ suffix = cons(variable(`__length${id}_${extra}`), suffix);
811
+ lengthAllocationCheckpoint(solver, ++steps);
812
+ }
813
+ }
814
+
815
+ function bindGeneratedLength(solver, length, value, env) {
816
+ const next = env.clone();
817
+ solver.stats.unify_calls++;
818
+ return unify(length, numberTerm(value), next) ? next : null;
819
+ }
820
+
821
+ function isAnonymousVariable(term) {
822
+ return term.type === VAR && term.name.startsWith('__anon');
823
+ }
824
+
825
+ function lengthAllocationCheckpoint(solver, steps) {
826
+ if ((steps & 255n) === 0n) solver.checkMemoryLimit(true);
827
+ }
828
+
665
829
  function pushFastPiFrames(stack, goal, rest, env, depth, active) {
666
830
  const values = goal.args.map((arg) => deref(arg, env));
667
831
  if ([0, 1, 2, 4].some((index) => values[index].type !== 'number')) return false;
@@ -1089,11 +1253,13 @@ function isScalarTerm(term) {
1089
1253
  }
1090
1254
 
1091
1255
  function sameScalarTerm(left, right) {
1092
- return isScalarTerm(left) && isScalarTerm(right) && left.type === right.type && left.name === right.name;
1256
+ return isScalarTerm(left) && isScalarTerm(right) && left.type === right.type &&
1257
+ (left.type === 'number' ? sameNumberValue(left.name, right.name) : left.name === right.name);
1093
1258
  }
1094
1259
 
1095
1260
  function sameGroundTerm(left, right) {
1096
- if (left?.type !== right?.type || left?.name !== right?.name) return false;
1261
+ if (left?.type !== right?.type) return false;
1262
+ if (left?.type === 'number' ? !sameNumberValue(left.name, right.name) : left?.name !== right?.name) return false;
1097
1263
  const arity = left.args?.length ?? 0;
1098
1264
  if (arity !== (right.args?.length ?? 0)) return false;
1099
1265
  for (let i = 0; i < arity; i++) if (!sameGroundTerm(left.args[i], right.args[i])) return false;
package/src/term.js CHANGED
@@ -1,5 +1,7 @@
1
1
  // Term model, environments, unification, readback, and ordering helpers.
2
- // This file is intentionally dependency-free because nearly every other module imports it.
2
+ // Keep dependencies minimal because nearly every other module imports this file.
3
+ import { sameNumberValue } from './number-value.js';
4
+
3
5
  export const VAR = 'var';
4
6
  export const ATOM = 'atom';
5
7
  export const STRING = 'string';
@@ -247,7 +249,7 @@ export function unify(left, right, env, options = {}) {
247
249
  }
248
250
 
249
251
  if (isScalar(a)) {
250
- if (a.name !== b.name) return false;
252
+ if (a.type === NUMBER ? !sameNumberValue(a.name, b.name) : a.name !== b.name) return false;
251
253
  continue;
252
254
  }
253
255
 
@@ -497,7 +499,8 @@ export function variantTerms(left, leftEnv, right, rightEnv, pairs = new Map(),
497
499
  reverse.set(right.name, left.name);
498
500
  return true;
499
501
  }
500
- if (left.type !== right.type || left.name !== right.name || left.arity !== right.arity) return false;
502
+ if (left.type !== right.type || left.arity !== right.arity) return false;
503
+ if (left.type === NUMBER ? !sameNumberValue(left.name, right.name) : left.name !== right.name) return false;
501
504
  for (let i = 0; i < left.arity; i++) {
502
505
  if (!variantTerms(left.args[i], leftEnv, right.args[i], rightEnv, pairs, reverse)) return false;
503
506
  }
@@ -567,6 +567,63 @@ c4 ?- call((!;1)).
567
567
  }
568
568
  },
569
569
  },
570
+ {
571
+ name: 'number conversion accepts ISO space and Unicode character-code constants exactly',
572
+ run: () => {
573
+ const source = String.raw`
574
+ ?- N = 0' .
575
+ N = 32.
576
+ ?- number_chars(N,"0' ").
577
+ N = 32.
578
+ ?- number_codes(N,[48,39,32]).
579
+ N = 32.
580
+ ?- N = 0'😀.
581
+ N = 128512.
582
+ ?- number_chars(N,"0'😀").
583
+ N = 128512.
584
+ ?- number_codes(N,[48,39,128512]).
585
+ N = 128512.
586
+ ?- number_codes(N,[48,39,34]).
587
+ N = 34.
588
+ ?- number_codes(N,[48,39,96]).
589
+ N = 96.
590
+ ?- number_codes(N,[48,39,92,92]).
591
+ N = 92.
592
+ ?- number_chars(01,Chars).
593
+ Chars = "1".
594
+ ?- number_codes(01,Codes).
595
+ Codes = [49].
596
+ ?- 1.2 = 1.20.
597
+ true.
598
+ ?- 1.2 == 1.20.
599
+ true.
600
+ ?- 1 = 1.0.
601
+ false.
602
+ ?- number_chars(1.20,C), number_chars(Y,C), 1.20 == Y.
603
+ C = "1.20", Y = 1.2.
604
+ `;
605
+ const result = publicApi.runQuads(source);
606
+ assertEqual(result.total, 15, 'quad total');
607
+ assertEqual(result.passed, 15, 'quad passed');
608
+ assertEqual(result.stdout, 'quads: 15 run, 15 passed, 0 failed.\n', 'quad report');
609
+
610
+ for (const goal of [
611
+ 'number_chars(N,"3/**/")',
612
+ 'number_codes(N,[51,47,42,42,47])',
613
+ 'number_chars(N,"0\' ")',
614
+ 'number_codes(N,[48,39,32,32])',
615
+ ]) {
616
+ let caught = null;
617
+ try {
618
+ publicApi.run('', { goal });
619
+ } catch (error) {
620
+ caught = error;
621
+ }
622
+ if (caught == null) throw new Error(`${goal} should reject trailing layout`);
623
+ assertIncludes(String(caught?.message ?? caught), 'syntax_error(number)', goal);
624
+ }
625
+ },
626
+ },
570
627
  {
571
628
  name: 'number conversion rejects parenthesized numeric terms',
572
629
  run: () => {
@@ -624,6 +681,21 @@ c4 ?- call((!;1)).
624
681
  assertEqual(result.stderr, '', 'quad stderr');
625
682
  },
626
683
  },
684
+ {
685
+ name: 'REPL advances anonymous Prologue length checks through I = 28',
686
+ run: () => {
687
+ const result = runCli([], {
688
+ input:
689
+ 'use_module(library(prologue)).\n' +
690
+ 'length(_,I),I>9,N is 2^I,\\+ \\+ length(_,N).\n' +
691
+ 'f\nf\nf\n;\n;\n;\n;\n\nhalt.\n',
692
+ });
693
+ assertEqual(result.status, 0, 'exit status');
694
+ assertIncludes(result.stdout, '; I = 28, N = 268435456\n; ... .\n?- ', 'large anonymous length answer');
695
+ assertNotIncludes(result.stdout, 'resource_error(memory)', 'stdout');
696
+ assertEqual(result.stderr, '', 'stderr');
697
+ },
698
+ },
627
699
  {
628
700
  name: 'Prologue freeze wakes delayed goals with their bindings',
629
701
  run: () => {
@@ -2415,14 +2487,14 @@ open(X) :- candidate(X), \\+ closed(X).
2415
2487
  },
2416
2488
  },
2417
2489
  {
2418
- name: 'list allocation heap pressure becomes resource_error(memory)',
2490
+ name: 'named list allocation heap pressure becomes resource_error(memory)',
2419
2491
  run: () => {
2420
2492
  const engineUrl = new URL('../src/index.js', import.meta.url).href;
2421
2493
  const programText = ':- use_module(library(prologue)).\n';
2422
- const goalText = 'length(_, I), I > 9, N is 2^I, \\+ \\+ length(_, N)';
2494
+ const goalText = '\\+ \\+ length(List, 1000000)';
2423
2495
  const script = `
2424
2496
  import { Program, Solver, Env, parseGoalText, getEyePrologRegistry } from ${JSON.stringify(engineUrl)};
2425
- const program = Program.parse(${JSON.stringify(programText)});
2497
+ const program = Program.parse(${JSON.stringify(programText)}, { sourceMetadata: false });
2426
2498
  const solver = new Solver(program, { registry: getEyePrologRegistry() });
2427
2499
  const goal = parseGoalText(${JSON.stringify(goalText)}, {
2428
2500
  operatorDefinitions: [...program.operators.values()],
@@ -2443,6 +2515,37 @@ open(X) :- candidate(X), \\+ closed(X).
2443
2515
  assertEqual(result.stdout, 'resource_error(memory)', 'heap pressure resource error');
2444
2516
  },
2445
2517
  },
2518
+ {
2519
+ name: 'anonymous Prologue length checks avoid materializing discarded lists',
2520
+ run: () => {
2521
+ const engineUrl = new URL('../src/index.js', import.meta.url).href;
2522
+ const programText = ':- use_module(library(prologue)).\n';
2523
+ const goalText = 'length(_, I), I > 9, N is 2^I, \\+ \\+ length(_, N)';
2524
+ const script = `
2525
+ import { Program, Solver, Env, parseGoalText, getEyePrologRegistry } from ${JSON.stringify(engineUrl)};
2526
+ const program = Program.parse(${JSON.stringify(programText)}, { sourceMetadata: false });
2527
+ const solver = new Solver(program, {
2528
+ registry: getEyePrologRegistry(),
2529
+ solutionLimit: 19,
2530
+ });
2531
+ const goal = parseGoalText(${JSON.stringify(goalText)}, {
2532
+ operatorDefinitions: [...program.operators.values()],
2533
+ });
2534
+ let answers = 0;
2535
+ for (const _ of solver.solve([goal], new Env(), 0)) answers++;
2536
+ process.stdout.write(String(answers));
2537
+ `;
2538
+ const result = spawnSync(process.execPath, [
2539
+ '--max-old-space-size=64',
2540
+ '--input-type=module',
2541
+ '--eval',
2542
+ script,
2543
+ ], { cwd: packageRoot, encoding: 'utf8', timeout: 30000 });
2544
+ if (result.error) throw result.error;
2545
+ assertEqual(result.status, 0, `bounded-heap child status; stderr=${result.stderr}`);
2546
+ assertEqual(result.stdout, '19', 'answers through I = 28');
2547
+ },
2548
+ },
2446
2549
  {
2447
2550
  name: 'solver honors solution limits',
2448
2551
  run: () => {
@@ -1843,6 +1843,17 @@ ISO 13211-1 leaves the resource atom implementation dependent. EyeProlog uses
1843
1843
  `finite_memory` spelling for the distinct convention where no finite amount of
1844
1844
  memory could complete the computation.
1845
1845
 
1846
+ The iterative solver keeps active-call frames only where they are semantically
1847
+ needed for cut scope or recursive variant guards. Bundled-library helpers whose
1848
+ callable dependency region is cut-free and which need no recursive variant
1849
+ guard therefore do not copy a growing active-call sequence at every step.
1850
+ Under the normal EyeProlog registry, the bundled Prologue `length/2` also has a
1851
+ scoped iterative execution path: named lists are counted or constructed without
1852
+ recursive interpreter frames, and an anonymous list is not materialized because
1853
+ its binding cannot be observed. The ordinary clauses remain the authoritative
1854
+ module definition and are used unchanged by the ISO-only registry and whenever
1855
+ delays or finite-domain constraints require their normal wake-up points.
1856
+
1846
1857
  ### Implementation boundary
1847
1858
 
1848
1859
  The source layout mirrors the language boundary. `src/iso.js` contains the
@@ -5777,7 +5788,13 @@ therefore follow `-` directly because `%` cannot continue a graphic token; an
5777
5788
  adjacent bracketed comment in `-/**/1` remains a syntax error under the eager
5778
5789
  token-consumer rule. Decimal fractions and decimal exponents are supported;
5779
5790
  the apostrophe character code is written with a doubled apostrophe as `0'''`
5780
- and has value 39. Trailing material and non-finite values are rejected. The
5791
+ and has value 39, while a literal space character code is `0' ` and has value
5792
+ 32. Character-code constants consume one Unicode scalar, including a
5793
+ non-BMP character. Trailing layout, comments, and other material are rejected;
5794
+ bound integers are converted to their canonical decimal spelling, and
5795
+ non-finite values are rejected. Equivalent spellings of the same numeric type
5796
+ compare by value, preserving the standard conversion round trip, while integer
5797
+ and floating-point terms remain distinct. The
5781
5798
  regression gate vendors all 74 numbered cases from Ulrich Neumerkel's contemporary
5782
5799
  `number_chars/2` comparison, including the Cor.2 error-precedence cases;
5783
5800
  `number_codes/2` shares the same numeric parser and has mirrored coverage for
@@ -5912,8 +5929,8 @@ so side effects occur in Prolog execution order.
5912
5929
  ### The EyeProlog library
5913
5930
 
5914
5931
  EyeProlog exposes **99 library predicate indicators** in addition to the 129
5915
- indicators in its isolated ISO profile. **60 are implemented entirely as
5916
- ordinary Prolog clauses** in focused modules under `src/lib/`. The remaining 39
5932
+ indicators in its isolated ISO profile. **60 are defined as ordinary Prolog
5933
+ clauses** in focused modules under `src/lib/`. The remaining 39
5917
5934
  are public wrappers around backtrackable host support: Prologue `call_nth/2` and
5918
5935
  `freeze/2`, plus the 37-predicate finite-domain `library(clpz)` kernel. The
5919
5936
  resulting normal EyeProlog language surface is therefore **228 public predicate