use anyhow::{Context, Result};
use async_trait::async_trait;
use crate::core::registry::IndexHandle;
use super::Migration;
pub struct M003HnswKeyRelativization;
#[async_trait]
impl Migration for M003HnswKeyRelativization {
fn source_version(&self) -> u32 {
2
}
fn target_version(&self) -> u32 {
3
}
fn description(&self) -> &'static str {
"M003: rewrite absolute HNSW key IDs to root-relative (issue #402 phase 2)"
}
async fn apply(&self, index: &IndexHandle) -> Result<(), anyhow::Error> {
let hnsw_path = resolve_hnsw_path(index)?;
if !hnsw_path.exists() {
tracing::debug!(
index_id = %index.id,
path = %hnsw_path.display(),
"M003: no hnsw snapshot found; skipping (BM25-only or not yet indexed)"
);
return Ok(());
}
let root_path = index.root_path.clone();
let count = {
let indexer = index.indexer.read().await;
indexer
.rewrite_vector_store_keys(&hnsw_path, &root_path)
.await
.context("M003: rewrite_vector_store_keys failed")?
};
if count == 0 {
tracing::info!(
index_id = %index.id,
"M003: all HNSW keys already relative (or no vector store wired); nothing to do"
);
} else {
tracing::info!(
index_id = %index.id,
count,
"M003: rewrote absolute HNSW key IDs to root-relative (sidecar flushed)"
);
}
Ok(())
}
}
fn resolve_hnsw_path(index: &IndexHandle) -> Result<std::path::PathBuf> {
let colocated = index.root_path.join(".trusty-search").join("hnsw.usearch");
if colocated.exists() {
return Ok(colocated);
}
crate::service::persistence::hnsw_path(&index.id.0)
.context("M003: could not resolve legacy hnsw path")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::indexer::CodeIndexer;
use crate::core::registry::{IndexHandle, IndexId};
use crate::core::store::{UsearchStore, VectorStore};
use std::sync::Arc;
use tokio::sync::RwLock;
#[test]
fn test_m003_from_target_version() {
let m = M003HnswKeyRelativization;
assert_eq!(m.source_version(), 2);
assert_eq!(m.target_version(), 3);
}
#[test]
fn test_m003_description_non_empty() {
let m = M003HnswKeyRelativization;
let desc = m.description();
assert!(!desc.is_empty());
assert!(desc.contains("M003"), "description should include 'M003'");
}
#[test]
fn test_m003_advances_exactly_one_version() {
let m = M003HnswKeyRelativization;
assert_eq!(
m.target_version() - m.source_version(),
1,
"each migration must advance exactly one version"
);
}
#[tokio::test]
async fn test_m003_apply_no_store_is_ok() {
let indexer = CodeIndexer::new("m003-test", "/tmp/m003-test-no-store");
let handle = IndexHandle::bare(
IndexId::new("m003-test-no-store"),
Arc::new(RwLock::new(indexer)),
std::path::PathBuf::from("/tmp/m003-test-no-store"),
);
let m = M003HnswKeyRelativization;
let result = m.apply(&handle).await;
assert!(
result.is_ok(),
"no-snapshot apply must be Ok, got: {result:?}"
);
}
#[tokio::test]
async fn test_m003_idempotent_when_already_relative() {
let store = UsearchStore::new(4).expect("store init");
store
.upsert("src/lib.rs:10:40", vec![1.0, 0.0, 0.0, 0.0])
.await
.unwrap();
let root = std::path::Path::new("/Users/alice/proj");
let count1 = store.rewrite_keys_to_relative(root).await.unwrap();
assert_eq!(count1, 0, "already-relative store must rewrite 0 keys");
let count2 = store.rewrite_keys_to_relative(root).await.unwrap();
assert_eq!(count2, 0, "idempotent second call must rewrite 0 keys");
let hits = store.search(&[1.0, 0.0, 0.0, 0.0], 1).await.unwrap();
assert_eq!(hits[0].chunk_id, "src/lib.rs:10:40");
}
#[tokio::test]
async fn test_m003_rewrites_absolute_to_relative() {
let root = std::path::Path::new("/Users/alice/proj");
let store = UsearchStore::new(4).expect("store init");
store
.upsert(
"/Users/alice/proj/src/lib.rs:10:40",
vec![1.0, 0.0, 0.0, 0.0],
)
.await
.unwrap();
store
.upsert(
"/Users/alice/proj/tests/foo.rs:1:20",
vec![0.0, 1.0, 0.0, 0.0],
)
.await
.unwrap();
let count = store.rewrite_keys_to_relative(root).await.unwrap();
assert_eq!(count, 2, "two absolute IDs must be rewritten");
let hits = store.search(&[1.0, 0.0, 0.0, 0.0], 1).await.unwrap();
assert_eq!(
hits[0].chunk_id, "src/lib.rs:10:40",
"search after rewrite must return relative ID"
);
let count2 = store.rewrite_keys_to_relative(root).await.unwrap();
assert_eq!(count2, 0, "second rewrite must be a no-op");
}
#[tokio::test]
async fn test_m003_skips_out_of_root_absolute_ids() {
let root = std::path::Path::new("/Users/alice/proj");
let store = UsearchStore::new(4).expect("store init");
store
.upsert("/Users/bob/other/src/lib.rs:1:10", vec![1.0, 0.0, 0.0, 0.0])
.await
.unwrap();
let count = store.rewrite_keys_to_relative(root).await.unwrap();
assert_eq!(count, 0, "out-of-root absolute ID must not be rewritten");
let hits = store.search(&[1.0, 0.0, 0.0, 0.0], 1).await.unwrap();
assert_eq!(
hits[0].chunk_id, "/Users/bob/other/src/lib.rs:1:10",
"out-of-root ID must survive unchanged"
);
}
#[tokio::test]
async fn test_m003_full_roundtrip_absolute_sidecar_to_relative() {
let dir = tempfile::tempdir().unwrap();
let hnsw_path = dir.path().join("hnsw.usearch");
let root = dir.path().join("proj");
let abs_id = format!("{}/src/lib.rs:42:78", root.display());
{
let store = UsearchStore::new(4).unwrap();
store
.upsert(&abs_id, vec![1.0, 0.0, 0.0, 0.0])
.await
.unwrap();
store.save(&hnsw_path).await.unwrap();
}
{
let json = std::fs::read(hnsw_path.with_extension("keys.json")).unwrap();
let map: serde_json::Value = serde_json::from_slice(&json).unwrap();
let keys: Vec<&str> = map["id_to_key"]
.as_object()
.unwrap()
.keys()
.map(|s| s.as_str())
.collect();
assert!(
keys.iter().any(|k| k.starts_with('/')),
"sidecar must have absolute keys before M003"
);
}
let store = UsearchStore::load_from(&hnsw_path)
.await
.unwrap()
.expect("load returned Some");
let hits_before = store.search(&[1.0, 0.0, 0.0, 0.0], 1).await.unwrap();
assert_eq!(
hits_before[0].chunk_id, abs_id,
"before M003, search must return the absolute ID"
);
let count = store.rewrite_keys_to_relative(&root).await.unwrap();
assert_eq!(count, 1, "M003 must rewrite the one absolute key");
store.save(&hnsw_path).await.unwrap();
let hits_after = store.search(&[1.0, 0.0, 0.0, 0.0], 1).await.unwrap();
assert_eq!(
hits_after[0].chunk_id, "src/lib.rs:42:78",
"after M003, search must return the relative ID"
);
let count2 = store.rewrite_keys_to_relative(&root).await.unwrap();
assert_eq!(count2, 0, "second M003 apply must be a no-op");
let reloaded = UsearchStore::load_from(&hnsw_path)
.await
.unwrap()
.expect("reload returned Some");
let hits_reloaded = reloaded.search(&[1.0, 0.0, 0.0, 0.0], 1).await.unwrap();
assert_eq!(
hits_reloaded[0].chunk_id, "src/lib.rs:42:78",
"reloaded store must return relative ID (persisted sidecar is now relative)"
);
}
}