Skip to main content

khive_db/stores/
sparse.rs

1//! SQLite-backed `SparseStore` implementation.
2
3use 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
31/// Validate that a sparse vector is well-formed.
32///
33/// - indices and values must have equal lengths
34/// - at least one element
35/// - all values must be finite
36/// - indices must be strictly increasing (no duplicates)
37fn 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    // Verify strictly increasing indices.
66    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
81/// Serialize f32 slice to little-endian bytes (same pattern as vectors.rs).
82fn f32_slice_as_bytes(data: &[f32]) -> &[u8] {
83    // SAFETY: same safety argument as vectors.rs — valid &[f32], alignment = 1, lifetime tied to input.
84    unsafe { std::slice::from_raw_parts(data.as_ptr() as *const u8, std::mem::size_of_val(data)) }
85}
86
87/// DML-only batch insert loop shared by both the legacy (flag-off) and
88/// WriterTask-routed (flag-on) `insert_sparse_batch` paths (ADR-067
89/// Component A).
90///
91/// Issues no `BEGIN` / `COMMIT` / `ROLLBACK` itself — the caller owns the
92/// enclosing transaction. Per-row failures (validation or SQL) are captured
93/// into `BatchWriteSummary::failed`/`first_error` rather than aborting the
94/// loop, matching the existing partial-success contract.
95fn 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        // Validate inline — skip invalid records rather than aborting the batch.
120        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
176/// Create the sparse table and its index for the given model_key.
177pub(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
199/// SQLite-backed sparse vector store.
200pub struct SqliteSparseStore {
201    pool: Arc<ConnectionPool>,
202    table_name: String,
203    namespace: String,
204    writer_task: Option<WriterTaskHandle>,
205}
206
207impl SqliteSparseStore {
208    /// Create a new sparse store for the given model key and namespace.
209    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        // Enabled by default for file-backed pools. Construction stays
217        // synchronous (ADR-067 Component A, mirrors entity.rs policy): a
218        // missing writer task is cached without failing construction. Every
219        // write re-resolves it and applies strict/compatibility policy then.
220        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    /// Route a single-row write through the pool-wide `WriterTask` when
238    /// the write queue is enabled and a handle is available. Strict mode
239    /// refuses a missing handle; compatibility mode falls back to the legacy
240    /// pool-mutex path (ADR-067 Component A, Fork C slice 2).
241    ///
242    /// This is the ONE routing point for every `with_writer` caller in this
243    /// store (`upsert_sparse_vector`, `delete_sparse_subject`). `f` must be
244    /// DML-only — on the flag-on path it runs inside the WriterTask's own
245    /// transaction, so a bare `BEGIN IMMEDIATE` would violate SQLite's
246    /// nested-transaction rule. `insert_sparse_batch` (the batch method)
247    /// performs the same write-time lookup first; a non-strict `None` then
248    /// falls through this helper, which records the actual compatibility
249    /// fallback. Strict mode returns before the direct-writer seam.
250    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        // ADR-067 Component A: when the write queue is enabled, route
349        // through the pool-wide WriterTask. DML-only closure — no BEGIN
350        // IMMEDIATE/COMMIT/ROLLBACK here, since the WriterTask's run loop
351        // owns the transaction.
352        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        // Explicitly disabled or degraded fallback path: byte-for-byte unchanged from pre-ADR-067
363        // behavior — the closure owns its own BEGIN IMMEDIATE/COMMIT.
364        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            // Load candidate rows for namespace (and optional kind).
427            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            // Collect rows.
448            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            // Bounded min-heap for top-k selection (KDB-AUD-003).
462            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                // surface malformed rows as errors instead of silently skipping them
477                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                // `as_chunks` is unstable on stable; keep `chunks_exact`
500                // until it lands.
501                #[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            // Drain heap and sort descending by score, ascending by UUID on tie.
523            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/// Candidate scored during sparse search, ordered for a min-heap so we can
560/// maintain a bounded top-k set: (score desc, subject_id asc) tie-breaking.
561#[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        // Min-heap: lower score pops first. On tie, higher UUID pops first
578        // (so lower UUID is retained = deterministic ascending tie-break).
579        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
590/// Validate invariants on a deserialized sparse vector from the database.
591/// Returns a storage error describing the corruption instead of silently
592/// skipping the row (KDB-AUD-002).
593fn 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
635/// Sparse dot product via merge of two sorted index arrays.
636fn 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        // Create schema.
705        {
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    /// STORAGE-AUD-002 / #470: top_k = u32::MAX must return InvalidInput
776    /// without allocating a multi-hundred-GB heap.
777    #[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    /// ADR-067 Component A entry 6: with `KHIVE_WRITE_QUEUE=1`, `insert_batch`
1007    /// (delegating to `insert_sparse_batch`) routes through the WriterTask
1008    /// channel instead of the pool-mutex path, and both rows are actually
1009    /// committed and independently searchable back.
1010    ///
1011    /// Constructed via a `PoolConfig` literal (`write_queue_enabled: Some(true)`),
1012    /// not the `KHIVE_WRITE_QUEUE` env var — that env var is process-global
1013    /// and this crate's other tests are NOT `#[serial]` against it, so a
1014    /// window where it is set here could leak into a
1015    /// concurrently-scheduled test's own pool construction (ADR-067
1016    /// Component A).
1017    #[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;