Skip to main content

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}