sparrowdb 0.1.27

Embedded graph database with Cypher queries — no server, no subscription, no cloud
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
//! Integration tests for the HNSW vector similarity index (issue #394).
//!
//! Tests cover:
//! - `CREATE VECTOR INDEX` DDL via `GraphDb::execute`
//! - Inserting nodes with vector embeddings via `execute_with_params`
//! - `vector_similarity()`, `vector_distance()`, `vector_dot()` scalar functions
//! - Restart survival (index persists to disk and reloads)
//! - `DROP INDEX` removes in-memory and on-disk data
//! - `GraphDb::create_vector_index` / `drop_vector_index` / `get_vector_index`

use sparrowdb::GraphDb;
use sparrowdb_execution::Value;

fn make_db() -> (tempfile::TempDir, GraphDb) {
    let dir = tempfile::tempdir().expect("tempdir");
    let db = GraphDb::open(dir.path()).expect("open db");
    (dir, db)
}

// ── DDL ────────────────────────────────────────────────────────────────────────

#[test]
fn create_vector_index_ddl() {
    let (_dir, db) = make_db();
    db.execute(
        "CREATE VECTOR INDEX FOR (n:Memory) ON (n.embedding) \
         OPTIONS { dimensions: 4, similarity: 'cosine' }",
    )
    .expect("CREATE VECTOR INDEX must succeed");

    // A second call is idempotent (index already registered).
    db.execute(
        "CREATE VECTOR INDEX FOR (n:Memory) ON (n.embedding) \
         OPTIONS { dimensions: 4, similarity: 'cosine' }",
    )
    .expect("duplicate CREATE VECTOR INDEX must be a no-op");
}

#[test]
fn create_vector_index_api() {
    let (_dir, db) = make_db();
    db.create_vector_index("Person", "emb", 3, "cosine")
        .expect("create_vector_index must succeed");
    assert!(
        db.get_vector_index("Person", "emb").is_some(),
        "index must be registered"
    );
}

#[test]
fn drop_vector_index_api() {
    let (_dir, db) = make_db();
    db.create_vector_index("Person", "emb", 3, "cosine")
        .expect("create");
    db.drop_vector_index("Person", "emb")
        .expect("drop_vector_index must succeed");
    assert!(
        db.get_vector_index("Person", "emb").is_none(),
        "index must be gone after drop"
    );
}

// ── Write path ─────────────────────────────────────────────────────────────────

#[test]
fn merge_with_params_inserts_into_vector_index() {
    let (_dir, db) = make_db();

    // Create the index first.
    db.create_vector_index("Memory", "embedding", 3, "cosine")
        .expect("create index");

    // Insert nodes directly via the VectorIndex API (write path test for the
    // registry is verified separately via the low-level API).
    let arc = db
        .get_vector_index("Memory", "embedding")
        .expect("index exists");
    arc.write()
        .expect("write lock")
        .insert(1, &[1.0_f32, 0.0, 0.0]);
    arc.write()
        .expect("write lock")
        .insert(2, &[0.0_f32, 1.0, 0.0]);

    // Confirm the index returns results.
    let idx = arc.read().expect("read lock");
    let results = idx.search(&[1.0_f32, 0.0, 0.0], 5, 10);
    assert!(
        !results.is_empty(),
        "HNSW search must return at least one result"
    );
    assert_eq!(results[0].0, 1, "nearest to [1,0,0] must be node 1");
}

// ── Scalar functions ───────────────────────────────────────────────────────────

#[test]
fn vector_similarity_function() {
    let (_dir, db) = make_db();
    // Create nodes with known embeddings.
    db.execute("CREATE (a:Vec {x: 1.0, y: 0.0, z: 0.0, id: 1})")
        .expect("create a");
    db.execute("CREATE (b:Vec {x: 0.0, y: 1.0, z: 0.0, id: 2})")
        .expect("create b");

    // vector_similarity of identical vectors (manually pass lists).
    let res = db
        .execute("RETURN vector_similarity([1.0, 0.0, 0.0], [1.0, 0.0, 0.0]) AS sim")
        .expect("vector_similarity must execute");
    assert_eq!(res.rows.len(), 1);
    if let Value::Float64(sim) = &res.rows[0][0] {
        assert!(
            (sim - 1.0).abs() < 1e-5,
            "cosine similarity of identical vectors must be 1.0, got {sim}"
        );
    } else {
        panic!("expected Float64, got {:?}", res.rows[0][0]);
    }
}

