@tpsdev-ai/flair-bench 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ Copyright 2026 TPS Dev AI
8
+
9
+ Licensed under the Apache License, Version 2.0 (the "License");
10
+ you may not use this file except in compliance with the License.
11
+ You may obtain a copy of the License at
12
+
13
+ http://www.apache.org/licenses/LICENSE-2.0
14
+
15
+ Unless required by applicable law or agreed to in writing, software
16
+ distributed under the License is distributed on an "AS IS" BASIS,
17
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18
+ See the License for the specific language governing permissions and
19
+ limitations under the License.
package/README.md ADDED
@@ -0,0 +1,157 @@
1
+ # @tpsdev-ai/flair-bench
2
+
3
+ A standalone embedding recall benchmark for [flair](https://github.com/tpsdev-ai/flair). Run real recall numbers — precision@3, MRR — against any GGUF embedding model, batch-compare several, get a host-aware recommendation, and (optionally) save a redacted, shareable result. **No flair install required.**
4
+
5
+ ```
6
+ npx @tpsdev-ai/flair-bench run --model-file ./nomic-embed-text-v1.5.Q4_K_M.gguf
7
+ ```
8
+
9
+ ## Why this exists
10
+
11
+ flair ships nomic-embed-text-v1.5 (Q4_K_M by default) for local semantic memory search. The question "would a different model/quant actually recall better *for flair's use case*, on *my* hardware?" used to require a full flair checkout, its recall-eval harness, and an ephemeral Harper instance. flair-bench packages the same corpus and the same scoring math into a small, dependency-light CLI/library you can point at any GGUF, anywhere.
12
+
13
+ ## Install / run
14
+
15
+ ```bash
16
+ npx @tpsdev-ai/flair-bench run --model-file /path/to/model.gguf
17
+ ```
18
+
19
+ or install it:
20
+
21
+ ```bash
22
+ npm install -g @tpsdev-ai/flair-bench
23
+ flair-bench run --model-file /path/to/model.gguf
24
+ ```
25
+
26
+ Requires Node.js >= 22. Downloads no models itself — point it at GGUF files you already have.
27
+
28
+ ## Commands
29
+
30
+ ### `run` — benchmark one or more models
31
+
32
+ ```bash
33
+ flair-bench run --model-file a.gguf --model-file b.gguf --model-file c.gguf
34
+ flair-bench run --manifest models.txt # one path per line, # comments allowed
35
+ flair-bench run --model-file a.gguf --json # machine-readable output
36
+ flair-bench run --model-file a.gguf --label "local-m4-mini" # freeform host/infra tag
37
+ ```
38
+
39
+ Reports, per model: p@3/MRR (aggregate and per-kind — stress/trap/hard/clean, see "The corpus" below), ms/embed (serial, warm, N≥64), peak RSS delta, GGUF file size/BPW, embedding dimensions, and load time. A batch (more than one `--model-file`) ends with a ranked comparison table.
40
+
41
+ ### `recommend` — pick a model for this host
42
+
43
+ ```bash
44
+ flair-bench recommend --model-file a.gguf --model-file b.gguf --model-file c.gguf
45
+ ```
46
+
47
+ Fingerprints the host (platform, arch, RAM, CPU model, and the **actual compute backend node-llama-cpp loaded** — Metal/CUDA/Vulkan/CPU, plus GPU device name(s) when present — not inferred from the OS), runs the batch, and recommends the best measured recall the host can plausibly afford. See "Recommend heuristic, and its limits" below — this is deliberately simple and says so.
48
+
49
+ ### `--share` — save a redacted, shareable result
50
+
51
+ ```bash
52
+ flair-bench run --model-file a.gguf --share
53
+ ```
54
+
55
+ Writes a canonical JSON file per model (see "Share schema" below) and prints where the eventual hosted-submission endpoint would receive it — **no network call is made**; the endpoint is a config placeholder (`SUBMISSION_ENDPOINT_PLACEHOLDER` in `src/share.ts`) for a hosted site that doesn't exist yet.
56
+
57
+ ## `--label`: benchmarking infra, not just models
58
+
59
+ flair-bench's core use case extends past "which model" to "which model on which infra" — comparing a GGUF across, say, a free-tier Fabric host, a GPU-backed Fabric host, and a local Mac. `--label <string>` is a **freeform, user-chosen** string (e.g. `"fabric-free-gcp"`, `"fabric-gpu-a"`, `"local-m4-mini"`) that becomes the grouping key in `run`/`recommend` output and in `--share`'s `hardware.label` field. It is never auto-filled from the machine's real hostname — real hostnames never need to appear in a shared result. Combined with the measured `hardware.backend`/`hardware.gpu` fields (see below), a set of `--share` outputs across labeled hosts is a model × infra matrix.
60
+
61
+ ## Comparable to flair, with a caveat
62
+
63
+ flair-bench uses **exactly the same corpus, the same p@3/MRR scoring math, and the same nomic search-prefix convention** flair's own isolated recall-eval harness (`test/bench/recall-harness/`) uses — see "What's shared vs. copied" below. The one deliberate difference: Harper's live `/SemanticSearch` endpoint ranks candidates through an **HNSW approximate index**; flair-bench has no HNSW graph, so it ranks every query against **every** corpus record by exact cosine similarity instead. For a corpus this size (251 records), exact search is strictly more informative, not less — but it means a number from this tool and a number from the harness aren't guaranteed to be bit-identical, only very close. See this repo's PR for the validation that quantifies exactly how close (spoiler: they match).
64
+
65
+ ## What's shared vs. copied (and how it's kept honest)
66
+
67
+ - **Corpus** (`src/corpus-v2.ts`): a **build-time copy** of `test/bench/recall-harness/corpus-v2.ts`. A standalone, npx-able package can't import from the monorepo's `test/` directory at runtime (it isn't published), so the corpus is copied at commit time via `scripts/sync-corpus.mjs` and kept honest by `test/corpus-sync.test.ts`, which deep-equals this copy's exported `CORPUS`/`QUERIES` against the live harness source on every `bun test` run inside the monorepo checkout. Any drift fails CI loudly.
68
+ - **Scorer** (`src/scorer.ts`): a **faithful hand-replication** of the harness's `statsFor()` (which isn't exported, so it can't be imported directly). `test/scorer-sync.test.ts` reads the harness source's raw text and asserts it still contains the exact formula fragments this package replicated — a tripwire against silent drift.
69
+ - **Prefix convention** (`src/prefixes.ts`): re-implements the same `search_document: `/`search_query: ` string-prepend `resources/embeddings-provider.ts` gets from harper-fabric-embeddings' engine, since flair-bench talks to node-llama-cpp directly and has no HFE wrapper in the loop. Keyed on model filename today (see the file's own header) — there's an upstream proposal to carry this convention in the GGUF/HFE registration surface itself instead of every consumer re-deriving it from a filename pattern: `harper-fabric-embeddings#4`.
70
+
71
+ ## The corpus
72
+
73
+ 251 synthetic records across 30 topic clusters, 126 hand-written ground-truth queries in four kinds:
74
+
75
+ - **stress** — durability/recency adversarial pairs (does a fresher-but-wrong record outrank an older-but-correct one?)
76
+ - **trap** — cross-cluster lexical traps (the same ambiguous term used in two different domains, e.g. "transaction" in database internals vs. finance ops)
77
+ - **hard** — near-duplicate-cluster disambiguation
78
+ - **clean** — unambiguous single-best-answer sanity floor
79
+
80
+ See `test/bench/recall-harness/corpus-v2.ts`'s header (in the main flair repo) for the full design rationale.
81
+
82
+ ## Recommend heuristic, and its limits
83
+
84
+ `recommend` picks the model with the best measured MRR among those whose peak RSS delta fits within a RAM headroom budget (default: 50% of currently-available RAM) and whose ms/embed is under a latency ceiling (default: 500ms — deliberately generous). Ties broken by faster ms/embed. If nothing fits the budget, it falls back to ranking the full set and says so explicitly rather than returning nothing.
85
+
86
+ **What it doesn't know**, stated plainly rather than hidden behind a confident-sounding number:
87
+
88
+ - RSS is measured for a single model loaded and queried **serially, one request at a time**, in this one process. A real server holding the model resident under concurrent requests will use more memory than this measurement shows.
89
+ - Latency is single-request serial ms/embed — not throughput under concurrency, not batched-request latency.
90
+ - The RAM/latency thresholds are simple fixed fractions/ceilings (`--ram-headroom`, `--latency-threshold`), not a learned or host-class-aware model.
91
+ - "Available RAM" comes from Node's `os.freemem()`, which on macOS in particular tends to report far less than what's actually usable — macOS treats most inactive/file-cache pages as reclaimable-but-not-"free", so `os.freemem()` can read a couple of GiB on a machine that's actually got plenty of headroom. On Linux, `os.freemem()` is closer to the truth (`MemFree`, not `MemAvailable`) but still not identical to it. Treat the RAM-budget gate as directionally useful, not authoritative — a "didn't fit the budget" fallback note is worth a second look on macOS specifically before concluding a model genuinely doesn't fit.
92
+
93
+ The recommendation always cites the actual measured numbers it's based on (e.g. *"X because p@3 0.984 vs 0.976 (MRR 0.950 vs 0.946) at 22.1ms/embed and 612 MiB peak RSS on your 40.0GiB-available metal host"*) — never a bare model name with no evidence.
94
+
95
+ ## Share schema
96
+
97
+ `--share` writes one JSON file per model:
98
+
99
+ ```jsonc
100
+ {
101
+ "toolVersion": "0.1.0",
102
+ "timestamp": "2026-07-12T00:00:00.000Z",
103
+ "model": {
104
+ "name": "nomic-embed-text-v1.5",
105
+ "fileBasename": "nomic-embed-text-v1.5.Q4_K_M.gguf",
106
+ "sha256": "…",
107
+ "quant": "Q4_K_M",
108
+ "paramsApprox": 136731648,
109
+ "dims": 768
110
+ },
111
+ "hardware": {
112
+ "label": "local-m4-mini",
113
+ "platform": "darwin",
114
+ "arch": "arm64",
115
+ "cpuModel": "Apple M4 Pro",
116
+ "ramGiB": 48,
117
+ "gpu": "Apple M4 Pro",
118
+ "backend": "metal"
119
+ },
120
+ "results": {
121
+ "aggregate": { "n": 126, "p3": 0.976, "mrr": 0.946 },
122
+ "perKind": { "stress": { … }, "trap": { … }, "hard": { … }, "clean": { … } },
123
+ "msPerEmbedSerialWarm": 22.1,
124
+ "peakRssMiB": 612.1
125
+ }
126
+ }
127
+ ```
128
+
129
+ **Privacy**: this document NEVER includes a hostname, a filesystem path, or a username. `model.fileBasename` is a basename only (never the directory it lives in); `hardware.label` is whatever freeform string you passed to `--label` — it defaults to nothing, never your machine's real hostname. `test/share-schema.test.ts` gates this contract.
130
+
131
+ **Stubbed**: the eventual hosted submission endpoint (a site to browse shared results — Nathan's "an option to share benchmarks") doesn't exist yet. `--share` writes the file locally and prints `submission endpoint not yet configured — file saved at <path>`; the endpoint URL in the code is a placeholder constant, and no network call is ever made.
132
+
133
+ ## Library use
134
+
135
+ The public API (`src/index.ts`) has **no `process.exit`, no `console.*` calls** anywhere in its call graph — `runBenchmark()`/`recommend()` return structured data (`BatchResult`/`RecommendResult`); progress is reported via an optional `onProgress` callback; rendering (pretty text / JSON) lives separately in `src/format.ts`. `src/cli.ts` is a thin argv-parsing layer on top, and is the *only* place in the package that prints or sets an exit code.
136
+
137
+ This shape is deliberate: a future `flair bench` subcommand on the main flair CLI is expected to import `runBenchmark`/`recommend` directly and drive its own UI, rather than shelling out to this package's bin.
138
+
139
+ ```ts
140
+ import { runBenchmark, recommend } from "@tpsdev-ai/flair-bench";
141
+
142
+ const batch = await runBenchmark({ modelFiles: ["./model.gguf"], label: "my-host" });
143
+ console.log(batch.models[0].aggregate); // { n: 126, p3: 0.976, mrr: 0.946 }
144
+
145
+ const picked = await recommend({ modelFiles: ["./a.gguf", "./b.gguf"] });
146
+ console.log(picked.recommendation?.reason);
147
+ ```
148
+
149
+ ## Development
150
+
151
+ ```bash
152
+ bun test # unit tests (scorer, prefixes, cosine, recommend heuristic, share schema, sync checks)
153
+ bun run build # tsc build to dist/
154
+ bun run sync:corpus # regenerate src/corpus-v2.ts from the harness source (run after editing the harness corpus)
155
+ ```
156
+
157
+ `test/corpus-sync.test.ts` and `test/scorer-sync.test.ts` only pass inside a full monorepo checkout (they read `test/bench/recall-harness/` two levels up) — that's expected; they're the drift guard for maintainers, not something a consumer of the published package ever runs.
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /**
4
+ * cli-shim.cts — Node-version PREFLIGHT for the flair-bench CLI.
5
+ *
6
+ * THIS IS THE BIN ENTRY (`package.json` "bin": { "flair-bench": "dist/cli-shim.cjs" }).
7
+ *
8
+ * Same pattern as the root package's src/cli-shim.cts and
9
+ * packages/flair-mcp/src/mcp-shim.cts — see either for the full rationale.
10
+ * Short version: the real CLI (dist/cli.js) is an ES module, and ESM hoists
11
+ * + links + evaluates the whole import graph before the first statement in
12
+ * the file body runs — so a Node-version check inside cli.ts itself would
13
+ * never execute on a too-old Node (node-llama-cpp requires a modern engine
14
+ * and fails to load first). This file is CommonJS, which evaluates
15
+ * top-to-bottom, so the version check below is guaranteed to run and print
16
+ * before anything tries to load the ESM CLI or node-llama-cpp.
17
+ *
18
+ * Deliberately ancient-safe syntax only — `var`, plain functions, string
19
+ * `.split`/`parseInt`, `console.error`, `process.exit`. No top-level await,
20
+ * no optional chaining reaching modern-only APIs.
21
+ */
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ var MIN_NODE_MAJOR = 22;
24
+ function flairBenchCurrentNodeMajor() {
25
+ var raw = (process && process.versions && process.versions.node) || "0";
26
+ return parseInt(String(raw).split(".")[0], 10) || 0;
27
+ }
28
+ var flairBenchNodeMajor = flairBenchCurrentNodeMajor();
29
+ if (flairBenchNodeMajor < MIN_NODE_MAJOR) {
30
+ console.error("");
31
+ console.error(" flair-bench requires Node.js >= " + MIN_NODE_MAJOR + ".");
32
+ console.error(" You are running Node.js " + (process.versions && process.versions.node ? process.versions.node : "(unknown)") + ".");
33
+ console.error("");
34
+ console.error(" Please upgrade Node and try again:");
35
+ console.error(" https://nodejs.org/ (or use nvm / fnm / volta)");
36
+ console.error("");
37
+ process.exit(1);
38
+ }
39
+ import("./cli.js")
40
+ .then(function (mod) {
41
+ if (mod && typeof mod.runCli === "function") {
42
+ return mod.runCli();
43
+ }
44
+ return undefined;
45
+ })
46
+ .catch(function (err) {
47
+ console.error(err && err.stack ? err.stack : err);
48
+ process.exit(1);
49
+ });
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * cli-shim.cts — Node-version PREFLIGHT for the flair-bench CLI.
4
+ *
5
+ * THIS IS THE BIN ENTRY (`package.json` "bin": { "flair-bench": "dist/cli-shim.cjs" }).
6
+ *
7
+ * Same pattern as the root package's src/cli-shim.cts and
8
+ * packages/flair-mcp/src/mcp-shim.cts — see either for the full rationale.
9
+ * Short version: the real CLI (dist/cli.js) is an ES module, and ESM hoists
10
+ * + links + evaluates the whole import graph before the first statement in
11
+ * the file body runs — so a Node-version check inside cli.ts itself would
12
+ * never execute on a too-old Node (node-llama-cpp requires a modern engine
13
+ * and fails to load first). This file is CommonJS, which evaluates
14
+ * top-to-bottom, so the version check below is guaranteed to run and print
15
+ * before anything tries to load the ESM CLI or node-llama-cpp.
16
+ *
17
+ * Deliberately ancient-safe syntax only — `var`, plain functions, string
18
+ * `.split`/`parseInt`, `console.error`, `process.exit`. No top-level await,
19
+ * no optional chaining reaching modern-only APIs.
20
+ */
21
+ export {};
package/dist/cli.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * cli.ts — thin argv-parsing bin. Parses process.argv into the options
3
+ * objects index.ts's API expects, calls that API, formats the result
4
+ * (format.ts), and prints — this file is the ONLY place in the package that
5
+ * calls console.log/console.error/process.exit(code) (process.exitCode is
6
+ * used, not process.exit(), so a caller that imports runCli() programmatically
7
+ * doesn't get its own process killed).
8
+ *
9
+ * Deliberately thin: the future `flair bench` subcommand (see README
10
+ * "Library use") is expected to import index.ts's runBenchmark()/recommend()
11
+ * directly and do its own argv parsing / rendering inside the main `flair`
12
+ * CLI's existing command tree, not shell out to this file.
13
+ */
14
+ export declare function runCli(argv?: string[]): Promise<void>;
package/dist/cli.js ADDED
@@ -0,0 +1,198 @@
1
+ /**
2
+ * cli.ts — thin argv-parsing bin. Parses process.argv into the options
3
+ * objects index.ts's API expects, calls that API, formats the result
4
+ * (format.ts), and prints — this file is the ONLY place in the package that
5
+ * calls console.log/console.error/process.exit(code) (process.exitCode is
6
+ * used, not process.exit(), so a caller that imports runCli() programmatically
7
+ * doesn't get its own process killed).
8
+ *
9
+ * Deliberately thin: the future `flair bench` subcommand (see README
10
+ * "Library use") is expected to import index.ts's runBenchmark()/recommend()
11
+ * directly and do its own argv parsing / rendering inside the main `flair`
12
+ * CLI's existing command tree, not shell out to this file.
13
+ */
14
+ import { readFileSync } from "node:fs";
15
+ import { runBenchmark, recommend, buildShareDocument, writeShareDocument, SUBMISSION_ENDPOINT_PLACEHOLDER, TOOL_VERSION } from "./index.js";
16
+ import { formatPretty, formatJson, formatRecommendPretty, formatRecommendJson } from "./format.js";
17
+ const HELP = `flair-bench ${TOOL_VERSION} — standalone embedding recall benchmark for flair
18
+
19
+ USAGE:
20
+ flair-bench run [options]
21
+ flair-bench recommend [options]
22
+
23
+ OPTIONS:
24
+ --model-file <path> GGUF file to benchmark. Repeatable for a batch.
25
+ --manifest <path> Text file, one GGUF path per line (# comments, blank lines ignored).
26
+ Combines with any --model-file flags.
27
+ --label <string> Freeform host/infra label (e.g. "fabric-gpu-a", "local-m4-mini") — becomes
28
+ the grouping key in output and in --share's hardware block. Never a
29
+ hostname; nothing is auto-filled from this machine's actual hostname.
30
+ --warmup-n <int> Untimed warmup embeds before ms/embed timing starts. Default 8.
31
+ --json Emit machine-readable JSON instead of the pretty text report.
32
+ --pretty Emit the pretty text report (default).
33
+ --share Also write a canonical, redacted result JSON per model to disk
34
+ (see --share-out). Prints where the endpoint submission would go,
35
+ once one exists — no network call is made.
36
+ --share-out <dir> Directory for --share output. Default: current directory.
37
+
38
+ RECOMMEND-ONLY OPTIONS:
39
+ --ram-headroom <fraction> Fraction of available RAM a model's peak RSS delta may use. Default 0.5.
40
+ --latency-threshold <ms> ms/embed ceiling for a model to be considered usable. Default 500.
41
+
42
+ PRIVACY NOTE:
43
+ --share writes model identity (name, file basename, sha256, quant, dims) and a host
44
+ fingerprint (platform, arch, CPU model, RAM, GPU backend/device string, and your --label)
45
+ plus the measured recall/latency/memory numbers. It NEVER includes a hostname, filesystem
46
+ path, or username — see the package README's "Share schema" section for the exact shape.
47
+ `;
48
+ function readManifest(path) {
49
+ const text = readFileSync(path, "utf8");
50
+ return text
51
+ .split("\n")
52
+ .map((l) => l.trim())
53
+ .filter((l) => l.length > 0 && !l.startsWith("#"));
54
+ }
55
+ function parseArgs(argv) {
56
+ const args = argv.slice(2);
57
+ const command = args[0] === "run" || args[0] === "recommend" ? args[0] : args[0] === "--help" || args[0] === "-h" || args[0] === undefined ? "help" : undefined;
58
+ if (command === undefined) {
59
+ throw new Error(`Unknown command "${args[0]}" — expected "run" or "recommend" (see --help)`);
60
+ }
61
+ const modelFiles = [];
62
+ let label;
63
+ let warmupN;
64
+ let json = false;
65
+ let share = false;
66
+ let shareOut;
67
+ let ramHeadroom;
68
+ let latencyThreshold;
69
+ for (let i = command === "help" ? 0 : 1; i < args.length; i++) {
70
+ const a = args[i];
71
+ const next = () => {
72
+ i++;
73
+ if (i >= args.length)
74
+ throw new Error(`${a} requires a value`);
75
+ return args[i];
76
+ };
77
+ switch (a) {
78
+ case "--model-file":
79
+ modelFiles.push(next());
80
+ break;
81
+ case "--manifest":
82
+ modelFiles.push(...readManifest(next()));
83
+ break;
84
+ case "--label":
85
+ label = next();
86
+ break;
87
+ case "--warmup-n":
88
+ warmupN = Number(next());
89
+ break;
90
+ case "--json":
91
+ json = true;
92
+ break;
93
+ case "--pretty":
94
+ json = false;
95
+ break;
96
+ case "--share":
97
+ share = true;
98
+ break;
99
+ case "--share-out":
100
+ shareOut = next();
101
+ break;
102
+ case "--ram-headroom":
103
+ ramHeadroom = Number(next());
104
+ break;
105
+ case "--latency-threshold":
106
+ latencyThreshold = Number(next());
107
+ break;
108
+ case "--help":
109
+ case "-h":
110
+ return { command: "help", modelFiles, json, share };
111
+ default:
112
+ throw new Error(`Unknown option "${a}" (see --help)`);
113
+ }
114
+ }
115
+ return { command, modelFiles, label, warmupN, json, share, shareOut, ramHeadroom, latencyThreshold };
116
+ }
117
+ function onProgress(event) {
118
+ switch (event.type) {
119
+ case "host-fingerprinted":
120
+ console.error(`[flair-bench] host: ${event.host.platform}/${event.host.arch} backend=${event.host.backend}`);
121
+ break;
122
+ case "model-start":
123
+ console.error(`[flair-bench] (${event.index + 1}/${event.total}) loading ${event.fileName}...`);
124
+ break;
125
+ case "model-loaded":
126
+ console.error(`[flair-bench] loaded ${event.fileName} in ${event.loadTimeMs.toFixed(0)}ms — embedding corpus...`);
127
+ break;
128
+ case "model-embedding":
129
+ if (event.done % 64 === 0 || event.done === event.total) {
130
+ console.error(`[flair-bench] ${event.fileName}: ${event.done}/${event.total} embedded`);
131
+ }
132
+ break;
133
+ case "model-done":
134
+ console.error(`[flair-bench] ${event.fileName} done — p@3=${event.result.aggregate.p3.toFixed(3)} MRR=${event.result.aggregate.mrr.toFixed(3)}`);
135
+ break;
136
+ }
137
+ }
138
+ export async function runCli(argv = process.argv) {
139
+ let parsed;
140
+ try {
141
+ parsed = parseArgs(argv);
142
+ }
143
+ catch (err) {
144
+ console.error(String(err instanceof Error ? err.message : err));
145
+ console.error("");
146
+ console.error(HELP);
147
+ process.exitCode = 1;
148
+ return;
149
+ }
150
+ if (parsed.command === "help") {
151
+ console.log(HELP);
152
+ return;
153
+ }
154
+ if (parsed.modelFiles.length === 0) {
155
+ console.error(`"${parsed.command}" requires at least one --model-file (or --manifest). See --help.`);
156
+ process.exitCode = 1;
157
+ return;
158
+ }
159
+ try {
160
+ if (parsed.command === "run") {
161
+ const options = { modelFiles: parsed.modelFiles, label: parsed.label, warmupN: parsed.warmupN, onProgress };
162
+ const result = await runBenchmark(options);
163
+ console.log(parsed.json ? formatJson(result) : formatPretty(result));
164
+ if (parsed.share) {
165
+ for (const m of result.models) {
166
+ const doc = buildShareDocument(m, result.host);
167
+ const written = writeShareDocument(doc, parsed.shareOut);
168
+ console.log(`\n${written.endpointNote}`);
169
+ console.log(`(submission endpoint placeholder: ${SUBMISSION_ENDPOINT_PLACEHOLDER} — not live, no network call was made)`);
170
+ }
171
+ }
172
+ }
173
+ else {
174
+ const options = {
175
+ modelFiles: parsed.modelFiles,
176
+ label: parsed.label,
177
+ warmupN: parsed.warmupN,
178
+ ramHeadroomFraction: parsed.ramHeadroom,
179
+ latencyThresholdMsPerEmbed: parsed.latencyThreshold,
180
+ onProgress,
181
+ };
182
+ const result = await recommend(options);
183
+ console.log(parsed.json ? formatRecommendJson(result) : formatRecommendPretty(result));
184
+ if (parsed.share) {
185
+ for (const m of result.batch.models) {
186
+ const doc = buildShareDocument(m, result.batch.host);
187
+ const written = writeShareDocument(doc, parsed.shareOut);
188
+ console.log(`\n${written.endpointNote}`);
189
+ console.log(`(submission endpoint placeholder: ${SUBMISSION_ENDPOINT_PLACEHOLDER} — not live, no network call was made)`);
190
+ }
191
+ }
192
+ }
193
+ }
194
+ catch (err) {
195
+ console.error(`[flair-bench] error: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
196
+ process.exitCode = 1;
197
+ }
198
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * corpus-v2.ts — SYNCED COPY of test/bench/recall-harness/corpus-v2.ts
3
+ * (the flair recall-eval harness's eval instrument v2).
4
+ *
5
+ * flair-bench ships standalone (npx-able, no monorepo checkout required at
6
+ * runtime), so it cannot import the harness's corpus at runtime from
7
+ * test/bench/ — that directory is dev-only and excluded from the published
8
+ * package. This file is a build-time copy kept faithful by
9
+ * test/corpus-sync.test.ts, which deep-equals this file's exported CORPUS
10
+ * and QUERIES against the live harness source on every `bun test` run
11
+ * inside the monorepo checkout — any drift fails CI loudly. Regenerate with
12
+ * `bun run sync:corpus` (packages/flair-bench/scripts/sync-corpus.mjs)
13
+ * whenever the harness corpus changes.
14
+ *
15
+ * Below this header, content is byte-for-byte the harness's corpus-v2.ts —
16
+ * see that file for the full design rationale (near-duplicate clusters,
17
+ * cross-cluster lexical traps, durability/recency stress pairs).
18
+ *
19
+ * ORIGINAL HEADER (test/bench/recall-harness/corpus-v2.ts):
20
+ *
21
+ * * corpus-v2.ts — eval instrument v2: a larger, harder synthetic corpus +
22
+ * ground-truth query set for the isolated recall-eval harness
23
+ * (test/bench/recall-harness/run.ts, --corpus v2).
24
+ *
25
+ * WHY THIS EXISTS (vs. corpus.ts / v1): v1 is 87 records / 30 queries. A
26
+ * flair#504 Phase 2 prefix A/B on v1 measured a small regression (p@3
27
+ * 0.967→0.933) that is, at N=30, just 1-2 queries shifting rank — not enough
28
+ * queries for the instrument to tell "ship" from "park" apart from noise. v2
29
+ * exists to have enough queries per "kind" (see QueryKind below) that a
30
+ * kind-level delta is a real signal, not a coin flip. v2 does NOT replace v1
31
+ * — v1's frozen numbers (README.md) stay reproducible verbatim by never
32
+ * touching corpus.ts; v2 is additive and becomes the standing gate for
33
+ * future embedding/scoring changes (larger N, same design principles).
34
+ *
35
+ * DESIGN — same three axes as v1, just wider:
36
+ * 1. NEAR-DUPLICATE / ADJACENT DENSITY — every cluster below holds one or
37
+ * two pairs of records about the SAME narrow sub-topic from different
38
+ * angles (see each record's "Near-dup facet of X" note). A "hard" query
39
+ * targets the specific facet, with the sibling as the plausible
40
+ * rank-2/3 confusion.
41
+ * 2. CROSS-CLUSTER LEXICAL "TRAP" PAIRS — three genuinely different-domain
42
+ * cluster pairs deliberately share one pivotal, ambiguous term used in
43
+ * a different sense in each domain, with high token overlap:
44
+ * - DBSTORE (database internals) ↔ FINOPS (finance operations): "transaction"
45
+ * - GITWF (git branching workflows) ↔ HORTIC (gardening/horticulture): "branch"
46
+ * - MUSICPROD (music production) ↔ SPORTAN (sports analytics): "score"
47
+ * Every record in a trap-pair cluster uses its cluster's sense of the
48
+ * shared term naturally and prominently (not shoehorned in for the
49
+ * trap alone) — the trap only works if the token overlap is real.
50
+ * 3. VARIED DURABILITY + createdAt (recency) — 17 of the 30 clusters carry
51
+ * a deliberate "stress pair": one older, standard/persistent-durability
52
+ * CORRECT record vs. a same-topic, fresh (~2-4 day) permanent-durability
53
+ * DISTRACTOR sibling — the same composite-vs-raw discriminator
54
+ * mechanism v1 uses (see corpus.ts's header and flair#623). The
55
+ * remaining records in every cluster (stress or not) still spread
56
+ * naturally across all four durability levels and a wide age range
57
+ * (~4-100 days), not just the engineered stress pairs.
58
+ *
59
+ * 30 topic clusters, genuinely different domains (distributed systems,
60
+ * medical scheduling, logistics, cooking, astronomy, legal contracts,
61
+ * fitness, team process, home renovation, marine biology, typography,
62
+ * database internals, finance ops, git workflow, horticulture, music
63
+ * production, sports analytics, cloud infra, winemaking, ornithology,
64
+ * carpentry, meteorology, chess, photography, aviation, insurance,
65
+ * archaeology, perfumery, ceramics, linguistics), 9 records each = 270
66
+ * records.
67
+ *
68
+ * ALL content below is authored synthetic prose — no real memories, no real
69
+ * project/people/host/key names, nothing copied from ~/ops or any
70
+ * production corpus. Every domain fact is invented-but-plausible, written
71
+ * fresh for this file.
72
+ *
73
+ * GROUND TRUTH: identical convention to v1 (see corpus.ts's header) — every
74
+ * query was written by hand against ONE specific record, then checked that
75
+ * no other record in the corpus answers the query's literal question as
76
+ * directly. See QueryKind below for what each kind tests.
77
+ *
78
+ * ageDays is relative to seed time (computed fresh by run.ts on every seed),
79
+ * matching v1.
80
+ */
81
+ export type Durability = "permanent" | "persistent" | "standard" | "ephemeral";
82
+ export interface CorpusRecord {
83
+ /** Unique marker, e.g. "DISTSYS::1". Also used to derive the record's id. */
84
+ marker: string;
85
+ cluster: string;
86
+ text: string;
87
+ durability: Durability;
88
+ /** Age in days at seed time (createdAt = now - ageDays). */
89
+ ageDays: number;
90
+ /** Free-form note on why this record's durability/age was chosen. */
91
+ note?: string;
92
+ }
93
+ export type QueryKind = "stress" | "trap" | "hard" | "clean";
94
+ export interface GroundTruthQuery {
95
+ q: string;
96
+ expectMarker: string;
97
+ kind: QueryKind;
98
+ /** What this query is specifically testing, and against which distractor. */
99
+ note: string;
100
+ }
101
+ export declare const CORPUS: CorpusRecord[];
102
+ export declare const QUERIES: GroundTruthQuery[];