margin-ts 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Streaming health updates via Server-Sent Events (SSE).
3
+ *
4
+ * Push health/drift/anomaly updates to browser dashboards in real-time.
5
+ *
6
+ * Express:
7
+ * app.get('/margin/stream', marginSSE(monitor));
8
+ *
9
+ * Next.js Pages Router:
10
+ * export default marginSSEHandler(monitor);
11
+ *
12
+ * Browser client:
13
+ * const source = new EventSource('/margin/stream');
14
+ * source.onmessage = (e) => {
15
+ * const health = JSON.parse(e.data);
16
+ * updateDashboard(health);
17
+ * };
18
+ *
19
+ * Zero dependencies. Works with any framework that supports
20
+ * writable response streams (Express, Fastify, Next.js, raw Node http).
21
+ */
22
+
23
+ import {
24
+ Confidence,
25
+ Health,
26
+ Absence,
27
+ Observation,
28
+ Expression,
29
+ Parser,
30
+ isAbsent,
31
+ createExpression,
32
+ expressionToString,
33
+ expressionToDict,
34
+ classifyDrift,
35
+ DriftClassification,
36
+ DriftState,
37
+ DriftDirection,
38
+ classifyAnomaly,
39
+ AnomalyClassification,
40
+ AnomalyState,
41
+ } from '../index.js';
42
+
43
+ // -----------------------------------------------------------------------
44
+ // StreamingMonitor — the TS equivalent of Python's Monitor
45
+ // -----------------------------------------------------------------------
46
+
47
+ interface ComponentWindow {
48
+ values: number[];
49
+ observations: Observation[];
50
+ maxWindow: number;
51
+ }
52
+
53
+ export class StreamingMonitor {
54
+ private parser: Parser;
55
+ private windows: Map<string, ComponentWindow> = new Map();
56
+ private _expression: Expression | null = null;
57
+ private _step = 0;
58
+ private maxWindow: number;
59
+ private minAnomalyRef: number;
60
+
61
+ constructor(parser: Parser, options: { window?: number; minAnomalyRef?: number } = {}) {
62
+ this.parser = parser;
63
+ this.maxWindow = options.window ?? 100;
64
+ this.minAnomalyRef = options.minAnomalyRef ?? 10;
65
+ }
66
+
67
+ get expression(): Expression | null { return this._expression; }
68
+ get step(): number { return this._step; }
69
+
70
+ update(
71
+ values: Record<string, number>,
72
+ nowOrOptions?: Date | { now?: Date; absences?: Record<string, Absence> },
73
+ ): Expression {
74
+ let now: Date;
75
+ let absences: Record<string, Absence> | undefined;
76
+ if (nowOrOptions instanceof Date) {
77
+ now = nowOrOptions;
78
+ } else {
79
+ now = nowOrOptions?.now ?? new Date();
80
+ absences = nowOrOptions?.absences;
81
+ }
82
+ this._expression = this.parser.parse(values, { step: this._step, absences });
83
+ this._step++;
84
+
85
+ // Update per-component windows (skip absent observations —
86
+ // they carry no measured value and would corrupt drift/anomaly windows)
87
+ for (const obs of this._expression.observations) {
88
+ if (isAbsent(obs)) continue;
89
+
90
+ if (!this.windows.has(obs.name)) {
91
+ this.windows.set(obs.name, { values: [], observations: [], maxWindow: this.maxWindow });
92
+ }
93
+ const w = this.windows.get(obs.name)!;
94
+ w.values.push(obs.value);
95
+ const stamped = { ...obs, measuredAt: now };
96
+ w.observations.push(stamped);
97
+ if (w.values.length > this.maxWindow) {
98
+ w.values.shift();
99
+ w.observations.shift();
100
+ }
101
+ }
102
+
103
+ return this._expression;
104
+ }
105
+
106
+ drift(component: string): DriftClassification | null {
107
+ const w = this.windows.get(component);
108
+ if (!w || w.observations.length < 3) return null;
109
+ return classifyDrift(w.observations);
110
+ }
111
+
112
+ anomaly(component: string): AnomalyClassification | null {
113
+ const w = this.windows.get(component);
114
+ if (!w || w.values.length < this.minAnomalyRef + 1) return null;
115
+ const ref = w.values.slice(0, -1);
116
+ const current = w.values[w.values.length - 1];
117
+ return classifyAnomaly(current, ref, { component });
118
+ }
119
+
120
+ status(): Record<string, unknown> {
121
+ const drift: Record<string, unknown> = {};
122
+ const anomaly: Record<string, unknown> = {};
123
+
124
+ for (const [name] of this.windows) {
125
+ const dc = this.drift(name);
126
+ if (dc) drift[name] = { state: dc.state, direction: dc.direction, rate: dc.rate };
127
+ const ac = this.anomaly(name);
128
+ if (ac) anomaly[name] = { state: ac.state, zScore: ac.zScore };
129
+ }
130
+
131
+ return {
132
+ step: this._step,
133
+ expression: this._expression ? expressionToString(this._expression) : null,
134
+ health: this._expression ? expressionToDict(this._expression) : null,
135
+ drift,
136
+ anomaly,
137
+ };
138
+ }
139
+
140
+ reset(): void {
141
+ this.windows.clear();
142
+ this._expression = null;
143
+ this._step = 0;
144
+ }
145
+ }
146
+
147
+ // -----------------------------------------------------------------------
148
+ // SSE helpers
149
+ // -----------------------------------------------------------------------
150
+
151
+ /** Connected SSE client. */
152
+ interface SSEClient {
153
+ id: string;
154
+ res: any;
155
+ }
156
+
157
+ let _clients: SSEClient[] = [];
158
+ let _clientIdCounter = 0;
159
+
160
+ /**
161
+ * Broadcast a status update to all connected SSE clients.
162
+ * Call this after each monitor.update().
163
+ */
164
+ export function broadcast(monitor: StreamingMonitor): void {
165
+ const data = JSON.stringify(monitor.status());
166
+ const message = `data: ${data}\n\n`;
167
+ _clients = _clients.filter(client => {
168
+ try {
169
+ client.res.write(message);
170
+ return true;
171
+ } catch {
172
+ return false; // client disconnected
173
+ }
174
+ });
175
+ }
176
+
177
+ /**
178
+ * Express/Fastify route handler for SSE streaming.
179
+ *
180
+ * app.get('/margin/stream', marginSSE(monitor));
181
+ *
182
+ * Each connected browser receives real-time health updates.
183
+ * Call broadcast(monitor) after each monitor.update() to push.
184
+ */
185
+ export function marginSSE(monitor: StreamingMonitor): (req: any, res: any) => void {
186
+ return (req: any, res: any) => {
187
+ res.writeHead(200, {
188
+ 'Content-Type': 'text/event-stream',
189
+ 'Cache-Control': 'no-cache',
190
+ 'Connection': 'keep-alive',
191
+ 'Access-Control-Allow-Origin': '*',
192
+ });
193
+
194
+ // Send current state immediately
195
+ const initial = JSON.stringify(monitor.status());
196
+ res.write(`data: ${initial}\n\n`);
197
+
198
+ const client: SSEClient = { id: String(++_clientIdCounter), res };
199
+ _clients.push(client);
200
+
201
+ req.on('close', () => {
202
+ _clients = _clients.filter(c => c.id !== client.id);
203
+ });
204
+ };
205
+ }
206
+
207
+ /**
208
+ * Next.js Pages Router SSE handler.
209
+ *
210
+ * // pages/api/margin/stream.ts
211
+ * export default marginSSEHandler(monitor);
212
+ */
213
+ export function marginSSEHandler(monitor: StreamingMonitor): (req: any, res: any) => void {
214
+ return marginSSE(monitor);
215
+ }
216
+
217
+ /**
218
+ * Create a polling endpoint (for environments that don't support SSE).
219
+ * Returns the current status as JSON on each request.
220
+ *
221
+ * app.get('/margin/poll', marginPoll(monitor));
222
+ */
223
+ export function marginPoll(monitor: StreamingMonitor): (req: any, res: any) => void {
224
+ return (_req: any, res: any) => {
225
+ res.json(monitor.status());
226
+ };
227
+ }
228
+
229
+ /** Number of connected SSE clients. */
230
+ export function connectedClients(): number {
231
+ return _clients.length;
232
+ }
233
+
234
+ /** Disconnect all SSE clients (for testing). */
235
+ export function resetClients(): void {
236
+ for (const client of _clients) {
237
+ try { client.res.end(); } catch {}
238
+ }
239
+ _clients = [];
240
+ _clientIdCounter = 0;
241
+ }
package/src/index.ts CHANGED
@@ -15,10 +15,12 @@ export {
15
15
  } from './health.js';
