feat(memory): embedders vectoriels réels HTTP + ONNX derrière features (LOT C1)

Remplace les StubEmbedder pour les stratégies localServer/api/localOnnx par de
vrais moteurs, chacun derrière une feature cargo off-by-default — la posture
fondatrice « rien d'imposé, zéro dépendance » (défaut none → rappel naïf) reste
byte-for-byte inchangée.

C1a (feature vector-http, reqwest rustls optional):
- HttpEmbedder couvrant localServer (Ollama/llama.cpp) et api (OpenAI/Voyage…),
  payload OpenAI-compatible /v1/embeddings, ordre restauré par index, bearer
  token lu via env var (jamais en clair), timeout client 30s.
- detect_ollama() pour la détection de l'existant (C3).

C1b (feature vector-onnx, fastembed v5 optional):
- OnnxEmbedder en-process (e5-small, dim 384), init paresseuse + spawn_blocking,
  cache modèle sous <app_data>/embedders/onnx — aucun download au build ni au
  first-run, uniquement à la demande au 1er embed.
- Catalogue RECOMMENDED_ONNX_MODELS + ONNX_CACHE_SUBDIR + onnx_model_is_cached
  exposés (sans feature) pour la config (C2) et la popup (C3).

embedder_from_profile(profile, onnx_cache_dir) dispatche feature-gated ; sans la
feature, retombe sur StubEmbedder (Unsupported) → fallback naïf via
AdaptiveMemoryRecall. Composition root (build_memory_recall) propage le cache dir.

