Skip to content

embedding pass: inference in a worker thread, the tick budget between batches, newcomers from a cursor; search weighs by query shape - #141

Merged
Evanfeenstra merged 5 commits into
mainfrom
fix/embedding-pass-worker
Oct 11, 2026
Merged

Evanfeenstra merged 5 commits into
mainfrom
fix/embedding-pass-worker

Conversation

@Evanfeenstra

@Evanfeenstra Evanfeenstra commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

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_m125 stamped, no live index left narrower than the library's, Function at 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 a setImmediate, 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:

on swarm38 idle during a tick
GET /lab/health mean (max) 0.34 s (2.2) 1.74 s (3.5); 41 of 60 samples over 1 s
the same graph/graph-search 0.66 s, 0.62 s 5.71 s, 5.52 s

The pass now embeds in a worker thread (WorkerEmbedder in embeddings.ts, entry embed-worker.ts). The writer and search keep the in-process model (a query is one short text). Real model, compiled build/, plain node, 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 (UnitTest bodies, ~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 since jumped to until. 1,000 vector-less rows ahead of a new function in the same minute hid it until the next sweep. That became likely once StrutToolCall stopped getting vectors: its busiest minute this week was 1,563 writes. Now:

  • the query reads marked labels only;
  • it reads from a >= cursor on date_added_to_graph, because stakgraph stamps a whole ingest batch with one $now;
  • a full page of blank rows on one stamp is stepped past;
  • it uses the same per-batch budget.

4. The plan's ruling 1 was wrong about no_index. It said the 62 swarm38 types with no index of their own (the Cloud Advisor*/Kn* types, Content) are skipped. In fact they inherit Thing's [name, description] through CHILD_OF, in jarvis's get_schema exactly 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 no node_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.

  • Weights: 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 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.
  • Phrases: the english analyzer split x-hub-signature-256 into x, hub, signatur, 256, and each fragment matched alone, so every function named X came 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's q tells the caller to quote an exact string.

Measured on swarm38 with the branch's own code (read-only replay of the step's queries):

query before after
five questions, targets in the top 5 9 12
x-hub-signature-256 #17 #6
"Invalid signature", quoted #13 #3
Invalid signature, unquoted #13 #13
exact names (computeHmacSha256Hex, validateUserWorkspaceAccess) #1 #1

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 short vector in fixtures/minilm-golden.json is 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 the STRUT_TEST_EMBEDDINGS=1 parity 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 to npm test), against a fake model that busy-waits like onnxruntime:
    • vectors come back in order, computed off the main thread;
    • the event loop stalls 400 ms inline and about 6 ms with the worker (thresholds 350 / 150);
    • a factory that throws or is missing fails the call with the reason;
    • an error in one call fails that call only;
    • a worker that dies fails its calls in flight, and the next call starts another.
  • embeddings.test.ts:
    • the real model in a worker gives the in-process vectors;
    • a tick stops between batches and the next resumes at the last batch, every node seen once;
    • newcomers get through a page of no-embed Turns, a page of blank rows, and a stamp shared by 7 rows;
    • newcomers resume after a spent budget, 2, 4, 5, 5 over four ticks;
    • the paging test keeps "one page per tick" with batchSize: 1000.
  • search.test.ts:
    • unit: phrases (quoted spans, split words, an unclosed quote), the exact-query rule, the weights by shape;
    • 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. Each live test fails with its change switched off.
  • Results: npm test 1,422 passing; npm run test:graph 283 passing (throwaway Neo4j, STRUT_TEST_EMBEDDINGS=1); tsc clean.

Choices to review

  • A second model instance in the worker (~90 MB of weights plus onnxruntime's arena), so a search query never waits behind a batch in the worker. Search keeps its ~10 ms of inline inference per query.
  • No fallback to the in-process model if the worker cannot start. The pass's lastError says why, and search and the writer are unaffected. A worker that dies is restarted on the next call.
  • Batches are 100 rows, not 100 stale rows, so a mostly-clean page makes smaller embed calls. Clean pages embed nothing either way.
  • Stepping past a stamp: when a full page is nothing but blank rows on one stamp, the cursor steps past that stamp. Anything else on that stamp then waits for the next sweep.

Not in this PR

  • The plan's indirect question still fails: "how do we stop one workspace from seeing another workspace's data" ranks resolveWorkspaceAccess about 170th at every weighting, because the semantic list itself does: MiniLM ranks hive's 64 useWorkspace jest mocks (cosine 0.81) above it (0.71).
  • Open decisions: leaving test-file code out of a typed search (34% of swarm38's Functions), and a retrieval-tuned model.
  • An evaluation set of real queries, to pick the 0.5 and the four-word threshold on more than nine queries.
  • Found when the first sweep ended: 27,438 nodes in 63 types carry no node_key (the Cloud Advisor*/Kn* types, PullRequest, Commit). The sweep pages by node_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, every Turn and StrutToolCall vector removed, 0 errors.
  • Found while testing, pre-existing: on a strut-seeded graph (the compose file, any graph without jarvis), the code domain's fulltext index spans 158 properties, against 32 on swarm38's jarvis-built one. A required term expands to one clause per property, so a question of seven or more terms passes Lucene's 1,024-clause limit and the keyword half quietly drops out.

After merge, mcp's strut pin carries this to the swarms; I'll re-read swarm38's tick timing and /lab/health then.

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.
@Evanfeenstra Evanfeenstra changed the title embedding pass: inference in a worker thread, the tick budget between batches, newcomers from a cursor embedding pass: inference in a worker thread, the tick budget between batches, newcomers from a cursor; search weighs by query shape Oct 11, 2026
@Evanfeenstra
Evanfeenstra merged commit bc36652 into main Oct 11, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant