From 3ae6034721592eab094172e3e62242fc8046b312 Mon Sep 17 00:00:00 2001 From: The Joel Date: Sat, 29 Aug 2026 21:03:33 +0100 Subject: [PATCH] feat: Implement Stellar Bridge Route Explainability Model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements route explainability model for Stellar bridge routes to expose the factors that influenced route selection decisions. Features: - Fee contribution analysis with scoring - Speed contribution analysis with scoring - Liquidity contribution analysis with scoring - Reliability contribution analysis with scoring - Risk contribution analysis with scoring - Human-readable explanations for route recommendations - Deterministic explanation output - Configurable scoring weights and factor labels - Customizable positive/negative threshold Implementation: - src/routing/explainability/stellar/types.ts - Type definitions - src/routing/explainability/stellar/route-explainer.ts - Main explainer class - src/routing/explainability/stellar/index.ts - Module exports - src/routing/scoring/route-scoring-factors.ts - Scoring factor calculations - src/routing/scoring/index.ts - Scoring module exports - tests/routing/explainability/route-explainer.spec.ts - Explainer tests - tests/routing/explainability/route-scoring-factors.spec.ts - Scoring tests Acceptance Criteria: - Route explainability model implemented ✓ - Scoring factors exposed ✓ - Route recommendation can be explained ✓ - Explanation output is deterministic ✓ --- src/routing/explainability/stellar/index.ts | 13 + .../explainability/stellar/route-explainer.ts | 310 ++++++++ src/routing/explainability/stellar/types.ts | 87 +++ src/routing/scoring/index.ts | 22 + src/routing/scoring/route-scoring-factors.ts | 168 +++++ .../explainability/route-explainer.spec.ts | 675 ++++++++++++++++++ .../route-scoring-factors.spec.ts | 369 ++++++++++ 7 files changed, 1644 insertions(+) create mode 100644 src/routing/explainability/stellar/index.ts create mode 100644 src/routing/explainability/stellar/route-explainer.ts create mode 100644 src/routing/explainability/stellar/types.ts create mode 100644 src/routing/scoring/index.ts create mode 100644 src/routing/scoring/route-scoring-factors.ts create mode 100644 tests/routing/explainability/route-explainer.spec.ts create mode 100644 tests/routing/explainability/route-scoring-factors.spec.ts diff --git a/src/routing/explainability/stellar/index.ts b/src/routing/explainability/stellar/index.ts new file mode 100644 index 00000000..6b6cb8cd --- /dev/null +++ b/src/routing/explainability/stellar/index.ts @@ -0,0 +1,13 @@ +/** + * Stellar Route Explainability Module + * + * Exports the route explainability model for Stellar bridge routes. + */ + +export { StellarRouteExplainer } from './route-explainer'; +export type { + ScoringFactor, + RouteExplanation, + ExplainabilityConfig, + ExplanationInput, +} from './types'; diff --git a/src/routing/explainability/stellar/route-explainer.ts b/src/routing/explainability/stellar/route-explainer.ts new file mode 100644 index 00000000..06c12c49 --- /dev/null +++ b/src/routing/explainability/stellar/route-explainer.ts @@ -0,0 +1,310 @@ +/** + * Stellar Route Explainability Model + * + * Generates human-readable explanations for why a particular route was recommended, + * exposing the scoring factors that influenced the decision. + * + * Features: + * - Fee contribution analysis + * - Speed contribution analysis + * - Liquidity contribution analysis + * - Reliability contribution analysis + * - Risk contribution analysis + * - Deterministic explanation output + */ + +import type { + ExplanationInput, + ExplainabilityConfig, + RouteExplanation, + ScoringFactor, +} from './types'; + +const DEFAULT_CONFIG: Required> = { + detailedFactors: true, + includeRisk: true, + positiveThreshold: 0.6, +}; + +const DEFAULT_FACTOR_LABELS: Record = { + fee: 'Transaction Fee', + speed: 'Transfer Speed', + liquidity: 'Available Liquidity', + reliability: 'Provider Reliability', + risk: 'Route Risk', +}; + +/** + * StellarRouteExplainer + * + * Generates explanations for route selection decisions by analyzing + * the scoring factors that contributed to the final route score. + */ +export class StellarRouteExplainer { + private config: Required>; + private factorLabels: Record; + + constructor(config: ExplainabilityConfig = {}) { + this.config = { + detailedFactors: config.detailedFactors ?? DEFAULT_CONFIG.detailedFactors, + includeRisk: config.includeRisk ?? DEFAULT_CONFIG.includeRisk, + positiveThreshold: config.positiveThreshold ?? DEFAULT_CONFIG.positiveThreshold, + }; + this.factorLabels = { ...DEFAULT_FACTOR_LABELS, ...config.factorLabels }; + } + + /** + * Generate a complete explanation for a route selection. + */ + explain(input: ExplanationInput): RouteExplanation { + const factors = this.calculateFactors(input); + const summary = this.generateSummary(input, factors); + + return { + route: input.evaluation.route, + finalScore: input.evaluation.score, + factors, + summary, + strategy: input.strategy, + timestamp: Date.now(), + }; + } + + /** + * Calculate individual scoring factors and their contributions. + */ + private calculateFactors(input: ExplanationInput): ScoringFactor[] { + const factors: ScoringFactor[] = []; + const weights = input.weights || { + fee: 0.35, + speed: 0.35, + reliability: 0.3, + liquidity: 0, + risk: 0, + }; + + // Fee factor + const feeFactor = this.calculateFeeFactor(input, weights.fee); + if (feeFactor) factors.push(feeFactor); + + // Speed factor + const speedFactor = this.calculateSpeedFactor(input, weights.speed); + if (speedFactor) factors.push(speedFactor); + + // Liquidity factor + if (input.liquidityData && weights.liquidity > 0) { + const liquidityFactor = this.calculateLiquidityFactor(input, weights.liquidity); + if (liquidityFactor) factors.push(liquidityFactor); + } + + // Reliability factor + if (input.reliabilityData) { + const reliabilityFactor = this.calculateReliabilityFactor(input, weights.reliability); + if (reliabilityFactor) factors.push(reliabilityFactor); + } + + // Risk factor + if (this.config.includeRisk && input.riskData && weights.risk > 0) { + const riskFactor = this.calculateRiskFactor(input, weights.risk); + if (riskFactor) factors.push(riskFactor); + } + + // Sort factors by contribution (highest first) + return factors.sort((a, b) => b.contribution - a.contribution); + } + + /** + * Calculate fee scoring factor. + */ + private calculateFeeFactor(input: ExplanationInput, weight: number): ScoringFactor | null { + const feeScore = input.evaluation.breakdown.feeScore; + const fee = input.evaluation.route.estimatedFee; + + const contribution = feeScore * weight; + const isPositive = feeScore >= this.config.positiveThreshold; + + let explanation: string; + if (isPositive) { + explanation = `Low transaction fee of ${fee.toFixed(2)} units (${(feeScore * 100).toFixed(1)}% score) positively influenced route selection.`; + } else { + explanation = `Higher transaction fee of ${fee.toFixed(2)} units (${(feeScore * 100).toFixed(1)}% score) negatively impacted route selection.`; + } + + return { + name: 'fee', + label: this.factorLabels.fee, + score: feeScore, + weight, + contribution, + explanation, + isPositive, + }; + } + + /** + * Calculate speed scoring factor. + */ + private calculateSpeedFactor(input: ExplanationInput, weight: number): ScoringFactor | null { + const speedScore = input.evaluation.breakdown.speedScore; + const timeMs = input.evaluation.route.estimatedTimeMs; + const timeMinutes = timeMs / 60_000; + + const contribution = speedScore * weight; + const isPositive = speedScore >= this.config.positiveThreshold; + + let explanation: string; + if (isPositive) { + explanation = `Fast estimated transfer time of ${timeMinutes.toFixed(1)} minutes (${(speedScore * 100).toFixed(1)}% score) positively influenced route selection.`; + } else { + explanation = `Slower estimated transfer time of ${timeMinutes.toFixed(1)} minutes (${(speedScore * 100).toFixed(1)}% score) negatively impacted route selection.`; + } + + return { + name: 'speed', + label: this.factorLabels.speed, + score: speedScore, + weight, + contribution, + explanation, + isPositive, + }; + } + + /** + * Calculate liquidity scoring factor. + */ + private calculateLiquidityFactor(input: ExplanationInput, weight: number): ScoringFactor | null { + if (!input.liquidityData) return null; + + const { availableLiquidity, requiredLiquidity, score } = input.liquidityData; + const liquidityRatio = availableLiquidity / requiredLiquidity; + + const contribution = score * weight; + const isPositive = score >= this.config.positiveThreshold; + + let explanation: string; + if (isPositive) { + explanation = `Sufficient liquidity available (${(liquidityRatio * 100).toFixed(1)}% of required, ${(score * 100).toFixed(1)}% score) positively influenced route selection.`; + } else { + explanation = `Limited liquidity available (${(liquidityRatio * 100).toFixed(1)}% of required, ${(score * 100).toFixed(1)}% score) negatively impacted route selection.`; + } + + return { + name: 'liquidity', + label: this.factorLabels.liquidity, + score, + weight, + contribution, + explanation, + isPositive, + }; + } + + /** + * Calculate reliability scoring factor. + */ + private calculateReliabilityFactor(input: ExplanationInput, weight: number): ScoringFactor | null { + if (!input.reliabilityData) return null; + + const { successRate, confidence, score } = input.reliabilityData; + const reliabilityScore = input.evaluation.breakdown.reliabilityScore; + + const contribution = reliabilityScore * weight; + const isPositive = reliabilityScore >= this.config.positiveThreshold; + + let explanation: string; + if (isPositive) { + explanation = `High provider reliability with ${(successRate * 100).toFixed(1)}% success rate and ${(confidence * 100).toFixed(1)}% confidence (${(reliabilityScore * 100).toFixed(1)}% score) positively influenced route selection.`; + } else { + explanation = `Lower provider reliability with ${(successRate * 100).toFixed(1)}% success rate (${(reliabilityScore * 100).toFixed(1)}% score) negatively impacted route selection.`; + } + + return { + name: 'reliability', + label: this.factorLabels.reliability, + score: reliabilityScore, + weight, + contribution, + explanation, + isPositive, + }; + } + + /** + * Calculate risk scoring factor. + */ + private calculateRiskFactor(input: ExplanationInput, weight: number): ScoringFactor | null { + if (!input.riskData) return null; + + const { riskScore, riskFactors } = input.riskData; + // Invert risk score so higher = better (like other factors) + const adjustedScore = 1 - riskScore; + + const contribution = adjustedScore * weight; + const isPositive = adjustedScore >= this.config.positiveThreshold; + + let explanation: string; + if (isPositive) { + explanation = `Low route risk (${(riskScore * 100).toFixed(1)}% risk score, ${(adjustedScore * 100).toFixed(1)}% safety score) positively influenced route selection.`; + } else { + const factorsList = riskFactors.length > 0 ? riskFactors.join(', ') : 'general risk factors'; + explanation = `Elevated route risk (${(riskScore * 100).toFixed(1)}% risk score, ${factorsList}) negatively impacted route selection.`; + } + + return { + name: 'risk', + label: this.factorLabels.risk, + score: adjustedScore, + weight, + contribution, + explanation, + isPositive, + }; + } + + /** + * Generate a human-readable summary of the route selection. + */ + private generateSummary(input: ExplanationInput, factors: ScoringFactor[]): string { + const route = input.evaluation.route; + const score = input.evaluation.score; + const positiveFactors = factors.filter(f => f.isPositive); + const negativeFactors = factors.filter(f => !f.isPositive); + + let summary = `Route "${route.id}" via ${route.provider} was selected with a final score of ${(score * 100).toFixed(1)}%. `; + + if (positiveFactors.length > 0) { + const topPositive = positiveFactors[0]; + summary += `The primary positive influence was ${topPositive.label.toLowerCase()} (${(topPositive.contribution * 100).toFixed(1)}% contribution). `; + } + + if (negativeFactors.length > 0) { + const topNegative = negativeFactors[0]; + summary += `The main concern was ${topNegative.label.toLowerCase()} (${(topNegative.contribution * 100).toFixed(1)}% contribution). `; + } + + if (this.config.detailedFactors) { + summary += `Scoring breakdown: ${factors.map(f => `${f.label} ${(f.contribution * 100).toFixed(1)}%`).join(', ')}.`; + } + + return summary; + } + + /** + * Update the explainability configuration. + */ + updateConfig(config: Partial): void { + if (config.detailedFactors !== undefined) { + this.config.detailedFactors = config.detailedFactors; + } + if (config.includeRisk !== undefined) { + this.config.includeRisk = config.includeRisk; + } + if (config.positiveThreshold !== undefined) { + this.config.positiveThreshold = config.positiveThreshold; + } + if (config.factorLabels) { + this.factorLabels = { ...this.factorLabels, ...config.factorLabels }; + } + } +} diff --git a/src/routing/explainability/stellar/types.ts b/src/routing/explainability/stellar/types.ts new file mode 100644 index 00000000..32405bfc --- /dev/null +++ b/src/routing/explainability/stellar/types.ts @@ -0,0 +1,87 @@ +/** + * Stellar Route Explainability Types + * + * Defines types for explaining why a particular route was recommended, + * including scoring factors and human-readable explanations. + */ + +import type { Route, RouteEvaluation } from '../../smart/stellar/soroban-smart-routing-engine'; + +/** Individual scoring factor contribution to route selection. */ +export interface ScoringFactor { + /** Factor name (e.g., 'fee', 'speed', 'liquidity', 'reliability', 'risk'). */ + name: string; + /** Human-readable label for the factor. */ + label: string; + /** Raw score value (0-1). */ + score: number; + /** Weight applied to this factor in the final score. */ + weight: number; + /** Weighted contribution to the final score (score * weight). */ + contribution: number; + /** Human-readable explanation of this factor's contribution. */ + explanation: string; + /** Whether this factor positively influenced the route selection. */ + isPositive: boolean; +} + +/** Complete explanation of why a route was selected. */ +export interface RouteExplanation { + /** The route being explained. */ + route: Route; + /** The final route score. */ + finalScore: number; + /** Individual scoring factors with their contributions. */ + factors: ScoringFactor[]; + /** Overall human-readable explanation. */ + summary: string; + /** The strategy that was used for route selection. */ + strategy: string; + /** Timestamp when the explanation was generated. */ + timestamp: number; +} + +/** Configuration for the explainability model. */ +export interface ExplainabilityConfig { + /** Enable detailed factor breakdown. */ + detailedFactors?: boolean; + /** Include risk scoring in explanation. */ + includeRisk?: boolean; + /** Custom labels for scoring factors. */ + factorLabels?: Partial>; + /** Threshold for considering a factor as "positive" influence. */ + positiveThreshold?: number; +} + +/** Input data for generating a route explanation. */ +export interface ExplanationInput { + /** The route evaluation to explain. */ + evaluation: RouteEvaluation; + /** The strategy used for selection. */ + strategy: string; + /** Optional liquidity data for the route. */ + liquidityData?: { + availableLiquidity: number; + requiredLiquidity: number; + score: number; + }; + /** Optional reliability data for the route. */ + reliabilityData?: { + successRate: number; + confidence: number; + score: number; + }; + /** Optional risk data for the route. */ + riskData?: { + riskScore: number; + riskFactors: string[]; + }; + /** Optional weights used in scoring. */ + weights?: { + fee: number; + speed: number; + reliability: number; + liquidity?: number; + risk?: number; + }; +} diff --git a/src/routing/scoring/index.ts b/src/routing/scoring/index.ts new file mode 100644 index 00000000..27b5a02c --- /dev/null +++ b/src/routing/scoring/index.ts @@ -0,0 +1,22 @@ +/** + * Route Scoring Module + * + * Exports scoring factor calculations for route explainability. + */ + +export { + calculateFeeScore, + calculateSpeedScore, + calculateLiquidityScore, + calculateRiskScore, + normalizeWeights, + calculateRouteScoringData, +} from './route-scoring-factors'; + +export type { + ScoringWeights, + LiquidityData, + ReliabilityData, + RiskData, + RouteScoringData, +} from './route-scoring-factors'; diff --git a/src/routing/scoring/route-scoring-factors.ts b/src/routing/scoring/route-scoring-factors.ts new file mode 100644 index 00000000..3caf97a6 --- /dev/null +++ b/src/routing/scoring/route-scoring-factors.ts @@ -0,0 +1,168 @@ +/** + * Route Scoring Factors + * + * Provides scoring factor calculations for route explainability. + * This module integrates with existing scoring systems to provide + * factor breakdowns for the explainability model. + */ + +import type { Route, RouteEvaluation } from '../smart/stellar/soroban-smart-routing-engine'; + +/** Scoring factor weights configuration. */ +export interface ScoringWeights { + fee: number; + speed: number; + reliability: number; + liquidity?: number; + risk?: number; +} + +/** Liquidity data for a route. */ +export interface LiquidityData { + availableLiquidity: number; + requiredLiquidity: number; + score: number; +} + +/** Reliability data for a route. */ +export interface ReliabilityData { + successRate: number; + confidence: number; + score: number; +} + +/** Risk data for a route. */ +export interface RiskData { + riskScore: number; + riskFactors: string[]; +} + +/** Complete scoring data for a route. */ +export interface RouteScoringData { + evaluation: RouteEvaluation; + liquidityData?: LiquidityData; + reliabilityData?: ReliabilityData; + riskData?: RiskData; + weights: ScoringWeights; +} + +/** + * Calculate fee score for a route (0-1, lower fee = higher score). + */ +export function calculateFeeScore(route: Route): number { + // Normalize fee assuming max reasonable fee is 100 units + return Math.max(0, 1 - route.estimatedFee / 100); +} + +/** + * Calculate speed score for a route (0-1, faster = higher score). + */ +export function calculateSpeedScore(route: Route): number { + // Normalize time assuming max reasonable time is 5 minutes (300,000ms) + return Math.max(0, 1 - route.estimatedTimeMs / 300_000); +} + +/** + * Calculate liquidity score based on available vs required liquidity. + */ +export function calculateLiquidityScore( + availableLiquidity: number, + requiredLiquidity: number +): number { + if (requiredLiquidity === 0) return 1; + const ratio = availableLiquidity / requiredLiquidity; + // Score saturates at 2x required liquidity + return Math.min(1, ratio / 2); +} + +/** + * Calculate risk-adjusted score (0-1, lower risk = higher score). + */ +export function calculateRiskScore(riskScore: number): number { + // Invert risk score so higher = better + return Math.max(0, 1 - riskScore); +} + +/** + * Normalize weights to ensure they sum to 1. + */ +export function normalizeWeights(weights: ScoringWeights): ScoringWeights { + const total = weights.fee + weights.speed + weights.reliability + + (weights.liquidity || 0) + (weights.risk || 0); + + if (Math.abs(total - 1) < 1e-9) return { ...weights }; + if (total === 0) { + return { + fee: 0.25, + speed: 0.25, + reliability: 0.25, + liquidity: 0.125, + risk: 0.125, + }; + } + + return { + fee: weights.fee / total, + speed: weights.speed / total, + reliability: weights.reliability / total, + liquidity: (weights.liquidity || 0) / total, + risk: (weights.risk || 0) / total, + }; +} + +/** + * Calculate comprehensive route scoring data. + */ +export function calculateRouteScoringData( + route: Route, + reliabilityScore: number, + options: { + liquidityData?: LiquidityData; + riskData?: RiskData; + weights?: Partial; + } = {} +): RouteScoringData { + const weights = normalizeWeights({ + fee: options.weights?.fee ?? 0.35, + speed: options.weights?.speed ?? 0.35, + reliability: options.weights?.reliability ?? 0.3, + liquidity: options.weights?.liquidity ?? 0, + risk: options.weights?.risk ?? 0, + }); + + const feeScore = calculateFeeScore(route); + const speedScore = calculateSpeedScore(route); + + const evaluation: RouteEvaluation = { + route, + score: 0, // Will be calculated below + breakdown: { + feeScore, + speedScore, + reliabilityScore, + }, + }; + + // Calculate final score + let totalScore = feeScore * weights.fee + + speedScore * weights.speed + + reliabilityScore * weights.reliability; + + if (options.liquidityData && weights.liquidity > 0) { + totalScore += options.liquidityData.score * weights.liquidity; + } + + if (options.riskData && weights.risk > 0) { + totalScore += calculateRiskScore(options.riskData.riskScore) * weights.risk; + } + + evaluation.score = totalScore; + + return { + evaluation, + liquidityData: options.liquidityData, + reliabilityData: options.reliabilityData, + riskData: options.riskData, + weights, + }; +} diff --git a/tests/routing/explainability/route-explainer.spec.ts b/tests/routing/explainability/route-explainer.spec.ts new file mode 100644 index 00000000..6da9fec7 --- /dev/null +++ b/tests/routing/explainability/route-explainer.spec.ts @@ -0,0 +1,675 @@ +/** + * Tests for Stellar Route Explainability Model + */ + +import { describe, it, expect, beforeEach } from '@jest/globals'; +import { StellarRouteExplainer } from '../../src/routing/explainability/stellar/route-explainer'; +import type { ExplanationInput } from '../../src/routing/explainability/stellar/types'; +import type { Route, RouteEvaluation } from '../../src/routing/smart/stellar/soroban-smart-routing-engine'; + +describe('StellarRouteExplainer', () => { + let explainer: StellarRouteExplainer; + + beforeEach(() => { + explainer = new StellarRouteExplainer(); + }); + + describe('explain', () => { + it('generates explanation with basic scoring factors', () => { + const route: Route = { + id: 'route-1', + provider: 'provider-a', + sourceChain: 'stellar', + destinationChain: 'ethereum', + estimatedFee: 10, + estimatedTimeMs: 120_000, + maxSlippage: 0.01, + }; + + const evaluation: RouteEvaluation = { + route, + score: 0.85, + breakdown: { + feeScore: 0.9, + speedScore: 0.8, + reliabilityScore: 0.85, + }, + }; + + const input: ExplanationInput = { + evaluation, + strategy: 'balanced', + weights: { + fee: 0.35, + speed: 0.35, + reliability: 0.3, + }, + }; + + const explanation = explainer.explain(input); + + expect(explanation.route).toEqual(route); + expect(explanation.finalScore).toBe(0.85); + expect(explanation.strategy).toBe('balanced'); + expect(explanation.factors).toHaveLength(3); + expect(explanation.summary).toBeDefined(); + expect(explanation.timestamp).toBeGreaterThan(0); + }); + + it('includes liquidity factor when data is provided', () => { + const route: Route = { + id: 'route-1', + provider: 'provider-a', + sourceChain: 'stellar', + destinationChain: 'ethereum', + estimatedFee: 10, + estimatedTimeMs: 120_000, + maxSlippage: 0.01, + }; + + const evaluation: RouteEvaluation = { + route, + score: 0.85, + breakdown: { + feeScore: 0.9, + speedScore: 0.8, + reliabilityScore: 0.85, + }, + }; + + const input: ExplanationInput = { + evaluation, + strategy: 'balanced', + liquidityData: { + availableLiquidity: 100_000, + requiredLiquidity: 50_000, + score: 0.9, + }, + weights: { + fee: 0.3, + speed: 0.3, + reliability: 0.25, + liquidity: 0.15, + }, + }; + + const explanation = explainer.explain(input); + + expect(explanation.factors).toHaveLength(4); + expect(explanation.factors.some(f => f.name === 'liquidity')).toBe(true); + }); + + it('includes reliability factor when data is provided', () => { + const route: Route = { + id: 'route-1', + provider: 'provider-a', + sourceChain: 'stellar', + destinationChain: 'ethereum', + estimatedFee: 10, + estimatedTimeMs: 120_000, + maxSlippage: 0.01, + }; + + const evaluation: RouteEvaluation = { + route, + score: 0.85, + breakdown: { + feeScore: 0.9, + speedScore: 0.8, + reliabilityScore: 0.85, + }, + }; + + const input: ExplanationInput = { + evaluation, + strategy: 'balanced', + reliabilityData: { + successRate: 0.95, + confidence: 0.9, + score: 0.85, + }, + weights: { + fee: 0.35, + speed: 0.35, + reliability: 0.3, + }, + }; + + const explanation = explainer.explain(input); + + expect(explanation.factors).toHaveLength(3); + expect(explanation.factors.some(f => f.name === 'reliability')).toBe(true); + }); + + it('includes risk factor when enabled and data is provided', () => { + const explainerWithRisk = new StellarRouteExplainer({ includeRisk: true }); + + const route: Route = { + id: 'route-1', + provider: 'provider-a', + sourceChain: 'stellar', + destinationChain: 'ethereum', + estimatedFee: 10, + estimatedTimeMs: 120_000, + maxSlippage: 0.01, + }; + + const evaluation: RouteEvaluation = { + route, + score: 0.85, + breakdown: { + feeScore: 0.9, + speedScore: 0.8, + reliabilityScore: 0.85, + }, + }; + + const input: ExplanationInput = { + evaluation, + strategy: 'balanced', + riskData: { + riskScore: 0.15, + riskFactors: ['low liquidity', 'high volatility'], + }, + weights: { + fee: 0.3, + speed: 0.3, + reliability: 0.25, + risk: 0.15, + }, + }; + + const explanation = explainerWithRisk.explain(input); + + expect(explanation.factors).toHaveLength(4); + expect(explanation.factors.some(f => f.name === 'risk')).toBe(true); + }); + + it('excludes risk factor when disabled', () => { + const explainerWithoutRisk = new StellarRouteExplainer({ includeRisk: false }); + + const route: Route = { + id: 'route-1', + provider: 'provider-a', + sourceChain: 'stellar', + destinationChain: 'ethereum', + estimatedFee: 10, + estimatedTimeMs: 120_000, + maxSlippage: 0.01, + }; + + const evaluation: RouteEvaluation = { + route, + score: 0.85, + breakdown: { + feeScore: 0.9, + speedScore: 0.8, + reliabilityScore: 0.85, + }, + }; + + const input: ExplanationInput = { + evaluation, + strategy: 'balanced', + riskData: { + riskScore: 0.15, + riskFactors: ['low liquidity'], + }, + weights: { + fee: 0.3, + speed: 0.3, + reliability: 0.25, + risk: 0.15, + }, + }; + + const explanation = explainerWithoutRisk.explain(input); + + expect(explanation.factors).toHaveLength(3); + expect(explanation.factors.some(f => f.name === 'risk')).toBe(false); + }); + + it('sorts factors by contribution (highest first)', () => { + const route: Route = { + id: 'route-1', + provider: 'provider-a', + sourceChain: 'stellar', + destinationChain: 'ethereum', + estimatedFee: 10, + estimatedTimeMs: 120_000, + maxSlippage: 0.01, + }; + + const evaluation: RouteEvaluation = { + route, + score: 0.85, + breakdown: { + feeScore: 0.9, + speedScore: 0.8, + reliabilityScore: 0.85, + }, + }; + + const input: ExplanationInput = { + evaluation, + strategy: 'balanced', + weights: { + fee: 0.5, + speed: 0.3, + reliability: 0.2, + }, + }; + + const explanation = explainer.explain(input); + + const contributions = explanation.factors.map(f => f.contribution); + for (let i = 0; i < contributions.length - 1; i++) { + expect(contributions[i]).toBeGreaterThanOrEqual(contributions[i + 1]); + } + }); + + it('generates human-readable summary', () => { + const route: Route = { + id: 'route-1', + provider: 'provider-a', + sourceChain: 'stellar', + destinationChain: 'ethereum', + estimatedFee: 10, + estimatedTimeMs: 120_000, + maxSlippage: 0.01, + }; + + const evaluation: RouteEvaluation = { + route, + score: 0.85, + breakdown: { + feeScore: 0.9, + speedScore: 0.8, + reliabilityScore: 0.85, + }, + }; + + const input: ExplanationInput = { + evaluation, + strategy: 'balanced', + weights: { + fee: 0.35, + speed: 0.35, + reliability: 0.3, + }, + }; + + const explanation = explainer.explain(input); + + expect(explanation.summary).toContain('route-1'); + expect(explanation.summary).toContain('provider-a'); + expect(explanation.summary).toContain('85.0%'); + }); + + it('identifies positive factors correctly', () => { + const route: Route = { + id: 'route-1', + provider: 'provider-a', + sourceChain: 'stellar', + destinationChain: 'ethereum', + estimatedFee: 10, + estimatedTimeMs: 120_000, + maxSlippage: 0.01, + }; + + const evaluation: RouteEvaluation = { + route, + score: 0.85, + breakdown: { + feeScore: 0.9, + speedScore: 0.8, + reliabilityScore: 0.85, + }, + }; + + const input: ExplanationInput = { + evaluation, + strategy: 'balanced', + weights: { + fee: 0.35, + speed: 0.35, + reliability: 0.3, + }, + }; + + const explanation = explainer.explain(input); + + explanation.factors.forEach(factor => { + if (factor.score >= 0.6) { + expect(factor.isPositive).toBe(true); + expect(factor.explanation).toContain('positively'); + } + }); + }); + + it('identifies negative factors correctly', () => { + const route: Route = { + id: 'route-1', + provider: 'provider-a', + sourceChain: 'stellar', + destinationChain: 'ethereum', + estimatedFee: 80, + estimatedTimeMs: 250_000, + maxSlippage: 0.01, + }; + + const evaluation: RouteEvaluation = { + route, + score: 0.4, + breakdown: { + feeScore: 0.2, + speedScore: 0.17, + reliabilityScore: 0.85, + }, + }; + + const input: ExplanationInput = { + evaluation, + strategy: 'balanced', + weights: { + fee: 0.35, + speed: 0.35, + reliability: 0.3, + }, + }; + + const explanation = explainer.explain(input); + + const negativeFactors = explanation.factors.filter(f => !f.isPositive); + expect(negativeFactors.length).toBeGreaterThan(0); + negativeFactors.forEach(factor => { + expect(factor.explanation).toContain('negatively'); + }); + }); + + it('uses custom factor labels when provided', () => { + const customExplainer = new StellarRouteExplainer({ + factorLabels: { + fee: 'Custom Fee Label', + speed: 'Custom Speed Label', + }, + }); + + const route: Route = { + id: 'route-1', + provider: 'provider-a', + sourceChain: 'stellar', + destinationChain: 'ethereum', + estimatedFee: 10, + estimatedTimeMs: 120_000, + maxSlippage: 0.01, + }; + + const evaluation: RouteEvaluation = { + route, + score: 0.85, + breakdown: { + feeScore: 0.9, + speedScore: 0.8, + reliabilityScore: 0.85, + }, + }; + + const input: ExplanationInput = { + evaluation, + strategy: 'balanced', + weights: { + fee: 0.35, + speed: 0.35, + reliability: 0.3, + }, + }; + + const explanation = customExplainer.explain(input); + + const feeFactor = explanation.factors.find(f => f.name === 'fee'); + expect(feeFactor?.label).toBe('Custom Fee Label'); + + const speedFactor = explanation.factors.find(f => f.name === 'speed'); + expect(speedFactor?.label).toBe('Custom Speed Label'); + }); + + it('respects custom positive threshold', () => { + const customExplainer = new StellarRouteExplainer({ positiveThreshold: 0.8 }); + + const route: Route = { + id: 'route-1', + provider: 'provider-a', + sourceChain: 'stellar', + destinationChain: 'ethereum', + estimatedFee: 10, + estimatedTimeMs: 120_000, + maxSlippage: 0.01, + }; + + const evaluation: RouteEvaluation = { + route, + score: 0.75, + breakdown: { + feeScore: 0.9, + speedScore: 0.7, + reliabilityScore: 0.65, + }, + }; + + const input: ExplanationInput = { + evaluation, + strategy: 'balanced', + weights: { + fee: 0.35, + speed: 0.35, + reliability: 0.3, + }, + }; + + const explanation = customExplainer.explain(input); + + const speedFactor = explanation.factors.find(f => f.name === 'speed'); + expect(speedFactor?.isPositive).toBe(false); + }); + + it('generates deterministic explanations', () => { + const route: Route = { + id: 'route-1', + provider: 'provider-a', + sourceChain: 'stellar', + destinationChain: 'ethereum', + estimatedFee: 10, + estimatedTimeMs: 120_000, + maxSlippage: 0.01, + }; + + const evaluation: RouteEvaluation = { + route, + score: 0.85, + breakdown: { + feeScore: 0.9, + speedScore: 0.8, + reliabilityScore: 0.85, + }, + }; + + const input: ExplanationInput = { + evaluation, + strategy: 'balanced', + weights: { + fee: 0.35, + speed: 0.35, + reliability: 0.3, + }, + }; + + const explanation1 = explainer.explain(input); + const explanation2 = explainer.explain(input); + + expect(explanation1.summary).toBe(explanation2.summary); + expect(explanation1.factors).toEqual(explanation2.factors); + }); + }); + + describe('updateConfig', () => { + it('updates detailed factors setting', () => { + explainer.updateConfig({ detailedFactors: false }); + + const route: Route = { + id: 'route-1', + provider: 'provider-a', + sourceChain: 'stellar', + destinationChain: 'ethereum', + estimatedFee: 10, + estimatedTimeMs: 120_000, + maxSlippage: 0.01, + }; + + const evaluation: RouteEvaluation = { + route, + score: 0.85, + breakdown: { + feeScore: 0.9, + speedScore: 0.8, + reliabilityScore: 0.85, + }, + }; + + const input: ExplanationInput = { + evaluation, + strategy: 'balanced', + weights: { + fee: 0.35, + speed: 0.35, + reliability: 0.3, + }, + }; + + const explanation = explainer.explain(input); + + expect(explanation.summary).not.toContain('Scoring breakdown'); + }); + + it('updates include risk setting', () => { + explainer.updateConfig({ includeRisk: false }); + + const route: Route = { + id: 'route-1', + provider: 'provider-a', + sourceChain: 'stellar', + destinationChain: 'ethereum', + estimatedFee: 10, + estimatedTimeMs: 120_000, + maxSlippage: 0.01, + }; + + const evaluation: RouteEvaluation = { + route, + score: 0.85, + breakdown: { + feeScore: 0.9, + speedScore: 0.8, + reliabilityScore: 0.85, + }, + }; + + const input: ExplanationInput = { + evaluation, + strategy: 'balanced', + riskData: { + riskScore: 0.15, + riskFactors: ['low liquidity'], + }, + weights: { + fee: 0.3, + speed: 0.3, + reliability: 0.25, + risk: 0.15, + }, + }; + + const explanation = explainer.explain(input); + + expect(explanation.factors.some(f => f.name === 'risk')).toBe(false); + }); + + it('updates positive threshold', () => { + explainer.updateConfig({ positiveThreshold: 0.9 }); + + const route: Route = { + id: 'route-1', + provider: 'provider-a', + sourceChain: 'stellar', + destinationChain: 'ethereum', + estimatedFee: 10, + estimatedTimeMs: 120_000, + maxSlippage: 0.01, + }; + + const evaluation: RouteEvaluation = { + route, + score: 0.85, + breakdown: { + feeScore: 0.9, + speedScore: 0.8, + reliabilityScore: 0.85, + }, + }; + + const input: ExplanationInput = { + evaluation, + strategy: 'balanced', + weights: { + fee: 0.35, + speed: 0.35, + reliability: 0.3, + }, + }; + + const explanation = explainer.explain(input); + + const speedFactor = explanation.factors.find(f => f.name === 'speed'); + expect(speedFactor?.isPositive).toBe(false); + }); + + it('updates factor labels', () => { + explainer.updateConfig({ factorLabels: { fee: 'Updated Fee Label' } }); + + const route: Route = { + id: 'route-1', + provider: 'provider-a', + sourceChain: 'stellar', + destinationChain: 'ethereum', + estimatedFee: 10, + estimatedTimeMs: 120_000, + maxSlippage: 0.01, + }; + + const evaluation: RouteEvaluation = { + route, + score: 0.85, + breakdown: { + feeScore: 0.9, + speedScore: 0.8, + reliabilityScore: 0.85, + }, + }; + + const input: ExplanationInput = { + evaluation, + strategy: 'balanced', + weights: { + fee: 0.35, + speed: 0.35, + reliability: 0.3, + }, + }; + + const explanation = explainer.explain(input); + + const feeFactor = explanation.factors.find(f => f.name === 'fee'); + expect(feeFactor?.label).toBe('Updated Fee Label'); + }); + }); +}); diff --git a/tests/routing/explainability/route-scoring-factors.spec.ts b/tests/routing/explainability/route-scoring-factors.spec.ts new file mode 100644 index 00000000..35717068 --- /dev/null +++ b/tests/routing/explainability/route-scoring-factors.spec.ts @@ -0,0 +1,369 @@ +/** + * Tests for Route Scoring Factors + */ + +import { describe, it, expect } from '@jest/globals'; +import { + calculateFeeScore, + calculateSpeedScore, + calculateLiquidityScore, + calculateRiskScore, + normalizeWeights, + calculateRouteScoringData, +} from '../../src/routing/scoring/route-scoring-factors'; +import type { Route } from '../../src/routing/smart/stellar/soroban-smart-routing-engine'; + +describe('Route Scoring Factors', () => { + describe('calculateFeeScore', () => { + it('returns high score for low fees', () => { + const route: Route = { + id: 'route-1', + provider: 'provider-a', + sourceChain: 'stellar', + destinationChain: 'ethereum', + estimatedFee: 5, + estimatedTimeMs: 120_000, + maxSlippage: 0.01, + }; + + const score = calculateFeeScore(route); + expect(score).toBeCloseTo(0.95, 1); + }); + + it('returns low score for high fees', () => { + const route: Route = { + id: 'route-1', + provider: 'provider-a', + sourceChain: 'stellar', + destinationChain: 'ethereum', + estimatedFee: 90, + estimatedTimeMs: 120_000, + maxSlippage: 0.01, + }; + + const score = calculateFeeScore(route); + expect(score).toBeCloseTo(0.1, 1); + }); + + it('returns 0 for fees above 100', () => { + const route: Route = { + id: 'route-1', + provider: 'provider-a', + sourceChain: 'stellar', + destinationChain: 'ethereum', + estimatedFee: 150, + estimatedTimeMs: 120_000, + maxSlippage: 0.01, + }; + + const score = calculateFeeScore(route); + expect(score).toBe(0); + }); + + it('returns 1 for zero fee', () => { + const route: Route = { + id: 'route-1', + provider: 'provider-a', + sourceChain: 'stellar', + destinationChain: 'ethereum', + estimatedFee: 0, + estimatedTimeMs: 120_000, + maxSlippage: 0.01, + }; + + const score = calculateFeeScore(route); + expect(score).toBe(1); + }); + }); + + describe('calculateSpeedScore', () => { + it('returns high score for fast transfers', () => { + const route: Route = { + id: 'route-1', + provider: 'provider-a', + sourceChain: 'stellar', + destinationChain: 'ethereum', + estimatedFee: 10, + estimatedTimeMs: 30_000, + maxSlippage: 0.01, + }; + + const score = calculateSpeedScore(route); + expect(score).toBeCloseTo(0.9, 1); + }); + + it('returns low score for slow transfers', () => { + const route: Route = { + id: 'route-1', + provider: 'provider-a', + sourceChain: 'stellar', + destinationChain: 'ethereum', + estimatedFee: 10, + estimatedTimeMs: 270_000, + maxSlippage: 0.01, + }; + + const score = calculateSpeedScore(route); + expect(score).toBeCloseTo(0.1, 1); + }); + + it('returns 0 for transfers above 5 minutes', () => { + const route: Route = { + id: 'route-1', + provider: 'provider-a', + sourceChain: 'stellar', + destinationChain: 'ethereum', + estimatedFee: 10, + estimatedTimeMs: 400_000, + maxSlippage: 0.01, + }; + + const score = calculateSpeedScore(route); + expect(score).toBe(0); + }); + + it('returns 1 for instant transfers', () => { + const route: Route = { + id: 'route-1', + provider: 'provider-a', + sourceChain: 'stellar', + destinationChain: 'ethereum', + estimatedFee: 10, + estimatedTimeMs: 0, + maxSlippage: 0.01, + }; + + const score = calculateSpeedScore(route); + expect(score).toBe(1); + }); + }); + + describe('calculateLiquidityScore', () => { + it('returns high score when liquidity exceeds requirement', () => { + const score = calculateLiquidityScore(100_000, 50_000); + expect(score).toBe(1); + }); + + it('returns medium score when liquidity meets requirement', () => { + const score = calculateLiquidityScore(50_000, 50_000); + expect(score).toBeCloseTo(0.5, 1); + }); + + it('returns low score when liquidity is below requirement', () => { + const score = calculateLiquidityScore(25_000, 50_000); + expect(score).toBeCloseTo(0.25, 1); + }); + + it('returns 1 when required liquidity is zero', () => { + const score = calculateLiquidityScore(100_000, 0); + expect(score).toBe(1); + }); + + it('saturates at 2x required liquidity', () => { + const score = calculateLiquidityScore(200_000, 50_000); + expect(score).toBe(1); + }); + }); + + describe('calculateRiskScore', () => { + it('returns high score for low risk', () => { + const score = calculateRiskScore(0.1); + expect(score).toBeCloseTo(0.9, 1); + }); + + it('returns low score for high risk', () => { + const score = calculateRiskScore(0.9); + expect(score).toBeCloseTo(0.1, 1); + }); + + it('returns 1 for zero risk', () => { + const score = calculateRiskScore(0); + expect(score).toBe(1); + }); + + it('returns 0 for maximum risk', () => { + const score = calculateRiskScore(1); + expect(score).toBe(0); + }); + }); + + describe('normalizeWeights', () => { + it('returns same weights if they sum to 1', () => { + const weights = { + fee: 0.35, + speed: 0.35, + reliability: 0.3, + }; + + const normalized = normalizeWeights(weights); + expect(normalized).toEqual(weights); + }); + + it('normalizes weights if they do not sum to 1', () => { + const weights = { + fee: 0.5, + speed: 0.5, + reliability: 0.5, + }; + + const normalized = normalizeWeights(weights); + const total = normalized.fee + normalized.speed + normalized.reliability; + expect(total).toBeCloseTo(1, 5); + }); + + it('returns equal weights if all are zero', () => { + const weights = { + fee: 0, + speed: 0, + reliability: 0, + liquidity: 0, + risk: 0, + }; + + const normalized = normalizeWeights(weights); + const total = normalized.fee + normalized.speed + normalized.reliability + + normalized.liquidity + normalized.risk; + expect(total).toBeCloseTo(1, 5); + }); + + it('handles optional liquidity and risk weights', () => { + const weights = { + fee: 0.4, + speed: 0.4, + reliability: 0.2, + }; + + const normalized = normalizeWeights(weights); + expect(normalized.liquidity).toBe(0); + expect(normalized.risk).toBe(0); + }); + }); + + describe('calculateRouteScoringData', () => { + it('calculates complete scoring data with basic factors', () => { + const route: Route = { + id: 'route-1', + provider: 'provider-a', + sourceChain: 'stellar', + destinationChain: 'ethereum', + estimatedFee: 10, + estimatedTimeMs: 120_000, + maxSlippage: 0.01, + }; + + const scoringData = calculateRouteScoringData(route, 0.85); + + expect(scoringData.evaluation.route).toEqual(route); + expect(scoringData.evaluation.breakdown.feeScore).toBeCloseTo(0.9, 1); + expect(scoringData.evaluation.breakdown.speedScore).toBeCloseTo(0.6, 1); + expect(scoringData.evaluation.breakdown.reliabilityScore).toBe(0.85); + expect(scoringData.weights).toBeDefined(); + }); + + it('includes liquidity data when provided', () => { + const route: Route = { + id: 'route-1', + provider: 'provider-a', + sourceChain: 'stellar', + destinationChain: 'ethereum', + estimatedFee: 10, + estimatedTimeMs: 120_000, + maxSlippage: 0.01, + }; + + const scoringData = calculateRouteScoringData(route, 0.85, { + liquidityData: { + availableLiquidity: 100_000, + requiredLiquidity: 50_000, + score: 0.9, + }, + weights: { + fee: 0.3, + speed: 0.3, + reliability: 0.25, + liquidity: 0.15, + }, + }); + + expect(scoringData.liquidityData).toBeDefined(); + expect(scoringData.liquidityData?.score).toBe(0.9); + expect(scoringData.weights.liquidity).toBeGreaterThan(0); + }); + + it('includes risk data when provided', () => { + const route: Route = { + id: 'route-1', + provider: 'provider-a', + sourceChain: 'stellar', + destinationChain: 'ethereum', + estimatedFee: 10, + estimatedTimeMs: 120_000, + maxSlippage: 0.01, + }; + + const scoringData = calculateRouteScoringData(route, 0.85, { + riskData: { + riskScore: 0.15, + riskFactors: ['low liquidity', 'high volatility'], + }, + weights: { + fee: 0.3, + speed: 0.3, + reliability: 0.25, + risk: 0.15, + }, + }); + + expect(scoringData.riskData).toBeDefined(); + expect(scoringData.riskData?.riskScore).toBe(0.15); + expect(scoringData.weights.risk).toBeGreaterThan(0); + }); + + it('uses custom weights when provided', () => { + const route: Route = { + id: 'route-1', + provider: 'provider-a', + sourceChain: 'stellar', + destinationChain: 'ethereum', + estimatedFee: 10, + estimatedTimeMs: 120_000, + maxSlippage: 0.01, + }; + + const customWeights = { + fee: 0.5, + speed: 0.3, + reliability: 0.2, + }; + + const scoringData = calculateRouteScoringData(route, 0.85, { + weights: customWeights, + }); + + expect(scoringData.weights.fee).toBeCloseTo(0.5, 5); + expect(scoringData.weights.speed).toBeCloseTo(0.3, 5); + expect(scoringData.weights.reliability).toBeCloseTo(0.2, 5); + }); + + it('calculates final score correctly', () => { + const route: Route = { + id: 'route-1', + provider: 'provider-a', + sourceChain: 'stellar', + destinationChain: 'ethereum', + estimatedFee: 10, + estimatedTimeMs: 120_000, + maxSlippage: 0.01, + }; + + const scoringData = calculateRouteScoringData(route, 0.85); + + const expectedScore = + scoringData.evaluation.breakdown.feeScore * scoringData.weights.fee + + scoringData.evaluation.breakdown.speedScore * scoringData.weights.speed + + scoringData.evaluation.breakdown.reliabilityScore * scoringData.weights.reliability; + + expect(scoringData.evaluation.score).toBeCloseTo(expectedScore, 5); + }); + }); +});