1use std::cmp::Reverse;
4use std::collections::BinaryHeap;
5use std::sync::Arc;
6
7use async_trait::async_trait;
8use uuid::Uuid;
9
10use khive_score::DeterministicScore;
11use khive_storage::error::StorageError;
12use khive_storage::types::{
13 BatchWriteSummary, SparseRecord, SparseSearchHit, SparseSearchRequest, SparseVector,
14};
15use khive_storage::{SparseStore, StorageCapability};
16use khive_types::SubstrateKind;
17
18use crate::error::SqliteError;
19use crate::pool::ConnectionPool;
20use crate::writer_task::WriterTaskHandle;
21
22fn map_err(e: rusqlite::Error, op: &'static str) -> StorageError {
23 StorageError::driver(StorageCapability::Sparse, op, e)
24}
25
26fn map_sqlite_err(e: SqliteError, op: &'static str) -> StorageError {
27 StorageError::driver(StorageCapability::Sparse, op, e)
28}
29
30fn validate_sparse_vector(vector: &SparseVector, op: &'static str) -> Result<(), StorageError> {
37 if vector.indices.len() != vector.values.len() {
38 return Err(StorageError::InvalidInput {
39 capability: StorageCapability::Sparse,
40 operation: op.into(),
41 message: format!(
42 "indices length ({}) != values length ({})",
43 vector.indices.len(),
44 vector.values.len()
45 ),
46 });
47 }
48 if vector.indices.is_empty() {
49 return Err(StorageError::InvalidInput {
50 capability: StorageCapability::Sparse,
51 operation: op.into(),
52 message: "sparse vector must have at least one element".into(),
53 });
54 }
55 for (i, v) in vector.values.iter().enumerate() {
56 if !v.is_finite() {
57 return Err(StorageError::InvalidInput {
58 capability: StorageCapability::Sparse,
59 operation: op.into(),
60 message: format!("non-finite value at position {i}: {v}"),
61 });
62 }
63 }
64 for window in vector.indices.windows(2) {
66 if window[0] >= window[1] {
67 return Err(StorageError::InvalidInput {
68 capability: StorageCapability::Sparse,
69 operation: op.into(),
70 message: format!(
71 "indices must be strictly increasing; found {} then {}",
72 window[0], window[1]
73 ),
74 });
75 }
76 }
77 Ok(())
78}
79
80fn f32_slice_as_bytes(data: &[f32]) -> &[u8] {
82 unsafe { std::slice::from_raw_parts(data.as_ptr() as *const u8, std::mem::size_of_val(data)) }
84}
85
86fn batch_insert_sparse_dml(
95 conn: &rusqlite::Connection,
96 table: &str,
97 records: &[SparseRecord],
98 attempted: u64,
99) -> Result<BatchWriteSummary, rusqlite::Error> {
100 let sql = format!(
101 "INSERT INTO {table} \
102 (subject_id, namespace, kind, field, indices_json, values_blob, updated_at) \
103 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \
104 ON CONFLICT(subject_id, namespace, field) DO UPDATE SET \
105 indices_json = excluded.indices_json, \
106 values_blob = excluded.values_blob, \
107 updated_at = excluded.updated_at"
108 );
109
110 let mut affected = 0u64;
111 let mut failed = 0u64;
112 let mut first_error = String::new();
113
114 for record in records {
115 if record.vector.indices.len() != record.vector.values.len()
117 || record.vector.indices.is_empty()
118 || record.vector.values.iter().any(|v| !v.is_finite())
119 || record.vector.indices.windows(2).any(|w| w[0] >= w[1])
120 {
121 if first_error.is_empty() {
122 first_error = format!("invalid sparse vector for subject {}", record.subject_id);
123 }
124 failed += 1;
125 continue;
126 }
127
128 let indices_json = match serde_json::to_string(&record.vector.indices) {
129 Ok(j) => j,
130 Err(e) => {
131 if first_error.is_empty() {
132 first_error = e.to_string();
133 }
134 failed += 1;
135 continue;
136 }
137 };
138 let values_blob = f32_slice_as_bytes(&record.vector.values);
139 let now = record.updated_at.timestamp();
140 let id_str = record.subject_id.to_string();
141 let kind_str = record.kind.to_string();
142
143 match conn.execute(
144 &sql,
145 rusqlite::params![
146 &id_str,
147 &record.namespace,
148 &kind_str,
149 &record.field,
150 &indices_json,
151 values_blob,
152 now
153 ],
154 ) {
155 Ok(_) => affected += 1,
156 Err(e) => {
157 if first_error.is_empty() {
158 first_error = e.to_string();
159 }
160 failed += 1;
161 }
162 }
163 }
164
165 Ok(BatchWriteSummary {
166 attempted,
167 affected,
168 failed,
169 first_error,
170 })
171}
172
173pub(crate) fn ensure_sparse_schema(
175 conn: &rusqlite::Connection,
176 model_key: &str,
177) -> Result<(), rusqlite::Error> {
178 let table = format!("sparse_{}", model_key);
179 let ddl = format!(
180 "CREATE TABLE IF NOT EXISTS {table} (\
181 subject_id TEXT NOT NULL, \
182 namespace TEXT NOT NULL, \
183 kind TEXT NOT NULL, \
184 field TEXT NOT NULL, \
185 indices_json TEXT NOT NULL, \
186 values_blob BLOB NOT NULL, \
187 updated_at INTEGER NOT NULL, \
188 PRIMARY KEY(subject_id, namespace, field)\
189 ); \
190 CREATE INDEX IF NOT EXISTS idx_{table}_namespace_kind \
191 ON {table}(namespace, kind);"
192 );
193 conn.execute_batch(&ddl)
194}
195
196pub struct SqliteSparseStore {
198 pool: Arc<ConnectionPool>,
199 is_file_backed: bool,
200 table_name: String,
201 namespace: String,
202 writer_task: Option<WriterTaskHandle>,
203}
204
205impl SqliteSparseStore {
206 pub fn new(
208 pool: Arc<ConnectionPool>,
209 is_file_backed: bool,
210 model_key: String,
211 namespace: String,
212 ) -> Result<Self, SqliteError> {
213 let table_name = format!("sparse_{}", model_key);
214 let writer_task = pool.writer_task_handle().ok().flatten();
218 Ok(Self {
219 pool,
220 is_file_backed,
221 table_name,
222 namespace,
223 writer_task,
224 })
225 }
226
227 async fn with_writer<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
241 where
242 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
243 R: Send + 'static,
244 {
245 if let Some(writer_task) = &self.writer_task {
246 return writer_task
247 .send(move |conn| f(conn).map_err(|e| map_err(e, op)))
248 .await;
249 }
250
251 let pool = Arc::clone(&self.pool);
252 tokio::task::spawn_blocking(move || {
253 let guard = pool.try_writer().map_err(|e| map_sqlite_err(e, op))?;
254 f(guard.conn()).map_err(|e| map_err(e, op))
255 })
256 .await
257 .map_err(|e| StorageError::driver(StorageCapability::Sparse, op, e))?
258 }
259
260 async fn with_reader<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
261 where
262 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
263 R: Send + 'static,
264 {
265 if self.is_file_backed {
266 let config = self.pool.config();
268 let path = config.path.as_ref().ok_or_else(|| StorageError::Pool {
269 operation: "sparse_reader".into(),
270 message: "in-memory databases do not support standalone connections".into(),
271 })?;
272 let conn = rusqlite::Connection::open_with_flags(
273 path,
274 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY
275 | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX
276 | rusqlite::OpenFlags::SQLITE_OPEN_URI,
277 )
278 .map_err(|e| map_err(e, op))?;
279 tokio::task::spawn_blocking(move || f(&conn).map_err(|e| map_err(e, op)))
280 .await
281 .map_err(|e| StorageError::driver(StorageCapability::Sparse, op, e))?
282 } else {
283 let pool = Arc::clone(&self.pool);
284 tokio::task::spawn_blocking(move || {
285 let guard = pool.reader().map_err(|e| map_sqlite_err(e, op))?;
286 f(guard.conn()).map_err(|e| map_err(e, op))
287 })
288 .await
289 .map_err(|e| StorageError::driver(StorageCapability::Sparse, op, e))?
290 }
291 }
292
293 async fn upsert_sparse_vector(
294 &self,
295 subject_id: Uuid,
296 kind: SubstrateKind,
297 namespace: &str,
298 field: &str,
299 vector: SparseVector,
300 ) -> Result<(), StorageError> {
301 let table = self.table_name.clone();
302 let ns = namespace.to_string();
303 let field = field.to_string();
304 let id_str = subject_id.to_string();
305 let kind_str = kind.to_string();
306
307 self.with_writer("sparse_upsert", move |conn| {
308 let indices_json = serde_json::to_string(&vector.indices).map_err(|e| {
309 rusqlite::Error::FromSqlConversionFailure(
310 0,
311 rusqlite::types::Type::Text,
312 Box::new(e),
313 )
314 })?;
315 let values_blob = f32_slice_as_bytes(&vector.values);
316 let now = chrono::Utc::now().timestamp();
317 let sql = format!(
318 "INSERT INTO {table} \
319 (subject_id, namespace, kind, field, indices_json, values_blob, updated_at) \
320 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \
321 ON CONFLICT(subject_id, namespace, field) DO UPDATE SET \
322 kind = excluded.kind, \
323 indices_json = excluded.indices_json, \
324 values_blob = excluded.values_blob, \
325 updated_at = excluded.updated_at"
326 );
327 conn.execute(
328 &sql,
329 rusqlite::params![
330 &id_str,
331 &ns,
332 &kind_str,
333 &field,
334 &indices_json,
335 values_blob,
336 now
337 ],
338 )?;
339 Ok(())
340 })
341 .await
342 }
343
344 async fn insert_sparse_batch(
345 &self,
346 records: Vec<SparseRecord>,
347 ) -> Result<BatchWriteSummary, StorageError> {
348 let table = self.table_name.clone();
349 let attempted = records.len() as u64;
350
351 if let Some(writer_task) = &self.writer_task {
356 let table2 = table.clone();
357 return writer_task
358 .send(move |conn| {
359 batch_insert_sparse_dml(conn, &table2, &records, attempted)
360 .map_err(|e| map_err(e, "sparse_insert_batch"))
361 })
362 .await;
363 }
364
365 self.with_writer("sparse_insert_batch", move |conn| {
368 conn.execute_batch("BEGIN IMMEDIATE")?;
369 let _tx_handle =
370 khive_storage::tx_registry::register(Some("sparse_insert_batch".to_string()));
371
372 let summary = batch_insert_sparse_dml(conn, &table, &records, attempted)?;
373
374 conn.execute_batch("COMMIT")?;
375 Ok(summary)
376 })
377 .await
378 }
379
380 async fn delete_sparse_subject(&self, subject_id: Uuid) -> Result<bool, StorageError> {
381 let table = self.table_name.clone();
382 let namespace = self.namespace.clone();
383 let id_str = subject_id.to_string();
384
385 self.with_writer("sparse_delete", move |conn| {
386 let sql = format!("DELETE FROM {table} WHERE subject_id = ?1 AND namespace = ?2");
387 let deleted = conn.execute(&sql, rusqlite::params![&id_str, &namespace])?;
388 Ok(deleted > 0)
389 })
390 .await
391 }
392
393 async fn search_sparse_vectors(
394 &self,
395 request: SparseSearchRequest,
396 ) -> Result<Vec<SparseSearchHit>, StorageError> {
397 request
398 .validate()
399 .map_err(|message| StorageError::InvalidInput {
400 capability: StorageCapability::Sparse,
401 operation: "sparse_search".into(),
402 message,
403 })?;
404
405 let table = self.table_name.clone();
406 let ns = request
407 .namespace
408 .clone()
409 .unwrap_or_else(|| self.namespace.clone());
410 let kind_filter = request.kind.map(|k| k.to_string());
411 let query = request.query;
412 let top_k = usize::try_from(request.top_k).map_err(|_| StorageError::InvalidInput {
413 capability: StorageCapability::Sparse,
414 operation: "sparse_search".into(),
415 message: "SparseSearchRequest: top_k does not fit usize".into(),
416 })?;
417 let heap_capacity = top_k
418 .checked_add(1)
419 .ok_or_else(|| StorageError::InvalidInput {
420 capability: StorageCapability::Sparse,
421 operation: "sparse_search".into(),
422 message: "SparseSearchRequest: top_k capacity overflow".into(),
423 })?;
424
425 self.with_reader("sparse_search", move |conn| {
426 let (sql, kind_str_ref) = if let Some(ref kind_str) = kind_filter {
428 (
429 format!(
430 "SELECT subject_id, indices_json, values_blob \
431 FROM {table} WHERE namespace = ?1 AND kind = ?2"
432 ),
433 Some(kind_str.as_str()),
434 )
435 } else {
436 (
437 format!(
438 "SELECT subject_id, indices_json, values_blob \
439 FROM {table} WHERE namespace = ?1"
440 ),
441 None,
442 )
443 };
444
445 let mut stmt = conn.prepare(&sql)?;
446
447 let rows: Vec<rusqlite::Result<(String, String, Vec<u8>)>> =
449 if let Some(kind_str) = kind_str_ref {
450 stmt.query_map(rusqlite::params![&ns, kind_str], |row| {
451 Ok((row.get(0)?, row.get(1)?, row.get(2)?))
452 })?
453 .collect()
454 } else {
455 stmt.query_map(rusqlite::params![&ns], |row| {
456 Ok((row.get(0)?, row.get(1)?, row.get(2)?))
457 })?
458 .collect()
459 };
460
461 let mut heap: BinaryHeap<Reverse<ScoredCandidate>> =
463 BinaryHeap::with_capacity(heap_capacity);
464
465 for row_result in rows {
466 let (id_str, indices_json, values_blob) = row_result?;
467
468 let subject_id = Uuid::parse_str(&id_str).map_err(|e| {
469 rusqlite::Error::FromSqlConversionFailure(
470 0,
471 rusqlite::types::Type::Text,
472 Box::new(e),
473 )
474 })?;
475
476 let stored_indices: Vec<u32> =
478 serde_json::from_str(&indices_json).map_err(|e| {
479 rusqlite::Error::FromSqlConversionFailure(
480 0,
481 rusqlite::types::Type::Text,
482 Box::<dyn std::error::Error + Send + Sync>::from(format!(
483 "corrupt sparse row {id_str}: invalid indices JSON: {e}"
484 )),
485 )
486 })?;
487
488 if values_blob.len() % 4 != 0 {
489 return Err(rusqlite::Error::FromSqlConversionFailure(
490 0,
491 rusqlite::types::Type::Blob,
492 Box::<dyn std::error::Error + Send + Sync>::from(format!(
493 "corrupt sparse row {id_str}: values blob length {} not a multiple of 4",
494 values_blob.len()
495 )),
496 ));
497 }
498
499 let stored_values: Vec<f32> = values_blob
500 .chunks_exact(4)
501 .map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
502 .collect();
503
504 validate_persisted_sparse(&id_str, &stored_indices, &stored_values)?;
505
506 let score = sparse_dot_product(
507 &query.indices,
508 &query.values,
509 &stored_indices,
510 &stored_values,
511 );
512
513 heap.push(Reverse(ScoredCandidate { score, subject_id }));
514 if heap.len() > top_k {
515 heap.pop();
516 }
517 }
518
519 let mut top: Vec<_> = heap.into_iter().map(|Reverse(c)| c).collect();
521 top.sort_by(|a, b| {
522 b.score
523 .partial_cmp(&a.score)
524 .unwrap_or(std::cmp::Ordering::Equal)
525 .then_with(|| a.subject_id.cmp(&b.subject_id))
526 });
527
528 let hits = top
529 .into_iter()
530 .enumerate()
531 .map(|(i, c)| SparseSearchHit {
532 subject_id: c.subject_id,
533 score: DeterministicScore::from_f64(c.score),
534 rank: (i + 1) as u32,
535 })
536 .collect();
537
538 Ok(hits)
539 })
540 .await
541 }
542
543 async fn count_sparse_rows(&self) -> Result<u64, StorageError> {
544 let table = self.table_name.clone();
545 let namespace = self.namespace.clone();
546 self.with_reader("sparse_count", move |conn| {
547 let sql = format!("SELECT COUNT(*) FROM {table} WHERE namespace = ?1");
548 let count: i64 =
549 conn.query_row(&sql, rusqlite::params![&namespace], |row| row.get(0))?;
550 Ok(count as u64)
551 })
552 .await
553 }
554}
555
556#[derive(PartialEq)]
559struct ScoredCandidate {
560 score: f64,
561 subject_id: Uuid,
562}
563
564impl Eq for ScoredCandidate {}
565
566impl PartialOrd for ScoredCandidate {
567 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
568 Some(self.cmp(other))
569 }
570}
571
572impl Ord for ScoredCandidate {
573 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
574 match self
577 .score
578 .partial_cmp(&other.score)
579 .unwrap_or(std::cmp::Ordering::Equal)
580 {
581 std::cmp::Ordering::Equal => other.subject_id.cmp(&self.subject_id),
582 ord => ord,
583 }
584 }
585}
586
587fn validate_persisted_sparse(
591 subject_id: &str,
592 indices: &[u32],
593 values: &[f32],
594) -> Result<(), rusqlite::Error> {
595 if indices.len() != values.len() {
596 return Err(rusqlite::Error::FromSqlConversionFailure(
597 0,
598 rusqlite::types::Type::Blob,
599 Box::<dyn std::error::Error + Send + Sync>::from(format!(
600 "corrupt sparse row {subject_id}: indices len {} != values len {}",
601 indices.len(),
602 values.len()
603 )),
604 ));
605 }
606 for (i, v) in values.iter().enumerate() {
607 if !v.is_finite() {
608 return Err(rusqlite::Error::FromSqlConversionFailure(
609 0,
610 rusqlite::types::Type::Blob,
611 Box::<dyn std::error::Error + Send + Sync>::from(format!(
612 "corrupt sparse row {subject_id}: non-finite value at position {i}: {v}"
613 )),
614 ));
615 }
616 }
617 for window in indices.windows(2) {
618 if window[0] >= window[1] {
619 return Err(rusqlite::Error::FromSqlConversionFailure(
620 0,
621 rusqlite::types::Type::Blob,
622 Box::<dyn std::error::Error + Send + Sync>::from(format!(
623 "corrupt sparse row {subject_id}: indices not strictly increasing at {} >= {}",
624 window[0], window[1]
625 )),
626 ));
627 }
628 }
629 Ok(())
630}
631
632fn sparse_dot_product(q_idx: &[u32], q_val: &[f32], s_idx: &[u32], s_val: &[f32]) -> f64 {
634 let mut dot = 0.0f64;
635 let mut qi = 0;
636 let mut si = 0;
637 while qi < q_idx.len() && si < s_idx.len() {
638 match q_idx[qi].cmp(&s_idx[si]) {
639 std::cmp::Ordering::Equal => {
640 dot += q_val[qi] as f64 * s_val[si] as f64;
641 qi += 1;
642 si += 1;
643 }
644 std::cmp::Ordering::Less => qi += 1,
645 std::cmp::Ordering::Greater => si += 1,
646 }
647 }
648 dot
649}
650
651#[async_trait]
652impl SparseStore for SqliteSparseStore {
653 async fn insert_sparse(
654 &self,
655 subject_id: Uuid,
656 kind: SubstrateKind,
657 namespace: &str,
658 field: &str,
659 vector: SparseVector,
660 ) -> Result<(), StorageError> {
661 validate_sparse_vector(&vector, "sparse_insert")?;
662 self.upsert_sparse_vector(subject_id, kind, namespace, field, vector)
663 .await
664 }
665
666 async fn insert_batch(
667 &self,
668 records: Vec<SparseRecord>,
669 ) -> Result<BatchWriteSummary, StorageError> {
670 self.insert_sparse_batch(records).await
671 }
672
673 async fn delete(&self, subject_id: Uuid) -> Result<bool, StorageError> {
674 self.delete_sparse_subject(subject_id).await
675 }
676
677 async fn search_sparse(
678 &self,
679 request: SparseSearchRequest,
680 ) -> Result<Vec<SparseSearchHit>, StorageError> {
681 validate_sparse_vector(&request.query, "sparse_search")?;
682 self.search_sparse_vectors(request).await
683 }
684
685 async fn count(&self) -> Result<u64, StorageError> {
686 self.count_sparse_rows().await
687 }
688}
689
690#[cfg(test)]
691mod tests {
692 use super::*;
693 use crate::pool::{ConnectionPool, PoolConfig};
694
695 fn make_store(model_key: &str) -> SqliteSparseStore {
696 let config = PoolConfig {
697 path: None,
698 ..PoolConfig::default()
699 };
700 let pool = Arc::new(ConnectionPool::new(config).expect("pool"));
701 {
703 let writer = pool.try_writer().expect("writer");
704 ensure_sparse_schema(writer.conn(), model_key).expect("schema");
705 }
706 SqliteSparseStore::new(pool, false, model_key.to_string(), "ns:test".to_string())
707 .expect("store")
708 }
709
710 fn sv(indices: Vec<u32>, values: Vec<f32>) -> SparseVector {
711 SparseVector { indices, values }
712 }
713
714 #[tokio::test]
715 async fn insert_and_count() {
716 let store = make_store("test_count");
717 let id = Uuid::new_v4();
718 store
719 .insert_sparse(
720 id,
721 SubstrateKind::Entity,
722 "ns:test",
723 "body",
724 sv(vec![0, 2], vec![1.0, 0.5]),
725 )
726 .await
727 .unwrap();
728 assert_eq!(store.count().await.unwrap(), 1);
729 }
730
731 #[tokio::test]
732 async fn insert_and_search() {
733 let store = make_store("test_search");
734 let id1 = Uuid::new_v4();
735 let id2 = Uuid::new_v4();
736 store
737 .insert_sparse(
738 id1,
739 SubstrateKind::Entity,
740 "ns:test",
741 "body",
742 sv(vec![0, 1], vec![1.0, 0.0]),
743 )
744 .await
745 .unwrap();
746 store
747 .insert_sparse(
748 id2,
749 SubstrateKind::Entity,
750 "ns:test",
751 "body",
752 sv(vec![0, 1], vec![0.0, 1.0]),
753 )
754 .await
755 .unwrap();
756
757 let hits = store
758 .search_sparse(SparseSearchRequest {
759 query: sv(vec![0], vec![1.0]),
760 top_k: 2,
761 namespace: Some("ns:test".into()),
762 kind: None,
763 })
764 .await
765 .unwrap();
766
767 assert!(!hits.is_empty());
768 assert_eq!(hits[0].subject_id, id1, "id1 should rank first");
769 assert_eq!(hits[0].rank, 1);
770 }
771
772 #[tokio::test]
775 async fn sparse_top_k_u32_max_rejected() {
776 let store = make_store("test_top_k_max");
777 let id = Uuid::new_v4();
778 store
779 .insert_sparse(
780 id,
781 SubstrateKind::Entity,
782 "ns:test",
783 "body",
784 sv(vec![0], vec![1.0]),
785 )
786 .await
787 .unwrap();
788
789 let result = store
790 .search_sparse(SparseSearchRequest {
791 query: sv(vec![0], vec![1.0]),
792 top_k: u32::MAX,
793 namespace: Some("ns:test".into()),
794 kind: None,
795 })
796 .await;
797
798 assert!(
799 matches!(result, Err(StorageError::InvalidInput { .. })),
800 "expected InvalidInput, got {result:?}"
801 );
802 }
803
804 #[tokio::test]
805 async fn delete_removes_row() {
806 let store = make_store("test_delete");
807 let id = Uuid::new_v4();
808 store
809 .insert_sparse(
810 id,
811 SubstrateKind::Entity,
812 "ns:test",
813 "body",
814 sv(vec![1], vec![1.0]),
815 )
816 .await
817 .unwrap();
818 assert_eq!(store.count().await.unwrap(), 1);
819
820 let deleted = store.delete(id).await.unwrap();
821 assert!(deleted);
822 assert_eq!(store.count().await.unwrap(), 0);
823 }
824
825 #[tokio::test]
826 async fn mismatched_lengths_rejected() {
827 let store = make_store("test_mismatch");
828 let result = store
829 .insert_sparse(
830 Uuid::new_v4(),
831 SubstrateKind::Entity,
832 "ns:test",
833 "body",
834 SparseVector {
835 indices: vec![0, 1],
836 values: vec![1.0],
837 },
838 )
839 .await;
840 assert!(matches!(result, Err(StorageError::InvalidInput { .. })));
841 }
842
843 #[tokio::test]
844 async fn non_finite_values_rejected() {
845 let store = make_store("test_nonfinite");
846 let result = store
847 .insert_sparse(
848 Uuid::new_v4(),
849 SubstrateKind::Entity,
850 "ns:test",
851 "body",
852 sv(vec![0], vec![f32::NAN]),
853 )
854 .await;
855 assert!(matches!(result, Err(StorageError::InvalidInput { .. })));
856 }
857
858 #[tokio::test]
859 async fn duplicate_indices_rejected() {
860 let store = make_store("test_dup_idx");
861 let result = store
862 .insert_sparse(
863 Uuid::new_v4(),
864 SubstrateKind::Entity,
865 "ns:test",
866 "body",
867 sv(vec![0, 0], vec![1.0, 2.0]),
868 )
869 .await;
870 assert!(matches!(result, Err(StorageError::InvalidInput { .. })));
871 }
872
873 #[tokio::test]
874 async fn empty_vector_rejected() {
875 let store = make_store("test_empty");
876 let result = store
877 .insert_sparse(
878 Uuid::new_v4(),
879 SubstrateKind::Entity,
880 "ns:test",
881 "body",
882 sv(vec![], vec![]),
883 )
884 .await;
885 assert!(matches!(result, Err(StorageError::InvalidInput { .. })));
886 }
887
888 #[tokio::test]
889 async fn namespace_isolation() {
890 let store = make_store("test_ns_iso");
891 let id = Uuid::new_v4();
892 store
893 .insert_sparse(
894 id,
895 SubstrateKind::Entity,
896 "ns:a",
897 "body",
898 sv(vec![0], vec![1.0]),
899 )
900 .await
901 .unwrap();
902
903 let hits = store
904 .search_sparse(SparseSearchRequest {
905 query: sv(vec![0], vec![1.0]),
906 top_k: 5,
907 namespace: Some("ns:b".into()),
908 kind: None,
909 })
910 .await
911 .unwrap();
912 assert!(hits.is_empty(), "ns:b should not see ns:a data");
913 }
914
915 #[tokio::test]
916 async fn insert_batch_happy_path() {
917 use chrono::Utc;
918 use khive_types::SubstrateKind;
919
920 let store = make_store("test_batch");
921 let id1 = Uuid::new_v4();
922 let id2 = Uuid::new_v4();
923 let records = vec![
924 SparseRecord {
925 subject_id: id1,
926 kind: SubstrateKind::Entity,
927 namespace: "ns:test".into(),
928 field: "body".into(),
929 vector: sv(vec![0, 3], vec![0.5, 0.8]),
930 updated_at: Utc::now(),
931 },
932 SparseRecord {
933 subject_id: id2,
934 kind: SubstrateKind::Entity,
935 namespace: "ns:test".into(),
936 field: "body".into(),
937 vector: sv(vec![1], vec![1.0]),
938 updated_at: Utc::now(),
939 },
940 ];
941 let summary = store.insert_batch(records).await.unwrap();
942 assert_eq!(summary.attempted, 2);
943 assert_eq!(summary.affected, 2);
944 assert_eq!(summary.failed, 0);
945 assert_eq!(store.count().await.unwrap(), 2);
946 }
947
948 #[tokio::test]
960 async fn insert_batch_routes_through_writer_task_when_flag_enabled() {
961 use chrono::Utc;
962 use khive_types::SubstrateKind;
963
964 let model_key = "write_queue_flag_test";
965 let dir = tempfile::tempdir().unwrap();
966 let path = dir.path().join("write_queue_sparse.db");
967 let pool_cfg = PoolConfig {
968 path: Some(path.clone()),
969 write_queue_enabled: true,
970 ..PoolConfig::default()
971 };
972 let pool = Arc::new(ConnectionPool::new(pool_cfg).expect("pool"));
973 {
974 let writer = pool.writer().expect("writer");
975 ensure_sparse_schema(writer.conn(), model_key).expect("schema");
976 }
977
978 let store = SqliteSparseStore::new(
979 Arc::clone(&pool),
980 true,
981 model_key.to_string(),
982 "ns:test".to_string(),
983 )
984 .expect("store");
985
986 let id1 = Uuid::new_v4();
987 let id2 = Uuid::new_v4();
988 let records = vec![
989 SparseRecord {
990 subject_id: id1,
991 kind: SubstrateKind::Entity,
992 namespace: "ns:test".into(),
993 field: "body".into(),
994 vector: sv(vec![0, 3], vec![0.5, 0.8]),
995 updated_at: Utc::now(),
996 },
997 SparseRecord {
998 subject_id: id2,
999 kind: SubstrateKind::Entity,
1000 namespace: "ns:test".into(),
1001 field: "body".into(),
1002 vector: sv(vec![1], vec![1.0]),
1003 updated_at: Utc::now(),
1004 },
1005 ];
1006
1007 let summary = store.insert_batch(records).await.unwrap();
1008 assert_eq!(summary.attempted, 2);
1009 assert_eq!(summary.affected, 2);
1010 assert_eq!(summary.failed, 0);
1011 assert_eq!(store.count().await.unwrap(), 2);
1012 assert_eq!(
1013 pool.writer_task_spawn_count(),
1014 1,
1015 "the flag-ON path must actually spawn and use the writer task"
1016 );
1017 }
1018}