@svrnsec/pulse 0.3.0 → 0.3.1

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/dist/pulse.cjs.js CHANGED
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ var module$1 = require('module');
3
4
  var node_crypto = require('node:crypto');
4
5
 
5
6
  var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
@@ -781,9 +782,9 @@ async function collectEntropy(opts = {}) {
781
782
  const hotQE = detectQuantizationEntropy(hotTimings);
782
783
 
783
784
  phases = {
784
- cold: { n: coldN, timings: coldTimings, qe: coldQE, mean: _mean$1(coldTimings) },
785
- load: { n: loadN, timings: loadTimings, qe: detectQuantizationEntropy(loadTimings), mean: _mean$1(loadTimings) },
786
- hot: { n: hotN, timings: hotTimings, qe: hotQE, mean: _mean$1(hotTimings) },
785
+ cold: { n: coldN, timings: coldTimings, qe: coldQE, mean: _mean$4(coldTimings) },
786
+ load: { n: loadN, timings: loadTimings, qe: detectQuantizationEntropy(loadTimings), mean: _mean$4(loadTimings) },
787
+ hot: { n: hotN, timings: hotTimings, qe: hotQE, mean: _mean$4(hotTimings) },
787
788
  // The key signal: entropy growth under load.
788
789
  // Real silicon: hotQE / coldQE typically 1.05 – 1.40
789
790
  // VM: hotQE / coldQE typically 0.95 – 1.05 (flat)
@@ -837,7 +838,7 @@ async function collectEntropy(opts = {}) {
837
838
  };
838
839
  }
839
840
 
840
- function _mean$1(arr) {
841
+ function _mean$4(arr) {
841
842
  return arr.length ? arr.reduce((s, v) => s + v, 0) / arr.length : 0;
842
843
  }
843
844
 
@@ -977,11 +978,11 @@ class BioCollector {
977
978
 
978
979
  const mouseStats = {
979
980
  sampleCount: iei.length,
980
- ieiMean: _mean(iei),
981
- ieiCV: _cv(iei),
981
+ ieiMean: _mean$3(iei),
982
+ ieiCV: _cv$1(iei),
982
983
  velocityP50: _percentile$1(velocities, 50),
983
984
  velocityP95: _percentile$1(velocities, 95),
984
- angularJerkMean: _mean(angJerk),
985
+ angularJerkMean: _mean$3(angJerk),
985
986
  pressureVariance: _variance(pressure),
986
987
  };
987
988
 
@@ -994,10 +995,10 @@ class BioCollector {
994
995
 
995
996
  const keyStats = {
996
997
  sampleCount: dwellTimes.length,
997
- dwellMean: _mean(dwellTimes),
998
- dwellCV: _cv(dwellTimes),
999
- ikiMean: _mean(iki),
1000
- ikiCV: _cv(iki),
998
+ dwellMean: _mean$3(dwellTimes),
999
+ dwellCV: _cv$1(dwellTimes),
1000
+ ikiMean: _mean$3(iki),
1001
+ ikiCV: _cv$1(iki),
1001
1002
  };
1002
1003
 
1003
1004
  // ── Interference Coefficient ──────────────────────────────────────────
@@ -1033,20 +1034,20 @@ class BioCollector {
1033
1034
  // Statistical helpers (private)
1034
1035
  // ---------------------------------------------------------------------------
1035
1036
 
1036
- function _mean(arr) {
1037
+ function _mean$3(arr) {
1037
1038
  if (!arr.length) return 0;
1038
1039
  return arr.reduce((a, b) => a + b, 0) / arr.length;
1039
1040
  }
1040
1041
 
1041
1042
  function _variance(arr) {
1042
1043
  if (arr.length < 2) return 0;
1043
- const m = _mean(arr);
1044
+ const m = _mean$3(arr);
1044
1045
  return arr.reduce((s, v) => s + (v - m) ** 2, 0) / (arr.length - 1);
1045
1046
  }
1046
1047
 
1047
- function _cv(arr) {
1048
+ function _cv$1(arr) {
1048
1049
  if (!arr.length) return 0;
1049
- const m = _mean(arr);
1050
+ const m = _mean$3(arr);
1050
1051
  if (m === 0) return 0;
1051
1052
  return Math.sqrt(_variance(arr)) / Math.abs(m);
1052
1053
  }
@@ -1103,7 +1104,7 @@ function _computeInterference(mouseEvents, keyEvents, timings) {
1103
1104
  if (!allInputTimes.length) return 0;
1104
1105
 
1105
1106
  const WINDOW_MS = 16;
1106
- const meanTiming = _mean(timings);
1107
+ const meanTiming = _mean$3(timings);
1107
1108
 
1108
1109
  // We need absolute timestamps for the probe samples.
1109
1110
  // We don't have them directly – use relative index spacing as a proxy.
@@ -1128,8 +1129,8 @@ function _computeInterference(mouseEvents, keyEvents, timings) {
1128
1129
  function _pearson(X, Y) {
1129
1130
  const n = X.length;
1130
1131
  if (n < 2) return 0;
1131
- const mx = _mean(X);
1132
- const my = _mean(Y);
1132
+ const mx = _mean$3(X);
1133
+ const my = _mean$3(Y);
1133
1134
  let num = 0, da = 0, db = 0;
1134
1135
  for (let i = 0; i < n; i++) {
1135
1136
  const a = X[i] - mx;
@@ -1746,9 +1747,9 @@ function blake3HexStr(str) {
1746
1747
  * @param {string} p.nonce – server-issued challenge nonce (hex)
1747
1748
  * @returns {ProofPayload}
1748
1749
  */
1749
- function buildProof({ entropy, jitter, bio, canvas, audio, nonce }) {
1750
+ function buildProof({ entropy, jitter, bio, canvas, audio, enf, gpu, dram, llm, nonce }) {
1750
1751
  if (!nonce || typeof nonce !== 'string') {
1751
- throw new Error('@sovereign/pulse: nonce is required for anti-replay protection');
1752
+ throw new Error('@svrnsec/pulse: nonce is required for anti-replay protection');
1752
1753
  }
1753
1754
 
1754
1755
  // Hash the raw timing arrays IN-BROWSER so we can prove their integrity
@@ -1830,12 +1831,68 @@ function buildProof({ entropy, jitter, bio, canvas, audio, nonce }) {
1830
1831
  jitterMeanMs: _round$1(audio.jitterMeanMs, 4),
1831
1832
  jitterP95Ms: _round$1(audio.jitterP95Ms, 4),
1832
1833
  },
1834
+
1835
+ // ── Electrical Network Frequency ─────────────────────────────────────
1836
+ enf: enf ? {
1837
+ available: enf.enfAvailable,
1838
+ ripplePresent: enf.ripplePresent,
1839
+ gridFrequency: enf.gridFrequency,
1840
+ gridRegion: enf.gridRegion,
1841
+ ripplePower: _round$1(enf.ripplePower, 4),
1842
+ enfDeviation: _round$1(enf.enfDeviation, 3),
1843
+ snr50hz: _round$1(enf.snr50hz, 2),
1844
+ snr60hz: _round$1(enf.snr60hz, 2),
1845
+ sampleRateHz: _round$1(enf.sampleRateHz, 1),
1846
+ verdict: enf.verdict,
1847
+ isVmIndicator: enf.isVmIndicator,
1848
+ capturedAt: enf.temporalAnchor?.capturedAt ?? null,
1849
+ } : null,
1850
+
1851
+ // ── WebGPU thermal variance ───────────────────────────────────────────
1852
+ gpu: gpu ? {
1853
+ available: gpu.gpuPresent,
1854
+ isSoftware: gpu.isSoftware,
1855
+ vendorString: gpu.vendorString,
1856
+ dispatchCV: _round$1(gpu.dispatchCV, 4),
1857
+ thermalGrowth: _round$1(gpu.thermalGrowth, 4),
1858
+ verdict: gpu.verdict,
1859
+ } : null,
1860
+
1861
+ // ── DRAM refresh cycle ────────────────────────────────────────────────
1862
+ dram: dram ? {
1863
+ refreshPresent: dram.refreshPresent,
1864
+ refreshPeriodMs: _round$1(dram.refreshPeriodMs, 2),
1865
+ peakPower: _round$1(dram.peakPower, 4),
1866
+ verdict: dram.verdict,
1867
+ } : null,
1868
+
1869
+ // ── LLM / AI agent behavioral fingerprint ────────────────────────────
1870
+ llm: llm ? {
1871
+ aiConf: _round$1(llm.aiConf, 3),
1872
+ thinkTimePattern: llm.thinkTimePattern,
1873
+ correctionRate: _round$1(llm.correctionRate, 3),
1874
+ rhythmicity: _round$1(llm.rhythmicity, 3),
1875
+ pauseDistribution: llm.pauseDistribution,
1876
+ verdict: llm.verdict,
1877
+ matchedModel: llm.matchedModel ?? null,
1878
+ } : null,
1833
1879
  },
1834
1880
 
1835
- // Top-level classification summary
1881
+ // Top-level classification summary — all signal layers combined
1836
1882
  classification: {
1837
- jitterScore: _round$1(jitter.score, 4),
1838
- flags: jitter.flags ?? [],
1883
+ jitterScore: _round$1(jitter.score, 4),
1884
+ flags: jitter.flags ?? [],
1885
+ enfVerdict: enf?.verdict ?? 'unavailable',
1886
+ gpuVerdict: gpu?.verdict ?? 'unavailable',
1887
+ dramVerdict: dram?.verdict ?? 'unavailable',
1888
+ llmVerdict: llm?.verdict ?? 'unavailable',
1889
+ // Combined VM confidence: any hard signal raises this
1890
+ vmIndicators: [
1891
+ enf?.isVmIndicator ? 'enf_no_grid' : null,
1892
+ gpu?.isSoftware ? 'gpu_software' : null,
1893
+ dram?.verdict === 'virtual' ? 'dram_no_refresh' : null,
1894
+ llm?.aiConf > 0.7 ? 'llm_agent' : null,
1895
+ ].filter(Boolean),
1839
1896
  },
1840
1897
  };
1841
1898
 
@@ -1919,7 +1976,7 @@ function _round$1(v, decimals) {
1919
1976
  // ---------------------------------------------------------------------------
1920
1977
  // Known software-renderer substrings (VM / headless environment indicators)
1921
1978
  // ---------------------------------------------------------------------------
1922
- const SOFTWARE_RENDERER_PATTERNS = [
1979
+ const SOFTWARE_RENDERER_PATTERNS$1 = [
1923
1980
  'llvmpipe', 'swiftshader', 'softpipe', 'mesa offscreen',
1924
1981
  'microsoft basic render', 'vmware svga', 'virtualbox',
1925
1982
  'parallels', 'angle (', 'google swiftshader',
@@ -1973,7 +2030,7 @@ async function collectCanvasFingerprint() {
1973
2030
 
1974
2031
  // Software-renderer detection
1975
2032
  const rendererLc = (result.webglRenderer ?? '').toLowerCase();
1976
- result.isSoftwareRenderer = SOFTWARE_RENDERER_PATTERNS.some(p =>
2033
+ result.isSoftwareRenderer = SOFTWARE_RENDERER_PATTERNS$1.some(p =>
1977
2034
  rendererLc.includes(p)
1978
2035
  );
1979
2036
 
@@ -2143,217 +2200,1798 @@ function _compileShader(gl, type, source) {
2143
2200
  }
2144
2201
 
2145
2202
  /**
2146
- * @sovereign/pulse — AudioContext Oscillator Jitter
2203
+ * @sovereign/pulse — AudioContext Oscillator Jitter
2204
+ *
2205
+ * Measures the scheduling jitter of the browser's audio pipeline.
2206
+ * Real audio hardware callbacks are driven by a hardware interrupt (IRQ)
2207
+ * from the sound card; the timing reflects the actual interrupt latency
2208
+ * of the physical device. VM audio drivers (if present at all) are
2209
+ * emulated and show either unrealistically low jitter or burst-mode
2210
+ * scheduling artefacts that are statistically distinguishable.
2211
+ */
2212
+
2213
+ /**
2214
+ * @param {object} [opts]
2215
+ * @param {number} [opts.durationMs=2000] - how long to collect audio callbacks
2216
+ * @param {number} [opts.bufferSize=256] - ScriptProcessorNode buffer size
2217
+ * @returns {Promise<AudioJitter>}
2218
+ */
2219
+ async function collectAudioJitter(opts = {}) {
2220
+ const { durationMs = 2000, bufferSize = 256 } = opts;
2221
+
2222
+ const base = {
2223
+ available: false,
2224
+ workletAvailable: false,
2225
+ callbackJitterCV: 0,
2226
+ noiseFloorMean: 0,
2227
+ sampleRate: 0,
2228
+ callbackCount: 0,
2229
+ jitterTimings: [],
2230
+ };
2231
+
2232
+ if (typeof AudioContext === 'undefined' && typeof webkitAudioContext === 'undefined') {
2233
+ return base; // Node.js / server environment
2234
+ }
2235
+
2236
+ let ctx;
2237
+ try {
2238
+ ctx = new (window.AudioContext || window.webkitAudioContext)();
2239
+ } catch (_) {
2240
+ return base;
2241
+ }
2242
+
2243
+ // Some browsers require a user gesture before AudioContext can run.
2244
+ if (ctx.state === 'suspended') {
2245
+ try {
2246
+ await ctx.resume();
2247
+ } catch (_) {
2248
+ await ctx.close().catch(() => {});
2249
+ return base;
2250
+ }
2251
+ }
2252
+
2253
+ const sampleRate = ctx.sampleRate;
2254
+ const expectedInterval = (bufferSize / sampleRate) * 1000; // ms per callback
2255
+
2256
+ const jitterTimings = []; // absolute AudioContext.currentTime at each callback
2257
+ const callbackDeltas = [];
2258
+
2259
+ await new Promise((resolve) => {
2260
+ // ── AudioWorklet (preferred — runs on dedicated real-time thread) ──────
2261
+ const useWorklet = typeof AudioWorkletNode !== 'undefined';
2262
+ base.workletAvailable = useWorklet;
2263
+
2264
+ if (useWorklet) {
2265
+ // Inline worklet: send currentTime back via MessagePort every buffer
2266
+ const workletCode = `
2267
+ class PulseProbe extends AudioWorkletProcessor {
2268
+ process(inputs, outputs) {
2269
+ this.port.postMessage({ t: currentTime });
2270
+ // Pass-through silence
2271
+ for (const out of outputs)
2272
+ for (const ch of out) ch.fill(0);
2273
+ return true;
2274
+ }
2275
+ }
2276
+ registerProcessor('pulse-probe', PulseProbe);
2277
+ `;
2278
+ const blob = new Blob([workletCode], { type: 'application/javascript' });
2279
+ const blobUrl = URL.createObjectURL(blob);
2280
+
2281
+ ctx.audioWorklet.addModule(blobUrl).then(() => {
2282
+ const node = new AudioWorkletNode(ctx, 'pulse-probe');
2283
+ node.port.onmessage = (e) => {
2284
+ jitterTimings.push(e.data.t * 1000); // convert to ms
2285
+ };
2286
+ node.connect(ctx.destination);
2287
+
2288
+ setTimeout(async () => {
2289
+ node.disconnect();
2290
+ URL.revokeObjectURL(blobUrl);
2291
+ resolve(node);
2292
+ }, durationMs);
2293
+ }).catch(() => {
2294
+ URL.revokeObjectURL(blobUrl);
2295
+ _fallbackScriptProcessor(ctx, bufferSize, durationMs, jitterTimings, resolve);
2296
+ });
2297
+
2298
+ } else {
2299
+ _fallbackScriptProcessor(ctx, bufferSize, durationMs, jitterTimings, resolve);
2300
+ }
2301
+ });
2302
+
2303
+ // ── Compute deltas between successive callback times ────────────────────
2304
+ for (let i = 1; i < jitterTimings.length; i++) {
2305
+ callbackDeltas.push(jitterTimings[i] - jitterTimings[i - 1]);
2306
+ }
2307
+
2308
+ // ── Noise floor via AnalyserNode ─────────────────────────────────────────
2309
+ // Feed a silent oscillator through an analyser; the FFT magnitude at silence
2310
+ // reveals the hardware's thermal noise floor (varies per ADC/DAC chipset).
2311
+ const noiseFloor = await _measureNoiseFloor(ctx);
2312
+
2313
+ await ctx.close().catch(() => {});
2314
+
2315
+ // ── Statistics ────────────────────────────────────────────────────────────
2316
+ const mean = callbackDeltas.length
2317
+ ? callbackDeltas.reduce((s, v) => s + v, 0) / callbackDeltas.length
2318
+ : 0;
2319
+ const variance = callbackDeltas.length > 1
2320
+ ? callbackDeltas.reduce((s, v) => s + (v - mean) ** 2, 0) / (callbackDeltas.length - 1)
2321
+ : 0;
2322
+ const jitterCV = mean > 0 ? Math.sqrt(variance) / mean : 0;
2323
+
2324
+ return {
2325
+ available: true,
2326
+ workletAvailable: base.workletAvailable,
2327
+ callbackJitterCV: jitterCV,
2328
+ noiseFloorMean: noiseFloor.mean,
2329
+ noiseFloorStd: noiseFloor.std,
2330
+ sampleRate,
2331
+ callbackCount: jitterTimings.length,
2332
+ expectedIntervalMs: expectedInterval,
2333
+ // Only include summary stats, not raw timings (privacy / size)
2334
+ jitterMeanMs: mean,
2335
+ jitterP95Ms: _percentile(callbackDeltas, 95),
2336
+ };
2337
+ }
2338
+
2339
+ /**
2340
+ * @typedef {object} AudioJitter
2341
+ * @property {boolean} available
2342
+ * @property {boolean} workletAvailable
2343
+ * @property {number} callbackJitterCV
2344
+ * @property {number} noiseFloorMean
2345
+ * @property {number} sampleRate
2346
+ * @property {number} callbackCount
2347
+ */
2348
+
2349
+ // ---------------------------------------------------------------------------
2350
+ // Internal helpers
2351
+ // ---------------------------------------------------------------------------
2352
+
2353
+ function _fallbackScriptProcessor(ctx, bufferSize, durationMs, jitterTimings, resolve) {
2354
+ // ScriptProcessorNode is deprecated but universally supported.
2355
+ const proc = ctx.createScriptProcessor(bufferSize, 1, 1);
2356
+ proc.onaudioprocess = () => {
2357
+ jitterTimings.push(ctx.currentTime * 1000);
2358
+ };
2359
+ // Connect to keep the graph alive
2360
+ const osc = ctx.createOscillator();
2361
+ osc.frequency.value = 1; // sub-audible
2362
+ osc.connect(proc);
2363
+ proc.connect(ctx.destination);
2364
+ osc.start();
2365
+
2366
+ setTimeout(() => {
2367
+ osc.stop();
2368
+ osc.disconnect();
2369
+ proc.disconnect();
2370
+ resolve(proc);
2371
+ }, durationMs);
2372
+ }
2373
+
2374
+ async function _measureNoiseFloor(ctx) {
2375
+ try {
2376
+ const analyser = ctx.createAnalyser();
2377
+ analyser.fftSize = 256;
2378
+ analyser.connect(ctx.destination);
2379
+
2380
+ // Silent source
2381
+ const buf = ctx.createBuffer(1, ctx.sampleRate * 0.1, ctx.sampleRate);
2382
+ const src = ctx.createBufferSource();
2383
+ src.buffer = buf;
2384
+ src.connect(analyser);
2385
+ src.start();
2386
+
2387
+ await new Promise(r => setTimeout(r, 150));
2388
+
2389
+ const data = new Float32Array(analyser.frequencyBinCount);
2390
+ analyser.getFloatFrequencyData(data);
2391
+ analyser.disconnect();
2392
+
2393
+ // Limit to 32 bins to keep the payload small
2394
+ const trimmed = Array.from(data.slice(0, 32)).map(v =>
2395
+ isFinite(v) ? Math.pow(10, v / 20) : 0 // dB → linear
2396
+ );
2397
+ const mean = trimmed.reduce((s, v) => s + v, 0) / trimmed.length;
2398
+ const std = Math.sqrt(
2399
+ trimmed.reduce((s, v) => s + (v - mean) ** 2, 0) / trimmed.length
2400
+ );
2401
+ return { mean, std };
2402
+ } catch (_) {
2403
+ return { mean: 0, std: 0 };
2404
+ }
2405
+ }
2406
+
2407
+ function _percentile(arr, p) {
2408
+ if (!arr.length) return 0;
2409
+ const sorted = [...arr].sort((a, b) => a - b);
2410
+ const idx = (p / 100) * (sorted.length - 1);
2411
+ const lo = Math.floor(idx);
2412
+ const hi = Math.ceil(idx);
2413
+ return sorted[lo] + (sorted[hi] - sorted[lo]) * (idx - lo);
2414
+ }
2415
+
2416
+ /**
2417
+ * @sovereign/pulse — WebGPU Thermal Variance Probe
2418
+ *
2419
+ * Runs a compute shader on the GPU and measures dispatch timing variance.
2420
+ *
2421
+ * Why this works
2422
+ * ──────────────
2423
+ * Real consumer GPUs (GTX 1650, RX 6600, M2 GPU) have thermal noise in shader
2424
+ * execution timing that increases under sustained load — the same thermodynamic
2425
+ * principle as the CPU probe but in silicon designed for parallel throughput.
2426
+ *
2427
+ * Cloud VMs with software GPU emulation (SwiftShader, llvmpipe, Mesa's softpipe)
2428
+ * execute shaders on the CPU and produce near-deterministic timing — flat CV,
2429
+ * no thermal growth across phases, no dispatch jitter.
2430
+ *
2431
+ * VMs with GPU passthrough (rare in practice, requires dedicated hardware) pass
2432
+ * this check — which is correct, they have real GPU silicon.
2433
+ *
2434
+ * Signals
2435
+ * ───────
2436
+ * gpuPresent false = WebGPU absent = software renderer = high VM probability
2437
+ * isSoftware true = SwiftShader/llvmpipe detected by adapter info
2438
+ * dispatchCV coefficient of variation across dispatch timings
2439
+ * thermalGrowth (hotDispatchMean - coldDispatchMean) / coldDispatchMean
2440
+ * vendorString GPU vendor from adapter info (Intel, NVIDIA, AMD, Apple, etc.)
2441
+ */
2442
+
2443
+ /* ─── WebGPU availability ────────────────────────────────────────────────── */
2444
+
2445
+ function isWebGPUAvailable() {
2446
+ return typeof navigator !== 'undefined' && 'gpu' in navigator;
2447
+ }
2448
+
2449
+ /* ─── Software renderer detection ───────────────────────────────────────── */
2450
+
2451
+ const SOFTWARE_RENDERER_PATTERNS = [
2452
+ /swiftshader/i,
2453
+ /llvmpipe/i,
2454
+ /softpipe/i,
2455
+ /microsoft basic render/i,
2456
+ /angle \(.*software/i,
2457
+ /cpu/i,
2458
+ ];
2459
+
2460
+ function detectSoftwareRenderer(adapterInfo) {
2461
+ const desc = [
2462
+ adapterInfo?.vendor ?? '',
2463
+ adapterInfo?.device ?? '',
2464
+ adapterInfo?.description ?? '',
2465
+ adapterInfo?.architecture ?? '',
2466
+ ].join(' ');
2467
+
2468
+ return SOFTWARE_RENDERER_PATTERNS.some(p => p.test(desc));
2469
+ }
2470
+
2471
+ /* ─── Compute shader ─────────────────────────────────────────────────────── */
2472
+
2473
+ // A compute workload that is trivially parallelisable but forces the GPU to
2474
+ // actually execute — matrix-multiply on 64 × 64 tiles across 256 workgroups.
2475
+ // Light enough that it doesn't block UI; heavy enough to generate thermal signal.
2476
+ const SHADER_SRC = /* wgsl */ `
2477
+ struct Matrix {
2478
+ values: array<f32, 4096>, // 64x64
2479
+ };
2480
+
2481
+ @group(0) @binding(0) var<storage, read> matA : Matrix;
2482
+ @group(0) @binding(1) var<storage, read> matB : Matrix;
2483
+ @group(0) @binding(2) var<storage, read_write> matC : Matrix;
2484
+
2485
+ @compute @workgroup_size(8, 8)
2486
+ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
2487
+ let row = gid.x;
2488
+ let col = gid.y;
2489
+ if (row >= 64u || col >= 64u) { return; }
2490
+
2491
+ var acc: f32 = 0.0;
2492
+ for (var k = 0u; k < 64u; k++) {
2493
+ acc += matA.values[row * 64u + k] * matB.values[k * 64u + col];
2494
+ }
2495
+ matC.values[row * 64u + col] = acc;
2496
+ }
2497
+ `;
2498
+
2499
+ /* ─── collectGpuEntropy ─────────────────────────────────────────────────── */
2500
+
2501
+ /**
2502
+ * @param {object} [opts]
2503
+ * @param {number} [opts.iterations=60] – dispatch rounds per phase
2504
+ * @param {boolean} [opts.phased=true] – cold / load / hot phases
2505
+ * @param {number} [opts.timeoutMs=8000] – hard abort if GPU stalls
2506
+ * @returns {Promise<GpuEntropyResult>}
2507
+ */
2508
+ async function collectGpuEntropy(opts = {}) {
2509
+ const { iterations = 60, phased = true, timeoutMs = 8000 } = opts;
2510
+
2511
+ if (!isWebGPUAvailable()) {
2512
+ return _noGpu('WebGPU not available in this environment');
2513
+ }
2514
+
2515
+ let adapter, device;
2516
+ try {
2517
+ adapter = await Promise.race([
2518
+ navigator.gpu.requestAdapter({ powerPreference: 'high-performance' }),
2519
+ _timeout(timeoutMs, 'requestAdapter timed out'),
2520
+ ]);
2521
+ if (!adapter) return _noGpu('No WebGPU adapter found');
2522
+
2523
+ device = await Promise.race([
2524
+ adapter.requestDevice(),
2525
+ _timeout(timeoutMs, 'requestDevice timed out'),
2526
+ ]);
2527
+ } catch (err) {
2528
+ return _noGpu(`WebGPU init failed: ${err.message}`);
2529
+ }
2530
+
2531
+ const adapterInfo = adapter.info ?? {};
2532
+ const isSoftware = detectSoftwareRenderer(adapterInfo);
2533
+
2534
+ // Compile the shader module once
2535
+ const shaderModule = device.createShaderModule({ code: SHADER_SRC });
2536
+
2537
+ // Create persistent GPU buffers (64×64 float32 = 16 KB each)
2538
+ const bufSize = 4096 * 4; // 4096 floats × 4 bytes
2539
+ const bufA = _createBuffer(device, bufSize, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
2540
+ const bufB = _createBuffer(device, bufSize, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST);
2541
+ const bufC = _createBuffer(device, bufSize, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC);
2542
+
2543
+ // Seed with random data
2544
+ const matData = new Float32Array(4096).map(() => Math.random());
2545
+ device.queue.writeBuffer(bufA, 0, matData);
2546
+ device.queue.writeBuffer(bufB, 0, matData);
2547
+
2548
+ const pipeline = device.createComputePipeline({
2549
+ layout: 'auto',
2550
+ compute: { module: shaderModule, entryPoint: 'main' },
2551
+ });
2552
+
2553
+ const bindGroup = device.createBindGroup({
2554
+ layout: pipeline.getBindGroupLayout(0),
2555
+ entries: [
2556
+ { binding: 0, resource: { buffer: bufA } },
2557
+ { binding: 1, resource: { buffer: bufB } },
2558
+ { binding: 2, resource: { buffer: bufC } },
2559
+ ],
2560
+ });
2561
+
2562
+ // ── Probe ──────────────────────────────────────────────────────────────
2563
+ async function runPhase(n) {
2564
+ const timings = [];
2565
+ for (let i = 0; i < n; i++) {
2566
+ const t0 = performance.now();
2567
+ const encoder = device.createCommandEncoder();
2568
+ const pass = encoder.beginComputePass();
2569
+ pass.setPipeline(pipeline);
2570
+ pass.setBindGroup(0, bindGroup);
2571
+ pass.dispatchWorkgroups(8, 8); // 64 workgroups total
2572
+ pass.end();
2573
+ device.queue.submit([encoder.finish()]);
2574
+ await device.queue.onSubmittedWorkDone();
2575
+ const t1 = performance.now();
2576
+ timings.push(t1 - t0);
2577
+ }
2578
+ return timings;
2579
+ }
2580
+
2581
+ let coldTimings, loadTimings, hotTimings;
2582
+
2583
+ if (phased) {
2584
+ coldTimings = await runPhase(Math.floor(iterations * 0.25));
2585
+ loadTimings = await runPhase(Math.floor(iterations * 0.50));
2586
+ hotTimings = await runPhase(iterations - coldTimings.length - loadTimings.length);
2587
+ } else {
2588
+ coldTimings = await runPhase(iterations);
2589
+ loadTimings = [];
2590
+ hotTimings = [];
2591
+ }
2592
+
2593
+ // Cleanup
2594
+ bufA.destroy(); bufB.destroy(); bufC.destroy();
2595
+ device.destroy();
2596
+
2597
+ const allTimings = [...coldTimings, ...loadTimings, ...hotTimings];
2598
+ const mean = _mean$2(allTimings);
2599
+ const cv = mean > 0 ? _std$1(allTimings) / mean : 0;
2600
+
2601
+ const coldMean = _mean$2(coldTimings);
2602
+ const hotMean = _mean$2(hotTimings.length ? hotTimings : coldTimings);
2603
+ const thermalGrowth = coldMean > 0 ? (hotMean - coldMean) / coldMean : 0;
2604
+
2605
+ return {
2606
+ gpuPresent: true,
2607
+ isSoftware,
2608
+ vendor: adapterInfo.vendor ?? 'unknown',
2609
+ architecture: adapterInfo.architecture ?? 'unknown',
2610
+ timings: allTimings,
2611
+ dispatchCV: cv,
2612
+ thermalGrowth,
2613
+ coldMean,
2614
+ hotMean,
2615
+ // Heuristic: real GPU → thermalGrowth > 0.02 and CV > 0.04
2616
+ // Software renderer → thermalGrowth ≈ 0, CV < 0.02
2617
+ verdict: isSoftware ? 'software_renderer'
2618
+ : thermalGrowth > 0.02 && cv > 0.04 ? 'real_gpu'
2619
+ : thermalGrowth < 0 && cv < 0.02 ? 'virtual_gpu'
2620
+ : 'ambiguous',
2621
+ };
2622
+ }
2623
+
2624
+ /* ─── helpers ────────────────────────────────────────────────────────────── */
2625
+
2626
+ function _noGpu(reason) {
2627
+ return { gpuPresent: false, isSoftware: false, vendor: null,
2628
+ architecture: null, timings: [], dispatchCV: 0,
2629
+ thermalGrowth: 0, coldMean: 0, hotMean: 0,
2630
+ verdict: 'no_gpu', reason };
2631
+ }
2632
+
2633
+ function _createBuffer(device, size, usage) {
2634
+ return device.createBuffer({ size, usage });
2635
+ }
2636
+
2637
+ function _mean$2(arr) {
2638
+ return arr.length ? arr.reduce((s, v) => s + v, 0) / arr.length : 0;
2639
+ }
2640
+
2641
+ function _std$1(arr) {
2642
+ const m = _mean$2(arr);
2643
+ return Math.sqrt(arr.reduce((s, v) => s + (v - m) ** 2, 0) / arr.length);
2644
+ }
2645
+
2646
+ function _timeout(ms, msg) {
2647
+ return new Promise((_, reject) => setTimeout(() => reject(new Error(msg)), ms));
2648
+ }
2649
+
2650
+ /**
2651
+ * @typedef {object} GpuEntropyResult
2652
+ * @property {boolean} gpuPresent
2653
+ * @property {boolean} isSoftware
2654
+ * @property {string|null} vendor
2655
+ * @property {string|null} architecture
2656
+ * @property {number[]} timings
2657
+ * @property {number} dispatchCV
2658
+ * @property {number} thermalGrowth
2659
+ * @property {string} verdict 'real_gpu' | 'virtual_gpu' | 'software_renderer' | 'no_gpu' | 'ambiguous'
2660
+ */
2661
+
2662
+ /**
2663
+ * @sovereign/pulse — DRAM Refresh Cycle Detector
2664
+ *
2665
+ * DDR4 DRAM refreshes every 7.8 ms (tREFI per JEDEC JESD79-4). During a
2666
+ * refresh, the memory controller stalls all access requests for ~350 ns.
2667
+ * In a tight sequential memory access loop this appears as a periodic
2668
+ * slowdown — detectable as a ~128Hz peak in the autocorrelation of access
2669
+ * timings.
2670
+ *
2671
+ * Virtual machines do not have physical DRAM. The hypervisor's memory
2672
+ * subsystem does not reproduce the refresh cycle because:
2673
+ * 1. The guest never touches real DRAM directly — there is always a
2674
+ * hypervisor-controlled indirection layer.
2675
+ * 2. EPT/NPT (Extended/Nested Page Tables) absorb the timing.
2676
+ * 3. The hypervisor's memory balloon driver further smooths access latency.
2677
+ *
2678
+ * What we detect
2679
+ * ──────────────
2680
+ * refreshPeriodMs estimated DRAM refresh period (should be ~7.8ms on real DDR4)
2681
+ * refreshPresent true if the ~7.8ms periodicity is statistically significant
2682
+ * peakLag autocorrelation lag with the highest power (units: sample index)
2683
+ * peakPower autocorrelation power at peakLag (0–1)
2684
+ * verdict 'dram' | 'virtual' | 'ambiguous'
2685
+ *
2686
+ * Calibration
2687
+ * ───────────
2688
+ * We allocate a buffer large enough to exceed all CPU caches (typically
2689
+ * L3 = 8–32 MB on consumer parts). Sequential reads then go to DRAM, not
2690
+ * cache. The refresh stall is only visible when we're actually hitting DRAM —
2691
+ * a cache-resident access loop shows no refresh signal.
2692
+ *
2693
+ * Buffer size: 64 MB — comfortably above L3 on all tested platforms.
2694
+ * Sampling interval: ~1 ms per iteration (chosen to resolve 7.8ms at ≥8 pts).
2695
+ * Total probe time: ~400 ms — well within the fingerprint collection window.
2696
+ */
2697
+
2698
+ const DRAM_REFRESH_MS = 7.8; // JEDEC DDR4 nominal
2699
+ const DRAM_REFRESH_SLACK = 1.5; // ±1.5 ms acceptable range for real hardware
2700
+ const BUFFER_MB = 64; // must exceed L3 cache
2701
+ const PROBE_ITERATIONS$1 = 400; // ~400 ms total
2702
+
2703
+ /* ─── collectDramTimings ─────────────────────────────────────────────────── */
2704
+
2705
+ /**
2706
+ * @param {object} [opts]
2707
+ * @param {number} [opts.iterations=400]
2708
+ * @param {number} [opts.bufferMb=64]
2709
+ * @returns {{ timings: number[], refreshPeriodMs: number|null,
2710
+ * refreshPresent: boolean, peakLag: number, peakPower: number,
2711
+ * verdict: string }}
2712
+ */
2713
+ function collectDramTimings(opts = {}) {
2714
+ const {
2715
+ iterations = PROBE_ITERATIONS$1,
2716
+ bufferMb = BUFFER_MB,
2717
+ } = opts;
2718
+
2719
+ // ── Allocate cache-busting buffer ────────────────────────────────────────
2720
+ const nElements = (bufferMb * 1024 * 1024) / 8; // 64-bit doubles
2721
+ let buf;
2722
+
2723
+ try {
2724
+ buf = new Float64Array(nElements);
2725
+ // Touch every cache line to ensure OS actually maps the pages
2726
+ const stride = 64 / 8; // 64-byte cache lines, 8 bytes per element
2727
+ for (let i = 0; i < nElements; i += stride) buf[i] = i;
2728
+ } catch {
2729
+ // Allocation failure (memory constrained) — cannot run this probe
2730
+ return _noSignal('buffer allocation failed');
2731
+ }
2732
+
2733
+ // ── Sequential access loop ───────────────────────────────────────────────
2734
+ // Each iteration does a full sequential pass over `passElements` worth of
2735
+ // the buffer. Pass size is tuned so each iteration takes ~1 ms wall-clock,
2736
+ // giving us enough resolution to see the 7.8 ms refresh cycle.
2737
+ //
2738
+ // We start with a small pass and auto-calibrate to hit the 1 ms target.
2739
+ const passElements = _calibratePassSize(buf);
2740
+
2741
+ const timings = new Float64Array(iterations);
2742
+ let checksum = 0;
2743
+
2744
+ for (let iter = 0; iter < iterations; iter++) {
2745
+ const t0 = performance.now();
2746
+ for (let i = 0; i < passElements; i++) checksum += buf[i];
2747
+ timings[iter] = performance.now() - t0;
2748
+ }
2749
+
2750
+ // Prevent dead-code elimination
2751
+ if (checksum === 0) buf[0] = 1;
2752
+
2753
+ // ── Autocorrelation over timings ─────────────────────────────────────────
2754
+ // The refresh stall appears as elevated autocorrelation at lag ≈ 7.8 / Δt
2755
+ // where Δt is the mean iteration time in ms.
2756
+ const meanIterMs = _mean$1(timings);
2757
+ if (meanIterMs <= 0) return _noSignal('zero mean iteration time');
2758
+
2759
+ const targetLag = Math.round(DRAM_REFRESH_MS / meanIterMs);
2760
+ const maxLag = Math.min(Math.round(50 / meanIterMs), iterations >> 1);
2761
+
2762
+ const ac = _autocorr(Array.from(timings), maxLag);
2763
+
2764
+ // Find the peak in the range [targetLag ± slack]
2765
+ const slackLags = Math.round(DRAM_REFRESH_SLACK / meanIterMs);
2766
+ const lagLo = Math.max(1, targetLag - slackLags);
2767
+ const lagHi = Math.min(maxLag, targetLag + slackLags);
2768
+
2769
+ let peakPower = -Infinity;
2770
+ let peakLag = targetLag;
2771
+ for (let l = lagLo; l <= lagHi; l++) {
2772
+ if (ac[l - 1] > peakPower) {
2773
+ peakPower = ac[l - 1];
2774
+ peakLag = l;
2775
+ }
2776
+ }
2777
+
2778
+ // Baseline: average autocorrelation outside the refresh window
2779
+ const baseline = _mean$1(
2780
+ Array.from({ length: maxLag }, (_, i) => ac[i])
2781
+ .filter((_, i) => i + 1 < lagLo || i + 1 > lagHi)
2782
+ );
2783
+
2784
+ const snr = baseline > 0 ? peakPower / baseline : 0;
2785
+ const refreshPresent = peakPower > 0.15 && snr > 1.8;
2786
+ const refreshPeriodMs = refreshPresent ? peakLag * meanIterMs : null;
2787
+
2788
+ const verdict =
2789
+ refreshPresent && refreshPeriodMs !== null &&
2790
+ Math.abs(refreshPeriodMs - DRAM_REFRESH_MS) < DRAM_REFRESH_SLACK
2791
+ ? 'dram'
2792
+ : peakPower < 0.05
2793
+ ? 'virtual'
2794
+ : 'ambiguous';
2795
+
2796
+ return {
2797
+ timings: Array.from(timings),
2798
+ refreshPeriodMs,
2799
+ refreshPresent,
2800
+ peakLag,
2801
+ peakPower: +peakPower.toFixed(4),
2802
+ snr: +snr.toFixed(2),
2803
+ meanIterMs: +meanIterMs.toFixed(3),
2804
+ verdict,
2805
+ };
2806
+ }
2807
+
2808
+ /* ─── helpers ────────────────────────────────────────────────────────────── */
2809
+
2810
+ function _noSignal(reason) {
2811
+ return {
2812
+ timings: [], refreshPeriodMs: null, refreshPresent: false,
2813
+ peakLag: 0, peakPower: 0, snr: 0, meanIterMs: 0,
2814
+ verdict: 'ambiguous', reason,
2815
+ };
2816
+ }
2817
+
2818
+ /**
2819
+ * Run a quick calibration pass to find how many elements to read per
2820
+ * iteration so each iteration takes approximately 1 ms.
2821
+ */
2822
+ function _calibratePassSize(buf) {
2823
+ const target = 1.0; // ms
2824
+ let n = Math.min(100_000, buf.length);
2825
+ let elapsed = 0;
2826
+ let dummy = 0;
2827
+
2828
+ // Warm up
2829
+ for (let i = 0; i < n; i++) dummy += buf[i];
2830
+
2831
+ // Measure
2832
+ const t0 = performance.now();
2833
+ for (let i = 0; i < n; i++) dummy += buf[i];
2834
+ elapsed = performance.now() - t0;
2835
+ if (dummy === 0) buf[0] = 1; // prevent DCE
2836
+
2837
+ if (elapsed <= 0) return n;
2838
+ return Math.min(buf.length, Math.round(n * (target / elapsed)));
2839
+ }
2840
+
2841
+ function _mean$1(arr) {
2842
+ if (!arr.length) return 0;
2843
+ return arr.reduce((s, v) => s + v, 0) / arr.length;
2844
+ }
2845
+
2846
+ function _autocorr(data, maxLag) {
2847
+ const n = data.length;
2848
+ const mean = _mean$1(data);
2849
+ let v = 0;
2850
+ for (let i = 0; i < n; i++) v += (data[i] - mean) ** 2;
2851
+ v /= n;
2852
+
2853
+ const result = new Float64Array(maxLag);
2854
+ if (v < 1e-14) return result;
2855
+
2856
+ for (let lag = 1; lag <= maxLag; lag++) {
2857
+ let cov = 0;
2858
+ for (let i = 0; i < n - lag; i++) {
2859
+ cov += (data[i] - mean) * (data[i + lag] - mean);
2860
+ }
2861
+ result[lag - 1] = cov / ((n - lag) * v);
2862
+ }
2863
+ return result;
2864
+ }
2865
+
2866
+ /**
2867
+ * @sovereign/pulse — SharedArrayBuffer Microsecond Timer
2868
+ *
2869
+ * Bypasses browser timer clamping (Brave 100µs cap, Firefox 20µs cap, Safari
2870
+ * 1ms cap) using Atomics.wait() which is exempt from clamping because it maps
2871
+ * directly to OS-level futex/semaphore primitives.
2872
+ *
2873
+ * Requirements
2874
+ * ────────────
2875
+ * The page must be served with Cross-Origin Isolation headers:
2876
+ * Cross-Origin-Opener-Policy: same-origin
2877
+ * Cross-Origin-Embedder-Policy: require-corp
2878
+ *
2879
+ * These are mandatory for security (Spectre mitigations) and are already
2880
+ * required by WebGPU, WebAssembly threads, and SharedArrayBuffer in all
2881
+ * modern browsers.
2882
+ *
2883
+ * What we measure
2884
+ * ───────────────
2885
+ * resolution the true timer resolution (pre-clamp) in microseconds
2886
+ * isClamped true if performance.now() is artificially reduced
2887
+ * clampAmount how much performance.now() was rounded (µs)
2888
+ * highResTimings entropy probe timings at true microsecond resolution
2889
+ *
2890
+ * Why this matters
2891
+ * ────────────────
2892
+ * With 1ms clamping, a VM's flat distribution and a real device's noisy
2893
+ * distribution can look similar — both get quantized to the same step.
2894
+ * At 1µs resolution, the difference between EJR=1.01 and EJR=1.24 is
2895
+ * unmistakable. This upgrade alone materially improves detection accuracy
2896
+ * on Brave and Firefox where timer clamping was previously a confound.
2897
+ */
2898
+
2899
+ /* ─── availability ───────────────────────────────────────────────────────── */
2900
+
2901
+ function isSabAvailable() {
2902
+ return (
2903
+ typeof SharedArrayBuffer !== 'undefined' &&
2904
+ typeof Atomics !== 'undefined' &&
2905
+ typeof Atomics.wait === 'function' &&
2906
+ crossOriginIsolated === true // window flag set by COOP+COEP headers
2907
+ );
2908
+ }
2909
+
2910
+ /* ─── Atomics-based high-resolution clock ───────────────────────────────── */
2911
+
2912
+ let _sab = null;
2913
+ let _i32 = null;
2914
+
2915
+ function _initSab() {
2916
+ if (!_sab) {
2917
+ _sab = new SharedArrayBuffer(4);
2918
+ _i32 = new Int32Array(_sab);
2919
+ }
2920
+ }
2921
+
2922
+ /**
2923
+ * Wait exactly `us` microseconds using Atomics.wait().
2924
+ * Returns wall-clock elapsed in milliseconds.
2925
+ * Much more accurate than setTimeout(fn, 0) or performance.now() loops.
2926
+ *
2927
+ * @param {number} us – microseconds to wait
2928
+ * @returns {number} actual elapsed ms
2929
+ */
2930
+ function _atomicsWait(us) {
2931
+ _initSab();
2932
+ const t0 = performance.now();
2933
+ Atomics.wait(_i32, 0, 0, us / 1000); // Atomics.wait timeout is in ms
2934
+ return performance.now() - t0;
2935
+ }
2936
+
2937
+ /* ─── measureClamp ───────────────────────────────────────────────────────── */
2938
+
2939
+ /**
2940
+ * Determine the true timer resolution by comparing a series of
2941
+ * sub-millisecond Atomics.wait() calls against performance.now() deltas.
2942
+ *
2943
+ * @returns {{ isClamped: boolean, clampAmountUs: number, resolutionUs: number }}
2944
+ */
2945
+ function measureClamp() {
2946
+ if (!isSabAvailable()) {
2947
+ return { isClamped: false, clampAmountUs: 0, resolutionUs: 1000 };
2948
+ }
2949
+
2950
+ // Measure the minimum non-zero performance.now() delta
2951
+ const performanceDeltas = [];
2952
+ for (let i = 0; i < 100; i++) {
2953
+ const t0 = performance.now();
2954
+ let t1 = t0;
2955
+ while (t1 === t0) t1 = performance.now();
2956
+ performanceDeltas.push((t1 - t0) * 1000); // convert to µs
2957
+ }
2958
+ performanceDeltas.sort((a, b) => a - b);
2959
+ const perfResolutionUs = performanceDeltas[Math.floor(performanceDeltas.length * 0.1)]; // 10th percentile
2960
+
2961
+ // Measure actual OS timer resolution via Atomics.wait
2962
+ const atomicsDeltas = [];
2963
+ for (let i = 0; i < 20; i++) {
2964
+ const elapsedMs = _atomicsWait(100); // wait 100µs
2965
+ atomicsDeltas.push(Math.abs(elapsedMs * 1000 - 100)); // error from target
2966
+ }
2967
+ const atomicsErrorUs = atomicsDeltas.reduce((s, v) => s + v, 0) / atomicsDeltas.length;
2968
+ const trueResolutionUs = Math.max(1, atomicsErrorUs);
2969
+
2970
+ const isClamped = perfResolutionUs > trueResolutionUs * 5;
2971
+ const clampAmountUs = isClamped ? perfResolutionUs - trueResolutionUs : 0;
2972
+
2973
+ return { isClamped, clampAmountUs, resolutionUs: perfResolutionUs };
2974
+ }
2975
+
2976
+ /* ─── collectHighResTimings ──────────────────────────────────────────────── */
2977
+
2978
+ /**
2979
+ * Collect entropy probe timings at Atomics-level resolution.
2980
+ * Falls back to performance.now() if SAB is unavailable.
2981
+ *
2982
+ * The probe itself is identical to the WASM matrix probe — CPU work unit
2983
+ * timed with the highest available clock. The difference: on a clamped
2984
+ * browser this replaces quantized 100µs buckets with true µs measurements.
2985
+ *
2986
+ * @param {object} opts
2987
+ * @param {number} [opts.iterations=200]
2988
+ * @param {number} [opts.matrixSize=32] – smaller than WASM probe (no SIMD here)
2989
+ * @returns {{ timings: number[], usingAtomics: boolean, resolutionUs: number }}
2990
+ */
2991
+ function collectHighResTimings(opts = {}) {
2992
+ const { iterations = 200, matrixSize = 32 } = opts;
2993
+
2994
+ const usingAtomics = isSabAvailable();
2995
+ const clampInfo = usingAtomics ? measureClamp() : { resolutionUs: 1000 };
2996
+
2997
+ // Simple matrix multiply work unit (JS — no WASM needed for the clock probe)
2998
+ const N = matrixSize;
2999
+ const A = new Float64Array(N * N).map(() => Math.random());
3000
+ const B = new Float64Array(N * N).map(() => Math.random());
3001
+ const C = new Float64Array(N * N);
3002
+
3003
+ const timings = new Array(iterations);
3004
+
3005
+ for (let iter = 0; iter < iterations; iter++) {
3006
+ C.fill(0);
3007
+
3008
+ if (usingAtomics) {
3009
+ // ── Atomics path: start timing, do work, read Atomics-calibrated time ──
3010
+ // We use a sliding window approach: measure with Atomics.wait(0) which
3011
+ // returns immediately but the OS schedules give us a high-res timestamp
3012
+ // via the before/after pattern on the shared memory notification.
3013
+ _initSab();
3014
+
3015
+ const tAtomicsBefore = _getAtomicsTs();
3016
+ for (let i = 0; i < N; i++) {
3017
+ for (let k = 0; k < N; k++) {
3018
+ const aik = A[i * N + k];
3019
+ for (let j = 0; j < N; j++) C[i * N + j] += aik * B[k * N + j];
3020
+ }
3021
+ }
3022
+ const tAtomicsAfter = _getAtomicsTs();
3023
+ timings[iter] = (tAtomicsAfter - tAtomicsBefore) * 1000; // µs → ms
3024
+
3025
+ } else {
3026
+ // ── Standard path: use performance.now() ──
3027
+ const t0 = performance.now();
3028
+ for (let i = 0; i < N; i++) {
3029
+ for (let k = 0; k < N; k++) {
3030
+ const aik = A[i * N + k];
3031
+ for (let j = 0; j < N; j++) C[i * N + j] += aik * B[k * N + j];
3032
+ }
3033
+ }
3034
+ timings[iter] = performance.now() - t0;
3035
+ }
3036
+ }
3037
+
3038
+ return {
3039
+ timings,
3040
+ usingAtomics,
3041
+ resolutionUs: clampInfo.resolutionUs,
3042
+ isClamped: clampInfo.isClamped ?? false,
3043
+ clampAmountUs: clampInfo.clampAmountUs ?? 0,
3044
+ };
3045
+ }
3046
+
3047
+ /* ─── internal Atomics timestamp ─────────────────────────────────────────── */
3048
+
3049
+ // Use a write to shared memory + memory fence as a timestamp anchor.
3050
+ // This forces the CPU to flush its store buffer, giving a hardware-ordered
3051
+ // time reference that survives compiler reordering.
3052
+ function _getAtomicsTs() {
3053
+ _initSab();
3054
+ Atomics.store(_i32, 0, Atomics.load(_i32, 0) + 1);
3055
+ return performance.now();
3056
+ }
3057
+
3058
+ /**
3059
+ * @sovereign/pulse — Electrical Network Frequency (ENF) Detection
3060
+ *
3061
+ * ┌─────────────────────────────────────────────────────────────────────────┐
3062
+ * │ WHAT THIS IS │
3063
+ * │ │
3064
+ * │ Power grids operate at a nominal frequency — 60 Hz in the Americas, │
3065
+ * │ 50 Hz in Europe, Asia, Africa, and Australia. This frequency is not │
3066
+ * │ perfectly stable. It deviates by ±0.05 Hz in real time as generators │
3067
+ * │ spin up and down to match load. These deviations are unique, logged │
3068
+ * │ by grid operators, and have been used in forensics since 2010 to │
3069
+ * │ timestamp recordings to within seconds. │
3070
+ * │ │
3071
+ * │ We are the first to measure it from a browser. │
3072
+ * └─────────────────────────────────────────────────────────────────────────┘
3073
+ *
3074
+ * Signal path
3075
+ * ───────────
3076
+ * AC mains (50/60 Hz)
3077
+ * → ATX power supply (full-wave rectified → 100/120 Hz ripple on DC rail)
3078
+ * → Voltage Regulator Module (VRM) on motherboard
3079
+ * → CPU Vcore (supply voltage to processor dies)
3080
+ * → Transistor switching speed (slightly modulated by Vcore)
3081
+ * → Matrix multiply loop timing (measurably longer when Vcore dips)
3082
+ * → Our microsecond-resolution timing probe
3083
+ *
3084
+ * The ripple amplitude at the timing layer is ~10–100 ns — invisible to
3085
+ * performance.now() at 1 ms resolution, clearly visible with Atomics-based
3086
+ * microsecond timing. This is why this module depends on sabTimer.js.
3087
+ *
3088
+ * What we detect
3089
+ * ──────────────
3090
+ * gridFrequency 50.0 or 60.0 Hz (nominal), ±0.5 Hz measured
3091
+ * gridRegion 'americas' (60 Hz) | 'emea_apac' (50 Hz) | 'unknown'
3092
+ * ripplePresent true if the 100/120 Hz harmonic is statistically significant
3093
+ * ripplePower power of the dominant grid harmonic (0–1)
3094
+ * enfDeviation precise measured frequency – nominal (Hz) — temporal fingerprint
3095
+ * temporalHash BLAKE3 of (enfDeviation + timestamp) — attestation anchor
3096
+ *
3097
+ * What this proves
3098
+ * ───────────────
3099
+ * 1. The device is connected to a real AC power grid (rules out cloud VMs,
3100
+ * UPS-backed datacenter servers, and battery-only devices off-grid)
3101
+ * 2. The geographic grid region (50 Hz vs 60 Hz — no IP, no location API)
3102
+ * 3. A temporal fingerprint that can be cross-referenced against public ENF
3103
+ * logs (e.g., www.gridwatch.templar.linux.org.uk) to verify the session
3104
+ * timestamp is authentic
3105
+ *
3106
+ * Why VMs fail
3107
+ * ────────────
3108
+ * Datacenter power is conditioned, filtered, and UPS-backed. Grid frequency
3109
+ * deviations are removed before they reach the server. Cloud VMs receive
3110
+ * perfectly regulated power — the ENF signal does not exist in their timing
3111
+ * measurements. This is a physical property of datacenter infrastructure,
3112
+ * not a software configuration that can be patched or spoofed.
3113
+ *
3114
+ * A VM attempting to inject synthetic ENF ripple into its virtual clock
3115
+ * would need to:
3116
+ * 1. Know the real-time ENF of the target grid region (requires live API)
3117
+ * 2. Modulate the virtual TSC at sub-microsecond precision
3118
+ * 3. Match the precise VRM transfer function of the target motherboard
3119
+ * This is not a realistic attack surface.
3120
+ *
3121
+ * Battery devices
3122
+ * ───────────────
3123
+ * Laptops on battery have no AC ripple. The module detects this via absence
3124
+ * of both 100 Hz and 120 Hz signal, combined with very low ripple variance.
3125
+ * This is handled by the 'battery_or_conditioned' verdict — treated as
3126
+ * inconclusive rather than VM (real laptops exist).
3127
+ *
3128
+ * Required: crossOriginIsolated = true (COOP + COEP headers)
3129
+ * The SAB microsecond timer is required for ENF detection. On browsers where
3130
+ * it is unavailable, the module returns { enfAvailable: false }.
3131
+ */
3132
+
3133
+
3134
+ // ── Grid frequency constants ──────────────────────────────────────────────────
3135
+ const GRID_60HZ_NOMINAL = 60.0; // Americas, parts of Japan & Korea
3136
+ const GRID_50HZ_NOMINAL = 50.0; // EMEA, APAC, most of Asia
3137
+ const RIPPLE_60HZ = 120.0; // Full-wave rectified: 2 × 60 Hz
3138
+ const RIPPLE_50HZ = 100.0; // Full-wave rectified: 2 × 50 Hz
3139
+ const RIPPLE_SLACK_HZ = 2.0; // ±2 Hz around nominal (accounts for VRM response)
3140
+ const MIN_RIPPLE_POWER = 0.04; // Minimum power ratio to declare ripple present
3141
+ const SNR_THRESHOLD = 2.0; // Signal-to-noise ratio for confident detection
3142
+
3143
+ // ── Probe parameters ──────────────────────────────────────────────────────────
3144
+ // We need enough samples at sufficient rate to resolve 100–120 Hz.
3145
+ // Nyquist: sample_rate > 240 Hz (need >2× the highest target frequency).
3146
+ // With ~1 ms per iteration, 100 Hz ≈ 10 samples per cycle — adequate.
3147
+ // We want at least 20 full cycles → 200 iterations minimum.
3148
+ const PROBE_ITERATIONS = 512; // power of 2 for clean FFT
3149
+ const PROBE_MATRIX_SIZE = 16; // small matrix → ~1 ms/iter → ~500 Hz sample rate
3150
+
3151
+ /* ─── collectEnfTimings ─────────────────────────────────────────────────────── */
3152
+
3153
+ /**
3154
+ * @param {object} [opts]
3155
+ * @param {number} [opts.iterations=512]
3156
+ * @returns {Promise<EnfResult>}
3157
+ */
3158
+ async function collectEnfTimings(opts = {}) {
3159
+ const { iterations = PROBE_ITERATIONS } = opts;
3160
+
3161
+ if (!isSabAvailable()) {
3162
+ return _noEnf('SharedArrayBuffer not available — COOP+COEP headers required');
3163
+ }
3164
+
3165
+ // Collect high-resolution CPU timing series
3166
+ const { timings, resolutionUs } = collectHighResTimings({
3167
+ iterations,
3168
+ matrixSize: PROBE_MATRIX_SIZE,
3169
+ });
3170
+
3171
+ if (timings.length < 128) {
3172
+ return _noEnf('insufficient timing samples');
3173
+ }
3174
+
3175
+ // Estimate the sample rate from actual timing
3176
+ const meanIterMs = timings.reduce((s, v) => s + v, 0) / timings.length;
3177
+ const sampleRateHz = meanIterMs > 0 ? 1000 / meanIterMs : 0;
3178
+
3179
+ if (sampleRateHz < 60) {
3180
+ return _noEnf(`sample rate too low for ENF detection: ${sampleRateHz.toFixed(0)} Hz`);
3181
+ }
3182
+
3183
+ // ── Power Spectral Density ────────────────────────────────────────────────
3184
+ timings.length;
3185
+ const psd = _computePsd(timings, sampleRateHz);
3186
+
3187
+ // Find the dominant frequency peak
3188
+ const peakIdx = psd.reduce((best, v, i) => v > psd[best] ? i : best, 0);
3189
+ psd.freqs[peakIdx];
3190
+
3191
+ // Power in 100 Hz window vs 120 Hz window
3192
+ const power100 = _bandPower$1(psd, RIPPLE_50HZ, RIPPLE_SLACK_HZ);
3193
+ const power120 = _bandPower$1(psd, RIPPLE_60HZ, RIPPLE_SLACK_HZ);
3194
+ const baseline = _baselinePower(psd, [
3195
+ [RIPPLE_50HZ - RIPPLE_SLACK_HZ, RIPPLE_50HZ + RIPPLE_SLACK_HZ],
3196
+ [RIPPLE_60HZ - RIPPLE_SLACK_HZ, RIPPLE_60HZ + RIPPLE_SLACK_HZ],
3197
+ ]);
3198
+
3199
+ const snr100 = baseline > 0 ? power100 / baseline : 0;
3200
+ const snr120 = baseline > 0 ? power120 / baseline : 0;
3201
+
3202
+ // ── Verdict ───────────────────────────────────────────────────────────────
3203
+ const has100 = power100 > MIN_RIPPLE_POWER && snr100 > SNR_THRESHOLD;
3204
+ const has120 = power120 > MIN_RIPPLE_POWER && snr120 > SNR_THRESHOLD;
3205
+
3206
+ let gridFrequency = null;
3207
+ let gridRegion = 'unknown';
3208
+ let ripplePower = 0;
3209
+ let nominalHz = null;
3210
+
3211
+ if (has120 && power120 >= power100) {
3212
+ gridFrequency = GRID_60HZ_NOMINAL;
3213
+ gridRegion = 'americas';
3214
+ ripplePower = power120;
3215
+ nominalHz = RIPPLE_60HZ;
3216
+ } else if (has100) {
3217
+ gridFrequency = GRID_50HZ_NOMINAL;
3218
+ gridRegion = 'emea_apac';
3219
+ ripplePower = power100;
3220
+ nominalHz = RIPPLE_50HZ;
3221
+ }
3222
+
3223
+ const ripplePresent = has100 || has120;
3224
+
3225
+ // ── ENF deviation (temporal fingerprint) ─────────────────────────────────
3226
+ // The precise ripple frequency deviates from nominal by ±0.1 Hz in real time.
3227
+ // We measure the peak frequency in the ripple band to extract this deviation.
3228
+ let enfDeviation = null;
3229
+ if (ripplePresent && nominalHz !== null) {
3230
+ const preciseRippleFreq = _precisePeakFreq(psd, nominalHz, RIPPLE_SLACK_HZ);
3231
+ enfDeviation = +(preciseRippleFreq - nominalHz).toFixed(3); // Hz deviation from nominal
3232
+ }
3233
+
3234
+ // ── Verdict ───────────────────────────────────────────────────────────────
3235
+ const verdict =
3236
+ !ripplePresent ? 'no_grid_signal' // VM, UPS, or battery
3237
+ : gridRegion === 'americas' ? 'grid_60hz'
3238
+ : gridRegion === 'emea_apac' ? 'grid_50hz'
3239
+ : 'grid_detected_region_unknown';
3240
+
3241
+ const isVmIndicator = !ripplePresent && sampleRateHz > 100;
3242
+ // High sample rate + no ripple = conditioned power (datacenter)
3243
+
3244
+ return {
3245
+ enfAvailable: true,
3246
+ ripplePresent,
3247
+ gridFrequency,
3248
+ gridRegion,
3249
+ ripplePower: +ripplePower.toFixed(4),
3250
+ snr50hz: +snr100.toFixed(2),
3251
+ snr60hz: +snr120.toFixed(2),
3252
+ enfDeviation,
3253
+ sampleRateHz: +sampleRateHz.toFixed(1),
3254
+ resolutionUs,
3255
+ verdict,
3256
+ isVmIndicator,
3257
+ // For cross-referencing against public ENF databases (forensic timestamp)
3258
+ temporalAnchor: enfDeviation !== null ? {
3259
+ nominalHz,
3260
+ measuredRippleHz: +(nominalHz + enfDeviation).toFixed(4),
3261
+ capturedAt: Date.now(),
3262
+ // Matches format used by ENF forensic databases:
3263
+ // https://www.enf.cc | UK National Grid ESO data
3264
+ gridHz: gridFrequency,
3265
+ } : null,
3266
+ };
3267
+ }
3268
+
3269
+ /* ─── Power Spectral Density (Welch-inspired DFT) ───────────────────────── */
3270
+
3271
+ function _computePsd(signal, sampleRateHz) {
3272
+ const n = signal.length;
3273
+ const mean = signal.reduce((s, v) => s + v, 0) / n;
3274
+
3275
+ // Remove DC offset and apply Hann window
3276
+ const windowed = signal.map((v, i) => {
3277
+ const w = 0.5 * (1 - Math.cos((2 * Math.PI * i) / (n - 1))); // Hann
3278
+ return (v - mean) * w;
3279
+ });
3280
+
3281
+ // DFT up to Nyquist — only need up to ~200 Hz so we cap bins
3282
+ const maxFreq = Math.min(200, sampleRateHz / 2);
3283
+ const maxBin = Math.floor(maxFreq * n / sampleRateHz);
3284
+
3285
+ const powers = new Float64Array(maxBin);
3286
+ const freqs = new Float64Array(maxBin);
3287
+
3288
+ for (let k = 1; k < maxBin; k++) {
3289
+ let re = 0, im = 0;
3290
+ for (let t = 0; t < n; t++) {
3291
+ const angle = (2 * Math.PI * k * t) / n;
3292
+ re += windowed[t] * Math.cos(angle);
3293
+ im -= windowed[t] * Math.sin(angle);
3294
+ }
3295
+ powers[k] = (re * re + im * im) / (n * n);
3296
+ freqs[k] = (k * sampleRateHz) / n;
3297
+ }
3298
+
3299
+ // Normalise powers so they sum to 1 (makes thresholds sample-count-independent)
3300
+ const total = powers.reduce((s, v) => s + v, 0);
3301
+ if (total > 0) for (let i = 0; i < powers.length; i++) powers[i] /= total;
3302
+
3303
+ return { powers, freqs };
3304
+ }
3305
+
3306
+ function _bandPower$1(psd, centerHz, halfwidthHz) {
3307
+ let power = 0;
3308
+ for (let i = 0; i < psd.freqs.length; i++) {
3309
+ if (Math.abs(psd.freqs[i] - centerHz) <= halfwidthHz) {
3310
+ power += psd.powers[i];
3311
+ }
3312
+ }
3313
+ return power;
3314
+ }
3315
+
3316
+ function _baselinePower(psd, excludeBands) {
3317
+ let sum = 0, count = 0;
3318
+ for (let i = 0; i < psd.freqs.length; i++) {
3319
+ const f = psd.freqs[i];
3320
+ const excluded = excludeBands.some(([lo, hi]) => f >= lo && f <= hi);
3321
+ if (!excluded && f > 10 && f < 200) { sum += psd.powers[i]; count++; }
3322
+ }
3323
+ return count > 0 ? sum / count : 0;
3324
+ }
3325
+
3326
+ function _precisePeakFreq(psd, centerHz, halfwidthHz) {
3327
+ // Quadratic interpolation around the peak bin for sub-bin precision
3328
+ let peakBin = 0, peakPow = -Infinity;
3329
+ for (let i = 0; i < psd.freqs.length; i++) {
3330
+ if (Math.abs(psd.freqs[i] - centerHz) <= halfwidthHz && psd.powers[i] > peakPow) {
3331
+ peakPow = psd.powers[i]; peakBin = i;
3332
+ }
3333
+ }
3334
+ if (peakBin <= 0 || peakBin >= psd.powers.length - 1) return psd.freqs[peakBin];
3335
+
3336
+ // Quadratic peak interpolation (Jacobsen method)
3337
+ const alpha = psd.powers[peakBin - 1];
3338
+ const beta = psd.powers[peakBin];
3339
+ const gamma = psd.powers[peakBin + 1];
3340
+ const denom = alpha - 2 * beta + gamma;
3341
+ if (Math.abs(denom) < 1e-14) return psd.freqs[peakBin];
3342
+ const deltaBin = 0.5 * (alpha - gamma) / denom;
3343
+ const binWidth = psd.freqs[1] - psd.freqs[0];
3344
+ return psd.freqs[peakBin] + deltaBin * binWidth;
3345
+ }
3346
+
3347
+ function _noEnf(reason) {
3348
+ return {
3349
+ enfAvailable: false, ripplePresent: false, gridFrequency: null,
3350
+ gridRegion: 'unknown', ripplePower: 0, snr50hz: 0, snr60hz: 0,
3351
+ enfDeviation: null, sampleRateHz: 0, resolutionUs: 0,
3352
+ verdict: 'unavailable', isVmIndicator: false, temporalAnchor: null, reason,
3353
+ };
3354
+ }
3355
+
3356
+ /**
3357
+ * @typedef {object} EnfResult
3358
+ * @property {boolean} enfAvailable
3359
+ * @property {boolean} ripplePresent false = VM / datacenter / battery
3360
+ * @property {number|null} gridFrequency 50 or 60 Hz
3361
+ * @property {string} gridRegion 'americas' | 'emea_apac' | 'unknown'
3362
+ * @property {number} ripplePower normalised PSD power at grid harmonic
3363
+ * @property {number|null} enfDeviation Hz deviation from nominal (temporal fingerprint)
3364
+ * @property {string} verdict
3365
+ * @property {boolean} isVmIndicator true if signal absence + high sample rate
3366
+ * @property {object|null} temporalAnchor forensic timestamp anchor
3367
+ */
3368
+
3369
+ /**
3370
+ * @sovereign/pulse — LLM / AI Agent Behavioral Fingerprint
3371
+ *
3372
+ * Detects automation driven by large language models, headless browsers
3373
+ * controlled by AI agents (AutoGPT, CrewAI, browser-use, Playwright+LLM,
3374
+ * Selenium+GPT-4), and synthetic user emulators.
3375
+ *
3376
+ * Why LLMs are detectable at the behavioral layer
3377
+ * ───────────────────────────────────────────────
3378
+ * A human interacting with a browser produces a signal shaped by:
3379
+ * – Motor control noise (Fitts' Law, signal-dependent noise in arm movement)
3380
+ * – Cognitive processing time (fixation → decision → motor initiation)
3381
+ * – Error and correction cycles (overshooting, backspacing, re-reading)
3382
+ * – Physiological rhythms (micro-tremor at 8–12 Hz, respiration at 0.2–0.3 Hz)
3383
+ *
3384
+ * An LLM agent produces:
3385
+ * – Think-time spikes at multiples of the model's token generation latency
3386
+ * (GPT-4 Turbo: ~50ms/token; Claude 3: ~30ms/token)
3387
+ * – Mouse paths generated by a trajectory model (no signal-dependent noise)
3388
+ * – Keystrokes at WPM limited by the agent's typing function, not human anatomy
3389
+ * – Absent micro-corrections (humans correct 7–12% of keystrokes; agents: 0%)
3390
+ *
3391
+ * Signals
3392
+ * ───────
3393
+ * thinkTimePattern peak in inter-event timing at known LLM token latencies
3394
+ * mousePathSmoothness human paths are fractal; LLM paths are piecewise linear
3395
+ * correctionRate keystrokes followed by Backspace (human: 7–12%, LLM: <1%)
3396
+ * pauseDistribution human pauses are Pareto-distributed; LLM pauses are uniform
3397
+ * rhythmicity presence of physiological tremor (8–12 Hz) in pointer data
3398
+ * eventGapCV coefficient of variation of inter-event gaps
3399
+ *
3400
+ * Scoring
3401
+ * ───────
3402
+ * Each signal contributes a weight to an overall `aiConf` score (0–1).
3403
+ * A score above 0.70 indicates likely AI agent. Above 0.85 is high confidence.
3404
+ * The score is designed to be combined with the physics layer — AI agents running
3405
+ * on real hardware (a human's machine being remote-controlled) will pass the
3406
+ * physics check but fail the behavioral check.
3407
+ */
3408
+
3409
+ // ── Known LLM token latency ranges (ms per token, observed empirically) ──────
3410
+ // These appear as periodic peaks in inter-event timing when the LLM is
3411
+ // "thinking" between actions.
3412
+ const LLM_LATENCY_RANGES = [
3413
+ { name: 'gpt-4-turbo', minMs: 40, maxMs: 80 },
3414
+ { name: 'gpt-4o', minMs: 20, maxMs: 50 },
3415
+ { name: 'claude-3-sonnet', minMs: 25, maxMs: 60 },
3416
+ { name: 'claude-3-opus', minMs: 50, maxMs: 120 },
3417
+ { name: 'gemini-1.5-pro', minMs: 30, maxMs: 70 },
3418
+ { name: 'llama-3-70b', minMs: 15, maxMs: 45 },
3419
+ ];
3420
+
3421
+ // ── Human physiological constants ─────────────────────────────────────────────
3422
+ const HUMAN_TREMOR_HZ_LO = 8; // micro-tremor band low (Hz)
3423
+ const HUMAN_TREMOR_HZ_HI = 12; // micro-tremor band high (Hz)
3424
+ const HUMAN_CORRECTION_MIN = 0.05; // minimum human backspace rate
3425
+ const HUMAN_CORRECTION_MAX = 0.18; // maximum human backspace rate
3426
+
3427
+ /* ─── Public API ─────────────────────────────────────────────────────────── */
3428
+
3429
+ /**
3430
+ * Analyse collected behavioral signals for AI agent indicators.
3431
+ *
3432
+ * @param {object} signals
3433
+ * @param {Array<{t:number, x:number, y:number}>} [signals.mouseEvents] – {t ms, x, y}
3434
+ * @param {Array<{t:number, key:string}>} [signals.keyEvents] – {t ms, key}
3435
+ * @param {Array<number>} [signals.interEventGaps] – ms between any UI events
3436
+ * @returns {LlmFingerprint}
3437
+ */
3438
+ function detectLlmAgent(signals = {}) {
3439
+ const {
3440
+ mouseEvents = [],
3441
+ keyEvents = [],
3442
+ interEventGaps = [],
3443
+ } = signals;
3444
+
3445
+ const checks = [];
3446
+
3447
+ // ── 1. Think-time pattern ────────────────────────────────────────────────
3448
+ if (interEventGaps.length >= 20) {
3449
+ const thinkCheck = _analyseThinkTime(interEventGaps);
3450
+ checks.push(thinkCheck);
3451
+ }
3452
+
3453
+ // ── 2. Mouse path smoothness ─────────────────────────────────────────────
3454
+ if (mouseEvents.length >= 30) {
3455
+ const pathCheck = _analyseMousePath(mouseEvents);
3456
+ checks.push(pathCheck);
3457
+ }
3458
+
3459
+ // ── 3. Keystroke correction rate ─────────────────────────────────────────
3460
+ if (keyEvents.length >= 15) {
3461
+ const corrCheck = _analyseCorrectionRate(keyEvents);
3462
+ checks.push(corrCheck);
3463
+ }
3464
+
3465
+ // ── 4. Physiological tremor ───────────────────────────────────────────────
3466
+ if (mouseEvents.length >= 50) {
3467
+ const tremorCheck = _analyseTremor(mouseEvents);
3468
+ checks.push(tremorCheck);
3469
+ }
3470
+
3471
+ // ── 5. Inter-event gap distribution ──────────────────────────────────────
3472
+ if (interEventGaps.length >= 30) {
3473
+ const gapCheck = _analyseGapDistribution(interEventGaps);
3474
+ checks.push(gapCheck);
3475
+ }
3476
+
3477
+ if (!checks.length) {
3478
+ return { aiConf: 0, humanConf: 0, checks: [], verdict: 'insufficient_data',
3479
+ dataPoints: { mouseEvents: mouseEvents.length,
3480
+ keyEvents: keyEvents.length,
3481
+ gaps: interEventGaps.length } };
3482
+ }
3483
+
3484
+ // Weighted average — weight by how many data points each check had
3485
+ const totalWeight = checks.reduce((s, c) => s + c.weight, 0);
3486
+ const aiConf = checks.reduce((s, c) => s + c.aiScore * c.weight, 0) / totalWeight;
3487
+ const humanConf = checks.reduce((s, c) => s + c.humanScore * c.weight, 0) / totalWeight;
3488
+
3489
+ const verdict =
3490
+ aiConf > 0.85 ? 'ai_agent_high_confidence'
3491
+ : aiConf > 0.70 ? 'ai_agent_likely'
3492
+ : humanConf > 0.75 ? 'human_likely'
3493
+ : 'ambiguous';
3494
+
3495
+ return {
3496
+ aiConf: +aiConf.toFixed(3),
3497
+ humanConf: +humanConf.toFixed(3),
3498
+ checks,
3499
+ verdict,
3500
+ dataPoints: {
3501
+ mouseEvents: mouseEvents.length,
3502
+ keyEvents: keyEvents.length,
3503
+ gaps: interEventGaps.length,
3504
+ },
3505
+ };
3506
+ }
3507
+
3508
+ /* ─── Internal checks ────────────────────────────────────────────────────── */
3509
+
3510
+ function _analyseThinkTime(gaps) {
3511
+ // Look for peaks in gap histogram that align with known LLM latency ranges
3512
+ const histogram = _histogram(gaps, 200); // 200 bins over the gap range
3513
+
3514
+ let matchScore = 0;
3515
+ const matched = [];
3516
+
3517
+ for (const llm of LLM_LATENCY_RANGES) {
3518
+ const binPower = _histogramPowerInRange(histogram, llm.minMs, llm.maxMs);
3519
+ if (binPower > 0.15) { // >15% of all gaps fall in this LLM's latency range
3520
+ matchScore = Math.max(matchScore, binPower);
3521
+ matched.push(llm.name);
3522
+ }
3523
+ }
3524
+
3525
+ // Human think times follow a Pareto distribution: many short, exponentially
3526
+ // fewer long pauses. A spike at a fixed latency range is anomalous.
3527
+ const cv = _cv(gaps);
3528
+ const isPareto = cv > 1.0; // Pareto CV is always > 1
3529
+
3530
+ return {
3531
+ name: 'think_time',
3532
+ aiScore: matchScore > 0.20 ? Math.min(1, matchScore * 3) : 0,
3533
+ humanScore: isPareto && matchScore < 0.10 ? 0.8 : 0.2,
3534
+ weight: Math.min(gaps.length / 50, 1),
3535
+ detail: { matchedLlms: matched, peakBinPower: matchScore, isPareto },
3536
+ };
3537
+ }
3538
+
3539
+ function _analyseMousePath(events) {
3540
+ // Compute path curvature at each triplet of points
3541
+ // Human paths are fractal (self-similar at multiple scales); AI paths are
3542
+ // smooth cubic splines or straight lines with programmatic waypoints.
3543
+ const curvatures = [];
3544
+ for (let i = 1; i < events.length - 1; i++) {
3545
+ const p0 = events[i - 1];
3546
+ const p1 = events[i];
3547
+ const p2 = events[i + 1];
3548
+
3549
+ const d01 = Math.hypot(p1.x - p0.x, p1.y - p0.y);
3550
+ const d12 = Math.hypot(p2.x - p1.x, p2.y - p1.y);
3551
+ const d02 = Math.hypot(p2.x - p0.x, p2.y - p0.y);
3552
+
3553
+ // Curvature = deviation from straight line (0 = straight, 1 = sharp turn)
3554
+ if (d01 + d12 > 0) {
3555
+ curvatures.push(1 - (d02 / (d01 + d12)));
3556
+ }
3557
+ }
3558
+
3559
+ if (!curvatures.length) return { name: 'mouse_path', aiScore: 0, humanScore: 0.5, weight: 0.1, detail: {} };
3560
+
3561
+ const meanCurv = _mean(curvatures);
3562
+ const cvCurv = _cv(curvatures);
3563
+
3564
+ // Human: moderate mean curvature (0.05–0.25), high CV (varying turns)
3565
+ // AI agent: very low mean curvature (near-straight lines), low CV (consistent)
3566
+ const isTooSmooth = meanCurv < 0.02 && cvCurv < 0.3;
3567
+ const isTooRegular = cvCurv < 0.2 && meanCurv > 0 && meanCurv < 0.05;
3568
+
3569
+ // Velocity profile: human acceleration follows a bell curve (min jerk model)
3570
+ // AI: piecewise constant velocity (linear interpolation between waypoints)
3571
+ const speeds = [];
3572
+ for (let i = 1; i < events.length; i++) {
3573
+ const dt = events[i].t - events[i - 1].t;
3574
+ const ds = Math.hypot(events[i].x - events[i - 1].x, events[i].y - events[i - 1].y);
3575
+ if (dt > 0) speeds.push(ds / dt);
3576
+ }
3577
+ const speedCV = _cv(speeds);
3578
+
3579
+ // Human speed is highly variable (CV > 0.5); AI speed is consistent (CV < 0.3)
3580
+ const aiScore = (
3581
+ (isTooSmooth ? 0.40 : 0) +
3582
+ (isTooRegular ? 0.30 : 0) +
3583
+ (speedCV < 0.25 ? 0.30 : speedCV < 0.40 ? 0.15 : 0)
3584
+ );
3585
+
3586
+ return {
3587
+ name: 'mouse_path',
3588
+ aiScore: Math.min(1, aiScore),
3589
+ humanScore: aiScore < 0.2 ? 0.8 : 0.2,
3590
+ weight: Math.min(events.length / 100, 1),
3591
+ detail: { meanCurvature: +meanCurv.toFixed(4), curvatureCV: +cvCurv.toFixed(3), speedCV: +speedCV.toFixed(3) },
3592
+ };
3593
+ }
3594
+
3595
+ function _analyseCorrectionRate(keyEvents) {
3596
+ const total = keyEvents.length;
3597
+ const backspaces = keyEvents.filter(e => e.key === 'Backspace').length;
3598
+ const rate = backspaces / total;
3599
+
3600
+ // Human: 5–18% correction rate (typos, editing)
3601
+ // AI agent: <1% (generates correct text directly from LLM output)
3602
+ const isTooClean = rate < HUMAN_CORRECTION_MIN;
3603
+ const isHuman = rate >= HUMAN_CORRECTION_MIN && rate <= HUMAN_CORRECTION_MAX;
3604
+
3605
+ return {
3606
+ name: 'correction_rate',
3607
+ aiScore: isTooClean ? 0.75 : 0,
3608
+ humanScore: isHuman ? 0.85 : 0.2,
3609
+ weight: Math.min(total / 30, 1),
3610
+ detail: { correctionRate: +rate.toFixed(3), backspaces, total },
3611
+ };
3612
+ }
3613
+
3614
+ function _analyseTremor(mouseEvents) {
3615
+ // Extract velocity time series and look for 8–12 Hz component
3616
+ // Human hands exhibit involuntary micro-tremor in this band.
3617
+ if (mouseEvents.length < 50) return { name: 'tremor', aiScore: 0, humanScore: 0.5, weight: 0.1, detail: {} };
3618
+
3619
+ const dt = (mouseEvents[mouseEvents.length - 1].t - mouseEvents[0].t) / (mouseEvents.length - 1);
3620
+ const sampleHz = dt > 0 ? 1000 / dt : 0;
3621
+
3622
+ if (sampleHz < 30) {
3623
+ // Not enough temporal resolution to detect 8–12 Hz
3624
+ return { name: 'tremor', aiScore: 0, humanScore: 0.5, weight: 0.1, detail: { reason: 'low_sample_rate' } };
3625
+ }
3626
+
3627
+ // Compute x-velocity series
3628
+ const vx = [];
3629
+ for (let i = 1; i < mouseEvents.length; i++) {
3630
+ const dtt = mouseEvents[i].t - mouseEvents[i - 1].t;
3631
+ vx.push(dtt > 0 ? (mouseEvents[i].x - mouseEvents[i - 1].x) / dtt : 0);
3632
+ }
3633
+
3634
+ // Rough power estimation in the tremor band using DFT on a windowed segment
3635
+ const n = Math.min(vx.length, 256);
3636
+ const segment = vx.slice(0, n);
3637
+ const tremorPower = _bandPower(segment, sampleHz, HUMAN_TREMOR_HZ_LO, HUMAN_TREMOR_HZ_HI);
3638
+ const totalPower = _bandPower(segment, sampleHz, 0, sampleHz / 2);
3639
+ const tremorRatio = totalPower > 0 ? tremorPower / totalPower : 0;
3640
+
3641
+ // Human: some tremor power present (ratio > 0.03)
3642
+ // AI: tremor band is silent (ratio ≈ 0)
3643
+ const hasTremor = tremorRatio > 0.03;
3644
+
3645
+ return {
3646
+ name: 'tremor',
3647
+ aiScore: hasTremor ? 0 : 0.55,
3648
+ humanScore: hasTremor ? 0.75 : 0.1,
3649
+ weight: 0.6,
3650
+ detail: { tremorRatio: +tremorRatio.toFixed(4), sampleHz: +sampleHz.toFixed(1), hasTremor },
3651
+ };
3652
+ }
3653
+
3654
+ function _analyseGapDistribution(gaps) {
3655
+ // Human inter-event gaps follow a heavy-tailed Pareto/lognormal distribution.
3656
+ // AI agents produce gaps that cluster around fixed latencies (think-time = API call)
3657
+ // making the distribution multimodal with low overall entropy.
3658
+ const cv = _cv(gaps);
3659
+ const skew = _skewness(gaps);
3660
+ const entropy = _shannonEntropy(gaps, 50);
3661
+
3662
+ // Human: high CV (>0.8), right-skewed (skew > 1), decent entropy (>3 bits)
3663
+ // AI: moderate CV, low skew, low entropy (gaps cluster at API latency values)
3664
+ const humanScore = (
3665
+ (cv > 0.8 ? 0.35 : cv > 0.5 ? 0.15 : 0) +
3666
+ (skew > 1.0 ? 0.35 : skew > 0.5 ? 0.15 : 0) +
3667
+ (entropy > 3.5 ? 0.30 : entropy > 2.5 ? 0.15 : 0)
3668
+ );
3669
+
3670
+ const aiScore = (
3671
+ (cv < 0.4 ? 0.40 : 0) +
3672
+ (skew < 0.3 ? 0.30 : 0) +
3673
+ (entropy < 2.0 ? 0.30 : 0)
3674
+ );
3675
+
3676
+ return {
3677
+ name: 'gap_distribution',
3678
+ aiScore: Math.min(1, aiScore),
3679
+ humanScore: Math.min(1, humanScore),
3680
+ weight: Math.min(gaps.length / 60, 1),
3681
+ detail: { cv: +cv.toFixed(3), skewness: +skew.toFixed(3), entropyBits: +entropy.toFixed(2) },
3682
+ };
3683
+ }
3684
+
3685
+ /* ─── Math helpers ───────────────────────────────────────────────────────── */
3686
+
3687
+ function _mean(arr) {
3688
+ return arr.length ? arr.reduce((s, v) => s + v, 0) / arr.length : 0;
3689
+ }
3690
+
3691
+ function _std(arr) {
3692
+ const m = _mean(arr);
3693
+ return Math.sqrt(arr.reduce((s, v) => s + (v - m) ** 2, 0) / arr.length);
3694
+ }
3695
+
3696
+ function _cv(arr) {
3697
+ const m = _mean(arr);
3698
+ return m > 0 ? _std(arr) / m : 0;
3699
+ }
3700
+
3701
+ function _skewness(arr) {
3702
+ const m = _mean(arr);
3703
+ const s = _std(arr);
3704
+ if (s === 0) return 0;
3705
+ const n = arr.length;
3706
+ return arr.reduce((sum, v) => sum + ((v - m) / s) ** 3, 0) / n;
3707
+ }
3708
+
3709
+ function _shannonEntropy(values, bins) {
3710
+ if (!values.length) return 0;
3711
+ const min = Math.min(...values);
3712
+ const max = Math.max(...values);
3713
+ if (max === min) return 0;
3714
+ const width = (max - min) / bins;
3715
+ const counts = new Array(bins).fill(0);
3716
+ for (const v of values) {
3717
+ const b = Math.min(bins - 1, Math.floor((v - min) / width));
3718
+ counts[b]++;
3719
+ }
3720
+ const n = values.length;
3721
+ return -counts.reduce((s, c) => {
3722
+ if (c === 0) return s;
3723
+ const p = c / n;
3724
+ return s + p * Math.log2(p);
3725
+ }, 0);
3726
+ }
3727
+
3728
+ function _histogram(values, bins) {
3729
+ if (!values.length) return { bins: [], min: 0, max: 0, binWidth: 0 };
3730
+ const min = Math.min(...values);
3731
+ const max = Math.max(...values) + 1e-9;
3732
+ const binWidth = (max - min) / bins;
3733
+ const counts = new Array(bins).fill(0);
3734
+ for (const v of values) counts[Math.floor((v - min) / binWidth)]++;
3735
+ return { bins: counts, min, max, binWidth };
3736
+ }
3737
+
3738
+ function _histogramPowerInRange(hist, lo, hi) {
3739
+ const total = hist.bins.reduce((s, c) => s + c, 0);
3740
+ if (!total) return 0;
3741
+ let power = 0;
3742
+ for (let i = 0; i < hist.bins.length; i++) {
3743
+ const center = hist.min + (i + 0.5) * hist.binWidth;
3744
+ if (center >= lo && center <= hi) power += hist.bins[i];
3745
+ }
3746
+ return power / total;
3747
+ }
3748
+
3749
+ // Discrete Fourier Transform power in a frequency band (O(n²) DFT — n ≤ 256)
3750
+ function _bandPower(signal, sampleHz, fLo, fHi) {
3751
+ const n = signal.length;
3752
+ let power = 0;
3753
+ for (let k = 0; k < n / 2; k++) {
3754
+ const freq = (k * sampleHz) / n;
3755
+ if (freq < fLo || freq > fHi) continue;
3756
+ let re = 0, im = 0;
3757
+ for (let t = 0; t < n; t++) {
3758
+ const angle = (2 * Math.PI * k * t) / n;
3759
+ re += signal[t] * Math.cos(angle);
3760
+ im -= signal[t] * Math.sin(angle);
3761
+ }
3762
+ power += (re * re + im * im) / (n * n);
3763
+ }
3764
+ return power;
3765
+ }
3766
+
3767
+ /**
3768
+ * @typedef {object} LlmFingerprint
3769
+ * @property {number} aiConf 0–1 AI agent confidence
3770
+ * @property {number} humanConf 0–1 human confidence
3771
+ * @property {object[]} checks per-signal breakdown
3772
+ * @property {string} verdict
3773
+ * @property {object} dataPoints
3774
+ */
3775
+
3776
+ /**
3777
+ * @svrnsec/pulse — Update Notifier
2147
3778
  *
2148
- * Measures the scheduling jitter of the browser's audio pipeline.
2149
- * Real audio hardware callbacks are driven by a hardware interrupt (IRQ)
2150
- * from the sound card; the timing reflects the actual interrupt latency
2151
- * of the physical device. VM audio drivers (if present at all) are
2152
- * emulated and show either unrealistically low jitter or burst-mode
2153
- * scheduling artefacts that are statistically distinguishable.
3779
+ * Checks the npm registry for a newer version and prints a styled terminal
3780
+ * notice when one is available. Non-blocking the check runs in the
3781
+ * background and only displays if a newer version is found before the
3782
+ * process exits.
3783
+ *
3784
+ * Zero dependencies. Pure Node.js https module.
3785
+ * Silent in browser environments and when stdout is not a TTY.
2154
3786
  */
2155
3787
 
2156
- /**
2157
- * @param {object} [opts]
2158
- * @param {number} [opts.durationMs=2000] - how long to collect audio callbacks
2159
- * @param {number} [opts.bufferSize=256] - ScriptProcessorNode buffer size
2160
- * @returns {Promise<AudioJitter>}
2161
- */
2162
- async function collectAudioJitter(opts = {}) {
2163
- const { durationMs = 2000, bufferSize = 256 } = opts;
2164
3788
 
2165
- const base = {
2166
- available: false,
2167
- workletAvailable: false,
2168
- callbackJitterCV: 0,
2169
- noiseFloorMean: 0,
2170
- sampleRate: 0,
2171
- callbackCount: 0,
2172
- jitterTimings: [],
2173
- };
3789
+ /* ─── version from package.json ─────────────────────────────────────────── */
2174
3790
 
2175
- if (typeof AudioContext === 'undefined' && typeof webkitAudioContext === 'undefined') {
2176
- return base; // Node.js / server environment
2177
- }
3791
+ let _currentVersion = '0.0.0';
3792
+ try {
3793
+ const require$1 = module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('pulse.cjs.js', document.baseURI).href)));
3794
+ _currentVersion = require$1('../package.json').version;
3795
+ } catch {}
2178
3796
 
2179
- let ctx;
2180
- try {
2181
- ctx = new (window.AudioContext || window.webkitAudioContext)();
2182
- } catch (_) {
2183
- return base;
2184
- }
3797
+ const CURRENT_VERSION = _currentVersion;
2185
3798
 
2186
- // Some browsers require a user gesture before AudioContext can run.
2187
- if (ctx.state === 'suspended') {
2188
- try {
2189
- await ctx.resume();
2190
- } catch (_) {
2191
- await ctx.close().catch(() => {});
2192
- return base;
2193
- }
2194
- }
3799
+ /* ─── ANSI helpers ───────────────────────────────────────────────────────── */
2195
3800
 
2196
- const sampleRate = ctx.sampleRate;
2197
- const expectedInterval = (bufferSize / sampleRate) * 1000; // ms per callback
3801
+ const isTTY$1 = () =>
3802
+ typeof process !== 'undefined' &&
3803
+ process.stdout?.isTTY === true &&
3804
+ process.env?.NO_COLOR == null &&
3805
+ process.env?.PULSE_NO_UPDATE == null;
2198
3806
 
2199
- const jitterTimings = []; // absolute AudioContext.currentTime at each callback
2200
- const callbackDeltas = [];
3807
+ const isNode = () => typeof process !== 'undefined' && typeof window === 'undefined';
2201
3808
 
2202
- await new Promise((resolve) => {
2203
- // ── AudioWorklet (preferred — runs on dedicated real-time thread) ──────
2204
- const useWorklet = typeof AudioWorkletNode !== 'undefined';
2205
- base.workletAvailable = useWorklet;
3809
+ const C = {
3810
+ reset: '\x1b[0m',
3811
+ bold: '\x1b[1m',
3812
+ yellow: '\x1b[33m',
3813
+ // bright foreground
3814
+ bgray: '\x1b[90m',
3815
+ bred: '\x1b[91m',
3816
+ bgreen: '\x1b[92m',
3817
+ byellow: '\x1b[93m',
3818
+ bcyan: '\x1b[96m',
3819
+ bwhite: '\x1b[97m'};
2206
3820
 
2207
- if (useWorklet) {
2208
- // Inline worklet: send currentTime back via MessagePort every buffer
2209
- const workletCode = `
2210
- class PulseProbe extends AudioWorkletProcessor {
2211
- process(inputs, outputs) {
2212
- this.port.postMessage({ t: currentTime });
2213
- // Pass-through silence
2214
- for (const out of outputs)
2215
- for (const ch of out) ch.fill(0);
2216
- return true;
2217
- }
2218
- }
2219
- registerProcessor('pulse-probe', PulseProbe);
2220
- `;
2221
- const blob = new Blob([workletCode], { type: 'application/javascript' });
2222
- const blobUrl = URL.createObjectURL(blob);
3821
+ const c$1 = isTTY$1;
3822
+ const ft = (code, s) => c$1() ? `${code}${s}${C.reset}` : s;
2223
3823
 
2224
- ctx.audioWorklet.addModule(blobUrl).then(() => {
2225
- const node = new AudioWorkletNode(ctx, 'pulse-probe');
2226
- node.port.onmessage = (e) => {
2227
- jitterTimings.push(e.data.t * 1000); // convert to ms
2228
- };
2229
- node.connect(ctx.destination);
3824
+ /* ─── box renderer ───────────────────────────────────────────────────────── */
2230
3825
 
2231
- setTimeout(async () => {
2232
- node.disconnect();
2233
- URL.revokeObjectURL(blobUrl);
2234
- resolve(node);
2235
- }, durationMs);
2236
- }).catch(() => {
2237
- URL.revokeObjectURL(blobUrl);
2238
- _fallbackScriptProcessor(ctx, bufferSize, durationMs, jitterTimings, resolve);
2239
- });
3826
+ /**
3827
+ * Render a bordered notification box to stderr.
3828
+ * Uses box-drawing characters and ANSI colors when the terminal supports them.
3829
+ */
3830
+ function _box(lines, opts = {}) {
3831
+ const { borderColor = C.yellow, titleColor = C.bwhite } = opts;
3832
+ const pad = 2;
3833
+ const width = Math.max(...lines.map(l => _visLen(l))) + pad * 2;
3834
+ const hr = '─'.repeat(width);
3835
+ const bc = (s) => c$1() ? `${borderColor}${s}${C.reset}` : s;
3836
+
3837
+ const out = [
3838
+ bc(`╭${hr}╮`),
3839
+ ...lines.map(l => {
3840
+ const vis = _visLen(l);
3841
+ const fill = ' '.repeat(Math.max(0, width - vis - pad * 2));
3842
+ return bc('│') + ' '.repeat(pad) + (c$1() ? l : _stripAnsi(l)) + fill + ' '.repeat(pad) + bc('│');
3843
+ }),
3844
+ bc(`╰${hr}╯`),
3845
+ ];
2240
3846
 
2241
- } else {
2242
- _fallbackScriptProcessor(ctx, bufferSize, durationMs, jitterTimings, resolve);
2243
- }
2244
- });
3847
+ process.stderr.write('\n' + out.join('\n') + '\n\n');
3848
+ }
2245
3849
 
2246
- // ── Compute deltas between successive callback times ────────────────────
2247
- for (let i = 1; i < jitterTimings.length; i++) {
2248
- callbackDeltas.push(jitterTimings[i] - jitterTimings[i - 1]);
3850
+ /* ─── version comparison ─────────────────────────────────────────────────── */
3851
+
3852
+ function _semverGt(a, b) {
3853
+ const pa = a.replace(/[^0-9.]/g, '').split('.').map(Number);
3854
+ const pb = b.replace(/[^0-9.]/g, '').split('.').map(Number);
3855
+ for (let i = 0; i < 3; i++) {
3856
+ const da = pa[i] ?? 0, db = pb[i] ?? 0;
3857
+ if (da > db) return true;
3858
+ if (da < db) return false;
2249
3859
  }
3860
+ return false;
3861
+ }
2250
3862
 
2251
- // ── Noise floor via AnalyserNode ─────────────────────────────────────────
2252
- // Feed a silent oscillator through an analyser; the FFT magnitude at silence
2253
- // reveals the hardware's thermal noise floor (varies per ADC/DAC chipset).
2254
- const noiseFloor = await _measureNoiseFloor(ctx);
3863
+ /* ─── registry fetch ─────────────────────────────────────────────────────── */
2255
3864
 
2256
- await ctx.close().catch(() => {});
3865
+ async function _fetchLatest(pkg) {
3866
+ return new Promise((resolve) => {
3867
+ let resolved = false;
3868
+ const done = (v) => { if (!resolved) { resolved = true; resolve(v); } };
2257
3869
 
2258
- // ── Statistics ────────────────────────────────────────────────────────────
2259
- const mean = callbackDeltas.length
2260
- ? callbackDeltas.reduce((s, v) => s + v, 0) / callbackDeltas.length
2261
- : 0;
2262
- const variance = callbackDeltas.length > 1
2263
- ? callbackDeltas.reduce((s, v) => s + (v - mean) ** 2, 0) / (callbackDeltas.length - 1)
2264
- : 0;
2265
- const jitterCV = mean > 0 ? Math.sqrt(variance) / mean : 0;
3870
+ const timeout = setTimeout(() => done(null), 3_000);
2266
3871
 
2267
- return {
2268
- available: true,
2269
- workletAvailable: base.workletAvailable,
2270
- callbackJitterCV: jitterCV,
2271
- noiseFloorMean: noiseFloor.mean,
2272
- noiseFloorStd: noiseFloor.std,
2273
- sampleRate,
2274
- callbackCount: jitterTimings.length,
2275
- expectedIntervalMs: expectedInterval,
2276
- // Only include summary stats, not raw timings (privacy / size)
2277
- jitterMeanMs: mean,
2278
- jitterP95Ms: _percentile(callbackDeltas, 95),
2279
- };
3872
+ try {
3873
+ const https = require$1('https');
3874
+ const req = https.get(
3875
+ `https://registry.npmjs.org/${encodeURIComponent(pkg)}/latest`,
3876
+ { headers: { 'Accept': 'application/json', 'User-Agent': `${pkg}/${_currentVersion}` } },
3877
+ (res) => {
3878
+ let body = '';
3879
+ res.setEncoding('utf8');
3880
+ res.on('data', d => body += d);
3881
+ res.on('end', () => {
3882
+ clearTimeout(timeout);
3883
+ try { done(JSON.parse(body).version ?? null); } catch { done(null); }
3884
+ });
3885
+ }
3886
+ );
3887
+ req.on('error', () => { clearTimeout(timeout); done(null); });
3888
+ req.end();
3889
+ } catch {
3890
+ clearTimeout(timeout);
3891
+ done(null);
3892
+ }
3893
+ });
3894
+ }
3895
+ function require$1(m) {
3896
+ if (typeof globalThis.require === 'function') return globalThis.require(m);
3897
+ // CJS interop — only used server-side
3898
+ if (typeof process !== 'undefined') {
3899
+ const mod = process.mainModule?.require ?? (() => null);
3900
+ return mod(m);
3901
+ }
3902
+ return null;
2280
3903
  }
2281
3904
 
3905
+ /* ─── checkForUpdate ─────────────────────────────────────────────────────── */
3906
+
2282
3907
  /**
2283
- * @typedef {object} AudioJitter
2284
- * @property {boolean} available
2285
- * @property {boolean} workletAvailable
2286
- * @property {number} callbackJitterCV
2287
- * @property {number} noiseFloorMean
2288
- * @property {number} sampleRate
2289
- * @property {number} callbackCount
3908
+ * Check npm for a newer version of @svrnsec/pulse.
3909
+ * Call once at process startup — the result is shown before process exit
3910
+ * (or immediately if already resolved).
3911
+ *
3912
+ * @param {object} [opts]
3913
+ * @param {boolean} [opts.silent=false] suppress output even when update exists
3914
+ * @param {string} [opts.pkg='@svrnsec/pulse']
3915
+ * @returns {Promise<{ current: string, latest: string|null, updateAvailable: boolean }>}
2290
3916
  */
3917
+ async function checkForUpdate(opts = {}) {
3918
+ const { silent = false, pkg = '@svrnsec/pulse' } = opts;
2291
3919
 
2292
- // ---------------------------------------------------------------------------
2293
- // Internal helpers
2294
- // ---------------------------------------------------------------------------
3920
+ if (!isNode()) return { current: _currentVersion, latest: null, updateAvailable: false };
2295
3921
 
2296
- function _fallbackScriptProcessor(ctx, bufferSize, durationMs, jitterTimings, resolve) {
2297
- // ScriptProcessorNode is deprecated but universally supported.
2298
- const proc = ctx.createScriptProcessor(bufferSize, 1, 1);
2299
- proc.onaudioprocess = () => {
2300
- jitterTimings.push(ctx.currentTime * 1000);
2301
- };
2302
- // Connect to keep the graph alive
2303
- const osc = ctx.createOscillator();
2304
- osc.frequency.value = 1; // sub-audible
2305
- osc.connect(proc);
2306
- proc.connect(ctx.destination);
2307
- osc.start();
3922
+ const latest = await _fetchLatest(pkg);
3923
+ const updateAvailable = latest != null && _semverGt(latest, _currentVersion);
2308
3924
 
2309
- setTimeout(() => {
2310
- osc.stop();
2311
- osc.disconnect();
2312
- proc.disconnect();
2313
- resolve(proc);
2314
- }, durationMs);
3925
+ if (updateAvailable && !silent && isTTY$1()) {
3926
+ _showUpdateBox(_currentVersion, latest, pkg);
3927
+ }
3928
+
3929
+ return { current: _currentVersion, latest, updateAvailable };
2315
3930
  }
2316
3931
 
2317
- async function _measureNoiseFloor(ctx) {
2318
- try {
2319
- const analyser = ctx.createAnalyser();
2320
- analyser.fftSize = 256;
2321
- analyser.connect(ctx.destination);
3932
+ /* ─── notifyOnExit ───────────────────────────────────────────────────────── */
2322
3933
 
2323
- // Silent source
2324
- const buf = ctx.createBuffer(1, ctx.sampleRate * 0.1, ctx.sampleRate);
2325
- const src = ctx.createBufferSource();
2326
- src.buffer = buf;
2327
- src.connect(analyser);
2328
- src.start();
3934
+ let _notifyRegistered = false;
2329
3935
 
2330
- await new Promise(r => setTimeout(r, 150));
3936
+ /**
3937
+ * Register a one-time process 'exit' listener that prints the update notice
3938
+ * after your application's own output has finished. This is the least
3939
+ * intrusive way to show the notification.
3940
+ *
3941
+ * Called automatically by the package initialiser — you do not need to call
3942
+ * this manually unless you want to control the timing.
3943
+ *
3944
+ * @param {object} [opts]
3945
+ * @param {string} [opts.pkg='@svrnsec/pulse']
3946
+ */
3947
+ function notifyOnExit(opts = {}) {
3948
+ if (!isNode() || _notifyRegistered) return;
3949
+ _notifyRegistered = true;
2331
3950
 
2332
- const data = new Float32Array(analyser.frequencyBinCount);
2333
- analyser.getFloatFrequencyData(data);
2334
- analyser.disconnect();
3951
+ const pkg = opts.pkg ?? '@svrnsec/pulse';
3952
+ let _latest = null;
2335
3953
 
2336
- // Limit to 32 bins to keep the payload small
2337
- const trimmed = Array.from(data.slice(0, 32)).map(v =>
2338
- isFinite(v) ? Math.pow(10, v / 20) : 0 // dB → linear
2339
- );
2340
- const mean = trimmed.reduce((s, v) => s + v, 0) / trimmed.length;
2341
- const std = Math.sqrt(
2342
- trimmed.reduce((s, v) => s + (v - mean) ** 2, 0) / trimmed.length
2343
- );
2344
- return { mean, std };
2345
- } catch (_) {
2346
- return { mean: 0, std: 0 };
2347
- }
3954
+ // Start the background check immediately
3955
+ _fetchLatest(pkg).then(v => { _latest = v; }).catch(() => {});
3956
+
3957
+ // Show the box just before the process exits (after all user output)
3958
+ process.on('exit', () => {
3959
+ if (_latest && _semverGt(_latest, _currentVersion) && isTTY$1()) {
3960
+ _showUpdateBox(_currentVersion, _latest, pkg);
3961
+ }
3962
+ });
2348
3963
  }
2349
3964
 
2350
- function _percentile(arr, p) {
2351
- if (!arr.length) return 0;
2352
- const sorted = [...arr].sort((a, b) => a - b);
2353
- const idx = (p / 100) * (sorted.length - 1);
2354
- const lo = Math.floor(idx);
2355
- const hi = Math.ceil(idx);
2356
- return sorted[lo] + (sorted[hi] - sorted[lo]) * (idx - lo);
3965
+ /* ─── _showUpdateBox ─────────────────────────────────────────────────────── */
3966
+
3967
+ function _showUpdateBox(current, latest, pkg) {
3968
+ const arrow = ft(C.bgray, '→');
3969
+ const oldV = ft(C.bred, current);
3970
+ const newV = ft(C.bgreen + C.bold, latest);
3971
+ const cmd = ft(C.bcyan + C.bold, `npm i ${pkg}@latest`);
3972
+ const notice = ft(C.byellow + C.bold, ' UPDATE AVAILABLE ');
3973
+
3974
+ _box([
3975
+ notice,
3976
+ '',
3977
+ ` ${oldV} ${arrow} ${newV}`,
3978
+ '',
3979
+ ` Run: ${cmd}`,
3980
+ '',
3981
+ ft(C.bgray, ` Changelog: https://github.com/ayronny14-alt/Svrn-Pulse-Security/releases`),
3982
+ ], { borderColor: C.byellow });
3983
+ }
3984
+
3985
+ /* ─── ANSI utilities ─────────────────────────────────────────────────────── */
3986
+
3987
+ // Measure visible length of string (strip ANSI escape codes)
3988
+ function _visLen(s) {
3989
+ return _stripAnsi(s).length;
3990
+ }
3991
+
3992
+ function _stripAnsi(s) {
3993
+ // eslint-disable-next-line no-control-regex
3994
+ return s.replace(/\x1b\[[0-9;]*m/g, '');
2357
3995
  }
2358
3996
 
2359
3997
  /**
@@ -4463,6 +6101,260 @@ function _computeEvidenceWeight(payload) {
4463
6101
  );
4464
6102
  }
4465
6103
 
6104
+ /**
6105
+ * @svrnsec/pulse — Terminal Result Renderer
6106
+ *
6107
+ * Pretty-prints probe results to the terminal for Node.js server usage.
6108
+ * Used by middleware and the CLI so developers see clean, actionable output
6109
+ * during integration and debugging — not raw JSON.
6110
+ *
6111
+ * Zero dependencies. Pure ANSI escape codes.
6112
+ * Automatically disabled when stdout is not a TTY or NO_COLOR is set.
6113
+ */
6114
+
6115
+ /* ─── TTY guard ──────────────────────────────────────────────────────────── */
6116
+
6117
+ const isTTY = () =>
6118
+ typeof process !== 'undefined' &&
6119
+ process.stderr?.isTTY === true &&
6120
+ process.env?.NO_COLOR == null;
6121
+
6122
+ const c = isTTY;
6123
+
6124
+ /* ─── ANSI color palette ─────────────────────────────────────────────────── */
6125
+
6126
+ const A = {
6127
+ reset: '\x1b[0m',
6128
+ bold: '\x1b[1m',
6129
+ cyan: '\x1b[36m',
6130
+ gray: '\x1b[90m',
6131
+ // foreground — bright
6132
+ bred: '\x1b[91m',
6133
+ bgreen: '\x1b[92m',
6134
+ byellow: '\x1b[93m',
6135
+ bmagenta:'\x1b[95m',
6136
+ bcyan: '\x1b[96m',
6137
+ bwhite: '\x1b[97m',
6138
+ };
6139
+
6140
+ const paint = (code, s) => c() ? `${code}${s}${A.reset}` : s;
6141
+ const bold = (s) => paint(A.bold, s);
6142
+ const gray = (s) => paint(A.gray, s);
6143
+ const cyan = (s) => paint(A.cyan, s);
6144
+ const green = (s) => paint(A.bgreen, s);
6145
+ const red = (s) => paint(A.bred, s);
6146
+ const yel = (s) => paint(A.byellow, s);
6147
+ const mag = (s) => paint(A.bmagenta,s);
6148
+ const wh = (s) => paint(A.bwhite, s);
6149
+
6150
+ function stripAnsi(s) {
6151
+ // eslint-disable-next-line no-control-regex
6152
+ return s.replace(/\x1b\[[0-9;]*m/g, '');
6153
+ }
6154
+ const visLen = (s) => stripAnsi(s).length;
6155
+
6156
+ /* ─── bar renderer ───────────────────────────────────────────────────────── */
6157
+
6158
+ /**
6159
+ * Render a horizontal progress / confidence bar.
6160
+ * @param {number} pct 0–1
6161
+ * @param {number} width character width of the bar
6162
+ * @param {string} fillCode ANSI color code for filled blocks
6163
+ */
6164
+ function bar(pct, width = 20, fillCode = A.bgreen) {
6165
+ const filled = Math.round(Math.min(1, Math.max(0, pct)) * width);
6166
+ const empty = width - filled;
6167
+ const fill = c() ? `${fillCode}${'█'.repeat(filled)}${A.reset}` : '█'.repeat(filled);
6168
+ const void_ = gray('░'.repeat(empty));
6169
+ return fill + void_;
6170
+ }
6171
+
6172
+ /* ─── verdict badge ──────────────────────────────────────────────────────── */
6173
+
6174
+ function verdictBadge(result) {
6175
+ if (!result) return gray(' PENDING ');
6176
+ const { valid, score, confidence } = result;
6177
+
6178
+ if (valid && confidence === 'high') return green(' ✓ PASS ');
6179
+ if (valid && confidence === 'medium') return yel(' ⚠ PASS ');
6180
+ if (!valid && score < 0.3) return red(' ✗ BLOCKED ');
6181
+ return yel(' ⚠ REVIEW ');
6182
+ }
6183
+
6184
+ /* ─── renderProbeResult ──────────────────────────────────────────────────── */
6185
+
6186
+ /**
6187
+ * Print a formatted probe result card to stderr.
6188
+ *
6189
+ * @param {object} opts
6190
+ * @param {object} opts.payload - ProofPayload from pulse()
6191
+ * @param {string} opts.hash - BLAKE3 hex commitment
6192
+ * @param {object} [opts.result] - ValidationResult (server-side verify)
6193
+ * @param {object} [opts.enf] - EnfResult if available
6194
+ * @param {object} [opts.gpu] - GpuEntropyResult if available
6195
+ * @param {object} [opts.dram] - DramResult if available
6196
+ * @param {object} [opts.llm] - LlmResult if available
6197
+ * @param {number} [opts.elapsedMs] - total probe time
6198
+ */
6199
+ function renderProbeResult({ payload, hash, result, enf, gpu, dram, llm, elapsedMs }) {
6200
+ if (!c()) return;
6201
+
6202
+ const W = 54;
6203
+ gray('─'.repeat(W));
6204
+ const vbar = gray('│');
6205
+
6206
+ const row = (label, value, valueColor = A.bwhite) => {
6207
+ const lbl = gray(label.padEnd(24));
6208
+ const val = c() ? `${valueColor}${value}${A.reset}` : String(value);
6209
+ const line = ` ${lbl}${val}`;
6210
+ const pad = ' '.repeat(Math.max(0, W - visLen(line) - 2));
6211
+ process.stderr.write(`${vbar}${line}${pad} ${vbar}\n`);
6212
+ };
6213
+
6214
+ const blank = () => {
6215
+ process.stderr.write(`${vbar}${' '.repeat(W + 2)}${vbar}\n`);
6216
+ };
6217
+
6218
+ const section = (title) => {
6219
+ const t = ` ${bold(title)}`;
6220
+ const pad = ' '.repeat(Math.max(0, W - visLen(t) - 2));
6221
+ process.stderr.write(`${vbar}${t}${pad} ${vbar}\n`);
6222
+ };
6223
+
6224
+ const badge = verdictBadge(result);
6225
+ const hashShort = hash ? hash.slice(0, 16) + '…' : 'pending';
6226
+ const elapsed = elapsedMs ? `${(elapsedMs / 1000).toFixed(2)}s` : '—';
6227
+
6228
+ const sigs = payload?.signals ?? {};
6229
+ const cls = payload?.classification ?? {};
6230
+ const jScore = cls.jitterScore ?? 0;
6231
+
6232
+ // ── Physics signals ──────────────────────────────────────────────────────
6233
+ const qe = sigs.entropy?.quantizationEntropy ?? 0;
6234
+ const hurst = sigs.entropy?.hurstExponent ?? 0;
6235
+ const cv = sigs.entropy?.timingsCV ?? 0;
6236
+ const ejrClass = qe >= 1.08 ? A.bgreen : qe >= 0.95 ? A.byellow : A.bred;
6237
+ const hwConf = result?.confidence === 'high' ? 1.0 : result?.confidence === 'medium' ? 0.65 : 0.3;
6238
+ const vmConf = 1 - hwConf;
6239
+
6240
+ // ── ENF signals ──────────────────────────────────────────────────────────
6241
+ const enfRegion = enf?.gridRegion === 'americas' ? '60 Hz Americas'
6242
+ : enf?.gridRegion === 'emea_apac' ? '50 Hz EMEA/APAC'
6243
+ : enf?.enfAvailable === false ? 'unavailable'
6244
+ : '—';
6245
+ const enfColor = enf?.ripplePresent ? A.bgreen : enf?.enfAvailable === false ? A.gray : A.byellow;
6246
+
6247
+ // ── GPU signals ──────────────────────────────────────────────────────────
6248
+ const gpuStr = gpu?.gpuPresent
6249
+ ? (gpu.isSoftware ? red('Software renderer') : green(gpu.vendorString ?? 'GPU detected'))
6250
+ : gray('unavailable');
6251
+
6252
+ // ── DRAM signals ─────────────────────────────────────────────────────────
6253
+ const dramStr = dram?.refreshPresent
6254
+ ? green(`${(dram.refreshPeriodMs ?? 0).toFixed(1)} ms (DDR4 JEDEC ✓)`)
6255
+ : dram ? red('No refresh cycle (VM)') : gray('unavailable');
6256
+
6257
+ // ── LLM signals ──────────────────────────────────────────────────────────
6258
+ const llmStr = llm
6259
+ ? (llm.aiConf > 0.7 ? red(`AI agent ${(llm.aiConf * 100).toFixed(0)}%`) : green(`Human ${((1 - llm.aiConf) * 100).toFixed(0)}%`))
6260
+ : gray('no bio data');
6261
+
6262
+ // ── Render ───────────────────────────────────────────────────────────────
6263
+ const topTitle = ` ${mag('SVRN')}${wh(':PULSE')} ${badge}`;
6264
+ const topPad = ' '.repeat(Math.max(0, W - visLen(topTitle) - 2));
6265
+ const topBorder = gray('╭' + '─'.repeat(W + 2) + '╮');
6266
+ const botBorder = gray('╰' + '─'.repeat(W + 2) + '╯');
6267
+
6268
+ process.stderr.write('\n');
6269
+ process.stderr.write(topBorder + '\n');
6270
+ process.stderr.write(`${vbar}${topTitle}${topPad} ${vbar}\n`);
6271
+ process.stderr.write(gray('├' + '─'.repeat(W + 2) + '┤') + '\n');
6272
+ blank();
6273
+
6274
+ section('PHYSICS LAYER');
6275
+ blank();
6276
+ row('Jitter score', (jScore * 100).toFixed(1) + '%', jScore > 0.7 ? A.bgreen : jScore > 0.45 ? A.byellow : A.bred);
6277
+ row('QE (entropy)', qe.toFixed(3), ejrClass);
6278
+ row('Hurst exponent', hurst.toFixed(4), Math.abs(hurst - 0.5) < 0.1 ? A.bgreen : A.byellow);
6279
+ row('Timing CV', cv.toFixed(4), cv > 0.08 ? A.bgreen : A.byellow);
6280
+ row('Timer granularity',`${((sigs.entropy?.timerGranularityMs ?? 0) * 1000).toFixed(1)} µs`, A.bcyan);
6281
+ blank();
6282
+
6283
+ const hwBar = bar(hwConf, 18, A.bgreen);
6284
+ const vmBar = bar(vmConf, 18, A.bred);
6285
+ row('HW confidence', hwBar + ' ' + (hwConf * 100).toFixed(0) + '%');
6286
+ row('VM confidence', vmBar + ' ' + (vmConf * 100).toFixed(0) + '%');
6287
+
6288
+ blank();
6289
+ process.stderr.write(gray('├' + '─'.repeat(W + 2) + '┤') + '\n');
6290
+ blank();
6291
+
6292
+ section('SIGNAL LAYERS');
6293
+ blank();
6294
+ row('Grid (ENF)', enfRegion, enfColor);
6295
+ process.stderr.write(`${vbar} ${gray('GPU (thermal)')}${' '.repeat(10)}${gpuStr} ${vbar}\n`);
6296
+ process.stderr.write(`${vbar} ${gray('DRAM refresh')} ${' '.repeat(11)}${dramStr} ${vbar}\n`);
6297
+ process.stderr.write(`${vbar} ${gray('Behavioral (LLM)')}${' '.repeat(7)}${llmStr} ${vbar}\n`);
6298
+
6299
+ blank();
6300
+ process.stderr.write(gray('├' + '─'.repeat(W + 2) + '┤') + '\n');
6301
+ blank();
6302
+
6303
+ section('PROOF');
6304
+ blank();
6305
+ row('BLAKE3', hashShort, A.bcyan);
6306
+ row('Nonce', (payload?.nonce ?? '').slice(0, 16) + '…', A.gray);
6307
+ row('Elapsed', elapsed, A.gray);
6308
+ if (result) {
6309
+ row('Server verdict', result.valid ? 'valid' : 'rejected', result.valid ? A.bgreen : A.bred);
6310
+ row('Score', ((result.score ?? 0) * 100).toFixed(1) + '%', A.bwhite);
6311
+ if ((result.riskFlags ?? []).length > 0) {
6312
+ blank();
6313
+ row('Risk flags', result.riskFlags.join(', '), A.byellow);
6314
+ }
6315
+ }
6316
+ blank();
6317
+ process.stderr.write(botBorder + '\n\n');
6318
+ }
6319
+
6320
+ /* ─── renderError ────────────────────────────────────────────────────────── */
6321
+
6322
+ /**
6323
+ * Print a formatted error card for pulse() failures.
6324
+ * @param {Error|string} err
6325
+ */
6326
+ function renderError(err) {
6327
+ if (!c()) return;
6328
+ const msg = err?.message ?? String(err);
6329
+ const W = 54;
6330
+ const vbar = gray('│');
6331
+
6332
+ process.stderr.write('\n');
6333
+ process.stderr.write(red('╭' + '─'.repeat(W + 2) + '╮') + '\n');
6334
+ process.stderr.write(`${red('│')} ${red('✗')} ${bold('SVRN:PULSE — probe failed')}${' '.repeat(Math.max(0, W - 28))} ${red('│')}\n`);
6335
+ process.stderr.write(red('├' + '─'.repeat(W + 2) + '┤') + '\n');
6336
+ process.stderr.write(`${vbar} ${gray(msg.slice(0, W - 2).padEnd(W))} ${vbar}\n`);
6337
+ process.stderr.write(red('╰' + '─'.repeat(W + 2) + '╯') + '\n\n');
6338
+ }
6339
+
6340
+ /* ─── renderUpdateBanner ─────────────────────────────────────────────────── */
6341
+
6342
+ /**
6343
+ * Render a simple one-line update available hint inline (used by middleware).
6344
+ * @param {string} latest
6345
+ */
6346
+ function renderInlineUpdateHint(latest) {
6347
+ if (!c()) return;
6348
+ process.stderr.write(
6349
+ gray(' ╴╴╴ ') +
6350
+ yel('update available ') +
6351
+ gray(latest) +
6352
+ ' ' + cyan('npm i @svrnsec/pulse@latest') +
6353
+ gray(' ╴╴╴') +
6354
+ '\n'
6355
+ );
6356
+ }
6357
+
4466
6358
  /**
4467
6359
  * @sovereign/pulse
4468
6360
  *
@@ -4510,6 +6402,11 @@ function _computeEvidenceWeight(payload) {
4510
6402
  */
4511
6403
 
4512
6404
 
6405
+ // Register background update check — fires once at process startup.
6406
+ // Shows a styled notification box after the process exits if a newer version
6407
+ // is available. No-op in browser environments and non-TTY outputs.
6408
+ notifyOnExit();
6409
+
4513
6410
  // ---------------------------------------------------------------------------
4514
6411
  // Hosted API mode — pulse({ apiKey }) with zero server setup
4515
6412
  // ---------------------------------------------------------------------------
@@ -4665,25 +6562,47 @@ async function _runProbe(opts) {
4665
6562
  const bioSnapshot = bio.snapshot(entropyResult.timings);
4666
6563
 
4667
6564
  if (requireBio && !bioSnapshot.hasActivity) {
4668
- throw new Error('@sovereign/pulse: no bio activity detected (requireBio=true)');
6565
+ throw new Error('@svrnsec/pulse: no bio activity detected (requireBio=true)');
4669
6566
  }
4670
6567
 
4671
6568
  _emit(onProgress, 'bio_done');
4672
6569
 
4673
- // ── Phase 4: Jitter analysis ───────────────────────────────────────────────
6570
+ // ── Phase 4: Extended signal collection (non-blocking, best-effort) ───────
6571
+ // ENF, GPU, DRAM, and LLM detectors run in parallel after the core probe.
6572
+ // Each gracefully returns a null/unavailable result if the environment does
6573
+ // not support it (e.g. no WebGPU, no SharedArrayBuffer, no bio events).
6574
+ const [enfResult, gpuResult, dramResult, llmResult] = await Promise.all([
6575
+ collectEnfTimings().catch(() => null),
6576
+ collectGpuEntropy().catch(() => null),
6577
+ collectDramTimings().catch(() => null),
6578
+ Promise.resolve(detectLlmAgent(bioSnapshot)).catch(() => null),
6579
+ ]);
6580
+
6581
+ _emit(onProgress, 'extended_done', {
6582
+ enf: enfResult?.verdict,
6583
+ gpu: gpuResult?.verdict,
6584
+ dram: dramResult?.verdict,
6585
+ llm: llmResult?.aiConf,
6586
+ });
6587
+
6588
+ // ── Phase 5: Jitter analysis ───────────────────────────────────────────────
4674
6589
  const jitterAnalysis = classifyJitter(entropyResult.timings, {
4675
6590
  autocorrelations: entropyResult.autocorrelations,
4676
6591
  });
4677
6592
 
4678
6593
  _emit(onProgress, 'analysis_done');
4679
6594
 
4680
- // ── Phase 5: Build proof & commitment ─────────────────────────────────────
6595
+ // ── Phase 6: Build proof & commitment ─────────────────────────────────────
4681
6596
  const payload = buildProof({
4682
6597
  entropy: entropyResult,
4683
6598
  jitter: jitterAnalysis,
4684
6599
  bio: bioSnapshot,
4685
6600
  canvas: canvasResult,
4686
6601
  audio: audioResult,
6602
+ enf: enfResult,
6603
+ gpu: gpuResult,
6604
+ dram: dramResult,
6605
+ llm: llmResult,
4687
6606
  nonce,
4688
6607
  });
4689
6608
 
@@ -4693,9 +6612,13 @@ async function _runProbe(opts) {
4693
6612
  score: jitterAnalysis.score,
4694
6613
  confidence: _scoreToLabel(jitterAnalysis.score),
4695
6614
  flags: jitterAnalysis.flags,
6615
+ enf: enfResult?.verdict,
6616
+ gpu: gpuResult?.verdict,
6617
+ dram: dramResult?.verdict,
6618
+ llmConf: llmResult?.aiConf ?? null,
4696
6619
  });
4697
6620
 
4698
- return commitment;
6621
+ return { ...commitment, extended: { enf: enfResult, gpu: gpuResult, dram: dramResult, llm: llmResult } };
4699
6622
  }
4700
6623
 
4701
6624
  // ---------------------------------------------------------------------------
@@ -4901,10 +6824,19 @@ var pulse_core = /*#__PURE__*/Object.freeze({
4901
6824
  run_memory_probe: run_memory_probe
4902
6825
  });
4903
6826
 
6827
+ exports.CURRENT_VERSION = CURRENT_VERSION;
4904
6828
  exports.Fingerprint = Fingerprint;
6829
+ exports.checkForUpdate = checkForUpdate;
6830
+ exports.collectDramTimings = collectDramTimings;
6831
+ exports.collectEnfTimings = collectEnfTimings;
6832
+ exports.collectGpuEntropy = collectGpuEntropy;
6833
+ exports.detectLlmAgent = detectLlmAgent;
4905
6834
  exports.detectProvider = detectProvider;
4906
6835
  exports.generateNonce = generateNonce;
4907
6836
  exports.pulse = pulse;
6837
+ exports.renderError = renderError;
6838
+ exports.renderInlineUpdateHint = renderInlineUpdateHint;
6839
+ exports.renderProbeResult = renderProbeResult;
4908
6840
  exports.runHeuristicEngine = runHeuristicEngine;
4909
6841
  exports.validateProof = validateProof;
4910
6842
  //# sourceMappingURL=pulse.cjs.js.map