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>
316 lines
10 KiB
JavaScript
316 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
|
|
type PatchOperation = {
|
|
op?: string;
|
|
path?: string;
|
|
value?: string;
|
|
evidence?: {
|
|
aiq?: number;
|
|
avgCostUsd?: number;
|
|
avgLatencyMs?: number;
|
|
regressions?: number;
|
|
};
|
|
rationale?: string;
|
|
};
|
|
|
|
type RouterConfigPatchArtifact = {
|
|
schemaVersion?: number;
|
|
kind?: string;
|
|
generatedAt?: string;
|
|
applyPolicy?: string;
|
|
source?: {
|
|
objective?: string;
|
|
runId?: string;
|
|
artifactPath?: string;
|
|
};
|
|
operations?: PatchOperation[];
|
|
};
|
|
|
|
type PatchComparison = {
|
|
schemaVersion: 1;
|
|
kind: "router-config-patch-comparison";
|
|
generatedAt: string;
|
|
runId: string;
|
|
thresholds: PatchThresholds;
|
|
baseline: PatchSummary;
|
|
candidate: PatchSummary;
|
|
delta: {
|
|
aiq: number;
|
|
avgCostUsd: number;
|
|
costIncreaseRatio: number;
|
|
avgLatencyMs: number;
|
|
latencyIncreaseRatio: number;
|
|
regressions: number;
|
|
};
|
|
changedRecommendation: boolean;
|
|
regressions: string[];
|
|
result: {
|
|
passed: boolean;
|
|
status: 0 | 1;
|
|
};
|
|
};
|
|
|
|
type PatchThresholds = {
|
|
maxAiqDrop: number;
|
|
maxCostIncrease: number;
|
|
maxLatencyIncrease: number;
|
|
maxRegressionIncrease: number;
|
|
};
|
|
|
|
type PatchSummary = {
|
|
name: string;
|
|
file: string;
|
|
objective: string;
|
|
runId: string;
|
|
recommendedConfigId: string;
|
|
aiq: number;
|
|
avgCostUsd: number;
|
|
avgLatencyMs: number;
|
|
regressions: number;
|
|
applyPolicy: 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:patch-compare -- --baseline <router-config.patch.json> --candidate <router-config.patch.json>",
|
|
" [--baseline-name <name>] [--candidate-name <name>] [--artifact-dir <dir>] [--run-id <id>]",
|
|
" [--output <file>] [--json-output <file>] [--fail-on-regression]",
|
|
" [--max-aiq-drop <n>] [--max-cost-increase <ratio>] [--max-latency-increase <ratio>]",
|
|
" [--max-regression-increase <n>]",
|
|
"",
|
|
"Compares two retained router config patch proposals without applying them.",
|
|
].join("\n");
|
|
}
|
|
|
|
function getNumberArg(name: string, fallback: number): number {
|
|
const value = getArgValue(name);
|
|
if (value === undefined) return fallback;
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
console.error(`Invalid --${name} value: ${value}`);
|
|
process.exit(2);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function requireArg(name: string): string {
|
|
const value = getArgValue(name);
|
|
if (!value) {
|
|
console.error(`Missing required --${name}`);
|
|
process.exit(2);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function readPatch(file: string): RouterConfigPatchArtifact {
|
|
if (!fs.existsSync(file)) {
|
|
console.error(`[router-eval:patch-compare] patch file missing: ${file}`);
|
|
process.exit(2);
|
|
}
|
|
try {
|
|
return JSON.parse(fs.readFileSync(file, "utf8")) as RouterConfigPatchArtifact;
|
|
} catch (error) {
|
|
console.error(
|
|
`[router-eval:patch-compare] invalid JSON in ${file}: ${(error as Error).message}`
|
|
);
|
|
process.exit(2);
|
|
}
|
|
}
|
|
|
|
function requireNumber(value: unknown, field: string, file: string): number {
|
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
console.error(`[router-eval:patch-compare] invalid numeric evidence field ${field} in ${file}`);
|
|
process.exit(2);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function summarizePatch(
|
|
name: string,
|
|
file: string,
|
|
patch: RouterConfigPatchArtifact
|
|
): PatchSummary {
|
|
if (patch.kind !== "router-config-patch") {
|
|
console.error(
|
|
`[router-eval:patch-compare] invalid patch kind in ${file}: ${patch.kind ?? "missing"}`
|
|
);
|
|
process.exit(2);
|
|
}
|
|
const operation = patch.operations?.[0];
|
|
if (operation?.op !== "recommend-router-config") {
|
|
console.error(
|
|
`[router-eval:patch-compare] missing recommend-router-config operation in ${file}`
|
|
);
|
|
process.exit(2);
|
|
}
|
|
if (typeof operation.value !== "string" || operation.value.length === 0) {
|
|
console.error(`[router-eval:patch-compare] invalid recommended config value in ${file}`);
|
|
process.exit(2);
|
|
}
|
|
return {
|
|
name,
|
|
file: path.resolve(file),
|
|
objective: patch.source?.objective ?? "unknown",
|
|
runId: patch.source?.runId ?? "unknown",
|
|
recommendedConfigId: operation.value,
|
|
aiq: requireNumber(operation.evidence?.aiq, "aiq", file),
|
|
avgCostUsd: requireNumber(operation.evidence?.avgCostUsd, "avgCostUsd", file),
|
|
avgLatencyMs: requireNumber(operation.evidence?.avgLatencyMs, "avgLatencyMs", file),
|
|
regressions: requireNumber(operation.evidence?.regressions, "regressions", file),
|
|
applyPolicy: patch.applyPolicy ?? "unknown",
|
|
};
|
|
}
|
|
|
|
function increaseRatio(delta: number, baseline: number): number {
|
|
if (baseline === 0) return delta > 0 ? Number.POSITIVE_INFINITY : 0;
|
|
return delta / baseline;
|
|
}
|
|
|
|
function findRegressions(
|
|
baseline: PatchSummary,
|
|
candidate: PatchSummary,
|
|
thresholds: PatchThresholds
|
|
): string[] {
|
|
const aiqDrop = baseline.aiq - candidate.aiq;
|
|
const costDelta = candidate.avgCostUsd - baseline.avgCostUsd;
|
|
const latencyDelta = candidate.avgLatencyMs - baseline.avgLatencyMs;
|
|
const regressionDelta = candidate.regressions - baseline.regressions;
|
|
const regressions: string[] = [];
|
|
if (aiqDrop > thresholds.maxAiqDrop) regressions.push(`AIQ dropped by ${aiqDrop.toFixed(3)}`);
|
|
if (increaseRatio(costDelta, baseline.avgCostUsd) > thresholds.maxCostIncrease) {
|
|
regressions.push(
|
|
`average cost increased by ${increaseRatio(costDelta, baseline.avgCostUsd).toFixed(3)}`
|
|
);
|
|
}
|
|
if (increaseRatio(latencyDelta, baseline.avgLatencyMs) > thresholds.maxLatencyIncrease) {
|
|
regressions.push(
|
|
`average latency increased by ${increaseRatio(latencyDelta, baseline.avgLatencyMs).toFixed(3)}`
|
|
);
|
|
}
|
|
if (regressionDelta > thresholds.maxRegressionIncrease) {
|
|
regressions.push(`regression count increased by ${regressionDelta}`);
|
|
}
|
|
return regressions;
|
|
}
|
|
|
|
function comparePatches(
|
|
runId: string,
|
|
thresholds: PatchThresholds,
|
|
failOnRegression: boolean,
|
|
baseline: PatchSummary,
|
|
candidate: PatchSummary
|
|
): PatchComparison {
|
|
const regressions = findRegressions(baseline, candidate, thresholds);
|
|
const status = failOnRegression && regressions.length > 0 ? 1 : 0;
|
|
const costDelta = candidate.avgCostUsd - baseline.avgCostUsd;
|
|
const latencyDelta = candidate.avgLatencyMs - baseline.avgLatencyMs;
|
|
return {
|
|
schemaVersion: 1,
|
|
kind: "router-config-patch-comparison",
|
|
generatedAt: new Date().toISOString(),
|
|
runId,
|
|
thresholds,
|
|
baseline,
|
|
candidate,
|
|
delta: {
|
|
aiq: candidate.aiq - baseline.aiq,
|
|
avgCostUsd: costDelta,
|
|
costIncreaseRatio: increaseRatio(costDelta, baseline.avgCostUsd),
|
|
avgLatencyMs: latencyDelta,
|
|
latencyIncreaseRatio: increaseRatio(latencyDelta, baseline.avgLatencyMs),
|
|
regressions: candidate.regressions - baseline.regressions,
|
|
},
|
|
changedRecommendation: baseline.recommendedConfigId !== candidate.recommendedConfigId,
|
|
regressions,
|
|
result: {
|
|
passed: regressions.length === 0,
|
|
status,
|
|
},
|
|
};
|
|
}
|
|
|
|
function formatComparison(comparison: PatchComparison): string {
|
|
return [
|
|
"# Router Config Patch Comparison",
|
|
"",
|
|
`Passed: ${comparison.result.passed ? "yes" : "no"}`,
|
|
`Changed recommendation: ${comparison.changedRecommendation ? "yes" : "no"}`,
|
|
...(comparison.regressions.length > 0
|
|
? ["", "## Regressions", "", ...comparison.regressions.map((item) => `- ${item}`)]
|
|
: []),
|
|
"",
|
|
"| Side | Name | Objective | Recommended Config | AIQ | Avg Cost | Avg Latency | Regressions | Apply Policy |",
|
|
"| --- | --- | --- | --- | ---: | ---: | ---: | ---: | --- |",
|
|
formatSummaryRow("Baseline", comparison.baseline),
|
|
formatSummaryRow("Candidate", comparison.candidate),
|
|
"",
|
|
"| Delta | AIQ | Avg Cost | Avg Latency | Regressions |",
|
|
"| --- | ---: | ---: | ---: | ---: |",
|
|
`| Candidate - Baseline | ${comparison.delta.aiq.toFixed(3)} | $${comparison.delta.avgCostUsd.toFixed(6)} | ${comparison.delta.avgLatencyMs.toFixed(2)}ms | ${comparison.delta.regressions} |`,
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
function formatSummaryRow(side: string, summary: PatchSummary): string {
|
|
return `| ${side} | ${summary.name} | ${summary.objective} | ${summary.recommendedConfigId} | ${summary.aiq.toFixed(3)} | $${summary.avgCostUsd.toFixed(6)} | ${summary.avgLatencyMs.toFixed(2)}ms | ${summary.regressions} | ${summary.applyPolicy} |`;
|
|
}
|
|
|
|
function main(): void {
|
|
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
|
console.log(usage());
|
|
return;
|
|
}
|
|
|
|
const baselineFile = requireArg("baseline");
|
|
const candidateFile = requireArg("candidate");
|
|
const baselineName = getArgValue("baseline-name") ?? "baseline";
|
|
const candidateName = getArgValue("candidate-name") ?? "candidate";
|
|
const runId = getArgValue("run-id") ?? new Date().toISOString().replace(/[:.]/g, "-");
|
|
const thresholds: PatchThresholds = {
|
|
maxAiqDrop: getNumberArg("max-aiq-drop", Number.POSITIVE_INFINITY),
|
|
maxCostIncrease: getNumberArg("max-cost-increase", Number.POSITIVE_INFINITY),
|
|
maxLatencyIncrease: getNumberArg("max-latency-increase", Number.POSITIVE_INFINITY),
|
|
maxRegressionIncrease: getNumberArg("max-regression-increase", Number.POSITIVE_INFINITY),
|
|
};
|
|
const failOnRegression = process.argv.includes("--fail-on-regression");
|
|
const baseline = summarizePatch(baselineName, baselineFile, readPatch(baselineFile));
|
|
const candidate = summarizePatch(candidateName, candidateFile, readPatch(candidateFile));
|
|
const comparison = comparePatches(runId, thresholds, failOnRegression, baseline, candidate);
|
|
const markdown = formatComparison(comparison);
|
|
const artifactDir = getArgValue("artifact-dir");
|
|
const output = getArgValue("output");
|
|
const jsonOutput = getArgValue("json-output");
|
|
|
|
if (artifactDir) {
|
|
const runDir = path.resolve(artifactDir, runId);
|
|
fs.mkdirSync(runDir, { recursive: true });
|
|
fs.writeFileSync(path.join(runDir, "patch-comparison.md"), markdown);
|
|
fs.writeFileSync(
|
|
path.join(runDir, "patch-comparison.json"),
|
|
`${JSON.stringify(comparison, null, 2)}\n`
|
|
);
|
|
}
|
|
if (output) {
|
|
fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true });
|
|
fs.writeFileSync(output, markdown);
|
|
}
|
|
if (jsonOutput) {
|
|
fs.mkdirSync(path.dirname(path.resolve(jsonOutput)), { recursive: true });
|
|
fs.writeFileSync(jsonOutput, `${JSON.stringify(comparison, null, 2)}\n`);
|
|
}
|
|
console.log(markdown);
|
|
process.exit(comparison.result.status);
|
|
}
|
|
|
|
main();
|