@svrnsec/pulse 0.6.0 → 0.8.0

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 (48) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +883 -622
  3. package/SECURITY.md +86 -86
  4. package/bin/svrnsec-pulse.js +7 -7
  5. package/dist/{pulse.cjs.js → pulse.cjs} +6379 -6420
  6. package/dist/pulse.cjs.map +1 -0
  7. package/dist/pulse.esm.js +6380 -6421
  8. package/dist/pulse.esm.js.map +1 -1
  9. package/index.d.ts +895 -846
  10. package/package.json +185 -165
  11. package/pkg/pulse_core.js +174 -173
  12. package/src/analysis/audio.js +213 -213
  13. package/src/analysis/authenticityAudit.js +408 -390
  14. package/src/analysis/coherence.js +502 -502
  15. package/src/analysis/coordinatedBehavior.js +825 -0
  16. package/src/analysis/heuristic.js +428 -428
  17. package/src/analysis/jitter.js +446 -446
  18. package/src/analysis/llm.js +473 -472
  19. package/src/analysis/populationEntropy.js +404 -403
  20. package/src/analysis/provider.js +248 -248
  21. package/src/analysis/refraction.js +392 -0
  22. package/src/analysis/trustScore.js +356 -356
  23. package/src/cli/args.js +36 -36
  24. package/src/cli/commands/scan.js +192 -192
  25. package/src/cli/runner.js +157 -157
  26. package/src/collector/adaptive.js +200 -200
  27. package/src/collector/bio.js +297 -287
  28. package/src/collector/canvas.js +247 -239
  29. package/src/collector/dram.js +203 -203
  30. package/src/collector/enf.js +311 -311
  31. package/src/collector/entropy.js +195 -195
  32. package/src/collector/gpu.js +248 -245
  33. package/src/collector/idleAttestation.js +480 -480
  34. package/src/collector/sabTimer.js +189 -191
  35. package/src/fingerprint.js +475 -475
  36. package/src/index.js +342 -342
  37. package/src/integrations/react-native.js +462 -459
  38. package/src/integrations/react.js +184 -185
  39. package/src/middleware/express.js +155 -155
  40. package/src/middleware/next.js +174 -175
  41. package/src/proof/challenge.js +249 -249
  42. package/src/proof/engagementToken.js +426 -394
  43. package/src/proof/fingerprint.js +268 -268
  44. package/src/proof/validator.js +83 -143
  45. package/src/registry/serializer.js +349 -349
  46. package/src/terminal.js +263 -263
  47. package/src/update-notifier.js +259 -264
  48. package/dist/pulse.cjs.js.map +0 -1
