akar-main 0.2.1

Akar - pure Rust embedded graph database for AI agent memory
Documentation
//! Regression tests for the vector index path (requires `vector-extension`).
//!
//! The gate `test [akar-core]` runs without features, so this file is compiled
//! out there; run it explicitly with `cargo test --features vector-extension`.
//!
//! P53.x: `bind_create_vector_index` used to hold the catalog `MutexGuard`
//! across a second `self.catalog.lock()`, self-deadlocking on the same thread
//! (a `WaitOnAddress` hang) for any valid metric. The smoke test surfaced it
//! as a hang on `CALL CREATE_VECTOR_INDEX`. Fixed by scoping the first guard.

#![cfg(feature = "vector-extension")]

use akar_common::types::Value;
use akar_main::{Connection, Database, SystemConfig};
use std::sync::Arc;
use std::sync::mpsc;
use std::time::Duration;
use tempfile::tempdir;

fn setup() -> (tempfile::TempDir, Arc<Database>, Connection) {
    let dir = tempdir().unwrap();
    let db = Arc::new(Database::new(dir.path().to_str().unwrap(), SystemConfig::default()).unwrap());
    let conn = Connection::new(&db);
    conn.query("CREATE NODE TABLE Memory (id INT64, content STRING, embedding FLOAT[], PRIMARY KEY (id))")
        .expect("create Memory table");
    (dir, db, conn)
}

/// A second `CREATE VECTOR INDEX` with the same name must fail fast with
/// "already exists" — never hang. Before the binder fix this deadlocked on the
/// catalog mutex (re-entrant guard in `bind_create_vector_index`). The
/// watchdog converts a deadlock into a test failure instead of a hang.
#[test]
fn create_vector_index_twice_no_deadlock() {
    let (_dir, _db, conn) = setup();

    conn.query("CREATE VECTOR INDEX mem_vec ON (Memory.embedding) WITH (metric=cosine, dims=384)")
        .expect("first create should succeed");

    let (tx, rx) = mpsc::channel();
    let handle = std::thread::spawn(move || {
        let r = conn.query("CREATE VECTOR INDEX mem_vec ON (Memory.embedding) WITH (metric=cosine, dims=384)");
        let _ = tx.send(r);
    });

    let result = match rx.recv_timeout(Duration::from_secs(15)) {
        Ok(r) => r,
        Err(_) => {
            panic!("second CREATE VECTOR INDEX deadlocked on the catalog mutex");
        }
    };
    handle.join().expect("worker thread panicked");

    match result {
        Err(e) => assert!(e.contains("already exists"), "expected 'already exists', got: {e}"),
        Ok(_) => panic!("second CREATE VECTOR INDEX should have errored with 'already exists'"),
    }
}

/// Different index name on the same column/table is allowed, and the table can
/// still be queried (scalar path) after vector indexes exist.
#[test]
fn vector_index_does_not_break_scalar_queries() {
    let (_dir, _db, conn) = setup();

    conn.query("CREATE VECTOR INDEX mem_vec ON (Memory.embedding) WITH (metric=cosine, dims=384)")
        .expect("first create");
    conn.query("CREATE VECTOR INDEX mem_vec_2 ON (Memory.embedding) WITH (metric=cosine, dims=384)")
        .expect("second create on same column");

    conn.query("CREATE (m:Memory {id: 1, content: 'hello', embedding: [0.1, 0.2]})")
        .expect("insert row");

    let res = conn
        .query("MATCH (m:Memory) RETURN m.id, m.content")
        .expect("scalar query");
    let chunk = res.chunks.first().expect("one chunk");
    assert_eq!(chunk.size, 1, "one row expected");
}

