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    BatchWriteSummary, SparseRecord, SparseSearchHit, SparseSearchRequest, SparseVector,
14};
15use khive_storage::{SparseStore, StorageCapability};
16use khive_types::SubstrateKind;
17
18use crate::error::SqliteError;
19use crate::pool::ConnectionPool;
20use crate::writer_task::WriterTaskHandle;
21
22fn map_err(e: rusqlite::Error, op: &'static str) -> StorageError {
23    StorageError::driver(StorageCapability::Sparse, op, e)
24}
25
26fn map_sqlite_err(e: SqliteError, op: &'static str) -> StorageError {
27    StorageError::driver(StorageCapability::Sparse, op, e)
28}
29
30/// Validate that a sparse vector is well-formed.
31///
32/// - indices and values must have equal lengths
33/// - at least one element
34/// - all values must be finite
35/// - indices must be strictly increasing (no duplicates)
36fn validate_sparse_vector(vector: &SparseVector, op: &'static str) -> Result<(), StorageError> {
37    if vector.indices.len() != vector.values.len() {
38        return Err(StorageError::InvalidInput {
39            capability: StorageCapability::Sparse,
40            operation: op.into(),
41            message: format!(
42                "indices length ({}) != values length ({})",
43                vector.indices.len(),
44                vector.values.len()
45            ),
46        });
47    }
48    if vector.indices.is_empty() {
49        return Err(StorageError::InvalidInput {
50            capability: StorageCapability::Sparse,
51            operation: op.into(),
52            message: "sparse vector must have at least one element".into(),
53        });
54    }
55    for (i, v) in vector.values.iter().enumerate() {
56        if !v.is_finite() {
57            return Err(StorageError::InvalidInput {
58                capability: StorageCapability::Sparse,
59                operation: op.into(),
60                message: format!("non-finite value at position {i}: {v}"),
61            });
62        }
63    }
64    // Verify strictly increasing indices.
65    for window in vector.indices.windows(2) {
66        if window[0] >= window[1] {
67            return Err(StorageError::InvalidInput {
68                capability: StorageCapability::Sparse,
69                operation: op.into(),
70                message: format!(
71                    "indices must be strictly increasing; found {} then {}",
72                    window[0], window[1]
73                ),
74            });
75        }
76    }
77    Ok(())
78}
79
80/// Serialize f32 slice to little-endian bytes (same pattern as vectors.rs).
81fn f32_slice_as_bytes(data: &[f32]) -> &[u8] {
82    // SAFETY: same safety argument as vectors.rs — valid &[f32], alignment = 1, lifetime tied to input.
83    unsafe { std::slice::from_raw_parts(data.as_ptr() as *const u8, std::mem::size_of_val(data)) }
84}
85
86/// DML-only batch insert loop shared by both the legacy (flag-off) and
87/// WriterTask-routed (flag-on) `insert_sparse_batch` paths (ADR-067
88/// Component A).
89///
90/// Issues no `BEGIN` / `COMMIT` / `ROLLBACK` itself — the caller owns the
91/// enclosing transaction. Per-row failures (validation or SQL) are captured
92/// into `BatchWriteSummary::failed`/`first_error` rather than aborting the
93/// loop, matching the existing partial-success contract.
94fn batch_insert_sparse_dml(
95    conn: &rusqlite::Connection,
96    table: &str,
97    records: &[SparseRecord],
98    attempted: u64,
99) -> Result<BatchWriteSummary, rusqlite::Error> {
100    let sql = format!(
101        "INSERT INTO {table} \
102         (subject_id, namespace, kind, field, indices_json, values_blob, updated_at) \
103         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \
104         ON CONFLICT(subject_id, namespace, field) DO UPDATE SET \
105         indices_json = excluded.indices_json, \
106         values_blob = excluded.values_blob, \
107         updated_at = excluded.updated_at"
108    );
109
110    let mut affected = 0u64;
111    let mut failed = 0u64;
112    let mut first_error = String::new();
113
114    for record in records {
115        // Validate inline — skip invalid records rather than aborting the batch.
116        if record.vector.indices.len() != record.vector.values.len()
117            || record.vector.indices.is_empty()
118            || record.vector.values.iter().any(|v| !v.is_finite())
119            || record.vector.indices.windows(2).any(|w| w[0] >= w[1])
120        {
121            if first_error.is_empty() {
122                first_error = format!("invalid sparse vector for subject {}", record.subject_id);
123            }
124            failed += 1;
125            continue;
126        }
127
128        let indices_json = match serde_json::to_string(&record.vector.indices) {
129            Ok(j) => j,
130            Err(e) => {
131                if first_error.is_empty() {
132                    first_error = e.to_string();
133                }
134                failed += 1;
135                continue;
136            }
137        };
138        let values_blob = f32_slice_as_bytes(&record.vector.values);
139        let now = record.updated_at.timestamp();
140        let id_str = record.subject_id.to_string();
141        let kind_str = record.kind.to_string();
142
143        match conn.execute(
144            &sql,
145            rusqlite::params![
146                &id_str,
147                &record.namespace,
148                &kind_str,
149                &record.field,
150                &indices_json,
151                values_blob,
152                now
153            ],
154        ) {
155            Ok(_) => affected += 1,
156            Err(e) => {
157                if first_error.is_empty() {
158                    first_error = e.to_string();
159                }
160                failed += 1;
161            }
162        }
163    }
164
165    Ok(BatchWriteSummary {
166        attempted,
167        affected,
168        failed,
169        first_error,
170    })
171}
172
173/// Create the sparse table and its index for the given model_key.
174pub(crate) fn ensure_sparse_schema(
175    conn: &rusqlite::Connection,
176    model_key: &str,
177) -> Result<(), rusqlite::Error> {
178    let table = format!("sparse_{}", model_key);
179    let ddl = format!(
180        "CREATE TABLE IF NOT EXISTS {table} (\
181         subject_id TEXT NOT NULL, \
182         namespace TEXT NOT NULL, \
183         kind TEXT NOT NULL, \
184         field TEXT NOT NULL, \
185         indices_json TEXT NOT NULL, \
186         values_blob BLOB NOT NULL, \
187         updated_at INTEGER NOT NULL, \
188         PRIMARY KEY(subject_id, namespace, field)\
189         ); \
190         CREATE INDEX IF NOT EXISTS idx_{table}_namespace_kind \
191         ON {table}(namespace, kind);"
192    );
193    conn.execute_batch(&ddl)
194}
195
196/// SQLite-backed sparse vector store.
197pub struct SqliteSparseStore {
198    pool: Arc<ConnectionPool>,
199    is_file_backed: bool,
200    table_name: String,
201    namespace: String,
202    writer_task: Option<WriterTaskHandle>,
203}
204
205impl SqliteSparseStore {
206    /// Create a new sparse store for the given model key and namespace.
207    pub fn new(
208        pool: Arc<ConnectionPool>,
209        is_file_backed: bool,
210        model_key: String,
211        namespace: String,
212    ) -> Result<Self, SqliteError> {
213        let table_name = format!("sparse_{}", model_key);
214        // Best-effort opt-in (ADR-067 Component A, mirrors entity.rs slice 1
215        // policy): a missing writer task degrades to the legacy pool-mutex
216        // path rather than failing construction.
217        let writer_task = pool.writer_task_handle().ok().flatten();
218        Ok(Self {
219            pool,
220            is_file_backed,
221            table_name,
222            namespace,
223            writer_task,
224        })
225    }
226
227    /// Route a single-row write through the pool-wide `WriterTask` when
228    /// `KHIVE_WRITE_QUEUE=1` and a handle is available; otherwise fall back
229    /// to the legacy pool-mutex path (ADR-067 Component A, Fork C slice 2).
230    ///
231    /// This is the ONE routing point for every `with_writer` caller in this
232    /// store (`upsert_sparse_vector`, `delete_sparse_subject`). `f` must be
233    /// DML-only — on the flag-on path it runs inside the WriterTask's own
234    /// transaction, so a bare `BEGIN IMMEDIATE` would violate SQLite's
235    /// nested-transaction rule. `insert_sparse_batch` (the batch method)
236    /// does its own flag check and returns early on `Some`, so its
237    /// fallback call into this helper only ever executes on the flag-off
238    /// path (`self.writer_task` is `None` by construction whenever that
239    /// call is reached) — no double-routing.
240    async fn with_writer<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
241    where
242        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
243        R: Send + 'static,
244    {
245        if let Some(writer_task) = &self.writer_task {
246            return writer_task
247                .send(move |conn| f(conn).map_err(|e| map_err(e, op)))
248                .await;
249        }
250
251        let pool = Arc::clone(&self.pool);
252        tokio::task::spawn_blocking(move || {
253            let guard = pool.try_writer().map_err(|e| map_sqlite_err(e, op))?;
254            f(guard.conn()).map_err(|e| map_err(e, op))
255        })
256        .await
257        .map_err(|e| StorageError::driver(StorageCapability::Sparse, op, e))?
258    }
259
260    async fn with_reader<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
261    where
262        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
263        R: Send + 'static,
264    {
265        if self.is_file_backed {
266            // For file-backed DBs open a standalone read-only connection.
267            let config = self.pool.config();
268            let path = config.path.as_ref().ok_or_else(|| StorageError::Pool {
269                operation: "sparse_reader".into(),
270                message: "in-memory databases do not support standalone connections".into(),
271            })?;
272            let conn = rusqlite::Connection::open_with_flags(
273                path,
274                rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY
275                    | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX
276                    | rusqlite::OpenFlags::SQLITE_OPEN_URI,
277            )
278            .map_err(|e| map_err(e, op))?;
279            tokio::task::spawn_blocking(move || f(&conn).map_err(|e| map_err(e, op)))
280                .await
281                .map_err(|e| StorageError::driver(StorageCapability::Sparse, op, e))?
282        } else {
283            let pool = Arc::clone(&self.pool);
284            tokio::task::spawn_blocking(move || {
285                let guard = pool.reader().map_err(|e| map_sqlite_err(e, op))?;
286                f(guard.conn()).map_err(|e| map_err(e, op))
287            })
288            .await
289            .map_err(|e| StorageError::driver(StorageCapability::Sparse, op, e))?
290        }
291    }
292
293    async fn upsert_sparse_vector(
294        &self,
295        subject_id: Uuid,
296        kind: SubstrateKind,
297        namespace: &str,
298        field: &str,
299        vector: SparseVector,
300    ) -> Result<(), StorageError> {
301        let table = self.table_name.clone();
302        let ns = namespace.to_string();
303        let field = field.to_string();
304        let id_str = subject_id.to_string();
305        let kind_str = kind.to_string();
306
307        self.with_writer("sparse_upsert", move |conn| {
308            let indices_json = serde_json::to_string(&vector.indices).map_err(|e| {
309                rusqlite::Error::FromSqlConversionFailure(
310                    0,
311                    rusqlite::types::Type::Text,
312                    Box::new(e),
313                )
314            })?;
315            let values_blob = f32_slice_as_bytes(&vector.values);
316            let now = chrono::Utc::now().timestamp();
317            let sql = format!(
318                "INSERT INTO {table} \
319                 (subject_id, namespace, kind, field, indices_json, values_blob, updated_at) \
320                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \
321                 ON CONFLICT(subject_id, namespace, field) DO UPDATE SET \
322                 kind = excluded.kind, \
323                 indices_json = excluded.indices_json, \
324                 values_blob = excluded.values_blob, \
325                 updated_at = excluded.updated_at"
326            );
327            conn.execute(
328                &sql,
329                rusqlite::params![
330                    &id_str,
331                    &ns,
332                    &kind_str,
333                    &field,
334                    &indices_json,
335                    values_blob,
336                    now
337                ],
338            )?;
339            Ok(())
340        })
341        .await
342    }
343
344    async fn insert_sparse_batch(
345        &self,
346        records: Vec<SparseRecord>,
347    ) -> Result<BatchWriteSummary, StorageError> {
348        let table = self.table_name.clone();
349        let attempted = records.len() as u64;
350
351        // ADR-067 Component A: when the write queue is enabled, route
352        // through the pool-wide WriterTask. DML-only closure — no BEGIN
353        // IMMEDIATE/COMMIT/ROLLBACK here, since the WriterTask's run loop
354        // owns the transaction.
355        if let Some(writer_task) = &self.writer_task {
356            let table2 = table.clone();
357            return writer_task
358                .send(move |conn| {
359                    batch_insert_sparse_dml(conn, &table2, &records, attempted)
360                        .map_err(|e| map_err(e, "sparse_insert_batch"))
361                })
362                .await;
363        }
364
365        // Flag-off (default) path: byte-for-byte unchanged from pre-ADR-067
366        // behavior — the closure owns its own BEGIN IMMEDIATE/COMMIT.
367        self.with_writer("sparse_insert_batch", move |conn| {
368            conn.execute_batch("BEGIN IMMEDIATE")?;
369            let _tx_handle =
370                khive_storage::tx_registry::register(Some("sparse_insert_batch".to_string()));
371
372            let summary = batch_insert_sparse_dml(conn, &table, &records, attempted)?;
373
374            conn.execute_batch("COMMIT")?;
375            Ok(summary)
376        })
377        .await
378    }
379
380    async fn delete_sparse_subject(&self, subject_id: Uuid) -> Result<bool, StorageError> {
381        let table = self.table_name.clone();
382        let namespace = self.namespace.clone();
383        let id_str = subject_id.to_string();
384
385        self.with_writer("sparse_delete", move |conn| {
386            let sql = format!("DELETE FROM {table} WHERE subject_id = ?1 AND namespace = ?2");
387            let deleted = conn.execute(&sql, rusqlite::params![&id_str, &namespace])?;
388            Ok(deleted > 0)
389        })
390        .await
391    }
392
393    async fn search_sparse_vectors(
394        &self,
395        request: SparseSearchRequest,
396    ) -> Result<Vec<SparseSearchHit>, StorageError> {
397        request
398            .validate()
399            .map_err(|message| StorageError::InvalidInput {
400                capability: StorageCapability::Sparse,
401                operation: "sparse_search".into(),
402                message,
403            })?;
404
405        let table = self.table_name.clone();
406        let ns = request
407            .namespace
408            .clone()
409            .unwrap_or_else(|| self.namespace.clone());
410        let kind_filter = request.kind.map(|k| k.to_string());
411        let query = request.query;
412        let top_k = usize::try_from(request.top_k).map_err(|_| StorageError::InvalidInput {
413            capability: StorageCapability::Sparse,
414            operation: "sparse_search".into(),
415            message: "SparseSearchRequest: top_k does not fit usize".into(),
416        })?;
417        let heap_capacity = top_k
418            .checked_add(1)
419            .ok_or_else(|| StorageError::InvalidInput {
420                capability: StorageCapability::Sparse,
421                operation: "sparse_search".into(),
422                message: "SparseSearchRequest: top_k capacity overflow".into(),
423            })?;
424
425        self.with_reader("sparse_search", move |conn| {
426            // 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                let stored_values: Vec<f32> = values_blob
500                    .chunks_exact(4)
501                    .map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
502                    .collect();
503
504                validate_persisted_sparse(&id_str, &stored_indices, &stored_values)?;
505
506                let score = sparse_dot_product(
507                    &query.indices,
508                    &query.values,
509                    &stored_indices,
510                    &stored_values,
511                );
512
513                heap.push(Reverse(ScoredCandidate { score, subject_id }));
514                if heap.len() > top_k {
515                    heap.pop();
516                }
517            }
518
519            // Drain heap and sort descending by score, ascending by UUID on tie.
520            let mut top: Vec<_> = heap.into_iter().map(|Reverse(c)| c).collect();
521            top.sort_by(|a, b| {
522                b.score
523                    .partial_cmp(&a.score)
524                    .unwrap_or(std::cmp::Ordering::Equal)
525                    .then_with(|| a.subject_id.cmp(&b.subject_id))
526            });
527
528            let hits = top
529                .into_iter()
530                .enumerate()
531                .map(|(i, c)| SparseSearchHit {
532                    subject_id: c.subject_id,
533                    score: DeterministicScore::from_f64(c.score),
534                    rank: (i + 1) as u32,
535                })
536                .collect();
537
538            Ok(hits)
539        })
540        .await
541    }
542
543    async fn count_sparse_rows(&self) -> Result<u64, StorageError> {
544        let table = self.table_name.clone();
545        let namespace = self.namespace.clone();
546        self.with_reader("sparse_count", move |conn| {
547            let sql = format!("SELECT COUNT(*) FROM {table} WHERE namespace = ?1");
548            let count: i64 =
549                conn.query_row(&sql, rusqlite::params![&namespace], |row| row.get(0))?;
550            Ok(count as u64)
551        })
552        .await
553    }
554}
555
556/// Candidate scored during sparse search, ordered for a min-heap so we can
557/// maintain a bounded top-k set: (score desc, subject_id asc) tie-breaking.
558#[derive(PartialEq)]
559struct ScoredCandidate {
560    score: f64,
561    subject_id: Uuid,
562}
563
564impl Eq for ScoredCandidate {}
565
566impl PartialOrd for ScoredCandidate {
567    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
568        Some(self.cmp(other))
569    }
570}
571
572impl Ord for ScoredCandidate {
573    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
574        // Min-heap: lower score pops first. On tie, higher UUID pops first
575        // (so lower UUID is retained = deterministic ascending tie-break).
576        match self
577            .score
578            .partial_cmp(&other.score)
579            .unwrap_or(std::cmp::Ordering::Equal)
580        {
581            std::cmp::Ordering::Equal => other.subject_id.cmp(&self.subject_id),
582            ord => ord,
583        }
584    }
585}
586
587/// Validate invariants on a deserialized sparse vector from the database.
588/// Returns a storage error describing the corruption instead of silently
589/// skipping the row (KDB-AUD-002).
590fn validate_persisted_sparse(
591    subject_id: &str,
592    indices: &[u32],
593    values: &[f32],
594) -> Result<(), rusqlite::Error> {
595    if indices.len() != values.len() {
596        return Err(rusqlite::Error::FromSqlConversionFailure(
597            0,
598            rusqlite::types::Type::Blob,
599            Box::<dyn std::error::Error + Send + Sync>::from(format!(
600                "corrupt sparse row {subject_id}: indices len {} != values len {}",
601                indices.len(),
602                values.len()
603            )),
604        ));
605    }
606    for (i, v) in values.iter().enumerate() {
607        if !v.is_finite() {
608            return Err(rusqlite::Error::FromSqlConversionFailure(
609                0,
610                rusqlite::types::Type::Blob,
611                Box::<dyn std::error::Error + Send + Sync>::from(format!(
612                    "corrupt sparse row {subject_id}: non-finite value at position {i}: {v}"
613                )),
614            ));
615        }
616    }
617    for window in indices.windows(2) {
618        if window[0] >= window[1] {
619            return Err(rusqlite::Error::FromSqlConversionFailure(
620                0,
621                rusqlite::types::Type::Blob,
622                Box::<dyn std::error::Error + Send + Sync>::from(format!(
623                    "corrupt sparse row {subject_id}: indices not strictly increasing at {} >= {}",
624                    window[0], window[1]
625                )),
626            ));
627        }
628    }
629    Ok(())
630}
631
632/// Sparse dot product via merge of two sorted index arrays.
633fn sparse_dot_product(q_idx: &[u32], q_val: &[f32], s_idx: &[u32], s_val: &[f32]) -> f64 {
634    let mut dot = 0.0f64;
635    let mut qi = 0;
636    let mut si = 0;
637    while qi < q_idx.len() && si < s_idx.len() {
638        match q_idx[qi].cmp(&s_idx[si]) {
639            std::cmp::Ordering::Equal => {
640                dot += q_val[qi] as f64 * s_val[si] as f64;
641                qi += 1;
642                si += 1;
643            }
644            std::cmp::Ordering::Less => qi += 1,
645            std::cmp::Ordering::Greater => si += 1,
646        }
647    }
648    dot
649}
650
651#[async_trait]
652impl SparseStore for SqliteSparseStore {
653    async fn insert_sparse(
654        &self,
655        subject_id: Uuid,
656        kind: SubstrateKind,
657        namespace: &str,
658        field: &str,
659        vector: SparseVector,
660    ) -> Result<(), StorageError> {
661        validate_sparse_vector(&vector, "sparse_insert")?;
662        self.upsert_sparse_vector(subject_id, kind, namespace, field, vector)
663            .await
664    }
665
666    async fn insert_batch(
667        &self,
668        records: Vec<SparseRecord>,
669    ) -> Result<BatchWriteSummary, StorageError> {
670        self.insert_sparse_batch(records).await
671    }
672
673    async fn delete(&self, subject_id: Uuid) -> Result<bool, StorageError> {
674        self.delete_sparse_subject(subject_id).await
675    }
676
677    async fn search_sparse(
678        &self,
679        request: SparseSearchRequest,
680    ) -> Result<Vec<SparseSearchHit>, StorageError> {
681        validate_sparse_vector(&request.query, "sparse_search")?;
682        self.search_sparse_vectors(request).await
683    }
684
685    async fn count(&self) -> Result<u64, StorageError> {
686        self.count_sparse_rows().await
687    }
688}
689
690#[cfg(test)]
691mod tests {
692    use super::*;
693    use crate::pool::{ConnectionPool, PoolConfig};
694
695    fn make_store(model_key: &str) -> SqliteSparseStore {
696        let config = PoolConfig {
697            path: None,
698            ..PoolConfig::default()
699        };
700        let pool = Arc::new(ConnectionPool::new(config).expect("pool"));
701        // Create schema.
702        {
703            let writer = pool.try_writer().expect("writer");
704            ensure_sparse_schema(writer.conn(), model_key).expect("schema");
705        }
706        SqliteSparseStore::new(pool, false, model_key.to_string(), "ns:test".to_string())
707            .expect("store")
708    }
709
710    fn sv(indices: Vec<u32>, values: Vec<f32>) -> SparseVector {
711        SparseVector { indices, values }
712    }
713
714    #[tokio::test]
715    async fn insert_and_count() {
716        let store = make_store("test_count");
717        let id = Uuid::new_v4();
718        store
719            .insert_sparse(
720                id,
721                SubstrateKind::Entity,
722                "ns:test",
723                "body",
724                sv(vec![0, 2], vec![1.0, 0.5]),
725            )
726            .await
727            .unwrap();
728        assert_eq!(store.count().await.unwrap(), 1);
729    }
730
731    #[tokio::test]
732    async fn insert_and_search() {
733        let store = make_store("test_search");
734        let id1 = Uuid::new_v4();
735        let id2 = Uuid::new_v4();
736        store
737            .insert_sparse(
738                id1,
739                SubstrateKind::Entity,
740                "ns:test",
741                "body",
742                sv(vec![0, 1], vec![1.0, 0.0]),
743            )
744            .await
745            .unwrap();
746        store
747            .insert_sparse(
748                id2,
749                SubstrateKind::Entity,
750                "ns:test",
751                "body",
752                sv(vec![0, 1], vec![0.0, 1.0]),
753            )
754            .await
755            .unwrap();
756
757        let hits = store
758            .search_sparse(SparseSearchRequest {
759                query: sv(vec![0], vec![1.0]),
760                top_k: 2,
761                namespace: Some("ns:test".into()),
762                kind: None,
763            })
764            .await
765            .unwrap();
766
767        assert!(!hits.is_empty());
768        assert_eq!(hits[0].subject_id, id1, "id1 should rank first");
769        assert_eq!(hits[0].rank, 1);
770    }
771
772    /// STORAGE-AUD-002 / #470: top_k = u32::MAX must return InvalidInput
773    /// without allocating a multi-hundred-GB heap.
774    #[tokio::test]
775    async fn sparse_top_k_u32_max_rejected() {
776        let store = make_store("test_top_k_max");
777        let id = Uuid::new_v4();
778        store
779            .insert_sparse(
780                id,
781                SubstrateKind::Entity,
782                "ns:test",
783                "body",
784                sv(vec![0], vec![1.0]),
785            )
786            .await
787            .unwrap();
788
789        let result = store
790            .search_sparse(SparseSearchRequest {
791                query: sv(vec![0], vec![1.0]),
792                top_k: u32::MAX,
793                namespace: Some("ns:test".into()),
794                kind: None,
795            })
796            .await;
797
798        assert!(
799            matches!(result, Err(StorageError::InvalidInput { .. })),
800            "expected InvalidInput, got {result:?}"
801        );
802    }
803
804    #[tokio::test]
805    async fn delete_removes_row() {
806        let store = make_store("test_delete");
807        let id = Uuid::new_v4();
808        store
809            .insert_sparse(
810                id,
811                SubstrateKind::Entity,
812                "ns:test",
813                "body",
814                sv(vec![1], vec![1.0]),
815            )
816            .await
817            .unwrap();
818        assert_eq!(store.count().await.unwrap(), 1);
819
820        let deleted = store.delete(id).await.unwrap();
821        assert!(deleted);
822        assert_eq!(store.count().await.unwrap(), 0);
823    }
824
825    #[tokio::test]
826    async fn mismatched_lengths_rejected() {
827        let store = make_store("test_mismatch");
828        let result = store
829            .insert_sparse(
830                Uuid::new_v4(),
831                SubstrateKind::Entity,
832                "ns:test",
833                "body",
834                SparseVector {
835                    indices: vec![0, 1],
836                    values: vec![1.0],
837                },
838            )
839            .await;
840        assert!(matches!(result, Err(StorageError::InvalidInput { .. })));
841    }
842
843    #[tokio::test]
844    async fn non_finite_values_rejected() {
845        let store = make_store("test_nonfinite");
846        let result = store
847            .insert_sparse(
848                Uuid::new_v4(),
849                SubstrateKind::Entity,
850                "ns:test",
851                "body",
852                sv(vec![0], vec![f32::NAN]),
853            )
854            .await;
855        assert!(matches!(result, Err(StorageError::InvalidInput { .. })));
856    }
857
858    #[tokio::test]
859    async fn duplicate_indices_rejected() {
860        let store = make_store("test_dup_idx");
861        let result = store
862            .insert_sparse(
863                Uuid::new_v4(),
864                SubstrateKind::Entity,
865                "ns:test",
866                "body",
867                sv(vec![0, 0], vec![1.0, 2.0]),
868            )
869            .await;
870        assert!(matches!(result, Err(StorageError::InvalidInput { .. })));
871    }
872
873    #[tokio::test]
874    async fn empty_vector_rejected() {
875        let store = make_store("test_empty");
876        let result = store
877            .insert_sparse(
878                Uuid::new_v4(),
879                SubstrateKind::Entity,
880                "ns:test",
881                "body",
882                sv(vec![], vec![]),
883            )
884            .await;
885        assert!(matches!(result, Err(StorageError::InvalidInput { .. })));
886    }
887
888    #[tokio::test]
889    async fn namespace_isolation() {
890        let store = make_store("test_ns_iso");
891        let id = Uuid::new_v4();
892        store
893            .insert_sparse(
894                id,
895                SubstrateKind::Entity,
896                "ns:a",
897                "body",
898                sv(vec![0], vec![1.0]),
899            )
900            .await
901            .unwrap();
902
903        let hits = store
904            .search_sparse(SparseSearchRequest {
905                query: sv(vec![0], vec![1.0]),
906                top_k: 5,
907                namespace: Some("ns:b".into()),
908                kind: None,
909            })
910            .await
911            .unwrap();
912        assert!(hits.is_empty(), "ns:b should not see ns:a data");
913    }
914
915    #[tokio::test]
916    async fn insert_batch_happy_path() {
917        use chrono::Utc;
918        use khive_types::SubstrateKind;
919
920        let store = make_store("test_batch");
921        let id1 = Uuid::new_v4();
922        let id2 = Uuid::new_v4();
923        let records = vec![
924            SparseRecord {
925                subject_id: id1,
926                kind: SubstrateKind::Entity,
927                namespace: "ns:test".into(),
928                field: "body".into(),
929                vector: sv(vec![0, 3], vec![0.5, 0.8]),
930                updated_at: Utc::now(),
931            },
932            SparseRecord {
933                subject_id: id2,
934                kind: SubstrateKind::Entity,
935                namespace: "ns:test".into(),
936                field: "body".into(),
937                vector: sv(vec![1], vec![1.0]),
938                updated_at: Utc::now(),
939            },
940        ];
941        let summary = store.insert_batch(records).await.unwrap();
942        assert_eq!(summary.attempted, 2);
943        assert_eq!(summary.affected, 2);
944        assert_eq!(summary.failed, 0);
945        assert_eq!(store.count().await.unwrap(), 2);
946    }
947
948    /// ADR-067 Component A entry 6: with `KHIVE_WRITE_QUEUE=1`, `insert_batch`
949    /// (delegating to `insert_sparse_batch`) routes through the WriterTask
950    /// channel instead of the pool-mutex path, and both rows are actually
951    /// committed and independently searchable back.
952    ///
953    /// Constructed via a `PoolConfig` literal (`write_queue_enabled: true`),
954    /// not the `KHIVE_WRITE_QUEUE` env var — that env var is process-global
955    /// and this crate's other tests are NOT `#[serial]` against it, so a
956    /// window where it is set here could leak into a
957    /// concurrently-scheduled test's own pool construction (ADR-067 Fork C
958    /// slice 2 round 2, LOW finding).
959    #[tokio::test]
960    async fn insert_batch_routes_through_writer_task_when_flag_enabled() {
961        use chrono::Utc;
962        use khive_types::SubstrateKind;
963
964        let model_key = "write_queue_flag_test";
965        let dir = tempfile::tempdir().unwrap();
966        let path = dir.path().join("write_queue_sparse.db");
967        let pool_cfg = PoolConfig {
968            path: Some(path.clone()),
969            write_queue_enabled: true,
970            ..PoolConfig::default()
971        };
972        let pool = Arc::new(ConnectionPool::new(pool_cfg).expect("pool"));
973        {
974            let writer = pool.writer().expect("writer");
975            ensure_sparse_schema(writer.conn(), model_key).expect("schema");
976        }
977
978        let store = SqliteSparseStore::new(
979            Arc::clone(&pool),
980            true,
981            model_key.to_string(),
982            "ns:test".to_string(),
983        )
984        .expect("store");
985
986        let id1 = Uuid::new_v4();
987        let id2 = Uuid::new_v4();
988        let records = vec![
989            SparseRecord {
990                subject_id: id1,
991                kind: SubstrateKind::Entity,
992                namespace: "ns:test".into(),
993                field: "body".into(),
994                vector: sv(vec![0, 3], vec![0.5, 0.8]),
995                updated_at: Utc::now(),
996            },
997            SparseRecord {
998                subject_id: id2,
999                kind: SubstrateKind::Entity,
1000                namespace: "ns:test".into(),
1001                field: "body".into(),
1002                vector: sv(vec![1], vec![1.0]),
1003                updated_at: Utc::now(),
1004            },
1005        ];
1006
1007        let summary = store.insert_batch(records).await.unwrap();
1008        assert_eq!(summary.attempted, 2);
1009        assert_eq!(summary.affected, 2);
1010        assert_eq!(summary.failed, 0);
1011        assert_eq!(store.count().await.unwrap(), 2);
1012        assert_eq!(
1013            pool.writer_task_spawn_count(),
1014            1,
1015            "the flag-ON path must actually spawn and use the writer task"
1016        );
1017    }
1018}