@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,248 +1,248 @@
1
- /**
2
- * @sovereign/pulse — Hypervisor & Cloud Provider Fingerprinter
3
- *
4
- * Each hypervisor has a distinct "steal-time rhythm" — a characteristic
5
- * pattern in how it schedules guest vCPUs on host physical cores.
6
- * This creates detectable signatures in the timing autocorrelation profile.
7
- *
8
- * Think of it like a heartbeat EKG:
9
- * KVM → regular 50-iteration bursts (~250ms quantum at 5ms/iter)
10
- * Xen → longer 150-iteration bursts (~750ms credit scheduler quantum)
11
- * VMware → irregular bursts, memory balloon noise
12
- * Hyper-V → 78-iteration bursts (~390ms at 5ms/iter, 15.6ms quantum)
13
- * Nitro → almost none — SR-IOV passthrough is nearly invisible
14
- * Physical → no rhythm at all
15
- *
16
- * Canvas renderer strings give a second, independent signal that we cross-
17
- * reference to increase confidence in the provider classification.
18
- */
19
-
20
- // ---------------------------------------------------------------------------
21
- // Provider profile database
22
- // ---------------------------------------------------------------------------
23
- // Each profile is calibrated from real benchmark data.
24
- // Fields: lag1_range, lag50_range, qe_range, cv_range, renderer_hints
25
-
26
- const PROVIDER_PROFILES = [
27
- {
28
- id: 'physical',
29
- label: 'Physical Hardware',
30
- profile: 'analog-fog',
31
- confidence: 0, // set dynamically
32
- match: ({ lag1, lag50, qe, cv, entropyJitterRatio, isSoftwareRenderer }) =>
33
- !isSoftwareRenderer &&
34
- Math.abs(lag1) < 0.20 &&
35
- Math.abs(lag50) < 0.15 &&
36
- qe > 3.0 &&
37
- cv > 0.06 &&
38
- (entropyJitterRatio === null || entropyJitterRatio >= 1.02),
39
- },
40
- {
41
- id: 'kvm-generic',
42
- label: 'KVM Hypervisor (generic)',
43
- profile: 'picket-fence',
44
- match: ({ lag1, lag50, qe, cv }) =>
45
- lag1 > 0.40 && qe < 2.5 && cv < 0.15 && Math.abs(lag50) > 0.25,
46
- providerHints: ['digitalocean', 'linode', 'vultr', 'hetzner', 'ovh'],
47
- },
48
- {
49
- id: 'kvm-digitalocean',
50
- label: 'DigitalOcean Droplet (KVM)',
51
- profile: 'picket-fence',
52
- match: ({ lag1, lag50, qe, cv, rendererHints }) =>
53
- lag1 > 0.55 && qe < 2.0 && cv < 0.12 &&
54
- (rendererHints.some(r => ['llvmpipe', 'virtio', 'qxl'].includes(r)) ||
55
- lag50 > 0.30),
56
- },
57
- {
58
- id: 'kvm-aws-ec2-xen',
59
- label: 'AWS EC2 (Xen/older generation)',
60
- profile: 'picket-fence',
61
- // Xen credit scheduler has longer period (~150 iters)
62
- match: ({ lag1, lag25, lag50, qe, cv }) =>
63
- qe < 2.2 && cv < 0.13 &&
64
- lag25 > 0.20 && lag50 > 0.20 &&
65
- lag1 < 0.50, // lag-1 less pronounced than KVM
66
- },
67
- {
68
- id: 'nitro-aws',
69
- label: 'AWS EC2 Nitro (near-baremetal)',
70
- profile: 'near-physical',
71
- // Nitro uses SR-IOV and dedicated hardware — steal-time is very low.
72
- // Looks almost physical but canvas renderer gives it away.
73
- match: ({ lag1, lag50, qe, cv, isSoftwareRenderer, rendererHints }) =>
74
- qe > 2.5 && cv > 0.05 &&
75
- lag1 < 0.25 && lag50 < 0.20 &&
76
- (isSoftwareRenderer ||
77
- rendererHints.some(r => r.includes('nvidia t4') || r.includes('nvidia a10'))),
78
- },
79
- {
80
- id: 'vmware-esxi',
81
- label: 'VMware ESXi',
82
- profile: 'burst-scheduler',
83
- // VMware balloon driver creates irregular memory pressure bursts
84
- match: ({ lag1, lag50, qe, cv, rendererHints }) =>
85
- qe < 2.5 &&
86
- (rendererHints.some(r => r.includes('vmware')) ||
87
- (lag1 > 0.30 && lag50 < lag1 * 0.7 && cv < 0.14)),
88
- },
89
- {
90
- id: 'hyperv',
91
- label: 'Microsoft Hyper-V',
92
- profile: 'picket-fence',
93
- // 15.6ms scheduler quantum → burst every ~78 iters
94
- match: ({ lag1, lag25, qe, cv, rendererHints }) =>
95
- qe < 2.3 &&
96
- (rendererHints.some(r => r.includes('microsoft basic render') || r.includes('warp')) ||
97
- (lag25 > 0.25 && lag1 > 0.35 && cv < 0.12)),
98
- },
99
- {
100
- id: 'gcp-kvm',
101
- label: 'Google Cloud (KVM)',
102
- profile: 'picket-fence',
103
- match: ({ lag1, lag50, qe, cv, rendererHints }) =>
104
- qe < 2.3 && lag1 > 0.45 &&
105
- (rendererHints.some(r => r.includes('swiftshader') || r.includes('google')) ||
106
- (lag50 > 0.28 && cv < 0.11)),
107
- },
108
- {
109
- id: 'gh200-datacenter',
110
- label: 'NVIDIA GH200 / HPC Datacenter',
111
- profile: 'hypervisor-flat',
112
- // Even with massive compute, still trapped by hypervisor clock.
113
- // GH200 shows near-zero Hurst (extreme quantization) + very high lag-1.
114
- match: ({ lag1, qe, hurst, cv, rendererHints }) =>
115
- (rendererHints.some(r => r.includes('gh200') || r.includes('grace hopper') ||
116
- r.includes('nvidia a100') || r.includes('nvidia h100')) ||
117
- (hurst < 0.10 && lag1 > 0.60 && qe < 1.8 && cv < 0.10)),
118
- },
119
- {
120
- id: 'generic-vm',
121
- label: 'Virtual Machine (unclassified)',
122
- profile: 'picket-fence',
123
- match: ({ lag1, qe, cv, isSoftwareRenderer }) =>
124
- isSoftwareRenderer ||
125
- (qe < 2.0 && lag1 > 0.35) ||
126
- (cv < 0.02),
127
- },
128
- ];
129
-
130
- // ---------------------------------------------------------------------------
131
- // detectProvider
132
- // ---------------------------------------------------------------------------
133
-
134
- /**
135
- * Classifies the host environment based on timing + canvas signals.
136
- *
137
- * @param {object} p
138
- * @param {import('./jitter.js').JitterAnalysis} p.jitter
139
- * @param {object} p.autocorrelations - extended lags including lag25, lag50
140
- * @param {import('../collector/canvas.js').CanvasFingerprint} p.canvas
141
- * @param {object|null} p.phases
142
- * @returns {ProviderResult}
143
- */
144
- export function detectProvider({ jitter, autocorrelations, canvas, phases }) {
145
- const rendererHints = _rendererHints(canvas?.webglRenderer, canvas?.webglVendor);
146
-
147
- const signals = {
148
- lag1: Math.abs(autocorrelations?.lag1 ?? 0),
149
- lag25: Math.abs(autocorrelations?.lag25 ?? 0),
150
- lag50: Math.abs(autocorrelations?.lag50 ?? 0),
151
- qe: jitter.quantizationEntropy,
152
- cv: jitter.stats?.cv ?? 0,
153
- hurst: jitter.hurstExponent ?? 0.5,
154
- isSoftwareRenderer: canvas?.isSoftwareRenderer ?? false,
155
- rendererHints,
156
- entropyJitterRatio: phases?.entropyJitterRatio ?? null,
157
- };
158
-
159
- // Score each profile and pick the best match
160
- const scored = PROVIDER_PROFILES
161
- .filter(p => {
162
- try { return p.match(signals); }
163
- catch { return false; }
164
- })
165
- .map(p => ({
166
- ...p,
167
- // Physical hardware is the last resort; give it lower priority when
168
- // other profiles match so we don't misclassify VMs.
169
- priority: p.id === 'physical' ? 0 : 1,
170
- }))
171
- .sort((a, b) => b.priority - a.priority);
172
-
173
- const best = scored[0] ?? { id: 'unknown', label: 'Unknown', profile: 'unknown' };
174
-
175
- // Confidence: how many "VM indicator" thresholds the signals cross
176
- const vmIndicatorCount = [
177
- signals.qe < 2.5,
178
- signals.lag1 > 0.35,
179
- signals.lag50 > 0.20,
180
- signals.cv < 0.04,
181
- signals.isSoftwareRenderer,
182
- signals.hurst < 0.15,
183
- phases?.entropyJitterRatio != null && phases.entropyJitterRatio < 1.02,
184
- ].filter(Boolean).length;
185
-
186
- const isPhysical = best.id === 'physical';
187
- const confidence = isPhysical
188
- ? Math.max(20, 95 - vmIndicatorCount * 15)
189
- : Math.min(95, 40 + vmIndicatorCount * 12);
190
-
191
- return {
192
- providerId: best.id,
193
- providerLabel: best.label,
194
- profile: best.profile,
195
- confidence,
196
- isVirtualized: best.id !== 'physical',
197
- signals,
198
- alternatives: scored.slice(1, 3).map(p => ({ id: p.id, label: p.label })),
199
- rendererHints,
200
- schedulerQuantumMs: _estimateQuantum(signals),
201
- };
202
- }
203
-
204
- /**
205
- * @typedef {object} ProviderResult
206
- * @property {string} providerId
207
- * @property {string} providerLabel
208
- * @property {string} profile 'analog-fog' | 'picket-fence' | 'burst-scheduler' | 'near-physical' | 'hypervisor-flat' | 'unknown'
209
- * @property {number} confidence 0–100
210
- * @property {boolean} isVirtualized
211
- * @property {object} signals
212
- * @property {object[]} alternatives
213
- * @property {string[]} rendererHints
214
- * @property {number|null} schedulerQuantumMs
215
- */
216
-
217
- // ---------------------------------------------------------------------------
218
- // Internal helpers
219
- // ---------------------------------------------------------------------------
220
-
221
- /**
222
- * Extract lowercase hint tokens from WebGL renderer string for pattern matching.
223
- */
224
- function _rendererHints(renderer = '', vendor = '') {
225
- return `${renderer} ${vendor}`.toLowerCase()
226
- .split(/[\s\/(),]+/)
227
- .filter(t => t.length > 2);
228
- }
229
-
230
- /**
231
- * Estimate the hypervisor's scheduler quantum from the dominant autocorrelation lag.
232
- * Returns null if the device appears to be physical.
233
- */
234
- function _estimateQuantum({ lag1, lag25, lag50, qe }) {
235
- if (qe > 3.2) return null; // likely physical
236
-
237
- // Find the dominant lag (highest absolute autocorrelation beyond lag-5)
238
- const lags = [
239
- { lag: 50, ac: lag50 },
240
- { lag: 25, ac: lag25 },
241
- ];
242
- const peak = lags.reduce((b, c) => c.ac > b.ac ? c : b, { lag: 0, ac: 0 });
243
-
244
- if (peak.ac < 0.20) return null;
245
-
246
- // Quantum (ms) ≈ dominant_lag × estimated_iteration_time (≈5ms)
247
- return peak.lag * 5;
248
- }
1
+ /**
2
+ * @svrnsec/pulse — Hypervisor & Cloud Provider Fingerprinter
3
+ *
4
+ * Each hypervisor has a distinct "steal-time rhythm" — a characteristic
5
+ * pattern in how it schedules guest vCPUs on host physical cores.
6
+ * This creates detectable signatures in the timing autocorrelation profile.
7
+ *
8
+ * Think of it like a heartbeat EKG:
9
+ * KVM → regular 50-iteration bursts (~250ms quantum at 5ms/iter)
10
+ * Xen → longer 150-iteration bursts (~750ms credit scheduler quantum)
11
+ * VMware → irregular bursts, memory balloon noise
12
+ * Hyper-V → 78-iteration bursts (~390ms at 5ms/iter, 15.6ms quantum)
13
+ * Nitro → almost none — SR-IOV passthrough is nearly invisible
14
+ * Physical → no rhythm at all
15
+ *
16
+ * Canvas renderer strings give a second, independent signal that we cross-
17
+ * reference to increase confidence in the provider classification.
18
+ */
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Provider profile database
22
+ // ---------------------------------------------------------------------------
23
+ // Each profile is calibrated from real benchmark data.
24
+ // Fields: lag1_range, lag50_range, qe_range, cv_range, renderer_hints
25
+
26
+ const PROVIDER_PROFILES = [
27
+ {
28
+ id: 'physical',
29
+ label: 'Physical Hardware',
30
+ profile: 'analog-fog',
31
+ confidence: 0, // set dynamically
32
+ match: ({ lag1, lag50, qe, cv, entropyJitterRatio, isSoftwareRenderer }) =>
33
+ !isSoftwareRenderer &&
34
+ Math.abs(lag1) < 0.20 &&
35
+ Math.abs(lag50) < 0.15 &&
36
+ qe > 3.0 &&
37
+ cv > 0.06 &&
38
+ (entropyJitterRatio === null || entropyJitterRatio >= 1.02),
39
+ },
40
+ {
41
+ id: 'kvm-generic',
42
+ label: 'KVM Hypervisor (generic)',
43
+ profile: 'picket-fence',
44
+ match: ({ lag1, lag50, qe, cv }) =>
45
+ lag1 > 0.40 && qe < 2.5 && cv < 0.15 && Math.abs(lag50) > 0.25,
46
+ providerHints: ['digitalocean', 'linode', 'vultr', 'hetzner', 'ovh'],
47
+ },
48
+ {
49
+ id: 'kvm-digitalocean',
50
+ label: 'DigitalOcean Droplet (KVM)',
51
+ profile: 'picket-fence',
52
+ match: ({ lag1, lag50, qe, cv, rendererHints }) =>
53
+ lag1 > 0.55 && qe < 2.0 && cv < 0.12 &&
54
+ (rendererHints.some(r => ['llvmpipe', 'virtio', 'qxl'].includes(r)) ||
55
+ lag50 > 0.30),
56
+ },
57
+ {
58
+ id: 'kvm-aws-ec2-xen',
59
+ label: 'AWS EC2 (Xen/older generation)',
60
+ profile: 'picket-fence',
61
+ // Xen credit scheduler has longer period (~150 iters)
62
+ match: ({ lag1, lag25, lag50, qe, cv }) =>
63
+ qe < 2.2 && cv < 0.13 &&
64
+ lag25 > 0.20 && lag50 > 0.20 &&
65
+ lag1 < 0.50, // lag-1 less pronounced than KVM
66
+ },
67
+ {
68
+ id: 'nitro-aws',
69
+ label: 'AWS EC2 Nitro (near-baremetal)',
70
+ profile: 'near-physical',
71
+ // Nitro uses SR-IOV and dedicated hardware — steal-time is very low.
72
+ // Looks almost physical but canvas renderer gives it away.
73
+ match: ({ lag1, lag50, qe, cv, isSoftwareRenderer, rendererHints }) =>
74
+ qe > 2.5 && cv > 0.05 &&
75
+ lag1 < 0.25 && lag50 < 0.20 &&
76
+ (isSoftwareRenderer ||
77
+ rendererHints.some(r => r.includes('nvidia t4') || r.includes('nvidia a10'))),
78
+ },
79
+ {
80
+ id: 'vmware-esxi',
81
+ label: 'VMware ESXi',
82
+ profile: 'burst-scheduler',
83
+ // VMware balloon driver creates irregular memory pressure bursts
84
+ match: ({ lag1, lag50, qe, cv, rendererHints }) =>
85
+ qe < 2.5 &&
86
+ (rendererHints.some(r => r.includes('vmware')) ||
87
+ (lag1 > 0.30 && lag50 < lag1 * 0.7 && cv < 0.14)),
88
+ },
89
+ {
90
+ id: 'hyperv',
91
+ label: 'Microsoft Hyper-V',
92
+ profile: 'picket-fence',
93
+ // 15.6ms scheduler quantum → burst every ~78 iters
94
+ match: ({ lag1, lag25, qe, cv, rendererHints }) =>
95
+ qe < 2.3 &&
96
+ (rendererHints.some(r => r.includes('microsoft basic render') || r.includes('warp')) ||
97
+ (lag25 > 0.25 && lag1 > 0.35 && cv < 0.12)),
98
+ },
99
+ {
100
+ id: 'gcp-kvm',
101
+ label: 'Google Cloud (KVM)',
102
+ profile: 'picket-fence',
103
+ match: ({ lag1, lag50, qe, cv, rendererHints }) =>
104
+ qe < 2.3 && lag1 > 0.45 &&
105
+ (rendererHints.some(r => r.includes('swiftshader') || r.includes('google')) ||
106
+ (lag50 > 0.28 && cv < 0.11)),
107
+ },
108
+ {
109
+ id: 'gh200-datacenter',
110
+ label: 'NVIDIA GH200 / HPC Datacenter',
111
+ profile: 'hypervisor-flat',
112
+ // Even with massive compute, still trapped by hypervisor clock.
113
+ // GH200 shows near-zero Hurst (extreme quantization) + very high lag-1.
114
+ match: ({ lag1, qe, hurst, cv, rendererHints }) =>
115
+ (rendererHints.some(r => r.includes('gh200') || r.includes('grace hopper') ||
116
+ r.includes('nvidia a100') || r.includes('nvidia h100')) ||
117
+ (hurst < 0.10 && lag1 > 0.60 && qe < 1.8 && cv < 0.10)),
118
+ },
119
+ {
120
+ id: 'generic-vm',
121
+ label: 'Virtual Machine (unclassified)',
122
+ profile: 'picket-fence',
123
+ match: ({ lag1, qe, cv, isSoftwareRenderer }) =>
124
+ isSoftwareRenderer ||
125
+ (qe < 2.0 && lag1 > 0.35) ||
126
+ (cv < 0.02),
127
+ },
128
+ ];
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // detectProvider
132
+ // ---------------------------------------------------------------------------
133
+
134
+ /**
135
+ * Classifies the host environment based on timing + canvas signals.
136
+ *
137
+ * @param {object} p
138
+ * @param {import('./jitter.js').JitterAnalysis} p.jitter
139
+ * @param {object} p.autocorrelations - extended lags including lag25, lag50
140
+ * @param {import('../collector/canvas.js').CanvasFingerprint} p.canvas
141
+ * @param {object|null} p.phases
142
+ * @returns {ProviderResult}
143
+ */
144
+ export function detectProvider({ jitter, autocorrelations, canvas, phases }) {
145
+ const rendererHints = _rendererHints(canvas?.webglRenderer, canvas?.webglVendor);
146
+
147
+ const signals = {
148
+ lag1: Math.abs(autocorrelations?.lag1 ?? 0),
149
+ lag25: Math.abs(autocorrelations?.lag25 ?? 0),
150
+ lag50: Math.abs(autocorrelations?.lag50 ?? 0),
151
+ qe: jitter.quantizationEntropy,
152
+ cv: jitter.stats?.cv ?? 0,
153
+ hurst: jitter.hurstExponent ?? 0.5,
154
+ isSoftwareRenderer: canvas?.isSoftwareRenderer ?? false,
155
+ rendererHints,
156
+ entropyJitterRatio: phases?.entropyJitterRatio ?? null,
157
+ };
158
+
159
+ // Score each profile and pick the best match
160
+ const scored = PROVIDER_PROFILES
161
+ .filter(p => {
162
+ try { return p.match(signals); }
163
+ catch { return false; }
164
+ })
165
+ .map(p => ({
166
+ ...p,
167
+ // Physical hardware is the last resort; give it lower priority when
168
+ // other profiles match so we don't misclassify VMs.
169
+ priority: p.id === 'physical' ? 0 : 1,
170
+ }))
171
+ .sort((a, b) => b.priority - a.priority);
172
+
173
+ const best = scored[0] ?? { id: 'unknown', label: 'Unknown', profile: 'unknown' };
174
+
175
+ // Confidence: how many "VM indicator" thresholds the signals cross
176
+ const vmIndicatorCount = [
177
+ signals.qe < 2.5,
178
+ signals.lag1 > 0.35,
179
+ signals.lag50 > 0.20,
180
+ signals.cv < 0.04,
181
+ signals.isSoftwareRenderer,
182
+ signals.hurst < 0.15,
183
+ phases?.entropyJitterRatio != null && phases.entropyJitterRatio < 1.02,
184
+ ].filter(Boolean).length;
185
+
186
+ const isPhysical = best.id === 'physical';
187
+ const confidence = isPhysical
188
+ ? Math.max(20, 95 - vmIndicatorCount * 15)
189
+ : Math.min(95, 40 + vmIndicatorCount * 12);
190
+
191
+ return {
192
+ providerId: best.id,
193
+ providerLabel: best.label,
194
+ profile: best.profile,
195
+ confidence,
196
+ isVirtualized: best.id !== 'physical',
197
+ signals,
198
+ alternatives: scored.slice(1, 3).map(p => ({ id: p.id, label: p.label })),
199
+ rendererHints,
200
+ schedulerQuantumMs: _estimateQuantum(signals),
201
+ };
202
+ }
203
+
204
+ /**
205
+ * @typedef {object} ProviderResult
206
+ * @property {string} providerId
207
+ * @property {string} providerLabel
208
+ * @property {string} profile 'analog-fog' | 'picket-fence' | 'burst-scheduler' | 'near-physical' | 'hypervisor-flat' | 'unknown'
209
+ * @property {number} confidence 0–100
210
+ * @property {boolean} isVirtualized
211
+ * @property {object} signals
212
+ * @property {object[]} alternatives
213
+ * @property {string[]} rendererHints
214
+ * @property {number|null} schedulerQuantumMs
215
+ */
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // Internal helpers
219
+ // ---------------------------------------------------------------------------
220
+
221
+ /**
222
+ * Extract lowercase hint tokens from WebGL renderer string for pattern matching.
223
+ */
224
+ function _rendererHints(renderer = '', vendor = '') {
225
+ return `${renderer} ${vendor}`.toLowerCase()
226
+ .split(/[\s\/(),]+/)
227
+ .filter(t => t.length > 2);
228
+ }
229
+
230
+ /**
231
+ * Estimate the hypervisor's scheduler quantum from the dominant autocorrelation lag.
232
+ * Returns null if the device appears to be physical.
233
+ */
234
+ function _estimateQuantum({ lag1, lag25, lag50, qe }) {
235
+ if (qe > 3.2) return null; // likely physical
236
+
237
+ // Find the dominant lag (highest absolute autocorrelation beyond lag-5)
238
+ const lags = [
239
+ { lag: 50, ac: lag50 },
240
+ { lag: 25, ac: lag25 },
241
+ ];
242
+ const peak = lags.reduce((b, c) => c.ac > b.ac ? c : b, { lag: 0, ac: 0 });
243
+
244
+ if (peak.ac < 0.20) return null;
245
+
246
+ // Quantum (ms) ≈ dominant_lag × estimated_iteration_time (≈5ms)
247
+ return peak.lag * 5;
248
+ }