harper-fabric-embeddings
Version:
Minimal llama.cpp embedding wrapper for Harper Fabric. Talks directly to the native addon — no build tools, no CLI, no chat wrappers.
228 lines • 9.63 kB
JavaScript
/**
* Minimal GGUF header reader — just enough to answer "what pooling does this
* model declare?" without loading the model.
*
* Why it exists (issue #12): the native addon accepts no pooling option, so
* the llama.cpp context always resolves pooling from the model's own
* `<arch>.pooling_type` metadata — and when that key is missing (common in
* third-party GGUF conversions), llama.cpp falls back to an arch-dependent
* default that may silently mean-pool a last-token model. Degraded vectors,
* no error. We can't override the pooling, but we CAN verify the file
* declares what the caller expects and fail loudly at init when it doesn't.
*
* Parses only the metadata KV section (never tensor data), early-exits once
* the interesting keys are found, and skips large values (tokenizer arrays)
* without materializing them.
*/
var _a;
import { open } from 'node:fs/promises';
// llama.cpp's llama_pooling_type enum, as written into GGUF metadata.
const POOLING_TYPES = { none: 0, mean: 1, cls: 2, last: 3, rank: 4 };
export const POOLING_NAMES = Object.keys(POOLING_TYPES);
function poolingName(value) {
const entry = Object.entries(POOLING_TYPES).find(([, v]) => v === value);
return entry ? entry[0] : `unknown(${value})`;
}
const FIXED_SIZES = {
[0 /* GgufType.UINT8 */]: 1,
[1 /* GgufType.INT8 */]: 1,
[2 /* GgufType.UINT16 */]: 2,
[3 /* GgufType.INT16 */]: 2,
[4 /* GgufType.UINT32 */]: 4,
[5 /* GgufType.INT32 */]: 4,
[6 /* GgufType.FLOAT32 */]: 4,
[7 /* GgufType.BOOL */]: 1,
[10 /* GgufType.UINT64 */]: 8,
[11 /* GgufType.INT64 */]: 8,
[12 /* GgufType.FLOAT64 */]: 8,
};
const GGUF_MAGIC = 0x46554747; // "GGUF" little-endian
// Defensive bounds — a malformed length should error, not attempt a huge read.
const MAX_KEY_LENGTH = 1 << 16;
const MAX_KV_COUNT = 1 << 20;
/** Sequential little-endian reader over a file descriptor with buffered refills and O(1) skips. */
class GgufReader {
#handle;
#buffer = Buffer.alloc(0);
#bufferStart = 0; // absolute file offset of #buffer[0]
#pos = 0; // absolute read cursor
static #CHUNK = 1 << 20;
constructor(handle) {
this.#handle = handle;
}
async #ensure(n) {
const offsetInBuffer = this.#pos - this.#bufferStart;
if (offsetInBuffer >= 0 && offsetInBuffer + n <= this.#buffer.length) {
return this.#buffer.subarray(offsetInBuffer, offsetInBuffer + n);
}
const size = Math.max(n, _a.#CHUNK);
const fresh = Buffer.alloc(size);
const { bytesRead } = await this.#handle.read(fresh, 0, size, this.#pos);
if (bytesRead < n) {
throw new Error(`Unexpected end of file at offset ${this.#pos} (needed ${n} bytes, got ${bytesRead})`);
}
this.#buffer = fresh.subarray(0, bytesRead);
this.#bufferStart = this.#pos;
return this.#buffer.subarray(0, n);
}
async u32() {
const b = await this.#ensure(4);
this.#pos += 4;
return b.readUInt32LE(0);
}
async u64() {
const b = await this.#ensure(8);
this.#pos += 8;
const value = b.readBigUInt64LE(0);
if (value > BigInt(Number.MAX_SAFE_INTEGER)) {
throw new Error(`64-bit value ${value} at offset ${this.#pos - 8} exceeds safe integer range`);
}
return Number(value);
}
/** Read a numeric metadata value of any integer/float type, coerced to number. */
async numeric(type) {
const size = FIXED_SIZES[type];
const b = await this.#ensure(size);
this.#pos += size;
switch (type) {
case 0 /* GgufType.UINT8 */:
return b.readUInt8(0);
case 1 /* GgufType.INT8 */:
return b.readInt8(0);
case 2 /* GgufType.UINT16 */:
return b.readUInt16LE(0);
case 3 /* GgufType.INT16 */:
return b.readInt16LE(0);
case 4 /* GgufType.UINT32 */:
return b.readUInt32LE(0);
case 5 /* GgufType.INT32 */:
return b.readInt32LE(0);
case 6 /* GgufType.FLOAT32 */:
return b.readFloatLE(0);
case 7 /* GgufType.BOOL */:
return b.readUInt8(0);
case 10 /* GgufType.UINT64 */:
case 11 /* GgufType.INT64 */: {
const value = b.readBigUInt64LE(0);
return Number(value); // metadata scalars are small; precision loss impossible in practice
}
case 12 /* GgufType.FLOAT64 */:
return b.readDoubleLE(0);
default:
throw new Error(`Not a numeric GGUF type: ${type}`);
}
}
async string(maxLength) {
const length = await this.u64();
if (length > maxLength) {
throw new Error(`String length ${length} at offset ${this.#pos - 8} exceeds limit ${maxLength}`);
}
const b = await this.#ensure(length);
this.#pos += length;
return b.toString('utf8');
}
skip(n) {
this.#pos += n;
}
async skipString() {
this.skip(await this.u64());
}
/** Skip a metadata value of the given type without materializing it. */
async skipValue(type) {
const fixed = FIXED_SIZES[type];
if (fixed !== undefined) {
this.skip(fixed);
return;
}
if (type === 8 /* GgufType.STRING */) {
await this.skipString();
return;
}
if (type === 9 /* GgufType.ARRAY */) {
const elemType = (await this.u32());
const count = await this.u64();
const elemFixed = FIXED_SIZES[elemType];
if (elemFixed !== undefined) {
this.skip(elemFixed * count);
return;
}
if (elemType === 8 /* GgufType.STRING */) {
// Tokenizer vocabularies land here — one length-prefixed skip per
// element, buffered reads keep it cheap.
for (let i = 0; i < count; i++)
await this.skipString();
return;
}
throw new Error(`Unsupported GGUF array element type ${elemType}`);
}
throw new Error(`Unsupported GGUF value type ${type}`);
}
}
_a = GgufReader;
/**
* Read `general.architecture` and the model's `<arch>.pooling_type` from a
* GGUF file's metadata. Matches the pooling key by `.pooling_type` suffix so
* key order relative to `general.architecture` doesn't matter. Returns what
* it found; both fields are optional — absence is data, not an error.
*/
export async function readGgufPooling(modelPath) {
const handle = await open(modelPath, 'r');
try {
const reader = new GgufReader(handle);
const magic = await reader.u32();
if (magic !== GGUF_MAGIC) {
throw new Error(`Not a GGUF file (bad magic 0x${magic.toString(16)}): ${modelPath}`);
}
const version = await reader.u32();
if (version < 2 || version > 3) {
throw new Error(`Unsupported GGUF version ${version} in ${modelPath} (supported: 2, 3)`);
}
await reader.u64(); // tensor count — not needed
const kvCount = await reader.u64();
if (kvCount > MAX_KV_COUNT) {
throw new Error(`Implausible GGUF metadata count ${kvCount} in ${modelPath}`);
}
const info = {};
for (let i = 0; i < kvCount; i++) {
const key = await reader.string(MAX_KEY_LENGTH);
const type = (await reader.u32());
if (key === 'general.architecture' && type === 8 /* GgufType.STRING */) {
info.architecture = await reader.string(MAX_KEY_LENGTH);
}
else if (key.endsWith('.pooling_type') && FIXED_SIZES[type] !== undefined) {
info.poolingType = await reader.numeric(type);
}
else {
await reader.skipValue(type);
}
if (info.architecture !== undefined && info.poolingType !== undefined)
break;
}
return info;
}
finally {
await handle.close();
}
}
/**
* Verify a model file declares the pooling the caller expects. The addon
* offers no pooling override, so a declaration that isn't in the file cannot
* be satisfied — fail at init (registration time) instead of silently
* producing wrong-pooling vectors.
*/
export async function assertDeclaredPooling(modelPath, declared) {
const expected = POOLING_TYPES[declared];
const { architecture, poolingType } = await readGgufPooling(modelPath);
const arch = architecture ?? '<arch>';
if (poolingType === undefined) {
throw new Error(`Model ${modelPath} declares no ${arch}.pooling_type metadata, so llama.cpp will use an ` +
`arch-dependent default that cannot be overridden through the addon and may not be '${declared}'. ` +
`Use a GGUF conversion that writes pooling metadata, or remove the 'pooling' option to accept the default.`);
}
if (poolingType !== expected) {
throw new Error(`Model ${modelPath} declares pooling '${poolingName(poolingType)}' (${arch}.pooling_type=${poolingType}) ` +
`but the config expects '${declared}'. The addon cannot override model pooling — fix the config or the model file.`);
}
console.log(`[harper-fabric-embeddings] pooling verified: '${declared}' (${arch}.pooling_type=${poolingType}) for ${modelPath}`);
}
//# sourceMappingURL=gguf.js.map