eyeling 1.24.30 → 1.24.33

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.
@@ -1207,25 +1207,36 @@ function parseXsdDateTerm(t) {
1207
1207
  return d;
1208
1208
  }
1209
1209
 
1210
+ function isXsdDateTimeDatatype(dt) {
1211
+ return dt === XSD_NS + 'dateTime' || dt === XSD_NS + 'dateTimeStamp';
1212
+ }
1213
+
1210
1214
  function parseXsdDatetimeTerm(t) {
1211
1215
  if (!(t instanceof Literal)) return null;
1212
1216
  const [lex, dt] = literalParts(t.value);
1213
- if (dt !== XSD_NS + 'dateTime') return null;
1217
+ if (!isXsdDateTimeDatatype(dt)) return null;
1214
1218
  const val = stripQuotes(lex);
1219
+
1220
+ // xsd:dateTimeStamp is a subtype of xsd:dateTime with a required timezone.
1221
+ // Keep xsd:dateTime's existing permissive behaviour, but reject stamp
1222
+ // lexicals that do not actually carry the required timezone.
1223
+ if (dt === XSD_NS + 'dateTimeStamp' && !/(Z|[+-]\d{2}:\d{2})$/.test(val)) return null;
1224
+
1215
1225
  const d = new Date(val);
1216
1226
  if (Number.isNaN(d.getTime())) return null;
1217
1227
  return d; // Date in local/UTC, we only use timestamp
1218
1228
  }
1219
1229
 
1220
1230
  function parseXsdDateTimeLexParts(t) {
1221
- // Parse *lexical* components of an xsd:dateTime literal without timezone normalization.
1231
+ // Parse *lexical* components of an xsd:dateTime/dateTimeStamp literal without timezone normalization.
1222
1232
  // Returns { yearStr, month, day, hour, minute, second, tz } or null.
1223
1233
  if (!(t instanceof Literal)) return null;
1224
1234
  const [lex, dt] = literalParts(t.value);
1225
- if (dt !== XSD_NS + 'dateTime') return null;
1235
+ if (!isXsdDateTimeDatatype(dt)) return null;
1226
1236
  const val = stripQuotes(lex);
1227
1237
 
1228
1238
  // xsd:dateTime lexical: YYYY-MM-DDThh:mm:ss(.s+)?(Z|(+|-)hh:mm)?
1239
+ // xsd:dateTimeStamp has the same lexical form, but with the timezone required.
1229
1240
  const m = /^(-?\d{4,})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-]\d{2}:\d{2})?$/.exec(val);
1230
1241
  if (!m) return null;
1231
1242
 
