@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 +19 -0
- package/README.md +157 -0
- package/dist/cli-shim.cjs +49 -0
- package/dist/cli-shim.d.cts +21 -0
- package/dist/cli.d.ts +14 -0
- package/dist/cli.js +198 -0
- package/dist/corpus-v2.d.ts +102 -0
- package/dist/corpus-v2.js +1087 -0
- package/dist/cosine.d.ts +21 -0
- package/dist/cosine.js +42 -0
- package/dist/engine.d.ts +36 -0
- package/dist/engine.js +203 -0
- package/dist/format.d.ts +13 -0
- package/dist/format.js +69 -0
- package/dist/hostinfo.d.ts +14 -0
- package/dist/hostinfo.js +40 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.js +82 -0
- package/dist/prefixes.d.ts +37 -0
- package/dist/prefixes.js +64 -0
- package/dist/recommend-heuristic.d.ts +22 -0
- package/dist/recommend-heuristic.js +48 -0
- package/dist/scorer.d.ts +32 -0
- package/dist/scorer.js +31 -0
- package/dist/share.d.ts +30 -0
- package/dist/share.js +75 -0
- package/dist/types.d.ts +156 -0
- package/dist/types.js +8 -0
- package/dist/version.d.ts +8 -0
- package/dist/version.js +8 -0
- package/package.json +68 -0
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[];
|