eyeprolog 1.3.84 → 1.3.85

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.3.84",
6
+ "version": "1.3.85",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
package/src/cleanup.js CHANGED
@@ -237,10 +237,11 @@ function cleanupProtectedIterator(solver, protectedGoal, cleanup, initialEnv) {
237
237
  let unwindToSetupBindings = false;
238
238
 
239
239
  const performCleanup = () => {
240
- if (cleaned) return;
240
+ if (cleaned) return cleanupEnv;
241
241
  // Mark first so a throwing Cleanup is still considered executed exactly once.
242
242
  cleaned = true;
243
- runCleanupOnce(solver, cleanup, cleanupEnv);
243
+ cleanupEnv = runCleanupOnce(solver, cleanup, cleanupEnv);
244
+ return cleanupEnv;
244
245
  };
245
246
 
246
247
  const iterator = (function* protectedWithCleanup() {
@@ -266,7 +267,7 @@ function cleanupProtectedIterator(solver, protectedGoal, cleanup, initialEnv) {
266
267
  performCleanup();
267
268
  }
268
269
 
269
- yield result.value;
270
+ yield pending ? result.value : cleanupEnv;
270
271
  if (!pending) return;
271
272
  }
272
273
  } catch (error) {
@@ -321,8 +322,9 @@ function runCleanupOnce(solver, cleanup, env) {
321
322
  solver.absorbStatsFrom(child);
322
323
  }
323
324
  }
324
- // Cleanup is semidet only as a control operation: failure is ignored, and
325
- // bindings made solely by Cleanup are intentionally not added to an answer
326
- // already produced by Goal.
327
- return !result.done;
325
+ // Cleanup failure is ignored. When Cleanup runs before a deterministic
326
+ // protected answer is yielded, however, its substitutions are part of that
327
+ // answer; callers that prune a pending answer have already observed it and
328
+ // therefore cannot receive later Cleanup substitutions.
329
+ return result.done ? env : result.value;
328
330
  }
