From ef5ff895c692c446859cf8183c37e2205a29b303 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 16:18:39 +0000 Subject: [PATCH 1/3] fix(kb): resolve the embedding width from the model instead of assuming it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SEC_EMBEDDING_DIMENSIONS` was a compile-time `768` used both to create the vector column and to check it, so `stored.dimensions === dimensions` was `768 === 768` on every path. The model id, meanwhile, is free-form env input. A genuinely narrower model therefore opened the knowledge base without complaint and failed on the first chunk, from inside `@workglow/knowledge-base`, with a message naming neither the variable nor the model — after the weights had downloaded and the run had started. `secEmbeddingDimensions()` resolves it: an explicit `SEC_EMBEDDING_DIMENSIONS` first, then a table of ten models whose widths this project records, keyed by the bare model name so the runtime prefix and quantization suffix do not each need an entry. A model with neither refuses at open, naming the variable, the model, and both ways forward — the same shape as the model-id mismatch error beside it, and the same principle that file already states: a mismatch must not be discovered partway through a run. That goes one step past refusing. Because the column is created at the resolved width, a recorded narrower model now works end to end rather than being turned away: `all-MiniLM-L6-v2` resolves to 384 and indexes at 384. And the width clause in the `kb_index` guard is live for the first time, since the configured width can finally differ from the stored one. A malformed override throws rather than falling back to the table — creating the column at a width the model does not produce is the corruption the guard exists to prevent. Deriving the width by asking the embedder stays the better destination and is left on the issue; it needs the model loaded or a maintained table either way, and this buys the whole diagnostic benefit without one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LWp6Z6wvAPDaDCjAFcTSj6 --- .claude/CLAUDE.md | 3 +- CHANGELOG.md | 7 +++ src/config/embeddingWidth.test.ts | 77 +++++++++++++++++++++++++++ src/config/models.ts | 86 ++++++++++++++++++++++++++++--- src/config/registerModels.ts | 4 +- src/kb/secKnowledgeBase.test.ts | 40 +++++++++++++- src/kb/secKnowledgeBase.ts | 10 ++-- 7 files changed, 211 insertions(+), 16 deletions(-) create mode 100644 src/config/embeddingWidth.test.ts diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 6d4260a4..d3df3584 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -160,7 +160,8 @@ first few. | `SEC_FETCH_MAX_CONCURRENT` | Requests **in flight**, per process (default 4, 1–64) | | `SEC_FETCH_TIMEOUT_MS` | Per-attempt timeout — time *without progress*, not elapsed | | `SEC_MODEL` | Generation model for `ask`; unset resolves by which API key is present | -| `SEC_EMBEDDING_MODEL` | Embedding model — changing it invalidates the index | +| `SEC_EMBEDDING_MODEL` | Embedding model — changing it invalidates the index. Its output width must be one this project records, or `SEC_EMBEDDING_DIMENSIONS` must state it | +| `SEC_EMBEDDING_DIMENSIONS` | The model's output width, for a model whose width is not recorded. The vector column is created at it | | `SEC_ONNX_DEVICE` | `cpu` (default) or `webgpu` where there is an adapter | | `SEC_FIXTURES_DIR`, `SEC_S1_MOCK_DIR` | Fixture roots | diff --git a/CHANGELOG.md b/CHANGELOG.md index 20a9e183..adcc218d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,13 @@ they care about is running `embarc-data`, which is unaffected. - `typecheck` covers src, scripts and tests in one pass; `typecheck-tests` and `tsconfig.test.json` are gone. CI runs it before the build. - `sec fetch golden-fixtures` is now `bun run check-fixtures`. +- **The embedding width is resolved from the model, not assumed.** It was a + literal `768` used both to create the vector column and to check it, so the + guard was `768 === 768` on every path; a genuinely narrower model opened the + knowledge base without complaint and failed on the first chunk with a library + message naming neither the variable nor the model. Ten models' widths are + recorded, `SEC_EMBEDDING_DIMENSIONS` states one that is not, and an unknown + width refuses at open — before any table is created. ## 0.1.5 diff --git a/src/config/embeddingWidth.test.ts b/src/config/embeddingWidth.test.ts new file mode 100644 index 00000000..042f0791 --- /dev/null +++ b/src/config/embeddingWidth.test.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { secEmbeddingDimensions } from "./models"; + +/** + * The width guard used to compare a compile-time `768` against itself. The + * model id is free-form env input and the width was a literal, so the clause + * `stored.dimensions === dimensions` was `768 === 768` on every path — and a + * genuinely narrower model opened the base without complaint, then failed on + * the first chunk from inside `@workglow/knowledge-base` with a message naming + * neither the variable nor the model. + */ +describe("secEmbeddingDimensions", () => { + const saved = { + model: process.env.SEC_EMBEDDING_MODEL, + dims: process.env.SEC_EMBEDDING_DIMENSIONS, + }; + + beforeEach(() => { + delete process.env.SEC_EMBEDDING_MODEL; + delete process.env.SEC_EMBEDDING_DIMENSIONS; + }); + + afterEach(() => { + if (saved.model === undefined) delete process.env.SEC_EMBEDDING_MODEL; + else process.env.SEC_EMBEDDING_MODEL = saved.model; + if (saved.dims === undefined) delete process.env.SEC_EMBEDDING_DIMENSIONS; + else process.env.SEC_EMBEDDING_DIMENSIONS = saved.dims; + }); + + it("knows the default model's width", () => { + expect(secEmbeddingDimensions()).toBe(768); + }); + + it("knows the width of the other models it has verified", () => { + process.env.SEC_EMBEDDING_MODEL = "onnx:Xenova/all-MiniLM-L6-v2:q8"; + expect(secEmbeddingDimensions()).toBe(384); + }); + + it("refuses a model whose width it does not know, naming the way forward", () => { + // Refusing at open is the whole point: the alternative is discovering it + // mid-`sec index`, after the weights have been downloaded. + process.env.SEC_EMBEDDING_MODEL = "onnx:some-org/some-unlisted-model:q8"; + + expect(() => secEmbeddingDimensions()).toThrow(/SEC_EMBEDDING_MODEL/); + expect(() => secEmbeddingDimensions()).toThrow(/some-unlisted-model/); + expect(() => secEmbeddingDimensions()).toThrow(/SEC_EMBEDDING_DIMENSIONS/); + }); + + it("takes an explicit width for a model it does not know", () => { + // The escape hatch that keeps the refusal from being a dead end. Stating + // the width is also what makes the stored-vs-configured check live. + process.env.SEC_EMBEDDING_MODEL = "onnx:some-org/some-unlisted-model:q8"; + process.env.SEC_EMBEDDING_DIMENSIONS = "1024"; + expect(secEmbeddingDimensions()).toBe(1024); + }); + + it("lets an explicit width override a known one", () => { + process.env.SEC_EMBEDDING_MODEL = "onnx:Xenova/bge-base-en-v1.5:q8"; + process.env.SEC_EMBEDDING_DIMENSIONS = "512"; + expect(secEmbeddingDimensions()).toBe(512); + }); + + it.each(["0", "-1", "12.5", "many", ""])("refuses %o as a width", (bad) => { + // A malformed override must not silently fall back to the table: creating + // the column at the wrong width is the corruption this whole guard exists + // to avoid. + process.env.SEC_EMBEDDING_MODEL = "onnx:some-org/some-unlisted-model:q8"; + process.env.SEC_EMBEDDING_DIMENSIONS = bad; + expect(() => secEmbeddingDimensions()).toThrow(/SEC_EMBEDDING_DIMENSIONS/); + }); +}); diff --git a/src/config/models.ts b/src/config/models.ts index a5d04291..164e5259 100644 --- a/src/config/models.ts +++ b/src/config/models.ts @@ -25,22 +25,92 @@ import { SecCliConfigurationError } from "./EnvToDI"; const DEFAULT_EMBEDDING_MODEL = "onnx:Xenova/bge-base-en-v1.5:q8"; /** - * The embedding model's output width. + * Output widths for the embedding models this project has verified, keyed by + * the bare model name — `onnx:Xenova/bge-base-en-v1.5:q8` is looked up as + * `bge-base-en-v1.5`, so the runtime prefix and the quantization suffix do not + * each need their own entry. * - * A schema fact, not a preference: the vector column is created at this width - * and every stored vector has it. Changing the model without re-indexing - * produces a store whose vectors mean nothing to the query, so the index - * records the model and width it was built with (`kb_index`) and - * `getSecKnowledgeBase()` refuses to open it under a different one rather than - * returning plausible nonsense. + * The width is a property of the model, and the model id is free-form env + * input. A constant cannot stand in for it: the vector column is created at + * whatever width is resolved here and every stored vector has it, so a value + * that does not match the configured model builds a store the query cannot + * read. */ -export const SEC_EMBEDDING_DIMENSIONS = 768; +const KNOWN_EMBEDDING_WIDTHS: Readonly> = { + "bge-base-en-v1.5": 768, + "bge-small-en-v1.5": 384, + "bge-large-en-v1.5": 1024, + "all-MiniLM-L6-v2": 384, + "all-mpnet-base-v2": 768, + "gte-base": 768, + "gte-small": 384, + "e5-base-v2": 768, + "e5-small-v2": 384, + "e5-large-v2": 1024, +}; /** The embedding model id, overridable with `SEC_EMBEDDING_MODEL`. */ export function secEmbeddingModel(): string { return process.env.SEC_EMBEDDING_MODEL?.trim() || DEFAULT_EMBEDDING_MODEL; } +/** + * The bare model name {@link KNOWN_EMBEDDING_WIDTHS} is keyed by: the segment + * after the last `/`, with a trailing `:quantization` dropped. + */ +function bareModelName(modelId: string): string { + const afterOrg = modelId.slice(modelId.lastIndexOf("/") + 1); + const colon = afterOrg.indexOf(":"); + return colon === -1 ? afterOrg : afterOrg.slice(0, colon); +} + +/** + * The configured embedding model's output width. + * + * Resolved rather than assumed, and it refuses rather than guessing. The + * alternative is what this replaced: the width was a literal `768` used both to + * create the column and to check it, so `stored.dimensions === dimensions` was + * `768 === 768` on every path, and a genuinely narrower model opened the + * knowledge base without complaint and failed on the first chunk with a + * `@workglow/knowledge-base` internal message naming neither the variable nor + * the model — after the weights had been downloaded and the run had started. + * + * `SEC_EMBEDDING_DIMENSIONS` is the way forward for a model this table does not + * carry, and stating it is also what makes the stored-vs-configured comparison + * in `kb_index` mean something. + */ +export function secEmbeddingDimensions(): number { + const model = secEmbeddingModel(); + const override = process.env.SEC_EMBEDDING_DIMENSIONS?.trim(); + + if (override !== undefined && override !== "") { + const width = Number(override); + // A malformed override must not fall through to the table: creating the + // column at a width the model does not produce is the corruption the whole + // guard exists to avoid. + if (!Number.isInteger(width) || width <= 0) { + throw new SecCliConfigurationError( + `SEC_EMBEDDING_DIMENSIONS is "${override}", which is not a positive whole number. ` + + `It is the output width of SEC_EMBEDDING_MODEL ("${model}") in dimensions — ` + + `768 for the default model. Unset it to use the width this project has recorded ` + + `for the model.` + ); + } + return width; + } + + const known = KNOWN_EMBEDDING_WIDTHS[bareModelName(model)]; + if (known !== undefined) return known; + + throw new SecCliConfigurationError( + `SEC_EMBEDDING_MODEL is "${model}", and this project has no recorded output width for ` + + `it. The width cannot be guessed: the vector column is created at it, so the wrong ` + + `value builds an index the query cannot read. Either set ` + + `SEC_EMBEDDING_DIMENSIONS to the model's width, or use one of the models whose width ` + + `is recorded: ${Object.keys(KNOWN_EMBEDDING_WIDTHS).join(", ")}.` + ); +} + /** Cloud generation models, in the order a key is looked for. */ const CLOUD_GENERATION: readonly { readonly env: string; readonly model: string }[] = [ { env: "ANTHROPIC_API_KEY", model: "claude-sonnet-5" }, diff --git a/src/config/registerModels.ts b/src/config/registerModels.ts index 2bb6b7f9..26a01179 100644 --- a/src/config/registerModels.ts +++ b/src/config/registerModels.ts @@ -8,7 +8,7 @@ import { isAbsolute, join } from "node:path"; import type { ModelRecord, ServiceRegistry } from "workglow"; import { getGlobalModelRepository, globalServiceRegistry } from "workglow"; import { SecCliConfigurationError } from "./EnvToDI"; -import { SEC_EMBEDDING_DIMENSIONS, secModelIds } from "./models"; +import { secEmbeddingDimensions, secModelIds } from "./models"; /** * Provider discriminators. Mirror the constants the provider packages register @@ -403,7 +403,7 @@ export function hftModelRecord(modelId: string): ModelRecord { // embedding record without it fails AFTER running the model, with the // declared width reported as `undefined`. ...(embedding - ? { native_dimensions: SEC_EMBEDDING_DIMENSIONS, pooling: "mean", normalize: true } + ? { native_dimensions: secEmbeddingDimensions(), pooling: "mean", normalize: true } : {}), }, metadata: {}, diff --git a/src/kb/secKnowledgeBase.test.ts b/src/kb/secKnowledgeBase.test.ts index 746d3201..d0bba1d6 100644 --- a/src/kb/secKnowledgeBase.test.ts +++ b/src/kb/secKnowledgeBase.test.ts @@ -10,7 +10,7 @@ import { resetAllDatabases } from "../config/resetAllDatabases"; import { resetDependencyInjectionsForTesting } from "../config/TestingDI"; import { withSqliteDb } from "../config/testing/withSqliteDb"; import { SEC_DB_TYPE } from "../config/tokens"; -import { SEC_EMBEDDING_DIMENSIONS } from "../config/models"; +import { secEmbeddingDimensions } from "../config/models"; import { getDb } from "../util/db"; import { KB_INDEX_TABLE, SEC_KB_TABLE_NAMES } from "./secKbTables"; import { getSecKnowledgeBase, resetSecKnowledgeBaseForTesting } from "./secKnowledgeBase"; @@ -159,7 +159,7 @@ describe("the SEC knowledge base's chunk search", () => { withSqliteDb("kb_search", []); const unit = (index: number): Float32Array => { - const vector = new Float32Array(SEC_EMBEDDING_DIMENSIONS); + const vector = new Float32Array(secEmbeddingDimensions()); vector[index] = 1; return vector; }; @@ -203,3 +203,39 @@ describe("the SEC knowledge base's chunk search", () => { expect(hits.map((hit) => hit.chunk_id)).toEqual(["east", "north"]); }); }); + +/** + * The width refusal has to land before any DDL. Discovering it afterwards is + * the failure this replaced — the column already exists at a width the model's + * vectors will not have, and the error arrives from inside the library on the + * first chunk. + */ +describe("an embedding model of unknown width", () => { + withSqliteDb("kb_width", []); + + beforeEach(async () => { + await resetSecKnowledgeBaseForTesting(); + process.env.SEC_EMBEDDING_MODEL = "onnx:some-org/some-unlisted-model:q8"; + delete process.env.SEC_EMBEDDING_DIMENSIONS; + }); + + afterEach(async () => { + await resetSecKnowledgeBaseForTesting(); + delete process.env.SEC_EMBEDDING_MODEL; + delete process.env.SEC_EMBEDDING_DIMENSIONS; + }); + + it("refuses before creating a single table", async () => { + await expect(getSecKnowledgeBase()).rejects.toThrow(/SEC_EMBEDDING_MODEL/); + + const rows = getDb() + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'kb_%'") + .all() as { name: string }[]; + expect(rows).toEqual([]); + }); + + it("opens once the width is stated", async () => { + process.env.SEC_EMBEDDING_DIMENSIONS = "384"; + await expect(getSecKnowledgeBase()).resolves.toBeDefined(); + }); +}); diff --git a/src/kb/secKnowledgeBase.ts b/src/kb/secKnowledgeBase.ts index 8ce9aac7..491e1135 100644 --- a/src/kb/secKnowledgeBase.ts +++ b/src/kb/secKnowledgeBase.ts @@ -21,7 +21,7 @@ import { } from "workglow"; import { isDryRun } from "../cli/isDryRun"; import { SecCliConfigurationError } from "../config/EnvToDI"; -import { secEmbeddingModel, SEC_EMBEDDING_DIMENSIONS } from "../config/models"; +import { secEmbeddingDimensions, secEmbeddingModel } from "../config/models"; import { SEC_DB_TYPE } from "../config/tokens"; import { getDb } from "../util/db"; import { KB_CHUNK_TABLE, KB_DOCUMENT_TABLE, KB_INDEX_TABLE } from "./secKbTables"; @@ -130,6 +130,10 @@ export async function getSecKnowledgeBase(): Promise { ); } + // Resolved before any DDL: an unknown model refuses here rather than after + // the column has been created at a width its vectors will not have. + const dimensions = secEmbeddingDimensions(); + const db = getDb(); // Tabular, not vector: the document table holds a filing's metadata and its // node tree. Only the chunks carry embeddings. @@ -145,7 +149,7 @@ export async function getSecKnowledgeBase(): Promise { ChunkVectorStorageSchema, ChunkVectorPrimaryKey, [], - SEC_EMBEDDING_DIMENSIONS + dimensions ); const index = new SqliteTabularStorage(db, KB_INDEX_TABLE, KbIndexSchema, KbIndexPrimaryKeyNames); await documents.setupDatabase(); @@ -155,7 +159,7 @@ export async function getSecKnowledgeBase(): Promise { const model = secEmbeddingModel(); // Before the knowledge base is handed out, so a mismatch cannot be discovered // partway through a run that has already embedded chunks into the old space. - await requireMatchingEmbeddingModel(index, model, SEC_EMBEDDING_DIMENSIONS); + await requireMatchingEmbeddingModel(index, model, dimensions); const kb = new KnowledgeBase(SEC_KB_ID, documents as never, chunks as never, { title: "SEC filings", From a34b558cb794f92e4ee2a90ce554d79e57c12d2b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 17:40:01 +0000 Subject: [PATCH 2/3] Read the embedding width off the model instead of a table kept here MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The width came from a table of ten model names. A table is wrong in the direction that costs: a model it does not carry is refused even though the model itself could always have answered, and an entry that drifts is believed over the model. `resolveEmbeddingWidth` asks the model — its published `config.json`, the same file the runtime loads the architecture from. `hidden_size` IS the width here rather than an approximation of it, because these records are registered with `pooling: "mean"`, so the vector handed back is the mean of the last hidden states and has exactly that many components. The other architectures' spellings are read too. The config is a couple of kilobytes and the answer is remembered per repo under the raw-data folder, so a model that has been used once resolves offline. First use needs the network — and first use downloads the weights over the same connection, so there is no case where this is the request that cannot be made. `SEC_EMBEDDING_DIMENSIONS` still comes first and is now the answer for a model with no config to read — a cloud embedding endpoint has no repo, and inventing a Hub path for one would ask about a model that is not there and read the 404 as a width. A width that cannot be established at all still refuses at open, naming the model and the variable, before any table is created. Resolving it is now asynchronous, which reaches `hftModelRecord` and `secModelRecord`; both were only ever called from `registerModelIds`, which already awaited. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LWp6Z6wvAPDaDCjAFcTSj6 --- .claude/CLAUDE.md | 4 +- .env.test | 3 +- CHANGELOG.md | 9 +- src/config/embeddingWidth.test.ts | 218 ++++++++++++++++++++++++++---- src/config/embeddingWidth.ts | 117 ++++++++++++++++ src/config/models.ts | 75 ++++------ src/config/registerModels.test.ts | 66 ++++----- src/config/registerModels.ts | 14 +- src/kb/secKnowledgeBase.test.ts | 10 +- src/kb/secKnowledgeBase.ts | 2 +- 10 files changed, 388 insertions(+), 130 deletions(-) create mode 100644 src/config/embeddingWidth.ts diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index d3df3584..a4e8d4e8 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -160,8 +160,8 @@ first few. | `SEC_FETCH_MAX_CONCURRENT` | Requests **in flight**, per process (default 4, 1–64) | | `SEC_FETCH_TIMEOUT_MS` | Per-attempt timeout — time *without progress*, not elapsed | | `SEC_MODEL` | Generation model for `ask`; unset resolves by which API key is present | -| `SEC_EMBEDDING_MODEL` | Embedding model — changing it invalidates the index. Its output width must be one this project records, or `SEC_EMBEDDING_DIMENSIONS` must state it | -| `SEC_EMBEDDING_DIMENSIONS` | The model's output width, for a model whose width is not recorded. The vector column is created at it | +| `SEC_EMBEDDING_MODEL` | Embedding model — changing it invalidates the index. Its output width is read from the model's published config | +| `SEC_EMBEDDING_DIMENSIONS` | The model's output width, for a model with no config to read (a cloud endpoint). The vector column is created at it | | `SEC_ONNX_DEVICE` | `cpu` (default) or `webgpu` where there is an adapter | | `SEC_FIXTURES_DIR`, `SEC_S1_MOCK_DIR` | Fixture roots | diff --git a/.env.test b/.env.test index 8b135b20..2d410e38 100644 --- a/.env.test +++ b/.env.test @@ -1,2 +1,3 @@ SEC_DB_FOLDER=./sec-db -SEC_DB_NAME=edgar \ No newline at end of file +SEC_DB_NAME=edgar +SEC_EMBEDDING_DIMENSIONS=768 diff --git a/CHANGELOG.md b/CHANGELOG.md index adcc218d..9573f12b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,9 +71,12 @@ they care about is running `embarc-data`, which is unaffected. literal `768` used both to create the vector column and to check it, so the guard was `768 === 768` on every path; a genuinely narrower model opened the knowledge base without complaint and failed on the first chunk with a library - message naming neither the variable nor the model. Ten models' widths are - recorded, `SEC_EMBEDDING_DIMENSIONS` states one that is not, and an unknown - width refuses at open — before any table is created. + message naming neither the variable nor the model. The width now comes from + the model's own published config — the same file the runtime loads the + architecture from, read once and remembered per repo, so a model nobody + listed here opens and nothing has to be kept in step. A model with no config + to read takes `SEC_EMBEDDING_DIMENSIONS`, and a width that cannot be + established at all refuses at open, before any table is created. ## 0.1.5 diff --git a/src/config/embeddingWidth.test.ts b/src/config/embeddingWidth.test.ts index 042f0791..c1ecf8fb 100644 --- a/src/config/embeddingWidth.test.ts +++ b/src/config/embeddingWidth.test.ts @@ -4,7 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { huggingFaceRepoOf, resolveEmbeddingWidth, widthFromModelConfig } from "./embeddingWidth"; import { secEmbeddingDimensions } from "./models"; /** @@ -14,64 +18,222 @@ import { secEmbeddingDimensions } from "./models"; * genuinely narrower model opened the base without complaint, then failed on * the first chunk from inside `@workglow/knowledge-base` with a message naming * neither the variable nor the model. + * + * The first fix for that was a table of model names kept here. This is the + * second: the model states its own width, so nothing has to be kept in step + * and a model nobody listed still opens. */ +describe("resolving the embedding width", () => { + const saved = { + model: process.env.SEC_EMBEDDING_MODEL, + dims: process.env.SEC_EMBEDDING_DIMENSIONS, + raw: process.env.SEC_RAW_DATA_FOLDER, + }; + + beforeEach(() => { + delete process.env.SEC_EMBEDDING_MODEL; + delete process.env.SEC_EMBEDDING_DIMENSIONS; + // Its own folder per test, so one test's remembered width is not another's + // answer and the cache is observable rather than inferred. + process.env.SEC_RAW_DATA_FOLDER = mkdtempSync(join(tmpdir(), "sec-width-")); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + for (const [key, value] of [ + ["SEC_EMBEDDING_MODEL", saved.model], + ["SEC_EMBEDDING_DIMENSIONS", saved.dims], + ["SEC_RAW_DATA_FOLDER", saved.raw], + ] as const) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); + + /** A Hub that answers with one config, and counts how often it was asked. */ + function stubHub(config: unknown): { calls: string[] } { + const calls: string[] = []; + vi.stubGlobal("fetch", (url: string) => { + calls.push(String(url)); + return Promise.resolve({ ok: true, json: () => Promise.resolve(config) } as Response); + }); + return { calls }; + } + + describe("huggingFaceRepoOf", () => { + it.each([ + ["onnx:Xenova/bge-base-en-v1.5:q8", "Xenova/bge-base-en-v1.5"], + ["onnx:Xenova/bge-base-en-v1.5", "Xenova/bge-base-en-v1.5"], + ["onnx:Xenova/all-MiniLM-L6-v2:fp16", "Xenova/all-MiniLM-L6-v2"], + ])("reads the repo out of %o", (modelId, repo) => { + // The runtime prefix and the quantization tail name neither the model nor + // its width, and both variants of one repo have one width. + expect(huggingFaceRepoOf(modelId)).toBe(repo); + }); + + it.each(["gemini-embedding-001", "text-embedding-3-small", "onnx:", "gguf:model.gguf"])( + "has no repo for %o", + (modelId) => { + // A cloud endpoint publishes no config. Inventing a repo path would ask + // the Hub about a model that is not there and read the 404 as a width. + expect(huggingFaceRepoOf(modelId)).toBeUndefined(); + } + ); + }); + + describe("widthFromModelConfig", () => { + it("reads hidden_size, which is what mean pooling produces", () => { + expect(widthFromModelConfig({ hidden_size: 384, model_type: "bert" })).toBe(384); + }); + + it.each([ + [{ d_model: 1024 }, 1024], + [{ n_embd: 2048 }, 2048], + [{ hidden_dim: 512 }, 512], + ])("reads the other architectures' spellings (%o)", (config, width) => { + expect(widthFromModelConfig(config)).toBe(width); + }); + + it.each([{}, { hidden_size: 0 }, { hidden_size: -1 }, { hidden_size: "768" }, null, "x"])( + "states no width for %o", + (config) => { + expect(widthFromModelConfig(config)).toBeUndefined(); + } + ); + }); + + describe("resolveEmbeddingWidth", () => { + it("asks the model, and takes the answer", async () => { + const hub = stubHub({ hidden_size: 384 }); + + expect(await resolveEmbeddingWidth("onnx:Xenova/all-MiniLM-L6-v2:q8")).toBe(384); + expect(hub.calls).toEqual([ + "https://huggingface.co/Xenova/all-MiniLM-L6-v2/resolve/main/config.json", + ]); + }); + + it("asks once and remembers, so a used model resolves offline", async () => { + const hub = stubHub({ hidden_size: 768 }); + expect(await resolveEmbeddingWidth("onnx:Xenova/bge-base-en-v1.5:q8")).toBe(768); + + // Not a second request, and not a second answer either — the same one, + // now from disk. First use needs the network because first use downloads + // the weights over the same connection. + vi.stubGlobal("fetch", () => Promise.reject(new Error("offline"))); + expect(await resolveEmbeddingWidth("onnx:Xenova/bge-base-en-v1.5:q8")).toBe(768); + expect(hub.calls).toHaveLength(1); + }); + + it("shares one answer across a repo's quantizations", async () => { + stubHub({ hidden_size: 768 }); + await resolveEmbeddingWidth("onnx:Xenova/bge-base-en-v1.5:q8"); + + const cache = JSON.parse( + readFileSync( + join(process.env.SEC_RAW_DATA_FOLDER!, "model-cache", "embedding-widths.json"), + "utf8" + ) + ) as Record; + expect(cache).toEqual({ "Xenova/bge-base-en-v1.5": 768 }); + }); + + it.each([ + ["an unreachable Hub", () => vi.stubGlobal("fetch", () => Promise.reject(new Error("no")))], + [ + "a repo that is not there", + () => vi.stubGlobal("fetch", () => Promise.resolve({ ok: false } as Response)), + ], + ["a config that states no width", () => stubHub({ model_type: "bert" })], + ])("does not know the width from %s", async (_label, arrange) => { + arrange(); + expect(await resolveEmbeddingWidth("onnx:some-org/some-model:q8")).toBeUndefined(); + }); + }); +}); + describe("secEmbeddingDimensions", () => { const saved = { model: process.env.SEC_EMBEDDING_MODEL, dims: process.env.SEC_EMBEDDING_DIMENSIONS, + raw: process.env.SEC_RAW_DATA_FOLDER, }; beforeEach(() => { delete process.env.SEC_EMBEDDING_MODEL; delete process.env.SEC_EMBEDDING_DIMENSIONS; + process.env.SEC_RAW_DATA_FOLDER = mkdtempSync(join(tmpdir(), "sec-width-")); }); afterEach(() => { - if (saved.model === undefined) delete process.env.SEC_EMBEDDING_MODEL; - else process.env.SEC_EMBEDDING_MODEL = saved.model; - if (saved.dims === undefined) delete process.env.SEC_EMBEDDING_DIMENSIONS; - else process.env.SEC_EMBEDDING_DIMENSIONS = saved.dims; + vi.unstubAllGlobals(); + for (const [key, value] of [ + ["SEC_EMBEDDING_MODEL", saved.model], + ["SEC_EMBEDDING_DIMENSIONS", saved.dims], + ["SEC_RAW_DATA_FOLDER", saved.raw], + ] as const) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } }); - it("knows the default model's width", () => { - expect(secEmbeddingDimensions()).toBe(768); + it("gets the default model's width from the model", async () => { + vi.stubGlobal("fetch", () => + Promise.resolve({ ok: true, json: () => Promise.resolve({ hidden_size: 768 }) } as Response) + ); + expect(await secEmbeddingDimensions()).toBe(768); }); - it("knows the width of the other models it has verified", () => { - process.env.SEC_EMBEDDING_MODEL = "onnx:Xenova/all-MiniLM-L6-v2:q8"; - expect(secEmbeddingDimensions()).toBe(384); + it("opens a model no table here lists", async () => { + // The table this replaced refused one, and refusing was the entire cost: + // the model had the answer the whole time. + vi.stubGlobal("fetch", () => + Promise.resolve({ ok: true, json: () => Promise.resolve({ hidden_size: 1024 }) } as Response) + ); + process.env.SEC_EMBEDDING_MODEL = "onnx:some-org/some-unlisted-model:q8"; + expect(await secEmbeddingDimensions()).toBe(1024); }); - it("refuses a model whose width it does not know, naming the way forward", () => { - // Refusing at open is the whole point: the alternative is discovering it + it("refuses when the model cannot be asked, naming the way forward", async () => { + // Refusing at open is the point: the alternative is discovering it // mid-`sec index`, after the weights have been downloaded. + vi.stubGlobal("fetch", () => Promise.reject(new Error("offline"))); process.env.SEC_EMBEDDING_MODEL = "onnx:some-org/some-unlisted-model:q8"; - expect(() => secEmbeddingDimensions()).toThrow(/SEC_EMBEDDING_MODEL/); - expect(() => secEmbeddingDimensions()).toThrow(/some-unlisted-model/); - expect(() => secEmbeddingDimensions()).toThrow(/SEC_EMBEDDING_DIMENSIONS/); + await expect(secEmbeddingDimensions()).rejects.toThrow(/SEC_EMBEDDING_MODEL/); + await expect(secEmbeddingDimensions()).rejects.toThrow(/some-unlisted-model/); + await expect(secEmbeddingDimensions()).rejects.toThrow(/SEC_EMBEDDING_DIMENSIONS/); }); - it("takes an explicit width for a model it does not know", () => { - // The escape hatch that keeps the refusal from being a dead end. Stating - // the width is also what makes the stored-vs-configured check live. - process.env.SEC_EMBEDDING_MODEL = "onnx:some-org/some-unlisted-model:q8"; - process.env.SEC_EMBEDDING_DIMENSIONS = "1024"; - expect(secEmbeddingDimensions()).toBe(1024); + it("takes an explicit width without asking anything", async () => { + // The escape hatch for a model with no config to read — a cloud endpoint, + // or an air-gapped run of one that has never been used here. + const calls: string[] = []; + vi.stubGlobal("fetch", (url: string) => { + calls.push(String(url)); + return Promise.reject(new Error("should not be called")); + }); + process.env.SEC_EMBEDDING_MODEL = "gemini-embedding-001"; + process.env.SEC_EMBEDDING_DIMENSIONS = "3072"; + + expect(await secEmbeddingDimensions()).toBe(3072); + expect(calls).toEqual([]); }); - it("lets an explicit width override a known one", () => { + it("lets an explicit width override what the model says", async () => { + vi.stubGlobal("fetch", () => + Promise.resolve({ ok: true, json: () => Promise.resolve({ hidden_size: 768 }) } as Response) + ); process.env.SEC_EMBEDDING_MODEL = "onnx:Xenova/bge-base-en-v1.5:q8"; process.env.SEC_EMBEDDING_DIMENSIONS = "512"; - expect(secEmbeddingDimensions()).toBe(512); + expect(await secEmbeddingDimensions()).toBe(512); }); - it.each(["0", "-1", "12.5", "many", ""])("refuses %o as a width", (bad) => { - // A malformed override must not silently fall back to the table: creating - // the column at the wrong width is the corruption this whole guard exists - // to avoid. + it.each(["0", "-1", "12.5", "many"])("refuses %o as a width", async (bad) => { + // A malformed override must not silently fall back to the derivation: + // creating the column at the wrong width is the corruption this whole guard + // exists to avoid. process.env.SEC_EMBEDDING_MODEL = "onnx:some-org/some-unlisted-model:q8"; process.env.SEC_EMBEDDING_DIMENSIONS = bad; - expect(() => secEmbeddingDimensions()).toThrow(/SEC_EMBEDDING_DIMENSIONS/); + await expect(secEmbeddingDimensions()).rejects.toThrow(/SEC_EMBEDDING_DIMENSIONS/); }); }); diff --git a/src/config/embeddingWidth.ts b/src/config/embeddingWidth.ts new file mode 100644 index 00000000..6a156a7d --- /dev/null +++ b/src/config/embeddingWidth.ts @@ -0,0 +1,117 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +/** + * The fields a model's own `config.json` states its hidden width in. + * + * BERT-family encoders — every model this CLI embeds with — write + * `hidden_size`. The rest are the spellings the other architectures on the Hub + * use, listed in the order a config that carries more than one should be read. + */ +const WIDTH_FIELDS = ["hidden_size", "d_model", "n_embd", "hidden_dim", "dim"] as const; + +/** Where the derived widths are remembered, under the raw-data folder. */ +function widthCachePath(): string { + const root = process.env.SEC_RAW_DATA_FOLDER?.trim() || "."; + return join(root, "model-cache", "embedding-widths.json"); +} + +function readWidthCache(): Record { + try { + const parsed: unknown = JSON.parse(readFileSync(widthCachePath(), "utf8")); + return typeof parsed === "object" && parsed !== null ? (parsed as Record) : {}; + } catch { + // Absent or unreadable is the same answer: nothing is remembered. + return {}; + } +} + +function rememberWidth(repo: string, width: number): void { + try { + const path = widthCachePath(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify({ ...readWidthCache(), [repo]: width }, null, 2)}\n`); + } catch { + // A cache that cannot be written costs a request next time and nothing + // else, so it must not fail the run that derived the width successfully. + } +} + +/** + * The HuggingFace repo behind a model id, or `undefined` where there is none. + * + * Strips the `onnx:` runtime prefix and the `:q8`-style quantization tail — + * neither names the model, and both variants of one repo have one width. A + * cloud model id (`gemini-embedding-001`) has no `org/name` shape and returns + * `undefined`: there is no config to read, and inventing a repo path would ask + * the Hub about a model that is not there. + */ +export function huggingFaceRepoOf(modelId: string): string | undefined { + const withoutPrefix = modelId.includes(":") ? modelId.slice(modelId.indexOf(":") + 1) : modelId; + const bare = withoutPrefix.replace(/:(?:q\d+f?\d*|fp\d+|int\d+)$/i, ""); + return /^[^/\s:]+\/[^/\s:]+$/.test(bare) ? bare : undefined; +} + +/** The width a parsed `config.json` states, or `undefined` if it states none. */ +export function widthFromModelConfig(config: unknown): number | undefined { + if (typeof config !== "object" || config === null) return undefined; + const record = config as Record; + for (const field of WIDTH_FIELDS) { + const value = record[field]; + if (typeof value === "number" && Number.isInteger(value) && value > 0) return value; + } + return undefined; +} + +/** + * The model's output width, read off the model. + * + * A hand-maintained table of model names was the alternative, and it is wrong + * in the direction that costs: a model it does not carry is refused even though + * the model itself has always been able to answer, and an entry that drifts is + * believed. So this asks the model — its published `config.json`, which is the + * same file the runtime loads the architecture from. + * + * `hidden_size` IS the width here rather than an approximation of it: these + * records are registered with `pooling: "mean"`, so the vector handed back is + * the mean of the last hidden states and has exactly that many components. + * + * The config is a couple of kilobytes and the answer is remembered per repo, so + * a model that has been used once resolves offline. First use needs the network + * — but first use downloads the weights over the same connection, so there is + * no case where this is the request that cannot be made. + * + * Returns `undefined` rather than throwing for every way of not knowing, so the + * caller can say which variable to set; the distinctions between "no repo", + * "unreachable" and "no width field" do not change that answer. + */ +export async function resolveEmbeddingWidth(modelId: string): Promise { + const repo = huggingFaceRepoOf(modelId); + if (repo === undefined) return undefined; + + const remembered = readWidthCache()[repo]; + if (typeof remembered === "number" && Number.isInteger(remembered) && remembered > 0) { + return remembered; + } + + let config: unknown; + try { + const response = await fetch(`https://huggingface.co/${repo}/resolve/main/config.json`, { + headers: { accept: "application/json" }, + }); + if (!response.ok) return undefined; + config = await response.json(); + } catch { + return undefined; + } + + const width = widthFromModelConfig(config); + if (width !== undefined) rememberWidth(repo, width); + return width; +} diff --git a/src/config/models.ts b/src/config/models.ts index 164e5259..5bccc8ae 100644 --- a/src/config/models.ts +++ b/src/config/models.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { resolveEmbeddingWidth } from "./embeddingWidth"; import { SecCliConfigurationError } from "./EnvToDI"; /** @@ -24,48 +25,13 @@ import { SecCliConfigurationError } from "./EnvToDI"; */ const DEFAULT_EMBEDDING_MODEL = "onnx:Xenova/bge-base-en-v1.5:q8"; -/** - * Output widths for the embedding models this project has verified, keyed by - * the bare model name — `onnx:Xenova/bge-base-en-v1.5:q8` is looked up as - * `bge-base-en-v1.5`, so the runtime prefix and the quantization suffix do not - * each need their own entry. - * - * The width is a property of the model, and the model id is free-form env - * input. A constant cannot stand in for it: the vector column is created at - * whatever width is resolved here and every stored vector has it, so a value - * that does not match the configured model builds a store the query cannot - * read. - */ -const KNOWN_EMBEDDING_WIDTHS: Readonly> = { - "bge-base-en-v1.5": 768, - "bge-small-en-v1.5": 384, - "bge-large-en-v1.5": 1024, - "all-MiniLM-L6-v2": 384, - "all-mpnet-base-v2": 768, - "gte-base": 768, - "gte-small": 384, - "e5-base-v2": 768, - "e5-small-v2": 384, - "e5-large-v2": 1024, -}; - /** The embedding model id, overridable with `SEC_EMBEDDING_MODEL`. */ export function secEmbeddingModel(): string { return process.env.SEC_EMBEDDING_MODEL?.trim() || DEFAULT_EMBEDDING_MODEL; } /** - * The bare model name {@link KNOWN_EMBEDDING_WIDTHS} is keyed by: the segment - * after the last `/`, with a trailing `:quantization` dropped. - */ -function bareModelName(modelId: string): string { - const afterOrg = modelId.slice(modelId.lastIndexOf("/") + 1); - const colon = afterOrg.indexOf(":"); - return colon === -1 ? afterOrg : afterOrg.slice(0, colon); -} - -/** - * The configured embedding model's output width. + * The configured embedding model's output width, asked of the model. * * Resolved rather than assumed, and it refuses rather than guessing. The * alternative is what this replaced: the width was a literal `768` used both to @@ -75,39 +41,44 @@ function bareModelName(modelId: string): string { * `@workglow/knowledge-base` internal message naming neither the variable nor * the model — after the weights had been downloaded and the run had started. * - * `SEC_EMBEDDING_DIMENSIONS` is the way forward for a model this table does not - * carry, and stating it is also what makes the stored-vs-configured comparison - * in `kb_index` mean something. + * The width comes from the model's own published config rather than from a + * table kept here. A table is wrong in the direction that costs: a model it + * does not carry is refused even though the model itself could always have + * answered, and an entry that drifts is believed over the model. + * + * `SEC_EMBEDDING_DIMENSIONS` comes first, and is the way forward for a model + * with no config to read — a cloud embedding endpoint, or an air-gapped run of + * one that has never been used here. Stating it is also what makes the + * stored-vs-configured comparison in `kb_index` mean something. */ -export function secEmbeddingDimensions(): number { +export async function secEmbeddingDimensions(): Promise { const model = secEmbeddingModel(); const override = process.env.SEC_EMBEDDING_DIMENSIONS?.trim(); if (override !== undefined && override !== "") { const width = Number(override); - // A malformed override must not fall through to the table: creating the - // column at a width the model does not produce is the corruption the whole - // guard exists to avoid. + // A malformed override must not fall through to the derivation: creating + // the column at a width the model does not produce is the corruption the + // whole guard exists to avoid. if (!Number.isInteger(width) || width <= 0) { throw new SecCliConfigurationError( `SEC_EMBEDDING_DIMENSIONS is "${override}", which is not a positive whole number. ` + `It is the output width of SEC_EMBEDDING_MODEL ("${model}") in dimensions — ` + - `768 for the default model. Unset it to use the width this project has recorded ` + - `for the model.` + `768 for the default model. Unset it to read the width off the model itself.` ); } return width; } - const known = KNOWN_EMBEDDING_WIDTHS[bareModelName(model)]; - if (known !== undefined) return known; + const derived = await resolveEmbeddingWidth(model); + if (derived !== undefined) return derived; throw new SecCliConfigurationError( - `SEC_EMBEDDING_MODEL is "${model}", and this project has no recorded output width for ` + - `it. The width cannot be guessed: the vector column is created at it, so the wrong ` + - `value builds an index the query cannot read. Either set ` + - `SEC_EMBEDDING_DIMENSIONS to the model's width, or use one of the models whose width ` + - `is recorded: ${Object.keys(KNOWN_EMBEDDING_WIDTHS).join(", ")}.` + `SEC_EMBEDDING_MODEL is "${model}", and its output width could not be read from the ` + + `model — it publishes no config this can reach, or the config states no hidden size. ` + + `The width cannot be guessed: the vector column is created at it, so the wrong value ` + + `builds an index the query cannot read. Set SEC_EMBEDDING_DIMENSIONS to the model's ` + + `width, or use a HuggingFace model whose config states it.` ); } diff --git a/src/config/registerModels.test.ts b/src/config/registerModels.test.ts index 7c9bf8d0..80342c60 100644 --- a/src/config/registerModels.test.ts +++ b/src/config/registerModels.test.ts @@ -59,8 +59,8 @@ describe("registerSecModels", () => { expect(record.provider_config.model_name).toBe("claude-sonnet-5"); }); - it("builds a routable HFT record", () => { - const record = hftModelRecord("onnx:onnx-community/Qwen2.5-0.5B-Instruct"); + it("builds a routable HFT record", async () => { + const record = await hftModelRecord("onnx:onnx-community/Qwen2.5-0.5B-Instruct"); expect(record.provider).toBe("HF_TRANSFORMERS_ONNX"); expect(record.provider_config.model_path).toBe("onnx-community/Qwen2.5-0.5B-Instruct"); expect(record.capabilities).toContain("json-mode"); @@ -91,22 +91,26 @@ describe("registerSecModels", () => { expect(deepSeekModelRecord("deepseek-v4-flash").capabilities).toContain("text.generation"); }); - it("dispatches secModelRecord by id shape across all providers", () => { - expect(secModelRecord("claude-opus-5").provider).toBe("ANTHROPIC"); - expect(secModelRecord("gpt-5.5").provider).toBe("OPENAI"); - expect(secModelRecord("gpt-5.4-mini").provider).toBe("OPENAI"); - expect(secModelRecord("gemini-3.1-pro-preview").provider).toBe("GOOGLE_GEMINI"); - expect(secModelRecord("grok-4.6").provider).toBe("XAI"); - expect(secModelRecord("deepseek-v4-flash").provider).toBe("DEEPSEEK"); - expect(secModelRecord("deepseek-v4-pro").provider).toBe("DEEPSEEK"); - expect(secModelRecord("onnx:onnx-community/Qwen2.5-0.5B-Instruct").provider).toBe( + it("dispatches secModelRecord by id shape across all providers", async () => { + expect((await secModelRecord("claude-opus-5")).provider).toBe("ANTHROPIC"); + expect((await secModelRecord("gpt-5.5")).provider).toBe("OPENAI"); + expect((await secModelRecord("gpt-5.4-mini")).provider).toBe("OPENAI"); + expect((await secModelRecord("gemini-3.1-pro-preview")).provider).toBe("GOOGLE_GEMINI"); + expect((await secModelRecord("grok-4.6")).provider).toBe("XAI"); + expect((await secModelRecord("deepseek-v4-flash")).provider).toBe("DEEPSEEK"); + expect((await secModelRecord("deepseek-v4-pro")).provider).toBe("DEEPSEEK"); + expect((await secModelRecord("onnx:onnx-community/Qwen2.5-0.5B-Instruct")).provider).toBe( "HF_TRANSFORMERS_ONNX" ); - expect(secModelRecord("gguf:model.gguf").provider).toBe("LOCAL_LLAMACPP"); - expect(secModelRecord("llama:model.gguf").provider).toBe("LOCAL_LLAMACPP"); - expect(secModelRecord("node-llama:model.gguf").provider).toBe("LOCAL_LLAMACPP"); - expect(secModelRecord("hfi:meta-llama/Llama-3.3-70B-Instruct").provider).toBe("HF_INFERENCE"); - expect(secModelRecord("open-router:anthropic/claude-sonnet-4").provider).toBe("OPENROUTER"); + expect((await secModelRecord("gguf:model.gguf")).provider).toBe("LOCAL_LLAMACPP"); + expect((await secModelRecord("llama:model.gguf")).provider).toBe("LOCAL_LLAMACPP"); + expect((await secModelRecord("node-llama:model.gguf")).provider).toBe("LOCAL_LLAMACPP"); + expect((await secModelRecord("hfi:meta-llama/Llama-3.3-70B-Instruct")).provider).toBe( + "HF_INFERENCE" + ); + expect((await secModelRecord("open-router:anthropic/claude-sonnet-4")).provider).toBe( + "OPENROUTER" + ); }); it("pins an optional inference provider from hfi: / open-router: ids onto provider_config", () => { @@ -159,14 +163,14 @@ describe("registerSecModels", () => { }); }); - it("points a bare org/name id at the prefix it now needs", () => { + it("points a bare org/name id at the prefix it now needs", async () => { // `org/name` used to route to the local ONNX provider. Listing every legal // shape leaves the operator to spot that one of them is their own id plus // five characters, which is the single likeliest reason a working // SEC_HFT_MODEL / --models value stopped resolving. let message = ""; try { - secModelRecord("onnx-community/Qwen3-4B-Instruct-2507-ONNX"); + await secModelRecord("onnx-community/Qwen3-4B-Instruct-2507-ONNX"); } catch (e) { message = e instanceof Error ? e.message : String(e); } @@ -177,25 +181,25 @@ describe("registerSecModels", () => { // An id with no slash cannot be a repo id, so it gets no misleading hint. let plain = ""; try { - secModelRecord("sonnet-5"); + await secModelRecord("sonnet-5"); } catch (e) { plain = e instanceof Error ? e.message : String(e); } expect(plain).not.toContain("bare"); }); - it("rejects empty inference-provider or model segments on gated ids", () => { + it("rejects empty inference-provider or model segments on gated ids", async () => { for (const id of [ "hfi:together:", "hfi::meta-llama/Llama-3.3-70B-Instruct", "open-router:Fireworks:", "open-router::deepseek/deepseek-chat", ]) { - expect(() => secModelRecord(id)).toThrow(SecCliConfigurationError); + await expect(secModelRecord(id)).rejects.toBeInstanceOf(SecCliConfigurationError); } }); - it("throws on a model id matching no provider shape instead of defaulting to Anthropic", () => { + it("throws on a model id matching no provider shape instead of defaulting to Anthropic", async () => { // Regression: these used to mint an ANTHROPIC record, so a typo or an // unwired provider only surfaced downstream as a `404 model: ` from the // Anthropic API — the wrong provider's error, well after registration. @@ -208,22 +212,22 @@ describe("registerSecModels", () => { "onnx-community/Qwen2.5-0.5B-Instruct", "", ]) { - expect(() => secModelRecord(id)).toThrow(SecCliConfigurationError); + await expect(secModelRecord(id)).rejects.toBeInstanceOf(SecCliConfigurationError); } - expect(() => secModelRecord("claude--typo")).not.toThrow(); + await expect(secModelRecord("claude--typo")).resolves.toBeDefined(); }); - it("names the offending id and the accepted shapes when it throws", () => { - expect(() => secModelRecord("deepseek-v4-flash".replace("deepseek", "deapseek"))).toThrow( - /deapseek-v4-flash.*deepseek-\*/s - ); + it("names the offending id and the accepted shapes when it throws", async () => { + await expect( + secModelRecord("deepseek-v4-flash".replace("deepseek", "deapseek")) + ).rejects.toThrow(/deapseek-v4-flash.*deepseek-\*/s); }); - it("routes a deepseek-ai HuggingFace repo id via onnx: to the local ONNX provider, not DeepSeek cloud", () => { - expect(secModelRecord("onnx:deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B").provider).toBe( + it("routes a deepseek-ai HuggingFace repo id via onnx: to the local ONNX provider, not DeepSeek cloud", async () => { + expect((await secModelRecord("onnx:deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B")).provider).toBe( "HF_TRANSFORMERS_ONNX" ); - expect(secModelRecord("deepseek-v4-flash").provider).toBe("DEEPSEEK"); + expect((await secModelRecord("deepseek-v4-flash")).provider).toBe("DEEPSEEK"); }); it("is idempotent — a second run does not duplicate or throw", async () => { diff --git a/src/config/registerModels.ts b/src/config/registerModels.ts index 26a01179..7d5fc66d 100644 --- a/src/config/registerModels.ts +++ b/src/config/registerModels.ts @@ -371,7 +371,7 @@ const HFT_CAPABILITIES: readonly string[] = [ * into the worker; `pipeline: "text-generation"` selects the causal-LM pipeline * the structured-generation path drives. */ -export function hftModelRecord(modelId: string): ModelRecord { +export async function hftModelRecord(modelId: string): Promise { const modelPath = modelId.startsWith(ONNX_ID_PREFIX) ? modelId.slice(ONNX_ID_PREFIX.length) : modelId; @@ -403,7 +403,7 @@ export function hftModelRecord(modelId: string): ModelRecord { // embedding record without it fails AFTER running the model, with the // declared width reported as `undefined`. ...(embedding - ? { native_dimensions: secEmbeddingDimensions(), pooling: "mean", normalize: true } + ? { native_dimensions: await secEmbeddingDimensions(), pooling: "mean", normalize: true } : {}), }, metadata: {}, @@ -589,9 +589,9 @@ export function openRouterModelRecord(modelId: string): ModelRecord { * model repository by an operator, a harness, or a test. Such an id is legal and * simply isn't ours to route, so those callers must not treat it as a failure. */ -export function trySecModelRecord(modelId: string): ModelRecord | undefined { +export async function trySecModelRecord(modelId: string): Promise { if (isLlamaCppModelId(modelId)) return llamaCppModelRecord(modelId); - if (isHftModelId(modelId)) return hftModelRecord(modelId); + if (isHftModelId(modelId)) return await hftModelRecord(modelId); if (isHfInferenceModelId(modelId)) return hfInferenceModelRecord(modelId); if (isOpenRouterModelId(modelId)) return openRouterModelRecord(modelId); if (isAnthropicModelId(modelId)) return anthropicModelRecord(modelId); @@ -656,8 +656,8 @@ export const KNOWN_MODEL_ID_SHAPES = "hfi:[provider:]org/name (HuggingFace Inference), " + "open-router:[provider:]vendor/model (OpenRouter)"; -export function secModelRecord(modelId: string): ModelRecord { - const record = trySecModelRecord(modelId); +export async function secModelRecord(modelId: string): Promise { + const record = await trySecModelRecord(modelId); if (record) return record; // A bare `org/name` id needs an explicit provider prefix — without this hint // the message lists every legal shape and leaves the operator to notice that @@ -685,7 +685,7 @@ export async function registerModelIds( const repo = getGlobalModelRepository(registry); for (const modelId of modelIds) { if (await repo.findByName(modelId)) continue; - await repo.addModel(secModelRecord(modelId)); + await repo.addModel(await secModelRecord(modelId)); } } diff --git a/src/kb/secKnowledgeBase.test.ts b/src/kb/secKnowledgeBase.test.ts index d0bba1d6..0f04f0a7 100644 --- a/src/kb/secKnowledgeBase.test.ts +++ b/src/kb/secKnowledgeBase.test.ts @@ -158,8 +158,8 @@ describe("the SEC knowledge base's tables and `db reset`", () => { describe("the SEC knowledge base's chunk search", () => { withSqliteDb("kb_search", []); - const unit = (index: number): Float32Array => { - const vector = new Float32Array(secEmbeddingDimensions()); + const unit = async (index: number): Promise => { + const vector = new Float32Array(await secEmbeddingDimensions()); vector[index] = 1; return vector; }; @@ -177,7 +177,7 @@ describe("the SEC knowledge base's chunk search", () => { await kb.upsertChunk({ chunk_id: "north", doc_id: "doc-1", - vector: unit(0), + vector: await unit(0), metadata: { chunkId: "north", doc_id: "doc-1", @@ -189,7 +189,7 @@ describe("the SEC knowledge base's chunk search", () => { await kb.upsertChunk({ chunk_id: "east", doc_id: "doc-1", - vector: unit(1), + vector: await unit(1), metadata: { chunkId: "east", doc_id: "doc-1", @@ -199,7 +199,7 @@ describe("the SEC knowledge base's chunk search", () => { }, }); - const hits = await kb.similaritySearch(unit(1), { topK: 2 }); + const hits = await kb.similaritySearch(await unit(1), { topK: 2 }); expect(hits.map((hit) => hit.chunk_id)).toEqual(["east", "north"]); }); }); diff --git a/src/kb/secKnowledgeBase.ts b/src/kb/secKnowledgeBase.ts index 491e1135..6fdda022 100644 --- a/src/kb/secKnowledgeBase.ts +++ b/src/kb/secKnowledgeBase.ts @@ -132,7 +132,7 @@ export async function getSecKnowledgeBase(): Promise { // Resolved before any DDL: an unknown model refuses here rather than after // the column has been created at a width its vectors will not have. - const dimensions = secEmbeddingDimensions(); + const dimensions = await secEmbeddingDimensions(); const db = getDb(); // Tabular, not vector: the document table holds a filing's metadata and its From 1f03d3a0dff23ea0e8f184db99cb92872dfb9508 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 19:30:10 +0000 Subject: [PATCH 3/3] Declare the embedding width beside the model instead of fetching it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading the width from the model's published `config.json` worked, but it bought a network call, a cache file under the raw-data folder, and an async hop through `hftModelRecord` / `secModelRecord` / the knowledge base — to discover a number that is a property of a model this CLI pins. `bge-base-en-v1.5` is a BERT-base encoder with a hidden size of 768 and the record registers it with `pooling: "mean"`, so 768 is what it produces. That belongs next to the model id, where a reader who changes one sees the other on the next line. The whole `embeddingWidth` module and its cache go, and every signature it made asynchronous goes back. What survives is the guard, which was always the point: the width is still resolved before any DDL, and a model this CLI does not pin is refused at open, naming the model and `SEC_EMBEDDING_DIMENSIONS`. The trade is that such a model no longer resolves by itself — the operator states its width. That is also the only answer for a cloud embedding endpoint, which has no local weights to inspect, and `.env.test` no longer has to pin a width to keep the suite off the network. Two tests in the model-record suite were reaching the width guard rather than their own. One of them PASSED doing it: it asserted the message names both models and `SEC_EMBEDDING_MODEL`, and the width refusal beside it names all three too. Both now state the width they need to reach the model-id check, and the first also asserts "not comparable" — wording only that guard uses — so it can tell its own guard from its neighbour. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LWp6Z6wvAPDaDCjAFcTSj6 --- .claude/CLAUDE.md | 4 +- .env.test | 1 - CHANGELOG.md | 13 +- src/config/embeddingDimensions.test.ts | 84 +++++++++ src/config/embeddingWidth.test.ts | 239 ------------------------- src/config/embeddingWidth.ts | 117 ------------ src/config/models.ts | 69 ++++--- src/config/registerModels.test.ts | 52 +++--- src/config/registerModels.ts | 14 +- src/kb/secKnowledgeBase.test.ts | 29 +-- src/kb/secKnowledgeBase.ts | 2 +- 11 files changed, 181 insertions(+), 443 deletions(-) create mode 100644 src/config/embeddingDimensions.test.ts delete mode 100644 src/config/embeddingWidth.test.ts delete mode 100644 src/config/embeddingWidth.ts diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index a4e8d4e8..b0fb3d2a 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -160,8 +160,8 @@ first few. | `SEC_FETCH_MAX_CONCURRENT` | Requests **in flight**, per process (default 4, 1–64) | | `SEC_FETCH_TIMEOUT_MS` | Per-attempt timeout — time *without progress*, not elapsed | | `SEC_MODEL` | Generation model for `ask`; unset resolves by which API key is present | -| `SEC_EMBEDDING_MODEL` | Embedding model — changing it invalidates the index. Its output width is read from the model's published config | -| `SEC_EMBEDDING_DIMENSIONS` | The model's output width, for a model with no config to read (a cloud endpoint). The vector column is created at it | +| `SEC_EMBEDDING_MODEL` | Embedding model — changing it invalidates the index. Only the pinned default's width is known here; any other model needs `SEC_EMBEDDING_DIMENSIONS` | +| `SEC_EMBEDDING_DIMENSIONS` | The model's output width, required for any model but the pinned default. The vector column is created at it | | `SEC_ONNX_DEVICE` | `cpu` (default) or `webgpu` where there is an adapter | | `SEC_FIXTURES_DIR`, `SEC_S1_MOCK_DIR` | Fixture roots | diff --git a/.env.test b/.env.test index 2d410e38..9b342c44 100644 --- a/.env.test +++ b/.env.test @@ -1,3 +1,2 @@ SEC_DB_FOLDER=./sec-db SEC_DB_NAME=edgar -SEC_EMBEDDING_DIMENSIONS=768 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ddbb957..2772ef5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,16 +86,15 @@ they care about is running `embarc-data`, which is unaffected. `code: "ERR_SQLITE_ERROR"`, so the `"SQLITE_CONSTRAINT_UNIQUE"` string — a `better-sqlite3` spelling — never matched, leaving SQLite with the error message as its only signal. -- **The embedding width is resolved from the model, not assumed.** It was a +- **The embedding width is declared beside the model it belongs to.** It was a literal `768` used both to create the vector column and to check it, so the guard was `768 === 768` on every path; a genuinely narrower model opened the knowledge base without complaint and failed on the first chunk with a library - message naming neither the variable nor the model. The width now comes from - the model's own published config — the same file the runtime loads the - architecture from, read once and remembered per repo, so a model nobody - listed here opens and nothing has to be kept in step. A model with no config - to read takes `SEC_EMBEDDING_DIMENSIONS`, and a width that cannot be - established at all refuses at open, before any table is created. + message naming neither the variable nor the model. This CLI pins one + embedding model and states its width next to it, so the two cannot disagree. + Point `SEC_EMBEDDING_MODEL` at anything else — including a cloud endpoint + with no local weights — and `SEC_EMBEDDING_DIMENSIONS` becomes required; a + width that is not stated refuses at open, before any table is created. - **`--dry-run` no longer creates the knowledge-base tables.** Those three are built lazily against the `getDb()` connection rather than through `createStorage`, so no `ReadOnlyTabularStorage` wrapper stood between a dry diff --git a/src/config/embeddingDimensions.test.ts b/src/config/embeddingDimensions.test.ts new file mode 100644 index 00000000..6af57a22 --- /dev/null +++ b/src/config/embeddingDimensions.test.ts @@ -0,0 +1,84 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: LicenseRef-Proprietary + */ + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { secEmbeddingDimensions, secEmbeddingModel } from "./models"; + +/** + * The width guard used to compare a compile-time `768` against itself. The + * model id is free-form env input and the width was a literal, so the clause + * `stored.dimensions === dimensions` was `768 === 768` on every path — and a + * genuinely narrower model opened the base without complaint, then failed on + * the first chunk from inside `@workglow/knowledge-base` with a message naming + * neither the variable nor the model. + * + * The width is declared beside the model it belongs to now, so the two cannot + * disagree; the guard is what happens when the model is not that one. + */ +describe("secEmbeddingDimensions", () => { + const saved = { + model: process.env.SEC_EMBEDDING_MODEL, + dims: process.env.SEC_EMBEDDING_DIMENSIONS, + }; + + beforeEach(() => { + delete process.env.SEC_EMBEDDING_MODEL; + delete process.env.SEC_EMBEDDING_DIMENSIONS; + }); + + afterEach(() => { + for (const [key, value] of [ + ["SEC_EMBEDDING_MODEL", saved.model], + ["SEC_EMBEDDING_DIMENSIONS", saved.dims], + ] as const) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); + + it("states the pinned model's width with nothing configured", () => { + expect(secEmbeddingDimensions()).toBe(768); + }); + + it("states it for the pinned model named explicitly, not just by default", () => { + // The two are one constant, so naming the default model in the environment + // must reach the same answer as leaving it unset. + process.env.SEC_EMBEDDING_MODEL = secEmbeddingModel(); + expect(secEmbeddingDimensions()).toBe(768); + }); + + it("refuses another model rather than assuming the pinned model's width", () => { + // The whole guard: 768 is a fact about `bge-base-en-v1.5`, and applying it + // to anything else is the bug this replaced. Refusing at open is what keeps + // it from being discovered mid-`sec index`, after the weights downloaded. + process.env.SEC_EMBEDDING_MODEL = "onnx:Xenova/all-MiniLM-L6-v2:q8"; + + expect(() => secEmbeddingDimensions()).toThrow(/SEC_EMBEDDING_MODEL/); + expect(() => secEmbeddingDimensions()).toThrow(/all-MiniLM-L6-v2/); + expect(() => secEmbeddingDimensions()).toThrow(/SEC_EMBEDDING_DIMENSIONS/); + }); + + it("takes another model once its width is stated", () => { + // The escape hatch that keeps the refusal from being a dead end — and the + // only answer for a cloud endpoint, which has no local weights to inspect. + process.env.SEC_EMBEDDING_MODEL = "onnx:Xenova/all-MiniLM-L6-v2:q8"; + process.env.SEC_EMBEDDING_DIMENSIONS = "384"; + expect(secEmbeddingDimensions()).toBe(384); + }); + + it("lets an explicit width override the pinned model's", () => { + process.env.SEC_EMBEDDING_DIMENSIONS = "512"; + expect(secEmbeddingDimensions()).toBe(512); + }); + + it.each(["0", "-1", "12.5", "many"])("refuses %o as a width", (bad) => { + // A malformed override must not silently fall back to the default: creating + // the column at the wrong width is the corruption this whole guard exists + // to avoid. + process.env.SEC_EMBEDDING_DIMENSIONS = bad; + expect(() => secEmbeddingDimensions()).toThrow(/SEC_EMBEDDING_DIMENSIONS/); + }); +}); diff --git a/src/config/embeddingWidth.test.ts b/src/config/embeddingWidth.test.ts deleted file mode 100644 index c1ecf8fb..00000000 --- a/src/config/embeddingWidth.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -/** - * @license - * Copyright 2026 Steven Roussey - * SPDX-License-Identifier: Apache-2.0 - */ - -import { mkdtempSync, readFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { huggingFaceRepoOf, resolveEmbeddingWidth, widthFromModelConfig } from "./embeddingWidth"; -import { secEmbeddingDimensions } from "./models"; - -/** - * The width guard used to compare a compile-time `768` against itself. The - * model id is free-form env input and the width was a literal, so the clause - * `stored.dimensions === dimensions` was `768 === 768` on every path — and a - * genuinely narrower model opened the base without complaint, then failed on - * the first chunk from inside `@workglow/knowledge-base` with a message naming - * neither the variable nor the model. - * - * The first fix for that was a table of model names kept here. This is the - * second: the model states its own width, so nothing has to be kept in step - * and a model nobody listed still opens. - */ -describe("resolving the embedding width", () => { - const saved = { - model: process.env.SEC_EMBEDDING_MODEL, - dims: process.env.SEC_EMBEDDING_DIMENSIONS, - raw: process.env.SEC_RAW_DATA_FOLDER, - }; - - beforeEach(() => { - delete process.env.SEC_EMBEDDING_MODEL; - delete process.env.SEC_EMBEDDING_DIMENSIONS; - // Its own folder per test, so one test's remembered width is not another's - // answer and the cache is observable rather than inferred. - process.env.SEC_RAW_DATA_FOLDER = mkdtempSync(join(tmpdir(), "sec-width-")); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - for (const [key, value] of [ - ["SEC_EMBEDDING_MODEL", saved.model], - ["SEC_EMBEDDING_DIMENSIONS", saved.dims], - ["SEC_RAW_DATA_FOLDER", saved.raw], - ] as const) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - }); - - /** A Hub that answers with one config, and counts how often it was asked. */ - function stubHub(config: unknown): { calls: string[] } { - const calls: string[] = []; - vi.stubGlobal("fetch", (url: string) => { - calls.push(String(url)); - return Promise.resolve({ ok: true, json: () => Promise.resolve(config) } as Response); - }); - return { calls }; - } - - describe("huggingFaceRepoOf", () => { - it.each([ - ["onnx:Xenova/bge-base-en-v1.5:q8", "Xenova/bge-base-en-v1.5"], - ["onnx:Xenova/bge-base-en-v1.5", "Xenova/bge-base-en-v1.5"], - ["onnx:Xenova/all-MiniLM-L6-v2:fp16", "Xenova/all-MiniLM-L6-v2"], - ])("reads the repo out of %o", (modelId, repo) => { - // The runtime prefix and the quantization tail name neither the model nor - // its width, and both variants of one repo have one width. - expect(huggingFaceRepoOf(modelId)).toBe(repo); - }); - - it.each(["gemini-embedding-001", "text-embedding-3-small", "onnx:", "gguf:model.gguf"])( - "has no repo for %o", - (modelId) => { - // A cloud endpoint publishes no config. Inventing a repo path would ask - // the Hub about a model that is not there and read the 404 as a width. - expect(huggingFaceRepoOf(modelId)).toBeUndefined(); - } - ); - }); - - describe("widthFromModelConfig", () => { - it("reads hidden_size, which is what mean pooling produces", () => { - expect(widthFromModelConfig({ hidden_size: 384, model_type: "bert" })).toBe(384); - }); - - it.each([ - [{ d_model: 1024 }, 1024], - [{ n_embd: 2048 }, 2048], - [{ hidden_dim: 512 }, 512], - ])("reads the other architectures' spellings (%o)", (config, width) => { - expect(widthFromModelConfig(config)).toBe(width); - }); - - it.each([{}, { hidden_size: 0 }, { hidden_size: -1 }, { hidden_size: "768" }, null, "x"])( - "states no width for %o", - (config) => { - expect(widthFromModelConfig(config)).toBeUndefined(); - } - ); - }); - - describe("resolveEmbeddingWidth", () => { - it("asks the model, and takes the answer", async () => { - const hub = stubHub({ hidden_size: 384 }); - - expect(await resolveEmbeddingWidth("onnx:Xenova/all-MiniLM-L6-v2:q8")).toBe(384); - expect(hub.calls).toEqual([ - "https://huggingface.co/Xenova/all-MiniLM-L6-v2/resolve/main/config.json", - ]); - }); - - it("asks once and remembers, so a used model resolves offline", async () => { - const hub = stubHub({ hidden_size: 768 }); - expect(await resolveEmbeddingWidth("onnx:Xenova/bge-base-en-v1.5:q8")).toBe(768); - - // Not a second request, and not a second answer either — the same one, - // now from disk. First use needs the network because first use downloads - // the weights over the same connection. - vi.stubGlobal("fetch", () => Promise.reject(new Error("offline"))); - expect(await resolveEmbeddingWidth("onnx:Xenova/bge-base-en-v1.5:q8")).toBe(768); - expect(hub.calls).toHaveLength(1); - }); - - it("shares one answer across a repo's quantizations", async () => { - stubHub({ hidden_size: 768 }); - await resolveEmbeddingWidth("onnx:Xenova/bge-base-en-v1.5:q8"); - - const cache = JSON.parse( - readFileSync( - join(process.env.SEC_RAW_DATA_FOLDER!, "model-cache", "embedding-widths.json"), - "utf8" - ) - ) as Record; - expect(cache).toEqual({ "Xenova/bge-base-en-v1.5": 768 }); - }); - - it.each([ - ["an unreachable Hub", () => vi.stubGlobal("fetch", () => Promise.reject(new Error("no")))], - [ - "a repo that is not there", - () => vi.stubGlobal("fetch", () => Promise.resolve({ ok: false } as Response)), - ], - ["a config that states no width", () => stubHub({ model_type: "bert" })], - ])("does not know the width from %s", async (_label, arrange) => { - arrange(); - expect(await resolveEmbeddingWidth("onnx:some-org/some-model:q8")).toBeUndefined(); - }); - }); -}); - -describe("secEmbeddingDimensions", () => { - const saved = { - model: process.env.SEC_EMBEDDING_MODEL, - dims: process.env.SEC_EMBEDDING_DIMENSIONS, - raw: process.env.SEC_RAW_DATA_FOLDER, - }; - - beforeEach(() => { - delete process.env.SEC_EMBEDDING_MODEL; - delete process.env.SEC_EMBEDDING_DIMENSIONS; - process.env.SEC_RAW_DATA_FOLDER = mkdtempSync(join(tmpdir(), "sec-width-")); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - for (const [key, value] of [ - ["SEC_EMBEDDING_MODEL", saved.model], - ["SEC_EMBEDDING_DIMENSIONS", saved.dims], - ["SEC_RAW_DATA_FOLDER", saved.raw], - ] as const) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - }); - - it("gets the default model's width from the model", async () => { - vi.stubGlobal("fetch", () => - Promise.resolve({ ok: true, json: () => Promise.resolve({ hidden_size: 768 }) } as Response) - ); - expect(await secEmbeddingDimensions()).toBe(768); - }); - - it("opens a model no table here lists", async () => { - // The table this replaced refused one, and refusing was the entire cost: - // the model had the answer the whole time. - vi.stubGlobal("fetch", () => - Promise.resolve({ ok: true, json: () => Promise.resolve({ hidden_size: 1024 }) } as Response) - ); - process.env.SEC_EMBEDDING_MODEL = "onnx:some-org/some-unlisted-model:q8"; - expect(await secEmbeddingDimensions()).toBe(1024); - }); - - it("refuses when the model cannot be asked, naming the way forward", async () => { - // Refusing at open is the point: the alternative is discovering it - // mid-`sec index`, after the weights have been downloaded. - vi.stubGlobal("fetch", () => Promise.reject(new Error("offline"))); - process.env.SEC_EMBEDDING_MODEL = "onnx:some-org/some-unlisted-model:q8"; - - await expect(secEmbeddingDimensions()).rejects.toThrow(/SEC_EMBEDDING_MODEL/); - await expect(secEmbeddingDimensions()).rejects.toThrow(/some-unlisted-model/); - await expect(secEmbeddingDimensions()).rejects.toThrow(/SEC_EMBEDDING_DIMENSIONS/); - }); - - it("takes an explicit width without asking anything", async () => { - // The escape hatch for a model with no config to read — a cloud endpoint, - // or an air-gapped run of one that has never been used here. - const calls: string[] = []; - vi.stubGlobal("fetch", (url: string) => { - calls.push(String(url)); - return Promise.reject(new Error("should not be called")); - }); - process.env.SEC_EMBEDDING_MODEL = "gemini-embedding-001"; - process.env.SEC_EMBEDDING_DIMENSIONS = "3072"; - - expect(await secEmbeddingDimensions()).toBe(3072); - expect(calls).toEqual([]); - }); - - it("lets an explicit width override what the model says", async () => { - vi.stubGlobal("fetch", () => - Promise.resolve({ ok: true, json: () => Promise.resolve({ hidden_size: 768 }) } as Response) - ); - process.env.SEC_EMBEDDING_MODEL = "onnx:Xenova/bge-base-en-v1.5:q8"; - process.env.SEC_EMBEDDING_DIMENSIONS = "512"; - expect(await secEmbeddingDimensions()).toBe(512); - }); - - it.each(["0", "-1", "12.5", "many"])("refuses %o as a width", async (bad) => { - // A malformed override must not silently fall back to the derivation: - // creating the column at the wrong width is the corruption this whole guard - // exists to avoid. - process.env.SEC_EMBEDDING_MODEL = "onnx:some-org/some-unlisted-model:q8"; - process.env.SEC_EMBEDDING_DIMENSIONS = bad; - await expect(secEmbeddingDimensions()).rejects.toThrow(/SEC_EMBEDDING_DIMENSIONS/); - }); -}); diff --git a/src/config/embeddingWidth.ts b/src/config/embeddingWidth.ts deleted file mode 100644 index 6a156a7d..00000000 --- a/src/config/embeddingWidth.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * @license - * Copyright 2026 Steven Roussey - * SPDX-License-Identifier: Apache-2.0 - */ - -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; - -/** - * The fields a model's own `config.json` states its hidden width in. - * - * BERT-family encoders — every model this CLI embeds with — write - * `hidden_size`. The rest are the spellings the other architectures on the Hub - * use, listed in the order a config that carries more than one should be read. - */ -const WIDTH_FIELDS = ["hidden_size", "d_model", "n_embd", "hidden_dim", "dim"] as const; - -/** Where the derived widths are remembered, under the raw-data folder. */ -function widthCachePath(): string { - const root = process.env.SEC_RAW_DATA_FOLDER?.trim() || "."; - return join(root, "model-cache", "embedding-widths.json"); -} - -function readWidthCache(): Record { - try { - const parsed: unknown = JSON.parse(readFileSync(widthCachePath(), "utf8")); - return typeof parsed === "object" && parsed !== null ? (parsed as Record) : {}; - } catch { - // Absent or unreadable is the same answer: nothing is remembered. - return {}; - } -} - -function rememberWidth(repo: string, width: number): void { - try { - const path = widthCachePath(); - mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, `${JSON.stringify({ ...readWidthCache(), [repo]: width }, null, 2)}\n`); - } catch { - // A cache that cannot be written costs a request next time and nothing - // else, so it must not fail the run that derived the width successfully. - } -} - -/** - * The HuggingFace repo behind a model id, or `undefined` where there is none. - * - * Strips the `onnx:` runtime prefix and the `:q8`-style quantization tail — - * neither names the model, and both variants of one repo have one width. A - * cloud model id (`gemini-embedding-001`) has no `org/name` shape and returns - * `undefined`: there is no config to read, and inventing a repo path would ask - * the Hub about a model that is not there. - */ -export function huggingFaceRepoOf(modelId: string): string | undefined { - const withoutPrefix = modelId.includes(":") ? modelId.slice(modelId.indexOf(":") + 1) : modelId; - const bare = withoutPrefix.replace(/:(?:q\d+f?\d*|fp\d+|int\d+)$/i, ""); - return /^[^/\s:]+\/[^/\s:]+$/.test(bare) ? bare : undefined; -} - -/** The width a parsed `config.json` states, or `undefined` if it states none. */ -export function widthFromModelConfig(config: unknown): number | undefined { - if (typeof config !== "object" || config === null) return undefined; - const record = config as Record; - for (const field of WIDTH_FIELDS) { - const value = record[field]; - if (typeof value === "number" && Number.isInteger(value) && value > 0) return value; - } - return undefined; -} - -/** - * The model's output width, read off the model. - * - * A hand-maintained table of model names was the alternative, and it is wrong - * in the direction that costs: a model it does not carry is refused even though - * the model itself has always been able to answer, and an entry that drifts is - * believed. So this asks the model — its published `config.json`, which is the - * same file the runtime loads the architecture from. - * - * `hidden_size` IS the width here rather than an approximation of it: these - * records are registered with `pooling: "mean"`, so the vector handed back is - * the mean of the last hidden states and has exactly that many components. - * - * The config is a couple of kilobytes and the answer is remembered per repo, so - * a model that has been used once resolves offline. First use needs the network - * — but first use downloads the weights over the same connection, so there is - * no case where this is the request that cannot be made. - * - * Returns `undefined` rather than throwing for every way of not knowing, so the - * caller can say which variable to set; the distinctions between "no repo", - * "unreachable" and "no width field" do not change that answer. - */ -export async function resolveEmbeddingWidth(modelId: string): Promise { - const repo = huggingFaceRepoOf(modelId); - if (repo === undefined) return undefined; - - const remembered = readWidthCache()[repo]; - if (typeof remembered === "number" && Number.isInteger(remembered) && remembered > 0) { - return remembered; - } - - let config: unknown; - try { - const response = await fetch(`https://huggingface.co/${repo}/resolve/main/config.json`, { - headers: { accept: "application/json" }, - }); - if (!response.ok) return undefined; - config = await response.json(); - } catch { - return undefined; - } - - const width = widthFromModelConfig(config); - if (width !== undefined) rememberWidth(repo, width); - return width; -} diff --git a/src/config/models.ts b/src/config/models.ts index 5bccc8ae..21525be1 100644 --- a/src/config/models.ts +++ b/src/config/models.ts @@ -4,7 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { resolveEmbeddingWidth } from "./embeddingWidth"; import { SecCliConfigurationError } from "./EnvToDI"; /** @@ -25,60 +24,72 @@ import { SecCliConfigurationError } from "./EnvToDI"; */ const DEFAULT_EMBEDDING_MODEL = "onnx:Xenova/bge-base-en-v1.5:q8"; +/** + * The default model's output width, declared beside the model it belongs to. + * + * `bge-base-en-v1.5` is a BERT-base encoder with a hidden size of 768, and the + * record registers it with `pooling: "mean"` — so the vector handed back is the + * mean of the last hidden states and has exactly that many components. It is a + * property of the pinned model, not something to look up: the two move + * together, and a reader who changes one sees the other on the next line. + */ +const DEFAULT_EMBEDDING_DIMENSIONS = 768; + /** The embedding model id, overridable with `SEC_EMBEDDING_MODEL`. */ export function secEmbeddingModel(): string { return process.env.SEC_EMBEDDING_MODEL?.trim() || DEFAULT_EMBEDDING_MODEL; } /** - * The configured embedding model's output width, asked of the model. + * The configured embedding model's output width. * - * Resolved rather than assumed, and it refuses rather than guessing. The - * alternative is what this replaced: the width was a literal `768` used both to - * create the column and to check it, so `stored.dimensions === dimensions` was - * `768 === 768` on every path, and a genuinely narrower model opened the - * knowledge base without complaint and failed on the first chunk with a - * `@workglow/knowledge-base` internal message naming neither the variable nor - * the model — after the weights had been downloaded and the run had started. + * Declared rather than discovered. The alternative is what this replaced: the + * width was a literal `768` used both to create the vector column and to check + * it, so `stored.dimensions === dimensions` was `768 === 768` on every path, + * and a genuinely narrower model opened the knowledge base without complaint + * and failed on the first chunk with a `@workglow/knowledge-base` internal + * message naming neither the variable nor the model — after the weights had + * been downloaded and the run had started. * - * The width comes from the model's own published config rather than from a - * table kept here. A table is wrong in the direction that costs: a model it - * does not carry is refused even though the model itself could always have - * answered, and an entry that drifts is believed over the model. + * The fix is not to look the width up; it is to stop pretending a constant + * describes a model it was not written for. This CLI pins ONE embedding model + * and states its width beside it. Point `SEC_EMBEDDING_MODEL` at anything else + * and the width becomes yours to state too — `SEC_EMBEDDING_DIMENSIONS`, which + * is also what a cloud endpoint with no local weights needs. * - * `SEC_EMBEDDING_DIMENSIONS` comes first, and is the way forward for a model - * with no config to read — a cloud embedding endpoint, or an air-gapped run of - * one that has never been used here. Stating it is also what makes the - * stored-vs-configured comparison in `kb_index` mean something. + * Refusing at open is the whole point, and it is what survives from the + * literal: the vector column is created at whatever this returns, so a value + * that does not match the configured model builds a store the query cannot + * read, and a mismatch must not be discovered partway through a run. */ -export async function secEmbeddingDimensions(): Promise { +export function secEmbeddingDimensions(): number { const model = secEmbeddingModel(); const override = process.env.SEC_EMBEDDING_DIMENSIONS?.trim(); if (override !== undefined && override !== "") { const width = Number(override); - // A malformed override must not fall through to the derivation: creating - // the column at a width the model does not produce is the corruption the - // whole guard exists to avoid. + // A malformed override must not fall through to the default: creating the + // column at a width the model does not produce is the corruption the whole + // guard exists to avoid. if (!Number.isInteger(width) || width <= 0) { throw new SecCliConfigurationError( `SEC_EMBEDDING_DIMENSIONS is "${override}", which is not a positive whole number. ` + `It is the output width of SEC_EMBEDDING_MODEL ("${model}") in dimensions — ` + - `768 for the default model. Unset it to read the width off the model itself.` + `${DEFAULT_EMBEDDING_DIMENSIONS} for the default model.` ); } return width; } - const derived = await resolveEmbeddingWidth(model); - if (derived !== undefined) return derived; + if (model === DEFAULT_EMBEDDING_MODEL) return DEFAULT_EMBEDDING_DIMENSIONS; throw new SecCliConfigurationError( - `SEC_EMBEDDING_MODEL is "${model}", and its output width could not be read from the ` + - `model — it publishes no config this can reach, or the config states no hidden size. ` + - `The width cannot be guessed: the vector column is created at it, so the wrong value ` + - `builds an index the query cannot read. Set SEC_EMBEDDING_DIMENSIONS to the model's ` + - `width, or use a HuggingFace model whose config states it.` + `SEC_EMBEDDING_MODEL is "${model}", which is not the model this CLI pins, so its output ` + + `width is not known here. The width cannot be guessed: the vector column is created at ` + + `it, so the wrong value builds an index the query cannot read. Set ` + + `SEC_EMBEDDING_DIMENSIONS to the model's width — 384 for all-MiniLM-L6-v2 and the other ` + + `small BERT encoders, 768 for the base ones, 1024 for the large — or unset ` + + `SEC_EMBEDDING_MODEL to use "${DEFAULT_EMBEDDING_MODEL}".` ); } diff --git a/src/config/registerModels.test.ts b/src/config/registerModels.test.ts index 80342c60..162305b9 100644 --- a/src/config/registerModels.test.ts +++ b/src/config/registerModels.test.ts @@ -60,7 +60,7 @@ describe("registerSecModels", () => { }); it("builds a routable HFT record", async () => { - const record = await hftModelRecord("onnx:onnx-community/Qwen2.5-0.5B-Instruct"); + const record = hftModelRecord("onnx:onnx-community/Qwen2.5-0.5B-Instruct"); expect(record.provider).toBe("HF_TRANSFORMERS_ONNX"); expect(record.provider_config.model_path).toBe("onnx-community/Qwen2.5-0.5B-Instruct"); expect(record.capabilities).toContain("json-mode"); @@ -92,25 +92,21 @@ describe("registerSecModels", () => { }); it("dispatches secModelRecord by id shape across all providers", async () => { - expect((await secModelRecord("claude-opus-5")).provider).toBe("ANTHROPIC"); - expect((await secModelRecord("gpt-5.5")).provider).toBe("OPENAI"); - expect((await secModelRecord("gpt-5.4-mini")).provider).toBe("OPENAI"); - expect((await secModelRecord("gemini-3.1-pro-preview")).provider).toBe("GOOGLE_GEMINI"); - expect((await secModelRecord("grok-4.6")).provider).toBe("XAI"); - expect((await secModelRecord("deepseek-v4-flash")).provider).toBe("DEEPSEEK"); - expect((await secModelRecord("deepseek-v4-pro")).provider).toBe("DEEPSEEK"); - expect((await secModelRecord("onnx:onnx-community/Qwen2.5-0.5B-Instruct")).provider).toBe( + expect(secModelRecord("claude-opus-5").provider).toBe("ANTHROPIC"); + expect(secModelRecord("gpt-5.5").provider).toBe("OPENAI"); + expect(secModelRecord("gpt-5.4-mini").provider).toBe("OPENAI"); + expect(secModelRecord("gemini-3.1-pro-preview").provider).toBe("GOOGLE_GEMINI"); + expect(secModelRecord("grok-4.6").provider).toBe("XAI"); + expect(secModelRecord("deepseek-v4-flash").provider).toBe("DEEPSEEK"); + expect(secModelRecord("deepseek-v4-pro").provider).toBe("DEEPSEEK"); + expect(secModelRecord("onnx:onnx-community/Qwen2.5-0.5B-Instruct").provider).toBe( "HF_TRANSFORMERS_ONNX" ); - expect((await secModelRecord("gguf:model.gguf")).provider).toBe("LOCAL_LLAMACPP"); - expect((await secModelRecord("llama:model.gguf")).provider).toBe("LOCAL_LLAMACPP"); - expect((await secModelRecord("node-llama:model.gguf")).provider).toBe("LOCAL_LLAMACPP"); - expect((await secModelRecord("hfi:meta-llama/Llama-3.3-70B-Instruct")).provider).toBe( - "HF_INFERENCE" - ); - expect((await secModelRecord("open-router:anthropic/claude-sonnet-4")).provider).toBe( - "OPENROUTER" - ); + expect(secModelRecord("gguf:model.gguf").provider).toBe("LOCAL_LLAMACPP"); + expect(secModelRecord("llama:model.gguf").provider).toBe("LOCAL_LLAMACPP"); + expect(secModelRecord("node-llama:model.gguf").provider).toBe("LOCAL_LLAMACPP"); + expect(secModelRecord("hfi:meta-llama/Llama-3.3-70B-Instruct").provider).toBe("HF_INFERENCE"); + expect(secModelRecord("open-router:anthropic/claude-sonnet-4").provider).toBe("OPENROUTER"); }); it("pins an optional inference provider from hfi: / open-router: ids onto provider_config", () => { @@ -170,7 +166,7 @@ describe("registerSecModels", () => { // SEC_HFT_MODEL / --models value stopped resolving. let message = ""; try { - await secModelRecord("onnx-community/Qwen3-4B-Instruct-2507-ONNX"); + secModelRecord("onnx-community/Qwen3-4B-Instruct-2507-ONNX"); } catch (e) { message = e instanceof Error ? e.message : String(e); } @@ -181,7 +177,7 @@ describe("registerSecModels", () => { // An id with no slash cannot be a repo id, so it gets no misleading hint. let plain = ""; try { - await secModelRecord("sonnet-5"); + secModelRecord("sonnet-5"); } catch (e) { plain = e instanceof Error ? e.message : String(e); } @@ -195,7 +191,7 @@ describe("registerSecModels", () => { "open-router:Fireworks:", "open-router::deepseek/deepseek-chat", ]) { - await expect(secModelRecord(id)).rejects.toBeInstanceOf(SecCliConfigurationError); + expect(() => secModelRecord(id)).toThrow(SecCliConfigurationError); } }); @@ -212,22 +208,22 @@ describe("registerSecModels", () => { "onnx-community/Qwen2.5-0.5B-Instruct", "", ]) { - await expect(secModelRecord(id)).rejects.toBeInstanceOf(SecCliConfigurationError); + expect(() => secModelRecord(id)).toThrow(SecCliConfigurationError); } - await expect(secModelRecord("claude--typo")).resolves.toBeDefined(); + expect(() => secModelRecord("claude--typo")).not.toThrow(); }); it("names the offending id and the accepted shapes when it throws", async () => { - await expect( - secModelRecord("deepseek-v4-flash".replace("deepseek", "deapseek")) - ).rejects.toThrow(/deapseek-v4-flash.*deepseek-\*/s); + expect(() => secModelRecord("deepseek-v4-flash".replace("deepseek", "deapseek"))).toThrow( + /deapseek-v4-flash.*deepseek-\*/s + ); }); it("routes a deepseek-ai HuggingFace repo id via onnx: to the local ONNX provider, not DeepSeek cloud", async () => { - expect((await secModelRecord("onnx:deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B")).provider).toBe( + expect(secModelRecord("onnx:deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B").provider).toBe( "HF_TRANSFORMERS_ONNX" ); - expect((await secModelRecord("deepseek-v4-flash")).provider).toBe("DEEPSEEK"); + expect(secModelRecord("deepseek-v4-flash").provider).toBe("DEEPSEEK"); }); it("is idempotent — a second run does not duplicate or throw", async () => { diff --git a/src/config/registerModels.ts b/src/config/registerModels.ts index 7d5fc66d..26a01179 100644 --- a/src/config/registerModels.ts +++ b/src/config/registerModels.ts @@ -371,7 +371,7 @@ const HFT_CAPABILITIES: readonly string[] = [ * into the worker; `pipeline: "text-generation"` selects the causal-LM pipeline * the structured-generation path drives. */ -export async function hftModelRecord(modelId: string): Promise { +export function hftModelRecord(modelId: string): ModelRecord { const modelPath = modelId.startsWith(ONNX_ID_PREFIX) ? modelId.slice(ONNX_ID_PREFIX.length) : modelId; @@ -403,7 +403,7 @@ export async function hftModelRecord(modelId: string): Promise { // embedding record without it fails AFTER running the model, with the // declared width reported as `undefined`. ...(embedding - ? { native_dimensions: await secEmbeddingDimensions(), pooling: "mean", normalize: true } + ? { native_dimensions: secEmbeddingDimensions(), pooling: "mean", normalize: true } : {}), }, metadata: {}, @@ -589,9 +589,9 @@ export function openRouterModelRecord(modelId: string): ModelRecord { * model repository by an operator, a harness, or a test. Such an id is legal and * simply isn't ours to route, so those callers must not treat it as a failure. */ -export async function trySecModelRecord(modelId: string): Promise { +export function trySecModelRecord(modelId: string): ModelRecord | undefined { if (isLlamaCppModelId(modelId)) return llamaCppModelRecord(modelId); - if (isHftModelId(modelId)) return await hftModelRecord(modelId); + if (isHftModelId(modelId)) return hftModelRecord(modelId); if (isHfInferenceModelId(modelId)) return hfInferenceModelRecord(modelId); if (isOpenRouterModelId(modelId)) return openRouterModelRecord(modelId); if (isAnthropicModelId(modelId)) return anthropicModelRecord(modelId); @@ -656,8 +656,8 @@ export const KNOWN_MODEL_ID_SHAPES = "hfi:[provider:]org/name (HuggingFace Inference), " + "open-router:[provider:]vendor/model (OpenRouter)"; -export async function secModelRecord(modelId: string): Promise { - const record = await trySecModelRecord(modelId); +export function secModelRecord(modelId: string): ModelRecord { + const record = trySecModelRecord(modelId); if (record) return record; // A bare `org/name` id needs an explicit provider prefix — without this hint // the message lists every legal shape and leaves the operator to notice that @@ -685,7 +685,7 @@ export async function registerModelIds( const repo = getGlobalModelRepository(registry); for (const modelId of modelIds) { if (await repo.findByName(modelId)) continue; - await repo.addModel(await secModelRecord(modelId)); + await repo.addModel(secModelRecord(modelId)); } } diff --git a/src/kb/secKnowledgeBase.test.ts b/src/kb/secKnowledgeBase.test.ts index 991b60b7..a7cee99c 100644 --- a/src/kb/secKnowledgeBase.test.ts +++ b/src/kb/secKnowledgeBase.test.ts @@ -47,11 +47,13 @@ describe("the SEC knowledge base's embedding-model record", () => { beforeEach(async () => { await resetSecKnowledgeBaseForTesting(); delete process.env.SEC_EMBEDDING_MODEL; + delete process.env.SEC_EMBEDDING_DIMENSIONS; }); afterEach(async () => { await resetSecKnowledgeBaseForTesting(); delete process.env.SEC_EMBEDDING_MODEL; + delete process.env.SEC_EMBEDDING_DIMENSIONS; }); it("reopens an index built by the same model", async () => { @@ -68,12 +70,19 @@ describe("the SEC knowledge base's embedding-model record", () => { await resetSecKnowledgeBaseForTesting(); // Same width, different space — the case a dimension check alone misses, - // and the one that answers questions instead of failing. + // and the one that answers questions instead of failing. The width is + // stated because it has to be for a model this CLI does not pin, and + // because 768 is what makes this the same-width case. process.env.SEC_EMBEDDING_MODEL = "onnx:Xenova/all-mpnet-base-v2:q8"; + process.env.SEC_EMBEDDING_DIMENSIONS = "768"; const failure = getSecKnowledgeBase(); await expect(failure).rejects.toThrow(/bge-base-en-v1\.5/); await expect(failure).rejects.toThrow(/all-mpnet-base-v2/); - await expect(failure).rejects.toThrow(/SEC_EMBEDDING_MODEL/); + // Wording only this guard uses. The width refusal beside it names both + // models and the variable too, so the three above cannot tell them apart — + // and a test that cannot tell its own guard from its neighbour passes for + // the wrong reason. + await expect(failure).rejects.toThrow(/not comparable/); }); it("adopts an index that predates the record rather than stranding it", async () => { @@ -88,6 +97,7 @@ describe("the SEC knowledge base's embedding-model record", () => { await resetSecKnowledgeBaseForTesting(); process.env.SEC_EMBEDDING_MODEL = "onnx:Xenova/all-mpnet-base-v2:q8"; + process.env.SEC_EMBEDDING_DIMENSIONS = "768"; await expect(getSecKnowledgeBase()).rejects.toThrow(/not comparable/); }); }); @@ -158,8 +168,8 @@ describe("the SEC knowledge base's tables and `db reset`", () => { describe("the SEC knowledge base's chunk search", () => { withSqliteDb("kb_search", []); - const unit = async (index: number): Promise => { - const vector = new Float32Array(await secEmbeddingDimensions()); + const unit = (index: number): Float32Array => { + const vector = new Float32Array(secEmbeddingDimensions()); vector[index] = 1; return vector; }; @@ -177,7 +187,7 @@ describe("the SEC knowledge base's chunk search", () => { await kb.upsertChunk({ chunk_id: "north", doc_id: "doc-1", - vector: await unit(0), + vector: unit(0), metadata: { chunkId: "north", doc_id: "doc-1", @@ -189,7 +199,7 @@ describe("the SEC knowledge base's chunk search", () => { await kb.upsertChunk({ chunk_id: "east", doc_id: "doc-1", - vector: await unit(1), + vector: unit(1), metadata: { chunkId: "east", doc_id: "doc-1", @@ -199,7 +209,7 @@ describe("the SEC knowledge base's chunk search", () => { }, }); - const hits = await kb.similaritySearch(await unit(1), { topK: 2 }); + const hits = await kb.similaritySearch(unit(1), { topK: 2 }); expect(hits.map((hit) => hit.chunk_id)).toEqual(["east", "north"]); }); }); @@ -215,17 +225,12 @@ describe("an embedding model of unknown width", () => { beforeEach(async () => { await resetSecKnowledgeBaseForTesting(); - // The width is read from the model's published config, so a model with no - // config to read is a Hub that cannot answer — stubbed rather than reached, - // since a test must not depend on the network to decide what it asserts. - vi.stubGlobal("fetch", () => Promise.reject(new Error("offline"))); process.env.SEC_EMBEDDING_MODEL = "onnx:some-org/some-unlisted-model:q8"; delete process.env.SEC_EMBEDDING_DIMENSIONS; }); afterEach(async () => { await resetSecKnowledgeBaseForTesting(); - vi.unstubAllGlobals(); delete process.env.SEC_EMBEDDING_MODEL; delete process.env.SEC_EMBEDDING_DIMENSIONS; }); diff --git a/src/kb/secKnowledgeBase.ts b/src/kb/secKnowledgeBase.ts index af4cc7e4..d467f6ec 100644 --- a/src/kb/secKnowledgeBase.ts +++ b/src/kb/secKnowledgeBase.ts @@ -152,7 +152,7 @@ export async function getSecKnowledgeBase(): Promise { // Resolved before any DDL: an unknown model refuses here rather than after // the column has been created at a width its vectors will not have. - const dimensions = await secEmbeddingDimensions(); + const dimensions = secEmbeddingDimensions(); const db = getDb(); // Tabular, not vector: the document table holds a filing's metadata and its