@svrnsec/pulse 0.3.1 → 0.5.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.
- package/bin/svrnsec-pulse.js +7 -0
- package/index.d.ts +130 -0
- package/package.json +70 -25
- package/src/analysis/audio.js +213 -0
- package/src/analysis/coherence.js +502 -0
- package/src/analysis/heuristic.js +428 -0
- package/src/analysis/jitter.js +446 -0
- package/src/analysis/llm.js +472 -0
- package/src/analysis/populationEntropy.js +403 -0
- package/src/analysis/provider.js +248 -0
- package/src/analysis/trustScore.js +356 -0
- package/src/cli/args.js +36 -0
- package/src/cli/commands/scan.js +192 -0
- package/src/cli/runner.js +157 -0
- package/src/collector/adaptive.js +200 -0
- package/src/collector/bio.js +287 -0
- package/src/collector/canvas.js +239 -0
- package/src/collector/dram.js +203 -0
- package/src/collector/enf.js +311 -0
- package/src/collector/entropy.js +195 -0
- package/src/collector/gpu.js +245 -0
- package/src/collector/idleAttestation.js +480 -0
- package/src/collector/sabTimer.js +191 -0
- package/src/fingerprint.js +475 -0
- package/src/index.js +342 -0
- package/src/integrations/react-native.js +459 -0
- package/src/proof/challenge.js +249 -0
- package/src/proof/engagementToken.js +394 -0
- package/src/terminal.js +263 -0
- package/src/update-notifier.js +264 -0
|
@@ -0,0 +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
|
+
*/
|