eyeprolog 1.3.77 → 1.3.79

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
@@ -12,28 +12,22 @@ EyeProlog turns portable ISO Prolog programs into answers and inspectable proofs
12
12
  <strong>Click the cover to read <em>The Art of EyeProlog</em>.</strong>
13
13
  </p>
14
14
 
15
- **[Why EyeProlog?](https://eyereasoner.github.io/eyeprolog/why-eyeprolog)** — Discover its purpose and design.
16
-
17
- **[Playground](https://eyereasoner.github.io/eyeprolog/playground)** — Run EyeProlog in your browser.
18
-
19
- The book is the reference for the language, command line, JavaScript API,
20
- examples, proofs, conformance, and implementation.
15
+ The single implementation reference is [*The Art of EyeProlog*](the-art-of-eyeprolog.md).
16
+ It documents the language, built-ins, libraries, command line, JavaScript API,
17
+ examples, proofs, conformance profile, and implementation.
21
18
 
22
19
  ## Quick start
23
20
 
24
- EyeProlog requires Node.js 18 or newer. Check the active runtime before
25
- installing:
21
+ EyeProlog requires Node.js 18 or newer:
26
22
 
27
23
  ```sh
28
24
  node --version
29
25
  ```
30
26
 
31
- If it reports an older release, upgrade through a Node version manager or the
32
- [official Node.js download](https://nodejs.org/en/download) and check again.
33
- Distribution packages can provide an older Node.js even on a current operating
34
- system.
27
+ If necessary, upgrade through a Node version manager or the
28
+ [official Node.js download](https://nodejs.org/en/download).
35
29
 
36
- Run EyeProlog without a global installation:
30
+ Run EyeProlog without installing it globally:
37
31
 
38
32
  ```sh
39
33
  npx --yes eyeprolog
@@ -45,8 +39,7 @@ npx --yes eyeprolog
45
39
  ?- halt.
46
40
  ```
47
41
 
48
- For a persistent `eyeprolog` command without administrator access, install it
49
- under a user-owned prefix and put that prefix's `bin` directory on `PATH`:
42
+ For a persistent command, use a user-owned npm prefix:
50
43
 
51
44
  ```sh
52
45
  npm install --global --prefix "$HOME/.local" eyeprolog
@@ -54,335 +47,26 @@ export PATH="$HOME/.local/bin:$PATH"
54
47
  eyeprolog
55
48
  ```
56
49
 
57
- Add the `PATH` export to your shell startup file to keep it across sessions.
58
- Do not use `sudo npm install`; npm's
59
- [EACCES guidance](https://docs.npmjs.com/resolving-eacces-permissions-errors-when-installing-packages-globally/)
60
- also recommends a Node version manager or a user-owned npm prefix.
50
+ Add the `PATH` export to your shell startup file. Do not use `sudo npm install`;
51
+ npm's [EACCES guidance](https://docs.npmjs.com/resolving-eacces-permissions-errors-when-installing-packages-globally/)
52
+ also recommends a Node version manager or a user-owned prefix.
61
53
 
62
- For a non-interactive run:
54
+ Run a program non-interactively:
63
55
 
64
56
  ```sh
65
57
  printf 'human(socrates).\nmortal(X) :- human(X).\n' |
66
58
  npx --yes eyeprolog --proof --goal 'mortal(socrates)' -
67
59
  ```
68
60
 
69
- Programs may declare their default queries with `%% goal:` comments.
70
- Double-quoted text follows the ISO `double_quotes` flag and defaults to a
71
- proper list of one-character atoms (`chars`), matching Trealla and Scryer.
72
-
73
- Portable unit tests can be embedded as quads—a query followed by its expected
74
- top-level answer—and run with `eyeprolog --quads program.pl`:
75
-
76
- ```prolog
77
- member_test ?- member(X, [prolog, logic]).
78
- X = prolog
79
- ; X = logic.
80
- ```
81
-
82
- A label is simply the first argument of the ordinary `(?-)/2` term, so it may
83
- use any normal Prolog term syntax; EyeProlog only requires it to be ground when
84
- the quad is checked. A non-ground label is a quad failure, not a source syntax
85
- error, and later quads still run. When a query has multiple indented answer
86
- descriptions, each description is checked and counted independently, so one
87
- failed expectation does not prevent the later ones from running. Following the
88
- Trealla quad convention, `sto` declares that the query is subject to occurs-check.
89
- EyeProlog now uses an occurs-check event observed during the query's ordinary
90
- execution as positive STO evidence, and rejects `sto` when a finite execution
91
- completes without such an event (for example `?- true. sto.`). If execution is
92
- cut short by a search/resource boundary, the STO claim remains conservatively
93
- unverified rather than being guessed.
94
- For nontermination expectations, `loops` is kept distinct from resource
95
- exhaustion: structural loop evidence may satisfy `loops`, while a bounded
96
- search that cannot establish the requested answer sequence is reported as
97
- `UNDECIDED`. In CLI quad mode, failures use exit status `1`; if there are no
98
- failures but at least one undecided quad, the exit status is `2`.
99
-
100
- ## Tabling and well-founded negation
101
-
102
- In normal mode, EyeProlog automatically tables eligible positive recursive
103
- predicates. For large finite, function-free Datalog dependency cones it may use
104
- one shared relation-wide table, so an open query such as `tc(X, Y)` computes a
105
- finite closure once and bound recursive calls can reuse indexed answers. The
106
- choice of when to use relation-wide tabling is an implementation optimization,
107
- not a language-level threshold or directive.
108
-
109
- Recursive nonterminals reached through `phrase/2-3` use a dedicated table
110
- scope keyed by the complete invocation rather than the caller's general memo.
111
- When a new input is seen, recursive DCGs that static analysis proves consume a
112
- list tail on every recursive step run directly without automatic tabling; this
113
- avoids building and retaining a family of suffix tables that cannot help the
114
- next distinct input. If the same `phrase/2-3` invocation repeats, normal tabling
115
- is enabled again and its small completed table can be reused. The retained
116
- phrase table is also bounded, with active fixed points never evicted. This
117
- keeps both long-running filters such as `\+ phrase((..., Pattern), Sequence)`
118
- and repeated same-input probes bounded without turning memory safety into a
119
- per-call cache-eviction cost.
120
-
121
- Finite-tree unification also accepts an internal proven-nonoccurrence hint: when
122
- the solver can prove that a variable cannot occur in the value it is about to
123
- receive, that binding skips an otherwise redundant occurs traversal. The main
124
- source-level case is conservative first use in a freshly renamed clause, inspired
125
- by the local-variable optimization used by WAM-family systems; native construction
126
- paths such as relational `length/2` reuse the same unifier mechanism. Repeated
127
- variables, variables seen earlier in the clause, and ordinary public unification
128
- remain fully occurs-checked. This is a proof local to one binding, not a WAM-style
129
- local/global variable stack. `phrase/2` likewise supplies its fixed `[]`
130
- remainder directly to the grammar; `phrase/3` retains its delayed final output
131
- unification for steadfastness.
132
-
133
- ### Delayed disequality with `dif/2`
134
-
135
- The normal EyeProlog runtime provides `dif/2` as a conforming extension;
136
- `--iso-strict` remains limited to ISO/IEC 13211-1 and does not expose it.
137
- Unlike `\=/2`, which only tests whether two terms are non-unifiable *now*,
138
- `dif/2` can succeed with a residual disequality constraint and reject a later
139
- binding that would make the terms identical. For example,
140
- `dif(X, Y), X = Y` fails, while `dif(X-Y, 1-2), X = Y, Y = 1` succeeds once
141
- the structural constraint becomes provably satisfied.
142
-
143
- The implementation uses backtracking-safe annotated variables in `Env`. A
144
- pending constraint is indexed by every currently unbound variable it mentions;
145
- variable aliasing and later bindings reindex the annotation, entailed constraints
146
- are removed, and violated constraints make unification fail. The interactive
147
- top level includes pending `dif/2` goals in residual answers. See
148
- [`examples/dif-constraints.pl`](examples/dif-constraints.pl).
149
-
150
- ### Attributed variables and CLP(Z)
151
-
152
- `library(atts)` exposes Scryer-style attributed-variable operations on the same
153
- persistent `Env` machinery used by `dif/2`: `put_atts/2`, `get_atts/2`,
154
- `put_attr/3`, `get_attr/3`, `del_attr/2`, `term_attributed_variables/2`, and
155
- `call_residue_vars/2`. The latter reports variables whose residual attributes or
156
- constraints were created or modified by a goal, including native `dif/2` residues.
157
- `:- attribute ...` declarations are accepted, `verify_attributes/3` runs before
158
- an attributed binding is committed, and the hook's returned goals run after
159
- that binding. Attribute state follows variable representatives and ordinary
160
- Prolog backtracking, and the REPL projects module `attribute_goals//1` hooks as
161
- residual goals. See [`examples/attributed-variables.pl`](examples/attributed-variables.pl).
162
-
163
- `library(clpz)` is the MIT-licensed Scryer Prolog implementation of CLP(Z),
164
- bundled as `src/lib/clpz.pl`. It provides relational integer arithmetic,
165
- finite-domain constraints, reification, labeling, global constraints such as
166
- `all_distinct/1`, `global_cardinality/2,3`, `cumulative/1,2`, `circuit/1`,
167
- `disjoint2/1`, and `automaton/3,8`, plus domain reflection predicates. The
168
- bundled source is synchronized with Scryer commit
169
- `e3df91e25f8a09ee942c04e8baef553bba5c6110` (Git blob
170
- `806445c11e14c8b2515f3de7f309e0ac04d9ad04`).
171
-
172
- EyeProlog's runtime supplies the generic facilities used by that Prolog source:
173
- attributed variables, user `term_expansion/2` and `goal_expansion/2`, DCG
174
- expansion through `expand_term/2`, module-aware meta-calls, a backtrackable
175
- blackboard, and the support modules `assoc`, `pairs`, `between`, `dcgs`, `terms`,
176
- `error`, `si`, `freeze`, `arithmetic`, `debug`, and `format`.
177
-
178
- Recursion through negation is explicit. EyeProlog provides `tnot/1` for
179
- well-founded negation over finite, range-restricted, function-free Datalog
180
- components. Ordinary `\+/1` remains negation-as-failure and is not silently
181
- reinterpreted as WFS.
182
-
183
- ```prolog
184
- move(1, 2).
185
- move(2, 1).
186
- win(X) :- move(X, Y), tnot(win(Y)).
187
-
188
- %% goal: win(X)
189
- ```
190
-
191
- A cycle through `tnot/1` can leave atoms *undefined* rather than true or false.
192
- EyeProlog exposes those as conditional successes so they can participate in
193
- collectors such as `findall/3`; it does not currently expose a residual-program
194
- API. Direct `tnot/1` calls must be ground. Variables used in an eligible WFS
195
- rule are range-restricted by positive body literals before they are negated.
196
-
197
- The normal-mode statistics interface includes `wfs_fixpoint_rounds` and
198
- `wfs_undefined_answers`:
199
-
200
- ```prolog
201
- statistics(wfs_fixpoint_rounds, Rounds).
202
- statistics(wfs_undefined_answers, UndefinedObservations).
203
- ```
204
-
205
- Both automatic tabling and `tnot/1` are EyeProlog extensions. Strict ISO mode
206
- disables automatic tabling and does not provide `tnot/1`.
207
-
208
- ## Cleanup-aware control
209
-
210
- Normal mode provides `call_cleanup/2` and `setup_call_cleanup/3` for resource
211
- lifetimes that follow Prolog search. Cleanup runs exactly once when the protected
212
- goal completes deterministically, is exhausted, is cut or otherwise pruned, the
213
- top level stops answer enumeration, or an exception unwinds the search.
214
- `setup_call_cleanup/3` runs Setup once and installs Cleanup only after Setup
215
- succeeds. On ordinary pruning Cleanup sees the current goal bindings; during
216
- exception unwinding bindings made by the protected goal have already been
217
- unwound. Cleanup failure is ignored, and an exception already being propagated
218
- takes precedence over a cleanup exception. Nested cleanups run inside-out.
219
-
220
- These predicates are EyeProlog normal-mode extensions and are absent from
221
- `--iso-strict`. Their implementation is lifecycle-aware: the interactive top
222
- level can report a remaining choicepoint without speculatively requesting the
223
- next solution, while abandoning that choicepoint still closes protected
224
- resources.
225
-
226
- ## OpenRuleBench portable profile
227
-
228
- The `openrulebench/` directory contains a deterministic four-engine adaptation
229
- for EyeProlog, Trealla, Scryer, and SWI-Prolog. Its default `portable` profile
230
- keeps the characteristic joins, recursive closures, and WFS cases while
231
- avoiding benchmark sizes that require multi-gigabyte collectors on some
232
- engines. Run EyeProlog's complete profile with:
233
-
234
- ```sh
235
- ./openrulebench/run-eyeprolog.mjs
236
- ```
237
-
238
- The benchmark README records the expected answer counts. Timing values are
239
- machine-dependent; use the same generated profile when comparing engines.
240
-
241
- ## Strict ISO/IEC 13211-1 core
242
-
243
- For portability and conformance work, run the Part 1 core with Technical
244
- Corrigenda 1–3 in strict mode:
245
-
246
- ```sh
247
- eyeprolog --iso-strict
248
- eyeprolog --iso-strict --goal 'p(X)' program.pl
249
- ```
250
-
251
- The equivalent JavaScript option is `isoStrict: true`. Strict mode keeps
252
- EyeProlog's documented implementation-defined Unicode scalar processor
253
- character set, while excluding normal-profile language extensions such as Part
254
- 2 module compatibility forms, Part 3 DCG expansion, quads, extra libraries,
255
- automatic tabling, and non-standard flags/control facilities. Normal mode is
256
- unchanged.
257
-
258
- This README intentionally stays at the project-overview level. The detailed
259
- implementation reference is [*The Art of EyeProlog*](the-art-of-eyeprolog.md).
260
- The current Part 1 audit status and closure criteria are recorded in
261
- [`test/conformance/ISO-COMPLIANCE.md`](test/conformance/ISO-COMPLIANCE.md);
262
- implementation-defined decisions are indexed in
263
- [`ISO-IMPLEMENTATION-DEFINED.md`](test/conformance/ISO-IMPLEMENTATION-DEFINED.md). The complete
264
- vendored WG17 syntax corpus and the strict ISO regression suite run as release
265
- gates.
266
-
267
- The release-facing Part 1 audit ledger has explicit dispositions for its tracked requirements. This implementation evidence is not an independent ISO certification.
268
-
269
- ## Module and definite clause grammar compatibility profiles
270
-
271
- Normal EyeProlog supports the widely used `module/2`, `use_module/1-2`,
272
- `meta_predicate/1`, and `Module:Goal` interface reflected in later WG17 module
273
- amendment work. This is a practical module compatibility profile; EyeProlog
274
- does **not** currently claim a clause-by-clause implementation or certification
275
- of the complete ISO/IEC 13211-2:2000 module model.
276
-
277
- Definite clause grammar support follows the ISO/IEC TS 13211-3 grammar-rule and
278
- `phrase/2-3` model and is exercised by the conformance corpus. As with Part 1,
279
- that implementation evidence is not an independent certification of every
280
- Part 3 requirement. For example:
281
-
282
- ```prolog
283
- sentence --> [hello], noun.
284
- noun --> [world] | [prolog].
285
-
286
- %% goal: phrase(sentence, Words)
287
- ```
288
-
289
- For a larger bidirectional example, see
290
- [`examples/dcg-expression-language.pl`](examples/dcg-expression-language.pl).
291
- It parses precedence-sensitive arithmetic into an AST, evaluates expressions
292
- with variables, generates tokens back from an AST with only the necessary
293
- parentheses, round-trips the generated form, and demonstrates `phrase/3`
294
- remainder handling. The example uses accumulator nonterminals for
295
- left-associative operators, so state is handed repeatedly from one nonterminal
296
- to the next rather than hidden in host code.
297
-
298
- Deep finite DCG traversal is kept relational but does not have to consume one
299
- general solver frame per token. In particular, the interoperable `... //0`
300
- helper from `library(iso_ext)` can scan a finite compact list iteratively, and a
301
- following grammar that is statically known to leave the DCG state unchanged can
302
- be continued without rebuilding a full clause-resolution frame for every
303
- suffix. Open and remainder-producing uses still retain the ordinary DCG
304
- solutions and backtracking behavior.
61
+ ## Links
305
62
 
306
- EyeProlog also adds 135 public library predicate indicators to its 129-entry ISO
307
- profile. **89 are implemented entirely as ordinary Prolog clauses** in focused
308
- modules under `src/lib/`; the remaining control predicates, attributed-variable
309
- primitives, and the transitional finite-domain `library(clpz)` kernel use
310
- backtrackable host support.
311
- They are ordinary Prolog modules using EyeProlog's documented module compatibility surface, loaded explicitly by purpose, such as
312
- `library(lists)`, `library(lambda)`, `library(strings)`, `library(aggregate)`,
313
- `library(atts)`, or `library(clpz)`.
314
- Portable text
315
- predicates use ISO atoms or character lists. The old catch-all
316
- `library(eyeprolog)` module is no longer needed.
317
-
318
- ## Trealla and Scryer interoperability
319
-
320
- EyeProlog keeps a conservative source-level interoperability profile separate
321
- from the larger EyeProlog library surface. `library(lists)` follows the common
322
- Trealla/Scryer organization for predicates such as `member/2`, `memberchk/2`,
323
- `append/2-3`, `nth0/3-4`, `nth1/3-4`, `length/2`, `maplist/2-8`, and
324
- `foldl/4-6`. Its `length/2` remains relational: with both arguments variable,
325
- `length(Xs, N)` enumerates lists of increasing length together with `N = 0, 1,
326
- 2, ...`. Open-ended generation uses the normal memory guard with recovery
327
- headroom, so an exhausted finite heap is reported as a catchable
328
- `resource_error(memory)` instead of degenerating into quadratic list checks.
329
-
330
- `library(iso_ext)` is also accepted as a common interop module name.
331
- EyeProlog exports `call_nth/2`, `time/1`, and the DCG helper `... //0` there.
332
- The latter describes an arbitrary number of input elements and supports the
333
- nonterminal hand-off benchmark used by the interoperability tests. These common predicates
334
- may be imported explicitly, while source/CLI/API dependency loading can resolve
335
- their unqualified forms conservatively. For Trealla-style interactive timing,
336
- `time/1` is also available directly in the normal EyeProlog runtime; strict ISO
337
- mode does not expose it.
338
- `library(lists)`, `library(iso_ext)`, and `library(freeze)` follow the focused
339
- module organization used by Trealla and Scryer. EyeProlog's legacy
340
- `library(prologue)` is now a compatibility facade over these canonical modules
341
- and `library(between)`, so it can be imported before or after the focused
342
- libraries without duplicate-import errors.
343
-
344
- `library(lambda)` follows Scryer's higher-order lambda notation, adapted from
345
- Ulrich Neumerkel's permissively licensed implementation. Importing it installs
346
- the `+\` operator and enables closures such as `\X^Goal` and
347
- `Free+\X^Goal`. Parameters are supplied by `call/N`; variables not listed in
348
- `Free` are copied afresh for each invocation, while explicitly free variables
349
- remain shared. For example:
350
-
351
- ```prolog
352
- :- use_module(library(lambda)).
353
- :- use_module(library(lists)).
354
-
355
- all_positive(Xs) :- maplist(\X^(X > 0), Xs).
356
- all_equal(Y, Xs) :- maplist(Y+\X^(X = Y), Xs).
357
- ```
358
-
359
- The explicit import is intentional: unlike ordinary predicate autoloading, the
360
- lambda syntax also changes the active operator table.
361
-
362
- Outside `--iso-strict`, an otherwise undefined unqualified call may autoload a
363
- predicate only when the interop profile has one canonical EyeProlog provider.
364
- For example, `member/2` autoloads from `library(lists)`, `call_nth/2` from
365
- `library(iso_ext)`, `call_residue_vars/2` from `library(atts)`, and `between/3`
366
- from `library(between)`. Use `--no-autoload` to disable this
367
- convenience. Strict ISO mode never autoloads library predicates.
368
-
369
- Use `-w` / `--warnings` to diagnose dependencies outside the interop profile,
370
- or `--portable` to make such diagnostics fail the run. This catches both
371
- implementation-specific library names and EyeProlog-only predicates such as
372
- `set_nth0/4` even when they live in an otherwise common module.
373
-
374
- Cross-engine smoke tests live in `test/run-interop.mjs`. With Trealla (`tpl`)
375
- and Scryer (`scryer-prolog`) installed, run:
376
-
377
- ```sh
378
- npm run test:interop
379
- ```
380
-
381
- This optional check runs the same portable Towers of Hanoi source under
382
- EyeProlog, Trealla, and Scryer. The default `npm test` does not require external
383
- Prolog implementations; it does validate all four generated OpenRuleBench
384
- source trees and their engine-specific tabling and WFS adaptations. Run those
385
- fast structural checks separately with `npm run test:openrulebench`.
63
+ - [The Art of EyeProlog](https://eyereasoner.github.io/eyeprolog/the-art-of-eyeprolog) — complete reference
64
+ - [Why EyeProlog?](https://eyereasoner.github.io/eyeprolog/why-eyeprolog) — project scope and design
65
+ - [Playground](https://eyereasoner.github.io/eyeprolog/playground) — run EyeProlog in a browser
66
+ - [Examples](examples) — runnable programs and checked output
67
+ - [ISO conformance audit](test/conformance/ISO-COMPLIANCE.md) — supported Part 1 profile
68
+ - [Conformance report](conformance-report.md) — generated public corpus summary
69
+ - [OpenRuleBench](openrulebench/README.md) — portable benchmark profile
386
70
 
387
71
  ## Development
388
72
 
@@ -393,14 +77,4 @@ npm install
393
77
  npm test
394
78
  ```
395
79
 
396
- The GitHub test workflow runs the complete suite and an npm package dry-run on
397
- both the minimum supported Node.js 18 release line and Node.js 24. Publishing
398
- repeats those release checks before uploading the package.
399
-
400
- The runtime JavaScript modules stay flat under `src/`; the existing `src/lib/`
401
- directory contains the portable Prolog library modules. See
402
- [`src/ARCHITECTURE.md`](src/ARCHITECTURE.md) for the source-layer boundaries,
403
- facade modules, dependency rule, and the requirement that architectural cleanup
404
- must preserve the existing solver hot paths and benchmark performance.
405
-
406
80
  EyeProlog is released under the [MIT License](LICENSE.md).
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.3.77",
6
+ "version": "1.3.79",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
package/src/iso.js CHANGED
@@ -65,9 +65,9 @@ export const isoBuiltins = {
65
65
  registry.add('=..', 2, univBuiltin, { deterministic: true });
66
66
  registry.add('copy_term', 2, copyTermBuiltin, { deterministic: true });
67
67
  registry.add('term_variables', 2, termVariablesBuiltin, { deterministic: true });
68
- registry.add('findall', 3, findallBuiltin);
69
- registry.add('bagof', 3, bagofBuiltin);
70
- registry.add('setof', 3, setofBuiltin);
68
+ registry.add('findall', 3, findallBuiltin, { deterministic: true });
69
+ registry.add('bagof', 3, bagofBuiltin, { deterministicWhen: allSolutionsHasAtMostOneGroup });
70
+ registry.add('setof', 3, setofBuiltin, { deterministicWhen: allSolutionsHasAtMostOneGroup });
71
71
  registry.add('clause', 2, clauseBuiltin, {
72
72
  shouldUse: ({ solver }) => solver.program.findGroup('clause', 2) == null,
73
73
  });
@@ -2367,6 +2367,16 @@ function allSolutionsBuiltin(asSet) {
2367
2367
  const bagofBuiltin = allSolutionsBuiltin(false);
2368
2368
  const setofBuiltin = allSolutionsBuiltin(true);
2369
2369
 
2370
+ function allSolutionsHasAtMostOneGroup({ goal, env }) {
2371
+ try {
2372
+ const { iterated, quantified } = bagGoalParts(goal.args[1], env);
2373
+ return freeVariables(iterated, goal.args[0], quantified, env).length === 0;
2374
+ } catch (_) {
2375
+ // Leave error selection and reporting to the builtin itself.
2376
+ return false;
2377
+ }
2378
+ }
2379
+
2370
2380
  function callable(term, env) {
2371
2381
  term = deref(term, env);
2372
2382
  if (term.type === VAR) throw new PrologError('instantiation_error');
@@ -2823,35 +2833,57 @@ function* negationBuiltin({ solver, goal, env }) {
2823
2833
  for (const _ of solver.cloneForInnerGoal(1).solve([invoked], env.clone(), 0)) return;
2824
2834
  yield env;
2825
2835
  }
2826
- function* solveControlBranch(solver, goal, env) {
2836
+ function* solveControlBranch(solver, goal, env, observePending = null) {
2837
+ const existingStacks = new Set(solver.solveStacks);
2827
2838
  for (const answer of solver.solve([callable(goal, env)], env, 0)) {
2828
2839
  // A branch answer is internal to its enclosing control construct. The
2829
2840
  // surrounding solve will count the completed control goal after the
2830
2841
  // builtin yields it. Leaving both counts in place makes a bounded search
2831
2842
  // such as once/1 or negation stop before it can observe the branch answer.
2832
2843
  if (solver.solutionsSeen > 0) solver.solutionsSeen--;
2844
+ observePending?.(solver.hasPendingAlternatives(existingStacks));
2833
2845
  yield answer;
2834
2846
  }
2835
2847
  }
2836
- function* disjunctionBuiltin({ solver, goal, env }) {
2848
+ function disjunctionBuiltin(context) {
2849
+ const state = { pending: true };
2850
+ const iterator = disjunctionSolutions(context, state);
2851
+ iterator.hasPendingAlternatives = () => state.pending;
2852
+ return iterator;
2853
+ }
2854
+ function* disjunctionSolutions({ solver, goal, env }, state) {
2837
2855
  const left = deref(goal.args[0], env);
2838
2856
  if (left.type === COMPOUND && left.name === '->' && left.arity === 2) {
2839
2857
  for (const conditionEnv of solver.cloneForInnerGoal(1).solve([callable(left.args[0], env)], env.clone(), 0)) {
2840
- yield* solveControlBranch(solver, left.args[1], conditionEnv);
2858
+ yield* solveControlBranch(solver, left.args[1], conditionEnv,
2859
+ (pending) => { state.pending = pending; });
2860
+ state.pending = false;
2841
2861
  return;
2842
2862
  }
2843
- yield* solveControlBranch(solver, goal.args[1], env.clone());
2863
+ yield* solveControlBranch(solver, goal.args[1], env.clone(),
2864
+ (pending) => { state.pending = pending; });
2865
+ state.pending = false;
2844
2866
  return;
2845
2867
  }
2846
2868
  const marker = solver.active[solver.active.length - 1] ?? null;
2847
2869
  const markerCutEpoch = marker?.cutEpoch ?? 0;
2848
2870
  const solverCutEpoch = solver.cutEpoch;
2849
- yield* solveControlBranch(solver, goal.args[0], env.clone());
2871
+ yield* solveControlBranch(solver, goal.args[0], env.clone(), (pending) => {
2872
+ const cutThisScope = marker == null
2873
+ ? solver.cutEpoch !== solverCutEpoch
2874
+ : (marker.cutEpoch ?? 0) !== markerCutEpoch;
2875
+ state.pending = pending || !cutThisScope;
2876
+ });
2850
2877
  const cutThisScope = marker == null
2851
2878
  ? solver.cutEpoch !== solverCutEpoch
2852
2879
  : (marker.cutEpoch ?? 0) !== markerCutEpoch;
2853
- if (cutThisScope) return;
2854
- yield* solveControlBranch(solver, goal.args[1], env.clone());
2880
+ if (cutThisScope) {
2881
+ state.pending = false;
2882
+ return;
2883
+ }
2884
+ yield* solveControlBranch(solver, goal.args[1], env.clone(),
2885
+ (pending) => { state.pending = pending; });
2886
+ state.pending = false;
2855
2887
  }
2856
2888
  function* ifThenBuiltin({ solver, goal, env }) {
2857
2889
  for (const conditionEnv of solver.cloneForInnerGoal(1).solve([callable(goal.args[0], env)], env.clone(), 0)) {
@@ -2875,6 +2907,7 @@ export class BuiltinRegistry {
2875
2907
  arity,
2876
2908
  handler,
2877
2909
  deterministic: options.deterministic ?? false,
2910
+ deterministicWhen: options.deterministicWhen ?? null,
2878
2911
  ready: options.ready ?? null,
2879
2912
  fallbackWhenNotReady: options.fallbackWhenNotReady ?? false,
2880
2913
  shouldUse: options.shouldUse ?? null,
package/src/solver.js CHANGED
@@ -607,14 +607,16 @@ export class Solver {
607
607
  const def = callable ? this.registry.get(goal.name, goal.arity) : null;
608
608
  this.active = active;
609
609
  if (def && builtinIsReadyOrAuthoritative(def, this, goal, env)) {
610
+ const deterministic = def.deterministic ||
611
+ def.deterministicWhen?.({ solver: this, goal, env }) === true;
610
612
  const iterator = def.handler({ solver: this, goal, env });
611
613
  const firstResult = iterator.next();
612
- if (def.deterministic) {
614
+ if (deterministic) {
613
615
  if (!firstResult.done) this.stats.deterministic_builtin_successes++;
614
616
  else this.stats.deterministic_builtin_failures++;
615
617
  }
616
618
  if (firstResult.done) break;
617
- if (!def.deterministic) {
619
+ if (!deterministic) {
618
620
  stack.push({
619
621
  kind: 'resumeBuiltin',
620
622
  iterator,
@@ -819,11 +821,22 @@ export class Solver {
819
821
  }
820
822
  }
821
823
 
822
- hasPendingAlternatives() {
823
- // When solve() is suspended at an answer, active solve stacks contain only
824
- // unexplored work. The demand-driven REPL uses this without speculatively
825
- // pulling the next answer.
826
- return this.solveStacks.some((stack) => stack.length !== 0);
824
+ hasPendingAlternatives(excludedStacks = null) {
825
+ // When solve() is suspended at an answer, active solve stacks contain
826
+ // unexplored work or an iterator frame that can prove it just yielded its
827
+ // final answer. The demand-driven REPL uses this without speculatively
828
+ // pulling the next answer. Control builtins can exclude their caller's
829
+ // pre-existing stacks when inspecting a nested branch.
830
+ return this.solveStacks.some((stack) => !excludedStacks?.has(stack) && stack.some((frame) => {
831
+ if (frame.kind === 'resumeBuiltin' &&
832
+ typeof frame.iterator?.hasPendingAlternatives === 'function') {
833
+ return frame.iterator.hasPendingAlternatives();
834
+ }
835
+ if (frame.kind === 'userClause' && headCannotMatch(frame.goal, frame.clause.head, frame.env)) {
836
+ return false;
837
+ }
838
+ return true;
839
+ }));
827
840
  }
828
841
 
829
842
  fastCountGoal(goal, env) {
@@ -1440,7 +1453,10 @@ function bundledBetweenIterator(solver, group, goal, env) {
1440
1453
  // the generated value in the caller repeatedly revisits all earlier frames.
1441
1454
  // Enumerate the canonical bundled relation directly while leaving user
1442
1455
  // definitions and non-EyeProlog registries on the ordinary Prolog path.
1443
- return bundledBetweenSolutions(solver, goal, env);
1456
+ const state = { pending: true };
1457
+ const iterator = bundledBetweenSolutions(solver, goal, env, state);
1458
+ iterator.hasPendingAlternatives = () => state.pending;
1459
+ return iterator;
1444
1460
  }
1445
1461
 
1446
1462
  function requireBetweenInteger(term, env) {
@@ -1452,7 +1468,7 @@ function requireBetweenInteger(term, env) {
1452
1468
  return BigInt(value.name);
1453
1469
  }
1454
1470
 
1455
- function* bundledBetweenSolutions(solver, goal, env) {
1471
+ function* bundledBetweenSolutions(solver, goal, env, state) {
1456
1472
  const lower = requireBetweenInteger(goal.args[0], env);
1457
1473
  const upper = requireBetweenInteger(goal.args[1], env);
1458
1474
  const requested = deref(goal.args[2], env);
@@ -1462,15 +1478,23 @@ function* bundledBetweenSolutions(solver, goal, env) {
1462
1478
  throw new PrologError('type_error(integer)', requested);
1463
1479
  }
1464
1480
  const value = BigInt(requested.name);
1465
- if (value >= lower && value <= upper) yield env;
1481
+ if (value >= lower && value <= upper) {
1482
+ state.pending = false;
1483
+ yield env;
1484
+ }
1485
+ state.pending = false;
1466
1486
  return;
1467
1487
  }
1468
1488
 
1469
1489
  for (let value = lower; value <= upper; value++) {
1470
1490
  const next = env.clone();
1471
1491
  solver.stats.unify_calls++;
1472
- if (unify(goal.args[2], numberTerm(value), next)) yield next;
1492
+ if (unify(goal.args[2], numberTerm(value), next)) {
1493
+ state.pending = value < upper;
1494
+ yield next;
1495
+ }
1473
1496
  }
1497
+ state.pending = false;
1474
1498
  }
1475
1499
 
1476
1500
  function bundledMemberIterator(solver, group, goal, env) {
@@ -1487,17 +1511,33 @@ function bundledMemberIterator(solver, group, goal, env) {
1487
1511
  let cursor = deref(goal.args[1], env);
1488
1512
  while (isCons(cursor)) cursor = deref(cursor.args[1], env);
1489
1513
  if (!isEmptyList(cursor)) return null;
1490
- return bundledMemberSolutions(solver, goal, env);
1514
+ const state = { pending: true };
1515
+ const iterator = bundledMemberSolutions(solver, goal, env, state);
1516
+ iterator.hasPendingAlternatives = () => state.pending;
1517
+ return iterator;
1491
1518
  }
1492
1519
 
1493
- function* bundledMemberSolutions(solver, goal, env) {
1520
+ function* bundledMemberSolutions(solver, goal, env, state) {
1494
1521
  let cursor = deref(goal.args[1], env);
1495
1522
  while (isCons(cursor)) {
1523
+ const item = cursor.args[0];
1524
+ cursor = deref(cursor.args[1], env);
1496
1525
  const next = env.clone();
1497
1526
  solver.stats.unify_calls++;
1498
- if (unify(goal.args[0], cursor.args[0], next)) yield next;
1527
+ if (unify(goal.args[0], item, next)) {
1528
+ state.pending = bundledMemberMayMatch(goal.args[0], cursor, env);
1529
+ yield next;
1530
+ }
1531
+ }
1532
+ state.pending = false;
1533
+ }
1534
+
1535
+ function bundledMemberMayMatch(target, cursor, env) {
1536
+ while (isCons(cursor)) {
1537
+ if (!goalHeadTermsCannotMatch(target, cursor.args[0], env)) return true;
1499
1538
  cursor = deref(cursor.args[1], env);
1500
1539
  }
1540
+ return false;
1501
1541
  }
1502
1542
 
1503
1543
  function bundledEllipsisPlan(solver, group, goal, rest, env) {
@@ -2849,11 +2889,22 @@ function headCannotMatch(goal, head, env) {
2849
2889
  if (goal.type !== COMPOUND || head.type !== COMPOUND) return false;
2850
2890
  if (goal.name !== head.name || goal.arity !== head.arity) return true;
2851
2891
  for (let i = 0; i < goal.arity; i++) {
2852
- const a = goal.args[i];
2853
- const b = head.args[i];
2854
- // Keep this only as a cheap scalar rejection. unify() remains authoritative.
2855
- const da = derefForLocal(a, env);
2856
- if (isScalarTerm(da) && isScalarTerm(b) && !sameScalarTerm(da, b)) return true;
2892
+ if (goalHeadTermsCannotMatch(goal.args[i], head.args[i], env)) return true;
2893
+ }
2894
+ return false;
2895
+ }
2896
+
2897
+ function goalHeadTermsCannotMatch(goalTerm, headTerm, env) {
2898
+ const pending = [[goalTerm, headTerm]];
2899
+ while (pending.length > 0) {
2900
+ const [goalItem, headItem] = pending.pop();
2901
+ const actual = derefForLocal(goalItem, env);
2902
+ if (actual.type === VAR || headItem.type === VAR) continue;
2903
+ if (actual.type !== headItem.type || actual.name !== headItem.name || actual.arity !== headItem.arity) return true;
2904
+ if (actual.type !== COMPOUND) continue;
2905
+ for (let index = 0; index < actual.arity; index++) {
2906
+ pending.push([actual.args[index], headItem.args[index]]);
2907
+ }
2857
2908
  }
2858
2909
  return false;
2859
2910
  }
@@ -69,25 +69,44 @@ export const eyePrologNativeLibraryIndicators = Object.freeze([
69
69
  'in/2', 'ins/2', 'all_different/1', 'all_distinct/1', 'nvalue/2', 'sum/3',
70
70
  'scalar_product/4', 'tuples_in/2', 'labeling/2', 'label/1',
71
71
  'indomain/1', 'lex_chain/1', 'serialized/2', 'global_cardinality/2',
72
- 'global_cardinality/3', 'circuit/1', 'chain/2', 'element/3', 'zcompare/3',
72
+ 'global_cardinality/3', 'circuit/1', 'cumulative/1', 'cumulative/2',
73
+ 'disjoint2/1', 'automaton/3', 'automaton/8', 'chain/2', 'element/3', 'zcompare/3',
73
74
  'fd_var/1', 'fd_inf/2', 'fd_sup/2', 'fd_size/2', 'fd_dom/2',
75
+ 'clpz_t/2', '#=/3', '#</3',
74
76
  ]);
75
77
  export const eyePrologPortableLibraryIndicators = Object.freeze([
76
78
  'sumall/3', 'aggregate_min/5', 'aggregate_max/5',
77
- 'lt/2', 'gt/2', 'le/2', 'ge/2', 'difference/3',
78
- 'maplist/2', 'maplist/3', 'maplist/4', 'maplist/5',
79
- 'maplist/6', 'maplist/7', 'maplist/8', 'between/3',
80
- 'smallest_divisor_from/3', 'random/3', 'matches/3', 'split/3',
81
- 'replace/4', 'lowercase/2', 'uppercase/2', 'trim/2', 'number_string/2',
82
- 'atom_string/2', 'term_string/2', 'append/2', 'append/3', 'string_concat/3', 'contains/2',
83
- 'matches/2', 'join/3', 'substring/4', 'member/2', 'memberchk/2', 'select/3', 'last/2', 'same_length/2',
84
- 'nth0/3', 'nth0/4', 'nth1/3', 'nth1/4', 'set_nth0/4', 'take/3', 'drop/3', 'slice/4', 'reverse/2',
85
- 'length/2', 'sum_list/2', 'min_list/2', 'max_list/2', 'list_to_set/2',
86
- 'succ/2', 'foldl/4', 'foldl/5', 'foldl/6',
87
- 'forall/2', 'cfor/3', 'findall/4', 'variant/2', '.../2', 'uuid/3',
79
+ 'lsb/2', 'msb/2', 'popcount/2',
80
+ 'empty_assoc/1', 'assoc_to_list/2', 'get_assoc/3', 'put_assoc/4',
81
+ 'between/3', 'gen_int/1', 'gen_nat/1', 'numlist/2', 'numlist/3', 'repeat/1',
82
+ 'lt/2', 'gt/2', 'le/2', 'ge/2',
83
+ 'difference/3',
84
+ 'seq/3', 'seqq/3',
85
+ 'debug/1', 'debug/3', 'nodebug/1', 'bb_get/2', 'bb_b_put/2',
86
+ 'must_be/2', 'can_be/2', 'instantiation_error/0', 'instantiation_error/1',
87
+ 'domain_error/2', 'domain_error/3', 'type_error/2', 'type_error/3',
88
+ 'representation_error/1', 'resource_error/1', 'call_with_error_context/2',
89
+ 'format/2',
90
+ 'forall/2', 'succ/2', 'cfor/3', 'findall/4', 'variant/2', '.../2',
88
91
  '^/3', '^/4', '^/5', '^/6', '^/7', '^/8', '^/9', '^/10',
89
92
  '\\/1', '\\/2', '\\/3', '\\/4', '\\/5', '\\/6', '\\/7', '\\/8',
90
93
  '+\\/2', '+\\/3', '+\\/4', '+\\/5', '+\\/6', '+\\/7', '+\\/8', '+\\/9',
94
+ 'member/2', 'memberchk/2', 'select/3', 'append/2', 'append/3', 'last/2', 'same_length/2',
95
+ 'nth0/3', 'nth0/4', 'nth1/3', 'nth1/4', 'set_nth0/4', 'take/3', 'drop/3', 'slice/4', 'reverse/2',
96
+ 'length/2', 'maplist/2', 'maplist/3', 'maplist/4', 'maplist/5',
97
+ 'maplist/6', 'maplist/7', 'maplist/8', 'foldl/4', 'foldl/5', 'foldl/6',
98
+ 'sum_list/2', 'min_list/2', 'max_list/2', 'list_to_set/2',
99
+ 'pairs_keys_values/3', 'pairs_keys/2', 'pairs_values/2',
100
+ 'group_pairs_by_key/2', 'map_list_to_pairs/3',
101
+ 'smallest_divisor_from/3',
102
+ 'random/3',
103
+ 'atom_si/1', 'integer_si/1', 'atomic_si/1', 'list_si/1', 'character_si/1',
104
+ 'term_si/1', 'chars_si/1', 'dif_si/2', 'not_si/1', 'when_si/2',
105
+ 'matches/3', 'split/3', 'replace/4', 'lowercase/2', 'uppercase/2', 'trim/2',
106
+ 'number_string/2', 'atom_string/2', 'term_string/2', 'string_concat/3',
107
+ 'contains/2', 'matches/2', 'join/3', 'substring/4',
108
+ 'numbervars/3', 'copy_term_nat/2',
109
+ 'uuid/3',
91
110
  ]);
92
111
  export const eyePrologLibraryIndicators = Object.freeze([
93
112
  ...eyePrologPortableLibraryIndicators,
@@ -1826,6 +1826,41 @@ c4 ?- call((!;1)).
1826
1826
  assertEqual(result.stderr, '', 'stderr');
1827
1827
  },
1828
1828
  },
1829
+ {
1830
+ name: 'REPL omits exhausted choicepoints without probing future branches (issue #74)',
1831
+ run: () => {
1832
+ const result = runCli([], {
1833
+ input:
1834
+ 'X=1;X=2.\n;\n' +
1835
+ 'setof(X,true,Xs).\n' +
1836
+ 'findall(t,false,Xs).\n' +
1837
+ 'use_module(library(lists)).\n' +
1838
+ 'member(a,"ba").\n' +
1839
+ 'append("a",Xs,Xs0).\n' +
1840
+ 'halt.\n',
1841
+ });
1842
+ assertEqual(result.status, 0, 'exit status');
1843
+ assertEqual(result.stdout,
1844
+ '?- X = 1\n' +
1845
+ '; X = 2.\n' +
1846
+ '?- Xs = [_A].\n' +
1847
+ '?- Xs = [].\n' +
1848
+ '?- true.\n' +
1849
+ '?- true.\n' +
1850
+ '?- Xs0 = [a | Xs].\n' +
1851
+ '?- ',
1852
+ 'stdout');
1853
+ assertEqual(result.stderr, '', 'stderr');
1854
+
1855
+ const grouped = new Solver(Program.parse(''));
1856
+ const groupedSolutions = grouped.solve([
1857
+ parseGoalText('setof(X,(Y=1,X=a;Y=2,X=b),Xs)'),
1858
+ ], new Env(), 0);
1859
+ assertEqual(groupedSolutions.next().done, false, 'first grouped setof/3 answer');
1860
+ assertEqual(grouped.hasPendingAlternatives(), true, 'genuine grouped setof/3 choicepoint');
1861
+ groupedSolutions.return();
1862
+ },
1863
+ },
1829
1864
  {
1830
1865
  name: 'REPL hides aliases to fresh throw variables while preserving query aliases',
1831
1866
  run: () => {
@@ -3952,7 +3987,15 @@ function documentationSyncCases() {
3952
3987
  },
3953
3988
  {
3954
3989
  name: 'book EyeProlog library matches runtime registry',
3955
- run: () => assertArrayEqual(bookEyePrologLibraryNames(), registeredEyePrologLibraryNames(), 'EyeProlog library predicates'),
3990
+ run: () => {
3991
+ assertArrayEqual(bookEyePrologLibraryNames(), registeredEyePrologLibraryNames(), 'EyeProlog library predicates');
3992
+ assertArrayEqual(bookLibraryModuleIssues(), [], 'library module export catalog');
3993
+ const book = fs.readFileSync(path.join(packageRoot, 'the-art-of-eyeprolog.md'), 'utf8');
3994
+ assertIncludes(book, `**${eyePrologLibraryIndicators.length} distinct non-ISO library and normal-extension predicate`, 'library predicate count');
3995
+ assertIncludes(book, `**${eyePrologPortableLibraryIndicators.length} are defined entirely as ordinary Prolog clauses**`, 'portable library count');
3996
+ assertIncludes(book, `**${eyePrologNativeLibraryIndicators.length} use host support**`, 'host-supported library count');
3997
+ assertIncludes(book, `**${createDefaultRegistry().defs.size + eyePrologLibraryIndicators.length} distinct predicate indicators**`, 'combined catalog count');
3998
+ },
3956
3999
  },
3957
4000
  {
3958
4001
  name: 'README cover links to the book and the book documents runtime boundaries',
@@ -3964,11 +4007,10 @@ function documentationSyncCases() {
3964
4007
  '<a href="https://eyereasoner.github.io/eyeprolog/the-art-of-eyeprolog">\n <img src="book-assets/title-page.svg" alt="Read The Art of EyeProlog"',
3965
4008
  'README cover links to the book',
3966
4009
  );
3967
- for (const filename of ['src/iso.js', 'src/dcg.js', 'src/atts.js', 'src/standard-library.js',
3968
- 'src/lib/aggregate.pl', 'src/lib/atts.pl', 'src/lib/comparison.pl', 'src/lib/dates.pl',
3969
- 'src/lib/iso_ext.pl', 'src/lib/lists.pl', 'src/lib/primes.pl', 'src/lib/prologue.pl',
3970
- 'src/lib/random.pl', 'src/lib/strings.pl', 'src/lib/uuid.pl',
3971
- 'src/playground-worker.js']) {
4010
+ const documentedSources = ['src/iso.js', 'src/dcg.js', 'src/atts.js', 'src/standard-library.js',
4011
+ ...[...standardLibrarySources.values()].map((entry) => entry.filename),
4012
+ 'src/playground-worker.js'];
4013
+ for (const filename of documentedSources) {
3972
4014
  assertEqual(fs.existsSync(path.join(packageRoot, filename)), true, `${filename} exists`);
3973
4015
  assertIncludes(book, filename, `book documents ${filename}`);
3974
4016
  }
@@ -4126,6 +4168,11 @@ function documentationSyncCases() {
4126
4168
  ]) assertIncludes(book, name, `book ${name}`);
4127
4169
  assertIncludes(readme, 'implementation reference is [*The Art of EyeProlog*]', 'README book hand-off');
4128
4170
  assertIncludes(readme, 'test/conformance/ISO-COMPLIANCE.md', 'README concise audit link');
4171
+ assertEqual(readme.split('\n').length <= 100, true, 'README remains a compact entry point');
4172
+ for (const heading of ['## Tabling', '## Cleanup-aware control', '## Strict ISO',
4173
+ '## Module and definite clause grammar', '## Trealla and Scryer interoperability']) {
4174
+ assertNotIncludes(readme, heading, `README delegates ${heading} to the book`);
4175
+ }
4129
4176
  assertEqual(readme.includes('2026-08-23 draft items #73-#76'), false, 'README omits audit-history detail');
4130
4177
  assertIncludes(profile, 'Part 1 processor, syntax, semantic, built-in, and arithmetic', 'Why EyeProlog audit state');
4131
4178
  },
@@ -5084,8 +5131,8 @@ answer(ok) :-
5084
5131
  assertEqual(Boolean(library.get('call_cleanup', 2)), true, 'call_cleanup/2 is an EyeProlog cleanup control');
5085
5132
  assertEqual(registry.get('setup_call_cleanup', 3), null, 'setup_call_cleanup/3 is absent from the ISO registry');
5086
5133
  assertEqual(Boolean(library.get('setup_call_cleanup', 3)), true, 'setup_call_cleanup/3 is an EyeProlog cleanup control');
5087
- assertEqual(registeredNativeEyePrologLibraryNames().length, 49, 'public native EyeProlog builtin count');
5088
- assertEqual(eyePrologPortableLibraryIndicators.length, 87, 'portable Prolog library count');
5134
+ assertEqual(registeredNativeEyePrologLibraryNames().length, 57, 'public host-supported EyeProlog library count');
5135
+ assertEqual(eyePrologPortableLibraryIndicators.length, 135, 'portable Prolog library count');
5089
5136
  assertEqual(eyePrologInteropLibraryIndicators.length, 30, 'cross-implementation interop profile count');
5090
5137
  assertEqual(eyePrologInteropLibraryModules.join(','), 'lists,iso_ext,lambda,atts,freeze', 'common explicit library module profile');
5091
5138
  assertEqual(eyePrologInteropAutoload['member/2'], 'lists', 'member/2 canonical autoload');
@@ -5095,9 +5142,9 @@ answer(ok) :-
5095
5142
  assertEqual(eyePrologInteropAutoload['time/1'], 'iso_ext', 'time/1 canonical interop autoload');
5096
5143
  assertEqual(eyePrologInteropAutoload['.../2'], 'iso_ext', '.../2 canonical interop autoload');
5097
5144
  assertEqual(eyePrologInteropAutoload['set_nth0/4'] ?? null, null, 'EyeProlog-only set_nth0/4 is not autoloadable');
5098
- assertEqual(eyePrologNativeLibraryIndicators.length, 49, 'native host library count');
5145
+ assertEqual(eyePrologNativeLibraryIndicators.length, 57, 'host-supported library count');
5099
5146
  assertEqual(eyePrologNativeLibraryIndicators.slice(0, 3).join(','), 'call_nth/2,freeze/2,dif/2', 'control and constraint predicates requiring host support');
5100
- assertEqual(eyePrologLibraryIndicators.length, 136, 'complete EyeProlog library surface');
5147
+ assertEqual(eyePrologLibraryIndicators.length, 192, 'complete non-ISO EyeProlog library surface');
5101
5148
  assertEqual(registry.get('eyeprolog__call_nth', 2), null, 'private call_nth adapter is absent from ISO registry');
5102
5149
  assertEqual(Boolean(library.get('eyeprolog__call_nth', 2)), true, 'private call_nth adapter is registered for EyeProlog');
5103
5150
  assertEqual(Boolean(library.get('eyeprolog__call_residue_vars', 2)), true, 'private call_residue_vars adapter is registered for EyeProlog');
@@ -6257,7 +6304,45 @@ function bookBuiltinNames() {
6257
6304
 
6258
6305
  function bookEyePrologLibraryNames() {
6259
6306
  const book = fs.readFileSync(path.join(packageRoot, 'the-art-of-eyeprolog.md'), 'utf8');
6260
- return documentedBuiltinNames(between(book, '<!-- eyeprolog-library-catalog:start -->', '<!-- eyeprolog-library-catalog:end -->'), 2);
6307
+ const iso = new Set(registeredBuiltinNames());
6308
+ return documentedBuiltinNames(
6309
+ between(book, '<!-- eyeprolog-library-catalog:start -->', '<!-- eyeprolog-library-catalog:end -->'),
6310
+ 2,
6311
+ ).filter((indicator) => !iso.has(indicator));
6312
+ }
6313
+
6314
+ function bookLibraryModuleIssues() {
6315
+ const book = fs.readFileSync(path.join(packageRoot, 'the-art-of-eyeprolog.md'), 'utf8');
6316
+ const section = between(book, '<!-- eyeprolog-library-catalog:start -->', '<!-- eyeprolog-library-catalog:end -->');
6317
+ const documented = new Map();
6318
+ for (const line of section.split('\n')) {
6319
+ const match = line.match(/^\|\s*`library\(([^)`]+)\)`\s*\|/);
6320
+ if (match == null) continue;
6321
+ documented.set(match[1], documentedBuiltinNames(line, 2));
6322
+ }
6323
+
6324
+ const issues = [];
6325
+ for (const [moduleName, entry] of standardLibrarySources) {
6326
+ const parsed = Program.parse(entry.source);
6327
+ const definition = parsed.modules.get(moduleName);
6328
+ if (definition == null) {
6329
+ issues.push(`missing module declaration for ${moduleName}`);
6330
+ continue;
6331
+ }
6332
+ const actual = [...definition.exports.keys()].sort();
6333
+ const expected = documented.get(moduleName);
6334
+ if (expected == null) {
6335
+ issues.push(`book catalog omits library(${moduleName})`);
6336
+ continue;
6337
+ }
6338
+ if (JSON.stringify(expected) !== JSON.stringify(actual)) {
6339
+ issues.push(`library(${moduleName}) exports: expected ${actual.join(', ')}, documented ${expected.join(', ')}`);
6340
+ }
6341
+ }
6342
+ for (const moduleName of documented.keys()) {
6343
+ if (!standardLibrarySources.has(moduleName)) issues.push(`book catalogs unknown library(${moduleName})`);
6344
+ }
6345
+ return issues.sort();
6261
6346
  }
6262
6347
 
6263
6348
  function bookBuiltinSummary() {
@@ -6211,16 +6211,18 @@ so side effects occur in Prolog execution order.
6211
6211
 
6212
6212
  ### The EyeProlog library
6213
6213
 
6214
- EyeProlog exposes **135 library predicate indicators** in addition to the 129
6215
- indicators in its isolated ISO profile. **89 are defined entirely as ordinary
6216
- Prolog clauses** in focused modules under `src/lib/`. The remaining public
6217
- relations use small private host adapters where control, constraints, or host
6218
- observability cannot be expressed by ordinary clauses alone; `time/1` is one
6219
- such relation. The resulting normal EyeProlog language surface is therefore
6220
- **264 public predicate indicators**. Most library relations remain module source
6221
- clauses over private adapters. `time/1` is additionally registered directly in
6222
- the normal EyeProlog runtime so Trealla-style timing works at the interactive
6223
- top level without an import; it is absent from the strict ISO registry.
6214
+ EyeProlog exposes **192 distinct non-ISO library and normal-extension predicate
6215
+ indicators** in addition to the 129 indicators in its isolated ISO profile.
6216
+ **135 are defined entirely as ordinary Prolog clauses** in focused modules under
6217
+ `src/lib/`; **57 use host support** for control, attributed variables,
6218
+ constraints, or observability. The ISO and library catalogs therefore cover
6219
+ **321 distinct predicate indicators**. Normal-mode controls and observability
6220
+ relations that do not belong to a library, such as `call_cleanup/2`,
6221
+ `setup_call_cleanup/3`, `statistics/0,2`, and `tnot/1`, are documented in their
6222
+ own sections rather than counted as library predicates. `time/1` is additionally
6223
+ registered directly in the normal runtime so Trealla-style timing works at the
6224
+ interactive top level without an import; it is absent from the strict ISO
6225
+ registry.
6224
6226
 
6225
6227
  The sources are `src/lib/aggregate.pl`, `src/lib/arithmetic.pl`,
6226
6228
  `src/lib/assoc.pl`, `src/lib/atts.pl`, `src/lib/between.pl`,
@@ -6256,22 +6258,33 @@ between solution branches.
6256
6258
 
6257
6259
  <!-- eyeprolog-library-catalog:start -->
6258
6260
 
6259
- | Module | Exported predicate indicators |
6260
- | --- | --- |
6261
- | `library(aggregate)` | `sumall/3`, `aggregate_min/5`, `aggregate_max/5` |
6262
- | `library(atts)` | `put_atts/2`, `get_atts/2`, `put_attr/3`, `get_attr/3`, `del_attr/2`, `term_attributed_variables/2`, `call_residue_vars/2` |
6263
- | `library(clpz)` | `#>/2`, `#</2`, `#>=/2`, `#=</2`, `#=/2`, `#\=/2`, `#\/1`, `#<==>/2`, `#==>/2`, `#<==/2`, `#\//2`, `#\/2`, `#/\/2`, `in/2`, `ins/2`, `all_different/1`, `all_distinct/1`, `nvalue/2`, `sum/3`, `scalar_product/4`, `tuples_in/2`, `labeling/2`, `label/1`, `indomain/1`, `lex_chain/1`, `serialized/2`, `global_cardinality/2`, `global_cardinality/3`, `circuit/1`, `chain/2`, `element/3`, `zcompare/3`, `fd_var/1`, `fd_inf/2`, `fd_sup/2`, `fd_size/2`, `fd_dom/2` |
6264
- | `library(comparison)` | `lt/2`, `gt/2`, `le/2`, `ge/2` |
6265
- | `library(dates)` | `difference/3` |
6266
- | `library(iso_ext)` | `call_nth/2`, `countall/2`, `forall/2`, `succ/2`, `cfor/3`, `findall/4`, `variant/2`, `time/1`, `.../2` |
6267
- | Normal runtime extension | `dif/2` |
6268
- | `library(lambda)` | `^/3`, `^/4`, `^/5`, `^/6`, `^/7`, `^/8`, `^/9`, `^/10`, `\/1`, `\/2`, `\/3`, `\/4`, `\/5`, `\/6`, `\/7`, `\/8`, `+\/2`, `+\/3`, `+\/4`, `+\/5`, `+\/6`, `+\/7`, `+\/8`, `+\/9` |
6269
- | `library(lists)` | `member/2`, `memberchk/2`, `select/3`, `append/2`, `append/3`, `last/2`, `same_length/2`, `nth0/3`, `nth0/4`, `nth1/3`, `nth1/4`, `reverse/2`, `length/2`, `maplist/2`, `maplist/3`, `maplist/4`, `maplist/5`, `maplist/6`, `maplist/7`, `maplist/8`, `foldl/4`, `foldl/5`, `foldl/6`, `sum_list/2`, `min_list/2`, `max_list/2`, `list_to_set/2`, `set_nth0/4`, `take/3`, `drop/3`, `slice/4` |
6270
- | `library(primes)` | `smallest_divisor_from/3` |
6271
- | `library(prologue)` | `member/2`, `append/3`, `length/2`, `between/3`, `select/3`, `succ/2`, `maplist/2`, `maplist/3`, `maplist/4`, `maplist/5`, `maplist/6`, `maplist/7`, `maplist/8`, `nth0/3`, `nth0/4`, `nth1/3`, `nth1/4`, `call_nth/2`, `freeze/2`, `foldl/4`, `foldl/5`, `foldl/6`, `countall/2` |
6272
- | `library(random)` | `random/3` |
6273
- | `library(strings)` | `matches/3`, `split/3`, `replace/4`, `lowercase/2`, `uppercase/2`, `trim/2`, `number_string/2`, `atom_string/2`, `term_string/2`, `string_concat/3`, `contains/2`, `matches/2`, `join/3`, `substring/4` |
6274
- | `library(uuid)` | `uuid/3` |
6261
+ | Module | Exported predicate indicators | Primary role |
6262
+ | --- | --- | --- |
6263
+ | `library(aggregate)` | `sumall/3`, `aggregate_min/5`, `aggregate_max/5` | Aggregation |
6264
+ | `library(arithmetic)` | `lsb/2`, `msb/2`, `popcount/2` | CLP(Z) arithmetic support |
6265
+ | `library(assoc)` | `empty_assoc/1`, `assoc_to_list/2`, `get_assoc/3`, `put_assoc/4` | Association lists used by CLP(Z) |
6266
+ | `library(atts)` | `put_atts/2`, `get_atts/2`, `put_attr/3`, `get_attr/3`, `del_attr/2`, `term_attributed_variables/2`, `call_residue_vars/2` | Attributed variables |
6267
+ | `library(between)` | `between/3`, `gen_int/1`, `gen_nat/1`, `numlist/2`, `numlist/3`, `repeat/1` | Integer generation |
6268
+ | `library(clpz)` | `#>/2`, `#</2`, `#>=/2`, `#=</2`, `#=/2`, `#\=/2`, `#\/1`, `#<==>/2`, `#==>/2`, `#<==/2`, `#\//2`, `#\/2`, `#/\/2`, `in/2`, `ins/2`, `all_different/1`, `all_distinct/1`, `nvalue/2`, `sum/3`, `scalar_product/4`, `tuples_in/2`, `labeling/2`, `label/1`, `indomain/1`, `lex_chain/1`, `serialized/2`, `global_cardinality/2`, `global_cardinality/3`, `circuit/1`, `cumulative/1`, `cumulative/2`, `disjoint2/1`, `element/3`, `automaton/3`, `automaton/8`, `zcompare/3`, `chain/2`, `fd_var/1`, `fd_inf/2`, `fd_sup/2`, `fd_size/2`, `fd_dom/2`, `clpz_t/2`, `#=/3`, `#</3` | Constraint logic programming over integers; the final three indicators support reified compatibility libraries |
6269
+ | `library(comparison)` | `lt/2`, `gt/2`, `le/2`, `ge/2` | Generic comparison |
6270
+ | `library(dates)` | `difference/3` | ISO duration differences |
6271
+ | `library(dcgs)` | `phrase/2`, `phrase/3`, `seq/3`, `seqq/3` | DCG compatibility; `seq/3` and `seqq/3` are declared as `seq //1` and `seqq //1` |
6272
+ | `library(debug)` | `debug/1`, `debug/3`, `nodebug/1`, `bb_get/2`, `bb_b_put/2` | Scryer compatibility and backtrackable-blackboard support |
6273
+ | `library(error)` | `must_be/2`, `can_be/2`, `instantiation_error/0`, `instantiation_error/1`, `domain_error/2`, `domain_error/3`, `type_error/2`, `type_error/3`, `representation_error/1`, `resource_error/1`, `call_with_error_context/2` | Error checking and construction |
6274
+ | `library(format)` | `format/2` | Formatted output used by bundled libraries |
6275
+ | `library(freeze)` | `freeze/2` | Delayed goals |
6276
+ | `library(iso_ext)` | `call_nth/2`, `countall/2`, `forall/2`, `succ/2`, `cfor/3`, `findall/4`, `variant/2`, `time/1`, `.../2` | Common ISO extensions |
6277
+ | Normal runtime extension | `dif/2` | Delayed disequality; available without a library import |
6278
+ | `library(lambda)` | `^/3`, `^/4`, `^/5`, `^/6`, `^/7`, `^/8`, `^/9`, `^/10`, `\/1`, `\/2`, `\/3`, `\/4`, `\/5`, `\/6`, `\/7`, `\/8`, `+\/2`, `+\/3`, `+\/4`, `+\/5`, `+\/6`, `+\/7`, `+\/8`, `+\/9` | Higher-order lambda notation |
6279
+ | `library(lists)` | `member/2`, `memberchk/2`, `select/3`, `append/2`, `append/3`, `last/2`, `same_length/2`, `nth0/3`, `nth0/4`, `nth1/3`, `nth1/4`, `reverse/2`, `length/2`, `maplist/2`, `maplist/3`, `maplist/4`, `maplist/5`, `maplist/6`, `maplist/7`, `maplist/8`, `foldl/4`, `foldl/5`, `foldl/6`, `sum_list/2`, `min_list/2`, `max_list/2`, `list_to_set/2`, `set_nth0/4`, `take/3`, `drop/3`, `slice/4` | List relations |
6280
+ | `library(pairs)` | `pairs_keys_values/3`, `pairs_keys/2`, `pairs_values/2`, `group_pairs_by_key/2`, `map_list_to_pairs/3` | Key-value pair support |
6281
+ | `library(primes)` | `smallest_divisor_from/3` | Prime factor support |
6282
+ | `library(prologue)` | `member/2`, `append/3`, `length/2`, `between/3`, `select/3`, `succ/2`, `maplist/2`, `maplist/3`, `maplist/4`, `maplist/5`, `maplist/6`, `maplist/7`, `maplist/8`, `nth0/3`, `nth0/4`, `nth1/3`, `nth1/4`, `call_nth/2`, `freeze/2`, `foldl/4`, `foldl/5`, `foldl/6`, `countall/2` | Legacy facade over canonical focused modules |
6283
+ | `library(random)` | `random/3` | Pure state-threaded random generation |
6284
+ | `library(si)` | `atom_si/1`, `integer_si/1`, `atomic_si/1`, `list_si/1`, `character_si/1`, `term_si/1`, `chars_si/1`, `dif_si/2`, `not_si/1`, `when_si/2` | Sufficient-instantiation checks used by CLP(Z) |
6285
+ | `library(strings)` | `matches/3`, `split/3`, `replace/4`, `lowercase/2`, `uppercase/2`, `trim/2`, `number_string/2`, `atom_string/2`, `term_string/2`, `string_concat/3`, `contains/2`, `matches/2`, `join/3`, `substring/4` | Text relations |
6286
+ | `library(terms)` | `numbervars/3`, `copy_term_nat/2` | Term operations used by bundled libraries |
6287
+ | `library(uuid)` | `uuid/3` | Pure state-threaded UUID generation |
6275
6288
 
6276
6289
  <!-- eyeprolog-library-catalog:end -->
6277
6290