/// P53.12: `RETURN n.embedding` on a `FLOAT[]` column must yield a `Value::List`
/// instead of NULL. Previously the scan collapsed List/Array columns to Int64
/// and the Arrow builders emitted all-null arrays.
#[test]
fn complex_type_list_column_round_trips() {
    let (_dir, _db, conn) = setup();
    conn.query("CREATE (m:Memory {id: 1, content: 'hello', embedding: [0.1, 0.2, 0.3]})")
        .expect("insert row");

    let res = conn.query("MATCH (m:Memory) RETURN m.embedding").expect("list query");
    let chunk = res.chunks.first().expect("one chunk");
    assert_eq!(chunk.size, 1, "one row expected");

    let val = chunk.get_value(0, 0).expect("embedding must not be null");
    match val {
        Value::List(items) => {
            assert_eq!(items.len(), 3, "embedding has 3 elements, got {items:?}");
            for item in &items {
                assert!(
                    matches!(item, Value::Float(_) | Value::Double(_)),
                    "expected numeric embedding element, got {item:?}"
                );
            }
        }
        other => panic!("expected Value::List, got {other:?}"),
    }
}

/// P53.12: `RETURN {id: n.id}` (a map literal) must yield a `Value::Struct`
/// instead of NULL. The map-literal projection now goes through
/// `evaluate_arrow` → `arrow_array_from_values` (StructArray), bypassing the
/// ValueVector that had no side-storage.
#[test]
fn complex_type_map_literal_returns_struct() {
    let (_dir, _db, conn) = setup();
    conn.query("CREATE (m:Memory {id: 1, content: 'hello', embedding: [0.1, 0.2]})")
        .expect("insert row");

    let res = conn
        .query("MATCH (m:Memory) RETURN {id: m.id}")
        .expect("map literal query");
    let chunk = res.chunks.first().expect("one chunk");
    assert_eq!(chunk.size, 1, "one row expected");

    let val = chunk.get_value(0, 0).expect("map literal must not be null");
    match val {
        Value::Struct(entries) => {
            assert_eq!(entries.len(), 1, "one field, got {entries:?}");
            assert_eq!(entries[0].0, "id");
            assert_eq!(entries[0].1, Value::Int64(1));
        }
        other => panic!("expected Value::Struct, got {other:?}"),
    }
}

/// P53.12: `array_cosine_similarity` over a `FLOAT[]` column + list literal
/// must return a Double. Both arguments now resolve through the Arrow-native
/// path (ListArray) so the scalar function sees real list values.
#[test]
fn complex_type_array_cosine_similarity_returns_double() {
    let (_dir, _db, conn) = setup();
    conn.query("CREATE (m:Memory {id: 1, content: 'hello', embedding: [1.0, 0.0]})")
        .expect("insert row");

    let res = conn
        .query("MATCH (m:Memory) RETURN array_cosine_similarity(m.embedding, [1.0, 0.0])")
        .expect("cosine query");
    let chunk = res.chunks.first().expect("one chunk");
    assert_eq!(chunk.size, 1, "one row expected");

    let val = chunk.get_value(0, 0).expect("cosine similarity must not be null");
    match val {
        Value::Double(d) => assert!((d - 1.0).abs() < 1e-9, "expected ~1.0, got {d}"),
        other => panic!("expected Value::Double, got {other:?}"),
    }
}

