eyeprolog 1.3.72 → 1.3.74

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.3.72",
6
+ "version": "1.3.74",
7
7
  "description": "EyeProlog turns facts and rules into answers and proofs.",
8
8
  "type": "module",
9
9
  "main": "./index.js",
package/src/atts.js CHANGED
@@ -69,7 +69,8 @@ function* getAttsBuiltin({ goal, env }) {
69
69
  if (pattern.type === VAR) {
70
70
  if (attributes.length === 0) return;
71
71
  const next = env.clone();
72
- if (unify(goal.args[1], listFromItems(attributes), next)) yield next;
72
+ if (unify(goal.args[1], listFromItems(attributes), next,
73
+ { knownNonoccurringVariables: goal._firstUseVariables })) yield next;
73
74
  return;
74
75
  }
75
76
 
@@ -89,7 +90,10 @@ function* getAttsBuiltin({ goal, env }) {
89
90
  }
90
91
  if (stored == null) return;
91
92
  const next = env.clone();
92
- if (unify(inner, stored, next, { skipVariableConstraints: true })) yield next;
93
+ if (unify(inner, stored, next, {
94
+ skipVariableConstraints: true,
95
+ knownNonoccurringVariables: goal._firstUseVariables,
96
+ })) yield next;
93
97
  }
94
98
 
