@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,403 +1,404 @@
1
- /**
2
- * @svrnsec/pulse — Population Entropy Analysis
3
- *
4
- * Server-side Sybil detector: given N engagement tokens from the same cohort
5
- * (e.g., all ad clicks in a 10-minute window), determine whether they
6
- * represent independently-operated consumer devices or a coordinated farm.
7
- *
8
- * Why per-token verification is necessary but not sufficient
9
- * ──────────────────────────────────────────────────────────
10
- * A single token from a farm device may pass every individual check:
11
- * - Real hardware ✓ (it is a real phone)
12
- * - Valid idle proof ✓ (45+ seconds of forced downtime)
13
- * - Plausible entropy ✓ (within normal ranges)
14
- *
15
- * But 1,000 farm devices in one warehouse share inescapable physical context:
16
- * - Same ambient temperature → correlated DRAM timing variance
17
- * - Same power circuit → near-identical ENF phase deviation
18
- * - Coordinator dispatch → rhythmic submission timestamp patterns
19
- * - Same operational pattern → homogeneous thermal transitions
20
- * - Economic throughput limit → idle durations cluster at the minimum
21
- *
22
- * Population analysis exposes these systematic correlations that are
23
- * individually invisible but statistically unmistakable at scale.
24
- *
25
- * Statistical tests (5)
26
- * ─────────────────────
27
- * 1. Timestamp rhythm — autocorrelation of inter-arrival times
28
- * 2. Entropy dispersion — coefficient of variation across physics scores
29
- * 3. Thermal diversity — Shannon entropy of thermal transition labels
30
- * 4. Idle duration cluster — fraction of durations near the minimum viable
31
- * 5. ENF phase coherence — variance of measured grid frequency deviations
32
- *
33
- * Scoring
34
- * ───────
35
- * Each test returns a sybilScore in [0, 100] where 100 = maximally suspicious.
36
- * The population sybilScore is a weighted average of the five tests.
37
- * An authentic cohort scores < 40; a clear farm scores > 70.
38
- */
39
-
40
- // ── analysePopulation ─────────────────────────────────────────────────────────
41
-
42
- /**
43
- * Analyse a cohort of parsed engagement tokens for population-level Sybil signals.
44
- *
45
- * @param {ParsedEngagementToken[]} tokens decoded (not yet signature-verified) tokens
46
- * @param {object} [opts]
47
- * @param {number} [opts.minSample=5] minimum cohort size for a meaningful verdict
48
- * @returns {PopulationVerdict}
49
- */
50
- export function analysePopulation(tokens, opts = {}) {
51
- const { minSample = 5 } = opts;
52
-
53
- if (!Array.isArray(tokens) || tokens.length < minSample) {
54
- return _insufficientSample(tokens?.length ?? 0, minSample);
55
- }
56
-
57
- const rhythmResult = testTimestampRhythm(tokens);
58
- const dispersion = testEntropyDispersion(tokens);
59
- const thermalDiv = testThermalDiversity(tokens);
60
- const idlePlaus = testIdlePlausibility(tokens);
61
- const enfCoherence = testEnfCoherence(tokens);
62
-
63
- // Weighted aggregate (weights reflect signal reliability and discriminative power)
64
- const sybilScore = Math.round(
65
- rhythmResult.score * 0.25 +
66
- dispersion.score * 0.25 +
67
- thermalDiv.score * 0.20 +
68
- idlePlaus.score * 0.20 +
69
- enfCoherence.score * 0.10
70
- );
71
-
72
- // Confidence scales with cohort size — small cohorts can't make strong claims
73
- const confidence = +Math.min(1.0, tokens.length / 50).toFixed(3);
74
-
75
- const flags = [
76
- rhythmResult.score > 60 && 'RHYTHMIC_DISPATCH',
77
- dispersion.score > 60 && 'HOMOGENEOUS_HARDWARE',
78
- thermalDiv.score > 60 && 'UNIFORM_THERMAL_PATTERN',
79
- idlePlaus.score > 60 && 'SYNTHETIC_IDLE_CLUSTERING',
80
- enfCoherence.score > 60 && 'CO_LOCATED_DEVICES',
81
- ].filter(Boolean);
82
-
83
- return {
84
- authentic: sybilScore < 40,
85
- sybilScore,
86
- confidence,
87
- tests: {
88
- timestampRhythm: rhythmResult,
89
- entropyDispersion: dispersion,
90
- thermalDiversity: thermalDiv,
91
- idlePlausibility: idlePlaus,
92
- enfCoherence,
93
- },
94
- flags,
95
- summary: _formatSummary(sybilScore, flags, tokens.length),
96
- };
97
- }
98
-
99
- // ── Test 1: Timestamp Rhythm ──────────────────────────────────────────────────
100
-
101
- /**
102
- * Farm coordinators dispatch tokens in batches at scheduler-driven intervals.
103
- * A coordinator sending 1,000 tokens at 120-second intervals produces strong
104
- * positive autocorrelation in the inter-arrival time series.
105
- *
106
- * Real users click independently; their inter-arrival times are random.
107
- *
108
- * @param {ParsedEngagementToken[]} tokens
109
- * @returns {{ score: number, ac1: number, ac2: number, detail: string }}
110
- */
111
- export function testTimestampRhythm(tokens) {
112
- const timestamps = tokens.map(t => t.iat).filter(Number.isFinite).sort((a, b) => a - b);
113
- if (timestamps.length < 4) {
114
- return { score: 0, ac1: 0, ac2: 0, detail: 'insufficient_samples' };
115
- }
116
-
117
- // Inter-arrival times in milliseconds
118
- const deltas = [];
119
- for (let i = 1; i < timestamps.length; i++) {
120
- deltas.push(timestamps[i] - timestamps[i - 1]);
121
- }
122
-
123
- const n = deltas.length;
124
- const mean = _mean(deltas);
125
- const v0 = _variance(deltas, mean);
126
-
127
- // Zero-variance: perfectly uniform dispatch — maximally suspicious
128
- if (v0 < 1e-6) {
129
- return { score: 100, ac1: 1, ac2: 1, detail: 'perfectly_uniform_dispatch' };
130
- }
131
-
132
- const ac1 = _autocorrAtLag(deltas, mean, v0, 1);
133
- const ac2 = _autocorrAtLag(deltas, mean, v0, 2);
134
-
135
- // Use the more suspicious of the two lags
136
- const signal = Math.max(Math.abs(ac1), Math.abs(ac2));
137
-
138
- // Threshold calibration:
139
- // |ac| < 0.15 → independent (real users)
140
- // |ac| > 0.40rhythmic (farm coordinator)
141
- const score = Math.min(100, Math.max(0,
142
- ((signal - 0.15) / (0.40 - 0.15)) * 100
143
- ));
144
-
145
- return {
146
- score: Math.round(score),
147
- ac1: +ac1.toFixed(3),
148
- ac2: +ac2.toFixed(3),
149
- detail: signal > 0.40 ? 'rhythmic_dispatch_detected' : 'normal_variance',
150
- };
151
- }
152
-
153
- // ── Test 2: Entropy Dispersion ────────────────────────────────────────────────
154
-
155
- /**
156
- * Real users bring diverse device histories: different ages, temperatures,
157
- * load profiles. Their physics scores (entropy, jitter) span a wide range.
158
- *
159
- * Farm devices in one warehouse are typically the same model, same ambient
160
- * temperature, running the same workload. Their scores cluster tightly.
161
- *
162
- * @param {ParsedEngagementToken[]} tokens
163
- * @returns {{ score: number, cv: number, mean: number, detail: string }}
164
- */
165
- export function testEntropyDispersion(tokens) {
166
- const scores = tokens.map(t => t.hw?.ent).filter(v => v != null && Number.isFinite(v) && v > 0);
167
- if (scores.length < 3) {
168
- return { score: 0, cv: 0, mean: 0, detail: 'no_entropy_data' };
169
- }
170
-
171
- const mean = _mean(scores);
172
- if (mean < 1e-9) return { score: 50, cv: 0, mean: 0, detail: 'zero_entropy_scores' };
173
-
174
- const stddev = Math.sqrt(_variance(scores, mean));
175
- const cv = stddev / mean; // Coefficient of Variation
176
-
177
- // Calibration:
178
- // CV > 0.12 → diverse hardware (real users)
179
- // CV < 0.04homogeneous (farm devices)
180
- const score = Math.min(100, Math.max(0,
181
- ((0.10 - cv) / (0.10 - 0.03)) * 100
182
- ));
183
-
184
- return {
185
- score: Math.round(score),
186
- cv: +cv.toFixed(4),
187
- mean: +mean.toFixed(3),
188
- detail: cv < 0.04 ? 'homogeneous_hardware_detected' : 'normal_diversity',
189
- };
190
- }
191
-
192
- // ── Test 3: Thermal Diversity ─────────────────────────────────────────────────
193
-
194
- /**
195
- * Real users encounter devices in diverse thermal states throughout the day:
196
- * cold morning wake-ups, warm post-commute picks, hot post-gaming sessions.
197
- * The population distribution across transition labels should be broad.
198
- *
199
- * Farm devices maintain constant operational throughput. Their transitions
200
- * cluster at 'step_function' (scripted pause) or 'sustained_hot' (constant load).
201
- *
202
- * @param {ParsedEngagementToken[]} tokens
203
- * @returns {{ score: number, suspiciousRatio: number, distribution: object, detail: string }}
204
- */
205
- export function testThermalDiversity(tokens) {
206
- const transitions = tokens.map(t => t.idle?.therm).filter(Boolean);
207
- if (transitions.length < 3) {
208
- return { score: 0, suspiciousRatio: 0, distribution: {}, detail: 'no_thermal_data' };
209
- }
210
-
211
- const counts = {};
212
- for (const t of transitions) counts[t] = (counts[t] ?? 0) + 1;
213
- const total = transitions.length;
214
-
215
- // Farm-indicator transitions
216
- const suspicious = (counts.step_function ?? 0) + (counts.sustained_hot ?? 0);
217
- const suspiciousRatio = suspicious / total;
218
-
219
- // Shannon entropy of the transition label distribution
220
- const probs = Object.values(counts).map(c => c / total);
221
- const popEntropy = _shannonEntropy(probs);
222
- const maxEntropy = Math.log2(6); // 6 possible labels
223
-
224
- // Combined: raw suspicious ratio + low population entropy both indicate farm
225
- const ratioScore = Math.min(100, suspiciousRatio * 100);
226
- const entropyScore = Math.min(80, (1 - popEntropy / maxEntropy) * 80);
227
- const score = Math.round(0.6 * ratioScore + 0.4 * entropyScore);
228
-
229
- const detail = suspiciousRatio > 0.50
230
- ? 'majority_farm_thermal_transitions'
231
- : popEntropy < maxEntropy * 0.40
232
- ? 'low_thermal_label_diversity'
233
- : 'normal_distribution';
234
-
235
- return {
236
- score,
237
- suspiciousRatio: +suspiciousRatio.toFixed(3),
238
- distribution: Object.fromEntries(
239
- Object.entries(counts).map(([k, v]) => [k, +(v / total).toFixed(3)])
240
- ),
241
- detail,
242
- };
243
- }
244
-
245
- // ── Test 4: Idle Duration Plausibility ───────────────────────────────────────
246
-
247
- /**
248
- * Real humans idle unpredictably: 5 minutes for a notification, an hour for
249
- * lunch, 8 hours overnight. The distribution is broad and roughly log-normal.
250
- *
251
- * Farm scripts, constrained by throughput targets, idle for exactly the
252
- * minimum duration required to pass attestation (~45–90s). The population
253
- * reveals a tight cluster just above the minimum viable threshold.
254
- *
255
- * @param {ParsedEngagementToken[]} tokens
256
- * @returns {{ score: number, clusterRatio: number, medianMs: number, detail: string }}
257
- */
258
- export function testIdlePlausibility(tokens) {
259
- const durations = tokens.map(t => t.idle?.dMs).filter(d => d != null && d > 0);
260
- if (durations.length < 3) {
261
- return { score: 0, clusterRatio: 0, medianMs: 0, detail: 'no_idle_data' };
262
- }
263
-
264
- // The "barely made it" window: idle just long enough to pass, then immediately resume
265
- const CLUSTER_LO = 45_000; // MIN_IDLE_MS
266
- const CLUSTER_HI = 100_000; // 2.2× minimum — beyond this, farms lose too much throughput
267
-
268
- const inCluster = durations.filter(d => d >= CLUSTER_LO && d <= CLUSTER_HI).length;
269
- const clusterRatio = inCluster / durations.length;
270
-
271
- // Low coefficient of variation = durations are suspiciously uniform
272
- const mean = _mean(durations);
273
- const stddev = Math.sqrt(_variance(durations, mean));
274
- const cv = stddev / (mean + 1);
275
-
276
- // Scoring: high cluster ratio + low duration CV = farm fingerprint
277
- const clusterScore = Math.min(70, Math.max(0, (clusterRatio - 0.40) / (0.80 - 0.40)) * 70);
278
- const cvScore = Math.min(30, Math.max(0, (0.50 - cv) / (0.50 - 0.10)) * 30);
279
- const score = Math.round(clusterScore + cvScore);
280
-
281
- return {
282
- score,
283
- clusterRatio: +clusterRatio.toFixed(3),
284
- medianMs: _median(durations),
285
- detail: clusterRatio > 0.70 ? 'idle_duration_clustered_at_minimum' : 'normal_distribution',
286
- };
287
- }
288
-
289
- // ── Test 5: ENF Phase Coherence ───────────────────────────────────────────────
290
-
291
- /**
292
- * All devices on the same electrical circuit share the same ENF phase.
293
- * A 1,000-device farm in a single warehouse shares one power feed — their
294
- * ENF frequency deviations are nearly identical.
295
- *
296
- * Real users across a city are on separate circuits with independent phase
297
- * evolution; their deviations spread naturally over a measurable range.
298
- *
299
- * @param {ParsedEngagementToken[]} tokens
300
- * @returns {{ score: number, phaseVariance: number|null, detail: string }}
301
- */
302
- export function testEnfCoherence(tokens) {
303
- const deviations = tokens
304
- .map(t => t.hw?.enfDev)
305
- .filter(d => d != null && Number.isFinite(d));
306
-
307
- if (deviations.length < 4) {
308
- return { score: 0, phaseVariance: null, detail: 'enf_unavailable_or_insufficient' };
309
- }
310
-
311
- const mean = _mean(deviations);
312
- const variance = _variance(deviations, mean);
313
-
314
- // Calibration (Hz² variance):
315
- // variance > 0.0002 → diverse circuits (real users across a city)
316
- // variance < 0.00002 same rack, same feed (co-located farm)
317
- const score = Math.round(Math.min(100, Math.max(0,
318
- (1 - variance / 0.0002) * 100
319
- )));
320
-
321
- return {
322
- score,
323
- phaseVariance: +variance.toFixed(8),
324
- detail: variance < 0.00002 ? 'co_located_devices_same_circuit' : 'normal_phase_spread',
325
- };
326
- }
327
-
328
- // ── Internal helpers ──────────────────────────────────────────────────────────
329
-
330
- function _mean(arr) {
331
- if (!arr.length) return 0;
332
- return arr.reduce((s, v) => s + v, 0) / arr.length;
333
- }
334
-
335
- function _variance(arr, mean) {
336
- if (arr.length < 2) return 0;
337
- return arr.reduce((s, v) => s + (v - mean) ** 2, 0) / arr.length;
338
- }
339
-
340
- function _median(arr) {
341
- const s = [...arr].sort((a, b) => a - b);
342
- const mid = Math.floor(s.length / 2);
343
- return s.length % 2 ? s[mid] : Math.round((s[mid - 1] + s[mid]) / 2);
344
- }
345
-
346
- function _autocorrAtLag(data, mean, variance, lag) {
347
- const n = data.length;
348
- if (lag >= n || variance < 1e-12) return 0;
349
- let cov = 0;
350
- for (let i = 0; i < n - lag; i++) {
351
- cov += (data[i] - mean) * (data[i + lag] - mean);
352
- }
353
- return cov / ((n - lag) * variance);
354
- }
355
-
356
- function _shannonEntropy(probs) {
357
- return -probs
358
- .filter(p => p > 0)
359
- .reduce((s, p) => s + p * Math.log2(p), 0);
360
- }
361
-
362
- function _formatSummary(score, flags, n) {
363
- const risk = score >= 80 ? 'HIGH_RISK'
364
- : score >= 50 ? 'SUSPICIOUS'
365
- : score >= 30 ? 'MARGINAL'
366
- : 'CLEAN';
367
- const flagStr = flags.length ? ` flags=[${flags.join(',')}]` : '';
368
- return `${risk}: sybilScore=${score}/100 n=${n}${flagStr}`;
369
- }
370
-
371
- function _insufficientSample(n, required) {
372
- return {
373
- authentic: true, // default to not blocking on insufficient data
374
- sybilScore: 0,
375
- confidence: 0,
376
- tests: {},
377
- flags: [],
378
- summary: `INSUFFICIENT_SAMPLE: ${n}/${required} tokens required for analysis`,
379
- };
380
- }
381
-
382
- // ── JSDoc types ───────────────────────────────────────────────────────────────
383
-
384
- /**
385
- * @typedef {object} ParsedEngagementToken
386
- * @property {number} iat issued-at Unix ms
387
- * @property {object} [idle] idle proof summary
388
- * @property {number|null} [idle.dMs] idle duration ms
389
- * @property {string} [idle.therm] thermal transition label
390
- * @property {object} [hw] hardware signal summary
391
- * @property {number} [hw.ent] normalized entropy score (0–1)
392
- * @property {number} [hw.enfDev] ENF frequency deviation (Hz)
393
- */
394
-
395
- /**
396
- * @typedef {object} PopulationVerdict
397
- * @property {boolean} authentic true if population appears legitimate (sybilScore < 40)
398
- * @property {number} sybilScore 0–100 suspicion score (higher = more suspicious)
399
- * @property {number} confidence 0–1 confidence in verdict (scales with cohort size)
400
- * @property {object} tests per-test result objects
401
- * @property {string[]} flags fired detection flags
402
- * @property {string} summary one-line human-readable summary
403
- */
1
+ /**
2
+ * @svrnsec/pulse — Population Entropy Analysis
3
+ *
4
+ * Server-side Sybil detector: given N engagement tokens from the same cohort
5
+ * (e.g., all ad clicks in a 10-minute window), determine whether they
6
+ * represent independently-operated consumer devices or a coordinated farm.
7
+ *
8
+ * Why per-token verification is necessary but not sufficient
9
+ * ──────────────────────────────────────────────────────────
10
+ * A single token from a farm device may pass every individual check:
11
+ * - Real hardware ✓ (it is a real phone)
12
+ * - Valid idle proof ✓ (45+ seconds of forced downtime)
13
+ * - Plausible entropy ✓ (within normal ranges)
14
+ *
15
+ * But 1,000 farm devices in one warehouse share inescapable physical context:
16
+ * - Same ambient temperature → correlated DRAM timing variance
17
+ * - Same power circuit → near-identical ENF phase deviation
18
+ * - Coordinator dispatch → rhythmic submission timestamp patterns
19
+ * - Same operational pattern → homogeneous thermal transitions
20
+ * - Economic throughput limit → idle durations cluster at the minimum
21
+ *
22
+ * Population analysis exposes these systematic correlations that are
23
+ * individually invisible but statistically unmistakable at scale.
24
+ *
25
+ * Statistical tests (5)
26
+ * ─────────────────────
27
+ * 1. Timestamp rhythm — autocorrelation of inter-arrival times
28
+ * 2. Entropy dispersion — coefficient of variation across physics scores
29
+ * 3. Thermal diversity — Shannon entropy of thermal transition labels
30
+ * 4. Idle duration cluster — fraction of durations near the minimum viable
31
+ * 5. ENF phase coherence — variance of measured grid frequency deviations
32
+ *
33
+ * Scoring
34
+ * ───────
35
+ * Each test returns a sybilScore in [0, 100] where 100 = maximally suspicious.
36
+ * The population sybilScore is a weighted average of the five tests.
37
+ * An authentic cohort scores < 40; a clear farm scores > 70.
38
+ */
39
+
40
+ // ── analysePopulation ─────────────────────────────────────────────────────────
41
+
42
+ /**
43
+ * Analyse a cohort of parsed engagement tokens for population-level Sybil signals.
44
+ *
45
+ * @param {ParsedEngagementToken[]} tokens decoded (not yet signature-verified) tokens
46
+ * @param {object} [opts]
47
+ * @param {number} [opts.minSample=5] minimum cohort size for a meaningful verdict
48
+ * @returns {PopulationVerdict}
49
+ */
50
+ export function analysePopulation(tokens, opts = {}) {
51
+ const { minSample = 5 } = opts;
52
+
53
+ if (!Array.isArray(tokens) || tokens.length < minSample) {
54
+ return _insufficientSample(tokens?.length ?? 0, minSample);
55
+ }
56
+
57
+ const rhythmResult = testTimestampRhythm(tokens);
58
+ const dispersion = testEntropyDispersion(tokens);
59
+ const thermalDiv = testThermalDiversity(tokens);
60
+ const idlePlaus = testIdlePlausibility(tokens);
61
+ const enfCoherence = testEnfCoherence(tokens);
62
+
63
+ // Weighted aggregate (weights reflect signal reliability and discriminative power)
64
+ const sybilScore = Math.round(
65
+ rhythmResult.score * 0.25 +
66
+ dispersion.score * 0.25 +
67
+ thermalDiv.score * 0.20 +
68
+ idlePlaus.score * 0.20 +
69
+ enfCoherence.score * 0.10
70
+ );
71
+
72
+ // Confidence scales with cohort size — small cohorts can't make strong claims
73
+ const confidence = +Math.min(1.0, tokens.length / 50).toFixed(3);
74
+
75
+ const flags = [
76
+ rhythmResult.score > 60 && 'RHYTHMIC_DISPATCH',
77
+ dispersion.score > 60 && 'HOMOGENEOUS_HARDWARE',
78
+ thermalDiv.score > 60 && 'UNIFORM_THERMAL_PATTERN',
79
+ idlePlaus.score > 60 && 'SYNTHETIC_IDLE_CLUSTERING',
80
+ enfCoherence.score > 60 && 'CO_LOCATED_DEVICES',
81
+ ].filter(Boolean);
82
+
83
+ return {
84
+ authentic: sybilScore < 40,
85
+ sybilScore,
86
+ confidence,
87
+ tests: {
88
+ timestampRhythm: rhythmResult,
89
+ entropyDispersion: dispersion,
90
+ thermalDiversity: thermalDiv,
91
+ idlePlausibility: idlePlaus,
92
+ enfCoherence,
93
+ },
94
+ flags,
95
+ summary: _formatSummary(sybilScore, flags, tokens.length),
96
+ };
97
+ }
98
+
99
+ // ── Test 1: Timestamp Rhythm ──────────────────────────────────────────────────
100
+
101
+ /**
102
+ * Farm coordinators dispatch tokens in batches at scheduler-driven intervals.
103
+ * A coordinator sending 1,000 tokens at 120-second intervals produces strong
104
+ * positive autocorrelation in the inter-arrival time series.
105
+ *
106
+ * Real users click independently; their inter-arrival times are random.
107
+ *
108
+ * @param {ParsedEngagementToken[]} tokens
109
+ * @returns {{ score: number, ac1: number, ac2: number, detail: string }}
110
+ */
111
+ export function testTimestampRhythm(tokens) {
112
+ const timestamps = tokens.map(t => t.iat).filter(Number.isFinite).sort((a, b) => a - b);
113
+ if (timestamps.length < 4) {
114
+ return { score: 0, ac1: 0, ac2: 0, detail: 'insufficient_samples' };
115
+ }
116
+
117
+ // Inter-arrival times in milliseconds
118
+ const deltas = [];
119
+ for (let i = 1; i < timestamps.length; i++) {
120
+ deltas.push(timestamps[i] - timestamps[i - 1]);
121
+ }
122
+
123
+ const n = deltas.length;
124
+ const mean = _mean(deltas);
125
+ const v0 = _variance(deltas, mean);
126
+
127
+ // Zero-variance: perfectly uniform dispatch — maximally suspicious
128
+ if (v0 < 1e-6) {
129
+ return { score: 100, ac1: 1, ac2: 1, detail: 'perfectly_uniform_dispatch' };
130
+ }
131
+
132
+ const ac1 = _autocorrAtLag(deltas, mean, v0, 1);
133
+ const ac2 = _autocorrAtLag(deltas, mean, v0, 2);
134
+
135
+ // Both positive autocorrelation (rhythmic dispatch from farm coordinator) and negative autocorrelation (perfectly alternating patterns) are treated as suspicious signals.
136
+ // Use the more suspicious of the two lags
137
+ const signal = Math.max(Math.abs(ac1), Math.abs(ac2));
138
+
139
+ // Threshold calibration:
140
+ // |ac| < 0.15independent (real users)
141
+ // |ac| > 0.40 → rhythmic (farm coordinator)
142
+ const score = Math.min(100, Math.max(0,
143
+ ((signal - 0.15) / (0.40 - 0.15)) * 100
144
+ ));
145
+
146
+ return {
147
+ score: Math.round(score),
148
+ ac1: +ac1.toFixed(3),
149
+ ac2: +ac2.toFixed(3),
150
+ detail: signal > 0.40 ? 'rhythmic_dispatch_detected' : 'normal_variance',
151
+ };
152
+ }
153
+
154
+ // ── Test 2: Entropy Dispersion ────────────────────────────────────────────────
155
+
156
+ /**
157
+ * Real users bring diverse device histories: different ages, temperatures,
158
+ * load profiles. Their physics scores (entropy, jitter) span a wide range.
159
+ *
160
+ * Farm devices in one warehouse are typically the same model, same ambient
161
+ * temperature, running the same workload. Their scores cluster tightly.
162
+ *
163
+ * @param {ParsedEngagementToken[]} tokens
164
+ * @returns {{ score: number, cv: number, mean: number, detail: string }}
165
+ */
166
+ export function testEntropyDispersion(tokens) {
167
+ const scores = tokens.map(t => t.hw?.ent).filter(v => v != null && Number.isFinite(v) && v > 0);
168
+ if (scores.length < 3) {
169
+ return { score: 0, cv: 0, mean: 0, detail: 'no_entropy_data' };
170
+ }
171
+
172
+ const mean = _mean(scores);
173
+ if (mean < 1e-9) return { score: 50, cv: 0, mean: 0, detail: 'zero_entropy_scores' };
174
+
175
+ const stddev = Math.sqrt(_variance(scores, mean));
176
+ const cv = stddev / mean; // Coefficient of Variation
177
+
178
+ // Calibration:
179
+ // CV > 0.12diverse hardware (real users)
180
+ // CV < 0.04 → homogeneous (farm devices)
181
+ const score = Math.min(100, Math.max(0,
182
+ ((0.10 - cv) / (0.10 - 0.03)) * 100
183
+ ));
184
+
185
+ return {
186
+ score: Math.round(score),
187
+ cv: +cv.toFixed(4),
188
+ mean: +mean.toFixed(3),
189
+ detail: cv < 0.04 ? 'homogeneous_hardware_detected' : 'normal_diversity',
190
+ };
191
+ }
192
+
193
+ // ── Test 3: Thermal Diversity ─────────────────────────────────────────────────
194
+
195
+ /**
196
+ * Real users encounter devices in diverse thermal states throughout the day:
197
+ * cold morning wake-ups, warm post-commute picks, hot post-gaming sessions.
198
+ * The population distribution across transition labels should be broad.
199
+ *
200
+ * Farm devices maintain constant operational throughput. Their transitions
201
+ * cluster at 'step_function' (scripted pause) or 'sustained_hot' (constant load).
202
+ *
203
+ * @param {ParsedEngagementToken[]} tokens
204
+ * @returns {{ score: number, suspiciousRatio: number, distribution: object, detail: string }}
205
+ */
206
+ export function testThermalDiversity(tokens) {
207
+ const transitions = tokens.map(t => t.idle?.therm).filter(Boolean);
208
+ if (transitions.length < 3) {
209
+ return { score: 0, suspiciousRatio: 0, distribution: {}, detail: 'no_thermal_data' };
210
+ }
211
+
212
+ const counts = {};
213
+ for (const t of transitions) counts[t] = (counts[t] ?? 0) + 1;
214
+ const total = transitions.length;
215
+
216
+ // Farm-indicator transitions
217
+ const suspicious = (counts.step_function ?? 0) + (counts.sustained_hot ?? 0);
218
+ const suspiciousRatio = suspicious / total;
219
+
220
+ // Shannon entropy of the transition label distribution
221
+ const probs = Object.values(counts).map(c => c / total);
222
+ const popEntropy = _shannonEntropy(probs);
223
+ const maxEntropy = Math.log2(6); // 6 possible labels
224
+
225
+ // Combined: raw suspicious ratio + low population entropy both indicate farm
226
+ const ratioScore = Math.min(100, suspiciousRatio * 100);
227
+ const entropyScore = Math.min(80, (1 - popEntropy / maxEntropy) * 80);
228
+ const score = Math.round(0.6 * ratioScore + 0.4 * entropyScore);
229
+
230
+ const detail = suspiciousRatio > 0.50
231
+ ? 'majority_farm_thermal_transitions'
232
+ : popEntropy < maxEntropy * 0.40
233
+ ? 'low_thermal_label_diversity'
234
+ : 'normal_distribution';
235
+
236
+ return {
237
+ score,
238
+ suspiciousRatio: +suspiciousRatio.toFixed(3),
239
+ distribution: Object.fromEntries(
240
+ Object.entries(counts).map(([k, v]) => [k, +(v / total).toFixed(3)])
241
+ ),
242
+ detail,
243
+ };
244
+ }
245
+
246
+ // ── Test 4: Idle Duration Plausibility ───────────────────────────────────────
247
+
248
+ /**
249
+ * Real humans idle unpredictably: 5 minutes for a notification, an hour for
250
+ * lunch, 8 hours overnight. The distribution is broad and roughly log-normal.
251
+ *
252
+ * Farm scripts, constrained by throughput targets, idle for exactly the
253
+ * minimum duration required to pass attestation (~45–90s). The population
254
+ * reveals a tight cluster just above the minimum viable threshold.
255
+ *
256
+ * @param {ParsedEngagementToken[]} tokens
257
+ * @returns {{ score: number, clusterRatio: number, medianMs: number, detail: string }}
258
+ */
259
+ export function testIdlePlausibility(tokens) {
260
+ const durations = tokens.map(t => t.idle?.dMs).filter(d => d != null && d > 0);
261
+ if (durations.length < 3) {
262
+ return { score: 0, clusterRatio: 0, medianMs: 0, detail: 'no_idle_data' };
263
+ }
264
+
265
+ // The "barely made it" window: idle just long enough to pass, then immediately resume
266
+ const CLUSTER_LO = 45_000; // MIN_IDLE_MS
267
+ const CLUSTER_HI = 100_000; // 2.2× minimum — beyond this, farms lose too much throughput
268
+
269
+ const inCluster = durations.filter(d => d >= CLUSTER_LO && d <= CLUSTER_HI).length;
270
+ const clusterRatio = inCluster / durations.length;
271
+
272
+ // Low coefficient of variation = durations are suspiciously uniform
273
+ const mean = _mean(durations);
274
+ const stddev = Math.sqrt(_variance(durations, mean));
275
+ const cv = stddev / (mean + 1);
276
+
277
+ // Scoring: high cluster ratio + low duration CV = farm fingerprint
278
+ const clusterScore = Math.min(70, Math.max(0, (clusterRatio - 0.40) / (0.80 - 0.40)) * 70);
279
+ const cvScore = Math.min(30, Math.max(0, (0.50 - cv) / (0.50 - 0.10)) * 30);
280
+ const score = Math.round(clusterScore + cvScore);
281
+
282
+ return {
283
+ score,
284
+ clusterRatio: +clusterRatio.toFixed(3),
285
+ medianMs: _median(durations),
286
+ detail: clusterRatio > 0.70 ? 'idle_duration_clustered_at_minimum' : 'normal_distribution',
287
+ };
288
+ }
289
+
290
+ // ── Test 5: ENF Phase Coherence ───────────────────────────────────────────────
291
+
292
+ /**
293
+ * All devices on the same electrical circuit share the same ENF phase.
294
+ * A 1,000-device farm in a single warehouse shares one power feed — their
295
+ * ENF frequency deviations are nearly identical.
296
+ *
297
+ * Real users across a city are on separate circuits with independent phase
298
+ * evolution; their deviations spread naturally over a measurable range.
299
+ *
300
+ * @param {ParsedEngagementToken[]} tokens
301
+ * @returns {{ score: number, phaseVariance: number|null, detail: string }}
302
+ */
303
+ export function testEnfCoherence(tokens) {
304
+ const deviations = tokens
305
+ .map(t => t.hw?.enfDev)
306
+ .filter(d => d != null && Number.isFinite(d));
307
+
308
+ if (deviations.length < 4) {
309
+ return { score: 0, phaseVariance: null, detail: 'enf_unavailable_or_insufficient' };
310
+ }
311
+
312
+ const mean = _mean(deviations);
313
+ const variance = _variance(deviations, mean);
314
+
315
+ // Calibration (Hz² variance):
316
+ // variance > 0.0002 diverse circuits (real users across a city)
317
+ // variance < 0.00002 → same rack, same feed (co-located farm)
318
+ const score = Math.round(Math.min(100, Math.max(0,
319
+ (1 - variance / 0.0002) * 100
320
+ )));
321
+
322
+ return {
323
+ score,
324
+ phaseVariance: +variance.toFixed(8),
325
+ detail: variance < 0.00002 ? 'co_located_devices_same_circuit' : 'normal_phase_spread',
326
+ };
327
+ }
328
+
329
+ // ── Internal helpers ──────────────────────────────────────────────────────────
330
+
331
+ function _mean(arr) {
332
+ if (!arr.length) return 0;
333
+ return arr.reduce((s, v) => s + v, 0) / arr.length;
334
+ }
335
+
336
+ function _variance(arr, mean) {
337
+ if (arr.length < 2) return 0;
338
+ return arr.reduce((s, v) => s + (v - mean) ** 2, 0) / arr.length;
339
+ }
340
+
341
+ function _median(arr) {
342
+ const s = [...arr].sort((a, b) => a - b);
343
+ const mid = Math.floor(s.length / 2);
344
+ return s.length % 2 ? s[mid] : Math.round((s[mid - 1] + s[mid]) / 2);
345
+ }
346
+
347
+ function _autocorrAtLag(data, mean, variance, lag) {
348
+ const n = data.length;
349
+ if (lag >= n || variance < 1e-12) return 0;
350
+ let cov = 0;
351
+ for (let i = 0; i < n - lag; i++) {
352
+ cov += (data[i] - mean) * (data[i + lag] - mean);
353
+ }
354
+ return cov / ((n - lag) * variance);
355
+ }
356
+
357
+ function _shannonEntropy(probs) {
358
+ return -probs
359
+ .filter(p => p > 0)
360
+ .reduce((s, p) => s + p * Math.log2(p), 0);
361
+ }
362
+
363
+ function _formatSummary(score, flags, n) {
364
+ const risk = score >= 80 ? 'HIGH_RISK'
365
+ : score >= 50 ? 'SUSPICIOUS'
366
+ : score >= 30 ? 'MARGINAL'
367
+ : 'CLEAN';
368
+ const flagStr = flags.length ? ` flags=[${flags.join(',')}]` : '';
369
+ return `${risk}: sybilScore=${score}/100 n=${n}${flagStr}`;
370
+ }
371
+
372
+ function _insufficientSample(n, required) {
373
+ return {
374
+ authentic: true, // default to not blocking on insufficient data
375
+ sybilScore: 0,
376
+ confidence: 0,
377
+ tests: {},
378
+ flags: [],
379
+ summary: `INSUFFICIENT_SAMPLE: ${n}/${required} tokens required for analysis`,
380
+ };
381
+ }
382
+
383
+ // ── JSDoc types ───────────────────────────────────────────────────────────────
384
+
385
+ /**
386
+ * @typedef {object} ParsedEngagementToken
387
+ * @property {number} iat issued-at Unix ms
388
+ * @property {object} [idle] idle proof summary
389
+ * @property {number|null} [idle.dMs] idle duration ms
390
+ * @property {string} [idle.therm] thermal transition label
391
+ * @property {object} [hw] hardware signal summary
392
+ * @property {number} [hw.ent] normalized entropy score (0–1)
393
+ * @property {number} [hw.enfDev] ENF frequency deviation (Hz)
394
+ */
395
+
396
+ /**
397
+ * @typedef {object} PopulationVerdict
398
+ * @property {boolean} authentic true if population appears legitimate (sybilScore < 40)
399
+ * @property {number} sybilScore 0–100 suspicion score (higher = more suspicious)
400
+ * @property {number} confidence 0–1 confidence in verdict (scales with cohort size)
401
+ * @property {object} tests per-test result objects
402
+ * @property {string[]} flags fired detection flags
403
+ * @property {string} summary one-line human-readable summary
404
+ */