tftio-kb 2.5.2

Personal knowledge base — typed AST with org-mode as projection, SQLite-backed
Documentation
//! Port of Haskell `test/EmbeddingSpec.hs`.
//!
//! Stub `EmbeddingClient` implementations only — no network traffic.
//! Tests exercise the v9 design where embedding generation happens at
//! the handler layer (here: the test driver) and the precomputed vector
//! is passed to storage as `Option<Vec<f32>>`.

use std::sync::{
    Arc, Mutex,
    atomic::{AtomicUsize, Ordering},
};

use async_trait::async_trait;
use kb::ast::*;
use kb::embedding::{EmbeddingClient, EmbeddingError, decode_embedding, encode_embedding};
use kb::storage::{self, init_db, insert_node_with, open_db, search_hybrid, update_node_with};

// ── Stub clients (async) ──────────────────────────────────────────────

struct ConstClient(Vec<f32>);

#[async_trait]
impl EmbeddingClient for ConstClient {
    async fn embed(&self, _input: &str) -> Result<Vec<f32>, EmbeddingError> {
        Ok(self.0.clone())
    }
}

struct FailingClient;

#[async_trait]
impl EmbeddingClient for FailingClient {
    async fn embed(&self, _input: &str) -> Result<Vec<f32>, EmbeddingError> {
        Err(EmbeddingError::EmptyResponse)
    }
}

/// Per-node deterministic vector, so different nodes have different
/// distances and hybrid ranking is observable.
struct DeterministicClient(Mutex<Vec<(String, Vec<f32>)>>);

#[async_trait]
impl EmbeddingClient for DeterministicClient {
    async fn embed(&self, input: &str) -> Result<Vec<f32>, EmbeddingError> {
        let map = self.0.lock().unwrap();
        for (key, vec) in map.iter() {
            if input.contains(key) {
                return Ok(vec.clone());
            }
        }
        Ok(vec![0.0_f32, 0.0, 0.0])
    }
}

struct CallCounter {
    inner: ConstClient,
    count: Arc<AtomicUsize>,
}

#[async_trait]
impl EmbeddingClient for CallCounter {
    async fn embed(&self, input: &str) -> Result<Vec<f32>, EmbeddingError> {
        self.count.fetch_add(1, Ordering::SeqCst);
        self.inner.embed(input).await
    }
}

fn fresh() -> (tempfile::TempDir, rusqlite::Connection) {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("emb.db");
    let conn = open_db(path.to_str().unwrap()).unwrap();
    init_db(&conn).unwrap();
    (dir, conn)
}

fn simple_doc(title: &str, body: &str) -> Document {
    Document {
        blocks: vec![
            Block::Heading {
                level: 1,
                title: Title(title.into()),
                tags: vec![],
                children: vec![],
            },
            Block::Paragraph {
                inlines: vec![Inline::Plain(body.into())],
            },
        ],
    }
}

/// Driver helper: compute the embedding for a doc via the supplied
/// async client (the role the handler layer plays in production), then
/// pass the precomputed vector into storage. Mirrors what the axum and
/// MCP handlers do.
async fn compute_payload<C: EmbeddingClient>(client: &C, doc: &Document) -> Option<Vec<f32>> {
    let title = match doc.blocks.first() {
        Some(Block::Heading { title, .. }) => title.0.clone(),
        _ => "(untitled)".into(),
    };
    let body = match doc.blocks.get(1) {
        Some(Block::Paragraph { inlines }) => inlines
            .iter()
            .filter_map(|i| match i {
                Inline::Plain(s) => Some(s.as_str()),
                _ => None,
            })
            .collect::<Vec<_>>()
            .join(" "),
        _ => String::new(),
    };
    let payload = format!("{title}\n{body}");
    client.embed(&payload).await.ok()
}

// ── Codec ─────────────────────────────────────────────────────────────

#[test]
fn codec_round_trip() {
    let v: Vec<f32> = vec![0.0, 1.0, -1.0, 0.5, f32::MIN, f32::MAX];
    let bytes = encode_embedding(&v);
    assert_eq!(bytes.len(), 4 * v.len());
    assert_eq!(decode_embedding(&bytes).unwrap(), v);
}

#[test]
fn codec_encode_of_empty_is_empty() {
    let v: Vec<f32> = vec![];
    assert!(encode_embedding(&v).is_empty());
    assert_eq!(decode_embedding(&encode_embedding(&v)).unwrap(), v);
}

#[test]
fn codec_decode_rejects_non_multiple_of_four() {
    let bad: Vec<u8> = vec![0; 7];
    let err = decode_embedding(&bad).unwrap_err();
    assert!(err.to_string().contains("multiple of 4"), "got: {err}");
}

// ── Write-path: handler computes, storage writes ──────────────────────

fn count_embeddings(conn: &rusqlite::Connection) -> i64 {
    conn.query_row("SELECT count(*) FROM embeddings", [], |r| r.get(0))
        .unwrap()
}

fn embedding_for(conn: &rusqlite::Connection, node_id: &str) -> Option<Vec<u8>> {
    conn.query_row(
        "SELECT embedding FROM embeddings WHERE node_id = ?1",
        [node_id],
        |r| r.get::<_, Vec<u8>>(0),
    )
    .ok()
}

