Files
2026-07-13 13:39:12 +08:00

319 lines
13 KiB
TypeScript

/**
* A/B benchmark for compression engines (F2.4 / L2).
*
* Runs the deterministic compression engines through the C1 harness and applies
* the N4 tokens-per-task gate so a human or CI job can choose the default engine
* from real numbers rather than intuition.
*
* Design notes
* ─────────────
* - Engine → compressFn adapter: wraps a plain string into the minimal
* `{messages:[{role:"user",content:text}]}` body that every engine expects,
* calls `applyAsync` when the engine declares one (H10-ready), else `apply`,
* then extracts `messages[0].content` back out. Engines that return
* non-string content fall back to the original string (fail-open).
*
* - Deterministic: no `Date.now()` / `Math.random()` calls here; the harness
* runner is already deterministic. Running `benchmarkEngines` twice on the
* same corpus MUST yield identical numbers.
*
* - `llmlingua` real-model A/B is a VPS-validated follow-up (Hard Rule #18 /
* L3). The sandbox benchmark covers the deterministic engines (rtk, caveman,
* lite, aggressive, ultra, session-dedup, headroom, ccr). The framework is
* engine-agnostic: adding llmlingua later is just another id in the engines
* array once the MobileBERT ONNX model file is available at runtime. The
* `llmlingua` engine registered here is already present in the registry but
* its `applyAsync` requires the backend service; without it the sync `apply`
* is a pass-through (fail-open) — reflected in 0% savings / 100% retention
* results that make the A/B table honest about the sandbox limitation.
*/
import { registerBuiltinCompressionEngines } from "../engines/index.ts";
import { getCompressionEngine } from "../engines/registry.ts";
import { runCompressionEval, type EvalCase, type CompressFn, type EvalReport } from "./runner.ts";
import {
checkTokensPerTaskGate,
type BudgetBaseline,
type BudgetGateResult,
} from "./budgetGate.ts";
// Register all built-in engines once (idempotent).
registerBuiltinCompressionEngines();
// ── Types ────────────────────────────────────────────────────────────────────
export interface EngineSummaryRow {
engine: string;
/** Mean token savings % across the corpus (higher = better). */
meanSavingsPercent: number;
/** Mean technical-entity retention score 0..1 (higher = better). */
meanRetention: number;
/** Total compressed tokens across all corpus items. */
totalCompressedTokens: number;
}
export interface EngineBenchmarkGateRow {
engine: string;
gate: BudgetGateResult;
}
// ── Fixture corpus (BENCHMARK_CORPUS) ────────────────────────────────────────
// Representative samples for reproducible A/B runs in CI and local dev.
// Three task groups mirror real OmniRoute workloads:
// "prose" — conversational / documentation turns
// "tool-output" — bash/CLI raw output with repeated structural noise
// "json" — structured tool results / API responses
export const BENCHMARK_CORPUS: EvalCase[] = [
// ── Prose ────────────────────────────────────────────────────────────────
{
id: "prose-1",
task: "prose",
input: [
"Actually, I think what you basically want to do is essentially iterate over the list",
"of files and then kind of process each one in turn. So basically what I mean is that",
"you should loop through them one by one. In other words, just go through each file",
"in the directory and perform the operation. Does that make sense? Let me know if you",
"need any clarification on this matter whatsoever.",
"",
"So anyway, the main takeaway here is that you need to call processFile() for each",
"item in the files array. That is essentially the core of what I am trying to convey.",
].join("\n"),
},
{
id: "prose-2",
task: "prose",
input: [
"I would like to add that, at the end of the day, the solution involves calling",
"https://api.example.com/v2/process with the API_KEY environment variable set.",
"The endpoint version is v2.3.1 and you must pass X-Request-Id in the headers.",
"",
"Please note that basically every request needs authentication via the Bearer token.",
"So in other words you should include Authorization: Bearer <token> in every call.",
"The error.message field will contain details when a 401 or 403 occurs.",
].join("\n"),
},
// ── Tool output ──────────────────────────────────────────────────────────
{
id: "tool-output-1",
task: "tool-output",
input: [
"$ npm install",
"npm warn deprecated inflight@1.0.6: This module is not supported",
"npm warn deprecated inflight@1.0.6: This module is not supported",
"npm warn deprecated inflight@1.0.6: This module is not supported",
"npm warn deprecated glob@7.2.3: Glob versions prior to v9 are no longer supported",
"npm warn deprecated glob@7.2.3: Glob versions prior to v9 are no longer supported",
"npm warn deprecated glob@7.2.3: Glob versions prior to v9 are no longer supported",
"npm warn deprecated rimraf@3.0.2: Rimraf versions prior to v4 are no longer supported",
"",
"added 1234 packages, and audited 1234 packages in 42s",
"",
"found 0 vulnerabilities",
].join("\n"),
},
{
id: "tool-output-2",
task: "tool-output",
input: [
"tests/unit/foo.test.ts ..........",
"tests/unit/bar.test.ts ..........",
"tests/unit/baz.test.ts ..........",
"tests/unit/qux.test.ts ..........",
"tests/unit/foo.test.ts ..........",
"tests/unit/bar.test.ts ..........",
"tests/unit/baz.test.ts ..........",
"tests/unit/qux.test.ts ..........",
" ✓ all 80 tests passed",
" coverage: 87.3% statements",
"Error: ENOENT: no such file or directory '/tmp/cache/build.lock'",
].join("\n"),
},
// ── JSON / structured ────────────────────────────────────────────────────
{
id: "json-1",
task: "json",
input: JSON.stringify(
{
model: "gpt-4o",
usage: { prompt_tokens: 1200, completion_tokens: 350, total_tokens: 1550 },
choices: [
{
index: 0,
finish_reason: "stop",
message: {
role: "assistant",
content:
"The file src/lib/db/core.ts exports getDbInstance() which returns the WAL-mode SQLite singleton.",
},
},
{
index: 1,
finish_reason: "stop",
message: {
role: "assistant",
content: "Call getDbInstance() from src/lib/db/core.ts to obtain the DB handle.",
},
},
],
id: "chatcmpl-abc123",
created: 1718000000,
},
null,
2
),
},
];
// ── Core API ──────────────────────────────────────────────────────────────────
/**
* Adapt a registered compression engine into the `CompressFn` interface that
* `runCompressionEval` expects.
*
* Wraps the input string as `{messages:[{role:"user",content:text}]}`, calls
* `applyAsync` when available or `apply` otherwise, then extracts the resulting
* `messages[0].content` string. Falls back to the original text on any error
* or if the engine returns a non-string content value (fail-open).
*
* Registers builtin engines on first call (idempotent).
*/
export function engineToCompressFn(engineId: string): CompressFn {
const engine = getCompressionEngine(engineId);
if (!engine) {
throw new Error(`Unknown compression engine: "${engineId}"`);
}
return async (text: string): Promise<string> => {
const body: Record<string, unknown> = {
messages: [{ role: "user", content: text }],
};
try {
let result;
if (typeof engine.applyAsync === "function") {
result = await engine.applyAsync(body);
} else {
result = engine.apply(body);
}
const messages = result.body["messages"];
if (Array.isArray(messages) && messages.length > 0) {
const content = (messages[0] as Record<string, unknown>)["content"];
if (typeof content === "string") return content;
}
// Fallback: content was an array of blocks or engine left body unchanged
return text;
} catch {
// Fail-open: engine error → return original text unchanged
return text;
}
};
}
/**
* Run the C1 harness on `corpus` for each engine id in `engineIds`.
*
* Returns a map of `engineId → EvalReport`. All engines run sequentially to
* keep determinism; the corpus order is preserved.
*
* No `Date.now()` or `Math.random()` calls — deterministic by construction.
*/
export async function benchmarkEngines(
corpus: EvalCase[],
engineIds: string[]
): Promise<Record<string, EvalReport>> {
const reports: Record<string, EvalReport> = {};
for (const id of engineIds) {
const compress = engineToCompressFn(id);
reports[id] = await runCompressionEval(corpus, compress);
}
return reports;
}
/**
* Build a flat, sortable A/B summary table from an engine→report map.
*
* Sort order: `meanSavingsPercent` descending (best token saver first).
* Ties are broken by `meanRetention` descending (better entity preservation).
* The sort is deterministic and documented here so the decision rationale is
* reproducible: "most savings, then most retention when savings are equal".
*/
export function compareReports(reports: Record<string, EvalReport>): EngineSummaryRow[] {
const rows: EngineSummaryRow[] = Object.entries(reports).map(([engine, report]) => ({
engine,
meanSavingsPercent: report.meanSavingsPercent,
meanRetention: report.meanRetention,
totalCompressedTokens: report.totalCompressedTokens,
}));
rows.sort((a, b) => {
if (b.meanSavingsPercent !== a.meanSavingsPercent) {
return b.meanSavingsPercent - a.meanSavingsPercent;
}
return b.meanRetention - a.meanRetention;
});
return rows;
}
/**
* Apply the N4 tokens-per-task gate to each engine's report against provided
* per-engine baselines.
*
* `baselines` is keyed by engineId. Engines absent from `baselines` are
* reported as passed (no baseline to regress against).
*
* `tolerancePercent` defaults to 2 (same default as `checkTokensPerTaskGate`).
*/
export function runBenchmarkGate(
reports: Record<string, EvalReport>,
baselines: Record<string, BudgetBaseline>,
tolerancePercent = 2
): EngineBenchmarkGateRow[] {
return Object.entries(reports).map(([engine, report]) => {
const baseline = baselines[engine];
if (!baseline) {
// No baseline for this engine → unconditional pass (nothing to regress against)
return {
engine,
gate: { passed: true, regressions: [], tolerancePercent },
};
}
const gate = checkTokensPerTaskGate(report, baseline, tolerancePercent);
return { engine, gate };
});
}
// ── CLI helpers ────────────────────────────────────────────────────────────────
/**
* Deterministic engines suitable for the sandbox A/B. `llmlingua` is excluded because its real
* compression is async-only and needs the MobileBERT ONNX model at runtime; add it once the
* model is provisioned (the framework is engine-agnostic).
*/
export const DEFAULT_BENCHMARK_ENGINES: string[] = [
"lite",
"caveman",
"aggressive",
"ultra",
"rtk",
"session-dedup",
"headroom",
"ccr",
];
/**
* Render an A/B summary (from {@link compareReports}) as a GitHub-flavored markdown table,
* best-first with the top engine bolded. Pure — used by the `bench:compression` CLI.
*/
export function formatBenchmarkTable(rows: EngineSummaryRow[]): string {
const header = "| Engine | Mean Savings % | Mean Retention | Total Compressed Tokens |";
const sep = "| --- | ---: | ---: | ---: |";
const body = rows.map((r, i) => {
const engine = i === 0 ? `**${r.engine}**` : r.engine;
return `| ${engine} | ${r.meanSavingsPercent.toFixed(1)} | ${r.meanRetention.toFixed(3)} | ${r.totalCompressedTokens} |`;
});
return [header, sep, ...body].join("\n");
}