Skip to content
101 changes: 101 additions & 0 deletions src/routing/advancedRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,86 @@ import { estimateCost } from "../utils/tokenUtils";
import { logScaleCostScore } from "../utils/costUtils";
import { quickselectTopK, selectTop } from "../utils/sorting";

// ============================================================
// TRAFFIC SHARES FOR ADAPTIVE DIVERSITY WEIGHT (EXP3-inspired)
// Based on negative frequency-dependent selection — as provider i's traffic
// share f_i grows above uniform (1/n), impose a diversity penalty.
// Formula: gamma = sqrt(n * log(n) / (T * G^2)) [Auer et al. 2002, EXP3]
// diversityPenalty_i = gamma * (f_i - 1/n)
// ============================================================

/** Number of times each provider has been selected (cumulative) */
const _selectionCount: Record<string, number> = {};

/** Total routing decisions since last reset */
let _totalDecisions = 0;

/** Reward range estimate for gamma computation (quality_score scale 0-1) */
const _REWARD_RANGE = 1.0;

/**
* Compute the EXP3-inspired diversity penalty for a provider.
* Based on: Auer et al. 2002 — "The Nonstochastic Multiarmed Bandit Problem"
* and the insight that as provider share f_i grows above uniform (1/n),
* imposing a penalty proportional to (f_i - 1/n) prevents monoculture
* (competitive exclusion — negative frequency-dependent selection).
*
* @param providerName - provider key
* @param nProviders - total number of available providers
* @param recentDecayFactor - optional decay for non-stationary environments
*/
function computeDiversityPenalty(
providerName: string,
nProviders: number,
recentDecayFactor = 0.0
): number {
if (nProviders < 2) return 0;
if (_totalDecisions < 2) return 0;

const share = _selectionCount[providerName] || 0;
const uniform = 1.0 / nProviders;

// Deviation from uniform distribution (can be negative if under-used)
const deviation = share - uniform;
if (Math.abs(deviation) < 1e-6) return 0;

// EXP3 learning rate: gamma = sqrt(n * log(n) / (T * G^2))
// G = reward range (quality_score in [0,1] → G = 1)
const T = Math.max(_totalDecisions, 10);
const gamma = Math.sqrt((nProviders * Math.log(nProviders)) / (T * _REWARD_RANGE * _REWARD_RANGE));

// Clamp gamma to prevent extreme penalties when T is very small
const clampedGamma = Math.min(gamma, 0.5);

// Optional: decay factor for non-stationary environments
// (higher share = stronger penalty, but decays over time)
// Currently disabled (recentDecayFactor=0) — re-enable if providers change frequently
const effectiveGamma = clampedGamma * (1.0 - recentDecayFactor);

// Penalty is proportional to how far above uniform the provider's share is
// (negative deviation = under-used = reward, not penalty)
if (deviation <= 0) return 0; // Reward already captured implicitly by below-uniform penalty

return effectiveGamma * deviation;
}
Comment on lines +47 to +80

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

share is a raw count, not a traffic fraction — diversity penalty math is broken.

Line 55 sets share = _selectionCount[providerName] || 0 (a cumulative integer count), then Line 59 computes deviation = share - uniform where uniform = 1/nProviders is a fraction (e.g. 0.5). The docstring and formula (lines 21-24, 39-41) both describe this as the provider's traffic share f_i, which should be _selectionCount[providerName] / _totalDecisions. As written, deviation grows unbounded with cumulative usage count instead of reflecting actual over-representation, so after just a couple of selections the "penalty" already dwarfs typical quality_score/cost_score values and never meaningfully reflects current traffic distribution.

