Declare the embedding width beside the model instead of assuming it - #359
Conversation
…ng it `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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LWp6Z6wvAPDaDCjAFcTSj6
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LWp6Z6wvAPDaDCjAFcTSj6
|
Done in How it asks
On the two objections in my own "Left open"I wrote that this "needs the model loaded or a maintained table either way". Both halves were wrong:
What is left, honestly
Resolving the width is asynchronous now, which reaches Verified32 cases in
Generated by Claude Code |
Three conflicts, all from the dry-run work landing on this file first. `secKnowledgeBase.ts` auto-merged and the result is right: the width is resolved and awaited before any DDL, and main's dry-run guard sits where the `setupDatabase()` calls were. The test file interleaved two adjacent, independent `describe` blocks — the width refusal and the dry-run guard. Both are kept whole. The width block now stubs `fetch` to fail: the width is read from the model's published config, so "a model of unknown width" is a Hub that cannot answer, and a test must not depend on the network to decide what it asserts. The CHANGELOG conflict is the one both sides' merge notes predicted: neither edits the other's bullets, so both sets are kept. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LWp6Z6wvAPDaDCjAFcTSj6
The release-floor work landed on main and appended its own bullet to the same `### Changed` list. Neither side edits the other's, so both are kept. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LWp6Z6wvAPDaDCjAFcTSj6
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LWp6Z6wvAPDaDCjAFcTSj6
### Features - implement ITabularStorage.join for @WorkGlow 0.5.0 (#362) - implement CLI signal teardown and resource shutdown ### Bug Fixes - declare the embedding width beside the model instead of assuming it (#359) #### release - declare the runtime floor, and derive the release bump (#357) #### kb - stop --dry-run creating the index tables, and report them in db stats - bound `ask`'s implicit index, pin the embedding model, and let `db reset` drop the index #### ask - refuse rather than answer from model memory when nothing is retrieved #### adv - scope each Form ADV archive to its own folder and make ingest idempotent ### Performance #### status - read the newest ADV snapshot with one indexed row ### Tests #### coverage - make the README witness check capable of failing (#360) ### Chores - update deps - update dependencies to latest versions - migrate from Prettier to oxfmt for code formatting #### deps - upgrade Vitest to 5 (#342) ### Updated Dependencies - `@workglow/cli`: 0.5.0 - `typebox`: 1.3.29 - `workglow`: 0.5.0 - `@types/bun`: 1.4.2 - `bunset`: 1.1.1 - `oxlint`: ^1.82.0 - `vitest`: ^5.0.0
Closes #354. Closes #359.
The problem
SEC_EMBEDDING_DIMENSIONSwas a compile-time768used both to create the vector column and to check it, sostored.dimensions === dimensionswas768 === 768on 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.The fix
The width is declared next to the model it belongs to:
bge-base-en-v1.5is a BERT-base encoder with a hidden size of 768, and the record registers it withpooling: "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, and the two now move together: a reader who changes one sees the other on the next line.Resolution is
SEC_EMBEDDING_DIMENSIONS→ the pinned model's declared width → refuse.What this replaced, and why it is smaller
An earlier version of this branch read the width from the model's published
config.jsonon the Hub and remembered it underSEC_RAW_DATA_FOLDER/model-cache/. It worked, and it bought a network call, a cache file, and an async hop throughhftModelRecord→secModelRecord→getSecKnowledgeBase— to discover a number about a model this CLI pins. The wholeembeddingWidthmodule and its cache are gone, and every signature it made asynchronous is back to synchronous.A hand-written table of ten model widths came before that, and is also gone. The fix is not to look the width up; it is to stop pretending a constant describes a model it was not written for.
The trade, stated plainly
A model this CLI does not pin no longer resolves by itself.
SEC_EMBEDDING_MODEL=onnx:Xenova/all-MiniLM-L6-v2:q8worked under both the table and the fetch; it now refuses untilSEC_EMBEDDING_DIMENSIONS=384is set.The guard the issue asked for is intact and is what always mattered: the width is resolved before any DDL, and a width that cannot be established refuses at open, naming the model and the variable. Stating the width is also the only answer for a cloud embedding endpoint, which has no local weights to inspect — and
.env.testno longer has to pin a width to keep the suite off the network, because nothing reaches for it.A malformed override throws rather than falling through to the default: creating the column at a width the model does not produce is the corruption the whole guard exists to prevent.
Two tests were measuring the wrong guard, and one passed while doing it
refuses an index built by a different modelasserted that the error names both models andSEC_EMBEDDING_MODEL. The width refusal beside it names all three too — so once the width guard started firing first, that test went green against an entirely different error.Both tests now state the width they need to reach the model-id check, and that one also asserts
not comparable, wording only the real guard uses. Verified by removing the width again and watching it fail, rather than assuming.Verification
9 cases in
embeddingDimensions.test.ts: the pinned model by default and named explicitly (they are one constant, so both must reach 768), the refusal naming model and variable, the override rescuing a non-pinned model, the override beating the default, and four malformed values.format-check,tsc --noEmit,lintclean; 667 passed / 21 skipped acrosssrc/config,src/kb,src/taskandsrc/cli.grepconfirms nomodel-cache,huggingface.coorresolveEmbeddingWidthremain.Merge notes
Two merges of main are in this branch; both conflicts were CHANGELOG bullets where neither side edits the other's, so both sets are kept. The first also interleaved two adjacent, independent
describeblocks insecKnowledgeBase.test.ts— the width refusal and the dry-run guard — and both are kept whole.secKnowledgeBase.tsauto-merged correctly: the width is resolved before any DDL, with the dry-run guard where thesetupDatabase()calls were..claude/CLAUDE.md's env table describes both variables in their new terms.🤖 Generated with Claude Code
https://claude.ai/code/session_01LWp6Z6wvAPDaDCjAFcTSj6