eyeling 1.28.7 → 1.28.8

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
@@ -621,11 +621,11 @@ Eyeling provides datatype built-ins in the namespace `https://eyereasoner.github
621
621
  Supported operations include:
622
622
 
623
623
  - `dt:datatype`, `dt:lexicalForm`, and `dt:language` for literal inspection;
624
- - `dt:validForDatatype` and `dt:invalidForDatatype` for lexical validity and datatype membership checks;
624
+ - `dt:validForDatatype` and `dt:invalidForDatatype` for lexical validity and datatype membership checks, either as `?literal dt:validForDatatype ?datatype` tests or as tuple-to-boolean checks like `(?literal ?datatype) dt:validForDatatype true`;
625
625
  - `dt:sameValueAs` and `dt:differentValueFrom` for value-space equality and inequality;
626
626
  - `dt:canonicalLiteral` for canonical literal production.
627
627
 
628
- The built-ins cover RDF language strings and the OWL 2 RL-relevant XSD set: `xsd:string`, `xsd:normalizedString`, `xsd:token`, `xsd:language`, `xsd:Name`, `xsd:NCName`, `xsd:NMTOKEN`, `xsd:boolean`, `xsd:decimal`, `xsd:integer` and its bounded integer subtypes, `xsd:float`, `xsd:double`, `xsd:hexBinary`, `xsd:base64Binary`, `xsd:anyURI`, `xsd:dateTime`, and `xsd:dateTimeStamp`.
628
+ The built-ins use strict datatype lexical validation for these checks, including exact dateTime rollover/canonicalization such as `24:00:00` normalizing to the following day. They cover RDF language strings and the OWL 2 RL-relevant XSD set: `xsd:string`, `xsd:normalizedString`, `xsd:token`, `xsd:language`, `xsd:Name`, `xsd:NCName`, `xsd:NMTOKEN`, `xsd:boolean`, `xsd:decimal`, `xsd:integer` and its bounded integer subtypes, `xsd:float`, `xsd:double`, `xsd:hexBinary`, `xsd:base64Binary`, `xsd:anyURI`, `xsd:dateTime`, and `xsd:dateTimeStamp`.
629
629
 
630
630
  Example:
631
631
 
@@ -638,6 +638,7 @@ Example:
638
638
  "01"^^xsd:integer dt:sameValueAs "1.0"^^xsd:decimal .
639
639
  "2026-06-10T12:00:00Z"^^xsd:dateTime
640
640
  dt:sameValueAs "2026-06-10T14:00:00+02:00"^^xsd:dateTime .
641
+ ("abc"^^xsd:integer xsd:integer) dt:validForDatatype false .
641
642
  }
642
643
  =>
643
644
  {
@@ -1179,7 +1179,7 @@ function parseIntegerDatatypeValue(lexical, dt) {
1179
1179
  }
1180
1180
 
1181
1181
  function parseDecimalDatatypeValue(lexical) {
1182
- const parsed = parseXsdDecimalToBigIntScale(String(lexical));
1182
+ const parsed = parseXsdDecimalToBigIntScale(String(lexical), { trim: false });
1183
1183
  if (!parsed) return null;
1184
1184
  const canonicalLex = canonicalDecimalLexFromParts(parsed.num, parsed.scale);
1185
1185
  return {
@@ -1305,58 +1305,93 @@ function parseAnyUriDatatypeValue(lexical) {
1305
1305
  };
1306
1306
  }
1307
1307
 
1308
- function isLeapYearNumber(year) {
1309
- if (year % 400 === 0) return true;
1310
- if (year % 100 === 0) return false;
1311
- return year % 4 === 0;
1308
+ function isLeapYearBigInt(year) {
1309
+ if (year % 400n === 0n) return true;
1310
+ if (year % 100n === 0n) return false;
1311
+ return year % 4n === 0n;
1312
1312
  }
1313
1313
 
1314
- function daysInMonthNumber(year, month) {
1315
- if (month === 2) return isLeapYearNumber(year) ? 29 : 28;
1314
+ function daysInMonthBigInt(year, month) {
1315
+ if (month === 2) return isLeapYearBigInt(year) ? 29 : 28;
1316
1316
  return [4, 6, 9, 11].includes(month) ? 30 : 31;
1317
1317
  }
1318
1318
 
1319
- function floorDivNumber(a, b) {
1320
- return Math.floor(a / b);
1319
+ function floorDivBigInt(a, b) {
1320
+ let q = a / b;
1321
+ const r = a % b;
1322
+ if (r !== 0n && ((r > 0n) !== (b > 0n))) q -= 1n;
1323
+ return q;
1321
1324
  }
1322
1325
 
1323
- function daysFromCivilNumber(year, month, day) {
1326
+ function daysFromCivilBigInt(year, month, day) {
1324
1327
  let y = year;
1325
- y -= month <= 2 ? 1 : 0;
1326
- const era = floorDivNumber(y, 400);
1327
- const yoe = y - era * 400;
1328
- const mp = month + (month > 2 ? -3 : 9);
1329
- const doy = Math.floor((153 * mp + 2) / 5) + day - 1;
1330
- const doe = yoe * 365 + Math.floor(yoe / 4) - Math.floor(yoe / 100) + doy;
1331
- return era * 146097 + doe - 719468;
1328
+ if (month <= 2) y -= 1n;
1329
+ const era = floorDivBigInt(y, 400n);
1330
+ const yoe = y - era * 400n;
1331
+ const mp = BigInt(month + (month > 2 ? -3 : 9));
1332
+ const doy = (153n * mp + 2n) / 5n + BigInt(day) - 1n;
1333
+ const doe = yoe * 365n + yoe / 4n - yoe / 100n + doy;
1334
+ return era * 146097n + doe - 719468n;
1335
+ }
1336
+
1337
+ function civilFromDaysBigInt(days) {
1338
+ const z = days + 719468n;
1339
+ const era = floorDivBigInt(z, 146097n);
1340
+ const doe = z - era * 146097n;
1341
+ const yoe = (doe - doe / 1460n + doe / 36524n - doe / 146096n) / 365n;
1342
+ let year = yoe + era * 400n;
1343
+ const doy = doe - (365n * yoe + yoe / 4n - yoe / 100n);
1344
+ const mp = (5n * doy + 2n) / 153n;
1345
+ const day = doy - (153n * mp + 2n) / 5n + 1n;
1346
+ const month = mp < 10n ? mp + 3n : mp - 9n;
1347
+ if (month <= 2n) year += 1n;
1348
+ return { year, month: Number(month), day: Number(day) };
1349
+ }
1350
+
1351
+ function isValidXsdYearLex(s) {
1352
+ const body = s.startsWith('-') ? s.slice(1) : s;
1353
+ if (!/^\d{4,}$/.test(body)) return false;
1354
+ return body.length === 4 || body[0] !== '0';
1355
+ }
1356
+
1357
+ function canonicalYearLex(year) {
1358
+ if (year < 0n) return `-${(-year).toString().padStart(4, '0')}`;
1359
+ return year.toString().padStart(4, '0');
1360
+ }
1361
+
1362
+ function formatDateTimeLocalKey(year, month, day, hour, minute, second, frac) {
1363
+ return `${canonicalYearLex(year)}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}T${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:${String(second).padStart(2, '0')}${frac ? `.${frac}` : ''}`;
1332
1364
  }
1333
1365
 
1334
1366
  function parseDateTimeDatatypeValue(lexical, dt) {
1335
1367
  const s = String(lexical);
1336
1368
  const m = /^(-?\d{4,})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:\d{2})?$/.exec(s);
1337
- if (!m) return null;
1369
+ if (!m || !isValidXsdYearLex(m[1])) return null;
1338
1370
 
1339
- const year = Number(m[1]);
1340
- const month = Number(m[2]);
1371
+ const year = BigInt(m[1]);
1372
+ let month = Number(m[2]);
1341
1373
  let day = Number(m[3]);
1342
1374
  let hour = Number(m[4]);
1343
1375
  const minute = Number(m[5]);
1344
1376
  const second = Number(m[6]);
1345
1377
  let frac = m[7] || '';
1346
1378
  const tz = m[8] || null;
1347
- if (!Number.isSafeInteger(year)) return null;
1348
1379
  if (dt === XSD_DATETIME_STAMP_DT && !tz) return null;
1349
1380
  if (!(month >= 1 && month <= 12)) return null;
1350
- if (!(day >= 1 && day <= daysInMonthNumber(year, month))) return null;
1381
+ if (!(day >= 1 && day <= daysInMonthBigInt(year, month))) return null;
1351
1382
  if (!(minute >= 0 && minute <= 59)) return null;
1352
1383
  if (!(second >= 0 && second <= 59)) return null;
1384
+
1385
+ let dayCount = daysFromCivilBigInt(year, month, day);
1386
+ let normalizedYear = year;
1353
1387
  if (hour === 24) {
1354
1388
  if (minute !== 0 || second !== 0 || /[1-9]/.test(frac)) return null;
1355
1389
  hour = 0;
1356
- const d = new Date(Date.UTC(year, month - 1, day));
1357
- d.setUTCFullYear(year);
1358
- d.setUTCDate(d.getUTCDate() + 1);
1359
- day = d.getUTCDate();
1390
+ dayCount += 1n;
1391
+ const civil = civilFromDaysBigInt(dayCount);
1392
+ normalizedYear = civil.year;
1393
+ month = civil.month;
1394
+ day = civil.day;
1360
1395
  } else if (!(hour >= 0 && hour <= 23)) {
1361
1396
  return null;
1362
1397
  }
@@ -1379,11 +1414,10 @@ function parseDateTimeDatatypeValue(lexical, dt) {
1379
1414
  }
1380
1415
  }
1381
1416
 
1382
- const dayCount = daysFromCivilNumber(year, month, day);
1383
- let totalSeconds = BigInt(dayCount) * 86400n + BigInt(hour * 3600 + minute * 60 + second);
1417
+ let totalSeconds = dayCount * 86400n + BigInt(hour * 3600 + minute * 60 + second);
1384
1418
  if (offsetMinutes !== null) totalSeconds -= BigInt(offsetMinutes * 60);
1385
1419
 
1386
- const localKey = `${m[1]}-${m[2]}-${String(day).padStart(2, '0')}T${String(hour).padStart(2, '0')}:${m[5]}:${m[6]}${frac ? `.${frac}` : ''}`;
1420
+ const localKey = formatDateTimeLocalKey(normalizedYear, month, day, hour, minute, second, frac);
1387
1421
  const canonicalLex = canonicalDateTimeLex(totalSeconds, fracNum, fracScale, offsetMinutes !== null, localKey);
1388
1422
  return {
1389
1423
  family: 'dateTime',
@@ -1403,15 +1437,14 @@ function canonicalDateTimeLex(totalSeconds, fracNum, fracScale, hasTimezone, loc
1403
1437
  const frac = fracScale > 0 ? `.${fracNum.toString().padStart(fracScale, '0')}` : '';
1404
1438
  if (!hasTimezone) return localKey;
1405
1439
 
1406
- const minSafeSeconds = BigInt(Math.ceil(Number.MIN_SAFE_INTEGER / 1000));
1407
- const maxSafeSeconds = BigInt(Math.floor(Number.MAX_SAFE_INTEGER / 1000));
1408
- if (totalSeconds < minSafeSeconds || totalSeconds > maxSafeSeconds) return `${localKey}Z`;
1409
-
1410
- const d = new Date(Number(totalSeconds) * 1000);
1411
- if (Number.isNaN(d.getTime())) return `${localKey}Z`;
1412
- let iso = d.toISOString().replace(/\.000Z$/, 'Z');
1413
- if (frac) iso = iso.replace('Z', `${frac}Z`);
1414
- return iso;
1440
+ const secondsPerDay = 86400n;
1441
+ const dayCount = floorDivBigInt(totalSeconds, secondsPerDay);
1442
+ const secondOfDay = totalSeconds - dayCount * secondsPerDay;
1443
+ const civil = civilFromDaysBigInt(dayCount);
1444
+ const hour = Number(secondOfDay / 3600n);
1445
+ const minute = Number((secondOfDay % 3600n) / 60n);
1446
+ const second = Number(secondOfDay % 60n);
1447
+ return formatDateTimeLocalKey(civil.year, civil.month, civil.day, hour, minute, second, frac) + 'Z';
1415
1448
  }
1416
1449
 
1417
1450
  function parseLangStringDatatypeValue(t, lexical) {
@@ -1529,7 +1562,31 @@ function evalDatatypeInspectionBuiltin(g, subst, kind) {
1529
1562
  return evalBindBuiltinObject(g.o, valueTerm, subst);
1530
1563
  }
1531
1564
 
1565
+ function booleanTermValue(t) {
1566
+ const info = parseBooleanLiteralInfo(t);
1567
+ return info ? info.value : null;
1568
+ }
1569
+
1570
+ function makeBooleanLiteral(value) {
1571
+ return internLiteral(value ? 'true' : 'false');
1572
+ }
1573
+
1574
+ function evalDatatypeValidityBooleanResult(outTerm, result, subst) {
1575
+ if (outTerm instanceof Var || outTerm instanceof Blank) return evalBindBuiltinObject(outTerm, makeBooleanLiteral(result), subst);
1576
+ const expected = booleanTermValue(outTerm);
1577
+ return expected === result ? [__cloneSubst(subst)] : [];
1578
+ }
1579
+
1532
1580
  function evalDatatypeValidityBuiltin(g, subst, positive) {
1581
+ if (g.s instanceof ListTerm && g.s.elems.length === 2) {
1582
+ const [lit, dtTerm] = g.s.elems;
1583
+ if (!(lit instanceof Literal) || !(dtTerm instanceof Iri)) return [];
1584
+ const dt = dtTerm.value;
1585
+ if (!isSupportedDatatypeIri(dt)) return [];
1586
+ const ok = parseDatatypeValueForDatatype(lit, dt) !== null;
1587
+ return evalDatatypeValidityBooleanResult(g.o, positive ? ok : !ok, subst);
1588
+ }
1589
+
1533
1590
  if (!(g.s instanceof Literal)) return [];
1534
1591
 
1535
1592
  let dt = null;
@@ -1723,8 +1780,9 @@ function formatNum(n) {
1723
1780
  return String(n);
1724
1781
  }
1725
1782
 
1726
- function parseXsdDecimalToBigIntScale(s) {
1727
- let t = String(s).trim();
1783
+ function parseXsdDecimalToBigIntScale(s, options = null) {
1784
+ let t = String(s);
1785
+ if (!options || options.trim !== false) t = t.trim();
1728
1786
  let sign = 1n;
1729
1787
 
1730
1788
  if (t.startsWith('+')) t = t.slice(1);
@@ -54,19 +54,19 @@ dt:language a ex:Builtin ; ex:kind ex:Function ;
54
54
  rdfs:comment "Extracts the language tag of an rdf:langString literal as a string literal." .
55
55
 
56
56
  dt:validForDatatype a ex:Builtin ; ex:kind ex:Test ;
57
- rdfs:comment "Succeeds iff the subject literal has a lexical form accepted by the object datatype and denotes a value in that datatype's value space. Supports OWL 2 RL-relevant XSD strings, booleans, numerics, binary types, anyURI, dateTime, and dateTimeStamp." .
57
+ rdfs:comment "Succeeds iff the subject literal has a lexical form accepted by the object datatype and denotes a value in that datatype's value space. Also supports tuple-to-boolean form: (literal datatype) dt:validForDatatype true/false. Supports OWL 2 RL-relevant XSD strings, booleans, numerics, binary types, anyURI, dateTime, and dateTimeStamp." .
58
58
 
59
59
  dt:invalidForDatatype a ex:Builtin ; ex:kind ex:Test ;
60
- rdfs:comment "Succeeds iff the object datatype is supported and the subject literal is not valid for it." .
60
+ rdfs:comment "Succeeds iff the object datatype is supported and the subject literal is not valid for it. Also supports tuple-to-boolean form: (literal datatype) dt:invalidForDatatype true/false." .
61
61
 
62
62
  dt:sameValueAs a ex:Builtin ; ex:kind ex:Test ;
63
- rdfs:comment "Value-space equality over supported datatypes, including integer/decimal numeric equality, boolean 1/true equality, timezone-normalized dateTime equality, string-derived whitespace facets, and binary byte equality." .
63
+ rdfs:comment "Value-space equality over supported datatypes, including integer/decimal numeric equality, boolean 1/true equality, timezone-normalized dateTime equality with exact midnight rollover, string-derived whitespace facets, and binary byte equality." .
64
64
 
65
65
  dt:differentValueFrom a ex:Builtin ; ex:kind ex:Test ;
66
66
  rdfs:comment "Value-space inequality over supported and comparable datatype values." .
67
67
 
68
68
  dt:canonicalLiteral a ex:Builtin ; ex:kind ex:Function ;
69
- rdfs:comment "Binds/unifies the object with a canonical literal for the subject literal's datatype value, such as normalized integer, boolean, token, binary, and timezone-normalized dateTime lexicals." .
69
+ rdfs:comment "Binds/unifies the object with a canonical literal for the subject literal's datatype value, such as normalized integer, boolean, token, binary, and timezone-normalized dateTime lexicals, including 24:00:00 rollover." .
70
70
 
71
71
  # --- crypto: ---------------------------------------------------------
72
72
 
package/eyeling.js CHANGED
@@ -1179,7 +1179,7 @@ function parseIntegerDatatypeValue(lexical, dt) {
1179
1179
  }
1180
1180
 
1181
1181
  function parseDecimalDatatypeValue(lexical) {
1182
- const parsed = parseXsdDecimalToBigIntScale(String(lexical));
1182
+ const parsed = parseXsdDecimalToBigIntScale(String(lexical), { trim: false });
1183
1183
  if (!parsed) return null;
1184
1184
  const canonicalLex = canonicalDecimalLexFromParts(parsed.num, parsed.scale);
1185
1185
  return {
@@ -1305,58 +1305,93 @@ function parseAnyUriDatatypeValue(lexical) {
1305
1305
  };
1306
1306
  }
1307
1307
 
1308
- function isLeapYearNumber(year) {
1309
- if (year % 400 === 0) return true;
1310
- if (year % 100 === 0) return false;
1311
- return year % 4 === 0;
1308
+ function isLeapYearBigInt(year) {
1309
+ if (year % 400n === 0n) return true;
1310
+ if (year % 100n === 0n) return false;
1311
+ return year % 4n === 0n;
1312
1312
  }
1313
1313
 
1314
- function daysInMonthNumber(year, month) {
1315
- if (month === 2) return isLeapYearNumber(year) ? 29 : 28;
1314
+ function daysInMonthBigInt(year, month) {
1315
+ if (month === 2) return isLeapYearBigInt(year) ? 29 : 28;
1316
1316
  return [4, 6, 9, 11].includes(month) ? 30 : 31;
1317
1317
  }
1318
1318
 
1319
- function floorDivNumber(a, b) {
1320
- return Math.floor(a / b);
1319
+ function floorDivBigInt(a, b) {
1320
+ let q = a / b;
1321
+ const r = a % b;
1322
+ if (r !== 0n && ((r > 0n) !== (b > 0n))) q -= 1n;
1323
+ return q;
1321
1324
  }
1322
1325
 
1323
- function daysFromCivilNumber(year, month, day) {
1326
+ function daysFromCivilBigInt(year, month, day) {
1324
1327
  let y = year;
1325
- y -= month <= 2 ? 1 : 0;
1326
- const era = floorDivNumber(y, 400);
1327
- const yoe = y - era * 400;
1328
- const mp = month + (month > 2 ? -3 : 9);
1329
- const doy = Math.floor((153 * mp + 2) / 5) + day - 1;
1330
- const doe = yoe * 365 + Math.floor(yoe / 4) - Math.floor(yoe / 100) + doy;
1331
- return era * 146097 + doe - 719468;
1328
+ if (month <= 2) y -= 1n;
1329
+ const era = floorDivBigInt(y, 400n);
1330
+ const yoe = y - era * 400n;
1331
+ const mp = BigInt(month + (month > 2 ? -3 : 9));
1332
+ const doy = (153n * mp + 2n) / 5n + BigInt(day) - 1n;
1333
+ const doe = yoe * 365n + yoe / 4n - yoe / 100n + doy;
1334
+ return era * 146097n + doe - 719468n;
1335
+ }
1336
+
1337
+ function civilFromDaysBigInt(days) {
1338
+ const z = days + 719468n;
1339
+ const era = floorDivBigInt(z, 146097n);
1340
+ const doe = z - era * 146097n;
1341
+ const yoe = (doe - doe / 1460n + doe / 36524n - doe / 146096n) / 365n;
1342
+ let year = yoe + era * 400n;
1343
+ const doy = doe - (365n * yoe + yoe / 4n - yoe / 100n);
1344
+ const mp = (5n * doy + 2n) / 153n;
1345
+ const day = doy - (153n * mp + 2n) / 5n + 1n;
1346
+ const month = mp < 10n ? mp + 3n : mp - 9n;
1347
+ if (month <= 2n) year += 1n;
1348
+ return { year, month: Number(month), day: Number(day) };
1349
+ }
1350
+
1351
+ function isValidXsdYearLex(s) {
1352
+ const body = s.startsWith('-') ? s.slice(1) : s;
1353
+ if (!/^\d{4,}$/.test(body)) return false;
1354
+ return body.length === 4 || body[0] !== '0';
1355
+ }
1356
+
1357
+ function canonicalYearLex(year) {
1358
+ if (year < 0n) return `-${(-year).toString().padStart(4, '0')}`;
1359
+ return year.toString().padStart(4, '0');
1360
+ }
1361
+
1362
+ function formatDateTimeLocalKey(year, month, day, hour, minute, second, frac) {
1363
+ return `${canonicalYearLex(year)}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}T${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:${String(second).padStart(2, '0')}${frac ? `.${frac}` : ''}`;
1332
1364
  }
1333
1365
 
1334
1366
  function parseDateTimeDatatypeValue(lexical, dt) {
1335
1367
  const s = String(lexical);
1336
1368
  const m = /^(-?\d{4,})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:\d{2})?$/.exec(s);
1337
- if (!m) return null;
1369
+ if (!m || !isValidXsdYearLex(m[1])) return null;
1338
1370
 
1339
- const year = Number(m[1]);
1340
- const month = Number(m[2]);
1371
+ const year = BigInt(m[1]);
1372
+ let month = Number(m[2]);
1341
1373
  let day = Number(m[3]);
1342
1374
  let hour = Number(m[4]);
1343
1375
  const minute = Number(m[5]);
1344
1376
  const second = Number(m[6]);
1345
1377
  let frac = m[7] || '';
1346
1378
  const tz = m[8] || null;
1347
- if (!Number.isSafeInteger(year)) return null;
1348
1379
  if (dt === XSD_DATETIME_STAMP_DT && !tz) return null;
1349
1380
  if (!(month >= 1 && month <= 12)) return null;
1350
- if (!(day >= 1 && day <= daysInMonthNumber(year, month))) return null;
1381
+ if (!(day >= 1 && day <= daysInMonthBigInt(year, month))) return null;
1351
1382
  if (!(minute >= 0 && minute <= 59)) return null;
1352
1383
  if (!(second >= 0 && second <= 59)) return null;
1384
+
1385
+ let dayCount = daysFromCivilBigInt(year, month, day);
1386
+ let normalizedYear = year;
1353
1387
  if (hour === 24) {
1354
1388
  if (minute !== 0 || second !== 0 || /[1-9]/.test(frac)) return null;
1355
1389
  hour = 0;
1356
- const d = new Date(Date.UTC(year, month - 1, day));
1357
- d.setUTCFullYear(year);
1358
- d.setUTCDate(d.getUTCDate() + 1);
1359
- day = d.getUTCDate();
1390
+ dayCount += 1n;
1391
+ const civil = civilFromDaysBigInt(dayCount);
1392
+ normalizedYear = civil.year;
1393
+ month = civil.month;
1394
+ day = civil.day;
1360
1395
  } else if (!(hour >= 0 && hour <= 23)) {
1361
1396
  return null;
1362
1397
  }
@@ -1379,11 +1414,10 @@ function parseDateTimeDatatypeValue(lexical, dt) {
1379
1414
  }
1380
1415
  }
1381
1416
 
1382
- const dayCount = daysFromCivilNumber(year, month, day);
1383
- let totalSeconds = BigInt(dayCount) * 86400n + BigInt(hour * 3600 + minute * 60 + second);
1417
+ let totalSeconds = dayCount * 86400n + BigInt(hour * 3600 + minute * 60 + second);
1384
1418
  if (offsetMinutes !== null) totalSeconds -= BigInt(offsetMinutes * 60);
1385
1419
 
1386
- const localKey = `${m[1]}-${m[2]}-${String(day).padStart(2, '0')}T${String(hour).padStart(2, '0')}:${m[5]}:${m[6]}${frac ? `.${frac}` : ''}`;
1420
+ const localKey = formatDateTimeLocalKey(normalizedYear, month, day, hour, minute, second, frac);
1387
1421
  const canonicalLex = canonicalDateTimeLex(totalSeconds, fracNum, fracScale, offsetMinutes !== null, localKey);
1388
1422
  return {
1389
1423
  family: 'dateTime',
@@ -1403,15 +1437,14 @@ function canonicalDateTimeLex(totalSeconds, fracNum, fracScale, hasTimezone, loc
1403
1437
  const frac = fracScale > 0 ? `.${fracNum.toString().padStart(fracScale, '0')}` : '';
1404
1438
  if (!hasTimezone) return localKey;
1405
1439
 
1406
- const minSafeSeconds = BigInt(Math.ceil(Number.MIN_SAFE_INTEGER / 1000));
1407
- const maxSafeSeconds = BigInt(Math.floor(Number.MAX_SAFE_INTEGER / 1000));
1408
- if (totalSeconds < minSafeSeconds || totalSeconds > maxSafeSeconds) return `${localKey}Z`;
1409
-
1410
- const d = new Date(Number(totalSeconds) * 1000);
1411
- if (Number.isNaN(d.getTime())) return `${localKey}Z`;
1412
- let iso = d.toISOString().replace(/\.000Z$/, 'Z');
1413
- if (frac) iso = iso.replace('Z', `${frac}Z`);
1414
- return iso;
1440
+ const secondsPerDay = 86400n;
1441
+ const dayCount = floorDivBigInt(totalSeconds, secondsPerDay);
1442
+ const secondOfDay = totalSeconds - dayCount * secondsPerDay;
1443
+ const civil = civilFromDaysBigInt(dayCount);
1444
+ const hour = Number(secondOfDay / 3600n);
1445
+ const minute = Number((secondOfDay % 3600n) / 60n);
1446
+ const second = Number(secondOfDay % 60n);
1447
+ return formatDateTimeLocalKey(civil.year, civil.month, civil.day, hour, minute, second, frac) + 'Z';
1415
1448
  }
1416
1449
 
1417
1450
  function parseLangStringDatatypeValue(t, lexical) {
@@ -1529,7 +1562,31 @@ function evalDatatypeInspectionBuiltin(g, subst, kind) {
1529
1562
  return evalBindBuiltinObject(g.o, valueTerm, subst);
1530
1563
  }
1531
1564
 
1565
+ function booleanTermValue(t) {
1566
+ const info = parseBooleanLiteralInfo(t);
1567
+ return info ? info.value : null;
1568
+ }
1569
+
1570
+ function makeBooleanLiteral(value) {
1571
+ return internLiteral(value ? 'true' : 'false');
1572
+ }
1573
+
1574
+ function evalDatatypeValidityBooleanResult(outTerm, result, subst) {
1575
+ if (outTerm instanceof Var || outTerm instanceof Blank) return evalBindBuiltinObject(outTerm, makeBooleanLiteral(result), subst);
1576
+ const expected = booleanTermValue(outTerm);
1577
+ return expected === result ? [__cloneSubst(subst)] : [];
1578
+ }
1579
+
1532
1580
  function evalDatatypeValidityBuiltin(g, subst, positive) {
1581
+ if (g.s instanceof ListTerm && g.s.elems.length === 2) {
1582
+ const [lit, dtTerm] = g.s.elems;
1583
+ if (!(lit instanceof Literal) || !(dtTerm instanceof Iri)) return [];
1584
+ const dt = dtTerm.value;
1585
+ if (!isSupportedDatatypeIri(dt)) return [];
1586
+ const ok = parseDatatypeValueForDatatype(lit, dt) !== null;
1587
+ return evalDatatypeValidityBooleanResult(g.o, positive ? ok : !ok, subst);
1588
+ }
1589
+
1533
1590
  if (!(g.s instanceof Literal)) return [];
1534
1591
 
1535
1592
  let dt = null;
@@ -1723,8 +1780,9 @@ function formatNum(n) {
1723
1780
  return String(n);
1724
1781
  }
1725
1782
 
1726
- function parseXsdDecimalToBigIntScale(s) {
1727
- let t = String(s).trim();
1783
+ function parseXsdDecimalToBigIntScale(s, options = null) {
1784
+ let t = String(s);
1785
+ if (!options || options.trim !== false) t = t.trim();
1728
1786
  let sign = 1n;
1729
1787
 
1730
1788
  if (t.startsWith('+')) t = t.slice(1);
package/lib/builtins.js CHANGED
@@ -1168,7 +1168,7 @@ function parseIntegerDatatypeValue(lexical, dt) {
1168
1168
  }
1169
1169
 
1170
1170
  function parseDecimalDatatypeValue(lexical) {
1171
- const parsed = parseXsdDecimalToBigIntScale(String(lexical));
1171
+ const parsed = parseXsdDecimalToBigIntScale(String(lexical), { trim: false });
1172
1172
  if (!parsed) return null;
1173
1173
  const canonicalLex = canonicalDecimalLexFromParts(parsed.num, parsed.scale);
1174
1174
  return {
@@ -1294,58 +1294,93 @@ function parseAnyUriDatatypeValue(lexical) {
1294
1294
  };
1295
1295
  }
1296
1296
 
1297
- function isLeapYearNumber(year) {
1298
- if (year % 400 === 0) return true;
1299
- if (year % 100 === 0) return false;
1300
- return year % 4 === 0;
1297
+ function isLeapYearBigInt(year) {
1298
+ if (year % 400n === 0n) return true;
1299
+ if (year % 100n === 0n) return false;
1300
+ return year % 4n === 0n;
1301
1301
  }
1302
1302
 
1303
- function daysInMonthNumber(year, month) {
1304
- if (month === 2) return isLeapYearNumber(year) ? 29 : 28;
1303
+ function daysInMonthBigInt(year, month) {
1304
+ if (month === 2) return isLeapYearBigInt(year) ? 29 : 28;
1305
1305
  return [4, 6, 9, 11].includes(month) ? 30 : 31;
1306
1306
  }
1307
1307
 
1308
- function floorDivNumber(a, b) {
1309
- return Math.floor(a / b);
1308
+ function floorDivBigInt(a, b) {
1309
+ let q = a / b;
1310
+ const r = a % b;
1311
+ if (r !== 0n && ((r > 0n) !== (b > 0n))) q -= 1n;
1312
+ return q;
1310
1313
  }
1311
1314
 
1312
- function daysFromCivilNumber(year, month, day) {
1315
+ function daysFromCivilBigInt(year, month, day) {
1313
1316
  let y = year;
1314
- y -= month <= 2 ? 1 : 0;
1315
- const era = floorDivNumber(y, 400);
1316
- const yoe = y - era * 400;
1317
- const mp = month + (month > 2 ? -3 : 9);
1318
- const doy = Math.floor((153 * mp + 2) / 5) + day - 1;
1319
- const doe = yoe * 365 + Math.floor(yoe / 4) - Math.floor(yoe / 100) + doy;
1320
- return era * 146097 + doe - 719468;
1317
+ if (month <= 2) y -= 1n;
1318
+ const era = floorDivBigInt(y, 400n);
1319
+ const yoe = y - era * 400n;
1320
+ const mp = BigInt(month + (month > 2 ? -3 : 9));
1321
+ const doy = (153n * mp + 2n) / 5n + BigInt(day) - 1n;
1322
+ const doe = yoe * 365n + yoe / 4n - yoe / 100n + doy;
1323
+ return era * 146097n + doe - 719468n;
1324
+ }
1325
+
1326
+ function civilFromDaysBigInt(days) {
1327
+ const z = days + 719468n;
1328
+ const era = floorDivBigInt(z, 146097n);
1329
+ const doe = z - era * 146097n;
1330
+ const yoe = (doe - doe / 1460n + doe / 36524n - doe / 146096n) / 365n;
1331
+ let year = yoe + era * 400n;
1332
+ const doy = doe - (365n * yoe + yoe / 4n - yoe / 100n);
1333
+ const mp = (5n * doy + 2n) / 153n;
1334
+ const day = doy - (153n * mp + 2n) / 5n + 1n;
1335
+ const month = mp < 10n ? mp + 3n : mp - 9n;
1336
+ if (month <= 2n) year += 1n;
1337
+ return { year, month: Number(month), day: Number(day) };
1338
+ }
1339
+
1340
+ function isValidXsdYearLex(s) {
1341
+ const body = s.startsWith('-') ? s.slice(1) : s;
1342
+ if (!/^\d{4,}$/.test(body)) return false;
1343
+ return body.length === 4 || body[0] !== '0';
1344
+ }
1345
+
1346
+ function canonicalYearLex(year) {
1347
+ if (year < 0n) return `-${(-year).toString().padStart(4, '0')}`;
1348
+ return year.toString().padStart(4, '0');
1349
+ }
1350
+
1351
+ function formatDateTimeLocalKey(year, month, day, hour, minute, second, frac) {
1352
+ return `${canonicalYearLex(year)}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}T${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:${String(second).padStart(2, '0')}${frac ? `.${frac}` : ''}`;
1321
1353
  }
1322
1354
 
1323
1355
  function parseDateTimeDatatypeValue(lexical, dt) {
1324
1356
  const s = String(lexical);
1325
1357
  const m = /^(-?\d{4,})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:\d{2})?$/.exec(s);
1326
- if (!m) return null;
1358
+ if (!m || !isValidXsdYearLex(m[1])) return null;
1327
1359
 
1328
- const year = Number(m[1]);
1329
- const month = Number(m[2]);
1360
+ const year = BigInt(m[1]);
1361
+ let month = Number(m[2]);
1330
1362
  let day = Number(m[3]);
1331
1363
  let hour = Number(m[4]);
1332
1364
  const minute = Number(m[5]);
1333
1365
  const second = Number(m[6]);
1334
1366
  let frac = m[7] || '';
1335
1367
  const tz = m[8] || null;
1336
- if (!Number.isSafeInteger(year)) return null;
1337
1368
  if (dt === XSD_DATETIME_STAMP_DT && !tz) return null;
1338
1369
  if (!(month >= 1 && month <= 12)) return null;
1339
- if (!(day >= 1 && day <= daysInMonthNumber(year, month))) return null;
1370
+ if (!(day >= 1 && day <= daysInMonthBigInt(year, month))) return null;
1340
1371
  if (!(minute >= 0 && minute <= 59)) return null;
1341
1372
  if (!(second >= 0 && second <= 59)) return null;
1373
+
1374
+ let dayCount = daysFromCivilBigInt(year, month, day);
1375
+ let normalizedYear = year;
1342
1376
  if (hour === 24) {
1343
1377
  if (minute !== 0 || second !== 0 || /[1-9]/.test(frac)) return null;
1344
1378
  hour = 0;
1345
- const d = new Date(Date.UTC(year, month - 1, day));
1346
- d.setUTCFullYear(year);
1347
- d.setUTCDate(d.getUTCDate() + 1);
1348
- day = d.getUTCDate();
1379
+ dayCount += 1n;
1380
+ const civil = civilFromDaysBigInt(dayCount);
1381
+ normalizedYear = civil.year;
1382
+ month = civil.month;
1383
+ day = civil.day;
1349
1384
  } else if (!(hour >= 0 && hour <= 23)) {
1350
1385
  return null;
1351
1386
  }
@@ -1368,11 +1403,10 @@ function parseDateTimeDatatypeValue(lexical, dt) {
1368
1403
  }
1369
1404
  }
1370
1405
 
1371
- const dayCount = daysFromCivilNumber(year, month, day);
1372
- let totalSeconds = BigInt(dayCount) * 86400n + BigInt(hour * 3600 + minute * 60 + second);
1406
+ let totalSeconds = dayCount * 86400n + BigInt(hour * 3600 + minute * 60 + second);
1373
1407
  if (offsetMinutes !== null) totalSeconds -= BigInt(offsetMinutes * 60);
1374
1408
 
1375
- const localKey = `${m[1]}-${m[2]}-${String(day).padStart(2, '0')}T${String(hour).padStart(2, '0')}:${m[5]}:${m[6]}${frac ? `.${frac}` : ''}`;
1409
+ const localKey = formatDateTimeLocalKey(normalizedYear, month, day, hour, minute, second, frac);
1376
1410
  const canonicalLex = canonicalDateTimeLex(totalSeconds, fracNum, fracScale, offsetMinutes !== null, localKey);
1377
1411
  return {
1378
1412
  family: 'dateTime',
@@ -1392,15 +1426,14 @@ function canonicalDateTimeLex(totalSeconds, fracNum, fracScale, hasTimezone, loc
1392
1426
  const frac = fracScale > 0 ? `.${fracNum.toString().padStart(fracScale, '0')}` : '';
1393
1427
  if (!hasTimezone) return localKey;
1394
1428
 
1395
- const minSafeSeconds = BigInt(Math.ceil(Number.MIN_SAFE_INTEGER / 1000));
1396
- const maxSafeSeconds = BigInt(Math.floor(Number.MAX_SAFE_INTEGER / 1000));
1397
- if (totalSeconds < minSafeSeconds || totalSeconds > maxSafeSeconds) return `${localKey}Z`;
1398
-
1399
- const d = new Date(Number(totalSeconds) * 1000);
1400
- if (Number.isNaN(d.getTime())) return `${localKey}Z`;
1401
- let iso = d.toISOString().replace(/\.000Z$/, 'Z');
1402
- if (frac) iso = iso.replace('Z', `${frac}Z`);
1403
- return iso;
1429
+ const secondsPerDay = 86400n;
1430
+ const dayCount = floorDivBigInt(totalSeconds, secondsPerDay);
1431
+ const secondOfDay = totalSeconds - dayCount * secondsPerDay;
1432
+ const civil = civilFromDaysBigInt(dayCount);
1433
+ const hour = Number(secondOfDay / 3600n);
1434
+ const minute = Number((secondOfDay % 3600n) / 60n);
1435
+ const second = Number(secondOfDay % 60n);
1436
+ return formatDateTimeLocalKey(civil.year, civil.month, civil.day, hour, minute, second, frac) + 'Z';
1404
1437
  }
1405
1438
 
1406
1439
  function parseLangStringDatatypeValue(t, lexical) {
@@ -1518,7 +1551,31 @@ function evalDatatypeInspectionBuiltin(g, subst, kind) {
1518
1551
  return evalBindBuiltinObject(g.o, valueTerm, subst);
1519
1552
  }
1520
1553
 
1554
+ function booleanTermValue(t) {
1555
+ const info = parseBooleanLiteralInfo(t);
1556
+ return info ? info.value : null;
1557
+ }
1558
+
1559
+ function makeBooleanLiteral(value) {
1560
+ return internLiteral(value ? 'true' : 'false');
1561
+ }
1562
+
1563
+ function evalDatatypeValidityBooleanResult(outTerm, result, subst) {
1564
+ if (outTerm instanceof Var || outTerm instanceof Blank) return evalBindBuiltinObject(outTerm, makeBooleanLiteral(result), subst);
1565
+ const expected = booleanTermValue(outTerm);
1566
+ return expected === result ? [__cloneSubst(subst)] : [];
1567
+ }
1568
+
1521
1569
  function evalDatatypeValidityBuiltin(g, subst, positive) {
1570
+ if (g.s instanceof ListTerm && g.s.elems.length === 2) {
1571
+ const [lit, dtTerm] = g.s.elems;
1572
+ if (!(lit instanceof Literal) || !(dtTerm instanceof Iri)) return [];
1573
+ const dt = dtTerm.value;
1574
+ if (!isSupportedDatatypeIri(dt)) return [];
1575
+ const ok = parseDatatypeValueForDatatype(lit, dt) !== null;
1576
+ return evalDatatypeValidityBooleanResult(g.o, positive ? ok : !ok, subst);
1577
+ }
1578
+
1522
1579
  if (!(g.s instanceof Literal)) return [];
1523
1580
 
1524
1581
  let dt = null;
@@ -1712,8 +1769,9 @@ function formatNum(n) {
1712
1769
  return String(n);
1713
1770
  }
1714
1771
 
1715
- function parseXsdDecimalToBigIntScale(s) {
1716
- let t = String(s).trim();
1772
+ function parseXsdDecimalToBigIntScale(s, options = null) {
1773
+ let t = String(s);
1774
+ if (!options || options.trim !== false) t = t.trim();
1717
1775
  let sign = 1n;
1718
1776
 
1719
1777
  if (t.startsWith('+')) t = t.slice(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eyeling",
3
- "version": "1.28.7",
3
+ "version": "1.28.8",
4
4
  "description": "A minimal Notation3 (N3) reasoner in JavaScript.",
5
5
  "main": "./index.js",
6
6
  "keywords": [
@@ -170,17 +170,25 @@ const cases = [
170
170
  { "2147483648"^^xsd:int dt:invalidForDatatype xsd:int . } => { :invalid :int true } .
171
171
  { "2"^^xsd:boolean dt:invalidForDatatype xsd:boolean . } => { :invalid :boolean true } .
172
172
  { "2026-02-31T00:00:00Z"^^xsd:dateTime dt:invalidForDatatype xsd:dateTime . } => { :invalid :dateTime true } .
173
+ { " 1.0 "^^xsd:decimal dt:invalidForDatatype xsd:decimal . } => { :invalid :decimalWhitespace true } .
174
+ { "02026-06-10T12:00:00Z"^^xsd:dateTime dt:invalidForDatatype xsd:dateTime . } => { :invalid :dateTimeYear true } .
173
175
 
174
176
  { "01"^^xsd:integer dt:sameValueAs "1.0"^^xsd:decimal . } => { :same :numeric true } .
175
177
  { "true"^^xsd:boolean dt:sameValueAs "1"^^xsd:boolean . } => { :same :boolean true } .
176
178
  { "2026-06-10T12:00:00Z"^^xsd:dateTime dt:sameValueAs "2026-06-10T14:00:00+02:00"^^xsd:dateTime . } => { :same :dateTime true } .
179
+ { "2026-12-31T24:00:00Z"^^xsd:dateTime dt:sameValueAs "2027-01-01T00:00:00Z"^^xsd:dateTime . } => { :same :midnightRollover true } .
177
180
  { "AQID"^^xsd:base64Binary dt:sameValueAs "010203"^^xsd:hexBinary . } => { :same :binary true } .
178
181
  { "11"^^xsd:integer dt:differentValueFrom "12"^^xsd:integer . } => { :different :numeric true } .
179
182
 
183
+ { ("1"^^xsd:integer xsd:integer) dt:validForDatatype true . } => { :tuple :valid true } .
184
+ { ("abc"^^xsd:integer xsd:integer) dt:validForDatatype false . } => { :tuple :invalidBoolean true } .
185
+ { ("abc"^^xsd:integer xsd:integer) dt:invalidForDatatype ?invalid . } => { :tuple :invalidResult ?invalid } .
186
+
180
187
  { "01"^^xsd:integer dt:canonicalLiteral ?ci . } => { :canonical :integer ?ci } .
181
188
  { "1"^^xsd:boolean dt:canonicalLiteral ?cb . } => { :canonical :boolean ?cb } .
182
189
  { " a\t b "^^xsd:token dt:canonicalLiteral ?ct . } => { :canonical :token ?ct } .
183
190
  { "2026-06-10T14:00:00+02:00"^^xsd:dateTime dt:canonicalLiteral ?cd . } => { :canonical :dateTime ?cd } .
191
+ { "2026-12-31T24:00:00Z"^^xsd:dateTime dt:canonicalLiteral ?cm . } => { :canonical :midnightRollover ?cm } .
184
192
  `);
