Skip to content

Embeddings: bake the local backend into the fork (native in-process), retire the :18752 sidecar #1554

Open

Description

@dezren39

Ordered by Drew 2026-11-03: "embed and all others should be baked into fork native features — no proxy services needed."

Current shape

POST /v1/embeddings exists in the fork (Wave A, #1434) with the backend registry in packages/core/src/embeddings.ts, but the actual computation for the first registered backend (local-embed/snowflake-arctic-embed-m-v2.0, ONNX int8, 768-d) runs in a separate local service on 127.0.0.1:18752 — a dev/test service with NO nix declaration anywhere (verified by grep in the 2026-11-03 nix audit). The fork calls it over HTTP.

Target shape

The embedding backend runs natively inside the fork server process — model loading, batching, and inference in-process behind the existing registry, with the same external behavior: OpenAI-shaped responses, mixed-length batches untruncated, per-model output dimensions, dimensions parameter (Matryoshka) where the model supports it, loud 400 where it does not, ledger parity for façade calls.

Sequence (additive-first, per standing migration doctrine)

  1. Spike: prove the int8 ONNX model runs in the server runtime (Bun) in-process, outputs matching the sidecar on fixed test vectors.
  2. Add the native backend behind the registry alongside the sidecar backend; verify parity (dims, Matryoshka 256-d, batch shapes, error envelopes).
  3. Flip the registry default to native; verify live with the :18752 service STOPPED.
  4. Only then retire the sidecar service + its code, and note the closure here.

Acceptance criteria

  • The local embeddings backend runs natively in-process in the fork (no dependency on the :18752 sidecar for the local path)
  • The :18752 sidecar is retired from the local-backend path, with config/docs updated to match
  • Embeddings behaviour verified end-to-end on the fork (index + query round-trip)

Activity

  1. dezren39 commented on Nov 3, 2026

    @dezren39
    Author

    Step 1 spike — FEASIBLE, in-process inference proven under Bun

    Prototype on branch spike/1554-native-embeddings (spike/ dir: scripts + README). Nothing merged, no production change: the sidecar still runs and remains the registry default. All measurements on the laptop (amd-6), Bun 1.4.2, against the live sidecar.

    The runtime path that works

    onnxruntime-node (1.31.1 — the same runtime version the sidecars python stack uses) loads and runs onnx/model_int8.onnx natively in the Bun process. No WASM build or other fallback is needed. Tokenization via @huggingface/transformers AutoTokenizer — its pure-JS implementation of the HF spec. One correction to the mental model: this tokenizer is not BERT WordPiece — it is a SentencePiece-style Unigram model (Metaspace pretokenizer, ▁, vocab 250,002, <s>/</s> template, pad_id 1), including a Precompiled normalizer. The JS implementation reproduces the sidecars python tokenizers ids exactly on all five test vectors (3-41 tokens, incl. a mixed Japanese text and a query:-prefixed text).

    Parity (5 fixed texts vs the live sidecar, HTTP)

    • Single text, unpadded: bit-identical (max abs delta 0.0, cosine 1.0).
    • Mixed-length batch (lengths 15/12/31/3/41 in one call): initially cosine ~0.97 — isolated in pad-probe.ts to padding semantics: the int8 graph is padding-sensitive, and the sidecar itself diverges single-vs-batch by the same amounts (digit-for-digit). Replicating the tokenizer padding config exactly (pad right, pad_id=1, mask 0) restores per-element bit-identity (max abs delta 0.0; cosine >= 0.99999949, dot-product accumulation noise only). Parity therefore means: the native backend must batch/pad exactly like the tokenizers library does — now a known, implementable rule, not a mystery.
    • Matryoshka dimensions: 256 (truncate + renormalize in JS): cosine 1.0, max abs delta ~7e-9.
    • Registry behaviors covered in-process: mixed-length batch in one call, per-model dims (768 native), 256-d truncation. Loud rejection over the models 8192-token context carries over as a native-side check (token counts come from the attention mask, as in the sidecar).

    Performance snapshot (warm medians, shared laptop — rough)

    case in-process sidecar (HTTP)
    single, 15 tokens 36 ms 22 ms
    batch of 32 short 288 ms 526 ms
    single, ~700 tokens 888 ms 1418 ms

    Tiny singles roughly tie; batch and long inputs are ~1.6-1.8x faster in-process, before counting the removed HTTP hop. Lazy load verified: module import 26 ms / no memory change; session created on first embed call (~1.2-1.5 s), RSS +~380 MB with the 311 MB int8 model resident. Boot cost of the server is unchanged; the model cost lands only on first embeddings use.

    Step-2 implementation notes (gotchas the spike paid for)

    1. Pin ONE onnxruntime-node version. @huggingface/transformers nests its own (1.30.x); two versions in one process die on VERS_* symbol mismatch. Dedupe/override to a single copy, or consume transformers own runtime wholesale.
    2. The native binding needs libstdc++/libz on the loader path. Bare dev shells on NixOS need LD_LIBRARY_PATH (same store paths as ~/local-embed/run.sh); step 2 must verify the packaged servers nix closure provides them.
    3. Replicate sidecar semantics exactly in the native backend: truncation DISABLED (the shipped tokenizer.json carries a 512 setting the sidecar deliberately overrides — copying the file defaults naively would silently truncate), >8192 tokens -> loud 400, prompt_tokens = summed mask counts, inputs embedded as-is (no query: prefix magic).

    Hard blockers

    None. The only hard constraints are the two packaging items above (single runtime version; loader path in the nix closure), both step-2 verification tasks, not feasibility risks.

    Step 2 (per the issues sequence)

    Add a native backend behind EMBEDDING_MODELS alongside the HTTP backend (new registration field selecting backend kind; the translation/validation/ledger layer in packages/core/src/embeddings.ts does not change), load tokenizer + session lazily on first call, replicate the padding/truncation semantics above, and re-run this spikes vectors as the parity test in the package suite.

  2. added a commit that references this issue on Nov 3, 2026
  3. dezren39 commented on Nov 3, 2026

    @dezren39
    Author

    Step 2 done — native backend implemented behind the registry (PR #1559, merged adc454be)

    Registration shape. EmbeddingModel gains backend?: EmbeddingBackend in packages/core/src/embeddings.ts: { kind: "http" } or { kind: "native-onnx", snapshotDir?, snapshotEnv?, onnxFile?, maxTokens }. Absent = HTTP. The shipped Snowflake registration is unchanged — the sidecar is still the default; a test pins that. The service dispatches on kind inside the existing observer/timeout/validation wrapper, so translation and the route_attempt ledger behave identically for both kinds.

    Native backend (packages/core/src/embeddings-native.ts) carries the spike's findings exactly:

    • onnxruntime-node 1.31.1 + @huggingface/transformers AutoTokenizer, both new @opencode/core deps; a root overrides entry pins the single onnxruntime-node version (transformers nests 1.30.0 — the VERS_* trap). Installed graph verified to carry exactly one copy.
    • Lazy load: tokenizer + session created on first embed call per snapshot, never at boot.
    • Unigram tokenization with truncation disabled in effect (verified: a 3000-token input encodes to 3002 ids = the sidecar's prompt_tokens), batch padded right with pad_id 1 / mask 0, prompt_tokens = true token counts, >8192 tokens rejected loudly, Matryoshka truncation in float64 → float32, pooling/L2 from the graph's sentence_embedding output.

    Parity suite (packages/core/test/embeddings-native.test.ts + fixture): the spike's five vectors, re-verified delta 0.0 against the live sidecar before baking in — single text bit-identical, mixed-length batch bit-identical per element, 256-d within 1e-6, >8192 rejected. The describe skips cleanly when the model artifact is absent (verified with an empty HOME), so CI stays green without the model file. Full @opencode/core suite exit 0; typecheck clean.

    ⚠ Loader-path finding (step-3 / switch-checklist item). The native bindings — onnxruntime-node's .node and sharp's (loaded transitively by transformers) — need libstdc++ (libstdc++.so.6) and zlib (libz.so.1) on the process loader path. Verified as far as a worktree can: model present + no loader path ⇒ embed fails loudly via the normal channel (Embeddings.UnavailableError: libstdc++.so.6: cannot open shared object file); with LD_LIBRARY_PATH carrying the gcc-lib + zlib store paths (same paths as ~/local-embed/run.sh; here gcc-16.2.0-lib / zlib-1.3.2) the parity suite passes. Before step 3 flips the registration, the packaged server's nix closure must provide libstdc++/libz on the loader path.

    Remaining per the sequence: step 3 flips the registry default to native and live-verifies with :18752 stopped; step 4 retires the sidecar. The sidecar is untouched and still running.

  4. dezren39 commented on Nov 3, 2026

    @dezren39
    Author

    Correction to the step-2 comment above (comment 6614951536): its claim "Full @opencode/core suite exit 0" was wrong — that reading came from a shell pipeline whose exit code was the pager's, not the suite's. The actual full-suite result for PR #1559's branch: 8125 pass / 17 skip / 4 fail (8146 tests).

    The 4 failures were identified and proven pre-existing: the identical 4 fail on the pristine base commit 974ca6c76d (origin/main before this change) with none of the step-2 change applied —

    None are in the embeddings area; all embeddings suites (existing HTTP door tests + the new native parity suite) pass, and the core typecheck is clean. The PR #1559 body has been corrected to state this accurately. Nothing about the step-2 deliverable changes: the native backend, parity results, loader-path finding, and sidecar-default confirmation all stand as reported.

  5. added
    priority/normalOrdinary priority (the default)
    effort/lLarge: multi-day or open-ended; expect staged check-ins
    meta/enrichedIntake review done: acceptance criteria, links, and normalized labels added
    on Nov 3, 2026
  6. dezren39 commented on Nov 3, 2026

    @dezren39
    Author

    Intake review

    Fleet monitor intake pass (2026-11-03T20:55:07Z): verified OPEN, no held/user/owned labels.

    Related (title-search of merged PRs):

    Notes: A merged PR appears to reference this issue — a worker must confirm whether it fully resolves it before closing; not closed by intake because the reference alone is not unambiguous resolution evidence. Acceptance criteria appended to the body. Priority: priority/normal — architectural improvement, no outage. Effort: effort/l.

    Risks / suggested approach: Work from the evidence in the body first; reproduce at current main before changing anything, and route the fix through the queue if it grows past the effort class above.

  7. dezren39 commented on Nov 4, 2026

    @dezren39
    Author

    agent-note:v1
    agent: repo-queue-worker-shift-opencode (lane opencode-a)
    task: 9460
    state: done (triaged)
    Triage at fresh main dc3613c: steps 1–2 of this issue's sequence are already DONE.

  8. added
    queue/queuedHas a queued task in the Supabase agent queue
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    and removed
    queue/queuedHas a queued task in the Supabase agent queue
    on Nov 4, 2026
  9. 11 remaining items

  10. dezren39 commented on Nov 4, 2026

    @dezren39
    Author
  11. dezren39 commented on Nov 4, 2026

    @dezren39
    Author
  12. dezren39 commented on Nov 5, 2026

    @dezren39
    Author

    agent-note:v1
    agent: repo-worker-opencode (event-hook, lane opencode-a)
    task: 9487
    state: parked (predecessor gate)
    Step 3 (flip embeddings registration to native-onnx) claimed and revalidated fresh: both named predecessors are still OPEN — #1567 (task 8843) and #1583 (task 8944; its PR #1632 is still open). PR #1617, the third predecessor, is MERGED. Per the packet this task parks naming #1567/#1583 and does not absorb their fixes. Resume when both #1567 and #1583 close.

  13. dezren39 commented on Nov 5, 2026

    @dezren39
    Author

    🤖 Agent note · parked

  14. added
    queue/claimedA queue worker has claimed the task for this item
    and removed
    status/parkedDeliberately paused with a recorded resume condition
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    on Nov 5, 2026
  15. dezren39 commented on Nov 5, 2026

    @dezren39
    Author

    agent-note:v1
    state: claimed
    task: 9487 (repo-health-opencode-pilot, lane opencode-a, direct)
    note: Predecessors revalidated fresh — #1567 CLOSED/COMPLETED, #1583 CLOSED/COMPLETED, PR #1617 MERGED, PR #1632 MERGED. Proceeding with step 3 flip to native-onnx.

  16. added a commit that references this issue on Nov 5, 2026
  17. dezren39 commented on Nov 5, 2026

    @dezren39
    Author

    agent-note:v1
    state: done (step 3)
    task: 9487
    note: Step 3 flip merged via PR #1690 (merge commit d6be871). EMBEDDING_MODELS[0] now native-onnx with snapshotEnv LOCAL_EMBED_SNAPSHOT + HF-cache snapshotDir default, maxTokens 8192. Verified on laptop: embeddings + embeddings-native suites 20 pass / 0 fail (native parity bit-identical); live index+query round-trip with the :18752 sidecar stopped (768-d, Matryoshka 256-d, mixed-length batch untruncated, retrieval ordering correct). Predecessors #1567/#1583 closed, PRs #1617/#1632 merged. Step 4 (sidecar retirement) filed as #1689 + queue task 9823. This issue stays open pending step 4.

  18. removed
    queue/claimedA queue worker has claimed the task for this item
    work/assignedAn agent holds this item via the queue (claimed task)
    on Nov 5, 2026
  19. dezren39 commented on Nov 8, 2026

    @dezren39
    Author

    Partial verification — staying open (2026-11-07 done-task-only audit, tracking: developing-today/code#7016).

    Queue task(s) [9460, 9487] were marked done, and some work did land:

    No merged change closes this issue's full scope, so the done status is not treated as completion. Remainder re-queued as triage task 11501.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    effort/lLarge: multi-day or open-ended; expect staged check-inskind/enhancementNew feature or requestmeta/enrichedIntake review done: acceptance criteria, links, and normalized labels addedpriority/normalOrdinary priority (the default)task/triageQueue packet kind: verify, reproduce, and route this item

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions