eyeling 1.12.6 → 1.12.7

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/HANDBOOK.md CHANGED
@@ -893,6 +893,12 @@ These are “function-like” relations where the subject is usually a list and
893
893
  - Computes the numeric sum.
894
894
  - Chooses an output datatype based on the “widest” numeric datatype seen among inputs and (optionally) the object position; integers stay integers unless the result is non-integer.
895
895
 
896
+ Eyeling also supports a small, EYE-style convenience for timestamp arithmetic:
897
+
898
+ - **DateTime plus duration/seconds**: `(dateTime durationOrSeconds) math:sum dateTime`
899
+ - `xsd:duration` is interpreted as seconds (same model as `math:difference`).
900
+ - Output is a normalized `xsd:dateTime` in UTC lexical form (`...Z`).
901
+
896
902
  #### `math:product`
897
903
 
898
904
  **Shape:** `( $x1 $x2 ... ) math:product $total`
@@ -911,9 +917,10 @@ Eyeling supports:
911
917
 
912
918
  1. **Numeric subtraction**: `c = a - b`.
913
919
  2. **DateTime difference**: `(dateTime1 dateTime2) math:difference duration`
914
- - Produces an `xsd:duration` in whole days (internally computed via seconds then formatted).
920
+ - Produces an **`xsd:duration`** in a seconds-only lexical form such as `"PT900S"^^xsd:duration`.
921
+ - This avoids ambiguity around month/year day-length and still plays well with `math:lessThan`, `math:greaterThan`, etc. because Eyeling's numeric comparison builtins treat `xsd:duration` as seconds.
915
922
 
916
- 3. **DateTime minus duration**: `(dateTime duration) math:difference dateTime`
923
+ 3. **DateTime minus duration**: `(dateTime durationOrSeconds) math:difference dateTime`
917
924
  - Subtracts a duration from a dateTime and yields a new dateTime.
918
925
 
919
926
  If the types don’t fit any supported case, the builtin fails.
@@ -1963,21 +1970,19 @@ In that sense, N3 is less a bid to make the web “smarter” than a bid to make
1963
1970
 
1964
1971
  <a id="app-c"></a>
1965
1972
 
1966
- ## Appendix C — N3 beyond Prolog: logic that survives the open web
1967
-
1968
- At first glance, an N3 rule set can feel familiar if you’ve used Prolog: variables, unification, and rules that read like “if this pattern holds, then that pattern follows.” But N3 is not just “logic programming with a different syntax.” It is logic shaped for a different environment: not a single program with a single database, but a world of distributed graphs that can be published, merged, and cited across boundaries.
1973
+ ## Appendix C — N3 beyond Prolog: logic for RDF-style graphs
1969
1974
 
1970
- That change of environment forces a change in whatbeyond Prolog” even means. It is less about being more powerful in the abstract, and more about being _more portable as meaning_ logic that stays connected when it moves between documents, vocabularies, and authors.
1975
+ Notation3 (N3) rule sets often look similar to Prolog at the surface: they use variables, unification, and implication-style rules (if these patterns match, then these patterns follow”). N3 is typically used in a different setting, though: instead of a single program operating over a single local database, N3 rules and data are commonly written as documents that can be published, shared, merged, and referenced across systems.
1971
1976
 
1972
- Several design moves push N3 into that web-native space:
1977
+ In practice, that setting is reflected in several common features of N3-style rule writing:
1973
1978
 
1974
- - **Global identity is the default.** Names are IRIs. A rule does not merely compute with local symbols; it operates over identifiers meant to be shared across datasets.
1975
- - **Graphs are the unit of exchange.** The input is a graph; the output is a graph. Inference produces new triples rather than hidden internal state, so results can travel the same way the facts do.
1976
- - **Statements can be treated as data.** Quoted graphs let you talk _about_ assertions: claims, policies, provenance, “this source says …,” “this formula implies …,” and other meta-level structure that is awkward in a plain predicate database.
1977
- - **Rules can be publishable artifacts.** Rules can live alongside data as text, be versioned, reviewed, and reused the “meaning” is not forced back into an external codebase.
1978
- - **Web-like computation can be pulled into rule bodies.** Built-ins make room for the small computations that real integration needs (strings, lists, comparisons), and some N3 workflows even treat IRIs as pointers to more knowledge.
1979
+ - **Identifiers are IRIs.** Terms are usually global identifiers rather than local symbols, which supports linking across datasets.
1980
+ - **Input and output are graphs.** Rules consume graph patterns and produce additional triples, so the result of inference can be represented in the same form as the input data.
1981
+ - **Quoted graphs allow statements-as-data.** N3 can treat a graph (a set of triples) as a term, which makes it possible to represent and reason about assertions (e.g., “this source says …” or “this formula implies …”) as data.
1982
+ - **Rules can be distributed as text artifacts.** Rules can live alongside data, be versioned, and be reused without requiring an external host language to “carry” the meaning.
1983
+ - **Built-ins cover common computations.** Many N3 workflows rely on built-ins for operations such as string handling, list processing, comparisons, and related utilities; some workflows also use IRIs as pointers to retrievable content.
1979
1984
 
