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::{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
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/// DML-only batch insert loop shared by both the legacy (flag-off) and
82/// WriterTask-routed (flag-on) `insert_sparse_batch` paths (ADR-067
83/// Component A).
84///
85/// Issues no `BEGIN` / `COMMIT` / `ROLLBACK` itself — the caller owns the
86/// enclosing transaction. Per-row failures (validation or SQL) are captured
87/// into `BatchWriteSummary::failed`/`first_error` rather than aborting the
88/// loop, matching the existing partial-success contract.
89fn 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        // Validate inline — skip invalid records rather than aborting the batch.
114        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
170/// Create the sparse table and its index for the given model_key.
171pub(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
193/// SQLite-backed sparse vector store.
194pub struct SqliteSparseStore {
195    pool: Arc<ConnectionPool>,
196    table_name: String,
197    namespace: String,
198    writer_task: Option<WriterTaskHandle>,
199}
200
201impl SqliteSparseStore {
202    /// Create a new sparse store for the given model key and namespace.
203    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        // Enabled by default for file-backed pools. Construction stays
211        // synchronous (ADR-067 Component A, mirrors entity.rs policy): a
212        // missing writer task is cached without failing construction. Every
213        // write re-resolves it and applies strict/compatibility policy then.
214        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    /// Route a single-row write through the pool-wide `WriterTask` when
232    /// the write queue is enabled and a handle is available. Strict mode
233    /// refuses a missing handle; compatibility mode falls back to the legacy
234    /// pool-mutex path (ADR-067 Component A, Fork C slice 2).
235    ///
236    /// This is the ONE routing point for every `with_writer` caller in this
237    /// store (`upsert_sparse_vector`, `delete_sparse_subject`). `f` must be
238    /// DML-only — on the flag-on path it runs inside the WriterTask's own
239    /// transaction, so a bare `BEGIN IMMEDIATE` would violate SQLite's
240    /// nested-transaction rule. `insert_sparse_batch` (the batch method)
241    /// performs the same write-time lookup first; a non-strict `None` then
242    /// falls through this helper, which records the actual compatibility
243    /// fallback. Strict mode returns before the direct-writer seam.
244    async fn with_writer<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
245    where
246        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
247        R: Send + 'static,
248    {
249        if let Some(writer_task) = self.current_writer_task(op)? {
250            return writer_task
251                .send_bounded(move |conn| f(conn).map_err(|e| map_err(e, op)))
252                .await;
253        }
254
255        self.pool
256            .record_direct_route(crate::timeout_sink::Site::DirectRouteSparseGeneralWrite);
257        let pool = Arc::clone(&self.pool);
258        tokio::task::spawn_blocking(move || {
259            let guard = pool
260                .autocommit_write_unit()
261                .map_err(|e| map_sqlite_err(e, op))?;
262            f(guard.conn())
263                .map_err(|e| map_err(e, op))
264                .inspect_err(|error| pool.record_direct_writer_error(error))
265        })
266        .await
267        .map_err(|e| StorageError::driver(StorageCapability::Sparse, op, e))?
268    }
269
270    async fn with_reader<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
271    where
272        F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
273        R: Send + 'static,
274    {
275        super::run_pooled_store_read(
276            Arc::clone(&self.pool),
277            StorageCapability::Sparse,
278            op,
279            move |conn| f(conn).map_err(|error| map_err(error, op)),
280        )
281        .await
282    }
283
284    async fn upsert_sparse_vector(
285        &self,
286        subject_id: Uuid,
287        kind: SubstrateKind,
288        namespace: &str,
289        field: &str,
290        vector: SparseVector,
291    ) -> Result<(), StorageError> {
292        let table = self.table_name.clone();
293        let ns = namespace.to_string();
294        let field = field.to_string();
295        let id_str = subject_id.to_string();
296        let kind_str = kind.to_string();
297
298        self.with_writer("sparse_upsert", move |conn| {
299            let indices_json = serde_json::to_string(&vector.indices).map_err(|e| {
300                rusqlite::Error::FromSqlConversionFailure(
301                    0,
302                    rusqlite::types::Type::Text,
303                    Box::new(e),
304                )
305            })?;
306            let values_blob = encode_f32_le(&vector.values);
307            let now = chrono::Utc::now().timestamp();
308            let sql = format!(
309                "INSERT INTO {table} \
310                 (subject_id, namespace, kind, field, indices_json, values_blob, updated_at) \
311                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \
312                 ON CONFLICT(subject_id, namespace, field) DO UPDATE SET \
313                 kind = excluded.kind, \
314                 indices_json = excluded.indices_json, \
315                 values_blob = excluded.values_blob, \
316                 updated_at = excluded.updated_at"
317            );
318            conn.execute(
319                &sql,
320                rusqlite::params![
321                    &id_str,
322                    &ns,
323                    &kind_str,
324                    &field,
325                    &indices_json,
326                    values_blob,
327                    now
328                ],
329            )?;
330            Ok(())
331        })
332        .await
333    }
334
335    async fn insert_sparse_batch(
336        &self,
337        records: Vec<SparseRecord>,
338    ) -> Result<BatchWriteSummary, StorageError> {
339        let table = self.table_name.clone();
340        let attempted = records.len() as u64;
341
342        // ADR-067 Component A: when the write queue is enabled, route
343        // through the pool-wide WriterTask. DML-only closure — no BEGIN
344        // IMMEDIATE/COMMIT/ROLLBACK here, since the WriterTask's run loop
345        // owns the transaction.
346        if let Some(writer_task) = self.current_writer_task("sparse_insert_batch")? {
347            let table2 = table.clone();
348            return writer_task
349                .send_bounded(move |conn| {
350                    batch_insert_sparse_dml(conn, &table2, &records, attempted)
351                        .map_err(|e| map_err(e, "sparse_insert_batch"))
352                })
353                .await;
354        }
355
356        // Explicitly disabled or degraded fallback path: byte-for-byte unchanged from pre-ADR-067
357        // behavior — the closure owns its own BEGIN IMMEDIATE/COMMIT.
358        let origin = self.pool.origin();
359        self.with_writer("sparse_insert_batch", move |conn| {
360            conn.execute_batch("BEGIN IMMEDIATE")?;
361            let _tx_handle = khive_storage::tx_registry::register_scoped(
362                Some("sparse_insert_batch".to_string()),
363                origin,
364            );
365
366            let summary = batch_insert_sparse_dml(conn, &table, &records, attempted)?;
367
368            conn.execute_batch("COMMIT")?;
369            Ok(summary)
370        })
371        .await
372    }
373
374    async fn delete_sparse_subject(&self, subject_id: Uuid) -> Result<bool, StorageError> {
375        let table = self.table_name.clone();
376        let namespace = self.namespace.clone();
377        let id_str = subject_id.to_string();
378
379        self.with_writer("sparse_delete", move |conn| {
380            let sql = format!("DELETE FROM {table} WHERE subject_id = ?1 AND namespace = ?2");
381            let deleted = conn.execute(&sql, rusqlite::params![&id_str, &namespace])?;
382            Ok(deleted > 0)
383        })
384        .await
385    }
386
387    async fn search_sparse_vectors(
388        &self,
389        request: SparseSearchRequest,
390    ) -> Result<Vec<SparseSearchHit>, StorageError> {
391        request
392            .validate()
393            .map_err(|message| StorageError::InvalidInput {
394                capability: StorageCapability::Sparse,
395                operation: "sparse_search".into(),
396                message,
397            })?;
398
399        let table = self.table_name.clone();
400        let ns = request
401            .namespace
402            .clone()
403            .unwrap_or_else(|| self.namespace.clone());
404        let kind_filter = request.kind.map(|k| k.to_string());
405        let query = request.query;
406        let top_k = usize::try_from(request.top_k).map_err(|_| StorageError::InvalidInput {
407            capability: StorageCapability::Sparse,
408            operation: "sparse_search".into(),
409            message: "SparseSearchRequest: top_k does not fit usize".into(),
410        })?;
411        let heap_capacity = top_k
412            .checked_add(1)
413            .ok_or_else(|| StorageError::InvalidInput {
414                capability: StorageCapability::Sparse,
415                operation: "sparse_search".into(),
416                message: "SparseSearchRequest: top_k capacity overflow".into(),
417            })?;
418
419        self.with_reader("sparse_search", move |conn| {
420            // Load candidate rows for namespace (and optional kind).
421            let (sql, kind_str_ref) = if let Some(ref kind_str) = kind_filter {
422                (
423                    format!(
424                        "SELECT subject_id, indices_json, values_blob \
425                         FROM {table} WHERE namespace = ?1 AND kind = ?2"
426                    ),
427                    Some(kind_str.as_str()),
428                )
429            } else {
430                (
431                    format!(
432                        "SELECT subject_id, indices_json, values_blob \
433                         FROM {table} WHERE namespace = ?1"
434                    ),
435                    None,
436                )
437            };
438
439            let mut stmt = conn.prepare(&sql)?;
440
441            // Collect rows.
442            let rows: Vec<rusqlite::Result<(String, String, Vec<u8>)>> =
443                if let Some(kind_str) = kind_str_ref {
444                    stmt.query_map(rusqlite::params![&ns, kind_str], |row| {
445                        Ok((row.get(0)?, row.get(1)?, row.get(2)?))
446                    })?
447                    .collect()
448                } else {
449                    stmt.query_map(rusqlite::params![&ns], |row| {
450                        Ok((row.get(0)?, row.get(1)?, row.get(2)?))
451                    })?
452                    .collect()
453                };
454
455            // Bounded min-heap for top-k selection (KDB-AUD-003).
456            let mut heap: BinaryHeap<Reverse<ScoredCandidate>> =
457                BinaryHeap::with_capacity(heap_capacity);
458
459            for row_result in rows {
460                let (id_str, indices_json, values_blob) = row_result?;
461
462                let subject_id = Uuid::parse_str(&id_str).map_err(|e| {
463                    rusqlite::Error::FromSqlConversionFailure(
464                        0,
465                        rusqlite::types::Type::Text,
466                        Box::new(e),
467                    )
468                })?;
469
470                // surface malformed rows as errors instead of silently skipping them
471                let stored_indices: Vec<u32> =
472                    serde_json::from_str(&indices_json).map_err(|e| {
473                        rusqlite::Error::FromSqlConversionFailure(
474                            0,
475                            rusqlite::types::Type::Text,
476                            Box::<dyn std::error::Error + Send + Sync>::from(format!(
477                                "corrupt sparse row {id_str}: invalid indices JSON: {e}"
478                            )),
479                        )
480                    })?;
481
482                let stored_values = decode_f32_le(&values_blob).map_err(|_| {
483                    rusqlite::Error::FromSqlConversionFailure(
484                        0,
485                        rusqlite::types::Type::Blob,
486                        Box::<dyn std::error::Error + Send + Sync>::from(format!(
487                            "corrupt sparse row {id_str}: values blob length {} not a multiple of 4",
488                            values_blob.len()
489                        )),
490                    )
491                })?;
492
493                validate_persisted_sparse(&id_str, &stored_indices, &stored_values)?;
494
495                let score = sparse_dot_product(
496                    &query.indices,
497                    &query.values,
498                    &stored_indices,
499                    &stored_values,
500                );
501
502                heap.push(Reverse(ScoredCandidate { score, subject_id }));
503                if heap.len() > top_k {
504                    heap.pop();
505                }
506            }
507
508            // Drain heap and sort descending by score, ascending by UUID on tie.
509            let mut top: Vec<_> = heap.into_iter().map(|Reverse(c)| c).collect();
510            top.sort_by(|a, b| {
511                b.score
512                    .partial_cmp(&a.score)
513                    .unwrap_or(std::cmp::Ordering::Equal)
514                    .then_with(|| a.subject_id.cmp(&b.subject_id))
515            });
516
517            let hits = top
518                .into_iter()
519                .enumerate()
520                .map(|(i, c)| SparseSearchHit {
521                    subject_id: c.subject_id,
522                    score: DeterministicScore::from_f64(c.score),
523                    rank: (i + 1) as u32,
524                })
525                .collect();
526
527            Ok(hits)
528        })
529        .await
530    }
531
532    async fn count_sparse_rows(&self) -> Result<u64, StorageError> {
533        let table = self.table_name.clone();
534        let namespace = self.namespace.clone();
535        self.with_reader("sparse_count", move |conn| {
536            let sql = format!("SELECT COUNT(*) FROM {table} WHERE namespace = ?1");
537            let count: i64 =
538                conn.query_row(&sql, rusqlite::params![&namespace], |row| row.get(0))?;
539            Ok(count as u64)
540        })
541        .await
542    }
543}
544
545/// Candidate scored during sparse search, ordered for a min-heap so we can
546/// maintain a bounded top-k set: (score desc, subject_id asc) tie-breaking.
547#[derive(PartialEq)]
548struct ScoredCandidate {
549    score: f64,
550    subject_id: Uuid,
551}
552
553impl Eq for ScoredCandidate {}
554
555impl PartialOrd for ScoredCandidate {
556    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
557        Some(self.cmp(other))
558    }
559}
560
561impl Ord for ScoredCandidate {
562    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
563        // Min-heap: lower score pops first. On tie, higher UUID pops first
564        // (so lower UUID is retained = deterministic ascending tie-break).
565        match self
566            .score
567            .partial_cmp(&other.score)
568            .unwrap_or(std::cmp::Ordering::Equal)
569        {
570            std::cmp::Ordering::Equal => other.subject_id.cmp(&self.subject_id),
571            ord => ord,
572        }
573    }
574}
575
576/// Validate invariants on a deserialized sparse vector from the database.
577/// Returns a storage error describing the corruption instead of silently
578/// skipping the row (KDB-AUD-002).
579fn validate_persisted_sparse(
580    subject_id: &str,
581    indices: &[u32],
582    values: &[f32],
583) -> Result<(), rusqlite::Error> {
584    if indices.len() != values.len() {
585        return Err(rusqlite::Error::FromSqlConversionFailure(
586            0,
587            rusqlite::types::Type::Blob,
588            Box::<dyn std::error::Error + Send + Sync>::from(format!(
589                "corrupt sparse row {subject_id}: indices len {} != values len {}",
590                indices.len(),
591                values.len()
592            )),
593        ));
594    }
595    for (i, v) in values.iter().enumerate() {
596        if !v.is_finite() {
597            return Err(rusqlite::Error::FromSqlConversionFailure(
598                0,
599                rusqlite::types::Type::Blob,
600                Box::<dyn std::error::Error + Send + Sync>::from(format!(
601                    "corrupt sparse row {subject_id}: non-finite value at position {i}: {v}"
602                )),
603            ));
604        }
605    }
606    for window in indices.windows(2) {
607        if window[0] >= window[1] {
608            return Err(rusqlite::Error::FromSqlConversionFailure(
609                0,
610                rusqlite::types::Type::Blob,
611                Box::<dyn std::error::Error + Send + Sync>::from(format!(
612                    "corrupt sparse row {subject_id}: indices not strictly increasing at {} >= {}",
613                    window[0], window[1]
614                )),
615            ));
616        }
617    }
618    Ok(())
619}
620
621/// Sparse dot product via merge of two sorted index arrays.
622fn sparse_dot_product(q_idx: &[u32], q_val: &[f32], s_idx: &[u32], s_val: &[f32]) -> f64 {
623    let mut dot = 0.0f64;
624    let mut qi = 0;
625    let mut si = 0;
626    while qi < q_idx.len() && si < s_idx.len() {
627        match q_idx[qi].cmp(&s_idx[si]) {
628            std::cmp::Ordering::Equal => {
629                dot += q_val[qi] as f64 * s_val[si] as f64;
630                qi += 1;
631                si += 1;
632            }
633            std::cmp::Ordering::Less => qi += 1,
634            std::cmp::Ordering::Greater => si += 1,
635        }
636    }
637    dot
638}
639
640#[async_trait]
641impl SparseStore for SqliteSparseStore {
642    async fn insert_sparse(
643        &self,
644        subject_id: Uuid,
645        kind: SubstrateKind,
646        namespace: &str,
647        field: &str,
648        vector: SparseVector,
649    ) -> Result<(), StorageError> {
650        validate_sparse_vector(&vector, "sparse_insert")?;
651        self.upsert_sparse_vector(subject_id, kind, namespace, field, vector)
652            .await
653    }
654
655    async fn insert_batch(
656        &self,
657        records: Vec<SparseRecord>,
658    ) -> Result<BatchWriteSummary, StorageError> {
659        self.insert_sparse_batch(records).await
660    }
661
662    async fn delete(&self, subject_id: Uuid) -> Result<bool, StorageError> {
663        self.delete_sparse_subject(subject_id).await
664    }
665
666    async fn search_sparse(
667        &self,
668        request: SparseSearchRequest,
669    ) -> Result<Vec<SparseSearchHit>, StorageError> {
670        validate_sparse_vector(&request.query, "sparse_search")?;
671        self.search_sparse_vectors(request).await
672    }
673
674    async fn count(&self) -> Result<u64, StorageError> {
675        self.count_sparse_rows().await
676    }
677}
678
679#[cfg(test)]
680mod tests {
681    use super::*;
682    use crate::pool::{ConnectionPool, PoolConfig};
683
684    fn make_store(model_key: &str) -> SqliteSparseStore {
685        let config = PoolConfig {
686            path: None,
687            ..PoolConfig::default()
688        };
689        let pool = Arc::new(ConnectionPool::new(config).expect("pool"));
690        // Create schema.
691        {
692            let writer = pool.try_writer().expect("writer");
693            ensure_sparse_schema(writer.conn(), model_key).expect("schema");
694        }
695        SqliteSparseStore::new(pool, false, model_key.to_string(), "ns:test".to_string())
696            .expect("store")
697    }
698
699    fn sv(indices: Vec<u32>, values: Vec<f32>) -> SparseVector {
700        SparseVector { indices, values }
701    }
702
703    #[tokio::test]
704    async fn single_and_batch_sparse_writes_store_fixed_little_endian_bytes() {
705        let pool = Arc::new(
706            ConnectionPool::new(PoolConfig {
707                path: None,
708                write_queue_enabled: Some(false),
709                write_routing_strict: false,
710                write_admission_deadline_ms: 2000,
711                disk_guard_config: Some(crate::EffectiveDiskGuardConfig::default()),
712                ..PoolConfig::for_test()
713            })
714            .expect("private memory pool"),
715        );
716        {
717            let writer = pool.try_writer().expect("writer");
718            ensure_sparse_schema(writer.conn(), "codec_bytes").expect("schema");
719        }
720        let store =
721            SqliteSparseStore::new(pool, false, "codec_bytes".into(), "ns:codec".into()).unwrap();
722        let values = || sv(vec![0, 1], vec![1.0, -2.5]);
723        let first = Uuid::from_u128(1);
724        let second = Uuid::from_u128(2);
725        store
726            .insert_sparse(first, SubstrateKind::Entity, "ns:codec", "body", values())
727            .await
728            .unwrap();
729        let summary = store
730            .insert_batch(vec![SparseRecord {
731                subject_id: second,
732                kind: SubstrateKind::Entity,
733                namespace: "ns:codec".into(),
734                field: "body".into(),
735                vector: values(),
736                updated_at: chrono::Utc::now(),
737            }])
738            .await
739            .unwrap();
740        assert_eq!(
741            (summary.attempted, summary.affected, summary.failed),
742            (1, 1, 0)
743        );
744        let blobs = store
745            .with_reader("sparse_codec_bytes", |conn| {
746                let mut statement =
747                    conn.prepare("SELECT values_blob FROM sparse_codec_bytes ORDER BY subject_id")?;
748                let rows = statement.query_map([], |row| row.get::<_, Vec<u8>>(0))?;
749                rows.collect::<Result<Vec<_>, _>>()
750            })
751            .await
752            .unwrap();
753        assert_eq!(blobs.len(), 2);
754        for blob in blobs {
755            assert_eq!(blob, [0, 0, 0x80, 0x3f, 0, 0, 0x20, 0xc0]);
756        }
757        let hits = store
758            .search_sparse(SparseSearchRequest {
759                query: values(),
760                top_k: 2,
761                namespace: Some("ns:codec".into()),
762                kind: None,
763            })
764            .await
765            .unwrap();
766        assert_eq!(
767            hits.iter().map(|hit| hit.subject_id).collect::<Vec<_>>(),
768            vec![first, second]
769        );
770    }
771
772    #[tokio::test]
773    async fn insert_and_count() {
774        let store = make_store("test_count");
775        let id = Uuid::new_v4();
776        store
777            .insert_sparse(
778                id,
779                SubstrateKind::Entity,
780                "ns:test",
781                "body",
782                sv(vec![0, 2], vec![1.0, 0.5]),
783            )
784            .await
785            .unwrap();
786        assert_eq!(store.count().await.unwrap(), 1);
787    }
788
789    #[tokio::test]
790    async fn insert_and_search() {
791        let store = make_store("test_search");
792        let id1 = Uuid::new_v4();
793        let id2 = Uuid::new_v4();
794        store
795            .insert_sparse(
796                id1,
797                SubstrateKind::Entity,
798                "ns:test",
799                "body",
800                sv(vec![0, 1], vec![1.0, 0.0]),
801            )
802            .await
803            .unwrap();
804        store
805            .insert_sparse(
806                id2,
807                SubstrateKind::Entity,
808                "ns:test",
809                "body",
810                sv(vec![0, 1], vec![0.0, 1.0]),
811            )
812            .await
813            .unwrap();
814
815        let hits = store
816            .search_sparse(SparseSearchRequest {
817                query: sv(vec![0], vec![1.0]),
818                top_k: 2,
819                namespace: Some("ns:test".into()),
820                kind: None,
821            })
822            .await
823            .unwrap();
824
825        assert!(!hits.is_empty());
826        assert_eq!(hits[0].subject_id, id1, "id1 should rank first");
827        assert_eq!(hits[0].rank, 1);
828    }
829
830    /// STORAGE-AUD-002 / #470: top_k = u32::MAX must return InvalidInput
831    /// without allocating a multi-hundred-GB heap.
832    #[tokio::test]
833    async fn sparse_top_k_u32_max_rejected() {
834        let store = make_store("test_top_k_max");
835        let id = Uuid::new_v4();
836        store
837            .insert_sparse(
838                id,
839                SubstrateKind::Entity,
840                "ns:test",
841                "body",
842                sv(vec![0], vec![1.0]),
843            )
844            .await
845            .unwrap();
846
847        let result = store
848            .search_sparse(SparseSearchRequest {
849                query: sv(vec![0], vec![1.0]),
850                top_k: u32::MAX,
851                namespace: Some("ns:test".into()),
852                kind: None,
853            })
854            .await;
855
856        assert!(
857            matches!(result, Err(StorageError::InvalidInput { .. })),
858            "expected InvalidInput, got {result:?}"
859        );
860    }
861
862    #[tokio::test]
863    async fn delete_removes_row() {
864        let store = make_store("test_delete");
865        let id = Uuid::new_v4();
866        store
867            .insert_sparse(
868                id,
869                SubstrateKind::Entity,
870                "ns:test",
871                "body",
872                sv(vec![1], vec![1.0]),
873            )
874            .await
875            .unwrap();
876        assert_eq!(store.count().await.unwrap(), 1);
877
878        let deleted = store.delete(id).await.unwrap();
879        assert!(deleted);
880        assert_eq!(store.count().await.unwrap(), 0);
881    }
882
883    #[tokio::test]
884    async fn mismatched_lengths_rejected() {
885        let store = make_store("test_mismatch");
886        let result = store
887            .insert_sparse(
888                Uuid::new_v4(),
889                SubstrateKind::Entity,
890                "ns:test",
891                "body",
892                SparseVector {
893                    indices: vec![0, 1],
894                    values: vec![1.0],
895                },
896            )
897            .await;
898        assert!(matches!(result, Err(StorageError::InvalidInput { .. })));
899    }
900
901    #[tokio::test]
902    async fn non_finite_values_rejected() {
903        let store = make_store("test_nonfinite");
904        let result = store
905            .insert_sparse(
906                Uuid::new_v4(),
907                SubstrateKind::Entity,
908                "ns:test",
909                "body",
910                sv(vec![0], vec![f32::NAN]),
911            )
912            .await;
913        assert!(matches!(result, Err(StorageError::InvalidInput { .. })));
914    }
915
916    #[tokio::test]
917    async fn duplicate_indices_rejected() {
918        let store = make_store("test_dup_idx");
919        let result = store
920            .insert_sparse(
921                Uuid::new_v4(),
922                SubstrateKind::Entity,
923                "ns:test",
924                "body",
925                sv(vec![0, 0], vec![1.0, 2.0]),
926            )
927            .await;
928        assert!(matches!(result, Err(StorageError::InvalidInput { .. })));
929    }
930
931    #[tokio::test]
932    async fn empty_vector_rejected() {
933        let store = make_store("test_empty");
934        let result = store
935            .insert_sparse(
936                Uuid::new_v4(),
937                SubstrateKind::Entity,
938                "ns:test",
939                "body",
940                sv(vec![], vec![]),
941            )
942            .await;
943        assert!(matches!(result, Err(StorageError::InvalidInput { .. })));
944    }
945
946    #[tokio::test]
947    async fn namespace_isolation() {
948        let store = make_store("test_ns_iso");
949        let id = Uuid::new_v4();
950        store
951            .insert_sparse(
952                id,
953                SubstrateKind::Entity,
954                "ns:a",
955                "body",
956                sv(vec![0], vec![1.0]),
957            )
958            .await
959            .unwrap();
960
961        let hits = store
962            .search_sparse(SparseSearchRequest {
963                query: sv(vec![0], vec![1.0]),
964                top_k: 5,
965                namespace: Some("ns:b".into()),
966                kind: None,
967            })
968            .await
969            .unwrap();
970        assert!(hits.is_empty(), "ns:b should not see ns:a data");
971    }
972
973    #[tokio::test]
974    async fn insert_batch_happy_path() {
975        use chrono::Utc;
976        use khive_types::SubstrateKind;
977
978        let store = make_store("test_batch");
979        let id1 = Uuid::new_v4();
980        let id2 = Uuid::new_v4();
981        let records = vec![
982            SparseRecord {
983                subject_id: id1,
984                kind: SubstrateKind::Entity,
985                namespace: "ns:test".into(),
986                field: "body".into(),
987                vector: sv(vec![0, 3], vec![0.5, 0.8]),
988                updated_at: Utc::now(),
989            },
990            SparseRecord {
991                subject_id: id2,
992                kind: SubstrateKind::Entity,
993                namespace: "ns:test".into(),
994                field: "body".into(),
995                vector: sv(vec![1], vec![1.0]),
996                updated_at: Utc::now(),
997            },
998        ];
999        let summary = store.insert_batch(records).await.unwrap();
1000        assert_eq!(summary.attempted, 2);
1001        assert_eq!(summary.affected, 2);
1002        assert_eq!(summary.failed, 0);
1003        assert_eq!(store.count().await.unwrap(), 2);
1004    }
1005
1006    #[tokio::test]
1007    async fn insert_batch_upsert_updates_kind_and_kind_filtered_search() {
1008        use chrono::Utc;
1009
1010        let store = make_store("test_batch_kind_upsert");
1011        let id = Uuid::new_v4();
1012        let record = |kind, vector| SparseRecord {
1013            subject_id: id,
1014            kind,
1015            namespace: "ns:test".into(),
1016            field: "body".into(),
1017            vector,
1018            updated_at: Utc::now(),
1019        };
1020
1021        let first = store
1022            .insert_batch(vec![record(SubstrateKind::Entity, sv(vec![0], vec![1.0]))])
1023            .await
1024            .unwrap();
1025        assert_eq!(first.affected, 1);
1026
1027        let replacement = store
1028            .insert_batch(vec![record(SubstrateKind::Note, sv(vec![1], vec![1.0]))])
1029            .await
1030            .unwrap();
1031        assert_eq!(replacement.affected, 1);
1032        assert_eq!(store.count().await.unwrap(), 1);
1033
1034        let stored_kind: String = {
1035            let writer = store.pool.try_writer().expect("writer");
1036            writer
1037                .conn()
1038                .query_row(
1039                    "SELECT kind FROM sparse_test_batch_kind_upsert \
1040                     WHERE subject_id = ?1 AND namespace = ?2 AND field = ?3",
1041                    rusqlite::params![id.to_string(), "ns:test", "body"],
1042                    |row| row.get(0),
1043                )
1044                .expect("stored kind")
1045        };
1046        assert_eq!(stored_kind, "note");
1047
1048        let hits = store
1049            .search_sparse(SparseSearchRequest {
1050                query: sv(vec![1], vec![1.0]),
1051                top_k: 1,
1052                namespace: Some("ns:test".into()),
1053                kind: Some(SubstrateKind::Note),
1054            })
1055            .await
1056            .unwrap();
1057        assert_eq!(hits.len(), 1);
1058        assert_eq!(hits[0].subject_id, id);
1059    }
1060
1061    /// ADR-067 Component A entry 6: with `KHIVE_WRITE_QUEUE=1`, `insert_batch`
1062    /// (delegating to `insert_sparse_batch`) routes through the WriterTask
1063    /// channel instead of the pool-mutex path, and both rows are actually
1064    /// committed and independently searchable back.
1065    ///
1066    /// Constructed via a `PoolConfig` literal (`write_queue_enabled: Some(true)`),
1067    /// not the `KHIVE_WRITE_QUEUE` env var — that env var is process-global
1068    /// and this crate's other tests are NOT `#[serial]` against it, so a
1069    /// window where it is set here could leak into a
1070    /// concurrently-scheduled test's own pool construction (ADR-067
1071    /// Component A).
1072    #[tokio::test]
1073    async fn insert_batch_routes_through_writer_task_when_flag_enabled() {
1074        use chrono::Utc;
1075        use khive_types::SubstrateKind;
1076
1077        let model_key = "write_queue_flag_test";
1078        let dir = tempfile::tempdir().unwrap();
1079        let path = dir.path().join("write_queue_sparse.db");
1080        let pool_cfg = PoolConfig {
1081            path: Some(path.clone()),
1082            write_queue_enabled: Some(true),
1083            ..PoolConfig::for_test()
1084        };
1085        let pool = Arc::new(ConnectionPool::new(pool_cfg).expect("pool"));
1086        {
1087            let writer = pool.writer().expect("writer");
1088            ensure_sparse_schema(writer.conn(), model_key).expect("schema");
1089        }
1090
1091        let store = SqliteSparseStore::new(
1092            Arc::clone(&pool),
1093            true,
1094            model_key.to_string(),
1095            "ns:test".to_string(),
1096        )
1097        .expect("store");
1098
1099        let id1 = Uuid::new_v4();
1100        let id2 = Uuid::new_v4();
1101        let records = vec![
1102            SparseRecord {
1103                subject_id: id1,
1104                kind: SubstrateKind::Entity,
1105                namespace: "ns:test".into(),
1106                field: "body".into(),
1107                vector: sv(vec![0, 3], vec![0.5, 0.8]),
1108                updated_at: Utc::now(),
1109            },
1110            SparseRecord {
1111                subject_id: id2,
1112                kind: SubstrateKind::Entity,
1113                namespace: "ns:test".into(),
1114                field: "body".into(),
1115                vector: sv(vec![1], vec![1.0]),
1116                updated_at: Utc::now(),
1117            },
1118        ];
1119
1120        let summary = store.insert_batch(records).await.unwrap();
1121        assert_eq!(summary.attempted, 2);
1122        assert_eq!(summary.affected, 2);
1123        assert_eq!(summary.failed, 0);
1124        assert_eq!(store.count().await.unwrap(), 2);
1125        assert_eq!(
1126            pool.writer_task_spawn_count(),
1127            1,
1128            "the flag-ON path must actually spawn and use the writer task"
1129        );
1130    }
1131}
1132
1133#[cfg(test)]
1134#[path = "sparse_busy_tests.rs"]
1135mod direct_busy_tests;