eyeling 1.33.11 → 1.34.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.
package/README.md CHANGED
@@ -818,13 +818,13 @@ Formula-aware built-ins make Eyeling useful for meta-reasoning. `log:includes`,
818
818
 
819
819
  ### eyelang built-ins
820
820
 
821
- The eyelang engine has its own built-in registry under `lib/eyelang/builtins/`. These are separate from the N3 namespaces above and are called as ordinary eyelang predicates. See the [eyelang language reference](docs/eyelang-language-reference.md#9-standard-built-in-predicates) for the portable profile. The bundled implementation currently registers:
821
+ The eyelang engine has its own built-in registry under `lib/eyelang/builtins/`. These are separate from the N3 namespaces above and are called as ordinary eyelang predicates. See the [eyelang language reference](docs/eyelang-language-reference.md#9-standard-built-in-predicates) for the portable profile. The bundled implementation currently registers 68 name/arity entries across 66 predicate names:
822
822
 
823
823
  | Family | Built-ins |
824
824
  |---|---|
825
825
  | Core and host | `eq/2`, `neq/2`, `local_time/1`, `difference/3` |
826
826
  | Arithmetic and comparison | `neg/2`, `abs/2`, `sin/2`, `cos/2`, `asin/2`, `acos/2`, `rounded/2`, `log/2`, `add/3`, `sub/3`, `mul/3`, `div/3`, `mod/3`, `min/3`, `pow/3`, `lt/2`, `gt/2`, `le/2`, `ge/2`, `between/3`, `smallest_divisor_from/3` |
827
- | Strings | `str_concat/3`, `contains/2`, `matches/2`, `not_matches/2` |
827
+ | Strings | `str_concat/3`, `contains/2`, `matches/2`, `matches/3`, `not_matches/2` |
828
828
  | Lists | `append/3`, `nth0/3`, `set_nth0/4`, `rest/2`, `member/2`, `select/3`, `not_member/2`, `reverse/2`, `length/2`, `sort/2` |
829
829
  | Aggregation | `findall/3`, `countall/2`, `sumall/3`, `aggregate_min/5`, `aggregate_max/5` |
830
830
  | Control | `not/1`, `once/1` |
@@ -261,7 +261,7 @@ best(Cycle, Cost) :-
261
261
  weighted_hamiltonian_cycle(edge, Cities, Cycle, Cost).
262
262
  ```
263
263
 
264
- The reusable search and numeric helpers include `n_queens/2`, Hamiltonian path/cycle helpers, `bounded_path/5`, `cnf_model/3`, Quine-McCluskey helpers, number-theory helpers such as `extended_gcd/5`, and matrix helpers such as `matrix_multiply/2`. These helpers are extension builtins of this implementation; [the eyelang language reference](eyelang-language-reference.md) defines the portable core and standard builtin profile.
264
+ The reusable search and numeric helpers include `n_queens/2`, Hamiltonian path/cycle helpers, `bounded_path/5`, `cnf_model/3`, Quine-McCluskey helpers, number-theory helpers such as `extended_gcd/5`, and matrix helpers such as `matrix_multiply/2`. These helpers are extension builtins of this implementation; [the eyelang language reference](eyelang-language-reference.md) defines the portable core and standard builtin profile. The complete bundled implementation list is kept in the top-level [README built-ins section](../README.md#built-ins-1), and the regression suite checks that table against the actual runtime registry.
265
265
 
266
266
  To add a builtin, create or extend a module with `register(registry)` and call `registry.add(name, arity, handler, options)`. The default registry is assembled in [`lib/eyelang/builtins/registry.js`](../lib/eyelang/builtins/registry.js). Builtins that are only safe for specific argument modes should provide a `ready` predicate and `fallbackWhenNotReady: true`, so user-defined clauses remain visible until the builtin is applicable.
267
267
 
@@ -298,6 +298,8 @@ holds((ready, name(alice, "Alice"), route(alice, bob, 7)), Name, Args).
298
298
 
299
299
  Use `holds/2` when you want to match the member term directly, for example `name(S, O)`, `route(A, B, Cost)`, or `edge(A, arc(B, Cost))`. Use `holds/3` when you need the predicate name and argument list as data: it exposes any-arity member as atom constant `Name` plus a proper list `Args`, so zero-, binary-, and ternary members appear as `ready/0`, `name/2`, and `route/3` shapes without a special binary predicate. These utilities are useful for quoted context data, but they do not make those context members true in the ambient program. The [`context-schema-audit.pl`](../examples/eyelang/context-schema-audit.pl) example shows a case that really needs `holds/3`: it audits heterogeneous message contexts by extracting every member as `Name + Args`, computing each arity, and checking the resulting shape against a schema without knowing the predicate names in advance.
300
300
 
301
+ `matches/3` can create context data from named regular-expression captures, which is useful when text logs or messages need to become facts before later rules inspect them with `holds/2` or `holds/3`. See [`observability-log-correlation.pl`](../examples/eyelang/observability-log-correlation.pl) for a complete log-correlation example.
302
+
301
303
 
302
304
  ## Example catalog
303
305
 
@@ -409,6 +411,7 @@ The repository includes examples for recursion, graph reachability, finite searc
409
411
  | [`network-sla.pl`](../examples/eyelang/network-sla.pl) | Checks network path SLA compliance. | [`output/network-sla.pl`](../examples/eyelang/output/network-sla.pl) |
410
412
  | [`newton-raphson.pl`](../examples/eyelang/newton-raphson.pl) | Finds roots by Newton-Raphson iteration. | [`output/newton-raphson.pl`](../examples/eyelang/output/newton-raphson.pl) |
411
413
  | [`nixon-diamond.pl`](../examples/eyelang/nixon-diamond.pl) | Reports the classic Nixon-diamond conflict. | [`output/nixon-diamond.pl`](../examples/eyelang/output/nixon-diamond.pl) |
414
+ | [`observability-log-correlation.pl`](../examples/eyelang/observability-log-correlation.pl) | Extracts named regex captures from observability logs and correlates events by trace id. | [`output/observability-log-correlation.pl`](../examples/eyelang/output/observability-log-correlation.pl) |
412
415
  | [`odrl-dpv-fpv-trust-flow.pl`](../examples/eyelang/odrl-dpv-fpv-trust-flow.pl) | Decides ODRL/DPV data flows with local FPV trust gates. | [`output/odrl-dpv-fpv-trust-flow.pl`](../examples/eyelang/output/odrl-dpv-fpv-trust-flow.pl) |
413
416
  | [`odrl-dpv-healthcare-risk-ranked.pl`](../examples/eyelang/odrl-dpv-healthcare-risk-ranked.pl) | Ranks healthcare policy risks and mitigations. | [`output/odrl-dpv-healthcare-risk-ranked.pl`](../examples/eyelang/output/odrl-dpv-healthcare-risk-ranked.pl) |
414
417
  | [`odrl-dpv-risk-ranked.pl`](../examples/eyelang/odrl-dpv-risk-ranked.pl) | Ranks data-policy risks and mitigations. | [`output/odrl-dpv-risk-ranked.pl`](../examples/eyelang/output/odrl-dpv-risk-ranked.pl) |
@@ -402,6 +402,7 @@ Comparisons interpret numeric-looking terms numerically. Other scalar terms are
402
402
  | `str_concat(A, B, C)` | String concatenation. |
403
403
  | `contains(Text, Needle)` | Text contains `Needle`. |
404
404
  | `matches(Text, Pattern)` | Text matches a simple implementation regex/search pattern. |
405
+ | `matches(Text, Pattern, Context)` | Text matches a JavaScript regular expression with named capture groups; `Context` is a comma context containing one unary term per matched capture group. |
405
406
  | `not_matches(Text, Pattern)` | Negation of `matches/2`. |
406
407
 
407
408
  ### 9.7 Lists
@@ -0,0 +1,34 @@
1
+ % Parse unstructured service logs with named regex captures, then reason over
2
+ % the extracted context to correlate events that share a trace id.
3
+ materialize(parsed_event, 5).
4
+ materialize(captured_field, 3).
5
+ materialize(trace_alert, 3).
6
+
7
+ log_pattern("^ts=(?<ts>\\S+) level=(?<level>\\w+) event=(?<event>\\w+) user=(?<user>\\w+) ip=(?<ip>\\S+) traceparent=00-(?<trace_id>[0-9a-f]{32})-(?<span_id>[0-9a-f]{16})-(?<flags>[0-9a-f]{2})$").
8
+
9
+ raw_log(l1, "ts=2026-06-18T10:00:00Z level=warn event=login_failed user=alice ip=203.0.113.9 traceparent=00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01").
10
+ raw_log(l2, "ts=2026-06-18T10:00:03Z level=error event=payment_denied user=alice ip=203.0.113.9 traceparent=00-4bf92f3577b34da6a3ce929d0e0e4736-aaf067aa0ba90000-01").
11
+ raw_log(l3, "ts=2026-06-18T10:01:12Z level=info event=login_success user=bob ip=198.51.100.4 traceparent=00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01").
12
+ raw_log(noise, "healthcheck ok").
13
+
14
+ parsed(Log, Context) :-
15
+ raw_log(Log, Text),
16
+ log_pattern(Pattern),
17
+ matches(Text, Pattern, Context).
18
+
19
+ % holds/3 lets one generic rule project every named capture as field data.
20
+ captured_field(Log, Name, Value) :-
21
+ parsed(Log, Context),
22
+ holds(Context, Name, [Value]).
23
+
24
+ parsed_event(Log, Event, User, Ip, TraceId) :-
25
+ parsed(Log, Context),
26
+ holds(Context, event(Event)),
27
+ holds(Context, user(User)),
28
+ holds(Context, ip(Ip)),
29
+ holds(Context, trace_id(TraceId)).
30
+
31
+ trace_alert(User, TraceId, Ip) :-
32
+ parsed_event(LoginLog, "login_failed", User, Ip, TraceId),
33
+ parsed_event(PaymentLog, "payment_denied", User, Ip, TraceId),
34
+ neq(LoginLog, PaymentLog).
@@ -0,0 +1,28 @@
1
+ captured_field(l1, ts, "2026-06-18T10:00:00Z").
2
+ captured_field(l1, level, "warn").
3
+ captured_field(l1, event, "login_failed").
4
+ captured_field(l1, user, "alice").
5
+ captured_field(l1, ip, "203.0.113.9").
6
+ captured_field(l1, trace_id, "4bf92f3577b34da6a3ce929d0e0e4736").
7
+ captured_field(l1, span_id, "00f067aa0ba902b7").
8
+ captured_field(l1, flags, "01").
9
+ captured_field(l2, ts, "2026-06-18T10:00:03Z").
10
+ captured_field(l2, level, "error").
11
+ captured_field(l2, event, "payment_denied").
12
+ captured_field(l2, user, "alice").
13
+ captured_field(l2, ip, "203.0.113.9").
14
+ captured_field(l2, trace_id, "4bf92f3577b34da6a3ce929d0e0e4736").
15
+ captured_field(l2, span_id, "aaf067aa0ba90000").
16
+ captured_field(l2, flags, "01").
17
+ captured_field(l3, ts, "2026-06-18T10:01:12Z").
18
+ captured_field(l3, level, "info").
19
+ captured_field(l3, event, "login_success").
20
+ captured_field(l3, user, "bob").
21
+ captured_field(l3, ip, "198.51.100.4").
22
+ captured_field(l3, trace_id, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").
23
+ captured_field(l3, span_id, "bbbbbbbbbbbbbbbb").
24
+ captured_field(l3, flags, "01").
25
+ parsed_event(l1, "login_failed", "alice", "203.0.113.9", "4bf92f3577b34da6a3ce929d0e0e4736").
26
+ parsed_event(l2, "payment_denied", "alice", "203.0.113.9", "4bf92f3577b34da6a3ce929d0e0e4736").
27
+ parsed_event(l3, "login_success", "bob", "198.51.100.4", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").
28
+ trace_alert("alice", "4bf92f3577b34da6a3ce929d0e0e4736", "203.0.113.9").
@@ -1,11 +1,12 @@
1
1
  // String builtins.
2
2
  // They mostly project from already-ground terms to avoid guessing string domains.
3
- import { lexicalValue, stringTerm, unify } from '../term.js';
3
+ import { compound, lexicalValue, stringTerm, unify } from '../term.js';
4
4
 
5
5
  export const stringBuiltins = {
6
6
  register(registry) {
7
7
  registry.add('str_concat', 3, concat, { deterministic: true });
8
8
  for (const name of ['contains', 'matches', 'not_matches']) registry.add(name, 2, contains(name), { deterministic: true });
9
+ registry.add('matches', 3, matchCaptures, { deterministic: true });
9
10
  }
10
11
  };
11
12
 
@@ -32,6 +33,38 @@ function contains(name) {
32
33
  };
33
34
  }
34
35
 
36
+ function* matchCaptures({ goal, env }) {
37
+ const text = lexicalValue(goal.args[0], env);
38
+ const pattern = lexicalValue(goal.args[1], env);
39
+ if (text == null || pattern == null) return;
40
+
41
+ let match;
42
+ try {
43
+ match = new RegExp(pattern).exec(text);
44
+ } catch (_) {
45
+ return;
46
+ }
47
+ if (!match?.groups) return;
48
+
49
+ const context = contextFromGroups(match.groups);
50
+ if (context == null) return;
51
+
52
+ const next = env.clone();
53
+ if (unify(goal.args[2], context, next)) yield next;
54
+ }
55
+
56
+ function contextFromGroups(groups) {
57
+ const terms = [];
58
+ for (const [name, value] of Object.entries(groups)) {
59
+ if (value !== undefined) terms.push(compound(name, [stringTerm(value)]));
60
+ }
61
+ if (terms.length === 0) return null;
62
+
63
+ let context = terms[terms.length - 1];
64
+ for (let i = terms.length - 2; i >= 0; i--) context = compound(',', [terms[i], context]);
65
+ return context;
66
+ }
67
+
35
68
  function simpleAlternationMatch(haystack, pattern) {
36
69
  return pattern.split('|').some((part) => part === '' || haystack.includes(part));
37
70
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eyeling",
3
- "version": "1.33.11",
3
+ "version": "1.34.1",
4
4
  "description": "A minimal Notation3 (N3) reasoner in JavaScript.",
5
5
  "main": "./index.js",
6
6
  "keywords": [
@@ -0,0 +1,13 @@
1
+ % Reference 9.6: matches/3 extracts named regular-expression captures into a context.
2
+ materialize(answer, 3).
3
+
4
+ line("event=login_failed user=alice trace=4bf92f3577b34da6a3ce929d0e0e4736").
5
+ pattern("^event=(?<event>\\w+) user=(?<user>\\w+) trace=(?<trace_id>[0-9a-f]{32})$").
6
+
7
+ answer(User, Event, TraceId) :-
8
+ line(Text),
9
+ pattern(Pattern),
10
+ matches(Text, Pattern, Context),
11
+ holds(Context, event(Event)),
12
+ holds(Context, user(User)),
13
+ holds(Context, trace_id(TraceId)).
@@ -0,0 +1 @@
1
+ answer("alice", "login_failed", "4bf92f3577b34da6a3ce929d0e0e4736").
@@ -196,6 +196,18 @@ why(
196
196
  assertEqual(readmeExamples.join('\n'), examples.join('\n'), 'README example catalog');
197
197
  },
198
198
  },
199
+ {
200
+ name: 'README mirrors the eyelang builtin registry',
201
+ run: () => {
202
+ const actual = registeredBuiltinNames();
203
+ const documented = readmeBuiltinNames();
204
+ assertEqual(documented.join('\n'), actual.join('\n'), 'README builtin catalog');
205
+
206
+ const { entries, names } = readmeBuiltinSummary();
207
+ assertEqual(entries, actual.length, 'README builtin entry count');
208
+ assertEqual(names, new Set(actual.map((item) => item.split('/')[0])).size, 'README builtin name count');
209
+ },
210
+ },
199
211
  {
200
212
  name: 'stdin input is accepted',
201
213
  run: () => {
@@ -499,6 +511,27 @@ function readmeCatalogExampleNames() {
499
511
  .sort();
500
512
  }
501
513
 
514
+ function registeredBuiltinNames() {
515
+ return [...createDefaultRegistry().defs.keys()].sort();
516
+ }
517
+
518
+ function readmeBuiltinNames() {
519
+ const readme = fs.readFileSync(path.join(packageRoot, 'README.md'), 'utf8');
520
+ const section = between(readme, '### eyelang built-ins', '## Custom built-ins');
521
+ return [...section.matchAll(/`([A-Za-z_][A-Za-z0-9_]*)\/(\d+)`/g)]
522
+ .map((match) => `${match[1]}/${match[2]}`)
523
+ .filter((name, index, names) => names.indexOf(name) === index)
524
+ .sort();
525
+ }
526
+
527
+ function readmeBuiltinSummary() {
528
+ const readme = fs.readFileSync(path.join(packageRoot, 'README.md'), 'utf8');
529
+ const section = between(readme, '### eyelang built-ins', '## Custom built-ins');
530
+ const match = section.match(/currently registers (\d+) name\/arity entries across (\d+) predicate names/);
531
+ if (match == null) throw new Error('README builtin summary not found');
532
+ return { entries: Number(match[1]), names: Number(match[2]) };
533
+ }
534
+
502
535
  function playgroundExampleNames() {
503
536
  const html = fs.readFileSync(path.join(root, 'playground.html'), 'utf8');
504
537
  const match = html.match(/const EXAMPLES = \[(.*?)\];/s);