refactor: extract the shared rolling-baseline core

BleFieldCore and MagFieldCore had converged on the same class: same
time-aware EMA, same warm-up gate, same "deviation past a threshold is an
event" shape, differing only in constants and field names. Three copies of
the alpha derivation existed across the sensor libs.

lib/baseline.ts now owns it. Both wrappers keep their own public types
(`.rssi`, `.magnitude`) so nothing downstream changed — which is what let
all 26 existing tests pass completely unmodified against the refactor.
That was the point of doing it this way: if the tests had needed editing,
the refactor would have been changing behaviour rather than removing
duplication.

coldSpot.ts deliberately does NOT adopt this. It threads immutable state
through pure functions so a whole session's narrative can be replayed in a
test without a clock — a different and equally valid shape. Collapsing the
two would force one into a style that doesn't fit it, which is how
deduplication turns into damage.

355 frontend tests pass unchanged; i18n parity gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Indiana
2026-07-28 13:36:24 +00:00
parent cf602ab3de
commit 12e2d36527
3 changed files with 152 additions and 67 deletions

View File

@@ -0,0 +1,117 @@
// Shared rolling-baseline core for threshold sensors.
//
// Extracted because BleFieldCore and MagFieldCore had become the same class
// with different constants and field names — same time-aware EMA, same
// warm-up gate, same "deviation past a threshold is an event" shape. One
// implementation means one place to get the time-awareness right, and one
// place to fix it when it's wrong.
//
// Why the EMA is time-aware rather than per-sample: real sensors do not
// report on a fixed schedule. BLE advertisement intervals vary and the OS
// coalesces them; a magnetometer can be throttled when the page is
// backgrounded; a paired device can drop offline and return. A fixed
// per-sample alpha would weight a tight burst and a two-minute gap
// identically, which is wrong in both directions — it drags the baseline
// far too slowly across a gap (as if a hundred readings' worth of recency
// happened at once) and far too eagerly through a burst.
//
// The exponential-decay form degrades gracefully at both extremes: a long
// gap pushes alpha toward 1 (the old baseline is stale, trust the new
// reading), a rapid burst toward 0 (barely move at all).
//
// coldSpot.ts deliberately does NOT use this: it threads immutable state
// through pure functions so a whole session's narrative can be replayed in
// a test without a clock, which is a different and equally valid shape.
// Collapsing the two would mean forcing one of them into a style that
// doesn't fit it.
export type BaselineEvent = {
/** Signed deviation from the baseline as it stood *before* this reading —
* i.e. "how surprising was this", not "how far is the baseline now". */
deviation: number
/** |deviation| normalised against `threshold * 3`, clamped to 0..1. */
severity: number
/** The raw reading that produced the event. */
value: number
at: number
}
export type ThresholdBaselineOptions = {
/** Deviation magnitude that counts as an event, in the sensor's own unit. */
threshold: number
/** EMA time constant, ms. */
tauMs: number
/** Readings folded in before deviations are trusted. A baseline has to
* mean something before a difference from it does. */
minSamples: number
}
/**
* Tracks a rolling baseline and reports readings that deviate far enough
* from it to count as events.
*
* Symmetric on purpose: a signal that strengthens is as much an event as
* one that weakens. A body can attenuate a BLE path *or* reflect into it,
* and ferrous mass can shield a magnetic field *or* add to it — a
* one-sided detector would miss half of what really happens.
*/
export class ThresholdBaseline {
private mean: number | null = null
private lastAt: number | null = null
private samples = 0
constructor(private readonly opts: ThresholdBaselineOptions) {}
get baseline(): number | null {
return this.mean
}
get warm(): boolean {
return this.samples >= this.opts.minSamples
}
/**
* Fold one reading in. Returns an event when the deviation clears the
* threshold and the baseline is warm, else null.
*
* Non-finite input (a garbled packet, a sensor returning NaN) is a no-op
* rather than a crash or — worse — a poisoned baseline that would then
* misreport every subsequent reading.
*/
push(value: number, atMs: number): BaselineEvent | null {
if (!Number.isFinite(value) || !Number.isFinite(atMs)) return null
if (this.mean === null) {
this.mean = value
this.lastAt = atMs
this.samples = 1
return null
}
const deviation = value - this.mean
const warm = this.warm
// Guard against zero/negative dt (duplicate timestamps, clock skew, two
// readings in one tick) with a flat fallback rather than dividing by an
// elapsed time that isn't trustworthy.
const dt = this.lastAt === null ? 0 : atMs - this.lastAt
const alpha = dt > 0 ? 1 - Math.exp(-dt / this.opts.tauMs) : 0.15
this.mean = this.mean + alpha * deviation
this.lastAt = atMs
this.samples++
if (!warm || Math.abs(deviation) < this.opts.threshold) return null
return {
deviation,
severity: Math.min(1, Math.abs(deviation) / (this.opts.threshold * 3)),
value,
at: atMs,
}
}
reset(): void {
this.mean = null
this.lastAt = null
this.samples = 0
}
}

View File

