Files
2026-07-13 12:22:59 +08:00

333 lines
12 KiB
TypeScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bun
import { Database } from "bun:sqlite";
/**
* Title-generation benchmark harness.
*
* Samples random first-of-session messages from the local history DB, renders
* the shipped `title-system.md` prompt, and runs every message against a matrix
* of title models — the on-device ONNX models (LFM2 350M/700M, Gemma 270M) via
* the tiny-title worker, plus a remote Ollama model (Llama 3.2 3B by default).
* Each model lane runs concurrently; within a lane requests are sequential
* because the local worker serializes generation on one pipeline.
*
* Results (per-sample titles + latency, plus per-model summaries) are written
* to a timestamped JSON file so runs can be compared later.
*
* Usage:
* bun scripts/bench-title-models.ts
* bun scripts/bench-title-models.ts --count 30 --seed 42
* bun scripts/bench-title-models.ts --models lfm2-350m,gemma-270m
* bun scripts/bench-title-models.ts --ollama-url http://spark.internal:11434 --ollama-models llama3.2:3b,lfm2:2.6b
* bun scripts/bench-title-models.ts --db ~/.omp/agent/history.db --out bench.json
*/
import * as os from "node:os";
import * as path from "node:path";
import { prompt } from "@oh-my-pi/pi-utils";
import titleSystemPrompt from "../src/prompts/system/title-system.md" with { type: "text" };
import { preprocessTinyMessage } from "../src/tiny/message-preproc";
import { isTinyTitleLocalModelKey } from "../src/tiny/models";
import { normalizeGeneratedTitle } from "../src/tiny/text";
import { shutdownTinyTitleClient, tinyTitleClient } from "../src/tiny/title-client";
/** A sampled prompt with the cleaned text actually fed to the models. */
interface PreparedPrompt {
id: number;
raw: string;
input: string;
}
/** One title produced for one input by one model, with wall-clock latency. */
interface BenchSample {
id: number;
input: string;
title: string | null;
ms: number;
}
/** All samples for one model plus the aggregate quality/latency summary. */
interface BenchLane {
model: string;
transport: "local" | "ollama";
samples: BenchSample[];
summary: BenchSummary;
}
/** Aggregate stats for a lane; latency percentiles skip the cold first call. */
interface BenchSummary {
count: number;
nulls: number;
coldMs: number;
warmMeanMs: number;
warmMedianMs: number;
warmP95Ms: number;
lengthCompliant: string;
punctuationFree: string;
}
interface BenchConfig {
dbPath: string;
count: number;
seed: number;
localModels: string[];
ollamaUrl: string | null;
ollamaModels: string[];
outPath: string;
}
const DEFAULT_LOCAL_MODELS = ["lfm2-350m", "lfm2-700m", "gemma-270m"];
const DEFAULT_OLLAMA_URL = "http://spark.internal:11434";
const DEFAULT_OLLAMA_MODELS = ["llama3.2:3b", "lfm2:2.6b"];
const MIN_INPUT_CHARS = 10;
const MAX_INPUT_CHARS = 800;
/** System prompt with examples (used for the capable Ollama model). */
const TITLE_PROMPT_WITH_EXAMPLES = prompt.render(titleSystemPrompt, { includeExamples: true });
/** Example-free prompt matching what the on-device worker ships to tiny models. */
const TITLE_PROMPT_NO_EXAMPLES = prompt.render(titleSystemPrompt, { includeExamples: false });
/** Deterministic mulberry32 PRNG so `--seed` reproduces a sample set. */
function createRng(seed: number): () => number {
let state = seed >>> 0;
return () => {
state |= 0;
state = (state + 0x6d2b79f5) | 0;
let t = Math.imul(state ^ (state >>> 15), 1 | state);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Pick `count` distinct random first-of-session prompts within the size band. */
function sampleHistoryPrompts(dbPath: string, count: number, rng: () => number): { id: number; prompt: string }[] {
const db = new Database(dbPath, { readonly: true });
try {
const rows = db
.query(
`WITH firsts AS (
SELECT session_id, MIN(id) AS id FROM history
WHERE session_id IS NOT NULL
GROUP BY session_id
)
SELECT h.id AS id, h.prompt AS prompt
FROM history h JOIN firsts ON firsts.id = h.id
WHERE length(trim(h.prompt)) BETWEEN ? AND ?`,
)
.all(MIN_INPUT_CHARS, MAX_INPUT_CHARS) as { id: number; prompt: string }[];
const seen = new Set<string>();
const unique: { id: number; prompt: string }[] = [];
for (const row of rows) {
const key = row.prompt.trim();
if (seen.has(key)) continue;
seen.add(key);
unique.push(row);
}
// FisherYates with the seeded RNG, then take the first `count`.
for (let i = unique.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[unique[i], unique[j]] = [unique[j], unique[i]];
}
return unique.slice(0, Math.min(count, unique.length));
} finally {
db.close();
}
}
/** Run one local ONNX model over every prompt (sequential; worker is single-lane). */
async function runLocalLane(model: string, prompts: PreparedPrompt[]): Promise<BenchSample[]> {
const samples: BenchSample[] = [];
for (const item of prompts) {
const started = performance.now();
const title = await tinyTitleClient.generate(model, item.input, { systemPrompt: TITLE_PROMPT_NO_EXAMPLES });
samples.push({ id: item.id, input: item.input, title, ms: performance.now() - started });
}
return samples;
}
/** Extract the `<title>` payload from a free-form chat completion. */
function parseChatTitle(text: string, sourceText: string): string | null {
if (!text || /<title\s*\/>/i.test(text)) return null;
const closed = /<title>([\s\S]*?)<\/title>/i.exec(text);
const open = closed ? null : /<title>([\s\S]*)/i.exec(text);
return normalizeGeneratedTitle(closed?.[1] ?? open?.[1] ?? text, sourceText);
}
/** Run one Ollama chat model over every prompt via the /api/chat endpoint. */
async function runOllamaLane(baseUrl: string, model: string, prompts: PreparedPrompt[]): Promise<BenchSample[]> {
const samples: BenchSample[] = [];
for (const item of prompts) {
const started = performance.now();
const response = await fetch(new URL("/api/chat", baseUrl), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
model,
stream: false,
keep_alive: "10m",
messages: [
{ role: "system", content: TITLE_PROMPT_WITH_EXAMPLES },
{ role: "user", content: `<user>\n${item.input}\n</user>` },
],
options: { temperature: 0, num_predict: 32 },
}),
});
if (!response.ok) throw new Error(`Ollama ${response.status}: ${await response.text()}`);
const payload = (await response.json()) as { message?: { content?: string } };
const raw = payload.message?.content ?? "";
samples.push({
id: item.id,
input: item.input,
title: parseChatTitle(raw, item.input),
ms: performance.now() - started,
});
}
return samples;
}
/** Fold a lane's samples into latency percentiles and title-quality ratios. */
function summarize(samples: BenchSample[]): BenchSummary {
const warm = samples
.slice(1)
.map(sample => sample.ms)
.sort((a, b) => a - b);
const outputs = samples.filter(sample => sample.title !== null);
const percentile = (sorted: number[], q: number): number =>
sorted.length === 0 ? 0 : sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * q) - 1))];
const wordCompliant = outputs.filter(sample => {
const words = sample.title!.trim().split(/\s+/).length;
return words >= 3 && words <= 7;
}).length;
const punctuationFree = outputs.filter(sample => !/\p{P}/u.test(sample.title!)).length;
return {
count: samples.length,
nulls: samples.length - outputs.length,
coldMs: Number((samples[0]?.ms ?? 0).toFixed(1)),
warmMeanMs: Number((warm.reduce((sum, value) => sum + value, 0) / (warm.length || 1)).toFixed(1)),
warmMedianMs: Number(percentile(warm, 0.5).toFixed(1)),
warmP95Ms: Number(percentile(warm, 0.95).toFixed(1)),
lengthCompliant: `${wordCompliant}/${outputs.length}`,
punctuationFree: `${punctuationFree}/${outputs.length}`,
};
}
function parseArgs(argv: string[]): BenchConfig {
const get = (flag: string): string | undefined => {
const index = argv.indexOf(flag);
return index >= 0 ? argv[index + 1] : undefined;
};
const has = (flag: string): boolean => argv.includes(flag);
const modelsArg = get("--models");
const ollamaModelsArg = get("--ollama-models");
const ollamaUrlArg = get("--ollama-url");
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
return {
dbPath: (get("--db") ?? path.join(os.homedir(), ".omp/agent/history.db")).replace(/^~/, os.homedir()),
count: Number(get("--count") ?? 20),
seed: Number(get("--seed") ?? Date.now() & 0xffffffff),
localModels: modelsArg
? modelsArg
.split(",")
.map(model => model.trim())
.filter(Boolean)
: DEFAULT_LOCAL_MODELS,
ollamaUrl: has("--no-ollama") ? null : (ollamaUrlArg ?? DEFAULT_OLLAMA_URL),
ollamaModels: ollamaModelsArg
? ollamaModelsArg
.split(",")
.map(model => model.trim())
.filter(Boolean)
: DEFAULT_OLLAMA_MODELS,
outPath: get("--out") ?? path.join(os.tmpdir(), `title-bench-${stamp}.json`),
};
}
async function main(): Promise<void> {
const config = parseArgs(Bun.argv.slice(2));
const rng = createRng(config.seed);
const rows = sampleHistoryPrompts(config.dbPath, config.count, rng);
if (rows.length === 0) throw new Error(`No history prompts found in ${config.dbPath}`);
const prepared: PreparedPrompt[] = rows.map(row => ({
id: row.id,
raw: row.prompt,
input: preprocessTinyMessage(row.prompt),
}));
const invalidLocal = config.localModels.filter(model => !isTinyTitleLocalModelKey(model));
if (invalidLocal.length > 0) throw new Error(`Unknown local title model(s): ${invalidLocal.join(", ")}`);
console.info(`Benchmarking ${rows.length} prompts (seed ${config.seed}) from ${config.dbPath}`);
// Each model is its own concurrent lane; the local worker still serializes
// its own lanes internally, but the Ollama lane genuinely runs in parallel.
const laneTasks: Promise<BenchLane>[] = [
...config.localModels.map(async (model): Promise<BenchLane> => {
const samples = await runLocalLane(model, prepared);
return { model, transport: "local", samples, summary: summarize(samples) };
}),
];
if (config.ollamaUrl) {
const url = config.ollamaUrl;
for (const model of config.ollamaModels) {
laneTasks.push(
(async (): Promise<BenchLane> => {
const samples = await runOllamaLane(url, model, prepared);
return { model: `${model}@ollama`, transport: "ollama", samples, summary: summarize(samples) };
})(),
);
}
}
const settled = await Promise.allSettled(laneTasks);
const lanes: BenchLane[] = [];
for (const result of settled) {
if (result.status === "fulfilled") lanes.push(result.value);
else
console.error(
`Lane failed: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`,
);
}
await shutdownTinyTitleClient();
// Prompt-centric view: each row is one input with every model's title beside it.
const matrix = prepared.map(item => {
const titles: Record<string, string> = {};
for (const lane of lanes) titles[lane.model] = lane.samples.find(sample => sample.id === item.id)?.title ?? "∅";
return { id: item.id, raw: item.raw, input: item.input, titles };
});
const report = {
generatedAt: new Date().toISOString(),
config: { ...config, prompts: prepared },
matrix,
lanes,
};
await Bun.write(config.outPath, JSON.stringify(report, null, 2));
for (const entry of matrix) {
console.info(`\n[#${entry.id}] ${entry.raw.replace(/\s+/g, " ").slice(0, 140)}`);
if (entry.input !== entry.raw.trim())
console.info(` (cleaned) ${entry.input.replace(/\s+/g, " ").slice(0, 140)}`);
console.table(Object.fromEntries(lanes.map(lane => [lane.model, { output: entry.titles[lane.model] }])));
}
console.info("\nSummary:");
console.table(
Object.fromEntries(
lanes.map(lane => [
lane.model,
{
cold: lane.summary.coldMs,
warmMean: lane.summary.warmMeanMs,
warmP95: lane.summary.warmP95Ms,
nulls: lane.summary.nulls,
len3to7: lane.summary.lengthCompliant,
punctFree: lane.summary.punctuationFree,
},
]),
),
);
console.info(`\nWrote ${config.outPath}`);
}
await main();