Repository navigation
embedding pass: inference in a worker thread, the tick budget between batches, newcomers from a cursor; search weighs by query shape - #141
Merged
Conversation
The vein → strut rename (a7bff88) changed the fixture's "short" text to "strut golden parity fixture" and kept the vector sentence-transformers produced for "vein golden parity fixture" (cosine 1.00000 against it, 0.67 against the renamed text). So the STRUT_TEST_EMBEDDINGS=1 parity case has failed since 2026-09-08; the model was never off. The text goes back to the one the vector is of.
… batches; newcomers by marked label from a >= cursor Found validating the pass on swarm38 (2026-10-11, plans/semantic-search.md "First look"). The worker (WorkerEmbedder + embed-worker.ts): onnxruntime-node runs a model synchronously — its run() is the native call inside a setImmediate — so on the main thread every forward pass stalls the event loop. mcp hosts strut in its own process, so during a tick of catch-up swarm38's GET /lab/health went from 0.34 s to 1.74 s (max 3.5 s) and a graph search from 0.6 s to 5.6 s. The pass now embeds in a worker thread built from a named factory (MiniLM by default, a fake in tests); the writer and search keep the in-process model. The worker starts on the first embed and again after it dies, and is unref'd when idle. Measured with the real model, compiled build, plain node: 128 long texts, longest event-loop stall 316 ms inline, 6 ms in the worker, same vectors. The budget: it was checked between pages, and a page of 1,000 long texts (swarm38's UnitTest bodies, ~29 texts/s) made ticks of 28–35 s, not 15. Pages are now repaired a batch of 100 rows at a time, each finished (text, vector, per-field vectors, label) before the cursor moves past it, and the budget is checked between batches. Newcomers: the query had no label filter and the loop ended on a page that embedded nothing, then `since` jumped past the rest — so 1,000 vector-less rows (a run's StrutToolCalls reach 1,563 a minute) would hide code ingested the same minute until the next sweep. Now marked labels only, read from a >= cursor on date_added_to_graph (stakgraph stamps a whole ingest batch with one $now), a full page of blank rows on one stamp stepped past, the same per-batch budget. The plan: ruling 1 said the 62 swarm38 types with no own `index` are skipped; they inherit Thing's [name, description] through CHILD_OF — in jarvis's get_schema as in strut's resolver — and are embedded from it, as jarvis composes them. §1 and the validation section say what changed. Tests: WorkerEmbedder (vectors in order off the main thread; the event loop free while a synchronous model holds the worker, stalled inline; a failing or missing factory; an error in one call; a dead worker's calls fail and the next call starts another), the real model in a worker = in-process; the pass stopping between batches and resuming, newcomers past a page of no-embed and blank rows and through a shared stamp, newcomers resuming after the budget. npm test 1422, test:graph 279 (STRUT_TEST_EMBEDDINGS=1).
…re phrases Measured on swarm38 (plans/semantic-search.md §3). Under jarvis's RRF weights (keyword 1.15, semantic 1.0) any of the top ten keyword-only hits outranks the best semantic-only one, so a prompt constant whose body holds every word of a question ranked first, and "check whether a user is allowed to access a workspace" had one of five access functions in its top five. A query of four words or more now fuses at keyword 0.5 / semantic 1.0 (fusionWeights); a shorter or exact one keeps jarvis's — the semantic half has nothing to say about a header name or an error message. Five questions: 9 targets in the top five became 12; of eight weightings tried, 0.5 was best. Strut's ranking of a question now differs from jarvis's /v2/nodes, on purpose. Exact strings: the english analyzer split x-hub-signature-256 into x, hub, signatur, 256 and each matched alone (every function named X). A word with a separator inside it, and a span the caller put in double quotes (escaped away before), now go to Lucene as phrases (queryParts), and an exact query gets no any-word fallback. The header went from #17 to #6, a quoted error message from #13 to #3; exact names stay #1. graph/graph-search's `q` tells the caller to quote an exact string. Tests: phrases, the exact rule, the weights by shape (pure); live, a prose question where a keyword-only match on a long body no longer outranks the semantic hit, and a header matched as a phrase, not by its fragments, with no fallback. npm test 1422, test:graph 283.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes from validating the pass on swarm38 (#140, deployed by stakwork/stakgraph#1762; read-only, the pin booted 2026-10-11 21:14Z). The boot and the code catch-up work:
strut_index_reconcile_m125stamped, no liveindexleft narrower than the library's,Functionat 15,536 / 15,536 vectors / 15,536 labeled 14 minutes after boot (from 3,533 / 3,953), errors 0. Three defects in the pass and one wrong claim in the plan, fixed here, plus a search-ranking change measured on the same graph (5):1. The pass stalled the whole server. onnxruntime-node runs a model synchronously (its
run()is the native call inside asetImmediate,onnxruntime-node/dist/backend.js), so on the main thread every forward pass blocks the event loop. mcp runs strut in its own process, so while a tick ran:GET /lab/healthmean (max)graph/graph-searchThe pass now embeds in a worker thread (
WorkerEmbedderinembeddings.ts, entryembed-worker.ts). The writer and search keep the in-process model (a query is one short text). Real model, compiledbuild/, plainnode, 128 texts of ~256 tokens: longest event-loop stall 316 ms inline, 6 ms in the worker, identical vectors (min cosine 1.000000).2. Ticks ran 28–35 s, not 15. The budget was checked between pages, and one page of 1,000 long texts (
UnitTestbodies, ~29 texts/s on swarm38) is half a minute of inference. Pages are now repaired a batch of 100 rows at a time. Each batch is finished (text, vector, per-field vectors, label) before the cursor moves past it, and the budget is checked between batches.3. The newcomer tick could skip code. The query had no label filter, and a page that embedded nothing ended the loop, after which
sincejumped tountil. 1,000 vector-less rows ahead of a new function in the same minute hid it until the next sweep. That became likely onceStrutToolCallstopped getting vectors: its busiest minute this week was 1,563 writes. Now:>=cursor ondate_added_to_graph, because stakgraph stamps a whole ingest batch with one$now;4. The plan's ruling 1 was wrong about
no_index. It said the 62 swarm38 types with noindexof their own (the CloudAdvisor*/Kn*types,Content) are skipped. In fact they inheritThing's[name, description]throughCHILD_OF, in jarvis'sget_schemaexactly as in strut's resolver, so jarvis composes the same text for them. The code was right; the ruling and the §1 text are corrected, and the validation section records what the first look found. (On swarm38 the Cloud types' nodes carry nonode_key, so the sweep doesn't reach them at all; see "Not in this PR".)5. Search leaned on keywords, and split code strings into fragments.
bodyholds every word of a question ranked first, and "check whether a user is allowed to access a workspace" had one of five access functions in its top five. A query of four words or more now merges at keyword 0.5 / semantic 1.0 (fusionWeights); a shorter or exact query keeps jarvis's weights, since the semantic half has nothing to say about a header name or an error message. This deliberately makes strut's ranking of a question differ from jarvis's/v2/nodes.englishanalyzer splitx-hub-signature-256intox,hub,signatur,256, and each fragment matched alone, so every function namedXcame back. A word with a separator inside it, and text the caller puts in double quotes (escaped away before), now go to Lucene as phrases (queryParts). An exact query gets no any-word fallback, which would OR the fragments back together.graph/graph-search'sqtells the caller to quote an exact string.Measured on swarm38 with the branch's own code (read-only replay of the step's queries):
x-hub-signature-256"Invalid signature", quotedInvalid signature, unquotedcomputeHmacSha256Hex,validateUserWorkspaceAccess)Of eight weightings tried, from keyword-only to semantic-only, 0.5 was best on the questions. Lowering the keyword weight for every query would have pushed the code strings to #69 and #70, which is why it depends on the query's shape.
Also, in its own commit: the golden
shortvector infixtures/minilm-golden.jsonis sentence-transformers' vector for "vein golden parity fixture" (cosine 1.00000 against it). The vein → strut rename changed the text but kept the vector, so theSTRUT_TEST_EMBEDDINGS=1parity case has failed since 2026-09-08. The text goes back to the one the vector is of; the model was never off.Tests
embed-worker.test.ts(offline, added tonpm test), against a fake model that busy-waits like onnxruntime:embeddings.test.ts:Turns, a page of blank rows, and a stamp shared by 7 rows;batchSize: 1000.search.test.ts:bodyno longer outranks the semantic hit, and a header matched as a phrase, not by its fragments, with no fallback. Each live test fails with its change switched off.npm test1,422 passing;npm run test:graph283 passing (throwaway Neo4j,STRUT_TEST_EMBEDDINGS=1);tscclean.Choices to review
lastErrorsays why, and search and the writer are unaffected. A worker that dies is restarted on the next call.Not in this PR
resolveWorkspaceAccessabout 170th at every weighting, because the semantic list itself does: MiniLM ranks hive's 64useWorkspacejest mocks (cosine 0.81) above it (0.71).node_key(the CloudAdvisor*/Kn*types,PullRequest,Commit). The sweep pages bynode_key > $cursor, so it never reads them, and nothing in the status says so. The first sweep otherwise ended clean after 85 minutes: 98,453 nodes re-embedded, everyTurnandStrutToolCallvector removed, 0 errors.After merge, mcp's strut pin carries this to the swarms; I'll re-read swarm38's tick timing and
/lab/healththen.