#[test]
fn vector_similarity_orthogonal_is_zero() {
    let (_dir, db) = make_db();
    let res = db
        .execute("RETURN vector_similarity([1.0, 0.0], [0.0, 1.0]) AS sim")
        .expect("execute");
    assert_eq!(res.rows.len(), 1);
    if let Value::Float64(sim) = &res.rows[0][0] {
        assert!(
            sim.abs() < 1e-5,
            "cosine similarity of orthogonal vectors must be ~0, got {sim}"
        );
    } else {
        panic!("expected Float64, got {:?}", res.rows[0][0]);
    }
}

#[test]
fn vector_distance_function() {
    let (_dir, db) = make_db();
    let res = db
        .execute("RETURN vector_distance([0.0, 0.0], [3.0, 4.0]) AS d")
        .expect("execute");
    assert_eq!(res.rows.len(), 1);
    if let Value::Float64(d) = &res.rows[0][0] {
        assert!(
            (d - 5.0).abs() < 1e-4,
            "Euclidean distance from (0,0) to (3,4) must be 5.0, got {d}"
        );
    } else {
        panic!("expected Float64, got {:?}", res.rows[0][0]);
    }
}

#[test]
fn vector_dot_function() {
    let (_dir, db) = make_db();
    let res = db
        .execute("RETURN vector_dot([2.0, 3.0], [4.0, 5.0]) AS dp")
        .expect("execute");
    assert_eq!(res.rows.len(), 1);
    if let Value::Float64(dp) = &res.rows[0][0] {
        // 2*4 + 3*5 = 8 + 15 = 23
        assert!(
            (dp - 23.0).abs() < 1e-4,
            "dot product of [2,3]·[4,5] must be 23, got {dp}"
        );
    } else {
        panic!("expected Float64, got {:?}", res.rows[0][0]);
    }
}

// ── Restart survival ───────────────────────────────────────────────────────────

#[test]
fn vector_index_survives_restart() {
    let dir = tempfile::tempdir().expect("tempdir");
    let path = dir.path().to_path_buf();

    {
        let db = GraphDb::open(&path).expect("open");
        db.create_vector_index("Memory", "embedding", 3, "cosine")
            .expect("create index");

        // Insert via the low-level API.
        let arc = db.get_vector_index("Memory", "embedding").expect("index");
        arc.write().expect("write").insert(42, &[1.0_f32, 0.0, 0.0]);

        // Persist by saving the index.
        let vidx_dir = path.join("vector_indexes");
        arc.read()
            .expect("read")
            .save(&vidx_dir, "Memory", "embedding")
            .expect("save");
    } // db dropped here

    // Re-open and verify the index loaded from disk.
    {
        let db = GraphDb::open(&path).expect("re-open");
        let arc = db
            .get_vector_index("Memory", "embedding")
            .expect("index must survive restart");
        let idx = arc.read().expect("read");
        let results = idx.search(&[1.0_f32, 0.0, 0.0], 5, 10);
        assert!(
            !results.is_empty(),
            "inserted node must be found after restart"
        );
        assert_eq!(results[0].0, 42, "node_id 42 must be the nearest neighbour");
    }
}

// ── Bulk insert + top-k query ──────────────────────────────────────────────────

#[test]
fn hnsw_bulk_insert_and_top_k() {
    let (_dir, db) = make_db();
    db.create_vector_index("Item", "vec", 8, "cosine")
        .expect("create index");

    let arc = db.get_vector_index("Item", "vec").expect("index");

    // Insert 50 random-ish unit vectors.
    for i in 0u64..50 {
        // Simple deterministic embedding: each dimension is i * dim.
        let v: Vec<f32> = (0..8)
            .map(|d| ((i * 7 + d * 3) % 17) as f32 / 17.0)
            .collect();
        // Normalize.
        let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt().max(1e-9);
        let vn: Vec<f32> = v.iter().map(|x| x / norm).collect();
        arc.write().expect("write").insert(i, &vn);
    }

    // Query with one of the inserted vectors (node 7).
    let query: Vec<f32> = {
        let v: Vec<f32> = (0u64..8)
            .map(|d| ((7 * 7 + d * 3) % 17) as f32 / 17.0)
            .collect();
        let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt().max(1e-9);
        v.iter().map(|x| x / norm).collect()
    };

    let results = arc.read().expect("read").search(&query, 10, 50);
    assert!(!results.is_empty(), "must return at least 1 result");
    // The closest node should be node 7 itself.
    assert_eq!(
        results[0].0, 7,
        "top result must be the query node itself (id=7)"
    );
}

