eyeprolog 1.3.66 → 1.3.67

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
@@ -147,6 +147,26 @@ are removed, and violated constraints make unification fail. The interactive
147
147
  top level includes pending `dif/2` goals in residual answers. See
148
148
  [`examples/dif-constraints.pl`](examples/dif-constraints.pl).
149
149
 
150
+ ### Attributed variables and the CLP(Z) migration
151
+
152
+ `library(atts)` now exposes Scryer-style attributed-variable operations on the
153
+ same persistent `Env` machinery introduced for `dif/2`: `put_atts/2`,
154
+ `get_atts/2`, `put_attr/3`, `get_attr/3`, `del_attr/2`, and
155
+ `term_attributed_variables/2`. `:- attribute ...` declarations are accepted,
156
+ `verify_attributes/3` is invoked before an attributed binding is committed, and
157
+ the hook's returned goals run after that binding. Attribute state follows
158
+ variable representatives and ordinary Prolog backtracking, and the REPL projects
159
+ module `attribute_goals//1` hooks as residual goals. See
160
+ [`examples/attributed-variables.pl`](examples/attributed-variables.pl).
161
+
162
+ This is the first migration stage toward reusing Markus Triska's MIT-licensed
163
+ [Scryer `library(clpz)`](https://github.com/mthom/scryer-prolog/blob/master/src/lib/clpz.pl)
164
+ instead of maintaining a separate constraint solver in `src/clpz.js`. The
165
+ current CLP(Z) implementation remains active while compatibility gaps such as
166
+ Scryer's compile-time term/goal expansion and supporting libraries are closed.
167
+ The staged plan and exact compatibility boundary are documented in
168
+ [`src/CLPZ-MIGRATION.md`](src/CLPZ-MIGRATION.md).
169
+
150
170
  Recursion through negation is explicit. EyeProlog provides `tnot/1` for
151
171
  well-founded negation over finite, range-restricted, function-free Datalog
152
172
  components. Ordinary `\+/1` remains negation-as-failure and is not silently
@@ -275,13 +295,14 @@ be continued without rebuilding a full clause-resolution frame for every
275
295
  suffix. Open and remainder-producing uses still retain the ordinary DCG
276
296
  solutions and backtracking behavior.
277
297
 
278
- EyeProlog also adds 129 public library predicate indicators to its 129-entry ISO
279
- profile. **88 are implemented entirely as ordinary Prolog clauses** in focused
280
- modules under `src/lib/`; the remaining control predicates and finite-domain
281
- `library(clpz)` kernel use backtrackable host support.
298
+ EyeProlog also adds 135 public library predicate indicators to its 129-entry ISO
299
+ profile. **89 are implemented entirely as ordinary Prolog clauses** in focused
300
+ modules under `src/lib/`; the remaining control predicates, attributed-variable
301
+ primitives, and the transitional finite-domain `library(clpz)` kernel use
302
+ backtrackable host support.
282
303
  They are ordinary Prolog modules using EyeProlog's documented module compatibility surface, loaded explicitly by purpose, such as
283
- `library(lists)`, `library(lambda)`, `library(strings)`, `library(aggregate)`, or
284
- `library(clpz)`.
304
+ `library(lists)`, `library(lambda)`, `library(strings)`, `library(aggregate)`,
305
+ `library(atts)`, or `library(clpz)`.
285
306
  Portable text
286
307
  predicates use ISO atoms or character lists. The old catch-all
287
308
  `library(eyeprolog)` module is no longer needed.
@@ -0,0 +1,40 @@
1
+ % Scryer-style attributed variables on EyeProlog's annotated-variable runtime.
2
+ %
3
+ % The attribute declaration is intentionally the same shape used by
4
+ % library(atts) clients such as Scryer's CLP(Z). verify_attributes/3 runs before
5
+ % an attributed variable is bound; goals it returns run after the binding.
6
+
7
+ :- use_module(library(atts)).
8
+ :- attribute required/1.
9
+
10
+ %% goal: accepts_required_value(ok)
11
+ %% goal: alias_preserves_attribute(ok)
12
+
13
+ attach_required(Variable, Value) :-
14
+ put_atts(Variable, required(Value)).
15
+
16
+ verify_attributes(Variable, Other, Goals) :-
17
+ ( get_atts(Variable, required(Value)) ->
18
+ ( var(Other) ->
19
+ put_atts(Other, required(Value)),
20
+ Goals = []
21
+ ; Goals = [required_value(Other, Value)] )
22
+ ; Goals = [] ).
23
+
24
+ required_value(Value, Value).
25
+
26
+ accepts_required_value(ok) :-
27
+ attach_required(X, 7),
28
+ X = 7.
29
+
30
+ alias_preserves_attribute(ok) :-
31
+ attach_required(X, ready),
32
+ X = Y,
33
+ get_atts(Y, required(ready)),
34
+ Y = ready.
35
+
36
+ % The REPL uses the conventional attribute_goals//1 hook to project remaining
37
+ % attributes as residual goals.
38
+ attribute_goals(X) -->
39
+ { get_atts(X, required(Value)) },
40
+ [required(X, Value)].
@@ -0,0 +1,2 @@
1
+ accepts_required_value(ok).
2
+ alias_preserves_attribute(ok).
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.3.66",
6
+ "version": "1.3.67",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
package/playground.html CHANGED
@@ -446,6 +446,7 @@
446
446
  "ancestor",
447
447
  "animal",
448
448
  "annotation",
449
+ "attributed-variables",
449
450
  "auroracare",
450
451
  "backward",
451
452
  "basic-monadic",
@@ -15,7 +15,7 @@ higher-level frontends.
15
15
  `clpz.js`. `cleanup.js` owns lifecycle-aware disposal of protected builtin
16
16
  iterators and registers the normal-profile cleanup controls without making
17
17
  `solver.js` depend back on the language registry.
18
- 4. **Language services** — `iso.js`, `iso-arithmetic.js`, `dcg.js`,
18
+ 4. **Language services** — `iso.js`, `iso-arithmetic.js`, `dcg.js`, `atts.js`,
19
19
  `standard-library.js`, and `src/lib/`.
20
20
  5. **Frontends/tools** — `execute.js`, `repl.js`, `cli.js`, `quads.js`,
21
21
  `explain.js`, and the playground worker.
@@ -36,7 +36,17 @@ Annotations are persistent across `Env.clone()` and therefore backtrack with the
36
36
  substitution. Language services may attach immutable constraint descriptors,
37
37
  but the unconstrained unification hot path only performs a null check; descriptor
38
38
  validation and reindexing run only in environments that actually contain
39
- annotations. `dif/2` is the first user of this mechanism.
39
+ annotations. `dif/2` is the first descriptor-based user of this mechanism.
40
+
41
+ `atts.js` layers Prolog-visible attributed variables over that same persistent
42
+ environment. Per-module attributes follow the current variable representative.
43
+ When an attributed variable is about to be bound, the solver runs that module's
44
+ `verify_attributes/3` against the still-unbound representative; only a successful
45
+ hook permits the binding, and any goals returned in the third argument are queued
46
+ immediately after it. This pre-bind/post-bind split is the compatibility boundary
47
+ needed by Scryer's `library(atts)` and `library(clpz)`. The current CLP(Z)-specific
48
+ `Env._clpz` store remains transitional until the Scryer Prolog implementation can
49
+ replace it without losing test or performance parity; see `src/CLPZ-MIGRATION.md`.
40
50
 
41
51
  The JavaScript runtime stays flat directly under `src/`; the existing `src/lib/`
42
52
  contains Prolog library sources rather than JavaScript runtime modules. The architecture
@@ -0,0 +1,61 @@
1
+ # CLP(Z) migration toward Scryer's Prolog implementation
2
+
3
+ EyeProlog's current `library(clpz)` has a Prolog API in `src/lib/clpz.pl` and a
4
+ CLP(Z)-specific propagation store in `src/clpz.js`. The long-term target is to
5
+ reuse Markus Triska's MIT-licensed [`clpz.pl` from Scryer
6
+ Prolog](https://github.com/mthom/scryer-prolog/blob/master/src/lib/clpz.pl) and
7
+ keep the JavaScript runtime responsible for generic Prolog VM facilities rather
8
+ than a second finite-domain solver.
9
+
10
+ ## Stage 1: attributed-variable substrate
11
+
12
+ This stage is implemented. `Env` carries persistent per-module Prolog
13
+ attributes in addition to the generic constraint descriptors introduced for
14
+ `dif/2`. `src/atts.js` and `library(atts)` expose the operations required by the
15
+ Scryer style: `put_atts/2`, `get_atts/2`, `put_attr/3`, `get_attr/3`,
16
+ `del_attr/2`, and `term_attributed_variables/2`. The parser accepts the
17
+ `attribute` declaration operator, Scryer-style comma-separated
18
+ `:- attribute a/1, b/0.` directives, and `op/3` entries in module export lists.
19
+
20
+ The important unification protocol is also implemented. Before an attributed
21
+ variable is bound to a nonvariable, or before two variables carrying attributes
22
+ for the same owner module are aliased, the solver invokes the owner's
23
+ `verify_attributes/3`. A failed hook rejects the unification. Goals returned in
24
+ the third argument are queued only after the binding succeeds. Aliasing an
25
+ attributed variable with a plain variable transfers the persistent attribute map
26
+ to the surviving representative, which is equivalent to Scryer's CLP(Z)
27
+ `verify_attributes/3` transfer branch and avoids a recursive hook call while
28
+ entering the hook itself.
29
+
30
+ The substrate is covered by regressions for lookup, alias transfer, conflicting
31
+ attributes, post-binding goals, backtracking, `term_attributed_variables/2`, and
32
+ REPL projection through the conventional module `attribute_goals//1` hook.
33
+ `examples/attributed-variables.pl` is the runnable source-level example.
34
+
35
+ ## What still prevents a drop-in Scryer `clpz.pl`
36
+
37
+ The upstream solver is not only an attributed-variable client. It also relies on
38
+ compile-time facilities that EyeProlog does not yet provide generically:
39
+
40
+ - user-defined `term_expansion/2` and `goal_expansion/2` during module loading;
41
+ - the generated predicates at the end of Scryer's `clpz.pl` and its custom Duo
42
+ DCG `++>` expansion;
43
+ - the supporting Scryer library surface (`assoc`, `pairs`, `between`, `dcgs`,
44
+ `terms`, `error`, `si`, `freeze`, `arithmetic`, `debug`, and `format`) at the
45
+ interfaces expected by that source;
46
+
47
+ These are compatibility gaps, not reasons to add more CLP(Z)-specific code to
48
+ `term.js` or `solver.js`.
49
+
50
+ ## Migration rule
51
+
52
+ `src/clpz.js` remains the active implementation until an upstream-based Prolog
53
+ solver passes the existing EyeProlog CLP(Z) corpus and the selected Scryer CLP(Z)
54
+ regressions. During the transition it is the behavioral reference, not the
55
+ architecture to extend. New generic VM work should go into attributed variables,
56
+ term/goal expansion, or reusable library support where possible.
57
+
58
+ Once the upstream source loads and reaches parity, remove the corresponding
59
+ `eyeprolog__clpz_*` adapters and the `_clpz` store in one or more reviewable
60
+ steps. Keep the upstream copyright and MIT license notice intact and keep local
61
+ patches small enough that future Scryer updates can be diffed mechanically.
package/src/atts.js ADDED
@@ -0,0 +1,224 @@
1
+ // Generic attributed-variable bridge for Prolog libraries.
2
+ //
3
+ // The storage is owned by Env so attributes backtrack with substitutions. This
4
+ // module exposes the small atts interface used by Scryer libraries. Attribute
5
+ // verification itself is scheduled by term.js and executed by Solver before a
6
+ // binding is committed; goals returned by verify_attributes/3 run immediately
7
+ // after the binding.
8
+
9
+ import {
10
+ ATOM, COMPOUND, VAR, atom, compound, deref, listFromItems,
11
+ properListItems, unify, variable,
12
+ } from './term.js';
13
+ import { PrologError } from './errors.js';
14
+
15
+ function callerModule(goal) {
16
+ return goal.module ?? 'user';
17
+ }
18
+
19
+ function attributeSignature(term) {
20
+ if (term.type === ATOM) return { name: term.name, arity: 0 };
21
+ if (term.type === COMPOUND) return { name: term.name, arity: term.arity };
22
+ return null;
23
+ }
24
+
25
+ function normalizedAttribute(term, env) {
26
+ let resolved = deref(term, env);
27
+ let mode = 'put';
28
+ if (resolved.type === COMPOUND && resolved.arity === 1 && (resolved.name === '+' || resolved.name === '-')) {
29
+ mode = resolved.name === '-' ? 'delete' : 'put';
30
+ resolved = deref(resolved.args[0], env);
31
+ }
32
+ if (resolved.type === VAR) throw new PrologError('instantiation_error');
33
+ const signature = attributeSignature(resolved);
34
+ if (signature == null) throw new PrologError('type_error(callable)', resolved);
35
+ return { mode, attribute: resolved, ...signature };
36
+ }
37
+
38
+ function putOne(next, variableTerm, owner, attributeTerm) {
39
+ const variableValue = deref(variableTerm, next);
40
+ if (variableValue.type !== VAR) throw new PrologError('uninstantiation_error', variableValue);
41
+ const normalized = normalizedAttribute(attributeTerm, next);
42
+ if (normalized.mode === 'delete') {
43
+ next.deletePrologAttribute(variableValue.name, owner, normalized.name, normalized.arity);
44
+ return;
45
+ }
46
+ next.putPrologAttribute(variableValue.name, owner, normalized.attribute);
47
+ }
48
+
49
+ function* putAttsBuiltin({ goal, env }) {
50
+ const next = env.clone();
51
+ const owner = callerModule(goal);
52
+ const variableTerm = deref(goal.args[0], next);
53
+ if (variableTerm.type !== VAR) throw new PrologError('uninstantiation_error', variableTerm);
54
+ const list = properListItems(goal.args[1], next);
55
+ if (list != null) {
56
+ for (const attribute of list) putOne(next, variableTerm, owner, attribute);
57
+ } else {
58
+ putOne(next, variableTerm, owner, goal.args[1]);
59
+ }
60
+ yield next;
61
+ }
62
+
63
+ function* getAttsBuiltin({ goal, env }) {
64
+ const variableTerm = deref(goal.args[0], env);
65
+ if (variableTerm.type !== VAR) throw new PrologError('uninstantiation_error', variableTerm);
66
+ const owner = callerModule(goal);
67
+ const pattern = deref(goal.args[1], env);
68
+ const attributes = env.prologAttributes(variableTerm.name, owner);
69
+ if (pattern.type === VAR) {
70
+ if (attributes.length === 0) return;
71
+ const next = env.clone();
72
+ if (unify(goal.args[1], listFromItems(attributes), next)) yield next;
73
+ return;
74
+ }
75
+
76
+ let mode = 'present';
77
+ let inner = pattern;
78
+ if (pattern.type === COMPOUND && pattern.arity === 1 && (pattern.name === '+' || pattern.name === '-')) {
79
+ mode = pattern.name === '-' ? 'absent' : 'present';
80
+ inner = deref(pattern.args[0], env);
81
+ }
82
+ if (inner.type === VAR) throw new PrologError('instantiation_error');
83
+ const signature = attributeSignature(inner);
84
+ if (signature == null) throw new PrologError('type_error(callable)', inner);
85
+ const stored = env.getPrologAttribute(variableTerm.name, owner, signature.name, signature.arity);
86
+ if (mode === 'absent') {
87
+ if (stored == null) yield env.clone();
88
+ return;
89
+ }
90
+ if (stored == null) return;
91
+ const next = env.clone();
92
+ if (unify(inner, stored, next, { skipVariableConstraints: true })) yield next;
93
+ }
94
+
95
+ function attributeName(term, env) {
96
+ const resolved = deref(term, env);
97
+ if (resolved.type === VAR) throw new PrologError('instantiation_error');
98
+ if (resolved.type !== ATOM) throw new PrologError('type_error(atom)', resolved);
99
+ return resolved.name;
100
+ }
101
+
102
+ function* putAttrBuiltin({ goal, env }) {
103
+ const next = env.clone();
104
+ const owner = callerModule(goal);
105
+ const variableTerm = deref(goal.args[0], next);
106
+ if (variableTerm.type !== VAR) throw new PrologError('uninstantiation_error', variableTerm);
107
+ const name = attributeName(goal.args[1], next);
108
+ next.putPrologAttribute(variableTerm.name, owner, compound(name, [goal.args[2]]));
109
+ yield next;
110
+ }
111
+
112
+ function* getAttrBuiltin({ goal, env }) {
113
+ const variableTerm = deref(goal.args[0], env);
114
+ if (variableTerm.type !== VAR) return;
115
+ const owner = callerModule(goal);
116
+ const name = attributeName(goal.args[1], env);
117
+ const stored = env.getPrologAttribute(variableTerm.name, owner, name, 1);
118
+ if (stored == null) return;
119
+ const next = env.clone();
120
+ if (unify(goal.args[2], stored.args[0], next, { skipVariableConstraints: true })) yield next;
121
+ }
122
+
123
+ function* delAttrBuiltin({ goal, env }) {
124
+ const next = env.clone();
125
+ const variableTerm = deref(goal.args[0], next);
126
+ if (variableTerm.type !== VAR) { yield next; return; }
127
+ const owner = callerModule(goal);
128
+ const name = attributeName(goal.args[1], next);
129
+ next.deletePrologAttribute(variableTerm.name, owner, name, null);
130
+ yield next;
131
+ }
132
+
133
+ function collectTermVariables(term, env) {
134
+ const result = [];
135
+ const seen = new Set();
136
+ const stack = [term];
137
+ while (stack.length) {
138
+ const current = deref(stack.pop(), env);
139
+ if (current.type === VAR) {
140
+ if (!seen.has(current.name)) {
141
+ seen.add(current.name);
142
+ result.push(current);
143
+ }
144
+ continue;
145
+ }
146
+ if (current.type === COMPOUND) {
147
+ for (let i = current.arity - 1; i >= 0; i--) stack.push(current.args[i]);
148
+ }
149
+ }
150
+ return result;
151
+ }
152
+
153
+ function* termAttributedVariablesBuiltin({ goal, env }) {
154
+ const variables = collectTermVariables(goal.args[0], env).filter((term) => env.hasPrologAttributes(term.name));
155
+ const next = env.clone();
156
+ if (unify(goal.args[1], listFromItems(variables), next)) yield next;
157
+ }
158
+
159
+ function* getAttrListBuiltin({ goal, env }) {
160
+ const variableTerm = deref(goal.args[0], env);
161
+ if (variableTerm.type !== VAR) return;
162
+ const entries = env.prologAttributes(variableTerm.name);
163
+ if (entries.length === 0) return;
164
+ const encoded = entries.map(({ module, attribute }) => compound(':', [atom(module), attribute]));
165
+ const next = env.clone();
166
+ if (unify(goal.args[1], listFromItems(encoded), next)) yield next;
167
+ }
168
+
169
+ function* getFromAttrListBuiltin({ goal, env }) {
170
+ const variableTerm = deref(goal.args[0], env);
171
+ if (variableTerm.type !== VAR) return;
172
+ const moduleTerm = deref(goal.args[1], env);
173
+ if (moduleTerm.type === VAR) throw new PrologError('instantiation_error');
174
+ if (moduleTerm.type !== ATOM) throw new PrologError('type_error(atom)', moduleTerm);
175
+ const pattern = deref(goal.args[2], env);
176
+ if (pattern.type === VAR) throw new PrologError('instantiation_error');
177
+ const signature = attributeSignature(pattern);
178
+ if (signature == null) throw new PrologError('type_error(callable)', pattern);
179
+ const stored = env.getPrologAttribute(variableTerm.name, moduleTerm.name, signature.name, signature.arity);
180
+ if (stored == null) return;
181
+ const next = env.clone();
182
+ if (unify(pattern, stored, next, { skipVariableConstraints: true })) yield next;
183
+ }
184
+
185
+ function* putToAttrListBuiltin({ goal, env }) {
186
+ const next = env.clone();
187
+ const variableTerm = deref(goal.args[0], next);
188
+ if (variableTerm.type !== VAR) throw new PrologError('uninstantiation_error', variableTerm);
189
+ const moduleTerm = deref(goal.args[1], next);
190
+ if (moduleTerm.type === VAR) throw new PrologError('instantiation_error');
191
+ if (moduleTerm.type !== ATOM) throw new PrologError('type_error(atom)', moduleTerm);
192
+ const normalized = normalizedAttribute(goal.args[2], next);
193
+ next.putPrologAttribute(variableTerm.name, moduleTerm.name, normalized.attribute);
194
+ yield next;
195
+ }
196
+
197
+ function* delFromAttrListBuiltin({ goal, env }) {
198
+ const next = env.clone();
199
+ const variableTerm = deref(goal.args[0], next);
200
+ if (variableTerm.type !== VAR) { yield next; return; }
201
+ const moduleTerm = deref(goal.args[1], next);
202
+ if (moduleTerm.type === VAR) throw new PrologError('instantiation_error');
203
+ if (moduleTerm.type !== ATOM) throw new PrologError('type_error(atom)', moduleTerm);
204
+ const normalized = normalizedAttribute(goal.args[2], next);
205
+ next.deletePrologAttribute(variableTerm.name, moduleTerm.name, normalized.name, normalized.arity);
206
+ yield next;
207
+ }
208
+
209
+ export const attsBuiltins = {
210
+ register(registry) {
211
+ registry.add('put_atts', 2, putAttsBuiltin, { deterministic: true, eyePrologLibrary: true });
212
+ registry.add('get_atts', 2, getAttsBuiltin, { deterministic: true, eyePrologLibrary: true });
213
+ // Scryer's CLP(Z) sources use these through goal expansion. EyeProlog
214
+ // provides them directly so the source does not depend on goal_expansion/2.
215
+ registry.add('put_attr', 3, putAttrBuiltin, { deterministic: true, eyePrologLibrary: true });
216
+ registry.add('get_attr', 3, getAttrBuiltin, { deterministic: true, eyePrologLibrary: true });
217
+ registry.add('del_attr', 2, delAttrBuiltin, { deterministic: true, eyePrologLibrary: true });
218
+ registry.add('$term_attributed_variables', 2, termAttributedVariablesBuiltin, { deterministic: true, eyePrologLibrary: true });
219
+ registry.add('$get_attr_list', 2, getAttrListBuiltin, { deterministic: true, eyePrologLibrary: true });
220
+ registry.add('$get_from_attr_list', 3, getFromAttrListBuiltin, { deterministic: true, eyePrologLibrary: true });
221
+ registry.add('$put_to_attr_list', 3, putToAttrListBuiltin, { deterministic: true, eyePrologLibrary: true });
222
+ registry.add('$del_from_attr_list', 3, delFromAttrListBuiltin, { deterministic: true, eyePrologLibrary: true });
223
+ },
224
+ };
@@ -0,0 +1,20 @@
1
+ /* Attributed variables compatibility layer.
2
+
3
+ The public surface follows Scryer Prolog library(atts). EyeProlog keeps the
4
+ actual attribute store and verify_attributes/3 scheduling in the generic
5
+ annotated-variable runtime, so libraries can use put_atts/2 and get_atts/2
6
+ without code-generation term expansion.
7
+ */
8
+
9
+ :- module(atts, [
10
+ put_atts/2,
11
+ get_atts/2,
12
+ put_attr/3,
13
+ get_attr/3,
14
+ del_attr/2,
15
+ term_attributed_variables/2
16
+ ]).
17
+ :- op(1199, fx, attribute).
18
+
19
+ term_attributed_variables(Term, Variables) :-
20
+ '$term_attributed_variables'(Term, Variables).
package/src/parser.js CHANGED
@@ -372,9 +372,15 @@ class Parser {
372
372
  if (directive.type !== COMPOUND || directive.name !== 'use_module' || ![1, 2].includes(directive.arity)) return;
373
373
  const designation = directive.args[0];
374
374
  if (designation?.type !== COMPOUND || designation.name !== 'library' || designation.arity !== 1 ||
375
- designation.args[0]?.type !== ATOM || designation.args[0].name !== 'clpz') return;
376
- for (const [priority, specifier, name] of CLPZ_OPERATOR_DEFINITIONS) {
377
- this.defineOperator(priority, specifier, name);
375
+ designation.args[0]?.type !== ATOM) return;
376
+ if (designation.args[0].name === 'clpz') {
377
+ for (const [priority, specifier, name] of CLPZ_OPERATOR_DEFINITIONS) {
378
+ this.defineOperator(priority, specifier, name);
379
+ }
380
+ } else if (designation.args[0].name === 'atts') {
381
+ // Scryer library(atts) exports this declaration operator. EyeProlog
382
+ // handles the directive directly instead of relying on term expansion.
383
+ this.defineOperator(1199, 'fx', 'attribute');
378
384
  }
379
385
  }
380
386
  operatorTokenName(token = this.token) {
@@ -1020,13 +1026,22 @@ class Parser {
1020
1026
  }
1021
1027
  if (this.token.type === TOK.IF) {
1022
1028
  this.advance();
1023
- const directive = this.parseTerm();
1029
+ const attributeSequence = !this.strictIso && this.operatorTokenName() === 'attribute';
1030
+ let directive = this.parseTerm(0, attributeSequence);
1031
+ // Scryer's library(atts) convention permits declarations such as
1032
+ // `:- attribute a/1, b/0.` without parentheses. At program level the
1033
+ // comma would otherwise sit outside the prefix term; fold it back into
1034
+ // the single attribute/1 directive before directive classification.
1035
+ if (attributeSequence && directive.type === COMPOUND && directive.name === ',' && directive.arity === 2 &&
1036
+ directive.args[0].type === COMPOUND && directive.args[0].name === 'attribute' && directive.args[0].arity === 1) {
1037
+ directive = compound('attribute', [compound(',', [directive.args[0].args[0], directive.args[1]])]);
1038
+ }
1024
1039
  const coreDirective = directive.type === 'compound' && (
1025
1040
  (['dynamic', 'multifile', 'discontiguous', 'initialization', 'include', 'ensure_loaded'].includes(directive.name) && directive.arity === 1) ||
1026
1041
  (['char_conversion', 'set_prolog_flag'].includes(directive.name) && directive.arity === 2)
1027
1042
  );
1028
1043
  const extensionDirective = directive.type === 'compound' && (
1029
- (['use_module', 'meta_predicate'].includes(directive.name) && directive.arity === 1) ||
1044
+ (['use_module', 'meta_predicate', 'attribute'].includes(directive.name) && directive.arity === 1) ||
1030
1045
  (['module', 'use_module'].includes(directive.name) && directive.arity === 2)
1031
1046
  );
1032
1047
  if (this.strictIso && extensionDirective) {
@@ -1044,7 +1059,7 @@ class Parser {
1044
1059
  // Module loading declarations describe the compilation unit rather
1045
1060
  // than an executable source clause, so they do not shift proof clause
1046
1061
  // numbers in the importing file.
1047
- if (!['module', 'use_module', 'meta_predicate'].includes(directive.name)) clauseNumber++;
1062
+ if (!['module', 'use_module', 'meta_predicate', 'attribute'].includes(directive.name)) clauseNumber++;
1048
1063
  if (this.sourceMetadata) clause.source = { filename: this.filename, line, clause: clauseNumber };
1049
1064
  accept(clause);
1050
1065
  continue;
package/src/program.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Program representation and clause indexing.
2
2
  // Indexes are deliberately conservative: they speed up common scalar arguments but never replace unification as the final check.
3
- import { ATOM, COMPOUND, STRING, VAR, Env, atom, compound, deref, flattenConjunction, isScalar, numberTerm, properListItems, termToString, variable } from './term.js';
3
+ import { ATOM, COMPOUND, NUMBER, STRING, VAR, Env, atom, compound, deref, flattenConjunction, isScalar, numberTerm, properListItems, termToString, variable } from './term.js';
4
4
  import { formatTermForWrite } from './write.js';
5
5
  import {
6
6
  ISO_OPERATOR_DEFINITIONS,
@@ -1195,6 +1195,19 @@ function moduleExportIndicators(term) {
1195
1195
  if (items == null) return null;
1196
1196
  const indicators = [];
1197
1197
  for (const item of items) {
1198
+ // Scryer-style modules may export operators alongside predicate
1199
+ // indicators, for example op(700, xfx, #=) from library(clpz).
1200
+ // Operator visibility while parsing bundled libraries is handled by the
1201
+ // parser's imported-library operator table; module import resolution only
1202
+ // needs procedure indicators here.
1203
+ if (item.type === COMPOUND && item.name === 'op' && item.arity === 3) {
1204
+ const [priority, specifier, names] = item.args;
1205
+ const operatorNames = names.type === ATOM ? [names] : properListItems(names, new Env());
1206
+ if (priority.type !== NUMBER || !/^\d+$/.test(priority.name) || Number(priority.name) > 1200 ||
1207
+ specifier.type !== ATOM || !['fx', 'fy', 'xf', 'yf', 'xfx', 'xfy', 'yfx'].includes(specifier.name) ||
1208
+ operatorNames == null || operatorNames.some((name) => name.type !== ATOM)) return null;
1209
+ continue;
1210
+ }
1198
1211
  if (item.type !== COMPOUND || !['/', '//'].includes(item.name) || item.arity !== 2) return null;
1199
1212
  const indicator = predicateIndicator(item.args[0], item.args[1]);
1200
1213
  if (!indicator) return null;
package/src/repl.js CHANGED
@@ -753,6 +753,24 @@ function formatAnswer(engine, state, variables, env) {
753
753
  doubleQuotes: state.solver.prologFlags.get('double_quotes')?.value?.name ?? 'chars',
754
754
  }));
755
755
  }
756
+ const projectedAttributeRoots = new Set();
757
+ for (const variable of variables) {
758
+ const root = engine.deref(variable, env);
759
+ if (root.type !== 'var' || projectedAttributeRoots.has(root.name) || !env.hasPrologAttributes?.(root.name)) continue;
760
+ projectedAttributeRoots.add(root.name);
761
+ names.set(root.name, variable.name);
762
+ for (const residual of state.solver.attributeResidualGoals(root, env)) {
763
+ collectUnboundVariables(engine, residual, env, names, () => `_${letterName(generated++)}`);
764
+ bindings.push(engine.formatTermForWrite(residual, env, {
765
+ quoted: true,
766
+ operators,
767
+ variableNames: names,
768
+ operatorAtomsAsArgs: true,
769
+ dottedGraphicAtoms: true,
770
+ doubleQuotes: state.solver.prologFlags.get('double_quotes')?.value?.name ?? 'chars',
771
+ }));
772
+ }
773
+ }
756
774
  return bindings.length === 0 ? 'true' : bindings.join(', ');
757
775
  }
758
776
 
package/src/solver.js CHANGED
@@ -3,7 +3,7 @@
3
3
  import {
4
4
  ATOM, COMPOUND, NUMBER, STRING, VAR, Env, compactListLength, compactVariableList, compound, cons, copyResolved, deref, emptyList,
5
5
  flattenConjunction, freshTerm, isCons, isDecimalInteger, isEmptyList,
6
- numberTerm, numberTextFromDouble, termIsGround, termToString, unify, variable, variantTerms,
6
+ numberTerm, numberTextFromDouble, properListItems, termIsGround, termToString, unify, variable, variantTerms,
7
7
  } from './term.js';
8
8
  import { numberValueKey, sameNumberValue } from './number-value.js';
9
9
  import { PrologError, getStrictIsoRegistry } from './iso.js';
@@ -32,6 +32,23 @@ function qualifyTerm(term, module) {
32
32
  return term;
33
33
  }
34
34
 
35
+ function renameVariableInTerm(term, fromName, toName) {
36
+ if (fromName === toName) return term;
37
+ const stack = [term];
38
+ const seen = new Set();
39
+ while (stack.length) {
40
+ const current = stack.pop();
41
+ if (current == null || seen.has(current)) continue;
42
+ seen.add(current);
43
+ if (current.type === VAR) {
44
+ if (current.name === fromName) current.name = toName;
45
+ continue;
46
+ }
47
+ if (current.type === COMPOUND) for (const arg of current.args) stack.push(arg);
48
+ }
49
+ return term;
50
+ }
51
+
35
52
  export function nextFreshId() {
36
53
  return ++freshCounter;
37
54
  }
@@ -120,6 +137,8 @@ export class Solver {
120
137
  raiseOccursCheckError(left, right, env);
121
138
  }
122
139
  };
140
+ this.attributeHookRunner = (module, attributed, other, env) =>
141
+ this.runAttributeHook(module, attributed, other, env);
123
142
  this.charConversions = options.charConversions ?? new Map();
124
143
  if (!options.prologFlags) {
125
144
  if (['chars', 'codes', 'atom'].includes(program.doubleQuotes)) {
@@ -303,6 +322,54 @@ export class Solver {
303
322
  }
304
323
  }
305
324
 
325
+ runAttributeHook(module, attributed, other, env) {
326
+ if (this.program.findGroup('verify_attributes', 3, module) == null) return true;
327
+ const goalsVariable = variable(`\u0000attributeGoals${nextFreshId()}`);
328
+ const hook = compound('verify_attributes', [attributed, other, goalsVariable]);
329
+ qualifyTerm(hook, module);
330
+ const child = this.cloneForInnerGoal(1);
331
+ const iterator = child.solve([hook], env.clone(), 0);
332
+ const result = iterator.next();
333
+ try { iterator.return?.(); } catch (_) { /* best-effort iterator cleanup */ }
334
+ this.absorbStatsFrom(child);
335
+ if (result.done) return false;
336
+ env.adopt(result.value);
337
+ env.setOccursCheckHandler(this.occursCheckHandler);
338
+ env.setAttributeHookRunner(this.attributeHookRunner);
339
+ const goals = properListItems(goalsVariable, env);
340
+ if (goals == null) throw new PrologError('type_error(list)', deref(goalsVariable, env));
341
+ for (const goal of goals) qualifyTerm(goal, module);
342
+ env.enqueueAttributeGoals(goals);
343
+ return true;
344
+ }
345
+
346
+ attributeResidualGoals(variableTerm, env) {
347
+ const root = deref(variableTerm, env);
348
+ if (root.type !== VAR || !env.hasPrologAttributes(root.name)) return [];
349
+ const residuals = [];
350
+ for (const module of env.prologAttributeModules(root.name)) {
351
+ if (this.program.findGroup('attribute_goals', 3, module) == null) continue;
352
+ const goalsVariable = variable(`\u0000attributeResidual${nextFreshId()}`);
353
+ const projection = compound('attribute_goals', [root, goalsVariable, emptyList()]);
354
+ qualifyTerm(projection, module);
355
+ const child = this.cloneForInnerGoal(1);
356
+ const iterator = child.solve([projection], env.clone(), 0);
357
+ const result = iterator.next();
358
+ try { iterator.return?.(); } catch (_) { /* best-effort iterator cleanup */ }
359
+ this.absorbStatsFrom(child);
360
+ if (result.done) continue;
361
+ const goals = properListItems(goalsVariable, result.value);
362
+ if (goals == null) throw new PrologError('type_error(list)', deref(goalsVariable, result.value));
363
+ const projectedRoot = deref(root, result.value);
364
+ for (const goal of goals) {
365
+ const residual = copyResolved(goal, result.value);
366
+ if (projectedRoot.type === VAR) renameVariableInTerm(residual, projectedRoot.name, root.name);
367
+ residuals.push(residual);
368
+ }
369
+ }
370
+ return residuals;
371
+ }
372
+
306
373
  runInitializations() {
307
374
  if (this.program._initializationsExecuted) return;
308
375
  for (const goal of this.program.initializations ?? []) {
@@ -321,6 +388,7 @@ export class Solver {
321
388
  *solve(goals, env = new Env(), depth = 0) {
322
389
  if (!Array.isArray(goals)) goals = [goals];
323
390
  env.setOccursCheckHandler(this.occursCheckHandler);
391
+ env.setAttributeHookRunner(this.attributeHookRunner);
324
392
  if (this.isoStrict) rejectStrictIsoStringTerms(goals, env);
325
393
 
326
394
  const writeVariableState = this.writeVariableState;
@@ -386,6 +454,7 @@ export class Solver {
386
454
  goals = frame.goals;
387
455
  env = frame.env;
388
456
  env.setOccursCheckHandler(this.occursCheckHandler);
457
+ env.setAttributeHookRunner(this.attributeHookRunner);
389
458
  depth = frame.depth;
390
459
  let active = frame.active;
391
460
 
@@ -408,6 +477,9 @@ export class Solver {
408
477
  }
409
478
  if (this.solutionsSeen >= this.solutionLimit) break;
410
479
 
480
+ const pendingAttributeGoals = env.takePendingAttributeGoals?.() ?? [];
481
+ if (pendingAttributeGoals.length > 0) goals = [...pendingAttributeGoals, ...goals];
482
+
411
483
  const readyDelays = env.takeReadyDelays();
412
484
  if (readyDelays.length > 0) {
413
485
  const awakened = readyDelays.map(({ goal, module }) => {
@@ -1971,7 +2043,7 @@ function fastCountBranch(branch, env) {
1971
2043
 
1972
2044
  function fastCountPureGoal(solver, goal, env) {
1973
2045
  if (solver.isoStrict || solver.solutionLimit !== Infinity || solver.maxInferences !== Infinity ||
1974
- env._clpz != null || env._variableConstraints != null || (env._delays != null && env._delays.size !== 0)) return null;
2046
+ env._clpz != null || env._variableConstraints != null || env._prologAttributes != null || (env._delays != null && env._delays.size !== 0)) return null;
1975
2047
  const budget = { remaining: 8192 };
1976
2048
  const branches = expandPureCountGoal(solver, goal, goal.module ?? 'user', new Set(), budget);
1977
2049
  if (branches == null) return null;
@@ -1981,6 +2053,7 @@ function fastCountPureGoal(solver, goal, env) {
1981
2053
  }
1982
2054
 
1983
2055
  function tryPushScalarFactRunFrames(stack, solver, goals, env, depth, active) {
2056
+ if (env._prologAttributes != null) return false;
1984
2057
  // Consecutive scalar-fact lookups are common in data-heavy joins. Short
1985
2058
  // joins are fastest when their continuation frames are materialized locally;
1986
2059
  // wide joins can explode combinatorially, so they are streamed lazily.
@@ -2164,6 +2237,11 @@ function matchScalarFactLocal(goal, head, env, names, values) {
2164
2237
  }
2165
2238
 
2166
2239
  function matchScalarFact(goal, head, env) {
2240
+ if (env._prologAttributes != null) {
2241
+ const next = env.clone();
2242
+ if (!unify(goal, head, next)) return null;
2243
+ return next;
2244
+ }
2167
2245
  // A scalar ground fact has no variables to freshen and no compound structure
2168
2246
  // to traverse. Match the goal arguments directly and clone only after the
2169
2247
  // candidate has succeeded.
@@ -4,12 +4,14 @@
4
4
  // source-level interop autoloader declared below.
5
5
  import { PrologError, createDefaultRegistry, eyePrologLibraryBuiltins } from './iso.js';
6
6
  import { clpzBuiltins } from './clpz.js';
7
+ import { attsBuiltins } from './atts.js';
7
8
  import { registerCleanupBuiltins } from './cleanup.js';
8
9
  import { fs, isNode, memoryStatistics } from './platform.js';
9
10
  import { ATOM, VAR, atom, deref, numberTerm, unify } from './term.js';
10
11
 
11
12
  const moduleFiles = Object.freeze({
12
13
  aggregate: 'aggregate.pl',
14
+ atts: 'atts.pl',
13
15
  clpz: 'clpz.pl',
14
16
  comparison: 'comparison.pl',
15
17
  dates: 'dates.pl',
@@ -49,6 +51,7 @@ function libraryUrl(filename) {
49
51
 
50
52
  export const eyePrologNativeLibraryIndicators = Object.freeze([
51
53
  'call_nth/2', 'freeze/2', 'dif/2', 'countall/2', 'time/1',
54
+ 'put_atts/2', 'get_atts/2', 'put_attr/3', 'get_attr/3', 'del_attr/2', 'term_attributed_variables/2',
52
55
  '#>/2', '#</2', '#>=/2', '#=</2', '#=/2', '#\\=/2', '#\\/1',
53
56
  '#<==>/2', '#==>/2', '#<==/2', '#\\//2', '#\\/2', '#/\\/2',
54
57
  'in/2', 'ins/2', 'all_different/1', 'all_distinct/1', 'nvalue/2', 'sum/3',
@@ -137,7 +140,7 @@ export const eyePrologInteropLibraryIndicators = Object.freeze(
137
140
  // Libraries whose *name* is part of the current interop profile. A program
138
141
  // may freely use_module/1 with these common module names; predicates in those
139
142
  // modules outside eyePrologInteropLibraryIndicators are still diagnosed when used.
140
- export const eyePrologInteropLibraryModules = Object.freeze(['lists', 'iso_ext', 'lambda']);
143
+ export const eyePrologInteropLibraryModules = Object.freeze(['lists', 'iso_ext', 'lambda', 'atts']);
141
144
 
142
145
  function* tabledNegationBuiltin({ solver, goal, env }) {
143
146
  yield* solver.solveTabledNegation(goal.args[0], env);
@@ -184,6 +187,7 @@ export function createEyePrologRegistry() {
184
187
  registry.add('tnot', 1, tabledNegationBuiltin, { deterministic: true });
185
188
  registerCleanupBuiltins(registry);
186
189
  eyePrologLibraryBuiltins.register(registry);
190
+ attsBuiltins.register(registry);
187
191
  clpzBuiltins.register(registry);
188
192
  registry.eyePrologLibrary = true;
189
193
  return registry;
package/src/term.js CHANGED
@@ -102,6 +102,13 @@ export class Env {
102
102
  // is copied only when a branch adds, removes, or reindexes a constraint.
103
103
  this._variableConstraints = null;
104
104
  this._variableAnnotations = null;
105
+ // Prolog-visible attributed variables. The outer map is indexed by the
106
+ // current unbound representative name; each representative owns module-
107
+ // scoped attribute terms keyed by functor/arity. Maps are copy-on-write so
108
+ // Env.clone() keeps ordinary Prolog backtracking constant-time.
109
+ this._prologAttributes = null;
110
+ this._attributeHookRunner = null;
111
+ this._pendingAttributeGoals = null;
105
112
  this._clpz = null;
106
113
  this._occursCheckHandler = null;
107
114
  this._localVariables = null;
@@ -117,6 +124,9 @@ export class Env {
117
124
  clone._delays = this._delays;
118
125
  clone._variableConstraints = this._variableConstraints;
119
126
  clone._variableAnnotations = this._variableAnnotations;
127
+ clone._prologAttributes = this._prologAttributes;
128
+ clone._attributeHookRunner = this._attributeHookRunner;
129
+ clone._pendingAttributeGoals = this._pendingAttributeGoals;
120
130
  clone._clpz = this._clpz;
121
131
  clone._occursCheckHandler = this._occursCheckHandler;
122
132
  clone._localVariables = this._localVariables;
@@ -126,6 +136,24 @@ export class Env {
126
136
  this._occursCheckHandler = typeof handler === 'function' ? handler : null;
127
137
  return this;
128
138
  }
139
+ setAttributeHookRunner(handler) {
140
+ this._attributeHookRunner = typeof handler === 'function' ? handler : null;
141
+ return this;
142
+ }
143
+ adopt(other) {
144
+ if (!(other instanceof Env)) throw new TypeError('Env.adopt expects Env');
145
+ this._state = other._state;
146
+ this._delays = other._delays;
147
+ this._variableConstraints = other._variableConstraints;
148
+ this._variableAnnotations = other._variableAnnotations;
149
+ this._prologAttributes = other._prologAttributes;
150
+ this._pendingAttributeGoals = other._pendingAttributeGoals;
151
+ this._clpz = other._clpz;
152
+ // Execution callbacks belong to the Solver driving this Env, not to the
153
+ // logical branch being adopted from an inner attribute-hook call.
154
+ this._localVariables = other._localVariables;
155
+ return this;
156
+ }
129
157
  hasLocalVariables() {
130
158
  return this._localVariables != null && this._localVariables.size !== 0;
131
159
  }
@@ -243,6 +271,150 @@ export class Env {
243
271
  if (ready.length > 0) this._delays = remaining;
244
272
  return ready;
245
273
  }
274
+ _attributeRootName(name) {
275
+ const root = deref(variable(name), this);
276
+ return root.type === VAR ? root.name : null;
277
+ }
278
+ _attributeModulesForRoot(name) {
279
+ const root = this._attributeRootName(name);
280
+ return root == null ? null : (this._prologAttributes?.get(root) ?? null);
281
+ }
282
+ prologAttributes(name, module = null) {
283
+ const modules = this._attributeModulesForRoot(name);
284
+ if (modules == null) return [];
285
+ if (module != null) return [...(modules.get(module)?.values() ?? [])];
286
+ const result = [];
287
+ for (const [owner, attrs] of modules) {
288
+ for (const attribute of attrs.values()) result.push({ module: owner, attribute });
289
+ }
290
+ return result;
291
+ }
292
+ prologAttributeModules(name) {
293
+ const modules = this._attributeModulesForRoot(name);
294
+ return modules == null ? [] : [...modules.keys()];
295
+ }
296
+ getPrologAttribute(name, module, functor, arity) {
297
+ const modules = this._attributeModulesForRoot(name);
298
+ return modules?.get(module)?.get(`${functor}/${arity}`) ?? null;
299
+ }
300
+ putPrologAttribute(name, module, attribute) {
301
+ const root = this._attributeRootName(name);
302
+ if (root == null) return false;
303
+ const signature = `${attribute.name}/${attribute.arity}`;
304
+ const outer = new Map(this._prologAttributes ?? []);
305
+ const modules = new Map(outer.get(root) ?? []);
306
+ const attrs = new Map(modules.get(module) ?? []);
307
+ attrs.set(signature, attribute);
308
+ modules.set(module, attrs);
309
+ outer.set(root, modules);
310
+ this._prologAttributes = outer;
311
+ return true;
312
+ }
313
+ deletePrologAttribute(name, module, functor, arity = null) {
314
+ const root = this._attributeRootName(name);
315
+ if (root == null) return false;
316
+ const existingModules = this._prologAttributes?.get(root);
317
+ const existingAttrs = existingModules?.get(module);
318
+ if (existingAttrs == null) return false;
319
+ const attrs = new Map(existingAttrs);
320
+ let deleted = false;
321
+ if (arity == null) {
322
+ for (const key of [...attrs.keys()]) {
323
+ if (key.startsWith(`${functor}/`)) { attrs.delete(key); deleted = true; }
324
+ }
325
+ } else {
326
+ deleted = attrs.delete(`${functor}/${arity}`);
327
+ }
328
+ if (!deleted) return false;
329
+ const modules = new Map(existingModules);
330
+ if (attrs.size === 0) modules.delete(module); else modules.set(module, attrs);
331
+ const outer = new Map(this._prologAttributes);
332
+ if (modules.size === 0) outer.delete(root); else outer.set(root, modules);
333
+ this._prologAttributes = outer.size === 0 ? null : outer;
334
+ return true;
335
+ }
336
+ hasPrologAttributes(name) {
337
+ const modules = this._attributeModulesForRoot(name);
338
+ if (modules == null) return false;
339
+ for (const attrs of modules.values()) if (attrs.size !== 0) return true;
340
+ return false;
341
+ }
342
+ attributedVariableNames() {
343
+ if (this._prologAttributes == null) return [];
344
+ const names = [];
345
+ const seen = new Set();
346
+ for (const name of this._prologAttributes.keys()) {
347
+ const root = deref(variable(name), this);
348
+ if (root.type !== VAR || seen.has(root.name)) continue;
349
+ if (!this.hasPrologAttributes(root.name)) continue;
350
+ seen.add(root.name);
351
+ names.push(root.name);
352
+ }
353
+ return names;
354
+ }
355
+ enqueueAttributeGoals(goals) {
356
+ if (goals == null || goals.length === 0) return;
357
+ this._pendingAttributeGoals = [...(this._pendingAttributeGoals ?? []), ...goals];
358
+ }
359
+ takePendingAttributeGoals() {
360
+ if (this._pendingAttributeGoals == null || this._pendingAttributeGoals.length === 0) return [];
361
+ const goals = this._pendingAttributeGoals;
362
+ this._pendingAttributeGoals = null;
363
+ return goals;
364
+ }
365
+ _ownerModules(name) {
366
+ const modules = this._attributeModulesForRoot(name);
367
+ return modules == null ? [] : [...modules.keys()];
368
+ }
369
+ _targetHasOwnerModule(name, module) {
370
+ return this._attributeModulesForRoot(name)?.has(module) === true;
371
+ }
372
+ preparePrologAttributeUnification(variableTerm, otherTerm) {
373
+ if (this._prologAttributes == null || variableTerm?.type !== VAR) return true;
374
+ const sourceRoot = deref(variableTerm, this);
375
+ if (sourceRoot.type !== VAR) return true;
376
+ const modules = this._ownerModules(sourceRoot.name);
377
+ if (modules.length === 0) return true;
378
+ const other = deref(otherTerm, this);
379
+ if (other.type === VAR) {
380
+ if (other.name === sourceRoot.name) return true;
381
+ // Aliasing an attributed variable with a plain variable is just a
382
+ // representative change. No user-level hook is needed; final aliasing
383
+ // moves the attributes to the surviving representative.
384
+ const hooks = modules.filter((module) => this._targetHasOwnerModule(other.name, module));
385
+ for (const module of hooks) {
386
+ if (this._attributeHookRunner && !this._attributeHookRunner(module, sourceRoot, other, this)) return false;
387
+ }
388
+ return true;
389
+ }
390
+ for (const module of modules) {
391
+ if (this._attributeHookRunner && !this._attributeHookRunner(module, sourceRoot, other, this)) return false;
392
+ }
393
+ return true;
394
+ }
395
+ finalizePrologAttributeAlias(sourceName, targetName) {
396
+ if (this._prologAttributes == null || sourceName === targetName) return;
397
+ const source = this._prologAttributes.get(sourceName);
398
+ if (source == null) return;
399
+ const outer = new Map(this._prologAttributes);
400
+ const target = new Map(outer.get(targetName) ?? []);
401
+ for (const [module, sourceAttrs] of source) {
402
+ const attrs = new Map(target.get(module) ?? []);
403
+ for (const [signature, attribute] of sourceAttrs) {
404
+ if (!attrs.has(signature)) attrs.set(signature, attribute);
405
+ }
406
+ target.set(module, attrs);
407
+ }
408
+ outer.delete(sourceName);
409
+ outer.set(targetName, target);
410
+ this._prologAttributes = outer;
411
+ }
412
+ dropPrologAttributes(name) {
413
+ if (this._prologAttributes == null) return;
414
+ const outer = new Map(this._prologAttributes);
415
+ outer.delete(name);
416
+ this._prologAttributes = outer.size === 0 ? null : outer;
417
+ }
246
418
  addVariableConstraint(constraint) {
247
419
  if (constraint == null || typeof constraint.variables !== 'function' ||
248
420
  typeof constraint.status !== 'function') {
@@ -374,6 +546,15 @@ export function unify(left, right, env, options = {}) {
374
546
 
375
547
  if (a.type === VAR && b.type === VAR && a.name === b.name) continue;
376
548
  if (a.type === VAR && b.type === VAR) {
549
+ if (env?._prologAttributes != null) {
550
+ if (!env.preparePrologAttributeUnification(a, b)) return false;
551
+ a = deref(a, env);
552
+ b = deref(b, env);
553
+ if (a.type !== VAR || b.type !== VAR || a.name === b.name) {
554
+ stack.push([a, b]);
555
+ continue;
556
+ }
557
+ }
377
558
  // For a compiler-generated DCG state handed directly to another
378
559
  // nonterminal, keep the local caller variable as representative. Ordinary
379
560
  // aliases retain the established direction and observable conventions.
@@ -381,9 +562,11 @@ export function unify(left, right, env, options = {}) {
381
562
  a.name.startsWith('\u0000dcg') && b.name.startsWith('\u0000dcg');
382
563
  if (aLocalDcg) {
383
564
  markCompactVariableBound(b);
565
+ env?.finalizePrologAttributeAlias?.(b.name, a.name);
384
566
  env.bind(b.name, a);
385
567
  } else {
386
568
  markCompactVariableBound(a);
569
+ env?.finalizePrologAttributeAlias?.(a.name, b.name);
387
570
  env.bind(a.name, b);
388
571
  }
389
572
  continue;
@@ -394,7 +577,14 @@ export function unify(left, right, env, options = {}) {
394
577
  occursCheckHandler?.(a, b, env);
395
578
  return false;
396
579
  }
580
+ if (env?._prologAttributes != null && env.hasPrologAttributes(a.name)) {
581
+ if (!env.preparePrologAttributeUnification(a, b)) return false;
582
+ a = deref(a, env);
583
+ b = deref(b, env);
584
+ if (a.type !== VAR) { stack.push([a, b]); continue; }
585
+ }
397
586
  markCompactVariableBound(a);
587
+ env?.dropPrologAttributes?.(a.name);
398
588
  env.bind(a.name, b);
399
589
  if (aLocal) env.forgetLocalVariable(a.name);
400
590
  continue;
@@ -405,7 +595,14 @@ export function unify(left, right, env, options = {}) {
405
595
  occursCheckHandler?.(b, a, env);
406
596
  return false;
407
597
  }
598
+ if (env?._prologAttributes != null && env.hasPrologAttributes(b.name)) {
599
+ if (!env.preparePrologAttributeUnification(b, a)) return false;
600
+ b = deref(b, env);
601
+ a = deref(a, env);
602
+ if (b.type !== VAR) { stack.push([a, b]); continue; }
603
+ }
408
604
  markCompactVariableBound(b);
605
+ env?.dropPrologAttributes?.(b.name);
409
606
  env.bind(b.name, a);
410
607
  if (bLocal) env.forgetLocalVariable(b.name);
411
608
  continue;
@@ -129,6 +129,62 @@ function regressionCases() {
129
129
  'scalar-fact fast matching can discharge an annotated disequality');
130
130
  },
131
131
  },
132
+ {
133
+ name: 'library(atts) provides Scryer-style attributed-variable hooks',
134
+ run: () => {
135
+ const source = `:- module(attr_probe, [check_get/0, check_alias/0, check_ok/0, check_bad/0, check_conflict/0, check_backtrack/0, check_term_vars/0]).
136
+ :- use_module(library(atts)).
137
+ :- attribute mark/1.
138
+ attach(X, V) :- put_atts(X, mark(V)).
139
+ verify_attributes(Var, Other, Goals) :-
140
+ ( get_atts(Var, mark(V)) ->
141
+ ( var(Other) ->
142
+ ( get_atts(Other, mark(W)) -> V = W ; put_atts(Other, mark(V)) ),
143
+ Goals = []
144
+ ; Goals = [same(Other, V)] )
145
+ ; Goals = [] ).
146
+ same(X, Y) :- X = Y.
147
+ check_get :- attach(X, a), get_atts(X, mark(a)), X = a.
148
+ check_alias :- attach(X, a), X = Y, get_atts(Y, mark(a)), Y = a.
149
+ check_ok :- attach(X, 3), X = 3.
150
+ check_bad :- attach(X, 3), X = 4.
151
+ check_conflict :- attach(X, a), attach(Y, b), X = Y.
152
+ check_backtrack :- (attach(X, a), fail ; true), \\+ get_atts(X, mark(_)).
153
+ check_term_vars :- attach(X, a), term_attributed_variables(pair(X,Y), [X]), var(Y), X = a.
154
+ `;
155
+ assertIncludes(run(source, { goal: 'attr_probe:check_get' }).stdout, 'attr_probe:check_get.', 'get_atts/2');
156
+ assertIncludes(run(source, { goal: 'attr_probe:check_alias' }).stdout, 'attr_probe:check_alias.', 'attribute transfer on aliasing');
157
+ assertIncludes(run(source, { goal: 'attr_probe:check_ok' }).stdout, 'attr_probe:check_ok.', 'post-binding hook goals');
158
+ assertEqual(run(source, { goal: 'attr_probe:check_bad' }).stdout, '', 'post-binding hook can reject a binding');
159
+ assertEqual(run(source, { goal: 'attr_probe:check_conflict' }).stdout, '', 'same-module attributes are verified before aliasing');
160
+ assertIncludes(run(source, { goal: 'attr_probe:check_backtrack' }).stdout, 'attr_probe:check_backtrack.', 'attributes backtrack with Env branches');
161
+ assertIncludes(run(source, { goal: 'attr_probe:check_term_vars' }).stdout, 'attr_probe:check_term_vars.', 'term_attributed_variables/2');
162
+
163
+ const residualFile = path.join(tmp, `atts-residual-${++tmpCounter}.pl`);
164
+ fs.writeFileSync(residualFile, `:- use_module(library(atts)).
165
+ :- attribute required/1.
166
+ attach(X,V) :- put_atts(X, required(V)).
167
+ verify_attributes(Var, Other, Goals) :-
168
+ ( get_atts(Var, required(V)) ->
169
+ ( var(Other) -> put_atts(Other, required(V)), Goals=[] ; Goals=[same(Other,V)] )
170
+ ; Goals=[] ).
171
+ same(X,X).
172
+ attribute_goals(X) --> { get_atts(X, required(V)) }, [required(X,V)].
173
+ `);
174
+ const repl = runCli([], { input: `[${sourceAtom(residualFile)}].\nattach(X,a).\nhalt.\n` });
175
+ assertEqual(repl.status, 0, 'attribute residual REPL status');
176
+ assertIncludes(repl.stdout, 'required(X, a).', 'attribute_goals//1 residual projection');
177
+
178
+ const scryerShape = Program.parse(`:- module(scryer_shape, [op(700, xfx, #=), probe/0]).
179
+ :- op(700, xfx, #=).
180
+ :- use_module(library(atts)).
181
+ :- attribute first/1, second/0.
182
+ probe.
183
+ `);
184
+ assertEqual(scryerShape.findGroup('probe', 0, 'scryer_shape')?.module, 'scryer_shape',
185
+ 'Scryer-style operator exports and multi-attribute directives load');
186
+ },
187
+ },
132
188
  {
133
189
  name: 'write_term variable_names/1 errors preserve the instantiated option culprit (issue #69)',
134
190
  run: () => {
@@ -3626,8 +3682,8 @@ function documentationSyncCases() {
3626
3682
  '<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"',
3627
3683
  'README cover links to the book',
3628
3684
  );
3629
- for (const filename of ['src/iso.js', 'src/dcg.js', 'src/standard-library.js',
3630
- 'src/lib/aggregate.pl', 'src/lib/comparison.pl', 'src/lib/dates.pl',
3685
+ for (const filename of ['src/iso.js', 'src/dcg.js', 'src/atts.js', 'src/standard-library.js',
3686
+ 'src/CLPZ-MIGRATION.md', 'src/lib/aggregate.pl', 'src/lib/atts.pl', 'src/lib/comparison.pl', 'src/lib/dates.pl',
3631
3687
  'src/lib/iso_ext.pl', 'src/lib/lists.pl', 'src/lib/primes.pl', 'src/lib/prologue.pl',
3632
3688
  'src/lib/random.pl', 'src/lib/strings.pl', 'src/lib/uuid.pl',
3633
3689
  'src/playground-worker.js']) {
@@ -4729,7 +4785,7 @@ answer(ok) :-
4729
4785
  assertEqual(Boolean(registry.get('is', 2)), true, 'ISO is/2 exists');
4730
4786
  assertEqual(Boolean(registry.get('append', 3)), false, 'append/3 is not ISO core');
4731
4787
  assertEqual(library.eyePrologLibrary, true, 'complete registry marker');
4732
- assertEqual(library.defs.size, 161, 'EyeProlog registry contains ISO definitions, cleanup controls, observability extensions, WFS tnot/1, and private library adapters');
4788
+ assertEqual(library.defs.size, 171, 'EyeProlog registry contains ISO definitions, cleanup controls, observability extensions, WFS tnot/1, and private library adapters');
4733
4789
  assertEqual(Boolean(registry.get('phrase', 2)), true, 'Part 3 phrase/2 exists');
4734
4790
  assertEqual(Boolean(registry.get('phrase', 3)), true, 'Part 3 phrase/3 exists');
4735
4791
  assertEqual(registry.get('statistics', 0), null, 'statistics/0 is absent from the ISO registry');
@@ -4746,19 +4802,19 @@ answer(ok) :-
4746
4802
  assertEqual(Boolean(library.get('call_cleanup', 2)), true, 'call_cleanup/2 is an EyeProlog cleanup control');
4747
4803
  assertEqual(registry.get('setup_call_cleanup', 3), null, 'setup_call_cleanup/3 is absent from the ISO registry');
4748
4804
  assertEqual(Boolean(library.get('setup_call_cleanup', 3)), true, 'setup_call_cleanup/3 is an EyeProlog cleanup control');
4749
- assertEqual(registeredNativeEyePrologLibraryNames().length, 42, 'public native EyeProlog builtin count');
4805
+ assertEqual(registeredNativeEyePrologLibraryNames().length, 48, 'public native EyeProlog builtin count');
4750
4806
  assertEqual(eyePrologPortableLibraryIndicators.length, 87, 'portable Prolog library count');
4751
4807
  assertEqual(eyePrologInteropLibraryIndicators.length, 29, 'cross-implementation interop profile count');
4752
- assertEqual(eyePrologInteropLibraryModules.join(','), 'lists,iso_ext,lambda', 'common explicit library module profile');
4808
+ assertEqual(eyePrologInteropLibraryModules.join(','), 'lists,iso_ext,lambda,atts', 'common explicit library module profile');
4753
4809
  assertEqual(eyePrologInteropAutoload['member/2'], 'lists', 'member/2 canonical autoload');
4754
4810
  assertEqual(eyePrologInteropAutoload['between/3'], 'prologue', 'between/3 canonical internal autoload');
4755
4811
  assertEqual(eyePrologInteropAutoload['call_nth/2'], 'iso_ext', 'call_nth/2 canonical interop autoload');
4756
4812
  assertEqual(eyePrologInteropAutoload['time/1'], 'iso_ext', 'time/1 canonical interop autoload');
4757
4813
  assertEqual(eyePrologInteropAutoload['.../2'], 'iso_ext', '.../2 canonical interop autoload');
4758
4814
  assertEqual(eyePrologInteropAutoload['set_nth0/4'] ?? null, null, 'EyeProlog-only set_nth0/4 is not autoloadable');
4759
- assertEqual(eyePrologNativeLibraryIndicators.length, 42, 'native host library count');
4815
+ assertEqual(eyePrologNativeLibraryIndicators.length, 48, 'native host library count');
4760
4816
  assertEqual(eyePrologNativeLibraryIndicators.slice(0, 3).join(','), 'call_nth/2,freeze/2,dif/2', 'control and constraint predicates requiring host support');
4761
- assertEqual(eyePrologLibraryIndicators.length, 129, 'complete EyeProlog library surface');
4817
+ assertEqual(eyePrologLibraryIndicators.length, 135, 'complete EyeProlog library surface');
4762
4818
  assertEqual(registry.get('eyeprolog__call_nth', 2), null, 'private call_nth adapter is absent from ISO registry');
4763
4819
  assertEqual(Boolean(library.get('eyeprolog__call_nth', 2)), true, 'private call_nth adapter is registered for EyeProlog');
4764
4820
  assertEqual(library.get('eyeprolog__call_nth', 2)?.eyePrologLibrary, true, 'private adapter is marked as library support');
@@ -4769,6 +4825,9 @@ answer(ok) :-
4769
4825
  assertEqual(Boolean(library.get('eyeprolog__time', 1)), true, 'private time adapter is registered for EyeProlog');
4770
4826
  assertEqual(Boolean(library.get('eyeprolog__clpz_labeling', 2)), true, 'private CLP(Z) labeling adapter is registered');
4771
4827
  assertEqual(Boolean(library.get('eyeprolog__clpz_global_cardinality', 3)), true, 'private CLP(Z) cardinality adapter is registered');
4828
+ assertEqual(registry.get('put_atts', 2), null, 'put_atts/2 is absent from the ISO registry');
4829
+ assertEqual(Boolean(library.get('put_atts', 2)), true, 'put_atts/2 is registered for attributed-variable libraries');
4830
+ assertEqual(Boolean(library.get('$put_to_attr_list', 3)), true, 'private attribute-list adapter is registered');
4772
4831
  assertEqual(library.get('between', 3), null, 'between/3 remains portable Prolog');
4773
4832
  assertEqual(library.get('smallest_divisor_from', 3), null, 'smallest_divisor_from/3 remains portable Prolog');
4774
4833
  assertEqual(library.get('random', 3), null, 'random/3 remains portable Prolog');
@@ -6211,18 +6211,18 @@ so side effects occur in Prolog execution order.
6211
6211
 
6212
6212
  ### The EyeProlog library
6213
6213
 
6214
- EyeProlog exposes **129 library predicate indicators** in addition to the 129
6215
- indicators in its isolated ISO profile. **88 are defined entirely as ordinary
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
6216
  Prolog clauses** in focused modules under `src/lib/`. The remaining public
6217
6217
  relations use small private host adapters where control, constraints, or host
6218
6218
  observability cannot be expressed by ordinary clauses alone; `time/1` is one
6219
6219
  such relation. The resulting normal EyeProlog language surface is therefore
6220
- **258 public predicate indicators**. Most library relations remain module source
6220
+ **264 public predicate indicators**. Most library relations remain module source
6221
6221
  clauses over private adapters. `time/1` is additionally registered directly in
6222
6222
  the normal EyeProlog runtime so Trealla-style timing works at the interactive
6223
6223
  top level without an import; it is absent from the strict ISO registry.
6224
6224
 
6225
- The sources are `src/lib/aggregate.pl`, `src/lib/clpz.pl`, `src/lib/comparison.pl`,
6225
+ The sources are `src/lib/aggregate.pl`, `src/lib/atts.pl`, `src/lib/clpz.pl`, `src/lib/comparison.pl`,
6226
6226
  `src/lib/dates.pl`, `src/lib/iso_ext.pl`, `src/lib/lambda.pl`,
6227
6227
  `src/lib/lists.pl`, `src/lib/primes.pl`, `src/lib/prologue.pl`,
6228
6228
  `src/lib/random.pl`, `src/lib/strings.pl`, and `src/lib/uuid.pl`. Each declares a same-named module
@@ -6253,6 +6253,7 @@ between solution branches.
6253
6253
  | Module | Exported predicate indicators |
6254
6254
  | --- | --- |
6255
6255
  | `library(aggregate)` | `sumall/3`, `aggregate_min/5`, `aggregate_max/5` |
6256
+ | `library(atts)` | `put_atts/2`, `get_atts/2`, `put_attr/3`, `get_attr/3`, `del_attr/2`, `term_attributed_variables/2` |
6256
6257
  | `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` |
6257
6258
  | `library(comparison)` | `lt/2`, `gt/2`, `le/2`, `ge/2` |
6258
6259
  | `library(dates)` | `difference/3` |
@@ -6402,17 +6403,32 @@ The catalog above is authoritative for the complete EyeProlog library surface.
6402
6403
  The following notes describe useful parts of that surface without extending the
6403
6404
  cross-engine claims made above.
6404
6405
 
6406
+ `library(atts)` is the Prolog-facing attributed-variable layer over the persistent
6407
+ annotated-variable machinery in `src/term.js`, with its small host bridge in
6408
+ `src/atts.js`. It provides the attribute operations used by Scryer libraries,
6409
+ accepts `:- attribute ...` declarations, invokes module-local
6410
+ `verify_attributes/3` before an attributed binding is committed, and schedules
6411
+ the returned goals immediately after the binding. Attribute maps are copied only
6412
+ when changed and therefore backtrack with `Env` branches; the interactive top
6413
+ level projects module `attribute_goals//1` hooks as residual goals. The checked
6414
+ [`examples/attributed-variables.pl`](https://github.com/eyereasoner/eyeprolog/blob/main/examples/attributed-variables.pl)
6415
+ demonstrates binding verification and attribute transfer across aliases.
6416
+
6405
6417
  `library(clpz)` follows the familiar Trealla and Scryer convention for
6406
6418
  constraint logic programming over integers. EyeProlog currently provides finite
6407
6419
  interval and union domains, arithmetic and reified constraints, backtrackable
6408
6420
  labeling with `ff`, `up`, and `down`, global distinctness, linear sums and scalar
6409
6421
  products, chains, elements, value counting, extensional tuple tables,
6410
6422
  lexicographic chains, serialized schedules, global cardinality with costs,
6411
- Hamiltonian circuits, three-way comparison, and domain reflection. Constraints
6412
- are kept in the logical environment, so failed alternatives cannot leak domains
6413
- into later branches. Trealla's larger library also contains facilities such as
6414
- automata, cumulative and two-dimensional scheduling constraints, and
6415
- unbounded-domain propagation that EyeProlog does not currently export.
6423
+ Hamiltonian circuits, three-way comparison, and domain reflection. Its existing
6424
+ `src/clpz.js` store is now explicitly transitional: the attributed-variable layer
6425
+ is intended to support staged reuse of Markus Triska's MIT-licensed
6426
+ [Scryer `clpz.pl`](https://github.com/mthom/scryer-prolog/blob/master/src/lib/clpz.pl).
6427
+ The remaining blockers are compile-time term/goal expansion and the supporting
6428
+ library surface used by that source; the migration state is tracked in
6429
+ [`src/CLPZ-MIGRATION.md`](src/CLPZ-MIGRATION.md). Until parity is reached, the JS
6430
+ kernel remains the active implementation so existing CLP(Z) behavior does not
6431
+ regress.
6416
6432
 
6417
6433
  Alongside its interop entries `call_nth/2`, `time/1`, and `.../2`,
6418
6434
  `library(iso_ext)` also exports EyeProlog's extension relations `countall/2`,
@@ -7131,7 +7147,7 @@ Review questions:
7131
7147
  </figure>
7132
7148
 
7133
7149
  The [examples directory](https://github.com/eyereasoner/eyeprolog/tree/main/examples/) is the book's executable companion. The
7134
- top-level directory contains **212 self-contained runnable programs**. Every
7150
+ top-level directory contains **213 self-contained runnable programs**. Every
7135
7151
  source program has an exact answer file under
7136
7152
  [examples/output](https://github.com/eyereasoner/eyeprolog/tree/main/examples/output/), and **61 selected programs** have a checked
7137
7153
  explanation under [examples/proof](https://github.com/eyereasoner/eyeprolog/tree/main/examples/proof/). The thematic tables below link every top-level program and open the program