1980
- In that sense, Prolog is a superb engine for proving things _inside_ a chosen world. N3 is a way to write rules so they keep working _across_ worlds: across documents, across graph boundaries, and across the open-ended growth of linked data. When an engine like Eyeling solves rule bodies with a Prolog-like prover but still saturates forward consequences, it’s exactly this bridge: Prolog-style execution serving a web-scale, graph-first notion of meaning.
1985
+ Engines can combine execution styles in different ways. One common pattern is to use a Prolog-like backward-chaining prover to satisfy rule bodies, while still using forward chaining to add the instantiated conclusions to the fact set until no new facts are produced.
1981
1986
 
1982
1987
  ---
1983
1988
 
@@ -0,0 +1,264 @@
1
+ # ============================================================================
2
+ # Bayes Therapy Decision Support
3
+ #
4
+ # This example extends a tiny Bayesian diagnostic model (Disease -> Symptoms)
5
+ # with a decision layer that scores therapies by expected utility.
6
+ #
7
+ # NOTE: All probabilities and weights are illustrative and not medical advice.
8
+ # ============================================================================
9
+
10
+ @prefix : <https://example.org/diag#> .
11
+ @prefix math: <http://www.w3.org/2000/10/swap/math#> .
12
+ @prefix list: <http://www.w3.org/2000/10/swap/list#> .
13
+
14
+ # -------------------------------------------
15
+ # 1) MODEL: Disease -> Symptoms (Naive Bayes)
16
+ # -------------------------------------------
17
+
18
+ :COVID19 a :Disease; :prior 0.05 .
19
+ :Influenza a :Disease; :prior 0.03 .
20
+ :AllergicRhinitis a :Disease; :prior 0.10 .
21
+ :BacterialPneumonia a :Disease; :prior 0.01 .
22
+
23
+ # Conditional probabilities P(symptom | disease)
24
+ :COVID19 :pGiven [ :symptom :Fever; :p 0.70 ] .
25
+ :COVID19 :pGiven [ :symptom :DryCough; :p 0.65 ] .
26
+ :COVID19 :pGiven [ :symptom :LossOfSmell; :p 0.40 ] .
27
+ :COVID19 :pGiven [ :symptom :Sneezing; :p 0.15 ] .
28
+ :COVID19 :pGiven [ :symptom :ShortBreath; :p 0.20 ] .
29
+
30
+ :Influenza :pGiven [ :symptom :Fever; :p 0.80 ] .
31
+ :Influenza :pGiven [ :symptom :DryCough; :p 0.50 ] .
32
+ :Influenza :pGiven [ :symptom :LossOfSmell; :p 0.05 ] .
33
+ :Influenza :pGiven [ :symptom :Sneezing; :p 0.20 ] .
34
+ :Influenza :pGiven [ :symptom :ShortBreath; :p 0.10 ] .
35
+
36
+ :AllergicRhinitis :pGiven [ :symptom :Fever; :p 0.05 ] .
37
+ :AllergicRhinitis :pGiven [ :symptom :DryCough; :p 0.15 ] .
38
+ :AllergicRhinitis :pGiven [ :symptom :LossOfSmell; :p 0.10 ] .
39
+ :AllergicRhinitis :pGiven [ :symptom :Sneezing; :p 0.80 ] .
40
+ :AllergicRhinitis :pGiven [ :symptom :ShortBreath; :p 0.05 ] .
41
+
42
+ :BacterialPneumonia :pGiven [ :symptom :Fever; :p 0.70 ] .
43
+ :BacterialPneumonia :pGiven [ :symptom :DryCough; :p 0.60 ] .
44
+ :BacterialPneumonia :pGiven [ :symptom :LossOfSmell; :p 0.02 ] .
45
+ :BacterialPneumonia :pGiven [ :symptom :Sneezing; :p 0.05 ] .
46
+ :BacterialPneumonia :pGiven [ :symptom :ShortBreath; :p 0.60 ] .
47
+
48
+ # ----------------------------------------------------------------------
49
+ # 2) THERAPY MODEL: P(improve | disease, therapy) + P(adverse | therapy)
50
+ # ----------------------------------------------------------------------
51
+
52
+ :Paxlovid a :Therapy;
53
+ # aligned with :Case :diseases order
54
+ :successByDisease ( 0.75 0.05 0.02 0.05 );
55
+ :adverse 0.10 .
56
+
57
+ :Oseltamivir a :Therapy;
58
+ :successByDisease ( 0.05 0.60 0.02 0.05 );
59
+ :adverse 0.08 .
60
+
61
+ :Antihistamine a :Therapy;
62
+ :successByDisease ( 0.10 0.10 0.75 0.05 );
63
+ :adverse 0.03 .
64
+
65
+ :Antibiotic a :Therapy;
66
+ :successByDisease ( 0.05 0.05 0.02 0.80 );
67
+ :adverse 0.07 .
68
+
69
+ :SupportiveCare a :Therapy;
70
+ :successByDisease ( 0.30 0.30 0.25 0.20 );
71
+ :adverse 0.01 .
72
+
73
+ :Model :benefitWeight 10; :harmWeight 3 .
74
+
75
+ # -------------------------------------------
76
+ # 3) CASE: evidence (symptoms present/absent)
77
+ # -------------------------------------------
78
+
79
+ :Case a :PatientCase;
80
+ :diseases ( :COVID19 :Influenza :AllergicRhinitis :BacterialPneumonia );
81
+ :therapies ( :Paxlovid :Oseltamivir :SupportiveCare :Antibiotic :Antihistamine );
82
+ :evidence (
83
+ [ :symptom :Fever; :present true ]
84
+ [ :symptom :DryCough; :present true ]
85
+ [ :symptom :LossOfSmell; :present false ]
86
+ [ :symptom :Sneezing; :present false ]
87
+ [ :symptom :ShortBreath; :present false ]
88
+ ).
89
+
90
+ # -----------------------------------------------------------
91
+ # 4) GUARDS (inference fuses): probabilities must be in [0,1]
92
+ # -----------------------------------------------------------
93
+
94
+ { ?d :prior ?p. ?p math:lessThan 0. } => false.
95
+ { ?d :prior ?p. ?p math:greaterThan 1. } => false.
96
+
97
+ { ?d :pGiven [ :p ?p ]. ?p math:lessThan 0. } => false.
98
+ { ?d :pGiven [ :p ?p ]. ?p math:greaterThan 1. } => false.
99
+
100
+ { ?t a :Therapy. ?t :adverse ?p. ?p math:lessThan 0. } => false.
101
+ { ?t a :Therapy. ?t :adverse ?p. ?p math:greaterThan 1. } => false.
102
+
103
+ { ?t a :Therapy. ?t :successByDisease ?ps.
104
+ ?ps list:iterate ( ?i ?p ).
105
+ ?p math:lessThan 0.
106
+ } => false.
107
+
108
+ { ?t a :Therapy. ?t :successByDisease ?ps.
109
+ ?ps list:iterate ( ?i ?p ).
110
+ ?p math:greaterThan 1.
111
+ } => false.
112
+
113
+ # ------------------------------------------
114
+ # 5) HELPERS (all written as backward rules)
115
+ # ------------------------------------------
116
+
117
+ # pairList(d, (x1 x2 ...)) -> ((d x1) (d x2) ...)
118
+ { ( ?d () ) :pairList () } <= true.
119
+
120
+ { ( ?d ?xs ) :pairList ?pairs } <= {
121
+ ?xs list:firstRest ( ?x ?rest ).
122
+ ( ?d ?rest ) :pairList ?tailPairs.
123
+ ?pairs list:firstRest ( ( ?d ?x ) ?tailPairs ).
124
+ }.
125
+
126
+ # factor(d, evidenceItem) -> probability factor for that symptom
127
+ { ( ?d ?ev ) :factor ?p } <= {
128
+ ?ev :symptom ?s.
129
+ ?ev :present true.
130
+ ?d :pGiven [ :symptom ?s; :p ?p ].
131
+ }.
132
+
133
+ { ( ?d ?ev ) :factor ?q } <= {
134
+ ?ev :symptom ?s.
135
+ ?ev :present false.
136
+ ?d :pGiven [ :symptom ?s; :p ?p ].
137
+ ( 1 ?p ) math:difference ?q.
138
+ }.
139
+
140
+ # pairWithConst((x1 x2 ...), c) -> ((x1 c) (x2 c) ...)
141
+ { ( () ?c ) :pairWithConst () } <= true.
142
+
143
+ { ( ?xs ?c ) :pairWithConst ?pairs } <= {
144
+ ?xs list:firstRest ( ?x ?rest ).
145
+ ( ?rest ?c ) :pairWithConst ?tailPairs.
146
+ ?pairs list:firstRest ( ( ?x ?c ) ?tailPairs ).
147
+ }.
148
+
149
+ # zip((a1 a2 ...), (b1 b2 ...)) -> ((a1 b1) (a2 b2) ...)
150
+ { ( () () ) :zip () } <= true.
151
+
152
+ { ( ?as ?bs ) :zip ?pairs } <= {
153
+ ?as list:firstRest ( ?a ?arest ).
154
+ ?bs list:firstRest ( ?b ?brest ).
155
+ ( ?arest ?brest ) :zip ?tailPairs.
156
+ ?pairs list:firstRest ( ( ?a ?b ) ?tailPairs ).
157
+ }.
158
+
159
+ # Helpers for list:map
160
+ { ?pair :mul ?p } <= { ?pair math:product ?p }.
161
+ { ?pair :quot2 ?q } <= { ?pair math:quotient ?q }.
162
+
163
+ # ---------------------------------------------------------
164
+ # 6) DIAGNOSIS: unnormalized scores + normalized posteriors
165
+ # ---------------------------------------------------------
166
+
167
+ # score(d) = prior(d) * Π_e factor(d,e)
168
+ { ?d :scoreFor ?score } <= {
169
+ ?d :prior ?prior.
170
+ :Case :evidence ?evs.
171
+ ( ?d ?evs ) :pairList ?pairs.
172
+ ( ?pairs :factor ) list:map ?factors.
173
+ ?factors math:product ?likelihood.
174
+ ( ?prior ?likelihood ) math:product ?score.
175
+ }.
176
+
177
+ # Compute the score list, total evidence, and posterior list once.
178
+ {
179
+ :Case :diseases ?ds.
180
+ ( ?ds :scoreFor ) list:map ?scores.
181
+ ?scores math:sum ?total.
182
+
183
+ ( ?scores ?total ) :pairWithConst ?scorePairs.
184
+ ( ?scorePairs :quot2 ) list:map ?posteriors.
185
+ } => {
186
+ :Case :scores ?scores;
187
+ :evidenceTotal ?total;
188
+ :posteriors ?posteriors.
189
+ }.
190
+
191
+ # Attach each disease posterior to the disease term (no blank nodes in output)
192
+ {
193
+ :Case :diseases ?ds.
194
+ :Case :posteriors ?posts.
195
+
196
+ ?ds list:iterate ( ?i ?d ).
197
+ ( ?posts ?i ) list:memberAt ?p.
198
+ } => {
199
+ ?d :posterior ?p.
200
+ }.
201
+
202
+ # -------------------------------------------------------
203
+ # 7) THERAPY SCORING: expected success + expected utility
204
+ # -------------------------------------------------------
205
+
206
+ # expectedSuccess(t) = Σ_i post[i] * successByDisease[i]
207
+ {
208
+ :Case :posteriors ?posts.
209
+ ?t a :Therapy.
210
+ ?t :successByDisease ?succ.
211
+
212
+ ( ?posts ?succ ) :zip ?pairs.
213
+ ( ?pairs :mul ) list:map ?terms.
214
+ ?terms math:sum ?expectedSuccess.
215
+
216
+ ?t :adverse ?adverse.
217
+ :Model :benefitWeight ?bw.
218
+ :Model :harmWeight ?hw.
219
+
220
+ ( ?bw ?expectedSuccess ) math:product ?benefit.
221
+ ( ?hw ?adverse ) math:product ?harmCost.
222
+ ( ?benefit ?harmCost ) math:difference ?utility.
223
+ } => {
224
+ ?t :expectedSuccess ?expectedSuccess;
225
+ :expectedAdverse ?adverse;
226
+ :utility ?utility.
227
+ }.
228
+
229
+ # --------------------------------------------------------------
230
+ # 8) RECOMMENDATION: pick max-utility therapy from the case list
231
+ # --------------------------------------------------------------
232
+
233
+ # betterOf(t1,t2) chooses t1 if utility(t1) >= utility(t2), else t2.
234
+ { ( ?t1 ?t2 ) :betterOf ?t1 } <= {
235
+ ?t1 :utility ?u1.
236
+ ?t2 :utility ?u2.
237
+ ?u1 math:notLessThan ?u2.
238
+ }.
239
+ { ( ?t1 ?t2 ) :betterOf ?t2 } <= {
240
+ ?t1 :utility ?u1.
241
+ ?t2 :utility ?u2.
242
+ ?u1 math:lessThan ?u2.
243
+ }.
244
+
245
+ # bestTherapy((t)) = t
246
+ { ( ?ts ) :bestTherapy ?t } <= {
247
+ ?ts list:firstRest ( ?t () ).
248
+ }.
249
+
250
+ # bestTherapy((head rest...)) = betterOf(head, bestTherapy(rest))
251
+ { ( ?ts ) :bestTherapy ?best } <= {
252
+ ?ts list:firstRest ( ?head ?rest ).
253
+ ?rest list:firstRest ( ?_ ?__ ). # ensure rest is non-empty
254
+ ( ?rest ) :bestTherapy ?bestRest.
255
+ ( ?head ?bestRest ) :betterOf ?best.
256
+ }.
257
+
258
+ # Emit a single recommendation for this case.
259
+ {
260
+ :Case :therapies ?ts.
261
+ ( ?ts ) :bestTherapy ?best.
262
+ } => {
263
+ :Case :recommendedTherapy ?best.
264
+ }.
@@ -0,0 +1,26 @@
1
+ @prefix : <https://example.org/diag#> .
2
+ @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
3
+
4
+ :Case :scores ("0.009281999999999999"^^xsd:decimal "0.008208000000000002"^^xsd:decimal "0.00012824999999999997"^^xsd:decimal "0.00156408"^^xsd:decimal) .
5
+ :Case :evidenceTotal "0.019182329999999997"^^xsd:decimal .
6
+ :Case :posteriors ("0.48388282341092037"^^xsd:decimal "0.4278937960091398"^^xsd:decimal "0.006685840562642807"^^xsd:decimal "0.08153754001729718"^^xsd:decimal) .
7
+ :COVID19 :posterior "0.48388282341092037"^^xsd:decimal .
8
+ :Influenza :posterior "0.4278937960091398"^^xsd:decimal .
9
+ :AllergicRhinitis :posterior "0.006685840562642807"^^xsd:decimal .
10
+ :BacterialPneumonia :posterior "0.08153754001729718"^^xsd:decimal .
11
+ :Paxlovid :expectedSuccess "0.388517401170765"^^xsd:decimal .
12
+ :Paxlovid :expectedAdverse 0.10 .
13
+ :Paxlovid :utility "3.5851740117076503"^^xsd:decimal .
14
+ :Oseltamivir :expectedSuccess "0.28514101258814756"^^xsd:decimal .
15
+ :Oseltamivir :expectedAdverse 0.08 .
16
+ :Oseltamivir :utility "2.6114101258814753"^^xsd:decimal .
17
+ :Antihistamine :expectedSuccess "0.10026891936485298"^^xsd:decimal .
18
+ :Antihistamine :expectedAdverse 0.03 .
19
+ :Antihistamine :utility "0.9126891936485299"^^xsd:decimal .
20
+ :Antibiotic :expectedSuccess "0.1109525797960936"^^xsd:decimal .
21
+ :Antibiotic :expectedAdverse 0.07 .
22
+ :Antibiotic :utility "0.8995257979609361"^^xsd:decimal .
23
+ :SupportiveCare :expectedSuccess "0.2915119539701382"^^xsd:decimal .
24
+ :SupportiveCare :expectedAdverse 0.01 .
25
+ :SupportiveCare :utility "2.8851195397013822"^^xsd:decimal .
26
+ :Case :recommendedTherapy :Paxlovid .
@@ -77,13 +77,13 @@ math:notGreaterThan a ex:Builtin ; ex:kind ex:Test;
77
77
  # --- math: arithmetic / numeric functions ----------------------------