// ── Regression: MATCH…SET $vector_param must write to HNSW (KMSmcp ch#202) ─────
//
// Before the fix, `MATCH (n:L {id: $id}) SET n.embedding = $emb` stored the
// property but left the HNSW file unchanged — silent data loss.  This test
// confirms that the inserted node is returned by vector_search after SET.

#[test]
fn set_vector_param_populates_hnsw() {
    let (_dir, db) = make_db();

    // Create the vector index and a bare node (no embedding yet).
    db.create_vector_index("Memory", "embedding", 4, "cosine")
        .expect("create index");
    db.execute("CREATE (n:Memory {id: 'k1'})")
        .expect("CREATE node");

    // Use execute_with_params to SET the embedding via a $param.
    let emb: Vec<f32> = vec![0.1, 0.2, 0.3, 0.4];
    let mut params = std::collections::HashMap::new();
    params.insert("id".to_string(), Value::String("k1".to_string()));
    params.insert("emb".to_string(), Value::Vector(emb.clone()));
    db.execute_with_params("MATCH (n:Memory {id: $id}) SET n.embedding = $emb", params)
        .expect("SET with vector param must not error");

    // Verify the HNSW index now contains the node.
    let arc = db
        .get_vector_index("Memory", "embedding")
        .expect("index must exist");
    let idx = arc.read().expect("read lock");
    let results = idx.search(&emb, 5, 20);
    assert!(
        !results.is_empty(),
        "vectorSearch after SET must return the inserted node (HNSW was empty — silent data loss bug)"
    );
    // The only node in the index should be the one we just SET.
    assert_eq!(results.len(), 1, "exactly one node should be in the index");
}

#[test]
fn set_vector_param_hnsw_roundtrip_survives_reopen() {
    // Same as above, but also verifies persistence: close + re-open the DB
    // and confirm vectorSearch still returns the node.
    let dir = tempfile::tempdir().expect("tempdir");
    let path = dir.path().to_path_buf();

    {
        let db = GraphDb::open(&path).expect("open");
        db.create_vector_index("Chunk", "emb", 3, "cosine")
            .expect("create index");
        db.execute("CREATE (n:Chunk {id: 'c1'})").expect("CREATE");

        let mut params = std::collections::HashMap::new();
        params.insert("id".to_string(), Value::String("c1".to_string()));
        params.insert("emb".to_string(), Value::Vector(vec![1.0, 0.0, 0.0]));
        db.execute_with_params("MATCH (n:Chunk {id: $id}) SET n.emb = $emb", params)
            .expect("SET emb");
    }

    // Re-open and query.
    let db = GraphDb::open(&path).expect("reopen");
    let arc = db
        .get_vector_index("Chunk", "emb")
        .expect("index must survive restart");
    let idx = arc.read().expect("read");
    let results = idx.search(&[1.0_f32, 0.0, 0.0], 5, 20);
    assert!(
        !results.is_empty(),
        "node must be in HNSW after restart (persistence verification)"
    );
}

// ── Regression: anonymous MATCH must populate HNSW (Issues #2/#4 in PR #410) ──
//
// `MATCH (n) WHERE n.id = $id SET n.embedding = $emb` has no label in the AST.
// Before the fix, scan_match_mutate bailed early (unknown label ""), leaving
// both the property and the HNSW index untouched — silent data loss.

