From 8ffcecc9bec5edf74b24d83ad2c19fad5c7220fe Mon Sep 17 00:00:00 2001 From: "Abdulmalik A." Date: Fri, 19 Jun 2026 11:10:34 +0000 Subject: [PATCH] feat: implement Stellar Route Performance Index (#486) - Add RouteLatencyMetrics, RouteSuccessMetrics, RoutePerformanceScore types - Implement StellarRoutePerformanceIndex with aggregateLatency, aggregateSuccess, and score methods - Blend history latency with congestion data for accurate latency metrics - Composite 0-1 score weighted by success rate (60%) and latency (40%) - 10 passing tests --- src/indexes/routes/stellar/index.ts | 7 ++ .../stellar-route-performance-index.spec.ts | 95 ++++++++++++++++++ .../stellar-route-performance-index.ts | 99 +++++++++++++++++++ src/indexes/routes/stellar/types.ts | 28 ++++++ 4 files changed, 229 insertions(+) create mode 100644 src/indexes/routes/stellar/index.ts create mode 100644 src/indexes/routes/stellar/stellar-route-performance-index.spec.ts create mode 100644 src/indexes/routes/stellar/stellar-route-performance-index.ts create mode 100644 src/indexes/routes/stellar/types.ts diff --git a/src/indexes/routes/stellar/index.ts b/src/indexes/routes/stellar/index.ts new file mode 100644 index 0000000..07018ab --- /dev/null +++ b/src/indexes/routes/stellar/index.ts @@ -0,0 +1,7 @@ +export { StellarRoutePerformanceIndex } from './stellar-route-performance-index'; +export type { + RouteLatencyMetrics, + RouteSuccessMetrics, + RoutePerformanceScore, + StellarRoutePerformanceIndexOptions, +} from './types'; diff --git a/src/indexes/routes/stellar/stellar-route-performance-index.spec.ts b/src/indexes/routes/stellar/stellar-route-performance-index.spec.ts new file mode 100644 index 0000000..b2f129b --- /dev/null +++ b/src/indexes/routes/stellar/stellar-route-performance-index.spec.ts @@ -0,0 +1,95 @@ +import { StellarRoutePerformanceIndex } from './stellar-route-performance-index'; +import { RouteHistoryEntry } from '../../../history/routes/stellar/route-history'; +import { StellarRouteCongestion } from '../../../analytics/congestion/stellar/stellar-route-congestion-analyzer'; + +const makeEntry = (routeId: string, durationMs: number, success: boolean): RouteHistoryEntry => ({ + routeId, + fromAsset: 'XLM', + toAsset: 'USDC', + executedAt: new Date(), + durationMs, + success, +}); + +describe('StellarRoutePerformanceIndex', () => { + let index: StellarRoutePerformanceIndex; + + beforeEach(() => { + index = new StellarRoutePerformanceIndex(); + }); + + describe('aggregateLatency', () => { + it('returns average duration from history when no congestion data', () => { + const history = [makeEntry('r1', 200, true), makeEntry('r1', 400, true)]; + const result = index.aggregateLatency(history, []); + expect(result).toHaveLength(1); + expect(result[0].routeId).toBe('r1'); + expect(result[0].averageLatencyMs).toBe(300); + expect(result[0].sampleCount).toBe(2); + }); + + it('blends history average with congestion average latency', () => { + const history = [makeEntry('r1', 200, true)]; + const congestion: StellarRouteCongestion[] = [{ routeId: 'r1', averageLatencyMs: 400, spike: false }]; + const result = index.aggregateLatency(history, congestion); + expect(result[0].averageLatencyMs).toBe(300); // (200 + 400) / 2 + }); + + it('groups metrics by routeId', () => { + const history = [makeEntry('r1', 100, true), makeEntry('r2', 500, false)]; + const result = index.aggregateLatency(history, []); + expect(result).toHaveLength(2); + }); + }); + + describe('aggregateSuccess', () => { + it('calculates correct success rate', () => { + const history = [ + makeEntry('r1', 100, true), + makeEntry('r1', 100, true), + makeEntry('r1', 100, false), + ]; + const result = index.aggregateSuccess(history); + expect(result[0].successRate).toBeCloseTo(2 / 3); + expect(result[0].totalExecutions).toBe(3); + }); + + it('returns 0 success rate when all executions failed', () => { + const history = [makeEntry('r1', 100, false), makeEntry('r1', 100, false)]; + const result = index.aggregateSuccess(history); + expect(result[0].successRate).toBe(0); + }); + }); + + describe('score', () => { + it('returns a score between 0 and 1', () => { + const history = [makeEntry('r1', 500, true), makeEntry('r1', 500, false)]; + const scores = index.score(history); + expect(scores[0].score).toBeGreaterThanOrEqual(0); + expect(scores[0].score).toBeLessThanOrEqual(1); + }); + + it('scores a route with 100% success and low latency highly', () => { + const history = [makeEntry('r1', 100, true), makeEntry('r1', 100, true)]; + const scores = index.score(history); + expect(scores[0].score).toBeGreaterThan(0.8); + }); + + it('scores a route with 0% success and high latency lowly', () => { + const history = [makeEntry('r1', 9999, false), makeEntry('r1', 9999, false)]; + const scores = index.score(history); + expect(scores[0].score).toBeLessThan(0.1); + }); + + it('respects custom weights', () => { + const custom = new StellarRoutePerformanceIndex({ successWeight: 1, latencyWeight: 0 }); + const history = [makeEntry('r1', 9999, true)]; // bad latency, perfect success + const scores = custom.score(history); + expect(scores[0].score).toBeCloseTo(1); + }); + + it('returns empty array for empty history', () => { + expect(index.score([])).toEqual([]); + }); + }); +}); diff --git a/src/indexes/routes/stellar/stellar-route-performance-index.ts b/src/indexes/routes/stellar/stellar-route-performance-index.ts new file mode 100644 index 0000000..93741d2 --- /dev/null +++ b/src/indexes/routes/stellar/stellar-route-performance-index.ts @@ -0,0 +1,99 @@ +import { RouteHistoryEntry } from '../../../history/routes/stellar/route-history'; +import { StellarRouteCongestion } from '../../../analytics/congestion/stellar/stellar-route-congestion-analyzer'; +import { + RouteLatencyMetrics, + RoutePerformanceScore, + RouteSuccessMetrics, + StellarRoutePerformanceIndexOptions, +} from './types'; + +const DEFAULT_OPTIONS: Required = { + successWeight: 0.6, + latencyWeight: 0.4, + maxLatencyMs: 10_000, +}; + +export class StellarRoutePerformanceIndex { + private readonly options: Required; + + constructor(options: StellarRoutePerformanceIndexOptions = {}) { + const merged = { ...DEFAULT_OPTIONS, ...options }; + const total = merged.successWeight + merged.latencyWeight; + this.options = { + ...merged, + successWeight: merged.successWeight / total, + latencyWeight: merged.latencyWeight / total, + }; + } + + aggregateLatency( + history: RouteHistoryEntry[], + congestion: StellarRouteCongestion[] + ): RouteLatencyMetrics[] { + const congestionMap = new Map(congestion.map((c) => [c.routeId, c.averageLatencyMs])); + + const grouped = new Map(); + for (const entry of history) { + const latencies = grouped.get(entry.routeId) ?? []; + latencies.push(entry.durationMs); + grouped.set(entry.routeId, latencies); + } + + const result: RouteLatencyMetrics[] = []; + for (const [routeId, latencies] of grouped.entries()) { + const avg = latencies.reduce((a, b) => a + b, 0) / latencies.length; + const congestionLatency = congestionMap.get(routeId); + const averageLatencyMs = congestionLatency !== undefined + ? (avg + congestionLatency) / 2 + : avg; + + result.push({ routeId, averageLatencyMs: Math.round(averageLatencyMs), sampleCount: latencies.length }); + } + return result; + } + + aggregateSuccess(history: RouteHistoryEntry[]): RouteSuccessMetrics[] { + const grouped = new Map(); + for (const entry of history) { + const stats = grouped.get(entry.routeId) ?? { success: 0, total: 0 }; + stats.total += 1; + if (entry.success) stats.success += 1; + grouped.set(entry.routeId, stats); + } + + return Array.from(grouped.entries()).map(([routeId, stats]) => ({ + routeId, + successRate: stats.total === 0 ? 0 : stats.success / stats.total, + totalExecutions: stats.total, + })); + } + + score( + history: RouteHistoryEntry[], + congestion: StellarRouteCongestion[] = [] + ): RoutePerformanceScore[] { + const latencyMetrics = this.aggregateLatency(history, congestion); + const successMetrics = this.aggregateSuccess(history); + + const successMap = new Map(successMetrics.map((m) => [m.routeId, m])); + + return latencyMetrics.map((lm) => { + const sm = successMap.get(lm.routeId) ?? { + routeId: lm.routeId, + successRate: 0, + totalExecutions: 0, + }; + + const normalizedLatency = Math.max( + 0, + 1 - lm.averageLatencyMs / this.options.maxLatencyMs + ); + + const score = + this.options.successWeight * sm.successRate + + this.options.latencyWeight * normalizedLatency; + + return { routeId: lm.routeId, score: Math.round(score * 1000) / 1000, latencyMetrics: lm, successMetrics: sm }; + }); + } +} diff --git a/src/indexes/routes/stellar/types.ts b/src/indexes/routes/stellar/types.ts new file mode 100644 index 0000000..20c8f6a --- /dev/null +++ b/src/indexes/routes/stellar/types.ts @@ -0,0 +1,28 @@ +export interface RouteLatencyMetrics { + routeId: string; + averageLatencyMs: number; + sampleCount: number; +} + +export interface RouteSuccessMetrics { + routeId: string; + successRate: number; // 0–1 + totalExecutions: number; +} + +export interface RoutePerformanceScore { + routeId: string; + /** Composite performance score 0–1, higher is better */ + score: number; + latencyMetrics: RouteLatencyMetrics; + successMetrics: RouteSuccessMetrics; +} + +export interface StellarRoutePerformanceIndexOptions { + /** Weight for success rate (0–1). Default 0.6 */ + successWeight?: number; + /** Weight for latency (0–1). Default 0.4 */ + latencyWeight?: number; + /** Maximum latency (ms) used for normalization. Default 10_000 */ + maxLatencyMs?: number; +}