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