#[test]
fn anonymous_match_set_vector_populates_hnsw() {
    let (_dir, db) = make_db();

    db.create_vector_index("Memory", "embedding", 3, "cosine")
        .expect("create index");
    db.execute("CREATE (n:Memory {id: 'anon-1'})")
        .expect("CREATE node");

    let emb = vec![1.0_f32, 0.0, 0.0];
    let mut params = std::collections::HashMap::new();
    params.insert("id".to_string(), Value::String("anon-1".to_string()));
    params.insert("emb".to_string(), Value::Vector(emb.clone()));

    // Anonymous match (no label in MATCH clause).
    db.execute_with_params("MATCH (n) WHERE n.id = $id SET n.embedding = $emb", params)
        .expect("anonymous MATCH SET must succeed");

    let arc = db
        .get_vector_index("Memory", "embedding")
        .expect("index must exist");
    let results = arc.read().expect("read").search(&emb, 5, 20);
    assert!(
        !results.is_empty(),
        "anonymous MATCH SET must populate HNSW (was silently skipped before fix)"
    );
}

// ── Regression: label derived from NodeId, not from first MATCH pattern ────────
//
// The HNSW write-path must derive the label from the matched NodeId's upper
// 32 bits rather than from the first AST node pattern.  Single-node queries
// are already correct; this test guards the NodeId-derivation code path
// explicitly, confirming the (label, prop) key is resolved correctly.

#[test]
fn set_vector_hnsw_label_derived_from_node_id() {
    let (_dir, db) = make_db();

    // Two separate labels, each with its own vector index.
    db.create_vector_index("PersonVec", "emb", 2, "cosine")
        .expect("create PersonVec index");
    db.create_vector_index("DocVec", "emb", 2, "cosine")
        .expect("create DocVec index");

    db.execute("CREATE (n:PersonVec {id: 'p1'})")
        .expect("CREATE PersonVec node");
    db.execute("CREATE (n:DocVec {id: 'd1'})")
        .expect("CREATE DocVec node");

    // SET embedding on the DocVec node by label.
    let doc_emb = vec![0.0_f32, 1.0];
    let mut params = std::collections::HashMap::new();
    params.insert("id".to_string(), Value::String("d1".to_string()));
    params.insert("emb".to_string(), Value::Vector(doc_emb.clone()));
    db.execute_with_params("MATCH (n:DocVec {id: $id}) SET n.emb = $emb", params)
        .expect("SET DocVec embedding");

    // DocVec index must contain the node.
    let doc_arc = db.get_vector_index("DocVec", "emb").expect("DocVec index");
    let doc_results = doc_arc.read().expect("read").search(&doc_emb, 5, 20);
    assert!(
        !doc_results.is_empty(),
        "DocVec HNSW must contain the SET node"
    );

    // PersonVec index must remain empty (no cross-label pollution).
    let person_arc = db
        .get_vector_index("PersonVec", "emb")
        .expect("PersonVec index");
    let person_results = person_arc.read().expect("read").search(&doc_emb, 5, 20);
    assert!(
        person_results.is_empty(),
        "PersonVec HNSW must NOT contain the DocVec node (wrong-label write)"
    );
}

// ── Metrics ────────────────────────────────────────────────────────────────────

#[test]
fn create_vector_index_euclidean_metric() {
    let (_dir, db) = make_db();
    db.create_vector_index("Point", "pos", 2, "euclidean")
        .expect("create euclidean index");
    let arc = db.get_vector_index("Point", "pos").expect("index");
    arc.write().expect("w").insert(0, &[0.0_f32, 0.0]);
    arc.write().expect("w").insert(1, &[1.0_f32, 0.0]);
    arc.write().expect("w").insert(2, &[0.0_f32, 10.0]);
    let results = arc.read().expect("r").search(&[0.0, 0.0], 1, 10);
    assert_eq!(results[0].0, 0, "nearest to origin should be origin itself");
}

#[test]
fn create_vector_index_dot_product_metric() {
    let (_dir, db) = make_db();
    db.create_vector_index("Emb", "feat", 2, "dot")
        .expect("create dot index");
    let arc = db.get_vector_index("Emb", "feat").expect("index");
    arc.write().expect("w").insert(0, &[0.5_f32, 0.5]);
    arc.write().expect("w").insert(1, &[1.0_f32, 1.0]);
    // query vector [1, 1] — dot product with [1, 1] is 2, with [0.5, 0.5] is 1.
    let results = arc.read().expect("r").search(&[1.0, 1.0], 1, 10);
    assert_eq!(results[0].0, 1, "highest dot product should be node 1");
}