@@ -1,446 +1,446 @@
1
- /**
2
- * @sovereign/pulse — Statistical Jitter Analysis
3
- *
4
- * Analyses the timing distribution from the entropy probe to classify
5
- * the host as a real consumer device or a sanitised datacenter VM.
6
- *
7
- * Core insight:
8
- * Real hardware → thermal throttling, OS context switches, DRAM refresh
9
- * cycles create a characteristic "noisy" but physically
10
- * plausible timing distribution.
11
- * Datacenter VM → hypervisor scheduler presents a nearly-flat execution
12
- * curve; thermal feedback is absent; timer may be
13
- * quantised to the host's scheduler quantum.
14
- */
15
-
16
- // ---------------------------------------------------------------------------
17
- // Public API
18
- // ---------------------------------------------------------------------------
19
-
20
- /**
21
- * Full statistical analysis of a timing vector.
22
- *
23
- * @param {number[]} timings - per-iteration millisecond deltas from WASM probe
24
- * @param {object} [opts]
25
- * @param {object} [opts.autocorrelations] - pre-computed { lag1 … lag10 }
26
- * @returns {JitterAnalysis}
27
- */
28
- export function classifyJitter(timings, opts = {}) {
29
- if (!timings || timings.length < 10) {
30
- return _insufficientData();
31
- }
32
-
33
- const stats = computeStats(timings);
34
- const autocorr = opts.autocorrelations ?? _computeLocalAutocorr(timings);
35
- const hurst = computeHurst(timings);
36
- const quantEnt = detectQuantizationEntropy(timings);
37
- const thermal = detectThermalSignature(timings);
38
- const outlierRate = _outlierRate(timings, stats);
39
-
40
- // ── Scoring rubric ───────────────────────────────────────────────────────
41
- // Each criterion contributes 0–1 to a weighted sum.
42
- // Weights sum to 1.0; final score is in [0, 1].
43
- // 1.0 = almost certainly a real consumer device + real silicon
44
- // 0.0 = almost certainly a sanitised VM / AI instance
45
-
46
- const components = {};
47
- const flags = [];
48
-
49
- // 1. Coefficient of Variation (weight 0.25)
50
- // Real hardware: CV ∈ [0.04, 0.35]
51
- // VM: CV often < 0.02 ("too flat") or > 0.5 (scheduler bursts)
52
- let cvScore = 0;
53
- if (stats.cv >= 0.04 && stats.cv <= 0.35) {
54
- cvScore = 1.0;
55
- } else if (stats.cv >= 0.02 && stats.cv < 0.04) {
56
- cvScore = (stats.cv - 0.02) / 0.02; // linear ramp up
57
- flags.push('LOW_CV_BORDERLINE');
58
- } else if (stats.cv > 0.35 && stats.cv < 0.5) {
59
- cvScore = 1.0 - (stats.cv - 0.35) / 0.15; // ramp down
60
- flags.push('HIGH_CV_POSSIBLE_SCHEDULER_BURST');
61
- } else if (stats.cv < 0.02) {
62
- cvScore = 0;
63
- flags.push('CV_TOO_FLAT_VM_INDICATOR');
64
- } else {
65
- cvScore = 0.2;
66
- flags.push('CV_TOO_HIGH_SCHEDULER_BURST');
67
- }
68
- components.cv = { score: cvScore, weight: 0.25, value: stats.cv };
69
-
70
- // 2. Autocorrelation profile (weight 0.20)
71
- // Real thermal noise → all lags near 0 (i.i.d. / Brownian)
72
- // VM hypervisor scheduler → positive autocorr (periodic steal-time bursts)
73
- // We use the maximum absolute autocorrelation across all measured lags
74
- // to catch both lag-1 and longer-period scheduler artifacts.
75
- const acVals = Object.values(autocorr).filter(v => v != null);
76
- const maxAbsAC = acVals.length ? Math.max(...acVals.map(Math.abs)) : 0;
77
- const meanAbsAC = acVals.length ? acVals.reduce((s, v) => s + Math.abs(v), 0) / acVals.length : 0;
78
- const acStat = (maxAbsAC + meanAbsAC) / 2; // blend: worst + average
79
-
80
- let ac1Score = 0;
81
- if (acStat < 0.12) {
82
- ac1Score = 1.0;
83
- } else if (acStat < 0.28) {
84
- ac1Score = 1.0 - (acStat - 0.12) / 0.16;
85
- flags.push('MODERATE_AUTOCORR_POSSIBLE_SCHEDULER');
86
- } else {
87
- ac1Score = 0;
88
- flags.push('HIGH_AUTOCORR_VM_SCHEDULER_DETECTED');
89
- }
90
- components.autocorr = { score: ac1Score, weight: 0.20, value: acStat };
91
-
92
- // 3. Quantization Entropy (weight 0.20)
93
- // High entropy → timings are spread, not clustered on fixed boundaries
94
- // Low entropy → values cluster on integer-ms ticks (legacy VM timer)
95
- //
96
- // Scale:
97
- // QE ≥ 4.5 → 1.00 (strongly physical)
98
- // QE 3.0–4.5 → 0.00–1.00 (linear ramp, healthy range)
99
- // QE 2.0–3.0 → 0.00–0.20 (borderline; still gives partial credit so one
100
- // weak metric doesn't zero-out the whole score)
101
- // QE < 2.0 → 0.00 (clearly synthetic/quantised timer)
102
- let qeScore = 0;
103
- if (quantEnt >= 4.5) {
104
- qeScore = 1.0;
105
- } else if (quantEnt >= 3.0) {
106
- qeScore = (quantEnt - 3.0) / 1.5; // 0.00 → 1.00
107
- } else if (quantEnt >= 2.0) {
108
- // Partial credit — not obviously VM but not clearly physical.
109
- // Lets other strong signals (CV, autocorr, Hurst) still carry the device
110
- // over the physical threshold instead of being zeroed by a single weak metric.
111
- qeScore = ((quantEnt - 2.0) / 1.0) * 0.20; // 0.00 → 0.20
112
- flags.push('LOW_QUANTIZATION_ENTROPY_BORDERLINE');
113
- } else {
114
- qeScore = 0;
115
- flags.push('LOW_QUANTIZATION_ENTROPY_SYNTHETIC_TIMER');
116
- }
117
- components.quantization = { score: qeScore, weight: 0.20, value: quantEnt };
118
-
119
- // 4. Hurst Exponent (weight 0.15)
120
- // Genuine white thermal noise → H ≈ 0.5
121
- // VM scheduler periodicity → H > 0.7 (persistent / self-similar)
122
- // Synthetic / replayed → H near 0 or 1
123
- let hurstScore = 0;
124
- const hurstDev = Math.abs(hurst - 0.5);
125
- if (hurstDev < 0.10) {
126
- hurstScore = 1.0;
127
- } else if (hurstDev < 0.25) {
128
- hurstScore = 1.0 - (hurstDev - 0.10) / 0.15;
129
- if (hurst > 0.7) flags.push('HIGH_HURST_VM_SCHEDULER_PERIODICITY');
130
- } else {
131
- hurstScore = 0;
132
- if (hurst > 0.7) flags.push('VERY_HIGH_HURST_VM');
133
- else if (hurst < 0.3) flags.push('VERY_LOW_HURST_ANTIPERSISTENT');
134
- }
135
- components.hurst = { score: hurstScore, weight: 0.15, value: hurst };
136
-
137
- // 5. Thermal signature (weight 0.10)
138
- // Real CPU under sustained load → upward drift or sawtooth (fan cycling)
139
- // VM: flat timing regardless of simulated load (no thermal feedback loop)
140
- let thermalScore = 0;
141
- if (thermal.pattern === 'rising' || thermal.pattern === 'sawtooth') {
142
- thermalScore = 1.0;
143
- } else if (Math.abs(thermal.slope) > 5e-5) {
144
- thermalScore = 0.5; // some drift present
145
- flags.push('WEAK_THERMAL_SIGNATURE');
146
- } else {
147
- thermalScore = 0;
148
- flags.push('FLAT_THERMAL_PROFILE_VM_INDICATOR');
149
- }
150
- components.thermal = { score: thermalScore, weight: 0.10, value: thermal.slope };
151
-
152
- // 6. Outlier rate (weight 0.10)
153
- // Context switches on real OS → occasional timing spikes (> 3σ)
154
- // VMs: far fewer OS-level interruptions visible to guest
155
- let outlierScore = 0;
156
- if (outlierRate >= 0.02 && outlierRate <= 0.15) {
157
- outlierScore = 1.0;
158
- } else if (outlierRate > 0 && outlierRate < 0.02) {
159
- outlierScore = outlierRate / 0.02;
160
- flags.push('FEW_OUTLIERS_POSSIBLY_VM');
161
- } else if (outlierRate > 0.15) {
162
- outlierScore = Math.max(0, 1.0 - (outlierRate - 0.15) / 0.15);
163
- flags.push('EXCESSIVE_OUTLIERS_UNSTABLE');
164
- }
165
- components.outliers = { score: outlierScore, weight: 0.10, value: outlierRate };
166
-
167
- // ── Weighted aggregate ────────────────────────────────────────────────────
168
- const score = Object.values(components)
169
- .reduce((sum, c) => sum + c.score * c.weight, 0);
170
-
171
- return {
172
- score: Math.max(0, Math.min(1, score)),
173
- flags,
174
- components,
175
- stats,
176
- autocorrelations: autocorr,
177
- hurstExponent: hurst,
178
- quantizationEntropy: quantEnt,
179
- thermalSignature: thermal,
180
- outlierRate,
181
- };
182
- }
183
-
184
- // ---------------------------------------------------------------------------
185
- // computeStats
186
- // ---------------------------------------------------------------------------
187
-
188
- /**
189
- * Descriptive statistics for a timing vector.
190
- * @param {number[]} arr
191
- * @returns {TimingStats}
192
- */
193
- export function computeStats(arr) {
194
- const sorted = [...arr].sort((a, b) => a - b);
195
- const n = arr.length;
196
- const mean = arr.reduce((s, v) => s + v, 0) / n;
197
- const varr = arr.reduce((s, v) => s + (v - mean) ** 2, 0) / (n - 1);
198
- const std = Math.sqrt(varr);
199
-
200
- const pct = (p) => {
201
- const idx = (p / 100) * (n - 1);
202
- const lo = Math.floor(idx);
203
- const hi = Math.ceil(idx);
204
- return sorted[lo] + (sorted[hi] - sorted[lo]) * (idx - lo);
205
- };
206
-
207
- // Skewness (Fisher-Pearson)
208
- const skew = n < 3 ? 0 :
209
- arr.reduce((s, v) => s + ((v - mean) / std) ** 3, 0) *
210
- (n / ((n - 1) * (n - 2)));
211
-
212
- // Excess kurtosis
213
- const kurt = n < 4 ? 0 :
214
- (arr.reduce((s, v) => s + ((v - mean) / std) ** 4, 0) *
215
- (n * (n + 1)) / ((n - 1) * (n - 2) * (n - 3))) -
216
- (3 * (n - 1) ** 2) / ((n - 2) * (n - 3));
217
-
218
- return {
219
- n, mean, std,
220
- cv: std / mean,
221
- min: sorted[0],
222
- max: sorted[n - 1],
223
- p5: pct(5),
224
- p25: pct(25),
225
- p50: pct(50),
226
- p75: pct(75),
227
- p95: pct(95),
228
- p99: pct(99),
229
- skewness: skew,
230
- kurtosis: kurt,
231
- };
232
- }
233
-
234
- /**
235
- * @typedef {object} TimingStats
236
- * @property {number} n
237
- * @property {number} mean
238
- * @property {number} std
239
- * @property {number} cv
240
- * @property {number} min
241
- * @property {number} max
242
- * @property {number} p5
243
- * @property {number} p25
244
- * @property {number} p50
245
- * @property {number} p75
246
- * @property {number} p95
247
- * @property {number} p99
248
- * @property {number} skewness
249
- * @property {number} kurtosis
250
- */
251
-
252
- // ---------------------------------------------------------------------------
253
- // computeHurst
254
- // ---------------------------------------------------------------------------
255
-
256
- /**
257
- * Estimates the Hurst exponent via Rescaled Range (R/S) analysis.
258
- * Covers 4 sub-series sizes (n/4, n/3, n/2, n) to get a log-log slope.
259
- *
260
- * H ≈ 0.5 → random walk (Brownian, thermal noise)
261
- * H > 0.5 → persistent (VM hypervisor periodicity)
262
- * H < 0.5 → anti-persistent
263
- *
264
- * @param {number[]} arr
265
- * @returns {number}
266
- */
267
- export function computeHurst(arr) {
268
- const n = arr.length;
269
- if (n < 16) return 0.5; // not enough data
270
-
271
- const sizes = [
272
- Math.floor(n / 4),
273
- Math.floor(n / 3),
274
- Math.floor(n / 2),
275
- n,
276
- ].filter(s => s >= 8);
277
-
278
- const points = sizes.map(s => {
279
- const rs = _rescaledRange(arr.slice(0, s));
280
- return [Math.log(s), Math.log(rs)];
281
- });
282
-
283
- // Ordinary least squares on log-log
284
- const xMean = points.reduce((s, p) => s + p[0], 0) / points.length;
285
- const yMean = points.reduce((s, p) => s + p[1], 0) / points.length;
286
- let num = 0, den = 0;
287
- for (const [x, y] of points) {
288
- num += (x - xMean) * (y - yMean);
289
- den += (x - xMean) ** 2;
290
- }
291
- const H = den === 0 ? 0.5 : num / den;
292
- return Math.max(0, Math.min(1, H));
293
- }
294
-
295
- function _rescaledRange(arr) {
296
- const n = arr.length;
297
- const mean = arr.reduce((s, v) => s + v, 0) / n;
298
- const dev = arr.map(v => v - mean);
299
-
300
- // Cumulative deviation
301
- const cum = [];
302
- let acc = 0;
303
- for (const d of dev) { acc += d; cum.push(acc); }
304
-
305
- const R = Math.max(...cum) - Math.min(...cum);
306
- const S = Math.sqrt(arr.reduce((s, v) => s + (v - mean) ** 2, 0) / n);
307
- return S === 0 ? 1 : R / S;
308
- }
309
-
310
- // ---------------------------------------------------------------------------
311
- // detectQuantizationEntropy
312
- // ---------------------------------------------------------------------------
313
-
314
- /**
315
- * Computes Shannon entropy of a histogram of timing values.
316
- * Low entropy (< 3 bits) indicates clustered / quantised timings (VM timer).
317
- *
318
- * @param {number[]} arr
319
- * @param {number} [binWidthMs=0.2]
320
- * @returns {number} entropy in bits
321
- */
322
- export function detectQuantizationEntropy(arr, binWidthMs = 0.2) {
323
- if (!arr.length) return 0;
324
- const bins = new Map();
325
- for (const v of arr) {
326
- const bin = Math.round(v / binWidthMs);
327
- bins.set(bin, (bins.get(bin) ?? 0) + 1);
328
- }
329
- const n = arr.length;
330
- let H = 0;
331
- for (const count of bins.values()) {
332
- const p = count / n;
333
- H -= p * Math.log2(p);
334
- }
335
- return H;
336
- }
337
-
338
- // ---------------------------------------------------------------------------
339
- // detectThermalSignature
340
- // ---------------------------------------------------------------------------
341
-
342
- /**
343
- * Analyses whether the timing series shows a thermal throttle pattern:
344
- * a rising trend (CPU heating up) or sawtooth (fan intervention).
345
- *
346
- * @param {number[]} arr
347
- * @returns {{ slope: number, pattern: 'rising'|'falling'|'sawtooth'|'flat', r2: number }}
348
- */
349
- export function detectThermalSignature(arr) {
350
- const n = arr.length;
351
- if (n < 10) return { slope: 0, pattern: 'flat', r2: 0 };
352
-
353
- // Linear regression (timing vs sample index)
354
- const xMean = (n - 1) / 2;
355
- const yMean = arr.reduce((s, v) => s + v, 0) / n;
356
- let num = 0, den = 0;
357
- for (let i = 0; i < n; i++) {
358
- num += (i - xMean) * (arr[i] - yMean);
359
- den += (i - xMean) ** 2;
360
- }
361
- const slope = den === 0 ? 0 : num / den;
362
-
363
- // R² of linear fit
364
- const ss_res = arr.reduce((s, v, i) => {
365
- const pred = yMean + slope * (i - xMean);
366
- return s + (v - pred) ** 2;
367
- }, 0);
368
- const ss_tot = arr.reduce((s, v) => s + (v - yMean) ** 2, 0);
369
- const r2 = ss_tot === 0 ? 0 : 1 - ss_res / ss_tot;
370
-
371
- // Sawtooth detection: look for a drop > 2σ after a rising segment
372
- const std = Math.sqrt(arr.reduce((s, v) => s + (v - yMean) ** 2, 0) / n);
373
- let sawtoothCount = 0;
374
- for (let i = 1; i < n; i++) {
375
- if (arr[i - 1] - arr[i] > 2 * std) sawtoothCount++;
376
- }
377
-
378
- let pattern;
379
- if (sawtoothCount >= 2) pattern = 'sawtooth';
380
- else if (slope > 5e-5) pattern = 'rising';
381
- else if (slope < -5e-5) pattern = 'falling';
382
- else pattern = 'flat';
383
-
384
- return { slope, pattern, r2, sawtoothCount };
385
- }
386
-
387
- // ---------------------------------------------------------------------------
388
- // Internal helpers
389
- // ---------------------------------------------------------------------------
390
-
391
- function _outlierRate(arr, stats) {
392
- const threshold = stats.mean + 3 * stats.std;
393
- return arr.filter(v => v > threshold).length / arr.length;
394
- }
395
-
396
- function _computeLocalAutocorr(arr) {
397
- const autocorr = {};
398
- for (const lag of [1, 2, 3, 5, 10]) {
399
- autocorr[`lag${lag}`] = _pearsonAC(arr, lag);
400
- }
401
- return autocorr;
402
- }
403
-
404
- function _pearsonAC(arr, lag) {
405
- const n = arr.length;
406
- if (lag >= n) return 0;
407
- const valid = n - lag;
408
- const mean = arr.reduce((s, v) => s + v, 0) / n;
409
- let num = 0, da = 0, db = 0;
410
- for (let i = 0; i < valid; i++) {
411
- const a = arr[i] - mean;
412
- const b = arr[i + lag] - mean;
413
- num += a * b;
414
- da += a * a;
415
- db += b * b;
416
- }
417
- const denom = Math.sqrt(da * db);
418
- return denom < 1e-14 ? 0 : num / denom;
419
- }
420
-
421
- function _insufficientData() {
422
- return {
423
- score: 0,
424
- flags: ['INSUFFICIENT_DATA'],
425
- components: {},
426
- stats: null,
427
- autocorrelations: {},
428
- hurstExponent: 0.5,
429
- quantizationEntropy: 0,
430
- thermalSignature: { slope: 0, pattern: 'flat', r2: 0 },
431
- outlierRate: 0,
432
- };
433
- }
434
-
435
- /**
436
- * @typedef {object} JitterAnalysis
437
- * @property {number} score - [0,1], 1 = real hardware
438
- * @property {string[]} flags - diagnostic flags
439
- * @property {object} components - per-criterion scores and weights
440
- * @property {TimingStats} stats
441
- * @property {object} autocorrelations
442
- * @property {number} hurstExponent
443
- * @property {number} quantizationEntropy
444
- * @property {object} thermalSignature
445
- * @property {number} outlierRate
446
- */
1
+ /**
2
+ * @svrnsec/pulse — Statistical Jitter Analysis
3
+ *
4
+ * Analyses the timing distribution from the entropy probe to classify
5
+ * the host as a real consumer device or a sanitised datacenter VM.
6
+ *
7
+ * Core insight:
8
+ * Real hardware → thermal throttling, OS context switches, DRAM refresh
9
+ * cycles create a characteristic "noisy" but physically
10
+ * plausible timing distribution.
11
+ * Datacenter VM → hypervisor scheduler presents a nearly-flat execution
12
+ * curve; thermal feedback is absent; timer may be
13
+ * quantised to the host's scheduler quantum.
14
+ */
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Public API
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /**
21
+ * Full statistical analysis of a timing vector.
22
+ *
23
+ * @param {number[]} timings - per-iteration millisecond deltas from WASM probe
24
+ * @param {object} [opts]
25
+ * @param {object} [opts.autocorrelations] - pre-computed { lag1 … lag10 }
26
+ * @returns {JitterAnalysis}
27
+ */
28
+ export function classifyJitter(timings, opts = {}) {
29
+ if (!timings || timings.length < 10) {
30
+ return _insufficientData();
31
+ }
32
+
33
+ const stats = computeStats(timings);
34
+ const autocorr = opts.autocorrelations ?? _computeLocalAutocorr(timings);
35
+ const hurst = computeHurst(timings);
36
+ const quantEnt = detectQuantizationEntropy(timings);
37
+ const thermal = detectThermalSignature(timings);
38
+ const outlierRate = _outlierRate(timings, stats);
39
+
40
+ // ── Scoring rubric ───────────────────────────────────────────────────────
41
+ // Each criterion contributes 0–1 to a weighted sum.
42
+ // Weights sum to 1.0; final score is in [0, 1].
43
+ // 1.0 = almost certainly a real consumer device + real silicon
44
+ // 0.0 = almost certainly a sanitised VM / AI instance
45
+
46
+ const components = {};
47
+ const flags = [];
48
+
49
+ // 1. Coefficient of Variation (weight 0.25)
50
+ // Real hardware: CV ∈ [0.04, 0.35]
51
+ // VM: CV often < 0.02 ("too flat") or > 0.5 (scheduler bursts)
52
+ let cvScore = 0;
53
+ if (stats.cv >= 0.04 && stats.cv <= 0.35) {
54
+ cvScore = 1.0;
55
+ } else if (stats.cv >= 0.02 && stats.cv < 0.04) {
56
+ cvScore = (stats.cv - 0.02) / 0.02; // linear ramp up
57
+ flags.push('LOW_CV_BORDERLINE');
58
+ } else if (stats.cv > 0.35 && stats.cv < 0.5) {
59
+ cvScore = 1.0 - (stats.cv - 0.35) / 0.15; // ramp down
60
+ flags.push('HIGH_CV_POSSIBLE_SCHEDULER_BURST');
61
+ } else if (stats.cv < 0.02) {
62
+ cvScore = 0;
63
+ flags.push('CV_TOO_FLAT_VM_INDICATOR');
64
+ } else {
65
+ cvScore = 0.2;
66
+ flags.push('CV_TOO_HIGH_SCHEDULER_BURST');
67
+ }
68
+ components.cv = { score: cvScore, weight: 0.25, value: stats.cv };
69
+
70
+ // 2. Autocorrelation profile (weight 0.20)
71
+ // Real thermal noise → all lags near 0 (i.i.d. / Brownian)
72
+ // VM hypervisor scheduler → positive autocorr (periodic steal-time bursts)
73
+ // We use the maximum absolute autocorrelation across all measured lags
74
+ // to catch both lag-1 and longer-period scheduler artifacts.
75
+ const acVals = Object.values(autocorr).filter(v => v != null);
76
+ const maxAbsAC = acVals.length ? Math.max(...acVals.map(Math.abs)) : 0;
77
+ const meanAbsAC = acVals.length ? acVals.reduce((s, v) => s + Math.abs(v), 0) / acVals.length : 0;
78
+ const acStat = (maxAbsAC + meanAbsAC) / 2; // blend: worst + average
79
+
80
+ let ac1Score = 0;
81
+ if (acStat < 0.12) {
82
+ ac1Score = 1.0;
83
+ } else if (acStat < 0.28) {
84
+ ac1Score = 1.0 - (acStat - 0.12) / 0.16;
85
+ flags.push('MODERATE_AUTOCORR_POSSIBLE_SCHEDULER');
86
+ } else {
87
+ ac1Score = 0;
88
+ flags.push('HIGH_AUTOCORR_VM_SCHEDULER_DETECTED');
89
+ }
90
+ components.autocorr = { score: ac1Score, weight: 0.20, value: acStat };
91
+
92
+ // 3. Quantization Entropy (weight 0.20)
93
+ // High entropy → timings are spread, not clustered on fixed boundaries
94
+ // Low entropy → values cluster on integer-ms ticks (legacy VM timer)
95
+ //
96
+ // Scale:
97
+ // QE ≥ 4.5 → 1.00 (strongly physical)
98
+ // QE 3.0–4.5 → 0.00–1.00 (linear ramp, healthy range)
99
+ // QE 2.0–3.0 → 0.00–0.20 (borderline; still gives partial credit so one
100
+ // weak metric doesn't zero-out the whole score)
101
+ // QE < 2.0 → 0.00 (clearly synthetic/quantised timer)
102
+ let qeScore = 0;
103
+ if (quantEnt >= 4.5) {
104
+ qeScore = 1.0;
105
+ } else if (quantEnt >= 3.0) {
106
+ qeScore = (quantEnt - 3.0) / 1.5; // 0.00 → 1.00
107
+ } else if (quantEnt >= 2.0) {
108
+ // Partial credit — not obviously VM but not clearly physical.
109
+ // Lets other strong signals (CV, autocorr, Hurst) still carry the device
110
+ // over the physical threshold instead of being zeroed by a single weak metric.
111
+ qeScore = ((quantEnt - 2.0) / 1.0) * 0.20; // 0.00 → 0.20
112
+ flags.push('LOW_QUANTIZATION_ENTROPY_BORDERLINE');
113
+ } else {
114
+ qeScore = 0;
115
+ flags.push('LOW_QUANTIZATION_ENTROPY_SYNTHETIC_TIMER');
116
+ }
117
+ components.quantization = { score: qeScore, weight: 0.20, value: quantEnt };
118
+
119
+ // 4. Hurst Exponent (weight 0.15)
120
+ // Genuine white thermal noise → H ≈ 0.5
121
+ // VM scheduler periodicity → H > 0.7 (persistent / self-similar)
122
+ // Synthetic / replayed → H near 0 or 1
123
+ let hurstScore = 0;
124
+ const hurstDev = Math.abs(hurst - 0.5);
125
+ if (hurstDev < 0.10) {
126
+ hurstScore = 1.0;
127
+ } else if (hurstDev < 0.25) {
128
+ hurstScore = 1.0 - (hurstDev - 0.10) / 0.15;
129
+ if (hurst > 0.7) flags.push('HIGH_HURST_VM_SCHEDULER_PERIODICITY');
130
+ } else {
131
+ hurstScore = 0;
132
+ if (hurst > 0.7) flags.push('VERY_HIGH_HURST_VM');
133
+ else if (hurst < 0.3) flags.push('VERY_LOW_HURST_ANTIPERSISTENT');
134
+ }
135
+ components.hurst = { score: hurstScore, weight: 0.15, value: hurst };
136
+
137
+ // 5. Thermal signature (weight 0.10)
138
+ // Real CPU under sustained load → upward drift or sawtooth (fan cycling)
139
+ // VM: flat timing regardless of simulated load (no thermal feedback loop)
140
+ let thermalScore = 0;
141
+ if (thermal.pattern === 'rising' || thermal.pattern === 'sawtooth') {
142
+ thermalScore = 1.0;
143
+ } else if (Math.abs(thermal.slope) > 5e-5) {
144
+ thermalScore = 0.5; // some drift present
145
+ flags.push('WEAK_THERMAL_SIGNATURE');
146
+ } else {
147
+ thermalScore = 0;
148
+ flags.push('FLAT_THERMAL_PROFILE_VM_INDICATOR');
149
+ }
150
+ components.thermal = { score: thermalScore, weight: 0.10, value: thermal.slope };
151
+
152
+ // 6. Outlier rate (weight 0.10)
153
+ // Context switches on real OS → occasional timing spikes (> 3σ)
154
+ // VMs: far fewer OS-level interruptions visible to guest
155
+ let outlierScore = 0;
156
+ if (outlierRate >= 0.02 && outlierRate <= 0.15) {
157
+ outlierScore = 1.0;
158
+ } else if (outlierRate > 0 && outlierRate < 0.02) {
159
+ outlierScore = outlierRate / 0.02;
160
+ flags.push('FEW_OUTLIERS_POSSIBLY_VM');
161
+ } else if (outlierRate > 0.15) {
162
+ outlierScore = Math.max(0, 1.0 - (outlierRate - 0.15) / 0.15);
163
+ flags.push('EXCESSIVE_OUTLIERS_UNSTABLE');
164
+ }
165
+ components.outliers = { score: outlierScore, weight: 0.10, value: outlierRate };
166
+
167
+ // ── Weighted aggregate ────────────────────────────────────────────────────
168
+ const score = Object.values(components)
169
+ .reduce((sum, c) => sum + c.score * c.weight, 0);
170
+
171
+ return {
172
+ score: Math.max(0, Math.min(1, score)),
173
+ flags,
174
+ components,
175
+ stats,
176
+ autocorrelations: autocorr,
177
+ hurstExponent: hurst,
178
+ quantizationEntropy: quantEnt,
179
+ thermalSignature: thermal,
180
+ outlierRate,
181
+ };
182
+ }
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // computeStats
186
+ // ---------------------------------------------------------------------------
187
+
188
+ /**
189
+ * Descriptive statistics for a timing vector.
190
+ * @param {number[]} arr
191
+ * @returns {TimingStats}
192
+ */
193
+ export function computeStats(arr) {
194
+ const sorted = [...arr].sort((a, b) => a - b);
195
+ const n = arr.length;
196
+ const mean = arr.reduce((s, v) => s + v, 0) / n;
197
+ const varr = arr.reduce((s, v) => s + (v - mean) ** 2, 0) / (n - 1);
198
+ const std = Math.sqrt(varr);
199
+
200
+ const pct = (p) => {
201
+ const idx = (p / 100) * (n - 1);
202
+ const lo = Math.floor(idx);
203
+ const hi = Math.ceil(idx);
204
+ return sorted[lo] + (sorted[hi] - sorted[lo]) * (idx - lo);
205
+ };
206
+
207
+ // Skewness (Fisher-Pearson)
208
+ const skew = n < 3 ? 0 :
209
+ arr.reduce((s, v) => s + ((v - mean) / std) ** 3, 0) *
210
+ (n / ((n - 1) * (n - 2)));
211
+
212
+ // Excess kurtosis
213
+ const kurt = n < 4 ? 0 :
214
+ (arr.reduce((s, v) => s + ((v - mean) / std) ** 4, 0) *
215
+ (n * (n + 1)) / ((n - 1) * (n - 2) * (n - 3))) -
216
+ (3 * (n - 1) ** 2) / ((n - 2) * (n - 3));
217
+
218
+ return {
219
+ n, mean, std,
220
+ cv: std / mean,
221
+ min: sorted[0],
222
+ max: sorted[n - 1],
223
+ p5: pct(5),
224
+ p25: pct(25),
225
+ p50: pct(50),
226
+ p75: pct(75),
227
+ p95: pct(95),
228
+ p99: pct(99),
229
+ skewness: skew,
230
+ kurtosis: kurt,
231
+ };
232
+ }
233
+
234
+ /**
235
+ * @typedef {object} TimingStats
236
+ * @property {number} n
237
+ * @property {number} mean
238
+ * @property {number} std
239
+ * @property {number} cv
240
+ * @property {number} min
241
+ * @property {number} max
242
+ * @property {number} p5
243
+ * @property {number} p25
244
+ * @property {number} p50
245
+ * @property {number} p75
246
+ * @property {number} p95
247
+ * @property {number} p99
248
+ * @property {number} skewness
249
+ * @property {number} kurtosis
250
+ */
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // computeHurst
254
+ // ---------------------------------------------------------------------------
255
+
256
+ /**
257
+ * Estimates the Hurst exponent via Rescaled Range (R/S) analysis.
258
+ * Covers 4 sub-series sizes (n/4, n/3, n/2, n) to get a log-log slope.
259
+ *
260
+ * H ≈ 0.5 → random walk (Brownian, thermal noise)
261
+ * H > 0.5 → persistent (VM hypervisor periodicity)
262
+ * H < 0.5 → anti-persistent
263
+ *
264
+ * @param {number[]} arr
265
+ * @returns {number}
266
+ */
267
+ export function computeHurst(arr) {
268
+ const n = arr.length;
269
+ if (n < 16) return 0.5; // not enough data
270
+
271
+ const sizes = [
272
+ Math.floor(n / 4),
273
+ Math.floor(n / 3),
274
+ Math.floor(n / 2),
275
+ n,
276
+ ].filter(s => s >= 8);
277
+
278
+ const points = sizes.map(s => {
279
+ const rs = _rescaledRange(arr.slice(0, s));
280
+ return [Math.log(s), Math.log(rs)];
281
+ });
282
+
283
+ // Ordinary least squares on log-log
284
+ const xMean = points.reduce((s, p) => s + p[0], 0) / points.length;
285
+ const yMean = points.reduce((s, p) => s + p[1], 0) / points.length;
286
+ let num = 0, den = 0;
287
+ for (const [x, y] of points) {
288
+ num += (x - xMean) * (y - yMean);
289
+ den += (x - xMean) ** 2;
290
+ }
291
+ const H = den === 0 ? 0.5 : num / den;
292
+ return Math.max(0, Math.min(1, H));
293
+ }
294
+
295
+ function _rescaledRange(arr) {
296
+ const n = arr.length;
297
+ const mean = arr.reduce((s, v) => s + v, 0) / n;
298
+ const dev = arr.map(v => v - mean);
299
+
300
+ // Cumulative deviation
301
+ const cum = [];
302
+ let acc = 0;
303
+ for (const d of dev) { acc += d; cum.push(acc); }
304
+
305
+ const R = Math.max(...cum) - Math.min(...cum);
306
+ const S = Math.sqrt(arr.reduce((s, v) => s + (v - mean) ** 2, 0) / n);
307
+ return S === 0 ? 1 : R / S;
308
+ }
309
+
310
+ // ---------------------------------------------------------------------------
311
+ // detectQuantizationEntropy
312
+ // ---------------------------------------------------------------------------
313
+
314
+ /**
315
+ * Computes Shannon entropy of a histogram of timing values.
316
+ * Low entropy (< 3 bits) indicates clustered / quantised timings (VM timer).
317
+ *
318
+ * @param {number[]} arr
319
+ * @param {number} [binWidthMs=0.2]
320
+ * @returns {number} entropy in bits
321
+ */
322
+ export function detectQuantizationEntropy(arr, binWidthMs = 0.2) {
323
+ if (!arr.length) return 0;
324
+ const bins = new Map();
325
+ for (const v of arr) {
326
+ const bin = Math.round(v / binWidthMs);
327
+ bins.set(bin, (bins.get(bin) ?? 0) + 1);
328
+ }
329
+ const n = arr.length;
330
+ let H = 0;
331
+ for (const count of bins.values()) {
332
+ const p = count / n;
333
+ H -= p * Math.log2(p);
334
+ }
335
+ return H;
336
+ }
337
+
338
+ // ---------------------------------------------------------------------------
339
+ // detectThermalSignature
340
+ // ---------------------------------------------------------------------------
341
+
342
+ /**
343
+ * Analyses whether the timing series shows a thermal throttle pattern:
344
+ * a rising trend (CPU heating up) or sawtooth (fan intervention).
345
+ *
346
+ * @param {number[]} arr
347
+ * @returns {{ slope: number, pattern: 'rising'|'falling'|'sawtooth'|'flat', r2: number }}
348
+ */
349
+ export function detectThermalSignature(arr) {
350
+ const n = arr.length;
351
+ if (n < 10) return { slope: 0, pattern: 'flat', r2: 0 };
352
+
353
+ // Linear regression (timing vs sample index)
354
+ const xMean = (n - 1) / 2;
355
+ const yMean = arr.reduce((s, v) => s + v, 0) / n;
356
+ let num = 0, den = 0;
357
+ for (let i = 0; i < n; i++) {
358
+ num += (i - xMean) * (arr[i] - yMean);
359
+ den += (i - xMean) ** 2;
360
+ }
361
+ const slope = den === 0 ? 0 : num / den;
362
+
363
+ // R² of linear fit
364
+ const ss_res = arr.reduce((s, v, i) => {
365
+ const pred = yMean + slope * (i - xMean);
366
+ return s + (v - pred) ** 2;
367
+ }, 0);
368
+ const ss_tot = arr.reduce((s, v) => s + (v - yMean) ** 2, 0);
369
+ const r2 = ss_tot === 0 ? 0 : 1 - ss_res / ss_tot;
370
+
371
+ // Sawtooth detection: look for a drop > 2σ after a rising segment
372
+ const std = Math.sqrt(arr.reduce((s, v) => s + (v - yMean) ** 2, 0) / n);
373
+ let sawtoothCount = 0;
374
+ for (let i = 1; i < n; i++) {
375
+ if (arr[i - 1] - arr[i] > 2 * std) sawtoothCount++;
376
+ }
377
+
378
+ let pattern;
379
+ if (sawtoothCount >= 2) pattern = 'sawtooth';
380
+ else if (slope > 5e-5) pattern = 'rising';
381
+ else if (slope < -5e-5) pattern = 'falling';
382
+ else pattern = 'flat';
383
+
384
+ return { slope, pattern, r2, sawtoothCount };
385
+ }
386
+
387
+ // ---------------------------------------------------------------------------
388
+ // Internal helpers
389
+ // ---------------------------------------------------------------------------
390
+
391
+ function _outlierRate(arr, stats) {
392
+ const threshold = stats.mean + 3 * stats.std;
393
+ return arr.filter(v => v > threshold).length / arr.length;
394
+ }
395
+
396
+ function _computeLocalAutocorr(arr) {
397
+ const autocorr = {};
398
+ for (const lag of [1, 2, 3, 5, 10]) {
399
+ autocorr[`lag${lag}`] = _pearsonAC(arr, lag);
400
+ }
401
+ return autocorr;
402
+ }
403
+
404
+ function _pearsonAC(arr, lag) {
405
+ const n = arr.length;
406
+ if (lag >= n) return 0;
407
+ const valid = n - lag;
408
+ const mean = arr.reduce((s, v) => s + v, 0) / n;
409
+ let num = 0, da = 0, db = 0;
410
+ for (let i = 0; i < valid; i++) {
411
+ const a = arr[i] - mean;
412
+ const b = arr[i + lag] - mean;
413
+ num += a * b;
414
+ da += a * a;
415
+ db += b * b;
416
+ }
417
+ const denom = Math.sqrt(da * db);
418
+ return denom < 1e-14 ? 0 : num / denom;
419
+ }
420
+
421
+ function _insufficientData() {
422
+ return {
423
+ score: 0,
424
+ flags: ['INSUFFICIENT_DATA'],
425
+ components: {},
426
+ stats: null,
427
+ autocorrelations: {},
428
+ hurstExponent: 0.5,
429
+ quantizationEntropy: 0,
430
+ thermalSignature: { slope: 0, pattern: 'flat', r2: 0 },
431
+ outlierRate: 0,
432
+ };
433
+ }
434
+
435
+ /**
436
+ * @typedef {object} JitterAnalysis
437
+ * @property {number} score - [0,1], 1 = real hardware
438
+ * @property {string[]} flags - diagnostic flags
439
+ * @property {object} components - per-criterion scores and weights
440
+ * @property {TimingStats} stats
441
+ * @property {object} autocorrelations
442
+ * @property {number} hurstExponent
443
+ * @property {number} quantizationEntropy
444
+ * @property {object} thermalSignature
445
+ * @property {number} outlierRate
446
+ */