185
193
 
186
194
  assert.match(out, /:integer :datatype xsd:integer \./);
@@ -192,15 +200,22 @@ const cases = [
192
200
  assert.match(out, /:invalid :int true \./);
193
201
  assert.match(out, /:invalid :boolean true \./);
194
202
  assert.match(out, /:invalid :dateTime true \./);
203
+ assert.match(out, /:invalid :decimalWhitespace true \./);
204
+ assert.match(out, /:invalid :dateTimeYear true \./);
195
205
  assert.match(out, /:same :numeric true \./);
196
206
  assert.match(out, /:same :boolean true \./);
197
207
  assert.match(out, /:same :dateTime true \./);
208
+ assert.match(out, /:same :midnightRollover true \./);
198
209
  assert.match(out, /:same :binary true \./);
199
210
  assert.match(out, /:different :numeric true \./);
211
+ assert.match(out, /:tuple :valid true \./);
212
+ assert.match(out, /:tuple :invalidBoolean true \./);
213
+ assert.match(out, /:tuple :invalidResult true \./);
200
214
  assert.match(out, /:canonical :integer "1"\^\^xsd:integer \./);
201
215
  assert.match(out, /:canonical :boolean true \./);
202
216
  assert.match(out, /:canonical :token "a b"\^\^xsd:token \./);
203
217
  assert.match(out, /:canonical :dateTime "2026-06-10T12:00:00Z"\^\^xsd:dateTime \./);
218
+ assert.match(out, /:canonical :midnightRollover "2027-01-01T00:00:00Z"\^\^xsd:dateTime \./);
204
219
  },
205
220
  },
206
221