Files
wifi-densepose/vendor/ruvector/npm/packages/rvdna/src/stream.js

313 lines
9.9 KiB
JavaScript

'use strict';
// ── Constants (identical to biomarker_stream.rs) ─────────────────────────────
const EMA_ALPHA = 0.1;
const Z_SCORE_THRESHOLD = 2.5;
const REF_OVERSHOOT = 0.20;
const CUSUM_THRESHOLD = 4.0;
const CUSUM_DRIFT = 0.5;
// ── Biomarker definitions ────────────────────────────────────────────────────
const BIOMARKER_DEFS = Object.freeze([
{ id: 'glucose', low: 70, high: 100 },
{ id: 'cholesterol_total', low: 150, high: 200 },
{ id: 'hdl', low: 40, high: 60 },
{ id: 'ldl', low: 70, high: 130 },
{ id: 'triglycerides', low: 50, high: 150 },
{ id: 'crp', low: 0.1, high: 3.0 },
]);
// ── RingBuffer ───────────────────────────────────────────────────────────────
class RingBuffer {
constructor(capacity) {
if (capacity <= 0) throw new Error('RingBuffer capacity must be > 0');
this._buffer = new Float64Array(capacity);
this._head = 0;
this._len = 0;
this._capacity = capacity;
}
push(item) {
this._buffer[this._head] = item;
this._head = (this._head + 1) % this._capacity;
if (this._len < this._capacity) this._len++;
}
/** Push item and return evicted value (NaN if buffer wasn't full). */
pushPop(item) {
const wasFull = this._len === this._capacity;
const evicted = wasFull ? this._buffer[this._head] : NaN;
this._buffer[this._head] = item;
this._head = (this._head + 1) % this._capacity;
if (!wasFull) this._len++;
return evicted;
}
/** Iterate in insertion order (oldest to newest). */
*[Symbol.iterator]() {
const start = this._len < this._capacity ? 0 : this._head;
for (let i = 0; i < this._len; i++) {
yield this._buffer[(start + i) % this._capacity];
}
}
/** Return values as a plain array (oldest to newest). */
toArray() {
const arr = new Array(this._len);
const start = this._len < this._capacity ? 0 : this._head;
for (let i = 0; i < this._len; i++) {
arr[i] = this._buffer[(start + i) % this._capacity];
}
return arr;
}
get length() { return this._len; }
get capacity() { return this._capacity; }
isFull() { return this._len === this._capacity; }
clear() {
this._head = 0;
this._len = 0;
}
}
// ── Welford's online mean+std (single-pass, mirrors Rust) ────────────────────
function windowMeanStd(buf) {
const n = buf.length;
if (n === 0) return [0, 0];
let mean = 0, m2 = 0, k = 0;
for (const x of buf) {
k++;
const delta = x - mean;
mean += delta / k;
m2 += delta * (x - mean);
}
if (n < 2) return [mean, 0];
return [mean, Math.sqrt(m2 / (n - 1))];
}
// ── Trend slope via simple linear regression (mirrors Rust) ──────────────────
function computeTrendSlope(buf) {
const n = buf.length;
if (n < 2) return 0;
const nf = n;
const xm = (nf - 1) / 2;
let ys = 0, xys = 0, xxs = 0, i = 0;
for (const y of buf) {
ys += y;
xys += i * y;
xxs += i * i;
i++;
}
const ssXy = xys - nf * xm * (ys / nf);
const ssXx = xxs - nf * xm * xm;
return Math.abs(ssXx) < 1e-12 ? 0 : ssXy / ssXx;
}
// ── StreamConfig ─────────────────────────────────────────────────────────────
function defaultStreamConfig() {
return {
baseIntervalMs: 1000,
noiseAmplitude: 0.02,
driftRate: 0.0,
anomalyProbability: 0.02,
anomalyMagnitude: 2.5,
numBiomarkers: 6,
windowSize: 100,
};
}
// ── Mulberry32 PRNG ──────────────────────────────────────────────────────────
function mulberry32(seed) {
let t = (seed + 0x6D2B79F5) | 0;
return function () {
t = (t + 0x6D2B79F5) | 0;
let z = t ^ (t >>> 15);
z = Math.imul(z | 1, z);
z ^= z + Math.imul(z ^ (z >>> 7), z | 61);
return ((z ^ (z >>> 14)) >>> 0) / 4294967296;
};
}
// Box-Muller for normal distribution
function normalSample(rng, mean, stddev) {
const u1 = rng();
const u2 = rng();
return mean + stddev * Math.sqrt(-2 * Math.log(u1 || 1e-12)) * Math.cos(2 * Math.PI * u2);
}
// ── Batch generation (mirrors generate_readings in Rust) ─────────────────────
function generateReadings(config, count, seed) {
const rng = mulberry32(seed);
const active = BIOMARKER_DEFS.slice(0, Math.min(config.numBiomarkers, BIOMARKER_DEFS.length));
const readings = [];
// Pre-compute distributions per biomarker
const dists = active.map(def => {
const range = def.high - def.low;
const mid = (def.low + def.high) / 2;
const sigma = Math.max(config.noiseAmplitude * range, 1e-12);
return { mid, range, sigma };
});
let ts = 0;
for (let step = 0; step < count; step++) {
for (let j = 0; j < active.length; j++) {
const def = active[j];
const { mid, range, sigma } = dists[j];
const drift = config.driftRate * range * step;
const isAnomaly = rng() < config.anomalyProbability;
const effectiveSigma = isAnomaly ? sigma * config.anomalyMagnitude : sigma;
const value = Math.max(normalSample(rng, mid + drift, effectiveSigma), 0);
readings.push({
timestampMs: ts,
biomarkerId: def.id,
value,
referenceLow: def.low,
referenceHigh: def.high,
isAnomaly,
zScore: 0,
});
}
ts += config.baseIntervalMs;
}
return readings;
}
// ── StreamProcessor ──────────────────────────────────────────────────────────
class StreamProcessor {
constructor(config) {
this._config = config || defaultStreamConfig();
this._buffers = new Map();
this._stats = new Map();
this._totalReadings = 0;
this._anomalyCount = 0;
this._anomPerBio = new Map();
this._welford = new Map();
this._startTs = null;
this._lastTs = null;
}
_initBiomarker(id) {
this._buffers.set(id, new RingBuffer(this._config.windowSize));
this._stats.set(id, {
mean: 0, variance: 0, min: Infinity, max: -Infinity,
count: 0, anomalyRate: 0, trendSlope: 0, ema: 0,
cusumPos: 0, cusumNeg: 0, changepointDetected: false,
});
// Incremental Welford state for windowed mean/variance (O(1) per reading)
this._welford.set(id, { n: 0, mean: 0, m2: 0 });
}
processReading(reading) {
const id = reading.biomarkerId;
if (this._startTs === null) this._startTs = reading.timestampMs;
this._lastTs = reading.timestampMs;
if (!this._buffers.has(id)) this._initBiomarker(id);
const buf = this._buffers.get(id);
const evicted = buf.pushPop(reading.value);
this._totalReadings++;
// Incremental windowed Welford: O(1) add + O(1) remove
const w = this._welford.get(id);
const val = reading.value;
if (Number.isNaN(evicted)) {
// Buffer wasn't full — just add
w.n++;
const d1 = val - w.mean;
w.mean += d1 / w.n;
w.m2 += d1 * (val - w.mean);
} else {
// Buffer full — remove evicted, add new (n stays the same)
const oldMean = w.mean;
w.mean += (val - evicted) / w.n;
w.m2 += (val - evicted) * ((val - w.mean) + (evicted - oldMean));
if (w.m2 < 0) w.m2 = 0; // numerical guard
}
const wmean = w.mean;
const wstd = w.n > 1 ? Math.sqrt(w.m2 / (w.n - 1)) : 0;
const z = wstd > 1e-12 ? (val - wmean) / wstd : 0;
const rng = reading.referenceHigh - reading.referenceLow;
const overshoot = REF_OVERSHOOT * rng;
const oor = val < (reading.referenceLow - overshoot) ||
val > (reading.referenceHigh + overshoot);
const isAnomaly = Math.abs(z) > Z_SCORE_THRESHOLD || oor;
if (isAnomaly) {
this._anomalyCount++;
this._anomPerBio.set(id, (this._anomPerBio.get(id) || 0) + 1);
}
const slope = computeTrendSlope(buf);
const bioAnom = this._anomPerBio.get(id) || 0;
const st = this._stats.get(id);
st.count++;
st.mean = wmean;
st.variance = wstd * wstd;
st.trendSlope = slope;
st.anomalyRate = bioAnom / st.count;
if (val < st.min) st.min = val;
if (val > st.max) st.max = val;
st.ema = st.count === 1
? val
: EMA_ALPHA * val + (1 - EMA_ALPHA) * st.ema;
// CUSUM changepoint detection
if (wstd > 1e-12) {
const normDev = (val - wmean) / wstd;
st.cusumPos = Math.max(st.cusumPos + normDev - CUSUM_DRIFT, 0);
st.cusumNeg = Math.max(st.cusumNeg - normDev - CUSUM_DRIFT, 0);
st.changepointDetected = st.cusumPos > CUSUM_THRESHOLD || st.cusumNeg > CUSUM_THRESHOLD;
if (st.changepointDetected) { st.cusumPos = 0; st.cusumNeg = 0; }
}
return { accepted: true, zScore: z, isAnomaly, currentTrend: slope };
}
getStats(biomarkerId) {
return this._stats.get(biomarkerId) || null;
}
summary() {
const elapsed = (this._startTs !== null && this._lastTs !== null && this._lastTs > this._startTs)
? this._lastTs - this._startTs : 1;
const ar = this._totalReadings > 0 ? this._anomalyCount / this._totalReadings : 0;
const statsObj = {};
for (const [k, v] of this._stats) statsObj[k] = { ...v };
return {
totalReadings: this._totalReadings,
anomalyCount: this._anomalyCount,
anomalyRate: ar,
biomarkerStats: statsObj,
throughputReadingsPerSec: this._totalReadings / (elapsed / 1000),
};
}
}
module.exports = {
RingBuffer,
StreamProcessor,
BIOMARKER_DEFS,
EMA_ALPHA,
Z_SCORE_THRESHOLD,
REF_OVERSHOOT,
CUSUM_THRESHOLD,
CUSUM_DRIFT,
defaultStreamConfig,
generateReadings,
};