@higher.archi/boe 1.0.22 → 1.0.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/core/types/rule.d.ts +1 -1
  2. package/dist/core/types/rule.d.ts.map +1 -1
  3. package/dist/engines/prediction/compiler.d.ts +11 -0
  4. package/dist/engines/prediction/compiler.d.ts.map +1 -0
  5. package/dist/engines/prediction/compiler.js +153 -0
  6. package/dist/engines/prediction/compiler.js.map +1 -0
  7. package/dist/engines/prediction/engine.d.ts +49 -0
  8. package/dist/engines/prediction/engine.d.ts.map +1 -0
  9. package/dist/engines/prediction/engine.js +91 -0
  10. package/dist/engines/prediction/engine.js.map +1 -0
  11. package/dist/engines/prediction/index.d.ts +9 -0
  12. package/dist/engines/prediction/index.d.ts.map +1 -0
  13. package/dist/engines/prediction/index.js +25 -0
  14. package/dist/engines/prediction/index.js.map +1 -0
  15. package/dist/engines/prediction/strategy.d.ts +20 -0
  16. package/dist/engines/prediction/strategy.d.ts.map +1 -0
  17. package/dist/engines/prediction/strategy.js +441 -0
  18. package/dist/engines/prediction/strategy.js.map +1 -0
  19. package/dist/engines/prediction/types.d.ts +150 -0
  20. package/dist/engines/prediction/types.d.ts.map +1 -0
  21. package/dist/engines/prediction/types.js +59 -0
  22. package/dist/engines/prediction/types.js.map +1 -0
  23. package/dist/index.d.ts +2 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +14 -2
  26. package/dist/index.js.map +1 -1
  27. package/package.json +1 -1
  28. package/src/core/types/rule.ts +1 -1
  29. package/src/engines/prediction/compiler.ts +186 -0
  30. package/src/engines/prediction/engine.ts +120 -0
  31. package/src/engines/prediction/index.ts +49 -0
  32. package/src/engines/prediction/strategy.ts +573 -0
  33. package/src/engines/prediction/types.ts +236 -0
  34. package/src/index.ts +39 -0
