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