78
78
 
79
79
  math:sum a ex:Builtin ; ex:kind ex:Function ;
80
- rdfs:comment "Sum of a list of 2+ numeric terms. Binds/unifies object with the total (integer mode uses BigInt when possible)." .
80
+ rdfs:comment "Sum of a list of numeric terms. Binds/unifies object with the total (integer mode uses BigInt when possible). Also supports 2-element timestamp arithmetic: (xsd:dateTime xsd:duration) or (xsd:dateTime seconds) (and the commuted forms) -> xsd:dateTime." .
81
81
 
82
82
  math:product a ex:Builtin ; ex:kind ex:Function ;
83
- rdfs:comment "Product of a list of 2+ numeric terms. Binds/unifies object with the product (integer mode uses BigInt when possible)." .
83
+ rdfs:comment "Product of a list of numeric terms. Binds/unifies object with the product (integer mode uses BigInt when possible)." .
84
84
 
85
85
  math:difference a ex:Builtin ; ex:kind ex:Function ;
86
- rdfs:comment "Difference of a 2-element list (a b). Supports datetime-datetime -> duration; datetime-duration -> datetime; integer BigInt; otherwise numeric a-b." .
86
+ rdfs:comment "Difference of a 2-element list (a b). Supports xsd:dateTime-xsd:dateTime -> xsd:duration (normalized to PT...S); xsd:dateTime-(xsd:duration|seconds) -> xsd:dateTime; integer BigInt; otherwise numeric a-b." .
87
87
 
