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::{decode_f32_le, encode_f32_le, 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 batch_insert_sparse_dml(
90 conn: &rusqlite::Connection,
91 table: &str,
92 records: &[SparseRecord],
93 attempted: u64,
94) -> Result<BatchWriteSummary, rusqlite::Error> {
95 let sql = format!(
96 "INSERT INTO {table} \
97 (subject_id, namespace, kind, field, indices_json, values_blob, updated_at) \
98 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \
99 ON CONFLICT(subject_id, namespace, field) DO UPDATE SET \
100 kind = excluded.kind, \
101 indices_json = excluded.indices_json, \
102 values_blob = excluded.values_blob, \
103 updated_at = excluded.updated_at"
104 );
105
106 let mut summary = BatchWriteSummary {
107 attempted,
108 ..BatchWriteSummary::default()
109 };
110
111 for (index, record) in records.iter().enumerate() {
112 let item_id = Some(record.subject_id.to_string());
113 if record.vector.indices.len() != record.vector.values.len()
115 || record.vector.indices.is_empty()
116 || record.vector.values.iter().any(|v| !v.is_finite())
117 || record.vector.indices.windows(2).any(|w| w[0] >= w[1])
118 {
119 summary.record_failure(
120 index,
121 item_id,
122 BatchWriteErrorClass::InvalidInput,
123 BatchWriteRetryability::Permanent,
124 format!("invalid sparse vector for subject {}", record.subject_id),
125 );
126 continue;
127 }
128
129 let indices_json = match serde_json::to_string(&record.vector.indices) {
130 Ok(j) => j,
131 Err(e) => {
132 summary.record_failure(
133 index,
134 item_id,
135 BatchWriteErrorClass::Serialization,
136 BatchWriteRetryability::Permanent,
137 e.to_string(),
138 );
139 continue;
140 }
141 };
142 let values_blob = encode_f32_le(&record.vector.values);
143 let now = record.updated_at.timestamp();
144 let id_str = record.subject_id.to_string();
145 let kind_str = record.kind.to_string();
146
147 match conn.execute(
148 &sql,
149 rusqlite::params![
150 &id_str,
151 &record.namespace,
152 &kind_str,
153 &record.field,
154 &indices_json,
155 values_blob,
156 now
157 ],
158 ) {
159 Ok(_) => summary.affected = summary.affected.saturating_add(1),
160 Err(e) => {
161 let (class, retryability) = super::classify_batch_sqlite_error(&e);
162 summary.record_failure(index, item_id, class, retryability, e.to_string());
163 }
164 }
165 }
166
167 Ok(summary)
168}
169
170pub(crate) fn ensure_sparse_schema(
172 conn: &rusqlite::Connection,
173 model_key: &str,
174) -> Result<(), rusqlite::Error> {
175 let table = format!("sparse_{}", model_key);
176 let ddl = format!(
177 "CREATE TABLE IF NOT EXISTS {table} (\
178 subject_id TEXT NOT NULL, \
179 namespace TEXT NOT NULL, \
180 kind TEXT NOT NULL, \
181 field TEXT NOT NULL, \
182 indices_json TEXT NOT NULL, \
183 values_blob BLOB NOT NULL, \
184 updated_at INTEGER NOT NULL, \
185 PRIMARY KEY(subject_id, namespace, field)\
186 ); \
187 CREATE INDEX IF NOT EXISTS idx_{table}_namespace_kind \
188 ON {table}(namespace, kind);"
189 );
190 conn.execute_batch(&ddl)
191}
192
193pub struct SqliteSparseStore {
195 pool: Arc<ConnectionPool>,
196 table_name: String,
197 namespace: String,
198 writer_task: Option<WriterTaskHandle>,
199}
200
201impl SqliteSparseStore {
202 pub fn new(
204 pool: Arc<ConnectionPool>,
205 _is_file_backed: bool,
206 model_key: String,
207 namespace: String,
208 ) -> Result<Self, SqliteError> {
209 let table_name = format!("sparse_{}", model_key);
210 let writer_task = pool.writer_task_handle().ok().flatten();
215 Ok(Self {
216 pool,
217 table_name,
218 namespace,
219 writer_task,
220 })
221 }
222
223 fn current_writer_task(
224 &self,
225 operation: &'static str,
226 ) -> Result<Option<WriterTaskHandle>, StorageError> {
227 self.pool
228 .writer_task_for_write(self.writer_task.as_ref(), operation)
229 }
230
231 async fn with_writer<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
245 where
246 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
247 R: Send + 'static,
248 {
249 if let Some(writer_task) = self.current_writer_task(op)? {
250 return writer_task
251 .send_bounded(move |conn| f(conn).map_err(|e| map_err(e, op)))
252 .await;
253 }
254
255 self.pool
256 .record_direct_route(crate::timeout_sink::Site::DirectRouteSparseGeneralWrite);
257 let pool = Arc::clone(&self.pool);
258 tokio::task::spawn_blocking(move || {
259 let guard = pool
260 .autocommit_write_unit()
261 .map_err(|e| map_sqlite_err(e, op))?;
262 f(guard.conn())
263 .map_err(|e| map_err(e, op))
264 .inspect_err(|error| pool.record_direct_writer_error(error))
265 })
266 .await
267 .map_err(|e| StorageError::driver(StorageCapability::Sparse, op, e))?
268 }
269
270 async fn with_reader<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
271 where
272 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
273 R: Send + 'static,
274 {
275 super::run_pooled_store_read(
276 Arc::clone(&self.pool),
277 StorageCapability::Sparse,
278 op,
279 move |conn| f(conn).map_err(|error| map_err(error, op)),
280 )
281 .await
282 }
283
284 async fn upsert_sparse_vector(
285 &self,
286 subject_id: Uuid,
287 kind: SubstrateKind,
288 namespace: &str,
289 field: &str,
290 vector: SparseVector,
291 ) -> Result<(), StorageError> {
292 let table = self.table_name.clone();
293 let ns = namespace.to_string();
294 let field = field.to_string();
295 let id_str = subject_id.to_string();
296 let kind_str = kind.to_string();
297
298 self.with_writer("sparse_upsert", move |conn| {
299 let indices_json = serde_json::to_string(&vector.indices).map_err(|e| {
300 rusqlite::Error::FromSqlConversionFailure(
301 0,
302 rusqlite::types::Type::Text,
303 Box::new(e),
304 )
305 })?;
306 let values_blob = encode_f32_le(&vector.values);
307 let now = chrono::Utc::now().timestamp();
308 let sql = format!(
309 "INSERT INTO {table} \
310 (subject_id, namespace, kind, field, indices_json, values_blob, updated_at) \
311 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \
312 ON CONFLICT(subject_id, namespace, field) DO UPDATE SET \
313 kind = excluded.kind, \
314 indices_json = excluded.indices_json, \
315 values_blob = excluded.values_blob, \
316 updated_at = excluded.updated_at"
317 );
318 conn.execute(
319 &sql,
320 rusqlite::params![
321 &id_str,
322 &ns,
323 &kind_str,
324 &field,
325 &indices_json,
326 values_blob,
327 now
328 ],
329 )?;
330 Ok(())
331 })
332 .await
333 }
334
335 async fn insert_sparse_batch(
336 &self,
337 records: Vec<SparseRecord>,
338 ) -> Result<BatchWriteSummary, StorageError> {
339 let table = self.table_name.clone();
340 let attempted = records.len() as u64;
341
342 if let Some(writer_task) = self.current_writer_task("sparse_insert_batch")? {
347 let table2 = table.clone();
348 return writer_task
349 .send_bounded(move |conn| {
350 batch_insert_sparse_dml(conn, &table2, &records, attempted)
351 .map_err(|e| map_err(e, "sparse_insert_batch"))
352 })
353 .await;
354 }
355
356 let origin = self.pool.origin();
359 self.with_writer("sparse_insert_batch", move |conn| {
360 conn.execute_batch("BEGIN IMMEDIATE")?;
361 let _tx_handle = khive_storage::tx_registry::register_scoped(
362 Some("sparse_insert_batch".to_string()),
363 origin,
364 );
365
366 let summary = batch_insert_sparse_dml(conn, &table, &records, attempted)?;
367
368 conn.execute_batch("COMMIT")?;
369 Ok(summary)
370 })
371 .await
372 }
373
374 async fn delete_sparse_subject(&self, subject_id: Uuid) -> Result<bool, StorageError> {
375 let table = self.table_name.clone();
376 let namespace = self.namespace.clone();
377 let id_str = subject_id.to_string();
378
379 self.with_writer("sparse_delete", move |conn| {
380 let sql = format!("DELETE FROM {table} WHERE subject_id = ?1 AND namespace = ?2");
381 let deleted = conn.execute(&sql, rusqlite::params![&id_str, &namespace])?;
382 Ok(deleted > 0)
383 })
384 .await
385 }
386
387 async fn search_sparse_vectors(
388 &self,
389 request: SparseSearchRequest,
390 ) -> Result<Vec<SparseSearchHit>, StorageError> {
391 request
392 .validate()
393 .map_err(|message| StorageError::InvalidInput {
394 capability: StorageCapability::Sparse,
395 operation: "sparse_search".into(),
396 message,
397 })?;
398
399 let table = self.table_name.clone();
400 let ns = request
401 .namespace
402 .clone()
403 .unwrap_or_else(|| self.namespace.clone());
404 let kind_filter = request.kind.map(|k| k.to_string());
405 let query = request.query;
406 let top_k = usize::try_from(request.top_k).map_err(|_| StorageError::InvalidInput {
407 capability: StorageCapability::Sparse,
408 operation: "sparse_search".into(),
409 message: "SparseSearchRequest: top_k does not fit usize".into(),
410 })?;
411 let heap_capacity = top_k
412 .checked_add(1)
413 .ok_or_else(|| StorageError::InvalidInput {
414 capability: StorageCapability::Sparse,
415 operation: "sparse_search".into(),
416 message: "SparseSearchRequest: top_k capacity overflow".into(),
417 })?;
418
419 self.with_reader("sparse_search", move |conn| {
420 let (sql, kind_str_ref) = if let Some(ref kind_str) = kind_filter {
422 (
423 format!(
424 "SELECT subject_id, indices_json, values_blob \
425 FROM {table} WHERE namespace = ?1 AND kind = ?2"
426 ),
427 Some(kind_str.as_str()),
428 )
429 } else {
430 (
431 format!(
432 "SELECT subject_id, indices_json, values_blob \
433 FROM {table} WHERE namespace = ?1"
434 ),
435 None,
436 )
437 };
438
439 let mut stmt = conn.prepare(&sql)?;
440
441 let rows: Vec<rusqlite::Result<(String, String, Vec<u8>)>> =
443 if let Some(kind_str) = kind_str_ref {
444 stmt.query_map(rusqlite::params![&ns, kind_str], |row| {
445 Ok((row.get(0)?, row.get(1)?, row.get(2)?))
446 })?
447 .collect()
448 } else {
449 stmt.query_map(rusqlite::params![&ns], |row| {
450 Ok((row.get(0)?, row.get(1)?, row.get(2)?))
451 })?
452 .collect()
453 };
454
455 let mut heap: BinaryHeap<Reverse<ScoredCandidate>> =
457 BinaryHeap::with_capacity(heap_capacity);
458
459 for row_result in rows {
460 let (id_str, indices_json, values_blob) = row_result?;
461
462 let subject_id = Uuid::parse_str(&id_str).map_err(|e| {
463 rusqlite::Error::FromSqlConversionFailure(
464 0,
465 rusqlite::types::Type::Text,
466 Box::new(e),
467 )
468 })?;
469
470 let stored_indices: Vec<u32> =
472 serde_json::from_str(&indices_json).map_err(|e| {
473 rusqlite::Error::FromSqlConversionFailure(
474 0,
475 rusqlite::types::Type::Text,
476 Box::<dyn std::error::Error + Send + Sync>::from(format!(
477 "corrupt sparse row {id_str}: invalid indices JSON: {e}"
478 )),
479 )
480 })?;
481
482 let stored_values = decode_f32_le(&values_blob).map_err(|_| {
483 rusqlite::Error::FromSqlConversionFailure(
484 0,
485 rusqlite::types::Type::Blob,
486 Box::<dyn std::error::Error + Send + Sync>::from(format!(
487 "corrupt sparse row {id_str}: values blob length {} not a multiple of 4",
488 values_blob.len()
489 )),
490 )
491 })?;
492
493 validate_persisted_sparse(&id_str, &stored_indices, &stored_values)?;
494
495 let score = sparse_dot_product(
496 &query.indices,
497 &query.values,
498 &stored_indices,
499 &stored_values,
500 );
501
502 heap.push(Reverse(ScoredCandidate { score, subject_id }));
503 if heap.len() > top_k {
504 heap.pop();
505 }
506 }
507
508 let mut top: Vec<_> = heap.into_iter().map(|Reverse(c)| c).collect();
510 top.sort_by(|a, b| {
511 b.score
512 .partial_cmp(&a.score)
513 .unwrap_or(std::cmp::Ordering::Equal)
514 .then_with(|| a.subject_id.cmp(&b.subject_id))
515 });
516
517 let hits = top
518 .into_iter()
519 .enumerate()
520 .map(|(i, c)| SparseSearchHit {
521 subject_id: c.subject_id,
522 score: DeterministicScore::from_f64(c.score),
523 rank: (i + 1) as u32,
524 })
525 .collect();
526
527 Ok(hits)
528 })
529 .await
530 }
531
532 async fn count_sparse_rows(&self) -> Result<u64, StorageError> {
533 let table = self.table_name.clone();
534 let namespace = self.namespace.clone();
535 self.with_reader("sparse_count", move |conn| {
536 let sql = format!("SELECT COUNT(*) FROM {table} WHERE namespace = ?1");
537 let count: i64 =
538 conn.query_row(&sql, rusqlite::params![&namespace], |row| row.get(0))?;
539 Ok(count as u64)
540 })
541 .await
542 }
543}
544
545#[derive(PartialEq)]
548struct ScoredCandidate {
549 score: f64,
550 subject_id: Uuid,
551}
552
553impl Eq for ScoredCandidate {}
554
555impl PartialOrd for ScoredCandidate {
556 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
557 Some(self.cmp(other))
558 }
559}
560
561impl Ord for ScoredCandidate {
562 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
563 match self
566 .score
567 .partial_cmp(&other.score)
568 .unwrap_or(std::cmp::Ordering::Equal)
569 {
570 std::cmp::Ordering::Equal => other.subject_id.cmp(&self.subject_id),
571 ord => ord,
572 }
573 }
574}
575
576fn validate_persisted_sparse(
580 subject_id: &str,
581 indices: &[u32],
582 values: &[f32],
583) -> Result<(), rusqlite::Error> {
584 if indices.len() != values.len() {
585 return Err(rusqlite::Error::FromSqlConversionFailure(
586 0,
587 rusqlite::types::Type::Blob,
588 Box::<dyn std::error::Error + Send + Sync>::from(format!(
589 "corrupt sparse row {subject_id}: indices len {} != values len {}",
590 indices.len(),
591 values.len()
592 )),
593 ));
594 }
595 for (i, v) in values.iter().enumerate() {
596 if !v.is_finite() {
597 return Err(rusqlite::Error::FromSqlConversionFailure(
598 0,
599 rusqlite::types::Type::Blob,
600 Box::<dyn std::error::Error + Send + Sync>::from(format!(
601 "corrupt sparse row {subject_id}: non-finite value at position {i}: {v}"
602 )),
603 ));
604 }
605 }
606 for window in indices.windows(2) {
607 if window[0] >= window[1] {
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}: indices not strictly increasing at {} >= {}",
613 window[0], window[1]
614 )),
615 ));
616 }
617 }
618 Ok(())
619}
620
621fn sparse_dot_product(q_idx: &[u32], q_val: &[f32], s_idx: &[u32], s_val: &[f32]) -> f64 {
623 let mut dot = 0.0f64;
624 let mut qi = 0;
625 let mut si = 0;
626 while qi < q_idx.len() && si < s_idx.len() {
627 match q_idx[qi].cmp(&s_idx[si]) {
628 std::cmp::Ordering::Equal => {
629 dot += q_val[qi] as f64 * s_val[si] as f64;
630 qi += 1;
631 si += 1;
632 }
633 std::cmp::Ordering::Less => qi += 1,
634 std::cmp::Ordering::Greater => si += 1,
635 }
636 }
637 dot
638}
639
640#[async_trait]
641impl SparseStore for SqliteSparseStore {
642 async fn insert_sparse(
643 &self,
644 subject_id: Uuid,
645 kind: SubstrateKind,
646 namespace: &str,
647 field: &str,
648 vector: SparseVector,
649 ) -> Result<(), StorageError> {
650 validate_sparse_vector(&vector, "sparse_insert")?;
651 self.upsert_sparse_vector(subject_id, kind, namespace, field, vector)
652 .await
653 }
654
655 async fn insert_batch(
656 &self,
657 records: Vec<SparseRecord>,
658 ) -> Result<BatchWriteSummary, StorageError> {
659 self.insert_sparse_batch(records).await
660 }
661
662 async fn delete(&self, subject_id: Uuid) -> Result<bool, StorageError> {
663 self.delete_sparse_subject(subject_id).await
664 }
665
666 async fn search_sparse(
667 &self,
668 request: SparseSearchRequest,
669 ) -> Result<Vec<SparseSearchHit>, StorageError> {
670 validate_sparse_vector(&request.query, "sparse_search")?;
671 self.search_sparse_vectors(request).await
672 }
673
674 async fn count(&self) -> Result<u64, StorageError> {
675 self.count_sparse_rows().await
676 }
677}
678
679#[cfg(test)]
680mod tests {
681 use super::*;
682 use crate::pool::{ConnectionPool, PoolConfig};
683
684 fn make_store(model_key: &str) -> SqliteSparseStore {
685 let config = PoolConfig {
686 path: None,
687 ..PoolConfig::default()
688 };
689 let pool = Arc::new(ConnectionPool::new(config).expect("pool"));
690 {
692 let writer = pool.try_writer().expect("writer");
693 ensure_sparse_schema(writer.conn(), model_key).expect("schema");
694 }
695 SqliteSparseStore::new(pool, false, model_key.to_string(), "ns:test".to_string())
696 .expect("store")
697 }
698
699 fn sv(indices: Vec<u32>, values: Vec<f32>) -> SparseVector {
700 SparseVector { indices, values }
701 }
702
703 #[tokio::test]
704 async fn single_and_batch_sparse_writes_store_fixed_little_endian_bytes() {
705 let pool = Arc::new(
706 ConnectionPool::new(PoolConfig {
707 path: None,
708 write_queue_enabled: Some(false),
709 write_routing_strict: false,
710 write_admission_deadline_ms: 2000,
711 disk_guard_config: Some(crate::EffectiveDiskGuardConfig::default()),
712 ..PoolConfig::for_test()
713 })
714 .expect("private memory pool"),
715 );
716 {
717 let writer = pool.try_writer().expect("writer");
718 ensure_sparse_schema(writer.conn(), "codec_bytes").expect("schema");
719 }
720 let store =
721 SqliteSparseStore::new(pool, false, "codec_bytes".into(), "ns:codec".into()).unwrap();
722 let values = || sv(vec![0, 1], vec![1.0, -2.5]);
723 let first = Uuid::from_u128(1);
724 let second = Uuid::from_u128(2);
725 store
726 .insert_sparse(first, SubstrateKind::Entity, "ns:codec", "body", values())
727 .await
728 .unwrap();
729 let summary = store
730 .insert_batch(vec![SparseRecord {
731 subject_id: second,
732 kind: SubstrateKind::Entity,
733 namespace: "ns:codec".into(),
734 field: "body".into(),
735 vector: values(),
736 updated_at: chrono::Utc::now(),
737 }])
738 .await
739 .unwrap();
740 assert_eq!(
741 (summary.attempted, summary.affected, summary.failed),
742 (1, 1, 0)
743 );
744 let blobs = store
745 .with_reader("sparse_codec_bytes", |conn| {
746 let mut statement =
747 conn.prepare("SELECT values_blob FROM sparse_codec_bytes ORDER BY subject_id")?;
748 let rows = statement.query_map([], |row| row.get::<_, Vec<u8>>(0))?;
749 rows.collect::<Result<Vec<_>, _>>()
750 })
751 .await
752 .unwrap();
753 assert_eq!(blobs.len(), 2);
754 for blob in blobs {
755 assert_eq!(blob, [0, 0, 0x80, 0x3f, 0, 0, 0x20, 0xc0]);
756 }
757 let hits = store
758 .search_sparse(SparseSearchRequest {
759 query: values(),
760 top_k: 2,
761 namespace: Some("ns:codec".into()),
762 kind: None,
763 })
764 .await
765 .unwrap();
766 assert_eq!(
767 hits.iter().map(|hit| hit.subject_id).collect::<Vec<_>>(),
768 vec![first, second]
769 );
770 }
771
772 #[tokio::test]
773 async fn insert_and_count() {
774 let store = make_store("test_count");
775 let id = Uuid::new_v4();
776 store
777 .insert_sparse(
778 id,
779 SubstrateKind::Entity,
780 "ns:test",
781 "body",
782 sv(vec![0, 2], vec![1.0, 0.5]),
783 )
784 .await
785 .unwrap();
786 assert_eq!(store.count().await.unwrap(), 1);
787 }
788
789 #[tokio::test]
790 async fn insert_and_search() {
791 let store = make_store("test_search");
792 let id1 = Uuid::new_v4();
793 let id2 = Uuid::new_v4();
794 store
795 .insert_sparse(
796 id1,
797 SubstrateKind::Entity,
798 "ns:test",
799 "body",
800 sv(vec![0, 1], vec![1.0, 0.0]),
801 )
802 .await
803 .unwrap();
804 store
805 .insert_sparse(
806 id2,
807 SubstrateKind::Entity,
808 "ns:test",
809 "body",
810 sv(vec![0, 1], vec![0.0, 1.0]),
811 )
812 .await
813 .unwrap();
814
815 let hits = store
816 .search_sparse(SparseSearchRequest {
817 query: sv(vec![0], vec![1.0]),
818 top_k: 2,
819 namespace: Some("ns:test".into()),
820 kind: None,
821 })
822 .await
823 .unwrap();
824
825 assert!(!hits.is_empty());
826 assert_eq!(hits[0].subject_id, id1, "id1 should rank first");
827 assert_eq!(hits[0].rank, 1);
828 }
829
830 #[tokio::test]
833 async fn sparse_top_k_u32_max_rejected() {
834 let store = make_store("test_top_k_max");
835 let id = Uuid::new_v4();
836 store
837 .insert_sparse(
838 id,
839 SubstrateKind::Entity,
840 "ns:test",
841 "body",
842 sv(vec![0], vec![1.0]),
843 )
844 .await
845 .unwrap();
846
847 let result = store
848 .search_sparse(SparseSearchRequest {
849 query: sv(vec![0], vec![1.0]),
850 top_k: u32::MAX,
851 namespace: Some("ns:test".into()),
852 kind: None,
853 })
854 .await;
855
856 assert!(
857 matches!(result, Err(StorageError::InvalidInput { .. })),
858 "expected InvalidInput, got {result:?}"
859 );
860 }
861
862 #[tokio::test]
863 async fn delete_removes_row() {
864 let store = make_store("test_delete");
865 let id = Uuid::new_v4();
866 store
867 .insert_sparse(
868 id,
869 SubstrateKind::Entity,
870 "ns:test",
871 "body",
872 sv(vec![1], vec![1.0]),
873 )
874 .await
875 .unwrap();
876 assert_eq!(store.count().await.unwrap(), 1);
877
878 let deleted = store.delete(id).await.unwrap();
879 assert!(deleted);
880 assert_eq!(store.count().await.unwrap(), 0);
881 }
882
883 #[tokio::test]
884 async fn mismatched_lengths_rejected() {
885 let store = make_store("test_mismatch");
886 let result = store
887 .insert_sparse(
888 Uuid::new_v4(),
889 SubstrateKind::Entity,
890 "ns:test",
891 "body",
892 SparseVector {
893 indices: vec![0, 1],
894 values: vec![1.0],
895 },
896 )
897 .await;
898 assert!(matches!(result, Err(StorageError::InvalidInput { .. })));
899 }
900
901 #[tokio::test]
902 async fn non_finite_values_rejected() {
903 let store = make_store("test_nonfinite");
904 let result = store
905 .insert_sparse(
906 Uuid::new_v4(),
907 SubstrateKind::Entity,
908 "ns:test",
909 "body",
910 sv(vec![0], vec![f32::NAN]),
911 )
912 .await;
913 assert!(matches!(result, Err(StorageError::InvalidInput { .. })));
914 }
915
916 #[tokio::test]
917 async fn duplicate_indices_rejected() {
918 let store = make_store("test_dup_idx");
919 let result = store
920 .insert_sparse(
921 Uuid::new_v4(),
922 SubstrateKind::Entity,
923 "ns:test",
924 "body",
925 sv(vec![0, 0], vec![1.0, 2.0]),
926 )
927 .await;
928 assert!(matches!(result, Err(StorageError::InvalidInput { .. })));
929 }
930
931 #[tokio::test]
932 async fn empty_vector_rejected() {
933 let store = make_store("test_empty");
934 let result = store
935 .insert_sparse(
936 Uuid::new_v4(),
937 SubstrateKind::Entity,
938 "ns:test",
939 "body",
940 sv(vec![], vec![]),
941 )
942 .await;
943 assert!(matches!(result, Err(StorageError::InvalidInput { .. })));
944 }
945
946 #[tokio::test]
947 async fn namespace_isolation() {
948 let store = make_store("test_ns_iso");
949 let id = Uuid::new_v4();
950 store
951 .insert_sparse(
952 id,
953 SubstrateKind::Entity,
954 "ns:a",
955 "body",
956 sv(vec![0], vec![1.0]),
957 )
958 .await
959 .unwrap();
960
961 let hits = store
962 .search_sparse(SparseSearchRequest {
963 query: sv(vec![0], vec![1.0]),
964 top_k: 5,
965 namespace: Some("ns:b".into()),
966 kind: None,
967 })
968 .await
969 .unwrap();
970 assert!(hits.is_empty(), "ns:b should not see ns:a data");
971 }
972
973 #[tokio::test]
974 async fn insert_batch_happy_path() {
975 use chrono::Utc;
976 use khive_types::SubstrateKind;
977
978 let store = make_store("test_batch");
979 let id1 = Uuid::new_v4();
980 let id2 = Uuid::new_v4();
981 let records = vec![
982 SparseRecord {
983 subject_id: id1,
984 kind: SubstrateKind::Entity,
985 namespace: "ns:test".into(),
986 field: "body".into(),
987 vector: sv(vec![0, 3], vec![0.5, 0.8]),
988 updated_at: Utc::now(),
989 },
990 SparseRecord {
991 subject_id: id2,
992 kind: SubstrateKind::Entity,
993 namespace: "ns:test".into(),
994 field: "body".into(),
995 vector: sv(vec![1], vec![1.0]),
996 updated_at: Utc::now(),
997 },
998 ];
999 let summary = store.insert_batch(records).await.unwrap();
1000 assert_eq!(summary.attempted, 2);
1001 assert_eq!(summary.affected, 2);
1002 assert_eq!(summary.failed, 0);
1003 assert_eq!(store.count().await.unwrap(), 2);
1004 }
1005
1006 #[tokio::test]
1007 async fn insert_batch_upsert_updates_kind_and_kind_filtered_search() {
1008 use chrono::Utc;
1009
1010 let store = make_store("test_batch_kind_upsert");
1011 let id = Uuid::new_v4();
1012 let record = |kind, vector| SparseRecord {
1013 subject_id: id,
1014 kind,
1015 namespace: "ns:test".into(),
1016 field: "body".into(),
1017 vector,
1018 updated_at: Utc::now(),
1019 };
1020
1021 let first = store
1022 .insert_batch(vec![record(SubstrateKind::Entity, sv(vec![0], vec![1.0]))])
1023 .await
1024 .unwrap();
1025 assert_eq!(first.affected, 1);
1026
1027 let replacement = store
1028 .insert_batch(vec![record(SubstrateKind::Note, sv(vec![1], vec![1.0]))])
1029 .await
1030 .unwrap();
1031 assert_eq!(replacement.affected, 1);
1032 assert_eq!(store.count().await.unwrap(), 1);
1033
1034 let stored_kind: String = {
1035 let writer = store.pool.try_writer().expect("writer");
1036 writer
1037 .conn()
1038 .query_row(
1039 "SELECT kind FROM sparse_test_batch_kind_upsert \
1040 WHERE subject_id = ?1 AND namespace = ?2 AND field = ?3",
1041 rusqlite::params![id.to_string(), "ns:test", "body"],
1042 |row| row.get(0),
1043 )
1044 .expect("stored kind")
1045 };
1046 assert_eq!(stored_kind, "note");
1047
1048 let hits = store
1049 .search_sparse(SparseSearchRequest {
1050 query: sv(vec![1], vec![1.0]),
1051 top_k: 1,
1052 namespace: Some("ns:test".into()),
1053 kind: Some(SubstrateKind::Note),
1054 })
1055 .await
1056 .unwrap();
1057 assert_eq!(hits.len(), 1);
1058 assert_eq!(hits[0].subject_id, id);
1059 }
1060
1061 #[tokio::test]
1073 async fn insert_batch_routes_through_writer_task_when_flag_enabled() {
1074 use chrono::Utc;
1075 use khive_types::SubstrateKind;
1076
1077 let model_key = "write_queue_flag_test";
1078 let dir = tempfile::tempdir().unwrap();
1079 let path = dir.path().join("write_queue_sparse.db");
1080 let pool_cfg = PoolConfig {
1081 path: Some(path.clone()),
1082 write_queue_enabled: Some(true),
1083 ..PoolConfig::for_test()
1084 };
1085 let pool = Arc::new(ConnectionPool::new(pool_cfg).expect("pool"));
1086 {
1087 let writer = pool.writer().expect("writer");
1088 ensure_sparse_schema(writer.conn(), model_key).expect("schema");
1089 }
1090
1091 let store = SqliteSparseStore::new(
1092 Arc::clone(&pool),
1093 true,
1094 model_key.to_string(),
1095 "ns:test".to_string(),
1096 )
1097 .expect("store");
1098
1099 let id1 = Uuid::new_v4();
1100 let id2 = Uuid::new_v4();
1101 let records = vec![
1102 SparseRecord {
1103 subject_id: id1,
1104 kind: SubstrateKind::Entity,
1105 namespace: "ns:test".into(),
1106 field: "body".into(),
1107 vector: sv(vec![0, 3], vec![0.5, 0.8]),
1108 updated_at: Utc::now(),
1109 },
1110 SparseRecord {
1111 subject_id: id2,
1112 kind: SubstrateKind::Entity,
1113 namespace: "ns:test".into(),
1114 field: "body".into(),
1115 vector: sv(vec![1], vec![1.0]),
1116 updated_at: Utc::now(),
1117 },
1118 ];
1119
1120 let summary = store.insert_batch(records).await.unwrap();
1121 assert_eq!(summary.attempted, 2);
1122 assert_eq!(summary.affected, 2);
1123 assert_eq!(summary.failed, 0);
1124 assert_eq!(store.count().await.unwrap(), 2);
1125 assert_eq!(
1126 pool.writer_task_spawn_count(),
1127 1,
1128 "the flag-ON path must actually spawn and use the writer task"
1129 );
1130 }
1131}
1132
1133#[cfg(test)]
1134#[path = "sparse_busy_tests.rs"]
1135mod direct_busy_tests;