16
16
 
17
17
  export {
18
+ Absence,
18
19
  Op, Observation, Correction, Expression,
20
+ isAbsent,
19
21
  observationSigma, observationToAtom, observationToDict, observationFromDict,
20
22
  correctionIsActive,
21
- createExpression, healthOf, degraded, expressionToString, expressionToDict,
23
+ createExpression, healthOf, degraded, absent, intact, expressionToString, expressionToDict,
22
24
  Parser,
23
25
  } from './observation.js';
24
26
 
@@ -44,3 +46,17 @@ export {
44
46
  DEFAULT_SUITE_THRESHOLDS,
45
47
  type SuiteResults, type SuiteThresholds,
46
48
  } from './adapters/vitest.js';
49
+
50
+ export {
51
+ withMargin, withMarginApp,
52
+ marginHealthHandler, marginHealthAppHandler,
53
+ resetRoutes,
54
+ DEFAULT_NEXTJS_THRESHOLDS,
55
+ type WithMarginOptions, type NextjsThresholds,
56
+ } from './adapters/nextjs.js';
57
+
58
+ export {
59
+ StreamingMonitor,
60
+ broadcast, marginSSE, marginSSEHandler, marginPoll,
61
+ connectedClients, resetClients,
62
+ } from './adapters/stream.js';
@@ -5,6 +5,20 @@
5
5
  import { Confidence, minConfidence } from './confidence.js';