95
99
  function attributeName(term, env) {
@@ -117,7 +121,10 @@ function* getAttrBuiltin({ goal, env }) {
117
121
  const stored = env.getPrologAttribute(variableTerm.name, owner, name, 1);
118
122
  if (stored == null) return;
119
123
  const next = env.clone();
120
- if (unify(goal.args[2], stored.args[0], next, { skipVariableConstraints: true })) yield next;
124
+ if (unify(goal.args[2], stored.args[0], next, {
125
+ skipVariableConstraints: true,
126
+ knownNonoccurringVariables: goal._firstUseVariables,
127
+ })) yield next;
121
128
  }
122
129
 
123
130
  function* delAttrBuiltin({ goal, env }) {
@@ -163,7 +170,8 @@ function* getAttrListBuiltin({ goal, env }) {
163
170
  if (entries.length === 0) return;
164
171
  const encoded = entries.map(({ module, attribute }) => compound(':', [atom(module), attribute]));
165
172
  const next = env.clone();
166
- if (unify(goal.args[1], listFromItems(encoded), next)) yield next;
173
+ if (unify(goal.args[1], listFromItems(encoded), next,
174
+ { knownNonoccurringVariables: goal._firstUseVariables })) yield next;
167
175
  }
168
176
 
169
177
  function* getFromAttrListBuiltin({ goal, env }) {
@@ -179,7 +187,10 @@ function* getFromAttrListBuiltin({ goal, env }) {
179
187
  const stored = env.getPrologAttribute(variableTerm.name, moduleTerm.name, signature.name, signature.arity);
180
188
  if (stored == null) return;
181
189
  const next = env.clone();
182
- if (unify(pattern, stored, next, { skipVariableConstraints: true })) yield next;
190
+ if (unify(pattern, stored, next, {
191
+ skipVariableConstraints: true,
192
+ knownNonoccurringVariables: goal._firstUseVariables,
193
+ })) yield next;
183
194
  }
184
195
 
185
196
  function* putToAttrListBuiltin({ goal, env }) {
package/src/lib/clpz.pl CHANGED
@@ -2248,10 +2248,15 @@ all_different([X|Right], Left, Orig) -->
2248
2248
  all_distinct(Ls) :-
2249
2249
  fd_must_be_list(Ls, all_distinct(Ls)-1),
2250
2250
  maplist(fd_variable, Ls),
2251
- make_propagator(pdistinct(Ls), Prop),
2252
- new_queue(Q0),
2253
- phrase((distinct_attach(Ls, Prop, []),trigger_prop(Prop),do_queue), [Q0], _),
2254
- variables_same_queue(Ls).
2251
+ % A ground list needs validation, but no attributed propagator.
2252
+ ( ground(Ls) ->
2253
+ sort(Ls, Distinct),
2254
+ same_length(Ls, Distinct)
2255
+ ; make_propagator(pdistinct(Ls), Prop),
2256
+ new_queue(Q0),
2257
+ phrase((distinct_attach(Ls, Prop, []),trigger_prop(Prop),do_queue), [Q0], _),
2258
+ variables_same_queue(Ls)
2259
+ ).
2255
2260
 
2256
2261
  %% nvalue(?N, +Vars).
2257
2262
  %
@@ -6707,10 +6712,21 @@ serialize_upper_bound(I, D_I, J, D_J, MState) -->
6707
6712
 
6708
6713
  element(N, Is, V) :-
6709
6714
  must_be(list, Is),
6710
- length(Is, L),
6711
- N in 1..L,
6712
- element_(Is, 1, N, V),
6713
- propagator_init_trigger([N|Is], pelement(N,Is,V)).
6715
+ % A constant table is one extensional relation; expanding it into
6716
+ % reified implications and a second propagator is needlessly costly.
6717
+ ( maplist(integer, Is) ->
6718
+ element_relation(Is, 1, Relation),
6719
+ tuples_in([[N,V]], Relation)
6720
+ ; length(Is, L),
6721
+ N in 1..L,
6722
+ element_(Is, 1, N, V),
6723
+ propagator_init_trigger([N|Is], pelement(N,Is,V))
6724
+ ).
6725
+
6726
+ element_relation([], _, []).
6727
+ element_relation([V|Vs], N, [[N,V]|Relation]) :-
6728
+ N1 is N + 1,
6729
+ element_relation(Vs, N1, Relation).
6714
6730
 
6715
6731
  element_domain(V, VD) :-
6716
6732
  ( fd_get(V, VD, _) -> true
@@ -6799,8 +6815,15 @@ global_cardinality(Xs, Pairs, Options) :-
6799
6815
  ).
6800
6816
 
6801
6817
  keys_costs(Keys, X, Row, C) :-
6802
- element(N, Keys, X),
6803
- element(N, Row, C).
6818
+ % Keys and matrix rows are integers here. Zip them once instead of
6819
+ % connecting two element/3 constraints through an auxiliary index.
6820
+ keys_cost_relation(Keys, Row, Relation),
6821
+ tuples_in([[X,C]], Relation).
6822
+
6823
+ keys_cost_relation([Key|Keys], [Cost|Costs], [[Key,Cost]|Relation]) :-
6824
+ keys_cost_relation(Keys, Costs, Relation).
6825
+ keys_cost_relation([], _, []).
6826
+ keys_cost_relation([_|_], [], []).
6804
6827
 
6805
6828
  gcc_pair(Pair) :-
6806
6829
  ( Pair = Key-Val ->
package/src/program.js CHANGED
@@ -34,6 +34,28 @@ import {
34
34
  const DEFER_PROGRAM_BUILD = Symbol('deferProgramBuild');
35
35
  const FAST_PARSE_ABORT = Symbol('fastParseAbort');
36
36
  const PROGRAM_BUILD_BATCH_SIZE = 16384;
37
+ const preparedBundledLibraryCache = new Map();
38
+
39
+ function preparedBundledLibraryCacheKey(program, options) {
40
+ const filename = String(options.filename ?? '');
41
+ if (filename !== standardLibrarySources.get('clpz')?.filename) return null;
42
+ // A user hook deliberately defined before use_module/1 is allowed to
43
+ // transform bundled source. Such a program needs its own ordinary expansion.
44
+ if (program.groups.has(modulePredicateKey('user', 'term_expansion', 2)) ||
45
+ program.groups.has(modulePredicateKey('user', 'goal_expansion', 2))) return null;
46
+ return `${filename}\u0000${options.sourceMetadata === false ? 'compact' : 'metadata'}`;
47
+ }
48
+
49
+ function cachedClauseCopy(clause) {
50
+ const copy = { ...clause };
51
+ if (Array.isArray(clause.body)) copy.body = [...clause.body];
52
+ if (clause.source != null) copy.source = { ...clause.source };
53
+ delete copy.index;
54
+ delete copy.groundHead;
55
+ delete copy.scalarHead;
56
+ delete copy._localFreshPlan;
57
+ return copy;
58
+ }
37
59
 
38
60
  function termContainsStringExtension(term, seen = new Set()) {
39
61
  if (term == null || typeof term !== 'object' || seen.has(term)) return false;
@@ -1074,6 +1096,8 @@ function sourcePath(options) {
1074
1096
  function loadSourceIntoBuilder(builder, source, options, ensured, loadedModules, fast, context) {
1075
1097
  const batch = [];
1076
1098
  const deferredGoalExpansions = [];
1099
+ const preparedCacheKey = preparedBundledLibraryCacheKey(builder.program, options);
1100
+ const preparedForCache = preparedCacheKey == null ? null : [];
1077
1101
  const flush = () => {
1078
1102
  if (batch.length === 0) return;
1079
1103
  builder.addClauses(batch);
@@ -1106,41 +1130,12 @@ function loadSourceIntoBuilder(builder, source, options, ensured, loadedModules,
1106
1130
  processPreparedClause(clause);
1107
1131
  };
1108
1132
 
1109
- const processPreparedClause = (inputClause) => {
1110
- let clause = inputClause;
1111
- const lexicalModule = context.module;
1112
-
1113
- // Grammar-rule expansion belongs to ISO/IEC TS 13211-3 rather than the
1114
- // Part 1 strict-core language. In strict core mode -->/2 remains the
1115
- // ordinary predefined operator term from Table 7 and is not rewritten.
1116
- const grammarClause = builder.program.strictIso ? null : expandDcgRuleClause(clause, lexicalModule);
1117
- if (grammarClause) clause = grammarClause;
1118
-
1119
- // A source module qualifier on a clause head selects the procedure owner,
1120
- // while unqualified calls in the body retain the lexical module of the
1121
- // source text. This distinction is essential for conventional hooks such
1122
- // as user:term_expansion/2 defined from library modules.
1123
- clause = normalizeQualifiedClauseHead(clause, lexicalModule);
1124
-
1125
- if (!builder.program.strictIso && !isDirectiveClause(clause)) {
1126
- try {
1127
- expandClauseGoals(builder.program, clause, lexicalModule, {
1128
- module: lexicalModule,
1129
- filename: options.filename ?? '<input>',
1130
- });
1131
- } catch (error) {
1132
- // Scryer-compatible goal-expansion hooks may refer to helper predicates
1133
- // that occur later in the same source file. Keep loading so those
1134
- // helpers become visible, then retry this clause at end-of-source.
1135
- // A still-missing helper (or any other expansion error) is reported on
1136
- // the final retry rather than being silently ignored.
1137
- if (!(error instanceof PrologError) || error.formal !== 'existence_error(procedure)') throw error;
1138
- clause.module ??= lexicalModule;
1139
- clause.textUnit ??= context.textUnit;
1140
- deferredGoalExpansions.push({ clause, lexicalModule });
1141
- return;
1133
+ const acceptPreparedClause = (clause, lexicalModule, record = true) => {
1134
+ const remember = () => {
1135
+ if (record && preparedForCache != null) {
1136
+ preparedForCache.push({ clause: cachedClauseCopy(clause), lexicalModule });
1142
1137
  }
1143
- }
1138
+ };
1144
1139
 
1145
1140
  const moduleDeclaration = moduleDirective(clause);
1146
1141
  if (moduleDeclaration) {
@@ -1148,6 +1143,7 @@ function loadSourceIntoBuilder(builder, source, options, ensured, loadedModules,
1148
1143
  clause.module = moduleDeclaration.name;
1149
1144
  clause.textUnit = context.textUnit;
1150
1145
  clause.moduleFilename = options.filename ?? '<input>';
1146
+ remember();
1151
1147
  builder.addClauses([clause]);
1152
1148
  context.module = moduleDeclaration.name;
1153
1149
  loadedModules.add(moduleDeclaration.name);
@@ -1158,6 +1154,7 @@ function loadSourceIntoBuilder(builder, source, options, ensured, loadedModules,
1158
1154
  flush();
1159
1155
  clause.module = context.module;
1160
1156
  clause.textUnit = context.textUnit;
1157
+ remember();
1161
1158
  builder.addClauses([clause]);
1162
1159
  const libraryName = libraryDesignationName(use.designation);
1163
1160
  if (libraryName != null) {
@@ -1185,6 +1182,7 @@ function loadSourceIntoBuilder(builder, source, options, ensured, loadedModules,
1185
1182
  const bodyModule = clause.lexicalModule ?? lexicalModule;
1186
1183
  for (const goal of clause.body) annotateGoalModule(goal, bodyModule);
1187
1184
  }
1185
+ remember();
1188
1186
  const isExpansionHook = !isDirectiveClause(clause) &&
1189
1187
  clause.head?.arity === 2 && ['term_expansion', 'goal_expansion'].includes(clause.head?.name);
1190
1188
  if (isExpansionHook) {
@@ -1199,6 +1197,7 @@ function loadSourceIntoBuilder(builder, source, options, ensured, loadedModules,
1199
1197
  return;
1200
1198
  }
1201
1199
  flush();
1200
+ remember();
1202
1201
  if (builder.program.strictIso && include.name === 'ensure_loaded') {
1203
1202
  builder.lastPredicateByText.set(context.textUnit ?? '<input>', '@directive');
1204
1203
  }
@@ -1212,6 +1211,54 @@ function loadSourceIntoBuilder(builder, source, options, ensured, loadedModules,
1212
1211
  }
1213
1212
  };
1214
1213
 
1214
+ const processPreparedClause = (inputClause) => {
1215
+ let clause = inputClause;
1216
+ const lexicalModule = context.module;
1217
+
1218
+ // Grammar-rule expansion belongs to ISO/IEC TS 13211-3 rather than the
1219
+ // Part 1 strict-core language. In strict core mode -->/2 remains the
1220
+ // ordinary predefined operator term from Table 7 and is not rewritten.
1221
+ const grammarClause = builder.program.strictIso ? null : expandDcgRuleClause(clause, lexicalModule);
1222
+ if (grammarClause) clause = grammarClause;
1223
+
1224
+ // A source module qualifier on a clause head selects the procedure owner,
1225
+ // while unqualified calls in the body retain the lexical module of the
1226
+ // source text. This distinction is essential for conventional hooks such
1227
+ // as user:term_expansion/2 defined from library modules.
1228
+ clause = normalizeQualifiedClauseHead(clause, lexicalModule);
1229
+
1230
+ if (!builder.program.strictIso && !isDirectiveClause(clause)) {
1231
+ try {
1232
+ expandClauseGoals(builder.program, clause, lexicalModule, {
1233
+ module: lexicalModule,
1234
+ filename: options.filename ?? '<input>',
1235
+ });
1236
+ } catch (error) {
1237
+ // Scryer-compatible goal-expansion hooks may refer to helper predicates
1238
+ // that occur later in the same source file. Keep loading so those
1239
+ // helpers become visible, then retry this clause at end-of-source.
1240
+ // A still-missing helper (or any other expansion error) is reported on
1241
+ // the final retry rather than being silently ignored.
1242
+ if (!(error instanceof PrologError) || error.formal !== 'existence_error(procedure)') throw error;
1243
+ clause.module ??= lexicalModule;
1244
+ clause.textUnit ??= context.textUnit;
1245
+ deferredGoalExpansions.push({ clause, lexicalModule });
1246
+ return;
1247
+ }
1248
+ }
1249
+
1250
+ acceptPreparedClause(clause, lexicalModule);
1251
+ };
1252
+
1253
+ const cachedEntry = preparedCacheKey == null ? null : preparedBundledLibraryCache.get(preparedCacheKey);
1254
+ if (cachedEntry?.source === source) {
1255
+ for (const item of cachedEntry.clauses) {
1256
+ acceptPreparedClause(cachedClauseCopy(item.clause), item.lexicalModule, false);
1257
+ }
1258
+ flush();
1259
+ return true;
1260
+ }
1261
+
1215
1262
  if (fast) {
1216
1263
  const acceptBinary = (headName, head0Type, head0Name, head1Type, head1Name,
1217
1264
  bodyName, body0Type, body0Name, body1Type, body1Name) => {
@@ -1235,9 +1282,12 @@ function loadSourceIntoBuilder(builder, source, options, ensured, loadedModules,
1235
1282
  module: lexicalModule,
1236
1283
  filename: options.filename ?? '<input>',
1237
1284
  });
1238
- for (const goal of clause.body) annotateGoalModule(goal, clause.lexicalModule ?? lexicalModule);
1239
- builder.addClauses([clause]);
1285
+ acceptPreparedClause(clause, lexicalModule);
1240
1286
  }
1287
+ flush();
1288
+ }
1289
+ if (preparedCacheKey != null) {
1290
+ preparedBundledLibraryCache.set(preparedCacheKey, { source, clauses: preparedForCache });
1241
1291
  }
1242
1292
  return true;
1243
1293
  }
package/src/solver.js CHANGED
@@ -1269,22 +1269,80 @@ function clauseLocalFreshPlan(clause) {
1269
1269
 
1270
1270
  function freshVariableSet(names, freshVariables) {
1271
1271
  if (names.length === 0) return null;
1272
- const fresh = new Set();
1272
+ const fresh = [];
1273
1273
  for (const name of names) {
1274
1274
  const term = freshVariables.get(name);
1275
- if (term != null) fresh.add(term.name);
1275
+ if (term != null) fresh.push(term.name);
1276
1276
  }
1277
- return fresh.size === 0 ? null : fresh;
1277
+ if (fresh.length === 0) return null;
1278
+ // Most clauses introduce only one or two first-use variables. A tiny linear
1279
+ // membership object avoids allocating and populating an OrderedHashSet on
1280
+ // every invocation; use the native Set once linear lookup would lose.
1281
+ return fresh.length <= 4 ? new SmallFreshVariableSet(fresh) : new Set(fresh);
1282
+ }
1283
+
1284
+ class SmallFreshVariableSet {
1285
+ constructor(values) {
1286
+ this.values = values;
1287
+ }
1288
+
1289
+ has(value) {
1290
+ for (let index = 0; index < this.values.length; index++) {
1291
+ if (this.values[index] === value) return true;
1292
+ }
1293
+ return false;
1294
+ }
1295
+ }
1296
+
1297
+ function goalUsesFirstUseProof(term) {
1298
+ if (term?.type !== COMPOUND) return false;
1299
+ if ((term.name === '=' && term.arity === 2)
1300
+ || (term.name === 'get_atts' && term.arity === 2)
1301
+ || (term.name === 'get_attr' && term.arity === 3)
1302
+ || (term.name === '$get_attr_list' && term.arity === 2)
1303
+ || (term.name === '$get_from_attr_list' && term.arity === 3)) return true;
1304
+ if ((term.name === ',' || term.name === ';' || term.name === '->') && term.arity === 2) {
1305
+ return goalUsesFirstUseProof(term.args[0]) || goalUsesFirstUseProof(term.args[1]);
1306
+ }
1307
+ if (term.name === '\\+' && term.arity === 1) return goalUsesFirstUseProof(term.args[0]);
1308
+ if (term.name === ':' && term.arity === 2) return goalUsesFirstUseProof(term.args[1]);
1309
+ return false;
1278
1310
  }
1279
1311
 
1280
1312
  function attachBodyLocalFreshVariables(freshBody, plan, freshVariables) {
1313
+ const attach = (term, knownNonoccurringVariables) => {
1314
+ if (term?.type !== COMPOUND) return;
1315
+ if (term.name === '=' && term.arity === 2) {
1316
+ term._knownNonoccurringVariables = knownNonoccurringVariables;
1317
+ } else if ((term.name === 'get_atts' && term.arity === 2)
1318
+ || (term.name === 'get_attr' && term.arity === 3)
1319
+ || (term.name === '$get_attr_list' && term.arity === 2)
1320
+ || (term.name === '$get_from_attr_list' && term.arity === 3)) {
1321
+ term._firstUseVariables = knownNonoccurringVariables;
1322
+ }
1323
+ // Goal expansion commonly wraps a primitive in a conjunction or
1324
+ // if-then-else. Preserve the proof on the executable child; attaching it
1325
+ // only to the outer control term leaves get_atts/2 unable to use it.
1326
+ if ((term.name === ',' || term.name === ';' || term.name === '->') && term.arity === 2) {
1327
+ attach(term.args[0], knownNonoccurringVariables);
1328
+ attach(term.args[1], knownNonoccurringVariables);
1329
+ } else if (term.name === '\\+' && term.arity === 1) {
1330
+ attach(term.args[0], knownNonoccurringVariables);
1331
+ } else if (term.name === ':' && term.arity === 2) {
1332
+ attach(term.args[1], knownNonoccurringVariables);
1333
+ }
1334
+ };
1281
1335
  for (let index = 0; index < freshBody.length; index++) {
1282
1336
  const goal = freshBody[index];
1337
+ if (!goalUsesFirstUseProof(goal)) continue;
1283
1338
  const knownNonoccurringVariables = freshVariableSet(plan[index] ?? [], freshVariables);
1284
1339
  if (knownNonoccurringVariables == null || goal?.type !== COMPOUND) continue;
1285
- if (goal.name === '=' && goal.arity === 2) {
1286
- goal._knownNonoccurringVariables = knownNonoccurringVariables;
1287
- }
1340
+ // Host builtins that only unify an output with already-existing logical
1341
+ // data can use the same first-use proof without treating the variable as a
1342
+ // WAM-style local across the whole call. This is especially important for
1343
+ // get_atts/2: CLP(Z) attributes are large trees that would otherwise be
1344
+ // traversed once per freshly introduced pattern variable.
1345
+ attach(goal, knownNonoccurringVariables);
1288
1346
  }
1289
1347
  }
1290
1348
 
@@ -2376,10 +2434,14 @@ function tryPushGroundScalarRuleFrame(stack, solver, group, goal, rest, env, dep
2376
2434
  // A fully-ground call to a single flat rule can be checked without freshening
2377
2435
  // the rule or allocating an Env for every body literal. This is especially
2378
2436
  // valuable for data-validation joins such as rb(A,B,C,D,E) in ORB Join2.
2379
- if (!termIsGround(goal, env) || group.clauses.length !== 1) return false;
2437
+ // Reject ineligible rule shapes before recursively resolving the goal. Most
2438
+ // library predicates have several clauses, and CLP(Z) calls are commonly
2439
+ // large non-ground structures for which that groundness walk is pure cost.
2440
+ if (group.clauses.length !== 1) return false;
2380
2441
  const clause = group.clauses[0];
2381
2442
  if (clause?.compactBinary === true || clause.body.length < 2 || clause.head.type !== COMPOUND) return false;
2382
2443
  if (clause.head.name !== goal.name || clause.head.arity !== goal.arity) return false;
2444
+ if (!termIsGround(goal, env)) return false;
2383
2445
 
2384
2446
  const bindings = new Map();
2385
2447
  const resolvedGoal = copyResolved(goal, env);
@@ -2438,14 +2500,17 @@ function tryPushGroundChainFrames(stack, solver, group, goal, rest, env, depth,
2438
2500
  // This is a search-control optimization only. It fires only while each step
2439
2501
  // has exactly one matching clause and a single ground body goal; otherwise the
2440
2502
  // normal clause path below remains authoritative.
2441
- if (!termIsGround(goal, env)) return false;
2442
2503
  // The chain matcher below only propagates variables bound to flat scalar
2443
2504
  // head arguments (or literal scalars). Keep this optimization on that flat
2444
- // domain instead of recursively copying an arbitrary ground compound just
2445
- // to discover later that a rule shape is unsupported. Deep list arguments
2446
- // such as DCG state therefore fall straight through to normal resolution.
2447
- const resolvedArgs = goal.args.map((arg) => derefForLocal(arg, env));
2448
- if (!resolvedArgs.every(isScalarTerm)) return false;
2505
+ // domain. Resolving the top-level arguments first proves groundness when all
2506
+ // of them are scalars and avoids recursively walking arbitrary CLP(Z) terms
2507
+ // only to reject them immediately afterward.
2508
+ const resolvedArgs = new Array(goal.arity);
2509
+ for (let index = 0; index < goal.arity; index++) {
2510
+ const resolved = derefForLocal(goal.args[index], env);
2511
+ if (!isScalarTerm(resolved)) return false;
2512
+ resolvedArgs[index] = resolved;
2513
+ }
2449
2514
 
2450
2515
  const baseEnv = env;
2451
2516
  let currentGroup = group;
package/src/term.js CHANGED
@@ -8,7 +8,17 @@ export const STRING = 'string';
8
8
  export const NUMBER = 'number';
9
9
  export const COMPOUND = 'compound';
10
10
  const EMPTY_ARGS = Object.freeze([]);
11
- const ENV_FLATTEN_DEPTH = 1024;
11
+ // Deep persistent binding chains make the many negative variable lookups in
12
+ // constraint propagation linear in the complete history. Periodically fold
13
+ // the chain into its indexed Map before that lookup cost dominates. Ordinary
14
+ // execution keeps the memory-friendly 512-layer threshold; attributed-variable
15
+ // propagation uses 256 because its repeated negative lookups dominate sooner.
16
+ const ENV_FLATTEN_DEPTH = 512;
17
+ const ATTRIBUTED_ENV_FLATTEN_DEPTH = 256;
18
+ // Runtime terms are structurally immutable: environments hold bindings beside
19
+ // them rather than rewriting their argument arrays. Cache only the syntactic
20
+ // variable names; binding reachability is still checked against each Env.
21
+ const structuralVariableCache = new WeakMap();
12
22
 
13
23
  export class Term {
14
24
  constructor(type, name, args = []) {
@@ -227,7 +237,10 @@ export class Env {
227
237
  return undefined;
228
238
  }
229
239
  bind(name, term) {
230
- if (this._state.depth >= ENV_FLATTEN_DEPTH) {
240
+ const flattenDepth = this._prologAttributes == null
241
+ ? ENV_FLATTEN_DEPTH
242
+ : ATTRIBUTED_ENV_FLATTEN_DEPTH;
243
+ if (this._state.depth >= flattenDepth) {
231
244
  const flattened = new Map();
232
245
  for (let state = this._state; state != null; state = state.parent) {
233
246
  if (state.bindingName != null && !flattened.has(state.bindingName)) {
@@ -531,7 +544,35 @@ export function isConjunction(term) {
531
544
  return term?.type === COMPOUND && term.name === ',' && term.arity === 2;
532
545
  }
533
546
 
534
- function occurs(variableName, term, env) {
547
+ function structuralVariableNames(term) {
548
+ if (isScalar(term)) return EMPTY_ARGS;
549
+ if (term?.type === VAR) return [term.name];
550
+ if (term?.type !== COMPOUND) return EMPTY_ARGS;
551
+
552
+ const cached = structuralVariableCache.get(term);
553
+ if (cached !== undefined) return cached;
554
+
555
+ const names = new Set();
556
+ const stack = [term];
557
+ const seenTerms = new Set();
558
+ while (stack.length) {
559
+ const current = stack.pop();
560
+ if (current?.type === VAR) {
561
+ names.add(current.name);
562
+ continue;
563
+ }
564
+ if (isCompactList(current)) return null;
565
+ if (current?.type !== COMPOUND || seenTerms.has(current)) continue;
566
+ seenTerms.add(current);
567
+ for (let i = 0; i < current.arity; i++) stack.push(current.args[i]);
568
+ }
569
+
570
+ const result = names.size === 0 ? EMPTY_ARGS : [...names];
571
+ structuralVariableCache.set(term, result);
572
+ return result;
573
+ }
574
+
575
+ function occursUncached(variableName, term, env) {
535
576
  // Walk bindings and compound arguments iteratively so the occurs check also
536
577
  // remains safe for very deep terms. The visited sets make this defensive
537
578
  // against cycles introduced through the public Env API.
@@ -559,6 +600,50 @@ function occurs(variableName, term, env) {
559
600
  return false;
560
601
  }
561
602
 
603
+ function occurs(variableName, term, env) {
604
+ if (isScalar(term)) return false;
605
+ const initial = structuralVariableNames(term);
606
+ if (initial == null) return occursUncached(variableName, term, env);
607
+ if (initial.length === 0) return false;
608
+
609
+ // Lists, wrappers, and arithmetic expressions commonly contain one logical
610
+ // variable. Follow that unary binding chain without allocating a work queue
611
+ // and hash set for every occurs check. Fall back to the general graph walk
612
+ // as soon as a binding fans out.
613
+ if (initial.length === 1) {
614
+ let name = initial[0];
615
+ const seen = [];
616
+ while (true) {
617
+ if (name === variableName) return true;
618
+ for (let index = 0; index < seen.length; index++) {
619
+ if (seen[index] === name) return false;
620
+ }
621
+ seen.push(name);
622
+ const binding = env?.get(name);
623
+ if (binding === undefined) return false;
624
+ const names = structuralVariableNames(binding);
625
+ if (names == null || names.length > 1) return occursUncached(variableName, term, env);
626
+ if (names.length === 0) return false;
627
+ name = names[0];
628
+ }
629
+ }
630
+
631
+ const pending = initial.slice();
632
+ const seenVariables = new Set();
633
+ for (let index = 0; index < pending.length; index++) {
634
+ const name = pending[index];
635
+ if (name === variableName) return true;
636
+ if (seenVariables.has(name)) continue;
637
+ seenVariables.add(name);
638
+ const binding = env?.get(name);
639
+ if (binding === undefined) continue;
640
+ const names = structuralVariableNames(binding);
641
+ if (names == null) return occursUncached(variableName, term, env);
642
+ for (const child of names) pending.push(child);
643
+ }
644
+ return false;
645
+ }
646
+
562
647
  export function unify(left, right, env, options = {}) {
563
648
  // Iterative unification avoids deep JavaScript recursion on long lists or
564
649
  // deeply nested compounds. The occurs check gives EyeProlog finite-tree
@@ -686,9 +771,16 @@ export function freshTerm(term, suffix, variables = new Map()) {
686
771
  }
687
772
  return fresh;
688
773
  }
689
- const fresh = term.type === COMPOUND && term.arity === 0
690
- ? atom(term.name)
691
- : new Term(term.type, term.name, term.args.map((arg) => freshTerm(arg, suffix, variables)));
774
+ let fresh;
775
+ if (term.type === COMPOUND && term.arity === 0) {
776
+ fresh = atom(term.name);
777
+ } else {
778
+ const args = new Array(term.args.length);
779
+ for (let index = 0; index < args.length; index++) {
780
+ args[index] = freshTerm(term.args[index], suffix, variables);
781
+ }
782
+ fresh = new Term(term.type, term.name, args);
783
+ }
692
784
  if (term.module != null) fresh.module = term.module;
693
785
  return fresh;
694
786
  }