/// P71.1: `CALL vector_similarity_scan('Table','col',[q], k)` must actually run
/// the HNSW ANN scan and return the k nearest rows plus a distance column,
/// instead of erroring in the table-function registry (which rejects
/// `TableFunction::Custom`). The call is routed in
/// `DbStandaloneCallHandler::execute_vector_similarity_scan_call` directly to a
/// `PhysicalVectorSimilarityScan`.
#[test]
fn call_vector_similarity_scan_runs_hnsw() {
    let (_dir, _db, conn) = setup();

    conn.query("CREATE VECTOR INDEX mem_vec ON (Memory.embedding) WITH (metric=cosine, dims=2)")
        .expect("create vector index");

    conn.query("CREATE (m:Memory {id: 1, content: 'one', embedding: [1.0, 0.0]})")
        .expect("insert one");
    conn.query("CREATE (m:Memory {id: 2, content: 'two', embedding: [0.0, 1.0]})")
        .expect("insert two");

    // dims=2 vectors and k=2: both rows are candidates. Query [0.9, 0.1] is
    // far closer to [1,0] (id 1) than to [0,1] (id 2).
    let res = conn
        .query("CALL vector_similarity_scan('Memory', 'embedding', [0.9, 0.1], 2)")
        .expect("vector_similarity_scan must execute (previously errored)");

    let chunk = res.chunks.first().expect("one chunk");
    assert_eq!(chunk.size, 2, "expected 2 nearest rows, got {}", chunk.size);

    // P71.2: output schema is named — [table columns..., distance, _id].
    assert_eq!(
        chunk.field_names,
        vec!["id", "content", "embedding", "distance", "_id"],
        "vector-scan chunk must carry real field names (was empty)"
    );

    // Output layout: id at 0, distance at 3, _id at 4 (Memory has 3 columns).
    let distance_col = 3;
    let id_col = 4;
    let mut ids = vec![];
    for row in 0..chunk.size {
        let id = chunk.get_value(0, row).expect("id column present");
        ids.push(id.clone());
        let dist = chunk.get_value(distance_col, row).expect("distance column present");
        match dist {
            Value::Double(_) => {}
            other => panic!("distance must be a Double, got {other:?}"),
        }
        let row_id = chunk.get_value(id_col, row).expect("_id column present");
        match row_id {
            Value::Int64(_) => {}
            other => panic!("_id must be an Int64 physical row offset, got {other:?}"),
        }
    }

    let mut got: Vec<i64> = ids
        .into_iter()
        .map(|v| match v {
            Value::Int64(i) => i,
            other => panic!("id must be Int64, got {other:?}"),
        })
        .collect();
    got.sort_unstable();
    assert_eq!(got, vec![1, 2], "both inserted rows should be returned");
}

/// P71.4: `MATCH (m:Memory) WHERE cosine_similarity(m.embedding, [q]) > thr
/// RETURN ... ORDER BY cosine_similarity(...) DESC LIMIT k` is rewritten by the
/// optimizer's `VectorSimilarityDetection` pass into a `VectorSimilarityScan`
/// while preserving the distance threshold, the RETURN projection and the
/// LIMIT. The results must be identical to the un-accelerated path.
#[test]
fn match_vector_similarity_rewrites_to_vector_scan() {
    let (_dir, _db, conn) = setup();

    conn.query("CREATE VECTOR INDEX mem_vec ON (Memory.embedding) WITH (metric=cosine, dims=2)")
        .expect("create vector index");

    conn.query("CREATE (m:Memory {id: 1, content: 'one', embedding: [1.0, 0.0]})")
        .expect("insert one");
    conn.query("CREATE (m:Memory {id: 2, content: 'two', embedding: [0.0, 1.0]})")
        .expect("insert two");

    // Query [0.9, 0.1]: cosine with [1,0] (id 1) ≈ 0.994, with [0,1] (id 2)
    // ≈ 0.110. Threshold 0.5 keeps only id 1; DESC order + LIMIT 2 → [1].
    let sql = "MATCH (m:Memory) \
        WHERE cosine_similarity(m.embedding, [0.9, 0.1]) > 0.5 \
        RETURN m.id \
        ORDER BY cosine_similarity(m.embedding, [0.9, 0.1]) DESC \
        LIMIT 2";
    let res = conn.query(sql).expect("vector match query must execute");

    let chunk = res.chunks.first().expect("one chunk");
    assert_eq!(chunk.size, 1, "threshold 0.5 must keep only id 1, got {}", chunk.size);
    match chunk.get_value(0, 0).expect("id present") {
        Value::Int64(v) => assert_eq!(v, 1),
        other => panic!("expected id 1, got {other:?}"),
    }

    // With a threshold below both similarities, both rows are returned and the
    // DESC order must put id 1 (cos 0.994) before id 2 (cos 0.110).
    let sql2 = "MATCH (m:Memory) \
        WHERE cosine_similarity(m.embedding, [0.9, 0.1]) > 0.0 \
        RETURN m.id \
        ORDER BY cosine_similarity(m.embedding, [0.9, 0.1]) DESC \
        LIMIT 2";
    let res2 = conn.query(sql2).expect("vector match query 2");
    let chunk2 = res2.chunks.first().expect("one chunk");
    assert_eq!(chunk2.size, 2, "both rows pass threshold 0.0");
    let first = match chunk2.get_value(0, 0).expect("id present") {
        Value::Int64(v) => v,
        other => panic!("expected Int64 id, got {other:?}"),
    };
    assert_eq!(first, 1, "DESC cosine order must put id 1 first (cos 0.994 > 0.110)");
}

