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 BatchWriteErrorClass, BatchWriteRetryability, BatchWriteSummary, SparseRecord, SparseSearchHit,
14 SparseSearchRequest, SparseVector,
15};
16use khive_storage::{SparseStore, StorageCapability};
17use khive_types::SubstrateKind;
18
19use crate::error::SqliteError;
20use crate::pool::ConnectionPool;
21use crate::writer_task::WriterTaskHandle;
22
23fn map_err(e: rusqlite::Error, op: &'static str) -> StorageError {
24 StorageError::driver(StorageCapability::Sparse, op, e)
25}
26
27fn map_sqlite_err(e: SqliteError, op: &'static str) -> StorageError {
28 e.into_storage_error(StorageCapability::Sparse, op)
29}
30
31fn validate_sparse_vector(vector: &SparseVector, op: &'static str) -> Result<(), StorageError> {
38 if vector.indices.len() != vector.values.len() {
39 return Err(StorageError::InvalidInput {
40 capability: StorageCapability::Sparse,
41 operation: op.into(),
42 message: format!(
43 "indices length ({}) != values length ({})",
44 vector.indices.len(),
45 vector.values.len()
46 ),
47 });
48 }
49 if vector.indices.is_empty() {
50 return Err(StorageError::InvalidInput {
51 capability: StorageCapability::Sparse,
52 operation: op.into(),
53 message: "sparse vector must have at least one element".into(),
54 });
55 }
56 for (i, v) in vector.values.iter().enumerate() {
57 if !v.is_finite() {
58 return Err(StorageError::InvalidInput {
59 capability: StorageCapability::Sparse,
60 operation: op.into(),
61 message: format!("non-finite value at position {i}: {v}"),
62 });
63 }
64 }
65 for window in vector.indices.windows(2) {
67 if window[0] >= window[1] {
68 return Err(StorageError::InvalidInput {
69 capability: StorageCapability::Sparse,
70 operation: op.into(),
71 message: format!(
72 "indices must be strictly increasing; found {} then {}",
73 window[0], window[1]
74 ),
75 });
76 }
77 }
78 Ok(())
79}
80
81fn f32_slice_as_bytes(data: &[f32]) -> &[u8] {
83 unsafe { std::slice::from_raw_parts(data.as_ptr() as *const u8, std::mem::size_of_val(data)) }
85}
86
87fn batch_insert_sparse_dml(
96 conn: &rusqlite::Connection,
97 table: &str,
98 records: &[SparseRecord],
99 attempted: u64,
100) -> Result<BatchWriteSummary, rusqlite::Error> {
101 let sql = format!(
102 "INSERT INTO {table} \
103 (subject_id, namespace, kind, field, indices_json, values_blob, updated_at) \
104 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \
105 ON CONFLICT(subject_id, namespace, field) DO UPDATE SET \
106 kind = excluded.kind, \
107 indices_json = excluded.indices_json, \
108 values_blob = excluded.values_blob, \
109 updated_at = excluded.updated_at"
110 );
111
112 let mut summary = BatchWriteSummary {
113 attempted,
114 ..BatchWriteSummary::default()
115 };
116
117 for (index, record) in records.iter().enumerate() {
118 let item_id = Some(record.subject_id.to_string());
119 if record.vector.indices.len() != record.vector.values.len()
121 || record.vector.indices.is_empty()
122 || record.vector.values.iter().any(|v| !v.is_finite())
123 || record.vector.indices.windows(2).any(|w| w[0] >= w[1])
124 {
125 summary.record_failure(
126 index,
127 item_id,
128 BatchWriteErrorClass::InvalidInput,
129 BatchWriteRetryability::Permanent,
130 format!("invalid sparse vector for subject {}", record.subject_id),
131 );
132 continue;
133 }
134
135 let indices_json = match serde_json::to_string(&record.vector.indices) {
136 Ok(j) => j,
137 Err(e) => {
138 summary.record_failure(
139 index,
140 item_id,
141 BatchWriteErrorClass::Serialization,
142 BatchWriteRetryability::Permanent,
143 e.to_string(),
144 );
145 continue;
146 }
147 };
148 let values_blob = f32_slice_as_bytes(&record.vector.values);
149 let now = record.updated_at.timestamp();
150 let id_str = record.subject_id.to_string();
151 let kind_str = record.kind.to_string();
152
153 match conn.execute(
154 &sql,
155 rusqlite::params![
156 &id_str,
157 &record.namespace,
158 &kind_str,
159 &record.field,
160 &indices_json,
161 values_blob,
162 now
163 ],
164 ) {
165 Ok(_) => summary.affected = summary.affected.saturating_add(1),
166 Err(e) => {
167 let (class, retryability) = super::classify_batch_sqlite_error(&e);
168 summary.record_failure(index, item_id, class, retryability, e.to_string());
169 }
170 }
171 }
172
173 Ok(summary)
174}
175
176pub(crate) fn ensure_sparse_schema(
178 conn: &rusqlite::Connection,
179 model_key: &str,
180) -> Result<(), rusqlite::Error> {
181 let table = format!("sparse_{}", model_key);
182 let ddl = format!(
183 "CREATE TABLE IF NOT EXISTS {table} (\
184 subject_id TEXT NOT NULL, \
185 namespace TEXT NOT NULL, \
186 kind TEXT NOT NULL, \
187 field TEXT NOT NULL, \
188 indices_json TEXT NOT NULL, \
189 values_blob BLOB NOT NULL, \
190 updated_at INTEGER NOT NULL, \
191 PRIMARY KEY(subject_id, namespace, field)\
192 ); \
193 CREATE INDEX IF NOT EXISTS idx_{table}_namespace_kind \
194 ON {table}(namespace, kind);"
195 );
196 conn.execute_batch(&ddl)
197}
198
199pub struct SqliteSparseStore {
201 pool: Arc<ConnectionPool>,
202 table_name: String,
203 namespace: String,
204 writer_task: Option<WriterTaskHandle>,
205}
206
207impl SqliteSparseStore {
208 pub fn new(
210 pool: Arc<ConnectionPool>,
211 _is_file_backed: bool,
212 model_key: String,
213 namespace: String,
214 ) -> Result<Self, SqliteError> {
215 let table_name = format!("sparse_{}", model_key);
216 let writer_task = pool.writer_task_handle().ok().flatten();
221 Ok(Self {
222 pool,
223 table_name,
224 namespace,
225 writer_task,
226 })
227 }
228
229 fn current_writer_task(
230 &self,
231 operation: &'static str,
232 ) -> Result<Option<WriterTaskHandle>, StorageError> {
233 self.pool
234 .writer_task_for_write(self.writer_task.as_ref(), operation)
235 }
236
237 async fn with_writer<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
251 where
252 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
253 R: Send + 'static,
254 {
255 if let Some(writer_task) = self.current_writer_task(op)? {
256 return writer_task
257 .send_bounded(move |conn| f(conn).map_err(|e| map_err(e, op)))
258 .await;
259 }
260
261 self.pool
262 .record_direct_route(crate::timeout_sink::Site::DirectRouteSparseGeneralWrite);
263 let pool = Arc::clone(&self.pool);
264 tokio::task::spawn_blocking(move || {
265 let guard = pool
266 .autocommit_write_unit()
267 .map_err(|e| map_sqlite_err(e, op))?;
268 f(guard.conn())
269 .map_err(|e| map_err(e, op))
270 .inspect_err(|error| pool.record_direct_writer_error(error))
271 })
272 .await
273 .map_err(|e| StorageError::driver(StorageCapability::Sparse, op, e))?
274 }
275
276 async fn with_reader<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
277 where
278 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
279 R: Send + 'static,
280 {
281 super::run_pooled_store_read(
282 Arc::clone(&self.pool),
283 StorageCapability::Sparse,
284 op,
285 move |conn| f(conn).map_err(|error| map_err(error, op)),
286 )
287 .await
288 }
289
290 async fn upsert_sparse_vector(
291 &self,
292 subject_id: Uuid,
293 kind: SubstrateKind,
294 namespace: &str,
295 field: &str,
296 vector: SparseVector,
297 ) -> Result<(), StorageError> {
298 let table = self.table_name.clone();
299 let ns = namespace.to_string();
300 let field = field.to_string();
301 let id_str = subject_id.to_string();
302 let kind_str = kind.to_string();
303
304 self.with_writer("sparse_upsert", move |conn| {
305 let indices_json = serde_json::to_string(&vector.indices).map_err(|e| {
306 rusqlite::Error::FromSqlConversionFailure(
307 0,
308 rusqlite::types::Type::Text,
309 Box::new(e),
310 )
311 })?;
312 let values_blob = f32_slice_as_bytes(&vector.values);
313 let now = chrono::Utc::now().timestamp();
314 let sql = format!(
315 "INSERT INTO {table} \
316 (subject_id, namespace, kind, field, indices_json, values_blob, updated_at) \
317 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \
318 ON CONFLICT(subject_id, namespace, field) DO UPDATE SET \
319 kind = excluded.kind, \
320 indices_json = excluded.indices_json, \
321 values_blob = excluded.values_blob, \
322 updated_at = excluded.updated_at"
323 );
324 conn.execute(
325 &sql,
326 rusqlite::params![
327 &id_str,
328 &ns,
329 &kind_str,
330 &field,
331 &indices_json,
332 values_blob,
333 now
334 ],
335 )?;
336 Ok(())
337 })
338 .await
339 }
340
341 async fn insert_sparse_batch(
342 &self,
343 records: Vec<SparseRecord>,
344 ) -> Result<BatchWriteSummary, StorageError> {
345 let table = self.table_name.clone();
346 let attempted = records.len() as u64;
347
348 if let Some(writer_task) = self.current_writer_task("sparse_insert_batch")? {
353 let table2 = table.clone();
354 return writer_task
355 .send_bounded(move |conn| {
356 batch_insert_sparse_dml(conn, &table2, &records, attempted)
357 .map_err(|e| map_err(e, "sparse_insert_batch"))
358 })
359 .await;
360 }
361
362 let origin = self.pool.origin();
365 self.with_writer("sparse_insert_batch", move |conn| {
366 conn.execute_batch("BEGIN IMMEDIATE")?;
367 let _tx_handle = khive_storage::tx_registry::register_scoped(
368 Some("sparse_insert_batch".to_string()),
369 origin,
370 );
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 #[allow(unknown_lints, clippy::chunks_exact_to_as_chunks)]
502 let stored_values: Vec<f32> = values_blob
503 .chunks_exact(4)
504 .map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
505 .collect();
506
507 validate_persisted_sparse(&id_str, &stored_indices, &stored_values)?;
508
509 let score = sparse_dot_product(
510 &query.indices,
511 &query.values,
512 &stored_indices,
513 &stored_values,
514 );
515
516 heap.push(Reverse(ScoredCandidate { score, subject_id }));
517 if heap.len() > top_k {
518 heap.pop();
519 }
520 }
521
522 let mut top: Vec<_> = heap.into_iter().map(|Reverse(c)| c).collect();
524 top.sort_by(|a, b| {
525 b.score
526 .partial_cmp(&a.score)
527 .unwrap_or(std::cmp::Ordering::Equal)
528 .then_with(|| a.subject_id.cmp(&b.subject_id))
529 });
530
531 let hits = top
532 .into_iter()
533 .enumerate()
534 .map(|(i, c)| SparseSearchHit {
535 subject_id: c.subject_id,
536 score: DeterministicScore::from_f64(c.score),
537 rank: (i + 1) as u32,
538 })
539 .collect();
540
541 Ok(hits)
542 })
543 .await
544 }
545
546 async fn count_sparse_rows(&self) -> Result<u64, StorageError> {
547 let table = self.table_name.clone();
548 let namespace = self.namespace.clone();
549 self.with_reader("sparse_count", move |conn| {
550 let sql = format!("SELECT COUNT(*) FROM {table} WHERE namespace = ?1");
551 let count: i64 =
552 conn.query_row(&sql, rusqlite::params![&namespace], |row| row.get(0))?;
553 Ok(count as u64)
554 })
555 .await
556 }
557}
558
559#[derive(PartialEq)]
562struct ScoredCandidate {
563 score: f64,
564 subject_id: Uuid,
565}
566
567impl Eq for ScoredCandidate {}
568
569impl PartialOrd for ScoredCandidate {
570 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
571 Some(self.cmp(other))
572 }
573}
574
575impl Ord for ScoredCandidate {
576 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
577 match self
580 .score
581 .partial_cmp(&other.score)
582 .unwrap_or(std::cmp::Ordering::Equal)
583 {
584 std::cmp::Ordering::Equal => other.subject_id.cmp(&self.subject_id),
585 ord => ord,
586 }
587 }
588}
589
590fn validate_persisted_sparse(
594 subject_id: &str,
595 indices: &[u32],
596 values: &[f32],
597) -> Result<(), rusqlite::Error> {
598 if indices.len() != values.len() {
599 return Err(rusqlite::Error::FromSqlConversionFailure(
600 0,
601 rusqlite::types::Type::Blob,
602 Box::<dyn std::error::Error + Send + Sync>::from(format!(
603 "corrupt sparse row {subject_id}: indices len {} != values len {}",
604 indices.len(),
605 values.len()
606 )),
607 ));
608 }
609 for (i, v) in values.iter().enumerate() {
610 if !v.is_finite() {
611 return Err(rusqlite::Error::FromSqlConversionFailure(
612 0,
613 rusqlite::types::Type::Blob,
614 Box::<dyn std::error::Error + Send + Sync>::from(format!(
615 "corrupt sparse row {subject_id}: non-finite value at position {i}: {v}"
616 )),
617 ));
618 }
619 }
620 for window in indices.windows(2) {
621 if window[0] >= window[1] {
622 return Err(rusqlite::Error::FromSqlConversionFailure(
623 0,
624 rusqlite::types::Type::Blob,
625 Box::<dyn std::error::Error + Send + Sync>::from(format!(
626 "corrupt sparse row {subject_id}: indices not strictly increasing at {} >= {}",
627 window[0], window[1]
628 )),
629 ));
630 }
631 }
632 Ok(())
633}
634
635fn sparse_dot_product(q_idx: &[u32], q_val: &[f32], s_idx: &[u32], s_val: &[f32]) -> f64 {
637 let mut dot = 0.0f64;
638 let mut qi = 0;
639 let mut si = 0;
640 while qi < q_idx.len() && si < s_idx.len() {
641 match q_idx[qi].cmp(&s_idx[si]) {
642 std::cmp::Ordering::Equal => {
643 dot += q_val[qi] as f64 * s_val[si] as f64;
644 qi += 1;
645 si += 1;
646 }
647 std::cmp::Ordering::Less => qi += 1,
648 std::cmp::Ordering::Greater => si += 1,
649 }
650 }
651 dot
652}
653
654#[async_trait]
655impl SparseStore for SqliteSparseStore {
656 async fn insert_sparse(
657 &self,
658 subject_id: Uuid,
659 kind: SubstrateKind,
660 namespace: &str,
661 field: &str,
662 vector: SparseVector,
663 ) -> Result<(), StorageError> {
664 validate_sparse_vector(&vector, "sparse_insert")?;
665 self.upsert_sparse_vector(subject_id, kind, namespace, field, vector)
666 .await
667 }
668
669 async fn insert_batch(
670 &self,
671 records: Vec<SparseRecord>,
672 ) -> Result<BatchWriteSummary, StorageError> {
673 self.insert_sparse_batch(records).await
674 }
675
676 async fn delete(&self, subject_id: Uuid) -> Result<bool, StorageError> {
677 self.delete_sparse_subject(subject_id).await
678 }
679
680 async fn search_sparse(
681 &self,
682 request: SparseSearchRequest,
683 ) -> Result<Vec<SparseSearchHit>, StorageError> {
684 validate_sparse_vector(&request.query, "sparse_search")?;
685 self.search_sparse_vectors(request).await
686 }
687
688 async fn count(&self) -> Result<u64, StorageError> {
689 self.count_sparse_rows().await
690 }
691}
692
693#[cfg(test)]
694mod tests {
695 use super::*;
696 use crate::pool::{ConnectionPool, PoolConfig};
697
698 fn make_store(model_key: &str) -> SqliteSparseStore {
699 let config = PoolConfig {
700 path: None,
701 ..PoolConfig::default()
702 };
703 let pool = Arc::new(ConnectionPool::new(config).expect("pool"));
704 {
706 let writer = pool.try_writer().expect("writer");
707 ensure_sparse_schema(writer.conn(), model_key).expect("schema");
708 }
709 SqliteSparseStore::new(pool, false, model_key.to_string(), "ns:test".to_string())
710 .expect("store")
711 }
712
713 fn sv(indices: Vec<u32>, values: Vec<f32>) -> SparseVector {
714 SparseVector { indices, values }
715 }
716
717 #[tokio::test]
718 async fn insert_and_count() {
719 let store = make_store("test_count");
720 let id = Uuid::new_v4();
721 store
722 .insert_sparse(
723 id,
724 SubstrateKind::Entity,
725 "ns:test",
726 "body",
727 sv(vec![0, 2], vec![1.0, 0.5]),
728 )
729 .await
730 .unwrap();
731 assert_eq!(store.count().await.unwrap(), 1);
732 }
733
734 #[tokio::test]
735 async fn insert_and_search() {
736 let store = make_store("test_search");
737 let id1 = Uuid::new_v4();
738 let id2 = Uuid::new_v4();
739 store
740 .insert_sparse(
741 id1,
742 SubstrateKind::Entity,
743 "ns:test",
744 "body",
745 sv(vec![0, 1], vec![1.0, 0.0]),
746 )
747 .await
748 .unwrap();
749 store
750 .insert_sparse(
751 id2,
752 SubstrateKind::Entity,
753 "ns:test",
754 "body",
755 sv(vec![0, 1], vec![0.0, 1.0]),
756 )
757 .await
758 .unwrap();
759
760 let hits = store
761 .search_sparse(SparseSearchRequest {
762 query: sv(vec![0], vec![1.0]),
763 top_k: 2,
764 namespace: Some("ns:test".into()),
765 kind: None,
766 })
767 .await
768 .unwrap();
769
770 assert!(!hits.is_empty());
771 assert_eq!(hits[0].subject_id, id1, "id1 should rank first");
772 assert_eq!(hits[0].rank, 1);
773 }
774
775 #[tokio::test]
778 async fn sparse_top_k_u32_max_rejected() {
779 let store = make_store("test_top_k_max");
780 let id = Uuid::new_v4();
781 store
782 .insert_sparse(
783 id,
784 SubstrateKind::Entity,
785 "ns:test",
786 "body",
787 sv(vec![0], vec![1.0]),
788 )
789 .await
790 .unwrap();
791
792 let result = store
793 .search_sparse(SparseSearchRequest {
794 query: sv(vec![0], vec![1.0]),
795 top_k: u32::MAX,
796 namespace: Some("ns:test".into()),
797 kind: None,
798 })
799 .await;
800
801 assert!(
802 matches!(result, Err(StorageError::InvalidInput { .. })),
803 "expected InvalidInput, got {result:?}"
804 );
805 }
806
807 #[tokio::test]
808 async fn delete_removes_row() {
809 let store = make_store("test_delete");
810 let id = Uuid::new_v4();
811 store
812 .insert_sparse(
813 id,
814 SubstrateKind::Entity,
815 "ns:test",
816 "body",
817 sv(vec![1], vec![1.0]),
818 )
819 .await
820 .unwrap();
821 assert_eq!(store.count().await.unwrap(), 1);
822
823 let deleted = store.delete(id).await.unwrap();
824 assert!(deleted);
825 assert_eq!(store.count().await.unwrap(), 0);
826 }
827
828 #[tokio::test]
829 async fn mismatched_lengths_rejected() {
830 let store = make_store("test_mismatch");
831 let result = store
832 .insert_sparse(
833 Uuid::new_v4(),
834 SubstrateKind::Entity,
835 "ns:test",
836 "body",
837 SparseVector {
838 indices: vec![0, 1],
839 values: vec![1.0],
840 },
841 )
842 .await;
843 assert!(matches!(result, Err(StorageError::InvalidInput { .. })));
844 }
845
846 #[tokio::test]
847 async fn non_finite_values_rejected() {
848 let store = make_store("test_nonfinite");
849 let result = store
850 .insert_sparse(
851 Uuid::new_v4(),
852 SubstrateKind::Entity,
853 "ns:test",
854 "body",
855 sv(vec![0], vec![f32::NAN]),
856 )
857 .await;
858 assert!(matches!(result, Err(StorageError::InvalidInput { .. })));
859 }
860
861 #[tokio::test]
862 async fn duplicate_indices_rejected() {
863 let store = make_store("test_dup_idx");
864 let result = store
865 .insert_sparse(
866 Uuid::new_v4(),
867 SubstrateKind::Entity,
868 "ns:test",
869 "body",
870 sv(vec![0, 0], vec![1.0, 2.0]),
871 )
872 .await;
873 assert!(matches!(result, Err(StorageError::InvalidInput { .. })));
874 }
875
876 #[tokio::test]
877 async fn empty_vector_rejected() {
878 let store = make_store("test_empty");
879 let result = store
880 .insert_sparse(
881 Uuid::new_v4(),
882 SubstrateKind::Entity,
883 "ns:test",
884 "body",
885 sv(vec![], vec![]),
886 )
887 .await;
888 assert!(matches!(result, Err(StorageError::InvalidInput { .. })));
889 }
890
891 #[tokio::test]
892 async fn namespace_isolation() {
893 let store = make_store("test_ns_iso");
894 let id = Uuid::new_v4();
895 store
896 .insert_sparse(
897 id,
898 SubstrateKind::Entity,
899 "ns:a",
900 "body",
901 sv(vec![0], vec![1.0]),
902 )
903 .await
904 .unwrap();
905
906 let hits = store
907 .search_sparse(SparseSearchRequest {
908 query: sv(vec![0], vec![1.0]),
909 top_k: 5,
910 namespace: Some("ns:b".into()),
911 kind: None,
912 })
913 .await
914 .unwrap();
915 assert!(hits.is_empty(), "ns:b should not see ns:a data");
916 }
917
918 #[tokio::test]
919 async fn insert_batch_happy_path() {
920 use chrono::Utc;
921 use khive_types::SubstrateKind;
922
923 let store = make_store("test_batch");
924 let id1 = Uuid::new_v4();
925 let id2 = Uuid::new_v4();
926 let records = vec![
927 SparseRecord {
928 subject_id: id1,
929 kind: SubstrateKind::Entity,
930 namespace: "ns:test".into(),
931 field: "body".into(),
932 vector: sv(vec![0, 3], vec![0.5, 0.8]),
933 updated_at: Utc::now(),
934 },
935 SparseRecord {
936 subject_id: id2,
937 kind: SubstrateKind::Entity,
938 namespace: "ns:test".into(),
939 field: "body".into(),
940 vector: sv(vec![1], vec![1.0]),
941 updated_at: Utc::now(),
942 },
943 ];
944 let summary = store.insert_batch(records).await.unwrap();
945 assert_eq!(summary.attempted, 2);
946 assert_eq!(summary.affected, 2);
947 assert_eq!(summary.failed, 0);
948 assert_eq!(store.count().await.unwrap(), 2);
949 }
950
951 #[tokio::test]
952 async fn insert_batch_upsert_updates_kind_and_kind_filtered_search() {
953 use chrono::Utc;
954
955 let store = make_store("test_batch_kind_upsert");
956 let id = Uuid::new_v4();
957 let record = |kind, vector| SparseRecord {
958 subject_id: id,
959 kind,
960 namespace: "ns:test".into(),
961 field: "body".into(),
962 vector,
963 updated_at: Utc::now(),
964 };
965
966 let first = store
967 .insert_batch(vec![record(SubstrateKind::Entity, sv(vec![0], vec![1.0]))])
968 .await
969 .unwrap();
970 assert_eq!(first.affected, 1);
971
972 let replacement = store
973 .insert_batch(vec![record(SubstrateKind::Note, sv(vec![1], vec![1.0]))])
974 .await
975 .unwrap();
976 assert_eq!(replacement.affected, 1);
977 assert_eq!(store.count().await.unwrap(), 1);
978
979 let stored_kind: String = {
980 let writer = store.pool.try_writer().expect("writer");
981 writer
982 .conn()
983 .query_row(
984 "SELECT kind FROM sparse_test_batch_kind_upsert \
985 WHERE subject_id = ?1 AND namespace = ?2 AND field = ?3",
986 rusqlite::params![id.to_string(), "ns:test", "body"],
987 |row| row.get(0),
988 )
989 .expect("stored kind")
990 };
991 assert_eq!(stored_kind, "note");
992
993 let hits = store
994 .search_sparse(SparseSearchRequest {
995 query: sv(vec![1], vec![1.0]),
996 top_k: 1,
997 namespace: Some("ns:test".into()),
998 kind: Some(SubstrateKind::Note),
999 })
1000 .await
1001 .unwrap();
1002 assert_eq!(hits.len(), 1);
1003 assert_eq!(hits[0].subject_id, id);
1004 }
1005
1006 #[tokio::test]
1018 async fn insert_batch_routes_through_writer_task_when_flag_enabled() {
1019 use chrono::Utc;
1020 use khive_types::SubstrateKind;
1021
1022 let model_key = "write_queue_flag_test";
1023 let dir = tempfile::tempdir().unwrap();
1024 let path = dir.path().join("write_queue_sparse.db");
1025 let pool_cfg = PoolConfig {
1026 path: Some(path.clone()),
1027 write_queue_enabled: Some(true),
1028 ..PoolConfig::for_test()
1029 };
1030 let pool = Arc::new(ConnectionPool::new(pool_cfg).expect("pool"));
1031 {
1032 let writer = pool.writer().expect("writer");
1033 ensure_sparse_schema(writer.conn(), model_key).expect("schema");
1034 }
1035
1036 let store = SqliteSparseStore::new(
1037 Arc::clone(&pool),
1038 true,
1039 model_key.to_string(),
1040 "ns:test".to_string(),
1041 )
1042 .expect("store");
1043
1044 let id1 = Uuid::new_v4();
1045 let id2 = Uuid::new_v4();
1046 let records = vec![
1047 SparseRecord {
1048 subject_id: id1,
1049 kind: SubstrateKind::Entity,
1050 namespace: "ns:test".into(),
1051 field: "body".into(),
1052 vector: sv(vec![0, 3], vec![0.5, 0.8]),
1053 updated_at: Utc::now(),
1054 },
1055 SparseRecord {
1056 subject_id: id2,
1057 kind: SubstrateKind::Entity,
1058 namespace: "ns:test".into(),
1059 field: "body".into(),
1060 vector: sv(vec![1], vec![1.0]),
1061 updated_at: Utc::now(),
1062 },
1063 ];
1064
1065 let summary = store.insert_batch(records).await.unwrap();
1066 assert_eq!(summary.attempted, 2);
1067 assert_eq!(summary.affected, 2);
1068 assert_eq!(summary.failed, 0);
1069 assert_eq!(store.count().await.unwrap(), 2);
1070 assert_eq!(
1071 pool.writer_task_spawn_count(),
1072 1,
1073 "the flag-ON path must actually spawn and use the writer task"
1074 );
1075 }
1076}
1077
1078#[cfg(test)]
1079#[path = "sparse_busy_tests.rs"]
1080mod direct_busy_tests;