UNPKG

@claude-flow/embeddings

Version:

V3 Embedding Service - OpenAI, Transformers.js, Agentic-Flow (ONNX), Mock providers with hyperbolic embeddings, normalization, and chunking

200 lines (189 loc) • 7.38 kB
/** * ADR-121 Phase 23 — Trend visualization + drift detection (BEYOND SOTA). * * Phase 22's regression check catches single-step drops well — if a * commit drops recall@5 by 15%, CI fails. But it misses the **death * by a thousand cuts** failure mode: every commit drops 1%, every * check passes the 10% threshold, but cumulative drift after 30 * commits is 30%. * * This module ships two pieces: * 1. `renderSparkline(values)` — zero-dep ASCII sparkline using * the standard 8-level block-element ramp. Turns a sequence of * metric values into a one-line trend visualization for CI * output, PR comments, and human dashboards. * 2. `detectDrift(values, options)` — rolling-window drift check. * Compares the mean of the EARLIEST window to the mean of the * LATEST window; reports the relative change. Designed to fire * when cumulative drift exceeds the threshold even if every * single-step delta was below the per-step threshold. * * Composable with Phase 22's `checkRegression`: production CI typically * wants BOTH — single-step regression detection (catches obvious * breaks) AND drift detection (catches slow rot). */ /** * Render a sequence of numeric values as an ASCII sparkline using * the 8-level block-element ramp: ▁▂▃▄▅▆▇█ * * - Maps min(values) → ▁ and max(values) → █, linearly interpolated. * - When all values are identical, renders a flat line of the * middle character (▄) — visually distinguishable from "no data". * - Returns the empty string for an empty input. */ export function renderSparkline(values: ReadonlyArray<number>): string { if (!Array.isArray(values) || values.length === 0) return ''; const ramp = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; const filtered = values.filter(v => Number.isFinite(v)); if (filtered.length === 0) return '?'.repeat(values.length); let min = Infinity; let max = -Infinity; for (const v of filtered) { if (v < min) min = v; if (v > max) max = v; } if (min === max) return ramp[3]!.repeat(values.length); const span = max - min; return values .map(v => { if (!Number.isFinite(v)) return '?'; const norm = (v - min) / span; const idx = Math.min(ramp.length - 1, Math.max(0, Math.floor(norm * ramp.length))); return ramp[idx]!; }) .join(''); } export interface DriftDetectionOptions { /** * How many values to average at each end of the series. Default 3. * The first `windowSize` values form the "early" mean; the last * `windowSize` values form the "late" mean. */ readonly windowSize?: number; /** * Maximum allowed cumulative drift, expressed as a fractional * change (early → late mean). Default 0.15 (15%). */ readonly threshold?: number; /** * Optimization direction. 'higher' means larger is better — drift * fires when late < early. 'lower' means smaller is better — drift * fires when late > early. Default 'higher'. */ readonly direction?: 'higher' | 'lower'; } export interface DriftDetectionResult { /** Number of values analyzed. */ readonly sampleCount: number; /** Mean of the first windowSize values. */ readonly earlyMean: number; /** Mean of the last windowSize values. */ readonly lateMean: number; /** Absolute delta = lateMean - earlyMean. */ readonly delta: number; /** Fractional drift = delta / |earlyMean|. */ readonly drift: number; /** True if no drift regression was detected. */ readonly passed: boolean; /** Human-readable reason. */ readonly reason: string; /** * True when we had < 2*windowSize samples and skipped the check * (not enough data to compute distinct early and late windows). */ readonly skipped: boolean; } /** * Compute drift between the earliest and latest windows of a numeric * series. Returns `skipped: true` when the series is too short to * compute distinct windows. */ export function detectDrift( values: ReadonlyArray<number>, options: DriftDetectionOptions = {}, ): DriftDetectionResult { const windowSize = options.windowSize ?? 3; const threshold = options.threshold ?? 0.15; const direction = options.direction ?? 'higher'; const finite = values.filter(v => Number.isFinite(v)); // Need at least 2 * windowSize samples for distinct early + late windows. if (finite.length < 2 * windowSize) { return { sampleCount: finite.length, earlyMean: 0, lateMean: 0, delta: 0, drift: 0, passed: true, reason: `skipped — only ${finite.length} samples, need ≥${2 * windowSize} for windowSize=${windowSize}`, skipped: true, }; } const earlySlice = finite.slice(0, windowSize); const lateSlice = finite.slice(-windowSize); const earlyMean = earlySlice.reduce((s, v) => s + v, 0) / earlySlice.length; const lateMean = lateSlice.reduce((s, v) => s + v, 0) / lateSlice.length; const delta = lateMean - earlyMean; const drift = earlyMean === 0 ? (lateMean === 0 ? 0 : Infinity) : delta / Math.abs(earlyMean); let passed: boolean; let reason: string; if (direction === 'higher') { if (drift >= -threshold) { passed = true; const sign = drift >= 0 ? '+' : ''; reason = `no significant drift — late mean ${lateMean.toFixed(4)} ${sign}${(drift * 100).toFixed(1)}% vs early mean ${earlyMean.toFixed(4)} (within ${(threshold * 100).toFixed(0)}% drift tolerance for higher-is-better)`; } else { passed = false; reason = `DRIFT DETECTED — late mean ${lateMean.toFixed(4)} dropped ${Math.abs(drift * 100).toFixed(1)}% from early mean ${earlyMean.toFixed(4)} (exceeds ${(threshold * 100).toFixed(0)}% drift threshold over ${finite.length} samples)`; } } else { if (drift <= threshold) { passed = true; const sign = drift >= 0 ? '+' : ''; reason = `no significant drift — late mean ${lateMean.toFixed(4)} ${sign}${(drift * 100).toFixed(1)}% vs early mean ${earlyMean.toFixed(4)} (within ${(threshold * 100).toFixed(0)}% drift tolerance for lower-is-better)`; } else { passed = false; reason = `DRIFT DETECTED — late mean ${lateMean.toFixed(4)} grew ${(drift * 100).toFixed(1)}% from early mean ${earlyMean.toFixed(4)} (exceeds ${(threshold * 100).toFixed(0)}% drift threshold over ${finite.length} samples)`; } } return { sampleCount: finite.length, earlyMean, lateMean, delta, drift, passed, reason, skipped: false, }; } /** * Produce a one-line summary of a benchmark metric's history: a * sparkline, the latest value, and the drift verdict. Useful for * dashboard / PR-comment output. */ export interface BenchmarkTrendSummary { readonly benchmark: string; readonly metricPath: string; readonly sparkline: string; readonly sampleCount: number; readonly latestValue: number | null; readonly drift: DriftDetectionResult; } export function summarizeBenchmarkTrend( values: ReadonlyArray<number>, benchmark: string, metricPath: string, options: DriftDetectionOptions = {}, ): BenchmarkTrendSummary { return { benchmark, metricPath, sparkline: renderSparkline(values), sampleCount: values.length, latestValue: values.length > 0 ? values[values.length - 1]! : null, drift: detectDrift(values, options), }; }