#[cfg(any(feature = "model2vec", feature = "fastembed"))]
use std::collections::BTreeSet;
use std::fmt::{self, Write as _};
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
use std::io::{self, IsTerminal, Write};
#[cfg(not(any(feature = "model2vec", feature = "fastembed")))]
use std::path::Path;
#[cfg(any(feature = "model2vec", feature = "fastembed"))]
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
use std::sync::atomic::{AtomicU8, Ordering};
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
use std::time::Instant;
use asupersync::Cx;
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
use asupersync::sync::OnceCell;
#[cfg(any(feature = "model2vec", feature = "fastembed"))]
use tracing::{debug, info, warn};
#[cfg(not(any(feature = "model2vec", feature = "fastembed")))]
use tracing::{info, warn};
use frankensearch_core::error::{SearchError, SearchResult};
use frankensearch_core::generation::EmbeddingIdentityBundleV1;
#[cfg(feature = "api")]
use frankensearch_core::generation::FrozenEmbeddingIdentityBundleV1;
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
use frankensearch_core::generation::QuantizationFormat;
use frankensearch_core::traits::{Embedder, SearchFuture};
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
use frankensearch_core::traits::{ModelCategory, ModelTier};
#[cfg(all(feature = "download", feature = "fastembed"))]
use crate::fastembed_embedder::DEFAULT_DIMENSION as MINILM_DIMENSION;
#[cfg(feature = "fastembed")]
use crate::fastembed_embedder::{
DEFAULT_HF_ID as MINILM_HF_ID, DEFAULT_MODEL_NAME as MINILM_MODEL_NAME, FastEmbedEmbedder,
find_model_dir_with_hf_id as find_fastembed_model_dir,
};
#[cfg(feature = "hash")]
use crate::hash_embedder::HashEmbedder;
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
use crate::model_download::{DownloadProgress, ModelDownloader};
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
use crate::model_manifest::ModelArtifactManifestV1;
#[cfg(any(feature = "model2vec", feature = "fastembed"))]
use crate::model_manifest::ModelManifest;
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
use crate::model_manifest::{
ConsentSource, DownloadConsent, ModelLifecycle, resolve_download_consent,
};
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
use crate::model_registry::ensure_model_storage_layout_checked;
#[cfg(feature = "model2vec")]
use crate::model2vec_embedder::{
Model2VecEmbedder, find_model_dir_with_hf_id as find_model2vec_model_dir,
};
#[cfg(feature = "model2vec")]
const POTION_MODEL_NAME: &str = "potion-multilingual-128M";
#[cfg(feature = "model2vec")]
const POTION_HF_ID: &str = "minishlab/potion-multilingual-128M";
#[cfg(all(feature = "download", feature = "model2vec"))]
const POTION_DIMENSION: usize = 256;
const OFFLINE_ENV: &str = "FRANKENSEARCH_OFFLINE";
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
const PROGRESS_BAR_WIDTH: usize = 30;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TwoTierAvailability {
Full,
FastOnly,
HashOnly,
}
impl TwoTierAvailability {
#[must_use]
pub const fn is_degraded(self) -> bool {
matches!(self, Self::FastOnly | Self::HashOnly)
}
#[must_use]
pub const fn degradation_summary(self) -> Option<&'static str> {
match self {
Self::Full => None,
Self::FastOnly => Some(
"Quality model unavailable: search will return fast-tier results only (no refinement phase).",
),
Self::HashOnly => Some(
"No semantic models available: hash control embeddings are not semantic search.",
),
}
}
}
impl fmt::Display for TwoTierAvailability {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Full => write!(f, "full (fast + quality)"),
Self::FastOnly => write!(f, "degraded (fast-only, no quality refinement)"),
Self::HashOnly => write!(f, "minimal (hash-only, no semantic search)"),
}
}
}
#[derive(Debug, Clone)]
pub struct ModelAvailabilityDiagnostic {
pub availability: TwoTierAvailability,
pub cache_dir: std::path::PathBuf,
pub offline: bool,
pub fast_status: ModelStatus,
pub quality_status: ModelStatus,
pub suggestions: Vec<String>,
}
#[derive(Debug, Clone)]
pub enum ModelStatus {
Ready {
id: String,
},
NotFound {
model_name: String,
hf_repo_url: String,
searched_paths: Vec<std::path::PathBuf>,
},
DownloadBlocked {
model_name: String,
reason: String,
},
FeatureDisabled {
feature_flag: String,
},
HashFallback,
}
impl fmt::Display for ModelAvailabilityDiagnostic {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "Model availability: {}", self.availability)?;
writeln!(f, "Cache directory: {}", self.cache_dir.display())?;
if self.offline {
writeln!(f, "Mode: OFFLINE (FRANKENSEARCH_OFFLINE=1)")?;
}
writeln!(f)?;
writeln!(f, "Fast tier: {}", self.fast_status)?;
writeln!(f, "Quality tier: {}", self.quality_status)?;
if !self.suggestions.is_empty() {
writeln!(f)?;
writeln!(f, "To resolve:")?;
for suggestion in &self.suggestions {
writeln!(f, " - {suggestion}")?;
}
}
Ok(())
}
}
impl fmt::Display for ModelStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Ready { id } => write!(f, "ready ({id})"),
Self::NotFound {
model_name,
hf_repo_url,
..
} => {
write!(f, "NOT FOUND ({model_name}) — download from {hf_repo_url}")
}
Self::DownloadBlocked { model_name, reason } => {
write!(f, "BLOCKED ({model_name}): {reason}")
}
Self::FeatureDisabled { feature_flag } => {
write!(f, "DISABLED (compile with --features {feature_flag})")
}
Self::HashFallback => write!(f, "hash control (not a semantic model)"),
}
}
}
#[derive(Clone)]
pub struct EmbedderStack {
fast: Arc<dyn Embedder>,
quality: Option<Arc<dyn Embedder>>,
availability: TwoTierAvailability,
}
impl fmt::Debug for EmbedderStack {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let fast_identity = self.fast.identity().map_or_else(
|_| "unverifiable".to_owned(),
EmbeddingIdentityBundleV1::fingerprint,
);
let quality_identity = self.quality.as_ref().map(|embedder| {
embedder.identity().map_or_else(
|_| "unverifiable".to_owned(),
EmbeddingIdentityBundleV1::fingerprint,
)
});
f.debug_struct("EmbedderStack")
.field("availability", &self.availability)
.field("fast_category", &self.fast.category())
.field("fast_dim", &self.fast.dimension())
.field("fast_identity", &fast_identity)
.field(
"quality_category",
&self.quality.as_ref().map(|embedder| embedder.category()),
)
.field("quality_identity", &quality_identity)
.finish()
}
}
fn classify_stack_availability(
fast: &dyn Embedder,
quality: Option<&dyn Embedder>,
) -> TwoTierAvailability {
if !fast.is_semantic() {
TwoTierAvailability::HashOnly
} else if quality.is_some_and(Embedder::is_semantic) {
TwoTierAvailability::Full
} else {
TwoTierAvailability::FastOnly
}
}
impl EmbedderStack {
#[must_use]
pub fn from_parts(fast: Arc<dyn Embedder>, quality: Option<Arc<dyn Embedder>>) -> Self {
let availability = classify_stack_availability(fast.as_ref(), quality.as_deref());
Self {
fast,
quality,
availability,
}
}
pub fn auto_detect() -> SearchResult<Self> {
Self::auto_detect_with(None)
}
pub fn auto_detect_with(model_root: Option<&Path>) -> SearchResult<Self> {
Self::auto_detect_with_options(model_root, &DetectOptions::default())
}
pub fn auto_detect_semantic() -> SearchResult<Self> {
Self::auto_detect()?.require_semantic()
}
pub fn auto_detect_semantic_with(model_root: Option<&Path>) -> SearchResult<Self> {
Self::auto_detect_with(model_root)?.require_semantic()
}
pub fn auto_detect_semantic_with_options(
model_root: Option<&Path>,
options: &DetectOptions,
) -> SearchResult<Self> {
Self::auto_detect_with_options(model_root, options)?.require_semantic()
}
pub fn require_semantic(self) -> SearchResult<Self> {
if matches!(self.availability, TwoTierAvailability::HashOnly) {
return Err(SearchError::EmbedderUnavailable {
model: "semantic".to_owned(),
reason: self.degradation_message().unwrap_or_else(|| {
"no semantic model; hash-only stacks are not a working search engine".to_owned()
}),
});
}
Ok(self)
}
pub fn auto_detect_with_options(
model_root: Option<&Path>,
options: &DetectOptions,
) -> SearchResult<Self> {
let remote_env = RemoteIntentEnv::from_environment();
let (offline, remote) = resolve_remote_intent(*options, &remote_env)?;
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
{
let policy = download_policy_from_environment(offline);
Self::auto_detect_with_policy(model_root, policy, remote)
}
#[cfg(not(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
)))]
{
let _ = offline;
Self::auto_detect_with_policy(model_root, remote)
}
}
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
fn auto_detect_with_policy(
model_root: Option<&Path>,
policy: DownloadPolicy,
remote: Option<Arc<dyn Embedder>>,
) -> SearchResult<Self> {
let quality = detect_quality_embedder(model_root)
.or_else(|| maybe_lazy_quality_embedder(model_root, policy))
.or(remote);
let fast = detect_fast_embedder(model_root)
.or_else(|| maybe_lazy_fast_embedder(model_root, policy))
.or_else(hash_fallback_embedder)
.ok_or_else(|| SearchError::EmbedderUnavailable {
model: "fast-tier".to_owned(),
reason: "no model2vec/hash embedder available in this build".to_owned(),
})?;
let stack = Self::from_parts(fast, quality);
stack.report_readiness();
Ok(stack)
}
#[cfg(not(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
)))]
fn auto_detect_with_policy(
model_root: Option<&Path>,
remote: Option<Arc<dyn Embedder>>,
) -> SearchResult<Self> {
let quality = detect_quality_embedder(model_root).or(remote);
let fast = detect_fast_embedder(model_root)
.or_else(hash_fallback_embedder)
.ok_or_else(|| SearchError::EmbedderUnavailable {
model: "fast-tier".to_owned(),
reason: "no model2vec/hash embedder available in this build".to_owned(),
})?;
let stack = Self::from_parts(fast, quality);
stack.report_readiness();
Ok(stack)
}
fn report_readiness(&self) {
let fast_identity = self.fast.identity().map_or_else(
|_| "unverifiable".to_owned(),
EmbeddingIdentityBundleV1::fingerprint,
);
let quality_identity = self.quality.as_ref().map(|embedder| {
embedder.identity().map_or_else(
|_| "unverifiable".to_owned(),
EmbeddingIdentityBundleV1::fingerprint,
)
});
let quality_category = self.quality.as_ref().map(|embedder| embedder.category());
if matches!(self.availability, TwoTierAvailability::HashOnly) {
warn!(
availability = ?self.availability,
fast_category = ?self.fast.category(),
fast_identity = %fast_identity,
quality_category = ?quality_category,
quality_identity = ?quality_identity,
reason = "no-semantic-model",
"SEMANTIC SEARCH UNAVAILABLE: no embedding model was found, so \
retrieval fell back to non-semantic hash vectors. Vector search \
will not return meaningful results until a model is installed."
);
return;
}
info!(
availability = ?self.availability,
fast_category = ?self.fast.category(),
fast_identity = %fast_identity,
quality_category = ?quality_category,
quality_identity = ?quality_identity,
"embedder stack ready"
);
}
pub fn with_mrl_target_dim(mut self, target_dim: usize) -> SearchResult<Self> {
if target_dim == 0 {
return Err(SearchError::InvalidConfig {
field: "target_dim".to_owned(),
value: "0".to_owned(),
reason: "target dimension must be at least 1".to_owned(),
});
}
self.fast = maybe_wrap_mrl(self.fast.clone(), target_dim)?;
self.quality = self
.quality
.clone()
.map(|embedder| maybe_wrap_mrl(embedder, target_dim))
.transpose()?;
self.availability =
classify_stack_availability(self.fast.as_ref(), self.quality.as_deref());
Ok(self)
}
#[must_use]
pub fn fast(&self) -> &dyn Embedder {
self.fast.as_ref()
}
#[must_use]
pub fn fast_embedder(&self) -> &dyn Embedder {
self.fast()
}
#[must_use]
pub fn fast_arc(&self) -> Arc<dyn Embedder> {
self.fast.clone()
}
#[must_use]
pub fn quality(&self) -> Option<&dyn Embedder> {
self.quality.as_deref()
}
#[must_use]
pub fn quality_embedder(&self) -> Option<&dyn Embedder> {
self.quality()
}
#[must_use]
pub fn quality_arc(&self) -> Option<Arc<dyn Embedder>> {
self.quality.clone()
}
#[must_use]
pub const fn availability(&self) -> TwoTierAvailability {
self.availability
}
#[allow(clippy::too_many_lines)]
#[must_use]
pub fn diagnose(&self) -> ModelAvailabilityDiagnostic {
let cache_dir = crate::model_cache::resolve_cache_root();
let offline = std::env::var("FRANKENSEARCH_OFFLINE")
.ok()
.as_deref()
.is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
let fast_status = if self.fast.is_semantic() {
ModelStatus::Ready {
id: self.fast.id().to_owned(),
}
} else {
ModelStatus::HashFallback
};
#[allow(clippy::option_if_let_else)] let quality_status = if let Some(ref quality) = self.quality {
ModelStatus::Ready {
id: quality.id().to_owned(),
}
} else {
#[cfg(feature = "fastembed")]
{
if offline {
ModelStatus::DownloadBlocked {
model_name: "all-MiniLM-L6-v2".to_owned(),
reason: "FRANKENSEARCH_OFFLINE=1 disables auto-download".to_owned(),
}
} else {
ModelStatus::NotFound {
model_name: "all-MiniLM-L6-v2".to_owned(),
hf_repo_url:
"https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2"
.to_owned(),
searched_paths: vec![cache_dir.join("all-MiniLM-L6-v2")],
}
}
}
#[cfg(not(feature = "fastembed"))]
{
ModelStatus::FeatureDisabled {
feature_flag: "fastembed".to_owned(),
}
}
};
let mut suggestions = Vec::new();
if self.availability.is_degraded() {
#[cfg(not(feature = "bundled-default-models"))]
if offline {
suggestions.push(
"Unset FRANKENSEARCH_OFFLINE to allow automatic model downloads.".to_owned(),
);
}
suggestions.push(format!(
"Set FRANKENSEARCH_MODEL_DIR to point to a pre-populated model cache (current: {}).",
cache_dir.display()
));
if matches!(self.availability, TwoTierAvailability::HashOnly) {
#[cfg(all(feature = "bundled-default-models", feature = "model2vec"))]
suggestions.push(
"Default semantic models are bundled in fsfs. If still unavailable, ensure the model cache path is writable and run `fsfs status`."
.to_owned(),
);
#[cfg(feature = "model2vec")]
#[cfg(not(feature = "bundled-default-models"))]
suggestions.push(
"Download potion-multilingual-128M from https://huggingface.co/minishlab/potion-multilingual-128M and place in cache dir."
.to_owned(),
);
#[cfg(not(feature = "model2vec"))]
suggestions.push(
"Compile with --features model2vec to enable the fast semantic tier."
.to_owned(),
);
}
if self.quality.is_none() {
#[cfg(all(feature = "bundled-default-models", feature = "fastembed"))]
suggestions.push(
"Quality model should be bundled by default. Check cache permissions and verify `all-MiniLM-L6-v2` exists under the model directory."
.to_owned(),
);
#[cfg(feature = "fastembed")]
#[cfg(not(feature = "bundled-default-models"))]
suggestions.push(
"Download all-MiniLM-L6-v2 from https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2 and place in cache dir."
.to_owned(),
);
#[cfg(not(feature = "fastembed"))]
suggestions.push(
"Compile with --features fastembed to enable the quality semantic tier."
.to_owned(),
);
}
#[cfg(not(feature = "bundled-default-models"))]
suggestions.push(
"For air-gapped environments: run `fsfs download-models --output ./models/` on a networked machine, then copy to target."
.to_owned(),
);
#[cfg(feature = "bundled-default-models")]
suggestions.push(
"Optional: use `fsfs download-models` only when you want alternate semantic models beyond the bundled defaults."
.to_owned(),
);
}
ModelAvailabilityDiagnostic {
availability: self.availability,
cache_dir,
offline,
fast_status,
quality_status,
suggestions,
}
}
#[must_use]
pub fn degradation_message(&self) -> Option<String> {
if !self.availability.is_degraded() {
return None;
}
let diag = self.diagnose();
let mut msg = String::new();
if let Some(summary) = self.availability.degradation_summary() {
msg.push_str(summary);
msg.push('\n');
}
let _ = writeln!(msg, "Model cache: {}", diag.cache_dir.display());
if diag.offline {
msg.push_str("Offline mode: enabled (FRANKENSEARCH_OFFLINE=1)\n");
}
if !diag.suggestions.is_empty() {
if matches!(self.availability, TwoTierAvailability::HashOnly) {
msg.push_str(
"\nTo enable semantic search (installing a model cannot repair a hash-written index):\n",
);
} else {
msg.push_str("\nTo improve search quality:\n");
}
for suggestion in &diag.suggestions {
let _ = writeln!(msg, " - {suggestion}");
}
}
Some(msg)
}
}
pub struct DimReduceEmbedder {
inner: Arc<dyn Embedder>,
target_dim: usize,
id: String,
model_name: String,
identity: EmbeddingIdentityBundleV1,
}
impl fmt::Debug for DimReduceEmbedder {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DimReduceEmbedder")
.field("target_dim", &self.target_dim)
.field("identity", &self.identity.fingerprint())
.finish_non_exhaustive()
}
}
impl DimReduceEmbedder {
pub fn new(inner: Arc<dyn Embedder>, target_dim: usize) -> SearchResult<Self> {
if target_dim == 0 {
return Err(SearchError::InvalidConfig {
field: "target_dim".to_owned(),
value: "0".to_owned(),
reason: "target dimension must be at least 1".to_owned(),
});
}
if target_dim > inner.dimension() {
return Err(SearchError::InvalidConfig {
field: "target_dim".to_owned(),
value: target_dim.to_string(),
reason: format!(
"target dimension cannot exceed embedder dimension {}",
inner.dimension()
),
});
}
if !inner.supports_mrl() {
return Err(SearchError::InvalidConfig {
field: "embedder.supports_mrl".to_owned(),
value: inner.id().to_owned(),
reason: "embedder does not support MRL truncation".to_owned(),
});
}
let target_dimension =
u32::try_from(target_dim).map_err(|_| SearchError::InvalidConfig {
field: "target_dim".to_owned(),
value: target_dim.to_string(),
reason: "target dimension does not fit the identity schema".to_owned(),
})?;
let identity = inner.identity()?.derive_projection(
target_dimension,
"prefix-truncate-first-n-dimensions-v1",
"l2-f32-zero-on-degenerate-v1",
)?;
Ok(Self {
id: format!("{}-mrl-{target_dim}", inner.id()),
model_name: format!("{} (MRL {target_dim})", inner.model_name()),
identity,
inner,
target_dim,
})
}
}
impl Embedder for DimReduceEmbedder {
fn embed<'a>(&'a self, cx: &'a Cx, text: &'a str) -> SearchFuture<'a, Vec<f32>> {
Box::pin(async move {
let full = self.inner.embed(cx, text).await?;
self.inner.truncate_embedding(&full, self.target_dim)
})
}
fn embed_batch<'a>(
&'a self,
cx: &'a Cx,
texts: &'a [&'a str],
) -> SearchFuture<'a, Vec<Vec<f32>>> {
Box::pin(async move {
let full_batch = self.inner.embed_batch(cx, texts).await?;
full_batch
.iter()
.map(|embedding| self.inner.truncate_embedding(embedding, self.target_dim))
.collect()
})
}
fn dimension(&self) -> usize {
self.target_dim
}
fn identity(&self) -> SearchResult<&EmbeddingIdentityBundleV1> {
Ok(&self.identity)
}
fn id(&self) -> &str {
&self.id
}
fn model_name(&self) -> &str {
&self.model_name
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn is_semantic(&self) -> bool {
self.inner.is_semantic()
}
fn category(&self) -> frankensearch_core::traits::ModelCategory {
self.inner.category()
}
fn tier(&self) -> frankensearch_core::traits::ModelTier {
self.inner.tier()
}
fn supports_mrl(&self) -> bool {
true
}
}
fn maybe_wrap_mrl(
embedder: Arc<dyn Embedder>,
target_dim: usize,
) -> SearchResult<Arc<dyn Embedder>> {
if target_dim >= embedder.dimension() || !embedder.supports_mrl() {
return Ok(embedder);
}
Ok(Arc::new(DimReduceEmbedder::new(embedder, target_dim)?))
}
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
#[derive(Debug, Clone, Copy)]
struct DownloadPolicy {
consent: DownloadConsent,
offline: bool,
stderr_is_tty: bool,
}
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
impl DownloadPolicy {
const fn can_download(self) -> bool {
self.consent.granted && !self.offline
}
fn blocked_reason(self) -> String {
if self.offline {
return format!(
"{OFFLINE_ENV}=1 or an explicit offline detection policy disables model \
auto-download"
);
}
if !self.consent.granted {
let source = self
.consent
.source
.map_or_else(|| "unset".to_owned(), |s| format!("{s:?}"));
return format!("download consent denied (source={source})");
}
"download policy blocked".to_owned()
}
#[cfg(test)]
const fn for_tests(consent: DownloadConsent, offline: bool, stderr_is_tty: bool) -> Self {
Self {
consent,
offline,
stderr_is_tty,
}
}
}
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
fn download_policy_from_environment(offline: bool) -> DownloadPolicy {
let consent = if offline {
DownloadConsent::denied(Some(ConsentSource::Environment))
} else {
resolve_download_consent(None, None, Some(false))
};
DownloadPolicy {
consent,
offline,
stderr_is_tty: io::stderr().is_terminal(),
}
}
fn parse_bool_flag(raw: &str) -> Option<bool> {
let value = raw.trim();
if value == "1"
|| value.eq_ignore_ascii_case("true")
|| value.eq_ignore_ascii_case("yes")
|| value.eq_ignore_ascii_case("on")
{
return Some(true);
}
if value == "0"
|| value.eq_ignore_ascii_case("false")
|| value.eq_ignore_ascii_case("no")
|| value.eq_ignore_ascii_case("off")
{
return Some(false);
}
None
}
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed"),
feature = "model2vec"
))]
fn maybe_lazy_fast_embedder(
model_root: Option<&Path>,
policy: DownloadPolicy,
) -> Option<Arc<dyn Embedder>> {
if !policy.can_download() {
info!(
model = POTION_MODEL_NAME,
tier = "fast",
reason = %policy.blocked_reason(),
"auto-download disabled; fast tier falling back"
);
return None;
}
info!(
model = POTION_MODEL_NAME,
tier = "fast",
"model not found locally; deferring download to first embed call"
);
match LazyModel2VecEmbedder::new(model_root.map(Path::to_path_buf), policy) {
Ok(embedder) => Some(Arc::new(embedder)),
Err(_error) => {
warn!(
model = POTION_MODEL_NAME,
reason = "registered-identity-invalid",
"registered lazy fast-tier identity is invalid"
);
None
}
}
}
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed"),
not(feature = "model2vec")
))]
fn maybe_lazy_fast_embedder(
_model_root: Option<&Path>,
_policy: DownloadPolicy,
) -> Option<Arc<dyn Embedder>> {
None
}
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed"),
feature = "fastembed"
))]
fn maybe_lazy_quality_embedder(
model_root: Option<&Path>,
policy: DownloadPolicy,
) -> Option<Arc<dyn Embedder>> {
if !policy.can_download() {
info!(
model = MINILM_MODEL_NAME,
tier = "quality",
reason = %policy.blocked_reason(),
"auto-download disabled; quality tier unavailable"
);
return None;
}
info!(
model = MINILM_MODEL_NAME,
tier = "quality",
"model not found locally; deferring download to first embed call"
);
match LazyFastEmbedEmbedder::new(model_root.map(Path::to_path_buf), policy) {
Ok(embedder) => Some(Arc::new(embedder)),
Err(_error) => {
warn!(
model = MINILM_MODEL_NAME,
reason = "registered-identity-invalid",
"registered lazy quality-tier identity is invalid"
);
None
}
}
}
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed"),
not(feature = "fastembed")
))]
fn maybe_lazy_quality_embedder(
_model_root: Option<&Path>,
_policy: DownloadPolicy,
) -> Option<Arc<dyn Embedder>> {
None
}
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
fn install_destination_dir(model_root: Option<&Path>, model_name: &str) -> SearchResult<PathBuf> {
if let Some(root) = model_root {
if root.ends_with(model_name) {
return Ok(root.to_path_buf());
}
return Ok(root.join(model_name));
}
Ok(ensure_model_storage_layout_checked()?.join(model_name))
}
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
async fn download_and_install_manifest(
cx: &Cx,
manifest: &ModelManifest,
destination_dir: &Path,
policy: DownloadPolicy,
) -> SearchResult<()> {
let start = Instant::now();
let downloader = ModelDownloader::with_defaults();
let mut lifecycle = ModelLifecycle::new(manifest.clone(), policy.consent);
let staging_root = destination_dir
.parent()
.map_or_else(|| destination_dir.to_path_buf(), Path::to_path_buf);
std::fs::create_dir_all(&staging_root)?;
let reporter = Arc::new(DownloadProgressReporter::new(
manifest.id.clone(),
policy.stderr_is_tty,
));
let reporter_for_cb = Arc::clone(&reporter);
info!(
model = %manifest.id,
bytes = manifest.total_size_bytes(),
"starting automatic model download"
);
let staged = match downloader
.download_model(
cx,
manifest,
&staging_root,
&mut lifecycle,
move |progress| {
reporter_for_cb.report(progress);
},
)
.await
{
Ok(staged) => staged,
Err(error) => {
reporter.finish_failed(start.elapsed(), &error);
warn!(
model = %manifest.id,
duration_ms = start.elapsed().as_millis(),
reason = "transport_or_verification_failed",
"automatic model download failed"
);
return Err(error);
}
};
match manifest.promote_verified_installation(&staged, destination_dir) {
Ok(backup) => {
reporter.finish_ok(start.elapsed(), manifest.total_size_bytes());
info!(
model = %manifest.id,
backup_created = backup.is_some(),
duration_ms = start.elapsed().as_millis(),
bytes = manifest.total_size_bytes(),
"automatic model download completed"
);
Ok(())
}
Err(error) => {
reporter.finish_failed(start.elapsed(), &error);
warn!(
model = %manifest.id,
duration_ms = start.elapsed().as_millis(),
reason = "verified_promotion_failed",
"automatic model promotion failed"
);
Err(error)
}
}
}
#[cfg(all(feature = "download", feature = "model2vec"))]
struct LazyModel2VecEmbedder {
model_root: Option<PathBuf>,
policy: DownloadPolicy,
inner: OnceCell<Arc<dyn Embedder>>,
identity: EmbeddingIdentityBundleV1,
}
#[cfg(all(feature = "download", feature = "model2vec"))]
impl LazyModel2VecEmbedder {
fn new(model_root: Option<PathBuf>, policy: DownloadPolicy) -> SearchResult<Self> {
let identity = ModelArtifactManifestV1::potion_128m_native()?
.declared_identity_bundle(QuantizationFormat::F32, "in-memory-f32-v1")?;
Ok(Self {
model_root,
policy,
inner: OnceCell::new(),
identity,
})
}
async fn ensure_loaded(&self, cx: &Cx) -> SearchResult<Arc<dyn Embedder>> {
let embedder = self
.inner
.get_or_try_init(|| async { self.initialize(cx).await })
.await?;
Ok(Arc::clone(embedder))
}
async fn initialize(&self, cx: &Cx) -> SearchResult<Arc<dyn Embedder>> {
if let Some(existing) = detect_fast_embedder(self.model_root.as_deref()) {
let loaded_identity = existing.identity()?;
if loaded_identity.fingerprint() != self.identity.fingerprint() {
return Err(SearchError::InvalidConfig {
field: "lazy_model2vec.identity".to_owned(),
value: loaded_identity.fingerprint(),
reason: "detected loaded identity disagrees with the registered lazy identity"
.to_owned(),
});
}
return Ok(existing);
}
if !self.policy.can_download() {
return Err(SearchError::EmbedderUnavailable {
model: POTION_MODEL_NAME.to_owned(),
reason: self.policy.blocked_reason(),
});
}
let manifest = ModelManifest::potion_128m();
let destination = install_destination_dir(self.model_root.as_deref(), POTION_MODEL_NAME)?;
download_and_install_manifest(cx, &manifest, &destination, self.policy).await?;
let embedder = Model2VecEmbedder::load_with_name(&destination, POTION_MODEL_NAME)?;
let loaded_identity = embedder.identity()?;
if loaded_identity.fingerprint() != self.identity.fingerprint() {
return Err(SearchError::InvalidConfig {
field: "lazy_model2vec.identity".to_owned(),
value: loaded_identity.fingerprint(),
reason: "verified loaded identity disagrees with the registered lazy identity"
.to_owned(),
});
}
Ok(Arc::new(embedder))
}
}
#[cfg(all(feature = "download", feature = "model2vec"))]
impl Embedder for LazyModel2VecEmbedder {
fn embed<'a>(&'a self, cx: &'a Cx, text: &'a str) -> SearchFuture<'a, Vec<f32>> {
Box::pin(async move {
let embedder = self.ensure_loaded(cx).await?;
embedder.embed(cx, text).await
})
}
fn embed_batch<'a>(
&'a self,
cx: &'a Cx,
texts: &'a [&'a str],
) -> SearchFuture<'a, Vec<Vec<f32>>> {
Box::pin(async move {
let embedder = self.ensure_loaded(cx).await?;
embedder.embed_batch(cx, texts).await
})
}
fn dimension(&self) -> usize {
POTION_DIMENSION
}
fn identity(&self) -> SearchResult<&EmbeddingIdentityBundleV1> {
Ok(&self.identity)
}
fn id(&self) -> &str {
POTION_MODEL_NAME
}
fn model_name(&self) -> &str {
POTION_MODEL_NAME
}
fn is_ready(&self) -> bool {
self.inner.get().is_some_and(|embedder| embedder.is_ready())
}
fn is_semantic(&self) -> bool {
true
}
fn category(&self) -> ModelCategory {
ModelCategory::StaticEmbedder
}
fn tier(&self) -> ModelTier {
ModelTier::Fast
}
}
#[cfg(all(feature = "download", feature = "fastembed"))]
struct LazyFastEmbedEmbedder {
model_root: Option<PathBuf>,
policy: DownloadPolicy,
inner: OnceCell<Arc<dyn Embedder>>,
identity: EmbeddingIdentityBundleV1,
}
#[cfg(all(feature = "download", feature = "fastembed"))]
impl LazyFastEmbedEmbedder {
fn new(model_root: Option<PathBuf>, policy: DownloadPolicy) -> SearchResult<Self> {
let identity = ModelArtifactManifestV1::minilm_fastembed()?
.declared_identity_bundle(QuantizationFormat::F32, "in-memory-f32-v1")?;
Ok(Self {
model_root,
policy,
inner: OnceCell::new(),
identity,
})
}
async fn ensure_loaded(&self, cx: &Cx) -> SearchResult<Arc<dyn Embedder>> {
let embedder = self
.inner
.get_or_try_init(|| async { self.initialize(cx).await })
.await?;
Ok(Arc::clone(embedder))
}
async fn initialize(&self, cx: &Cx) -> SearchResult<Arc<dyn Embedder>> {
if let Some(existing) = detect_quality_embedder(self.model_root.as_deref()) {
let loaded_identity = existing.identity()?;
if loaded_identity.fingerprint() != self.identity.fingerprint() {
return Err(SearchError::InvalidConfig {
field: "lazy_fastembed.identity".to_owned(),
value: loaded_identity.fingerprint(),
reason: "detected loaded identity disagrees with the registered lazy identity"
.to_owned(),
});
}
return Ok(existing);
}
if !self.policy.can_download() {
return Err(SearchError::EmbedderUnavailable {
model: MINILM_MODEL_NAME.to_owned(),
reason: self.policy.blocked_reason(),
});
}
let manifest = ModelManifest::minilm_v2();
let destination = install_destination_dir(self.model_root.as_deref(), MINILM_MODEL_NAME)?;
download_and_install_manifest(cx, &manifest, &destination, self.policy).await?;
let embedder = FastEmbedEmbedder::load_with_name(&destination, MINILM_MODEL_NAME)?;
let loaded_identity = embedder.identity()?;
if loaded_identity.fingerprint() != self.identity.fingerprint() {
return Err(SearchError::InvalidConfig {
field: "lazy_fastembed.identity".to_owned(),
value: loaded_identity.fingerprint(),
reason: "verified loaded identity disagrees with the registered lazy identity"
.to_owned(),
});
}
Ok(Arc::new(embedder))
}
}
#[cfg(all(feature = "download", feature = "fastembed"))]
impl Embedder for LazyFastEmbedEmbedder {
fn embed<'a>(&'a self, cx: &'a Cx, text: &'a str) -> SearchFuture<'a, Vec<f32>> {
Box::pin(async move {
let embedder = self.ensure_loaded(cx).await?;
embedder.embed(cx, text).await
})
}
fn embed_batch<'a>(
&'a self,
cx: &'a Cx,
texts: &'a [&'a str],
) -> SearchFuture<'a, Vec<Vec<f32>>> {
Box::pin(async move {
let embedder = self.ensure_loaded(cx).await?;
embedder.embed_batch(cx, texts).await
})
}
fn dimension(&self) -> usize {
MINILM_DIMENSION
}
fn identity(&self) -> SearchResult<&EmbeddingIdentityBundleV1> {
Ok(&self.identity)
}
fn id(&self) -> &str {
MINILM_MODEL_NAME
}
fn model_name(&self) -> &str {
MINILM_MODEL_NAME
}
fn is_ready(&self) -> bool {
self.inner.get().is_some_and(|embedder| embedder.is_ready())
}
fn is_semantic(&self) -> bool {
true
}
fn category(&self) -> ModelCategory {
ModelCategory::TransformerEmbedder
}
fn tier(&self) -> ModelTier {
ModelTier::Quality
}
}
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
#[derive(Debug)]
struct DownloadProgressReporter {
model_id: String,
stderr_is_tty: bool,
last_bucket: AtomicU8,
}
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
impl DownloadProgressReporter {
const fn new(model_id: String, stderr_is_tty: bool) -> Self {
Self {
model_id,
stderr_is_tty,
last_bucket: AtomicU8::new(0),
}
}
fn report(&self, progress: &DownloadProgress) {
let progress_x100 = progress_percent_x100(progress);
if self.stderr_is_tty {
self.report_tty(progress, progress_x100);
} else {
self.report_non_tty(progress, progress_x100);
}
}
fn finish_ok(&self, elapsed: std::time::Duration, total_bytes: u64) {
if self.stderr_is_tty {
eprintln!(
"\rDownloaded {} in {:.1}s ({})",
self.model_id,
elapsed.as_secs_f64(),
format_bytes(total_bytes),
);
} else {
eprintln!(
"Downloaded {} in {:.1}s ({})",
self.model_id,
elapsed.as_secs_f64(),
format_bytes(total_bytes),
);
}
}
fn finish_failed(&self, elapsed: std::time::Duration, _error: &SearchError) {
if self.stderr_is_tty {
eprintln!(
"\rDownload failed for {} after {:.1}s",
self.model_id,
elapsed.as_secs_f64(),
);
} else {
eprintln!(
"Download failed for {} after {:.1}s",
self.model_id,
elapsed.as_secs_f64(),
);
}
}
fn report_tty(&self, progress: &DownloadProgress, progress_x100: u64) {
let pct_whole = progress_x100 / 100;
let pct_frac = progress_x100 % 100;
let bar = render_progress_bar(progress_x100);
let total = progress
.total_bytes
.map_or_else(|| "?".to_owned(), format_bytes);
eprint!(
"\rDownloading {} [{}] {:>3}.{pct_frac:02}% {}/{} {} ETA {} ({}/{})",
self.model_id,
bar,
pct_whole,
format_bytes(progress.bytes_downloaded),
total,
format_speed(progress.speed_bytes_per_sec),
format_eta(progress.eta_seconds),
progress.files_completed + 1,
progress.files_total.max(1),
);
let _ = io::stderr().flush();
}
fn report_non_tty(&self, progress: &DownloadProgress, progress_x100: u64) {
let bucket = u8::try_from((progress_x100 / 1000).min(10)).unwrap_or(10);
let previous = self.last_bucket.load(Ordering::Relaxed);
if bucket <= previous {
return;
}
if self
.last_bucket
.compare_exchange(previous, bucket, Ordering::Relaxed, Ordering::Relaxed)
.is_ok()
{
eprintln!(
"Downloading {}... {}% ({}/{} {} ETA {})",
self.model_id,
bucket.saturating_mul(10),
format_bytes(progress.bytes_downloaded),
progress
.total_bytes
.map_or_else(|| "?".to_owned(), format_bytes),
format_speed(progress.speed_bytes_per_sec),
format_eta(progress.eta_seconds),
);
}
}
}
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
fn progress_percent_x100(progress: &DownloadProgress) -> u64 {
let files_total = u64::try_from(progress.files_total).unwrap_or(1).max(1);
let files_completed = u64::try_from(progress.files_completed)
.unwrap_or(files_total)
.min(files_total);
let current_file_percent_x100 = progress
.total_bytes
.filter(|&total| total > 0)
.map_or(0, |total| {
progress.bytes_downloaded.min(total).saturating_mul(10_000) / total
});
files_completed
.saturating_mul(10_000)
.saturating_add(current_file_percent_x100)
/ files_total
}
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
fn render_progress_bar(progress_x100: u64) -> String {
let width = u64::try_from(PROGRESS_BAR_WIDTH).unwrap_or(30);
let filled = usize::try_from(progress_x100.saturating_mul(width) / 10_000)
.unwrap_or(PROGRESS_BAR_WIDTH)
.min(PROGRESS_BAR_WIDTH);
let mut bar = String::with_capacity(PROGRESS_BAR_WIDTH);
bar.push_str(&"=".repeat(filled));
bar.push_str(&" ".repeat(PROGRESS_BAR_WIDTH.saturating_sub(filled)));
bar
}
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
fn format_eta(seconds: Option<f64>) -> String {
match seconds {
Some(value) if value.is_finite() && value >= 0.0 => format!("{value:.1}s"),
_ => "?".to_owned(),
}
}
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
fn format_speed(bytes_per_sec: f64) -> String {
const KB: f64 = 1024.0;
const MB: f64 = 1024.0 * 1024.0;
const GB: f64 = 1024.0 * 1024.0 * 1024.0;
if !bytes_per_sec.is_finite() || bytes_per_sec <= 0.0 {
return "0 B/s".to_owned();
}
if bytes_per_sec >= GB {
format!("{:.1} GB/s", bytes_per_sec / GB)
} else if bytes_per_sec >= MB {
format!("{:.1} MB/s", bytes_per_sec / MB)
} else if bytes_per_sec >= KB {
format!("{:.1} KB/s", bytes_per_sec / KB)
} else {
format!("{bytes_per_sec:.0} B/s")
}
}
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
fn format_bytes(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = 1024 * 1024;
const GB: u64 = 1024 * 1024 * 1024;
if bytes >= GB {
let whole = bytes / GB;
let frac = bytes % GB * 10 / GB;
format!("{whole}.{frac} GB")
} else if bytes >= MB {
let whole = bytes / MB;
let frac = bytes % MB * 10 / MB;
format!("{whole}.{frac} MB")
} else if bytes >= KB {
let whole = bytes / KB;
let frac = bytes % KB * 10 / KB;
format!("{whole}.{frac} KB")
} else {
format!("{bytes} B")
}
}
#[cfg(feature = "model2vec")]
fn detect_fast_embedder(model_root: Option<&Path>) -> Option<Arc<dyn Embedder>> {
let manifest = ModelManifest::potion_128m();
let discovered = if model_root.is_some() {
None
} else {
find_model2vec_model_dir(POTION_MODEL_NAME, POTION_HF_ID)
};
let candidates = candidate_directories(model_root, POTION_MODEL_NAME, discovered.as_deref());
let checked_candidates = candidates.len();
for candidate in candidates {
let missing = missing_manifest_files(&manifest, &candidate);
if !missing.is_empty() {
if candidate.is_dir() {
debug!(
model = POTION_MODEL_NAME,
tier = "fast",
missing = ?missing,
"model directory exists but is incomplete, skipping candidate"
);
}
continue;
}
if let Err(_error) = crate::model_manifest::verify_dir_cached(&manifest, &candidate) {
warn!(
model = POTION_MODEL_NAME,
reason = "manifest_verification_failed",
"model2vec manifest verification failed, skipping candidate"
);
continue;
}
match Model2VecEmbedder::load_with_name(&candidate, POTION_MODEL_NAME) {
Ok(embedder) => {
info!(
model = POTION_MODEL_NAME,
tier = "fast",
dimension = embedder.dimension(),
identity = embedder.identity().map_or_else(
|_| "unverifiable".to_owned(),
EmbeddingIdentityBundleV1::fingerprint
),
"embedder detected"
);
return Some(Arc::new(embedder));
}
Err(_error) => {
warn!(
model = POTION_MODEL_NAME,
tier = "fast",
reason = "backend_load_failed",
"embedder unavailable"
);
}
}
}
info!(
model = POTION_MODEL_NAME,
tier = "fast",
checked_candidates,
"embedder unavailable"
);
None
}
#[cfg(not(feature = "model2vec"))]
fn detect_fast_embedder(_model_root: Option<&Path>) -> Option<Arc<dyn Embedder>> {
None
}
#[cfg(feature = "fastembed")]
fn detect_quality_embedder(model_root: Option<&Path>) -> Option<Arc<dyn Embedder>> {
let manifest = ModelManifest::minilm_v2();
let discovered = if model_root.is_some() {
None
} else {
find_fastembed_model_dir(MINILM_MODEL_NAME, MINILM_HF_ID)
};
let candidates = candidate_directories(model_root, MINILM_MODEL_NAME, discovered.as_deref());
let checked_candidates = candidates.len();
for candidate in candidates {
let missing = missing_manifest_files(&manifest, &candidate);
if !missing.is_empty() {
if candidate.is_dir() {
debug!(
model = MINILM_MODEL_NAME,
tier = "quality",
missing = ?missing,
"model directory exists but is incomplete, skipping candidate"
);
}
continue;
}
if let Err(_error) = crate::model_manifest::verify_dir_cached(&manifest, &candidate) {
warn!(
model = MINILM_MODEL_NAME,
reason = "manifest_verification_failed",
"quality manifest verification failed, skipping candidate"
);
continue;
}
match FastEmbedEmbedder::load_with_name(&candidate, MINILM_MODEL_NAME) {
Ok(embedder) => {
info!(
model = MINILM_MODEL_NAME,
tier = "quality",
dimension = embedder.dimension(),
identity = embedder.identity().map_or_else(
|_| "unverifiable".to_owned(),
EmbeddingIdentityBundleV1::fingerprint
),
"embedder detected"
);
return Some(Arc::new(embedder));
}
Err(_error) => {
warn!(
model = MINILM_MODEL_NAME,
tier = "quality",
reason = "backend_load_failed",
"embedder unavailable"
);
}
}
}
info!(
model = MINILM_MODEL_NAME,
tier = "quality",
checked_candidates,
"embedder unavailable"
);
None
}
#[cfg(not(feature = "fastembed"))]
fn detect_quality_embedder(_model_root: Option<&Path>) -> Option<Arc<dyn Embedder>> {
None
}
#[cfg(feature = "hash")]
#[allow(clippy::unnecessary_wraps)]
fn hash_fallback_embedder() -> Option<Arc<dyn Embedder>> {
Some(Arc::new(HashEmbedder::default_256()))
}
#[cfg(not(feature = "hash"))]
fn hash_fallback_embedder() -> Option<Arc<dyn Embedder>> {
None
}
#[derive(Debug, Clone, Copy, Default)]
pub struct DetectOptions {
pub offline: Option<bool>,
}
#[derive(Debug, Clone, Default)]
struct RemoteIntentEnv {
offline: Option<String>,
provider: Option<String>,
model: Option<String>,
dimension: Option<String>,
identity_json: Option<String>,
openai_key: Option<String>,
gemini_key: Option<String>,
}
impl RemoteIntentEnv {
fn from_environment() -> Self {
Self {
offline: std::env::var(OFFLINE_ENV).ok(),
provider: std::env::var("FRANKENSEARCH_API_PROVIDER").ok(),
model: std::env::var("FRANKENSEARCH_API_MODEL").ok(),
dimension: std::env::var("FRANKENSEARCH_API_DIMENSION").ok(),
identity_json: std::env::var("FRANKENSEARCH_API_IDENTITY_JSON").ok(),
openai_key: std::env::var("OPENAI_API_KEY").ok(),
gemini_key: std::env::var("GEMINI_API_KEY").ok(),
}
}
fn has_explicit_intent(&self) -> bool {
self.provider.is_some()
|| self.model.is_some()
|| self.dimension.is_some()
|| self.identity_json.is_some()
}
}
fn resolve_offline_policy(options: DetectOptions, env: &RemoteIntentEnv) -> SearchResult<bool> {
if let Some(offline) = options.offline {
return Ok(offline);
}
let Some(raw) = env.offline.as_deref() else {
return Ok(false);
};
parse_bool_flag(raw).ok_or_else(|| SearchError::InvalidConfig {
field: OFFLINE_ENV.to_owned(),
value: raw.to_owned(),
reason: "expected a boolean flag: 1/0, true/false, yes/no, or on/off".to_owned(),
})
}
fn unverifiable_remote(reason: &str) -> SearchError {
SearchError::UnverifiableRemoteSpace {
producer: "environment".to_owned(),
reason: reason.to_owned(),
}
}
fn resolve_remote_intent(
options: DetectOptions,
env: &RemoteIntentEnv,
) -> SearchResult<(bool, Option<Arc<dyn Embedder>>)> {
resolve_remote_intent_with(options, env, resolve_remote_intent_online)
}
fn resolve_remote_intent_with<T>(
options: DetectOptions,
env: &RemoteIntentEnv,
construct_online: impl FnOnce(&RemoteIntentEnv) -> SearchResult<Option<T>>,
) -> SearchResult<(bool, Option<T>)> {
let offline = resolve_offline_policy(options, env)?;
if offline {
if env.has_explicit_intent() {
return Err(SearchError::EmbedderUnavailable {
model: "remote-tier".to_owned(),
reason: format!(
"{OFFLINE_ENV}=1 or an explicit offline detection policy forbids remote \
provider construction"
),
});
}
return Ok((true, None));
}
construct_online(env).map(|remote| (false, remote))
}
#[cfg(feature = "api")]
fn resolve_remote_intent_online(env: &RemoteIntentEnv) -> SearchResult<Option<Arc<dyn Embedder>>> {
use crate::api_embedder::ApiEmbedder;
use crate::api_provider::{GeminiProvider, OpenAiProvider};
let explicit_dim: Option<usize> = env.dimension.as_deref().and_then(|s| s.parse().ok());
let Some(identity_json) = env.identity_json.as_deref() else {
if env.has_explicit_intent() {
return Err(unverifiable_remote(
"explicit remote configuration is present but \
FRANKENSEARCH_API_IDENTITY_JSON (the immutable space epoch) is not set",
));
}
if env.openai_key.is_some() || env.gemini_key.is_some() {
warn!(
field = "FRANKENSEARCH_API_IDENTITY_JSON",
"ambient provider key ignored: no frankensearch remote configuration \
and no immutable space epoch was supplied"
);
}
return Ok(None);
};
let identity: FrozenEmbeddingIdentityBundleV1 =
serde_json::from_str(identity_json).map_err(|_| {
unverifiable_remote("FRANKENSEARCH_API_IDENTITY_JSON is not valid identity JSON")
})?;
identity.validate().map_err(|_| {
unverifiable_remote("FRANKENSEARCH_API_IDENTITY_JSON failed identity validation")
})?;
let provider: Box<dyn crate::api_provider::ApiProvider> = match env.provider.as_deref() {
Some("gemini") => {
let key = env.gemini_key.clone().ok_or_else(|| {
unverifiable_remote("provider gemini is configured but GEMINI_API_KEY is not set")
})?;
match env.model.as_deref() {
Some("embedding-001") => Box::new(GeminiProvider::embedding_001(key)),
_ => Box::new(GeminiProvider::text_embedding_004(key)),
}
}
Some("openai") => {
let key = env.openai_key.clone().ok_or_else(|| {
unverifiable_remote("provider openai is configured but OPENAI_API_KEY is not set")
})?;
match env.model.as_deref() {
Some("text-embedding-3-large") => {
Box::new(OpenAiProvider::text_embedding_3_large(key, explicit_dim))
}
_ => Box::new(OpenAiProvider::text_embedding_3_small(key, explicit_dim)),
}
}
None => {
if let Some(key) = env.openai_key.clone() {
match env.model.as_deref() {
Some("text-embedding-3-large") => {
Box::new(OpenAiProvider::text_embedding_3_large(key, explicit_dim))
}
_ => Box::new(OpenAiProvider::text_embedding_3_small(key, explicit_dim)),
}
} else if let Some(key) = env.gemini_key.clone() {
match env.model.as_deref() {
Some("embedding-001") => Box::new(GeminiProvider::embedding_001(key)),
_ => Box::new(GeminiProvider::text_embedding_004(key)),
}
} else {
return Err(unverifiable_remote(
"an immutable space epoch is configured but no provider API key is set",
));
}
}
Some(_other) => {
return Err(unverifiable_remote(
"FRANKENSEARCH_API_PROVIDER names an unknown provider",
));
}
};
info!(
provider = provider.provider_name(),
dimension = provider.dimension(),
identity = identity.fingerprint.as_str(),
"detected API embedder from environment"
);
ApiEmbedder::with_defaults(provider, Some(identity))
.map(|embedder| Some(Arc::new(embedder.cached_default()) as Arc<dyn Embedder>))
.map_err(|_| {
unverifiable_remote("the configured provider and the immutable space epoch disagree")
})
}
#[cfg(not(feature = "api"))]
fn resolve_remote_intent_online(env: &RemoteIntentEnv) -> SearchResult<Option<Arc<dyn Embedder>>> {
if env.has_explicit_intent() {
return Err(unverifiable_remote(
"explicit remote configuration is present but this build lacks the `api` feature",
));
}
if env.openai_key.is_some() || env.gemini_key.is_some() {
warn!(
"ambient provider key ignored: no frankensearch remote configuration \
was supplied and this build lacks the `api` feature"
);
}
Ok(None)
}
#[cfg(any(feature = "model2vec", feature = "fastembed"))]
fn missing_manifest_files<'a>(manifest: &'a ModelManifest, model_dir: &Path) -> Vec<&'a str> {
manifest
.files
.iter()
.filter(|file| !model_dir.join(&file.name).is_file())
.map(|file| file.name.as_str())
.collect()
}
#[cfg(any(feature = "model2vec", feature = "fastembed"))]
fn candidate_directories(
model_root: Option<&Path>,
model_name: &str,
discovered: Option<&Path>,
) -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Some(root) = model_root {
paths.push(root.join(model_name));
paths.push(root.to_path_buf());
}
if let Some(path) = discovered {
paths.push(path.to_path_buf());
}
let mut seen = BTreeSet::new();
paths
.into_iter()
.filter(|path| seen.insert(path.clone()))
.collect()
}
#[cfg(test)]
mod tests {
#[cfg(any(
feature = "bundled-default-models",
all(feature = "model2vec", not(feature = "bundled-default-models"))
))]
use std::fs;
#[cfg(all(feature = "download", feature = "model2vec"))]
use asupersync::test_utils::run_test_with_cx;
use super::*;
use frankensearch_core::traits::ModelCategory;
#[cfg(feature = "bundled-default-models")]
#[derive(Debug, PartialEq, Eq)]
struct ObservedModelEntry {
file_kind: u8,
len: u64,
modified: std::time::SystemTime,
created: Option<std::time::SystemTime>,
changed: Option<(i64, i64)>,
permission_key: u64,
bytes: Option<Vec<u8>>,
}
#[cfg(feature = "bundled-default-models")]
fn snapshot_model_tree(root: &Path) -> Vec<(PathBuf, ObservedModelEntry)> {
fn visit(root: &Path, path: &Path, entries: &mut Vec<(PathBuf, ObservedModelEntry)>) {
let mut children = fs::read_dir(path)
.expect("read model tree")
.map(|entry| entry.expect("read model tree entry").path())
.collect::<Vec<_>>();
children.sort();
for child in children {
let relative = child
.strip_prefix(root)
.expect("model tree entry under root")
.to_path_buf();
let metadata = fs::symlink_metadata(&child).expect("read model tree metadata");
let file_type = metadata.file_type();
let file_kind = if file_type.is_dir() {
1
} else if file_type.is_file() {
2
} else if file_type.is_symlink() {
3
} else {
4
};
#[cfg(unix)]
let (permission_key, changed) = {
use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
(
u64::from(metadata.permissions().mode()),
Some((metadata.ctime(), metadata.ctime_nsec())),
)
};
#[cfg(not(unix))]
let permission_key = u64::from(metadata.permissions().readonly());
#[cfg(not(unix))]
let changed = None;
entries.push((
relative,
ObservedModelEntry {
file_kind,
len: metadata.len(),
modified: metadata.modified().expect("read model tree mtime"),
created: metadata.created().ok(),
changed,
permission_key,
bytes: file_type
.is_file()
.then(|| fs::read(&child).expect("read model tree file")),
},
));
if file_type.is_dir() {
visit(root, &child, entries);
}
}
}
if !root.exists() {
return Vec::new();
}
let mut entries = Vec::new();
visit(root, root, &mut entries);
entries
}
#[cfg(feature = "bundled-default-models")]
fn detect_observationally(model_root: &Path) -> SearchResult<EmbedderStack> {
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
{
EmbedderStack::auto_detect_with_policy(
Some(model_root),
DownloadPolicy::for_tests(
DownloadConsent::denied(Some(ConsentSource::Programmatic)),
false,
false,
),
None,
)
}
#[cfg(not(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
)))]
{
EmbedderStack::auto_detect_with(Some(model_root))
}
}
#[cfg(all(feature = "bundled-default-models", feature = "hash"))]
#[test]
fn auto_detect_does_not_materialize_an_absent_bundled_cache() {
let temp = tempfile::tempdir().expect("tempdir");
let model_root = temp.path().join("absent-model-root");
let stack = detect_observationally(&model_root).expect("observational detection");
assert_eq!(stack.availability(), TwoTierAvailability::HashOnly);
assert!(
!model_root.exists(),
"auto-detection must not create an absent bundled-model cache"
);
}
#[cfg(all(feature = "bundled-default-models", feature = "hash"))]
#[test]
fn auto_detect_leaves_corrupt_bundled_bytes_and_receipts_unchanged() {
let temp = tempfile::tempdir().expect("tempdir");
let model_root = temp.path().join("models");
let fast_dir = model_root.join(POTION_MODEL_NAME);
let quality_dir = model_root.join(MINILM_MODEL_NAME);
fs::create_dir_all(&fast_dir).expect("create corrupt fast model dir");
fs::create_dir_all(&quality_dir).expect("create corrupt quality model dir");
fs::write(fast_dir.join("tokenizer.json"), b"corrupt-fast-tokenizer")
.expect("write corrupt fast bytes");
fs::write(fast_dir.join(".verified"), b"stale-fast-receipt")
.expect("write stale fast receipt");
fs::write(quality_dir.join("model.onnx"), b"corrupt-quality-model")
.expect("write corrupt quality bytes");
fs::write(quality_dir.join(".verified"), b"stale-quality-receipt")
.expect("write stale quality receipt");
let before = snapshot_model_tree(&model_root);
let stack = detect_observationally(&model_root).expect("observational detection");
assert_eq!(stack.availability(), TwoTierAvailability::HashOnly);
assert_eq!(
snapshot_model_tree(&model_root),
before,
"auto-detection must not repair model bytes or mint receipts"
);
}
struct MrlFixtureEmbedder {
identity: EmbeddingIdentityBundleV1,
}
impl MrlFixtureEmbedder {
fn semantic_nomic() -> Self {
let identity = crate::model_manifest::ModelArtifactManifestV1::nomic_fastembed()
.expect("registered Nomic manifest")
.declared_identity_bundle(
frankensearch_core::generation::QuantizationFormat::F32,
"in-memory-f32-v1",
)
.expect("derive semantic MRL fixture identity");
Self { identity }
}
}
impl Embedder for MrlFixtureEmbedder {
fn embed<'a>(&'a self, _cx: &'a Cx, _text: &'a str) -> SearchFuture<'a, Vec<f32>> {
Box::pin(async move {
Err(SearchError::EmbeddingFailed {
model: "mrl-structural-fixture".to_owned(),
source: "identity-only test fixture does not run inference".into(),
})
})
}
fn identity(&self) -> SearchResult<&EmbeddingIdentityBundleV1> {
Ok(&self.identity)
}
fn dimension(&self) -> usize {
usize::try_from(self.identity.space.dimension)
.expect("fixture dimension must fit usize")
}
fn id(&self) -> &'static str {
"mrl-structural-fixture"
}
fn model_name(&self) -> &'static str {
"MRL structural fixture"
}
fn is_semantic(&self) -> bool {
true
}
fn category(&self) -> ModelCategory {
ModelCategory::TransformerEmbedder
}
fn supports_mrl(&self) -> bool {
true
}
}
#[cfg(all(feature = "hash", not(feature = "bundled-default-models")))]
#[test]
fn auto_detect_hash_only_when_no_models_present() {
let temp = tempfile::tempdir().unwrap();
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
let stack = EmbedderStack::auto_detect_with_policy(
Some(temp.path()),
DownloadPolicy::for_tests(
DownloadConsent::denied(Some(ConsentSource::Programmatic)),
false,
false,
),
None,
)
.unwrap();
#[cfg(not(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
)))]
let stack = EmbedderStack::auto_detect_with(Some(temp.path())).unwrap();
assert_eq!(stack.availability(), TwoTierAvailability::HashOnly);
assert_eq!(stack.fast().category(), ModelCategory::HashEmbedder);
assert!(stack.quality().is_none());
assert!(matches!(
stack.require_semantic(),
Err(SearchError::EmbedderUnavailable { .. })
));
assert!(matches!(
EmbedderStack::auto_detect_semantic_with(Some(temp.path())),
Err(SearchError::EmbedderUnavailable { .. })
));
assert!(matches!(
EmbedderStack::auto_detect_semantic_with_options(
Some(temp.path()),
&DetectOptions::default()
),
Err(SearchError::EmbedderUnavailable { .. })
));
}
#[cfg(all(
feature = "model2vec",
feature = "hash",
not(feature = "bundled-default-models")
))]
#[test]
fn auto_detect_rejects_unverified_model2vec_layout() {
let temp = tempfile::tempdir().unwrap();
let model_dir = temp.path().join(POTION_MODEL_NAME);
fs::create_dir_all(&model_dir).unwrap();
create_test_model2vec_layout(&model_dir, 16, 8);
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
let stack = EmbedderStack::auto_detect_with_policy(
Some(temp.path()),
DownloadPolicy::for_tests(
DownloadConsent::denied(Some(ConsentSource::Programmatic)),
false,
false,
),
None,
)
.unwrap();
#[cfg(not(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
)))]
let stack = EmbedderStack::auto_detect_with(Some(temp.path())).unwrap();
assert_eq!(stack.availability(), TwoTierAvailability::HashOnly);
assert_eq!(stack.fast().category(), ModelCategory::HashEmbedder);
assert!(stack.quality().is_none());
}
#[cfg(all(
feature = "model2vec",
feature = "hash",
not(feature = "bundled-default-models")
))]
#[test]
fn corrupted_model2vec_falls_back_to_hash() {
let temp = tempfile::tempdir().unwrap();
let model_dir = temp.path().join(POTION_MODEL_NAME);
fs::create_dir_all(&model_dir).unwrap();
fs::write(model_dir.join("tokenizer.json"), "{}").unwrap();
fs::write(model_dir.join("model.safetensors"), b"not-safetensors").unwrap();
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
let stack = EmbedderStack::auto_detect_with_policy(
Some(temp.path()),
DownloadPolicy::for_tests(
DownloadConsent::denied(Some(ConsentSource::Programmatic)),
false,
false,
),
None,
)
.unwrap();
#[cfg(not(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
)))]
let stack = EmbedderStack::auto_detect_with(Some(temp.path())).unwrap();
assert_eq!(stack.availability(), TwoTierAvailability::HashOnly);
assert_eq!(stack.fast().category(), ModelCategory::HashEmbedder);
}
#[cfg(all(feature = "download", feature = "model2vec", feature = "hash"))]
#[test]
fn auto_detect_prefers_lazy_fast_embedder_when_download_enabled() {
let temp = tempfile::tempdir().unwrap();
let stack = EmbedderStack::auto_detect_with_policy(
Some(temp.path()),
DownloadPolicy::for_tests(
DownloadConsent::granted(ConsentSource::Programmatic),
false,
false,
),
None,
)
.unwrap();
assert_eq!(stack.fast().id(), POTION_MODEL_NAME);
assert_eq!(stack.fast().category(), ModelCategory::StaticEmbedder);
}
#[cfg(all(feature = "download", feature = "model2vec"))]
#[test]
fn lazy_model2vec_returns_unavailable_when_download_is_denied() {
let temp = tempfile::tempdir().unwrap();
let lazy = LazyModel2VecEmbedder::new(
Some(temp.path().to_path_buf()),
DownloadPolicy::for_tests(
DownloadConsent::denied(Some(ConsentSource::Programmatic)),
false,
false,
),
)
.unwrap();
run_test_with_cx(|cx| async move {
let err = lazy
.embed(&cx, "hello world")
.await
.expect_err("download-denied lazy model should error");
assert!(matches!(err, SearchError::EmbedderUnavailable { .. }));
});
}
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
#[test]
fn parse_bool_flag_supports_common_values() {
assert_eq!(parse_bool_flag("1"), Some(true));
assert_eq!(parse_bool_flag("true"), Some(true));
assert_eq!(parse_bool_flag("YES"), Some(true));
assert_eq!(parse_bool_flag("0"), Some(false));
assert_eq!(parse_bool_flag("false"), Some(false));
assert_eq!(parse_bool_flag("off"), Some(false));
assert_eq!(parse_bool_flag("invalid"), None);
}
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
#[test]
fn progress_percent_accounts_for_current_file_fraction() {
let progress = DownloadProgress {
file_name: "model.onnx".to_owned(),
bytes_downloaded: 50,
total_bytes: Some(100),
files_completed: 1,
files_total: 4,
speed_bytes_per_sec: 1.0,
eta_seconds: Some(2.0),
};
assert_eq!(progress_percent_x100(&progress), 3_750);
}
#[cfg(all(feature = "model2vec", not(feature = "bundled-default-models")))]
fn create_test_model2vec_layout(dir: &Path, vocab_size: usize, dimensions: usize) {
let tokenizer_json = serde_json::json!({
"version": "1.0",
"truncation": null,
"padding": null,
"added_tokens": [{
"id": 0,
"content": "[UNK]",
"single_word": false,
"lstrip": false,
"rstrip": false,
"normalized": false,
"special": true
}],
"normalizer": { "type": "Lowercase" },
"pre_tokenizer": { "type": "Whitespace" },
"post_processor": null,
"decoder": null,
"model": {
"type": "WordLevel",
"vocab": create_test_vocab(vocab_size),
"unk_token": "[UNK]"
}
});
fs::write(
dir.join("tokenizer.json"),
serde_json::to_string_pretty(&tokenizer_json).unwrap(),
)
.unwrap();
create_test_safetensors(dir, vocab_size, dimensions);
}
#[cfg(all(feature = "model2vec", not(feature = "bundled-default-models")))]
fn create_test_vocab(vocab_size: usize) -> serde_json::Value {
let mut vocab = serde_json::Map::new();
vocab.insert("[UNK]".to_owned(), serde_json::Value::from(0));
for idx in 1..vocab_size {
vocab.insert(format!("token{idx}"), serde_json::Value::from(idx));
}
serde_json::Value::Object(vocab)
}
struct SemanticTestEmbedder {
id: &'static str,
dimension: usize,
}
impl SemanticTestEmbedder {
const fn new(id: &'static str, dimension: usize) -> Self {
Self { id, dimension }
}
}
impl Embedder for SemanticTestEmbedder {
fn embed<'a>(&'a self, _cx: &'a Cx, _text: &'a str) -> SearchFuture<'a, Vec<f32>> {
let dim = self.dimension;
Box::pin(async move { Ok(vec![0.0; dim]) })
}
fn dimension(&self) -> usize {
self.dimension
}
fn id(&self) -> &str {
self.id
}
fn model_name(&self) -> &str {
self.id
}
fn is_semantic(&self) -> bool {
true
}
fn category(&self) -> ModelCategory {
ModelCategory::StaticEmbedder
}
}
#[cfg(feature = "hash")]
#[test]
fn from_parts_hash_only_availability() {
let hash = Arc::new(crate::hash_embedder::HashEmbedder::default_256());
let stack = EmbedderStack::from_parts(hash, None);
assert_eq!(stack.availability(), TwoTierAvailability::HashOnly);
assert!(stack.quality().is_none());
assert!(stack.quality_arc().is_none());
assert_eq!(stack.fast().category(), ModelCategory::HashEmbedder);
assert_eq!(stack.fast_embedder().id(), stack.fast().id());
}
#[cfg(feature = "hash")]
#[test]
fn from_parts_two_hash_embedders_is_hash_only() {
let fast: Arc<dyn Embedder> = Arc::new(crate::hash_embedder::HashEmbedder::default_256());
let quality: Arc<dyn Embedder> =
Arc::new(crate::hash_embedder::HashEmbedder::default_384());
let stack = EmbedderStack::from_parts(fast, Some(quality));
assert_eq!(stack.availability(), TwoTierAvailability::HashOnly);
assert!(
stack.require_semantic().is_err(),
"two hash embedders must not pass require_semantic"
);
}
#[cfg(feature = "hash")]
#[test]
fn from_parts_semantic_pair_is_full() {
let fast: Arc<dyn Embedder> = Arc::new(SemanticTestEmbedder::new("test-fast", 4));
let quality: Arc<dyn Embedder> = Arc::new(SemanticTestEmbedder::new("test-quality", 4));
let stack = EmbedderStack::from_parts(fast, Some(quality));
assert_eq!(stack.availability(), TwoTierAvailability::Full);
assert!(stack.quality().is_some());
assert!(stack.quality_arc().is_some());
assert_eq!(
stack.quality_embedder().unwrap().id(),
stack.quality().unwrap().id()
);
}
#[cfg(feature = "hash")]
#[test]
fn from_parts_semantic_plus_hash_quality_is_fast_only() {
let fast: Arc<dyn Embedder> = Arc::new(SemanticTestEmbedder::new("test-fast", 4));
let quality: Arc<dyn Embedder> =
Arc::new(crate::hash_embedder::HashEmbedder::default_384());
let stack = EmbedderStack::from_parts(fast, Some(quality));
assert_eq!(stack.availability(), TwoTierAvailability::FastOnly);
assert!(stack.require_semantic().is_ok());
}
#[cfg(feature = "hash")]
#[test]
fn dim_reduce_rejects_zero_target_dim() {
let inner: Arc<dyn Embedder> = Arc::new(crate::hash_embedder::HashEmbedder::default_256());
let err = DimReduceEmbedder::new(inner, 0).expect_err("should reject target_dim=0");
assert!(matches!(err, SearchError::InvalidConfig { .. }));
}
#[cfg(feature = "hash")]
#[test]
fn dim_reduce_rejects_target_exceeding_inner_dim() {
let inner: Arc<dyn Embedder> = Arc::new(crate::hash_embedder::HashEmbedder::default_256());
let err =
DimReduceEmbedder::new(inner, 512).expect_err("should reject target_dim > inner dim");
assert!(matches!(err, SearchError::InvalidConfig { .. }));
}
#[cfg(feature = "hash")]
#[test]
fn dim_reduce_rejects_non_mrl_embedder() {
let inner: Arc<dyn Embedder> = Arc::new(crate::hash_embedder::HashEmbedder::default_256());
assert!(!inner.supports_mrl());
let err = DimReduceEmbedder::new(inner, 64).expect_err("should reject non-MRL embedder");
assert!(matches!(err, SearchError::InvalidConfig { .. }));
}
#[test]
fn dim_reduce_derives_structural_identity() {
let inner: Arc<dyn Embedder> = Arc::new(MrlFixtureEmbedder::semantic_nomic());
assert!(inner.is_semantic());
assert!(inner.supports_mrl());
let parent = inner.identity().unwrap().clone();
let reduced = DimReduceEmbedder::new(inner, 4).unwrap();
let child = reduced.identity().unwrap();
child.validate().unwrap();
let projection = child.space.projection.as_ref().unwrap();
assert_eq!(
projection.parent_space_fingerprint,
parent.space.fingerprint()
);
assert_eq!(projection.source_dimension, 768);
assert_eq!(projection.output_dimension, 4);
assert_eq!(child.storage.dimension, 4);
}
#[cfg(feature = "hash")]
#[test]
fn with_mrl_target_dim_zero_is_rejected() {
let fast: Arc<dyn Embedder> = Arc::new(crate::hash_embedder::HashEmbedder::default_256());
let stack = EmbedderStack::from_parts(fast, None);
let err = stack
.with_mrl_target_dim(0)
.expect_err("should reject target_dim=0");
assert!(matches!(err, SearchError::InvalidConfig { .. }));
}
#[cfg(feature = "hash")]
#[test]
fn with_mrl_passthrough_when_non_mrl() {
let fast: Arc<dyn Embedder> = Arc::new(crate::hash_embedder::HashEmbedder::default_256());
let stack = EmbedderStack::from_parts(fast, None);
let stack = stack.with_mrl_target_dim(64).unwrap();
assert_eq!(stack.availability(), TwoTierAvailability::HashOnly);
assert_eq!(stack.fast().dimension(), 256);
}
#[cfg(feature = "hash")]
#[test]
fn embedder_stack_debug_format() {
let fast: Arc<dyn Embedder> = Arc::new(crate::hash_embedder::HashEmbedder::default_256());
let stack = EmbedderStack::from_parts(fast, None);
let debug = format!("{stack:?}");
assert!(debug.contains("EmbedderStack"));
assert!(debug.contains("HashOnly"));
}
#[cfg(feature = "hash")]
#[test]
fn availability_is_degraded_for_hash_only() {
assert!(TwoTierAvailability::HashOnly.is_degraded());
assert!(TwoTierAvailability::FastOnly.is_degraded());
assert!(!TwoTierAvailability::Full.is_degraded());
}
#[cfg(feature = "hash")]
#[test]
fn availability_display_format() {
let full = format!("{}", TwoTierAvailability::Full);
assert!(full.contains("full"));
let fast = format!("{}", TwoTierAvailability::FastOnly);
assert!(fast.contains("degraded"));
let hash = format!("{}", TwoTierAvailability::HashOnly);
assert!(hash.contains("minimal"));
}
#[cfg(feature = "hash")]
#[test]
fn degradation_summary_none_for_full() {
assert!(TwoTierAvailability::Full.degradation_summary().is_none());
}
#[cfg(feature = "hash")]
#[test]
fn degradation_summary_present_for_degraded() {
assert!(
TwoTierAvailability::FastOnly
.degradation_summary()
.unwrap()
.contains("Quality model")
);
let hash_summary = TwoTierAvailability::HashOnly.degradation_summary().unwrap();
assert!(
hash_summary.contains("not semantic search")
&& !hash_summary.contains("reduced relevance"),
"hash-only must not be framed as weaker semantic search: {hash_summary}"
);
}
#[cfg(feature = "hash")]
#[test]
fn diagnose_hash_only_provides_suggestions() {
let fast: Arc<dyn Embedder> = Arc::new(crate::hash_embedder::HashEmbedder::default_256());
let stack = EmbedderStack::from_parts(fast, None);
let diag = stack.diagnose();
assert_eq!(diag.availability, TwoTierAvailability::HashOnly);
assert!(matches!(diag.fast_status, ModelStatus::HashFallback));
assert!(!diag.suggestions.is_empty());
assert!(
diag.suggestions
.iter()
.any(|s| s.contains("FRANKENSEARCH_MODEL_DIR"))
);
#[cfg(not(feature = "bundled-default-models"))]
assert!(diag.suggestions.iter().any(|s| s.contains("air-gapped")));
#[cfg(feature = "bundled-default-models")]
assert!(
diag.suggestions
.iter()
.any(|s| s.contains("download-models"))
);
}
#[cfg(feature = "hash")]
#[test]
fn diagnose_full_has_no_suggestions() {
let fast: Arc<dyn Embedder> = Arc::new(SemanticTestEmbedder::new("test-fast", 4));
let quality: Arc<dyn Embedder> = Arc::new(SemanticTestEmbedder::new("test-quality", 4));
let stack = EmbedderStack::from_parts(fast, Some(quality));
let diag = stack.diagnose();
assert_eq!(diag.availability, TwoTierAvailability::Full);
assert!(diag.suggestions.is_empty());
}
#[cfg(feature = "hash")]
#[test]
fn degradation_message_none_when_full() {
let fast: Arc<dyn Embedder> = Arc::new(SemanticTestEmbedder::new("test-fast", 4));
let quality: Arc<dyn Embedder> = Arc::new(SemanticTestEmbedder::new("test-quality", 4));
let stack = EmbedderStack::from_parts(fast, Some(quality));
assert!(stack.degradation_message().is_none());
}
#[cfg(feature = "hash")]
#[test]
fn degradation_message_present_when_degraded() {
let fast: Arc<dyn Embedder> = Arc::new(crate::hash_embedder::HashEmbedder::default_256());
let stack = EmbedderStack::from_parts(fast, None);
let msg = stack
.degradation_message()
.expect("should be present for hash-only");
assert!(msg.contains("Model cache:"));
assert!(msg.contains("FRANKENSEARCH_MODEL_DIR"));
assert!(
msg.contains("not semantic search")
&& msg.contains("cannot repair a hash-written index")
&& !msg.contains("reduced relevance")
&& !msg.contains("To improve search quality"),
"hash-only message must not sound like a quality downgrade: {msg}"
);
}
#[cfg(feature = "hash")]
#[test]
fn model_status_display_ready() {
let status = ModelStatus::Ready {
id: "test-model".to_owned(),
};
let display = format!("{status}");
assert!(display.contains("ready"));
assert!(display.contains("test-model"));
}
#[cfg(feature = "hash")]
#[test]
fn model_status_display_not_found() {
let status = ModelStatus::NotFound {
model_name: "test-model".to_owned(),
hf_repo_url: "https://huggingface.co/test/model".to_owned(),
searched_paths: vec![],
};
let display = format!("{status}");
assert!(display.contains("NOT FOUND"));
assert!(display.contains("https://huggingface.co/test/model"));
}
#[cfg(feature = "hash")]
#[test]
fn model_status_display_download_blocked() {
let status = ModelStatus::DownloadBlocked {
model_name: "test-model".to_owned(),
reason: "offline mode".to_owned(),
};
let display = format!("{status}");
assert!(display.contains("BLOCKED"));
assert!(display.contains("offline mode"));
}
#[cfg(feature = "hash")]
#[test]
fn model_status_display_feature_disabled() {
let status = ModelStatus::FeatureDisabled {
feature_flag: "fastembed".to_owned(),
};
let display = format!("{status}");
assert!(display.contains("DISABLED"));
assert!(display.contains("fastembed"));
}
#[cfg(feature = "hash")]
#[test]
fn diagnostic_display_includes_all_sections() {
let diag = ModelAvailabilityDiagnostic {
availability: TwoTierAvailability::HashOnly,
cache_dir: std::path::PathBuf::from("/tmp/test-cache"),
offline: false,
fast_status: ModelStatus::HashFallback,
quality_status: ModelStatus::FeatureDisabled {
feature_flag: "fastembed".to_owned(),
},
suggestions: vec!["Fix something".to_owned()],
};
let display = format!("{diag}");
assert!(display.contains("minimal"));
assert!(display.contains("/tmp/test-cache"));
assert!(display.contains("hash control"));
assert!(display.contains("Fix something"));
}
#[cfg(feature = "hash")]
#[test]
fn diagnostic_display_offline_mode_indicator() {
let diag = ModelAvailabilityDiagnostic {
availability: TwoTierAvailability::HashOnly,
cache_dir: std::path::PathBuf::from("/tmp/test-cache"),
offline: true,
fast_status: ModelStatus::HashFallback,
quality_status: ModelStatus::DownloadBlocked {
model_name: "test".to_owned(),
reason: "offline".to_owned(),
},
suggestions: vec![],
};
let display = format!("{diag}");
assert!(display.contains("OFFLINE"));
}
#[test]
fn two_tier_availability_clone_copy_eq() {
let a = TwoTierAvailability::Full;
let b = a;
assert_eq!(a, b);
#[allow(clippy::clone_on_copy)]
let c = a.clone();
assert_eq!(a, c);
assert_ne!(TwoTierAvailability::Full, TwoTierAvailability::HashOnly);
assert_ne!(TwoTierAvailability::FastOnly, TwoTierAvailability::HashOnly);
}
#[test]
fn two_tier_availability_debug() {
let debug = format!("{:?}", TwoTierAvailability::Full);
assert_eq!(debug, "Full");
let debug = format!("{:?}", TwoTierAvailability::FastOnly);
assert_eq!(debug, "FastOnly");
let debug = format!("{:?}", TwoTierAvailability::HashOnly);
assert_eq!(debug, "HashOnly");
}
#[test]
fn model_status_debug_all_variants() {
let ready = ModelStatus::Ready {
id: "test".to_owned(),
};
let debug = format!("{ready:?}");
assert!(debug.contains("Ready"));
assert!(debug.contains("test"));
let not_found = ModelStatus::NotFound {
model_name: "m".to_owned(),
hf_repo_url: "url".to_owned(),
searched_paths: vec![],
};
let debug = format!("{not_found:?}");
assert!(debug.contains("NotFound"));
let blocked = ModelStatus::DownloadBlocked {
model_name: "m".to_owned(),
reason: "r".to_owned(),
};
let debug = format!("{blocked:?}");
assert!(debug.contains("DownloadBlocked"));
let disabled = ModelStatus::FeatureDisabled {
feature_flag: "f".to_owned(),
};
let debug = format!("{disabled:?}");
assert!(debug.contains("FeatureDisabled"));
let hash = ModelStatus::HashFallback;
let debug = format!("{hash:?}");
assert!(debug.contains("HashFallback"));
}
#[test]
fn model_status_clone_all_variants() {
fn clone_and_format(status: &ModelStatus) -> String {
format!("{}", status.clone())
}
assert!(
clone_and_format(&ModelStatus::Ready {
id: "test".to_owned(),
})
.contains("test")
);
assert!(
clone_and_format(&ModelStatus::NotFound {
model_name: "m".to_owned(),
hf_repo_url: "u".to_owned(),
searched_paths: vec![std::path::PathBuf::from("/tmp/p")],
})
.contains("NOT FOUND")
);
assert!(
clone_and_format(&ModelStatus::DownloadBlocked {
model_name: "m".to_owned(),
reason: "r".to_owned(),
})
.contains("BLOCKED")
);
assert!(
clone_and_format(&ModelStatus::FeatureDisabled {
feature_flag: "f".to_owned(),
})
.contains("DISABLED")
);
assert!(clone_and_format(&ModelStatus::HashFallback).contains("hash control"));
}
#[test]
fn model_status_hash_fallback_display() {
let status = ModelStatus::HashFallback;
let display = format!("{status}");
assert_eq!(display, "hash control (not a semantic model)");
}
#[test]
fn model_availability_diagnostic_clone_debug() {
let diag = ModelAvailabilityDiagnostic {
availability: TwoTierAvailability::Full,
cache_dir: std::path::PathBuf::from("/tmp/cache"),
offline: false,
fast_status: ModelStatus::Ready {
id: "fast".to_owned(),
},
quality_status: ModelStatus::Ready {
id: "quality".to_owned(),
},
suggestions: vec![],
};
let cloned = diag.clone();
assert_eq!(cloned.availability, TwoTierAvailability::Full);
assert!(!cloned.offline);
assert!(cloned.suggestions.is_empty());
let debug = format!("{diag:?}");
assert!(debug.contains("ModelAvailabilityDiagnostic"));
assert!(debug.contains("Full"));
}
#[test]
fn diagnostic_display_no_suggestions_skips_resolve_section() {
let diag = ModelAvailabilityDiagnostic {
availability: TwoTierAvailability::Full,
cache_dir: std::path::PathBuf::from("/tmp/cache"),
offline: false,
fast_status: ModelStatus::Ready {
id: "fast".to_owned(),
},
quality_status: ModelStatus::Ready {
id: "quality".to_owned(),
},
suggestions: vec![],
};
let display = format!("{diag}");
assert!(!display.contains("To resolve:"));
}
#[cfg(feature = "hash")]
#[test]
fn embedder_stack_clone() {
let fast: Arc<dyn Embedder> = Arc::new(crate::hash_embedder::HashEmbedder::default_256());
let stack = EmbedderStack::from_parts(fast, None);
let cloned = stack.clone();
assert_eq!(cloned.availability(), stack.availability());
assert_eq!(cloned.fast().id(), stack.fast().id());
}
#[cfg(feature = "hash")]
#[test]
fn embedder_stack_fast_arc_returns_same_id() {
let fast: Arc<dyn Embedder> = Arc::new(crate::hash_embedder::HashEmbedder::default_256());
let stack = EmbedderStack::from_parts(fast, None);
let arc = stack.fast_arc();
assert_eq!(arc.id(), stack.fast().id());
}
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
#[test]
fn format_bytes_edge_cases() {
assert_eq!(format_bytes(0), "0 B");
assert_eq!(format_bytes(1), "1 B");
assert_eq!(format_bytes(1023), "1023 B");
assert_eq!(format_bytes(1024), "1.0 KB");
assert_eq!(format_bytes(1536), "1.5 KB");
assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
assert_eq!(
format_bytes(1024 * 1024 * 1024 + 512 * 1024 * 1024),
"1.5 GB"
);
}
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
#[test]
fn format_speed_edge_cases() {
assert_eq!(format_speed(0.0), "0 B/s");
assert_eq!(format_speed(-1.0), "0 B/s");
assert_eq!(format_speed(f64::NAN), "0 B/s");
assert_eq!(format_speed(f64::INFINITY), "0 B/s");
assert_eq!(format_speed(f64::NEG_INFINITY), "0 B/s");
assert_eq!(format_speed(500.0), "500 B/s");
assert!(format_speed(2048.0).contains("KB/s"));
assert!(format_speed(2.0 * 1024.0 * 1024.0).contains("MB/s"));
assert!(format_speed(2.0 * 1024.0 * 1024.0 * 1024.0).contains("GB/s"));
}
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
#[test]
fn format_eta_edge_cases() {
assert_eq!(format_eta(None), "?");
assert_eq!(format_eta(Some(f64::NAN)), "?");
assert_eq!(format_eta(Some(f64::INFINITY)), "?");
assert_eq!(format_eta(Some(-1.0)), "?");
assert_eq!(format_eta(Some(0.0)), "0.0s");
assert_eq!(format_eta(Some(5.5)), "5.5s");
}
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
#[test]
fn render_progress_bar_edge_cases() {
let empty_bar = render_progress_bar(0);
assert_eq!(empty_bar.len(), PROGRESS_BAR_WIDTH);
assert!(empty_bar.chars().all(|c| c == ' '));
let full_bar = render_progress_bar(10_000);
assert_eq!(full_bar.len(), PROGRESS_BAR_WIDTH);
assert!(full_bar.chars().all(|c| c == '='));
let half_bar = render_progress_bar(5_000);
assert_eq!(half_bar.len(), PROGRESS_BAR_WIDTH);
let filled = half_bar.chars().filter(|&c| c == '=').count();
assert_eq!(filled, PROGRESS_BAR_WIDTH / 2);
let bar_over = render_progress_bar(20_000);
assert_eq!(bar_over.len(), PROGRESS_BAR_WIDTH);
assert!(bar_over.chars().all(|c| c == '='));
}
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
#[test]
fn parse_bool_flag_whitespace_and_on() {
assert_eq!(parse_bool_flag(" 1 "), Some(true));
assert_eq!(parse_bool_flag(" true "), Some(true));
assert_eq!(parse_bool_flag("on"), Some(true));
assert_eq!(parse_bool_flag("ON"), Some(true));
assert_eq!(parse_bool_flag("On"), Some(true));
assert_eq!(parse_bool_flag("no"), Some(false));
assert_eq!(parse_bool_flag("NO"), Some(false));
assert_eq!(parse_bool_flag("No"), Some(false));
assert_eq!(parse_bool_flag(""), None);
assert_eq!(parse_bool_flag(" "), None);
assert_eq!(parse_bool_flag("maybe"), None);
}
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
#[test]
fn progress_percent_zero_files_completed() {
let progress = DownloadProgress {
file_name: "model.onnx".to_owned(),
bytes_downloaded: 0,
total_bytes: Some(100),
files_completed: 0,
files_total: 2,
speed_bytes_per_sec: 0.0,
eta_seconds: None,
};
assert_eq!(progress_percent_x100(&progress), 0);
}
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
#[test]
fn progress_percent_all_files_completed() {
let progress = DownloadProgress {
file_name: "last.onnx".to_owned(),
bytes_downloaded: 100,
total_bytes: Some(100),
files_completed: 3,
files_total: 4,
speed_bytes_per_sec: 1000.0,
eta_seconds: Some(0.0),
};
assert_eq!(progress_percent_x100(&progress), 10_000);
}
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
#[test]
fn progress_percent_no_total_bytes() {
let progress = DownloadProgress {
file_name: "model.onnx".to_owned(),
bytes_downloaded: 50,
total_bytes: None,
files_completed: 1,
files_total: 2,
speed_bytes_per_sec: 100.0,
eta_seconds: None,
};
assert_eq!(progress_percent_x100(&progress), 5_000);
}
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
#[test]
fn download_policy_can_download_logic() {
use crate::model_manifest::ConsentSource;
let allowed = DownloadPolicy::for_tests(
DownloadConsent::granted(ConsentSource::Programmatic),
false,
false,
);
assert!(allowed.can_download());
let offline = DownloadPolicy::for_tests(
DownloadConsent::granted(ConsentSource::Programmatic),
true,
false,
);
assert!(!offline.can_download());
let denied = DownloadPolicy::for_tests(
DownloadConsent::denied(Some(ConsentSource::Programmatic)),
false,
false,
);
assert!(!denied.can_download());
}
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
#[test]
fn download_policy_blocked_reason_offline() {
let policy = DownloadPolicy::for_tests(
DownloadConsent::granted(ConsentSource::Programmatic),
true,
false,
);
let reason = policy.blocked_reason();
assert!(reason.contains("OFFLINE"));
}
#[cfg(all(
feature = "download",
any(feature = "model2vec", feature = "fastembed")
))]
#[test]
fn download_policy_blocked_reason_consent_denied() {
let policy = DownloadPolicy::for_tests(
DownloadConsent::denied(Some(ConsentSource::Environment)),
false,
false,
);
let reason = policy.blocked_reason();
assert!(reason.contains("consent denied"));
}
#[cfg(all(feature = "model2vec", not(feature = "bundled-default-models")))]
fn create_test_safetensors(dir: &Path, vocab_size: usize, dimensions: usize) {
use std::collections::HashMap;
let mut data = Vec::with_capacity(vocab_size * dimensions * 4);
for row in 0..vocab_size {
for col in 0..dimensions {
#[allow(clippy::cast_precision_loss)]
let value = (row as f32).mul_add(0.01, (col as f32) * 0.001);
data.extend_from_slice(&value.to_le_bytes());
}
}
let mut tensors = HashMap::new();
tensors.insert(
"embeddings".to_owned(),
safetensors::tensor::TensorView::new(
safetensors::Dtype::F32,
vec![vocab_size, dimensions],
&data,
)
.unwrap(),
);
let encoded = safetensors::tensor::serialize(&tensors, None).unwrap();
fs::write(dir.join("model.safetensors"), encoded).unwrap();
}
}
#[cfg(test)]
mod remote_intent_tests {
use std::cell::Cell;
use frankensearch_core::SearchError;
use super::{
DetectOptions, RemoteIntentEnv, resolve_remote_intent, resolve_remote_intent_with,
};
fn expect_unverifiable(env: &RemoteIntentEnv, context: &str) {
match resolve_remote_intent(DetectOptions::default(), env) {
Err(SearchError::UnverifiableRemoteSpace { .. }) => {}
Err(other) => panic!("{context}: expected UnverifiableRemoteSpace, got {other:?}"),
Ok((_, Some(_))) => {
panic!("{context}: expected typed failure, got a verified embedder")
}
Ok((_, None)) => panic!("{context}: explicit intent silently degraded to None"),
}
}
#[test]
fn no_intent_and_no_keys_is_none() {
let (offline, outcome) =
resolve_remote_intent(DetectOptions::default(), &RemoteIntentEnv::default())
.expect("no intent is not an error");
assert!(!offline);
assert!(outcome.is_none());
}
#[test]
fn ambient_provider_key_without_intent_stays_none() {
let env = RemoteIntentEnv {
openai_key: Some("sk-ambient-unrelated-tool".to_owned()),
..RemoteIntentEnv::default()
};
let (offline, outcome) = resolve_remote_intent(DetectOptions::default(), &env)
.expect("ambient key is not intent");
assert!(!offline);
assert!(outcome.is_none());
}
#[test]
fn explicit_offline_rejects_remote_intent_before_construction() {
let env = RemoteIntentEnv {
offline: Some("false".to_owned()),
provider: Some("openai".to_owned()),
identity_json: Some("{malformed identity".to_owned()),
openai_key: Some("sk-test".to_owned()),
..RemoteIntentEnv::default()
};
let construction_calls = Cell::new(0);
let outcome = resolve_remote_intent_with(
DetectOptions {
offline: Some(true),
},
&env,
|_| {
construction_calls.set(construction_calls.get() + 1);
Ok(Some(()))
},
);
assert!(matches!(
outcome,
Err(SearchError::EmbedderUnavailable { .. })
));
assert_eq!(construction_calls.get(), 0);
}
#[test]
fn explicit_offline_ignores_ambient_credentials_without_construction() {
let env = RemoteIntentEnv {
openai_key: Some("sk-ambient-unrelated-tool".to_owned()),
gemini_key: Some("ambient-gemini-key".to_owned()),
..RemoteIntentEnv::default()
};
let construction_calls = Cell::new(0);
let (offline, outcome) = resolve_remote_intent_with(
DetectOptions {
offline: Some(true),
},
&env,
|_| {
construction_calls.set(construction_calls.get() + 1);
Ok(Some(()))
},
)
.expect("ambient credentials are not explicit remote intent");
assert!(offline);
assert!(outcome.is_none());
assert_eq!(construction_calls.get(), 0);
}
#[test]
fn explicit_online_overrides_offline_environment_and_constructs_once() {
let env = RemoteIntentEnv {
offline: Some("true".to_owned()),
provider: Some("openai".to_owned()),
..RemoteIntentEnv::default()
};
let construction_calls = Cell::new(0);
let (offline, outcome) = resolve_remote_intent_with(
DetectOptions {
offline: Some(false),
},
&env,
|_| {
construction_calls.set(construction_calls.get() + 1);
Ok(Some(41_u8))
},
)
.expect("explicit online policy must override the environment");
assert!(!offline);
assert_eq!(outcome, Some(41));
assert_eq!(construction_calls.get(), 1);
}
#[test]
fn explicit_offline_does_not_parse_malformed_environment() {
let env = RemoteIntentEnv {
offline: Some("definitely".to_owned()),
..RemoteIntentEnv::default()
};
let construction_calls = Cell::new(0);
let (offline, outcome) = resolve_remote_intent_with(
DetectOptions {
offline: Some(true),
},
&env,
|_| {
construction_calls.set(construction_calls.get() + 1);
Ok(Some(()))
},
)
.expect("explicit offline policy must dominate malformed ambient policy");
assert!(offline);
assert!(outcome.is_none());
assert_eq!(construction_calls.get(), 0);
}
#[test]
fn explicit_online_does_not_parse_malformed_environment() {
let env = RemoteIntentEnv {
offline: Some("definitely".to_owned()),
..RemoteIntentEnv::default()
};
let construction_calls = Cell::new(0);
let (offline, outcome) = resolve_remote_intent_with(
DetectOptions {
offline: Some(false),
},
&env,
|_| {
construction_calls.set(construction_calls.get() + 1);
Ok(Some(19_u8))
},
)
.expect("explicit online policy must dominate malformed ambient policy");
assert!(!offline);
assert_eq!(outcome, Some(19));
assert_eq!(construction_calls.get(), 1);
}
#[test]
fn deferred_malformed_offline_environment_fails_before_construction() {
let env = RemoteIntentEnv {
offline: Some("definitely".to_owned()),
..RemoteIntentEnv::default()
};
let construction_calls = Cell::new(0);
let outcome = resolve_remote_intent_with(DetectOptions::default(), &env, |_| {
construction_calls.set(construction_calls.get() + 1);
Ok(Some(()))
});
assert!(
matches!(&outcome, Err(SearchError::InvalidConfig { .. })),
"malformed ambient offline policy must fail as InvalidConfig"
);
let Err(SearchError::InvalidConfig {
field,
value,
reason,
}) = outcome
else {
return;
};
assert_eq!(field, super::OFFLINE_ENV);
assert_eq!(value, "definitely");
assert!(reason.contains("boolean flag"));
assert_eq!(construction_calls.get(), 0);
}
#[test]
fn deferred_policy_honors_offline_environment_without_construction() {
let env = RemoteIntentEnv {
offline: Some("yes".to_owned()),
provider: Some("gemini".to_owned()),
..RemoteIntentEnv::default()
};
let construction_calls = Cell::new(0);
let outcome = resolve_remote_intent_with(DetectOptions::default(), &env, |_| {
construction_calls.set(construction_calls.get() + 1);
Ok(Some(()))
});
assert!(matches!(
outcome,
Err(SearchError::EmbedderUnavailable { .. })
));
assert_eq!(construction_calls.get(), 0);
}
#[test]
fn deferred_policy_honors_online_environment_and_constructs_once() {
let env = RemoteIntentEnv {
offline: Some("off".to_owned()),
provider: Some("gemini".to_owned()),
..RemoteIntentEnv::default()
};
let construction_calls = Cell::new(0);
let (offline, outcome) = resolve_remote_intent_with(DetectOptions::default(), &env, |_| {
construction_calls.set(construction_calls.get() + 1);
Ok(Some(73_u8))
})
.expect("deferred policy must honor an online environment");
assert!(!offline);
assert_eq!(outcome, Some(73));
assert_eq!(construction_calls.get(), 1);
}
#[test]
fn explicit_provider_without_identity_fails_closed() {
let env = RemoteIntentEnv {
provider: Some("openai".to_owned()),
openai_key: Some("sk-test".to_owned()),
..RemoteIntentEnv::default()
};
expect_unverifiable(&env, "provider without identity");
}
#[test]
fn explicit_model_alone_is_intent_and_fails_closed_without_identity() {
let env = RemoteIntentEnv {
model: Some("text-embedding-3-small".to_owned()),
..RemoteIntentEnv::default()
};
expect_unverifiable(&env, "model without identity");
}
#[cfg(feature = "api")]
#[test]
fn malformed_identity_fails_closed() {
let env = RemoteIntentEnv {
identity_json: Some("{not json".to_owned()),
openai_key: Some("sk-test".to_owned()),
..RemoteIntentEnv::default()
};
expect_unverifiable(&env, "malformed identity");
}
#[cfg(feature = "api")]
#[test]
fn structurally_empty_identity_fails_closed() {
let env = RemoteIntentEnv {
identity_json: Some("{}".to_owned()),
..RemoteIntentEnv::default()
};
expect_unverifiable(&env, "empty identity object");
}
}