eyelang 2.0.0 → 4.0.1

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.
Files changed (62) hide show
  1. package/README.md +6 -7
  2. package/docs/guide.md +6 -6
  3. package/docs/language-reference.md +116 -35
  4. package/examples/derived-rule.pl +1 -1
  5. package/examples/good-cobbler.pl +1 -1
  6. package/package.json +2 -2
  7. package/src/cli.js +2 -2
  8. package/src/parser.js +112 -26
  9. package/src/program.js +10 -0
  10. package/src/solver.js +269 -14
  11. package/test/conformance/cases/builtins/037_matching_and_comparison.pl +1 -1
  12. package/test/conformance/cases/strings/030_strings_and_atoms.pl +1 -1
  13. package/test/conformance/cases/strings/090_string_substring_replace_edges.pl +5 -5
  14. package/test/run-conformance.mjs +1 -1
  15. package/test/run-regression.mjs +3 -4
  16. package/src/bin.js +0 -7
  17. package/test/conformance/cases/aggregation/044_findall_user_goal.query +0 -1
  18. package/test/conformance/cases/aggregation/057_countall_empty_and_nonempty.query +0 -1
  19. package/test/conformance/cases/aggregation/058_sumall_numeric_template.query +0 -1
  20. package/test/conformance/cases/aggregation/059_aggregate_min_template.query +0 -1
  21. package/test/conformance/cases/aggregation/060_aggregate_max_compound_key.query +0 -1
  22. package/test/conformance/cases/arithmetic/029_arithmetic_and_comparison.query +0 -1
  23. package/test/conformance/cases/arithmetic/039_numeric_functions.query +0 -1
  24. package/test/conformance/cases/arithmetic/040_between_enumeration.query +0 -1
  25. package/test/conformance/cases/arithmetic/041_smallest_divisor.query +0 -1
  26. package/test/conformance/cases/arithmetic/054_numeric_and_lexical_comparison.query +0 -1
  27. package/test/conformance/cases/atoms/008_graphic_atoms.query +0 -1
  28. package/test/conformance/cases/atoms/018_quoted_atom_readback.query +0 -1
  29. package/test/conformance/cases/builtins/037_matching_and_comparison.query +0 -1
  30. package/test/conformance/cases/control/014_failure_filters_answers.query +0 -1
  31. package/test/conformance/cases/control/043_once_user_predicate.query +0 -1
  32. package/test/conformance/cases/lists/005_list_deconstruction.query +0 -1
  33. package/test/conformance/cases/lists/015_improper_list_unification.query +0 -1
  34. package/test/conformance/cases/lists/020_nested_list_terms.query +0 -1
  35. package/test/conformance/cases/lists/031_lists_aggregation_ordering.query +0 -1
  36. package/test/conformance/cases/lists/035_list_relations.query +0 -1
  37. package/test/conformance/cases/lists/036_append_splits.query +0 -1
  38. package/test/conformance/cases/lists/045_sort_deduplicates_atoms.query +0 -1
  39. package/test/conformance/cases/lists/046_append_bound_prefix_suffix.query +0 -1
  40. package/test/conformance/cases/lists/047_nth0_index_generation.query +0 -1
  41. package/test/conformance/cases/lists/048_set_nth0_edges.query +0 -1
  42. package/test/conformance/cases/lists/049_select_duplicate_occurrences.query +0 -1
  43. package/test/conformance/cases/negation/033_negation_once_generators.query +0 -1
  44. package/test/conformance/cases/negation/042_negation_filter.query +0 -1
  45. package/test/conformance/cases/negation/050_not_member_filter.query +0 -1
  46. package/test/conformance/cases/rules/002_rule_recursion.query +0 -1
  47. package/test/conformance/cases/rules/011_predicate_arity.query +0 -1
  48. package/test/conformance/cases/rules/013_multiple_clauses_order.query +0 -1
  49. package/test/conformance/cases/rules/017_three_step_recursion.query +0 -1
  50. package/test/conformance/cases/strings/030_strings_and_atoms.query +0 -1
  51. package/test/conformance/cases/strings/055_string_matching_filters.query +0 -1
  52. package/test/conformance/cases/strings/056_string_and_atom_concat.query +0 -1
  53. package/test/conformance/cases/syntax/004_conjunction_and_parentheses.query +0 -1
  54. package/test/conformance/cases/syntax/006_comma_formula_data.query +0 -1
  55. package/test/conformance/cases/syntax/009_comments_and_whitespace.query +0 -1
  56. package/test/conformance/cases/syntax/019_parenthesized_three_conjuncts.query +0 -1
  57. package/test/conformance/cases/table/038_table_declaration.query +0 -1
  58. package/test/conformance/cases/terms/003_terms_and_readback.query +0 -1
  59. package/test/conformance/cases/unification/012_nested_compound_unification.query +0 -1
  60. package/test/conformance/cases/unification/034_equality_and_inequality.query +0 -1
  61. package/test/conformance/cases/variables/007_anonymous_variables.query +0 -1
  62. package/test/conformance/cases/variables/010_variable_scope_and_reuse.query +0 -1
