eyeprolog 1.5.94 → 1.5.95

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
@@ -83,8 +83,8 @@ The checked [Symbiotic Knowledge Graphs example](examples/symbiotic-knowledge-gr
83
83
 
84
84
  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.
85
85
 
86
- ## Benchmarks
87
- EyeProlog has 21 checksum-protected wall-clock benchmarks spanning recursion/indexing, constraints, tabling/WFS, DCGs, Eyelet, search, term I/O, attributes, rewriting, the dynamic database, and bignum arithmetic. 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 -- --save .benchmarks/baseline.json`; use `node test/run-benchmark-tests.mjs` for harness checks. Details are in [*The Art of EyeProlog*](the-art-of-eyeprolog.md).
86
+ ## Performance
87
+ EyeProlog does not carry a separate wall-clock benchmark harness. `npm test`'s own elapsed time, run across thousands of conformance, regression, and example programs, is the coarse performance indicator instead a real slowdown shows up there. [OpenRuleBench](openrulebench/README.md) remains a dedicated, checked correctness-and-scale benchmark for the Datalog rule-engine profile specifically.
88
88
  For the project policy on post-ISO-standard and WG17 compatibility features such as digit separators, see [ISO/WG17 compatibility extensions](test/conformance/ISO-WG17-EXTENSIONS.md).
89
89
  ## Development
90
90
  ```sh
@@ -97,7 +97,6 @@ The npm command list is deliberately small:
97
97
 
98
98
  - `npm test` (or `npm run test`): run the release gate, including live upstream conformity checks (WG17 syntax among them).
99
99
  - `npm run generate`: rebuild generated library and book files.
100
- - `npm run benchmark`: run the wall-clock benchmarks.
101
100
 
102
101
  Use `npm test -- --offline` for a network-free local pass (this also skips the live-discovered WG17 syntax check, since it has no offline snapshot). Focused checks remain available directly, for example `node test/run-regression.mjs docs`; see [test runners](test/README.md). The automatic version hooks still run the release gate, refresh and stage conformance reports, and push the release. Detailed upstream report maintenance is documented in the [conformance guide](test/conformance/README.md).
103
102
 
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.5.94",
6
+ "version": "1.5.95",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
@@ -46,7 +46,6 @@
46
46
  },
