@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.
@@ -0,0 +1,21 @@
1
+ /**
2
+ * cosine.ts — exact cosine similarity + full-corpus ranking.
3
+ *
4
+ * Stands in for Harper's HNSW approximate-nearest-neighbor index (see
5
+ * README "Comparable to flair, with a caveat"): flair-bench has no HNSW
6
+ * graph, so instead of an approximate top-K it scores EVERY corpus vector
7
+ * against the query and sorts — the correct answer for a corpus this size
8
+ * (251 records), and the reason the tool can claim exact recall numbers
9
+ * rather than approximate ones.
10
+ */
11
+ export declare function cosineSimilarity(a: readonly number[], b: readonly number[]): number;
12
+ /**
13
+ * 0-based rank of `targetIndex` within `corpusVectors` when sorted by
14
+ * descending cosine similarity to `query`. Ties are broken by corpus
15
+ * insertion order (stable sort — Array.prototype.sort is guaranteed stable
16
+ * since ES2019/V8 since Node 11), the simplest documented tie-break
17
+ * available without reverse-engineering Harper's own HNSW/BM25-RRF
18
+ * tie-break internals. See README's "exact-vs-HNSW caveat" for what this
19
+ * does and doesn't guarantee to reproduce.
20
+ */
21
+ export declare function rankOf(query: readonly number[], corpusVectors: readonly (readonly number[])[], targetIndex: number): number;
package/dist/cosine.js ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * cosine.ts — exact cosine similarity + full-corpus ranking.
3
+ *
4
+ * Stands in for Harper's HNSW approximate-nearest-neighbor index (see
5
+ * README "Comparable to flair, with a caveat"): flair-bench has no HNSW
6
+ * graph, so instead of an approximate top-K it scores EVERY corpus vector
7
+ * against the query and sorts — the correct answer for a corpus this size
8
+ * (251 records), and the reason the tool can claim exact recall numbers
9
+ * rather than approximate ones.
10
+ */
11
+ export function cosineSimilarity(a, b) {
12
+ if (a.length !== b.length) {
13
+ throw new Error(`cosineSimilarity: dimension mismatch (${a.length} vs ${b.length})`);
14
+ }
15
+ let dot = 0;
16
+ let normA = 0;
17
+ let normB = 0;
18
+ for (let i = 0; i < a.length; i++) {
19
+ const x = a[i];
20
+ const y = b[i];
21
+ dot += x * y;
22
+ normA += x * x;
23
+ normB += y * y;
24
+ }
25
+ if (normA === 0 || normB === 0)
26
+ return 0;
27
+ return dot / (Math.sqrt(normA) * Math.sqrt(normB));
28
+ }
29
+ /**
30
+ * 0-based rank of `targetIndex` within `corpusVectors` when sorted by
31
+ * descending cosine similarity to `query`. Ties are broken by corpus
32
+ * insertion order (stable sort — Array.prototype.sort is guaranteed stable
33
+ * since ES2019/V8 since Node 11), the simplest documented tie-break
34
+ * available without reverse-engineering Harper's own HNSW/BM25-RRF
35
+ * tie-break internals. See README's "exact-vs-HNSW caveat" for what this
36
+ * does and doesn't guarantee to reproduce.
37
+ */
38
+ export function rankOf(query, corpusVectors, targetIndex) {
39
+ const scored = corpusVectors.map((v, index) => ({ index, score: cosineSimilarity(query, v) }));
40
+ scored.sort((x, y) => y.score - x.score);
41
+ return scored.findIndex((s) => s.index === targetIndex);
42
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * engine.ts — node-llama-cpp integration: model loading, identity, and
3
+ * corpus embedding.
4
+ *
5
+ * Uses node-llama-cpp DIRECTLY (the same engine dependency
6
+ * harper-fabric-embeddings/HFE wraps for Harper) rather than going through
7
+ * HFE — flair-bench has to run with no Flair/Harper installation at all, so
8
+ * it can't depend on the Harper-only wrapper package. See prefixes.ts for
9
+ * how the nomic search-prefix convention HFE applies is replicated here.
10
+ */
11
+ import { type Llama, type LlamaModel } from "node-llama-cpp";
12
+ import type { BenchProgressEvent, CorpusInfo, ModelBenchResult, ModelIdentity } from "./types.js";
13
+ /** Shared Llama instance for the whole batch run — one backend/GPU detection, reused across every model load. */
14
+ export declare function getSharedLlama(): Promise<Llama>;
15
+ export declare function corpusInfo(): CorpusInfo;
16
+ export declare function computeModelIdentity(model: LlamaModel, filePath: string): Promise<ModelIdentity>;
17
+ /**
18
+ * Full single-model benchmark: load, embed the corpus, exact-cosine rank
19
+ * every query, score p@3/MRR overall and per-kind. Disposes the model
20
+ * before returning so a batch run's next model starts from a clean slate.
21
+ *
22
+ * Peak-RSS baseline is captured BEFORE `llama.loadModel()` runs — sampling
23
+ * only from inside the embedding phase (an earlier version of this
24
+ * function did that) would already have the model's own weights resident
25
+ * in the baseline, silently undercounting the number the spec actually
26
+ * wants ("how much memory does loading+using this model cost"). Verified
27
+ * against the Q4 baseline: an 80 MiB GGUF was reporting a ~24 MiB delta
28
+ * before this fix, because mmap'd model weight pages had already been
29
+ * counted into the baseline by the time sampling started.
30
+ */
31
+ export declare function benchModel(llama: Llama, filePath: string, options?: {
32
+ warmupN?: number;
33
+ onProgress?: (e: BenchProgressEvent) => void;
34
+ index?: number;
35
+ total?: number;
36
+ }): Promise<ModelBenchResult>;
package/dist/engine.js ADDED
@@ -0,0 +1,203 @@
1
+ /**
2
+ * engine.ts — node-llama-cpp integration: model loading, identity, and
3
+ * corpus embedding.
4
+ *
5
+ * Uses node-llama-cpp DIRECTLY (the same engine dependency
6
+ * harper-fabric-embeddings/HFE wraps for Harper) rather than going through
7
+ * HFE — flair-bench has to run with no Flair/Harper installation at all, so
8
+ * it can't depend on the Harper-only wrapper package. See prefixes.ts for
9
+ * how the nomic search-prefix convention HFE applies is replicated here.
10
+ */
11
+ import { createHash } from "node:crypto";
12
+ import { createReadStream, statSync } from "node:fs";
13
+ import { basename } from "node:path";
14
+ import { getLlama, GgufFileType } from "node-llama-cpp";
15
+ import { CORPUS, QUERIES } from "./corpus-v2.js";
16
+ import { rankOf } from "./cosine.js";
17
+ import { applyDocumentPrefix, applyQueryPrefix, resolvePrefixConvention } from "./prefixes.js";
18
+ import { scoreRows } from "./scorer.js";
19
+ const DEFAULT_WARMUP_N = 8;
20
+ const MIN_TIMED_EMBEDS = 64;
21
+ let _llama;
22
+ /** Shared Llama instance for the whole batch run — one backend/GPU detection, reused across every model load. */
23
+ export async function getSharedLlama() {
24
+ if (_llama)
25
+ return _llama;
26
+ _llama = await getLlama();
27
+ return _llama;
28
+ }
29
+ export function corpusInfo() {
30
+ return { version: "v2", records: CORPUS.length, queries: QUERIES.length };
31
+ }
32
+ async function sha256File(filePath) {
33
+ return new Promise((resolve, reject) => {
34
+ const hash = createHash("sha256");
35
+ const stream = createReadStream(filePath);
36
+ stream.on("data", (chunk) => hash.update(chunk));
37
+ stream.on("end", () => resolve(hash.digest("hex")));
38
+ stream.on("error", reject);
39
+ });
40
+ }
41
+ /**
42
+ * Total parameter count, approximated by summing every tensor's element
43
+ * count from the GGUF's own tensor index — the same method llama.cpp's CLI
44
+ * uses to report "params" in its load banner. Embedding-architecture GGUFs
45
+ * (BERT-family, unlike LLaMA-family) don't reliably carry a
46
+ * `general.parameter_count` metadata field, so this is computed rather than
47
+ * read.
48
+ */
49
+ function approxParamCount(model) {
50
+ const tensors = model.fileInfo.tensorInfo ?? [];
51
+ let total = 0n;
52
+ for (const t of tensors) {
53
+ let elements = 1n;
54
+ for (const d of t.dimensions) {
55
+ elements *= typeof d === "bigint" ? d : BigInt(d);
56
+ }
57
+ total += elements;
58
+ }
59
+ return Number(total);
60
+ }
61
+ /** "MOSTLY_Q4_K_M" -> "Q4_K_M"; falls back to parsing the filename if GGUF metadata carries no file_type. */
62
+ function resolveQuant(model, fileName) {
63
+ const fileType = model.fileInfo.metadata?.general?.file_type;
64
+ if (fileType !== undefined) {
65
+ const name = GgufFileType[fileType];
66
+ if (name)
67
+ return { quant: name.replace(/^MOSTLY_/, "").replace(/^ALL_/, ""), quantSource: "gguf-metadata" };
68
+ }
69
+ const match = fileName.match(/\.((?:Q|IQ)[0-9][A-Z0-9_]*|F16|F32|BF16)\.gguf$/i);
70
+ return { quant: match ? match[1].toUpperCase() : "unknown", quantSource: "filename-fallback" };
71
+ }
72
+ export async function computeModelIdentity(model, filePath) {
73
+ const fileName = basename(filePath);
74
+ const sizeBytes = statSync(filePath).size;
75
+ const sha256 = await sha256File(filePath);
76
+ const { quant, quantSource } = resolveQuant(model, fileName);
77
+ const dims = model.fileInsights.embeddingVectorSize ?? 0;
78
+ const paramsApprox = approxParamCount(model);
79
+ const bpw = paramsApprox > 0 ? (sizeBytes * 8) / paramsApprox : 0;
80
+ return { fileName, sha256, sizeBytes, quant, quantSource, dims, paramsApprox, bpw };
81
+ }
82
+ /**
83
+ * Embeds every CORPUS record (as "document") and every QUERIES entry (as
84
+ * "query") serially, applying the resolved prefix convention. The first
85
+ * `warmupN` embeds (drawn from CORPUS) run untimed before the stopwatch
86
+ * starts, so first-call allocation/JIT overhead doesn't skew the reported
87
+ * ms/embed — the timed set is CORPUS.length + QUERIES.length - warmupN,
88
+ * comfortably >= 64 for corpus-v2 (251 + 126 - 8 = 369).
89
+ */
90
+ async function embedCorpus(model, fileName, warmupN, sample, onProgress) {
91
+ const convention = resolvePrefixConvention(fileName);
92
+ const embeddingContext = await model.createEmbeddingContext();
93
+ sample();
94
+ try {
95
+ const documentTexts = CORPUS.map((r) => applyDocumentPrefix(r.text, convention));
96
+ const queryTexts = QUERIES.map((q) => applyQueryPrefix(q.q, convention));
97
+ const warm = Math.min(Math.max(0, warmupN), documentTexts.length);
98
+ for (let i = 0; i < warm; i++) {
99
+ await embeddingContext.getEmbeddingFor(documentTexts[i]);
100
+ sample();
101
+ }
102
+ const documentVectors = new Array(documentTexts.length);
103
+ const queryVectors = new Array(queryTexts.length);
104
+ const totalToEmbed = documentTexts.length + queryTexts.length;
105
+ let done = 0;
106
+ let timedCount = 0;
107
+ let timedMs = 0;
108
+ for (let i = 0; i < documentTexts.length; i++) {
109
+ const timed = i >= warm;
110
+ const t0 = timed ? performance.now() : 0;
111
+ const emb = await embeddingContext.getEmbeddingFor(documentTexts[i]);
112
+ if (timed) {
113
+ timedMs += performance.now() - t0;
114
+ timedCount++;
115
+ }
116
+ documentVectors[i] = emb.vector;
117
+ sample();
118
+ done++;
119
+ onProgress?.({ type: "model-embedding", fileName, done, total: totalToEmbed });
120
+ }
121
+ for (let i = 0; i < queryTexts.length; i++) {
122
+ const t0 = performance.now();
123
+ const emb = await embeddingContext.getEmbeddingFor(queryTexts[i]);
124
+ timedMs += performance.now() - t0;
125
+ timedCount++;
126
+ queryVectors[i] = emb.vector;
127
+ sample();
128
+ done++;
129
+ onProgress?.({ type: "model-embedding", fileName, done, total: totalToEmbed });
130
+ }
131
+ const msPerEmbedSerialWarm = timedCount > 0 ? timedMs / timedCount : 0;
132
+ if (timedCount < MIN_TIMED_EMBEDS) {
133
+ // Should be unreachable with corpus-v2's fixed size, but fail loudly
134
+ // rather than silently report a latency figure the spec's own N>=64
135
+ // floor wasn't met by.
136
+ throw new Error(`embedCorpus: only ${timedCount} timed embeds (need >= ${MIN_TIMED_EMBEDS}) — corpus-v2 too small or warmupN too large`);
137
+ }
138
+ return { documentVectors, queryVectors, msPerEmbedSerialWarm };
139
+ }
140
+ finally {
141
+ await embeddingContext.dispose();
142
+ }
143
+ }
144
+ /**
145
+ * Full single-model benchmark: load, embed the corpus, exact-cosine rank
146
+ * every query, score p@3/MRR overall and per-kind. Disposes the model
147
+ * before returning so a batch run's next model starts from a clean slate.
148
+ *
149
+ * Peak-RSS baseline is captured BEFORE `llama.loadModel()` runs — sampling
150
+ * only from inside the embedding phase (an earlier version of this
151
+ * function did that) would already have the model's own weights resident
152
+ * in the baseline, silently undercounting the number the spec actually
153
+ * wants ("how much memory does loading+using this model cost"). Verified
154
+ * against the Q4 baseline: an 80 MiB GGUF was reporting a ~24 MiB delta
155
+ * before this fix, because mmap'd model weight pages had already been
156
+ * counted into the baseline by the time sampling started.
157
+ */
158
+ export async function benchModel(llama, filePath, options = {}) {
159
+ const fileName = basename(filePath);
160
+ const { warmupN = DEFAULT_WARMUP_N, onProgress, index = 0, total = 1 } = options;
161
+ onProgress?.({ type: "model-start", fileName, index, total });
162
+ const baselineRss = process.memoryUsage().rss;
163
+ let peakRss = baselineRss;
164
+ const sample = () => {
165
+ const rss = process.memoryUsage().rss;
166
+ if (rss > peakRss)
167
+ peakRss = rss;
168
+ };
169
+ const loadStart = performance.now();
170
+ const model = await llama.loadModel({ modelPath: filePath });
171
+ const loadTimeMs = performance.now() - loadStart;
172
+ sample();
173
+ onProgress?.({ type: "model-loaded", fileName, loadTimeMs });
174
+ try {
175
+ const modelIdentity = await computeModelIdentity(model, filePath);
176
+ sample();
177
+ const { documentVectors, queryVectors, msPerEmbedSerialWarm } = await embedCorpus(model, fileName, warmupN, sample, onProgress);
178
+ const peakRssDeltaMiB = Math.max(0, (peakRss - baselineRss) / (1024 * 1024));
179
+ const markerToIndex = new Map(CORPUS.map((r, i) => [r.marker, i]));
180
+ const rows = QUERIES.map((q, qi) => {
181
+ const targetIndex = markerToIndex.get(q.expectMarker);
182
+ if (targetIndex === undefined) {
183
+ throw new Error(`benchModel: QUERIES[${qi}].expectMarker "${q.expectMarker}" has no matching CORPUS record`);
184
+ }
185
+ const rank = rankOf(queryVectors[qi], documentVectors, targetIndex);
186
+ return { rank, kind: q.kind };
187
+ });
188
+ const { aggregate, perKind } = scoreRows(rows);
189
+ const result = {
190
+ model: modelIdentity,
191
+ loadTimeMs,
192
+ msPerEmbedSerialWarm,
193
+ peakRssDeltaMiB,
194
+ aggregate,
195
+ perKind,
196
+ };
197
+ onProgress?.({ type: "model-done", fileName, result });
198
+ return result;
199
+ }
200
+ finally {
201
+ await model.dispose();
202
+ }
203
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * format.ts — rendering layer. Takes structured results (index.ts's return
3
+ * types) and produces strings; never calls process.exit or writes anywhere
4
+ * itself. cli.ts is the only place that actually console.logs these
5
+ * strings — keeping this module pure text-in/text-out means a library
6
+ * consumer (see index.ts's header) can call these directly or write its
7
+ * own renderer against the same BatchResult/RecommendResult shapes.
8
+ */
9
+ import type { BatchResult, RecommendResult } from "./types.js";
10
+ export declare function formatPretty(result: BatchResult): string;
11
+ export declare function formatJson(result: BatchResult): string;
12
+ export declare function formatRecommendPretty(result: RecommendResult): string;
13
+ export declare function formatRecommendJson(result: RecommendResult): string;
package/dist/format.js ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * format.ts — rendering layer. Takes structured results (index.ts's return
3
+ * types) and produces strings; never calls process.exit or writes anywhere
4
+ * itself. cli.ts is the only place that actually console.logs these
5
+ * strings — keeping this module pure text-in/text-out means a library
6
+ * consumer (see index.ts's header) can call these directly or write its
7
+ * own renderer against the same BatchResult/RecommendResult shapes.
8
+ */
9
+ function fmt(n, d = 3) {
10
+ return n.toFixed(d);
11
+ }
12
+ function miB(n) {
13
+ return `${n.toFixed(1)} MiB`;
14
+ }
15
+ function modelLine(m) {
16
+ return (`${m.model.fileName}\n` +
17
+ ` quant=${m.model.quant} (${m.model.quantSource}) dims=${m.model.dims} size=${(m.model.sizeBytes / (1024 * 1024)).toFixed(1)} MiB ` +
18
+ `bpw=${fmt(m.model.bpw, 2)} params~${(m.model.paramsApprox / 1e6).toFixed(1)}M\n` +
19
+ ` sha256=${m.model.sha256}\n` +
20
+ ` load=${fmt(m.loadTimeMs, 1)}ms embed=${fmt(m.msPerEmbedSerialWarm, 2)}ms/ea (serial, warm) peak-rss-delta=${miB(m.peakRssDeltaMiB)}\n` +
21
+ ` aggregate: p@3=${fmt(m.aggregate.p3)} MRR=${fmt(m.aggregate.mrr)} (n=${m.aggregate.n})\n` +
22
+ ` by kind: ` +
23
+ ["stress", "trap", "hard", "clean"]
24
+ .map((k) => `${k}=${fmt(m.perKind[k].mrr)}(n=${m.perKind[k].n})`)
25
+ .join(" "));
26
+ }
27
+ export function formatPretty(result) {
28
+ const lines = [];
29
+ lines.push(`flair-bench ${result.toolVersion} — ${result.timestamp}`);
30
+ lines.push(`corpus: v2 (${result.corpus.records} records, ${result.corpus.queries} queries)`);
31
+ const label = result.host.label ? ` [${result.host.label}]` : "";
32
+ lines.push(`host${label}: ${result.host.platform}/${result.host.arch} ${result.host.cpuModel} ` +
33
+ `${result.host.totalRamGiB.toFixed(1)}GiB total / ${result.host.availableRamGiB.toFixed(1)}GiB available ` +
34
+ `backend=${result.host.backend}${result.host.gpuDeviceNames.length ? ` gpu=${result.host.gpuDeviceNames.join(", ")}` : ""}`);
35
+ lines.push("");
36
+ for (const m of result.models) {
37
+ lines.push(modelLine(m));
38
+ lines.push("");
39
+ }
40
+ if (result.models.length > 1) {
41
+ lines.push("── ranked comparison (by MRR) ──");
42
+ const ranked = [...result.models].sort((a, b) => b.aggregate.mrr - a.aggregate.mrr);
43
+ for (const m of ranked) {
44
+ lines.push(` ${m.model.fileName.padEnd(40)} p@3=${fmt(m.aggregate.p3)} MRR=${fmt(m.aggregate.mrr)} ` +
45
+ `${fmt(m.msPerEmbedSerialWarm, 1)}ms/embed ${miB(m.peakRssDeltaMiB)}`);
46
+ }
47
+ lines.push("");
48
+ }
49
+ return lines.join("\n");
50
+ }
51
+ export function formatJson(result) {
52
+ return JSON.stringify(result, null, 2);
53
+ }
54
+ export function formatRecommendPretty(result) {
55
+ const lines = [formatPretty(result.batch), "── recommendation ──"];
56
+ if (result.recommendation) {
57
+ lines.push(result.recommendation.reason);
58
+ }
59
+ else {
60
+ lines.push("(no recommendation — see notes below)");
61
+ }
62
+ lines.push("");
63
+ for (const note of result.notes)
64
+ lines.push(`note: ${note}`);
65
+ return lines.join("\n");
66
+ }
67
+ export function formatRecommendJson(result) {
68
+ return JSON.stringify(result, null, 2);
69
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * hostinfo.ts — host fingerprinting.
3
+ *
4
+ * Compute backend + GPU device string come from node-llama-cpp's OWN report
5
+ * (`llama.gpu`, `llama.getGpuDeviceNames()`) — the engine that actually
6
+ * loaded the model, not inferred from `os.platform()`/`os.arch()`. Two
7
+ * hosts can share a platform/arch and still land on different backends
8
+ * (e.g. a Linux x64 box with no CUDA/Vulkan falls back to CPU) — this is
9
+ * what turns shared results into a real model × infra matrix rather than a
10
+ * model × OS-name one.
11
+ */
12
+ import type { Llama } from "node-llama-cpp";
13
+ import type { HostFingerprint } from "./types.js";
14
+ export declare function fingerprintHost(llama: Llama, label?: string): Promise<HostFingerprint>;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * hostinfo.ts — host fingerprinting.
3
+ *
4
+ * Compute backend + GPU device string come from node-llama-cpp's OWN report
5
+ * (`llama.gpu`, `llama.getGpuDeviceNames()`) — the engine that actually
6
+ * loaded the model, not inferred from `os.platform()`/`os.arch()`. Two
7
+ * hosts can share a platform/arch and still land on different backends
8
+ * (e.g. a Linux x64 box with no CUDA/Vulkan falls back to CPU) — this is
9
+ * what turns shared results into a real model × infra matrix rather than a
10
+ * model × OS-name one.
11
+ */
12
+ import os from "node:os";
13
+ const BYTES_PER_GIB = 1024 ** 3;
14
+ export async function fingerprintHost(llama, label) {
15
+ const gpuType = llama.gpu; // "metal" | "cuda" | "vulkan" | false
16
+ const backend = gpuType === false ? "cpu" : gpuType;
17
+ let gpuDeviceNames = [];
18
+ if (backend !== "cpu") {
19
+ try {
20
+ gpuDeviceNames = await llama.getGpuDeviceNames();
21
+ }
22
+ catch {
23
+ // Some backends/drivers don't expose device enumeration — backend is
24
+ // still known and reported; device names degrade to empty, not a crash.
25
+ gpuDeviceNames = [];
26
+ }
27
+ }
28
+ const cpus = os.cpus();
29
+ const cpuModel = cpus[0]?.model?.trim() || "unknown";
30
+ return {
31
+ label,
32
+ platform: os.platform(),
33
+ arch: os.arch(),
34
+ cpuModel,
35
+ totalRamGiB: os.totalmem() / BYTES_PER_GIB,
36
+ availableRamGiB: os.freemem() / BYTES_PER_GIB,
37
+ backend,
38
+ gpuDeviceNames,
39
+ };
40
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * index.ts — flair-bench's public library API.
3
+ *
4
+ * CORE CONTRACT (so a future `flair bench` CLI subcommand — see README
5
+ * "Library use" — can consume this package directly): no `process.exit`,
6
+ * no `console.*` calls anywhere in this file or the modules it drives
7
+ * (engine.ts, hostinfo.ts, scorer.ts, cosine.ts, prefixes.ts, share.ts).
8
+ * Progress is reported via the optional `onProgress` callback
9
+ * (BenchOptions.onProgress); errors are thrown as real Error objects, never
10
+ * swallowed into an exit code. Rendering (pretty tables / JSON strings) and
11
+ * printing both live in cli.ts + format.ts — this file only computes and
12
+ * returns structured data.
13
+ */
14
+ import type { BatchResult, BenchOptions, RecommendOptions, RecommendResult } from "./types.js";
15
+ export * from "./types.js";
16
+ export { buildShareDocument, writeShareDocument, SUBMISSION_ENDPOINT_PLACEHOLDER } from "./share.js";
17
+ export type { WrittenShare } from "./share.js";
18
+ export { TOOL_VERSION } from "./version.js";
19
+ export { resolvePrefixConvention } from "./prefixes.js";
20
+ export { pickRecommendation, DEFAULT_RAM_HEADROOM_FRACTION, DEFAULT_LATENCY_THRESHOLD_MS } from "./recommend-heuristic.js";
21
+ export type { RecommendHeuristicOptions, RecommendHeuristicResult } from "./recommend-heuristic.js";
22
+ /**
23
+ * Run the embedding benchmark for one or more GGUF files. Batch = more than
24
+ * one entry in `options.modelFiles`. Models are loaded and disposed one at
25
+ * a time (see engine.ts's benchModel) so peak-RSS measurement stays
26
+ * per-model rather than accumulating across the whole batch.
27
+ */
28
+ export declare function runBenchmark(options: BenchOptions): Promise<BatchResult>;
29
+ /**
30
+ * Runs the batch, then recommends the model with the best measured recall
31
+ * (MRR, tie-broken by faster ms/embed) among those that fit the host's RAM
32
+ * headroom and latency threshold. HONEST ABOUT WHAT IT DOESN'T KNOW (see
33
+ * README "Recommend heuristic, and its limits"):
34
+ * - RSS is measured for THIS process's single-model-at-a-time load, not a
35
+ * concurrent multi-request serving load — a real server's memory
36
+ * footprint under concurrency will be higher.
37
+ * - Latency is single-request serial ms/embed, not throughput under
38
+ * concurrent load or with batching.
39
+ * - The RAM/latency thresholds are simple fixed fractions/ceilings, not a
40
+ * learned or host-class-specific model — see the two options below to
41
+ * tune them.
42
+ * If nothing fits the budget, recommend() falls back to ranking the full
43
+ * set and says so explicitly in `notes` rather than silently returning null.
44
+ *
45
+ * The actual pick logic is pickRecommendation() (recommend-heuristic.ts) —
46
+ * factored out so it's unit-testable against fixture BatchResult objects
47
+ * without touching node-llama-cpp (see test/recommend.test.ts).
48
+ */
49
+ export declare function recommend(options: RecommendOptions): Promise<RecommendResult>;
package/dist/index.js ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * index.ts — flair-bench's public library API.
3
+ *
4
+ * CORE CONTRACT (so a future `flair bench` CLI subcommand — see README
5
+ * "Library use" — can consume this package directly): no `process.exit`,
6
+ * no `console.*` calls anywhere in this file or the modules it drives
7
+ * (engine.ts, hostinfo.ts, scorer.ts, cosine.ts, prefixes.ts, share.ts).
8
+ * Progress is reported via the optional `onProgress` callback
9
+ * (BenchOptions.onProgress); errors are thrown as real Error objects, never
10
+ * swallowed into an exit code. Rendering (pretty tables / JSON strings) and
11
+ * printing both live in cli.ts + format.ts — this file only computes and
12
+ * returns structured data.
13
+ */
14
+ import { benchModel, corpusInfo, getSharedLlama } from "./engine.js";
15
+ import { fingerprintHost } from "./hostinfo.js";
16
+ import { TOOL_VERSION } from "./version.js";
17
+ import { pickRecommendation } from "./recommend-heuristic.js";
18
+ export * from "./types.js";
19
+ export { buildShareDocument, writeShareDocument, SUBMISSION_ENDPOINT_PLACEHOLDER } from "./share.js";
20
+ export { TOOL_VERSION } from "./version.js";
21
+ export { resolvePrefixConvention } from "./prefixes.js";
22
+ export { pickRecommendation, DEFAULT_RAM_HEADROOM_FRACTION, DEFAULT_LATENCY_THRESHOLD_MS } from "./recommend-heuristic.js";
23
+ /**
24
+ * Run the embedding benchmark for one or more GGUF files. Batch = more than
25
+ * one entry in `options.modelFiles`. Models are loaded and disposed one at
26
+ * a time (see engine.ts's benchModel) so peak-RSS measurement stays
27
+ * per-model rather than accumulating across the whole batch.
28
+ */
29
+ export async function runBenchmark(options) {
30
+ if (!options.modelFiles || options.modelFiles.length === 0) {
31
+ throw new Error("runBenchmark: options.modelFiles must contain at least one GGUF file path");
32
+ }
33
+ const llama = await getSharedLlama();
34
+ const host = await fingerprintHost(llama, options.label);
35
+ options.onProgress?.({ type: "host-fingerprinted", host });
36
+ const models = [];
37
+ for (let i = 0; i < options.modelFiles.length; i++) {
38
+ const filePath = options.modelFiles[i];
39
+ const result = await benchModel(llama, filePath, {
40
+ warmupN: options.warmupN,
41
+ onProgress: options.onProgress,
42
+ index: i,
43
+ total: options.modelFiles.length,
44
+ });
45
+ models.push(result);
46
+ }
47
+ return {
48
+ toolVersion: TOOL_VERSION,
49
+ timestamp: new Date().toISOString(),
50
+ corpus: corpusInfo(),
51
+ host,
52
+ models,
53
+ };
54
+ }
55
+ /**
56
+ * Runs the batch, then recommends the model with the best measured recall
57
+ * (MRR, tie-broken by faster ms/embed) among those that fit the host's RAM
58
+ * headroom and latency threshold. HONEST ABOUT WHAT IT DOESN'T KNOW (see
59
+ * README "Recommend heuristic, and its limits"):
60
+ * - RSS is measured for THIS process's single-model-at-a-time load, not a
61
+ * concurrent multi-request serving load — a real server's memory
62
+ * footprint under concurrency will be higher.
63
+ * - Latency is single-request serial ms/embed, not throughput under
64
+ * concurrent load or with batching.
65
+ * - The RAM/latency thresholds are simple fixed fractions/ceilings, not a
66
+ * learned or host-class-specific model — see the two options below to
67
+ * tune them.
68
+ * If nothing fits the budget, recommend() falls back to ranking the full
69
+ * set and says so explicitly in `notes` rather than silently returning null.
70
+ *
71
+ * The actual pick logic is pickRecommendation() (recommend-heuristic.ts) —
72
+ * factored out so it's unit-testable against fixture BatchResult objects
73
+ * without touching node-llama-cpp (see test/recommend.test.ts).
74
+ */
75
+ export async function recommend(options) {
76
+ const batch = await runBenchmark(options);
77
+ const { recommendation, notes } = pickRecommendation(batch, {
78
+ ramHeadroomFraction: options.ramHeadroomFraction,
79
+ latencyThresholdMsPerEmbed: options.latencyThresholdMsPerEmbed,
80
+ });
81
+ return { batch, recommendation, notes };
82
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * prefixes.ts — the input-prefix convention table.
3
+ *
4
+ * Mirrors resources/embeddings-provider.ts's Phase 2 behavior (flair#504):
5
+ * HFE 0.3.0's engine prepends a literal `"search_document: "` /
6
+ * `"search_query: "` string ahead of the text it hands to the model, for
7
+ * nomic-family models trained on that asymmetric-prefix convention. flair-
8
+ * bench talks to node-llama-cpp directly (no HFE in the loop — see the
9
+ * package README for why), so it has to replicate that string-prepend
10
+ * itself rather than pass an `inputType` option through to a wrapper that
11
+ * does it.
12
+ *
13
+ * KEYED ON MODEL FILENAME, for now — nomic is the only convention flair
14
+ * ships today, and there's exactly one production model family to match.
15
+ * This table is the seam for adding more: a future entry keys on another
16
+ * filename pattern (or, if a GGUF ever carries a documented convention-id
17
+ * in its own metadata, that becomes the preferred match — filename is a
18
+ * pragmatic fallback, not a design commitment). Nomic upstream has a request
19
+ * to carry this in the GGUF/HFE registration surface itself rather than
20
+ * every consumer re-deriving it from a filename convention — see
21
+ * harper-fabric-embeddings#4 (upstream proposal, not yet merged as of this
22
+ * writing).
23
+ *
24
+ * A model whose filename matches no entry gets `undefined` from
25
+ * `resolvePrefixConvention()` — texts are embedded unprefixed, and the
26
+ * result is labeled accordingly rather than silently guessing.
27
+ */
28
+ export interface PrefixConvention {
29
+ /** Human-readable id, surfaced in output so a reader knows which convention (if any) applied. */
30
+ id: string;
31
+ documentPrefix: string;
32
+ queryPrefix: string;
33
+ }
34
+ /** Resolve the prefix convention for a GGUF file, by basename. `undefined` = no known convention (embed unprefixed). */
35
+ export declare function resolvePrefixConvention(fileName: string): PrefixConvention | undefined;
36
+ export declare function applyDocumentPrefix(text: string, convention: PrefixConvention | undefined): string;
37
+ export declare function applyQueryPrefix(text: string, convention: PrefixConvention | undefined): string;