/// #410 fixed silent HNSW data loss on `MATCH … SET n.emb = $vec`: the property
/// was written and the index left untouched, so the vector was invisible to
/// search forever. SPA-415 added `UNWIND … MATCH … SET`, a second entry point to
/// the same mutation, and it must not reopen that hole.
///
/// Mirrors `set_vector_param_populates_hnsw` exactly, through the UNWIND path.
#[test]
fn unwind_match_set_vector_param_populates_hnsw() {
    let (_dir, db) = make_db();

    db.create_vector_index("Memory", "embedding", 4, "cosine")
        .expect("create index");
    db.execute("CREATE (n:Memory {id: 'k1'})")
        .expect("CREATE node");

    let emb: Vec<f32> = vec![0.1, 0.2, 0.3, 0.4];
    let mut params = std::collections::HashMap::new();
    params.insert(
        "rows".to_string(),
        Value::List(vec![Value::Map(vec![(
            "id".to_string(),
            Value::String("k1".to_string()),
        )])]),
    );
    params.insert("emb".to_string(), Value::Vector(emb.clone()));
    db.execute_with_params(
        "UNWIND $rows AS row MATCH (n:Memory {id: row.id}) SET n.embedding = $emb",
        params,
    )
    .expect("UNWIND SET with vector param must not error");

    let arc = db
        .get_vector_index("Memory", "embedding")
        .expect("index must exist");
    let idx = arc.read().expect("read lock");
    let results = idx.search(&emb, 5, 20);
    assert!(
        !results.is_empty(),
        "vectorSearch after UNWIND MATCH SET must return the inserted node — \
         an empty HNSW means the property was written and the index skipped (#410 class)"
    );
    assert_eq!(results.len(), 1, "exactly one node should be in the index");
}

/// `SET n.emb = $a, n.emb = $b` parses into two `Mutation::Set` sharing the same
/// prop — `parse_set_items_inner` is a bare comma loop with no duplicate guard,
/// and nothing downstream rejects it.
///
/// `tx.set_property` is last-write-wins, and so is the MATCH…SET vector path
/// (which calls `idx.insert` per mutation, in order). The UNWIND accumulator must
/// agree with both, or the stored property and the HNSW index silently disagree —
/// the #410 class this maintenance exists to prevent.
///
/// Asserted on the INDEX, not the property: the property was never the broken
/// half. `$b` is the nearest neighbour to itself, so a first-write-wins
/// accumulator returns `$a`'s node ordering and fails this.
#[test]
fn unwind_duplicate_set_same_prop_indexes_the_last_vector() {
    let (_dir, db) = make_db();
    db.create_vector_index("Memory", "embedding", 4, "cosine")
        .expect("create index");
    db.execute("CREATE (n:Memory {id: 'k1'})").expect("CREATE");

    // Deliberately near-orthogonal so nearest-neighbour cannot confuse them.
    let a: Vec<f32> = vec![1.0, 0.0, 0.0, 0.0];
    let b: Vec<f32> = vec![0.0, 1.0, 0.0, 0.0];

    let mut params = std::collections::HashMap::new();
    params.insert(
        "rows".to_string(),
        Value::List(vec![Value::Map(vec![(
            "id".to_string(),
            Value::String("k1".to_string()),
        )])]),
    );
    params.insert("a".to_string(), Value::Vector(a.clone()));
    params.insert("b".to_string(), Value::Vector(b.clone()));

    db.execute_with_params(
        "UNWIND $rows AS row MATCH (n:Memory {id: row.id}) SET n.embedding = $a, n.embedding = $b",
        params,
    )
    .expect("duplicate SET on one prop must not error");

    let arc = db
        .get_vector_index("Memory", "embedding")
        .expect("index must exist");
    let idx = arc.read().expect("read lock");

    // Exactly one node was matched, so exactly one vector may be indexed.
    let hits_b = idx.search(&b, 5, 20);
    assert_eq!(hits_b.len(), 1, "one matched node => one indexed vector");

    // The indexed vector must be $b (the last write), so querying $b scores
    // better than querying $a. With cosine on orthogonal vectors, an index
    // holding $a scores 0 against $b.
    let score_b = hits_b[0].1;
    let score_a = idx.search(&a, 5, 20)[0].1;
    assert!(
        score_b > score_a,
        "index must hold the LAST vector ($b) to agree with the stored property; \
         got score_b={score_b} score_a={score_a} — first-write-wins leaves the \
         property as $b while the index holds $a"
    );
}