/** * RouteSelector.ts — Adaptive A/B routing between GraphOrchestrator and ReAct (FASE 5) * * Selects the best execution path based on: * 1. Deterministic rules (low complexity → react, high+3tools → graph) * 2. Historical quality scores per task signature (stored in agentMemory) * 3. 10% epsilon-greedy exploration to avoid local optima * * S344: RouteStats ora traccia qualityScore (0–1 da goalVerifier) invece di binario * success/failure — il RouteSelector sceglie per qualità reale, non lunghezza testo. * * 100% local, no API calls, synchronous selection, async persistence. */ import { agentMemory } from "../agentMemory"; import type { PlanLike } from "./GraphOrchestrator"; export type Route = "graph" | "react"; export interface RouteOutcome { route: Route; success: boolean; latencyMs: number; qualityScore?: number; // S344: 0–1 da goalVerifier (sostituisce heuristica lunghezza) } interface RouteStats { gs: number; // graph successes (binario — backward compat) gt: number; // graph total rs: number; // react successes (binario — backward compat) rt: number; // react total gqs: number; // graph quality score sum (S344 — goalVerifier scores) rqs: number; // react quality score sum (S344 — goalVerifier scores) } const STORAGE_KEY = "route_selector:v2"; // S344: bump version — nuovo schema stats const MIN_SAMPLES_FOR_STATS = 2; // FIX-5a: 2 sample già sufficienti su progetti piccoli const EXPLORE_RATE = 0.12; // FIX-5b: 12% — più esplorazione con pochi task totali function planSignature(plan: PlanLike): string { const tools = [...new Set(plan.subtasks.filter(s => s.tool !== "respond").map(s => s.tool))].sort().join(","); return `${plan.complexity}|${tools}`; } /** * S344: usa qualityScore medio se disponibile (goalVerifier), altrimenti successRate binario. * qualitySum > 0 → almeno una sessione con goalVerifier attivo → score medio più accurato. */ function winRate(success: number, total: number, qualitySum: number): number { if (total === 0) return 0.5; // prior ottimistico al primo run if (qualitySum > 0) return qualitySum / total; // goalVerifier score — il più accurato // FIX-5c: Laplace smoothing — (success+1)/(total+2) evita 0.0/1.0 con 1-2 sample return (success + 1) / (total + 2); } export class RouteSelector { private stats = new Map(); private loaded = false; // Lazy-load from agentMemory on first use async ensureLoaded(): Promise { if (this.loaded) return; this.loaded = true; try { // Tenta prima la chiave v2 (S344), poi v1 per backward compat const raw = agentMemory.get(STORAGE_KEY) ?? agentMemory.get("route_selector:v1"); if (raw) { const obj = JSON.parse(raw) as Record; for (const [k, v] of Object.entries(obj)) { // S344: backward compat — old v1 entries non hanno gqs/rqs this.stats.set(k, { ...v, gqs: v.gqs ?? 0, rqs: v.rqs ?? 0 }); } } } catch { /* first run — empty stats */ } } private persist(): void { try { const obj: Record = {}; for (const [k, v] of this.stats.entries()) { obj[k] = v; } agentMemory.set(STORAGE_KEY, JSON.stringify(obj), "routing"); } catch { /* non-blocking */ } } /** Select the route for a given plan. Must call ensureLoaded() first. */ select(plan: PlanLike): Route { const toolCount = plan.subtasks.filter(s => s.tool !== "respond").length; // ── Hard rules (deterministic) ────────────────────────────────────────── if (plan.complexity === "low" || toolCount <= 1) return "react"; if (plan.complexity === "high" && toolCount >= 3) return "graph"; // ── Epsilon-greedy exploration ─────────────────────────────────────────── if (Math.random() < EXPLORE_RATE) { return Math.random() < 0.5 ? "graph" : "react"; } // ── Stats-based selection (S344: qualità-pesata) ───────────────────────── const sig = planSignature(plan); const s = this.stats.get(sig); if (!s || (s.gt + s.rt) < MIN_SAMPLES_FOR_STATS) { // Default for medium complexity: graph if 2+ tools, react otherwise return toolCount >= 2 ? "graph" : "react"; } const graphWin = winRate(s.gs, s.gt, s.gqs); const reactWin = winRate(s.rs, s.rt, s.rqs); return graphWin >= reactWin ? "graph" : "react"; } /** * Record the outcome of a completed execution for future routing decisions. * S344: qualityScore (da goalVerifier) sostituisce l'heuristica binaria length > 80. * Fallback: se qualityScore non disponibile (task senza formalGoal), usa 0.8/0.2. */ record(plan: PlanLike, outcome: RouteOutcome): void { const sig = planSignature(plan); const s = this.stats.get(sig) ?? { gs: 0, gt: 0, rs: 0, rt: 0, gqs: 0, rqs: 0 }; // S344: quality score da usare — goalVerifier se disponibile, fallback binario const quality = outcome.qualityScore ?? (outcome.success ? 0.8 : 0.2); if (outcome.route === "graph") { s.gt++; if (outcome.success) s.gs++; s.gqs += quality; } else { s.rt++; if (outcome.success) s.rs++; s.rqs += quality; } this.stats.set(sig, s); this.persist(); } /** Debug: return stats for all known signatures */ dump(): Record { const out: Record = {}; for (const [k, v] of this.stats.entries()) out[k] = v; return out; } } export const routeSelector = new RouteSelector();