/// P71.5: the whole MATCH ANN read path (P71.4) must stay scale-invariant,
/// not just `DistanceMetric::Cosine::compute` / `cosine_similarity` (P51.46
/// unit test). Insert the same two directions but with arbitrary scales, so
/// the stored vectors are NON-unit-norm; cosine similarity against the query
/// must be unchanged, so the HNSW ranking and the threshold filter/ORDER BY
/// (which re-evaluate `cosine_similarity` on the raw stored vectors) must
/// match the unit-norm case exactly.
#[test]
fn match_vector_scan_cosine_scale_invariant() {
    let (_dir, _db, conn) = setup();

    conn.query("CREATE VECTOR INDEX mem_vec ON (Memory.embedding) WITH (metric=cosine, dims=2)")
        .expect("create vector index");

    // Same directions as the P71.4 test but scaled: [1,0] -> [10,0] (scaled
    // up), [0,1] -> [0,0.5] (scaled down). Cosine is scale-invariant, so the
    // similarities against [0.9, 0.1] are still 0.994 (id 1) and 0.110 (id 2).
    conn.query("CREATE (m:Memory {id: 1, content: 'one', embedding: [10.0, 0.0]})")
        .expect("insert one");
    conn.query("CREATE (m:Memory {id: 2, content: 'two', embedding: [0.0, 0.5]})")
        .expect("insert two");

    // Threshold 0.5 keeps only id 1 (cos 0.994), regardless of vector scale.
    let sql = "MATCH (m:Memory) \
        WHERE cosine_similarity(m.embedding, [0.9, 0.1]) > 0.5 \
        RETURN m.id \
        ORDER BY cosine_similarity(m.embedding, [0.9, 0.1]) DESC \
        LIMIT 2";
    let res = conn.query(sql).expect("vector match query must execute");
    let chunk = res.chunks.first().expect("one chunk");
    assert_eq!(
        chunk.size, 1,
        "scale-invariant: threshold 0.5 must keep only id 1, got {}",
        chunk.size
    );
    match chunk.get_value(0, 0).expect("id present") {
        Value::Int64(v) => assert_eq!(v, 1),
        other => panic!("expected id 1, got {other:?}"),
    }

    // Threshold 0.0 keeps both, still in DESC order id 1 (0.994) then id 2 (0.110).
    let sql2 = "MATCH (m:Memory) \
        WHERE cosine_similarity(m.embedding, [0.9, 0.1]) > 0.0 \
        RETURN m.id \
        ORDER BY cosine_similarity(m.embedding, [0.9, 0.1]) DESC \
        LIMIT 2";
    let res2 = conn.query(sql2).expect("vector match query 2");
    let chunk2 = res2.chunks.first().expect("one chunk");
    assert_eq!(chunk2.size, 2, "scale-invariant: both rows pass threshold 0.0");
    let first = match chunk2.get_value(0, 0).expect("id present") {
        Value::Int64(v) => v,
        other => panic!("expected Int64 id, got {other:?}"),
    };
    assert_eq!(
        first, 1,
        "scale-invariant: DESC cosine order must put id 1 first (cos 0.994 > 0.110)"
    );
}