Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/indexes/routes/stellar/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export { StellarRoutePerformanceIndex } from './stellar-route-performance-index';
export type {
RouteLatencyMetrics,
RouteSuccessMetrics,
RoutePerformanceScore,
StellarRoutePerformanceIndexOptions,
} from './types';
95 changes: 95 additions & 0 deletions src/indexes/routes/stellar/stellar-route-performance-index.spec.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
});
99 changes: 99 additions & 0 deletions src/indexes/routes/stellar/stellar-route-performance-index.ts
Original file line number Diff line number Diff line change
@@ -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<StellarRoutePerformanceIndexOptions> = {
successWeight: 0.6,
latencyWeight: 0.4,
maxLatencyMs: 10_000,
};

export class StellarRoutePerformanceIndex {
private readonly options: Required<StellarRoutePerformanceIndexOptions>;

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<string, number[]>();
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<string, { success: number; total: number }>();
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 };
});
}
}
28 changes: 28 additions & 0 deletions src/indexes/routes/stellar/types.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading