@higher.archi/boe 1.0.23 → 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.
- package/dist/core/types/rule.d.ts +1 -1
- package/dist/core/types/rule.d.ts.map +1 -1
- package/dist/engines/prediction/compiler.d.ts +11 -0
- package/dist/engines/prediction/compiler.d.ts.map +1 -0
- package/dist/engines/prediction/compiler.js +153 -0
- package/dist/engines/prediction/compiler.js.map +1 -0
- package/dist/engines/prediction/engine.d.ts +49 -0
- package/dist/engines/prediction/engine.d.ts.map +1 -0
- package/dist/engines/prediction/engine.js +91 -0
- package/dist/engines/prediction/engine.js.map +1 -0
- package/dist/engines/prediction/index.d.ts +9 -0
- package/dist/engines/prediction/index.d.ts.map +1 -0
- package/dist/engines/prediction/index.js +25 -0
- package/dist/engines/prediction/index.js.map +1 -0
- package/dist/engines/prediction/strategy.d.ts +20 -0
- package/dist/engines/prediction/strategy.d.ts.map +1 -0
- package/dist/engines/prediction/strategy.js +441 -0
- package/dist/engines/prediction/strategy.js.map +1 -0
- package/dist/engines/prediction/types.d.ts +150 -0
- package/dist/engines/prediction/types.d.ts.map +1 -0
- package/dist/engines/prediction/types.js +59 -0
- package/dist/engines/prediction/types.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -2
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/core/types/rule.ts +1 -1
- package/src/engines/prediction/compiler.ts +186 -0
- package/src/engines/prediction/engine.ts +120 -0
- package/src/engines/prediction/index.ts +49 -0
- package/src/engines/prediction/strategy.ts +573 -0
- package/src/engines/prediction/types.ts +236 -0
- 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();
|