eyeprolog 1.5.10 → 1.5.12

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
@@ -85,6 +85,8 @@ The checked [Symbiotic Knowledge Graphs example](examples/symbiotic-knowledge-gr
85
85
 
86
86
  The same RDF → Prolog → RDF boundary is exercised by five additional checked scenarios: [cross-organization data sharing](https://eyereasoner.github.io/eyeprolog/examples/deck/cross-organization-data-sharing), [explainable EV-depot configuration](https://eyereasoner.github.io/eyeprolog/examples/deck/explainable-ev-depot-configuration), [operational incident response](https://eyereasoner.github.io/eyeprolog/examples/deck/operational-incident-response), [software supply-chain vulnerability response](https://eyereasoner.github.io/eyeprolog/examples/deck/sbom-vulnerability-response), and a [scientific evidence graph](https://eyereasoner.github.io/eyeprolog/examples/deck/scientific-evidence-graph). Together they cover policy decisions, reversible configuration reasoning, dependency-graph diagnosis, transitive SBOM exposure, and evidence aggregation with explicit disagreement.
87
87
 
88
+ ## Benchmarks
89
+ EyeProlog has 20 checksum-protected wall-clock benchmarks spanning recursion/indexing, constraints, tabling/WFS, DCGs, Eyelet, search, term I/O, attributes, and rewriting. Short workloads are adaptively batched before timing so millisecond-scale noise is not mistaken for a regression. Run `npm run benchmark`; create a machine-local comparison point with `npm run benchmark:baseline`; use `npm run test:benchmark` for harness checks. Details are in [*The Art of EyeProlog*](the-art-of-eyeprolog.md).
88
90
  ## Development
89
91
 
90
92
  ```sh
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.5.10",
6
+ "version": "1.5.12",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
@@ -66,6 +66,9 @@
66
66
  "report:wg17-syntax": "node tools/report-wg17-syntax-coverage.mjs",
67
67
  "preversion": "npm test && node test/run-conformance-report.mjs conformance-report.md",
68
68
  "postversion": "git push origin HEAD --follow-tags",
69
- "test:architecture": "node test/run-architecture.mjs"
69
+ "test:architecture": "node test/run-architecture.mjs",
70
+ "benchmark": "node test/benchmark.mjs",
71
+ "benchmark:baseline": "node test/benchmark.mjs --save .benchmarks/baseline.json",
72
+ "test:benchmark": "node test/run-benchmark-tests.mjs"
70
73
  }
71
74
  }
@@ -88,3 +88,10 @@ retains the same data structures and selection functions.
88
88
  Large solver fast paths deliberately remain co-located in `solver.js` until a
89
89
  split can demonstrate benchmark parity. A cleaner file layout is not worth a
90
90
  runtime regression.
91
+
92
+ Performance claims use the wall-clock workloads in `test/bench/`, not predicate,
93
+ inference, or host-call counts as a substitute for elapsed time. Each benchmark
94
+ runs in its own Node process, warms the engine before measured parse+execute
95
+ runs, reports the median, and verifies a committed output SHA-256 before its
96
+ timing is accepted. Machine-specific timing baselines live under `.benchmarks/`
97
+ and are intentionally not versioned.
package/src/execute.js CHANGED
@@ -51,6 +51,7 @@ export function executeGoals(program, solver, goals, { onAnswer = () => {} } = {
51
51
  function currentWriteOptions(program, solver) {
52
52
  return {
53
53
  doubleQuotes: solver.prologFlags.get('double_quotes')?.value?.name ?? 'chars',
54
+ doubleBar: !solver.isoStrict,
54
55
  operators: [...program.operators.values()],
55
56
  quoted: true,
56
57
  };
package/src/iso.js CHANGED
@@ -1988,6 +1988,7 @@ function writeBuiltin(mode) {
1988
1988
  generateVariableNames: true,
1989
1989
  variableNameState: solver.writeVariableState,
1990
1990
  operators: solver.program.operators.values(),
1991
+ doubleBar: !solver.isoStrict,
1991
1992
  }));
1992
1993
  yield env;
1993
1994
  };
@@ -2019,6 +2020,7 @@ function* writeTermBuiltin({ solver, goal, env }) {
2019
2020
  generateVariableNames: true,
2020
2021
  variableNameState: solver.writeVariableState,
2021
2022
  operators: solver.program.operators.values(),
2023
+ doubleBar: !solver.isoStrict,
2022
2024
  }));
2023
2025
  yield env;
2024
2026
  }
package/src/parser.js CHANGED
@@ -937,9 +937,37 @@ class Parser {
937
937
  this.parserFlagState.doubleQuotes === 'chars'
938
938
  ? atom(character)
939
939
  : numberTerm(character.codePointAt(0)));
940
- let list = emptyList();
941
- for (let i = items.length - 1; i >= 0; i--) list = cons(items[i], list);
942
- return list;
940
+ let tail = emptyList();
941
+
942
+ // Trealla-compatible double-bar right splice (issue #88): in normal
943
+ // mode, a double-quoted chars/codes prefix may be followed by `||Tail`.
944
+ // It is equivalent to the corresponding list prefix with Tail as its
945
+ // final list tail, e.g. "ab"||T is [a,b|T] with double_quotes(chars).
946
+ // The extension binds at priority 1 (tighter than ordinary operators)
947
+ // and is deliberately absent from the strict ISO profile.
948
+ if (!this.strictIso && this.token.type === TOK.BAR) {
949
+ const state = {
950
+ pos: this.pos,
951
+ line: this.line,
952
+ previousToken: this.previousToken,
953
+ token: this.token,
954
+ };
955
+ this.advance();
956
+ if (this.token.type === TOK.BAR) {
957
+ this.advance();
958
+ tail = this.parseTerm(operatorStrength(1), false, allowBar, false);
959
+ } else {
960
+ // A single bar remains ordinary bar syntax. We had to advance once
961
+ // to distinguish it from `||`, so restore the tokenizer state.
962
+ this.pos = state.pos;
963
+ this.line = state.line;
964
+ this.previousToken = state.previousToken;
965
+ this.token = state.token;
966
+ }
967
+ }
968
+
969
+ for (let i = items.length - 1; i >= 0; i--) tail = cons(items[i], tail);
970
+ return tail;
943
971
  }
944
972
  if (this.token.type === TOK.NUMBER) {
945
973
  const value = this.token.text;
package/src/repl.js CHANGED
@@ -746,6 +746,7 @@ function formatAnswer(engine, state, variables, env) {
746
746
  operatorAtomsAsArgs: true,
747
747
  dottedGraphicAtoms: true,
748
748
  doubleQuotes: state.solver.prologFlags.get('double_quotes')?.value?.name ?? 'chars',
749
+ doubleBar: !state.strictIso,
749
750
  };
750
751
  let generated = 0;
751
752
 
@@ -844,6 +845,7 @@ function formatError(engine, state, error) {
844
845
  quoted: true,
845
846
  operators: [...state.program.operators.values()],
846
847
  variableNames,
848
+ doubleBar: !state.strictIso,
847
849
  })}.`;
848
850
  }
849
851
  const message = error?.message ?? String(error);
package/src/term.js CHANGED
@@ -1067,14 +1067,18 @@ function writeString(value, quoteStrings) {
1067
1067
  return out + '"';
1068
1068
  }
1069
1069
 
1070
- function quotedListText(term, env, doubleQuotes) {
1070
+ function quotedListSplice(term, env, doubleQuotes) {
1071
1071
  if (doubleQuotes !== 'chars' && doubleQuotes !== 'codes') return null;
1072
1072
  const characters = [];
1073
1073
  let cursor = term;
1074
1074
  while (true) {
1075
1075
  cursor = deref(cursor, env);
1076
- if (isEmptyList(cursor)) return characters.length === 0 ? null : characters.join('');
1077
- if (!isCons(cursor)) return null;
1076
+ if (isEmptyList(cursor)) {
1077
+ return characters.length === 0 ? null : { text: characters.join(''), tail: null };
1078
+ }
1079
+ if (!isCons(cursor)) {
1080
+ return characters.length === 0 ? null : { text: characters.join(''), tail: cursor };
1081
+ }
1078
1082
  const item = deref(cursor.args[0], env);
1079
1083
  if (doubleQuotes === 'chars') {
1080
1084
  if (item.type !== ATOM || Array.from(item.name).length !== 1) return null;
@@ -1090,8 +1094,12 @@ function quotedListText(term, env, doubleQuotes) {
1090
1094
  }
1091
1095
 
1092
1096
  function writeList(term, env, options) {
1093
- const quotedText = quotedListText(term, env, options.doubleQuotes);
1094
- if (quotedText != null) return writeString(quotedText, true);
1097
+ const quotedSplice = quotedListSplice(term, env, options.doubleQuotes);
1098
+ if (quotedSplice != null && (quotedSplice.tail == null || options.doubleBar === true)) {
1099
+ const prefix = writeString(quotedSplice.text, true);
1100
+ if (quotedSplice.tail == null) return prefix;
1101
+ return `${prefix}||${termToString(quotedSplice.tail, env, true, options)}`;
1102
+ }
1095
1103
  const parts = [];
1096
1104
  let cursor = term;
1097
1105
  while (true) {
@@ -1110,6 +1118,10 @@ export function termToString(term, env = new Env(), quoteStrings = true, options
1110
1118
  options = {
1111
1119
  ...options,
1112
1120
  doubleQuotes: options.doubleQuotes ?? 'chars',
1121
+ // termToString is also used for context-free processor-error messages.
1122
+ // Keep the normal-profile `||` extension opt-in here so strict errors do
1123
+ // not accidentally emit syntax that the strict parser rejects.
1124
+ doubleBar: options.doubleBar === true,
1113
1125
  readVariableNames: options.readVariableNames instanceof Map ? options.readVariableNames : new Map(),
1114
1126
  usedReadVariableNames: options.usedReadVariableNames instanceof Set ? options.usedReadVariableNames : new Set(),
1115
1127
  };
package/src/write.js CHANGED
@@ -152,14 +152,18 @@ function writeString(value) {
152
152
  return out + '"';
153
153
  }
154
154
 
155
- function quotedListText(term, env, doubleQuotes) {
155
+ function quotedListSplice(term, env, doubleQuotes) {
156
156
  if (doubleQuotes !== 'chars' && doubleQuotes !== 'codes') return null;
157
157
  const characters = [];
158
158
  let cursor = term;
159
159
  while (true) {
160
160
  cursor = deref(cursor, env);
161
- if (isEmptyList(cursor)) return characters.length === 0 ? null : characters.join('');
162
- if (!isCons(cursor)) return null;
161
+ if (isEmptyList(cursor)) {
162
+ return characters.length === 0 ? null : { text: characters.join(''), tail: null };
163
+ }
164
+ if (!isCons(cursor)) {
165
+ return characters.length === 0 ? null : { text: characters.join(''), tail: cursor };
166
+ }
163
167
  const item = deref(cursor.args[0], env);
164
168
  if (doubleQuotes === 'chars') {
165
169
  if (item.type !== ATOM || Array.from(item.name).length !== 1) return null;
@@ -331,8 +335,15 @@ function format(term, env, options, table, maxPriority = 1200, context = 'term')
331
335
  }
332
336
 
333
337
  if (!options.ignoreOps && isCons(resolved)) {
334
- const quotedText = quotedListText(resolved, env, options.doubleQuotes);
335
- if (quotedText != null) return writeString(quotedText);
338
+ const quotedSplice = quotedListSplice(resolved, env, options.doubleQuotes);
339
+ if (quotedSplice != null && (quotedSplice.tail == null || options.doubleBar)) {
340
+ const prefix = writeString(quotedSplice.text);
341
+ if (quotedSplice.tail == null) return prefix;
342
+ // `||` is a normal-profile syntax extension with priority 1, so a
343
+ // looser operator in the right operand must be parenthesized to keep the
344
+ // emitted text readable as the same term.
345
+ return `${prefix}||${format(quotedSplice.tail, env, options, table, 1, 'term')}`;
346
+ }
336
347
  const parts = [];
337
348
  let cursor = resolved;
338
349
  while (true) {
@@ -442,6 +453,9 @@ export function formatTermForWrite(term, env = new Env(), options = {}) {
442
453
  ignoreOps: options.ignoreOps === true,
443
454
  numbervars: options.numbervars !== false,
444
455
  doubleQuotes: options.doubleQuotes,
456
+ // `||` is a normal-profile syntax extension. Public normal-mode writers
457
+ // enable it by default; strict callers explicitly turn it off.
458
+ doubleBar: options.doubleBar !== false,
445
459
  variableNames: options.generateVariableNames === true
446
460
  ? printableGeneratedVariableNames(term, env, explicitVariableNames, options.variableNameState)
447
461
  : printableReadVariableNames(term, env, explicitVariableNames),
@@ -0,0 +1,184 @@
1
+ [
2
+ {
3
+ "name": "deep-taxonomy",
4
+ "group": "recursion/indexing",
5
+ "file": "examples/deep-taxonomy-100000.pl",
6
+ "goals": [
7
+ "result(report, success)"
8
+ ],
9
+ "expectedSha256": "a0aa24fcbc87c394a4da789ac861b8e18cabad5e0d73b25ddc02df5dd4376c1b"
10
+ },
11
+ {
12
+ "name": "clpz-sudoku",
13
+ "group": "clpz",
14
+ "file": "examples/clpz-sudoku-9x9.pl",
15
+ "goals": [
16
+ "sudoku9_solution(X0)"
17
+ ],
18
+ "expectedSha256": "0eed3bcf38498cb514143adb2e475d8e48b0326bcf329fbedb6a3b0e87646731"
19
+ },
20
+ {
21
+ "name": "clpb-weighted-planning",
22
+ "group": "clpb",
23
+ "file": "examples/clpb-weighted-planning.pl",
24
+ "goals": [
25
+ "best_release_plan(X0, X1)"
26
+ ],
27
+ "expectedSha256": "52f2cc7d22873accef7968f89aea3584fb3304b2dceb555428e79477b41f0b5d"
28
+ },
29
+ {
30
+ "name": "clpb-cardinality",
31
+ "group": "clpb",
32
+ "file": "examples/clpb-cardinality.pl",
33
+ "goals": [
34
+ "review_quorum_count(X0)"
35
+ ],
36
+ "expectedSha256": "a643a582baefc95da37b0ccf9609a2241aeb7cfe559e2636bb439c05c50e2177"
37
+ },
38
+ {
39
+ "name": "tabling-cycle",
40
+ "group": "tabling",
41
+ "file": "examples/cyclic-path.pl",
42
+ "goals": [
43
+ "path(X0, X1)"
44
+ ],
45
+ "expectedSha256": "5580a2782c6896ffdf36bd6cc242931e96e1722b154976c1a20e7cad1206566f"
46
+ },
47
+ {
48
+ "name": "tabling-chart-parser",
49
+ "group": "tabling",
50
+ "file": "examples/chart-parser.pl",
51
+ "goals": [
52
+ "chart_parser_answer(X0, X1)"
53
+ ],
54
+ "expectedSha256": "70707d3026aabc65982b82b9617a15d3de6cbb5fa6cdb2ee955bedd4f29f0e5a"
55
+ },
56
+ {
57
+ "name": "wfs-policy",
58
+ "group": "wfs",
59
+ "file": "examples/odrl-policy-reasoning.pl",
60
+ "goals": [
61
+ "wfsQuestion(X0, X1)"
62
+ ],
63
+ "expectedSha256": "b4a153234f3daf1e2ba6c26843349a1bc1919ebd9f9e98559835eb5ef915716b"
64
+ },
65
+ {
66
+ "name": "dcg-command",
67
+ "group": "dcg",
68
+ "file": "examples/dcg-command-parser.pl",
69
+ "goals": [
70
+ "dcg_example(X0, X1)"
71
+ ],
72
+ "expectedSha256": "6f640fb0d327eb9139d3568cf7a86ad55a52afdbb0208ca35a70419be2bf1b9c"
73
+ },
74
+ {
75
+ "name": "dcg-expression",
76
+ "group": "dcg",
77
+ "file": "examples/dcg-expression-language.pl",
78
+ "goals": [
79
+ "dcg_expression_example(X0, X1)"
80
+ ],
81
+ "expectedSha256": "562985450257f1db8c67682c6184102622b4e6946f2cb0abd3ac15bde493f5d5"
82
+ },
83
+ {
84
+ "name": "eyelet-forward",
85
+ "group": "eyelet",
86
+ "file": "test/bench/eyelet-forward.pl",
87
+ "goals": [],
88
+ "expectedSha256": "127277f2ba5c02d41143b22ac0ae069596a4a7dd4dc71079f3dff1319ca019cc"
89
+ },
90
+ {
91
+ "name": "takeuchi",
92
+ "group": "recursive-control",
93
+ "file": "examples/takeuchi.pl",
94
+ "goals": [
95
+ "tak([34, 13, 8], X0)"
96
+ ],
97
+ "expectedSha256": "a7c33872b86aa7877b9c38ec41c447fc96a328d40f512428993d662f28a097ad"
98
+ },
99
+ {
100
+ "name": "clpz-register-allocation",
101
+ "group": "clpz",
102
+ "file": "examples/register-allocation.pl",
103
+ "goals": [
104
+ "registerAnswer(X0, X1)"
105
+ ],
106
+ "expectedSha256": "1ed0b4a90deff2908463774d0025210d58b89366efb5d82b94ff5b51daca046b"
107
+ },
108
+ {
109
+ "name": "clpb-feature-model",
110
+ "group": "clpb",
111
+ "file": "examples/clpb-feature-model.pl",
112
+ "goals": [
113
+ "feature_plan(X0)"
114
+ ],
115
+ "expectedSha256": "47abfd8a87efb595ffb11c6c8cbda3cb5855e95124bc574e3cae387c12318f33"
116
+ },
117
+ {
118
+ "name": "attributed-variables",
119
+ "group": "attributes",
120
+ "file": "examples/attributed-variables.pl",
121
+ "goals": [
122
+ "accepts_required_value(X0)",
123
+ "alias_preserves_attribute(X0)"
124
+ ],
125
+ "expectedSha256": "3d60cd3464f2aae44ad01859e838fc2b8754e35916bafe3bba66ca745f77c236"
126
+ },
127
+ {
128
+ "name": "dif-constraints",
129
+ "group": "constraints",
130
+ "file": "examples/dif-constraints.pl",
131
+ "goals": [
132
+ "allowed_pair(X0, X1)",
133
+ "specialization(X0, X1)"
134
+ ],
135
+ "expectedSha256": "b6268a6653d02f7312f73406612b451d030a3766931e093003c3642cc170009d"
136
+ },
137
+ {
138
+ "name": "dijkstra-findall-sort",
139
+ "group": "search/collections",
140
+ "file": "examples/dijkstra-findall-sort.pl",
141
+ "goals": [
142
+ "shortestPath(X0, X1)",
143
+ "cost(X0, X1)"
144
+ ],
145
+ "expectedSha256": "1318aaaca44aa19f295f988d87b09165cd0fc279e94ae14c10e2d1f146d544f3"
146
+ },
147
+ {
148
+ "name": "iso-term-io",
149
+ "group": "term-io",
150
+ "file": "examples/iso-term-io.pl",
151
+ "goals": [
152
+ "report(X0, X1)"
153
+ ],
154
+ "expectedSha256": "0719932d697e7587285082b558f4c39c04ec1f2222131073e73e6b6fe37e0320"
155
+ },
156
+ {
157
+ "name": "symbolic-derivative",
158
+ "group": "term-rewriting",
159
+ "file": "examples/symbolic-derivative.pl",
160
+ "goals": [
161
+ "derivative_result(X0, X1)"
162
+ ],
163
+ "expectedSha256": "49f9f33e89e8c8c63ce2eb64ea2639e416869d6ef5db185cab70452d1f77f342"
164
+ },
165
+ {
166
+ "name": "sat-dpll",
167
+ "group": "search/control",
168
+ "file": "examples/sat-solver-dpll.pl",
169
+ "goals": [
170
+ "satModel(X0)",
171
+ "satValue(X0, X1)"
172
+ ],
173
+ "expectedSha256": "a8c5cdddac5efcb7d5b1031200d3bbc5b78c1668928a45e064377432f05b3c9f"
174
+ },
175
+ {
176
+ "name": "type-inference",
177
+ "group": "unification",
178
+ "file": "examples/type-inference.pl",
179
+ "goals": [
180
+ "type_answer(X0, X1)"
181
+ ],
182
+ "expectedSha256": "acad9ddb995d9ddef091e17cc91659d36ed7f9014d6714fd40100d85a53ae203"
183
+ }
184
+ ]
@@ -0,0 +1,58 @@
1
+ % Synthetic Eyelet fixed-point benchmark.
2
+ % The chain is intentionally moderate: large enough to exercise repeated
3
+ % forward-rule rounds without making the default benchmark suite unwieldy.
4
+
5
+ reach(0).
6
+ edge(0, 1).
7
+ edge(1, 2).
8
+ edge(2, 3).
9
+ edge(3, 4).
10
+ edge(4, 5).
11
+ edge(5, 6).
12
+ edge(6, 7).
13
+ edge(7, 8).
14
+ edge(8, 9).
15
+ edge(9, 10).
16
+ edge(10, 11).
17
+ edge(11, 12).
18
+ edge(12, 13).
19
+ edge(13, 14).
20
+ edge(14, 15).
21
+ edge(15, 16).
22
+ edge(16, 17).
23
+ edge(17, 18).
24
+ edge(18, 19).
25
+ edge(19, 20).
26
+ edge(20, 21).
27
+ edge(21, 22).
28
+ edge(22, 23).
29
+ edge(23, 24).
30
+ edge(24, 25).
31
+ edge(25, 26).
32
+ edge(26, 27).
33
+ edge(27, 28).
34
+ edge(28, 29).
35
+ edge(29, 30).
36
+ edge(30, 31).
37
+ edge(31, 32).
38
+ edge(32, 33).
39
+ edge(33, 34).
40
+ edge(34, 35).
41
+ edge(35, 36).
42
+ edge(36, 37).
43
+ edge(37, 38).
44
+ edge(38, 39).
45
+ edge(39, 40).
46
+ edge(40, 41).
47
+ edge(41, 42).
48
+ edge(42, 43).
49
+ edge(43, 44).
50
+ edge(44, 45).
51
+ edge(45, 46).
52
+ edge(46, 47).
53
+ edge(47, 48).
54
+ edge(48, 49).
55
+ edge(49, 50).
56
+
57
+ reach(Y) :+ reach(X), edge(X, Y).
58
+ true :+ reach(50).
@@ -0,0 +1,94 @@
1
+ import crypto from 'node:crypto';
2
+ import { performance } from 'node:perf_hooks';
3
+ import { run } from '../index.js';
4
+
5
+ function digest(text) {
6
+ return crypto.createHash('sha256').update(text).digest('hex');
7
+ }
8
+
9
+ function nextPowerOfTwo(value) {
10
+ let power = 1;
11
+ while (power < value && power < 1024) power *= 2;
12
+ return power;
13
+ }
14
+
15
+ export function measureBenchmark(source, goals, { runs = 5, warmup = 1, targetMs = 400 } = {}) {
16
+ let referenceStdout = null;
17
+ let referenceDigest = null;
18
+ let answerLines = null;
19
+ let outputBytes = null;
20
+
21
+ function execute() {
22
+ return run(source, { goals });
23
+ }
24
+
25
+ function validateResult(result) {
26
+ if (result.haltCode != null) throw new Error(`benchmark halted with code ${result.haltCode}`);
27
+ if (referenceStdout == null) {
28
+ referenceStdout = result.stdout;
29
+ referenceDigest = digest(result.stdout);
30
+ answerLines = result.stdout.split('\n').filter((line) => line.length > 0).length;
31
+ outputBytes = Buffer.byteLength(result.stdout);
32
+ } else if (result.stdout !== referenceStdout) {
33
+ throw new Error(`non-deterministic output digest: ${referenceDigest} != ${digest(result.stdout)}`);
34
+ }
35
+ }
36
+
37
+ function runBatch(batchSize) {
38
+ const results = new Array(batchSize);
39
+ const started = performance.now();
40
+ for (let i = 0; i < batchSize; i++) results[i] = execute();
41
+ const elapsed = performance.now() - started;
42
+ for (const result of results) validateResult(result);
43
+ return elapsed;
44
+ }
45
+
46
+ let batchSize = 1;
47
+ let calibrationMs = 0;
48
+ if (targetMs > 0) {
49
+ runBatch(1); // prime parser/JIT/module state before sizing the measured batch
50
+ calibrationMs = runBatch(1);
51
+ if (calibrationMs < targetMs) {
52
+ batchSize = nextPowerOfTwo(Math.ceil(targetMs / Math.max(calibrationMs, 0.01)));
53
+ calibrationMs = runBatch(batchSize);
54
+ while (calibrationMs < targetMs * 0.8 && batchSize < 1024) {
55
+ batchSize = Math.min(batchSize * 2, 1024);
56
+ calibrationMs = runBatch(batchSize);
57
+ }
58
+ while (calibrationMs > targetMs * 2 && batchSize > 1) {
59
+ batchSize = Math.max(Math.floor(batchSize / 2), 1);
60
+ calibrationMs = runBatch(batchSize);
61
+ }
62
+ }
63
+ }
64
+
65
+ for (let i = 0; i < warmup; i++) runBatch(batchSize);
66
+
67
+ // Warm-up can materially change short-workload cost. Re-check the chosen batch
68
+ // in the warmed state and grow it until samples are long enough to be useful.
69
+ if (targetMs > 0) {
70
+ calibrationMs = runBatch(batchSize);
71
+ while (calibrationMs < targetMs * 0.8 && batchSize < 1024) {
72
+ batchSize = Math.min(batchSize * 2, 1024);
73
+ calibrationMs = runBatch(batchSize);
74
+ }
75
+ }
76
+
77
+ const samplesMs = [];
78
+ const batchSamplesMs = [];
79
+ for (let i = 0; i < runs; i++) {
80
+ const elapsed = runBatch(batchSize);
81
+ batchSamplesMs.push(elapsed);
82
+ samplesMs.push(elapsed / batchSize);
83
+ }
84
+
85
+ return {
86
+ digest: referenceDigest,
87
+ answerLines,
88
+ outputBytes,
89
+ batchSize,
90
+ calibrationMs,
91
+ batchSamplesMs,
92
+ samplesMs,
93
+ };
94
+ }
@@ -0,0 +1,28 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import process from 'node:process';
4
+ import { measureBenchmark } from './benchmark-core.mjs';
5
+
6
+ function parseInteger(value, name, minimum) {
7
+ const number = Number(value);
8
+ if (!Number.isInteger(number) || number < minimum) throw new Error(`${name} must be an integer >= ${minimum}`);
9
+ return number;
10
+ }
11
+
12
+ function parseNumber(value, name, minimum) {
13
+ const number = Number(value);
14
+ if (!Number.isFinite(number) || number < minimum) throw new Error(`${name} must be a number >= ${minimum}`);
15
+ return number;
16
+ }
17
+
18
+ const [fileArg, goalsJson = '[]', runsArg = '5', warmupArg = '1', targetMsArg = '400'] = process.argv.slice(2);
19
+ if (!fileArg) throw new Error('benchmark worker requires a file');
20
+ const goals = JSON.parse(goalsJson);
21
+ if (!Array.isArray(goals) || !goals.every((goal) => typeof goal === 'string')) throw new Error('benchmark worker goals must be a JSON array of strings');
22
+ const source = await fs.readFile(path.resolve(fileArg), 'utf8');
23
+ const result = measureBenchmark(source, goals, {
24
+ runs: parseInteger(runsArg, 'runs', 1),
25
+ warmup: parseInteger(warmupArg, 'warmup', 0),
26
+ targetMs: parseNumber(targetMsArg, 'targetMs', 0),
27
+ });
28
+ process.stdout.write(`${JSON.stringify(result)}\n`);
@@ -0,0 +1,228 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import process from 'node:process';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
8
+ const manifestPath = path.join(root, 'test', 'bench', 'benchmarks.json');
9
+ const defaultBaselinePath = path.join(root, '.benchmarks', 'baseline.json');
10
+
11
+ function usage() {
12
+ process.stdout.write(`EyeProlog benchmark runner\n\nUsage:\n node test/benchmark.mjs [options]\n\nOptions:\n --runs N Measured batches per benchmark (default: 5)\n --warmup N Warm-up batches per benchmark (default: 1)\n --target-ms N Minimum target for short measured batches (default: 400)\n --filter TEXT Run benchmarks whose name or group contains TEXT\n --baseline FILE Compare against a saved timing baseline\n --save FILE Save current medians as a timing baseline\n --json Print machine-readable JSON\n --list List benchmark names and exit\n -h, --help Show this help\n\nAfter one untimed priming execution, short workloads are automatically repeated\nin independent run() executions until a measured batch lasts about --target-ms. The report divides batch time by the\niteration count, so all results remain milliseconds per workload execution.\nOutput digests are always checked against the committed semantic checksums in\ntest/bench/benchmarks.json.\n`);
13
+ }
14
+
15
+ function parseInteger(value, name, minimum) {
16
+ const number = Number(value);
17
+ if (!Number.isInteger(number) || number < minimum) {
18
+ throw new Error(`${name} must be an integer >= ${minimum}`);
19
+ }
20
+ return number;
21
+ }
22
+
23
+ function parseNumber(value, name, minimum) {
24
+ const number = Number(value);
25
+ if (!Number.isFinite(number) || number < minimum) {
26
+ throw new Error(`${name} must be a number >= ${minimum}`);
27
+ }
28
+ return number;
29
+ }
30
+
31
+ const options = {
32
+ runs: 5,
33
+ warmup: 1,
34
+ targetMs: 400,
35
+ filter: null,
36
+ baseline: null,
37
+ save: null,
38
+ json: false,
39
+ list: false,
40
+ };
41
+
42
+ for (let i = 2; i < process.argv.length; i++) {
43
+ const arg = process.argv[i];
44
+ if (arg === '--runs') options.runs = parseInteger(process.argv[++i], '--runs', 1);
45
+ else if (arg === '--warmup') options.warmup = parseInteger(process.argv[++i], '--warmup', 0);
46
+ else if (arg === '--target-ms') options.targetMs = parseNumber(process.argv[++i], '--target-ms', 0);
47
+ else if (arg === '--filter') options.filter = process.argv[++i] ?? '';
48
+ else if (arg === '--baseline') options.baseline = path.resolve(process.argv[++i] ?? '');
49
+ else if (arg === '--save') options.save = path.resolve(process.argv[++i] ?? '');
50
+ else if (arg === '--json') options.json = true;
51
+ else if (arg === '--list') options.list = true;
52
+ else if (arg === '--help' || arg === '-h') { usage(); process.exit(0); }
53
+ else throw new Error(`unknown option: ${arg}`);
54
+ }
55
+
56
+ const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
57
+ if (!Array.isArray(manifest) || manifest.length === 0) throw new Error('benchmark manifest is empty');
58
+
59
+ const names = new Set();
60
+ for (const item of manifest) {
61
+ if (!item || typeof item.name !== 'string' || typeof item.group !== 'string' || typeof item.file !== 'string') {
62
+ throw new Error('invalid benchmark manifest entry');
63
+ }
64
+ if (names.has(item.name)) throw new Error(`duplicate benchmark name: ${item.name}`);
65
+ names.add(item.name);
66
+ if (!Array.isArray(item.goals) || !item.goals.every((goal) => typeof goal === 'string')) {
67
+ throw new Error(`invalid goals for benchmark ${item.name}`);
68
+ }
69
+ if (!/^[0-9a-f]{64}$/.test(item.expectedSha256)) {
70
+ throw new Error(`invalid expectedSha256 for benchmark ${item.name}`);
71
+ }
72
+ }
73
+
74
+ const selected = manifest.filter((item) => {
75
+ if (options.filter == null) return true;
76
+ const needle = options.filter.toLowerCase();
77
+ return item.name.toLowerCase().includes(needle) || item.group.toLowerCase().includes(needle);
78
+ }).sort((a, b) => a.name.localeCompare(b.name));
79
+ if (selected.length === 0) throw new Error(`no benchmarks match filter: ${options.filter}`);
80
+
81
+ if (options.list) {
82
+ for (const item of selected) process.stdout.write(`${item.name}\t${item.group}\t${item.file}\n`);
83
+ process.exit(0);
84
+ }
85
+
86
+ let baselinePath = options.baseline;
87
+ if (baselinePath == null) {
88
+ try {
89
+ await fs.access(defaultBaselinePath);
90
+ baselinePath = defaultBaselinePath;
91
+ } catch (_) {
92
+ baselinePath = null;
93
+ }
94
+ }
95
+
96
+ let baseline = null;
97
+ let baselineWarning = null;
98
+ if (baselinePath != null) {
99
+ const loaded = JSON.parse(await fs.readFile(baselinePath, 'utf8'));
100
+ if (loaded.format === 2) baseline = loaded;
101
+ else {
102
+ baselineWarning = `Ignoring legacy timing baseline format ${loaded.format ?? 'unknown'}; regenerate it with npm run benchmark:baseline.`;
103
+ baselinePath = null;
104
+ }
105
+ }
106
+
107
+ function median(values) {
108
+ const sorted = [...values].sort((a, b) => a - b);
109
+ const middle = Math.floor(sorted.length / 2);
110
+ return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle];
111
+ }
112
+
113
+ function formatMs(value) {
114
+ if (value == null) return '—';
115
+ return `${value.toFixed(1)} ms`;
116
+ }
117
+
118
+ function changePercent(medianMs, baselineMs) {
119
+ if (baselineMs == null || baselineMs === 0) return null;
120
+ return ((medianMs - baselineMs) / baselineMs) * 100;
121
+ }
122
+
123
+ function changeText(item) {
124
+ if (item.changePercent == null) return '—';
125
+ const change = item.changePercent;
126
+ const value = `${change >= 0 ? '+' : ''}${change.toFixed(1)}%`;
127
+ if (Math.abs(change) < 5) return `≈ ${value}`;
128
+ return `${change < 0 ? '↓' : '↑'} ${value}`;
129
+ }
130
+
131
+ function runWorker(item) {
132
+ const child = spawnSync(process.execPath, [
133
+ path.join(root, 'test', 'benchmark-worker.mjs'),
134
+ path.join(root, item.file),
135
+ JSON.stringify(item.goals),
136
+ String(options.runs),
137
+ String(options.warmup),
138
+ String(options.targetMs),
139
+ ], { cwd: root, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, timeout: 120_000 });
140
+ if (child.error) throw new Error(`${item.name} failed: ${child.error.message}`);
141
+ if (child.status !== 0) {
142
+ throw new Error(`${item.name} failed${child.stderr ? `: ${child.stderr.trim()}` : ''}`);
143
+ }
144
+ try { return JSON.parse(child.stdout); }
145
+ catch (error) { throw new Error(`${item.name} returned invalid worker output: ${error.message}`); }
146
+ }
147
+
148
+ const results = [];
149
+ for (const item of selected) {
150
+ if (!options.json) process.stderr.write(`benchmark ${item.name}...\n`);
151
+ const worker = runWorker(item);
152
+ if (worker.digest !== item.expectedSha256) {
153
+ throw new Error(`${item.name} semantic checksum changed: expected ${item.expectedSha256}, got ${worker.digest}`);
154
+ }
155
+ const medianMs = median(worker.samplesMs);
156
+ const minMs = Math.min(...worker.samplesMs);
157
+ const maxMs = Math.max(...worker.samplesMs);
158
+ const baselineItem = baseline?.benchmarks?.[item.name] ?? null;
159
+ results.push({
160
+ name: item.name,
161
+ group: item.group,
162
+ file: item.file,
163
+ goals: item.goals,
164
+ medianMs,
165
+ minMs,
166
+ maxMs,
167
+ batchSize: worker.batchSize,
168
+ calibrationMs: worker.calibrationMs,
169
+ batchSamplesMs: worker.batchSamplesMs,
170
+ samplesMs: worker.samplesMs,
171
+ answerLines: worker.answerLines,
172
+ outputBytes: worker.outputBytes,
173
+ sha256: worker.digest,
174
+ baselineMs: baselineItem?.medianMs ?? null,
175
+ changePercent: changePercent(medianMs, baselineItem?.medianMs ?? null),
176
+ });
177
+ }
178
+
179
+ if (options.save != null) {
180
+ await fs.mkdir(path.dirname(options.save), { recursive: true });
181
+ const saved = {
182
+ format: 2,
183
+ generatedAt: new Date().toISOString(),
184
+ node: process.version,
185
+ platform: `${process.platform}-${process.arch}`,
186
+ runs: options.runs,
187
+ warmup: options.warmup,
188
+ targetMs: options.targetMs,
189
+ benchmarks: Object.fromEntries(results.map((item) => [item.name, {
190
+ medianMs: item.medianMs,
191
+ batchSize: item.batchSize,
192
+ sha256: item.sha256,
193
+ }])),
194
+ };
195
+ await fs.writeFile(options.save, `${JSON.stringify(saved, null, 2)}\n`);
196
+ }
197
+
198
+ if (options.json) {
199
+ process.stdout.write(`${JSON.stringify({
200
+ runs: options.runs,
201
+ warmup: options.warmup,
202
+ targetMs: options.targetMs,
203
+ baseline: baselinePath,
204
+ baselineWarning,
205
+ results,
206
+ }, null, 2)}\n`);
207
+ } else {
208
+ const headers = ['Benchmark', 'Median/op', 'Range/op', 'Batch', 'Baseline', 'Change', 'Answers'];
209
+ const rows = results.map((item) => [
210
+ item.name,
211
+ formatMs(item.medianMs),
212
+ `${formatMs(item.minMs)}–${formatMs(item.maxMs)}`,
213
+ String(item.batchSize),
214
+ formatMs(item.baselineMs),
215
+ changeText(item),
216
+ String(item.answerLines),
217
+ ]);
218
+ const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => row[index].length)));
219
+ const printRow = (row) => process.stdout.write(`${row.map((cell, index) => cell.padEnd(widths[index])).join(' ')}\n`);
220
+ printRow(headers);
221
+ printRow(widths.map((width) => '-'.repeat(width)));
222
+ for (const row of rows) printRow(row);
223
+ process.stdout.write(`\n${results.length} benchmarks; ${options.runs} measured batch${options.runs === 1 ? '' : 'es'} each after ${options.warmup} warm-up batch${options.warmup === 1 ? '' : 'es'}, calibrated after one priming execution toward ${options.targetMs} ms per batch.\n`);
224
+ if (baselineWarning) process.stdout.write(`${baselineWarning}\n`);
225
+ if (baselinePath == null && !baselineWarning) process.stdout.write('No timing baseline found; run npm run benchmark:baseline to create .benchmarks/baseline.json.\n');
226
+ if (options.save != null) process.stdout.write(`Saved timing baseline: ${path.relative(root, options.save)}\n`);
227
+ process.stdout.write('Change compares the current median/op directly with the saved baseline median/op; the measured range is shown separately.\n');
228
+ }
@@ -113,7 +113,7 @@ families; `--iso-strict` is intended to remove their Part 1 interpretation.
113
113
 
114
114
  | Part 1 extension hook | EyeProlog normal-profile feature | Strict-core disposition |
115
115
  | --- | --- | --- |
116
- | 5.5.1 Syntax | Part 2 modules, Part 3 grammar-rule expansion, and embedded quad syntax | Module directives are rejected; grammar rules remain ordinary `-->/2` terms rather than being expanded; quad syntax is rejected. Unicode PCS membership/classification is implementation defined and therefore shared with normal mode rather than treated as an extension. |
116
+ | 5.5.1 Syntax | Part 2 modules, Part 3 grammar-rule expansion, embedded quad syntax, and Trealla-compatible `"text"||Tail` right-splicing for double-quoted `chars`/`codes` lists | Module directives are rejected; grammar rules remain ordinary `-->/2` terms rather than being expanded; quad syntax and double-bar right-splicing are rejected. Unicode PCS membership/classification is implementation defined and therefore shared with normal mode rather than treated as an extension. |
117
117
  | 5.5.2 Predefined operators | Part 3 `|` and EyeProlog's labelable infix `(?-)/2`; CLP(Z) operators when that library is imported | Only the Part 1 operator table is predefined; a conforming `op/3` may still add permitted operators. |
118
118
  | 5.5.3 Character-conversion mapping | No non-identity initial `Convc` extension | Identity initial mapping. |
119
119
  | 5.5.4 Types | The normal JavaScript API exposes an implementation-specific string term `stringTerm(Text)`. It is disjoint from the five Part 1 term types; normal term order places it after atoms and before compound terms, and normal `atomic/1` treats it as atomic. It has no Prolog source token syntax (double-quoted source still follows `double_quotes`), is non-callable for term-to-clause conversion, is not evaluable as an arithmetic expression, and writes as a double-quoted host string. | Strict mode rejects a programmatic string term at program/goal entry with `representation_error(term)`, so the additional type cannot enter the Part 1 execution domain. **defined** — `src/term.js`, `src/program.js`, `src/solver.js`; strict API-boundary regression. |
@@ -1,4 +1,4 @@
1
1
  scryer_escapes(ok).
2
2
  scryer_numbers(-1, 1, 1, 1, 1).
3
3
  scryer_comments(7, 7).
4
- scryer_terms({1}, "a", [a, b | c], []).
4
+ scryer_terms({1}, "a", "ab"||c, []).
@@ -1 +1 @@
1
- answer(append_to_improper_tail, [a, b | tail]).
1
+ answer(append_to_improper_tail, "ab"||tail).
@@ -1 +1 @@
1
- answer([a, b | tail]).
1
+ answer("ab"||tail).
@@ -10,4 +10,4 @@ value(compound, pair(3, nested(atom, "xy"))).
10
10
  value(arity_zero_atom, nil).
11
11
  value(empty_list, []).
12
12
  value(proper_list, "abc").
13
- value(improper_list, [a, b | tail]).
13
+ value(improper_list, "ab"||tail).
@@ -0,0 +1,73 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import process from 'node:process';
4
+ import { spawn } from 'node:child_process';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
8
+ const manifest = JSON.parse(await fs.readFile(path.join(root, 'test', 'bench', 'benchmarks.json'), 'utf8'));
9
+
10
+ let passed = 0;
11
+ function ok(condition, message) {
12
+ if (!condition) throw new Error(message);
13
+ passed++;
14
+ }
15
+
16
+ function spawnJson(args) {
17
+ return new Promise((resolve, reject) => {
18
+ const child = spawn(process.execPath, args, { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
19
+ let stdout = '';
20
+ let stderr = '';
21
+ child.stdout.setEncoding('utf8');
22
+ child.stderr.setEncoding('utf8');
23
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
24
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
25
+ child.on('error', reject);
26
+ child.on('close', (code) => {
27
+ if (code !== 0) return reject(new Error(stderr || `child exited ${code}`));
28
+ try { resolve(JSON.parse(stdout)); }
29
+ catch (error) { reject(new Error(`invalid child JSON: ${error.message}`)); }
30
+ });
31
+ });
32
+ }
33
+
34
+ function runWorker(item) {
35
+ return spawnJson([
36
+ path.join(root, 'test', 'benchmark-worker.mjs'),
37
+ path.join(root, item.file),
38
+ JSON.stringify(item.goals),
39
+ '1',
40
+ '0',
41
+ '0',
42
+ ]);
43
+ }
44
+
45
+ ok(Array.isArray(manifest) && manifest.length === 20, 'benchmark manifest should contain exactly 20 representative workloads');
46
+ ok(new Set(manifest.map((item) => item.name)).size === manifest.length, 'benchmark names should be unique');
47
+
48
+ for (const item of manifest) {
49
+ ok(/^[0-9a-f]{64}$/.test(item.expectedSha256), `${item.name} should have a committed semantic checksum`);
50
+ await fs.access(path.join(root, item.file));
51
+ passed++;
52
+
53
+ const worker = await runWorker(item);
54
+ ok(worker.digest === item.expectedSha256, `${item.name} should preserve its committed semantic checksum`);
55
+ ok(worker.batchSize === 1, `${item.name} digest test should use one execution per batch`);
56
+ ok(Array.isArray(worker.samplesMs) && worker.samplesMs.length === 1, `${item.name} should return one requested sample`);
57
+ ok(worker.samplesMs[0] >= 0, `${item.name} should return a non-negative wall time`);
58
+ }
59
+
60
+ const adaptive = await spawnJson([
61
+ path.join(root, 'test', 'benchmark.mjs'),
62
+ '--filter', 'dcg-command',
63
+ '--runs', '1',
64
+ '--warmup', '0',
65
+ '--target-ms', '50',
66
+ '--json',
67
+ ]);
68
+ ok(adaptive.results.length === 1, 'adaptive benchmark smoke test should select one workload');
69
+ ok(adaptive.results[0].batchSize > 1, 'adaptive benchmark smoke test should batch a short workload');
70
+ ok(adaptive.results[0].sha256 === manifest.find((item) => item.name === 'dcg-command').expectedSha256,
71
+ 'adaptive batching should preserve the semantic checksum');
72
+
73
+ process.stdout.write(`Benchmark harness tests: ${passed}/${passed} passed.\n`);
@@ -2441,7 +2441,7 @@ c4 ?- call((!;1)).
2441
2441
  '?- Xs = [].\n' +
2442
2442
  '?- true.\n' +
2443
2443
  '?- true.\n' +
2444
- '?- Xs0 = [a | Xs].\n' +
2444
+ '?- Xs0 = "a"||Xs.\n' +
2445
2445
  '?- ',
2446
2446
  'stdout');
2447
2447
  assertEqual(result.stderr, '', 'stderr');
@@ -6947,6 +6947,44 @@ function whiteBoxCases() {
6947
6947
  assertEqual(`${quotedAtom.type}:${quotedAtom.name}`, 'atom:aλ', 'atom');
6948
6948
  },
6949
6949
  },
6950
+ {
6951
+ name: 'double-bar syntax splices double-quoted list prefixes (issue #88)',
6952
+ run: () => {
6953
+ const chars = parseGoalText('p("ab"||Tail)').args[0];
6954
+ assertEqual(chars.args[0].name, 'a', 'chars first element');
6955
+ assertEqual(chars.args[1].args[0].name, 'b', 'chars second element');
6956
+ assertEqual(chars.args[1].args[1].type, 'var', 'chars partial tail type');
6957
+ assertEqual(chars.args[1].args[1].name, 'Tail', 'chars partial tail name');
6958
+ assertEqual(termToString(chars, new Env(), true, { doubleBar: true }), '"ab"||Tail', 'chars readback');
6959
+
6960
+ const codes = parseGoalText('p("aλ" || Tail)', { doubleQuotes: 'codes' }).args[0];
6961
+ assertEqual(codes.args[0].name, '97', 'codes first element');
6962
+ assertEqual(codes.args[1].args[0].name, '955', 'codes second element');
6963
+ assertEqual(termToString(codes, new Env(), true, { doubleQuotes: 'codes', doubleBar: true }), '"aλ"||Tail', 'codes readback');
6964
+
6965
+ const nested = parseGoalText('p(["ab"|Tail])');
6966
+ assertEqual(termToString(nested), 'p(["ab" | Tail])', 'single bar remains list syntax');
6967
+
6968
+ for (const options of [{ isoStrict: true }, { doubleQuotes: 'atom' }]) {
6969
+ let threw = false;
6970
+ try { parseGoalText('p("ab"||Tail)', options); } catch (_) { threw = true; }
6971
+ assertEqual(threw, true, `double-bar rejected for ${JSON.stringify(options)}`);
6972
+ }
6973
+ },
6974
+ },
6975
+ {
6976
+ name: 'REPL prints partial character lists with double-bar syntax (issue #88)',
6977
+ run: () => {
6978
+ const result = runCli([], {
6979
+ input: 'phrase("Think of this text, and much more.", S0,S).\nhalt.\n',
6980
+ });
6981
+ assertEqual(result.status, 0, 'REPL exit status');
6982
+ assertIncludes(result.stdout,
6983
+ 'S0 = "Think of this text, and much more."||S.',
6984
+ 'Trealla-compatible partial string answer');
6985
+ assertNotIncludes(result.stdout, "S0 = ['T'", 'legacy expanded list answer');
6986
+ },
6987
+ },
6950
6988
  {
6951
6989
  name: 'double_quotes directives affect subsequent source text',
6952
6990
  run: () => {
@@ -474,6 +474,12 @@ Prolog programs accepted by EyeProlog are built from terms:
474
474
  - compound terms: `point(3, 4)`, `reading(temp, 91)`;
475
475
  - lists: `[]`, `[red, green, blue]`, `[Head | Tail]`.
476
476
 
477
+ In normal mode, double-quoted character/code lists also support Trealla's
478
+ right-splice notation. With `double_quotes(chars)`, `"ab"||Tail` is shorthand
479
+ for `[a,b|Tail]`; with `double_quotes(codes)`, it denotes `[97,98|Tail]`. The
480
+ splice is not available when `double_quotes(atom)` is active, and
481
+ `--iso-strict` rejects it as an implementation-specific syntax extension.
482
+
477
483
  Plain atom constants begin with a lowercase ASCII letter. Variables begin with
478
484
  an uppercase letter or underscore. The bare `_` is anonymous and every
479
485
  occurrence is fresh. `_Name` is a named variable; repeated occurrences refer to
@@ -3147,7 +3153,12 @@ Before replacing one definition with another, record:
3147
3153
 
3148
3154
  Then compare both versions. `--stats` can show fewer calls or unifications, but
3149
3155
  performance evidence comes after semantic evidence. A faster program that
3150
- silently drops a mode is a different program.
3156
+ silently drops a mode is a different program. Predicate and inference counts are
3157
+ also not reliable substitutes for elapsed time: one expensive host call can cost
3158
+ more than thousands of cheap Prolog calls. Repository-level performance work
3159
+ therefore uses `npm run benchmark`, which measures median wall-clock parse+execute
3160
+ time over representative workloads and rejects any run whose answer checksum no
3161
+ longer matches the committed result.
3151
3162
 
3152
3163
  **Exercises.**
3153
3164
 
@@ -5487,8 +5498,10 @@ still retained where adjacent graphic tokens would otherwise merge, as in
5487
5498
  `write_term/2-3` supports `quoted/1`, `ignore_ops/1`, `numbervars/1`, and
5488
5499
  `variable_names/1`. Normal mode additionally accepts the EyeProlog extension
5489
5500
  `double_quotes(true|false)`: `true` lets eligible character/code lists use the
5490
- current `double_quotes` representation, while strict ISO mode rejects this
5491
- implementation-specific write option. Normal mode also accepts the
5501
+ current `double_quotes` representation. Proper lists can therefore be written
5502
+ as `"text"`, while a partial list such as `[a,b|Tail]` is written as
5503
+ `"ab"||Tail`. Strict ISO mode rejects this implementation-specific write
5504
+ option. Normal mode also accepts the
5492
5505
  implementation-specific boolean `spacing(true|false)` option: `false` emits
5493
5506
  only separators required to avoid lexical ambiguity, while `true` adds
5494
5507
  conventional layout around operators. For example,
@@ -5583,6 +5596,8 @@ lowercase ASCII letter. Variables begin with uppercase or underscore. The bare
5583
5596
  `_` is fresh each time. Single quotes delimit quoted atoms; double quotes use
5584
5597
  ISO double-quoted-list notation. Integers, decimals, scientific notation,
5585
5598
  binary/octal/hexadecimal integers, and character-code constants are accepted.
5599
+ Normal mode additionally accepts the Trealla-compatible `"text"||Tail`
5600
+ right-splice for double-quoted `chars`/`codes` lists; strict ISO mode does not.
5586
5601
 
5587
5602
  The processor character set is shared by normal and `--iso-strict` modes because
5588
5603
  Part 1 makes it implementation defined rather than an extension boundary.
@@ -7858,6 +7873,30 @@ with:
7858
7873
  npm test
7859
7874
  ```
7860
7875
 
7876
+ Performance is checked separately so ordinary correctness tests stay deterministic
7877
+ and fast:
7878
+
7879
+ ```sh
7880
+ npm run benchmark
7881
+ npm run benchmark:baseline
7882
+ ```
7883
+
7884
+ The benchmark suite contains 20 representative workloads and stores their
7885
+ semantic output digests in the repository, while wall-clock baselines remain
7886
+ machine-local under `.benchmarks/` because absolute timings are machine-specific.
7887
+ Each benchmark runs in its own fresh Node worker. Inside that worker, one untimed
7888
+ execution primes parser, module, and JIT state; short workloads are then repeated
7889
+ with independent `run()` calls until a batch is roughly 400 ms long. After one
7890
+ warm-up batch, five measured batches are reported as milliseconds per workload
7891
+ execution. Naturally long workloads keep a batch size of one.
7892
+
7893
+ The report shows the median, per-operation range, chosen batch size, saved
7894
+ baseline median, and the percentage change between the current median and baseline
7895
+ median. The range remains visible as context, but it does not suppress or reinterpret
7896
+ the median-to-median comparison. Older unbatched baseline files are ignored with a
7897
+ request to regenerate them. This keeps the rule simple: unchanged answers first,
7898
+ sufficiently long wall-clock samples second, and direct median-versus-median change.
7899
+
7861
7900
  When adding an example:
7862
7901
 
7863
7902
  1. choose a filename that names the mathematical or domain idea;
@@ -7988,7 +8027,9 @@ Notable implementation boundaries are:
7988
8027
  - module and ISO/IEC TS 13211-3-oriented DCG compatibility profiles are supported in normal mode, without a complete Part 2/Part 3 certification claim;
7989
8028
  - variables cannot occupy functor or predicate position;
7990
8029
  - double-quoted text follows `double_quotes` exactly; the default `chars` value
7991
- matches Trealla and Scryer and may be changed to `codes` or `atom`;
8030
+ matches Trealla and Scryer and may be changed to `codes` or `atom`; normal
8031
+ mode additionally accepts Trealla-compatible `"text"||Tail` right-splicing
8032
+ for the `chars` and `codes` values, while strict mode rejects that syntax;
7992
8033
  - `write_term/2-3` implements the Part 1 plus Corrigendum 3 `quoted/1`,
7993
8034
  `ignore_ops/1`, `numbervars/1`, and `variable_names/1` option surface,
7994
8035
  including option validation and traversal rules; normal mode also offers