@@ -46,6 +46,8 @@ function bluetoothApi(): BluetoothLike | null {
return nav.bluetooth ?? null return nav.bluetooth ?? null
} }
import { ThresholdBaseline } from './baseline'
export type BleReading = { export type BleReading = {
/** Received signal strength, dBm. Typically -30 (touching) to -100 (far). */ /** Received signal strength, dBm. Typically -30 (touching) to -100 (far). */
rssi: number rssi: number
@@ -96,55 +98,36 @@ export function isSupported(): boolean {
* Pure and separately testable: no Bluetooth objects appear in here. * Pure and separately testable: no Bluetooth objects appear in here.
*/ */
export class BleFieldCore { export class BleFieldCore {
private mean: number | null = null private readonly core = new ThresholdBaseline({
private lastAt: number | null = null threshold: DISTURBANCE_DB,
private samples = 0 tauMs: BASELINE_TAU_MS,
minSamples: MIN_SAMPLES,
})
get baseline(): number | null { get baseline(): number | null {
return this.mean return this.core.baseline
} }
get warm(): boolean { get warm(): boolean {
return this.samples >= MIN_SAMPLES return this.core.warm
} }
/** /** Fold one reading in; see ThresholdBaseline.push. Returns a
* Fold one reading in. Returns a disturbance when the deviation clears * BLE-flavoured disturbance so callers keep reading `.rssi` rather than a
* the threshold and the baseline is warm, else null. Non-finite input is * generic `.value`. */
* a no-op rather than a crash or a poisoned baseline.
*/
push(rssi: number, atMs: number): BleDisturbance | null { push(rssi: number, atMs: number): BleDisturbance | null {
if (!Number.isFinite(rssi) || !Number.isFinite(atMs)) return null const event = this.core.push(rssi, atMs)
if (!event) return null
if (this.mean === null) {
this.mean = rssi
this.lastAt = atMs
this.samples = 1
return null
}
const deviation = rssi - this.mean
const warm = this.warm
const dt = this.lastAt === null ? 0 : atMs - this.lastAt
const alpha = dt > 0 ? 1 - Math.exp(-dt / BASELINE_TAU_MS) : 0.15
this.mean = this.mean + alpha * deviation
this.lastAt = atMs
this.samples++
if (!warm || Math.abs(deviation) < DISTURBANCE_DB) return null
return { return {
deviation, deviation: event.deviation,
severity: Math.min(1, Math.abs(deviation) / (DISTURBANCE_DB * 3)), severity: event.severity,
rssi, rssi: event.value,
at: atMs, at: event.at,
} }
} }
reset(): void { reset(): void {
this.mean = null this.core.reset()
this.lastAt = null
this.samples = 0
} }
} }

View File

@@ -25,6 +25,8 @@
// rolling-EMA-baseline shape used by coldSpot.ts and bluetooth.ts applies // rolling-EMA-baseline shape used by coldSpot.ts and bluetooth.ts applies
// here too. // here too.
import { ThresholdBaseline } from './baseline'
export type MagReading = { export type MagReading = {
/** Field magnitude in microtesla, sqrt(x^2 + y^2 + z^2). */ /** Field magnitude in microtesla, sqrt(x^2 + y^2 + z^2). */
magnitude: number magnitude: number
@@ -76,50 +78,33 @@ export function isSupported(): boolean {
* testable — no Sensor objects in here. * testable — no Sensor objects in here.
*/ */
export class MagFieldCore { export class MagFieldCore {
private mean: number | null = null private readonly core = new ThresholdBaseline({
private lastAt: number | null = null threshold: SPIKE_UT,
private samples = 0 tauMs: BASELINE_TAU_MS,
minSamples: MIN_SAMPLES,
})
get baseline(): number | null { get baseline(): number | null {
return this.mean return this.core.baseline
} }
get warm(): boolean { get warm(): boolean {
return this.samples >= MIN_SAMPLES return this.core.warm
} }
push(magnitude: number, atMs: number): MagAnomaly | null { push(magnitude: number, atMs: number): MagAnomaly | null {
if (!Number.isFinite(magnitude) || !Number.isFinite(atMs)) return null const event = this.core.push(magnitude, atMs)
if (!event) return null
if (this.mean === null) {
this.mean = magnitude
this.lastAt = atMs
this.samples = 1
return null
}
const deviation = magnitude - this.mean
const warm = this.warm
const dt = this.lastAt === null ? 0 : atMs - this.lastAt
const alpha = dt > 0 ? 1 - Math.exp(-dt / BASELINE_TAU_MS) : 0.15
this.mean = this.mean + alpha * deviation
this.lastAt = atMs
this.samples++
if (!warm || Math.abs(deviation) < SPIKE_UT) return null
return { return {
deviation, deviation: event.deviation,
severity: Math.min(1, Math.abs(deviation) / (SPIKE_UT * 3)), severity: event.severity,
magnitude, magnitude: event.value,
at: atMs, at: event.at,
} }
} }
reset(): void { reset(): void {
this.mean = null this.core.reset()
this.lastAt = null
this.samples = 0
} }
} }