#[tokio::test]
async fn insert_node_writes_embedding_when_client_configured() {
    let (_d, c) = fresh();
    let client = ConstClient(vec![0.1, 0.2, 0.3]);
    let doc = simple_doc("T", "body");
    let v = compute_payload(&client, &doc).await;
    insert_node_with(&c, "n1", &doc, v, Some("stub")).unwrap();
    assert_eq!(count_embeddings(&c), 1);
    let blob = embedding_for(&c, "n1").unwrap();
    assert_eq!(decode_embedding(&blob).unwrap(), vec![0.1, 0.2, 0.3]);
}

#[tokio::test]
async fn update_node_re_embeds_via_insert_or_replace() {
    let (_d, c) = fresh();
    let c1 = ConstClient(vec![0.5, 0.5]);
    let doc1 = simple_doc("T", "alpha");
    let v1 = compute_payload(&c1, &doc1).await;
    insert_node_with(&c, "n1", &doc1, v1, Some("stub")).unwrap();
    assert_eq!(count_embeddings(&c), 1);

    let c2 = ConstClient(vec![0.9, 0.1]);
    let doc2 = simple_doc("T", "beta");
    let v2 = compute_payload(&c2, &doc2).await;
    update_node_with(&c, "n1", &doc2, v2, Some("stub")).unwrap();
    assert_eq!(count_embeddings(&c), 1);
    let blob = embedding_for(&c, "n1").unwrap();
    assert_eq!(decode_embedding(&blob).unwrap(), vec![0.9, 0.1]);
}

#[tokio::test]
async fn client_failure_does_not_block_node_write() {
    let (_d, c) = fresh();
    let client = FailingClient;
    let doc = simple_doc("T", "body");
    // Handler-layer behaviour: failed embed -> proceed with None.
    let v = compute_payload(&client, &doc).await;
    assert!(v.is_none());
    insert_node_with(&c, "n1", &doc, v, Some("stub")).unwrap();
    assert!(storage::get_node(&c, "n1").unwrap().is_some());
    assert_eq!(count_embeddings(&c), 0);
}

#[test]
fn no_client_means_no_embeddings_rows() {
    let (_d, c) = fresh();
    insert_node_with(&c, "n1", &simple_doc("T", "body"), None, None).unwrap();
    assert_eq!(count_embeddings(&c), 0);
}

// ── Hybrid search ─────────────────────────────────────────────────────

#[test]
fn search_hybrid_falls_back_to_fts_when_no_client() {
    let (_d, c) = fresh();
    insert_node_with(&c, "a", &simple_doc("Apple", "first"), None, None).unwrap();
    insert_node_with(&c, "b", &simple_doc("Banana", "second"), None, None).unwrap();
    let hits = search_hybrid(&c, "Apple", None).unwrap();
    assert_eq!(hits, vec![NodeId("a".into())]);
}

#[tokio::test]
async fn search_hybrid_blends_fts_and_vector_via_rrf() {
    let (_d, c) = fresh();

    let det = DeterministicClient(Mutex::new(vec![
        ("Apple".to_string(), vec![1.0_f32, 0.0, 0.0]),
        ("Banana".to_string(), vec![0.9_f32, 0.0, 0.0]),
        ("shared".to_string(), vec![1.0_f32, 0.0, 0.0]),
    ]));
    let doc_a = simple_doc("Apple", "shared body");
    let doc_b = simple_doc("Banana", "completely different");
    let va = compute_payload(&det, &doc_a).await;
    let vb = compute_payload(&det, &doc_b).await;
    insert_node_with(&c, "a", &doc_a, va, Some("stub")).unwrap();
    insert_node_with(&c, "b", &doc_b, vb, Some("stub")).unwrap();

    let q = "shared Banana";
    let qv = det.embed(q).await.unwrap();
    let hits = search_hybrid(&c, q, Some((qv, "stub"))).unwrap();
    let ids: Vec<String> = hits.into_iter().map(|n| n.0).collect();
    assert!(
        ids.contains(&"a".to_string()),
        "RRF result missing a: {ids:?}"
    );
    assert!(
        ids.contains(&"b".to_string()),
        "RRF result missing b: {ids:?}"
    );
}

#[tokio::test]
async fn search_hybrid_handles_client_failure_gracefully() {
    let (_d, c) = fresh();
    let working = ConstClient(vec![1.0]);
    let doc = simple_doc("Apple", "first");
    let v = compute_payload(&working, &doc).await;
    insert_node_with(&c, "a", &doc, v, Some("stub")).unwrap();
    // Handler-layer behaviour with a failing client: query embedding =
    // None; result equals the FTS ranking.
    let failing = FailingClient;
    let qe = failing.embed("Apple").await.ok().map(|v| (v, "stub"));
    assert!(qe.is_none());
    let hits = search_hybrid(&c, "Apple", qe).unwrap();
    assert_eq!(hits, vec![NodeId("a".into())]);
}

#[tokio::test]
async fn embed_called_once_on_insert_one_on_update() {
    let (_d, c) = fresh();
    let count = Arc::new(AtomicUsize::new(0));
    let client = CallCounter {
        inner: ConstClient(vec![1.0_f32, 0.0]),
        count: Arc::clone(&count),
    };
    let doc1 = simple_doc("T", "body");
    let v1 = compute_payload(&client, &doc1).await;
    insert_node_with(&c, "n1", &doc1, v1, Some("stub")).unwrap();
    let doc2 = simple_doc("T", "body2");
    let v2 = compute_payload(&client, &doc2).await;
    update_node_with(&c, "n1", &doc2, v2, Some("stub")).unwrap();
    assert_eq!(count.load(Ordering::SeqCst), 2);
}