mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
* feat(eval): add router-eval harness (AIQ scoring, regression gate, Pareto search)
Extracts a standalone router-eval evaluation tool that replays routing
decisions (from NDJSON corpora or the usage_history/call_logs SQLite
tables) into an AIQ (success/latency/cost) score, compares baseline vs.
candidate router configs with a retained-run regression gate, and ranks
Pareto-optimal candidates across a search space — a sibling to the
existing eval:compression harness.
New scripts: scripts/router-eval/{index,compare,patch-compare,search,
trends}.ts, scripts/check/check-router-eval-regression.ts, and
src/lib/routerEval/index.ts, wired via 6 new package.json entries
(eval:router, eval:router:compare, eval:router:patch-compare,
eval:router:search, eval:router:trends, check:router-eval).
Reconstructed onto current release/v3.8.49 from the original ~142-commit
stale PR branch: only the genuinely new router-eval payload (17 files)
was extracted — the other ~560 changed files in the original diff were
base-drift already present on release in newer form. The new package.json
scripts now invoke `node --import tsx` instead of `bun`, matching the
`eval:compression` precedent (Bun is reserved for a closed 5-script
allowlist). The runtime-detection shim (`"Bun" in globalThis`) already
present in the harness gracefully falls back to better-sqlite3 under
Node, so no logic changes were needed there; the retained-run manifest's
previously-hardcoded `runtime: "bun"` field and matching CLI help text
were corrected to reflect the actual invocation.
All 27 existing router-eval unit tests pass unchanged.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* refactor(router-eval): decompose toRouterObservation below complexity gate
toRouterObservation had cyclomatic complexity 18 (gate max is 15). Extract
the per-field parsing/normalization into pure helpers (sampleId, model
fields, latency, cost derivation, success) so the entry point is a plain
sequential assembly of a RouterObservation. Behavior is unchanged — same
tests pass, same fallbacks, same precedence between input aliases.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
199 lines
6.3 KiB
JavaScript
199 lines
6.3 KiB
JavaScript
#!/usr/bin/env node
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
|
|
import type {
|
|
RouterConfigAggregate,
|
|
RouterEvalArtifact,
|
|
RouterEvalArtifactMetadata,
|
|
RouterEvalComparison,
|
|
RouterEvalReport,
|
|
} from "@/lib/routerEval/index.ts";
|
|
|
|
type TrendRow = {
|
|
runId: string;
|
|
generatedAt: string;
|
|
kind: RouterEvalArtifact["kind"];
|
|
bestConfig: string;
|
|
aiq: number;
|
|
avgCostUsd: number;
|
|
avgLatencyMs: number;
|
|
regressions: number;
|
|
source: string;
|
|
window: string;
|
|
};
|
|
|
|
function getArgValue(name: string): string | undefined {
|
|
const index = process.argv.indexOf(`--${name}`);
|
|
if (index < 0) return undefined;
|
|
const value = process.argv[index + 1];
|
|
return value && !value.startsWith("--") ? value : undefined;
|
|
}
|
|
|
|
function usage(): string {
|
|
return [
|
|
"Usage:",
|
|
" npm run eval:router:trends -- --artifact-dir <dir> [--limit <n>] [--dashboard]",
|
|
"",
|
|
"Reads retained router-eval JSON artifacts and prints a markdown trend table or dashboard.",
|
|
].join("\n");
|
|
}
|
|
|
|
function readJson(filePath: string): RouterEvalArtifact | null {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(filePath, "utf8")) as RouterEvalArtifact;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function bestFromReport(report: RouterEvalReport): RouterConfigAggregate | undefined {
|
|
return report.top[0];
|
|
}
|
|
|
|
function bestFromComparison(comparison: RouterEvalComparison): RouterConfigAggregate | undefined {
|
|
return comparison.candidate.top[0];
|
|
}
|
|
|
|
function toTrendRow(runId: string, artifact: RouterEvalArtifact): TrendRow | null {
|
|
const best = artifact.comparison
|
|
? bestFromComparison(artifact.comparison)
|
|
: artifact.report
|
|
? bestFromReport(artifact.report)
|
|
: undefined;
|
|
|
|
if (!best) return null;
|
|
|
|
return {
|
|
runId,
|
|
generatedAt: artifact.generatedAt,
|
|
kind: artifact.kind,
|
|
bestConfig: best.configId,
|
|
aiq: best.aiq,
|
|
avgCostUsd: best.avgCostUsd,
|
|
avgLatencyMs: best.avgLatencyMs,
|
|
regressions: artifact.comparison?.regressions.length ?? 0,
|
|
source: artifact.metadata?.candidate?.source ?? "unknown",
|
|
window: formatWindow(artifact.metadata?.window),
|
|
};
|
|
}
|
|
|
|
function formatWindow(window: RouterEvalArtifactMetadata["window"]): string {
|
|
if (!window || typeof window !== "object") return "all";
|
|
const parts: string[] = [];
|
|
if ("since" in window && typeof window.since === "string") parts.push(`since ${window.since}`);
|
|
if ("limit" in window && typeof window.limit === "number") parts.push(`limit ${window.limit}`);
|
|
return parts.length > 0 ? parts.join(", ") : "all";
|
|
}
|
|
|
|
function collectTrendRows(artifactDir: string): TrendRow[] {
|
|
if (!fs.existsSync(artifactDir)) return [];
|
|
|
|
const rows: TrendRow[] = [];
|
|
for (const entry of fs.readdirSync(artifactDir, { withFileTypes: true })) {
|
|
const runId = entry.name;
|
|
const jsonPath = entry.isDirectory()
|
|
? path.join(artifactDir, runId, "router-eval.json")
|
|
: entry.isFile() && entry.name.endsWith(".json")
|
|
? path.join(artifactDir, entry.name)
|
|
: "";
|
|
if (!jsonPath) continue;
|
|
|
|
const artifact = readJson(jsonPath);
|
|
if (!artifact || artifact.schemaVersion !== 1) continue;
|
|
const row = toTrendRow(runId.replace(/\.json$/, ""), artifact);
|
|
if (row) rows.push(row);
|
|
}
|
|
|
|
return rows.sort((a, b) => a.generatedAt.localeCompare(b.generatedAt));
|
|
}
|
|
|
|
function formatTrend(rows: TrendRow[], limit: number): string {
|
|
const limited = rows.slice(-limit);
|
|
const lines = [
|
|
"# Router Eval Trends",
|
|
"",
|
|
"| Run | Kind | Source | Window | Best Config | AIQ | Avg Cost | Avg Latency | Regressions |",
|
|
"| --- | --- | --- | --- | --- | ---: | ---: | ---: | ---: |",
|
|
];
|
|
|
|
for (const row of limited) {
|
|
lines.push(
|
|
`| ${row.runId} | ${row.kind} | ${row.source} | ${row.window} | ${row.bestConfig} | ${row.aiq.toFixed(3)} | $${row.avgCostUsd.toFixed(6)} | ${row.avgLatencyMs.toFixed(2)}ms | ${row.regressions} |`
|
|
);
|
|
}
|
|
|
|
return `${lines.join("\n")}\n`;
|
|
}
|
|
|
|
function formatDelta(value: number): string {
|
|
if (value > 0) return `+${value.toFixed(3)}`;
|
|
return value.toFixed(3);
|
|
}
|
|
|
|
function average(values: number[]): number {
|
|
if (values.length === 0) return 0;
|
|
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
}
|
|
|
|
function formatDashboard(rows: TrendRow[], limit: number): string {
|
|
const limited = rows.slice(-limit);
|
|
const latest = limited[limited.length - 1];
|
|
const previous = limited[limited.length - 2];
|
|
const aiqDelta = latest && previous ? latest.aiq - previous.aiq : 0;
|
|
const latencyDelta = latest && previous ? latest.avgLatencyMs - previous.avgLatencyMs : 0;
|
|
const costDelta = latest && previous ? latest.avgCostUsd - previous.avgCostUsd : 0;
|
|
const regressions = limited.reduce((sum, row) => sum + row.regressions, 0);
|
|
const lines = [
|
|
"# Router Eval Dashboard",
|
|
"",
|
|
`Runs: ${limited.length}`,
|
|
`Latest: ${latest?.runId ?? "n/a"}`,
|
|
`Best config: ${latest?.bestConfig ?? "n/a"}`,
|
|
`AIQ: ${latest ? latest.aiq.toFixed(3) : "0.000"} (${formatDelta(aiqDelta)})`,
|
|
`Avg latency: ${latest ? latest.avgLatencyMs.toFixed(2) : "0.00"}ms (${formatDelta(latencyDelta)}ms)`,
|
|
`Avg cost: $${latest ? latest.avgCostUsd.toFixed(6) : "0.000000"} (${formatDelta(costDelta)})`,
|
|
`Window: ${latest?.window ?? "all"}`,
|
|
`Source: ${latest?.source ?? "unknown"}`,
|
|
`Regression count: ${regressions}`,
|
|
"",
|
|
"## Rolling Averages",
|
|
"",
|
|
`AIQ: ${average(limited.map((row) => row.aiq)).toFixed(3)}`,
|
|
`Latency: ${average(limited.map((row) => row.avgLatencyMs)).toFixed(2)}ms`,
|
|
`Cost: $${average(limited.map((row) => row.avgCostUsd)).toFixed(6)}`,
|
|
];
|
|
|
|
return `${lines.join("\n")}\n`;
|
|
}
|
|
|
|
function main(): void {
|
|
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
|
console.log(usage());
|
|
return;
|
|
}
|
|
|
|
const artifactDir = getArgValue("artifact-dir");
|
|
if (!artifactDir) {
|
|
console.error("Missing required --artifact-dir");
|
|
process.exit(2);
|
|
}
|
|
|
|
const limit = Number.parseInt(getArgValue("limit") ?? "20", 10);
|
|
const rows = collectTrendRows(path.resolve(artifactDir));
|
|
if (rows.length === 0) {
|
|
console.error(`No router-eval artifacts found in ${artifactDir}`);
|
|
process.exit(2);
|
|
}
|
|
|
|
const boundedLimit = Number.isFinite(limit) && limit > 0 ? limit : 20;
|
|
if (process.argv.includes("--dashboard")) {
|
|
console.log(formatDashboard(rows, boundedLimit));
|
|
return;
|
|
}
|
|
|
|
console.log(formatTrend(rows, boundedLimit));
|
|
}
|
|
|
|
main();
|