plugmem_host/embedder.rs
1//! The embedder contract and its implementations.
2//!
3//! The engine takes ready vectors; computing them is the host's job.
4//! One HTTP client covers the whole OpenAI-compatible ecosystem —
5//! OpenAI itself, Ollama (`http://localhost:11434/v1/embeddings`), LM Studio,
6//! vLLM, llama.cpp-server — because they all speak the same
7//! `/v1/embeddings` shape; a provider-specific client would be a second
8//! implementation of the same JSON (records the decision).
9
10use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard};
11use std::time::{Duration, Instant};
12
13use crate::error::HostError;
14
15/// How long one embeddings request may take before it is abandoned.
16///
17/// Ten seconds: long enough for a local server to load a model it had unloaded
18/// (seconds, once) and for a remote provider to answer a large batch, short
19/// enough that a provider which accepted the connection and then stopped
20/// talking does not hold a caller for minutes.
21pub const DEFAULT_EMBED_TIMEOUT: Duration = Duration::from_secs(10);
22
23/// One place that turns "how long may this take" into an agent, so the
24/// constructor and the override cannot drift apart.
25fn agent_with_timeout(timeout: Option<Duration>) -> ureq::Agent {
26 ureq::Agent::new_with_config(
27 ureq::Agent::config_builder()
28 .timeout_global(timeout)
29 .build(),
30 )
31}
32
33/// Turns texts into embedding vectors. Batched by design — providers
34/// price and perform far better on batches.
35///
36/// `embed` takes `&self`, and the trait requires `Sync`, because an embedder is
37/// a *client* of a remote service, not a piece of mutable state. Every caller
38/// in this workspace shares one instance across threads (a database's writer,
39/// the napi binding's libuv workers, the MCP worker pool), and a `&mut self`
40/// signature forced every one of them to put a `Mutex` in front of it. That
41/// mutex serialized the HTTP round trips: four concurrent recalls against a
42/// 300 ms provider took 1200 ms, with the provider seeing one request at a
43/// time. With `&self` they take 300 ms and the provider sees four.
44///
45/// An implementation that genuinely needs mutable state (a local cache, a
46/// rate-limit budget) brings its own interior mutability, which is the right
47/// place for it: only that implementation knows what may overlap and what may
48/// not. [`OpenAiCompatEmbedder`] needs none — a `ureq::Agent` is a
49/// connection-pool handle built for concurrent use.
50pub trait Embedder: Send + Sync {
51 /// Stable, human-readable identity of the semantic vector space.
52 /// Different models (or incompatible revisions of one model) must return
53 /// different ids even when their dimensions match.
54 fn space_id(&self) -> &str;
55
56 /// Vector dimension this embedder produces. `0` disables the vector
57 /// layer (the engine is fully functional without it).
58 fn dim(&self) -> usize;
59
60 /// Embeds every text, one vector per input, in input order.
61 ///
62 /// Called concurrently from several threads. An implementation that keeps
63 /// state must guard it itself.
64 ///
65 /// # Errors
66 ///
67 /// [`HostError::Embed`] describing the transport or response
68 /// problem.
69 fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, HostError>;
70}
71
72/// The no-op embedder: dimension 0, never called by the database (a
73/// structural-only memory).
74#[derive(Clone, Copy, Debug, Default)]
75pub struct NullEmbedder;
76
77impl Embedder for NullEmbedder {
78 fn space_id(&self) -> &str {
79 "none"
80 }
81
82 fn dim(&self) -> usize {
83 0
84 }
85
86 fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, HostError> {
87 Ok(vec![Vec::new(); texts.len()])
88 }
89}
90
91/// One embedder handed to several databases.
92///
93/// [`crate::DatabaseBuilder::embedder`] takes ownership, which is right for one
94/// database and wrong for a workspace: a hundred chats do not want a hundred
95/// HTTP clients pointed at the same endpoint. Each database gets its own
96/// `SharedEmbedder` over one shared provider instead.
97///
98/// A plain refcount, with no lock in it. Sharing an [`Embedder`] needs nothing
99/// more, because `embed` takes `&self`; this type used to hold a `Mutex` and
100/// that mutex was the only reason concurrent embedding serialized. Cloning is
101/// an atomic increment, so handing one out per database costs nothing.
102#[derive(Clone)]
103pub struct SharedEmbedder(std::sync::Arc<dyn Embedder>);
104
105impl SharedEmbedder {
106 /// Wraps `inner` so it can be cloned into many databases.
107 pub fn new(inner: Box<dyn Embedder>) -> Self {
108 Self(std::sync::Arc::from(inner))
109 }
110}
111
112impl std::fmt::Debug for SharedEmbedder {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 f.debug_struct("SharedEmbedder")
115 .field("dim", &self.0.dim())
116 .finish()
117 }
118}
119
120impl Embedder for SharedEmbedder {
121 fn space_id(&self) -> &str {
122 self.0.space_id()
123 }
124
125 fn dim(&self) -> usize {
126 self.0.dim()
127 }
128
129 fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, HostError> {
130 self.0.embed(texts)
131 }
132}
133
134/// First wait of the default embedder backoff.
135///
136/// One second, because the cheap failure is the common one: a provider that is
137/// not listening refuses the connection immediately, so retrying often costs
138/// almost nothing — while waiting costs facts stored without vectors after the
139/// provider is already back.
140pub const DEFAULT_EMBED_RETRY_FIRST: Duration = Duration::from_secs(1);
141
142/// Longest wait the default embedder backoff grows to.
143pub const DEFAULT_EMBED_RETRY_MAX: Duration = Duration::from_secs(60);
144
145/// What a database does when its embedder cannot be reached.
146///
147/// The choice only ever concerns *transport and provider* failures
148/// ([`HostError::Embed`]) — a refused connection, a timeout, a 500, a body
149/// that is not the documented shape. A [`plugmem_core::Error::VectorSpaceMismatch`] is a
150/// different thing entirely: the provider answered, and its answer does not
151/// belong in this database. That stays an error under both policies, because
152/// degrading it would mix two semantic spaces in one index, and no later
153/// repair can tell the halves apart.
154#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
155pub enum EmbedErrorPolicy {
156 /// Propagate the failure. A `remember` fails, a text `recall` fails, and
157 /// the caller decides what that means. The default, because it is what
158 /// every release so far did, and because silence is the wrong default for
159 /// a component whose job is to answer.
160 #[default]
161 Fail,
162 /// Carry on without the vector, and suspend the embedder.
163 ///
164 /// The write stores its fact with no vector, the recall answers from the
165 /// lexical, tag, graph and time sources — a smaller answer, never a wrong
166 /// one. Nothing is lost that cannot be recovered: the missing vectors are
167 /// exactly the state a database has when it is written with no embedder at
168 /// all, and [`crate::Database::reembed`] fills them in from the stored text.
169 ///
170 /// The suspension is the other half, and it is the half that matters in
171 /// practice. Without it every later call pays the same failure again —
172 /// a full timeout each, on every recall of every turn — so the degraded
173 /// mode would cost more than the error it replaced.
174 Degrade,
175}
176
177/// When a database that suspended its own embedder calls it again.
178///
179/// Only [`EmbedErrorPolicy::Degrade`] ever suspends by itself, so this is inert
180/// under the default policy. An explicit [`EmbedderGate::suspend`] is
181/// never retried by any of these — a decision is not an observation, and it is
182/// undone by [`EmbedderGate::resume`] alone.
183#[derive(Clone, Copy, Debug, PartialEq, Eq)]
184pub enum EmbedRetry {
185 /// Wait `first`, then double per consecutive failure up to `max`; the
186 /// first success starts over at `first`.
187 ///
188 /// The default, because the two failures worth optimising for pull in
189 /// opposite directions. A provider that blinked (a restart, a reloaded
190 /// model) is back within a second, and a fixed long interval would keep
191 /// storing vectorless facts long after it recovered. A provider that is
192 /// genuinely gone should stop being asked. Doubling serves both without
193 /// being told which one is happening.
194 Backoff {
195 /// Wait after the first failure.
196 first: Duration,
197 /// Ceiling the doubling stops at.
198 max: Duration,
199 },
200 /// The same interval after every failure.
201 Fixed(Duration),
202 /// Never. The host decides when to call [`EmbedderGate::resume`].
203 Manual,
204}
205
206impl Default for EmbedRetry {
207 fn default() -> Self {
208 Self::Backoff {
209 first: DEFAULT_EMBED_RETRY_FIRST,
210 max: DEFAULT_EMBED_RETRY_MAX,
211 }
212 }
213}
214
215impl EmbedRetry {
216 /// The wait after `failures` consecutive failures (`failures >= 1`).
217 fn wait(self, failures: u32) -> Option<Duration> {
218 match self {
219 Self::Manual => None,
220 Self::Fixed(after) => Some(after),
221 Self::Backoff { first, max } => {
222 // Saturating rather than wrapping: a provider down for a day
223 // must not shift its way back to a one-second retry.
224 let factor = 1u32
225 .checked_shl(failures.saturating_sub(1))
226 .unwrap_or(u32::MAX);
227 Some(first.saturating_mul(factor).min(max))
228 }
229 }
230 }
231}
232
233/// Whether a database has an embedder, and whether it is usable right now.
234#[derive(Clone, Copy, Debug, PartialEq, Eq)]
235pub enum EmbedderState {
236 /// None was configured. Vectors are not part of this database's answers.
237 Absent,
238 /// Configured and in use.
239 Active,
240 /// Configured, and currently not called.
241 ///
242 /// Either [`EmbedderGate::suspend`] was called, or a failure under
243 /// [`EmbedErrorPolicy::Degrade`] suspended it. `retry_at` is when the next
244 /// call will try the provider again; `None` means it will not until
245 /// [`EmbedderGate::resume`] says so.
246 Suspended {
247 /// When the next call will try the provider again; `None` = not until
248 /// [`EmbedderGate::resume`].
249 retry_at: Option<Instant>,
250 },
251}
252
253/// The embedder and whether it is currently allowed to be called.
254///
255/// Suspension is deliberately *not* modelled as `provider: None`: a suspended
256/// embedder must come back without the caller having to rebuild it from the
257/// config, and `reembed` has to be able to say "suspended" rather than
258/// "you configured none".
259struct EmbedderSlot {
260 provider: Option<Arc<dyn Embedder>>,
261 /// `Some(None)` = suspended indefinitely; `Some(Some(t))` = until `t`.
262 suspended_until: Option<Option<Instant>>,
263 /// Consecutive failures since the last success. Drives the backoff, and is
264 /// reset by one successful call rather than by the passage of time.
265 failures: u32,
266}
267
268impl EmbedderSlot {
269 fn new(provider: Option<Arc<dyn Embedder>>) -> Self {
270 Self {
271 provider,
272 suspended_until: None,
273 failures: 0,
274 }
275 }
276
277 /// The provider, if it may be called now.
278 ///
279 /// Also the half-open step: a suspension whose deadline has passed is
280 /// cleared here, so the next call goes to the provider and either succeeds
281 /// (back to normal) or suspends it again for a longer interval. There is
282 /// no timer and no background probe — the retry rides on the next call
283 /// that wanted an embedding anyway.
284 fn usable(&mut self, now: Instant) -> Option<Arc<dyn Embedder>> {
285 match self.suspended_until {
286 None => self.provider.clone(),
287 Some(Some(deadline)) if deadline <= now => {
288 self.suspended_until = None;
289 self.provider.clone()
290 }
291 Some(_) => None,
292 }
293 }
294
295 /// Records a failure and suspends accordingly. `now` is passed in so the
296 /// tests can drive the clock instead of sleeping through a backoff.
297 fn note_failure(&mut self, retry: EmbedRetry, now: Instant) {
298 self.failures = self.failures.saturating_add(1);
299 // An explicit suspension outranks a failure's timer: it was a
300 // decision, and a decision is not lifted by a clock.
301 if self.suspended_until.is_some() {
302 return;
303 }
304 self.suspended_until = Some(retry.wait(self.failures).map(|wait| now + wait));
305 }
306
307 fn state(&self) -> EmbedderState {
308 match (&self.provider, self.suspended_until) {
309 (None, _) => EmbedderState::Absent,
310 (Some(_), None) => EmbedderState::Active,
311 (Some(_), Some(retry_at)) => EmbedderState::Suspended { retry_at },
312 }
313 }
314}
315
316/// One vector plus the identity of the space it belongs to.
317///
318/// The two always travel together: a vector without its space is a number
319/// sequence nobody can tell apart from one produced by a different model, and
320/// pairing them anywhere but at the point of production is a chance to pair
321/// them wrongly.
322pub type Embedded = (Vec<f32>, String);
323
324/// A batch of vectors, in input order, plus their shared space identity.
325pub type EmbeddedBatch = (Vec<Vec<f32>>, String);
326
327/// A provider that may be called now, and the space it produces.
328type Ready = (Arc<dyn Embedder>, String);
329
330/// The embedder, the policy for its failures, and whether it may be called.
331///
332/// One implementation, deliberately, because there are two callers and they
333/// must not drift: a read-write [`crate::Database`] embeds inside its verbs,
334/// and a wrapper over a zero-copy [`crate::ReadOnlyDatabase`] embeds the query
335/// itself (the reader carries no provider by design). Before this type the
336/// second path had no policy at all — a dead provider failed every read in
337/// exactly the surface where the memory is only ever read.
338pub struct EmbedderGate {
339 slot: RwLock<EmbedderSlot>,
340 policy: EmbedErrorPolicy,
341 retry: EmbedRetry,
342}
343
344impl EmbedderGate {
345 /// A gate over `provider` (which may be `None` — then it does nothing but
346 /// answer [`EmbedderState::Absent`]).
347 pub fn new(
348 provider: Option<Arc<dyn Embedder>>,
349 policy: EmbedErrorPolicy,
350 retry: EmbedRetry,
351 ) -> Self {
352 Self {
353 slot: RwLock::new(EmbedderSlot::new(provider)),
354 policy,
355 retry,
356 }
357 }
358
359 /// What a caller does when the provider cannot be reached.
360 pub fn policy(&self) -> EmbedErrorPolicy {
361 self.policy
362 }
363
364 /// Whether there is a provider, and whether it is usable right now.
365 pub fn state(&self) -> EmbedderState {
366 let mut slot = self.write();
367 // Through the same half-open step the verbs use, so a state read never
368 // claims "suspended" about an embedder the very next call would use.
369 let _ = slot.usable(Instant::now());
370 slot.state()
371 }
372
373 /// Stops calling the provider until [`Self::resume`]. Idempotent, and a
374 /// no-op when there is no provider.
375 pub fn suspend(&self) {
376 self.write().suspended_until = Some(None);
377 }
378
379 /// Calls the provider again. Nothing is verified here: the next embedding
380 /// finds out, and suspends it again if it is still down.
381 pub fn resume(&self) {
382 self.write().suspended_until = None;
383 }
384
385 /// The configured provider, whether or not it is suspended. For the paths
386 /// that must tell "suspended" from "never configured".
387 pub fn provider(&self) -> Option<Arc<dyn Embedder>> {
388 self.read().provider.clone()
389 }
390
391 /// Embeds one text. `Ok(None)` = carry on without a vector: no provider,
392 /// a suspended one, or - under [`EmbedErrorPolicy::Degrade`] - one that
393 /// just failed.
394 ///
395 /// `check_space` runs after the provider is chosen and before it is
396 /// called, with the space id it would produce. It is where a caller
397 /// refuses a vector that does not belong in its database, and it is
398 /// deliberately outside the policy: a space mismatch is never degraded.
399 pub fn embed_one(
400 &self,
401 text: &str,
402 check_space: impl FnOnce(&str) -> Result<(), HostError>,
403 ) -> Result<Option<Embedded>, HostError> {
404 let Some((embedder, space)) = self.ready(check_space)? else {
405 return Ok(None);
406 };
407 let mut vectors = match embedder.embed(&[text]) {
408 Ok(vectors) => vectors,
409 Err(error) => return self.degrade(error).map(|()| None),
410 };
411 if vectors.len() != 1 {
412 let got = vectors.len();
413 return self
414 .degrade(HostError::Embed(format!("expected 1 embedding, got {got}")))
415 .map(|()| None);
416 }
417 self.note_success();
418 Ok(Some((vectors.remove(0), space)))
419 }
420
421 /// Embeds a whole batch in one provider call. `Ok(None)` means the same as
422 /// in [`Self::embed_one`], and means it for the *whole* batch: a degraded
423 /// bulk write stores every fact vectorless rather than some of them.
424 pub fn embed_many(
425 &self,
426 texts: &[&str],
427 check_space: impl FnOnce(&str) -> Result<(), HostError>,
428 ) -> Result<Option<EmbeddedBatch>, HostError> {
429 let Some((embedder, space)) = self.ready(check_space)? else {
430 return Ok(None);
431 };
432 if texts.is_empty() {
433 return Ok(Some((Vec::new(), space)));
434 }
435 let vectors = match embedder.embed(texts) {
436 Ok(vectors) => vectors,
437 Err(error) => return self.degrade(error).map(|()| None),
438 };
439 if vectors.len() != texts.len() {
440 let (want, got) = (texts.len(), vectors.len());
441 return self
442 .degrade(HostError::Embed(format!(
443 "expected {want} embeddings, got {got}"
444 )))
445 .map(|()| None);
446 }
447 self.note_success();
448 Ok(Some((vectors, space)))
449 }
450
451 /// Replaces the provider and forgets the old one's failures - for a
452 /// reembed, which has just had a new provider answer for every fact.
453 pub(crate) fn install(&self, provider: Arc<dyn Embedder>) {
454 let mut slot = self.write();
455 slot.provider = Some(provider);
456 slot.suspended_until = None;
457 slot.failures = 0;
458 }
459
460 /// The provider to call and the space it produces, or `None` when there is
461 /// nothing to call.
462 fn ready(
463 &self,
464 check_space: impl FnOnce(&str) -> Result<(), HostError>,
465 ) -> Result<Option<Ready>, HostError> {
466 let Some(embedder) = self.usable() else {
467 return Ok(None);
468 };
469 if embedder.dim() == 0 {
470 return Ok(None);
471 }
472 let space = embedder.space_id().to_owned();
473 check_space(&space)?;
474 Ok(Some((embedder, space)))
475 }
476
477 /// The provider, if it may be called now.
478 ///
479 /// The common case - nothing suspended - answers under the shared guard,
480 /// so concurrent callers do not serialize on the slot on their way to a
481 /// provider that takes `&self` precisely so they need not. Only a
482 /// suspension takes the exclusive one, and only to clear an expired
483 /// deadline. Neither guard is ever held across the round trip.
484 fn usable(&self) -> Option<Arc<dyn Embedder>> {
485 {
486 let slot = self.read();
487 if slot.suspended_until.is_none() {
488 return slot.provider.clone();
489 }
490 }
491 self.write().usable(Instant::now())
492 }
493
494 /// Applies the policy to a failed provider call. `Ok(())` = carry on
495 /// without a vector; `Err` = the caller's verb fails.
496 fn degrade(&self, error: HostError) -> Result<(), HostError> {
497 if self.policy != EmbedErrorPolicy::Degrade || !matches!(error, HostError::Embed(_)) {
498 return Err(error);
499 }
500 self.write().note_failure(self.retry, Instant::now());
501 Ok(())
502 }
503
504 /// Ends a backoff after a call that worked. Takes the exclusive guard only
505 /// when there is something to clear, which is never in the case that
506 /// matters - a healthy provider embedding on every write and every recall.
507 fn note_success(&self) {
508 if self.read().failures == 0 {
509 return;
510 }
511 self.write().failures = 0;
512 }
513
514 fn read(&self) -> RwLockReadGuard<'_, EmbedderSlot> {
515 self.slot.read().unwrap_or_else(|e| e.into_inner())
516 }
517
518 fn write(&self) -> RwLockWriteGuard<'_, EmbedderSlot> {
519 self.slot.write().unwrap_or_else(|e| e.into_inner())
520 }
521}
522
523/// An `/v1/embeddings` client for any OpenAI-compatible server.
524#[derive(Debug)]
525pub struct OpenAiCompatEmbedder {
526 url: String,
527 model: String,
528 space_id: String,
529 api_key: Option<String>,
530 dim: usize,
531 agent: ureq::Agent,
532}
533
534impl OpenAiCompatEmbedder {
535 /// Creates a client for the exact embeddings `endpoint_url` (e.g.
536 /// `https://api.openai.com/v1/embeddings` or
537 /// `http://localhost:11434/v1/embeddings`), a model name and the expected
538 /// dimension. The model name is also the default semantic-space id; use
539 /// [`Self::with_space_id`] to declare a stable revision or digest instead.
540 /// The URL is used as supplied; this constructor does not append or
541 /// otherwise rewrite a path. The dimension and identity are explicit — no
542 /// startup probe request, and a server disagreeing with the dimension is a
543 /// typed error, not a silently reconfigured database.
544 pub fn new(endpoint_url: &str, model: &str, dim: usize) -> Self {
545 Self {
546 url: endpoint_url.to_string(),
547 model: model.to_string(),
548 space_id: model.to_string(),
549 api_key: None,
550 dim,
551 agent: agent_with_timeout(Some(DEFAULT_EMBED_TIMEOUT)),
552 }
553 }
554
555 /// Overrides how long one embeddings request may take end to end
556 /// (default: [`DEFAULT_EMBED_TIMEOUT`]; `None` = wait indefinitely).
557 ///
558 /// The bound covers the whole exchange — connect, send, wait, read — not
559 /// each stage, because it exists to answer one question: how long may a
560 /// caller be blocked by this provider before being told it is not
561 /// answering. A provider that hangs rather than refusing is the case this
562 /// is for, and it is the case where an unbounded wait costs a whole turn.
563 pub fn with_timeout(mut self, timeout: Option<Duration>) -> Self {
564 self.agent = agent_with_timeout(timeout);
565 self
566 }
567
568 /// Adds a bearer API key (OpenAI et al.; local servers usually need
569 /// none).
570 pub fn with_api_key(mut self, key: impl Into<String>) -> Self {
571 self.api_key = Some(key.into());
572 self
573 }
574
575 /// Overrides the semantic vector-space identity persisted in the
576 /// database. By default this is the model name passed to [`Self::new`].
577 ///
578 /// Use an explicit id when the provider's request model is an alias, or
579 /// when two differently named endpoints are known to produce compatible
580 /// vectors. Plugmem trusts this declaration and never probes the provider
581 /// to infer it. Invalid ids are rejected when the database first uses the
582 /// embedder.
583 pub fn with_space_id(mut self, space_id: impl Into<String>) -> Self {
584 self.space_id = space_id.into();
585 self
586 }
587}
588
589impl Embedder for OpenAiCompatEmbedder {
590 fn space_id(&self) -> &str {
591 &self.space_id
592 }
593
594 fn dim(&self) -> usize {
595 self.dim
596 }
597
598 fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, HostError> {
599 if texts.is_empty() {
600 return Ok(Vec::new());
601 }
602 let body = serde_json::json!({ "model": self.model, "input": texts });
603 let mut request = self.agent.post(&self.url);
604 if let Some(key) = &self.api_key {
605 request = request.header("Authorization", &format!("Bearer {key}"));
606 }
607 let mut response = request
608 .send_json(&body)
609 .map_err(|e| HostError::Embed(format!("request to {}: {e}", self.url)))?;
610 let value: serde_json::Value = response
611 .body_mut()
612 .read_json()
613 .map_err(|e| HostError::Embed(format!("response body: {e}")))?;
614
615 // { "data": [ { "index": i, "embedding": [f32...] }, ... ] } —
616 // placed by the `index` field, per the contract (providers may
617 // reorder).
618 let data = value
619 .get("data")
620 .and_then(|d| d.as_array())
621 .ok_or_else(|| HostError::Embed("response has no data array".into()))?;
622 if data.len() != texts.len() {
623 return Err(HostError::Embed(format!(
624 "expected {} embeddings, got {}",
625 texts.len(),
626 data.len()
627 )));
628 }
629 let mut out = vec![Vec::new(); texts.len()];
630 for item in data {
631 let index = item
632 .get("index")
633 .and_then(|i| i.as_u64())
634 .ok_or_else(|| HostError::Embed("embedding without an index".into()))?
635 as usize;
636 let raw = item
637 .get("embedding")
638 .and_then(|e| e.as_array())
639 .ok_or_else(|| HostError::Embed("embedding is not an array".into()))?;
640 if index >= out.len() || !out[index].is_empty() {
641 return Err(HostError::Embed(format!("bad embedding index {index}")));
642 }
643 if raw.len() != self.dim {
644 return Err(HostError::Embed(format!(
645 "dimension mismatch: server sent {}, configured {}",
646 raw.len(),
647 self.dim
648 )));
649 }
650 let mut v = Vec::with_capacity(raw.len());
651 for x in raw {
652 v.push(
653 x.as_f64().ok_or_else(|| {
654 HostError::Embed("embedding component is not a number".into())
655 })? as f32,
656 );
657 }
658 out[index] = v;
659 }
660 Ok(out)
661 }
662}
663
664#[cfg(test)]
665mod tests {
666 use super::*;
667
668 use std::sync::atomic::{AtomicUsize, Ordering};
669
670 /// A fixed instant to measure from, so the assertions below are about the
671 /// arithmetic and not about how fast the machine running them is.
672 ///
673 /// Every timing rule here used to be tested by sleeping through a
674 /// millisecond-scale interval against a real database. That works on an
675 /// idle laptop and fails under `cargo tarpaulin`, whose ptrace
676 /// instrumentation makes a write take long enough that a 20 ms deadline
677 /// has already expired by the time the state is read. The mechanism takes
678 /// `now` as an argument precisely so it can be driven instead of waited
679 /// for.
680 fn t0() -> Instant {
681 Instant::now()
682 }
683
684 #[test]
685 fn a_backoff_doubles_per_consecutive_failure_and_stops_at_its_ceiling() {
686 let retry = EmbedRetry::Backoff {
687 first: Duration::from_secs(1),
688 max: Duration::from_secs(8),
689 };
690 let waits: Vec<Duration> = (1..=6).map(|n| retry.wait(n).unwrap()).collect();
691 assert_eq!(
692 waits,
693 [
694 Duration::from_secs(1),
695 Duration::from_secs(2),
696 Duration::from_secs(4),
697 Duration::from_secs(8),
698 Duration::from_secs(8),
699 Duration::from_secs(8),
700 ]
701 );
702 // A provider down for a very long time must not shift its way back to
703 // a one-second retry: the doubling saturates rather than wrapping.
704 assert_eq!(retry.wait(64), Some(Duration::from_secs(8)));
705 assert_eq!(retry.wait(u32::MAX), Some(Duration::from_secs(8)));
706 }
707
708 #[test]
709 fn a_fixed_retry_ignores_the_failure_count_and_manual_never_retries() {
710 let fixed = EmbedRetry::Fixed(Duration::from_millis(250));
711 assert_eq!(fixed.wait(1), Some(Duration::from_millis(250)));
712 assert_eq!(fixed.wait(9), Some(Duration::from_millis(250)));
713 assert_eq!(EmbedRetry::Manual.wait(1), None);
714 }
715
716 #[test]
717 fn a_suspension_expires_on_the_next_call_after_its_deadline() {
718 let mut slot = EmbedderSlot::new(Some(Arc::new(NullEmbedder)));
719 let retry = EmbedRetry::Fixed(Duration::from_secs(30));
720 let now = t0();
721
722 slot.note_failure(retry, now);
723 assert!(slot.usable(now).is_none(), "still inside the interval");
724 assert!(matches!(
725 slot.state(),
726 EmbedderState::Suspended { retry_at: Some(_) }
727 ));
728
729 // One second short of the deadline: still suspended.
730 assert!(slot.usable(now + Duration::from_secs(29)).is_none());
731 // At it: the half-open step clears the suspension and hands the
732 // provider back, without any timer having run.
733 assert!(slot.usable(now + Duration::from_secs(30)).is_some());
734 assert_eq!(slot.state(), EmbedderState::Active);
735 }
736
737 #[test]
738 fn consecutive_failures_lengthen_the_wait_and_a_success_resets_it() {
739 let mut slot = EmbedderSlot::new(Some(Arc::new(NullEmbedder)));
740 let retry = EmbedRetry::Backoff {
741 first: Duration::from_secs(1),
742 max: Duration::from_secs(60),
743 };
744 let now = t0();
745
746 slot.note_failure(retry, now);
747 // Second failure, after the first suspension expired: two seconds now,
748 // so one is no longer enough.
749 assert!(slot.usable(now + Duration::from_secs(1)).is_some());
750 slot.note_failure(retry, now + Duration::from_secs(1));
751 assert!(slot.usable(now + Duration::from_secs(2)).is_none());
752 assert!(slot.usable(now + Duration::from_secs(3)).is_some());
753
754 // A success puts the ladder back to the bottom.
755 slot.failures = 0;
756 slot.note_failure(retry, now + Duration::from_secs(3));
757 assert!(slot.usable(now + Duration::from_secs(4)).is_some());
758 }
759
760 #[test]
761 fn an_explicit_suspension_has_no_deadline_and_survives_a_failure() {
762 let mut slot = EmbedderSlot::new(Some(Arc::new(NullEmbedder)));
763 let now = t0();
764 slot.suspended_until = Some(None);
765 slot.note_failure(EmbedRetry::Fixed(Duration::from_millis(1)), now);
766 // A decision is not undone by a clock, however much of it passes.
767 assert!(slot.usable(now + Duration::from_secs(3600)).is_none());
768 assert_eq!(slot.state(), EmbedderState::Suspended { retry_at: None });
769 }
770
771 /// Answers with a count of its own choosing, so the "the provider broke
772 /// its own contract" branch can be reached without a server.
773 struct MiscountingEmbedder(usize);
774
775 impl Embedder for MiscountingEmbedder {
776 fn space_id(&self) -> &str {
777 "miscounting"
778 }
779
780 fn dim(&self) -> usize {
781 4
782 }
783
784 fn embed(&self, _texts: &[&str]) -> Result<Vec<Vec<f32>>, HostError> {
785 Ok(vec![vec![0.0; 4]; self.0])
786 }
787 }
788
789 #[test]
790 fn a_provider_answering_with_the_wrong_number_of_vectors_follows_the_policy() {
791 // Not a transport failure, but the same class of problem: the answer
792 // cannot be used. Under `fail` it is an error; under `degrade` it costs
793 // the vector and suspends the provider, exactly like a refused
794 // connection - anything else would let a broken provider quietly write
795 // a vector against the wrong fact.
796 let strict = EmbedderGate::new(
797 Some(Arc::new(MiscountingEmbedder(2))),
798 EmbedErrorPolicy::Fail,
799 EmbedRetry::Manual,
800 );
801 assert!(matches!(
802 strict.embed_one("one text", |_| Ok(())),
803 Err(HostError::Embed(_))
804 ));
805 assert!(matches!(
806 strict.embed_many(&["a", "b", "c"], |_| Ok(())),
807 Err(HostError::Embed(_))
808 ));
809 assert_eq!(strict.state(), EmbedderState::Active);
810
811 let lenient = EmbedderGate::new(
812 Some(Arc::new(MiscountingEmbedder(2))),
813 EmbedErrorPolicy::Degrade,
814 EmbedRetry::Manual,
815 );
816 assert_eq!(lenient.embed_one("one text", |_| Ok(())).unwrap(), None);
817 assert_eq!(lenient.state(), EmbedderState::Suspended { retry_at: None });
818 assert_eq!(lenient.policy(), EmbedErrorPolicy::Degrade);
819 }
820
821 #[test]
822 fn an_empty_batch_answers_without_a_round_trip() {
823 let gate = EmbedderGate::new(
824 Some(Arc::new(Counting(AtomicUsize::new(0)))),
825 EmbedErrorPolicy::Fail,
826 EmbedRetry::Manual,
827 );
828 let (vectors, space) = gate.embed_many(&[], |_| Ok(())).unwrap().unwrap();
829 assert!(vectors.is_empty());
830 assert_eq!(space, "test/counting");
831 assert_eq!(gate.provider().unwrap().dim(), 3);
832 }
833
834 #[test]
835 fn a_refused_space_is_not_a_failure_the_policy_may_swallow() {
836 // `check_space` runs before the provider is called and its error is
837 // the caller's, not the provider's: degrading it would mix two
838 // semantic spaces in one index.
839 let gate = EmbedderGate::new(
840 Some(Arc::new(Counting(AtomicUsize::new(0)))),
841 EmbedErrorPolicy::Degrade,
842 EmbedRetry::Manual,
843 );
844 let refused = gate.embed_one("a text", |_| {
845 Err(HostError::Engine(plugmem_core::Error::UntrackedVectorSpace))
846 });
847 assert!(matches!(refused, Err(HostError::Engine(_))));
848 // And nothing was suspended: the provider never misbehaved.
849 assert_eq!(gate.state(), EmbedderState::Active);
850 }
851
852 #[test]
853 fn a_slot_with_no_provider_is_absent_whatever_is_done_to_it() {
854 let mut slot = EmbedderSlot::new(None);
855 assert_eq!(slot.state(), EmbedderState::Absent);
856 slot.suspended_until = Some(None);
857 assert_eq!(slot.state(), EmbedderState::Absent);
858 assert!(slot.usable(t0()).is_none());
859 }
860
861 /// Counts its calls, so a test can tell one shared provider from several
862 /// independent ones. State behind an atomic because `embed` takes `&self`
863 /// — the arrangement the trait asks a stateful implementation to make.
864 struct Counting(AtomicUsize);
865 impl Embedder for Counting {
866 fn space_id(&self) -> &str {
867 "test/counting"
868 }
869
870 fn dim(&self) -> usize {
871 3
872 }
873 fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, HostError> {
874 let total = self.0.fetch_add(texts.len(), Ordering::Relaxed) + texts.len();
875 Ok(vec![vec![total as f32; 3]; texts.len()])
876 }
877 }
878
879 /// Blocks for a moment and records how many calls were inside `embed` at
880 /// once, which is what a mutex in front of it would hold at one.
881 struct Overlapping {
882 inside: AtomicUsize,
883 peak: AtomicUsize,
884 }
885 impl Embedder for Overlapping {
886 fn space_id(&self) -> &str {
887 "test/overlapping"
888 }
889
890 fn dim(&self) -> usize {
891 1
892 }
893 fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, HostError> {
894 let now = self.inside.fetch_add(1, Ordering::SeqCst) + 1;
895 self.peak.fetch_max(now, Ordering::SeqCst);
896 std::thread::sleep(std::time::Duration::from_millis(50));
897 self.inside.fetch_sub(1, Ordering::SeqCst);
898 Ok(vec![vec![0.0]; texts.len()])
899 }
900 }
901
902 #[test]
903 fn clones_of_a_shared_embedder_reach_the_same_provider() {
904 let shared = SharedEmbedder::new(Box::new(Counting(AtomicUsize::new(0))));
905 let a = shared.clone();
906 let b = shared.clone();
907
908 assert_eq!(a.dim(), 3);
909 assert_eq!(format!("{shared:?}"), "SharedEmbedder { dim: 3 }");
910
911 // Two databases' worth of handles, one counter behind them: the second
912 // call sees the first one's effect.
913 assert_eq!(a.embed(&["x"]).unwrap(), vec![vec![1.0; 3]]);
914 assert_eq!(b.embed(&["y", "z"]).unwrap(), vec![vec![3.0; 3]; 2]);
915 }
916
917 #[test]
918 fn concurrent_callers_are_inside_the_provider_at_the_same_time() {
919 // The invariant the `&self` signature exists for. Under the old
920 // `&mut self` trait this could not be written at all: the `Mutex` that
921 // a shared embedder needed held `peak` at 1, and four concurrent
922 // recalls against a slow provider cost four round trips.
923 let provider = std::sync::Arc::new(Overlapping {
924 inside: AtomicUsize::new(0),
925 peak: AtomicUsize::new(0),
926 });
927 let shared = SharedEmbedder(provider.clone());
928
929 std::thread::scope(|scope| {
930 for _ in 0..4 {
931 let handle = shared.clone();
932 scope.spawn(move || handle.embed(&["question"]).unwrap());
933 }
934 });
935
936 assert!(
937 provider.peak.load(Ordering::SeqCst) > 1,
938 "callers serialized: peak concurrency was {}",
939 provider.peak.load(Ordering::SeqCst)
940 );
941 }
942
943 #[test]
944 fn the_null_embedder_produces_one_empty_vector_per_text() {
945 let null = NullEmbedder;
946 assert_eq!(null.dim(), 0);
947 assert_eq!(null.embed(&["a", "b"]).unwrap(), vec![Vec::<f32>::new(); 2]);
948 }
949}