@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/dist/cosine.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/engine.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/format.d.ts
ADDED
|
@@ -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>;
|
package/dist/hostinfo.js
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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;
|