88
88
  math:quotient a ex:Builtin ; ex:kind ex:Function ;
89
89
  rdfs:comment "Quotient of a 2-element list (a b). Binds/unifies object with a/b (guards division by zero and non-finite results)." .
package/eyeling.js CHANGED
@@ -788,9 +788,14 @@ function parseNumOrDuration(t) {
788
788
  }
789
789
 
790
790
  function formatDurationLiteralFromSeconds(secs) {
791
+ // xsd:duration allows a leading '-' sign.
792
+ // We emit a conservative seconds-only lexical form so we don't lose precision
793
+ // for sub-day differences (e.g., PT900S).
791
794
  const neg = secs < 0;
792
- const days = Math.round(Math.abs(secs) / 86400.0);
793
- const literalLex = neg ? `"-P${days}D"` : `"P${days}D"`;
795
+ const abs = Math.abs(secs);
796
+ const sLex = Number.isFinite(abs) ? (Number.isInteger(abs) && abs < 1e21 ? abs.toFixed(0) : String(abs)) : 'NaN';
797
+ const core = `P${abs === 0 ? 'T0S' : `T${sLex}S`}`;
798
+ const literalLex = neg ? `"-${core}"` : `"${core}"`;
794
799
  return internLiteral(`${literalLex}^^<${XSD_NS}duration>`);
795
800
  }
