@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.
- package/LICENSE +21 -21
- package/README.md +883 -622
- package/SECURITY.md +86 -86
- package/bin/svrnsec-pulse.js +7 -7
- package/dist/{pulse.cjs.js → pulse.cjs} +6379 -6420
- package/dist/pulse.cjs.map +1 -0
- package/dist/pulse.esm.js +6380 -6421
- package/dist/pulse.esm.js.map +1 -1
- package/index.d.ts +895 -846
- package/package.json +185 -165
- package/pkg/pulse_core.js +174 -173
- package/src/analysis/audio.js +213 -213
- package/src/analysis/authenticityAudit.js +408 -390
- package/src/analysis/coherence.js +502 -502
- package/src/analysis/coordinatedBehavior.js +825 -0
- package/src/analysis/heuristic.js +428 -428
- package/src/analysis/jitter.js +446 -446
- package/src/analysis/llm.js +473 -472
- package/src/analysis/populationEntropy.js +404 -403
- package/src/analysis/provider.js +248 -248
- package/src/analysis/refraction.js +392 -0
- package/src/analysis/trustScore.js +356 -356
- package/src/cli/args.js +36 -36
- package/src/cli/commands/scan.js +192 -192
- package/src/cli/runner.js +157 -157
- package/src/collector/adaptive.js +200 -200
- package/src/collector/bio.js +297 -287
- package/src/collector/canvas.js +247 -239
- package/src/collector/dram.js +203 -203
- package/src/collector/enf.js +311 -311
- package/src/collector/entropy.js +195 -195
- package/src/collector/gpu.js +248 -245
- package/src/collector/idleAttestation.js +480 -480
- package/src/collector/sabTimer.js +189 -191
- package/src/fingerprint.js +475 -475
- package/src/index.js +342 -342
- package/src/integrations/react-native.js +462 -459
- package/src/integrations/react.js +184 -185
- package/src/middleware/express.js +155 -155
- package/src/middleware/next.js +174 -175
- package/src/proof/challenge.js +249 -249
- package/src/proof/engagementToken.js +426 -394
- package/src/proof/fingerprint.js +268 -268
- package/src/proof/validator.js +83 -143
- package/src/registry/serializer.js +349 -349
- package/src/terminal.js +263 -263
- package/src/update-notifier.js +259 -264
- package/dist/pulse.cjs.js.map +0 -1
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @svrnsec/pulse — Refraction
|
|
3
|
+
*
|
|
4
|
+
* The same physics signal "refracts" through different measurement mediums.
|
|
5
|
+
* A browser's clamped performance.now() (~100μs Spectre mitigation) shifts
|
|
6
|
+
* every statistical distribution compared to Node.js process.hrtime.bigint().
|
|
7
|
+
*
|
|
8
|
+
* Instead of maintaining two sets of hardcoded thresholds, Refraction:
|
|
9
|
+
* 1. Probes the timer to measure actual resolution
|
|
10
|
+
* 2. Detects the execution environment (browser, Node, Deno, worker, etc.)
|
|
11
|
+
* 3. Computes a calibration profile that shifts all scoring bands
|
|
12
|
+
* 4. Exposes the profile so every downstream analyzer (jitter, trustScore,
|
|
13
|
+
* population entropy) can score against the correct baseline
|
|
14
|
+
*
|
|
15
|
+
* Core insight:
|
|
16
|
+
* A VM in Node.js and real hardware in a browser can produce identical
|
|
17
|
+
* Hurst exponents. Without knowing the medium, the score is meaningless.
|
|
18
|
+
* Refraction makes the medium explicit.
|
|
19
|
+
*
|
|
20
|
+
* Usage:
|
|
21
|
+
* import { calibrate, getProfile } from '@svrnsec/pulse/refraction'
|
|
22
|
+
* const profile = await calibrate() // run once at init
|
|
23
|
+
* const score = classifyJitter(timings, { refraction: profile })
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// ─── Environment detection ───────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
const ENV = Object.freeze({
|
|
29
|
+
NODE: 'node',
|
|
30
|
+
BROWSER: 'browser',
|
|
31
|
+
WORKER: 'worker', // Web Worker / Service Worker
|
|
32
|
+
DENO: 'deno',
|
|
33
|
+
BUN: 'bun',
|
|
34
|
+
UNKNOWN: 'unknown',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Detect the current JS runtime without relying on user-agent sniffing.
|
|
39
|
+
* Uses capability detection — what APIs exist, not what strings say.
|
|
40
|
+
*/
|
|
41
|
+
function detectEnvironment() {
|
|
42
|
+
// Deno has Deno global
|
|
43
|
+
if (typeof globalThis.Deno !== 'undefined') return ENV.DENO;
|
|
44
|
+
// Bun has Bun global
|
|
45
|
+
if (typeof globalThis.Bun !== 'undefined') return ENV.BUN;
|
|
46
|
+
// Node.js has process.versions.node
|
|
47
|
+
if (typeof globalThis.process !== 'undefined' &&
|
|
48
|
+
globalThis.process.versions?.node) return ENV.NODE;
|
|
49
|
+
// Web Worker has self but no document
|
|
50
|
+
if (typeof globalThis.WorkerGlobalScope !== 'undefined') return ENV.WORKER;
|
|
51
|
+
// Browser has window + document
|
|
52
|
+
if (typeof globalThis.window !== 'undefined' &&
|
|
53
|
+
typeof globalThis.document !== 'undefined') return ENV.BROWSER;
|
|
54
|
+
return ENV.UNKNOWN;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── Timer resolution probe ──────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Measure the actual timer resolution by collecting minimum non-zero deltas.
|
|
61
|
+
* Returns resolution in microseconds.
|
|
62
|
+
*
|
|
63
|
+
* Browser (Spectre-mitigated): typically 100μs
|
|
64
|
+
* Node.js hrtime: typically < 1μs
|
|
65
|
+
* Node.js performance.now(): typically ~1μs
|
|
66
|
+
*
|
|
67
|
+
* We probe with performance.now() since it's cross-platform.
|
|
68
|
+
* In Node.js we also check process.hrtime.bigint() availability.
|
|
69
|
+
*/
|
|
70
|
+
function probeTimerResolution(iterations = 500) {
|
|
71
|
+
const deltas = [];
|
|
72
|
+
const pnow = typeof performance !== 'undefined' && performance.now
|
|
73
|
+
? () => performance.now()
|
|
74
|
+
: () => Date.now(); // fallback — 1ms resolution
|
|
75
|
+
|
|
76
|
+
for (let i = 0; i < iterations; i++) {
|
|
77
|
+
const t0 = pnow();
|
|
78
|
+
// Minimal work — just enough to not get optimized away
|
|
79
|
+
let x = 0;
|
|
80
|
+
for (let j = 0; j < 10; j++) x += j;
|
|
81
|
+
const t1 = pnow();
|
|
82
|
+
const dt = t1 - t0;
|
|
83
|
+
if (dt > 0) deltas.push(dt);
|
|
84
|
+
if (x === -1) deltas.push(0); // prevent dead code elimination
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (deltas.length === 0) return { resolutionUs: 1000, grain: 'coarse' };
|
|
88
|
+
|
|
89
|
+
deltas.sort((a, b) => a - b);
|
|
90
|
+
|
|
91
|
+
// Minimum non-zero delta = timer resolution floor
|
|
92
|
+
const minDelta = deltas[0];
|
|
93
|
+
// Median gives stable estimate
|
|
94
|
+
const medDelta = deltas[Math.floor(deltas.length / 2)];
|
|
95
|
+
// Count unique values — clamped timers produce few unique values
|
|
96
|
+
const unique = new Set(deltas.map(d => d.toFixed(4))).size;
|
|
97
|
+
const uniqueRatio = unique / deltas.length;
|
|
98
|
+
|
|
99
|
+
const resolutionUs = minDelta * 1000; // ms → μs
|
|
100
|
+
|
|
101
|
+
let grain;
|
|
102
|
+
if (resolutionUs < 5) grain = 'nanosecond'; // < 5μs — Node.js / Deno
|
|
103
|
+
else if (resolutionUs < 50) grain = 'fine'; // 5–50μs — some browsers with relaxed policy
|
|
104
|
+
else if (resolutionUs < 200) grain = 'clamped'; // 50–200μs — standard Spectre mitigation
|
|
105
|
+
else grain = 'coarse'; // > 200μs — aggressive clamping or Date.now()
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
resolutionUs: +resolutionUs.toFixed(2),
|
|
109
|
+
minDeltaMs: +minDelta.toFixed(6),
|
|
110
|
+
medDeltaMs: +medDelta.toFixed(6),
|
|
111
|
+
uniqueRatio: +uniqueRatio.toFixed(4),
|
|
112
|
+
grain,
|
|
113
|
+
samples: deltas.length,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Check if high-resolution timer is available (Node.js process.hrtime).
|
|
119
|
+
*/
|
|
120
|
+
function hasHrtime() {
|
|
121
|
+
return typeof globalThis.process !== 'undefined' &&
|
|
122
|
+
typeof globalThis.process.hrtime?.bigint === 'function';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ─── Threshold profile ───────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Scoring thresholds shift based on timer grain.
|
|
129
|
+
*
|
|
130
|
+
* Why each threshold changes:
|
|
131
|
+
*
|
|
132
|
+
* CV (Coefficient of Variation):
|
|
133
|
+
* Clamped timers absorb small jitter → CV appears lower on real hardware.
|
|
134
|
+
* But heavy ops (32K+) push deltas above clamp floor, creating burst variance.
|
|
135
|
+
* Browser real HW: CV 0.01–0.90. Node real HW: CV 0.04–0.35.
|
|
136
|
+
*
|
|
137
|
+
* Hurst Exponent:
|
|
138
|
+
* Timer clamping introduces quantization steps that shift H upward.
|
|
139
|
+
* Browser real HW: H 0.15–0.82. Node real HW: H 0.25–0.55.
|
|
140
|
+
*
|
|
141
|
+
* Autocorrelation:
|
|
142
|
+
* Browser event loop scheduling adds baseline AC ~0.3–0.5 on real hardware.
|
|
143
|
+
* VMs in browser still show higher AC > 0.65 from hypervisor tick.
|
|
144
|
+
* Node real HW: AC < 0.20. Browser real HW: AC < 0.50.
|
|
145
|
+
*
|
|
146
|
+
* Quantization Entropy:
|
|
147
|
+
* Fewer unique timer values → fewer populated bins → lower QE.
|
|
148
|
+
* Node real HW: QE > 3.0. Browser real HW: QE > 0.8.
|
|
149
|
+
*
|
|
150
|
+
* Unique Value Ratio:
|
|
151
|
+
* Clamped timers repeat values more.
|
|
152
|
+
* Node real HW: UVR > 0.60. Browser real HW: UVR > 0.15.
|
|
153
|
+
*/
|
|
154
|
+
|
|
155
|
+
const PROFILES = {
|
|
156
|
+
nanosecond: {
|
|
157
|
+
label: 'High-resolution timer (Node.js / Deno)',
|
|
158
|
+
cv: { floor: 0.04, ceil: 0.35, vmFloor: 0.02 },
|
|
159
|
+
hurst: { floor: 0.25, ceil: 0.55, vmCeil: 0.60 },
|
|
160
|
+
ac: { pass: 0.20, warn: 0.35, fail: 0.50 },
|
|
161
|
+
qe: { pass: 3.0, warn: 1.5 },
|
|
162
|
+
uvr: { pass: 0.60, warn: 0.30 },
|
|
163
|
+
dram: { elFloor: 2, mcvFloor: 0.04 },
|
|
164
|
+
},
|
|
165
|
+
fine: {
|
|
166
|
+
label: 'Fine timer (relaxed browser policy)',
|
|
167
|
+
cv: { floor: 0.02, ceil: 0.60, vmFloor: 0.01 },
|
|
168
|
+
hurst: { floor: 0.20, ceil: 0.70, vmCeil: 0.75 },
|
|
169
|
+
ac: { pass: 0.35, warn: 0.50, fail: 0.60 },
|
|
170
|
+
qe: { pass: 1.5, warn: 0.8 },
|
|
171
|
+
uvr: { pass: 0.30, warn: 0.15 },
|
|
172
|
+
dram: { elFloor: 3, mcvFloor: 0.03 },
|
|
173
|
+
},
|
|
174
|
+
clamped: {
|
|
175
|
+
label: 'Spectre-mitigated browser timer (~100μs)',
|
|
176
|
+
cv: { floor: 0.01, ceil: 0.90, vmFloor: 0.005 },
|
|
177
|
+
hurst: { floor: 0.15, ceil: 0.82, vmCeil: 0.88 },
|
|
178
|
+
ac: { pass: 0.50, warn: 0.60, fail: 0.70 },
|
|
179
|
+
qe: { pass: 0.8, warn: 0.5 },
|
|
180
|
+
uvr: { pass: 0.15, warn: 0.08 },
|
|
181
|
+
dram: { elFloor: 5, mcvFloor: 0.02 },
|
|
182
|
+
},
|
|
183
|
+
coarse: {
|
|
184
|
+
label: 'Coarse timer (aggressive clamping / Date.now)',
|
|
185
|
+
cv: { floor: 0.005, ceil: 1.0, vmFloor: 0.002 },
|
|
186
|
+
hurst: { floor: 0.10, ceil: 0.88, vmCeil: 0.92 },
|
|
187
|
+
ac: { pass: 0.55, warn: 0.65, fail: 0.75 },
|
|
188
|
+
qe: { pass: 0.5, warn: 0.3 },
|
|
189
|
+
uvr: { pass: 0.08, warn: 0.04 },
|
|
190
|
+
dram: { elFloor: 5, mcvFloor: 0.02 },
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// ─── Calibration ─────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
/** @type {RefractionProfile|null} */
|
|
197
|
+
// Module-level singleton — environment-specific. Not safe to share between server-side and client-side timing data. Use resetProfile() to clear.
|
|
198
|
+
let _cached = null;
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Run the full calibration sequence. Call once at init; results are cached.
|
|
202
|
+
*
|
|
203
|
+
* @param {object} [opts]
|
|
204
|
+
* @param {boolean} [opts.force=false] - bypass cache and re-probe
|
|
205
|
+
* @returns {Promise<RefractionProfile>}
|
|
206
|
+
*/
|
|
207
|
+
export async function calibrate(opts = {}) {
|
|
208
|
+
if (_cached && !opts.force) return _cached;
|
|
209
|
+
|
|
210
|
+
const env = detectEnvironment();
|
|
211
|
+
const timer = probeTimerResolution();
|
|
212
|
+
const hrtime = hasHrtime();
|
|
213
|
+
|
|
214
|
+
// If Node.js with hrtime, always use nanosecond profile regardless of
|
|
215
|
+
// performance.now() resolution (which may be coarser on some builds).
|
|
216
|
+
const grain = (env === ENV.NODE && hrtime) ? 'nanosecond' : timer.grain;
|
|
217
|
+
const thresholds = PROFILES[grain];
|
|
218
|
+
|
|
219
|
+
_cached = Object.freeze({
|
|
220
|
+
env,
|
|
221
|
+
timer,
|
|
222
|
+
grain,
|
|
223
|
+
hrtime,
|
|
224
|
+
thresholds,
|
|
225
|
+
label: thresholds.label,
|
|
226
|
+
calibratedAt: Date.now(),
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
return _cached;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Get the cached profile. Returns null if calibrate() hasn't been called.
|
|
234
|
+
* @returns {RefractionProfile|null}
|
|
235
|
+
*/
|
|
236
|
+
export function getProfile() {
|
|
237
|
+
return _cached;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Synchronous calibration for environments that can't await.
|
|
242
|
+
* Slightly less accurate than async version but sufficient for most cases.
|
|
243
|
+
*/
|
|
244
|
+
export function calibrateSync() {
|
|
245
|
+
if (_cached) return _cached;
|
|
246
|
+
|
|
247
|
+
const env = detectEnvironment();
|
|
248
|
+
const timer = probeTimerResolution();
|
|
249
|
+
const hrtime = hasHrtime();
|
|
250
|
+
const grain = (env === ENV.NODE && hrtime) ? 'nanosecond' : timer.grain;
|
|
251
|
+
const thresholds = PROFILES[grain];
|
|
252
|
+
|
|
253
|
+
_cached = Object.freeze({
|
|
254
|
+
env,
|
|
255
|
+
timer,
|
|
256
|
+
grain,
|
|
257
|
+
hrtime,
|
|
258
|
+
thresholds,
|
|
259
|
+
label: thresholds.label,
|
|
260
|
+
calibratedAt: Date.now(),
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
return _cached;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Reset cached profile. Useful for testing.
|
|
268
|
+
*/
|
|
269
|
+
export function resetProfile() {
|
|
270
|
+
_cached = null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ─── Threshold accessors ─────────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Get the active thresholds for a specific signal.
|
|
277
|
+
* Falls back to 'clamped' profile if not calibrated (safe default).
|
|
278
|
+
*
|
|
279
|
+
* @param {'cv'|'hurst'|'ac'|'qe'|'uvr'|'dram'} signal
|
|
280
|
+
* @returns {object}
|
|
281
|
+
*/
|
|
282
|
+
export function getThresholds(signal) {
|
|
283
|
+
const profile = _cached?.thresholds ?? PROFILES.clamped;
|
|
284
|
+
return profile[signal];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Score a value against refraction-aware thresholds.
|
|
289
|
+
* Returns { score: 0-1, pass: boolean, flag: string|null }
|
|
290
|
+
*
|
|
291
|
+
* @param {'cv'|'hurst'|'ac'|'qe'|'uvr'} signal
|
|
292
|
+
* @param {number} value
|
|
293
|
+
* @returns {{ score: number, pass: boolean, flag: string|null }}
|
|
294
|
+
*/
|
|
295
|
+
export function scoreSignal(signal, value) {
|
|
296
|
+
const t = getThresholds(signal);
|
|
297
|
+
|
|
298
|
+
switch (signal) {
|
|
299
|
+
case 'cv': {
|
|
300
|
+
if (value >= t.floor && value <= t.ceil) return { score: 1, pass: true, flag: null };
|
|
301
|
+
if (value < t.vmFloor) return { score: 0.3, pass: false, flag: 'CV_FLAT_HYPERVISOR' };
|
|
302
|
+
if (value < t.floor) return { score: 0.6 + (value - t.vmFloor) / (t.floor - t.vmFloor) * 0.4, pass: false, flag: 'CV_LOW_BORDERLINE' };
|
|
303
|
+
if (value > t.ceil) return { score: 0.6, pass: false, flag: 'CV_HIGH_BURST' };
|
|
304
|
+
return { score: 0.5, pass: false, flag: 'CV_ANOMALOUS' };
|
|
305
|
+
}
|
|
306
|
+
case 'hurst': {
|
|
307
|
+
if (value >= t.floor && value <= t.ceil) return { score: 1, pass: true, flag: null };
|
|
308
|
+
if (value > t.vmCeil) return { score: 0.3, pass: false, flag: 'HURST_PERSISTENT_VM' };
|
|
309
|
+
if (value > t.ceil) return { score: 0.6, pass: false, flag: 'HURST_HIGH_BORDERLINE' };
|
|
310
|
+
if (value < t.floor) return { score: 0.65, pass: false, flag: 'HURST_WEAK' };
|
|
311
|
+
return { score: 0.5, pass: false, flag: 'HURST_ANOMALOUS' };
|
|
312
|
+
}
|
|
313
|
+
case 'ac': {
|
|
314
|
+
if (value < t.pass) return { score: 1, pass: true, flag: null };
|
|
315
|
+
if (value < t.warn) return { score: 0.7, pass: false, flag: 'AC_MODERATE' };
|
|
316
|
+
if (value < t.fail) return { score: 0.5, pass: false, flag: 'AC_HIGH' };
|
|
317
|
+
return { score: 0.2, pass: false, flag: 'AC_VM_PERIODIC' };
|
|
318
|
+
}
|
|
319
|
+
case 'qe': {
|
|
320
|
+
if (value >= t.pass) return { score: 1, pass: true, flag: null };
|
|
321
|
+
if (value >= t.warn) return { score: 0.75, pass: false, flag: 'QE_LOW_BORDERLINE' };
|
|
322
|
+
return { score: 0.35, pass: false, flag: 'QE_QUANTIZED' };
|
|
323
|
+
}
|
|
324
|
+
case 'uvr': {
|
|
325
|
+
if (value >= t.pass) return { score: 1, pass: true, flag: null };
|
|
326
|
+
if (value >= t.warn) return { score: 0.7, pass: false, flag: 'UVR_LOW_DIVERSITY' };
|
|
327
|
+
return { score: 0.3, pass: false, flag: 'UVR_CLAMPED' };
|
|
328
|
+
}
|
|
329
|
+
default:
|
|
330
|
+
return { score: 0.5, pass: false, flag: 'UNKNOWN_SIGNAL' };
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ─── Composite scoring ───────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Score an entire jitter analysis result through the refraction lens.
|
|
338
|
+
* This is the primary API — pass your raw stats and get back a
|
|
339
|
+
* refraction-aware score with full breakdown.
|
|
340
|
+
*
|
|
341
|
+
* @param {object} stats - { cv, hurst, ac1, qe, uvr }
|
|
342
|
+
* @returns {{ score: number, signals: object, flags: string[], profile: string }}
|
|
343
|
+
*/
|
|
344
|
+
export function scoreJitter(stats) {
|
|
345
|
+
const profile = _cached ?? calibrateSync();
|
|
346
|
+
|
|
347
|
+
const cv = scoreSignal('cv', stats.cv);
|
|
348
|
+
const hurst = scoreSignal('hurst', stats.hurst ?? stats.H);
|
|
349
|
+
const ac = scoreSignal('ac', stats.ac1 ?? stats.a1);
|
|
350
|
+
const qe = scoreSignal('qe', stats.qe ?? stats.QE);
|
|
351
|
+
const uvr = scoreSignal('uvr', stats.uvr ?? stats.ur);
|
|
352
|
+
|
|
353
|
+
const flags = [cv, hurst, ac, qe, uvr]
|
|
354
|
+
.map(s => s.flag)
|
|
355
|
+
.filter(Boolean);
|
|
356
|
+
|
|
357
|
+
// Weighted fusion — same weights regardless of medium
|
|
358
|
+
const raw = cv.score * 0.20 +
|
|
359
|
+
hurst.score * 0.20 +
|
|
360
|
+
ac.score * 0.20 +
|
|
361
|
+
qe.score * 0.15 +
|
|
362
|
+
uvr.score * 0.25;
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
score: +Math.min(0.99, Math.max(0.01, raw)).toFixed(4),
|
|
366
|
+
signals: { cv, hurst, ac, qe, uvr },
|
|
367
|
+
flags,
|
|
368
|
+
grain: profile.grain,
|
|
369
|
+
profile: profile.label,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ─── Exports ─────────────────────────────────────────────────────────────────
|
|
374
|
+
|
|
375
|
+
export {
|
|
376
|
+
ENV,
|
|
377
|
+
PROFILES,
|
|
378
|
+
detectEnvironment,
|
|
379
|
+
probeTimerResolution,
|
|
380
|
+
hasHrtime,
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* @typedef {object} RefractionProfile
|
|
385
|
+
* @property {string} env - 'node' | 'browser' | 'worker' | 'deno' | 'bun' | 'unknown'
|
|
386
|
+
* @property {object} timer - { resolutionUs, minDeltaMs, medDeltaMs, uniqueRatio, grain, samples }
|
|
387
|
+
* @property {string} grain - 'nanosecond' | 'fine' | 'clamped' | 'coarse'
|
|
388
|
+
* @property {boolean} hrtime - true if process.hrtime.bigint() is available
|
|
389
|
+
* @property {object} thresholds - the active PROFILES[grain] threshold set
|
|
390
|
+
* @property {string} label - human-readable description
|
|
391
|
+
* @property {number} calibratedAt - Date.now() when calibration ran
|
|
392
|
+
*/
|