@@ -1236,6 +1247,7 @@ function parseXsdDateTimeLexParts(t) {
1236
1247
  const minute = parseInt(m[5], 10);
1237
1248
  const second = parseInt(m[6], 10);
1238
1249
  const tz = m[7] || null;
1250
+ if (dt === XSD_NS + 'dateTimeStamp' && !tz) return null;
1239
1251
 
1240
1252
  if (!(month >= 1 && month <= 12)) return null;
1241
1253
  if (!(day >= 1 && day <= 31)) return null;
@@ -1302,7 +1314,8 @@ function parseIso8601DurationToSeconds(s) {
1302
1314
  }
1303
1315
 
1304
1316
  function parseNumericForCompareTerm(t) {
1305
- // Strict: only accept xsd numeric literals, xsd:duration, xsd:date, xsd:dateTime
1317
+ // Strict: only accept xsd numeric literals, xsd:duration, xsd:date,
1318
+ // xsd:dateTime, and xsd:dateTimeStamp.
1306
1319
  // (or untyped numeric tokens).
1307
1320
  const bi = parseIntLiteral(t);
1308
1321
  if (bi !== null) return { kind: 'bigint', value: bi };
@@ -1369,7 +1382,7 @@ function parseNumOrDuration(t) {
1369
1382
  }
1370
1383
  }
1371
1384
 
1372
- // xsd:date / xsd:dateTime
1385
+ // xsd:date / xsd:dateTime / xsd:dateTimeStamp
1373
1386
  const dtval = parseDatetimeLike(t);
1374
1387
  if (dtval !== null) {
1375
1388
  return dtval.getTime() / 1000.0;
@@ -9769,6 +9782,24 @@ function stripQuotes(lex) {
9769
9782
  // This keeps all downstream parsing/reasoning N3-only.
9770
9783
  const LOG_NAME_OF_IRI = '<http://www.w3.org/2000/10/swap/log#nameOf>';
9771
9784
  const RDF_REIFIES_IRI = '<http://www.w3.org/1999/02/22-rdf-syntax-ns#reifies>';
9785
+ const RDF_TYPE_IRI = '<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>';
9786
+ const XSD_INTEGER_IRI = '<http://www.w3.org/2001/XMLSchema#integer>';
9787
+ const EYMSG_NS = 'https://eyereasoner.github.io/eyeling/vocab/message#';
9788
+ const EYMSG = Object.freeze({
9789
+ RDFMessageStream: `<${EYMSG_NS}RDFMessageStream>`,
9790
+ MessageEnvelope: `<${EYMSG_NS}MessageEnvelope>`,
9791
+ envelope: `<${EYMSG_NS}envelope>`,
9792
+ firstEnvelope: `<${EYMSG_NS}firstEnvelope>`,
9793
+ lastEnvelope: `<${EYMSG_NS}lastEnvelope>`,
9794
+ orderedEnvelopes: `<${EYMSG_NS}orderedEnvelopes>`,
9795
+ messageCount: `<${EYMSG_NS}messageCount>`,
9796
+ offset: `<${EYMSG_NS}offset>`,
9797
+ nextEnvelope: `<${EYMSG_NS}nextEnvelope>`,
9798
+ payloadGraph: `<${EYMSG_NS}payloadGraph>`,
9799
+ payloadKind: `<${EYMSG_NS}payloadKind>`,
9800
+ empty: `<${EYMSG_NS}empty>`,
9801
+ nonEmpty: `<${EYMSG_NS}nonEmpty>`,
9802
+ });
9772
9803
 
9773
9804
  function normalizeRdfCompatibility(inputText) {
9774
9805
  let text = String(inputText ?? '');
@@ -9779,10 +9810,11 @@ function normalizeRdfCompatibility(inputText) {
9779
9810
  // plausible top-level TriG named graph block.
9780
9811
  const hasTripleTerms = text.includes('<<');
9781
9812
  const hasVersionDirective = /^\s*(?:@version|VERSION)\s+(["'])1\.2\1\s*\.?\s*(?:#.*)?$/im.test(text);
9813
+ const hasMessageVersionDirective = /^\s*(?:@version|VERSION)\s+(["'])(?:1\.1|1\.2|1\.2-basic)-messages\1\s*\.?\s*(?:#.*)?$/im.test(text);
9782
9814
  const hasNamedGraphCandidate = /(?:^|[.\r\n])\s*(?:GRAPH\s+)?(?:<[^>\r\n]*>|_:[A-Za-z][A-Za-z0-9_-]*|[A-Za-z][A-Za-z0-9_-]*:[^\s{};,.()[\]]*)\s*\{/m.test(text);
9783
9815
  const hasAnnotationSyntax = /(?:^|\s)~\s*(?:<|_:[A-Za-z]|[A-Za-z][A-Za-z0-9_-]*:|\{\|)|\{\|/.test(text);
9784
9816
 
9785
- if (!hasTripleTerms && !hasVersionDirective && !hasNamedGraphCandidate && !hasAnnotationSyntax) return text;
9817
+ if (!hasTripleTerms && !hasVersionDirective && !hasMessageVersionDirective && !hasNamedGraphCandidate && !hasAnnotationSyntax) return text;
9786
9818
 
9787
9819
  function isWordChar(ch) {
9788
9820
  return ch != null && /[A-Za-z0-9_:-]/.test(ch);
@@ -10136,7 +10168,7 @@ function normalizeRdfCompatibility(inputText) {
10136
10168
  }
10137
10169
 
10138
10170
  function stripVersionDirectives(s) {
10139
- return s.replace(/^\s*(?:@version|VERSION)\s+(["'])1\.2\1\s*\.?\s*(?:#.*)?$/gim, '');
10171
+ return s.replace(/^\s*(?:@version|VERSION)\s+(["'])(?:1\.1|1\.2|1\.2-basic)(?:-messages)?\1\s*\.?\s*(?:#.*)?$/gim, '');
10140
10172
  }
10141
10173
 
10142
10174
  function skipWsAndComments(s, at) {
@@ -10290,10 +10322,270 @@ function normalizeRdfCompatibility(inputText) {
10290
10322
  return out;
10291
10323
  }
10292
10324
 
10325
+
10326
+ function isOnlyWhitespaceAndComments(s) {
10327
+ return skipWsAndComments(s, 0) >= s.length;
10328
+ }
10329
+
10330
+
10331
+ function skipOldStyleDirective(s, at) {
10332
+ let i = at;
10333
+ while (i < s.length) {
10334
+ const ch = s[i];
10335
+ if (ch === '"' || ch === "'") {
10336
+ i = readStringAt(s, i).end;
10337
+ continue;
10338
+ }
10339
+ if (ch === '<') {
10340
+ i = readIriAt(s, i).end;
10341
+ continue;
10342
+ }
10343
+ if (ch === '#') {
10344
+ while (i < s.length && s[i] !== '\n' && s[i] !== '\r') i += 1;
10345
+ continue;
10346
+ }
10347
+ i += 1;
10348
+ if (ch === '.') return i;
10349
+ }
10350
+ return i;
10351
+ }
10352
+
10353
+ function stripDirectivesAndCommentsForEmptiness(s) {
10354
+ let out = '';
10355
+ let i = 0;
10356
+ let statementStart = true;
10357
+ while (i < s.length) {
10358
+ const ch = s[i];
10359
+ if (ch === '"' || ch === "'") {
10360
+ const str = readStringAt(s, i);
10361
+ out += str.text;
10362
+ i = str.end;
10363
+ statementStart = false;
10364
+ continue;
10365
+ }
10366
+ if (ch === '<') {
10367
+ const iri = readIriAt(s, i);
10368
+ out += iri.text;
10369
+ i = iri.end;
10370
+ statementStart = false;
10371
+ continue;
10372
+ }
10373
+ if (ch === '#') {
10374
+ while (i < s.length && s[i] !== '\n' && s[i] !== '\r') i += 1;
10375
+ statementStart = true;
10376
+ continue;
10377
+ }
10378
+ if (statementStart) {
10379
+ const start = skipWsAndComments(s, i);
10380
+ out += s.slice(i, start);
10381
+ i = start;
10382
+ if (startsWordAt(s, 'PREFIX', i) || startsWordAt(s, 'BASE', i) || startsWordAt(s, 'VERSION', i)) {
10383
+ while (i < s.length && s[i] !== '\n' && s[i] !== '\r') i += 1;
10384
+ statementStart = true;
10385
+ continue;
10386
+ }
10387
+ if (s[i] === '@') {
10388
+ const lower = s.slice(i, i + 9).toLowerCase();
10389
+ if (lower.startsWith('@prefix') || lower.startsWith('@base') || lower.startsWith('@version')) {
10390
+ i = skipOldStyleDirective(s, i);
10391
+ statementStart = true;
10392
+ continue;
10393
+ }
10394
+ }
10395
+ }
10396
+ out += ch;
10397
+ if (ch === '.' || ch === '}' || ch === '\n' || ch === '\r') statementStart = true;
10398
+ else if (!/\s/.test(ch)) statementStart = false;
10399
+ i += 1;
10400
+ }
10401
+ return out;
10402
+ }
10403
+
10404
+ function simpleHashText(s) {
10405
+ let h = 0x811c9dc5;
10406
+ for (let i = 0; i < s.length; i += 1) {
10407
+ h ^= s.charCodeAt(i);
10408
+ h = Math.imul(h, 0x01000193) >>> 0;
10409
+ }
10410
+ return h.toString(16).padStart(8, '0');
10411
+ }
10412
+
10413
+ function rewriteMessageBlankLabels(s, messageIndex) {
10414
+ let out = '';
10415
+ let i = 0;
10416
+ const prefix = `_:eyeling_m${String(messageIndex).padStart(3, '0')}_`;
10417
+ while (i < s.length) {
10418
+ const ch = s[i];
10419
+ if (ch === '"' || ch === "'") {
10420
+ const str = readStringAt(s, i);
10421
+ out += str.text;
10422
+ i = str.end;
10423
+ continue;
10424
+ }
10425
+ if (ch === '<' && !s.startsWith('<<', i)) {
10426
+ const iri = readIriAt(s, i);
10427
+ out += iri.text;
10428
+ i = iri.end;
10429
+ continue;
10430
+ }
10431
+ if (ch === '#') {
10432
+ while (i < s.length) {
10433
+ const c = s[i++];
10434
+ out += c;
10435
+ if (c === '\n' || c === '\r') break;
10436
+ }
10437
+ continue;
10438
+ }
10439
+ if (s.startsWith('_:', i)) {
10440
+ let j = i + 2;
10441
+ while (j < s.length && !/\s/.test(s[j]) && !'{}[](),;.'.includes(s[j])) j += 1;
10442
+ const label = s.slice(i + 2, j);
10443
+ if (label) {
10444
+ out += prefix + label.replace(/[^A-Za-z0-9_]/g, '_');
10445
+ i = j;
10446
+ continue;
10447
+ }
10448
+ }
10449
+ out += ch;
10450
+ i += 1;
10451
+ }
10452
+ return out;
10453
+ }
10454
+
10455
+ function findMessageDirectiveAt(s, at) {
10456
+ if (startsWordAt(s, 'MESSAGE', at)) return { start: at, end: at + 'MESSAGE'.length };
10457
+ if (s.slice(at, at + 8).toLowerCase() === '@message' && !isWordChar(s[at + 8])) {
10458
+ let end = at + 8;
10459
+ end = skipWsAndComments(s, end);
10460
+ if (s[end] === '.') end += 1;
10461
+ return { start: at, end };
10462
+ }
10463
+ return null;
10464
+ }
10465
+
10466
+ function splitRdfMessageLog(s) {
10467
+ const chunks = [];
10468
+ let i = 0;
10469
+ let start = 0;
10470
+ let braceDepth = 0;
10471
+ let bracketDepth = 0;
10472
+ let parenDepth = 0;
10473
+ let statementStart = true;
10474
+ let sawDelimiter = false;
10475
+
10476
+ while (i < s.length) {
10477
+ const ch = s[i];
10478
+ if (ch === '"' || ch === "'") {
10479
+ i = readStringAt(s, i).end;
10480
+ statementStart = false;
10481
+ continue;
10482
+ }
10483
+ if (ch === '<' && !s.startsWith('<<', i)) {
10484
+ i = readIriAt(s, i).end;
10485
+ statementStart = false;
10486
+ continue;
10487
+ }
10488
+ if (ch === '#') {
10489
+ while (i < s.length && s[i] !== '\n' && s[i] !== '\r') i += 1;
10490
+ statementStart = true;
10491
+ continue;
10492
+ }
10493
+ if (statementStart && braceDepth === 0 && bracketDepth === 0 && parenDepth === 0) {
10494
+ const termStart = skipWsAndComments(s, i);
10495
+ const msg = findMessageDirectiveAt(s, termStart);
10496
+ if (msg) {
10497
+ chunks.push(s.slice(start, termStart));
10498
+ start = msg.end;
10499
+ i = msg.end;
10500
+ statementStart = true;
10501
+ sawDelimiter = true;
10502
+ continue;
10503
+ }
10504
+ if (termStart !== i) {
10505
+ i = termStart;
10506
+ continue;
10507
+ }
10508
+ }
10509
+ if (ch === '{') braceDepth += 1;
10510
+ else if (ch === '}' && braceDepth > 0) braceDepth -= 1;
10511
+ else if (ch === '[') bracketDepth += 1;
10512
+ else if (ch === ']' && bracketDepth > 0) bracketDepth -= 1;
10513
+ else if (ch === '(') parenDepth += 1;
10514
+ else if (ch === ')' && parenDepth > 0) parenDepth -= 1;
10515
+
10516
+ if (ch === '.' && braceDepth === 0 && bracketDepth === 0 && parenDepth === 0) statementStart = true;
10517
+ else if (ch === '\n' || ch === '\r') statementStart = true;
10518
+ else if (!/\s/.test(ch)) statementStart = false;
10519
+ i += 1;
10520
+ }
10521
+
10522
+ const tail = s.slice(start);
10523
+ if (!sawDelimiter || !isOnlyWhitespaceAndComments(tail)) chunks.push(tail);
10524
+ return chunks;
10525
+ }
10526
+
10527
+ function normalizeMessageChunk(chunk, messageIndex) {
10528
+ let body = String(chunk || '');
10529
+ if (hasTripleTerms || body.includes('<<')) body = convertTripleTerms(body);
10530
+ if (hasAnnotationSyntax || /(?:^|\s)~\s*(?:<|_:[A-Za-z]|[A-Za-z][A-Za-z0-9_-]*:|\{\|)|\{\|/.test(body)) {
10531
+ body = convertAnnotations(body);
10532
+ }
10533
+ body = normalizeNamedGraphs(body);
10534
+ body = rewriteMessageBlankLabels(body, messageIndex);
10535
+ return body.trim();
10536
+ }
10537
+
10538
+ function messageChunkHasRdf(body) {
10539
+ return !isOnlyWhitespaceAndComments(stripDirectivesAndCommentsForEmptiness(body));
10540
+ }
10541
+
10542
+ function normalizeRdfMessageLog(s) {
10543
+ const withoutVersion = stripVersionDirectives(s);
10544
+ const chunks = splitRdfMessageLog(withoutVersion);
10545
+ const hash = simpleHashText(s);
10546
+ const base = `urn:eyeling:message-log:${hash}`;
10547
+ const stream = `<${base}#stream>`;
10548
+ const envelopeIris = chunks.map((unused, idx) => `<${base}#m${String(idx + 1).padStart(3, '0')}>`);
10549
+ const payloadIris = chunks.map((unused, idx) => `<${base}#m${String(idx + 1).padStart(3, '0')}/payload>`);
10550
+ const out = [];
10551
+
10552
+ out.push(`${stream} ${RDF_TYPE_IRI} ${EYMSG.RDFMessageStream} .`);
10553
+ out.push(`${stream} ${EYMSG.messageCount} "${chunks.length}"^^${XSD_INTEGER_IRI} .`);
10554
+ if (envelopeIris.length) {
10555
+ out.push(`${stream} ${EYMSG.orderedEnvelopes} (${envelopeIris.join(' ')}) .`);
10556
+ out.push(`${stream} ${EYMSG.firstEnvelope} ${envelopeIris[0]} .`);
10557
+ out.push(`${stream} ${EYMSG.lastEnvelope} ${envelopeIris[envelopeIris.length - 1]} .`);
10558
+ }
10559
+
10560
+ for (let idx = 0; idx < chunks.length; idx += 1) {
10561
+ const n = idx + 1;
10562
+ const envelope = envelopeIris[idx];
10563
+ const payload = payloadIris[idx];
10564
+ const body = normalizeMessageChunk(chunks[idx], n);
10565
+ const hasBody = messageChunkHasRdf(body);
10566
+
10567
+ out.push(`${stream} ${EYMSG.envelope} ${envelope} .`);
10568
+ out.push(`${envelope} ${RDF_TYPE_IRI} ${EYMSG.MessageEnvelope} .`);
10569
+ out.push(`${envelope} ${EYMSG.offset} "${n}"^^${XSD_INTEGER_IRI} .`);
10570
+ out.push(`${envelope} ${EYMSG.payloadKind} ${hasBody ? EYMSG.nonEmpty : EYMSG.empty} .`);
10571
+ if (idx + 1 < envelopeIris.length) out.push(`${envelope} ${EYMSG.nextEnvelope} ${envelopeIris[idx + 1]} .`);
10572
+ if (hasBody) {
10573
+ out.push(`${envelope} ${EYMSG.payloadGraph} ${payload} .`);
10574
+ out.push(`${payload} ${LOG_NAME_OF_IRI} {`);
10575
+ out.push(body);
10576
+ out.push(`} .`);
10577
+ }
10578
+ }
10579
+
10580
+ return out.join('\n') + '\n';
10581
+ }
10582
+
10583
+ if (hasMessageVersionDirective) return normalizeRdfMessageLog(text);
10584
+
10293
10585
  if (hasTripleTerms) text = convertTripleTerms(text);
10294
10586
  if (hasAnnotationSyntax) text = convertAnnotations(text);
10295
- if (hasVersionDirective) text = stripVersionDirectives(text);
10296
- if (hasVersionDirective || hasNamedGraphCandidate) text = normalizeNamedGraphs(text);
10587
+ if (hasVersionDirective || hasMessageVersionDirective) text = stripVersionDirectives(text);
10588
+ if (hasVersionDirective || hasMessageVersionDirective || hasNamedGraphCandidate) text = normalizeNamedGraphs(text);
10297
10589
  return text;
10298
10590
  }
10299
10591
 
@@ -0,0 +1,273 @@
1
+ # RDF Message Logs in Eyeling — from stream to reasoning
2
+
3
+ This deck explains the example `rdf-message-flow.n3` and its input file `input/rdf-message-flow.trig`.
4
+
5
+ The goal is to show, in plain language, how Eyeling can now read an RDF Message Log directly instead of asking the example data to describe its own message envelopes by hand.
6
+
7
+ ---
8
+
9
+ ## The everyday problem
10
+
11
+ Many real systems do not receive one big dataset.
12
+
13
+ They receive a stream of small updates:
14
+
15
+ - a sensor reading,
16
+ - a command,
17
+ - a status heartbeat,
18
+ - an alert,
19
+ - another sensor reading.
20
+
21
+ Each update matters as a separate communication event.
22
+
23
+ If we simply merge everything into one graph, we lose the order and the boundary between messages.
24
+
25
+ ---
26
+
27
+ ## A message is a sealed packet
28
+
29
+ Think of an RDF Message as a sealed packet of RDF data.
30
+
31
+ Inside the packet there may be triples or named graphs.
32
+
33
+ Outside the packet there is the stream order: first message, second message, third message, and so on.
34
+
35
+ The important idea is:
36
+
37
+ > The reasoner should know when one message ends and the next one begins.
38
+
39
+ ---
40
+
41
+ ## What an RDF Message Log adds
42
+
43
+ An RDF Message Log is a replayable record of a message stream.
44
+
45
+ Instead of saying “subscribe to this live channel”, the file says:
46
+
47
+ > Here are the messages that arrived, in order.
48
+
49
+ That makes it useful for examples, tests, audits, debugging, reproducible reasoning, and explanations.
50
+
51
+ ---
52
+
53
+ ## The new syntax in the input file
54
+
55
+ The input begins with:
56
+
57
+ ```trig
58
+ VERSION "1.2-messages"
59
+ ```
60
+
61
+ That tells Eyeling:
62
+
63
+ > This file contains message boundaries.
64
+
65
+ Then each boundary is written as:
66
+
67
+ ```trig
68
+ MESSAGE
69
+ ```
70
+
71
+ So the file can look like this:
72
+
73
+ ```trig
74
+ # message 1 data
75
+ :temperatureFlow :highThreshold 26 .
76
+ _:obs sosa:hasSimpleResult 21 .
77
+
78
+ MESSAGE
79
+
80
+ # message 2 data
81
+ _:obs sosa:hasSimpleResult 22 .
82
+
83
+ MESSAGE
84
+
85
+ # message 3: empty heartbeat
86
+ MESSAGE
87
+
88
+ # message 4 data
89
+ _:obs sosa:hasSimpleResult 28 .
90
+ ```
91
+
92
+ ---
93
+
94
+ ## What Eyeling does internally
95
+
96
+ Eyeling does not treat `MESSAGE` as an ordinary RDF term.
97
+
98
+ It handles it before normal N3 reasoning starts.
99
+
100
+ Internally, Eyeling turns the log into a replay view:
101
+
102
+ - one stream resource,
103
+ - one envelope per message,
104
+ - an offset for each envelope,
105
+ - a link to the next envelope,
106
+ - a payload graph for each non-empty message,
107
+ - and an explicit marker for empty messages.
108
+
109
+ The rules then reason over that replay view.
110
+
111
+ ---
112
+
113
+ ## Why this is better than hand-written envelopes
114
+
115
+ Before this change, the example input had to describe the stream manually:
116
+
117
+ - message `:m001`,
118
+ - message `:m002`,
119
+ - payload graph `in:payload001`,
120
+ - next message links,
121
+ - payload kind markers,
122
+ - offsets.
123
+
124
+ That worked, but it made the example bulky.
125
+
126
+ It also mixed two concerns:
127
+
128
+ 1. the message-log machinery, and
129
+ 2. the domain logic of routing temperature observations.
130
+
131
+ Now Eyeling handles the message-log machinery.
132
+
133
+ The N3 file can focus on the logic.
134
+
135
+ ---
136
+
137
+ ## What the temperature-flow example does
138
+
139
+ The example models a small stream processor.
140
+
141
+ Messages move through these stages:
142
+
143
+ 1. ingest,
144
+ 2. validate,
145
+ 3. interpret,
146
+ 4. route,
147
+ 5. sink.
148
+
149
+ The stream contains temperature readings and one empty heartbeat.
150
+
151
+ The rules route normal readings to an archive sink and high readings to an alert sink.
152
+
153
+ ---
154
+
155
+ ## The empty heartbeat matters
156
+
157
+ One message in the example contains no RDF triples.
158
+
159
+ That is not an error.
160
+
161
+ It represents a heartbeat:
162
+
163
+ > “The stream is still alive, even though there is no new observation payload.”
164
+
165
+ Eyeling still creates an envelope for it.
166
+
167
+ That means the empty message keeps its place in the ordered replay.
168
+
169
+ ---
170
+
171
+ ## Blank nodes stay message-local
172
+
173
+ The input deliberately reuses the same blank-node label in several messages:
174
+
175
+ ```trig
176
+ _:obs sosa:hasSimpleResult 21 .
177
+
178
+ MESSAGE
179
+
180
+ _:obs sosa:hasSimpleResult 22 .
181
+ ```
182
+
183
+ That does not mean both messages talk about the same blank node.
184
+
185
+ In a message log, blank-node labels are scoped to the message.
186
+
187
+ Eyeling rewrites them internally so each message gets its own blank nodes.
188
+
189
+ ---
190
+
191
+ ## How the N3 rules see the replay
192
+
193
+ The N3 rules do not see `MESSAGE` directly.
194
+
195
+ They see Eyeling’s replay vocabulary, `eymsg:`.
196
+
197
+ For example, a rule can ask:
198
+
199
+ ```n3
200
+ ?Stream a eymsg:RDFMessageStream;
201
+ eymsg:firstEnvelope ?Envelope.
202
+ ```
203
+
204
+ Another rule can inspect a payload:
205
+
206
+ ```n3
207
+ ?Envelope eymsg:payloadGraph ?Payload.
208
+ ?Payload log:nameOf ?PayloadContext.
209
+ ?PayloadContext log:includes {
210
+ ?Observation sosa:hasSimpleResult ?Result.
211
+ }.
212
+ ```
213
+
214
+ That keeps each message payload inside its own context.
215
+
216
+ ---
217
+
218
+ ## Back pressure in one sentence
219
+
220
+ The example releases only the first envelope at the start.
221
+
222
+ Each envelope must reach the sink before the next envelope is released.
223
+
224
+ That gives a simple form of ordered replay or back pressure:
225
+
226
+ > process this message, then release the next one.
227
+
228
+ ---
229
+
230
+ ## What the final answer says
231
+
232
+ When the example succeeds, Eyeling reports that five parser-replayed envelopes moved through the flow.
233
+
234
+ With a threshold of 26:
235
+
236
+ - 21 is archived,
237
+ - 22 is archived,
238
+ - the empty heartbeat is accepted,
239
+ - 28 becomes an alert,
240
+ - 29 becomes an alert.
241
+
242
+ The important part is not only the routing result.
243
+
244
+ The important part is that the result was derived while preserving message boundaries.
245
+
246
+ ---
247
+
248
+ ## Why a wide audience should care
249
+
250
+ This pattern is useful wherever data arrives over time:
251
+
252
+ - sensors,
253
+ - event logs,
254
+ - audit trails,
255
+ - clinical systems,
256
+ - energy systems,
257
+ - pub/sub channels,
258
+ - digital twins,
259
+ - provenance streams.
260
+
261
+ You can replay the stream, reason over each message atomically, and explain what happened without flattening the whole history into one graph.
262
+
263
+ ---
264
+
265
+ ## The takeaway
266
+
267
+ `MESSAGE` is the boundary.
268
+
269
+ Eyeling turns those boundaries into ordered replay envelopes.
270
+
271
+ The N3 rules consume the replay and focus on the domain logic.
272
+
273
+ That makes the example shorter, clearer, and closer to how a real pub/sub channel would be processed.