Tests: 10 HTTP + 6 ONNX (dont 2 #[ignore] download réel) + 26 vectoriels, verts
en défaut, --features vector-http et --features vector-onnx.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 19:00:40 +02:00
parent b39c11a64d
commit 32398827fb
10 changed files with 2012 additions and 50 deletions

View File

@ -0,0 +1,202 @@
//! Tests for the real HTTP-backed embedder (LOT C1a, §14.5.3), gated by the
//! `vector-http` feature. They exercise [`HttpEmbedder`] against a **minimal,
//! one-shot, in-process HTTP server** (raw tokio `TcpListener`, no new test
//! dependency) and the [`detect_ollama`] probe.
//!
//! The whole file is compiled out unless `--features vector-http` is set, so the
//! default dependency-free build is unaffected.
#![cfg(feature = "vector-http")]
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use domain::ports::{Embedder, EmbedderError};
use domain::profile::{EmbedderProfile, EmbedderStrategy};
use infrastructure::{detect_ollama, HttpEmbedder};
/// Spawns a one-shot HTTP server on `127.0.0.1:0` that, for the next single
/// connection, reads the full request (honouring `Content-Length`) then writes
/// `response` verbatim and closes. Returns the bound `base` URL (`http://host:port`).
async fn one_shot_server(response: &'static str) -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
if let Ok((mut stream, _)) = listener.accept().await {
drain_request(&mut stream).await;
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
}
});
format!("http://{addr}")
}
/// Reads an HTTP request off `stream` until headers (and any `Content-Length`
/// body) are fully consumed, so the client never sees a premature reset.
async fn drain_request(stream: &mut tokio::net::TcpStream) {
let mut buf = Vec::new();
let mut tmp = [0u8; 1024];
loop {
let headers_end = find_subslice(&buf, b"\r\n\r\n");
if let Some(h) = headers_end {
let header_text = String::from_utf8_lossy(&buf[..h]).to_ascii_lowercase();
let content_len = header_text
.lines()
.find_map(|l| l.strip_prefix("content-length:"))
.and_then(|v| v.trim().parse::<usize>().ok())
.unwrap_or(0);
if buf.len() >= h + 4 + content_len {
return;
}
}
match stream.read(&mut tmp).await {
Ok(0) => return,
Ok(n) => buf.extend_from_slice(&tmp[..n]),
Err(_) => return,
}
}
}
fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack
.windows(needle.len())
.position(|w| w == needle)
}
/// Builds an HTTP `200 OK` response with a JSON body and the right `Content-Length`.
fn ok_json(body: &str) -> String {
format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
)
}
fn local_server_profile(endpoint: &str, dimension: usize) -> EmbedderProfile {
EmbedderProfile::new(
"test-local",
"Test Local",
EmbedderStrategy::LocalServer,
Some("test-model".to_string()),
Some(endpoint.to_string()),
None,
dimension,
)
.unwrap()
}
#[tokio::test]
async fn http_embedder_parses_vectors_and_restores_input_order() {
// The server returns the two embeddings with their `index` fields swapped; the
// embedder must sort by `index` so the output matches the *input* order.
let body = r#"{"data":[
{"embedding":[0.0,1.0],"index":1},
{"embedding":[1.0,0.0],"index":0}
]}"#;
let base = one_shot_server_leaked(ok_json(body)).await;
let embedder = HttpEmbedder::from_profile(&local_server_profile(&base, 2));
let out = embedder
.embed(&["first".to_string(), "second".to_string()])
.await
.expect("embed must succeed");
assert_eq!(out, vec![vec![1.0, 0.0], vec![0.0, 1.0]], "input order restored by index");
}
#[tokio::test]
async fn http_embedder_empty_input_short_circuits_without_network() {
// A closed/never-bound endpoint: no request must be made for empty input.
let embedder = HttpEmbedder::from_profile(&local_server_profile(
"http://127.0.0.1:1/v1/embeddings",
4,
));
let out = embedder.embed(&[]).await.expect("empty input ⇒ empty output, no I/O");
assert!(out.is_empty());
}
#[tokio::test]
async fn http_embedder_non_2xx_is_unavailable() {
let resp = "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\n\r\n".to_string();
let base = one_shot_server_leaked(resp).await;
let embedder = HttpEmbedder::from_profile(&local_server_profile(&base, 2));
let err = embedder.embed(&["x".to_string()]).await.unwrap_err();
assert!(matches!(err, EmbedderError::Unavailable(_)), "got {err:?}");
}
#[tokio::test]
async fn http_embedder_unreachable_host_is_unavailable() {
// Port 1 on loopback: nothing listens ⇒ connection refused ⇒ Unavailable.
let embedder = HttpEmbedder::from_profile(&local_server_profile(
"http://127.0.0.1:1/v1/embeddings",
2,
));
let err = embedder.embed(&["x".to_string()]).await.unwrap_err();
assert!(matches!(err, EmbedderError::Unavailable(_)), "got {err:?}");
}
#[tokio::test]
async fn http_embedder_malformed_body_is_io() {
let base = one_shot_server_leaked(ok_json("not json at all")).await;
let embedder = HttpEmbedder::from_profile(&local_server_profile(&base, 2));
let err = embedder.embed(&["x".to_string()]).await.unwrap_err();
assert!(matches!(err, EmbedderError::Io(_)), "got {err:?}");
}
#[tokio::test]
async fn http_embedder_count_mismatch_is_io() {
// Two inputs but the server returns a single embedding.
let body = r#"{"data":[{"embedding":[1.0,0.0],"index":0}]}"#;
let base = one_shot_server_leaked(ok_json(body)).await;
let embedder = HttpEmbedder::from_profile(&local_server_profile(&base, 2));
let err = embedder
.embed(&["a".to_string(), "b".to_string()])
.await
.unwrap_err();
assert!(matches!(err, EmbedderError::Io(_)), "got {err:?}");
}
#[tokio::test]
async fn http_embedder_dimension_mismatch_is_io() {
// Profile declares dimension 4 but the server returns a length-2 vector.
let body = r#"{"data":[{"embedding":[1.0,0.0],"index":0}]}"#;
let base = one_shot_server_leaked(ok_json(body)).await;
let embedder = HttpEmbedder::from_profile(&local_server_profile(&base, 4));
let err = embedder.embed(&["x".to_string()]).await.unwrap_err();
assert!(matches!(err, EmbedderError::Io(_)), "got {err:?}");
}
#[tokio::test]
async fn http_embedder_api_strategy_missing_key_is_unavailable() {
// `api` strategy whose configured key env var is guaranteed unset ⇒ Unavailable
// *before* any network call (we point at a dead endpoint to prove no request).
let profile = EmbedderProfile::new(
"test-api",
"Test API",
EmbedderStrategy::Api,
Some("model".to_string()),
Some("http://127.0.0.1:1/v1/embeddings".to_string()),
Some("IDEA_TEST_DEFINITELY_UNSET_KEY_VAR".to_string()),
2,
)
.unwrap();
let embedder = HttpEmbedder::from_profile(&profile);
let err = embedder.embed(&["x".to_string()]).await.unwrap_err();
assert!(matches!(err, EmbedderError::Unavailable(_)), "got {err:?}");
}
#[tokio::test]
async fn detect_ollama_true_when_tags_endpoint_ok() {
let base = one_shot_server_leaked(ok_json(r#"{"models":[]}"#)).await;
assert!(detect_ollama(&base).await, "a 200 on /api/tags ⇒ detected");
}
#[tokio::test]
async fn detect_ollama_false_when_nothing_listening() {
// Port 1 on loopback: connection refused ⇒ not detected, never panics.
assert!(!detect_ollama("http://127.0.0.1:1").await);
}
/// Like [`one_shot_server`] but takes an owned `String` and leaks it to obtain the
/// `'static` lifetime the spawned task needs (test-only; the process is short-lived).
async fn one_shot_server_leaked(response: String) -> String {
let leaked: &'static str = Box::leak(response.into_boxed_str());
one_shot_server(leaked).await
}

View File

@ -0,0 +1,215 @@
//! Tests for the real in-process ONNX-backed embedder (LOT C1b, §14.5.3), gated by
//! the `vector-onnx` feature. They exercise [`OnnxEmbedder`] and the
//! [`embedder_from_profile`] mapping *with* the feature on.
//!
//! The whole file is compiled out unless `--features vector-onnx` is set, so the
//! default dependency-free build is unaffected.
//!
//! ## What runs by default vs. behind `#[ignore]`
//!
//! - **No-network tests** (always run with the feature on): every path that
//! short-circuits *before* `fastembed`'s `try_new`/download — empty input, an
//! unknown model, and cheap/infallible construction. These never touch disk or
//! the network, so they are safe in CI.
//! - **Real-download tests** (`#[ignore]`, never run by default): the ones that
//! actually load/download the ~118 MB e5-small model. Run them on demand with
//! `--features vector-onnx --test onnx_embedder -- --ignored`.
#![cfg(feature = "vector-onnx")]
use std::path::PathBuf;
use domain::ports::{Embedder, EmbedderError};
use domain::profile::{EmbedderProfile, EmbedderStrategy};
use infrastructure::{embedder_from_profile, OnnxEmbedder};
use uuid::Uuid;
// ---------------------------------------------------------------------------
// A unique, self-cleaning scratch dir under the OS temp dir (the project's
// established test convention — see e.g. tests/project_store.rs — rather than a
// new `tempfile` dev-dependency). It is created lazily by callers when a real
// download is involved; the no-network tests use a never-created path on purpose.
// ---------------------------------------------------------------------------
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
let p = std::env::temp_dir().join(format!("idea-onnx-{}", Uuid::new_v4()));
std::fs::create_dir_all(&p).unwrap();
Self(p)
}
fn path(&self) -> &std::path::Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// A `localOnnx` profile with the given (optional) model string and dimension.
fn onnx_profile(model: Option<&str>, dimension: usize) -> EmbedderProfile {
EmbedderProfile::new(
"test-onnx",
"Test ONNX",
EmbedderStrategy::LocalOnnx,
model.map(str::to_string),
None,
None,
dimension,
)
.unwrap()
}
// ===========================================================================
// No-network tests (always run with the feature on).
// ===========================================================================
#[tokio::test]
async fn onnx_unknown_model_is_unsupported() {
// A non-empty model string outside the whitelist must surface `Unsupported`
// *before* any `try_new`/download — so this is safe without a network. We point
// the cache at a path that is never created to prove no download is attempted.
let cache = std::path::Path::new("/idea-onnx-cache-that-never-exists");
let embedder = OnnxEmbedder::from_profile(&onnx_profile(Some("definitely-unknown"), 384), cache);
let err = embedder
.embed(&["x".to_string()])
.await
.expect_err("unknown model must error, not embed");
assert!(
matches!(err, EmbedderError::Unsupported(_)),
"unknown model ⇒ Unsupported (no download), got {err:?}"
);
// The cache dir must not have been created by the failed call.
assert!(
!cache.exists(),
"an unknown model must not trigger any I/O / download"
);
}
#[tokio::test]
async fn onnx_empty_input_short_circuits() {
// An empty batch returns `Ok(vec![])` before any model load — even with a known
// model and a never-created cache dir, so it can never download.
let cache = std::path::Path::new("/idea-onnx-cache-that-never-exists-2");
let embedder = OnnxEmbedder::from_profile(&onnx_profile(None, 384), cache);
let out = embedder
.embed(&[])
.await
.expect("empty input ⇒ empty output, no load");
assert!(out.is_empty(), "empty batch ⇒ empty result");
assert!(
!cache.exists(),
"empty input must not trigger any I/O / download"
);
}
#[tokio::test]
async fn onnx_construction_is_cheap() {
// `from_profile` is documented cheap & infallible: no panic, no I/O, and it
// advertises the profile's id and dimension verbatim. Try both a known model
// and an unknown one (construction never fails for either).
let cache = std::path::Path::new("/idea-onnx-cache-that-never-exists-3");
let known = OnnxEmbedder::from_profile(&onnx_profile(Some("multilingual-e5-small"), 384), cache);
assert_eq!(known.id(), "test-onnx");
assert_eq!(known.dimension(), 384);
let default_model = OnnxEmbedder::from_profile(&onnx_profile(None, 384), cache);
assert_eq!(default_model.dimension(), 384);
let unknown = OnnxEmbedder::from_profile(&onnx_profile(Some("definitely-unknown"), 384), cache);
// Construction still succeeds for an unknown model (the error is deferred to embed).
assert_eq!(unknown.id(), "test-onnx");
assert_eq!(unknown.dimension(), 384);
assert!(
!cache.exists(),
"construction must perform no I/O whatsoever"
);
}
#[tokio::test]
async fn embedder_from_profile_localonnx_is_real_engine_not_unsupported_stub() {
// With the feature on, `localOnnx` must map to the real OnnxEmbedder, NOT the
// Unsupported stub. We can prove "not the stub" without a download: a *known*
// model with an empty batch returns Ok(vec![]) (the real engine short-circuits),
// whereas the StubEmbedder would return Unsupported even for an empty batch.
let cache = std::path::Path::new("/idea-onnx-cache-that-never-exists-4");
let known = onnx_profile(Some("multilingual-e5-small"), 384);
let embedder =
embedder_from_profile(&known, cache).expect("localOnnx must yield an embedder");
assert_eq!(embedder.dimension(), 384);
let out = embedder
.embed(&[])
.await
.expect("real engine short-circuits empty input to Ok(vec![])");
assert!(out.is_empty(), "empty batch ⇒ empty result on the real engine");
// And an *unknown* model still surfaces Unsupported (deferred resolution), so the
// mapping is the real engine in both cases (the stub would also say Unsupported,
// but the empty-batch check above already disproves the stub for the known case).
let unknown = onnx_profile(Some("definitely-unknown"), 384);
let embedder =
embedder_from_profile(&unknown, cache).expect("localOnnx must yield an embedder");
let err = embedder
.embed(&["x".to_string()])
.await
.expect_err("unknown model ⇒ Unsupported");
assert!(
matches!(err, EmbedderError::Unsupported(_)),
"unknown model via mapping ⇒ Unsupported, got {err:?}"
);
assert!(!cache.exists(), "no I/O for these short-circuiting paths");
}
// ===========================================================================
// Real-download tests — IGNORED by default (they fetch the ~118 MB e5-small model).
// Run on demand: `cargo test -p infrastructure --features vector-onnx \
// --test onnx_embedder -- --ignored`.
// ===========================================================================
#[tokio::test]
#[ignore = "downloads the ~118 MB e5-small ONNX model; run explicitly with --ignored"]
async fn onnx_embeds_e5_small_real_model() {
let cache = TempDir::new();
let embedder = OnnxEmbedder::from_profile(&onnx_profile(None, 384), cache.path());
let texts = vec!["query: hello".to_string(), "passage: world".to_string()];
let out = embedder
.embed(&texts)
.await
.expect("real e5-small embedding must succeed");
assert_eq!(out.len(), 2, "one vector per input");
for v in &out {
assert_eq!(v.len(), 384, "e5-small produces 384-dim vectors");
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
assert!((norm - 1.0).abs() < 1e-2, "fastembed L2-normalises; norm ≈ 1, got {norm}");
}
// Deterministic across calls (model already cached after the first call).
let again = embedder.embed(&texts).await.expect("second embedding must succeed");
assert_eq!(out, again, "embedding must be deterministic across calls");
}
#[tokio::test]
#[ignore = "loads the e5-small model to observe the dimension-mismatch validation; run with --ignored"]
async fn onnx_dimension_mismatch_is_unavailable() {
// The profile declares 999 dimensions but e5-small produces 384; the model loads
// fine, then per-vector validation rejects the mismatch as `Unavailable`. This
// requires the real model load, hence `#[ignore]`.
let cache = TempDir::new();
let embedder = OnnxEmbedder::from_profile(&onnx_profile(Some("e5-small"), 999), cache.path());
let err = embedder
.embed(&["query: hello".to_string()])
.await
.expect_err("a profile/model dimension mismatch must error");
assert!(
matches!(err, EmbedderError::Unavailable(_)),
"dimension mismatch ⇒ Unavailable, got {err:?}"
);
}

View File

@ -26,8 +26,9 @@ use domain::ports::{
use domain::profile::{EmbedderProfile, EmbedderStrategy};
use domain::project::ProjectPath;
use infrastructure::{
embedder_from_profile, should_use_vector, AdaptiveMemoryRecall, FsMemoryStore, HashEmbedder,
NaiveMemoryRecall, StubEmbedder, VectorMemoryRecall,
embedder_from_profile, onnx_model_is_cached, should_use_vector, AdaptiveMemoryRecall,
FsMemoryStore, HashEmbedder, NaiveMemoryRecall, StubEmbedder, VectorMemoryRecall,
ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
};
// ---------------------------------------------------------------------------
@ -255,26 +256,48 @@ async fn stub_embedder_returns_unsupported() {
#[tokio::test]
async fn embedder_from_profile_maps_each_strategy() {
// none ⇒ no embedder (recall stays naïve).
assert!(embedder_from_profile(&EmbedderProfile::none()).is_none());
// The ONNX cache dir is irrelevant to every strategy exercised here (none of
// these short-circuiting cases loads a model), so a throwaway path is fine.
let onnx_cache = std::path::Path::new("/unused-onnx-cache");
// localOnnx / localServer / api ⇒ a StubEmbedder (Unsupported), dimension kept.
for strategy in [
EmbedderStrategy::LocalOnnx,
EmbedderStrategy::LocalServer,
EmbedderStrategy::Api,
] {
let profile =
EmbedderProfile::new("e", "E", strategy, None, None, None, 24).unwrap();
let embedder = embedder_from_profile(&profile)
.unwrap_or_else(|| panic!("{strategy:?} must yield an embedder"));
assert_eq!(embedder.dimension(), 24);
// none ⇒ no embedder (recall stays naïve).
assert!(embedder_from_profile(&EmbedderProfile::none(), onnx_cache).is_none());
// localOnnx without the `vector-onnx` feature maps to a StubEmbedder
// (Unsupported); with the feature it is the real OnnxEmbedder. Dimension is
// preserved either way (the real engine validates dimension only at embed time).
let onnx =
EmbedderProfile::new("e", "E", EmbedderStrategy::LocalOnnx, None, None, None, 24).unwrap();
let embedder =
embedder_from_profile(&onnx, onnx_cache).expect("localOnnx must yield an embedder");
assert_eq!(embedder.dimension(), 24);
#[cfg(not(feature = "vector-onnx"))]
{
let err = embedder.embed(&["t".to_string()]).await.unwrap_err();
assert!(
matches!(err, EmbedderError::Unsupported(_)),
"{strategy:?} stub must be Unsupported, got {err:?}"
"localOnnx stub must be Unsupported without vector-onnx, got {err:?}"
);
}
// localServer / api: a stub *without* the `vector-http` feature (default,
// dependency-free build), a real HttpEmbedder *with* it. Either way an embedder
// is produced with the declared dimension.
for strategy in [EmbedderStrategy::LocalServer, EmbedderStrategy::Api] {
let profile = EmbedderProfile::new("e", "E", strategy, None, None, None, 24).unwrap();
let embedder = embedder_from_profile(&profile, onnx_cache)
.unwrap_or_else(|| panic!("{strategy:?} must yield an embedder"));
assert_eq!(embedder.dimension(), 24);
#[cfg(not(feature = "vector-http"))]
{
let err = embedder.embed(&["t".to_string()]).await.unwrap_err();
assert!(
matches!(err, EmbedderError::Unsupported(_)),
"{strategy:?} stub must be Unsupported without vector-http, got {err:?}"
);
}
}
}
// ===========================================================================
@ -597,6 +620,93 @@ async fn adaptive_falls_back_to_naive_when_embedder_fails() {
assert!(!got.is_empty(), "the degraded result still returns entries");
}
// ===========================================================================
// 7. Always-available ONNX data/inspection helpers (compiled WITHOUT the
// `vector-onnx` feature — they are pure data + pure-FS, no `fastembed`).
// ===========================================================================
/// A unique, self-cleaning scratch dir under the OS temp dir (project convention;
/// see tests/project_store.rs), used to exercise `onnx_model_is_cached` on real FS.
struct TempDir(std::path::PathBuf);
impl TempDir {
fn new() -> Self {
let p = std::env::temp_dir().join(format!("idea-onnx-cached-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&p).unwrap();
Self(p)
}
fn path(&self) -> &std::path::Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn recommended_onnx_models_advertise_e5_small_384() {
assert_eq!(RECOMMENDED_ONNX_MODELS.len(), 1, "exactly one curated model");
let m = RECOMMENDED_ONNX_MODELS[0];
assert_eq!(m.id, "multilingual-e5-small");
assert_eq!(m.dimension, 384);
assert!(m.recommended, "the single curated model is the recommended default");
}
#[test]
fn onnx_cache_subdir_is_stable() {
assert_eq!(ONNX_CACHE_SUBDIR, "embedders/onnx");
}
#[test]
fn onnx_model_is_cached_false_on_empty_dir() {
// A fresh, empty cache dir ⇒ nothing cached.
let tmp = TempDir::new();
assert!(
!onnx_model_is_cached(tmp.path(), "multilingual-e5-small"),
"an empty cache dir reports no cached model"
);
}
#[test]
fn onnx_model_is_cached_false_when_dir_missing() {
// A path that does not exist must report `false`, never panic.
let missing = std::path::Path::new("/idea-onnx-cache-definitely-missing-xyz");
assert!(!onnx_model_is_cached(missing, "multilingual-e5-small"));
}
#[test]
fn onnx_model_is_cached_true_when_model_subdir_present_and_nonempty() {
// The heuristic looks for a non-empty subdirectory whose (lowercased) name
// *contains* the model token (slashes/underscores normalised to `-`), mirroring
// hf-hub's `models--<org>--<name>` layout.
let tmp = TempDir::new();
let model_dir = tmp.path().join("models--Qdrant--multilingual-e5-small-onnx");
std::fs::create_dir_all(&model_dir).unwrap();
// The subdir must be non-empty to count as "cached".
std::fs::write(model_dir.join("model.onnx"), b"not really a model").unwrap();
assert!(
onnx_model_is_cached(tmp.path(), "multilingual-e5-small"),
"a non-empty matching subdir ⇒ cached"
);
}
#[test]
fn onnx_model_is_cached_false_when_matching_subdir_is_empty() {
// A matching but *empty* subdir does not count as a present cached model.
let tmp = TempDir::new();
std::fs::create_dir_all(tmp.path().join("models--Qdrant--multilingual-e5-small-onnx")).unwrap();
assert!(
!onnx_model_is_cached(tmp.path(), "multilingual-e5-small"),
"an empty matching subdir is not a cached model"
);
}
// ===========================================================================
// 8. AdaptiveMemoryRecall — the étage-1/étage-2 switch (cont.).
// ===========================================================================
#[tokio::test]
async fn adaptive_empty_memory_and_zero_budget_are_empty() {
// Liskov common contract: empty list, never an error, both paths.