Build: The Embed Load Target
Maps to: Task 5.1 + 5.2 (the load half). Kind: Build — spec and test names only. No implementation.
Objective
Implement two things that form the write side of semantic retrieval: VoyageEmbedder, a concrete Embedder backed by a real HTTP API but tested entirely against a mock; and EmbedSink, a Sink that embeds prose only, content-addressed so the same text is never embedded twice. Together they are how launch descriptions become durable, deduplicated vectors on disk.
Scaffold
Create: crates/etl-core/src/vector.rs (the EmbedSink, plus Row::prose); crates/etl-sources/src/voyage.rs (the VoyageEmbedder). The Embedder trait itself lives in crates/etl-core/src/embed.rs (Task 5.1). Declare the modules and re-export EmbedSink, EmbeddedRow, VectorIndex, cosine from etl-core's lib.rs, and VoyageEmbedder from etl-sources's lib.rs.
Dependencies: none new. reqwest, serde/serde_json, tokio, async-trait, sha2 and the dev-only wiremock were all declared in earlier arcs. The embedder reuses the HTTP-error classifier (crate::http::classify_status) from the authenticated arc.
Expected result: cargo test -p etl-sources voyage → the Voyage tests pass; cargo test -p etl-core embed (or vector) → the sink tests pass.
The spec (givens) — VoyageEmbedder
- Fields:
base_url,api_key,model(allString;base_urlinjectable so tests point at a mock). Constructornew(base_url, api_key)defaultingmodelto"voyage-3"; plusfrom_env(base_url)that readsVOYAGE_API_KEYand maps a missing key toEtlError::Auth. - Implements
Embedder.embed(&self, texts: &[String]):- Empty input is a no-op: if
textsis empty, returnOk(vec![])without making a request. (This is a real correctness rule — an empty batch must not cost a round trip, and some APIs reject an emptyinput.) POST {base_url}/v1/embeddings(trim a trailing/offbase_url), bearer auth with the key, JSON body{"input": <texts>, "model": <model>}.- On a non-success status, return
classify_status(status, headers)— the same transient/permanent split the retry layer reads. - Parse
{"data": [{"embedding": [...]}, ...]}and return the embeddings in order. A parse failure maps toEtlError::Parse.
- Empty input is a no-op: if
The spec (givens) — Row::prose + EmbedSink
Row::prose(&self) -> Option<String>— returnsSome(description)only for aRow::Launchwhosedescriptionis non-empty aftertrim;NoneforSpaceObject,Conjunction, and empty-description launches. This method is the structured-vs-semantic split; the sink embeds exactly what it returns.EmbeddedRow { id: String, text: String, vector: Vec<f32> }— one embedded prose field;Serialize/Deserialize, so it round-trips through JSONL.EmbedSink<E: Embedder>— fields: theembedder, apath: PathBuf, and aseen: Mutex<HashSet<String>>. Two constructors:new(embedder, path)(emptyseen) andwith_seen(embedder, path, ids)(seedseenfrom ids already on disk).impl Sink for EmbedSink—load(&self, rows):- For each row, take
row.prose(); skipNone. Computecontent_id("embed", &text). Insert the id intoseen; keep the text only if the insert was new (dedupe within and across loads). - If nothing is fresh, return
Ok(0)— no embed call. - Embed the fresh texts in one batch. Guard the length: if the embedder returns a vector count that differs from the text count, return
EtlError::Parse— never silently misalign texts and vectors. - Append one
EmbeddedRowper (id, text, vector) as JSONL (create + append, single bufferedwrite_all, same file discipline asJsonlSink). Return the count written.
- For each row, take
await
The dedupe check touches shared state (seen); the embed call is a slow network await. Hold the lock only long enough to insert ids and collect the fresh texts, then drop it before embed(...).await. Hold it across the await and every concurrent load serializes behind one HTTP call — you have turned a batching sink into a global mutex. Scope the lock in an inner block; the shipped code does exactly this.
Test names to hit
VoyageEmbedder (wiremock):
embeds_batch_of_texts_with_bearer_auth— mount a mock matchingPOST /v1/embeddingsandheader("authorization", "Bearer test-key"), returning two embeddings. Callembedwith two texts; assert the parsed vectors equal the mocked ones. The header matcher is an assertion that your client actually sent bearer auth.empty_input_makes_no_request— construct an embedder pointing at an unroutable address, mount no mock, and assertembed(&[])returns an emptyVec— proving the empty-input short-circuit never calls out.
EmbedSink:
embed_sink_only_touches_prose— load aLaunch(has prose) and aConjunction(no prose) through aCountingEmbedder; assert the return is1and the embedder was called for exactly1text. Proves the split: numbers are never embedded.cached_text_not_re_embedded— load two launches with identical descriptions; assert1written and1embed call (dedupe within a load). Load the same rows again; assert0written and the call count unchanged (dedupe across loads). This is the content-addressed cache, made a test.
cached_text_not_re_embedded, before writing the code: what does the *second* load of the same rows return, and how many total embed calls has the CountingEmbedder seen by the end? Write both numbers down. If your prediction for the second load is anything but 0 new rows and an unchanged call count, your dedupe is keyed on the wrong thing.
The build loop (you drive)
- Build the
Embeddertrait first (Task 5.1,embed.rs) — one async method; it is three lines but it is the seam everything else names. - Write
embeds_batch_of_texts_with_bearer_auth, then implementVoyageEmbedderagainst it. Addempty_input_makes_no_requestand confirm the short-circuit. - Write
embed_sink_only_touches_prosewith aCountingEmbeddermock (deterministic vectors, an atomic call counter). ImplementRow::proseand the prose-filtering half ofload. - Write
cached_text_not_re_embedded. Predict both loads' results, then implement theseen/content_iddedupe. Watch the lock scope (see the trap). - Run green, commit.
Done when
cargo test -p etl-sources voyage and cargo test -p etl-core embed both pass. You now have the write side of retrieval: prose becomes deduplicated EmbeddedRow JSONL, at most one embed call per distinct text, and a real HTTP client proven correct without spending a cent. The next build page reads those vectors back — the search side — and wires the whole embed step into the orchestrator.