@@ -0,0 +1,573 @@
1
+ /**
2
+ * Prediction Engine Strategy
3
+ *
4
+ * Core execution logic for all prediction strategies:
5
+ * - linear-regression: Least-squares line fit with R² confidence
6
+ * - exponential-smoothing: Holt's double exponential (level + trend)
7
+ * - weighted-moving-average: Rolling window with recency weighting
8
+ */
9
+
10
+ import type { IWorkingMemory, Fact } from '../../core';
11
+
12
+ import type {
13
+ CompiledPredictionRuleSet,
14
+ CompiledLinearRegressionRuleSet,
15
+ CompiledExponentialSmoothingRuleSet,
16
+ CompiledWeightedMovingAverageRuleSet,
17
+ PredictionOptions,
18
+ PredictionResult,
19
+ EntityPrediction,
20
+ ThresholdCrossing,
21
+ ThresholdDefinition
22
+ } from './types';
23
+ import { resolveConfidenceBracket, resolveTrendDirection } from './types';
24
+
25
+ // ========================================
26
+ // Internal Types
27
+ // ========================================
28
+
29
+ type DataPoint = { score: number; timestamp: number };
30
+
31
+ // Milliseconds per day / per month (30 days)
32
+ const MS_PER_DAY = 86_400_000;
33
+ const MS_PER_MONTH = 2_592_000_000; // 30 days
34
+
35
+ export class PredictionExecutor {
36
+ run(
37
+ ruleSet: CompiledPredictionRuleSet,
38
+ wm: IWorkingMemory,
39
+ options: PredictionOptions = {}
40
+ ): PredictionResult {
41
+ const startTime = performance.now();
42
+ const asOf = options.asOf ? options.asOf.getTime() : Date.now();
43
+
44
+ // Group snapshots by entity
45
+ const entityGroups = groupByEntity(wm.getByType(ruleSet.snapshotType), ruleSet.fields);
46
+
47
+ const predictions: EntityPrediction[] = [];
48
+
49
+ for (const [entityId, points] of entityGroups) {
50
+ let prediction: EntityPrediction;
51
+
52
+ switch (ruleSet.strategy) {
53
+ case 'linear-regression':
54
+ prediction = this.runLinearRegression(
55
+ ruleSet as CompiledLinearRegressionRuleSet,
56
+ entityId, points, asOf
57
+ );
58
+ break;
59
+ case 'exponential-smoothing':
60
+ prediction = this.runExponentialSmoothing(
61
+ ruleSet as CompiledExponentialSmoothingRuleSet,
62
+ entityId, points, asOf
63
+ );
64
+ break;
65
+ case 'weighted-moving-average':
66
+ prediction = this.runWeightedMovingAverage(
67
+ ruleSet as CompiledWeightedMovingAverageRuleSet,
68
+ entityId, points, asOf
69
+ );
70
+ break;
71
+ default:
72
+ throw new Error(`Unknown prediction strategy: '${(ruleSet as any).strategy}'`);
73
+ }
74
+
75
+ if (options.onPredict) {
76
+ options.onPredict(prediction);
77
+ }
78
+
79
+ predictions.push(prediction);
80
+ }
81
+
82
+ const executionTimeMs = Math.round((performance.now() - startTime) * 100) / 100;
83
+
84
+ return {
85
+ predictions,
86
+ totalEntities: predictions.length,
87
+ strategy: ruleSet.strategy,
88
+ horizonMs: ruleSet.horizonMs,
89
+ executionTimeMs
90
+ };
91
+ }
92
+
93
+ // ========================================
94
+ // Linear Regression
95
+ // ========================================
96
+
97
+ private runLinearRegression(
98
+ ruleSet: CompiledLinearRegressionRuleSet,
99
+ entityId: string,
100
+ points: DataPoint[],
101
+ asOf: number
102
+ ): EntityPrediction {
103
+ const currentScore = points[points.length - 1].score;
104
+ const forecastTime = asOf + ruleSet.horizonMs;
105
+ const forecastDate = new Date(forecastTime);
106
+
107
+ // 1 data point
108
+ if (points.length === 1) {
109
+ return this.buildSinglePointPrediction(
110
+ ruleSet, entityId, currentScore, asOf, forecastDate, 'linear-regression'
111
+ );
112
+ }
113
+
114
+ // 2 data points
115
+ if (points.length === 2) {
116
+ const dt = points[1].timestamp - points[0].timestamp;
117
+ const ds = points[1].score - points[0].score;
118
+ const slopePerMs = dt > 0 ? ds / dt : 0;
119
+ const predicted = currentScore + slopePerMs * ruleSet.horizonMs;
120
+ const stableThreshold = ruleSet.config.stableThreshold / MS_PER_DAY; // convert to per-ms
121
+
122
+ return {
123
+ entityId,
124
+ dataPoints: 2,
125
+ trend: resolveTrendDirection(slopePerMs, stableThreshold),
126
+ trendSlope: round(slopePerMs * MS_PER_DAY, 4),
127
+ confidence: 0.3,
128
+ confidenceBracket: resolveConfidenceBracket(0.3),
129
+ currentScore,
130
+ forecast: {
131
+ date: forecastDate,
132
+ dateIso: forecastDate.toISOString(),
133
+ predictedScore: round(predicted, 2)
134
+ },
135
+ thresholdCrossings: computeThresholdCrossings(currentScore, slopePerMs, asOf, ruleSet.thresholds),
136
+ model: {
137
+ strategy: 'linear-regression',
138
+ params: { slopePerDay: round(slopePerMs * MS_PER_DAY, 6), intercept: 0, rSquared: 0 }
139
+ }
140
+ };
141
+ }
142
+
143
+ // 3+ data points: full least-squares
144
+ const n = points.length;
145
+ const tMin = points[0].timestamp;
146
+
147
+ // Normalize timestamps to avoid precision loss
148
+ const xs = points.map(p => p.timestamp - tMin);
149
+ const ys = points.map(p => p.score);
150
+
151
+ const xMean = xs.reduce((a, b) => a + b, 0) / n;
152
+ const yMean = ys.reduce((a, b) => a + b, 0) / n;
153
+
154
+ let sXX = 0;
155
+ let sXY = 0;
156
+ for (let i = 0; i < n; i++) {
157
+ const dx = xs[i] - xMean;
158
+ sXX += dx * dx;
159
+ sXY += dx * (ys[i] - yMean);
160
+ }
161
+
162
+ const slope = sXX > 0 ? sXY / sXX : 0;
163
+ const intercept = yMean - slope * xMean;
164
+
165
+ // R²
166
+ let ssTot = 0;
167
+ let ssRes = 0;
168
+ for (let i = 0; i < n; i++) {
169
+ const yHat = slope * xs[i] + intercept;
170
+ ssTot += (ys[i] - yMean) * (ys[i] - yMean);
171
+ ssRes += (ys[i] - yHat) * (ys[i] - yHat);
172
+ }
173
+ const rSquared = ssTot > 0 ? Math.max(0, Math.min(1, 1 - ssRes / ssTot)) : 0;
174
+
175
+ // Prediction at horizon
176
+ const xPred = forecastTime - tMin;
177
+ const predicted = slope * xPred + intercept;
178
+
179
+ // 95% confidence interval
180
+ const se = n > 2 ? Math.sqrt(ssRes / (n - 2)) : 0;
181
+ const margin = 1.96 * se * Math.sqrt(1 + 1 / n + ((xPred - xMean) * (xPred - xMean)) / sXX);
182
+
183
+ const stableThreshold = ruleSet.config.stableThreshold / MS_PER_DAY;
184
+
185
+ return {
186
+ entityId,
187
+ dataPoints: n,
188
+ trend: resolveTrendDirection(slope, stableThreshold),
189
+ trendSlope: round(slope * MS_PER_DAY, 4),
190
+ confidence: round(rSquared, 4),
191
+ confidenceBracket: resolveConfidenceBracket(rSquared),
192
+ currentScore,
193
+ forecast: {
194
+ date: forecastDate,
195
+ dateIso: forecastDate.toISOString(),
196
+ predictedScore: round(predicted, 2),
197
+ confidenceInterval: margin > 0
198
+ ? { lower: round(predicted - margin, 2), upper: round(predicted + margin, 2) }
199
+ : undefined
200
+ },
201
+ thresholdCrossings: computeThresholdCrossings(currentScore, slope, asOf - tMin, ruleSet.thresholds, tMin),
202
+ model: {
203
+ strategy: 'linear-regression',
204
+ params: {
205
+ slopePerDay: round(slope * MS_PER_DAY, 6),
206
+ intercept: round(intercept, 4),
207
+ rSquared: round(rSquared, 4)
208
+ }
209
+ }
210
+ };
211
+ }
212
+
213
+ // ========================================
214
+ // Exponential Smoothing (Holt's Double)
215
+ // ========================================
216
+
217
+ private runExponentialSmoothing(
218
+ ruleSet: CompiledExponentialSmoothingRuleSet,
219
+ entityId: string,
220
+ points: DataPoint[],
221
+ asOf: number
222
+ ): EntityPrediction {
223
+ const currentScore = points[points.length - 1].score;
224
+ const forecastTime = asOf + ruleSet.horizonMs;
225
+ const forecastDate = new Date(forecastTime);
226
+
227
+ // 1 data point
228
+ if (points.length === 1) {
229
+ return this.buildSinglePointPrediction(
230
+ ruleSet, entityId, currentScore, asOf, forecastDate, 'exponential-smoothing'
231
+ );
232
+ }
233
+
234
+ // 2 data points
235
+ if (points.length === 2) {
236
+ const dt = points[1].timestamp - points[0].timestamp;
237
+ const ds = points[1].score - points[0].score;
238
+ const slopePerMs = dt > 0 ? ds / dt : 0;
239
+ const predicted = currentScore + slopePerMs * ruleSet.horizonMs;
240
+
241
+ // Use a default stable threshold for exponential smoothing
242
+ const stableThreshold = 0.001 / MS_PER_DAY;
243
+
244
+ return {
245
+ entityId,
246
+ dataPoints: 2,
247
+ trend: resolveTrendDirection(slopePerMs, stableThreshold),
248
+ trendSlope: round(slopePerMs * MS_PER_DAY, 4),
249
+ confidence: 0.3,
250
+ confidenceBracket: resolveConfidenceBracket(0.3),
251
+ currentScore,
252
+ forecast: {
253
+ date: forecastDate,
254
+ dateIso: forecastDate.toISOString(),
255
+ predictedScore: round(predicted, 2)
256
+ },
257
+ thresholdCrossings: computeThresholdCrossings(currentScore, slopePerMs, asOf, ruleSet.thresholds),
258
+ model: {
259
+ strategy: 'exponential-smoothing',
260
+ params: {
261
+ level: currentScore,
262
+ trendPerDay: round(slopePerMs * MS_PER_DAY, 6),
263
+ alpha: ruleSet.config.alpha,
264
+ beta: ruleSet.config.beta
265
+ }
266
+ }
267
+ };
268
+ }
269
+
270
+ // 3+ points: Holt's double exponential smoothing
271
+ const { alpha, beta } = ruleSet.config;
272
+
273
+ let level = points[0].score;
274
+ let trend = points[1].score - points[0].score;
275
+
276
+ // Track errors for MAE-based confidence
277
+ let totalAbsError = 0;
278
+ let minScore = points[0].score;
279
+ let maxScore = points[0].score;
280
+
281
+ for (let i = 1; i < points.length; i++) {
282
+ const score = points[i].score;
283
+ minScore = Math.min(minScore, score);
284
+ maxScore = Math.max(maxScore, score);
285
+
286
+ const predicted = level + trend;
287
+ totalAbsError += Math.abs(score - predicted);
288
+
289
+ const prevLevel = level;
290
+ level = alpha * score + (1 - alpha) * (prevLevel + trend);
291
+ trend = beta * (level - prevLevel) + (1 - beta) * trend;
292
+ }
293
+
294
+ const avgInterval = (points[points.length - 1].timestamp - points[0].timestamp) / (points.length - 1);
295
+ const stepsAhead = avgInterval > 0 ? ruleSet.horizonMs / avgInterval : 0;
296
+ const predicted = level + trend * stepsAhead;
297
+
298
+ // Confidence: 1 - (MAE / range), clamped [0, 1]
299
+ const mae = totalAbsError / (points.length - 1);
300
+ const range = maxScore - minScore;
301
+ const confidence = range > 0 ? Math.max(0, Math.min(1, 1 - mae / range)) : (mae === 0 ? 1 : 0);
302
+
303
+ // Convert trend per interval to per ms
304
+ const trendPerMs = avgInterval > 0 ? trend / avgInterval : 0;
305
+ const stableThreshold = 0.001 / MS_PER_DAY;
306
+
307
+ return {
308
+ entityId,
309
+ dataPoints: points.length,
310
+ trend: resolveTrendDirection(trendPerMs, stableThreshold),
311
+ trendSlope: round(trendPerMs * MS_PER_DAY, 4),
312
+ confidence: round(confidence, 4),
313
+ confidenceBracket: resolveConfidenceBracket(confidence),
314
+ currentScore,
315
+ forecast: {
316
+ date: forecastDate,
317
+ dateIso: forecastDate.toISOString(),
318
+ predictedScore: round(predicted, 2)
319
+ },
320
+ thresholdCrossings: computeThresholdCrossings(currentScore, trendPerMs, asOf, ruleSet.thresholds),
321
+ model: {
322
+ strategy: 'exponential-smoothing',
323
+ params: {
324
+ level: round(level, 4),
325
+ trendPerDay: round(trendPerMs * MS_PER_DAY, 6),
326
+ alpha,
327
+ beta
328
+ }
329
+ }
330
+ };
331
+ }
332
+
333
+ // ========================================
334
+ // Weighted Moving Average
335
+ // ========================================
336
+
337
+ private runWeightedMovingAverage(
338
+ ruleSet: CompiledWeightedMovingAverageRuleSet,
339
+ entityId: string,
340
+ points: DataPoint[],
341
+ asOf: number
342
+ ): EntityPrediction {
343
+ const currentScore = points[points.length - 1].score;
344
+ const forecastTime = asOf + ruleSet.horizonMs;
345
+ const forecastDate = new Date(forecastTime);
346
+
347
+ // 1 data point
348
+ if (points.length === 1) {
349
+ return this.buildSinglePointPrediction(
350
+ ruleSet, entityId, currentScore, asOf, forecastDate, 'weighted-moving-average'
351
+ );
352
+ }
353
+
354
+ // 2+ points
355
+ const windowSize = Math.min(ruleSet.config.window, points.length);
356
+ const recent = points.slice(-windowSize);
357
+
358
+ // Weighted average: weights = [1, 2, ..., N]
359
+ let weightedSum = 0;
360
+ let totalWeight = 0;
361
+ for (let i = 0; i < recent.length; i++) {
362
+ const w = i + 1;
363
+ weightedSum += recent[i].score * w;
364
+ totalWeight += w;
365
+ }
366
+ const weightedAvg = weightedSum / totalWeight;
367
+
368
+ // Weighted slope from consecutive diffs
369
+ let slopeWeightedSum = 0;
370
+ let slopeTotalWeight = 0;
371
+ for (let i = 1; i < recent.length; i++) {
372
+ const dt = recent[i].timestamp - recent[i - 1].timestamp;
373
+ if (dt > 0) {
374
+ const diff = (recent[i].score - recent[i - 1].score) / dt;
375
+ const w = i; // more weight on recent diffs
376
+ slopeWeightedSum += diff * w;
377
+ slopeTotalWeight += w;
378
+ }
379
+ }
380
+ const slopePerMs = slopeTotalWeight > 0 ? slopeWeightedSum / slopeTotalWeight : 0;
381
+
382
+ const predicted = weightedAvg + slopePerMs * ruleSet.horizonMs;
383
+
384
+ // Confidence: max(0, 1 - CV) where CV = stdDev / |mean|
385
+ const mean = recent.reduce((a, b) => a + b.score, 0) / recent.length;
386
+ const variance = recent.reduce((a, b) => a + (b.score - mean) * (b.score - mean), 0) / recent.length;
387
+ const stdDev = Math.sqrt(variance);
388
+ const cv = Math.abs(mean) > 0 ? stdDev / Math.abs(mean) : 0;
389
+ const confidence = Math.max(0, Math.min(1, 1 - cv));
390
+
391
+ const stableThreshold = 0.001 / MS_PER_DAY;
392
+
393
+ return {
394
+ entityId,
395
+ dataPoints: points.length,
396
+ trend: resolveTrendDirection(slopePerMs, stableThreshold),
397
+ trendSlope: round(slopePerMs * MS_PER_DAY, 4),
398
+ confidence: round(confidence, 4),
399
+ confidenceBracket: resolveConfidenceBracket(confidence),
400
+ currentScore,
401
+ forecast: {
402
+ date: forecastDate,
403
+ dateIso: forecastDate.toISOString(),
404
+ predictedScore: round(predicted, 2)
405
+ },
406
+ thresholdCrossings: computeThresholdCrossings(currentScore, slopePerMs, asOf, ruleSet.thresholds),
407
+ model: {
408
+ strategy: 'weighted-moving-average',
409
+ params: {
410
+ weightedAverage: round(weightedAvg, 4),
411
+ window: windowSize,
412
+ slopePerDay: round(slopePerMs * MS_PER_DAY, 6)
413
+ }
414
+ }
415
+ };
416
+ }
417
+
418
+ // ========================================
419
+ // Shared Helpers
420
+ // ========================================
421
+
422
+ private buildSinglePointPrediction(
423
+ ruleSet: CompiledPredictionRuleSet,
424
+ entityId: string,
425
+ currentScore: number,
426
+ asOf: number,
427
+ forecastDate: Date,
428
+ strategy: EntityPrediction['model']['strategy']
429
+ ): EntityPrediction {
430
+ // If defaultTrend is configured, use it as slope (per month -> per ms)
431
+ let slopePerMs = 0;
432
+ let trend: EntityPrediction['trend'] = 'insufficient-data';
433
+ let predicted = currentScore;
434
+
435
+ if (ruleSet.defaultTrend !== undefined) {
436
+ slopePerMs = ruleSet.defaultTrend / MS_PER_MONTH;
437
+ predicted = currentScore + slopePerMs * ruleSet.horizonMs;
438
+ const stableThreshold = 0.001 / MS_PER_DAY;
439
+ trend = resolveTrendDirection(slopePerMs, stableThreshold);
440
+ }
441
+
442
+ return {
443
+ entityId,
444
+ dataPoints: 1,
445
+ trend,
446
+ trendSlope: round(slopePerMs * MS_PER_DAY, 4),
447
+ confidence: 0,
448
+ confidenceBracket: 'insufficient-data',
449
+ currentScore,
450
+ forecast: {
451
+ date: forecastDate,
452
+ dateIso: forecastDate.toISOString(),
453
+ predictedScore: round(predicted, 2)
454
+ },
455
+ thresholdCrossings: slopePerMs !== 0
456
+ ? computeThresholdCrossings(currentScore, slopePerMs, asOf, ruleSet.thresholds)
457
+ : [],
458
+ model: {
459
+ strategy,
460
+ params: {}
461
+ }
462
+ };
463
+ }
464
+ }
465
+
466
+ // ========================================
467
+ // Module-Level Helpers
468
+ // ========================================
469
+
470
+ function resolveDotPath(obj: any, path: string): any {
471
+ const parts = path.split('.');
472
+ let current = obj;
473
+ for (const part of parts) {
474
+ if (current == null) return undefined;
475
+ current = current[part];
476
+ }
477
+ return current;
478
+ }
479
+
480
+ function groupByEntity(
481
+ facts: Fact[],
482
+ fields: { entityId: string; score: string; timestamp: string }
483
+ ): Map<string, DataPoint[]> {
484
+ const groups = new Map<string, DataPoint[]>();
485
+
486
+ for (const fact of facts) {
487
+ const entityId = resolveDotPath(fact.data, fields.entityId);
488
+ const score = resolveDotPath(fact.data, fields.score);
489
+ const timestampRaw = resolveDotPath(fact.data, fields.timestamp);
490
+
491
+ if (entityId == null || score == null || timestampRaw == null) continue;
492
+
493
+ const timestamp = typeof timestampRaw === 'string'
494
+ ? new Date(timestampRaw).getTime()
495
+ : typeof timestampRaw === 'number'
496
+ ? timestampRaw
497
+ : NaN;
498
+
499
+ if (isNaN(timestamp)) continue;
500
+
501
+ if (!groups.has(entityId)) {
502
+ groups.set(entityId, []);
503
+ }
504
+ groups.get(entityId)!.push({ score: Number(score), timestamp });
505
+ }
506
+
507
+ // Sort each group by timestamp ascending
508
+ for (const points of groups.values()) {
509
+ points.sort((a, b) => a.timestamp - b.timestamp);
510
+ }
511
+
512
+ return groups;
513
+ }
514
+
515
+ function computeThresholdCrossings(
516
+ currentScore: number,
517
+ slopePerMs: number,
518
+ asOf: number,
519
+ thresholds: ThresholdDefinition[],
520
+ tMin: number = 0
521
+ ): ThresholdCrossing[] {
522
+ if (slopePerMs === 0 || thresholds.length === 0) return [];
523
+
524
+ const crossings: ThresholdCrossing[] = [];
525
+ const now = tMin > 0 ? asOf + tMin : asOf; // adjust if tMin is a normalization offset
526
+
527
+ for (const threshold of thresholds) {
528
+ const diff = threshold.value - currentScore;
529
+
530
+ // Check if crossing is possible given direction of slope
531
+ if (threshold.direction === 'above') {
532
+ // Currently below threshold, trending up
533
+ if (currentScore < threshold.value && slopePerMs > 0) {
534
+ const msToThreshold = diff / slopePerMs;
535
+ const crossTime = now + msToThreshold;
536
+ const crossDate = new Date(crossTime);
537
+ crossings.push({
538
+ thresholdId: threshold.id,
539
+ thresholdName: threshold.name,
540
+ value: threshold.value,
541
+ direction: 'above',
542
+ estimatedAt: crossDate,
543
+ estimatedAtIso: crossDate.toISOString()
544
+ });
545
+ }
546
+ } else {
547
+ // direction === 'below': currently above threshold, trending down
548
+ if (currentScore > threshold.value && slopePerMs < 0) {
549
+ const msToThreshold = diff / slopePerMs;
550
+ const crossTime = now + msToThreshold;
551
+ const crossDate = new Date(crossTime);
552
+ crossings.push({
553
+ thresholdId: threshold.id,
554
+ thresholdName: threshold.name,
555
+ value: threshold.value,
556
+ direction: 'below',
557
+ estimatedAt: crossDate,
558
+ estimatedAtIso: crossDate.toISOString()
559
+ });
560
+ }
561
+ }
562
+ }
563
+
564
+ return crossings;
565
+ }
566
+
567
+ function round(value: number, decimals: number): number {
568
+ const factor = Math.pow(10, decimals);
569
+ return Math.round(value * factor) / factor;
570
+ }
571
+
572
+ /** Singleton instance */
573
+ export const predictionStrategy = new PredictionExecutor();