796
801
  function numEqualTerm(t, n, eps = 1e-9) {
@@ -1433,6 +1438,49 @@ function evalBuiltin(goal, subst, facts, backRules, depth, varGen, maxResults) {
1433
1438
  if (!(g.s instanceof ListTerm)) return [];
1434
1439
  const xs = g.s.elems;
1435
1440
 
1441
+ // Special-case: (dateTime durationOrSeconds) math:sum dateTime
1442
+ // This mirrors EYE-style convenience for timestamp arithmetic.
1443
+ //
1444
+ // Notes:
1445
+ // - We treat xsd:duration as seconds via parseNumOrDuration (same model as math:difference).
1446
+ // - Output is normalized to UTC lexical form ("...Z"), consistent with other dateTime outputs.
1447
+ if (xs.length === 2) {
1448
+ const dt0 = parseDatetimeLike(xs[0]);
1449
+ const dt1 = parseDatetimeLike(xs[1]);
1450
+
1451
+ if (dt0 !== null && dt1 === null) {
1452
+ const secs = parseNumOrDuration(xs[1]);
1453
+ if (secs !== null) {
1454
+ const outSecs = dt0.getTime() / 1000.0 + secs;
1455
+ const lex = time.utcIsoDateTimeStringFromEpochSeconds(outSecs);
1456
+ const lit = internLiteral(`"${lex}"^^<${XSD_NS}dateTime>`);
1457
+ if (g.o instanceof Var) {
1458
+ const s2 = { ...subst };
1459
+ s2[g.o.name] = lit;
1460
+ return [s2];
1461
+ }
1462
+ const s2 = unifyTerm(g.o, lit, subst);
1463
+ return s2 !== null ? [s2] : [];
1464
+ }
1465
+ }
1466
+
1467
+ if (dt1 !== null && dt0 === null) {
1468
+ const secs = parseNumOrDuration(xs[0]);
1469
+ if (secs !== null) {
1470
+ const outSecs = dt1.getTime() / 1000.0 + secs;
1471
+ const lex = time.utcIsoDateTimeStringFromEpochSeconds(outSecs);
1472
+ const lit = internLiteral(`"${lex}"^^<${XSD_NS}dateTime>`);
1473
+ if (g.o instanceof Var) {
1474
+ const s2 = { ...subst };
1475
+ s2[g.o.name] = lit;
1476
+ return [s2];
1477
+ }
1478
+ const s2 = unifyTerm(g.o, lit, subst);
1479
+ return s2 !== null ? [s2] : [];
1480
+ }
1481
+ }
1482
+ }
1483
+
1436
1484
  const dtOut0 = commonNumericDatatype(xs, g.o);
1437
1485
 
1438
1486
  // Exact integer mode
@@ -1540,18 +1588,25 @@ function evalBuiltin(goal, subst, facts, backRules, depth, varGen, maxResults) {
1540
1588
  if (!(g.s instanceof ListTerm) || g.s.elems.length !== 2) return [];
1541
1589
  const [a0, b0] = g.s.elems;
1542
1590
 
1543
- // 1) Date/datetime difference -> duration (needed for examples/age.n3)
1591
+ // 1) Date/datetime difference -> xsd:duration
1592
+ //
1593
+ // Emit a conservative seconds-only lexical form (e.g., "PT900S"^^xsd:duration).
1594
+ // This avoids day/month/year normalization ambiguity and still allows numeric
1595
+ // comparisons via parseNumOrDuration (used by math:lessThan, etc.).
1544
1596
  const aDt = parseDatetimeLike(a0);
1545
1597
  const bDt = parseDatetimeLike(b0);
1546
1598
  if (aDt !== null && bDt !== null) {
1547
1599
  const diffSecs = (aDt.getTime() - bDt.getTime()) / 1000.0;
1548
- const durTerm = formatDurationLiteralFromSeconds(diffSecs);
1600
+ if (!Number.isFinite(diffSecs)) return [];
1601
+
1602
+ const lit = formatDurationLiteralFromSeconds(diffSecs);
1603
+
1549
1604
  if (g.o instanceof Var) {
1550
1605
  const s2 = { ...subst };
1551
- s2[g.o.name] = durTerm;
1606
+ s2[g.o.name] = lit;
1552
1607
  return [s2];
1553
1608
  }
1554
- const s2 = unifyTerm(g.o, durTerm, subst);
1609
+ const s2 = unifyTerm(g.o, lit, subst);
1555
1610
  return s2 !== null ? [s2] : [];
1556
1611
  }
1557
1612
 
package/lib/builtins.js CHANGED
@@ -776,9 +776,14 @@ function parseNumOrDuration(t) {
776
776
  }
777
777
 
778
778
  function formatDurationLiteralFromSeconds(secs) {
779
+ // xsd:duration allows a leading '-' sign.
780
+ // We emit a conservative seconds-only lexical form so we don't lose precision
781
+ // for sub-day differences (e.g., PT900S).
779
782
  const neg = secs < 0;
780
- const days = Math.round(Math.abs(secs) / 86400.0);
781
- const literalLex = neg ? `"-P${days}D"` : `"P${days}D"`;
783
+ const abs = Math.abs(secs);
784
+ const sLex = Number.isFinite(abs) ? (Number.isInteger(abs) && abs < 1e21 ? abs.toFixed(0) : String(abs)) : 'NaN';
785
+ const core = `P${abs === 0 ? 'T0S' : `T${sLex}S`}`;
786
+ const literalLex = neg ? `"-${core}"` : `"${core}"`;
782
787
  return internLiteral(`${literalLex}^^<${XSD_NS}duration>`);
783
788
  }
784
789
  function numEqualTerm(t, n, eps = 1e-9) {
@@ -1421,6 +1426,49 @@ function evalBuiltin(goal, subst, facts, backRules, depth, varGen, maxResults) {
1421
1426
  if (!(g.s instanceof ListTerm)) return [];
1422
1427
  const xs = g.s.elems;
1423
1428
 
1429
+ // Special-case: (dateTime durationOrSeconds) math:sum dateTime
1430
+ // This mirrors EYE-style convenience for timestamp arithmetic.
1431
+ //
1432
+ // Notes:
1433
+ // - We treat xsd:duration as seconds via parseNumOrDuration (same model as math:difference).
1434
+ // - Output is normalized to UTC lexical form ("...Z"), consistent with other dateTime outputs.
1435
+ if (xs.length === 2) {
1436
+ const dt0 = parseDatetimeLike(xs[0]);
1437
+ const dt1 = parseDatetimeLike(xs[1]);
1438
+
1439
+ if (dt0 !== null && dt1 === null) {
1440
+ const secs = parseNumOrDuration(xs[1]);
1441
+ if (secs !== null) {
1442
+ const outSecs = dt0.getTime() / 1000.0 + secs;
1443
+ const lex = time.utcIsoDateTimeStringFromEpochSeconds(outSecs);
1444
+ const lit = internLiteral(`"${lex}"^^<${XSD_NS}dateTime>`);
1445
+ if (g.o instanceof Var) {
1446
+ const s2 = { ...subst };
1447
+ s2[g.o.name] = lit;
1448
+ return [s2];
1449
+ }
1450
+ const s2 = unifyTerm(g.o, lit, subst);
1451
+ return s2 !== null ? [s2] : [];
1452
+ }
1453
+ }
1454
+
1455
+ if (dt1 !== null && dt0 === null) {
1456
+ const secs = parseNumOrDuration(xs[0]);
1457
+ if (secs !== null) {
1458
+ const outSecs = dt1.getTime() / 1000.0 + secs;
1459
+ const lex = time.utcIsoDateTimeStringFromEpochSeconds(outSecs);
1460
+ const lit = internLiteral(`"${lex}"^^<${XSD_NS}dateTime>`);
1461
+ if (g.o instanceof Var) {
1462
+ const s2 = { ...subst };
1463
+ s2[g.o.name] = lit;
1464
+ return [s2];
1465
+ }
1466
+ const s2 = unifyTerm(g.o, lit, subst);
1467
+ return s2 !== null ? [s2] : [];
1468
+ }
1469
+ }
1470
+ }
1471
+
1424
1472
  const dtOut0 = commonNumericDatatype(xs, g.o);
1425
1473
 
1426
1474
  // Exact integer mode
@@ -1528,18 +1576,25 @@ function evalBuiltin(goal, subst, facts, backRules, depth, varGen, maxResults) {
1528
1576
  if (!(g.s instanceof ListTerm) || g.s.elems.length !== 2) return [];
1529
1577
  const [a0, b0] = g.s.elems;
1530
1578
 
1531
- // 1) Date/datetime difference -> duration (needed for examples/age.n3)
1579
+ // 1) Date/datetime difference -> xsd:duration
1580
+ //
1581
+ // Emit a conservative seconds-only lexical form (e.g., "PT900S"^^xsd:duration).
1582
+ // This avoids day/month/year normalization ambiguity and still allows numeric
1583
+ // comparisons via parseNumOrDuration (used by math:lessThan, etc.).
1532
1584
  const aDt = parseDatetimeLike(a0);
1533
1585
  const bDt = parseDatetimeLike(b0);
1534
1586
  if (aDt !== null && bDt !== null) {
1535
1587
  const diffSecs = (aDt.getTime() - bDt.getTime()) / 1000.0;
1536
- const durTerm = formatDurationLiteralFromSeconds(diffSecs);
1588
+ if (!Number.isFinite(diffSecs)) return [];
1589
+
1590
+ const lit = formatDurationLiteralFromSeconds(diffSecs);
1591
+
1537
1592
  if (g.o instanceof Var) {
1538
1593
  const s2 = { ...subst };
1539
- s2[g.o.name] = durTerm;
1594
+ s2[g.o.name] = lit;
1540
1595
  return [s2];
1541
1596
  }
1542
- const s2 = unifyTerm(g.o, durTerm, subst);
1597
+ const s2 = unifyTerm(g.o, lit, subst);
1543
1598
  return s2 !== null ? [s2] : [];
1544
1599
  }
1545
1600
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eyeling",
3
- "version": "1.12.6",
3
+ "version": "1.12.7",
4
4
  "description": "A minimal Notation3 (N3) reasoner in JavaScript.",
5
5
  "main": "./index.js",
6
6
  "keywords": [
@@ -145,8 +145,11 @@ function main() {
145
145
  let passed = 0;
146
146
  let failed = 0;
147
147
 
148
+ // Pretty, stable numbering (e.g., 001..100 when running 100 tests)
149
+ const idxWidth = String(files.length).length;
150
+
148
151
  for (let i = 0; i < files.length; i++) {
149
- const idx = String(i + 1).padStart(2, '0');
152
+ const idx = String(i + 1).padStart(idxWidth, '0');
150
153
  const file = files[i];
151
154
 
152
155
  const start = Date.now();