#[cfg(feature = "ann")]
use std::collections::HashSet;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::fs;
use std::io::{Read as _, Seek as _, SeekFrom};
use std::path::{Path, PathBuf};
#[cfg(feature = "ann")]
use std::sync::atomic::AtomicU64;
use std::sync::atomic::{AtomicU8, Ordering as AtomicOrdering};
use frankensearch_core::config::ZeroSignalReason;
use frankensearch_core::generation::EmbeddingIdentityBundleV1;
use frankensearch_core::{
BoundQueryEmbedding, RetrievalTopology, SearchCoverageV1, SearchError, SearchResult,
SpaceIdentityAdmission, TierQueryCoverageV1, TieredQueryEmbeddings, TwoTierConfig, VectorHit,
};
use tracing::{debug, info, warn};
#[cfg(all(feature = "ann", test))]
use crate::hnsw::HNSW_META_FORMAT_CURRENT;
#[cfg(feature = "ann")]
use crate::hnsw::HnswLoadDisposition;
use crate::{
ClassifiedHits, FsviAdmissionError, FsviUpgradeRequired, FsviV2IdentityBinding, Quantization,
SearchParams, ValidatedFsviBytes, VectorIndex, VectorMetadata, dot_product_f32_f32,
};
#[cfg(feature = "ann")]
use crate::{HNSW_DEFAULT_MAX_LAYER, HnswConfig, HnswIndex};
pub const VECTOR_INDEX_FAST_FILENAME: &str = "vector.fast.idx";
pub const VECTOR_INDEX_QUALITY_FILENAME: &str = "vector.quality.idx";
pub const VECTOR_INDEX_FALLBACK_FILENAME: &str = "vector.idx";
#[cfg(feature = "ann")]
pub const VECTOR_ANN_FAST_FILENAME: &str = "vector.fast.hnsw";
#[cfg(feature = "ann")]
pub const VECTOR_ANN_QUALITY_FILENAME: &str = "vector.quality.hnsw";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TwoTierIndexPaths {
fast_index: PathBuf,
quality_index: Option<PathBuf>,
#[cfg(feature = "ann")]
fast_ann: Option<PathBuf>,
#[cfg(feature = "ann")]
quality_ann: Option<PathBuf>,
}
impl TwoTierIndexPaths {
#[must_use]
pub fn new(fast_index: impl Into<PathBuf>) -> Self {
Self {
fast_index: fast_index.into(),
quality_index: None,
#[cfg(feature = "ann")]
fast_ann: None,
#[cfg(feature = "ann")]
quality_ann: None,
}
}
#[must_use]
pub fn with_quality_index(mut self, quality_index: impl Into<PathBuf>) -> Self {
self.quality_index = Some(quality_index.into());
self
}
#[cfg(feature = "ann")]
#[must_use]
pub fn with_fast_ann(mut self, fast_ann: impl Into<PathBuf>) -> Self {
self.fast_ann = Some(fast_ann.into());
self
}
#[cfg(feature = "ann")]
#[must_use]
pub fn with_quality_ann(mut self, quality_ann: impl Into<PathBuf>) -> Self {
self.quality_ann = Some(quality_ann.into());
self
}
#[must_use]
pub fn fast_index(&self) -> &Path {
&self.fast_index
}
#[must_use]
pub fn quality_index(&self) -> Option<&Path> {
self.quality_index.as_deref()
}
#[cfg(feature = "ann")]
#[must_use]
pub fn fast_ann(&self) -> Option<&Path> {
self.fast_ann.as_deref()
}
#[cfg(feature = "ann")]
#[must_use]
pub fn quality_ann(&self) -> Option<&Path> {
self.quality_ann.as_deref()
}
pub fn into_absolute(self) -> SearchResult<Self> {
let current_dir = std::env::current_dir()?;
self.into_absolute_from(¤t_dir)
}
pub fn into_absolute_from(mut self, base: &Path) -> SearchResult<Self> {
if !base.is_absolute() {
return Err(SearchError::InvalidConfig {
field: "index_paths_base".to_owned(),
value: base.display().to_string(),
reason: "the explicit-path resolution base must be absolute".to_owned(),
});
}
self.fast_index = make_path_absolute(base, self.fast_index);
self.quality_index = self
.quality_index
.map(|path| make_path_absolute(base, path));
#[cfg(feature = "ann")]
{
self.fast_ann = self.fast_ann.map(|path| make_path_absolute(base, path));
self.quality_ann = self.quality_ann.map(|path| make_path_absolute(base, path));
}
Ok(self)
}
}
fn make_path_absolute(current_dir: &Path, path: PathBuf) -> PathBuf {
if path.is_absolute() {
path
} else {
current_dir.join(path)
}
}
fn canonical_path_identity(path: &Path) -> SearchResult<PathBuf> {
let absolute = TwoTierIndexPaths::new(path).into_absolute()?.fast_index;
let mut cursor = absolute.as_path();
let mut missing_components = Vec::new();
loop {
match fs::canonicalize(cursor) {
Ok(mut canonical) => {
for component in missing_components.iter().rev() {
canonical.push(component);
}
return Ok(canonical);
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
let Some(file_name) = cursor.file_name() else {
return Err(SearchError::Io(error));
};
missing_components.push(file_name.to_os_string());
let Some(parent) = cursor.parent() else {
return Err(SearchError::Io(error));
};
cursor = parent;
}
Err(error) => return Err(SearchError::Io(error)),
}
}
}
fn paths_alias(left: &Path, right: &Path) -> SearchResult<bool> {
let left_identity = canonical_path_identity(left)?;
let right_identity = canonical_path_identity(right)?;
if left_identity == right_identity {
return Ok(true);
}
match crate::file_identity::is_same_file(left, right) {
Ok(is_same) => Ok(is_same),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(SearchError::Io(error)),
}
}
fn reject_final_symlink(role: &str, path: &Path) -> SearchResult<()> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() => Err(SearchError::InvalidConfig {
field: "index_paths".to_owned(),
value: format!("{role}={}", path.display()),
reason: "configured artifact roles must not be final-component symlinks".to_owned(),
}),
Ok(_) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(SearchError::Io(error)),
}
}
fn validate_index_paths(paths: &TwoTierIndexPaths) -> SearchResult<()> {
#[cfg(feature = "ann")]
if paths.quality_ann.is_some() && paths.quality_index.is_none() {
return Err(SearchError::InvalidConfig {
field: "quality_ann".to_owned(),
value: paths
.quality_ann
.as_deref()
.map_or_else(String::new, |path| path.display().to_string()),
reason: "a quality ANN sidecar requires a quality index path".to_owned(),
});
}
let mut roles = vec![("fast_index", paths.fast_index())];
if let Some(path) = paths.quality_index() {
roles.push(("quality_index", path));
}
#[cfg(feature = "ann")]
if let Some(path) = paths.fast_ann() {
roles.push(("fast_ann", path));
}
#[cfg(feature = "ann")]
if let Some(path) = paths.quality_ann() {
roles.push(("quality_ann", path));
}
for (role, path) in &roles {
reject_final_symlink(role, path)?;
}
for (index, (left_role, left_path)) in roles.iter().enumerate() {
for (right_role, right_path) in &roles[index + 1..] {
if paths_alias(left_path, right_path)? {
return Err(SearchError::InvalidConfig {
field: "index_paths".to_owned(),
value: format!(
"{left_role}={}, {right_role}={}",
left_path.display(),
right_path.display()
),
reason: "each index and ANN role must reference a distinct artifact".to_owned(),
});
}
}
}
Ok(())
}
#[cfg(feature = "ann")]
fn validate_ann_save_lock_identities(
paths: &TwoTierIndexPaths,
fast_ann_enabled: bool,
quality_ann_enabled: bool,
) -> SearchResult<()> {
let configured_roles = {
let mut roles = vec![("fast_index", paths.fast_index())];
if let Some(path) = paths.quality_index() {
roles.push(("quality_index", path));
}
if let Some(path) = paths.fast_ann() {
roles.push(("fast_ann", path));
}
if let Some(path) = paths.quality_ann() {
roles.push(("quality_ann", path));
}
roles
};
let mut lock_roles = Vec::with_capacity(2);
for (role, path, enabled) in [
("fast_ann_lock", paths.fast_ann(), fast_ann_enabled),
("quality_ann_lock", paths.quality_ann(), quality_ann_enabled),
] {
let Some(path) = path.filter(|_| enabled) else {
continue;
};
let lock_path = crate::hnsw::hnsw_save_lock_artifact_path(path).map_err(|error| {
SearchError::InvalidConfig {
field: role.to_owned(),
value: path.display().to_string(),
reason: error.to_string(),
}
})?;
for (artifact_role, artifact_path) in &configured_roles {
if paths_alias(&lock_path, artifact_path)? {
return Err(SearchError::InvalidConfig {
field: "index_paths".to_owned(),
value: format!(
"{role}={}, {artifact_role}={}",
lock_path.display(),
artifact_path.display()
),
reason: "an ANN save-lock artifact must not alias any configured index or ANN \
role"
.to_owned(),
});
}
}
lock_roles.push((role, lock_path));
}
for (index, (left_role, left_path)) in lock_roles.iter().enumerate() {
for (right_role, right_path) in &lock_roles[index + 1..] {
if paths_alias(left_path, right_path)? {
return Err(SearchError::InvalidConfig {
field: "index_paths".to_owned(),
value: format!(
"{left_role}={}, {right_role}={}",
left_path.display(),
right_path.display()
),
reason: "configured ANN roles resolve to the same filesystem artifact"
.to_owned(),
});
}
}
}
let mut materialized = Vec::with_capacity(lock_roles.len());
for (role, lock_path) in lock_roles {
let identity = crate::hnsw::materialize_hnsw_save_lock_artifact(&lock_path)?;
materialized.push((role, lock_path, identity));
}
for (index, (left_role, left_path, left_identity)) in materialized.iter().enumerate() {
for (right_role, right_path, right_identity) in &materialized[index + 1..] {
if left_identity == right_identity
|| crate::file_identity::is_same_file(left_path, right_path)
.map_err(SearchError::Io)?
{
return Err(SearchError::InvalidConfig {
field: "index_paths".to_owned(),
value: format!(
"{left_role}={}, {right_role}={}",
left_path.display(),
right_path.display()
),
reason: "configured ANN roles alias under the mounted filesystem's path \
comparison rules"
.to_owned(),
});
}
}
}
Ok(())
}
#[cfg(feature = "ann")]
fn validate_ann_persistence_paths(
paths: &TwoTierIndexPaths,
fast_ann_enabled: bool,
quality_ann_enabled: bool,
) -> SearchResult<()> {
validate_index_paths(paths)?;
validate_ann_save_lock_identities(paths, fast_ann_enabled, quality_ann_enabled)
}
#[derive(Debug)]
enum QualityAlignment {
None,
Aligned,
Mapping(Vec<Option<usize>>),
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug)]
enum TierSource {
PathOpened(VectorIndex),
AdmittedV2 {
owner: ValidatedFsviBytes,
binding: FsviV2IdentityBinding,
},
}
impl TierSource {
const fn index(&self) -> &VectorIndex {
match self {
Self::PathOpened(index) => index,
Self::AdmittedV2 { owner, .. } => &owner.index,
}
}
const fn admitted_owner(&self) -> Option<&ValidatedFsviBytes> {
match self {
Self::PathOpened(_) => None,
Self::AdmittedV2 { owner, .. } => Some(owner),
}
}
const fn admitted_binding(&self) -> Option<&FsviV2IdentityBinding> {
match self {
Self::PathOpened(_) => None,
Self::AdmittedV2 { binding, .. } => Some(binding),
}
}
const fn admitted_pair(&self) -> Option<(&ValidatedFsviBytes, &FsviV2IdentityBinding)> {
match self {
Self::PathOpened(_) => None,
Self::AdmittedV2 { owner, binding } => Some((owner, binding)),
}
}
}
#[derive(Debug)]
pub struct TwoTierIndex {
fast_source: TierSource,
quality_source: Option<TierSource>,
#[cfg(feature = "ann")]
fast_ann: Option<HnswIndex>,
#[cfg(feature = "ann")]
quality_ann: Option<HnswIndex>,
#[cfg(feature = "ann")]
ann_fallback_count: AtomicU64,
last_zero_signal: AtomicU8,
quality_alignment: QualityAlignment,
config: TwoTierConfig,
fast_space_fingerprint_hex: Option<String>,
quality_space_fingerprint_hex: Option<String>,
fast_declared_identity: Option<EmbeddingIdentityBundleV1>,
quality_declared_identity: Option<EmbeddingIdentityBundleV1>,
}
const ZERO_SIGNAL_NONE: u8 = u8::MAX;
const fn zero_signal_code(reason: ZeroSignalReason) -> u8 {
match reason {
ZeroSignalReason::CallerRequestedZeroK => 0,
ZeroSignalReason::FilterEliminatedAll => 1,
ZeroSignalReason::NonFiniteQuery => 2,
ZeroSignalReason::ZeroNormQuery => 3,
ZeroSignalReason::NewlyCreatedEmpty => 4,
ZeroSignalReason::AllTombstoned => 5,
ZeroSignalReason::WalOnlyNoLiveRecords => 6,
ZeroSignalReason::NoUsableVectors => 7,
ZeroSignalReason::AnnReturnedEmptyDespiteUsableVectors => 8,
}
}
impl TwoTierIndex {
pub fn open(dir: &Path, config: TwoTierConfig) -> SearchResult<Self> {
let fast_path = resolve_fast_path(dir)?;
let quality_path = dir.join(VECTOR_INDEX_QUALITY_FILENAME);
let mut paths = TwoTierIndexPaths::new(fast_path);
if quality_path.exists() {
paths = paths.with_quality_index(quality_path);
}
#[cfg(feature = "ann")]
{
paths = paths.with_fast_ann(dir.join(VECTOR_ANN_FAST_FILENAME));
if paths.quality_index().is_some() {
paths = paths.with_quality_ann(dir.join(VECTOR_ANN_QUALITY_FILENAME));
}
}
Self::open_with_paths_inner(&paths, config, true)
}
pub fn open_with_paths(paths: &TwoTierIndexPaths, config: TwoTierConfig) -> SearchResult<Self> {
Self::open_with_paths_inner(paths, config, false)
}
#[allow(clippy::too_many_lines)]
fn open_with_paths_inner(
paths: &TwoTierIndexPaths,
config: TwoTierConfig,
degrade_discovered_quality_errors: bool,
) -> SearchResult<Self> {
let paths = paths.clone().into_absolute()?;
validate_index_paths(&paths)?;
let fast_index = VectorIndex::open_read_only(paths.fast_index())?;
warn_if_wal_rows_replayed("fast", paths.fast_index(), &fast_index);
let fast_source = TierSource::PathOpened(fast_index);
let quality_source = match paths.quality_index() {
Some(quality_path) => match VectorIndex::open_read_only(quality_path) {
Ok(quality_index) => {
warn_if_wal_rows_replayed("quality", quality_path, &quality_index);
Some(TierSource::PathOpened(quality_index))
}
Err(error) if degrade_discovered_quality_errors => {
warn!(
path = %quality_path.display(),
?error,
"discovered optional quality index is unavailable; degrading to fast-only"
);
None
}
Err(error) => return Err(error),
},
None => None,
};
Self::assemble_opened(fast_source, quality_source, &paths, config)
}
pub fn open_admitted_v2_with_paths(
paths: &TwoTierIndexPaths,
config: TwoTierConfig,
fast_binding: &FsviV2IdentityBinding,
quality_binding: Option<&FsviV2IdentityBinding>,
) -> SearchResult<Self> {
let paths = paths.clone().into_absolute()?;
validate_index_paths(&paths)?;
let fast_source = TierSource::AdmittedV2 {
owner: admit_v2_tier(paths.fast_index(), fast_binding, "fast")?,
binding: fast_binding.clone(),
};
let quality_source = match (paths.quality_index(), quality_binding) {
(Some(path), Some(binding)) => Some(TierSource::AdmittedV2 {
owner: admit_v2_tier(path, binding, "quality")?,
binding: binding.clone(),
}),
(None, None) => None,
(Some(path), None) => {
return Err(SearchError::InvalidConfig {
field: "two_tier.quality_v2_admission".to_owned(),
value: path.display().to_string(),
reason: "a quality index path was supplied without its identity binding; \
v2 tiers are only opened through exact admission"
.to_owned(),
});
}
(None, Some(_)) => {
return Err(SearchError::InvalidConfig {
field: "two_tier.quality_v2_admission".to_owned(),
value: "<no-quality-path>".to_owned(),
reason: "a quality identity binding was supplied without a quality index path"
.to_owned(),
});
}
};
Self::assemble_opened(fast_source, quality_source, &paths, config)
}
#[allow(clippy::too_many_lines)]
fn assemble_opened(
fast_source: TierSource,
quality_source: Option<TierSource>,
paths: &TwoTierIndexPaths,
config: TwoTierConfig,
) -> SearchResult<Self> {
let fast_index = fast_source.index();
let mut quality_alignment = QualityAlignment::None;
let quality_source = quality_source.filter(|source| {
let quality_nonce = source.index().publication_nonce();
if quality_nonce == fast_index.publication_nonce() {
true
} else {
warn!(
fast_nonce = fast_index.publication_nonce(),
quality_nonce,
"fast/quality publication identities disagree (mixed-generation pair, \
likely a crash between tier installs); degrading to fast-only"
);
false
}
});
let quality_index = if let Some(quality) = quality_source.as_ref().map(TierSource::index) {
if quality.record_count() != fast_index.record_count() {
warn!(
fast_records = fast_index.record_count(),
quality_records = quality.record_count(),
"fast and quality index record counts differ; using doc-id alignment"
);
}
quality_alignment = QualityAlignment::Aligned;
let mut f_idx = 0;
let mut q_idx = 0;
let f_count = fast_index.record_count();
let q_count = quality.record_count();
let mut unmatched_quality_docs = 0;
let ensure_mapping = |quality_alignment: &mut QualityAlignment,
current_f_idx: usize| {
if matches!(quality_alignment, QualityAlignment::Aligned) {
let map = (0..current_f_idx).map(Some).collect();
*quality_alignment = QualityAlignment::Mapping(map);
}
};
while f_idx < f_count && q_idx < q_count {
let f_rec = fast_index.record_at(f_idx)?;
let q_rec = quality.record_at(q_idx)?;
if crate::is_tombstoned_flags(f_rec.flags) {
ensure_mapping(&mut quality_alignment, f_idx);
if let QualityAlignment::Mapping(vec) = &mut quality_alignment {
vec.push(None);
}
f_idx += 1;
continue;
}
if crate::is_tombstoned_flags(q_rec.flags) {
q_idx += 1;
continue;
}
if matches!(quality_alignment, QualityAlignment::Aligned) && f_idx != q_idx {
ensure_mapping(&mut quality_alignment, f_idx);
}
match f_rec.doc_id_hash.cmp(&q_rec.doc_id_hash) {
std::cmp::Ordering::Less => {
ensure_mapping(&mut quality_alignment, f_idx);
if let QualityAlignment::Mapping(vec) = &mut quality_alignment {
vec.push(None);
}
f_idx += 1;
}
std::cmp::Ordering::Greater => {
unmatched_quality_docs += 1;
q_idx += 1;
}
std::cmp::Ordering::Equal => {
let f_id = fast_index.doc_id_at(f_idx)?;
let q_id = quality.doc_id_at(q_idx)?;
match f_id.cmp(q_id) {
std::cmp::Ordering::Equal => {
if let QualityAlignment::Mapping(vec) = &mut quality_alignment {
vec.push(Some(q_idx));
}
f_idx += 1;
q_idx += 1;
}
std::cmp::Ordering::Less => {
ensure_mapping(&mut quality_alignment, f_idx);
if let QualityAlignment::Mapping(vec) = &mut quality_alignment {
vec.push(None);
}
f_idx += 1;
}
std::cmp::Ordering::Greater => {
unmatched_quality_docs += 1;
q_idx += 1;
}
}
}
}
}
if f_idx < f_count {
ensure_mapping(&mut quality_alignment, f_idx);
if let QualityAlignment::Mapping(vec) = &mut quality_alignment {
while vec.len() < f_count {
vec.push(None);
}
}
}
while q_idx < q_count {
let q_rec = quality.record_at(q_idx)?;
if !crate::is_tombstoned_flags(q_rec.flags) {
unmatched_quality_docs += 1;
}
q_idx += 1;
}
if unmatched_quality_docs > 0 {
warn!(
unmatched_quality_docs,
"quality index contains doc_ids that are not present in fast index"
);
}
Some(quality)
} else {
None
};
#[cfg(feature = "ann")]
let fast_ann_plan = paths.fast_ann.as_deref().and_then(|fast_ann_path| {
plan_load_or_build_ann(
fast_index,
fast_ann_path,
config.hnsw_threshold,
&config,
"fast",
)
});
#[cfg(feature = "ann")]
let quality_ann_plan = quality_index.and_then(|quality_index| {
paths.quality_ann.as_deref().and_then(|quality_ann_path| {
plan_load_or_build_ann(
quality_index,
quality_ann_path,
config.hnsw_threshold,
&config,
"quality",
)
})
});
#[cfg(feature = "ann")]
{
let fast_needs_persistence = fast_ann_plan
.as_ref()
.is_some_and(AnnOpenPlan::needs_persistence);
let quality_needs_persistence = quality_ann_plan
.as_ref()
.is_some_and(AnnOpenPlan::needs_persistence);
let persistence_prepared = if fast_needs_persistence || quality_needs_persistence {
match validate_ann_persistence_paths(
paths,
fast_needs_persistence,
quality_needs_persistence,
) {
Ok(()) => true,
Err(error @ SearchError::InvalidConfig { .. }) => return Err(error),
Err(error) => {
warn!(
?error,
fast_needs_persistence,
quality_needs_persistence,
"failed to prepare ANN persistence identities; all rebuilt ANN tiers \
stay in-memory for this process and the next startup may rebuild them"
);
false
}
}
} else {
true
};
if persistence_prepared {
if let (Some(plan), Some(path)) = (fast_ann_plan.as_ref(), paths.fast_ann()) {
persist_ann_plan(
plan,
path,
"fast",
paths,
fast_needs_persistence,
quality_needs_persistence,
);
}
if let (Some(plan), Some(path)) = (quality_ann_plan.as_ref(), paths.quality_ann()) {
persist_ann_plan(
plan,
path,
"quality",
paths,
fast_needs_persistence,
quality_needs_persistence,
);
}
}
}
#[cfg(feature = "ann")]
let fast_ann = fast_ann_plan.map(|plan| plan.index);
#[cfg(feature = "ann")]
let quality_ann = quality_ann_plan.map(|plan| plan.index);
#[cfg(feature = "ann")]
debug!(
fast_path = %paths.fast_index().display(),
quality_path = ?paths.quality_index(),
quality_available = quality_index.is_some(),
fast_ann = fast_ann.is_some(),
quality_ann = quality_ann.is_some(),
doc_count = fast_index.record_count(),
"opened two-tier index"
);
#[cfg(not(feature = "ann"))]
debug!(
fast_path = %paths.fast_index().display(),
quality_path = ?paths.quality_index(),
quality_available = quality_index.is_some(),
doc_count = fast_index.record_count(),
"opened two-tier index"
);
let fast_space_fingerprint_hex = fast_index
.identity_v2()
.map(|identity| crate::fingerprint_hex(&identity.space_fingerprint));
let quality_space_fingerprint_hex = quality_index
.and_then(VectorIndex::identity_v2)
.map(|identity| crate::fingerprint_hex(&identity.space_fingerprint));
Ok(Self {
fast_source,
quality_source,
#[cfg(feature = "ann")]
fast_ann,
#[cfg(feature = "ann")]
quality_ann,
#[cfg(feature = "ann")]
ann_fallback_count: AtomicU64::new(0),
last_zero_signal: AtomicU8::new(ZERO_SIGNAL_NONE),
quality_alignment,
config,
fast_space_fingerprint_hex,
quality_space_fingerprint_hex,
fast_declared_identity: None,
quality_declared_identity: None,
})
}
pub fn create(dir: &Path, config: TwoTierConfig) -> SearchResult<TwoTierIndexBuilder> {
fs::create_dir_all(dir)?;
Ok(TwoTierIndexBuilder::new(dir.to_path_buf(), config))
}
const fn fast_tier(&self) -> &VectorIndex {
self.fast_source.index()
}
fn quality_tier(&self) -> Option<&VectorIndex> {
self.quality_source.as_ref().map(TierSource::index)
}
#[cfg(test)]
fn fast_tier_mut_for_test(&mut self) -> Option<&mut VectorIndex> {
match &mut self.fast_source {
TierSource::PathOpened(index) => Some(index),
TierSource::AdmittedV2 { .. } => None,
}
}
#[must_use]
pub const fn fast_admitted_owner(&self) -> Option<&ValidatedFsviBytes> {
self.fast_source.admitted_owner()
}
#[must_use]
pub const fn fast_admitted_binding(&self) -> Option<&FsviV2IdentityBinding> {
self.fast_source.admitted_binding()
}
#[must_use]
pub fn quality_admitted_binding(&self) -> Option<&FsviV2IdentityBinding> {
self.quality_source
.as_ref()
.and_then(TierSource::admitted_binding)
}
pub fn try_replace_admitted_v2(
&mut self,
paths: &TwoTierIndexPaths,
fast_binding: &FsviV2IdentityBinding,
quality_binding: Option<&FsviV2IdentityBinding>,
) -> SearchResult<()> {
let candidate = match Self::open_admitted_v2_with_paths(
paths,
self.config.clone(),
fast_binding,
quality_binding,
) {
Ok(candidate) => candidate,
Err(error) => {
warn!(
incumbent_generation = self.admitted_generation_sequence(),
incumbent_space = self.bounded_fast_space_digest(),
reason = %error,
"candidate generation was refused; the retained owner keeps serving"
);
return Err(error);
}
};
info!(
previous_generation = self.admitted_generation_sequence(),
installed_generation = candidate.admitted_generation_sequence(),
installed_space = candidate.bounded_fast_space_digest(),
installed_records = candidate.doc_count(),
"installed an admitted successor generation"
);
*self = candidate;
Ok(())
}
fn admitted_generation_sequence(&self) -> Option<u64> {
self.fast_admitted_owner()
.map(|owner| owner.witness().generation.sequence)
}
fn bounded_fast_space_digest(&self) -> Option<String> {
self.fast_space_fingerprint_hex
.as_ref()
.map(|hex| hex.chars().take(16).collect())
}
pub fn activate_owner_backed_search<'index, 'query>(
&'index self,
embeddings: &'query TieredQueryEmbeddings,
) -> SearchResult<ActivatedTierSearch<'index, 'query>> {
let topology = embeddings.supported_topology();
let fast = match embeddings.fast() {
Some(query) => Some(activate_tier(
self.fast_source.admitted_pair(),
query,
"fast",
)?),
None => None,
};
let quality = match embeddings.quality() {
Some(query) => Some(activate_tier(
self.quality_source
.as_ref()
.and_then(TierSource::admitted_pair),
query,
"quality",
)?),
None => None,
};
if fast.is_none() && quality.is_none() {
return Err(SearchError::InvalidConfig {
field: "search_activation.topology".to_owned(),
value: format!("{topology:?}"),
reason: "no tier was requested; vector activation requires at least one \
owner-backed tier"
.to_owned(),
});
}
Ok(ActivatedTierSearch {
fast,
quality,
topology,
})
}
#[must_use]
pub fn quality_admitted_owner(&self) -> Option<&ValidatedFsviBytes> {
self.quality_source
.as_ref()
.and_then(TierSource::admitted_owner)
}
pub fn search_fast(&self, query_vec: &[f32], k: usize) -> SearchResult<Vec<VectorHit>> {
self.search_fast_with_params(query_vec, k, None)
}
pub fn search_fast_with_params(
&self,
query_vec: &[f32],
k: usize,
params: Option<SearchParams>,
) -> SearchResult<Vec<VectorHit>> {
#[cfg(feature = "ann")]
if let Some(ann) = &self.fast_ann {
let (hits, stats) = ann.knn_search_raw_with_stats_against(
self.fast_tier(),
query_vec,
k,
self.config.hnsw_ef_search,
)?;
if let Some(reason) = stats.fallback_reason {
let fallback_count = self
.ann_fallback_count
.fetch_add(1, AtomicOrdering::Relaxed)
.saturating_add(1);
warn!(
?reason,
fallback_count,
index_size = stats.index_size,
k_requested = stats.k_requested,
ef_search = stats.ef_search,
search_time_us = stats.search_time_us,
"fast-tier ANN degraded to exact search"
);
}
return self.resolve_fast_ann_and_wal(hits, query_vec, k);
}
let mrl_config = crate::mrl::MrlConfig {
search_dims: self.config.mrl_search_dims,
rescore_dims: 0,
rescore_top_k: self.config.mrl_rescore_top_k,
};
if mrl_config.search_dims > 0 && mrl_config.search_dims < self.fast_tier().dimension() {
return self.fast_tier().mrl_search(query_vec, k, &mrl_config, None);
}
const FAST_TIER_MULT: usize = 3;
params.map_or_else(
|| {
self.fast_tier()
.search_top_k_int8_two_pass(query_vec, k, FAST_TIER_MULT)
},
|params| {
self.fast_tier()
.search_top_k_with_params(query_vec, k, None, params)
},
)
}
pub fn search_fast_classified(
&self,
query_vec: &[f32],
k: usize,
) -> SearchResult<ClassifiedHits> {
if query_vec.len() != self.fast_tier().dimension() {
return Err(SearchError::DimensionMismatch {
expected: self.fast_tier().dimension(),
found: query_vec.len(),
});
}
if k == 0 {
let classified = ClassifiedHits::empty(ZeroSignalReason::CallerRequestedZeroK);
self.note_zero_signal(classified.zero_signal);
return Ok(classified);
}
if query_vec.iter().any(|value| !value.is_finite()) {
return Err(SearchError::InvalidConfig {
field: "query".to_owned(),
value: "<contains non-finite values>".to_owned(),
reason: "query vector must be finite".to_owned(),
});
}
if query_vec.iter().all(|&value| value == 0.0) {
let classified = ClassifiedHits::empty(ZeroSignalReason::ZeroNormQuery);
self.note_zero_signal(classified.zero_signal);
return Ok(classified);
}
let hits = self.search_fast(query_vec, k)?;
let zero_signal = hits.is_empty().then(|| self.classify_fast_empty());
self.note_zero_signal(zero_signal);
Ok(ClassifiedHits { hits, zero_signal })
}
fn classify_fast_empty(&self) -> ZeroSignalReason {
let state = self.fast_tier().zero_signal_state();
if let Some(reason) = state.state_reason() {
return reason;
}
if state.is_wal_only() {
return ZeroSignalReason::WalOnlyNoLiveRecords;
}
#[cfg(feature = "ann")]
if self.fast_ann.is_some() {
return ZeroSignalReason::AnnReturnedEmptyDespiteUsableVectors;
}
ZeroSignalReason::NoUsableVectors
}
fn note_zero_signal(&self, reason: Option<ZeroSignalReason>) {
match reason {
Some(request_scoped) if request_scoped.is_request_scoped() => {
debug!(
reason_code = request_scoped.reason_code(),
"fast-tier search returned empty: request-scoped zero-signal"
);
}
Some(state_scoped) => {
let code = zero_signal_code(state_scoped);
let previous = self.last_zero_signal.swap(code, AtomicOrdering::Relaxed);
if previous == code {
return;
}
if state_scoped.is_availability_failure() {
warn!(
reason_code = state_scoped.reason_code(),
reason = %state_scoped,
"fast-tier semantic lane is unusable"
);
} else {
info!(
reason_code = state_scoped.reason_code(),
reason = %state_scoped,
"fast-tier semantic lane has no signal"
);
}
}
None => {
let previous = self
.last_zero_signal
.swap(ZERO_SIGNAL_NONE, AtomicOrdering::Relaxed);
if previous != ZERO_SIGNAL_NONE {
info!("fast-tier semantic lane recovered: search produced hits");
}
}
}
}
#[cfg(feature = "ann")]
fn resolve_fast_ann_and_wal(
&self,
ann_hits: Vec<VectorHit>,
query_vec: &[f32],
k: usize,
) -> SearchResult<Vec<VectorHit>> {
let base_index = self.fast_tier().record_count();
let mut hits = Vec::with_capacity(
ann_hits
.len()
.saturating_add(self.fast_tier().wal_entries.len()),
);
for hit in ann_hits {
if let Ok(position) = usize::try_from(hit.index)
&& position < base_index
&& !self.fast_tier().is_deleted(position)
{
hits.push(hit);
}
}
for (wal_index, entry) in self.fast_tier().wal_entries.iter().enumerate() {
let score = dot_product_f32_f32(&entry.embedding, query_vec)?;
if !score.is_finite() {
continue;
}
let virtual_index =
base_index
.checked_add(wal_index)
.ok_or_else(|| SearchError::InvalidConfig {
field: "index".to_owned(),
value: wal_index.to_string(),
reason: "WAL virtual index overflow".to_owned(),
})?;
let index = u32::try_from(virtual_index).map_err(|_| SearchError::InvalidConfig {
field: "index".to_owned(),
value: virtual_index.to_string(),
reason: "WAL entry index exceeds u32 range".to_owned(),
})?;
hits.push(VectorHit {
index,
score,
doc_id: entry.doc_id.as_str().into(),
});
}
let by_score_index = |left: &VectorHit, right: &VectorHit| {
left.cmp_by_score(right)
.then_with(|| left.index.cmp(&right.index))
};
const SELECT_NTH_MIN: usize = 256;
if k < hits.len() && hits.len() >= SELECT_NTH_MIN {
hits.select_nth_unstable_by(k, by_score_index);
hits.truncate(k);
hits.sort_unstable_by(by_score_index);
} else {
hits.sort_by(by_score_index);
hits.truncate(k);
}
let wal_doc_ids: HashSet<&str> = self
.fast_tier()
.wal_entries
.iter()
.map(|entry| entry.doc_id.as_str())
.collect();
let mut seen_doc_ids = HashSet::with_capacity(hits.len());
hits.retain(|hit| {
let is_main = usize::try_from(hit.index).is_ok_and(|index| index < base_index);
if is_main && wal_doc_ids.contains(hit.doc_id.as_str()) {
return false;
}
seen_doc_ids.insert(hit.doc_id.clone())
});
Ok(hits)
}
pub fn quality_scores_for_hits(
&self,
query_vec: &[f32],
hits: &[VectorHit],
) -> SearchResult<Vec<Option<f32>>> {
let Some(quality_index) = self.quality_tier() else {
return Ok(vec![None; hits.len()]);
};
if query_vec.len() != quality_index.dimension() {
return Err(SearchError::DimensionMismatch {
expected: quality_index.dimension(),
found: query_vec.len(),
});
}
let wal_latest: HashMap<&str, usize> = {
let mut m = HashMap::with_capacity(quality_index.wal_entries.len());
for (i, entry) in quality_index.wal_entries.iter().enumerate() {
m.insert(entry.doc_id.as_str(), i);
}
m
};
let mut scores = Vec::with_capacity(hits.len());
for hit in hits {
let mut found_score = if let Some(&i) = wal_latest.get(hit.doc_id.as_str()) {
Some(dot_product_f32_f32(
&quality_index.wal_entries[i].embedding,
query_vec,
)?)
} else {
None
};
if found_score.is_none() {
let fast_idx = if hit.index == u32::MAX {
self.fast_tier().find_index_by_doc_id(&hit.doc_id)?
} else if (hit.index as usize) < self.fast_tier().record_count() {
Some(hit.index as usize)
} else {
None
};
if let Some(idx) = fast_idx {
found_score =
self.score_quality_for_fast_index(quality_index, query_vec, idx)?;
}
}
if found_score.is_none() {
if let Some(qual_idx) = quality_index.find_index_by_doc_id(&hit.doc_id)? {
found_score = Some(quality_index.dot_query_at(qual_idx, query_vec)?);
}
}
scores.push(found_score);
}
Ok(scores)
}
#[must_use]
pub const fn has_quality_index(&self) -> bool {
self.quality_source.is_some()
}
#[cfg(feature = "ann")]
#[must_use]
pub const fn has_fast_ann(&self) -> bool {
self.fast_ann.is_some()
}
#[cfg(feature = "ann")]
#[must_use]
pub const fn has_quality_ann(&self) -> bool {
self.quality_ann.is_some()
}
#[cfg(feature = "ann")]
#[must_use]
pub fn ann_fallback_count(&self) -> u64 {
self.ann_fallback_count.load(AtomicOrdering::Relaxed)
}
#[must_use]
pub const fn doc_count(&self) -> usize {
self.fast_tier().record_count()
}
#[must_use]
pub fn fast_embedder_id(&self) -> &str {
self.fast_tier().embedder_id()
}
#[must_use]
pub fn fast_embedder_revision(&self) -> &str {
self.fast_tier().embedder_revision()
}
#[must_use]
pub fn quality_embedder_id(&self) -> Option<&str> {
self.quality_tier().map(VectorIndex::embedder_id)
}
#[must_use]
pub fn quality_embedder_revision(&self) -> Option<&str> {
self.quality_tier().map(VectorIndex::embedder_revision)
}
#[must_use]
pub fn fast_space_fingerprint_hex(&self) -> Option<&str> {
self.fast_space_fingerprint_hex.as_deref()
}
#[must_use]
pub fn quality_space_fingerprint_hex(&self) -> Option<&str> {
self.quality_space_fingerprint_hex.as_deref()
}
#[must_use]
pub const fn fast_declared_identity(&self) -> Option<&EmbeddingIdentityBundleV1> {
self.fast_declared_identity.as_ref()
}
#[must_use]
pub const fn quality_declared_identity(&self) -> Option<&EmbeddingIdentityBundleV1> {
self.quality_declared_identity.as_ref()
}
#[must_use]
pub fn fast_identity_is_attested(&self) -> bool {
self.fast_tier().identity_v2().is_some()
}
#[must_use]
pub fn quality_identity_is_attested(&self) -> bool {
self.quality_tier()
.is_some_and(|index| index.identity_v2().is_some())
}
#[must_use]
pub fn space_fingerprint_hex_for_tier(&self, tier: SemanticVectorTier) -> Option<&str> {
match tier {
SemanticVectorTier::Fast => self.fast_space_fingerprint_hex(),
SemanticVectorTier::Quality => self.quality_space_fingerprint_hex(),
}
}
#[must_use]
pub fn fast_index_path(&self) -> &Path {
&self.fast_tier().path
}
#[must_use]
pub fn quality_index_path(&self) -> Option<&Path> {
self.quality_tier().map(|index| index.path.as_path())
}
pub fn iter_doc_ids(&self) -> impl Iterator<Item = SearchResult<String>> + '_ {
(0..self.doc_count()).map(|i| self.fast_tier().doc_id_at(i).map(ToOwned::to_owned))
}
pub fn doc_id_at(&self, index: usize) -> SearchResult<&str> {
self.fast_tier().doc_id_at(index)
}
pub fn fast_index_for_doc_id(&self, doc_id: &str) -> SearchResult<Option<usize>> {
self.fast_tier().find_index_by_doc_id(doc_id)
}
pub fn fast_vector_for_doc_id(&self, doc_id: &str) -> SearchResult<Option<Vec<f32>>> {
let hash = crate::fnv1a_hash(doc_id.as_bytes());
for entry in self.fast_tier().wal_entries.iter().rev() {
if entry.doc_id_hash == hash && entry.doc_id == doc_id {
return Ok(Some(entry.embedding.clone()));
}
}
if let Some(index) = self.fast_tier().find_index_by_doc_id(doc_id)? {
return self.fast_tier().vector_at_f32(index).map(Some);
}
Ok(None)
}
pub fn quality_vector_for_doc_id(&self, doc_id: &str) -> SearchResult<Option<Vec<f32>>> {
let Some(quality_index) = self.quality_tier() else {
return Ok(None);
};
let hash = crate::fnv1a_hash(doc_id.as_bytes());
for entry in quality_index.wal_entries.iter().rev() {
if entry.doc_id_hash == hash && entry.doc_id == doc_id {
return Ok(Some(entry.embedding.clone()));
}
}
if let Some(fast_index) = self.fast_tier().find_index_by_doc_id(doc_id)? {
if let Some(quality_index_pos) = self.quality_index_for_fast_index(fast_index) {
return quality_index.vector_at_f32(quality_index_pos).map(Some);
}
}
if let Some(qual_idx) = quality_index.find_index_by_doc_id(doc_id)? {
return quality_index.vector_at_f32(qual_idx).map(Some);
}
Ok(None)
}
pub fn semantic_vector_for_doc_id(&self, doc_id: &str) -> SearchResult<Option<Vec<f32>>> {
Ok(self
.semantic_vector_with_tier_for_doc_id(doc_id)?
.map(|(_, vector)| vector))
}
pub fn semantic_vector_with_tier_for_doc_id(
&self,
doc_id: &str,
) -> SearchResult<Option<(SemanticVectorTier, Vec<f32>)>> {
if let Some(quality) = self.quality_vector_for_doc_id(doc_id)? {
return Ok(Some((SemanticVectorTier::Quality, quality)));
}
Ok(self
.fast_vector_for_doc_id(doc_id)?
.map(|vector| (SemanticVectorTier::Fast, vector)))
}
#[must_use]
pub fn has_quality_for_index(&self, index: usize) -> bool {
if index >= self.doc_count() {
return false;
}
match &self.quality_alignment {
QualityAlignment::None => false,
QualityAlignment::Aligned => true,
QualityAlignment::Mapping(map) => map.get(index).copied().flatten().is_some(),
}
}
#[must_use]
pub const fn config(&self) -> &TwoTierConfig {
&self.config
}
fn score_quality_for_fast_index(
&self,
quality_index: &VectorIndex,
query_vec: &[f32],
fast_idx: usize,
) -> SearchResult<Option<f32>> {
if fast_idx >= self.doc_count() {
return Ok(None);
}
let quality_idx = match &self.quality_alignment {
QualityAlignment::None => return Ok(None),
QualityAlignment::Aligned => fast_idx,
QualityAlignment::Mapping(map) => match map.get(fast_idx).copied().flatten() {
Some(idx) => idx,
None => return Ok(None),
},
};
quality_index.dot_query_at(quality_idx, query_vec).map(Some)
}
fn quality_index_for_fast_index(&self, fast_idx: usize) -> Option<usize> {
match &self.quality_alignment {
QualityAlignment::None => None,
QualityAlignment::Aligned => Some(fast_idx),
QualityAlignment::Mapping(map) => map.get(fast_idx).copied().flatten(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SemanticVectorTier {
Quality,
Fast,
}
#[derive(Debug)]
pub struct TwoTierIndexBuilder {
dir: PathBuf,
config: TwoTierConfig,
fast_embedder_id: String,
quality_embedder_id: String,
fast_embedder_revision: String,
quality_embedder_revision: String,
fast_identity: Option<EmbeddingIdentityBundleV1>,
quality_identity: Option<EmbeddingIdentityBundleV1>,
fast_dimension: Option<usize>,
quality_dimension: Option<usize>,
fast_records: Vec<(String, Vec<f32>)>,
quality_records: Vec<(String, Vec<f32>)>,
fast_ids: std::collections::HashSet<String>,
quality_ids: std::collections::HashSet<String>,
}
impl TwoTierIndexBuilder {
fn new(dir: PathBuf, config: TwoTierConfig) -> Self {
Self {
dir,
config,
fast_embedder_id: "fast-tier".to_owned(),
quality_embedder_id: "quality-tier".to_owned(),
fast_embedder_revision: String::new(),
quality_embedder_revision: String::new(),
fast_identity: None,
quality_identity: None,
fast_dimension: None,
quality_dimension: None,
fast_records: Vec::new(),
quality_records: Vec::new(),
fast_ids: std::collections::HashSet::new(),
quality_ids: std::collections::HashSet::new(),
}
}
pub fn set_fast_embedder_id(&mut self, embedder_id: impl Into<String>) -> &mut Self {
self.fast_embedder_id = embedder_id.into();
self
}
pub fn set_quality_embedder_id(&mut self, embedder_id: impl Into<String>) -> &mut Self {
self.quality_embedder_id = embedder_id.into();
self
}
pub fn set_fast_identity(
&mut self,
identity: &EmbeddingIdentityBundleV1,
) -> SearchResult<&mut Self> {
identity.validate()?;
self.fast_embedder_revision
.clone_from(&identity.space.immutable_revision);
self.fast_identity = Some(identity.clone());
Ok(self)
}
pub fn set_quality_identity(
&mut self,
identity: &EmbeddingIdentityBundleV1,
) -> SearchResult<&mut Self> {
identity.validate()?;
self.quality_embedder_revision
.clone_from(&identity.space.immutable_revision);
self.quality_identity = Some(identity.clone());
Ok(self)
}
pub fn add_fast_record(
&mut self,
doc_id: impl Into<String>,
embedding: &[f32],
) -> SearchResult<()> {
let dimension = embedding.len();
let expected = self.fast_dimension.get_or_insert(dimension);
if *expected != dimension {
return Err(SearchError::DimensionMismatch {
expected: *expected,
found: dimension,
});
}
let doc_id = doc_id.into();
if !self.fast_ids.insert(doc_id.clone()) {
return Err(SearchError::InvalidConfig {
field: "doc_id".to_owned(),
value: doc_id,
reason: "duplicate doc_id in fast tier; each document must have a unique id"
.to_owned(),
});
}
self.fast_records.push((doc_id, embedding.to_vec()));
Ok(())
}
#[cfg(feature = "bench-internals")]
#[doc(hidden)]
pub fn add_fast_record_owned_for_benchmark(
&mut self,
doc_id: impl Into<String>,
embedding: Vec<f32>,
) -> SearchResult<()> {
let dimension = embedding.len();
let expected = self.fast_dimension.get_or_insert(dimension);
if *expected != dimension {
return Err(SearchError::DimensionMismatch {
expected: *expected,
found: dimension,
});
}
let doc_id = doc_id.into();
if !self.fast_ids.insert(doc_id.clone()) {
return Err(SearchError::InvalidConfig {
field: "doc_id".to_owned(),
value: doc_id,
reason: "duplicate doc_id in fast tier; each document must have a unique id"
.to_owned(),
});
}
self.fast_records.push((doc_id, embedding));
Ok(())
}
pub fn add_quality_record(
&mut self,
doc_id: impl Into<String>,
embedding: &[f32],
) -> SearchResult<()> {
let dimension = embedding.len();
let expected = self.quality_dimension.get_or_insert(dimension);
if *expected != dimension {
return Err(SearchError::DimensionMismatch {
expected: *expected,
found: dimension,
});
}
let doc_id = doc_id.into();
if !self.quality_ids.insert(doc_id.clone()) {
return Err(SearchError::InvalidConfig {
field: "doc_id".to_owned(),
value: doc_id,
reason: "duplicate doc_id in quality tier; each document must have a unique id"
.to_owned(),
});
}
self.quality_records.push((doc_id, embedding.to_vec()));
Ok(())
}
pub fn add_record(
&mut self,
doc_id: impl Into<String>,
fast_embedding: &[f32],
quality_embedding: Option<&[f32]>,
) -> SearchResult<()> {
let doc_id = doc_id.into();
self.add_fast_record(doc_id.clone(), fast_embedding)?;
if let Some(quality_embedding) = quality_embedding {
self.add_quality_record(doc_id, quality_embedding)?;
}
Ok(())
}
pub fn finish(self) -> SearchResult<TwoTierIndex> {
let fast_dimension = self
.fast_dimension
.ok_or_else(|| SearchError::InvalidConfig {
field: "fast_records".to_owned(),
value: "0".to_owned(),
reason: "at least one fast-tier record is required".to_owned(),
})?;
if let Some(identity) = &self.fast_identity {
ensure_identity_describes_tier(identity, fast_dimension, "fast_identity")?;
}
if let (Some(identity), Some(quality_dimension)) =
(&self.quality_identity, self.quality_dimension)
{
ensure_identity_describes_tier(identity, quality_dimension, "quality_identity")?;
}
let fast_path = self.dir.join(VECTOR_INDEX_FAST_FILENAME);
let publication_nonce = crate::next_publication_nonce(if fast_path.exists() {
VectorIndex::peek_publication_nonce(&fast_path)?
} else {
0
});
publish_tier(
&fast_path,
&self.fast_embedder_id,
&self.fast_embedder_revision,
fast_dimension,
publication_nonce,
&self.fast_records,
)?;
if let Some(quality_dimension) = self.quality_dimension {
let quality_path = self.dir.join(VECTOR_INDEX_QUALITY_FILENAME);
publish_tier(
&quality_path,
&self.quality_embedder_id,
&self.quality_embedder_revision,
quality_dimension,
publication_nonce,
&self.quality_records,
)?;
}
let mut index = TwoTierIndex::open(&self.dir, self.config)?;
if let Some(identity) = self.fast_identity {
if index.fast_space_fingerprint_hex.is_none() {
index.fast_space_fingerprint_hex = Some(identity.space.fingerprint());
}
index.fast_declared_identity = Some(identity);
}
if let Some(identity) = self.quality_identity
&& self.quality_dimension.is_some()
&& index.quality_source.is_some()
{
if index.quality_space_fingerprint_hex.is_none() {
index.quality_space_fingerprint_hex = Some(identity.space.fingerprint());
}
index.quality_declared_identity = Some(identity);
}
Ok(index)
}
}
fn publish_tier(
canonical_path: &Path,
embedder_id: &str,
embedder_revision: &str,
dimension: usize,
publication_nonce: u16,
records: &[(String, Vec<f32>)],
) -> SearchResult<()> {
let generation = if canonical_path.exists() {
crate::next_generation(VectorIndex::peek_compaction_gen(canonical_path)?)
} else {
1
};
let staging_path = crate::temporary_output_path(canonical_path);
let result = (|| {
let mut writer = VectorIndex::create_with_revision(
&staging_path,
embedder_id,
embedder_revision,
dimension,
Quantization::F16,
)?
.with_generation(generation)
.with_publication_nonce(publication_nonce);
for (doc_id, embedding) in records {
writer.write_record(doc_id, embedding)?;
}
writer.finish()?;
VectorIndex::install_replacement(canonical_path, &staging_path).map(drop)
})();
if result.is_err()
&& staging_path.exists()
&& let Err(cleanup_err) = fs::remove_file(&staging_path)
{
warn!(
staging_path = %staging_path.display(),
?cleanup_err,
"failed to clean up staged tier replacement after error"
);
}
result
}
fn ensure_identity_describes_tier(
identity: &EmbeddingIdentityBundleV1,
tier_dimension: usize,
tier_field: &str,
) -> SearchResult<()> {
if usize::try_from(identity.space.dimension).ok() == Some(tier_dimension) {
return Ok(());
}
Err(SearchError::InvalidConfig {
field: format!("{tier_field}.space.dimension"),
value: identity.space.dimension.to_string(),
reason: format!(
"declared embedding-space dimension must equal the written tier dimension \
({tier_dimension}); refusing to retain an identity that does not describe \
this tier's vectors"
),
})
}
#[derive(Debug)]
pub struct ActivatedTier<'index, 'query> {
owner: &'index ValidatedFsviBytes,
query: &'query BoundQueryEmbedding,
}
impl ActivatedTier<'_, '_> {
pub fn search_top_k(&self, k: usize) -> SearchResult<Vec<VectorHit>> {
self.owner.search_top_k(self.query.vector(), k, None)
}
#[must_use]
pub const fn owner(&self) -> &ValidatedFsviBytes {
self.owner
}
#[must_use]
pub const fn generation_sequence(&self) -> u64 {
self.owner.witness().generation.sequence
}
#[must_use]
pub const fn live_count(&self) -> u64 {
self.owner.witness().live_count
}
}
#[derive(Debug)]
pub struct ActivatedTierSearch<'index, 'query> {
fast: Option<ActivatedTier<'index, 'query>>,
quality: Option<ActivatedTier<'index, 'query>>,
topology: RetrievalTopology,
}
impl<'index, 'query> ActivatedTierSearch<'index, 'query> {
#[must_use]
pub const fn topology(&self) -> RetrievalTopology {
self.topology
}
#[must_use]
pub const fn fast(&self) -> Option<&ActivatedTier<'index, 'query>> {
self.fast.as_ref()
}
#[must_use]
pub const fn quality(&self) -> Option<&ActivatedTier<'index, 'query>> {
self.quality.as_ref()
}
pub fn search_quality(&self, k: usize) -> SearchResult<Vec<VectorHit>> {
self.quality
.as_ref()
.ok_or_else(|| SearchError::InvalidConfig {
field: "search_activation.quality".to_owned(),
value: format!("{:?}", self.topology),
reason: "quality retrieval requires an activated quality tier".to_owned(),
})?
.search_top_k(k)
}
pub fn search_fast(&self, k: usize) -> SearchResult<Vec<VectorHit>> {
self.fast
.as_ref()
.ok_or_else(|| SearchError::InvalidConfig {
field: "search_activation.fast".to_owned(),
value: format!("{:?}", self.topology),
reason: "fast retrieval requires an activated fast tier".to_owned(),
})?
.search_top_k(k)
}
pub fn coverage(&self, returned: &[VectorHit], k: usize) -> SearchResult<SearchCoverageV1> {
let fast = Self::tier_coverage(self.fast.as_ref(), returned, k)?;
let quality = Self::tier_coverage(self.quality.as_ref(), returned, k)?;
Ok(SearchCoverageV1::new(self.topology, fast, quality))
}
fn tier_coverage(
tier: Option<&ActivatedTier<'index, 'query>>,
returned: &[VectorHit],
k: usize,
) -> SearchResult<TierQueryCoverageV1> {
let Some(tier) = tier else {
return Ok(TierQueryCoverageV1::NotRequested);
};
let served: BTreeSet<String> = tier
.search_top_k(k)?
.into_iter()
.map(|hit| hit.doc_id.to_string())
.collect();
let contributed = returned
.iter()
.filter(|hit| served.contains(hit.doc_id.as_str()))
.count();
Ok(TierQueryCoverageV1::Witnessed {
generation_sequence: tier.generation_sequence(),
live_count: tier.live_count(),
contributed_candidates: contributed as u64,
})
}
pub fn search_union(&self, k: usize) -> SearchResult<Vec<VectorHit>> {
let mut merged: BTreeMap<String, VectorHit> = BTreeMap::new();
for tier in [self.fast.as_ref(), self.quality.as_ref()]
.into_iter()
.flatten()
{
for hit in tier.search_top_k(k)? {
merged
.entry(hit.doc_id.to_string())
.and_modify(|existing| {
if hit.score > existing.score {
*existing = hit.clone();
}
})
.or_insert(hit);
}
}
let mut union: Vec<VectorHit> = merged.into_values().collect();
union.sort_by(|left, right| {
right
.score
.total_cmp(&left.score)
.then_with(|| left.doc_id.cmp(&right.doc_id))
});
union.truncate(k);
Ok(union)
}
}
fn activate_tier<'index, 'query>(
admitted: Option<(&'index ValidatedFsviBytes, &FsviV2IdentityBinding)>,
query: &'query BoundQueryEmbedding,
tier: &str,
) -> SearchResult<ActivatedTier<'index, 'query>> {
let (owner, binding) = admitted.ok_or_else(|| SearchError::InvalidConfig {
field: format!("search_activation.{tier}.owner"),
value: "<absent>".to_owned(),
reason: format!(
"the query binds a {tier} embedding but this index retains no admitted FSVI v2 \
{tier} owner; a legacy or path-opened tier is not searchable under typed \
activation and requires a reindex"
),
})?;
match query.verify_producer_conformance(&binding.frozen_identity().identity, tier)? {
SpaceIdentityAdmission::SameProducer => {}
other => {
return Err(SearchError::InvalidConfig {
field: format!("search_activation.{tier}.producer_conformance"),
value: format!("{other:?}"),
reason: format!(
"the query's producer is not the {tier} index's attested producer; a \
certified-compatible pairing is comparison-grade telemetry and never an \
admission basis"
),
});
}
}
if query.vector().len() != owner.dimension() {
return Err(SearchError::InvalidConfig {
field: format!("search_activation.{tier}.dimension"),
value: query.vector().len().to_string(),
reason: format!(
"query vector width does not match the {tier} index dimension {}",
owner.dimension()
),
});
}
Ok(ActivatedTier { owner, query })
}
fn admit_v2_tier(
path: &Path,
binding: &FsviV2IdentityBinding,
tier: &str,
) -> SearchResult<ValidatedFsviBytes> {
VectorIndex::open_admitted_v2(path, binding)
.map_err(|error| admission_error_to_search_error(error, tier, path))
}
fn admission_error_to_search_error(
error: FsviAdmissionError,
tier: &str,
path: &Path,
) -> SearchError {
match error {
FsviAdmissionError::Index(error) => error,
other => SearchError::InvalidConfig {
field: format!("two_tier.{tier}_v2_admission"),
value: path.display().to_string(),
reason: other.to_string(),
},
}
}
fn warn_if_wal_rows_replayed(tier: &str, path: &Path, index: &VectorIndex) {
let replayed = index.wal_record_count();
if replayed > 0 {
warn!(
tier,
path = %path.display(),
replayed_wal_records = replayed,
"two-tier v1 open replayed WAL sidecar rows; if this main was \
just republished, the rows may belong to a replaced foreign \
generation (generation counters wrap mod 255)"
);
}
}
fn resolve_fast_path(dir: &Path) -> SearchResult<PathBuf> {
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
if fast_path.exists() {
return Ok(fast_path);
}
let fallback_path = dir.join(VECTOR_INDEX_FALLBACK_FILENAME);
if fallback_path.exists() {
return Ok(fallback_path);
}
Err(SearchError::IndexCandidatesNotFound {
paths: vec![fast_path, fallback_path],
})
}
#[derive(Debug)]
pub enum FsviTierObservation {
V1(FsviV1Observation),
V2IdentityComplete(Box<VectorMetadata>),
UpgradeRequired(FsviUpgradeRequired),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FsviV1Observation {
pub record_count: usize,
pub active_wal_records: usize,
pub wal_sidecar_present: bool,
}
impl FsviV1Observation {
#[must_use]
pub const fn retains_content(&self) -> bool {
self.record_count > 0 || self.active_wal_records > 0
}
}
fn open_tier_readonly(path: &Path) -> SearchResult<fs::File> {
crate::open_readonly_noatime_nofollow(path)
.or_else(|_| fs::File::open(path).map_err(SearchError::Io))
}
pub fn observe_tier(path: &Path) -> SearchResult<FsviTierObservation> {
if !path.exists() {
return Err(SearchError::IndexNotFound {
path: path.to_path_buf(),
});
}
let mut file = open_tier_readonly(path)?;
let file_len = file.metadata().map_err(SearchError::Io)?.len();
let mut prefix = [0_u8; 6];
crate::read_exact_index_bytes(path, &mut file, &mut prefix, "magic and version")?;
if prefix[..4] != crate::FSVI_MAGIC {
return Err(crate::index_corrupted(
path,
format!(
"bad magic bytes: expected {:?}, found {:?}",
crate::FSVI_MAGIC,
&prefix[..4]
),
));
}
let version = u16::from_le_bytes([prefix[4], prefix[5]]);
match version {
crate::FSVI_VERSION => {
let bounded_len = usize::try_from(file_len)
.unwrap_or(usize::MAX)
.min(crate::FSVI_V1_MAX_HEADER_BYTES);
file.seek(SeekFrom::Start(0)).map_err(SearchError::Io)?;
let mut header = Vec::with_capacity(bounded_len);
file.take(u64::try_from(bounded_len).unwrap_or(u64::MAX))
.read_to_end(&mut header)
.map_err(SearchError::Io)?;
let (metadata, _header_len) = crate::parse_header(path, &header)?;
Ok(FsviTierObservation::V1(observe_v1_wal(path, &metadata)?))
}
crate::FSVI_V2_VERSION => {
let mut encoded_size = [0_u8; 4];
crate::read_exact_index_bytes(path, &mut file, &mut encoded_size, "v2 header_size")?;
let header_size = usize::try_from(u32::from_le_bytes(encoded_size)).map_err(|_| {
crate::index_corrupted(path, "v2 header_size does not fit in usize")
})?;
crate::validate_v2_header_size(path, header_size)?;
if u64::try_from(header_size).is_ok_and(|size| size > file_len) {
return Err(crate::index_corrupted(
path,
format!(
"v2 header is truncated: declared {header_size} bytes, file has {file_len}"
),
));
}
file.seek(SeekFrom::Start(0)).map_err(SearchError::Io)?;
let mut header = vec![0_u8; header_size];
crate::read_exact_index_bytes(path, &mut file, &mut header, "v2 header")?;
let (metadata, _header_len) = crate::parse_v2_header(path, &header)?;
Ok(FsviTierObservation::V2IdentityComplete(Box::new(metadata)))
}
found if found > crate::FSVI_V2_VERSION => {
Ok(FsviTierObservation::UpgradeRequired(FsviUpgradeRequired {
found_version: found,
supported_version: crate::FSVI_V2_VERSION,
}))
}
found => Err(crate::index_corrupted(
path,
format!("unsupported historical FSVI schema version {found}"),
)),
}
}
fn observe_v1_wal(path: &Path, metadata: &VectorMetadata) -> SearchResult<FsviV1Observation> {
let wal_path = crate::wal::wal_path_for(path);
let wal_sidecar_present = wal_path.exists();
let (entries, wal_compaction_gen, valid_len) =
crate::wal::read_wal(&wal_path, metadata.dimension, metadata.quantization)?;
let is_stale = if valid_len > 0 {
if wal_compaction_gen == 0 {
metadata.compaction_gen > 0
} else {
wal_compaction_gen != crate::next_generation(metadata.compaction_gen)
}
} else {
false
};
let active_wal_records = if is_stale { 0 } else { entries.len() };
Ok(FsviV1Observation {
record_count: metadata.record_count,
active_wal_records,
wal_sidecar_present,
})
}
#[cfg(feature = "ann")]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum AnnPersistenceReason {
FreshBuild,
RebuiltFallback,
}
#[cfg(feature = "ann")]
#[derive(Debug)]
struct AnnOpenPlan {
index: HnswIndex,
persistence: Option<AnnPersistenceReason>,
}
#[cfg(feature = "ann")]
impl AnnOpenPlan {
const fn needs_persistence(&self) -> bool {
self.persistence.is_some()
}
}
#[cfg(feature = "ann")]
fn plan_load_or_build_ann(
vector_index: &VectorIndex,
ann_path: &Path,
threshold: usize,
config: &TwoTierConfig,
tier: &str,
) -> Option<AnnOpenPlan> {
if vector_index.record_count() < threshold {
return None;
}
let ann_config = HnswConfig {
m: config.hnsw_m,
ef_construction: config.hnsw_ef_construction,
ef_search: config.hnsw_ef_search,
max_layer: HNSW_DEFAULT_MAX_LAYER,
};
if ann_path.exists() {
match HnswIndex::load_with_disposition(ann_path, vector_index) {
Ok((ann, load_disposition)) => match ann.matches_vector_index(vector_index) {
Ok(true) => {
let loaded_config = ann.config();
if loaded_config == ann_config {
return Some(AnnOpenPlan {
index: ann,
persistence: (load_disposition == HnswLoadDisposition::Rebuilt)
.then_some(AnnPersistenceReason::RebuiltFallback),
});
}
warn!(
tier,
ann_path = %ann_path.display(),
?loaded_config,
?ann_config,
"ANN sidecar config differs from requested config; rebuilding"
);
}
Ok(false) => {
warn!(
tier,
ann_path = %ann_path.display(),
"ANN sidecar exists but does not match vector index; rebuilding"
);
}
Err(error) => {
warn!(
tier,
ann_path = %ann_path.display(),
?error,
"failed to validate ANN sidecar; rebuilding"
);
}
},
Err(error) => {
warn!(
tier,
ann_path = %ann_path.display(),
?error,
"failed to load ANN sidecar; rebuilding"
);
}
}
}
let ann = match HnswIndex::build_from_vector_index(vector_index, ann_config) {
Ok(ann) => ann,
Err(error) => {
warn!(
tier,
?error,
"failed to build ANN index; using brute-force fallback"
);
return None;
}
};
Some(AnnOpenPlan {
index: ann,
persistence: Some(AnnPersistenceReason::FreshBuild),
})
}
#[cfg(feature = "ann")]
fn persist_ann_plan(
plan: &AnnOpenPlan,
ann_path: &Path,
tier: &str,
paths: &TwoTierIndexPaths,
fast_ann_enabled: bool,
quality_ann_enabled: bool,
) {
let Some(reason) = plan.persistence else {
return;
};
if let Err(error) = validate_ann_persistence_paths(paths, fast_ann_enabled, quality_ann_enabled)
.and_then(|()| HnswIndex::save(&plan.index, ann_path))
{
warn!(
tier,
ann_path = %ann_path.display(),
?reason,
?error,
"failed to persist ANN sidecar; ANN stays in-memory for this process, persistence \
durability was not confirmed, and the next startup may rebuild it again; check path \
permissions and free space"
);
} else {
debug!(
tier,
ann_path = %ann_path.display(),
?reason,
"persisted ANN sidecar"
);
}
}
#[cfg(all(feature = "ann", test))]
fn maybe_load_or_build_ann_with_save<Validate, Save>(
vector_index: &VectorIndex,
ann_path: &Path,
threshold: usize,
config: &TwoTierConfig,
tier: &str,
validate_before_save: Validate,
save_ann: Save,
) -> Option<HnswIndex>
where
Validate: Fn() -> SearchResult<()>,
Save: Fn(&HnswIndex, &Path) -> SearchResult<()>,
{
let plan = plan_load_or_build_ann(vector_index, ann_path, threshold, config, tier)?;
if let Some(reason) = plan.persistence {
if let Err(error) = validate_before_save().and_then(|()| save_ann(&plan.index, ann_path)) {
warn!(
tier,
ann_path = %ann_path.display(),
?reason,
?error,
"failed to persist ANN sidecar; ANN stays in-memory for this process"
);
}
}
Some(plan.index)
}
#[cfg(test)]
mod tests {
use std::time::{SystemTime, UNIX_EPOCH};
use super::*;
fn temp_index_dir(label: &str) -> PathBuf {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
std::env::temp_dir().join(format!(
"frankensearch-two-tier-{label}-{}-{timestamp}",
std::process::id()
))
}
fn write_index_file(path: &Path, rows: &[(&str, &[f32])]) -> SearchResult<()> {
let dimension = rows
.first()
.map(|(_, vector)| vector.len())
.ok_or_else(|| SearchError::InvalidConfig {
field: "rows".to_owned(),
value: "[]".to_owned(),
reason: "rows must not be empty".to_owned(),
})?;
let mut writer = VectorIndex::create(path, "test", dimension)?;
for (doc_id, vector) in rows {
writer.write_record(doc_id, vector)?;
}
writer.finish()
}
#[cfg(feature = "ann")]
fn load_native_ann_sidecar(path: &Path, source_index: &VectorIndex) -> HnswIndex {
let (ann, disposition) = HnswIndex::load_with_disposition(path, source_index)
.expect("load persisted ANN sidecar");
assert_eq!(
disposition,
HnswLoadDisposition::Native,
"the repaired sidecar must use the native graph path on its next load"
);
let metadata: serde_json::Value =
serde_json::from_slice(&fs::read(path).expect("read ANN metadata"))
.expect("parse ANN metadata");
assert_eq!(
metadata["format_version"].as_u64(),
Some(u64::from(HNSW_META_FORMAT_CURRENT)),
"repaired ANN metadata must use the current format"
);
let generation = metadata["sidecar_generation"]
.as_str()
.expect("current metadata generation");
let basename = metadata["sidecar_basename"]
.as_str()
.expect("current metadata basename");
let sidecar_parent = path.parent().expect("ANN metadata parent").join(generation);
assert!(
sidecar_parent
.join(format!("{basename}.hnsw.graph"))
.is_file(),
"repaired native graph sibling must exist"
);
assert!(
sidecar_parent
.join(format!("{basename}.hnsw.data"))
.is_file(),
"repaired native data sibling must exist"
);
ann
}
#[test]
fn opens_with_fallback_fast_index() {
let dir = temp_index_dir("fallback");
fs::create_dir_all(&dir).expect("create temp dir");
let fallback = dir.join(VECTOR_INDEX_FALLBACK_FILENAME);
write_index_file(
&fallback,
&[
("doc-a", &[1.0, 0.0, 0.0, 0.0]),
("doc-b", &[0.0, 1.0, 0.0, 0.0]),
],
)
.expect("write fallback index");
let index = TwoTierIndex::open(&dir, TwoTierConfig::default()).expect("open two-tier");
assert_eq!(index.doc_count(), 2);
assert!(!index.has_quality_index());
let ids: Vec<String> = index
.iter_doc_ids()
.collect::<SearchResult<_>>()
.expect("ids");
assert_eq!(ids, vec!["doc-a".to_owned(), "doc-b".to_owned()]);
assert_eq!(index.fast_index_for_doc_id("doc-a").unwrap(), Some(0));
assert_eq!(index.fast_index_for_doc_id("doc-b").unwrap(), Some(1));
assert_eq!(index.fast_index_for_doc_id("missing").unwrap(), None);
let hits = index
.search_fast(&[1.0, 0.0, 0.0, 0.0], 1)
.expect("fast search");
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].doc_id, "doc-a");
#[cfg(feature = "ann")]
assert_eq!(index.ann_fallback_count(), 0);
}
#[test]
fn search_fast_with_params_matches_default_path() {
let dir = temp_index_dir("search-params-default-match");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
write_index_file(
&fast_path,
&[
("doc-a", &[1.0, 0.0, 0.0, 0.0]),
("doc-b", &[0.0, 1.0, 0.0, 0.0]),
("doc-c", &[0.0, 0.0, 1.0, 0.0]),
],
)
.expect("write fast index");
let index = TwoTierIndex::open(&dir, TwoTierConfig::default()).expect("open two-tier");
let baseline = index
.search_fast(&[1.0, 0.0, 0.0, 0.0], 2)
.expect("baseline");
let overridden = index
.search_fast_with_params(&[1.0, 0.0, 0.0, 0.0], 2, Some(SearchParams::default()))
.expect("search with params");
assert_eq!(baseline, overridden);
}
#[test]
fn search_fast_with_params_accepts_explicit_sequential_override() {
let dir = temp_index_dir("search-params-seq-override");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
write_index_file(
&fast_path,
&[
("doc-a", &[1.0, 0.0, 0.0, 0.0]),
("doc-b", &[0.0, 1.0, 0.0, 0.0]),
("doc-c", &[0.0, 0.0, 1.0, 0.0]),
("doc-d", &[0.0, 0.0, 0.0, 1.0]),
],
)
.expect("write fast index");
let index = TwoTierIndex::open(&dir, TwoTierConfig::default()).expect("open two-tier");
let params = SearchParams {
parallel_enabled: false,
parallel_threshold: usize::MAX,
parallel_chunk_size: 2,
};
let hits = index
.search_fast_with_params(&[1.0, 0.0, 0.0, 0.0], 3, Some(params))
.expect("sequential override search");
assert_eq!(hits.len(), 3);
assert_eq!(hits[0].doc_id, "doc-a");
}
#[test]
fn quality_alignment_handles_partial_coverage() {
let dir = temp_index_dir("alignment");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
let quality_path = dir.join(VECTOR_INDEX_QUALITY_FILENAME);
write_index_file(
&fast_path,
&[
("doc-a", &[1.0, 0.0, 0.0, 0.0]),
("doc-b", &[0.0, 1.0, 0.0, 0.0]),
("doc-c", &[0.0, 0.0, 1.0, 0.0]),
],
)
.expect("write fast index");
write_index_file(
&quality_path,
&[
("doc-c", &[0.0, 1.0, 0.0, 0.0]),
("doc-a", &[1.0, 0.0, 0.0, 0.0]),
],
)
.expect("write quality index");
let index = TwoTierIndex::open(&dir, TwoTierConfig::default()).expect("open two-tier");
assert!(index.has_quality_index());
assert!(index.has_quality_for_index(0));
assert!(!index.has_quality_for_index(1));
assert!(index.has_quality_for_index(2));
let hits = vec![
VectorHit {
index: 0,
score: 0.0,
doc_id: "doc-a".into(),
},
VectorHit {
index: 1,
score: 0.0,
doc_id: "doc-b".into(),
},
VectorHit {
index: 2,
score: 0.0,
doc_id: "doc-c".into(),
},
];
let scores = index
.quality_scores_for_hits(&[1.0, 0.0, 0.0, 0.0], &hits)
.expect("quality scores");
assert_eq!(scores.len(), 3);
assert!((scores[0].unwrap() - 1.0).abs() < 1e-6);
assert!(scores[1].is_none());
assert!(scores[2].unwrap().abs() < 1e-6);
}
#[test]
fn quality_scores_are_none_without_quality_index() {
let dir = temp_index_dir("no-quality");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
write_index_file(
&fast_path,
&[("doc-a", &[1.0, 0.0]), ("doc-b", &[0.0, 1.0])],
)
.expect("write fast index");
let index = TwoTierIndex::open(&dir, TwoTierConfig::default()).expect("open");
let hits = vec![
VectorHit {
index: 0,
score: 0.0,
doc_id: "doc-a".into(),
},
VectorHit {
index: 1,
score: 0.0,
doc_id: "doc-b".into(),
},
VectorHit {
index: 99,
score: 0.0,
doc_id: "doc-missing".into(),
},
];
let scores = index
.quality_scores_for_hits(&[1.0, 0.0], &hits)
.expect("scores");
assert_eq!(scores, vec![None, None, None]);
}
#[test]
fn open_degrades_to_fast_only_when_tier_publication_identities_disagree() {
let dir = temp_index_dir("mixed-pair-degrade");
let mut builder = TwoTierIndex::create(&dir, TwoTierConfig::default()).expect("builder");
builder
.add_record("doc-a", &[1.0, 0.0, 0.0, 0.0], Some(&[1.0, 0.0]))
.expect("add doc-a");
drop(builder.finish().expect("finish pair"));
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
let fast_gen =
crate::next_generation(VectorIndex::peek_compaction_gen(&fast_path).expect("peek gen"));
let fast_nonce = crate::next_publication_nonce(
VectorIndex::peek_publication_nonce(&fast_path).expect("peek nonce"),
);
let staging = crate::temporary_output_path(&fast_path);
let mut writer = VectorIndex::create(&staging, "fast-tier", 4)
.expect("staging writer")
.with_generation(fast_gen)
.with_publication_nonce(fast_nonce);
writer
.write_record("doc-b", &[0.0, 1.0, 0.0, 0.0])
.expect("write doc-b");
writer.finish().expect("finish staging");
VectorIndex::install_replacement(&fast_path, &staging).expect("install fast tier only");
let reopened = TwoTierIndex::open(&dir, TwoTierConfig::default()).expect("open mixed pair");
assert!(
!reopened.has_quality_index(),
"a mixed-publication pair must degrade to fast-only, not blend tiers"
);
}
#[test]
fn open_degrades_to_fast_only_when_discovered_quality_index_is_corrupt() {
let dir = temp_index_dir("corrupt-discovered-quality-degrade");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
write_index_file(
&fast_path,
&[("doc-a", &[1.0, 0.0]), ("doc-b", &[0.0, 1.0])],
)
.expect("write fast index");
fs::write(dir.join(VECTOR_INDEX_QUALITY_FILENAME), b"not an FSVI file")
.expect("write corrupt optional quality index");
let opened = TwoTierIndex::open(&dir, TwoTierConfig::default())
.expect("a corrupt discovered optional quality tier must not block fast-only open");
assert_eq!(opened.doc_count(), 2);
assert!(
!opened.has_quality_index(),
"a corrupt discovered quality tier must degrade to fast-only"
);
assert_eq!(
opened
.search_fast(&[1.0, 0.0], 1)
.expect("fast search after quality degradation")[0]
.doc_id,
"doc-a"
);
}
#[test]
fn open_with_paths_rejects_a_corrupt_explicit_quality_index() {
let dir = temp_index_dir("corrupt-explicit-quality-reject");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join("fast.fsvi");
let quality_path = dir.join("quality.fsvi");
write_index_file(&fast_path, &[("doc-a", &[1.0, 0.0])]).expect("write fast index");
fs::write(&quality_path, b"not an FSVI file").expect("write corrupt quality index");
let error = TwoTierIndex::open_with_paths(
&TwoTierIndexPaths::new(&fast_path).with_quality_index(&quality_path),
TwoTierConfig::default(),
)
.expect_err("an explicit corrupt quality path must remain an error");
assert!(
!matches!(error, SearchError::IndexNotFound { .. }),
"the corrupt explicit path must not be reported as absent: {error:?}"
);
}
#[test]
fn finish_stamps_both_tiers_with_one_nonzero_publication_nonce() {
let dir = temp_index_dir("pair-nonce");
let mut builder = TwoTierIndex::create(&dir, TwoTierConfig::default()).expect("builder");
builder
.add_record("doc-a", &[1.0, 0.0], Some(&[0.0, 1.0]))
.expect("add doc-a");
drop(builder.finish().expect("finish pair"));
let fast_nonce = VectorIndex::peek_publication_nonce(&dir.join(VECTOR_INDEX_FAST_FILENAME))
.expect("fast nonce");
let quality_nonce =
VectorIndex::peek_publication_nonce(&dir.join(VECTOR_INDEX_QUALITY_FILENAME))
.expect("quality nonce");
assert_ne!(fast_nonce, 0, "a published pair must carry a real identity");
assert_eq!(
fast_nonce, quality_nonce,
"both tiers share one publication"
);
let mut second = TwoTierIndex::create(&dir, TwoTierConfig::default()).expect("builder");
second
.add_record("doc-b", &[0.0, 1.0], Some(&[1.0, 0.0]))
.expect("add doc-b");
drop(second.finish().expect("finish second pair"));
let second_nonce =
VectorIndex::peek_publication_nonce(&dir.join(VECTOR_INDEX_FAST_FILENAME))
.expect("second fast nonce");
assert_ne!(second_nonce, fast_nonce);
}
#[test]
fn finish_over_live_dir_never_adopts_the_prior_generations_wal() {
let dir = temp_index_dir("rebuild-wal-supersede");
let mut first = TwoTierIndex::create(&dir, TwoTierConfig::default()).expect("builder");
first
.add_record("doc-a", &[1.0, 0.0, 0.0, 0.0], None)
.expect("add doc-a");
drop(first.finish().expect("finish first generation"));
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
let mut live = VectorIndex::open(&fast_path).expect("open first generation");
live.append("ghost", &[0.0, 1.0, 0.0, 0.0])
.expect("append acknowledged WAL record");
drop(live);
let mut second = TwoTierIndex::create(&dir, TwoTierConfig::default()).expect("builder");
second
.add_record("doc-b", &[0.0, 0.0, 1.0, 0.0], None)
.expect("add doc-b");
drop(second.finish().expect("finish second generation"));
let rebuilt = VectorIndex::open(&fast_path).expect("open second generation");
let live_ids = rebuilt.live_doc_ids().expect("live ids");
assert!(
!live_ids.contains("ghost"),
"prior generation's WAL record resurrected into the rebuilt index: {live_ids:?}"
);
assert!(live_ids.contains("doc-b"));
assert_eq!(
rebuilt.wal_record_count(),
0,
"rebuilt generation must start with no adopted WAL"
);
}
#[test]
fn builder_round_trips_fast_and_quality_records() {
let dir = temp_index_dir("builder");
let mut builder = TwoTierIndex::create(&dir, TwoTierConfig::default()).expect("builder");
builder
.set_fast_embedder_id("fast-test")
.set_quality_embedder_id("quality-test");
builder
.add_record("doc-a", &[1.0, 0.0, 0.0], Some(&[1.0, 0.0, 0.0]))
.expect("add doc-a");
builder
.add_record("doc-b", &[0.0, 1.0, 0.0], None)
.expect("add doc-b");
let index = builder.finish().expect("finish builder");
assert_eq!(index.doc_count(), 2);
assert!(index.has_quality_index());
assert!(index.has_quality_for_index(0));
assert!(!index.has_quality_for_index(1));
}
#[test]
fn builder_rejects_inconsistent_fast_dimension() {
let dir = temp_index_dir("bad-dim");
let mut builder = TwoTierIndex::create(&dir, TwoTierConfig::default()).expect("builder");
builder
.add_fast_record("doc-a", &[1.0, 0.0, 0.0])
.expect("first record");
let err = builder
.add_fast_record("doc-b", &[1.0, 0.0])
.expect_err("must reject dimension mismatch");
assert!(matches!(
err,
SearchError::DimensionMismatch {
expected: 3,
found: 2
}
));
}
#[cfg(feature = "ann")]
#[test]
fn ann_sidecar_is_created_when_threshold_is_met() {
let dir = temp_index_dir("ann-enabled");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
write_index_file(
&fast_path,
&[
("doc-a", &[1.0, 0.0, 0.0, 0.0]),
("doc-b", &[0.0, 1.0, 0.0, 0.0]),
("doc-c", &[0.0, 0.0, 1.0, 0.0]),
],
)
.expect("write fast index");
let config = TwoTierConfig {
hnsw_threshold: 1,
hnsw_ef_search: 32,
..TwoTierConfig::default()
};
let index = TwoTierIndex::open(&dir, config).expect("open with ann");
assert!(index.has_fast_ann());
assert!(dir.join(VECTOR_ANN_FAST_FILENAME).exists());
let hits = index
.search_fast(&[1.0, 0.0, 0.0, 0.0], 1)
.expect("ann search");
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].doc_id, "doc-a");
}
#[cfg(feature = "ann")]
#[test]
fn ann_wal_merge_matches_canonical_resolution_before_and_after_post_build_tombstone() {
use crate::wal::WalEntry;
let dir = temp_index_dir("ann-wal-canonical-resolution");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
write_index_file(
&fast_path,
&[
("doc-a", &[1.0, 0.0]),
("doc-b", &[0.8, 0.6]),
("doc-c", &[0.0, 1.0]),
("doc-delete", &[-1.0, 0.0]),
],
)
.expect("write fast index");
let config = TwoTierConfig {
hnsw_threshold: 1,
hnsw_ef_search: 32,
..TwoTierConfig::default()
};
let mut index = TwoTierIndex::open(&dir, config.clone()).expect("open ANN index");
assert!(index.has_fast_ann());
let wal_entries = vec![
WalEntry {
doc_id: "doc-a".into(),
doc_id_hash: crate::fnv1a_hash(b"doc-a"),
embedding: vec![-1.0, 0.0],
},
WalEntry {
doc_id: "doc-a".into(),
doc_id_hash: crate::fnv1a_hash(b"doc-a"),
embedding: vec![0.2, 0.979_795_9],
},
WalEntry {
doc_id: "doc-new".into(),
doc_id_hash: crate::fnv1a_hash(b"doc-new"),
embedding: vec![0.95, 0.312_249_9],
},
];
let fast_tier = index
.fast_tier_mut_for_test()
.expect("path-opened fast tier");
fast_tier.wal_entries.extend(wal_entries.clone());
let query = [1.0_f32, 0.0];
let assert_canonical = |index: &TwoTierIndex, label: &str| {
let expected = index
.fast_tier()
.search_top_k(&query, 10, None)
.expect("canonical main plus WAL search");
let actual = index.search_fast(&query, 10).expect("ANN plus WAL search");
let expected_identity: Vec<_> = expected
.iter()
.map(|hit| (hit.doc_id.clone(), hit.index))
.collect();
let actual_identity: Vec<_> = actual
.iter()
.map(|hit| (hit.doc_id.clone(), hit.index))
.collect();
assert_eq!(
actual_identity, expected_identity,
"{label}: ANN and exact search must resolve the same public identities"
);
let unique_ids: HashSet<_> = actual.iter().map(|hit| hit.doc_id.as_str()).collect();
assert_eq!(
unique_ids.len(),
actual.len(),
"{label}: public results must not contain duplicate document IDs"
);
let doc_a = actual
.iter()
.find(|hit| hit.doc_id == "doc-a")
.expect("WAL doc-a remains searchable");
assert!(
usize::try_from(doc_a.index).expect("u32 fits usize")
>= index.fast_tier().record_count(),
"{label}: WAL doc-a must supersede the stale main-slab version"
);
};
assert_canonical(&index, "normal ANN");
assert_eq!(index.ann_fallback_count(), 0);
drop(index);
let mut writer =
VectorIndex::open_writer(&fast_path).expect("writer opens after reader drops");
assert!(
writer
.soft_delete("doc-delete")
.expect("post-build tombstone")
);
drop(writer);
let mut index = TwoTierIndex::open(&dir, config).expect("reopen ANN index after tombstone");
index
.fast_tier_mut_for_test()
.expect("reopened path-opened fast tier")
.wal_entries
.extend(wal_entries);
assert!(
!index
.search_fast(&query, 10)
.expect("refreshed ANN search")
.iter()
.any(|hit| hit.doc_id == "doc-delete"),
"the writer-role tombstone must survive the reader reopen"
);
assert_canonical(&index, "post-build tombstone");
assert_eq!(
index.ann_fallback_count(),
0,
"a sidecar refreshed for the tombstone must not report stale-graph fallback"
);
}
#[cfg(feature = "ann")]
#[test]
fn ann_wal_shadowed_top_hit_stays_suppressed_through_post_build_tombstone() {
use crate::wal::WalEntry;
let dir = temp_index_dir("ann-wal-shadowed-top");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
write_index_file(
&fast_path,
&[
("doc-a", &[0.9, 0.435_889_9]),
("doc-b", &[0.8, -0.6]),
("doc-tombstone", &[0.0, 1.0]),
],
)
.expect("write fast index");
let config = TwoTierConfig {
hnsw_threshold: 1,
hnsw_ef_search: 32,
..TwoTierConfig::default()
};
let mut index = TwoTierIndex::open(&dir, config.clone()).expect("open ANN index");
let wal_entry = WalEntry {
doc_id: "doc-a".into(),
doc_id_hash: crate::fnv1a_hash(b"doc-a"),
embedding: vec![-1.0, 0.0],
};
let fast_tier = index
.fast_tier_mut_for_test()
.expect("path-opened fast tier");
fast_tier.wal_entries.push(wal_entry.clone());
let normal_query = [1.0_f32, 0.0];
assert!(
index
.fast_tier()
.search_top_k(&normal_query, 1, None)
.expect("canonical normal search")
.is_empty(),
"the physical main winner is shadowed after top-k selection"
);
assert!(
index
.search_fast(&normal_query, 1)
.expect("normal ANN search")
.is_empty(),
"normal ANN must not backfill after suppressing a WAL-shadowed winner"
);
assert_eq!(index.ann_fallback_count(), 0);
drop(index);
let mut writer =
VectorIndex::open_writer(&fast_path).expect("writer opens after reader drops");
assert!(
writer
.soft_delete("doc-tombstone")
.expect("post-build tombstone")
);
drop(writer);
let mut index = TwoTierIndex::open(&dir, config).expect("reopen ANN index after tombstone");
index
.fast_tier_mut_for_test()
.expect("reopened path-opened fast tier")
.wal_entries
.push(wal_entry);
let tombstone_query = [0.0_f32, 1.0];
assert!(
index
.fast_tier()
.search_top_k(&tombstone_query, 1, None)
.expect("canonical tombstone search")
.is_empty(),
"the exact main winner is still shadowed by the WAL"
);
assert!(
index
.search_fast(&tombstone_query, 1)
.expect("refreshed ANN search")
.is_empty(),
"the refreshed ANN path must preserve WAL suppression after tombstoning"
);
assert_eq!(
index.ann_fallback_count(),
0,
"a sidecar refreshed for the tombstone must not report stale-graph fallback"
);
}
#[cfg(feature = "ann")]
#[test]
fn ann_two_tier_duplicate_main_ids_consume_physical_top_k_slots() {
let dir = temp_index_dir("ann-duplicate-main-top-k");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
write_index_file(
&fast_path,
&[
("duplicate", &[1.0, 0.0]),
("duplicate", &[0.95, 0.312_249_9]),
("lower-unique", &[0.8, 0.6]),
],
)
.expect("write duplicate-ID fast index");
let config = TwoTierConfig {
hnsw_threshold: 1,
hnsw_ef_search: 32,
..TwoTierConfig::default()
};
let index = TwoTierIndex::open(&dir, config).expect("open ANN index");
let query = [1.0_f32, 0.0];
let expected = index
.fast_tier()
.search_top_k(&query, 2, None)
.expect("canonical duplicate-ID search");
let actual = index
.search_fast(&query, 2)
.expect("ANN duplicate-ID search");
assert_eq!(expected.len(), 1);
assert_eq!(actual, expected);
assert_eq!(actual[0].doc_id, "duplicate");
assert!(
actual.iter().all(|hit| hit.doc_id != "lower-unique"),
"requesting k=2 must not overfetch and backfill a third physical row"
);
}
#[cfg(feature = "ann")]
#[test]
fn ann_is_skipped_below_threshold() {
let dir = temp_index_dir("ann-disabled");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
write_index_file(
&fast_path,
&[("doc-a", &[1.0, 0.0]), ("doc-b", &[0.0, 1.0])],
)
.expect("write fast index");
let config = TwoTierConfig {
hnsw_threshold: 10_000,
..TwoTierConfig::default()
};
let index = TwoTierIndex::open(&dir, config).expect("open");
assert!(!index.has_fast_ann());
assert!(!dir.join(VECTOR_ANN_FAST_FILENAME).exists());
}
#[test]
fn open_returns_index_not_found_when_no_fast_or_fallback() {
let dir = temp_index_dir("missing");
fs::create_dir_all(&dir).expect("create temp dir");
let error = TwoTierIndex::open(&dir, TwoTierConfig::default()).unwrap_err();
let paths = match &error {
SearchError::IndexCandidatesNotFound { paths } => paths.as_slice(),
_ => &[],
};
let expected_paths = [
dir.join(VECTOR_INDEX_FAST_FILENAME),
dir.join(VECTOR_INDEX_FALLBACK_FILENAME),
];
assert_eq!(
paths,
expected_paths.as_slice(),
"unexpected error variant: {error:?}"
);
let message = error.to_string();
assert!(message.contains(VECTOR_INDEX_FAST_FILENAME));
assert!(message.contains(VECTOR_INDEX_FALLBACK_FILENAME));
}
#[test]
fn open_with_paths_supports_consumer_owned_filenames() {
let dir = temp_index_dir("explicit-paths");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join("index-fnv1a-384.fsvi");
let quality_path = dir.join("index-minilm-384.fsvi");
write_index_file(
&fast_path,
&[("doc-a", &[1.0, 0.0]), ("doc-b", &[0.0, 1.0])],
)
.expect("write custom fast index");
write_index_file(
&quality_path,
&[("doc-a", &[0.5, 0.5]), ("doc-b", &[1.0, 0.0])],
)
.expect("write custom quality index");
let paths = TwoTierIndexPaths::new(&fast_path).with_quality_index(&quality_path);
let index = TwoTierIndex::open_with_paths(&paths, TwoTierConfig::default())
.expect("open custom two-tier paths");
assert_eq!(paths.fast_index(), fast_path);
assert_eq!(paths.quality_index(), Some(quality_path.as_path()));
assert!(index.has_quality_index());
assert_eq!(index.doc_count(), 2);
assert_eq!(index.fast_embedder_id(), "test");
assert_eq!(index.fast_embedder_revision(), "");
assert_eq!(index.quality_embedder_id(), Some("test"));
assert_eq!(index.quality_embedder_revision(), Some(""));
assert_eq!(index.fast_index_path(), fast_path);
assert_eq!(index.quality_index_path(), Some(quality_path.as_path()));
assert!(!dir.join(VECTOR_INDEX_FAST_FILENAME).exists());
assert!(!dir.join(VECTOR_INDEX_QUALITY_FILENAME).exists());
let hits = index.search_fast(&[1.0, 0.0], 2).expect("search fast");
let quality_scores = index
.quality_scores_for_hits(&[1.0, 0.0], &hits)
.expect("score quality tier");
assert_eq!(quality_scores.len(), hits.len());
assert!(quality_scores.iter().all(Option::is_some));
}
#[test]
fn open_with_paths_supports_custom_fast_only_without_copying() {
let dir = temp_index_dir("explicit-fast-only");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join("index-fnv1a-384.fsvi");
write_index_file(&fast_path, &[("doc-a", &[1.0, 0.0])]).expect("write custom fast index");
let paths = TwoTierIndexPaths::new(&fast_path);
let index = TwoTierIndex::open_with_paths(&paths, TwoTierConfig::default())
.expect("open custom fast-only path");
assert_eq!(index.fast_index_path(), fast_path);
assert_eq!(index.quality_index_path(), None);
assert!(!index.has_quality_index());
assert!(!dir.join(VECTOR_INDEX_FAST_FILENAME).exists());
assert!(!dir.join(VECTOR_INDEX_FALLBACK_FILENAME).exists());
assert!(!dir.join(VECTOR_INDEX_QUALITY_FILENAME).exists());
}
#[test]
fn open_with_paths_reports_the_explicit_missing_path() {
let dir = temp_index_dir("explicit-missing");
fs::create_dir_all(&dir).expect("create temp dir");
let missing_path = dir.join("index-custom.fsvi");
let paths = TwoTierIndexPaths::new(&missing_path);
let error = TwoTierIndex::open_with_paths(&paths, TwoTierConfig::default()).unwrap_err();
assert!(
matches!(
error,
SearchError::IndexNotFound { ref path } if path == &missing_path
),
"expected exact explicit path, got {error:?}"
);
}
#[test]
fn open_with_paths_reports_an_explicit_missing_quality_path() {
let dir = temp_index_dir("explicit-missing-quality");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join("index-fnv1a-384.fsvi");
let missing_quality_path = dir.join("index-minilm-384.fsvi");
write_index_file(&fast_path, &[("doc-a", &[1.0, 0.0])]).expect("write custom fast index");
let paths = TwoTierIndexPaths::new(&fast_path).with_quality_index(&missing_quality_path);
let error = TwoTierIndex::open_with_paths(&paths, TwoTierConfig::default()).unwrap_err();
assert!(
matches!(
error,
SearchError::IndexNotFound { ref path } if path == &missing_quality_path
),
"expected exact missing quality path, got {error:?}"
);
}
#[cfg(unix)]
#[test]
fn explicit_fast_final_symlink_to_distinct_target_is_rejected() {
use std::os::unix::fs::symlink;
let dir = temp_index_dir("explicit-fast-distinct-symlink");
fs::create_dir_all(&dir).expect("create temp dir");
let target_path = dir.join("target.fsvi");
let symlink_path = dir.join("index-fast.fsvi");
write_index_file(&target_path, &[("doc-a", &[1.0, 0.0])])
.expect("write distinct fast target");
symlink(&target_path, &symlink_path).expect("create fast final symlink");
let error = TwoTierIndex::open_with_paths(
&TwoTierIndexPaths::new(&symlink_path),
TwoTierConfig::default(),
)
.expect_err("a distinct-target final symlink must be rejected");
assert!(
matches!(
error,
SearchError::InvalidConfig { ref field, .. } if field == "index_paths"
),
"unexpected final-symlink error: {error:?}"
);
assert_eq!(
fs::read(&target_path).expect("read preserved target"),
fs::read(&symlink_path).expect("read through preserved symlink")
);
}
#[cfg(unix)]
#[test]
fn explicit_dangling_fast_final_symlink_is_rejected_as_configuration() {
use std::os::unix::fs::symlink;
let dir = temp_index_dir("explicit-fast-dangling-symlink");
fs::create_dir_all(&dir).expect("create temp dir");
let missing_target = dir.join("missing-target.fsvi");
let symlink_path = dir.join("index-fast.fsvi");
symlink(&missing_target, &symlink_path).expect("create dangling fast symlink");
let error = TwoTierIndex::open_with_paths(
&TwoTierIndexPaths::new(&symlink_path),
TwoTierConfig::default(),
)
.expect_err("a dangling final symlink must be rejected");
assert!(
matches!(
error,
SearchError::InvalidConfig { ref field, .. } if field == "index_paths"
),
"unexpected dangling-symlink error: {error:?}"
);
assert!(!missing_target.exists());
assert!(fs::symlink_metadata(&symlink_path).is_ok());
}
#[cfg(feature = "ann")]
#[test]
fn explicit_ann_paths_are_inspectable() {
let fast_index = PathBuf::from("index-fast.fsvi");
let quality_index = PathBuf::from("index-quality.fsvi");
let fast_ann = PathBuf::from("index-fast.hnsw");
let quality_ann = PathBuf::from("index-quality.hnsw");
let paths = TwoTierIndexPaths::new(&fast_index)
.with_quality_index(&quality_index)
.with_fast_ann(&fast_ann)
.with_quality_ann(&quality_ann);
assert_eq!(paths.fast_index(), fast_index);
assert_eq!(paths.quality_index(), Some(quality_index.as_path()));
assert_eq!(paths.fast_ann(), Some(fast_ann.as_path()));
assert_eq!(paths.quality_ann(), Some(quality_ann.as_path()));
}
#[cfg(feature = "ann")]
#[test]
fn explicit_path_roles_must_not_alias() {
let cases = [
TwoTierIndexPaths::new("fast.fsvi").with_quality_index("fast.fsvi"),
TwoTierIndexPaths::new("fast.fsvi").with_fast_ann("fast.fsvi"),
TwoTierIndexPaths::new("fast.fsvi")
.with_quality_index("quality.fsvi")
.with_quality_ann("fast.fsvi"),
TwoTierIndexPaths::new("fast.fsvi")
.with_quality_index("quality.fsvi")
.with_fast_ann("quality.fsvi"),
TwoTierIndexPaths::new("fast.fsvi")
.with_quality_index("quality.fsvi")
.with_quality_ann("quality.fsvi"),
TwoTierIndexPaths::new("fast.fsvi")
.with_quality_index("quality.fsvi")
.with_fast_ann("shared.hnsw")
.with_quality_ann("shared.hnsw"),
];
for paths in cases {
let error = validate_index_paths(&paths).expect_err("aliased roles must be rejected");
assert!(
matches!(
error,
SearchError::InvalidConfig { ref field, .. } if field == "index_paths"
),
"unexpected alias error: {error:?}"
);
}
}
#[cfg(feature = "ann")]
#[test]
fn quality_ann_requires_a_quality_index() {
let paths = TwoTierIndexPaths::new("fast.fsvi").with_quality_ann("orphan-quality.hnsw");
let error = validate_index_paths(&paths).expect_err("orphan quality ANN must be rejected");
assert!(
matches!(
error,
SearchError::InvalidConfig { ref field, .. } if field == "quality_ann"
),
"unexpected quality ANN error: {error:?}"
);
}
#[cfg(feature = "ann")]
#[test]
fn custom_ann_path_builds_without_creating_a_conventional_sidecar() {
let dir = temp_index_dir("explicit-custom-ann");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join("index-fnv1a-4.fsvi");
let fast_ann_path = dir.join("index-fnv1a-4.hnsw");
write_index_file(
&fast_path,
&[
("doc-a", &[1.0, 0.0, 0.0, 0.0]),
("doc-b", &[0.0, 1.0, 0.0, 0.0]),
],
)
.expect("write custom fast index");
let paths = TwoTierIndexPaths::new(&fast_path).with_fast_ann(&fast_ann_path);
let config = TwoTierConfig {
hnsw_threshold: 1,
..TwoTierConfig::default()
};
let index =
TwoTierIndex::open_with_paths(&paths, config).expect("open and build custom ANN");
assert!(index.has_fast_ann());
assert!(fast_ann_path.exists());
assert!(!dir.join(VECTOR_ANN_FAST_FILENAME).exists());
}
#[cfg(feature = "ann")]
#[test]
fn native_custom_ann_reopen_does_not_materialize_a_missing_save_lock() {
let dir = temp_index_dir("explicit-native-ann-read-only-reopen");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join("index-fnv1a-4.fsvi");
let fast_ann_path = dir.join("index-fnv1a-4.hnsw");
write_index_file(
&fast_path,
&[
("doc-a", &[1.0, 0.0, 0.0, 0.0]),
("doc-b", &[0.0, 1.0, 0.0, 0.0]),
],
)
.expect("write custom fast index");
let paths = TwoTierIndexPaths::new(&fast_path).with_fast_ann(&fast_ann_path);
let config = TwoTierConfig {
hnsw_threshold: 1,
..TwoTierConfig::default()
};
let first = TwoTierIndex::open_with_paths(&paths, config.clone())
.expect("build custom ANN sidecar");
assert!(first.has_fast_ann());
load_native_ann_sidecar(&fast_ann_path, first.fast_tier());
drop(first);
let lock_path =
crate::hnsw::hnsw_save_lock_artifact_path(&fast_ann_path).expect("lock path");
let retained_lock = lock_path.with_extension("lock.retained-for-native-reopen");
fs::rename(&lock_path, &retained_lock).expect("retain lock under a non-active name");
assert!(!lock_path.exists());
assert!(retained_lock.exists());
let reopened =
TwoTierIndex::open_with_paths(&paths, config).expect("native read-only-style reopen");
assert!(reopened.has_fast_ann());
assert!(
!lock_path.exists(),
"a native-valid reopen must not require or recreate a writable save lock"
);
assert!(retained_lock.exists());
}
#[cfg(feature = "ann")]
#[test]
fn custom_ann_save_creates_a_missing_parent_and_reopens_natively() {
let dir = temp_index_dir("explicit-custom-ann-missing-parent");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join("index-fnv1a-4.fsvi");
let fast_ann_path = dir
.join("consumer-owned")
.join("ann")
.join("index-fnv1a-4.hnsw");
write_index_file(
&fast_path,
&[
("doc-a", &[1.0, 0.0, 0.0, 0.0]),
("doc-b", &[0.0, 1.0, 0.0, 0.0]),
],
)
.expect("write custom fast index");
assert!(!fast_ann_path.parent().expect("ANN parent").exists());
let paths = TwoTierIndexPaths::new(&fast_path).with_fast_ann(&fast_ann_path);
let config = TwoTierConfig {
hnsw_threshold: 1,
..TwoTierConfig::default()
};
let first = TwoTierIndex::open_with_paths(&paths, config.clone())
.expect("build ANN below a newly created custom parent");
assert!(first.has_fast_ann());
load_native_ann_sidecar(&fast_ann_path, first.fast_tier());
drop(first);
let reopened =
TwoTierIndex::open_with_paths(&paths, config).expect("native custom ANN reopen");
assert!(reopened.has_fast_ann());
load_native_ann_sidecar(&fast_ann_path, reopened.fast_tier());
assert!(!dir.join(VECTOR_ANN_FAST_FILENAME).exists());
}
#[cfg(feature = "ann")]
#[test]
fn custom_fast_and_quality_ann_paths_reopen_through_native_sidecars() {
let dir = temp_index_dir("explicit-custom-two-tier-ann");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join("index-fnv1a-4.fsvi");
let quality_path = dir.join("index-minilm-4.fsvi");
let fast_ann_path = dir.join("index-fnv1a-4.hnsw");
let quality_ann_path = dir.join("index-minilm-4.hnsw");
let rows = [
("doc-a", &[1.0, 0.0, 0.0, 0.0][..]),
("doc-b", &[0.0, 1.0, 0.0, 0.0][..]),
("doc-c", &[0.0, 0.0, 1.0, 0.0][..]),
];
write_index_file(&fast_path, &rows).expect("write custom fast index");
write_index_file(&quality_path, &rows).expect("write custom quality index");
let paths = TwoTierIndexPaths::new(&fast_path)
.with_quality_index(&quality_path)
.with_fast_ann(&fast_ann_path)
.with_quality_ann(&quality_ann_path);
let config = TwoTierConfig {
hnsw_threshold: 1,
..TwoTierConfig::default()
};
let first = TwoTierIndex::open_with_paths(&paths, config.clone())
.expect("build both custom ANN tiers");
assert!(first.has_fast_ann());
assert!(first.has_quality_ann());
load_native_ann_sidecar(&fast_ann_path, first.fast_tier());
load_native_ann_sidecar(
&quality_ann_path,
first.quality_tier().expect("quality index"),
);
let fast_metadata = fs::read(&fast_ann_path).expect("read fast ANN metadata");
let quality_metadata = fs::read(&quality_ann_path).expect("read quality ANN metadata");
drop(first);
let reopened =
TwoTierIndex::open_with_paths(&paths, config).expect("reopen both custom ANN tiers");
assert!(reopened.has_fast_ann());
assert!(reopened.has_quality_ann());
load_native_ann_sidecar(&fast_ann_path, reopened.fast_tier());
load_native_ann_sidecar(
&quality_ann_path,
reopened.quality_tier().expect("quality index"),
);
assert_eq!(
fs::read(&fast_ann_path).expect("reread fast ANN metadata"),
fast_metadata,
"native reopen must not republish fast ANN metadata"
);
assert_eq!(
fs::read(&quality_ann_path).expect("reread quality ANN metadata"),
quality_metadata,
"native reopen must not republish quality ANN metadata"
);
assert!(!dir.join(VECTOR_ANN_FAST_FILENAME).exists());
assert!(!dir.join(VECTOR_ANN_QUALITY_FILENAME).exists());
}
#[cfg(feature = "ann")]
#[test]
fn custom_quality_only_ann_path_does_not_enable_or_create_fast_ann() {
let dir = temp_index_dir("explicit-custom-quality-only-ann");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join("index-fnv1a-4.fsvi");
let quality_path = dir.join("index-minilm-4.fsvi");
let quality_ann_path = dir.join("index-minilm-4.hnsw");
let rows = [
("doc-a", &[1.0, 0.0, 0.0, 0.0][..]),
("doc-b", &[0.0, 1.0, 0.0, 0.0][..]),
];
write_index_file(&fast_path, &rows).expect("write custom fast index");
write_index_file(&quality_path, &rows).expect("write custom quality index");
let paths = TwoTierIndexPaths::new(&fast_path)
.with_quality_index(&quality_path)
.with_quality_ann(&quality_ann_path);
let config = TwoTierConfig {
hnsw_threshold: 1,
..TwoTierConfig::default()
};
let first = TwoTierIndex::open_with_paths(&paths, config.clone())
.expect("build only the custom quality ANN tier");
assert!(!first.has_fast_ann());
assert!(first.has_quality_ann());
load_native_ann_sidecar(
&quality_ann_path,
first.quality_tier().expect("quality index"),
);
drop(first);
let reopened =
TwoTierIndex::open_with_paths(&paths, config).expect("reopen quality-only custom ANN");
assert!(!reopened.has_fast_ann());
assert!(reopened.has_quality_ann());
load_native_ann_sidecar(
&quality_ann_path,
reopened.quality_tier().expect("quality index"),
);
assert!(!dir.join("index-fnv1a-4.hnsw").exists());
assert!(!dir.join(VECTOR_ANN_FAST_FILENAME).exists());
assert!(!dir.join(VECTOR_ANN_QUALITY_FILENAME).exists());
}
#[cfg(feature = "ann")]
#[test]
fn missing_ann_role_aliases_follow_the_mounted_filesystem_not_the_os_name() {
let dir = temp_index_dir("ann-volume-collation");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join("fast.fsvi");
let quality_path = dir.join("quality.fsvi");
write_index_file(&fast_path, &[("doc-a", &[1.0, 0.0])]).expect("write fast index");
write_index_file(&quality_path, &[("doc-a", &[1.0, 0.0])]).expect("write quality index");
for (label, probe_name, alternate_probe_name, left_name, right_name) in [
(
"ascii",
"ascii-Probe",
"ASCII-pROBE",
"shared-ascii.hnsw",
"SHARED-ASCII.HNSW",
),
(
"unicode-full-case-fold",
"unicode-Straße",
"unicode-STRASSE",
"shared-Straße.hnsw",
"shared-STRASSE.hnsw",
),
(
"unicode-one-to-one-case",
"unicode-É-probe",
"unicode-é-probe",
"shared-É.hnsw",
"shared-é.hnsw",
),
(
"unicode-normalization",
"unicode-é-normalization-probe",
"unicode-e\u{301}-normalization-probe",
"shared-é-normalization.hnsw",
"shared-e\u{301}-normalization.hnsw",
),
] {
let probe = dir.join(probe_name);
fs::write(&probe, b"filesystem collation probe").expect("write collation probe");
let alternate_probe = dir.join(alternate_probe_name);
let volume_aliases = crate::file_identity::is_same_file(&probe, &alternate_probe)
.unwrap_or_else(|error| {
assert_eq!(
error.kind(),
std::io::ErrorKind::NotFound,
"unexpected collation probe error"
);
false
});
eprintln!(
"bd-07os filesystem-alias probe: label={label} primary={} alternate={} \
aliases={volume_aliases}",
probe.display(),
alternate_probe.display()
);
let paths = TwoTierIndexPaths::new(&fast_path)
.with_quality_index(&quality_path)
.with_fast_ann(dir.join(left_name))
.with_quality_ann(dir.join(right_name));
validate_index_paths(&paths).expect("missing leaves are initially distinct");
let validation = validate_ann_save_lock_identities(&paths, true, true);
if volume_aliases {
assert!(
matches!(
validation,
Err(SearchError::InvalidConfig { ref field, .. })
if field == "index_paths"
),
"mounted filesystem must reject {label} ANN aliases: {validation:?}"
);
} else {
validation.unwrap_or_else(|error| {
panic!(
"mounted filesystem must preserve distinct {label} ANN roles: \
{error:?}"
)
});
}
}
}
#[cfg(feature = "ann")]
#[test]
fn ann_persistence_revalidation_rejects_new_save_lock_alias() {
let dir = temp_index_dir("ann-save-lock-alias-race");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join("index-fast.fsvi");
let missing_quality_path = dir.join("quality-will-alias-lock.fsvi");
let ann_path = dir.join("index-fast.hnsw");
write_index_file(&fast_path, &[("doc-a", &[1.0, 0.0])]).expect("write fast index");
let paths = TwoTierIndexPaths::new(&fast_path)
.with_quality_index(&missing_quality_path)
.with_fast_ann(&ann_path);
validate_ann_persistence_paths(&paths, true, false)
.expect("initial roles and materialized save lock are distinct");
let lock_path =
crate::hnsw::hnsw_save_lock_artifact_path(&ann_path).expect("derive save-lock path");
fs::hard_link(&lock_path, &missing_quality_path)
.expect("inject a configured-role alias to the materialized save lock");
let error = validate_ann_persistence_paths(&paths, true, false)
.expect_err("complete immediate pre-save validation must reject the new lock alias");
assert!(
matches!(
error,
SearchError::InvalidConfig { ref field, .. } if field == "index_paths"
),
"unexpected save-lock alias error: {error:?}"
);
assert!(
crate::file_identity::is_same_file(&lock_path, &missing_quality_path)
.expect("compare injected save-lock alias")
);
}
#[cfg(all(feature = "ann", unix))]
#[test]
fn fresh_ann_save_revalidates_paths_after_an_alias_race() {
use std::cell::Cell;
let dir = temp_index_dir("ann-fresh-save-alias-race");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join("index-fnv1a-4.fsvi");
let ann_path = dir.join("index-fnv1a-4.hnsw");
write_index_file(
&fast_path,
&[
("doc-a", &[1.0, 0.0, 0.0, 0.0]),
("doc-b", &[0.0, 1.0, 0.0, 0.0]),
],
)
.expect("write custom fast index");
let original = fs::read(&fast_path).expect("read original FSVI");
let vector_index = VectorIndex::open(&fast_path).expect("open custom fast index");
let paths = TwoTierIndexPaths::new(&fast_path).with_fast_ann(&ann_path);
validate_index_paths(&paths).expect("paths start distinct");
let save_called = Cell::new(false);
let config = TwoTierConfig {
hnsw_threshold: 1,
..TwoTierConfig::default()
};
let ann = maybe_load_or_build_ann_with_save(
&vector_index,
&ann_path,
1,
&config,
"fast",
|| {
fs::hard_link(&fast_path, &ann_path).expect("inject hardlink alias before save");
validate_ann_persistence_paths(&paths, true, false)
},
|_, _| {
save_called.set(true);
Ok(())
},
)
.expect("keep freshly built ANN in memory after rejected persistence");
assert!(
!save_called.get(),
"save must not run after revalidation fails"
);
assert_eq!(ann.len(), 2);
assert!(
crate::file_identity::is_same_file(&fast_path, &ann_path).expect("compare aliases")
);
assert_eq!(fs::read(&fast_path).expect("read preserved FSVI"), original);
}
#[cfg(all(feature = "ann", unix))]
#[test]
fn rebuilt_ann_resave_revalidates_every_role_after_an_alias_race() {
use std::cell::Cell;
let dir = temp_index_dir("ann-rebuilt-save-alias-race");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join("index-fnv1a-4.fsvi");
let quality_path = dir.join("index-minilm-4.fsvi");
let fast_ann_path = dir.join("index-fnv1a-4.hnsw");
let quality_ann_path = dir.join("index-minilm-4.hnsw");
let rows = [
("doc-a", &[1.0, 0.0, 0.0, 0.0][..]),
("doc-b", &[0.0, 1.0, 0.0, 0.0][..]),
];
write_index_file(&fast_path, &rows).expect("write custom fast index");
write_index_file(&quality_path, &rows).expect("write custom quality index");
let paths = TwoTierIndexPaths::new(&fast_path)
.with_quality_index(&quality_path)
.with_fast_ann(&fast_ann_path)
.with_quality_ann(&quality_ann_path);
let config = TwoTierConfig {
hnsw_threshold: 1,
..TwoTierConfig::default()
};
let seeded = TwoTierIndex::open_with_paths(
&TwoTierIndexPaths::new(&fast_path).with_fast_ann(&fast_ann_path),
config.clone(),
)
.expect("seed fast ANN sidecar");
assert!(seeded.has_fast_ann());
drop(seeded);
let mut metadata: serde_json::Value =
serde_json::from_slice(&fs::read(&fast_ann_path).expect("read ANN metadata"))
.expect("parse ANN metadata");
let object = metadata.as_object_mut().expect("ANN metadata object");
object.remove("format_version");
object.remove("sidecar_generation");
object.remove("sidecar_basename");
fs::write(
&fast_ann_path,
serde_json::to_vec(&metadata).expect("serialize legacy metadata"),
)
.expect("write legacy ANN metadata");
let vector_index = VectorIndex::open(&fast_path).expect("reopen custom fast index");
validate_index_paths(&paths).expect("paths start distinct");
let save_called = Cell::new(false);
let quality_original = fs::read(&quality_path).expect("read original quality FSVI");
let ann = maybe_load_or_build_ann_with_save(
&vector_index,
&fast_ann_path,
1,
&config,
"fast",
|| {
fs::hard_link(&quality_path, &quality_ann_path)
.expect("inject quality-role hardlink alias before rebuilt resave");
validate_ann_persistence_paths(&paths, true, true)
},
|_, _| {
save_called.set(true);
Ok(())
},
)
.expect("keep rebuilt ANN in memory after rejected resave");
assert!(
!save_called.get(),
"resave must not run after revalidation fails"
);
assert_eq!(ann.len(), 2);
assert!(
crate::file_identity::is_same_file(&quality_path, &quality_ann_path)
.expect("compare aliases")
);
assert_eq!(
fs::read(&quality_path).expect("read preserved quality FSVI"),
quality_original
);
}
#[cfg(feature = "ann")]
#[test]
fn ann_path_equal_to_fsvi_is_rejected_before_any_overwrite() {
let dir = temp_index_dir("ann-direct-alias");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join("index-fnv1a-4.fsvi");
write_index_file(&fast_path, &[("doc-a", &[1.0, 0.0, 0.0, 0.0])])
.expect("write custom fast index");
let original = fs::read(&fast_path).expect("read original FSVI");
let paths = TwoTierIndexPaths::new(&fast_path).with_fast_ann(&fast_path);
let config = TwoTierConfig {
hnsw_threshold: 1,
..TwoTierConfig::default()
};
let error =
TwoTierIndex::open_with_paths(&paths, config).expect_err("alias must be rejected");
assert!(matches!(error, SearchError::InvalidConfig { .. }));
assert_eq!(fs::read(&fast_path).expect("read preserved FSVI"), original);
}
#[cfg(all(feature = "ann", unix))]
#[test]
fn ann_hardlink_alias_is_rejected_before_any_overwrite() {
let dir = temp_index_dir("ann-hardlink-alias");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join("index-fnv1a-4.fsvi");
let ann_path = dir.join("index-fnv1a-4.hnsw");
write_index_file(&fast_path, &[("doc-a", &[1.0, 0.0, 0.0, 0.0])])
.expect("write custom fast index");
fs::hard_link(&fast_path, &ann_path).expect("create hardlink alias");
let original = fs::read(&fast_path).expect("read original FSVI");
let paths = TwoTierIndexPaths::new(&fast_path).with_fast_ann(&ann_path);
let config = TwoTierConfig {
hnsw_threshold: 1,
..TwoTierConfig::default()
};
let error =
TwoTierIndex::open_with_paths(&paths, config).expect_err("alias must be rejected");
assert!(matches!(error, SearchError::InvalidConfig { .. }));
assert_eq!(fs::read(&fast_path).expect("read preserved FSVI"), original);
}
#[cfg(all(feature = "ann", unix))]
#[test]
fn ann_symlink_alias_is_rejected_before_any_overwrite() {
use std::os::unix::fs::symlink;
let dir = temp_index_dir("ann-symlink-alias");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join("index-fnv1a-4.fsvi");
let ann_path = dir.join("index-fnv1a-4.hnsw");
write_index_file(&fast_path, &[("doc-a", &[1.0, 0.0, 0.0, 0.0])])
.expect("write custom fast index");
symlink(&fast_path, &ann_path).expect("create symlink alias");
let original = fs::read(&fast_path).expect("read original FSVI");
let paths = TwoTierIndexPaths::new(&fast_path).with_fast_ann(&ann_path);
let config = TwoTierConfig {
hnsw_threshold: 1,
..TwoTierConfig::default()
};
let error =
TwoTierIndex::open_with_paths(&paths, config).expect_err("alias must be rejected");
assert!(matches!(error, SearchError::InvalidConfig { .. }));
assert_eq!(fs::read(&fast_path).expect("read preserved FSVI"), original);
}
#[cfg(all(feature = "ann", unix))]
#[test]
fn ann_final_symlink_to_distinct_target_is_rejected_before_load_or_save() {
use std::os::unix::fs::symlink;
let dir = temp_index_dir("ann-distinct-final-symlink");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join("index-fnv1a-4.fsvi");
let ann_target = dir.join("distinct-target.hnsw");
let ann_path = dir.join("index-fnv1a-4.hnsw");
write_index_file(&fast_path, &[("doc-a", &[1.0, 0.0, 0.0, 0.0])])
.expect("write custom fast index");
fs::write(&ann_target, b"preserve distinct ANN target").expect("write distinct ANN target");
let original_target = fs::read(&ann_target).expect("read original ANN target");
symlink(&ann_target, &ann_path).expect("create distinct-target ANN symlink");
let paths = TwoTierIndexPaths::new(&fast_path).with_fast_ann(&ann_path);
let config = TwoTierConfig {
hnsw_threshold: 1,
..TwoTierConfig::default()
};
let error = TwoTierIndex::open_with_paths(&paths, config)
.expect_err("a distinct-target ANN final symlink must be rejected");
assert!(
matches!(
error,
SearchError::InvalidConfig { ref field, .. } if field == "index_paths"
),
"unexpected final-symlink error: {error:?}"
);
assert_eq!(
fs::read(&ann_target).expect("read preserved ANN target"),
original_target
);
}
#[cfg(all(feature = "ann", unix))]
#[test]
fn dangling_ann_final_symlink_is_rejected_before_parent_or_lock_creation() {
use std::os::unix::fs::symlink;
let dir = temp_index_dir("ann-dangling-final-symlink");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join("index-fnv1a-4.fsvi");
let missing_target = dir.join("missing-target.hnsw");
let ann_path = dir.join("index-fnv1a-4.hnsw");
write_index_file(&fast_path, &[("doc-a", &[1.0, 0.0, 0.0, 0.0])])
.expect("write custom fast index");
symlink(&missing_target, &ann_path).expect("create dangling ANN symlink");
let lock_path =
crate::hnsw::hnsw_save_lock_artifact_path(&ann_path).expect("derive save-lock path");
let paths = TwoTierIndexPaths::new(&fast_path).with_fast_ann(&ann_path);
let config = TwoTierConfig {
hnsw_threshold: 1,
..TwoTierConfig::default()
};
let error = TwoTierIndex::open_with_paths(&paths, config)
.expect_err("a dangling ANN final symlink must be rejected");
assert!(
matches!(
error,
SearchError::InvalidConfig { ref field, .. } if field == "index_paths"
),
"unexpected dangling-symlink error: {error:?}"
);
assert!(!missing_target.exists());
assert!(!lock_path.exists());
assert!(fs::symlink_metadata(&ann_path).is_ok());
}
#[cfg(all(feature = "ann", unix))]
#[test]
fn ann_symlinked_ancestor_with_parent_component_cannot_alias_fsvi() {
use std::os::unix::fs::symlink;
let dir = temp_index_dir("ann-symlink-ancestor-parent");
let artifact_dir = dir.join("artifacts");
let nested_dir = artifact_dir.join("nested");
let indirection_dir = dir.join("indirection");
fs::create_dir_all(&nested_dir).expect("create artifact directories");
fs::create_dir_all(&indirection_dir).expect("create indirection directory");
let fast_path = artifact_dir.join("index-fnv1a-4.fsvi");
write_index_file(&fast_path, &[("doc-a", &[1.0, 0.0, 0.0, 0.0])])
.expect("write custom fast index");
let original = fs::read(&fast_path).expect("read original FSVI");
let ancestor_link = indirection_dir.join("link");
symlink(&nested_dir, &ancestor_link).expect("create symlinked ancestor");
let ann_path = ancestor_link.join("..").join("index-fnv1a-4.fsvi");
assert_eq!(
fs::canonicalize(&ann_path).expect("resolve OS path semantics"),
fs::canonicalize(&fast_path).expect("resolve fast path")
);
let paths = TwoTierIndexPaths::new(&fast_path).with_fast_ann(&ann_path);
let config = TwoTierConfig {
hnsw_threshold: 1,
..TwoTierConfig::default()
};
let error =
TwoTierIndex::open_with_paths(&paths, config).expect_err("alias must be rejected");
assert!(matches!(error, SearchError::InvalidConfig { .. }));
assert_eq!(fs::read(&fast_path).expect("read preserved FSVI"), original);
}
#[cfg(all(feature = "ann", unix))]
#[test]
fn missing_ann_leaves_cannot_alias_through_a_symlinked_ancestor_and_parent_component() {
use std::os::unix::fs::symlink;
let dir = temp_index_dir("ann-missing-symlink-ancestor-parent");
let artifact_dir = dir.join("artifacts");
let nested_dir = artifact_dir.join("nested");
let indirection_dir = dir.join("indirection");
fs::create_dir_all(&nested_dir).expect("create artifact directories");
fs::create_dir_all(&indirection_dir).expect("create indirection directory");
let fast_path = artifact_dir.join("fast.fsvi");
let quality_path = artifact_dir.join("quality.fsvi");
write_index_file(&fast_path, &[("doc-a", &[1.0, 0.0])]).expect("write fast index");
write_index_file(&quality_path, &[("doc-a", &[1.0, 0.0])]).expect("write quality index");
let ancestor_link = indirection_dir.join("link");
symlink(&nested_dir, &ancestor_link).expect("create symlinked ancestor");
let fast_ann_path = artifact_dir.join("shared.hnsw");
let quality_ann_path = ancestor_link.join("..").join("shared.hnsw");
assert!(!fast_ann_path.exists());
assert!(!quality_ann_path.exists());
let paths = TwoTierIndexPaths::new(&fast_path)
.with_quality_index(&quality_path)
.with_fast_ann(&fast_ann_path)
.with_quality_ann(&quality_ann_path);
let error = validate_index_paths(&paths)
.expect_err("missing leaves with the same OS-resolved identity must be rejected");
assert!(
matches!(
error,
SearchError::InvalidConfig { ref field, .. } if field == "index_paths"
),
"unexpected missing-leaf alias error: {error:?}"
);
assert!(!fast_ann_path.exists());
assert!(!quality_ann_path.exists());
}
#[test]
fn quality_scores_dimension_mismatch() {
let dir = temp_index_dir("quality-dim");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
let quality_path = dir.join(VECTOR_INDEX_QUALITY_FILENAME);
write_index_file(&fast_path, &[("doc-a", &[1.0, 0.0, 0.0, 0.0])])
.expect("write fast index");
write_index_file(&quality_path, &[("doc-a", &[1.0, 0.0, 0.0, 0.0, 0.0, 0.0])])
.expect("write quality index");
let index = TwoTierIndex::open(&dir, TwoTierConfig::default()).expect("open");
assert!(index.has_quality_index());
let hits = vec![VectorHit {
index: 0,
score: 0.0,
doc_id: "doc-a".into(),
}];
let error = index
.quality_scores_for_hits(&[1.0, 0.0, 0.0, 0.0], &hits)
.unwrap_err();
assert!(
matches!(
error,
SearchError::DimensionMismatch {
expected: 6,
found: 4
}
),
"expected DimensionMismatch, got {error:?}"
);
}
#[test]
fn has_quality_for_index_out_of_bounds_returns_false() {
let dir = temp_index_dir("oob");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
write_index_file(&fast_path, &[("doc-a", &[1.0, 0.0])]).expect("write fast index");
let index = TwoTierIndex::open(&dir, TwoTierConfig::default()).expect("open");
assert!(!index.has_quality_for_index(999));
}
#[test]
fn builder_finish_rejects_empty_fast_records() {
let dir = temp_index_dir("empty-builder");
let builder = TwoTierIndex::create(&dir, TwoTierConfig::default()).expect("builder");
let error = builder.finish().unwrap_err();
assert!(
matches!(error, SearchError::InvalidConfig { ref field, .. } if field == "fast_records"),
"expected InvalidConfig for fast_records, got {error:?}"
);
}
#[test]
fn builder_rejects_inconsistent_quality_dimension() {
let dir = temp_index_dir("bad-quality-dim");
let mut builder = TwoTierIndex::create(&dir, TwoTierConfig::default()).expect("builder");
builder
.add_fast_record("doc-a", &[1.0, 0.0, 0.0])
.expect("fast record");
builder
.add_quality_record("doc-a", &[1.0, 0.0, 0.0, 0.0])
.expect("first quality");
let error = builder
.add_quality_record("doc-b", &[1.0, 0.0])
.unwrap_err();
assert!(
matches!(
error,
SearchError::DimensionMismatch {
expected: 4,
found: 2
}
),
"expected DimensionMismatch, got {error:?}"
);
}
#[test]
fn opens_with_explicit_fast_index() {
let dir = temp_index_dir("explicit-fast");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
write_index_file(
&fast_path,
&[("doc-x", &[0.0, 1.0, 0.0]), ("doc-y", &[0.0, 0.0, 1.0])],
)
.expect("write fast index");
let index = TwoTierIndex::open(&dir, TwoTierConfig::default()).expect("open");
assert_eq!(index.doc_count(), 2);
let ids: Vec<String> = index
.iter_doc_ids()
.collect::<SearchResult<_>>()
.expect("ids");
assert_eq!(ids, vec!["doc-x".to_owned(), "doc-y".to_owned()]);
let hits = index.search_fast(&[0.0, 0.0, 1.0], 1).expect("fast search");
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].doc_id, "doc-y");
}
#[test]
fn config_accessor_returns_construction_config() {
let dir = temp_index_dir("config-acc");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
write_index_file(&fast_path, &[("doc-a", &[1.0, 0.0])]).expect("write fast index");
let config = TwoTierConfig {
hnsw_threshold: 42,
..TwoTierConfig::default()
};
let index = TwoTierIndex::open(&dir, config).expect("open");
assert_eq!(index.config().hnsw_threshold, 42);
}
#[test]
fn quality_index_with_extra_doc_ids_still_opens() {
let dir = temp_index_dir("quality-extra");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
let quality_path = dir.join(VECTOR_INDEX_QUALITY_FILENAME);
write_index_file(&fast_path, &[("doc-a", &[1.0, 0.0])]).expect("write fast");
write_index_file(
&quality_path,
&[
("doc-a", &[1.0, 0.0]),
("doc-z", &[0.0, 1.0]), ],
)
.expect("write quality");
let index = TwoTierIndex::open(&dir, TwoTierConfig::default()).expect("open");
assert!(index.has_quality_index());
assert!(index.has_quality_for_index(0)); assert_eq!(index.doc_count(), 1); }
#[test]
fn builder_add_record_with_none_quality_skips_quality_tier() {
let dir = temp_index_dir("add-record-no-quality");
let mut builder = TwoTierIndex::create(&dir, TwoTierConfig::default()).expect("builder");
builder
.add_record("doc-a", &[1.0, 0.0], None)
.expect("add doc-a");
builder
.add_record("doc-b", &[0.0, 1.0], None)
.expect("add doc-b");
let index = builder.finish().expect("finish");
assert_eq!(index.doc_count(), 2);
assert!(!index.has_quality_index());
}
#[cfg(feature = "ann")]
#[test]
fn ann_sidecar_rebuilds_when_config_changes() {
let dir = temp_index_dir("ann-rebuild-config");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
write_index_file(
&fast_path,
&[
("doc-a", &[1.0, 0.0, 0.0, 0.0]),
("doc-b", &[0.0, 1.0, 0.0, 0.0]),
("doc-c", &[0.0, 0.0, 1.0, 0.0]),
],
)
.expect("write fast index");
let initial = TwoTierConfig {
hnsw_threshold: 1,
hnsw_m: 8,
hnsw_ef_construction: 64,
hnsw_ef_search: 16,
..TwoTierConfig::default()
};
let first_open = TwoTierIndex::open(&dir, initial).expect("open with initial ann config");
assert!(first_open.has_fast_ann());
let ann_path = dir.join(VECTOR_ANN_FAST_FILENAME);
let before = load_native_ann_sidecar(&ann_path, first_open.fast_tier());
let before_config = before.config();
assert_eq!(before_config.m, 8);
assert_eq!(before_config.ef_construction, 64);
assert_eq!(before_config.ef_search, 16);
let updated = TwoTierConfig {
hnsw_threshold: 1,
hnsw_m: 24,
hnsw_ef_construction: 96,
hnsw_ef_search: 48,
..TwoTierConfig::default()
};
let second_open = TwoTierIndex::open(&dir, updated).expect("open with updated ann config");
assert!(second_open.has_fast_ann());
let after = load_native_ann_sidecar(&ann_path, second_open.fast_tier());
let after_config = after.config();
assert_eq!(after_config.m, 24);
assert_eq!(after_config.ef_construction, 96);
assert_eq!(after_config.ef_search, 48);
}
#[cfg(feature = "ann")]
#[test]
fn ann_sidecar_rebuilds_when_vectors_change_with_same_doc_ids() {
let dir = temp_index_dir("ann-rebuild-vectors");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
write_index_file(
&fast_path,
&[("doc-a", &[1.0, 0.0, 0.0]), ("doc-b", &[0.0, 1.0, 0.0])],
)
.expect("write initial fast index");
let config = TwoTierConfig {
hnsw_threshold: 1,
hnsw_ef_search: 64,
..TwoTierConfig::default()
};
let first = TwoTierIndex::open(&dir, config.clone()).expect("open initial");
assert!(first.has_fast_ann());
let before = first
.search_fast(&[1.0, 0.0, 0.0], 1)
.expect("search before");
assert_eq!(before[0].doc_id, "doc-a");
write_index_file(
&fast_path,
&[("doc-a", &[0.0, 1.0, 0.0]), ("doc-b", &[1.0, 0.0, 0.0])],
)
.expect("rewrite fast index");
let reopened = TwoTierIndex::open(&dir, config).expect("reopen");
assert!(reopened.has_fast_ann());
let ann_path = dir.join(VECTOR_ANN_FAST_FILENAME);
load_native_ann_sidecar(&ann_path, reopened.fast_tier());
let after = reopened
.search_fast(&[1.0, 0.0, 0.0], 1)
.expect("search after");
assert_eq!(
after[0].doc_id, "doc-b",
"ANN sidecar should rebuild when vector content changes"
);
}
#[cfg(feature = "ann")]
#[test]
fn legacy_ann_fallback_is_persisted_for_native_second_load() {
let dir = temp_index_dir("ann-self-heal-legacy");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
write_index_file(
&fast_path,
&[("doc-a", &[1.0, 0.0, 0.0]), ("doc-b", &[0.0, 1.0, 0.0])],
)
.expect("write fast index");
let config = TwoTierConfig {
hnsw_threshold: 1,
..TwoTierConfig::default()
};
let initial = TwoTierIndex::open(&dir, config.clone()).expect("seed ANN sidecar");
assert!(initial.has_fast_ann());
let ann_path = dir.join(VECTOR_ANN_FAST_FILENAME);
let mut metadata: serde_json::Value =
serde_json::from_slice(&fs::read(&ann_path).expect("read ANN metadata"))
.expect("parse ANN metadata");
let object = metadata.as_object_mut().expect("ANN metadata object");
object.remove("format_version");
object.remove("sidecar_generation");
object.remove("sidecar_basename");
fs::write(
&ann_path,
serde_json::to_vec(&metadata).expect("serialize legacy metadata"),
)
.expect("write legacy metadata");
let (_, disposition) = HnswIndex::load_with_disposition(&ann_path, initial.fast_tier())
.expect("legacy fallback rebuild");
assert_eq!(disposition, HnswLoadDisposition::Rebuilt);
let reopened = TwoTierIndex::open(&dir, config).expect("self-heal legacy ANN sidecar");
assert!(reopened.has_fast_ann());
load_native_ann_sidecar(&ann_path, reopened.fast_tier());
}
#[cfg(feature = "ann")]
#[test]
fn degraded_ann_fallback_is_persisted_for_native_second_load() {
let dir = temp_index_dir("ann-self-heal-degraded");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
write_index_file(
&fast_path,
&[("doc-a", &[1.0, 0.0, 0.0]), ("doc-b", &[0.0, 1.0, 0.0])],
)
.expect("write fast index");
let config = TwoTierConfig {
hnsw_threshold: 1,
..TwoTierConfig::default()
};
let initial = TwoTierIndex::open(&dir, config.clone()).expect("seed ANN sidecar");
assert!(initial.has_fast_ann());
let ann_path = dir.join(VECTOR_ANN_FAST_FILENAME);
let missing_generation = ".missing-hnsw-generation";
let mut metadata: serde_json::Value =
serde_json::from_slice(&fs::read(&ann_path).expect("read ANN metadata"))
.expect("parse ANN metadata");
metadata["sidecar_generation"] = missing_generation.into();
fs::write(
&ann_path,
serde_json::to_vec(&metadata).expect("serialize degraded metadata"),
)
.expect("write degraded metadata");
let (_, disposition) = HnswIndex::load_with_disposition(&ann_path, initial.fast_tier())
.expect("degraded fallback rebuild");
assert_eq!(disposition, HnswLoadDisposition::Rebuilt);
let reopened = TwoTierIndex::open(&dir, config).expect("self-heal degraded ANN sidecar");
assert!(reopened.has_fast_ann());
load_native_ann_sidecar(&ann_path, reopened.fast_tier());
let repaired: serde_json::Value =
serde_json::from_slice(&fs::read(&ann_path).expect("read repaired metadata"))
.expect("parse repaired metadata");
assert_ne!(
repaired["sidecar_generation"].as_str(),
Some(missing_generation),
"self-heal must publish metadata naming the new native generation"
);
}
#[cfg(feature = "ann")]
#[test]
fn ann_persistence_failure_keeps_rebuilt_index_in_memory() {
fn reject_persistence(_: &HnswIndex, _: &Path) -> SearchResult<()> {
Err(SearchError::Io(std::io::Error::other(
"injected ANN persistence failure",
)))
}
let dir = temp_index_dir("ann-persist-failure");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
write_index_file(
&fast_path,
&[("doc-a", &[1.0, 0.0, 0.0]), ("doc-b", &[0.0, 1.0, 0.0])],
)
.expect("write fast index");
let ann_path = dir.join(VECTOR_ANN_FAST_FILENAME);
let config = TwoTierConfig {
hnsw_threshold: 1,
hnsw_ef_search: 64,
..TwoTierConfig::default()
};
let initial = TwoTierIndex::open(&dir, config.clone()).expect("seed ANN sidecar");
assert!(initial.has_fast_ann());
let mut metadata: serde_json::Value =
serde_json::from_slice(&fs::read(&ann_path).expect("read ANN metadata"))
.expect("parse ANN metadata");
let object = metadata.as_object_mut().expect("ANN metadata object");
object.remove("format_version");
object.remove("sidecar_generation");
object.remove("sidecar_basename");
fs::write(
&ann_path,
serde_json::to_vec(&metadata).expect("serialize legacy metadata"),
)
.expect("write legacy metadata");
let rebuilt = maybe_load_or_build_ann_with_save(
initial.fast_tier(),
&ann_path,
1,
&config,
"fast",
|| Ok(()),
reject_persistence,
)
.expect("retain rebuilt ANN after persistence failure");
let (_, disposition) = HnswIndex::load_with_disposition(&ann_path, initial.fast_tier())
.expect("legacy sidecar still rebuilds after failed persistence");
assert_eq!(disposition, HnswLoadDisposition::Rebuilt);
let hits = rebuilt
.knn_search(&[1.0, 0.0, 0.0], 2, config.hnsw_ef_search)
.expect("search in-memory rebuilt ANN");
assert_eq!(
hits.len(),
2,
"a retained rebuilt ANN must keep every live point reachable"
);
let mut hit_ids: Vec<&str> = hits.iter().map(|hit| hit.doc_id.as_str()).collect();
hit_ids.sort_unstable();
assert_eq!(hit_ids, ["doc-a", "doc-b"]);
}
#[cfg(feature = "ann")]
#[test]
fn ann_search_excludes_tombstoned_docs() {
let dir = temp_index_dir("ann-tombstones");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
write_index_file(
&fast_path,
&[
("doc-a", &[1.0, 0.0, 0.0]),
("doc-b", &[0.0, 1.0, 0.0]),
("doc-c", &[0.0, 0.0, 1.0]),
],
)
.expect("write fast index");
let mut fast_index = VectorIndex::open(&fast_path).expect("open fast index");
let deleted = fast_index
.soft_delete("doc-b")
.expect("soft delete should succeed");
assert!(deleted);
drop(fast_index);
let config = TwoTierConfig {
hnsw_threshold: 1,
hnsw_ef_search: 64,
..TwoTierConfig::default()
};
let index = TwoTierIndex::open(&dir, config).expect("open with ann");
assert!(index.has_fast_ann());
let hits = index.search_fast(&[0.0, 1.0, 0.0], 10).expect("search");
assert!(
!hits.iter().any(|hit| hit.doc_id == "doc-b"),
"tombstoned document should not be returned by ANN search"
);
}
#[cfg(feature = "ann")]
#[test]
fn ann_tombstone_filter_uses_doc_id_not_hnsw_position() {
let dir = temp_index_dir("ann-tombstone-docid");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
write_index_file(
&fast_path,
&[
("doc-a", &[1.0, 0.0, 0.0, 0.0]),
("doc-b", &[0.0, 1.0, 0.0, 0.0]),
("doc-c", &[0.0, 0.0, 1.0, 0.0]),
("doc-d", &[0.0, 0.0, 0.0, 1.0]),
],
)
.expect("write fast index");
let mut fast_index = VectorIndex::open(&fast_path).expect("open for delete");
assert!(fast_index.soft_delete("doc-b").expect("soft_delete"));
drop(fast_index);
let config = TwoTierConfig {
hnsw_threshold: 1,
hnsw_ef_search: 64,
..TwoTierConfig::default()
};
let index = TwoTierIndex::open(&dir, config).expect("open with ann");
assert!(index.has_fast_ann());
let hits = index
.search_fast(&[0.25, 0.25, 0.25, 0.25], 10)
.expect("search");
let hit_ids: Vec<&str> = hits.iter().map(|h| h.doc_id.as_str()).collect();
assert!(
hit_ids.contains(&"doc-a"),
"doc-a should be returned, got: {hit_ids:?}"
);
assert!(
hit_ids.contains(&"doc-c"),
"doc-c should be returned (bd-2grj regression), got: {hit_ids:?}"
);
assert!(
hit_ids.contains(&"doc-d"),
"doc-d should be returned (bd-2grj regression), got: {hit_ids:?}"
);
assert!(
!hit_ids.contains(&"doc-b"),
"tombstoned doc-b should NOT be returned, got: {hit_ids:?}"
);
assert_eq!(
hits.len(),
3,
"expected exactly 3 live docs, got: {hit_ids:?}"
);
}
#[test]
fn search_fast_skips_nan_score_wal_entries() {
use crate::wal::WalEntry;
let dir = temp_index_dir("nan-wal-score");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
write_index_file(&fast_path, &[("doc-a", &[1.0, 0.0, 0.0, 0.0])])
.expect("write fast index");
let mut index = TwoTierIndex::open(&dir, TwoTierConfig::default()).expect("open");
index
.fast_tier_mut_for_test()
.expect("path-opened tier")
.wal_entries
.push(WalEntry {
doc_id: "doc-nan".into(),
doc_id_hash: crate::fnv1a_hash(b"doc-nan"),
embedding: vec![f32::NAN, 0.0, 0.0, 0.0],
});
index
.fast_tier_mut_for_test()
.expect("path-opened tier")
.wal_entries
.push(WalEntry {
doc_id: "doc-wal-ok".into(),
doc_id_hash: crate::fnv1a_hash(b"doc-wal-ok"),
embedding: vec![0.0, 1.0, 0.0, 0.0],
});
let hits = index
.search_fast(&[1.0, 0.0, 0.0, 0.0], 10)
.expect("search");
let ids: Vec<&str> = hits.iter().map(|h| h.doc_id.as_str()).collect();
assert!(
!ids.contains(&"doc-nan"),
"NaN-scored WAL entry must be excluded, got: {ids:?}"
);
assert!(
ids.contains(&"doc-a"),
"base doc-a should be returned, got: {ids:?}"
);
assert!(
ids.contains(&"doc-wal-ok"),
"valid WAL entry should be returned, got: {ids:?}"
);
for hit in &hits {
assert!(
hit.score.is_finite(),
"hit {} has non-finite score {}",
hit.doc_id,
hit.score
);
}
}
#[test]
fn filename_constants_are_correct() {
assert_eq!(VECTOR_INDEX_FAST_FILENAME, "vector.fast.idx");
assert_eq!(VECTOR_INDEX_QUALITY_FILENAME, "vector.quality.idx");
assert_eq!(VECTOR_INDEX_FALLBACK_FILENAME, "vector.idx");
}
#[test]
fn two_tier_index_implements_debug() {
let dir = temp_index_dir("debug-index");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
write_index_file(&fast_path, &[("doc-a", &[1.0, 0.0])]).expect("write fast");
let index = TwoTierIndex::open(&dir, TwoTierConfig::default()).expect("open");
let debug_str = format!("{index:?}");
assert!(debug_str.contains("TwoTierIndex"));
}
#[test]
fn two_tier_index_builder_implements_debug() {
let dir = temp_index_dir("debug-builder");
let builder = TwoTierIndex::create(&dir, TwoTierConfig::default()).expect("builder");
let debug_str = format!("{builder:?}");
assert!(debug_str.contains("TwoTierIndexBuilder"));
}
#[test]
fn fast_index_preferred_over_fallback_when_both_exist() {
let dir = temp_index_dir("prefer-fast");
fs::create_dir_all(&dir).expect("create temp dir");
let fallback_path = dir.join(VECTOR_INDEX_FALLBACK_FILENAME);
write_index_file(&fallback_path, &[("doc-fallback", &[0.0, 1.0])]).expect("write fallback");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
write_index_file(&fast_path, &[("doc-fast", &[1.0, 0.0])]).expect("write fast");
let index = TwoTierIndex::open(&dir, TwoTierConfig::default()).expect("open");
assert_eq!(index.doc_count(), 1);
let ids: Vec<String> = index
.iter_doc_ids()
.collect::<SearchResult<_>>()
.expect("ids");
assert_eq!(ids, vec!["doc-fast".to_owned()]);
}
#[test]
fn search_fast_returns_sorted_by_score_descending() {
let dir = temp_index_dir("sort-order");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
write_index_file(
&fast_path,
&[
("doc-low", &[0.1, 0.0, 0.0]),
("doc-mid", &[0.5, 0.5, 0.0]),
("doc-high", &[1.0, 0.0, 0.0]),
],
)
.expect("write fast");
let index = TwoTierIndex::open(&dir, TwoTierConfig::default()).expect("open");
let hits = index.search_fast(&[1.0, 0.0, 0.0], 3).expect("search");
assert_eq!(hits.len(), 3);
assert!(hits[0].score >= hits[1].score);
assert!(hits[1].score >= hits[2].score);
assert_eq!(hits[0].doc_id, "doc-high");
}
#[test]
fn search_fast_k_zero_returns_empty() {
let dir = temp_index_dir("k-zero");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
write_index_file(&fast_path, &[("doc-a", &[1.0, 0.0])]).expect("write fast");
let index = TwoTierIndex::open(&dir, TwoTierConfig::default()).expect("open");
let hits = index.search_fast(&[1.0, 0.0], 0).expect("search k=0");
assert!(hits.is_empty());
}
#[test]
fn search_fast_k_larger_than_doc_count_returns_all() {
let dir = temp_index_dir("k-large");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
write_index_file(
&fast_path,
&[("doc-a", &[1.0, 0.0]), ("doc-b", &[0.0, 1.0])],
)
.expect("write fast");
let index = TwoTierIndex::open(&dir, TwoTierConfig::default()).expect("open");
let hits = index.search_fast(&[1.0, 0.0], 100).expect("search k=100");
assert_eq!(hits.len(), 2);
}
#[test]
fn quality_scores_empty_indices_returns_empty() {
let dir = temp_index_dir("empty-indices");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
let quality_path = dir.join(VECTOR_INDEX_QUALITY_FILENAME);
write_index_file(&fast_path, &[("doc-a", &[1.0, 0.0])]).expect("write fast");
write_index_file(&quality_path, &[("doc-a", &[1.0, 0.0])]).expect("write quality");
let index = TwoTierIndex::open(&dir, TwoTierConfig::default()).expect("open");
let scores = index
.quality_scores_for_hits(&[1.0, 0.0], &[])
.expect("empty indices");
assert!(scores.is_empty());
}
#[test]
fn quality_scores_full_coverage() {
let dir = temp_index_dir("full-quality");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
let quality_path = dir.join(VECTOR_INDEX_QUALITY_FILENAME);
write_index_file(
&fast_path,
&[("doc-a", &[1.0, 0.0, 0.0]), ("doc-b", &[0.0, 1.0, 0.0])],
)
.expect("write fast");
write_index_file(
&quality_path,
&[("doc-a", &[0.0, 0.0, 1.0]), ("doc-b", &[0.0, 1.0, 0.0])],
)
.expect("write quality");
let index = TwoTierIndex::open(&dir, TwoTierConfig::default()).expect("open");
assert!(index.has_quality_for_index(0));
assert!(index.has_quality_for_index(1));
let hits = vec![
VectorHit {
index: 0,
score: 0.0,
doc_id: "doc-a".into(),
},
VectorHit {
index: 1,
score: 0.0,
doc_id: "doc-b".into(),
},
];
let scores = index
.quality_scores_for_hits(&[0.0, 1.0, 0.0], &hits)
.expect("quality scores");
assert_eq!(scores.len(), 2);
assert!(scores[0].unwrap().abs() < 1e-6);
assert!((scores[1].unwrap() - 1.0).abs() < 1e-6);
}
#[test]
fn builder_embedder_id_chaining() {
let dir = temp_index_dir("embedder-chain");
let mut builder = TwoTierIndex::create(&dir, TwoTierConfig::default()).expect("builder");
let _same_ref = builder
.set_fast_embedder_id("custom-fast")
.set_quality_embedder_id("custom-quality");
builder
.add_record("doc-a", &[1.0, 0.0], Some(&[0.0, 1.0]))
.expect("add record");
let index = builder.finish().expect("finish");
assert_eq!(index.doc_count(), 1);
}
#[test]
fn builder_finish_retains_declared_typed_identity() {
let dir = temp_index_dir("typed-identity-retention");
let fast_bundle = EmbeddingIdentityBundleV1::explicit_test_model("builder-fast-model", 4);
let quality_bundle =
EmbeddingIdentityBundleV1::explicit_test_model("builder-quality-model", 4);
let mut builder = TwoTierIndex::create(&dir, TwoTierConfig::default()).expect("builder");
builder.set_fast_embedder_id("op-fast-id");
builder.set_quality_embedder_id("op-quality-id");
builder
.set_fast_identity(&fast_bundle)
.expect("declare fast identity");
builder
.set_quality_identity(&quality_bundle)
.expect("declare quality identity");
builder
.add_record("doc-a", &[1.0, 0.0, 0.0, 0.0], Some(&[0.0, 1.0, 0.0, 0.0]))
.expect("add record");
let index = builder.finish().expect("finish");
assert_eq!(
index.fast_space_fingerprint_hex(),
Some(fast_bundle.space.fingerprint().as_str())
);
assert_eq!(
index.quality_space_fingerprint_hex(),
Some(quality_bundle.space.fingerprint().as_str())
);
assert_ne!(
index.fast_space_fingerprint_hex(),
index.quality_space_fingerprint_hex(),
"distinct producing models must stay distinct per tier"
);
assert_eq!(
index
.fast_declared_identity()
.map(EmbeddingIdentityBundleV1::fingerprint),
Some(fast_bundle.fingerprint()),
"the full producing bundle survives finish for the admission law"
);
assert_eq!(
index
.quality_declared_identity()
.map(EmbeddingIdentityBundleV1::fingerprint),
Some(quality_bundle.fingerprint())
);
assert_eq!(index.fast_embedder_id(), "op-fast-id");
assert_eq!(index.quality_embedder_id(), Some("op-quality-id"));
assert_eq!(
index.fast_embedder_revision(),
fast_bundle.space.immutable_revision.as_str()
);
assert_eq!(
index.quality_embedder_revision(),
Some(quality_bundle.space.immutable_revision.as_str())
);
drop(index);
let reopened = TwoTierIndex::open(&dir, TwoTierConfig::default()).expect("reopen");
assert_eq!(reopened.fast_embedder_id(), "op-fast-id");
assert_eq!(
reopened.fast_embedder_revision(),
fast_bundle.space.immutable_revision.as_str(),
"the declared revision must survive persistence in the v1 header"
);
assert_eq!(reopened.fast_space_fingerprint_hex(), None);
assert_eq!(reopened.quality_space_fingerprint_hex(), None);
assert!(reopened.fast_declared_identity().is_none());
assert!(reopened.quality_declared_identity().is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn builder_finish_rejects_identity_not_describing_vectors() -> Result<(), String> {
let dir = temp_index_dir("typed-identity-dim-mismatch");
let wrong_bundle = EmbeddingIdentityBundleV1::explicit_test_model("wrong-dim-model", 16);
let mut builder = TwoTierIndex::create(&dir, TwoTierConfig::default()).expect("builder");
builder
.set_fast_identity(&wrong_bundle)
.expect("bundle itself is coherent; the mismatch is against the records");
builder
.add_record("doc-a", &[1.0, 0.0, 0.0, 0.0], None)
.expect("add record");
let error = builder
.finish()
.expect_err("a non-describing identity must be rejected at finish");
let rendered = format!("{error:?}");
let SearchError::InvalidConfig { field, value, .. } = error else {
return Err(format!("expected InvalidConfig, got {rendered}"));
};
assert_eq!(field, "fast_identity.space.dimension");
assert_eq!(value, "16");
let mut builder = TwoTierIndex::create(&dir, TwoTierConfig::default()).expect("builder");
builder
.set_quality_identity(&wrong_bundle)
.expect("bundle itself is coherent");
builder
.add_record("doc-a", &[1.0, 0.0, 0.0, 0.0], Some(&[0.0, 1.0, 0.0, 0.0]))
.expect("add record");
let error = builder
.finish()
.expect_err("a non-describing quality identity must be rejected at finish");
let rendered = format!("{error:?}");
let SearchError::InvalidConfig { field, value, .. } = error else {
return Err(format!("expected InvalidConfig, got {rendered}"));
};
assert_eq!(field, "quality_identity.space.dimension");
assert_eq!(value, "16");
let _ = std::fs::remove_dir_all(&dir);
Ok(())
}
#[test]
fn builder_quality_identity_without_quality_tier_is_dropped() {
let dir = temp_index_dir("typed-identity-no-quality");
let quality_bundle = EmbeddingIdentityBundleV1::explicit_test_model("unused-quality", 4);
let mut builder = TwoTierIndex::create(&dir, TwoTierConfig::default()).expect("builder");
builder
.set_quality_identity(&quality_bundle)
.expect("declare quality identity");
builder
.add_record("doc-a", &[1.0, 0.0, 0.0, 0.0], None)
.expect("add fast-only record");
let index = builder.finish().expect("finish fast-only build");
assert!(!index.has_quality_index());
assert_eq!(index.quality_space_fingerprint_hex(), None);
assert!(index.quality_declared_identity().is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn builder_without_identity_stays_legacy_unidentified() {
let dir = temp_index_dir("typed-identity-legacy");
let mut builder = TwoTierIndex::create(&dir, TwoTierConfig::default()).expect("builder");
builder
.add_record("doc-a", &[1.0, 0.0], Some(&[0.0, 1.0]))
.expect("add record");
let index = builder.finish().expect("finish");
assert_eq!(index.fast_space_fingerprint_hex(), None);
assert_eq!(index.quality_space_fingerprint_hex(), None);
assert!(index.fast_declared_identity().is_none());
assert!(index.quality_declared_identity().is_none());
assert_eq!(index.fast_embedder_revision(), "");
assert_eq!(index.quality_embedder_revision(), Some(""));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn semantic_vector_with_tier_reports_serving_tier() {
let dir = temp_index_dir("semantic-vector-tier");
let fast_bundle = EmbeddingIdentityBundleV1::explicit_test_model("svt-fast-model", 4);
let quality_bundle = EmbeddingIdentityBundleV1::explicit_test_model("svt-quality-model", 4);
let mut builder = TwoTierIndex::create(&dir, TwoTierConfig::default()).expect("builder");
builder
.set_fast_identity(&fast_bundle)
.expect("declare fast identity");
builder
.set_quality_identity(&quality_bundle)
.expect("declare quality identity");
builder
.add_record("doc-a", &[1.0, 0.0, 0.0, 0.0], Some(&[0.0, 1.0, 0.0, 0.0]))
.expect("add doc-a");
builder
.add_record("doc-b", &[0.0, 0.0, 1.0, 0.0], None)
.expect("add doc-b");
let index = builder.finish().expect("finish");
let (tier, vector) = index
.semantic_vector_with_tier_for_doc_id("doc-a")
.expect("lookup doc-a")
.expect("doc-a exists");
assert_eq!(tier, SemanticVectorTier::Quality);
assert_eq!(
Some(vector.clone()),
index
.semantic_vector_for_doc_id("doc-a")
.expect("legacy lookup doc-a"),
"the provenance twin must return exactly the legacy accessor's vector"
);
assert_eq!(
index.space_fingerprint_hex_for_tier(tier),
Some(quality_bundle.space.fingerprint().as_str()),
"Quality provenance joins to the quality tier's space"
);
let (tier, vector) = index
.semantic_vector_with_tier_for_doc_id("doc-b")
.expect("lookup doc-b")
.expect("doc-b exists");
assert_eq!(tier, SemanticVectorTier::Fast);
assert_eq!(
Some(vector),
index
.semantic_vector_for_doc_id("doc-b")
.expect("legacy lookup doc-b")
);
assert_eq!(
index.space_fingerprint_hex_for_tier(tier),
Some(fast_bundle.space.fingerprint().as_str()),
"Fast provenance joins to the fast tier's space"
);
assert!(
index
.semantic_vector_with_tier_for_doc_id("doc-missing")
.expect("lookup missing")
.is_none()
);
assert!(
index
.semantic_vector_for_doc_id("doc-missing")
.expect("legacy lookup missing")
.is_none()
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn builder_fast_only_no_quality_index_created() {
let dir = temp_index_dir("fast-only-builder");
let mut builder = TwoTierIndex::create(&dir, TwoTierConfig::default()).expect("builder");
builder
.add_fast_record("doc-a", &[1.0, 0.0, 0.0])
.expect("fast a");
builder
.add_fast_record("doc-b", &[0.0, 1.0, 0.0])
.expect("fast b");
let index = builder.finish().expect("finish");
assert_eq!(index.doc_count(), 2);
assert!(!index.has_quality_index());
assert!(!dir.join(VECTOR_INDEX_QUALITY_FILENAME).exists());
}
#[test]
fn builder_add_record_with_quality_creates_both_tiers() {
let dir = temp_index_dir("both-tiers-builder");
let mut builder = TwoTierIndex::create(&dir, TwoTierConfig::default()).expect("builder");
builder
.add_record("doc-a", &[1.0, 0.0], Some(&[0.5, 0.5, 0.0]))
.expect("add doc-a");
builder
.add_record("doc-b", &[0.0, 1.0], Some(&[0.0, 0.5, 0.5]))
.expect("add doc-b");
let index = builder.finish().expect("finish");
assert_eq!(index.doc_count(), 2);
assert!(index.has_quality_index());
assert!(index.has_quality_for_index(0));
assert!(index.has_quality_for_index(1));
}
#[test]
fn builder_preserves_all_doc_ids() {
let dir = temp_index_dir("all-docids");
let mut builder = TwoTierIndex::create(&dir, TwoTierConfig::default()).expect("builder");
let names = ["zebra", "apple", "mango", "banana"];
for name in &names {
builder
.add_fast_record(*name, &[1.0, 0.0])
.expect("add record");
}
let index = builder.finish().expect("finish");
assert_eq!(index.doc_count(), 4);
let ids: Vec<String> = index
.iter_doc_ids()
.collect::<SearchResult<_>>()
.expect("ids");
let mut actual: Vec<&str> = ids.iter().map(String::as_str).collect();
actual.sort_unstable();
let mut expected = names.to_vec();
expected.sort_unstable();
assert_eq!(actual, expected);
}
#[test]
fn fast_index_for_doc_id_empty_string_returns_none() {
let dir = temp_index_dir("empty-docid-lookup");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
write_index_file(&fast_path, &[("doc-a", &[1.0, 0.0])]).expect("write fast");
let index = TwoTierIndex::open(&dir, TwoTierConfig::default()).expect("open");
assert_eq!(index.fast_index_for_doc_id("").unwrap(), None);
}
#[test]
fn has_quality_for_index_boundary_last_valid() {
let dir = temp_index_dir("boundary-last");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
let quality_path = dir.join(VECTOR_INDEX_QUALITY_FILENAME);
write_index_file(
&fast_path,
&[
("doc-a", &[1.0, 0.0]),
("doc-b", &[0.0, 1.0]),
("doc-c", &[0.5, 0.5]),
],
)
.expect("write fast");
write_index_file(&quality_path, &[("doc-c", &[0.5, 0.5])]).expect("write quality");
let index = TwoTierIndex::open(&dir, TwoTierConfig::default()).expect("open");
assert!(!index.has_quality_for_index(0));
assert!(!index.has_quality_for_index(1));
assert!(index.has_quality_for_index(2)); assert!(!index.has_quality_for_index(3)); }
#[test]
fn quality_scores_no_quality_index_ignores_query_dimension() {
let dir = temp_index_dir("no-quality-any-dim");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
write_index_file(&fast_path, &[("doc-a", &[1.0, 0.0])]).expect("write fast");
let index = TwoTierIndex::open(&dir, TwoTierConfig::default()).expect("open");
assert!(!index.has_quality_index());
let hits = vec![VectorHit {
index: 0,
score: 0.0,
doc_id: "doc-a".into(),
}];
let scores = index
.quality_scores_for_hits(&[1.0, 2.0, 3.0, 4.0, 5.0], &hits)
.expect("any dim accepted");
assert_eq!(scores, vec![None]);
}
#[test]
fn search_fast_returns_correct_doc_ids() {
let dir = temp_index_dir("correct-docids");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
write_index_file(
&fast_path,
&[
("alpha", &[1.0, 0.0, 0.0]),
("beta", &[0.0, 1.0, 0.0]),
("gamma", &[0.0, 0.0, 1.0]),
],
)
.expect("write fast");
let index = TwoTierIndex::open(&dir, TwoTierConfig::default()).expect("open");
let hits = index
.search_fast(&[0.0, 1.0, 0.0], 1)
.expect("search for beta");
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].doc_id, "beta");
let hits = index
.search_fast(&[0.0, 0.0, 1.0], 1)
.expect("search for gamma");
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].doc_id, "gamma");
}
#[test]
fn builder_add_record_dimension_mismatch_in_quality() {
let dir = temp_index_dir("record-quality-dim");
let mut builder = TwoTierIndex::create(&dir, TwoTierConfig::default()).expect("builder");
builder
.add_record("doc-a", &[1.0, 0.0], Some(&[1.0, 0.0, 0.0]))
.expect("first record ok");
let err = builder
.add_record("doc-b", &[0.0, 1.0], Some(&[1.0, 0.0]))
.expect_err("quality dim mismatch");
assert!(matches!(
err,
SearchError::DimensionMismatch {
expected: 3,
found: 2
}
));
}
#[test]
fn open_nonexistent_directory_returns_error() {
let dir = temp_index_dir("nonexistent-subdir");
let result = TwoTierIndex::open(&dir, TwoTierConfig::default());
assert!(result.is_err());
}
use frankensearch_core::generation::{ArtifactGenerationIdentityV1, QuantizationFormat};
fn fsvi_v2_binding(
model_id: &str,
dimension: u32,
sequence: u64,
) -> (FsviV2IdentityBinding, EmbeddingIdentityBundleV1) {
let mut identity = EmbeddingIdentityBundleV1::explicit_test_model(model_id, dimension);
"fsvi-v2".clone_into(&mut identity.storage.format);
identity.storage.quantization = QuantizationFormat::F16;
"little-endian".clone_into(&mut identity.storage.endianness);
let generation =
ArtifactGenerationIdentityV1::new(sequence, [0x4d; 16]).expect("valid test generation");
let binding = FsviV2IdentityBinding::new(
generation,
identity.freeze().expect("freeze artifact identity"),
)
.expect("valid FSVI v2 identity binding");
(binding, identity)
}
fn write_v2_tier(path: &Path, binding: &FsviV2IdentityBinding, rows: &[(&str, &[f32])]) {
let mut writer =
VectorIndex::create_v2(path, binding.clone()).expect("create_v2 writer for fixture");
for (doc_id, vector) in rows {
writer.write_record(doc_id, vector).expect("write v2 row");
}
writer.finish().expect("finish v2 fixture");
}
#[test]
fn admitted_v2_open_reports_header_attested_identity() {
let dir = temp_index_dir("admitted-v2-attested");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
let quality_path = dir.join(VECTOR_INDEX_QUALITY_FILENAME);
let (fast_binding, fast_identity) = fsvi_v2_binding("attested-fast-model", 4, 3);
let (quality_binding, quality_identity) = fsvi_v2_binding("attested-quality-model", 4, 3);
let rows: [(&str, &[f32]); 2] = [
("doc-a", &[1.0, 0.0, 0.0, 0.0]),
("doc-b", &[0.0, 1.0, 0.0, 0.0]),
];
write_v2_tier(&fast_path, &fast_binding, &rows);
write_v2_tier(&quality_path, &quality_binding, &rows);
let plain = TwoTierIndex::open(&dir, TwoTierConfig::default());
assert!(
matches!(plain, Err(SearchError::IndexVersionMismatch { .. })),
"plain open must reject v2 bytes, got {plain:?}"
);
let paths = TwoTierIndexPaths::new(&fast_path).with_quality_index(&quality_path);
let index = TwoTierIndex::open_admitted_v2_with_paths(
&paths,
TwoTierConfig::default(),
&fast_binding,
Some(&quality_binding),
)
.expect("admit both v2 tiers");
assert!(
index.fast_identity_is_attested(),
"identity parsed from the artifact's validated v2 header is ATTESTED"
);
assert!(index.quality_identity_is_attested());
assert_eq!(
index.fast_space_fingerprint_hex(),
Some(fast_identity.space.fingerprint().as_str()),
"the join key must be the header's space fingerprint, bit-for-bit"
);
assert_eq!(
index.quality_space_fingerprint_hex(),
Some(quality_identity.space.fingerprint().as_str())
);
assert!(
index.fast_declared_identity().is_none(),
"attested identity is not a builder declaration"
);
assert!(index.quality_declared_identity().is_none());
assert_eq!(index.doc_count(), 2);
let hits = index
.search_fast(&[0.0, 1.0, 0.0, 0.0], 1)
.expect("search admitted v2 fast tier");
assert_eq!(hits[0].doc_id, "doc-b");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn builder_declared_identity_is_never_attested() {
let dir = temp_index_dir("declared-not-attested");
let fast_bundle = EmbeddingIdentityBundleV1::explicit_test_model("declared-fast", 4);
let quality_bundle = EmbeddingIdentityBundleV1::explicit_test_model("declared-quality", 4);
let mut builder = TwoTierIndex::create(&dir, TwoTierConfig::default()).expect("builder");
builder
.set_fast_identity(&fast_bundle)
.expect("declare fast identity");
builder
.set_quality_identity(&quality_bundle)
.expect("declare quality identity");
builder
.add_record("doc-a", &[1.0, 0.0, 0.0, 0.0], Some(&[0.0, 1.0, 0.0, 0.0]))
.expect("add record");
let index = builder.finish().expect("finish");
assert!(
index.fast_space_fingerprint_hex().is_some()
&& index.fast_declared_identity().is_some(),
"the declaration is retained (C2)…"
);
assert!(
!index.fast_identity_is_attested(),
"…but a declaration is a process-local claim, never a header attestation"
);
assert!(!index.quality_identity_is_attested());
drop(index);
let reopened = TwoTierIndex::open(&dir, TwoTierConfig::default()).expect("reopen");
assert!(!reopened.fast_identity_is_attested());
assert!(!reopened.quality_identity_is_attested());
assert_eq!(reopened.fast_space_fingerprint_hex(), None);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn admitted_v2_open_requires_binding_for_quality_path() {
let dir = temp_index_dir("admitted-v2-missing-binding");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
let quality_path = dir.join(VECTOR_INDEX_QUALITY_FILENAME);
let (fast_binding, _) = fsvi_v2_binding("binding-gap-fast", 4, 1);
let (quality_binding, _) = fsvi_v2_binding("binding-gap-quality", 4, 1);
let rows: [(&str, &[f32]); 1] = [("doc-a", &[1.0, 0.0, 0.0, 0.0])];
write_v2_tier(&fast_path, &fast_binding, &rows);
write_v2_tier(&quality_path, &quality_binding, &rows);
let paths = TwoTierIndexPaths::new(&fast_path).with_quality_index(&quality_path);
let error = TwoTierIndex::open_admitted_v2_with_paths(
&paths,
TwoTierConfig::default(),
&fast_binding,
None,
)
.expect_err("a quality path without its binding must be refused");
assert!(
matches!(
error,
SearchError::InvalidConfig { ref field, .. }
if field == "two_tier.quality_v2_admission"
),
"got {error:?}"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn admitted_v2_open_rejects_foreign_binding() {
let dir = temp_index_dir("admitted-v2-foreign-binding");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
let (fast_binding, _) = fsvi_v2_binding("real-artifact-model", 4, 1);
let rows: [(&str, &[f32]); 1] = [("doc-a", &[1.0, 0.0, 0.0, 0.0])];
write_v2_tier(&fast_path, &fast_binding, &rows);
let (foreign_binding, _) = fsvi_v2_binding("foreign-expectation-model", 4, 1);
let paths = TwoTierIndexPaths::new(&fast_path);
let error = TwoTierIndex::open_admitted_v2_with_paths(
&paths,
TwoTierConfig::default(),
&foreign_binding,
None,
)
.expect_err("admission against a foreign identity binding must be refused");
assert!(
matches!(
error,
SearchError::InvalidConfig { ref field, .. }
if field == "two_tier.fast_v2_admission"
),
"got {error:?}"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn admitted_v2_open_retains_sealed_owners_in_full() {
let dir = temp_index_dir("admitted-v2-retained-owners");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
let quality_path = dir.join(VECTOR_INDEX_QUALITY_FILENAME);
let (fast_binding, _) = fsvi_v2_binding("retained-fast-model", 4, 5);
let (quality_binding, _) = fsvi_v2_binding("retained-quality-model", 4, 5);
let rows: [(&str, &[f32]); 2] = [
("doc-a", &[1.0, 0.0, 0.0, 0.0]),
("doc-b", &[0.0, 1.0, 0.0, 0.0]),
];
write_v2_tier(&fast_path, &fast_binding, &rows);
write_v2_tier(&quality_path, &quality_binding, &rows);
let paths = TwoTierIndexPaths::new(&fast_path).with_quality_index(&quality_path);
let index = TwoTierIndex::open_admitted_v2_with_paths(
&paths,
TwoTierConfig::default(),
&fast_binding,
Some(&quality_binding),
)
.expect("admit both v2 tiers");
let fast_owner = index
.fast_admitted_owner()
.expect("fast admission owner must be retained");
let quality_owner = index
.quality_admitted_owner()
.expect("quality admission owner must be retained");
assert_eq!(fast_owner.witness().record_count, 2);
assert_eq!(quality_owner.witness().record_count, 2);
assert!(
fast_owner.published_wal_absent(),
"canonical pathname admission proves WAL absence; the publication \
state must survive into the retained owner"
);
assert!(quality_owner.published_wal_absent());
assert_eq!(
fast_owner.owned_byte_len(),
usize::try_from(fs::metadata(&fast_path).expect("stat fast").len())
.expect("length fits usize"),
"the retained owner holds the complete admitted byte image"
);
assert!(index.fast_identity_is_attested());
assert_eq!(
index.fast_space_fingerprint_hex(),
Some(crate::fingerprint_hex(&fast_owner.identity_v2().space_fingerprint).as_str())
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn admitted_owner_reads_survive_path_replacement() {
let dir = temp_index_dir("admitted-v2-path-replacement");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
let (fast_binding, _) = fsvi_v2_binding("replacement-proof-model", 4, 9);
let rows: [(&str, &[f32]); 2] = [
("doc-a", &[1.0, 0.0, 0.0, 0.0]),
("doc-b", &[0.0, 1.0, 0.0, 0.0]),
];
write_v2_tier(&fast_path, &fast_binding, &rows);
let paths = TwoTierIndexPaths::new(&fast_path);
let index = TwoTierIndex::open_admitted_v2_with_paths(
&paths,
TwoTierConfig::default(),
&fast_binding,
None,
)
.expect("admit fast v2 tier");
let witness_before = index
.fast_admitted_owner()
.expect("retained owner")
.witness()
.clone();
let hits_before = index
.search_fast(&[0.0, 1.0, 0.0, 0.0], 1)
.expect("search before replacement");
assert_eq!(hits_before[0].doc_id, "doc-b");
let renamed = dir.join("vector.fast.idx.renamed-away");
fs::rename(&fast_path, &renamed).expect("rename admitted source");
fs::write(&fast_path, b"garbage-not-an-index").expect("plant garbage");
let owner = index.fast_admitted_owner().expect("retained owner");
assert_eq!(
owner.witness(),
&witness_before,
"witness is part of the sealed owner, not re-derived from the path"
);
let hits_after = index
.search_fast(&[0.0, 1.0, 0.0, 0.0], 1)
.expect("search after replacement");
assert_eq!(
hits_after[0].doc_id, "doc-b",
"reads must serve the admitted Arc'd bytes, never the pathname"
);
assert_eq!(owner.row(0).expect("row 0").doc_id(), "doc-a");
assert_eq!(index.doc_count(), 2);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn admitted_owner_survives_in_place_truncation_swap_and_descriptor_reuse() {
let dir = temp_index_dir("admitted-v2-inode-mutation");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
let (fast_binding, _) = fsvi_v2_binding("inode-mutation-model", 4, 11);
let rows: [(&str, &[f32]); 2] = [
("doc-a", &[1.0, 0.0, 0.0, 0.0]),
("doc-b", &[0.0, 1.0, 0.0, 0.0]),
];
write_v2_tier(&fast_path, &fast_binding, &rows);
let admitted_bytes = fs::read(&fast_path).expect("read admitted image");
let paths = TwoTierIndexPaths::new(&fast_path);
let index = TwoTierIndex::open_admitted_v2_with_paths(
&paths,
TwoTierConfig::default(),
&fast_binding,
None,
)
.expect("admit fast v2 tier");
let witness_before = index
.fast_admitted_owner()
.expect("retained owner")
.witness()
.clone();
let fingerprint_before = index.fast_space_fingerprint_hex().map(str::to_owned);
let assert_owner_intact = |label: &str| {
let owner = index.fast_admitted_owner().expect("retained owner");
assert_eq!(owner.witness(), &witness_before, "{label}: witness");
assert_eq!(
owner.owned_byte_len(),
admitted_bytes.len(),
"{label}: owned byte length"
);
assert_eq!(
owner.row(0).expect("row 0").doc_id(),
"doc-a",
"{label}: borrowed row view"
);
assert_eq!(
index.fast_space_fingerprint_hex().map(str::to_owned),
fingerprint_before,
"{label}: space fingerprint"
);
assert_eq!(index.doc_count(), 2, "{label}: doc count");
let hits = index
.search_fast(&[0.0, 1.0, 0.0, 0.0], 1)
.unwrap_or_else(|error| panic!("{label}: search failed: {error}"));
assert_eq!(hits[0].doc_id, "doc-b", "{label}: served rows");
};
assert_owner_intact("before any mutation");
fs::OpenOptions::new()
.write(true)
.truncate(true)
.open(&fast_path)
.expect("truncate admitted inode");
assert_eq!(
fs::metadata(&fast_path).expect("stat truncated").len(),
0,
"the fixture must really have truncated the source"
);
assert_owner_intact("after in-place truncation");
let foreign = vec![0x5a_u8; admitted_bytes.len()];
assert_ne!(foreign, admitted_bytes);
fs::write(&fast_path, &foreign).expect("overwrite admitted inode in place");
assert_owner_intact("after same-length in-place byte swap");
fs::remove_file(&fast_path).expect("unlink admitted source");
let decoy = dir.join("descriptor-reuse-decoy");
let decoy_handle = fs::File::create(&decoy).expect("claim the freed descriptor slot");
write_v2_tier(
&fast_path,
&fsvi_v2_binding("descriptor-reuse-impostor", 4, 12).0,
&[("doc-impostor", &[0.0, 0.0, 1.0, 0.0])],
);
assert_owner_intact("after unlink, descriptor reuse, and re-creation");
drop(decoy_handle);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn same_identity_fast_and_quality_tiers_retain_independent_owners() {
let dir = temp_index_dir("admitted-v2-independent-owners");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
let quality_path = dir.join(VECTOR_INDEX_QUALITY_FILENAME);
let (fast_binding, _) = fsvi_v2_binding("indistinguishable-model", 4, 21);
let (quality_binding, _) = fsvi_v2_binding("indistinguishable-model", 4, 21);
let fast_rows: [(&str, &[f32]); 2] = [
("doc-a", &[1.0, 0.0, 0.0, 0.0]),
("doc-b", &[0.0, 1.0, 0.0, 0.0]),
];
let quality_rows: [(&str, &[f32]); 2] = [
("doc-a", &[1.0, 0.0, 0.0, 0.0]),
("doc-b", &[0.0, 0.0, 1.0, 0.0]),
];
write_v2_tier(&fast_path, &fast_binding, &fast_rows);
write_v2_tier(&quality_path, &quality_binding, &quality_rows);
let paths = TwoTierIndexPaths::new(&fast_path).with_quality_index(&quality_path);
let index = TwoTierIndex::open_admitted_v2_with_paths(
&paths,
TwoTierConfig::default(),
&fast_binding,
Some(&quality_binding),
)
.expect("admit both v2 tiers");
let fast_owner = index.fast_admitted_owner().expect("fast owner");
let quality_owner = index.quality_admitted_owner().expect("quality owner");
assert_eq!(
fast_owner.identity_v2().space_fingerprint,
quality_owner.identity_v2().space_fingerprint,
"the fixture is only meaningful while the two identities are indistinguishable"
);
assert!(
!std::ptr::eq(fast_owner, quality_owner),
"each tier retains its own admission owner"
);
assert_ne!(
fast_owner.witness().whole_image_sha256,
quality_owner.witness().whole_image_sha256,
"two independent admissions over two artifacts must witness \
different images"
);
assert_eq!(
index
.quality_vector_for_doc_id("doc-b")
.expect("quality vector lookup")
.expect("doc-b is present in the quality tier"),
vec![0.0, 0.0, 1.0, 0.0],
"the quality tier must serve its own vectors, not the fast tier's"
);
assert_eq!(
index
.search_fast(&[0.0, 1.0, 0.0, 0.0], 1)
.expect("fast search")[0]
.doc_id,
"doc-b",
"the fast tier keeps serving its own vectors"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn refused_candidate_leaves_the_retained_owner_serving_and_accepted_one_installs() {
let dir = temp_index_dir("admitted-v2-candidate-refresh");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
let (generation_one, _) = fsvi_v2_binding("refresh-model", 4, 31);
let incumbent_rows: [(&str, &[f32]); 2] = [
("doc-a", &[1.0, 0.0, 0.0, 0.0]),
("doc-b", &[0.0, 1.0, 0.0, 0.0]),
];
write_v2_tier(&fast_path, &generation_one, &incumbent_rows);
let paths = TwoTierIndexPaths::new(&fast_path);
let mut index = TwoTierIndex::open_admitted_v2_with_paths(
&paths,
TwoTierConfig::default(),
&generation_one,
None,
)
.expect("admit generation one");
let reader = TwoTierIndex::open_admitted_v2_with_paths(
&paths,
TwoTierConfig::default(),
&generation_one,
None,
)
.expect("second reader admits generation one");
let incumbent_witness = index
.fast_admitted_owner()
.expect("retained owner")
.witness()
.clone();
let reader_witness = reader
.fast_admitted_owner()
.expect("reader owner")
.witness()
.clone();
let (generation_two, _) = fsvi_v2_binding("refresh-model", 4, 32);
let refusal = index
.try_replace_admitted_v2(&paths, &generation_two, None)
.expect_err("a candidate whose identity does not match the artifact is refused");
assert!(
matches!(
refusal,
SearchError::InvalidConfig { ref field, .. } if field == "two_tier.fast_v2_admission"
),
"got {refusal:?}"
);
assert_eq!(
index
.fast_admitted_owner()
.expect("incumbent still retained")
.witness(),
&incumbent_witness,
"a refused candidate must not modify or evict the retained owner"
);
assert_eq!(
index
.search_fast(&[0.0, 1.0, 0.0, 0.0], 1)
.expect("incumbent still serves")[0]
.doc_id,
"doc-b"
);
assert_eq!(index.doc_count(), 2);
let successor_rows: [(&str, &[f32]); 3] = [
("doc-a", &[1.0, 0.0, 0.0, 0.0]),
("doc-b", &[0.0, 1.0, 0.0, 0.0]),
("doc-c", &[0.0, 0.0, 1.0, 0.0]),
];
write_v2_tier(&fast_path, &generation_two, &successor_rows);
index
.try_replace_admitted_v2(&paths, &generation_two, None)
.expect("a matching candidate installs");
let installed = index.fast_admitted_owner().expect("successor retained");
assert_eq!(installed.witness().generation.sequence, 32);
assert_ne!(
installed.witness(),
&incumbent_witness,
"the successor is a different admission"
);
assert_eq!(index.doc_count(), 3);
assert_eq!(
index
.search_fast(&[0.0, 0.0, 1.0, 0.0], 1)
.expect("successor serves")[0]
.doc_id,
"doc-c"
);
assert_eq!(
reader
.fast_admitted_owner()
.expect("reader owner")
.witness(),
&reader_witness,
"installing a successor must not disturb a live predecessor reader"
);
assert_eq!(reader.doc_count(), 2);
assert_eq!(
reader
.search_fast(&[0.0, 1.0, 0.0, 0.0], 1)
.expect("reader still serves generation one")[0]
.doc_id,
"doc-b"
);
assert!(
!reader
.search_fast(&[0.0, 0.0, 1.0, 0.0], 3)
.expect("reader exhaustive search")
.iter()
.any(|hit| hit.doc_id == "doc-c"),
"generation one has no doc-c; the reader must not observe the successor"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn retained_owners_serve_concurrent_readers_while_a_successor_installs() {
use std::sync::Barrier;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::thread;
const READERS: usize = 4;
const MIN_ITERATIONS_PER_READER: usize = 200;
let dir = temp_index_dir("admitted-v2-concurrent-refresh");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
let (generation_one, _) = fsvi_v2_binding("concurrent-model", 4, 41);
let incumbent_rows: [(&str, &[f32]); 2] = [
("doc-a", &[1.0, 0.0, 0.0, 0.0]),
("doc-b", &[0.0, 1.0, 0.0, 0.0]),
];
write_v2_tier(&fast_path, &generation_one, &incumbent_rows);
let paths = TwoTierIndexPaths::new(&fast_path);
let reader_index = TwoTierIndex::open_admitted_v2_with_paths(
&paths,
TwoTierConfig::default(),
&generation_one,
None,
)
.expect("admit generation one for the reader handle");
let witness_before = reader_index
.fast_admitted_owner()
.expect("retained owner")
.witness()
.clone();
let barrier = Barrier::new(READERS + 1);
let writer_done = AtomicBool::new(false);
let observed = AtomicUsize::new(0);
let overlapped = AtomicUsize::new(0);
let index_ref = &reader_index;
let barrier_ref = &barrier;
let writer_done_ref = &writer_done;
let observed_ref = &observed;
let overlapped_ref = &overlapped;
let fast_path_ref = fast_path.as_path();
thread::scope(|scope| {
for _ in 0..READERS {
scope.spawn(move || {
barrier_ref.wait();
let mut iterations = 0_usize;
while !writer_done_ref.load(Ordering::Acquire)
|| iterations < MIN_ITERATIONS_PER_READER
{
let hits = index_ref
.search_fast(&[0.0, 1.0, 0.0, 0.0], 3)
.expect("a retained owner never fails a concurrent read");
assert_eq!(
hits.len(),
2,
"generation one has exactly two rows; a short read means the \
owner re-read the pathname"
);
assert_eq!(
hits[0].doc_id, "doc-b",
"generation one's nearest neighbour must not change under a \
concurrent install"
);
assert!(
!hits.iter().any(|hit| hit.doc_id == "doc-c"),
"doc-c exists only in the successor; a retained owner must \
never observe it"
);
assert_eq!(index_ref.doc_count(), 2);
iterations = iterations.saturating_add(1);
observed_ref.fetch_add(1, Ordering::Release);
}
});
}
scope.spawn(move || {
barrier_ref.wait();
let (generation_two, _) = fsvi_v2_binding("concurrent-model", 4, 42);
let successor_rows: [(&str, &[f32]); 3] = [
("doc-a", &[1.0, 0.0, 0.0, 0.0]),
("doc-b", &[0.0, 1.0, 0.0, 0.0]),
("doc-c", &[0.0, 0.0, 1.0, 0.0]),
];
let reads_before_rewrite = observed_ref.load(Ordering::Acquire);
write_v2_tier(fast_path_ref, &generation_two, &successor_rows);
let successor_paths = TwoTierIndexPaths::new(fast_path_ref);
let mut successor = TwoTierIndex::open_admitted_v2_with_paths(
&successor_paths,
TwoTierConfig::default(),
&generation_two,
None,
)
.expect("admit generation two on an independent handle");
successor
.try_replace_admitted_v2(&successor_paths, &generation_two, None)
.expect("re-admitting the installed generation succeeds");
assert_eq!(successor.doc_count(), 3);
overlapped_ref.store(
observed_ref
.load(Ordering::Acquire)
.saturating_sub(reads_before_rewrite),
Ordering::Release,
);
writer_done_ref.store(true, Ordering::Release);
});
});
assert!(
observed.load(Ordering::Acquire) >= READERS * MIN_ITERATIONS_PER_READER,
"readers did not complete the minimum number of passes"
);
assert!(
overlapped.load(Ordering::Acquire) > 0,
"no read completed between the pathname rewrite and the successor install; \
the reads never actually raced the writer, so this run is vacuous"
);
assert_eq!(
reader_index
.fast_admitted_owner()
.expect("retained owner survives the concurrent install")
.witness(),
&witness_before,
"a concurrent successor install must not mutate a live predecessor's owner"
);
assert_eq!(reader_index.doc_count(), 2);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn owner_drop_order_is_not_load_bearing_because_the_image_is_shared_not_borrowed() {
use std::sync::Arc;
let dir = temp_index_dir("admitted-v2-drop-order");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
let (generation, _) = fsvi_v2_binding("drop-order-model", 4, 51);
let rows: [(&str, &[f32]); 2] = [
("doc-a", &[1.0, 0.0, 0.0, 0.0]),
("doc-b", &[0.0, 1.0, 0.0, 0.0]),
];
write_v2_tier(&fast_path, &generation, &rows);
let paths = TwoTierIndexPaths::new(&fast_path);
let index = TwoTierIndex::open_admitted_v2_with_paths(
&paths,
TwoTierConfig::default(),
&generation,
None,
)
.expect("admit the owner");
let owner = index.fast_admitted_owner().expect("retained owner");
assert!(
owner.owner_and_search_share_allocation(),
"the retained owner and its serving index must share one refcounted \
image; a separate copy or a borrow would make declaration order \
load-bearing"
);
let external = Arc::clone(&owner.bytes);
let image_before: Vec<u8> = external.to_vec();
assert_eq!(
Arc::strong_count(&external),
3,
"owner field + serving index + this test's clone"
);
drop(index);
assert_eq!(
Arc::strong_count(&external),
1,
"dropping the owner must release exactly its own two references"
);
assert_eq!(
external.as_ref(),
image_before.as_slice(),
"the byte image must be unchanged and fully readable after the owner \
that admitted it is gone"
);
let _ = fs::remove_dir_all(&dir);
}
#[cfg(feature = "ann")]
#[test]
fn ann_sidecar_validation_binds_the_retained_owners_generation_not_its_content() {
let dir = temp_index_dir("admitted-v2-ann-identity");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
let ann_path = dir.join("fast.ann");
let rows: [(&str, &[f32]); 3] = [
("doc-a", &[1.0, 0.0, 0.0, 0.0]),
("doc-b", &[0.0, 1.0, 0.0, 0.0]),
("doc-c", &[0.0, 0.0, 1.0, 0.0]),
];
let config = TwoTierConfig {
hnsw_threshold: 1,
hnsw_ef_search: 64,
..TwoTierConfig::default()
};
let (generation_one, _) = fsvi_v2_binding("ann-identity-model", 4, 31);
write_v2_tier(&fast_path, &generation_one, &rows);
let paths = TwoTierIndexPaths::new(&fast_path).with_fast_ann(&ann_path);
let first = TwoTierIndex::open_admitted_v2_with_paths(
&paths,
config.clone(),
&generation_one,
None,
)
.expect("admit generation 31");
assert!(
first.has_fast_ann(),
"an admitted owner must be able to carry an ANN sidecar"
);
let owner_one = first.fast_admitted_owner().expect("retained owner");
assert!(
owner_one.owner_and_search_share_allocation(),
"building an ANN sidecar must not detach the owner from its image"
);
let witness_one = owner_one.witness().clone();
assert_eq!(
first
.search_fast(&[0.0, 0.0, 1.0, 0.0], 1)
.expect("generation 31 serves")[0]
.doc_id,
"doc-c"
);
assert!(ann_path.exists(), "the sidecar was persisted");
let sidecar = load_native_ann_sidecar(&ann_path, first.fast_tier());
assert!(
sidecar
.matches_vector_index(first.fast_tier())
.expect("validate sidecar against its own generation"),
"a sidecar must admit the retained owner it was built from; if this fails \
the identity capture cannot see an owner-borrowed index at all"
);
let (generation_two, _) = fsvi_v2_binding("ann-identity-model", 4, 32);
write_v2_tier(&fast_path, &generation_two, &rows);
let successor =
TwoTierIndex::open_admitted_v2_with_paths(&paths, config, &generation_two, None)
.expect("admit generation 32");
assert_ne!(
successor
.fast_admitted_owner()
.expect("successor owner")
.witness(),
&witness_one,
"the fixture must actually change generation, or this test is vacuous"
);
assert!(
!sidecar
.matches_vector_index(successor.fast_tier())
.expect("validate the generation-31 sidecar against generation 32"),
"an ANN sidecar bound to generation 31 must not be served against a \
generation-32 retained owner, even though their content is identical"
);
assert!(successor.has_fast_ann());
assert!(
successor
.fast_admitted_owner()
.expect("successor owner")
.owner_and_search_share_allocation()
);
assert_eq!(
successor
.search_fast(&[0.0, 0.0, 1.0, 0.0], 1)
.expect("generation 32 serves")[0]
.doc_id,
"doc-c"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn a_refused_quality_tier_leaves_both_incumbent_tiers_installed() {
let dir = temp_index_dir("admitted-v2-partial-install");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
let quality_path = dir.join(VECTOR_INDEX_QUALITY_FILENAME);
let (generation_one, _) = fsvi_v2_binding("partial-install-model", 4, 61);
let rows: [(&str, &[f32]); 2] = [
("doc-a", &[1.0, 0.0, 0.0, 0.0]),
("doc-b", &[0.0, 1.0, 0.0, 0.0]),
];
write_v2_tier(&fast_path, &generation_one, &rows);
write_v2_tier(&quality_path, &generation_one, &rows);
let paths = TwoTierIndexPaths::new(&fast_path).with_quality_index(&quality_path);
let mut index = TwoTierIndex::open_admitted_v2_with_paths(
&paths,
TwoTierConfig::default(),
&generation_one,
Some(&generation_one),
)
.expect("admit generation one on both tiers");
let fast_witness = index
.fast_admitted_owner()
.expect("fast owner")
.witness()
.clone();
let quality_witness = index
.quality_admitted_owner()
.expect("quality owner")
.witness()
.clone();
let (generation_two, _) = fsvi_v2_binding("partial-install-model", 4, 62);
let successor_rows: [(&str, &[f32]); 3] = [
("doc-a", &[1.0, 0.0, 0.0, 0.0]),
("doc-b", &[0.0, 1.0, 0.0, 0.0]),
("doc-c", &[0.0, 0.0, 1.0, 0.0]),
];
write_v2_tier(&fast_path, &generation_two, &successor_rows);
let refusal = index
.try_replace_admitted_v2(&paths, &generation_two, Some(&generation_two))
.expect_err("a candidate whose quality tier cannot be admitted is refused whole");
assert!(
matches!(
refusal,
SearchError::InvalidConfig { ref field, .. }
if field == "two_tier.quality_v2_admission"
),
"the refusal must name the quality tier: got {refusal:?}"
);
assert_eq!(
index.fast_admitted_owner().expect("fast owner").witness(),
&fast_witness,
"the incumbent FAST owner must survive a quality-tier refusal"
);
assert_eq!(
index
.quality_admitted_owner()
.expect("quality owner")
.witness(),
&quality_witness,
"the incumbent QUALITY owner must survive too"
);
assert_eq!(index.doc_count(), 2, "no half-installed successor");
assert!(
!index
.search_fast(&[0.0, 0.0, 1.0, 0.0], 3)
.expect("incumbent still serves")
.iter()
.any(|hit| hit.doc_id == "doc-c"),
"the successor's doc-c must not be observable after a refused refresh"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn each_single_component_identity_mismatch_is_refused_at_two_tier_open() {
let dir = temp_index_dir("admitted-v2-mismatch-table");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
let (binding, identity) = fsvi_v2_binding("mismatch-table-model", 4, 41);
let rows: [(&str, &[f32]); 2] = [
("doc-a", &[1.0, 0.0, 0.0, 0.0]),
("doc-b", &[0.0, 1.0, 0.0, 0.0]),
];
write_v2_tier(&fast_path, &binding, &rows);
let paths = TwoTierIndexPaths::new(&fast_path);
let index = TwoTierIndex::open_admitted_v2_with_paths(
&paths,
TwoTierConfig::default(),
&binding,
None,
)
.expect("the exact binding admits");
assert_eq!(index.doc_count(), 2);
drop(index);
let generation =
ArtifactGenerationIdentityV1::new(41, [0x4d; 16]).expect("valid test generation");
for (label, mutation) in [
(
"tokenizer",
Box::new(|bundle: &mut EmbeddingIdentityBundleV1| {
"a-different-tokenizer".clone_into(&mut bundle.space.tokenizer_fingerprint);
}) as Box<dyn Fn(&mut EmbeddingIdentityBundleV1)>,
),
(
"input contract",
Box::new(|bundle: &mut EmbeddingIdentityBundleV1| {
"a-different-canonicalization".clone_into(&mut bundle.input.canonicalization);
}),
),
(
"storage endianness",
Box::new(|bundle: &mut EmbeddingIdentityBundleV1| {
"big-endian".clone_into(&mut bundle.storage.endianness);
}),
),
] {
let mut mutated = identity.clone();
mutation(&mut mutated);
assert!(
mutated.freeze().is_err(),
"{label}: an identity the format cannot represent must not be constructible"
);
}
let mutate = |mutation: &dyn Fn(&mut EmbeddingIdentityBundleV1)| {
let mut mutated = identity.clone();
mutation(&mut mutated);
FsviV2IdentityBinding::new(
generation,
mutated.freeze().expect("freeze mutated identity"),
)
.expect("valid binding over a mutated identity")
};
type IdentityMutation = Box<dyn Fn(&mut EmbeddingIdentityBundleV1)>;
let cases: [(&str, IdentityMutation); 2] = [
(
"producer attestation",
Box::new(|bundle: &mut EmbeddingIdentityBundleV1| {
"a-different-backend".clone_into(&mut bundle.producer.backend);
}),
),
(
"quantization",
Box::new(|bundle: &mut EmbeddingIdentityBundleV1| {
bundle.storage.quantization = QuantizationFormat::F32;
}),
),
];
for (label, candidate) in [
("model", fsvi_v2_binding("a-different-model", 4, 41).0),
(
"dimension",
fsvi_v2_binding("mismatch-table-model", 8, 41).0,
),
] {
let error = TwoTierIndex::open_admitted_v2_with_paths(
&paths,
TwoTierConfig::default(),
&candidate,
None,
)
.err()
.unwrap_or_else(|| panic!("{label} mismatch must be refused"));
assert!(
matches!(
error,
SearchError::InvalidConfig { ref field, .. }
if field == "two_tier.fast_v2_admission"
),
"{label}: got {error:?}"
);
}
for (label, mutation) in &cases {
let candidate = mutate(mutation.as_ref());
let error = TwoTierIndex::open_admitted_v2_with_paths(
&paths,
TwoTierConfig::default(),
&candidate,
None,
)
.expect_err(&format!("{label} mismatch must be refused"));
assert!(
matches!(
error,
SearchError::InvalidConfig { ref field, .. }
if field == "two_tier.fast_v2_admission"
),
"{label}: got {error:?}"
);
}
let wrong_generation = FsviV2IdentityBinding::new(
ArtifactGenerationIdentityV1::new(42, [0x4d; 16]).expect("valid generation"),
identity.clone().freeze().expect("freeze identity"),
)
.expect("valid binding");
assert!(
TwoTierIndex::open_admitted_v2_with_paths(
&paths,
TwoTierConfig::default(),
&wrong_generation,
None,
)
.is_err(),
"generation mismatch must be refused"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn owners_are_reachable_only_through_admission() {
let dir = temp_index_dir("owner-reachability");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
write_index_file(&fast_path, &[("doc-a", &[1.0, 0.0, 0.0, 0.0])])
.expect("write v1 fixture");
let opened = TwoTierIndex::open(&dir, TwoTierConfig::default()).expect("v1 open");
assert!(opened.fast_admitted_owner().is_none());
assert!(opened.quality_admitted_owner().is_none());
assert!(!opened.fast_identity_is_attested());
assert!(opened.fast_space_fingerprint_hex().is_none());
let by_paths = TwoTierIndex::open_with_paths(
&TwoTierIndexPaths::new(&fast_path),
TwoTierConfig::default(),
)
.expect("v1 open_with_paths");
assert!(by_paths.fast_admitted_owner().is_none());
assert!(!by_paths.fast_identity_is_attested());
let v2_dir = temp_index_dir("owner-reachability-v2");
fs::create_dir_all(&v2_dir).expect("create temp dir");
let v2_path = v2_dir.join(VECTOR_INDEX_FAST_FILENAME);
let (binding, _) = fsvi_v2_binding("reachability-model", 4, 51);
write_v2_tier(&v2_path, &binding, &[("doc-a", &[1.0, 0.0, 0.0, 0.0])]);
assert!(
TwoTierIndex::open(&v2_dir, TwoTierConfig::default()).is_err(),
"a v2 artifact must not be reachable through the v1 opener"
);
let _ = fs::remove_dir_all(&dir);
let _ = fs::remove_dir_all(&v2_dir);
}
fn bound_query(identity: &EmbeddingIdentityBundleV1, vector: &[f32]) -> BoundQueryEmbedding {
let mut query_identity = identity.clone();
query_identity.storage.quantization = QuantizationFormat::F32;
"in-memory-f32".clone_into(&mut query_identity.storage.format);
"native-f32-values".clone_into(&mut query_identity.storage.endianness);
BoundQueryEmbedding::new(vector.to_vec(), query_identity)
.expect("bind a query embedding in the fixture's space")
}
#[test]
fn quality_only_and_union_reach_a_document_outside_the_fast_pool() {
let dir = temp_index_dir("ctzo-out-of-fast-pool");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
let quality_path = dir.join(VECTOR_INDEX_QUALITY_FILENAME);
let (fast_binding, fast_identity) = fsvi_v2_binding("ctzo-fast-model", 4, 71);
let (quality_binding, quality_identity) = fsvi_v2_binding("ctzo-quality-model", 4, 71);
write_v2_tier(
&fast_path,
&fast_binding,
&[
("doc-near", &[1.0, 0.0, 0.0, 0.0]),
("doc-far", &[0.0, 0.0, 1.0, 0.0]),
],
);
write_v2_tier(
&quality_path,
&quality_binding,
&[
("doc-near", &[0.0, 0.0, 1.0, 0.0]),
("doc-far", &[0.0, 0.5, 0.0, 0.0]),
("doc-quality-only", &[0.0, 1.0, 0.0, 0.0]),
],
);
let paths = TwoTierIndexPaths::new(&fast_path).with_quality_index(&quality_path);
let index = TwoTierIndex::open_admitted_v2_with_paths(
&paths,
TwoTierConfig::default(),
&fast_binding,
Some(&quality_binding),
)
.expect("admit both tiers");
let fast_query = bound_query(&fast_identity, &[1.0, 0.0, 0.0, 0.0]);
let quality_query = bound_query(&quality_identity, &[0.0, 1.0, 0.0, 0.0]);
let fast_only_embeddings = TieredQueryEmbeddings::fast_only(fast_query.clone());
let fast_only = index
.activate_owner_backed_search(&fast_only_embeddings)
.expect("fast-only activation");
assert_eq!(fast_only.topology(), RetrievalTopology::HashControl);
let fast_pool = fast_only.search_fast(1).expect("fast search");
assert_eq!(fast_pool[0].doc_id, "doc-near");
assert!(
!fast_pool.iter().any(|hit| hit.doc_id == "doc-far"),
"the fixture is only meaningful while doc-far is outside the fast pool"
);
let quality_only_embeddings = TieredQueryEmbeddings::quality_only(quality_query.clone());
let quality_only = index
.activate_owner_backed_search(&quality_only_embeddings)
.expect("quality-only activation");
assert_eq!(quality_only.topology(), RetrievalTopology::HashControl);
assert_eq!(
quality_only.search_quality(1).expect("quality search")[0].doc_id,
"doc-quality-only",
"QualityOnly must reach a document the fast tier does not even contain"
);
assert!(
quality_only.search_fast(1).is_err(),
"a quality-only activation exposes no fast retrieval"
);
let progressive_embeddings = TieredQueryEmbeddings::progressive(fast_query, quality_query);
let progressive = index
.activate_owner_backed_search(&progressive_embeddings)
.expect("progressive activation");
assert_eq!(progressive.topology(), RetrievalTopology::HashControl);
let union = progressive.search_union(2).expect("union");
let union_ids: Vec<&str> = union.iter().map(|hit| hit.doc_id.as_str()).collect();
assert!(
union_ids.contains(&"doc-quality-only"),
"the union must carry independent quality retrieval, got {union_ids:?}"
);
assert!(
union_ids.contains(&"doc-near"),
"the union must also carry the fast tier's own selection, got {union_ids:?}"
);
for pair in union.windows(2) {
assert!(
pair[0].score > pair[1].score
|| (pair[0].score.to_bits() == pair[1].score.to_bits()
&& pair[0].doc_id <= pair[1].doc_id),
"union ordering must be deterministic, got {union_ids:?}"
);
}
assert!(
!fast_pool.iter().any(|hit| hit.doc_id == "doc-quality-only"),
"rescoring a fast-selected pool can never surface doc-quality-only; that is \
precisely why direct quality retrieval and the union are first-class operations"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn activation_refuses_every_identity_divergence_before_any_read() {
let dir = temp_index_dir("ctzo-activation-refusals");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
let (binding, identity) = fsvi_v2_binding("ctzo-guard-model", 4, 81);
write_v2_tier(
&fast_path,
&binding,
&[
("doc-a", &[1.0, 0.0, 0.0, 0.0]),
("doc-b", &[0.0, 1.0, 0.0, 0.0]),
],
);
let paths = TwoTierIndexPaths::new(&fast_path);
let index = TwoTierIndex::open_admitted_v2_with_paths(
&paths,
TwoTierConfig::default(),
&binding,
None,
)
.expect("admit the fast tier");
assert_eq!(
index.fast_admitted_binding(),
Some(&binding),
"the tier must retain the exact binding it was admitted under"
);
let matching =
TieredQueryEmbeddings::fast_only(bound_query(&identity, &[1.0, 0.0, 0.0, 0.0]));
let activated = index
.activate_owner_backed_search(&matching)
.expect("the matching query activates");
assert_eq!(activated.search_fast(1).expect("search")[0].doc_id, "doc-a");
let (_, foreign_identity) = fsvi_v2_binding("a-foreign-model", 4, 81);
let foreign =
TieredQueryEmbeddings::fast_only(bound_query(&foreign_identity, &[1.0, 0.0, 0.0, 0.0]));
let error = index
.activate_owner_backed_search(&foreign)
.expect_err("a same-dimension foreign space must be refused");
assert!(
matches!(
error,
SearchError::InvalidConfig { ref field, .. }
if field == "query_embedding.fast.space_identity"
),
"got {error:?}"
);
let mut compatible_identity = identity.clone();
"a-different-implementation".clone_into(&mut compatible_identity.producer.backend);
assert_eq!(
compatible_identity.space.fingerprint(),
identity.space.fingerprint(),
"the fixture is only meaningful while the SPACE is unchanged"
);
assert_ne!(
compatible_identity.producer.fingerprint(),
identity.producer.fingerprint(),
"the fixture is only meaningful while the PRODUCER differs"
);
assert_eq!(
compatible_identity.producer.golden_vectors, identity.producer.golden_vectors,
"the fixture is only meaningful while both producers are certified compatible"
);
let compatible = TieredQueryEmbeddings::fast_only(bound_query(
&compatible_identity,
&[1.0, 0.0, 0.0, 0.0],
));
let error = index
.activate_owner_backed_search(&compatible)
.expect_err("a certified-compatible foreign producer must be refused");
assert!(
matches!(
error,
SearchError::InvalidConfig { ref field, .. }
if field == "search_activation.fast.producer_conformance"
),
"got {error:?}"
);
let quality_bound =
TieredQueryEmbeddings::quality_only(bound_query(&identity, &[1.0, 0.0, 0.0, 0.0]));
let error = index
.activate_owner_backed_search(&quality_bound)
.expect_err("a missing quality owner must be refused");
assert!(
matches!(
error,
SearchError::InvalidConfig { ref field, .. }
if field == "search_activation.quality.owner"
),
"got {error:?}"
);
let legacy_dir = temp_index_dir("ctzo-legacy");
fs::create_dir_all(&legacy_dir).expect("create temp dir");
let legacy_path = legacy_dir.join(VECTOR_INDEX_FAST_FILENAME);
write_index_file(&legacy_path, &[("doc-a", &[1.0, 0.0, 0.0, 0.0])])
.expect("write v1 fixture");
let legacy =
TwoTierIndex::open(&legacy_dir, TwoTierConfig::default()).expect("open legacy v1");
assert!(
legacy.fast_admitted_binding().is_none(),
"a legacy v1 tier retains no admitted identity to join against"
);
let error = legacy
.activate_owner_backed_search(&matching)
.expect_err("a legacy tier is not searchable under typed activation");
assert!(
matches!(
error,
SearchError::InvalidConfig { ref field, .. }
if field == "search_activation.fast.owner"
),
"got {error:?}"
);
let _ = fs::remove_dir_all(&dir);
let _ = fs::remove_dir_all(&legacy_dir);
}
#[test]
fn activated_coverage_comes_from_the_owner_witness() {
let dir = temp_index_dir("ctzo-coverage");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
let (binding, identity) = fsvi_v2_binding("ctzo-coverage-model", 4, 91);
write_v2_tier(
&fast_path,
&binding,
&[
("doc-a", &[1.0, 0.0, 0.0, 0.0]),
("doc-b", &[0.0, 1.0, 0.0, 0.0]),
("doc-c", &[0.0, 0.0, 1.0, 0.0]),
],
);
let index = TwoTierIndex::open_admitted_v2_with_paths(
&TwoTierIndexPaths::new(&fast_path),
TwoTierConfig::default(),
&binding,
None,
)
.expect("admit the fast tier");
let embeddings =
TieredQueryEmbeddings::fast_only(bound_query(&identity, &[1.0, 0.0, 0.0, 0.0]));
let activated = index
.activate_owner_backed_search(&embeddings)
.expect("activate");
let tier = activated.fast().expect("fast tier activated");
assert_eq!(tier.generation_sequence(), 91);
assert_eq!(tier.live_count(), 3);
assert_eq!(
tier.owner().witness().record_count,
3,
"coverage is read from the retained witness, not from the caller"
);
let returned = activated.search_fast(2).expect("fast search");
assert_eq!(returned.len(), 2);
let coverage = activated.coverage(&returned, 2).expect("coverage");
assert_eq!(
coverage.fast,
TierQueryCoverageV1::Witnessed {
generation_sequence: 91,
live_count: 3,
contributed_candidates: 2,
}
);
assert_eq!(
coverage.quality,
TierQueryCoverageV1::NotRequested,
"a tier the query never bound is NOT REQUESTED -- not a tier that covered nothing"
);
assert_eq!(coverage.quality.witnessed_live_count(), None);
assert!(
coverage.is_hash_control(),
"the fixture space is a deterministic control, so the receipt must say so"
);
let foreign = vec![VectorHit {
index: u32::MAX,
score: 1.0,
doc_id: "doc-from-somewhere-else".into(),
}];
let diluted = activated.coverage(&foreign, 2).expect("coverage");
assert_eq!(
diluted.fast,
TierQueryCoverageV1::Witnessed {
generation_sequence: 91,
live_count: 3,
contributed_candidates: 0,
}
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn plain_v1_open_has_no_admitted_owners() {
let dir = temp_index_dir("plain-v1-no-owners");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
write_index_file(&fast_path, &[("doc-a", &[1.0, 0.0, 0.0, 0.0])])
.expect("write v1 fixture");
let index = TwoTierIndex::open(&dir, TwoTierConfig::default()).expect("open v1");
assert!(index.fast_admitted_owner().is_none());
assert!(index.quality_admitted_owner().is_none());
let _ = fs::remove_dir_all(&dir);
}
fn tier_snapshot(path: &Path) -> (Vec<u8>, Option<Vec<u8>>) {
let main = fs::read(path).expect("read main artifact");
let wal_path = crate::wal::wal_path_for(path);
let wal = wal_path
.exists()
.then(|| fs::read(&wal_path).expect("read wal"));
(main, wal)
}
#[test]
fn observe_tier_classifies_v1_seed_live_and_v2_without_mutation() {
let dir = temp_index_dir("observe-basic");
fs::create_dir_all(&dir).expect("create temp dir");
let seed_path = dir.join("seed.idx");
VectorIndex::create(&seed_path, "seed-model", 4)
.expect("create seed")
.finish()
.expect("finish seed");
let seed_snapshot = tier_snapshot(&seed_path);
let seed_observation = observe_tier(&seed_path).expect("observe seed");
assert!(
matches!(seed_observation, FsviTierObservation::V1(_)),
"v1 seed must observe as V1, got {seed_observation:?}"
);
let FsviTierObservation::V1(seed) = seed_observation else {
return;
};
assert_eq!(seed.record_count, 0);
assert_eq!(seed.active_wal_records, 0);
assert!(!seed.wal_sidecar_present);
assert!(!seed.retains_content());
assert_eq!(tier_snapshot(&seed_path), seed_snapshot);
let live_path = dir.join("live.idx");
write_index_file(&live_path, &[("doc-a", &[1.0, 0.0, 0.0, 0.0])]).expect("write live v1");
{
let mut live = VectorIndex::open(&live_path).expect("open live v1");
live.append("doc-wal", &[0.0, 1.0, 0.0, 0.0])
.expect("append WAL resident");
}
let live_snapshot = tier_snapshot(&live_path);
assert!(live_snapshot.1.is_some(), "fixture must have a WAL sidecar");
let live_observation = observe_tier(&live_path).expect("observe live");
assert!(
matches!(live_observation, FsviTierObservation::V1(_)),
"live v1 must observe as V1, got {live_observation:?}"
);
let FsviTierObservation::V1(live) = live_observation else {
return;
};
assert_eq!(live.record_count, 1);
assert_eq!(live.active_wal_records, 1);
assert!(live.wal_sidecar_present);
assert!(live.retains_content());
assert_eq!(
tier_snapshot(&live_path),
live_snapshot,
"observation must not rewrite the artifact or its WAL"
);
let v2_path = dir.join("observed.v2.idx");
let (binding, _) = fsvi_v2_binding("observe-v2-model", 4, 2);
write_v2_tier(&v2_path, &binding, &[("doc-a", &[1.0, 0.0, 0.0, 0.0])]);
let v2_snapshot = tier_snapshot(&v2_path);
let v2_observation = observe_tier(&v2_path).expect("observe v2");
assert!(
matches!(v2_observation, FsviTierObservation::V2IdentityComplete(_)),
"v2 tier must observe as V2IdentityComplete, got {v2_observation:?}"
);
let FsviTierObservation::V2IdentityComplete(metadata) = v2_observation else {
return;
};
assert_eq!(metadata.record_count, 1);
assert!(metadata.identity_v2.is_some());
assert_eq!(tier_snapshot(&v2_path), v2_snapshot);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn observe_tier_never_deletes_a_stale_wal_sidecar() {
let dir = temp_index_dir("observe-stale-wal");
fs::create_dir_all(&dir).expect("create temp dir");
let donor_path = dir.join("donor.idx");
write_index_file(&donor_path, &[("donor-a", &[1.0, 0.0, 0.0, 0.0])]).expect("write donor");
{
let mut donor = VectorIndex::open(&donor_path).expect("open donor");
donor
.append("donor-wal-1", &[0.0, 1.0, 0.0, 0.0])
.expect("append pre-compaction");
donor.compact().expect("compact to bump generation");
donor
.append("donor-wal-2", &[0.0, 0.0, 1.0, 0.0])
.expect("append post-compaction");
}
let donor_wal = crate::wal::wal_path_for(&donor_path);
assert!(donor_wal.exists(), "donor WAL must exist");
let target_path = dir.join("target.idx");
write_index_file(&target_path, &[("doc-a", &[1.0, 0.0, 0.0, 0.0])]).expect("write target");
let target_wal = crate::wal::wal_path_for(&target_path);
fs::copy(&donor_wal, &target_wal).expect("transplant stale WAL");
let (entries, wal_gen, valid_len) =
crate::wal::read_wal(&target_wal, 4, Quantization::F16).expect("read fixture WAL");
assert!(
!entries.is_empty() && valid_len > 0,
"fixture WAL has entries"
);
assert_ne!(
wal_gen,
crate::next_generation(0),
"fixture WAL generation must mismatch the target main slab"
);
let snapshot = tier_snapshot(&target_path);
let target_observation = observe_tier(&target_path).expect("observe target");
assert!(
matches!(target_observation, FsviTierObservation::V1(_)),
"target must observe as V1, got {target_observation:?}"
);
let FsviTierObservation::V1(observation) = target_observation else {
return;
};
assert_eq!(
observation.active_wal_records, 0,
"stale WAL entries belong to a dead generation"
);
assert!(observation.wal_sidecar_present);
assert!(
observation.retains_content(),
"the live main row retains content regardless of WAL staleness"
);
assert!(
target_wal.exists(),
"read-only observation must NEVER delete a stale WAL (VectorIndex::open does)"
);
assert_eq!(
tier_snapshot(&target_path),
snapshot,
"artifact and WAL must be byte-identical after observation"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn observe_tier_never_truncates_a_corrupt_wal_trailer() {
let dir = temp_index_dir("observe-corrupt-trailer");
fs::create_dir_all(&dir).expect("create temp dir");
let path = dir.join("trailer.idx");
write_index_file(&path, &[("doc-a", &[1.0, 0.0, 0.0, 0.0])]).expect("write v1");
{
let mut index = VectorIndex::open(&path).expect("open v1");
index
.append("doc-wal", &[0.0, 1.0, 0.0, 0.0])
.expect("append valid WAL entry");
}
let wal_path = crate::wal::wal_path_for(&path);
let clean_len = fs::metadata(&wal_path).expect("stat wal").len();
{
use std::io::Write as _;
let mut wal_file = fs::OpenOptions::new()
.append(true)
.open(&wal_path)
.expect("open wal for corruption");
wal_file
.write_all(&[0xAB; 32])
.expect("append corrupt trailer");
}
let corrupted_len = fs::metadata(&wal_path).expect("stat wal").len();
assert_eq!(corrupted_len, clean_len + 32);
let snapshot = tier_snapshot(&path);
let trailer_observation = observe_tier(&path).expect("observe");
assert!(
matches!(trailer_observation, FsviTierObservation::V1(_)),
"must observe as V1, got {trailer_observation:?}"
);
let FsviTierObservation::V1(observation) = trailer_observation else {
return;
};
assert_eq!(
observation.active_wal_records, 1,
"the valid prefix still counts"
);
assert_eq!(
fs::metadata(&wal_path).expect("re-stat wal").len(),
corrupted_len,
"read-only observation must NEVER truncate a corrupt trailer \
(VectorIndex::open does)"
);
assert_eq!(tier_snapshot(&path), snapshot);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn observe_tier_reports_upgrade_required_for_future_schema() {
let dir = temp_index_dir("observe-upgrade");
fs::create_dir_all(&dir).expect("create temp dir");
let path = dir.join("future.idx");
let mut bytes = Vec::new();
bytes.extend_from_slice(&crate::FSVI_MAGIC);
bytes.extend_from_slice(&3_u16.to_le_bytes());
bytes.extend_from_slice(&[0_u8; 16]);
fs::write(&path, &bytes).expect("write future-schema fixture");
let observation = observe_tier(&path).expect("observe future schema");
assert!(
matches!(
observation,
FsviTierObservation::UpgradeRequired(FsviUpgradeRequired {
found_version: 3,
supported_version: crate::FSVI_V2_VERSION,
})
),
"got {observation:?}"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn builder_finish_removes_adjacent_wal_sidecars() {
let dir = temp_index_dir("builder-wal-hygiene");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
write_index_file(&fast_path, &[("old-doc", &[1.0, 0.0, 0.0, 0.0])])
.expect("write prior generation");
{
let mut prior = VectorIndex::open(&fast_path).expect("open prior");
prior
.append("wal-resident", &[0.0, 1.0, 0.0, 0.0])
.expect("append WAL resident");
}
let wal_path = crate::wal::wal_path_for(&fast_path);
assert!(wal_path.exists(), "fixture WAL must exist before rebuild");
let mut builder = TwoTierIndex::create(&dir, TwoTierConfig::default()).expect("builder");
builder
.add_fast_record("new-doc", &[0.0, 0.0, 1.0, 0.0])
.expect("add rebuild row");
let rebuilt = builder.finish().expect("finish rebuild");
assert!(
!wal_path.exists(),
"the rewritten tier's dead WAL sidecar must be removed by the write path"
);
assert_eq!(rebuilt.doc_count(), 1);
let ids: Vec<String> = rebuilt.iter_doc_ids().filter_map(Result::ok).collect();
assert_eq!(ids, vec!["new-doc".to_owned()]);
assert!(
!ids.iter().any(|id| id == "wal-resident" || id == "old-doc"),
"no row from the replaced generation may leak into the rebuild: {ids:?}"
);
let _ = fs::remove_dir_all(&dir);
}
fn staging_leftovers(dir: &Path) -> Vec<String> {
let mut names: Vec<String> = fs::read_dir(dir)
.expect("read dir")
.filter_map(Result::ok)
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.filter(|name| name.contains(".tmp."))
.collect();
names.sort();
names
}
#[test]
fn crash_after_rename_state_is_pinned_and_unconstructable_through_finish() {
let dir = temp_index_dir("r3-crash-after-rename");
fs::create_dir_all(&dir).expect("create temp dir");
let fast_path = dir.join(VECTOR_INDEX_FAST_FILENAME);
write_index_file(&fast_path, &[("old-doc", &[1.0, 0.0, 0.0, 0.0])])
.expect("write old generation");
{
let mut old = VectorIndex::open(&fast_path).expect("open old generation");
old.append("foreign-doc", &[0.0, 1.0, 0.0, 0.0])
.expect("append WAL resident");
}
let wal_path = crate::wal::wal_path_for(&fast_path);
let (entries, wal_gen, valid_len) =
crate::wal::read_wal(&wal_path, 4, Quantization::F16).expect("read fixture WAL");
assert_eq!(entries.len(), 1, "fixture WAL carries the foreign row");
assert!(valid_len > 0);
assert_eq!(
wal_gen,
crate::next_generation(1),
"fixture WAL generation must be 2 = next(1): the exact collision"
);
let scratch = dir.join("replacement.scratch");
write_index_file(&scratch, &[("new-doc", &[0.0, 0.0, 1.0, 0.0])])
.expect("write replacement main");
fs::rename(&scratch, &fast_path).expect("simulate crash after rename");
assert!(wal_path.exists(), "crash state: gen-2 WAL still adjacent");
let observation = observe_tier(&fast_path).expect("observe crash state");
assert!(
matches!(observation, FsviTierObservation::V1(_)),
"crash state must observe as V1, got {observation:?}"
);
let FsviTierObservation::V1(observed) = observation else {
return;
};
assert_eq!(observed.record_count, 1);
assert_eq!(
observed.active_wal_records, 1,
"a generation-2 WAL is byte-indistinguishable from legitimate \
incremental appends against a generation-1 main"
);
assert!(observed.wal_sidecar_present);
assert!(observed.retains_content());
assert!(wal_path.exists(), "observation never deletes the sidecar");
{
let reopened = VectorIndex::open(&fast_path).expect("v1 reopen of crash state");
assert_eq!(reopened.record_count(), 1, "main slab rows");
assert_eq!(
reopened.wal_record_count(),
1,
"the foreign WAL row IS replayed by the frozen v1 open path"
);
let wal_ids: Vec<&str> = reopened.wal_records().map(|(id, _)| id).collect();
assert_eq!(
wal_ids,
vec!["foreign-doc"],
"the replayed resident row is exactly the foreign append"
);
}
let two_tier = TwoTierIndex::open(&dir, TwoTierConfig::default()).expect("two-tier reopen");
assert_eq!(
two_tier.doc_count(),
1,
"the doc-id surface counts the main slab only"
);
let hits = two_tier
.search_fast(&[0.0, 0.8, 0.6, 0.0], 4)
.expect("search crash state");
let hit_ids: Vec<&str> = hits.iter().map(|hit| hit.doc_id.as_str()).collect();
assert_eq!(
hit_ids,
vec!["foreign-doc", "new-doc"],
"pinned: the legacy crash state resurrects the foreign row \
through the SEARCH surface on plain reopen — unreachable \
through finish() after r3"
);
drop(two_tier);
let mut builder = TwoTierIndex::create(&dir, TwoTierConfig::default()).expect("builder");
builder
.add_fast_record("rebuilt-doc", &[0.5, 0.5, 0.0, 0.0])
.expect("add rebuild row");
let rebuilt = builder.finish().expect("rebuild over crash state");
assert!(
!wal_path.exists(),
"finish() must never exit with a published main and a live WAL"
);
let ids: Vec<String> = rebuilt.iter_doc_ids().filter_map(Result::ok).collect();
assert_eq!(ids, vec!["rebuilt-doc".to_owned()]);
assert_eq!(
staging_leftovers(&dir),
Vec::<String>::new(),
"no staged replacement survives a successful finish()"
);
let _ = fs::remove_dir_all(&dir);
}
}