6
6
  import { Health, Thresholds, classify, createThresholds, isIntact, SEVERITY } from './health.js';
7
7
 
8
+ // -----------------------------------------------------------------------
9
+ // Absence — why a value is missing
10
+ // -----------------------------------------------------------------------
11
+
12
+ export enum Absence {
13
+ NOT_MEASURED = 'not_measured',
14
+ BELOW_DETECTION = 'below_detection',
15
+ ABOVE_RANGE = 'above_range',
16
+ SENSOR_FAILED = 'sensor_failed',
17
+ REDACTED = 'redacted',
18
+ NOT_APPLICABLE = 'not_applicable',
19
+ PENDING = 'pending',
20
+ }
21
+
8
22
  // -----------------------------------------------------------------------
9
23
  // Observation
10
24
  // -----------------------------------------------------------------------
@@ -18,6 +32,12 @@ export interface Observation {
18
32
  higherIsBetter: boolean;
19
33
  provenance: string[];
20
34
  measuredAt?: Date;
35
+ absence?: Absence;
36
+ absenceDetail?: string;
37
+ }
38
+
39
+ export function isAbsent(obs: Observation): boolean {
40
+ return obs.absence !== undefined;
21
41
  }
22
42
 
23
43
  export function observationSigma(obs: Observation): number {
@@ -27,6 +47,7 @@ export function observationSigma(obs: Observation): number {
27
47
  }
28
48
 
29
49
  export function observationToAtom(obs: Observation): string {
50
+ if (obs.absence !== undefined) return `${obs.name}:ABSENT(${obs.absence})`;
30
51
  if (obs.health === Health.OOD) return `${obs.name}:${obs.health}`;
31
52
  const sigma = observationSigma(obs);
32
53
  const sign = sigma >= 0 ? '+' : '';
@@ -45,11 +66,13 @@ export function observationToDict(obs: Observation): Record<string, unknown> {
45
66
  provenance: obs.provenance,
46
67
  };
47
68
  if (obs.measuredAt) d.measuredAt = obs.measuredAt.toISOString();
69
+ if (obs.absence !== undefined) d.absence = obs.absence;
70
+ if (obs.absenceDetail !== undefined) d.absenceDetail = obs.absenceDetail;
48
71
  return d;
49
72
  }
50
73
 
51
74
  export function observationFromDict(d: Record<string, unknown>): Observation {
52
- return {
75
+ const obs: Observation = {
53
76
  name: d.name as string,
54
77
  health: d.health as Health,
55
78
  value: d.value as number,
@@ -59,6 +82,9 @@ export function observationFromDict(d: Record<string, unknown>): Observation {
59
82
  provenance: (d.provenance as string[]) ?? [],
60
83
  measuredAt: d.measuredAt ? new Date(d.measuredAt as string) : undefined,
61
84
  };
85
+ if (d.absence !== undefined) obs.absence = d.absence as Absence;
86
+ if (d.absenceDetail !== undefined) obs.absenceDetail = d.absenceDetail as string;
87
+ return obs;
62
88
  }
63
89
 
64
90
  // -----------------------------------------------------------------------
@@ -119,6 +145,14 @@ export function degraded(expr: Expression): Observation[] {
119
145
  );
120
146
  }
121
147
 
148
+ export function absent(expr: Expression): Observation[] {
149
+ return expr.observations.filter(o => isAbsent(o));
150
+ }
151
+
152
+ export function intact(expr: Expression): Observation[] {
153
+ return expr.observations.filter(o => o.health === Health.INTACT);
154
+ }
155
+
122
156
  export function expressionToString(expr: Expression): string {
123
157
  if (expr.observations.length === 0) return '[∅]';
124
158
  const parts = expr.observations.map(o => {
@@ -174,12 +208,15 @@ export class Parser {
174
208
  label?: string;
175
209
  step?: number;
176
210
  confidences?: Record<string, Confidence>;
211
+ absences?: Record<string, Absence>;
177
212
  } = {},
178
213
  ): Expression {
179
214
  const confidences = options.confidences ?? {};
215
+ const absences = options.absences ?? {};
180
216
  const observations: Observation[] = [];
181
217
 
182
218
  for (const [name, val] of Object.entries(values)) {
219
+ if (name in absences) continue; // handled below
183
220
  const baseline = this.baselines[name] ?? val;
184
221
  const conf = confidences[name] ?? Confidence.MODERATE;
185
222
  const t = this.thresholdsFor(name);
@@ -196,6 +233,24 @@ export class Parser {
196
233
  });
197
234
  }
198
235
 
236
+ // Emit absent observations
237
+ for (const [name, reason] of Object.entries(absences)) {
238
+ const baseline = this.baselines[name] ?? 0;
239
+ const t = this.thresholdsFor(name);
240
+ const conf = confidences[name] ?? Confidence.INDETERMINATE;
241
+ observations.push({
242
+ name,
243
+ health: Health.OOD,
244
+ value: baseline,
245
+ baseline,
246
+ confidence: conf,
247
+ higherIsBetter: t.higherIsBetter !== false,
248
+ provenance: [],
249
+ measuredAt: undefined,
250
+ absence: reason,
251
+ });
252
+ }
253
+
199
254
  return createExpression(observations, [], options.label ?? '', options.step);
200
255
  }
201
256
  }