package/README.md CHANGED
@@ -3,9 +3,9 @@
3
3
  [![npm version](https://img.shields.io/npm/v/eyelang.svg)](https://www.npmjs.com/package/eyelang)
4
4
  [![DOI](https://img.shields.io/badge/DOI-10.5281%2Fzenodo.20761726-blue.svg)](https://doi.org/10.5281/zenodo.20761726)
5
5
 
6
- Eyelang is a small logic programming language for facts, rules, goals, answers, and proofs.
7
- Its source syntax is Prolog-like Horn-clause syntax with deliberate eyelang choices, including ISO Prolog-style `X` variables, explicit `table(path, 2).` declarations for tabled predicates, advisory `mode/3` declarations for host tooling, explicit Herbrand witness terms for executable existential-style consequences, and stratified-negation diagnostics for portable `not/1` usage.
8
- It grew out of logic-language experiments in the EYE/N3 reasoning tradition, but is packaged here as its own project.
6
+ Eyelang is a small reasoning language for turning facts and rules into answers and proofs.
7
+
8
+ Prolog-like syntax. Small core. Inspectable results.
9
9
 
10
10
  ## Install and run
11
11
 
@@ -14,15 +14,15 @@ Eyelang has no runtime npm dependencies and no build step. From a source checkou
14
14
  ```bash
15
15
  node bin/eyelang.js examples/ancestor.pl
16
16
  node bin/eyelang.js --proof examples/socrates.pl
17
- node bin/eyelang.js --warnings examples/policy.pl
17
+ node bin/eyelang.js --warnings test/conformance/warnings/negation/unstratified_mutual.pl
18
18
  printf 'works(stdin, true) :- eq(ok, ok).\n' | node bin/eyelang.js -
19
19
  ```
20
20
 
21
21
  For one-off local CLI use from the checkout, npm can run the package bin without a manual symlink:
22
22
 
23
23
  ```bash
24
- npm exec -- eyelang --version
25
- npm exec -- eyelang examples/ancestor.pl
24
+ npm exec --yes --package=. -- eyelang --version
25
+ npm exec --yes --package=. -- eyelang examples/ancestor.pl
26
26
  ```
27
27
 
28
28
  To install the checkout's `eyelang` command on your `PATH`, use npm's package link:
@@ -68,4 +68,3 @@ npm run conformance:report
68
68
  npm run test:examples
69
69
  npm run test:regression
70
70
  ```
71
-
package/docs/guide.md CHANGED
@@ -36,8 +36,8 @@ printf 'works(stdin, true) :- eq(ok, ok).\n' | node bin/eyelang.js -
36
36
  You can also use npm's local package-bin runner from the checkout:
37
37
 
38
38
  ```sh
39
- npm exec -- eyelang --version
40
- npm exec -- eyelang examples/ancestor.pl
39
+ npm exec --yes --package=. -- eyelang --version
40
+ npm exec --yes --package=. -- eyelang examples/ancestor.pl
41
41
  ```
42
42
 
43
43
  To make the `eyelang` command available on your `PATH` while developing this checkout, prefer npm's package link instead of a manual symlink:
@@ -51,7 +51,7 @@ eyelang --version
51
51
 
52
52
  ## Running eyelang
53
53
 
54
- The commands in this section use `eyelang` for readability. In a source checkout where you have not run `npm link` or `npm install -g .`, replace `eyelang` with `node bin/eyelang.js`, or run the command through `npm exec -- eyelang`.
54
+ The commands in this section use `eyelang` for readability. In a source checkout where you have not run `npm link` or `npm install -g .`, replace `eyelang` with `node bin/eyelang.js`, or run the command through `npm exec --yes --package=. -- eyelang`.
55
55
 
56
56
  Show the package version:
57
57
 
@@ -246,7 +246,7 @@ eyelang -s examples/observability-log-correlation.pl
246
246
  Add `-w` or `--warnings` when you want non-fatal portability diagnostics, such as unstratified `not/1` dependencies, printed to stderr while normal answer output still goes to stdout:
247
247
 
248
248
  ```sh
249
- eyelang --warnings policy.pl
249
+ eyelang --warnings test/conformance/warnings/negation/unstratified_mutual.pl
250
250
  ```
251
251
 
252
252
  The playground has matching `--stats` and `--proof` checkboxes, so browser runs can show the same counters or explanations like the CLI.
@@ -573,7 +573,7 @@ A useful rule of thumb:
573
573
  | Human-auditable derivations | Either | Both can emit proof explanations when requested. |
574
574
  | Large generated Horn-clause workloads | eyelang | The engine specializes in predicate/arity indexing, scalar argument indexes, fast fact paths, and materialized output goals. |
575
575
 
576
- On local smoke benchmarks, eyelang is substantially faster on large generated Horn-clause and recursion-heavy workloads. These numbers are 5-run medians with stdout redirected to `/dev/null`, using Node.js `v22.16.0`, eyelang from this checkout, and Eyeling package version `1.34.6` with its default output mode. The ratio is `Eyeling median / eyelang median`, so larger numbers mean eyelang was faster.
576
+ On local smoke benchmarks, eyelang is substantially faster on large generated Horn-clause and recursion-heavy workloads. These numbers are 5-run medians with stdout redirected to `/dev/null`, using Node.js `v22.16.0`, eyelang from this checkout, and Eyeling package version `1.34.6` with its default output mode. The ratio is `Eyeling median / eyelang median`, so larger numbers mea eyelang was faster.
577
577
 
578
578
  | Example | eyelang median | Eyeling median | Ratio |
579
579
  | --- | ---: | ---: | ---: |
@@ -618,4 +618,4 @@ When using `not/1` over user-defined predicates, keep the dependency graph strat
618
618
 
619
619
  ## Implementation limits
620
620
 
621
- Eyelang is intentionally smaller than ISO Prolog. It has no operators, zero-arity compound syntax, cut, modules, dynamic database updates, DCGs, or complete ISO library. Arity-zero data is always written and read back as an atom, such as `nil`, never `nil()`. Negation is negation-as-failure through `not/1`. Search is goal-directed and expected to be finite for the selected output goals. Output explanations are non-normative proof printouts and do not change answer semantics.
621
+ Eyelang is intentionally smaller than ISO Prolog. It has no operators, zero-arity compound syntax, cut, modules, dynamic database updates, DCGs, or complete ISO library. Arity-zero data is always written and read back as an atom, such as `nil`, never `nil()`. Negation is negation-as-failure through `not/1`. Search is goal-directed and expected to be finite for the selected output goals. Output explanations are non-normative proof printouts and do not change answer semantics.
@@ -7,13 +7,16 @@
7
7
  - [2. Design goals](#2-design-goals)
8
8
  - [3. Lexical structure](#3-lexical-structure)
9
9
  - [3.1 Character stream](#31-character-stream)
10
- - [3.2 Comments](#32-comments)
11
- - [3.3 Punctuation tokens](#33-punctuation-tokens)
12
- - [3.4 Variables](#34-variables)
13
- - [3.5 Atom constants](#35-atom-constants)
14
- - [3.6 Strings](#36-strings)
15
- - [3.7 Numbers](#37-numbers)
10
+ - [3.2 Unicode and UTF-8](#32-unicode-and-utf-8)
11
+ - [3.3 Comments](#33-comments)
12
+ - [3.4 Punctuation tokens](#34-punctuation-tokens)
13
+ - [3.5 Variables](#35-variables)
14
+ - [3.6 Atom constants](#36-atom-constants)
15
+ - [3.7 Strings](#37-strings)
16
+ - [3.8 Numbers](#38-numbers)
16
17
  - [4. Surface grammar](#4-surface-grammar)
18
+ - [4.1 EBNF grammar](#41-ebnf-grammar)
19
+ - [4.2 Grammar notes](#42-grammar-notes)
17
20
  - [5. Terms](#5-terms)
18
21
  - [5.1 Variables](#51-variables)
19
22
  - [5.2 Atom constants, strings, and numbers](#52-atom-constants-strings-and-numbers)
@@ -100,19 +103,32 @@ Non-goals include complete ISO Prolog compatibility, operator declarations, modu
100
103
  ## 3. Lexical structure
101
104
 
102
105
  ### 3.1 Character stream
103
-
104
106
  Input is Unicode text. Whitespace separates tokens and is otherwise insignificant outside quoted strings and quoted atom constants.
105
107
 
106
- ### 3.2 Comments
107
108
 
109
+ ### 3.2 Unicode and UTF-8
110
+ Eyelang source files are UTF-8.
111
+
112
+ Quoted atoms and strings may contain Unicode text:
113
+
114
+ ```eyelang
115
+ name(alice, 'Élodie').
116
+ city('München').
117
+ message("café").
118
+ ```
119
+
120
+ Unquoted atoms and variables intentionally use ASCII syntax. This keeps programs portable and makes the Prolog-style distinction between atoms and variables clear: lowercase names are atoms, while uppercase and underscore names are variables.
121
+
122
+ Use quoted atoms for non-ASCII names.
123
+
124
+ ### 3.3 Comments
108
125
  A percent sign starts a line comment outside quoted strings and quoted atom constants. The comment extends to the end of the line.
109
126
 
110
127
  ```eyelang
111
128
  parent(pat, jan). % this is a comment
112
129
  ```
113
130
 
114
- ### 3.3 Punctuation tokens
115
-
131
+ ### 3.4 Punctuation tokens
116
132
  The punctuation tokens are:
117
133
 
118
134
  ```text
@@ -121,8 +137,7 @@ The punctuation tokens are:
121
137
 
122
138
  A colon outside `:-` is not part of the language. Namespace-like names SHOULD be written as explicit atom constants such as `person_type`, `odrl_permission`, or quoted atoms such as `'org.schema'`.
123
139
 
124
- ### 3.4 Variables
125
-
140
+ ### 3.5 Variables
126
141
  A variable is either the bare anonymous variable `_`, or starts with an uppercase ASCII letter or underscore and then zero or more ASCII letters, digits, or underscores. This is the source-level variable spelling used by ISO Prolog.
127
142
 
128
143
  Examples:
@@ -136,8 +151,7 @@ _
136
151
 
137
152
  Each bare `_` anonymous variable occurrence is fresh. A name such as `_thing` is a named variable and is reused within its clause.
138
153
 
139
- ### 3.5 Atom constants
140
-
154
+ ### 3.6 Atom constants
141
155
  A plain atom constant starts with a lowercase ASCII letter and is followed by zero or more ASCII letters, digits, or underscores. A dot is not part of a plain atom; dotted web spaces such as `'be.ugent'` or `'org.schema'` MUST be quoted if they are meant as one atom constant. Names such as `a-b` MUST also be quoted if they are meant as one atom constant:
142
156
 
143
157
  ```eyelang
@@ -176,12 +190,10 @@ A graphic atom constant is one or more graphic characters from this set:
176
190
 
177
191
  Graphic atoms such as `<=>`, `<`, and `>=` remain graphic atoms. Longer names containing letters or punctuation outside the graphic set should be quoted, for example `'<abc>'`.
178
192
 
179
- ### 3.6 Strings
180
-
193
+ ### 3.7 Strings
181
194
  A string is enclosed in double quotes. The implementation supports common escapes such as `\n`, `\t`, `\"`, and `\\`.
182
195
 
183
- ### 3.7 Numbers
184
-
196
+ ### 3.8 Numbers
185
197
  Numbers are scalar terms. Integers, decimal numbers, and scientific notation are accepted:
186
198
 
187
199
  ```eyelang
@@ -196,33 +208,103 @@ Integer arithmetic built-ins use arbitrary-precision decimal strings where possi
196
208
 
197
209
  ## 4. Surface grammar
198
210
 
199
- This grammar is descriptive. Implementations MAY reject programs that exceed implementation limits.
211
+ This section gives the portable source grammar in EBNF. Lexical tokens are defined in section 3. Whitespace and comments may appear between tokens and are otherwise ignored.
212
+
213
+ ### 4.1 EBNF grammar
200
214
 
201
215
  ```text
202
- program ::= clause*
203
- clause ::= term "." | term ":-" goal_list "."
204
- goal_list ::= term ("," term)*
205
- term ::= variable
206
- | atom_constant
207
- | string
208
- | number
209
- | atom_constant "(" term ("," term)* ")"
210
- | "[" [list_items] "]"
211
- | "(" term ("," term)+ ")"
212
- list_items ::= term ("," term)* ["|" term]
216
+ program ::= { clause } ;
217
+
218
+ clause ::= head, "."
219
+ | head, ":-", goal-list, "." ;
220
+
221
+ head ::= term ;
222
+
223
+ goal-list ::= term, { ",", term } ;
224
+
225
+ term ::= variable
226
+ | atom-constant
227
+ | string
228
+ | number
229
+ | compound
230
+ | list
231
+ | parenthesized-term ;
232
+
233
+ compound ::= atom-constant, "(", term, { ",", term }, ")" ;
234
+
235
+ list ::= "[", "]"
236
+ | "[", list-items, "]" ;
237
+
238
+ list-items ::= term, { ",", term }, [ "|", term ] ;
239
+
240
+ parenthesized-term ::= "(", term, [ ",", term, { ",", term } ], ")" ;
241
+
242
+ variable ::= "_"
243
+ | variable-start, { name-continue } ;
244
+
245
+ atom-constant ::= plain-atom
246
+ | quoted-atom
247
+ | graphic-atom ;
248
+
249
+ plain-atom ::= lowercase-letter, { name-continue } ;
250
+
251
+ quoted-atom ::= "'", { quoted-atom-char }, "'" ;
252
+
253
+ quoted-atom-char ::= non-single-quote-char
254
+ | "''"
255
+ | escape-sequence ;
256
+
257
+ string ::= '"', { string-char }, '"' ;
258
+
259
+ string-char ::= non-double-quote-char
260
+ | '""'
261
+ | escape-sequence ;
262
+
263
+ number ::= [ "-" ], digits, [ ".", digits ], [ exponent ] ;
264
+
265
+ exponent ::= ( "e" | "E" ), [ "+" | "-" ], digits ;
266
+
267
+ graphic-atom ::= graphic-char, { graphic-char } ;
268
+
269
+ variable-start ::= uppercase-letter | "_" ;
270
+
271
+ name-continue ::= uppercase-letter | lowercase-letter | digit | "_" ;
272
+
273
+ digits ::= digit, { digit } ;
274
+
275
+ escape-sequence ::= "\\", any-char ;
276
+
277
+ uppercase-letter ::= "A" | ... | "Z" ;
278
+ lowercase-letter ::= "a" | ... | "z" ;
279
+ digit ::= "0" | ... | "9" ;
280
+
281
+ non-single-quote-char ::= any source character except "'", "\\", or end of input ;
282
+ non-double-quote-char ::= any source character except '"', "\\", or end of input ;
283
+ any-char ::= any source character except end of input ;
284
+
285
+ graphic-char ::= "#" | "$" | "&" | "*" | "+" | "-" | "/" | "<"
286
+ | "=" | ">" | "@" | "^" | "~" | "\\" ;
213
287
  ```
214
288
 
215
- Here `atom_constant` is a lexical class for symbolic scalar terms, not an atomic formula. Atomic formulas are represented by the grammar alternative `atom_constant "(" ... ")"` when such a compound appears in a clause head, rule body, or selected goal. Compound syntax always has at least one argument.
289
+ ### 4.2 Grammar notes
216
290
 
217
- A clause head SHOULD be a compound term. Non-compound heads are parsed but are not useful in the current predicate index.
291
+ The `atom-constant` nonterminal is a lexical class for symbolic scalar terms, not an atomic formula. Atomic formulas are represented by the `compound` alternative when such a term appears as a clause head, rule body, or selected goal. The functor or predicate name is always an atom constant; Eyelang does not support variables in functor or predicate position.
218
292
 
219
- Arity-zero data is written as an atom constant, not as a zero-arity compound:
293
+ A portable clause head SHOULD be a compound term. Non-compound heads are parsed, but they are not useful in the current predicate index.
294
+
295
+ Compound syntax always has at least one argument. Arity-zero data is written as an atom constant, not as a zero-arity compound:
220
296
 
221
297
  ```eyelang
222
298
  value(example, nil).
223
299
  ```
224
300
 
225
- The syntax `nil()` is intentionally rejected so eyelang source and read-back output use one representation for arity-zero data. Host APIs SHOULD follow the same rule: constructing a term with an atom name and an empty argument list is canonicalized to the atom constant itself.
301
+ The syntax `nil()` is intentionally rejected so Eyelang source and read-back output use one representation for arity-zero data. Host APIs SHOULD follow the same rule: constructing a term with an atom name and an empty argument list is canonicalized to the atom constant itself.
302
+
303
+ Parentheses around a single term are accepted and denote that same term. Parentheses around two or more comma-separated terms denote a right-associated comma term using the functor `','/2`. When such a comma term appears as a goal, it is evaluated as conjunction.
304
+
305
+ A quoted atom represents an atom constant. A doubled single quote inside a quoted atom represents one literal single quote. A backslash may introduce an escaped character in quoted atoms and strings. Portable source SHOULD use the common escapes `\\`, `\n`, `\t`, `\'` in quoted atoms, and `\\`, `\n`, `\t`, `\"` in strings.
306
+
307
+ A percent sign outside a quoted atom or string starts a line comment that extends to the end of the line.
226
308
 
227
309
  ## 5. Terms
228
310
 
@@ -770,4 +852,3 @@ status(a, open) :- open(a).
770
852
  URL input uses host networking support when available. Hosts SHOULD treat downloaded programs as untrusted code because they can trigger expensive search.
771
853
 
772
854
  Programs SHOULD be written with finite search in mind. Broad default materialization can be expensive for helper predicates; use `materialize/2` declarations and concise output predicates when needed.
773
-
@@ -6,7 +6,7 @@
6
6
  % { X a cat. } => { { Y a dog. } => { test is true. }. }.
7
7
  %
8
8
  % The inner implication is represented directly as quoted formula data.
9
- % var(y) is not an eyelang variable; it is a ground term that names
9
+ % var(y) is not a eyelang variable; it is a ground term that names
10
10
  % a variable placeholder inside the quoted formula.
11
11
 
12
12
  % Output declarations: materialize/2 selects the relations written to this example's golden output.
@@ -1,7 +1,7 @@
1
1
  % Good cobbler, adapted from Eyeling's examples/good-cobbler.n3.
2
2
  %
3
3
  % The Eyeling result is a quoted assertion saying that joe is a good Cobbler.
4
- % Here the quoted assertion is represented as an eyelang term.
4
+ % Here the quoted assertion is represented as a eyelang term.
5
5
 
6
6
  % Output declarations: materialize/2 selects the relations written to this example's golden output.
7
7
  materialize(is, 2).
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "eyelang",
3
- "version": "2.0.0",
4
- "description": "A small Prolog-like logic programming language for facts, rules, goals, answers, and proofs.",
3
+ "version": "4.0.1",
4
+ "description": "A small reasoning language with ISO Prolog-compatible surface syntax.",
5
5
  "type": "module",
6
6
  "main": "./index.js",
7
7
  "types": "./index.d.ts",
package/src/cli.js CHANGED
@@ -139,8 +139,8 @@ Usage:
139
139
  eyelang [options] [file-or-url.pl|- ...]
140
140
 
141
141
  Input:
142
- file-or-url.pl Read an Eyelang program from a local file or http(s) URL.
143
- - Read an Eyelang program from standard input.
142
+ file-or-url.pl Read a Eyelang program from a local file or http(s) URL.
143
+ - Read a Eyelang program from standard input.
144
144
 
145
145
  Options:
146
146
  -h, --help Show this help text and exit.
package/src/parser.js CHANGED
@@ -335,6 +335,7 @@ function parseClausesFastNoSource(source) {
335
335
  cache.set(key, value);
336
336
  return value;
337
337
  };
338
+
338
339
  const isFastScalarToken = (text) => SIMPLE_VARIABLE.test(text) || SIMPLE_ATOM.test(text) || GRAPHIC_ATOM.test(text) || SIMPLE_NUMBER.test(text);
339
340
  const scalarOrVariableFast = (text) => {
340
341
  if (!text || !isFastScalarToken(text)) throw new Error('bad simple term');
@@ -350,8 +351,117 @@ function parseClausesFastNoSource(source) {
350
351
  if ((first === 45 || isDigitCode(first)) && SIMPLE_NUMBER.test(text)) return cached(numberCache, text, numberTerm);
351
352
  return atom(text);
352
353
  };
354
+
355
+ const trimRange = (text, start, end) => {
356
+ while (start < end && isWhitespaceCode(text.charCodeAt(start))) start++;
357
+ while (end > start && isWhitespaceCode(text.charCodeAt(end - 1))) end--;
358
+ return [start, end];
359
+ };
360
+
361
+ const tokenKindInRange = (text, start, end) => {
362
+ if (start >= end) return null;
363
+ const first = text.charCodeAt(start);
364
+ if (first === 95 || (first >= 65 && first <= 90)) {
365
+ for (let i = start + 1; i < end; i++) if (!isNameContinueCode(text.charCodeAt(i))) return null;
366
+ return 'var';
367
+ }
368
+ if (first >= 97 && first <= 122) {
369
+ for (let i = start + 1; i < end; i++) if (!isNameContinueCode(text.charCodeAt(i))) return null;
370
+ return 'atom';
371
+ }
372
+ let allGraphic = true;
373
+ for (let i = start; i < end; i++) {
374
+ if (!isGraphicAtomCode(text.charCodeAt(i))) { allGraphic = false; break; }
375
+ }
376
+ if (allGraphic) return 'atom';
377
+ return null;
378
+ };
379
+
380
+ const simpleNumberInRange = (text, start, end) => {
381
+ let i = start;
382
+ if (text.charCodeAt(i) === 45) i++;
383
+ if (i >= end || !isDigitCode(text.charCodeAt(i))) return false;
384
+ while (i < end && isDigitCode(text.charCodeAt(i))) i++;
385
+ if (i < end && text.charCodeAt(i) === 46) {
386
+ i++;
387
+ if (i >= end || !isDigitCode(text.charCodeAt(i))) return false;
388
+ while (i < end && isDigitCode(text.charCodeAt(i))) i++;
389
+ }
390
+ if (i < end && (text.charCodeAt(i) === 101 || text.charCodeAt(i) === 69)) {
391
+ i++;
392
+ if (i < end && (text.charCodeAt(i) === 43 || text.charCodeAt(i) === 45)) i++;
393
+ if (i >= end || !isDigitCode(text.charCodeAt(i))) return false;
394
+ while (i < end && isDigitCode(text.charCodeAt(i))) i++;
395
+ }
396
+ return i === end;
397
+ };
398
+
399
+ const scalarOrVariableRange = (text, start, end) => {
400
+ [start, end] = trimRange(text, start, end);
401
+ const kind = tokenKindInRange(text, start, end);
402
+ const value = text.slice(start, end);
403
+ if (kind === 'var') {
404
+ if (value === '_') return variable(`__anon${anonymous++}`);
405
+ const existing = variableCache.get(value);
406
+ if (existing) return existing;
407
+ const term = variable(value);
408
+ variableCache.set(value, term);
409
+ return term;
410
+ }
411
+ if (kind === 'atom') return atom(value);
412
+ if (simpleNumberInRange(text, start, end)) return cached(numberCache, value, numberTerm);
413
+ return null;
414
+ };
415
+
416
+ const parseBinaryCompoundRange = (text, start = 0, end = text.length) => {
417
+ [start, end] = trimRange(text, start, end);
418
+ let i = start;
419
+ const first = text.charCodeAt(i);
420
+ if (!(first >= 97 && first <= 122)) return null;
421
+ i++;
422
+ while (i < end && isNameContinueCode(text.charCodeAt(i))) i++;
423
+ const nameEnd = i;
424
+ while (i < end && isWhitespaceCode(text.charCodeAt(i))) i++;
425
+ if (text.charCodeAt(i) !== 40) return null;
426
+ i++;
427
+ const arg1Start = i;
428
+ while (i < end && text.charCodeAt(i) !== 44 && text.charCodeAt(i) !== 40 && text.charCodeAt(i) !== 41 && text.charCodeAt(i) !== 91 && text.charCodeAt(i) !== 93 && text.charCodeAt(i) !== 124 && text.charCodeAt(i) !== 34 && text.charCodeAt(i) !== 39) i++;
429
+ if (i >= end || text.charCodeAt(i) !== 44) return null;
430
+ const arg1End = i;
431
+ i++;
432
+ const arg2Start = i;
433
+ while (i < end && text.charCodeAt(i) !== 41 && text.charCodeAt(i) !== 40 && text.charCodeAt(i) !== 44 && text.charCodeAt(i) !== 91 && text.charCodeAt(i) !== 93 && text.charCodeAt(i) !== 124 && text.charCodeAt(i) !== 34 && text.charCodeAt(i) !== 39) i++;
434
+ if (i >= end || text.charCodeAt(i) !== 41) return null;
435
+ const arg2End = i;
436
+ i++;
437
+ while (i < end && isWhitespaceCode(text.charCodeAt(i))) i++;
438
+ if (i !== end) return null;
439
+ const left = scalarOrVariableRange(text, arg1Start, arg1End);
440
+ if (!left) return null;
441
+ const right = scalarOrVariableRange(text, arg2Start, arg2End);
442
+ if (!right) return null;
443
+ return compound(text.slice(start, nameEnd), [left, right]);
444
+ };
445
+
446
+ const parseFastLine = (text) => {
447
+ if (!text.endsWith('.')) return null;
448
+ const end = text.length - 1;
449
+ const rule = text.indexOf(':-');
450
+ if (rule < 0) {
451
+ const head = parseBinaryCompoundRange(text, 0, end);
452
+ return head ? { head, body: [] } : null;
453
+ }
454
+ if (text.indexOf(':-', rule + 2) >= 0) return null;
455
+ const head = parseBinaryCompoundRange(text, 0, rule);
456
+ if (!head) return null;
457
+ const bodyGoal = parseBinaryCompoundRange(text, rule + 2, end);
458
+ return bodyGoal ? { head, body: [bodyGoal] } : null;
459
+ };
460
+
353
461
  const scalarOrVariable = (text) => scalarOrVariableFast(text.trim());
354
462
  const parseBinaryCompound = (text) => {
463
+ const parsed = parseBinaryCompoundRange(text, 0, text.length);
464
+ if (parsed) return parsed;
355
465
  text = text.trim();
356
466
  const open = text.indexOf('(');
357
467
  if (open <= 0 || text[text.length - 1] !== ')') return null;
@@ -366,35 +476,11 @@ function parseClausesFastNoSource(source) {
366
476
  if (!isFastScalarToken(left) || !isFastScalarToken(right)) return null;
367
477
  return compound(name, [scalarOrVariable(left), scalarOrVariable(right)]);
368
478
  };
369
- const parseFastBinaryMatch = (match) => {
370
- if (!isFastScalarToken(match[2]) || !isFastScalarToken(match[3])) return null;
371
- return compound(match[1], [scalarOrVariableFast(match[2]), scalarOrVariableFast(match[3])]);
372
- };
373
- const parseFastBinaryRuleMatch = (match) => {
374
- if (!isFastScalarToken(match[2]) || !isFastScalarToken(match[3]) || !isFastScalarToken(match[5]) || !isFastScalarToken(match[6])) return null;
375
- return {
376
- head: compound(match[1], [scalarOrVariableFast(match[2]), scalarOrVariableFast(match[3])]),
377
- body: [compound(match[4], [scalarOrVariableFast(match[5]), scalarOrVariableFast(match[6])])],
378
- };
379
- };
380
- const parseFastLine = (text) => {
381
- if (!text.endsWith('.')) return null;
382
- const ruleMatch = FAST_BINARY_RULE.exec(text);
383
- if (ruleMatch) {
384
- const parsed = parseFastBinaryRuleMatch(ruleMatch);
385
- if (parsed) return parsed;
386
- }
387
- const factMatch = FAST_BINARY_FACT.exec(text);
388
- if (factMatch) {
389
- const head = parseFastBinaryMatch(factMatch);
390
- if (head) return { head, body: [] };
391
- }
392
- return null;
393
- };
479
+
394
480
  const parseSimple = (text) => {
395
- if (!text.endsWith('.') || text.includes('\n')) return null;
396
481
  const fast = parseFastLine(text);
397
482
  if (fast) return fast;
483
+ if (!text.endsWith('.') || text.includes('\n')) return null;
398
484
  text = text.slice(0, -1);
399
485
  const rule = text.indexOf(':-');
400
486
  if (rule < 0) {
package/src/program.js CHANGED
@@ -44,6 +44,7 @@ export class Program {
44
44
  mode: null,
45
45
  determinism: null,
46
46
  recursive: false,
47
+ scalarFactsOnly: true,
47
48
  negationStratum: null,
48
49
  };
49
50
  if (arity > 2) {
@@ -64,6 +65,9 @@ export class Program {
64
65
  group = this.makeGroup(head.name, head.arity);
65
66
  this.groups.set(key, group);
66
67
  }
68
+ clause.groundHead = termHasNoVariables(head);
69
+ clause.scalarHead = head.type === COMPOUND && head.args.every(isScalar);
70
+ if (clause.body.length !== 0 || !clause.scalarHead) group.scalarFactsOnly = false;
67
71
  group.clauses.push(clause);
68
72
  for (let i = 0; i < head.arity; i++) indexOne(group.argIndexes[i], head.args[i], clause);
69
73
  for (const pair of group.pairIndexes) indexPair(pair, head, clause);
@@ -265,6 +269,12 @@ export class Program {
265
269
 
266
270
 
267
271
 
272
+
273
+ function termHasNoVariables(term) {
274
+ if (!term || term.type === 'var') return false;
275
+ return !term.args?.some((arg) => !termHasNoVariables(arg));
276
+ }
277
+
268
278
  function collectGoalDependencies(goal, negated) {
269
279
  if (goal.type !== COMPOUND) return [];
270
280
  if (goal.name === ',' && goal.arity === 2) {
package/src/solver.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Depth-first eyelang solver with builtin dispatch, memoization, and guarded recursion handling.
2
2
  // Most semantic decisions still flow through unification; optimizations only select candidates earlier.
3
- import { COMPOUND, Env, copyResolved, flattenConjunction, freshTerm, termIsGround, termToString, unify, variantTerms } from './term.js';
3
+ import { COMPOUND, Env, compound, copyResolved, flattenConjunction, freshTerm, termIsGround, termToString, unify, variantTerms } from './term.js';
4
4
  import { createDefaultRegistry } from './builtins/registry.js';
5
5
  import { selectClauseCandidates } from './program.js';
6
6
 
@@ -156,6 +156,7 @@ export class Solver {
156
156
  }
157
157
  }
158
158
 
159
+ if (!group.tabled && tryPushScalarFactRunFrames(stack, this, [goal, ...rest], env, depth, active)) break;
159
160
  pushUserGoalUncachedFrames(stack, this, group, goal, rest, env, depth, active);
160
161
  break;
161
162
  }
@@ -230,6 +231,14 @@ export class Solver {
230
231
  const candidates = selectClauseCandidates(group, goal, env);
231
232
  for (const pass of [candidates.primary, candidates.fallback]) {
232
233
  for (const clause of pass) {
234
+ if (clause.body.length === 0 && clause.scalarHead) {
235
+ const next = matchScalarFact(goal, clause.head, env);
236
+ if (!next) continue;
237
+ this.stats.unify_calls++;
238
+ yield* this.solve(rest, next, depth + 1);
239
+ if (this.solutionsSeen >= this.solutionLimit) return;
240
+ continue;
241
+ }
233
242
  if (headCannotMatch(goal, clause.head, env)) continue;
234
243
  const id = nextFreshId();
235
244
  const freshHead = freshTerm(clause.head, id);
@@ -285,6 +294,14 @@ function pushUserGoalUncachedFrames(stack, solver, group, goal, rest, env, depth
285
294
  const frames = [];
286
295
  for (const pass of [candidates.primary, candidates.fallback]) {
287
296
  for (const clause of pass) {
297
+ if (clause.body.length === 0 && clause.scalarHead) {
298
+ const next = matchScalarFact(goal, clause.head, env);
299
+ if (next) {
300
+ solver.stats.unify_calls++;
301
+ frames.push({ kind: 'goals', goals: rest, env: next, depth: depth + 1, active });
302
+ }
303
+ continue;
304
+ }
288
305
  if (headCannotMatch(goal, clause.head, env)) continue;
289
306
  const id = nextFreshId();
290
307
  const freshHead = freshTerm(clause.head, id);
@@ -309,6 +326,184 @@ function pushUserGoalUncachedFrames(stack, solver, group, goal, rest, env, depth
309
326
  }
310
327
 
311
328
 
329
+
330
+ const SCALAR_FACT_RUN_FRAME_LIMIT = 100000;
331
+
332
+ function tryPushScalarFactRunFrames(stack, solver, goals, env, depth, active) {
333
+ // Consecutive lookups into predicates that are entirely scalar ground facts
334
+ // are common in data-heavy joins. Execute such a prefix as one iterative join
335
+ // using local binding arrays, so intermediate fact candidates do not allocate
336
+ // cloned Env maps.
337
+ let runLength = 0;
338
+ const groups = [];
339
+ while (runLength < goals.length) {
340
+ const goal = goals[runLength];
341
+ if (!goal || goal.kind === 'releaseActive' || goal.kind === 'memoStore') break;
342
+ if (goal.type !== COMPOUND) break;
343
+ const def = solver.registry.get(goal.name, goal.arity);
344
+ if (def) break;
345
+ const group = solver.program.findGroup(goal.name, goal.arity);
346
+ if (!group || group.tabled || !group.scalarFactsOnly) break;
347
+ groups.push(group);
348
+ runLength++;
349
+ }
350
+ if (runLength < 2) return false;
351
+
352
+ const rest = goals.slice(runLength);
353
+ const localStack = [{ index: 0, names: [], values: [], depth }];
354
+ const frames = [];
355
+
356
+ while (localStack.length) {
357
+ const state = localStack.pop();
358
+ solver.stats.max_depth = Math.max(solver.stats.max_depth, state.depth);
359
+ if (state.index === runLength) {
360
+ const next = env.clone();
361
+ for (let i = 0; i < state.names.length; i++) next.bind(state.names[i], state.values[i]);
362
+ frames.push({ kind: 'goals', goals: rest, env: next, depth: state.depth, active });
363
+ if (frames.length > SCALAR_FACT_RUN_FRAME_LIMIT) return false;
364
+ continue;
365
+ }
366
+
367
+ const goal = goals[state.index];
368
+ if (activeMightContain(goal, active) && activeVariantIn(goal, envWithLocal(env, state.names, state.values), active)) continue;
369
+ solver.stats.solve_one_goal_calls++;
370
+ const candidates = selectScalarFactCandidates(groups[state.index], goal, env, state.names, state.values);
371
+ const nextStates = [];
372
+ for (const pass of [candidates.primary, candidates.fallback]) {
373
+ for (const clause of pass) {
374
+ const match = matchScalarFactLocal(goal, clause.head, env, state.names, state.values);
375
+ if (!match) continue;
376
+ solver.stats.unify_calls++;
377
+ nextStates.push({ index: state.index + 1, names: match.names, values: match.values, depth: state.depth + 1 });
378
+ }
379
+ }
380
+ for (let i = nextStates.length - 1; i >= 0; i--) localStack.push(nextStates[i]);
381
+ if (solver.solutionsSeen >= solver.solutionLimit) break;
382
+ }
383
+
384
+ for (let i = frames.length - 1; i >= 0; i--) stack.push(frames[i]);
385
+ return true;
386
+ }
387
+
388
+
389
+ function activeMightContain(goal, active) {
390
+ if (active.length === 0 || goal.type !== COMPOUND) return false;
391
+ for (const entry of active) {
392
+ const activeGoal = entry.goal;
393
+ if (activeGoal?.type === COMPOUND && activeGoal.name === goal.name && activeGoal.arity === goal.arity) return true;
394
+ }
395
+ return false;
396
+ }
397
+
398
+ function envWithLocal(env, names, values) {
399
+ if (names.length === 0) return env;
400
+ return {
401
+ has(name) { return names.includes(name) || env.has(name); },
402
+ get(name) {
403
+ const index = names.indexOf(name);
404
+ return index >= 0 ? values[index] : env.get(name);
405
+ },
406
+ };
407
+ }
408
+
409
+ function selectScalarFactCandidates(group, goal, env, names, values) {
410
+ let bestPrimary = group.clauses;
411
+ let bestFallback = [];
412
+ let bestLen = group.clauses.length;
413
+ let indexed = false;
414
+
415
+ for (let i = 0; i < goal.arity; i++) {
416
+ const arg = derefScalarMatch(goal.args[i], env, names, values);
417
+ if (!isScalarTerm(arg)) continue;
418
+ const index = group.argIndexes[i];
419
+ const bucket = index.buckets.get(arg.name) ?? [];
420
+ const candidateLen = bucket.length + index.fallback.length;
421
+ if (!indexed || candidateLen < bestLen) {
422
+ bestPrimary = bucket;
423
+ bestFallback = index.fallback;
424
+ bestLen = candidateLen;
425
+ indexed = true;
426
+ if (bestLen === 0) break;
427
+ }
428
+ }
429
+
430
+ for (const pair of group.pairIndexes) {
431
+ const left = derefScalarMatch(goal.args[pair.left], env, names, values);
432
+ const right = derefScalarMatch(goal.args[pair.right], env, names, values);
433
+ if (!isScalarTerm(left) || !isScalarTerm(right)) continue;
434
+ const bucket = pair.buckets.get(`${left.name}\x1f${right.name}`) ?? [];
435
+ const candidateLen = bucket.length + pair.fallback.length;
436
+ if (!indexed || candidateLen < bestLen) {
437
+ bestPrimary = bucket;
438
+ bestFallback = pair.fallback;
439
+ bestLen = candidateLen;
440
+ indexed = true;
441
+ if (bestLen === 0) break;
442
+ }
443
+ }
444
+
445
+ return { primary: bestPrimary, fallback: bestFallback };
446
+ }
447
+
448
+ function matchScalarFactLocal(goal, head, env, names, values) {
449
+ if (goal.type !== COMPOUND || head.type !== COMPOUND) return null;
450
+ if (goal.name !== head.name || goal.arity !== head.arity) return null;
451
+
452
+ let nextNames = names;
453
+ let nextValues = values;
454
+ for (let i = 0; i < goal.arity; i++) {
455
+ const factArg = head.args[i];
456
+ const arg = derefScalarMatch(goal.args[i], env, nextNames, nextValues);
457
+ if (arg.type === 'var') {
458
+ if (nextNames === names) {
459
+ nextNames = names.slice();
460
+ nextValues = values.slice();
461
+ }
462
+ nextNames.push(arg.name);
463
+ nextValues.push(factArg);
464
+ continue;
465
+ }
466
+ if (!isScalarTerm(arg) || arg.name !== factArg.name) return null;
467
+ }
468
+ return { names: nextNames, values: nextValues };
469
+ }
470
+
471
+ function matchScalarFact(goal, head, env) {
472
+ // A scalar ground fact has no variables to freshen and no compound structure
473
+ // to traverse. Match the goal arguments directly and clone only after the
474
+ // candidate has succeeded.
475
+ if (goal.type !== COMPOUND || head.type !== COMPOUND) return null;
476
+ if (goal.name !== head.name || goal.arity !== head.arity) return null;
477
+
478
+ const names = [];
479
+ const values = [];
480
+ for (let i = 0; i < goal.arity; i++) {
481
+ const factArg = head.args[i];
482
+ let arg = derefScalarMatch(goal.args[i], env, names, values);
483
+ if (arg.type === 'var') {
484
+ names.push(arg.name);
485
+ values.push(factArg);
486
+ continue;
487
+ }
488
+ if (!isScalarTerm(arg) || arg.name !== factArg.name) return null;
489
+ }
490
+
491
+ const next = env.clone();
492
+ for (let i = 0; i < names.length; i++) next.bind(names[i], values[i]);
493
+ return next;
494
+ }
495
+
496
+ function derefScalarMatch(term, env, names, values) {
497
+ let current = term;
498
+ for (let guard = 0; current?.type === 'var' && guard < 128; guard++) {
499
+ const localIndex = names.indexOf(current.name);
500
+ if (localIndex >= 0) current = values[localIndex];
501
+ else if (env.has(current.name)) current = env.get(current.name);
502
+ else break;
503
+ }
504
+ return current;
505
+ }
506
+
312
507
  function tryPushGroundChainFrames(stack, solver, group, goal, rest, env, depth, active) {
313
508
  // Compress deterministic ground single-goal chains such as deep taxonomy
314
509
  // proofs: a(ind, n100000) -> a(ind, n99999) -> ... -> a(ind, n0).
@@ -331,7 +526,7 @@ function tryPushGroundChainFrames(stack, solver, group, goal, rest, env, depth,
331
526
  // finite taxonomy chains with the recursive maxDepth guard.
332
527
  if (solver.solutionsSeen >= solver.solutionLimit) return true;
333
528
  solver.stats.max_depth = Math.max(solver.stats.max_depth, currentDepth);
334
- const key = `${currentGoal.name}/${currentGoal.arity}:${termToString(currentGoal, currentEnv, true)}`;
529
+ const key = groundChainKey(currentGoal);
335
530
  if (seen.has(key)) return true;
336
531
  if (activeVariantIn(currentGoal, currentEnv, active)) return true;
337
532
  if (solver.groundChainSuccess.has(key)) {
@@ -346,28 +541,22 @@ function tryPushGroundChainFrames(stack, solver, group, goal, rest, env, depth,
346
541
  for (const pass of [candidates.primary, candidates.fallback]) {
347
542
  for (const clause of pass) {
348
543
  if (headCannotMatch(currentGoal, clause.head, currentEnv)) continue;
349
- const id = nextFreshId();
350
- const freshHead = freshTerm(clause.head, id);
351
- const freshBody = clause.body.map((term) => freshTerm(term, id));
352
- const next = new Env();
353
- solver.stats.unify_calls++;
354
- if (!unify(currentGoal, freshHead, next)) continue;
355
- matches.push({ body: freshBody, env: next });
544
+ const match = matchGroundClause(currentGoal, clause);
545
+ if (match === undefined) return false;
546
+ if (match === null) continue;
547
+ matches.push(match);
356
548
  if (matches.length > 1) return false;
357
549
  }
358
550
  }
359
551
 
360
552
  if (matches.length !== 1) return false;
361
553
  const match = matches[0];
362
- if (match.body.length === 0) {
554
+ if (match.done) {
363
555
  rememberGroundChainSuccess(solver, seen);
364
556
  stack.push({ kind: 'goals', goals: rest, env: baseEnv, depth: depth + 1, active });
365
557
  return true;
366
558
  }
367
- if (match.body.length !== 1) return false;
368
- const nextGoal = match.body[0];
369
- if (nextGoal.type !== COMPOUND || !termIsGround(nextGoal, match.env)) return false;
370
- const resolvedNextGoal = copyResolved(nextGoal, match.env);
559
+ const resolvedNextGoal = match.nextGoal;
371
560
  const nextGroup = solver.program.findGroup(resolvedNextGoal.name, resolvedNextGoal.arity);
372
561
  if (!nextGroup) return false;
373
562
 
@@ -378,6 +567,72 @@ function tryPushGroundChainFrames(stack, solver, group, goal, rest, env, depth,
378
567
  }
379
568
 
380
569
 
570
+
571
+
572
+ function matchGroundClause(goal, clause) {
573
+ if (clause.head.type !== COMPOUND || goal.type !== COMPOUND) return undefined;
574
+ if (clause.head.name !== goal.name || clause.head.arity !== goal.arity) return null;
575
+
576
+ const names = [];
577
+ const values = [];
578
+ for (let i = 0; i < goal.arity; i++) {
579
+ const headArg = clause.head.args[i];
580
+ const goalArg = goal.args[i];
581
+ if (headArg.type === 'var') {
582
+ let index = names.indexOf(headArg.name);
583
+ if (index < 0) {
584
+ names.push(headArg.name);
585
+ values.push(goalArg);
586
+ } else if (!sameGroundTerm(values[index], goalArg)) {
587
+ return null;
588
+ }
589
+ } else if (isScalarTerm(headArg)) {
590
+ if (!sameGroundTerm(headArg, goalArg)) return null;
591
+ } else {
592
+ return undefined;
593
+ }
594
+ }
595
+
596
+ if (clause.body.length === 0) return { done: true };
597
+ if (clause.body.length !== 1) return undefined;
598
+ const bodyGoal = clause.body[0];
599
+ if (bodyGoal.type !== COMPOUND) return undefined;
600
+ const args = [];
601
+ for (const arg of bodyGoal.args) {
602
+ if (arg.type === 'var') {
603
+ const index = names.indexOf(arg.name);
604
+ if (index < 0) return undefined;
605
+ args.push(values[index]);
606
+ } else if (isScalarTerm(arg)) {
607
+ args.push(arg);
608
+ } else {
609
+ return undefined;
610
+ }
611
+ }
612
+ return { nextGoal: compound(bodyGoal.name, args) };
613
+ }
614
+
615
+ function isScalarTerm(term) {
616
+ return term && (term.type === 'atom' || term.type === 'string' || term.type === 'number');
617
+ }
618
+
619
+ function sameGroundTerm(left, right) {
620
+ if (left?.type !== right?.type || left?.name !== right?.name) return false;
621
+ const arity = left.args?.length ?? 0;
622
+ if (arity !== (right.args?.length ?? 0)) return false;
623
+ for (let i = 0; i < arity; i++) if (!sameGroundTerm(left.args[i], right.args[i])) return false;
624
+ return true;
625
+ }
626
+
627
+ function groundChainKey(term) {
628
+ if (term?.type === COMPOUND) {
629
+ let out = `${term.name}/${term.arity}`;
630
+ for (let i = 0; i < term.arity; i++) out += `${groundChainKey(term.args[i])}`;
631
+ return out;
632
+ }
633
+ return `${term?.type ?? ''}:${term?.name ?? ''}`;
634
+ }
635
+
381
636
  function rememberGroundChainSuccess(solver, seen) {
382
637
  for (const key of seen) solver.groundChainSuccess.add(key);
383
638
  }
@@ -1,5 +1,5 @@
1
1
  % Reference 9.3, 9.6: lexical comparison and simple text matching.
2
- answer(matches, true) :- matches("eyelang", "yel").
2
+ answer(matches, true) :- matches("eyelang", "eye").
3
3
  answer(not_matches, true) :- not_matches("eyelang", "cat").
4
4
  answer(lex_lt, true) :- lt(alpha, beta).
5
5
  answer(lex_gt, true) :- gt(beta, alpha).
@@ -1,4 +1,4 @@
1
1
  % Reference 9.6: atom and string built-ins.
2
2
  answer(str_concat, X) :- str_concat("eye", "lang", X).
3
- answer(contains, true) :- contains("eyelang", "yel").
3
+ answer(contains, true) :- contains("eyelang", "eye").
4
4
  materialize(answer, 2).
@@ -1,9 +1,9 @@
1
1
  % Reference 9.6: substring and replace have finite boundary behavior.
2
2
  materialize(answer, 2).
3
- answer(prefix, X) :- substring("eyelang", 0, 3, X).
4
- answer(middle, X) :- substring("eyelang", 3, 2, X).
5
- answer(suffix, X) :- substring("eyelang", 2, 3, X).
6
- answer(empty_at_end, X) :- substring("eyelang", 7, 0, X).
7
- answer(out_of_range_rejected, ok) :- not(substring("eyelang", 7, 2, X)).
3
+ answer(prefix, X) :- substring("eyelanglet", 0, 3, X).
4
+ answer(middle, X) :- substring("eyelanglet", 3, 2, X).
5
+ answer(suffix, X) :- substring("eyelanglet", 2, 3, X).
6
+ answer(empty_at_end, X) :- substring("eyelanglet", 10, 0, X).
7
+ answer(out_of_range_rejected, ok) :- not(substring("eyelanglet", 10, 2, X)).
8
8
  answer(replace_all, X) :- replace("banana", "na", "NA", X).
9
9
  answer(replace_missing, X) :- replace("banana", "x", "y", X).
@@ -10,7 +10,7 @@ import { TestReporter, isMainModule } from './test-style.mjs';
10
10
 
11
11
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)));
12
12
  const packageRoot = path.resolve(root, '..');
13
- const cliBin = path.join(packageRoot, 'src', 'bin.js');
13
+ const cliBin = path.join(packageRoot, 'bin', 'eyelang.js');
14
14
  const filterArg = process.argv[2] ?? null;
15
15
 
16
16
  export function runConformance(reporter = new TestReporter(), requestedFilter = null) {
@@ -40,8 +40,7 @@ import { hashHex } from '../src/hash.js';
40
40
 
41
41
  const testRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)));
42
42
  const packageRoot = path.resolve(testRoot, '..');
43
- const runtimeRoot = path.join(packageRoot, 'src');
44
- const bin = path.join(runtimeRoot, 'bin.js');
43
+ const bin = path.join(packageRoot, 'bin', 'eyelang.js');
45
44
  const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
46
45
  let tmp = null;
47
46
  let tmpCounter = 0;
@@ -182,7 +181,7 @@ why(
182
181
  assertIncludes(result.stdout, '-v, --version', 'stdout');
183
182
  assertIncludes(result.stdout, '-w, --warnings', 'stdout');
184
183
  assertIncludes(result.stdout, '-v, --version Show the package version and exit.\n -w, --warnings Print non-fatal portability warnings to stderr.', 'stdout');
185
- assertIncludes(result.stdout, 'Read an Eyelang program', 'stdout');
184
+ assertIncludes(result.stdout, 'Read a Eyelang program', 'stdout');
186
185
  assertEqual(result.stderr, '', 'stderr');
187
186
  },
188
187
  },
@@ -207,7 +206,7 @@ why(
207
206
  {
208
207
  name: 'npm exec can run package CLI bin from checkout',
209
208
  run: () => {
210
- const result = spawnSync('npm', ['exec', '--', 'eyelang', '--version'], {
209
+ const result = spawnSync('npm', ['exec', '--yes', '--package=.', '--', 'eyelang', '--version'], {
211
210
  cwd: packageRoot,
212
211
  encoding: 'utf8',
213
212
  env: { ...process.env, npm_config_update_notifier: 'false' },
package/src/bin.js DELETED
@@ -1,7 +0,0 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- import('./cli.js').then(({ main }) => main(process.argv.slice(2))).catch((error) => {
5
- console.error(`eyelang: ${error && error.message ? error.message : String(error)}`);
6
- process.exit(1);
7
- });
@@ -1 +0,0 @@
1
- (countall(item(X), C), countall(missing(X), Z))
@@ -1 +0,0 @@
1
- sumall(S, score(_item, S), Sum)
@@ -1 +0,0 @@
1
- aggregate_min(S, item(Name, S), score(Name, S), Bests, Best)
@@ -1 +0,0 @@
1
- aggregate_max([S, Name], Name, score(Name, S), Key, Bestname)
@@ -1 +0,0 @@
1
- value(K, V)
@@ -1 +0,0 @@
1
- answer(K, V)
@@ -1 +0,0 @@
1
- answer(K, V)
@@ -1 +0,0 @@
1
- answer(K, V)
@@ -1 +0,0 @@
1
- answer(K, V)
@@ -1 +0,0 @@
1
- ancestor(pat, X)
@@ -1 +0,0 @@
1
- answer(K, V)