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:
970
Cargo.lock
generated
970
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -31,6 +31,12 @@ serde_json = { workspace = true }
|
|||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# Passthrough toggles to enable the real embedders in an IDE build. OFF by default
|
||||||
|
# (founding posture: `none` ⇒ naïve recall, zero dependency).
|
||||||
|
vector-http = ["infrastructure/vector-http"]
|
||||||
|
vector-onnx = ["infrastructure/vector-onnx"]
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
|
|||||||
@ -35,7 +35,7 @@ use infrastructure::{
|
|||||||
FsEmbedderProfileStore, FsMemoryStore, FsOrchestratorWatcher, FsProfileStore, FsProjectStore,
|
FsEmbedderProfileStore, FsMemoryStore, FsOrchestratorWatcher, FsProfileStore, FsProjectStore,
|
||||||
FsSkillStore, FsTemplateStore, Git2Repository, IdeaiContextStore, LocalFileSystem,
|
FsSkillStore, FsTemplateStore, Git2Repository, IdeaiContextStore, LocalFileSystem,
|
||||||
LocalProcessSpawner, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter,
|
LocalProcessSpawner, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter,
|
||||||
SystemClock, TokioBroadcastEventBus, UuidGenerator, VectorMemoryRecall,
|
SystemClock, TokioBroadcastEventBus, UuidGenerator, VectorMemoryRecall, ONNX_CACHE_SUBDIR,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::pty::PtyBridge;
|
use crate::pty::PtyBridge;
|
||||||
@ -393,10 +393,12 @@ impl AppState {
|
|||||||
.flatten()
|
.flatten()
|
||||||
})
|
})
|
||||||
.unwrap_or_else(EmbedderProfile::none);
|
.unwrap_or_else(EmbedderProfile::none);
|
||||||
|
let onnx_cache_dir = app_data_dir.join(ONNX_CACHE_SUBDIR);
|
||||||
let memory_recall_port = build_memory_recall(
|
let memory_recall_port = build_memory_recall(
|
||||||
Arc::clone(&fs_port),
|
Arc::clone(&fs_port),
|
||||||
Arc::clone(&memory_store_port),
|
Arc::clone(&memory_store_port),
|
||||||
&embedder_profile,
|
&embedder_profile,
|
||||||
|
&onnx_cache_dir,
|
||||||
);
|
);
|
||||||
|
|
||||||
let create_agent = Arc::new(CreateAgentFromScratch::new(
|
let create_agent = Arc::new(CreateAgentFromScratch::new(
|
||||||
@ -711,9 +713,10 @@ pub(crate) fn build_memory_recall(
|
|||||||
fs: Arc<dyn FileSystem>,
|
fs: Arc<dyn FileSystem>,
|
||||||
store: Arc<dyn MemoryStore>,
|
store: Arc<dyn MemoryStore>,
|
||||||
profile: &EmbedderProfile,
|
profile: &EmbedderProfile,
|
||||||
|
onnx_cache_dir: &std::path::Path,
|
||||||
) -> Arc<dyn MemoryRecall> {
|
) -> Arc<dyn MemoryRecall> {
|
||||||
let naive = Arc::new(NaiveMemoryRecall::new(Arc::clone(&store))) as Arc<dyn MemoryRecall>;
|
let naive = Arc::new(NaiveMemoryRecall::new(Arc::clone(&store))) as Arc<dyn MemoryRecall>;
|
||||||
match embedder_from_profile(profile) {
|
match embedder_from_profile(profile, onnx_cache_dir) {
|
||||||
None => naive,
|
None => naive,
|
||||||
Some(embedder) => {
|
Some(embedder) => {
|
||||||
let embedder: Arc<dyn Embedder> = Arc::from(embedder);
|
let embedder: Arc<dyn Embedder> = Arc::from(embedder);
|
||||||
@ -839,7 +842,12 @@ mod tests {
|
|||||||
async fn build_memory_recall_none_profile_matches_naive_recall() {
|
async fn build_memory_recall_none_profile_matches_naive_recall() {
|
||||||
let (fs, store) = seed_store().await;
|
let (fs, store) = seed_store().await;
|
||||||
|
|
||||||
let wired = build_memory_recall(Arc::clone(&fs), Arc::clone(&store), &EmbedderProfile::none());
|
let wired = build_memory_recall(
|
||||||
|
Arc::clone(&fs),
|
||||||
|
Arc::clone(&store),
|
||||||
|
&EmbedderProfile::none(),
|
||||||
|
std::path::Path::new("/unused-onnx-cache"),
|
||||||
|
);
|
||||||
let naive = NaiveMemoryRecall::new(Arc::clone(&store));
|
let naive = NaiveMemoryRecall::new(Arc::clone(&store));
|
||||||
|
|
||||||
// 0 ⇒ empty; mid budgets ⇒ partial truncation; huge ⇒ full index order.
|
// 0 ⇒ empty; mid budgets ⇒ partial truncation; huge ⇒ full index order.
|
||||||
|
|||||||
@ -23,3 +23,20 @@ git2 = { workspace = true }
|
|||||||
# Filesystem change notifications used to *wake* the orchestrator poll loop early
|
# Filesystem change notifications used to *wake* the orchestrator poll loop early
|
||||||
# (the poll loop remains the robust cross-platform correctness guarantee).
|
# (the poll loop remains the robust cross-platform correctness guarantee).
|
||||||
notify = "6"
|
notify = "6"
|
||||||
|
# Optional HTTP client for the real `localServer`/`api` embedders (LOT C1a,
|
||||||
|
# §14.5.3). `default-features = false` + `rustls-tls` keeps it OpenSSL-free so the
|
||||||
|
# AppImage stays portable; pulled in *only* under the `vector-http` feature so the
|
||||||
|
# zero-dependency default (`none` strategy) compiles nothing extra.
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true }
|
||||||
|
# Optional in-process ONNX embedder (LOT C1b, §14.5.3). rustls all the way down
|
||||||
|
# (HF model download + ort binaries fetched at build time, NOT load-dynamic) so the
|
||||||
|
# AppImage stays self-contained and OpenSSL-free. Pulled in *only* under the
|
||||||
|
# `vector-onnx` feature; the zero-dependency default compiles nothing extra.
|
||||||
|
fastembed = { version = "5", default-features = false, features = ["hf-hub-rustls-tls", "ort-download-binaries-rustls-tls"], optional = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# Real HTTP-backed embedders (`localServer` Ollama/llama.cpp, `api` OpenAI/Voyage…).
|
||||||
|
# OFF by default: the founding posture is `none` ⇒ naïve recall, zero dependency.
|
||||||
|
vector-http = ["dep:reqwest"]
|
||||||
|
# Real in-process ONNX embedder (`localOnnx`). OFF by default, same posture.
|
||||||
|
vector-onnx = ["dep:fastembed"]
|
||||||
|
|||||||
@ -40,8 +40,12 @@ pub use pty::PortablePtyAdapter;
|
|||||||
pub use remote::{remote_host, LocalHost};
|
pub use remote::{remote_host, LocalHost};
|
||||||
pub use runtime::CliAgentRuntime;
|
pub use runtime::CliAgentRuntime;
|
||||||
pub use store::{
|
pub use store::{
|
||||||
embedder_from_profile, index_token_size, should_use_vector, AdaptiveMemoryRecall,
|
embedder_from_profile, index_token_size, onnx_model_is_cached, should_use_vector,
|
||||||
FsEmbedderProfileStore, FsMemoryStore, FsProfileStore, FsProjectStore, FsSkillStore,
|
AdaptiveMemoryRecall, FsEmbedderProfileStore, FsMemoryStore, FsProfileStore, FsProjectStore,
|
||||||
FsTemplateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, StubEmbedder,
|
FsSkillStore, FsTemplateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall,
|
||||||
VectorMemoryRecall,
|
OnnxModelInfo, StubEmbedder, VectorMemoryRecall, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
|
||||||
};
|
};
|
||||||
|
#[cfg(feature = "vector-http")]
|
||||||
|
pub use store::{detect_ollama, HttpEmbedder, DEFAULT_LOCAL_EMBED_ENDPOINT};
|
||||||
|
#[cfg(feature = "vector-onnx")]
|
||||||
|
pub use store::OnnxEmbedder;
|
||||||
|
|||||||
@ -31,28 +31,57 @@ use domain::profile::{EmbedderProfile, EmbedderStrategy};
|
|||||||
/// Builds the concrete [`Embedder`] for a profile, or `None` when the strategy is
|
/// Builds the concrete [`Embedder`] for a profile, or `None` when the strategy is
|
||||||
/// [`EmbedderStrategy::None`] (recall stays naïve).
|
/// [`EmbedderStrategy::None`] (recall stays naïve).
|
||||||
///
|
///
|
||||||
/// The concrete engines are stubs ([`StubEmbedder`]) until their real ONNX/HTTP
|
/// The remaining concrete engines are stubs ([`StubEmbedder`]) until their real
|
||||||
/// integration lands; they fail cleanly with [`EmbedderError::Unsupported`] rather
|
/// HTTP/ONNX integration is enabled via the matching feature; they fail cleanly
|
||||||
/// than panic, so a composing recall degrades to naïve.
|
/// with [`EmbedderError::Unsupported`] rather than panic, so a composing recall
|
||||||
|
/// degrades to naïve.
|
||||||
|
///
|
||||||
|
/// `onnx_cache_dir` is the directory under which a `localOnnx` engine caches its
|
||||||
|
/// model files (`<app_data_dir>/embedders/onnx`, see [`ONNX_CACHE_SUBDIR`]). It is
|
||||||
|
/// ignored by every other strategy.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn embedder_from_profile(profile: &EmbedderProfile) -> Option<Box<dyn Embedder>> {
|
pub fn embedder_from_profile(
|
||||||
|
profile: &EmbedderProfile,
|
||||||
|
onnx_cache_dir: &std::path::Path,
|
||||||
|
) -> Option<Box<dyn Embedder>> {
|
||||||
|
let _ = onnx_cache_dir; // used only under `vector-onnx`; silence the unused warning otherwise.
|
||||||
match profile.strategy {
|
match profile.strategy {
|
||||||
EmbedderStrategy::None => None,
|
EmbedderStrategy::None => None,
|
||||||
EmbedderStrategy::LocalOnnx => Some(Box::new(StubEmbedder::new(
|
EmbedderStrategy::LocalOnnx => {
|
||||||
|
#[cfg(feature = "vector-onnx")]
|
||||||
|
{
|
||||||
|
Some(Box::new(OnnxEmbedder::from_profile(profile, onnx_cache_dir)))
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "vector-onnx"))]
|
||||||
|
{
|
||||||
|
Some(Box::new(StubEmbedder::new(
|
||||||
profile.id.clone(),
|
profile.id.clone(),
|
||||||
profile.dimension,
|
profile.dimension,
|
||||||
"localOnnx",
|
"localOnnx",
|
||||||
))),
|
)))
|
||||||
EmbedderStrategy::LocalServer => Some(Box::new(StubEmbedder::new(
|
}
|
||||||
|
}
|
||||||
|
// Real HTTP engines under the `vector-http` feature; the dependency-free
|
||||||
|
// build keeps the documented stub so the default posture is unchanged.
|
||||||
|
EmbedderStrategy::LocalServer | EmbedderStrategy::Api => {
|
||||||
|
#[cfg(feature = "vector-http")]
|
||||||
|
{
|
||||||
|
Some(Box::new(HttpEmbedder::from_profile(profile)))
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "vector-http"))]
|
||||||
|
{
|
||||||
|
let strategy = if profile.strategy == EmbedderStrategy::Api {
|
||||||
|
"api"
|
||||||
|
} else {
|
||||||
|
"localServer"
|
||||||
|
};
|
||||||
|
Some(Box::new(StubEmbedder::new(
|
||||||
profile.id.clone(),
|
profile.id.clone(),
|
||||||
profile.dimension,
|
profile.dimension,
|
||||||
"localServer",
|
strategy,
|
||||||
))),
|
)))
|
||||||
EmbedderStrategy::Api => Some(Box::new(StubEmbedder::new(
|
}
|
||||||
profile.id.clone(),
|
}
|
||||||
profile.dimension,
|
|
||||||
"api",
|
|
||||||
))),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -171,3 +200,411 @@ fn fnv1a(bytes: &[u8]) -> u64 {
|
|||||||
}
|
}
|
||||||
hash
|
hash
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HttpEmbedder — real `localServer` / `api` engine (LOT C1a, feature `vector-http`).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Default Ollama (and llama.cpp `--api`) OpenAI-compatible embeddings endpoint,
|
||||||
|
/// used when a `localServer` profile leaves [`EmbedderProfile::endpoint`] empty.
|
||||||
|
#[cfg(feature = "vector-http")]
|
||||||
|
pub const DEFAULT_LOCAL_EMBED_ENDPOINT: &str = "http://localhost:11434/v1/embeddings";
|
||||||
|
|
||||||
|
/// A real [`Embedder`] talking to an **OpenAI-compatible** `/v1/embeddings`
|
||||||
|
/// endpoint over HTTP — the shape served by Ollama, llama.cpp's server, HuggingFace
|
||||||
|
/// text-embeddings-inference, and the OpenAI/Voyage/Together APIs alike. One adapter
|
||||||
|
/// covers both [`EmbedderStrategy::LocalServer`] (no auth) and
|
||||||
|
/// [`EmbedderStrategy::Api`] (a `Bearer` token read from an env var — never the key
|
||||||
|
/// itself in config).
|
||||||
|
///
|
||||||
|
/// **Best-effort by contract** (see [`Embedder`]): an unreachable host, a non-2xx
|
||||||
|
/// status, a missing API key, or a malformed body all map to a clean
|
||||||
|
/// [`EmbedderError`] (never a panic), so a composing [`crate::store::AdaptiveMemoryRecall`]
|
||||||
|
/// degrades to the naïve recall. Construction is cheap and infallible; nothing
|
||||||
|
/// hits the network until [`embed`](Embedder::embed) is called (so the
|
||||||
|
/// zero-download posture holds until the embedder is actually used).
|
||||||
|
#[cfg(feature = "vector-http")]
|
||||||
|
pub struct HttpEmbedder {
|
||||||
|
id: String,
|
||||||
|
dimension: usize,
|
||||||
|
endpoint: String,
|
||||||
|
model: Option<String>,
|
||||||
|
/// `Some(var)` for the `api` strategy: the env var carrying the bearer token.
|
||||||
|
api_key_env: Option<String>,
|
||||||
|
client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "vector-http")]
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct EmbeddingsRequest<'a> {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
model: Option<&'a str>,
|
||||||
|
input: &'a [String],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "vector-http")]
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct EmbeddingsResponse {
|
||||||
|
data: Vec<EmbeddingDatum>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "vector-http")]
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct EmbeddingDatum {
|
||||||
|
embedding: Vec<f32>,
|
||||||
|
/// Position in the input batch; the OpenAI shape guarantees it, and we sort by
|
||||||
|
/// it to restore input order regardless of how the server returns the array.
|
||||||
|
#[serde(default)]
|
||||||
|
index: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "vector-http")]
|
||||||
|
impl HttpEmbedder {
|
||||||
|
/// Builds the embedder from a declarative profile. `localServer` profiles with
|
||||||
|
/// no `endpoint` fall back to [`DEFAULT_LOCAL_EMBED_ENDPOINT`]; `api` profiles
|
||||||
|
/// carry their endpoint explicitly. `api_key_env` is retained only for the
|
||||||
|
/// `api` strategy.
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_profile(profile: &EmbedderProfile) -> Self {
|
||||||
|
let is_api = profile.strategy == EmbedderStrategy::Api;
|
||||||
|
let endpoint = profile
|
||||||
|
.endpoint
|
||||||
|
.clone()
|
||||||
|
.filter(|e| !e.is_empty())
|
||||||
|
.unwrap_or_else(|| DEFAULT_LOCAL_EMBED_ENDPOINT.to_owned());
|
||||||
|
Self {
|
||||||
|
id: profile.id.clone(),
|
||||||
|
dimension: profile.dimension,
|
||||||
|
endpoint,
|
||||||
|
model: profile.model.clone(),
|
||||||
|
api_key_env: if is_api {
|
||||||
|
profile.api_key_env.clone()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
// A bounded timeout keeps a hung/slow endpoint from stalling a recall
|
||||||
|
// forever; the composing `AdaptiveMemoryRecall` then degrades to naïve.
|
||||||
|
// Fall back to a default client if the builder cannot be constructed.
|
||||||
|
client: reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.build()
|
||||||
|
.unwrap_or_else(|_| reqwest::Client::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "vector-http")]
|
||||||
|
#[async_trait]
|
||||||
|
impl Embedder for HttpEmbedder {
|
||||||
|
fn id(&self) -> &str {
|
||||||
|
&self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbedderError> {
|
||||||
|
if texts.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut request = self.client.post(&self.endpoint).json(&EmbeddingsRequest {
|
||||||
|
model: self.model.as_deref(),
|
||||||
|
input: texts,
|
||||||
|
});
|
||||||
|
|
||||||
|
// `api` strategy: attach the bearer token read from the configured env var.
|
||||||
|
// A configured-but-unset var is an "unavailable" condition, not a panic.
|
||||||
|
if let Some(var) = &self.api_key_env {
|
||||||
|
match std::env::var(var) {
|
||||||
|
Ok(key) if !key.is_empty() => {
|
||||||
|
request = request.bearer_auth(key);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(EmbedderError::Unavailable(format!(
|
||||||
|
"API key env var `{var}` is not set"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = request
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| EmbedderError::Unavailable(format!("request to `{}` failed: {e}", self.endpoint)))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(EmbedderError::Unavailable(format!(
|
||||||
|
"embeddings endpoint `{}` returned status {}",
|
||||||
|
self.endpoint,
|
||||||
|
response.status()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: EmbeddingsResponse = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| EmbedderError::Io(format!("malformed embeddings response: {e}")))?;
|
||||||
|
|
||||||
|
if body.data.len() != texts.len() {
|
||||||
|
return Err(EmbedderError::Io(format!(
|
||||||
|
"embeddings count mismatch: expected {}, got {}",
|
||||||
|
texts.len(),
|
||||||
|
body.data.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore input order, then validate each vector's dimension.
|
||||||
|
let mut data = body.data;
|
||||||
|
data.sort_by_key(|d| d.index);
|
||||||
|
let mut vectors = Vec::with_capacity(data.len());
|
||||||
|
for datum in data {
|
||||||
|
if datum.embedding.len() != self.dimension {
|
||||||
|
return Err(EmbedderError::Io(format!(
|
||||||
|
"embedding dimension mismatch: profile declares {}, server returned {}",
|
||||||
|
self.dimension,
|
||||||
|
datum.embedding.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
vectors.push(datum.embedding);
|
||||||
|
}
|
||||||
|
Ok(vectors)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dimension(&self) -> usize {
|
||||||
|
self.dimension
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort probe of whether an Ollama-style local embedding server is reachable
|
||||||
|
/// at `base_url` (e.g. `http://localhost:11434`). Used by the contextual
|
||||||
|
/// "configure an embedder?" prompt (C3) to detect an already-installed local engine
|
||||||
|
/// before suggesting any download — the Linux-spirit "detect the existing first"
|
||||||
|
/// rule. Never errors: a failure to reach the host simply means "not detected".
|
||||||
|
#[cfg(feature = "vector-http")]
|
||||||
|
pub async fn detect_ollama(base_url: &str) -> bool {
|
||||||
|
let base = base_url.trim_end_matches('/');
|
||||||
|
let url = format!("{base}/api/tags");
|
||||||
|
let client = match reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_millis(800))
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
matches!(client.get(&url).send().await, Ok(r) if r.status().is_success())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// OnnxEmbedder — real in-process `localOnnx` engine (LOT C1b, feature `vector-onnx`).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// The data/inspection helpers below (`OnnxModelInfo`, `RECOMMENDED_ONNX_MODELS`,
|
||||||
|
// `ONNX_CACHE_SUBDIR`, `onnx_model_is_cached`) are **always** compiled — they are
|
||||||
|
// pure data and pure-FS inspection, with no dependency on `fastembed`, so the C2/C3
|
||||||
|
// UI can describe and probe the ONNX models even in a build without the feature.
|
||||||
|
|
||||||
|
/// Cache subdirectory (under the app data dir) where the `localOnnx` engine stores
|
||||||
|
/// its downloaded model files. Forced explicitly so nothing ever lands in the
|
||||||
|
/// global hf-hub cache outside IdeA's data directory.
|
||||||
|
pub const ONNX_CACHE_SUBDIR: &str = "embedders/onnx";
|
||||||
|
|
||||||
|
/// Static, dependency-free description of a recommendable local ONNX model, for the
|
||||||
|
/// "configure an embedder?" UI (C2/C3): display name, vector dimension, approximate
|
||||||
|
/// on-disk size, and whether it is the recommended default.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct OnnxModelInfo {
|
||||||
|
/// Stable model id accepted by a `localOnnx` profile's `model` field.
|
||||||
|
pub id: &'static str,
|
||||||
|
/// Human-readable name for the UI.
|
||||||
|
pub display_name: &'static str,
|
||||||
|
/// Length of the vectors this model produces.
|
||||||
|
pub dimension: usize,
|
||||||
|
/// Approximate download/disk size in megabytes (for the UI download hint).
|
||||||
|
pub approx_size_mb: u32,
|
||||||
|
/// Whether this is the recommended default model.
|
||||||
|
pub recommended: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The curated list of local ONNX models IdeA can offer to download. Kept tiny on
|
||||||
|
/// purpose (the Linux-spirit "small, multilingual, good enough" default).
|
||||||
|
pub const RECOMMENDED_ONNX_MODELS: &[OnnxModelInfo] = &[OnnxModelInfo {
|
||||||
|
id: "multilingual-e5-small",
|
||||||
|
display_name: "Multilingual E5 Small",
|
||||||
|
dimension: 384,
|
||||||
|
approx_size_mb: 118,
|
||||||
|
recommended: true,
|
||||||
|
}];
|
||||||
|
|
||||||
|
/// Best-effort, **pure-FS** probe of whether a `localOnnx` model already lives in
|
||||||
|
/// `cache_dir` (no network, no `fastembed` dependency — available even without the
|
||||||
|
/// `vector-onnx` feature). Heuristic: a non-empty subdirectory whose name contains
|
||||||
|
/// the model token, mirroring hf-hub's `models--<org>--<name>` cache layout.
|
||||||
|
///
|
||||||
|
/// A `false` here only means "not detected"; it never blocks anything.
|
||||||
|
#[must_use]
|
||||||
|
pub fn onnx_model_is_cached(cache_dir: &std::path::Path, model: &str) -> bool {
|
||||||
|
let needle = model.replace(['/', '_'], "-").to_ascii_lowercase();
|
||||||
|
let Ok(entries) = std::fs::read_dir(cache_dir) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = entry.file_name().to_string_lossy().to_ascii_lowercase();
|
||||||
|
if !name.contains(&needle) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Non-empty directory ⇒ treat as a present cached model.
|
||||||
|
if std::fs::read_dir(entry.path())
|
||||||
|
.map(|mut d| d.next().is_some())
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "vector-onnx")]
|
||||||
|
mod onnx {
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use fastembed::{EmbeddingModel, InitOptions, TextEmbedding};
|
||||||
|
use tokio::sync::{Mutex, OnceCell};
|
||||||
|
|
||||||
|
use domain::ports::{Embedder, EmbedderError};
|
||||||
|
use domain::profile::EmbedderProfile;
|
||||||
|
|
||||||
|
/// Resolves a profile `model` string to a concrete fastembed model + its
|
||||||
|
/// dimension. `None` model ⇒ the recommended default (Multilingual E5 Small,
|
||||||
|
/// 384). An unknown, non-empty string ⇒ `None` (caller maps to `Unsupported`).
|
||||||
|
pub(super) fn resolve_onnx_model(model: Option<&str>) -> Option<(EmbeddingModel, usize)> {
|
||||||
|
match model {
|
||||||
|
None => Some((EmbeddingModel::MultilingualE5Small, 384)),
|
||||||
|
Some(m) => match m.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"multilingual-e5-small" | "e5-small" => {
|
||||||
|
Some((EmbeddingModel::MultilingualE5Small, 384))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A real in-process [`Embedder`] running a quantised ONNX model via `fastembed`
|
||||||
|
/// (`localOnnx` strategy). The model is loaded **lazily** on first
|
||||||
|
/// [`embed`](Embedder::embed) (downloaded once into `cache_dir` if missing), then
|
||||||
|
/// reused; all CPU-bound work (model load + inference) runs on a blocking thread.
|
||||||
|
///
|
||||||
|
/// **Best-effort by contract** (see [`Embedder`]): a failed download/load,
|
||||||
|
/// an unknown model, or a profile/model dimension mismatch all map to a clean
|
||||||
|
/// [`EmbedderError`] (never a panic), so a composing
|
||||||
|
/// [`crate::store::AdaptiveMemoryRecall`] degrades to naïve. Construction is
|
||||||
|
/// cheap and infallible — nothing touches disk or the network until `embed`.
|
||||||
|
pub struct OnnxEmbedder {
|
||||||
|
id: String,
|
||||||
|
dimension: usize,
|
||||||
|
/// `Some(model)` when the profile's model string resolved to a known model;
|
||||||
|
/// `None` for an unknown string ⇒ `embed` returns [`EmbedderError::Unsupported`].
|
||||||
|
model: Option<EmbeddingModel>,
|
||||||
|
/// The original (unknown) model string, for the `Unsupported` message.
|
||||||
|
requested_model: Option<String>,
|
||||||
|
cache_dir: PathBuf,
|
||||||
|
cell: OnceCell<Arc<Mutex<TextEmbedding>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OnnxEmbedder {
|
||||||
|
/// Builds the embedder from a declarative profile. **Cheap and infallible**:
|
||||||
|
/// no I/O, no download. An unknown model is detected lazily at `embed` time
|
||||||
|
/// (mapped to [`EmbedderError::Unsupported`]); here it is stored as-is and the
|
||||||
|
/// fallback model is recorded so the struct stays valid.
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_profile(profile: &EmbedderProfile, cache_dir: &Path) -> Self {
|
||||||
|
// Record the resolved model when known; an unknown string is kept so
|
||||||
|
// construction never fails — `embed` returns `Unsupported` for it.
|
||||||
|
let model = resolve_onnx_model(profile.model.as_deref()).map(|(m, _)| m);
|
||||||
|
Self {
|
||||||
|
id: profile.id.clone(),
|
||||||
|
dimension: profile.dimension,
|
||||||
|
model,
|
||||||
|
requested_model: profile.model.clone(),
|
||||||
|
cache_dir: cache_dir.to_path_buf(),
|
||||||
|
cell: OnceCell::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lazily loads (and caches) the `TextEmbedding`, returning the shared
|
||||||
|
/// `Mutex`-guarded handle. Heavy work runs on a blocking thread.
|
||||||
|
async fn engine(&self) -> Result<Arc<Mutex<TextEmbedding>>, EmbedderError> {
|
||||||
|
let Some(model) = self.model.clone() else {
|
||||||
|
return Err(EmbedderError::Unsupported(format!(
|
||||||
|
"unknown ONNX model `{}` (supported: multilingual-e5-small)",
|
||||||
|
self.requested_model.as_deref().unwrap_or("")
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
self.cell
|
||||||
|
.get_or_try_init(|| async {
|
||||||
|
let cache_dir = self.cache_dir.clone();
|
||||||
|
let built = tokio::task::spawn_blocking(move || {
|
||||||
|
TextEmbedding::try_new(
|
||||||
|
InitOptions::new(model).with_cache_dir(cache_dir),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| EmbedderError::Io(format!("onnx init task failed: {e}")))?
|
||||||
|
.map_err(|e| {
|
||||||
|
EmbedderError::Unavailable(format!(
|
||||||
|
"failed to load/download ONNX model: {e}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Ok(Arc::new(Mutex::new(built)))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Embedder for OnnxEmbedder {
|
||||||
|
fn id(&self) -> &str {
|
||||||
|
&self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbedderError> {
|
||||||
|
// Short-circuit BEFORE any model load: an empty batch never downloads.
|
||||||
|
if texts.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
// `engine()` surfaces `Unsupported` for an unknown model and
|
||||||
|
// `Unavailable` for a failed load/download — never a panic.
|
||||||
|
let expected = self.dimension;
|
||||||
|
let engine = self.engine().await?;
|
||||||
|
let texts_owned = texts.to_vec();
|
||||||
|
let vectors = tokio::task::spawn_blocking(move || {
|
||||||
|
let mut guard = engine.blocking_lock();
|
||||||
|
guard.embed(texts_owned, None)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| EmbedderError::Io(format!("onnx inference task failed: {e}")))?
|
||||||
|
.map_err(|e| EmbedderError::Io(format!("onnx inference failed: {e}")))?;
|
||||||
|
|
||||||
|
for v in &vectors {
|
||||||
|
if v.len() != expected {
|
||||||
|
return Err(EmbedderError::Unavailable(format!(
|
||||||
|
"embedding dimension mismatch: profile declares {expected}, model produces {}",
|
||||||
|
v.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(vectors)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dimension(&self) -> usize {
|
||||||
|
self.dimension
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "vector-onnx")]
|
||||||
|
pub use onnx::OnnxEmbedder;
|
||||||
|
|||||||
@ -14,7 +14,14 @@ mod template;
|
|||||||
mod vector;
|
mod vector;
|
||||||
|
|
||||||
pub use context::IdeaiContextStore;
|
pub use context::IdeaiContextStore;
|
||||||
pub use embedder::{embedder_from_profile, HashEmbedder, StubEmbedder};
|
pub use embedder::{
|
||||||
|
embedder_from_profile, onnx_model_is_cached, HashEmbedder, OnnxModelInfo, StubEmbedder,
|
||||||
|
ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
|
||||||
|
};
|
||||||
|
#[cfg(feature = "vector-http")]
|
||||||
|
pub use embedder::{detect_ollama, HttpEmbedder, DEFAULT_LOCAL_EMBED_ENDPOINT};
|
||||||
|
#[cfg(feature = "vector-onnx")]
|
||||||
|
pub use embedder::OnnxEmbedder;
|
||||||
pub use memory::{index_token_size, FsMemoryStore, NaiveMemoryRecall};
|
pub use memory::{index_token_size, FsMemoryStore, NaiveMemoryRecall};
|
||||||
pub use profile::{FsEmbedderProfileStore, FsProfileStore};
|
pub use profile::{FsEmbedderProfileStore, FsProfileStore};
|
||||||
pub use project::FsProjectStore;
|
pub use project::FsProjectStore;
|
||||||
|
|||||||
202
crates/infrastructure/tests/http_embedder.rs
Normal file
202
crates/infrastructure/tests/http_embedder.rs
Normal 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
|
||||||
|
}
|
||||||
215
crates/infrastructure/tests/onnx_embedder.rs
Normal file
215
crates/infrastructure/tests/onnx_embedder.rs
Normal 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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -26,8 +26,9 @@ use domain::ports::{
|
|||||||
use domain::profile::{EmbedderProfile, EmbedderStrategy};
|
use domain::profile::{EmbedderProfile, EmbedderStrategy};
|
||||||
use domain::project::ProjectPath;
|
use domain::project::ProjectPath;
|
||||||
use infrastructure::{
|
use infrastructure::{
|
||||||
embedder_from_profile, should_use_vector, AdaptiveMemoryRecall, FsMemoryStore, HashEmbedder,
|
embedder_from_profile, onnx_model_is_cached, should_use_vector, AdaptiveMemoryRecall,
|
||||||
NaiveMemoryRecall, StubEmbedder, VectorMemoryRecall,
|
FsMemoryStore, HashEmbedder, NaiveMemoryRecall, StubEmbedder, VectorMemoryRecall,
|
||||||
|
ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -255,26 +256,48 @@ async fn stub_embedder_returns_unsupported() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn embedder_from_profile_maps_each_strategy() {
|
async fn embedder_from_profile_maps_each_strategy() {
|
||||||
// none ⇒ no embedder (recall stays naïve).
|
// The ONNX cache dir is irrelevant to every strategy exercised here (none of
|
||||||
assert!(embedder_from_profile(&EmbedderProfile::none()).is_none());
|
// 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.
|
// none ⇒ no embedder (recall stays naïve).
|
||||||
for strategy in [
|
assert!(embedder_from_profile(&EmbedderProfile::none(), onnx_cache).is_none());
|
||||||
EmbedderStrategy::LocalOnnx,
|
|
||||||
EmbedderStrategy::LocalServer,
|
// localOnnx without the `vector-onnx` feature maps to a StubEmbedder
|
||||||
EmbedderStrategy::Api,
|
// (Unsupported); with the feature it is the real OnnxEmbedder. Dimension is
|
||||||
] {
|
// preserved either way (the real engine validates dimension only at embed time).
|
||||||
let profile =
|
let onnx =
|
||||||
EmbedderProfile::new("e", "E", strategy, None, None, None, 24).unwrap();
|
EmbedderProfile::new("e", "E", EmbedderStrategy::LocalOnnx, None, None, None, 24).unwrap();
|
||||||
let embedder = embedder_from_profile(&profile)
|
let embedder =
|
||||||
.unwrap_or_else(|| panic!("{strategy:?} must yield an embedder"));
|
embedder_from_profile(&onnx, onnx_cache).expect("localOnnx must yield an embedder");
|
||||||
assert_eq!(embedder.dimension(), 24);
|
assert_eq!(embedder.dimension(), 24);
|
||||||
|
#[cfg(not(feature = "vector-onnx"))]
|
||||||
|
{
|
||||||
let err = embedder.embed(&["t".to_string()]).await.unwrap_err();
|
let err = embedder.embed(&["t".to_string()]).await.unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, EmbedderError::Unsupported(_)),
|
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");
|
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]
|
#[tokio::test]
|
||||||
async fn adaptive_empty_memory_and_zero_budget_are_empty() {
|
async fn adaptive_empty_memory_and_zero_budget_are_empty() {
|
||||||
// Liskov common contract: empty list, never an error, both paths.
|
// Liskov common contract: empty list, never an error, both paths.
|
||||||
|
|||||||
Reference in New Issue
Block a user