package/src/iso.js CHANGED
@@ -1555,6 +1555,24 @@ function scopeReadTerm(term) {
1555
1555
 
1556
1556
  function parseReadTermText(text, solver) {
1557
1557
  const converted = convertedTermText(text, solver);
1558
+ // A standalone number needs no general term parser or operator tables.
1559
+ // Reuse the same bounded numeric scanner as number_chars/2 so stream reads
1560
+ // and number conversion agree on every accepted numeric token. The stream
1561
+ // scanner has already established that the final full stop is a candidate
1562
+ // terminator; trim only ISO layout immediately before that terminator.
1563
+ const numericText = converted.slice(0, -1).replace(/[\u0009-\u000d\u0020]+$/, '');
1564
+ let numericTerm;
1565
+ try {
1566
+ numericTerm = parseIsoNumber(numericText);
1567
+ } catch (error) {
1568
+ // parseIsoNumber/1 exposes Prolog errors to number_chars/2. The stream
1569
+ // reader's candidate loop instead recognizes parser representation errors
1570
+ // and must not reinterpret overflow as a later syntax error.
1571
+ if (error instanceof PrologError) throw new NumberRepresentationError(error.formal);
1572
+ throw error;
1573
+ }
1574
+ if (numericTerm != null) return numericTerm;
1575
+
1558
1576
  const operatorState = createParserOperatorState(solver.program.operators.values(), false);
1559
1577
  return parseTermText(converted, {
1560
1578
  operatorState,
@@ -1720,6 +1738,15 @@ function writeOptionBoolean(value, env, option) {
1720
1738
  return value.name === 'true';
1721
1739
  }
1722
1740
 
1741
+ function writeOptionSpacing(value, env, option) {
1742
+ value = deref(value, env);
1743
+ if (value.type === VAR) throw new PrologError('instantiation_error');
1744
+ if (value.type !== ATOM || !['minimal', 'standard'].includes(value.name)) {
1745
+ throw new PrologError('domain_error(write_option)', copyResolved(option, env));
1746
+ }
1747
+ return value.name;
1748
+ }
1749
+
1723
1750
  function writeVariableNames(value, env, option) {
1724
1751
  value = deref(value, env);
1725
1752
  if (value.type === VAR) throw new PrologError('instantiation_error');
@@ -1760,6 +1787,8 @@ function termWriteOptionsFromItems(options, env, mode = 'write_term', solver = n
1760
1787
  else if (option.name === 'variable_names') result.variableNames = writeVariableNames(option.args[0], env, option);
1761
1788
  else if (option.name === 'double_quotes' && !solver?.isoStrict) {
1762
1789
  result.doubleQuotes = writeOptionBoolean(option.args[0], env, option);
1790
+ } else if (option.name === 'spacing' && !solver?.isoStrict) {
1791
+ result.minimalOperatorSpacing = writeOptionSpacing(option.args[0], env, option) === 'minimal';
1763
1792
  } else {
1764
1793
  throw new PrologError('domain_error(write_option)', copyResolved(option, env));
1765
1794
  }
package/src/solver.js CHANGED
@@ -1495,12 +1495,29 @@ function* bundledMemberSolutions(solver, goal, env, state) {
1495
1495
 
1496
1496
  function bundledMemberMayMatch(target, cursor, env) {
1497
1497
  while (isCons(cursor)) {
1498
- if (!goalHeadTermsCannotMatch(target, cursor.args[0], env)) return true;
1498
+ if (!termsCannotUnify(target, cursor.args[0], env)) return true;
1499
1499
  cursor = deref(cursor.args[1], env);
1500
1500
  }
1501
1501
  return false;
1502
1502
  }
1503
1503
 
1504
+ function termsCannotUnify(left, right, env) {
1505
+ const pending = [[left, right]];
1506
+ while (pending.length > 0) {
1507
+ const [leftItem, rightItem] = pending.pop();
1508
+ const actualLeft = derefForLocal(leftItem, env);
1509
+ const actualRight = derefForLocal(rightItem, env);
1510
+ if (actualLeft.type === VAR || actualRight.type === VAR) continue;
1511
+ if (actualLeft.type !== actualRight.type || actualLeft.name !== actualRight.name ||
1512
+ actualLeft.arity !== actualRight.arity) return true;
1513
+ if (actualLeft.type !== COMPOUND) continue;
1514
+ for (let index = 0; index < actualLeft.arity; index++) {
1515
+ pending.push([actualLeft.args[index], actualRight.args[index]]);
1516
+ }
1517
+ }
1518
+ return false;
1519
+ }
1520
+
1504
1521
  function bundledEllipsisPlan(solver, group, goal, rest, env) {
1505
1522
  if (solver.registry.eyePrologLibrary !== true ||
1506
1523
  group.module !== 'iso_ext' || group.name !== '...' || group.arity !== 2 ||
@@ -1613,7 +1630,13 @@ function bundledLengthIterator(solver, group, goal, env) {
1613
1630
  if (length.type === VAR && cursor.name === length.name) return null;
1614
1631
  }
1615
1632
  }
1616
- return bundledLengthSolutions(solver, goal, env);
1633
+ const iterator = bundledLengthSolutions(solver, goal, env);
1634
+ // A supplied length selects at most one list, and a closed supplied list has
1635
+ // exactly one length. Only an open list paired with an output variable can
1636
+ // produce another answer after the first one.
1637
+ const hasOpenTail = cursor.type === VAR;
1638
+ iterator.hasPendingAlternatives = () => length.type === VAR && hasOpenTail;
1639
+ return iterator;
1617
1640
  }
1618
1641
 
1619
1642
  function* bundledLengthSolutions(solver, goal, env) {
@@ -97,8 +97,8 @@ required permission error. `read_term/3`, `write_term/3`, `op/3`, and
97
97
  EyeProlog's chosen simultaneous-error behavior stable. Section 7.12 makes that
98
98
  choice implementation dependent when several error conditions hold at once. Strict `write_term/2-3` accepts only
99
99
  the Part 1 plus Corrigendum 3 option surface; the normal-profile
100
- `double_quotes/1` write option remains an explicitly documented EyeProlog
101
- extension.
100
+ `double_quotes/1` and `spacing/1` write options remain explicitly documented
101
+ implementation-specific EyeProlog extensions.
102
102
 
103
103
 
104
104
  The preparation, stream, conversion, sorting, and arithmetic audit expands the coverage into Prolog-text,
@@ -124,7 +124,7 @@ families; `--iso-strict` is intended to remove their Part 1 interpretation.
124
124
  | 5.5.9 Built-in predicates | EyeProlog libraries, CLP(Z), statistics, Part 3 `phrase/2-3`, and interop autoloaded predicates | Strict registry contains only the Part 1 + Corrigenda core registry. |
125
125
  | 5.5.10 Evaluable functors | Normal mode additionally accepts the EyeProlog evaluable atom `e`; the remaining arithmetic functors accepted by strict mode are the Part 1 + Corrigenda set. | Strict mode rejects `e/0` as non-evaluable and retains the Corrigendum arithmetic additions. **covered** — strict extension-boundary regression plus `src/iso-arithmetic.js`. |
126
126
  | 5.5.11 Reserved atoms | None | None. |
127
- | Cor.3 5.5.12 Options | Extra library/host options may exist outside core option lists | Normal mode additionally accepts the EyeProlog `write_term/2-3` option `double_quotes(true|false)`. Strict core excludes that extension and accepts only the Part 1 plus Corrigendum 3 write-option surface; unknown extension options raise `domain_error(write_option,...)`. |
127
+ | Cor.3 5.5.12 Options | Extra library/host options may exist outside core option lists | Normal mode additionally accepts the EyeProlog `write_term/2-3` options `double_quotes(true|false)` and `spacing(minimal|standard)`. `minimal` emits only lexically required separators; `standard` adds conventional layout around operators. Strict core excludes these implementation-specific extensions and accepts only the Part 1 plus Corrigendum 3 write-option surface; unknown extension options raise `domain_error(write_option,...)`. |
128
128
 
129
129
  Normal mode provides documented module and DCG compatibility profiles whose
130
130
  features overlap standardized Part 2 and Part 3 facilities. They are extensions
@@ -89,6 +89,18 @@ export function runCleanup(reporter) {
89
89
  assertEqual(result.stderr, '', 'stderr');
90
90
  });
91
91
 
92
+ reporter.test('setup_call_cleanup/3 exposes deterministic Cleanup substitutions (issue #77)', () => {
93
+ const result = runRepl(
94
+ 'setup_call_cleanup(true,A=1,throw(called(A))).\n' +
95
+ 'setup_call_cleanup(true,A=1,B=1).\n' +
96
+ 'halt.\n',
97
+ );
98
+ assertEqual(result.status, 0, 'exit status');
99
+ assertIncludes(result.stdout, 'throw(called(1)).', 'Cleanup sees Goal substitutions');
100
+ assertIncludes(result.stdout, 'A = 1, B = 1.', 'answer includes Cleanup substitutions');
101
+ assertEqual(result.stderr, '', 'stderr');
102
+ });
103
+
92
104
  reporter.test('setup_call_cleanup/3 does not install cleanup when Setup fails', () => {
93
105
  const result = runRepl('setup_call_cleanup(fail,true,write(should_not_run)).\nhalt.\n');
94
106
  assertEqual(result.status, 0, 'status');
@@ -283,8 +283,16 @@ export function runIsoStrict(reporter = new TestReporter()) {
283
283
  }));
284
284
  equal(strictError.formal, 'domain_error(write_option)', 'strict extension rejection');
285
285
 
286
+ const strictSpacingError = capture(() => run('', {
287
+ isoStrict: true,
288
+ goal: 'write_term(1+1,[spacing(minimal)])',
289
+ }));
290
+ equal(strictSpacingError.formal, 'domain_error(write_option)', 'strict spacing extension rejection');
291
+
286
292
  const normal = run('', { goal: 'write_term("ab",[double_quotes(true)])' });
287
293
  includes(normal.stdout, '"ab"', 'normal-profile extension remains available');
294
+ includes(run('', { goal: 'write_term(1+1,[spacing(standard)])' }).stdout,
295
+ '1 + 1', 'normal-profile spacing extension remains available');
288
296
  });
289
297
 
290
298
  reporter.test('follows Corrigendum 3 variable metadata traversal and write naming', () => {
@@ -1032,6 +1032,108 @@ c4 ?- call((!;1)).
1032
1032
  }
1033
1033
  },
1034
1034
  },
1035
+ {
1036
+ name: 'read/2 and number_chars/2 agree on bounded numeric syntax (issue #29)',
1037
+ run: () => {
1038
+ const registry = createDefaultRegistry();
1039
+ const numberChars = registry.get('number_chars', 2).handler;
1040
+ const read = registry.get('read', 2).handler;
1041
+ const solver = new Solver(Program.parse(''), { registry, ioOptions: { input: '' } });
1042
+ const stream = solver.io.resolve(0);
1043
+ const streamTerm = compound('$stream', [numberTerm('0')]);
1044
+ const alphabet = ['0', '1', '2', '7', '8', '9', 'a', 'f', 'x', 'e', 'E', '+', '-', '.', "'", '\\', ' '];
1045
+ let checked = 0;
1046
+ let accepted = 0;
1047
+
1048
+ const visit = (prefix, remaining) => {
1049
+ if (remaining === 0) {
1050
+ checked++;
1051
+ const converted = variable('Converted');
1052
+ const conversionGoal = compound('number_chars', [
1053
+ converted,
1054
+ listFromItems(Array.from(prefix, atom)),
1055
+ ]);
1056
+ let conversion;
1057
+ try {
1058
+ conversion = numberChars({ goal: conversionGoal, env: new Env() }).next();
1059
+ } catch (_) {
1060
+ return;
1061
+ }
1062
+ if (conversion.done) return;
1063
+ accepted++;
1064
+ const expected = copyResolved(converted, conversion.value);
1065
+
1066
+ stream.content = `${prefix}. `;
1067
+ stream.position = 0;
1068
+ stream.pastEnd = false;
1069
+ const readValue = variable('ReadValue');
1070
+ const readGoal = compound('read', [streamTerm, readValue]);
1071
+ const answer = read({ solver, goal: readGoal, env: new Env() }).next();
1072
+ if (answer.done) throw new Error(`read/2 rejected number_chars/2 spelling ${JSON.stringify(prefix)}`);
1073
+ const actual = copyResolved(readValue, answer.value);
1074
+ assertEqual(actual.type, 'number', `read/2 type for ${JSON.stringify(prefix)}`);
1075
+ assertEqual(actual.name, expected.name, `read/2 value for ${JSON.stringify(prefix)}`);
1076
+ return;
1077
+ }
1078
+ for (const character of alphabet) visit(prefix + character, remaining - 1);
1079
+ };
1080
+
1081
+ for (let length = 1; length <= 4; length++) visit('', length);
1082
+ assertEqual(checked, 88740, 'bounded numeric spellings checked');
1083
+ if (accepted < 1000) throw new Error(`unexpectedly small accepted numeric corpus: ${accepted}`);
1084
+ },
1085
+ },
1086
+ {
1087
+ name: 'number_chars/2 to read/2 cross-check stays bounded under a small heap (issue #29)',
1088
+ run: () => {
1089
+ const engineUrl = new URL('../src/index.js', import.meta.url).href;
1090
+ const script = `
1091
+ import {
1092
+ Program, Solver, Env, atom, compound, variable, listFromItems,
1093
+ numberTerm, copyResolved, createDefaultRegistry,
1094
+ } from ${JSON.stringify(engineUrl)};
1095
+ const registry = createDefaultRegistry();
1096
+ const numberChars = registry.get('number_chars', 2).handler;
1097
+ const read = registry.get('read', 2).handler;
1098
+ const solver = new Solver(Program.parse(''), { registry, ioOptions: { input: '' } });
1099
+ const stream = solver.io.resolve(0);
1100
+ const streamTerm = compound('$stream', [numberTerm('0')]);
1101
+ for (let i = 0; i < 250000; i++) {
1102
+ const text = String(i);
1103
+ const converted = variable('Converted');
1104
+ const conversionGoal = compound('number_chars', [
1105
+ converted,
1106
+ listFromItems(Array.from(text, atom)),
1107
+ ]);
1108
+ const conversion = numberChars({ goal: conversionGoal, env: new Env() }).next();
1109
+ if (conversion.done) throw new Error('number_chars/2 failed at ' + i);
1110
+ const expected = copyResolved(converted, conversion.value);
1111
+
1112
+ stream.content = text + '. ';
1113
+ stream.position = 0;
1114
+ stream.pastEnd = false;
1115
+ const readValue = variable('ReadValue');
1116
+ const readGoal = compound('read', [streamTerm, readValue]);
1117
+ const answer = read({ solver, goal: readGoal, env: new Env() }).next();
1118
+ if (answer.done) throw new Error('read/2 failed at ' + i);
1119
+ const actual = copyResolved(readValue, answer.value);
1120
+ if (actual.type !== 'number' || actual.name !== expected.name) {
1121
+ throw new Error('number mismatch at ' + i + ': ' + actual.name + ' != ' + expected.name);
1122
+ }
1123
+ }
1124
+ process.stdout.write('250000');
1125
+ `;
1126
+ const result = spawnSync(process.execPath, [
1127
+ '--max-old-space-size=32',
1128
+ '--input-type=module',
1129
+ '--eval',
1130
+ script,
1131
+ ], { cwd: packageRoot, encoding: 'utf8', timeout: 30000 });
1132
+ if (result.error) throw result.error;
1133
+ assertEqual(result.status, 0, `bounded-heap child status; stderr=${result.stderr}`);
1134
+ assertEqual(result.stdout, '250000', 'number_chars/read cross-check count');
1135
+ },
1136
+ },
1035
1137
  {
1036
1138
  name: 'number_chars and number_codes keep exponent floats syntactically readable (issue #50)',
1037
1139
  run: () => {
@@ -1956,6 +2058,50 @@ c4 ?- call((!;1)).
1956
2058
  groupedSolutions.return();
1957
2059
  },
1958
2060
  },
2061
+ {
2062
+ name: 'bound member tails do not invent predicate choicepoints (issue #74 follow-up)',
2063
+ run: () => {
2064
+ const result = runCli([], {
2065
+ input:
2066
+ 'use_module(library(lists)).\n' +
2067
+ 'L=[i],append(L,_,[i|L]).\n' +
2068
+ 'L=[_],append(L,_,[i|L]).\n' +
2069
+ 'L=[i],append(L,_,[i|L]),member(a,[a|L]).\n' +
2070
+ 'L=[_],append(L,_,[i|L]),member(a,[a|L]).\n' +
2071
+ 'halt.\n',
2072
+ });
2073
+ assertEqual(result.status, 0, 'exit status');
2074
+ assertEqual(result.stdout,
2075
+ '?- true.\n' +
2076
+ '?- L = "i".\n' +
2077
+ '?- L = "i".\n' +
2078
+ '?- L = "i".\n' +
2079
+ '?- L = "i".\n' +
2080
+ '?- ',
2081
+ 'ground and formerly variable list elements have identical determinism');
2082
+ assertEqual(result.stderr, '', 'stderr');
2083
+ },
2084
+ },
2085
+ {
2086
+ name: 'length/2 reports fixed and closed-list answers as deterministic (issue #78)',
2087
+ run: () => {
2088
+ const result = runCli([], {
2089
+ input:
2090
+ 'use_module(library(lists)).\n' +
2091
+ 'length(K,0).\n' +
2092
+ 'length([],N).\n' +
2093
+ 'halt.\n',
2094
+ });
2095
+ assertEqual(result.status, 0, 'exit status');
2096
+ assertEqual(result.stdout,
2097
+ '?- true.\n' +
2098
+ '?- K = [].\n' +
2099
+ '?- N = 0.\n' +
2100
+ '?- ',
2101
+ 'no exhausted native length iterator remains');
2102
+ assertEqual(result.stderr, '', 'stderr');
2103
+ },
2104
+ },
1959
2105
  {
1960
2106
  name: 'REPL hides aliases to fresh throw variables while preserving query aliases',
1961
2107
  run: () => {
@@ -3884,6 +4030,15 @@ child.stdin.write(\`consult(${consultedAtom}).\\n\`);
3884
4030
  {
3885
4031
  name: 'REPL answers use only lexically required operator spacing (issue #76)',
3886
4032
  run: () => {
4033
+ const source = [
4034
+ 'emit :-',
4035
+ ' write_term(1+1, [spacing(minimal)]), put_char(\'|\'),',
4036
+ ' write_term(1+1, [spacing(standard)]).',
4037
+ '',
4038
+ ].join('\n');
4039
+ assertEqual(run(source, { goal: 'emit' }).stdout, '1+1|1 + 1emit.\n',
4040
+ 'documented spacing values select write_term layout');
4041
+
3887
4042
  const result = runCli([], {
3888
4043
  input: 'X=1*1.\nX=a + -b.\nhalt.\n',
3889
4044
  });
@@ -3894,6 +4049,24 @@ child.stdin.write(\`consult(${consultedAtom}).\\n\`);
3894
4049
  '?- ',
3895
4050
  'minimal operator layout preserves required token separation');
3896
4051
  assertEqual(result.stderr, '', 'stderr');
4052
+
4053
+ let variableError = null;
4054
+ try {
4055
+ run('', { goal: 'write_term(1+1,[spacing(X)])' });
4056
+ } catch (error) {
4057
+ variableError = error;
4058
+ }
4059
+ assertEqual(variableError?.formal, 'instantiation_error', 'variable spacing value');
4060
+
4061
+ for (const value of ['true', 'false', '1', '0']) {
4062
+ let domainError = null;
4063
+ try {
4064
+ run('', { goal: `write_term(1+1,[spacing(${value})])` });
4065
+ } catch (error) {
4066
+ domainError = error;
4067
+ }
4068
+ assertEqual(domainError?.formal, 'domain_error(write_option)', `spacing(${value}) domain`);
4069
+ }
3897
4070
  },
3898
4071
  },
3899
4072
  {
@@ -5189,6 +5362,7 @@ answer(ok) :-
5189
5362
  name: 'discarded recursive allocations recover after resource_error(memory)',
5190
5363
  run: () => {
5191
5364
  const engineUrl = new URL('../src/index.js', import.meta.url).href;
5365
+ const platformUrl = new URL('../src/platform.js', import.meta.url).href;
5192
5366
  const programText = `
5193
5367
  :- use_module(library(prologue)).
5194
5368
  l([]).
@@ -5197,8 +5371,14 @@ answer(ok) :-
5197
5371
  const reportedGoal = 'length(_,I),N is 2^I,\\+ \\+ (length(L,N),l(L)),L=[_|_]';
5198
5372
  const script = `
5199
5373
  import { Program, Solver, Env, deref, variable, parseGoalText, getEyePrologRegistry } from ${JSON.stringify(engineUrl)};
5374
+ import { usedHeapSize } from ${JSON.stringify(platformUrl)};
5200
5375
  const program = Program.parse(${JSON.stringify(programText)}, { sourceMetadata: false });
5201
- const solver = new Solver(program, { registry: getEyePrologRegistry() });
5376
+ // Exercise EyeProlog's recovery window at a stable threshold instead
5377
+ // of relying on V8's version-dependent old-generation promotion.
5378
+ const solver = new Solver(program, {
5379
+ registry: getEyePrologRegistry(),
5380
+ maxMemoryBytes: usedHeapSize() + 20 * 1024 * 1024,
5381
+ });
5202
5382
  const execute = (text) => {
5203
5383
  const goal = parseGoalText(text, { operatorDefinitions: [...program.operators.values()] });
5204
5384
  return [...solver.solve([goal], new Env(), 0)];
@@ -5187,8 +5187,10 @@ pruned, top-level answer enumeration is abandoned, or an exception unwinds the
5187
5187
  search. `setup_call_cleanup/3` runs Setup once and installs Cleanup only after
5188
5188
  Setup succeeds. On cut or ordinary pruning Cleanup sees the current Goal
5189
5189
  bindings; on exception unwind the Goal bindings have been removed and Cleanup
5190
- sees the Setup environment. Cleanup failure is ignored, and an exception already
5191
- being propagated takes precedence over a cleanup exception. Nested cleanups run
5190
+ sees the Setup environment. When a deterministic protected goal runs Cleanup
5191
+ before yielding, substitutions produced by a successful Cleanup are included in
5192
+ that answer. Cleanup failure is ignored, and an exception already being
5193
+ propagated takes precedence over a cleanup exception. Nested cleanups run
5192
5194
  inside-out. These two controls are EyeProlog extensions and are absent from
5193
5195
  `--iso-strict`.
5194
5196
 
@@ -5427,9 +5429,15 @@ still retained where adjacent graphic tokens would otherwise merge, as in
5427
5429
  `variable_names/1`. Normal mode additionally accepts the EyeProlog extension
5428
5430
  `double_quotes(true|false)`: `true` lets eligible character/code lists use the
5429
5431
  current `double_quotes` representation, while strict ISO mode rejects this
5430
- non-standard write option. The REPL follows the same minimal-separator rule as
5431
- term output, so `X=1*1` is displayed as `X = 1*1`, while a separator is retained
5432
- in `X = a+ -b` because the adjacent graphic tokens would otherwise merge.
5432
+ implementation-specific write option. Normal mode also accepts the
5433
+ implementation-specific `spacing(minimal|standard)` option: `minimal` emits
5434
+ only separators required to avoid lexical ambiguity, while `standard` adds
5435
+ conventional layout around operators. For example,
5436
+ `write_term(1+1,[spacing(minimal)])` emits `1+1` and
5437
+ `write_term(1+1,[spacing(standard)])` emits `1 + 1`. The REPL always follows
5438
+ the minimal-separator rule, so `X=1*1` is displayed as `X = 1*1`, while a
5439
+ separator is retained in `X = a+ -b` because the adjacent graphic tokens would
5440
+ otherwise merge. Strict ISO mode rejects both extension options.
5433
5441
 
5434
5442
  Character operations are `get_char`, `peek_char`, `put_char`, `get_code`,
5435
5443
  `peek_code`, and `put_code`; byte streams use the corresponding byte
@@ -6122,7 +6130,7 @@ the beginning. A peek does not mark the stream as past-end.
6122
6130
  | `write(+Term)`, `write(+Stream,+Term)` | Writes readable operator notation without quoting atoms merely because quoting would be required for reparsing. Number variables are enabled. |
6123
6131
  | `writeq(+Term)`, `writeq(+Stream,+Term)` | Like `write`, but quotes atoms when required for unambiguous input syntax. |
6124
6132
  | `write_canonical(+Term)`, `write_canonical(+Stream,+Term)` | Writes quoted canonical functor notation while ignoring operators and without interpreting *$VAR/1*. |
6125
- | `write_term(+Term,+Options)`, `write_term(+Stream,+Term,+Options)` | Writes with *quoted(true or false)*, *ignore_ops(true or false)*, *numbervars(true or false)*, and *variable_names([Name=Variable,...])*. Normal mode also supports *double_quotes(true or false)*. |
6133
+ | `write_term(+Term,+Options)`, `write_term(+Stream,+Term,+Options)` | Writes with *quoted(true or false)*, *ignore_ops(true or false)*, *numbervars(true or false)*, and *variable_names([Name=Variable,...])*. Normal mode also supports *double_quotes(true or false)* and *spacing(minimal or standard)*. |
6126
6134
 
6127
6135
  Term input uses the program's current operator table and the same ISO quoted-character
6128
6136
  syntax as source text, including backslash-terminated octal and hexadecimal
@@ -6132,6 +6140,12 @@ when the `char_conversion` flag is `on`. `variable_names/1` and `singletons/1` o
6132
6140
  predicates do not append a period or newline; call `write/1`, then `write('.')`
6133
6141
  and `nl/0` when emitting a complete source term manually.
6134
6142
 
6143
+ A standalone numeric term read by `read/1-2` or `read_term/2-3` uses the same
6144
+ bounded numeric scanner and canonical value conversion as `number_chars/2`.
6145
+ Consequently every numeric character sequence accepted by `number_chars/2` has
6146
+ the same value when read as a full-stop-terminated term, without requiring EOF
6147
+ after that term.
6148
+
6135
6149
  ### Arithmetic expressions
6136
6150
 
6137
6151
  | Predicate and principal call | Behavior |
@@ -6209,7 +6223,7 @@ so side effects occur in Prolog execution order.
6209
6223
 
6210
6224
  ### Normal-mode cleanup controls
6211
6225
 
6212
- `call_cleanup/2` and `setup_call_cleanup/3` are normal EyeProlog runtime extensions rather than members of the isolated ISO builtin registry. They protect a goal across deterministic completion, exhaustion, cut, top-level abandonment, and exception unwinding, running Cleanup exactly once. `setup_call_cleanup/3` runs Setup once and installs Cleanup only after Setup succeeds. Nested cleanups run inside-out, and strict ISO mode does not provide either predicate.
6226
+ `call_cleanup/2` and `setup_call_cleanup/3` are normal EyeProlog runtime extensions rather than members of the isolated ISO builtin registry. They protect a goal across deterministic completion, exhaustion, cut, top-level abandonment, and exception unwinding, running Cleanup exactly once. `setup_call_cleanup/3` runs Setup once and installs Cleanup only after Setup succeeds. A successful Cleanup run before a deterministic answer contributes its substitutions to that answer. Nested cleanups run inside-out, and strict ISO mode does not provide either predicate.
6213
6227
 
6214
6228
  ### The EyeProlog library
6215
6229
 
@@ -6363,7 +6377,9 @@ conservative cross-engine subset.
6363
6377
  `length(Xs, N)` enumerates `Xs = [], N = 0`, then one-element lists with
6364
6378
  `N = 1`, and so on. Open-ended generation uses the normal memory guard with
6365
6379
  recovery headroom so finite-heap exhaustion remains a catchable
6366
- `resource_error(memory)`.
6380
+ `resource_error(memory)`. A supplied nonnegative length selects at most one
6381
+ answer, and a supplied closed list has exactly one length; these modes do not
6382
+ retain an exhausted choicepoint.
6367
6383
 
6368
6384
  `library(iso_ext)` is a common interop module name, but only part of its
6369
6385
  EyeProlog API belongs to the shared profile. `call_nth/2`, `time/1`, and
@@ -7767,8 +7783,8 @@ Notable implementation boundaries are:
7767
7783
  - `write_term/2-3` implements the Part 1 plus Corrigendum 3 `quoted/1`,
7768
7784
  `ignore_ops/1`, `numbervars/1`, and `variable_names/1` option surface,
7769
7785
  including option validation and traversal rules; normal mode also offers
7770
- `double_quotes(true|false)` as an explicitly non-standard extension, which
7771
- strict mode rejects;
7786
+ `double_quotes(true|false)` and `spacing(minimal|standard)` as explicitly
7787
+ implementation-specific extensions, which strict mode rejects;
7772
7788
  - unification consistently performs an occurs check, rejecting rational-tree
7773
7789
  bindings accepted as extensions by some systems.
7774
7790