47
47
  "scripts": {
48
48
  "test": "node test/run-all.mjs",
49
- "benchmark": "node test/benchmark.mjs",
50
49
  "generate": "node tools/generate-library-autoload-index.mjs && node tools/generate-predicate-reference.mjs && node tools/extract-book-examples.mjs",
51
50
  "preversion": "npm test && node test/run-neumerkel.mjs --cached --update-report && node test/run-conformance-report.mjs conformance-report.md && git add test/conformance/NEUMERKEL-LATEST.md conformance-report.md",
52
51
  "postversion": "git push origin HEAD --follow-tags"
@@ -92,15 +92,12 @@ as direct function calls. Candidate indexing is separated physically but
92
92
  retains the same data structures and selection functions.
93
93
 
94
94
  Large solver fast paths deliberately remain co-located in `solver.js` until a
95
- split can demonstrate benchmark parity. A cleaner file layout is not worth a
95
+ split can demonstrate performance parity. A cleaner file layout is not worth a
96
96
  runtime regression.
97
97
 
98
- Performance claims use the wall-clock workloads in `test/bench/`, not predicate,
99
- inference, or host-call counts as a substitute for elapsed time. Each benchmark
100
- runs in its own Node process, warms the engine before measured parse+execute
101
- runs, reports the median, and verifies a committed output SHA-256 before its
102
- timing is accepted. Machine-specific timing baselines live under `.benchmarks/`
103
- and are intentionally not versioned.
98
+ Performance claims use `npm test`'s own elapsed time across the full corpus,
99
+ not predicate, inference, or host-call counts as a substitute for elapsed
100
+ time, and not a separate wall-clock benchmark harness.
104
101
 
105
102
 
106
103
  ## HTTP and JSON library ownership
package/test/README.md CHANGED
@@ -21,7 +21,6 @@ node test/run-properties.mjs # seeded random-term invariant checks (see b
21
21
  node test/run-openrulebench.mjs
22
22
  node test/run-http-json.mjs
23
23
  node test/run-interop.mjs # requires the comparison engines
24
- node test/run-benchmark-tests.mjs # benchmark harness
25
24
  ```
26
25
 
27
26
  These runners retain their existing options; there is no separate npm alias for
@@ -52,7 +51,7 @@ still runs in about two seconds. The seed (`SEED` in that file) is fixed so a
52
51
  failure is exactly reproducible by rerunning it -- change it only
53
52
  deliberately, and say why, never to make a transient failure disappear.
54
53
 
55
- For performance measurements, use `npm run benchmark`. Save a local baseline with
56
- `npm run benchmark -- --save .benchmarks/baseline.json`.
54
+ There is no separate wall-clock benchmark harness: `npm test`'s own elapsed
55
+ time is the project's performance indicator.
57
56
 
58
57
  See the [conformance guide](conformance/README.md) for report maintenance.
@@ -629,7 +629,7 @@ ${profile}`;
629
629
  const publishIndex = publishWorkflow.indexOf('run: npm publish');
630
630
  assertEqual(testIndex >= 0 && testIndex < publishIndex, true, 'publish workflow test gate');
631
631
  assertEqual(packIndex >= 0 && packIndex < publishIndex, true, 'publish workflow package gate');
632
- assertArrayEqual(Object.keys(pkg.scripts).sort(), ['benchmark', 'generate', 'postversion', 'preversion', 'test'], 'small npm command surface');
632
+ assertArrayEqual(Object.keys(pkg.scripts).sort(), ['generate', 'postversion', 'preversion', 'test'], 'small npm command surface');
633
633
  assertEqual(pkg.scripts.test, 'node test/run-all.mjs', 'full release gate');
634
634
  const runner = fs.readFileSync(path.join(packageRoot, 'test', 'run-all.mjs'), 'utf8');
635
635
  assertIncludes(runner, 'runOpenRuleBenchChecks(reporter)', 'OpenRuleBench remains in release gate');
@@ -2083,7 +2083,7 @@ while compact-clause representation and conservative candidate indexes live in
2083
2083
  `src/program-indexing.js`. The solver consumes those same indexes directly;
2084
2084
  large execution fast paths deliberately remain in `src/solver.js` rather than
2085
2085
  being split through extra strategy objects or callbacks. Architectural cleanup
2086
- is required to preserve benchmark performance as well as semantics.
2086
+ is required to preserve performance as well as semantics.
2087
2087
 
2088
2088
  Focused files under `src/lib/` contain the portable extensions, with
2089
2089
  `src/lib/lists.pl` supplying common list relations. They are ordinary Prolog modules using EyeProlog's documented module compatibility
@@ -3221,9 +3221,8 @@ performance evidence comes after semantic evidence. A faster program that
3221
3221
  silently drops a mode is a different program. Predicate and inference counts are
3222
3222
  also not reliable substitutes for elapsed time: one expensive host call can cost
3223
3223
  more than thousands of cheap Prolog calls. Repository-level performance work
3224
- therefore uses `npm run benchmark`, which measures median wall-clock parse+execute
3225
- time over representative workloads and rejects any run whose answer checksum no
3226
- longer matches the committed result.
3224
+ watches `npm test`'s own elapsed time across the whole corpus rather than a
3225
+ dedicated wall-clock benchmark harness.
3227
3226
 
3228
3227
  **Exercises.**
3229
3228
 
@@ -10360,29 +10359,9 @@ with:
10360
10359
  npm test
10361
10360
  ```
10362
10361
 
10363
- Performance is checked separately so ordinary correctness tests stay deterministic
10364
- and fast:
10365
-
10366
- ```sh
10367
- npm run benchmark
10368
- npm run benchmark -- --save .benchmarks/baseline.json
10369
- ```
10370
-
10371
- The benchmark suite contains 21 representative workloads and stores their
10372
- semantic output digests in the repository, while wall-clock baselines remain
10373
- machine-local under `.benchmarks/` because absolute timings are machine-specific.
10374
- Each benchmark runs in its own fresh Node worker. Inside that worker, one untimed
10375
- execution primes parser, module, and JIT state; short workloads are then repeated
10376
- with independent `run()` calls until a batch is roughly 400 ms long. After one
10377
- warm-up batch, five measured batches are reported as milliseconds per workload
10378
- execution. Naturally long workloads keep a batch size of one.
10379
-
10380
- The report shows the median, per-operation range, chosen batch size, saved
10381
- baseline median, and the percentage change between the current median and baseline
10382
- median. The range remains visible as context, but it does not suppress or reinterpret
10383
- the median-to-median comparison. Older unbatched baseline files are ignored with a
10384
- request to regenerate them. This keeps the rule simple: unchanged answers first,
10385
- sufficiently long wall-clock samples second, and direct median-versus-median change.
10362
+ There is no separate wall-clock benchmark harness. `npm test`'s own elapsed
10363
+ time, run across thousands of conformance, regression, and example programs,
10364
+ is the project's performance indicator: a real slowdown shows up there.
10386
10365
 
10387
10366
  When adding an example:
10388
10367
 
@@ -1,193 +0,0 @@
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-expression",
67
- "group": "dcg",
68
- "file": "examples/dcg-expression-language.pl",
69
- "goals": [
70
- "dcg_expression_example(X0, X1)"
71
- ],
72
- "expectedSha256": "562985450257f1db8c67682c6184102622b4e6946f2cb0abd3ac15bde493f5d5"
73
- },
74
- {
75
- "name": "eyelet-forward",
76
- "group": "eyelet",
77
- "file": "test/bench/eyelet-forward.pl",
78
- "goals": [],
79
- "expectedSha256": "127277f2ba5c02d41143b22ac0ae069596a4a7dd4dc71079f3dff1319ca019cc"
80
- },
81
- {
82
- "name": "takeuchi",
83
- "group": "recursive-control",
84
- "file": "examples/takeuchi.pl",
85
- "goals": [
86
- "tak([34, 13, 8], X0)"
87
- ],
88
- "expectedSha256": "a7c33872b86aa7877b9c38ec41c447fc96a328d40f512428993d662f28a097ad"
89
- },
90
- {
91
- "name": "attributed-variables",
92
- "group": "attributes",
93
- "file": "examples/attributed-variables.pl",
94
- "goals": [
95
- "accepts_required_value(X0)",
96
- "alias_preserves_attribute(X0)"
97
- ],
98
- "expectedSha256": "3d60cd3464f2aae44ad01859e838fc2b8754e35916bafe3bba66ca745f77c236"
99
- },
100
- {
101
- "name": "dif-constraints",
102
- "group": "constraints",
103
- "file": "examples/dif-constraints.pl",
104
- "goals": [
105
- "allowed_pair(X0, X1)",
106
- "specialization(X0, X1)"
107
- ],
108
- "expectedSha256": "b6268a6653d02f7312f73406612b451d030a3766931e093003c3642cc170009d"
109
- },
110
- {
111
- "name": "dijkstra-findall-sort",
112
- "group": "search/collections",
113
- "file": "examples/dijkstra-findall-sort.pl",
114
- "goals": [
115
- "shortestPath(X0, X1)",
116
- "cost(X0, X1)"
117
- ],
118
- "expectedSha256": "1318aaaca44aa19f295f988d87b09165cd0fc279e94ae14c10e2d1f146d544f3"
119
- },
120
- {
121
- "name": "iso-term-io",
122
- "group": "term-io",
123
- "file": "examples/iso-term-io.pl",
124
- "goals": [
125
- "report(X0, X1)"
126
- ],
127
- "expectedSha256": "0719932d697e7587285082b558f4c39c04ec1f2222131073e73e6b6fe37e0320"
128
- },
129
- {
130
- "name": "symbolic-derivative",
131
- "group": "term-rewriting",
132
- "file": "examples/symbolic-derivative.pl",
133
- "goals": [
134
- "derivative_result(X0, X1)"
135
- ],
136
- "expectedSha256": "49f9f33e89e8c8c63ce2eb64ea2639e416869d6ef5db185cab70452d1f77f342"
137
- },
138
- {
139
- "name": "sat-dpll",
140
- "group": "search/control",
141
- "file": "examples/sat-solver-dpll.pl",
142
- "goals": [
143
- "satModel(X0)",
144
- "satValue(X0, X1)"
145
- ],
146
- "expectedSha256": "a8c5cdddac5efcb7d5b1031200d3bbc5b78c1668928a45e064377432f05b3c9f"
147
- },
148
- {
149
- "name": "type-inference",
150
- "group": "unification",
151
- "file": "examples/type-inference.pl",
152
- "goals": [
153
- "type_answer(X0, X1)"
154
- ],
155
- "expectedSha256": "acad9ddb995d9ddef091e17cc91659d36ed7f9014d6714fd40100d85a53ae203"
156
- },
157
- {
158
- "name": "bulk-stream-write",
159
- "group": "term-io",
160
- "file": "examples/bulk-stream-write.pl",
161
- "goals": [
162
- "bulk_write_result(X0, X1)"
163
- ],
164
- "expectedSha256": "8471da130a4a3a92dd31ee6cf285b1e129886177d7be367d51650a48b8920324"
165
- },
166
- {
167
- "name": "quine-mccluskey",
168
- "group": "dynamic-database",
169
- "file": "examples/quine-mccluskey.pl",
170
- "goals": [
171
- "answer(X0)"
172
- ],
173
- "expectedSha256": "9ebf4a55f6f15a78ad614da02b418650d77cae39eb0d49cbf9eb614865bec501"
174
- },
175
- {
176
- "name": "fibonacci",
177
- "group": "tabling",
178
- "file": "examples/fibonacci.pl",
179
- "goals": [
180
- "fibonacci(X0, X1)"
181
- ],
182
- "expectedSha256": "3adc819083c11d6bf4195e2718904d1712c21553d8fcad970c11a4a4e79e2a3f"
183
- },
184
- {
185
- "name": "iso-atomic-conversion",
186
- "group": "term-io",
187
- "file": "examples/iso-atomic-conversion.pl",
188
- "goals": [
189
- "report(X0, X1)"
190
- ],
191
- "expectedSha256": "68c82dac7dd666cae5af51786f4fbc75423aac12309cedda1e342aba08ea4253"
192
- }
193
- ]
@@ -1,58 +0,0 @@
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).
@@ -1,94 +0,0 @@
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
- }
@@ -1,28 +0,0 @@
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`);
@@ -1,307 +0,0 @@
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 -- --save .benchmarks/baseline.json.`;
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
- // A regression (slower) reads red, an improvement (faster) reads green, and a
132
- // change too small to trust (< 5%) reads dim rather than either color, so the
133
- // palette itself does not editorialize about noise-level swings.
134
- function changeColor(item) {
135
- if (item.changePercent == null) return null;
136
- if (Math.abs(item.changePercent) < 5) return 'dim';
137
- return item.changePercent < 0 ? 'green' : 'red';
138
- }
139
-
140
- const supportsColor = !options.json && process.stdout.isTTY &&
141
- process.env.NO_COLOR == null && process.env.TERM !== 'dumb';
142
- const ANSI = { bold: '1', dim: '2', green: '32', red: '31' };
143
- function paint(name, text) {
144
- return name && supportsColor ? `\x1b[${ANSI[name]}m${text}\x1b[0m` : text;
145
- }
146
- function visibleLength(text) {
147
- return text.replace(/\x1b\[[0-9;]*m/g, '').length;
148
- }
149
- function padVisible(text, width) {
150
- return text + ' '.repeat(Math.max(0, width - visibleLength(text)));
151
- }
152
-
153
- function summarizeResults(results) {
154
- const comparable = results.filter((item) =>
155
- Number.isFinite(item.medianMs) &&
156
- item.medianMs > 0 &&
157
- Number.isFinite(item.baselineMs) &&
158
- item.baselineMs > 0);
159
-
160
- if (comparable.length === 0) {
161
- return {
162
- comparable: 0,
163
- total: results.length,
164
- ratio: null,
165
- changePercent: null,
166
- currentTotalMs: null,
167
- baselineTotalMs: null,
168
- totalRatio: null,
169
- totalChangePercent: null,
170
- };
171
- }
172
-
173
- const ratio = Math.exp(
174
- comparable.reduce(
175
- (sum, item) => sum + Math.log(item.medianMs / item.baselineMs),
176
- 0,
177
- ) / comparable.length,
178
- );
179
- const currentTotalMs = comparable.reduce((sum, item) => sum + item.medianMs, 0);
180
- const baselineTotalMs = comparable.reduce((sum, item) => sum + item.baselineMs, 0);
181
- const totalRatio = currentTotalMs / baselineTotalMs;
182
-
183
- return {
184
- comparable: comparable.length,
185
- total: results.length,
186
- ratio,
187
- changePercent: (ratio - 1) * 100,
188
- currentTotalMs,
189
- baselineTotalMs,
190
- totalRatio,
191
- totalChangePercent: (totalRatio - 1) * 100,
192
- };
193
- }
194
-
195
- function runWorker(item) {
196
- const child = spawnSync(process.execPath, [
197
- path.join(root, 'test', 'benchmark-worker.mjs'),
198
- path.join(root, item.file),
199
- JSON.stringify(item.goals),
200
- String(options.runs),
201
- String(options.warmup),
202
- String(options.targetMs),
203
- ], { cwd: root, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, timeout: 120_000 });
204
- if (child.error) throw new Error(`${item.name} failed: ${child.error.message}`);
205
- if (child.status !== 0) {
206
- throw new Error(`${item.name} failed${child.stderr ? `: ${child.stderr.trim()}` : ''}`);
207
- }
208
- try { return JSON.parse(child.stdout); }
209
- catch (error) { throw new Error(`${item.name} returned invalid worker output: ${error.message}`); }
210
- }
211
-
212
- const results = [];
213
- for (const item of selected) {
214
- if (!options.json) process.stderr.write(`benchmark ${item.name}...\n`);
215
- const worker = runWorker(item);
216
- if (worker.digest !== item.expectedSha256) {
217
- throw new Error(`${item.name} semantic checksum changed: expected ${item.expectedSha256}, got ${worker.digest}`);
218
- }
219
- const medianMs = median(worker.samplesMs);
220
- const minMs = Math.min(...worker.samplesMs);
221
- const maxMs = Math.max(...worker.samplesMs);
222
- const baselineItem = baseline?.benchmarks?.[item.name] ?? null;
223
- results.push({
224
- name: item.name,
225
- group: item.group,
226
- file: item.file,
227
- goals: item.goals,
228
- medianMs,
229
- minMs,
230
- maxMs,
231
- batchSize: worker.batchSize,
232
- calibrationMs: worker.calibrationMs,
233
- batchSamplesMs: worker.batchSamplesMs,
234
- samplesMs: worker.samplesMs,
235
- answerLines: worker.answerLines,
236
- outputBytes: worker.outputBytes,
237
- sha256: worker.digest,
238
- baselineMs: baselineItem?.medianMs ?? null,
239
- changePercent: changePercent(medianMs, baselineItem?.medianMs ?? null),
240
- });
241
- }
242
-
243
- const summary = summarizeResults(results);
244
-
245
- if (options.save != null) {
246
- await fs.mkdir(path.dirname(options.save), { recursive: true });
247
- const saved = {
248
- format: 2,
249
- generatedAt: new Date().toISOString(),
250
- node: process.version,
251
- platform: `${process.platform}-${process.arch}`,
252
- runs: options.runs,
253
- warmup: options.warmup,
254
- targetMs: options.targetMs,
255
- benchmarks: Object.fromEntries(results.map((item) => [item.name, {
256
- medianMs: item.medianMs,
257
- batchSize: item.batchSize,
258
- sha256: item.sha256,
259
- }])),
260
- };
261
- await fs.writeFile(options.save, `${JSON.stringify(saved, null, 2)}\n`);
262
- }
263
-
264
- if (options.json) {
265
- process.stdout.write(`${JSON.stringify({
266
- runs: options.runs,
267
- warmup: options.warmup,
268
- targetMs: options.targetMs,
269
- baseline: baselinePath,
270
- baselineWarning,
271
- summary,
272
- results,
273
- }, null, 2)}\n`);
274
- } else {
275
- const headers = ['Benchmark', 'Median/op', 'Range/op', 'Batch', 'Baseline', 'Change', 'Answers'];
276
- const rows = results.map((item) => [
277
- item.name,
278
- formatMs(item.medianMs),
279
- `${formatMs(item.minMs)}–${formatMs(item.maxMs)}`,
280
- String(item.batchSize),
281
- formatMs(item.baselineMs),
282
- paint(changeColor(item), changeText(item)),
283
- String(item.answerLines),
284
- ]);
285
- const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => visibleLength(row[index]))));
286
- const printRow = (row, style) => process.stdout.write(
287
- `${row.map((cell, index) => paint(style, padVisible(cell, widths[index]))).join(' ')}\n`,
288
- );
289
- printRow(headers, 'bold');
290
- printRow(widths.map((width) => '-'.repeat(width)), 'dim');
291
- for (const row of rows) printRow(row);
292
-
293
- if (summary.comparable > 0) {
294
- process.stdout.write(
295
- `\nSuite score: ${summary.ratio.toFixed(3)}x baseline ` +
296
- `(${paint(changeColor({ changePercent: summary.changePercent }), changeText({ changePercent: summary.changePercent }))}), ` +
297
- `${summary.comparable}/${summary.total} comparable. ` +
298
- `Time-weighted: ${formatMs(summary.currentTotalMs)} vs ${formatMs(summary.baselineTotalMs)} ` +
299
- `(${paint(changeColor({ changePercent: summary.totalChangePercent }), changeText({ changePercent: summary.totalChangePercent }))}).\n`,
300
- );
301
- }
302
-
303
- process.stdout.write(`\n${results.length} benchmarks, ${options.runs}× after ${options.warmup}× warm-up, ~${options.targetMs} ms/batch.\n`);
304
- if (baselineWarning) process.stdout.write(paint('dim', `${baselineWarning}\n`));
305
- else if (baselinePath == null) process.stdout.write(paint('dim', 'No baseline: npm run benchmark -- --save .benchmarks/baseline.json\n'));
306
- if (options.save != null) process.stdout.write(`Saved baseline: ${path.relative(root, options.save)}\n`);
307
- }
@@ -1,73 +0,0 @@
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 === 21, 'benchmark manifest should contain exactly 21 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-expression',
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-expression').expectedSha256,
71
- 'adaptive batching should preserve the semantic checksum');
72
-
73
- process.stdout.write(`Benchmark harness tests: ${passed}/${passed} passed.\n`);