Files
OmniRoute/scripts/router-eval/trends.ts
KooshaPari f8e56e4615 fix(router-eval): retained-optimization gate cleanup (#7318)
* 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>
2026-07-18 15:13:39 -03:00

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();