🐛 Proposed fix
-  const share = _selectionCount[providerName] || 0;
+  const share = _totalDecisions > 0 ? (_selectionCount[providerName] || 0) / _totalDecisions : 0;
   const uniform = 1.0 / nProviders;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function computeDiversityPenalty(
providerName: string,
nProviders: number,
recentDecayFactor = 0.0
): number {
if (nProviders < 2) return 0;
if (_totalDecisions < 2) return 0;
const share = _selectionCount[providerName] || 0;
const uniform = 1.0 / nProviders;
// Deviation from uniform distribution (can be negative if under-used)
const deviation = share - uniform;
if (Math.abs(deviation) < 1e-6) return 0;
// EXP3 learning rate: gamma = sqrt(n * log(n) / (T * G^2))
// G = reward range (quality_score in [0,1] → G = 1)
const T = Math.max(_totalDecisions, 10);
const gamma = Math.sqrt((nProviders * Math.log(nProviders)) / (T * _REWARD_RANGE * _REWARD_RANGE));
// Clamp gamma to prevent extreme penalties when T is very small
const clampedGamma = Math.min(gamma, 0.5);
// Optional: decay factor for non-stationary environments
// (higher share = stronger penalty, but decays over time)
// Currently disabled (recentDecayFactor=0) — re-enable if providers change frequently
const effectiveGamma = clampedGamma * (1.0 - recentDecayFactor);
// Penalty is proportional to how far above uniform the provider's share is
// (negative deviation = under-used = reward, not penalty)
if (deviation <= 0) return 0; // Reward already captured implicitly by below-uniform penalty
return effectiveGamma * deviation;
}
function computeDiversityPenalty(
providerName: string,
nProviders: number,
recentDecayFactor = 0.0
): number {
if (nProviders < 2) return 0;
if (_totalDecisions < 2) return 0;
const share = _totalDecisions > 0 ? (_selectionCount[providerName] || 0) / _totalDecisions : 0;
const uniform = 1.0 / nProviders;
// Deviation from uniform distribution (can be negative if under-used)
const deviation = share - uniform;
if (Math.abs(deviation) < 1e-6) return 0;
// EXP3 learning rate: gamma = sqrt(n * log(n) / (T * G^2))
// G = reward range (quality_score in [0,1] → G = 1)
const T = Math.max(_totalDecisions, 10);
const gamma = Math.sqrt((nProviders * Math.log(nProviders)) / (T * _REWARD_RANGE * _REWARD_RANGE));
// Clamp gamma to prevent extreme penalties when T is very small
const clampedGamma = Math.min(gamma, 0.5);
// Optional: decay factor for non-stationary environments
// (higher share = stronger penalty, but decays over time)
// Currently disabled (recentDecayFactor=0) — re-enable if providers change frequently
const effectiveGamma = clampedGamma * (1.0 - recentDecayFactor);
// Penalty is proportional to how far above uniform the provider's share is
// (negative deviation = under-used = reward, not penalty)
if (deviation <= 0) return 0; // Reward already captured implicitly by below-uniform penalty
return effectiveGamma * deviation;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routing/advancedRouter.ts` around lines 47 - 80, Update
computeDiversityPenalty so share is calculated as the provider’s selection count
divided by _totalDecisions before comparing it with uniform. Keep the existing
early returns, deviation handling, and penalty calculation unchanged, ensuring
the resulting penalty reflects traffic fraction rather than an unbounded
cumulative count.


/**
* Record that a provider was selected (call after each routing decision).
* Used to compute the diversity penalty on subsequent decisions.
*/
function recordSelection(providerName: string): void {
_selectionCount[providerName] = (_selectionCount[providerName] || 0) + 1;
_totalDecisions += 1;
}

/**
* Reset traffic tracking (call when provider pool changes or for eval resets).
*/
function resetDiversityState(): void {
Object.keys(_selectionCount).forEach(k => delete _selectionCount[k]);
_totalDecisions = 0;
}

Comment on lines +91 to +98

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n 'resetDiversityState' -g '*.ts'

Repository: Das-rebel/a3m-router

Length of output: 158


🏁 Script executed:

#!/bin/bash
set -u

printf 'Tracked files matching advancedRouter.ts:\n'
git ls-files | rg '(^|/)advancedRouter\.ts$|src/routing/advancedRouter\.ts$' || true

printf '\nReferences to resetDiversityState (all tracked files):\n'
git ls-files -z | xargs -0 rg -n 'resetDiversityState' || true

printf '\nRelevant src/routing/advancedRouter.ts lines around selection/diversity state:\n'
if [ -f src/routing/advancedRouter.ts ]; then
  nl -ba src/routing/advancedRouter.ts | sed -n '1,140p'
fi

printf '\nExports from src/routing/advancedRouter.ts:\n'
if [ -f src/routing/advancedRouter.ts ]; then
  rg -n '^\s*(export\s+)?(function|const|let|var|class|async function)\b' src/routing/advancedRouter.ts || true
fi

printf '\nImports/references in typescript files containing resetDiversityState text (case-sensitive):\n'
rg -n -S '\bresetDiversityState\b' '*.ts' . || true

printf '\nImports/references in repository files containing resetDiversityState text:\n'
rg -n -a -S '\bresetDiversityState\b' . || true

Repository: Das-rebel/a3m-router

Length of output: 10980


Export resetDiversityState or remove it if eval resets are not intended.

resetDiversityState is referenced only by its own declaration, but its JSDoc explicitly says callers should invoke it for provider pool or eval resets. Make it exportable/importable where this is needed, or remove it if dead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routing/advancedRouter.ts` around lines 91 - 98, Resolve the unused
resetDiversityState declaration by exporting it for the provider-pool and
evaluation reset callers that its JSDoc describes, then update those callers to
import and invoke it where needed; if no such callers are intended, remove the
function and its JSDoc instead.

// ============================================================
// CACHE FOR MODEL PROFILES (avoids O(n*m) rebuild on every routeQuery)
// ============================================================
Expand Down Expand Up @@ -767,6 +847,24 @@ export function routeQuery(prompt: string, available_models?: string[], budget_m

let topCandidates = quickselectTopK(candidates, 4, scoreFn);

// === DIVERSITY PENALTY (EXP3-inspired, negative frequency-dependence) ===
// As provider share f_i grows above uniform (1/n), impose a diversity penalty.
// This prevents provider monoculture (competitive exclusion) — the core insight
// from the Paradox of the Plankton / negative frequency-dependent selection.
// Penalty is applied only to the quality dimension (complexity_bias-weighted),
// since cost already naturally distributes across providers.
// See: Auer et al. 2002, "The Nonstochastic Multiarmed Bandit Problem"
const nProviders = candidates.length;
for (const c of candidates) {
const divPenalty = computeDiversityPenalty(c.name, nProviders);
// Diversity penalty applies to quality dimension only (cost already disperses traffic)
c.total_score -= divPenalty * complexity_bias;
}

// Re-rank after diversity adjustment
candidates.sort((a, b) => b.total_score - a.total_score);
topCandidates = candidates.slice(0, 4);

// Adaptive quality floor: for complex queries, prefer models above the floor
if (adaptiveQualityFloor > 0) {
const qualified = topCandidates.filter(c => c.quality_score >= adaptiveQualityFloor);
Expand All @@ -779,6 +877,9 @@ export function routeQuery(prompt: string, available_models?: string[], budget_m
const primary = topCandidates[0];
const secondary = topCandidates.slice(1, 3);

// Record selection for diversity tracking (after final decision, before return)
recordSelection(primary.name);

// Calculate confidence based on score gap
let confidence = 0.5;
if (candidates.length > 1) {
Expand Down
Loading
Loading