margin-ts 0.6.0 → 0.6.2

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,227 @@
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
+ Observation,
27
+ Expression,
28
+ Parser,
29
+ createExpression,
30
+ expressionToString,
31
+ expressionToDict,
32
+ classifyDrift,
33
+ DriftClassification,
34
+ DriftState,
35
+ DriftDirection,
36
+ classifyAnomaly,
37
+ AnomalyClassification,
38
+ AnomalyState,
39
+ } from '../index.js';
40
+
41
+ // -----------------------------------------------------------------------
42
+ // StreamingMonitor — the TS equivalent of Python's Monitor
43
+ // -----------------------------------------------------------------------
44
+
45
+ interface ComponentWindow {
46
+ values: number[];
47
+ observations: Observation[];
48
+ maxWindow: number;
49
+ }
50
+
51
+ export class StreamingMonitor {
52
+ private parser: Parser;
53
+ private windows: Map<string, ComponentWindow> = new Map();
54
+ private _expression: Expression | null = null;
55
+ private _step = 0;
56
+ private maxWindow: number;
57
+ private minAnomalyRef: number;
58
+
59
+ constructor(parser: Parser, options: { window?: number; minAnomalyRef?: number } = {}) {
60
+ this.parser = parser;
61
+ this.maxWindow = options.window ?? 100;
62
+ this.minAnomalyRef = options.minAnomalyRef ?? 10;
63
+ }
64
+
65
+ get expression(): Expression | null { return this._expression; }
66
+ get step(): number { return this._step; }
67
+
68
+ update(values: Record<string, number>, now?: Date): Expression {
69
+ now = now ?? new Date();
70
+ this._expression = this.parser.parse(values, { step: this._step });
71
+ this._step++;
72
+
73
+ // Update per-component windows
74
+ for (const obs of this._expression.observations) {
75
+ if (!this.windows.has(obs.name)) {
76
+ this.windows.set(obs.name, { values: [], observations: [], maxWindow: this.maxWindow });
77
+ }
78
+ const w = this.windows.get(obs.name)!;
79
+ w.values.push(obs.value);
80
+ // Stamp the observation with time for drift
81
+ const stamped = { ...obs, measuredAt: now };
82
+ w.observations.push(stamped);
83
+ if (w.values.length > this.maxWindow) {
84
+ w.values.shift();
85
+ w.observations.shift();
86
+ }
87
+ }
88
+
89
+ return this._expression;
90
+ }
91
+
92
+ drift(component: string): DriftClassification | null {
93
+ const w = this.windows.get(component);
94
+ if (!w || w.observations.length < 3) return null;
95
+ return classifyDrift(w.observations);
96
+ }
97
+
98
+ anomaly(component: string): AnomalyClassification | null {
99
+ const w = this.windows.get(component);
100
+ if (!w || w.values.length < this.minAnomalyRef + 1) return null;
101
+ const ref = w.values.slice(0, -1);
102
+ const current = w.values[w.values.length - 1];
103
+ return classifyAnomaly(current, ref, { component });
104
+ }
105
+
106
+ status(): Record<string, unknown> {
107
+ const drift: Record<string, unknown> = {};
108
+ const anomaly: Record<string, unknown> = {};
109
+
110
+ for (const [name] of this.windows) {
111
+ const dc = this.drift(name);
112
+ if (dc) drift[name] = { state: dc.state, direction: dc.direction, rate: dc.rate };
113
+ const ac = this.anomaly(name);
114
+ if (ac) anomaly[name] = { state: ac.state, zScore: ac.zScore };
115
+ }
116
+
117
+ return {
118
+ step: this._step,
119
+ expression: this._expression ? expressionToString(this._expression) : null,
120
+ health: this._expression ? expressionToDict(this._expression) : null,
121
+ drift,
122
+ anomaly,
123
+ };
124
+ }
125
+
126
+ reset(): void {
127
+ this.windows.clear();
128
+ this._expression = null;
129
+ this._step = 0;
130
+ }
131
+ }
132
+
133
+ // -----------------------------------------------------------------------
134
+ // SSE helpers
135
+ // -----------------------------------------------------------------------
136
+
137
+ /** Connected SSE client. */
138
+ interface SSEClient {
139
+ id: string;
140
+ res: any;
141
+ }
142
+
143
+ let _clients: SSEClient[] = [];
144
+ let _clientIdCounter = 0;
145
+
146
+ /**
147
+ * Broadcast a status update to all connected SSE clients.
148
+ * Call this after each monitor.update().
149
+ */
150
+ export function broadcast(monitor: StreamingMonitor): void {
151
+ const data = JSON.stringify(monitor.status());
152
+ const message = `data: ${data}\n\n`;
153
+ _clients = _clients.filter(client => {
154
+ try {
155
+ client.res.write(message);
156
+ return true;
157
+ } catch {
158
+ return false; // client disconnected
159
+ }
160
+ });
161
+ }
162
+
163
+ /**
164
+ * Express/Fastify route handler for SSE streaming.
165
+ *
166
+ * app.get('/margin/stream', marginSSE(monitor));
167
+ *
168
+ * Each connected browser receives real-time health updates.
169
+ * Call broadcast(monitor) after each monitor.update() to push.
170
+ */
171
+ export function marginSSE(monitor: StreamingMonitor): (req: any, res: any) => void {
172
+ return (req: any, res: any) => {
173
+ res.writeHead(200, {
174
+ 'Content-Type': 'text/event-stream',
175
+ 'Cache-Control': 'no-cache',
176
+ 'Connection': 'keep-alive',
177
+ 'Access-Control-Allow-Origin': '*',
178
+ });
179
+
180
+ // Send current state immediately
181
+ const initial = JSON.stringify(monitor.status());
182
+ res.write(`data: ${initial}\n\n`);
183
+
184
+ const client: SSEClient = { id: String(++_clientIdCounter), res };
185
+ _clients.push(client);
186
+
187
+ req.on('close', () => {
188
+ _clients = _clients.filter(c => c.id !== client.id);
189
+ });
190
+ };
191
+ }
192
+
193
+ /**
194
+ * Next.js Pages Router SSE handler.
195
+ *
196
+ * // pages/api/margin/stream.ts
197
+ * export default marginSSEHandler(monitor);
198
+ */
199
+ export function marginSSEHandler(monitor: StreamingMonitor): (req: any, res: any) => void {
200
+ return marginSSE(monitor);
201
+ }
202
+
203
+ /**
204
+ * Create a polling endpoint (for environments that don't support SSE).
205
+ * Returns the current status as JSON on each request.
206
+ *
207
+ * app.get('/margin/poll', marginPoll(monitor));
208
+ */
209
+ export function marginPoll(monitor: StreamingMonitor): (req: any, res: any) => void {
210
+ return (_req: any, res: any) => {
211
+ res.json(monitor.status());
212
+ };
213
+ }
214
+
215
+ /** Number of connected SSE clients. */
216
+ export function connectedClients(): number {
217
+ return _clients.length;
218
+ }
219
+
220
+ /** Disconnect all SSE clients (for testing). */
221
+ export function resetClients(): void {
222
+ for (const client of _clients) {
223
+ try { client.res.end(); } catch {}
224
+ }
225
+ _clients = [];
226
+ _clientIdCounter = 0;
227
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Vitest/Jest test health reporter for margin.
3
+ *
4
+ * Classifies test suite health after every run: pass rate, duration,
5
+ * flake rate. The JS equivalent of margin's pytest plugin.
6
+ *
7
+ * Usage with vitest:
8
+ * // vitest.config.ts
9
+ * import { marginReporter } from 'margin-ts/adapters/vitest';
10
+ * export default { test: { reporters: ['default', marginReporter()] } };
11
+ *
12
+ * Or use standalone after collecting results:
13
+ * import { classifySuite } from 'margin-ts/adapters/vitest';
14
+ * const health = classifySuite({ passed: 95, failed: 3, skipped: 2, durationMs: 4500 });
15
+ */
16
+
17
+ import {
18
+ Confidence,
19
+ Health,
20
+ Thresholds,
21
+ createThresholds,
22
+ classify,
23
+ Observation,
24
+ Expression,
25
+ createExpression,
26
+ expressionToString,
27
+ } from '../index.js';
28
+
29
+ // -----------------------------------------------------------------------
30
+ // Suite metrics thresholds
31
+ // -----------------------------------------------------------------------
32
+
33
+ export interface SuiteThresholds {
34
+ passRate: Thresholds;
35
+ skipRate: Thresholds;
36
+ meanDurationMs: Thresholds;
37
+ totalDurationMs: Thresholds;
38
+ }
39
+
40
+ export const DEFAULT_SUITE_THRESHOLDS: SuiteThresholds = {
41
+ passRate: createThresholds(0.95, 0.70, true), // higher is better
42
+ skipRate: createThresholds(0.05, 0.20, false), // lower is better
43
+ meanDurationMs: createThresholds(100, 1000, false), // lower is better
44
+ totalDurationMs: createThresholds(30000, 120000, false), // lower is better
45
+ };
46
+
47
+ // -----------------------------------------------------------------------
48
+ // Suite results → Expression
49
+ // -----------------------------------------------------------------------
50
+
51
+ export interface SuiteResults {
52
+ passed: number;
53
+ failed: number;
54
+ skipped: number;
55
+ durationMs: number;
56
+ testDurations?: number[]; // per-test durations in ms
57
+ }
58
+
59
+ export function classifySuite(
60
+ results: SuiteResults,
61
+ thresholds: SuiteThresholds = DEFAULT_SUITE_THRESHOLDS,
62
+ label = '',
63
+ ): Expression {
64
+ const total = results.passed + results.failed + results.skipped;
65
+ if (total === 0) return createExpression([], [], label);
66
+
67
+ const passRate = results.passed / total;
68
+ const skipRate = results.skipped / total;
69
+ const meanDuration = results.testDurations && results.testDurations.length > 0
70
+ ? results.testDurations.reduce((a, b) => a + b, 0) / results.testDurations.length
71
+ : results.durationMs / total;
72
+
73
+ const now = new Date();
74
+ const observations: Observation[] = [
75
+ {
76
+ name: 'pass_rate',
77
+ health: classify(passRate, Confidence.HIGH, thresholds.passRate),
78
+ value: passRate,
79
+ baseline: 1.0,
80
+ confidence: Confidence.HIGH,
81
+ higherIsBetter: true,
82
+ provenance: [],
83
+ measuredAt: now,
84
+ },
85
+ {
86
+ name: 'skip_rate',
87
+ health: classify(skipRate, Confidence.HIGH, thresholds.skipRate),
88
+ value: skipRate,
89
+ baseline: 0.0,
90
+ confidence: Confidence.HIGH,
91
+ higherIsBetter: false,
92
+ provenance: [],
93
+ measuredAt: now,
94
+ },
95
+ {
96
+ name: 'mean_duration',
97
+ health: classify(meanDuration, Confidence.HIGH, thresholds.meanDurationMs),
98
+ value: meanDuration,
99
+ baseline: 50,
100
+ confidence: Confidence.HIGH,
101
+ higherIsBetter: false,
102
+ provenance: [],
103
+ measuredAt: now,
104
+ },
105
+ {
106
+ name: 'total_duration',
107
+ health: classify(results.durationMs, Confidence.MODERATE, thresholds.totalDurationMs),
108
+ value: results.durationMs,
109
+ baseline: 10000,
110
+ confidence: Confidence.MODERATE,
111
+ higherIsBetter: false,
112
+ provenance: [],
113
+ measuredAt: now,
114
+ },
115
+ {
116
+ name: 'failures',
117
+ health: classify(results.failed, Confidence.HIGH, createThresholds(0, 5, false)),
118
+ value: results.failed,
119
+ baseline: 0,
120
+ confidence: Confidence.HIGH,
121
+ higherIsBetter: false,
122
+ provenance: [],
123
+ measuredAt: now,
124
+ },
125
+ ];
126
+
127
+ return createExpression(observations, [], label || `suite (${total} tests)`);
128
+ }
129
+
130
+ /**
131
+ * Format suite health as a printable string.
132
+ */
133
+ export function suiteHealthString(results: SuiteResults, thresholds?: SuiteThresholds): string {
134
+ const expr = classifySuite(results, thresholds);
135
+ const total = results.passed + results.failed + results.skipped;
136
+ const lines = [
137
+ `margin health: ${total} tests (${results.passed} passed, ${results.failed} failed, ${results.skipped} skipped)`,
138
+ ` ${expressionToString(expr)}`,
139
+ ];
140
+ return lines.join('\n');
141
+ }
package/src/index.ts CHANGED
@@ -31,3 +31,30 @@ export {
31
31
  AnomalyState, ANOMALY_SEVERITY, AnomalyClassification,
32
32
  classifyAnomaly,
33
33
  } from './anomaly.js';
34
+
35
+ // Adapters
36
+ export {
37
+ marginMiddleware, marginHealthRoute,
38
+ DEFAULT_THRESHOLDS as EXPRESS_THRESHOLDS,
39
+ type MarginMiddlewareOptions, type MarginState, type EndpointThresholds,
40
+ } from './adapters/express.js';
41
+
42
+ export {
43
+ classifySuite, suiteHealthString,
44
+ DEFAULT_SUITE_THRESHOLDS,
45
+ type SuiteResults, type SuiteThresholds,
46
+ } from './adapters/vitest.js';
47
+
48
+ export {
49
+ withMargin, withMarginApp,
50
+ marginHealthHandler, marginHealthAppHandler,
51
+ resetRoutes,
52
+ DEFAULT_NEXTJS_THRESHOLDS,
53
+ type WithMarginOptions, type NextjsThresholds,
54
+ } from './adapters/nextjs.js';
55
+
56
+ export {
57
+ StreamingMonitor,
58
+ broadcast, marginSSE, marginSSEHandler, marginPoll,
59
+ connectedClients, resetClients,
60
+ } from './adapters/stream.js';