Skip to main content

velesdb_mobile/
lib.rs

1// Mobile SDK - pedantic/nursery lints relaxed for UniFFI FFI boundary
2#![allow(clippy::pedantic)]
3#![allow(clippy::nursery)]
4#![allow(clippy::needless_pass_by_value)]
5// FFI boundary - pedantic lints relaxed for UniFFI compatibility
6#![allow(clippy::missing_errors_doc)]
7#![allow(clippy::missing_panics_doc)]
8#![allow(clippy::must_use_candidate)]
9#![allow(clippy::uninlined_format_args)]
10#![allow(clippy::similar_names)]
11#![allow(clippy::module_name_repetitions)]
12#![allow(clippy::doc_markdown)]
13#![allow(clippy::wildcard_imports)]
14#![allow(clippy::redundant_closure_for_method_calls)]
15
16//! VelesDB Mobile - Native bindings for iOS and Android
17//!
18//! This crate provides UniFFI bindings for VelesDB, enabling native integration
19//! with Swift (iOS) and Kotlin (Android) applications.
20//!
21//! # Architecture
22//!
23//! - **iOS**: Generates Swift bindings + XCFramework (arm64 device, arm64/x86_64 simulator)
24//! - **Android**: Generates Kotlin bindings + AAR (arm64-v8a, armeabi-v7a, x86_64)
25//!
26//! # Build Commands
27//!
28//! ```bash
29//! # iOS
30//! cargo build --release --target aarch64-apple-ios
31//! cargo build --release --target aarch64-apple-ios-sim
32//!
33//! # Android (requires NDK)
34//! cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 build --release
35//! ```
36
37uniffi::setup_scaffolding!();
38
39mod graph;
40mod types;
41
42pub use graph::{MobileGraphEdge, MobileGraphNode, MobileGraphStore, TraversalResult};
43pub use types::{
44    DistanceMetric, FusionStrategy, IndividualSearchRequest, MobileCollectionStats,
45    MobileIndexInfo, PqTrainConfig, SearchResult, StorageMode, VelesError, VelesPoint,
46    VelesSparseVector,
47};
48
49use std::sync::Arc;
50use velesdb_core::Database as CoreDatabase;
51use velesdb_core::FusionStrategy as CoreFusionStrategy;
52use velesdb_core::VectorCollection as CoreCollection;
53
54#[cfg(test)]
55use velesdb_core::DistanceMetric as CoreDistanceMetric;
56
57// NOTE: VelesError, DistanceMetric, StorageMode, FusionStrategy, SearchResult,
58// VelesPoint, IndividualSearchRequest moved to types.rs (EPIC-061/US-005 refactoring)
59
60// ============================================================================
61// Database
62// ============================================================================
63
64/// VelesDB database instance.
65///
66/// Thread-safe handle to a VelesDB database. Can be shared across threads.
67#[derive(uniffi::Object)]
68pub struct VelesDatabase {
69    inner: CoreDatabase,
70}
71
72#[uniffi::export]
73impl VelesDatabase {
74    /// Opens or creates a database at the specified path.
75    ///
76    /// # Arguments
77    ///
78    /// * `path` - Path to the database directory (will be created if needed)
79    ///
80    /// # Errors
81    ///
82    /// Returns an error if the path is invalid or cannot be accessed.
83    #[uniffi::constructor]
84    pub fn open(path: String) -> Result<Arc<Self>, VelesError> {
85        let db = CoreDatabase::open(&path)?;
86        Ok(Arc::new(Self { inner: db }))
87    }
88
89    /// Creates a new collection with the specified parameters.
90    ///
91    /// # Arguments
92    ///
93    /// * `name` - Unique name for the collection
94    /// * `dimension` - Vector dimension (e.g., 384, 768, 1536)
95    /// * `metric` - Distance metric for similarity calculations
96    pub fn create_collection(
97        &self,
98        name: String,
99        dimension: u32,
100        metric: DistanceMetric,
101    ) -> Result<(), VelesError> {
102        self.inner.create_collection(
103            &name,
104            usize::try_from(dimension).unwrap_or(usize::MAX),
105            metric.into(),
106        )?;
107        Ok(())
108    }
109
110    /// Creates a new collection with custom storage mode for IoT/Edge devices.
111    ///
112    /// # Arguments
113    ///
114    /// * `name` - Unique name for the collection
115    /// * `dimension` - Vector dimension
116    /// * `metric` - Distance metric
117    /// * `storage_mode` - Storage optimization (Full, Sq8, Binary)
118    ///
119    /// # Storage Modes
120    ///
121    /// - **Full**: Best recall, 4 bytes/dimension
122    /// - **Sq8**: 4x compression, ~1% recall loss (recommended for mobile)
123    /// - **Binary**: 32x compression, ~5-10% recall loss (for extreme constraints)
124    pub fn create_collection_with_storage(
125        &self,
126        name: String,
127        dimension: u32,
128        metric: DistanceMetric,
129        storage_mode: StorageMode,
130    ) -> Result<(), VelesError> {
131        self.inner.create_vector_collection_with_options(
132            &name,
133            usize::try_from(dimension).unwrap_or(usize::MAX),
134            metric.into(),
135            storage_mode.into(),
136        )?;
137        Ok(())
138    }
139
140    /// Creates a metadata-only collection (no vectors).
141    ///
142    /// Useful for storing reference data, lookups, or auxiliary information
143    /// that doesn't require vector similarity search.
144    ///
145    /// # Arguments
146    ///
147    /// * `name` - Unique name for the collection
148    pub fn create_metadata_collection(&self, name: String) -> Result<(), VelesError> {
149        self.inner.create_metadata_collection(&name)?;
150        Ok(())
151    }
152
153    /// Gets a collection by name.
154    ///
155    /// Returns `None` if the collection does not exist.
156    /// Checks vector, metadata, and graph registries in order.
157    pub fn get_collection(&self, name: String) -> Result<Option<Arc<VelesCollection>>, VelesError> {
158        // Try typed vector collection first (most common case).
159        if let Some(coll) = self.inner.get_vector_collection(&name) {
160            return Ok(Some(Arc::new(VelesCollection { inner: coll })));
161        }
162        // For metadata-only and graph collections, the legacy registry holds the
163        // same shared inner Collection. VectorCollection wraps Collection 1:1
164        // (same Arc<> fields) so opening it from the same on-disk path is
165        // equivalent — but cheaper: just ask get_vector_collection which falls
166        // back to disk and checks config type. The disk fallback in
167        // get_vector_collection already guards against non-vector types, so we
168        // need to open directly from disk for metadata/graph.
169        // Simplest correct path: VectorCollection::open the path, which loads all
170        // Collection fields regardless of collection type.
171        let path = self.inner.data_dir().join(&name);
172        if path.join("config.json").exists() {
173            match velesdb_core::VectorCollection::open(path) {
174                Ok(coll) => return Ok(Some(Arc::new(VelesCollection { inner: coll }))),
175                Err(e) => {
176                    tracing::warn!(
177                        collection = %name,
178                        error = %e,
179                        "VectorCollection::open failed for existing config; collection skipped"
180                    );
181                }
182            }
183        }
184        Ok(None)
185    }
186
187    /// Lists all collection names.
188    pub fn list_collections(&self) -> Vec<String> {
189        self.inner.list_collections()
190    }
191
192    /// Deletes a collection by name.
193    pub fn delete_collection(&self, name: String) -> Result<(), VelesError> {
194        self.inner.delete_collection(&name)?;
195        Ok(())
196    }
197
198    /// Trains a Product Quantizer on a collection.
199    ///
200    /// PQ training is a database-level operation that requires access to the
201    /// VelesQL TRAIN executor.
202    ///
203    /// # Arguments
204    ///
205    /// * `collection_name` - Name of the collection to train PQ on
206    /// * `config` - PQ training configuration
207    ///
208    /// # Returns
209    ///
210    /// Status message from the training process.
211    pub fn train_pq(
212        &self,
213        collection_name: String,
214        config: PqTrainConfig,
215    ) -> Result<String, VelesError> {
216        use std::collections::HashMap;
217        use velesdb_core::velesql::{Query, TrainStatement, WithValue};
218
219        let mut params = HashMap::new();
220        params.insert("m".to_string(), WithValue::Integer(i64::from(config.m)));
221        params.insert("k".to_string(), WithValue::Integer(i64::from(config.k)));
222        if config.opq {
223            params.insert("type".to_string(), WithValue::Identifier("opq".to_string()));
224        }
225
226        let query = Query::new_train(TrainStatement {
227            collection: collection_name,
228            params,
229        });
230
231        let empty_params = HashMap::new();
232        self.inner
233            .execute_query(&query, &empty_params)
234            .map_err(|e| VelesError::Database {
235                message: format!("PQ training failed: {e}"),
236            })?;
237
238        Ok("PQ training complete".to_string())
239    }
240}
241
242// ============================================================================
243// Collection
244// ============================================================================
245
246/// A collection of vectors with associated metadata.
247#[derive(uniffi::Object)]
248pub struct VelesCollection {
249    inner: CoreCollection,
250}
251
252#[uniffi::export]
253impl VelesCollection {
254    /// Searches for the k nearest neighbors to the query vector.
255    ///
256    /// # Arguments
257    ///
258    /// * `vector` - Query vector
259    /// * `limit` - Maximum number of results to return
260    ///
261    /// # Returns
262    ///
263    /// Vector of search results sorted by similarity.
264    pub fn search(&self, vector: Vec<f32>, limit: u32) -> Result<Vec<SearchResult>, VelesError> {
265        let results = self
266            .inner
267            .search_ids(&vector, usize::try_from(limit).unwrap_or(usize::MAX))?;
268
269        Ok(results
270            .into_iter()
271            .map(|(id, score)| SearchResult { id, score })
272            .collect())
273    }
274
275    /// Inserts or updates a single point.
276    ///
277    /// # Arguments
278    ///
279    /// * `point` - The point to upsert
280    pub fn upsert(&self, point: VelesPoint) -> Result<(), VelesError> {
281        let payload = point
282            .payload
283            .map(|s| serde_json::from_str(&s))
284            .transpose()
285            .map_err(|e| VelesError::Database {
286                message: format!("Invalid JSON payload: {e}"),
287            })?;
288
289        let core_point = velesdb_core::Point::new(point.id, point.vector, payload);
290        self.inner.upsert(vec![core_point])?;
291        Ok(())
292    }
293
294    /// Inserts or updates multiple points in batch.
295    ///
296    /// # Arguments
297    ///
298    /// * `points` - Points to upsert
299    pub fn upsert_batch(&self, points: Vec<VelesPoint>) -> Result<(), VelesError> {
300        let core_points: Result<Vec<velesdb_core::Point>, VelesError> = points
301            .into_iter()
302            .map(|p| {
303                let payload = p
304                    .payload
305                    .map(|s| serde_json::from_str(&s))
306                    .transpose()
307                    .map_err(|e| VelesError::Database {
308                        message: format!("Invalid JSON payload: {e}"),
309                    })?;
310                Ok(velesdb_core::Point::new(p.id, p.vector, payload))
311            })
312            .collect();
313
314        self.inner.upsert(core_points?)?;
315        Ok(())
316    }
317
318    /// Deletes a point by ID.
319    pub fn delete(&self, id: u64) -> Result<(), VelesError> {
320        self.inner.delete(&[id])?;
321        Ok(())
322    }
323
324    /// Returns the number of points in the collection.
325    #[allow(clippy::cast_possible_truncation)]
326    pub fn count(&self) -> u64 {
327        self.inner.config().point_count as u64
328    }
329
330    /// Returns the vector dimension.
331    #[allow(clippy::cast_possible_truncation)]
332    pub fn dimension(&self) -> u32 {
333        self.inner.config().dimension as u32
334    }
335
336    /// Gets points by their IDs.
337    ///
338    /// # Arguments
339    ///
340    /// * `ids` - List of point IDs to retrieve
341    ///
342    /// # Returns
343    ///
344    /// Vector of points found. Missing IDs are silently skipped.
345    pub fn get(&self, ids: Vec<u64>) -> Vec<VelesPoint> {
346        self.inner
347            .get(&ids)
348            .into_iter()
349            .flatten()
350            .map(|p| VelesPoint {
351                id: p.id,
352                vector: p.vector,
353                payload: p.payload.map(|v| v.to_string()),
354            })
355            .collect()
356    }
357
358    /// Gets a single point by ID.
359    ///
360    /// # Arguments
361    ///
362    /// * `id` - Point ID to retrieve
363    ///
364    /// # Returns
365    ///
366    /// The point if found, None otherwise.
367    pub fn get_by_id(&self, id: u64) -> Option<VelesPoint> {
368        self.inner
369            .get(&[id])
370            .into_iter()
371            .flatten()
372            .next()
373            .map(|p| VelesPoint {
374                id: p.id,
375                vector: p.vector,
376                payload: p.payload.map(|v| v.to_string()),
377            })
378    }
379
380    /// Checks if this is a metadata-only collection.
381    pub fn is_metadata_only(&self) -> bool {
382        self.inner.config().metadata_only
383    }
384
385    /// Performs full-text search using BM25.
386    ///
387    /// # Arguments
388    ///
389    /// * `query` - Text query to search for
390    /// * `limit` - Maximum number of results to return
391    ///
392    /// # Returns
393    ///
394    /// Vector of search results sorted by BM25 score.
395    pub fn text_search(&self, query: String, limit: u32) -> Vec<SearchResult> {
396        let results = self
397            .inner
398            .text_search(&query, usize::try_from(limit).unwrap_or(usize::MAX));
399
400        results
401            .into_iter()
402            .map(|r| SearchResult {
403                id: r.point.id,
404                score: r.score,
405            })
406            .collect()
407    }
408
409    /// Performs hybrid search combining vector similarity and BM25 text search.
410    ///
411    /// # Arguments
412    ///
413    /// * `vector` - Query vector for similarity search
414    /// * `text_query` - Text query for BM25 search
415    /// * `limit` - Maximum number of results
416    /// * `vector_weight` - Weight for vector similarity (0.0-1.0)
417    ///
418    /// # Returns
419    ///
420    /// Vector of search results sorted by fused score.
421    pub fn hybrid_search(
422        &self,
423        vector: Vec<f32>,
424        text_query: String,
425        limit: u32,
426        vector_weight: f32,
427    ) -> Result<Vec<SearchResult>, VelesError> {
428        let results = self.inner.hybrid_search(
429            &vector,
430            &text_query,
431            usize::try_from(limit).unwrap_or(usize::MAX),
432            Some(vector_weight),
433        )?;
434
435        Ok(results
436            .into_iter()
437            .map(|r| SearchResult {
438                id: r.point.id,
439                score: r.score,
440            })
441            .collect())
442    }
443
444    /// Searches with metadata filtering.
445    ///
446    /// # Arguments
447    ///
448    /// * `vector` - Query vector
449    /// * `limit` - Maximum number of results
450    /// * `filter_json` - JSON filter string (e.g., `{"condition": {"type": "eq", "field": "category", "value": "tech"}}`)
451    ///
452    /// # Returns
453    ///
454    /// Vector of search results matching the filter.
455    pub fn search_with_filter(
456        &self,
457        vector: Vec<f32>,
458        limit: u32,
459        filter_json: String,
460    ) -> Result<Vec<SearchResult>, VelesError> {
461        // Parse filter JSON
462        let filter: velesdb_core::Filter =
463            serde_json::from_str(&filter_json).map_err(|e| VelesError::Database {
464                message: format!("Invalid filter JSON: {e}"),
465            })?;
466
467        let results = self.inner.search_with_filter(
468            &vector,
469            usize::try_from(limit).unwrap_or(usize::MAX),
470            &filter,
471        )?;
472
473        Ok(results
474            .into_iter()
475            .map(|r| SearchResult {
476                id: r.point.id,
477                score: r.score,
478            })
479            .collect())
480    }
481
482    /// Performs batch search for multiple query vectors in parallel.
483    ///
484    /// # Arguments
485    ///
486    /// * `searches` - List of search requests
487    ///
488    /// # Returns
489    ///
490    /// List of result lists (one per query vector).
491    pub fn batch_search(
492        &self,
493        searches: Vec<IndividualSearchRequest>,
494    ) -> Result<Vec<Vec<SearchResult>>, VelesError> {
495        let query_refs: Vec<&[f32]> = searches.iter().map(|s| s.vector.as_slice()).collect();
496
497        let filters: Result<Vec<Option<velesdb_core::Filter>>, VelesError> = searches
498            .iter()
499            .map(|s| {
500                s.filter
501                    .as_ref()
502                    .map(|f_json| {
503                        serde_json::from_str(f_json).map_err(|e| VelesError::Database {
504                            message: format!("Invalid filter JSON in batch: {e}"),
505                        })
506                    })
507                    .transpose()
508            })
509            .collect();
510
511        let filters = filters?;
512        let max_top_k = searches.iter().map(|s| s.top_k).max().unwrap_or(10);
513
514        let all_results = self.inner.search_batch_with_filters(
515            &query_refs,
516            usize::try_from(max_top_k).unwrap_or(usize::MAX),
517            &filters,
518        )?;
519
520        Ok(all_results
521            .into_iter()
522            .zip(searches)
523            .map(
524                |(results, s): (Vec<velesdb_core::SearchResult>, IndividualSearchRequest)| {
525                    results
526                        .into_iter()
527                        .take(usize::try_from(s.top_k).unwrap_or(usize::MAX))
528                        .map(|r| SearchResult {
529                            id: r.point.id,
530                            score: r.score,
531                        })
532                        .collect()
533                },
534            )
535            .collect())
536    }
537
538    /// Performs text search with metadata filtering.
539    ///
540    /// # Arguments
541    ///
542    /// * `query` - Text query
543    /// * `limit` - Maximum number of results
544    /// * `filter_json` - JSON filter string
545    pub fn text_search_with_filter(
546        &self,
547        query: String,
548        limit: u32,
549        filter_json: String,
550    ) -> Result<Vec<SearchResult>, VelesError> {
551        let filter: velesdb_core::Filter =
552            serde_json::from_str(&filter_json).map_err(|e| VelesError::Database {
553                message: format!("Invalid filter JSON: {e}"),
554            })?;
555
556        let results = self.inner.text_search_with_filter(
557            &query,
558            usize::try_from(limit).unwrap_or(usize::MAX),
559            &filter,
560        );
561
562        Ok(results
563            .into_iter()
564            .map(|r| SearchResult {
565                id: r.point.id,
566                score: r.score,
567            })
568            .collect())
569    }
570
571    /// Performs hybrid search with metadata filtering.
572    ///
573    /// # Arguments
574    ///
575    /// * `vector` - Query vector
576    /// * `text_query` - Text query
577    /// * `limit` - Maximum number of results
578    /// * `vector_weight` - Weight for vector similarity (0.0-1.0)
579    /// * `filter_json` - JSON filter string
580    pub fn hybrid_search_with_filter(
581        &self,
582        vector: Vec<f32>,
583        text_query: String,
584        limit: u32,
585        vector_weight: f32,
586        filter_json: String,
587    ) -> Result<Vec<SearchResult>, VelesError> {
588        let filter: velesdb_core::Filter =
589            serde_json::from_str(&filter_json).map_err(|e| VelesError::Database {
590                message: format!("Invalid filter JSON: {e}"),
591            })?;
592
593        let results = self.inner.hybrid_search_with_filter(
594            &vector,
595            &text_query,
596            usize::try_from(limit).unwrap_or(usize::MAX),
597            Some(vector_weight),
598            &filter,
599        )?;
600
601        Ok(results
602            .into_iter()
603            .map(|r| SearchResult {
604                id: r.point.id,
605                score: r.score,
606            })
607            .collect())
608    }
609
610    /// Executes a VelesQL query.
611    ///
612    /// # Arguments
613    ///
614    /// * `query_str` - VelesQL query string
615    /// * `params_json` - Optional JSON object with query parameters
616    ///
617    /// # Returns
618    ///
619    /// Vector of search results.
620    ///
621    /// # Example
622    ///
623    /// ```swift
624    /// let results = try collection.query(
625    ///     "SELECT * FROM vectors WHERE category = 'tech' LIMIT 10",
626    ///     nil
627    /// )
628    /// ```
629    pub fn query(
630        &self,
631        query_str: String,
632        params_json: Option<String>,
633    ) -> Result<Vec<SearchResult>, VelesError> {
634        // Parse the VelesQL query
635        let parsed =
636            velesdb_core::velesql::Parser::parse(&query_str).map_err(|e| VelesError::Database {
637                message: format!("VelesQL parse error: {}", e.message),
638            })?;
639
640        // Parse params from JSON if provided
641        let params: std::collections::HashMap<String, serde_json::Value> = params_json
642            .map(|json| serde_json::from_str(&json))
643            .transpose()
644            .map_err(|e| VelesError::Database {
645                message: format!("Invalid params JSON: {e}"),
646            })?
647            .unwrap_or_default();
648
649        // Execute the query
650        let results =
651            self.inner
652                .execute_query(&parsed, &params)
653                .map_err(|e| VelesError::Database {
654                    message: format!("Query execution failed: {e}"),
655                })?;
656
657        Ok(results
658            .into_iter()
659            .map(|r| SearchResult {
660                id: r.point.id,
661                score: r.score,
662            })
663            .collect())
664    }
665
666    /// Performs multi-query search with result fusion.
667    ///
668    /// Executes parallel searches for multiple query vectors and fuses
669    /// results using the specified strategy. Ideal for Multiple Query
670    /// Generation (MQG) pipelines on mobile.
671    ///
672    /// # Arguments
673    ///
674    /// * `vectors` - List of query vectors
675    /// * `limit` - Maximum number of results after fusion
676    /// * `strategy` - Fusion strategy to use
677    ///
678    /// # Returns
679    ///
680    /// Vector of fused search results sorted by relevance.
681    ///
682    /// # Example
683    ///
684    /// ```swift
685    /// let results = try collection.multiQuerySearch(
686    ///     vectors: [query1, query2, query3],
687    ///     limit: 10,
688    ///     strategy: .rrf(k: 60)
689    /// )
690    /// ```
691    pub fn multi_query_search(
692        &self,
693        vectors: Vec<Vec<f32>>,
694        limit: u32,
695        strategy: FusionStrategy,
696    ) -> Result<Vec<SearchResult>, VelesError> {
697        if vectors.is_empty() {
698            return Err(VelesError::Database {
699                message: "multi_query_search requires at least one vector".to_string(),
700            });
701        }
702
703        let query_refs: Vec<&[f32]> = vectors.iter().map(|v| v.as_slice()).collect();
704        let core_strategy: CoreFusionStrategy = strategy.into();
705
706        let results = self
707            .inner
708            .multi_query_search(
709                &query_refs,
710                usize::try_from(limit).unwrap_or(usize::MAX),
711                core_strategy,
712                None,
713            )
714            .map_err(|e| VelesError::Database {
715                message: format!("Multi-query search failed: {e}"),
716            })?;
717
718        Ok(results
719            .into_iter()
720            .map(|r| SearchResult {
721                id: r.point.id,
722                score: r.score,
723            })
724            .collect())
725    }
726
727    /// Performs multi-query search with metadata filtering.
728    ///
729    /// # Arguments
730    ///
731    /// * `vectors` - List of query vectors
732    /// * `limit` - Maximum number of results after fusion
733    /// * `strategy` - Fusion strategy to use
734    /// * `filter_json` - JSON filter string
735    pub fn multi_query_search_with_filter(
736        &self,
737        vectors: Vec<Vec<f32>>,
738        limit: u32,
739        strategy: FusionStrategy,
740        filter_json: String,
741    ) -> Result<Vec<SearchResult>, VelesError> {
742        if vectors.is_empty() {
743            return Err(VelesError::Database {
744                message: "multi_query_search requires at least one vector".to_string(),
745            });
746        }
747
748        let filter: velesdb_core::Filter =
749            serde_json::from_str(&filter_json).map_err(|e| VelesError::Database {
750                message: format!("Invalid filter JSON: {e}"),
751            })?;
752
753        let query_refs: Vec<&[f32]> = vectors.iter().map(|v| v.as_slice()).collect();
754        let core_strategy: CoreFusionStrategy = strategy.into();
755
756        let results = self
757            .inner
758            .multi_query_search(
759                &query_refs,
760                usize::try_from(limit).unwrap_or(usize::MAX),
761                core_strategy,
762                Some(&filter),
763            )
764            .map_err(|e| VelesError::Database {
765                message: format!("Multi-query search failed: {e}"),
766            })?;
767
768        Ok(results
769            .into_iter()
770            .map(|r| SearchResult {
771                id: r.point.id,
772                score: r.score,
773            })
774            .collect())
775    }
776
777    /// Flushes collection data to durable storage.
778    pub fn flush(&self) -> Result<(), VelesError> {
779        self.inner.flush()?;
780        Ok(())
781    }
782
783    /// Returns all point IDs currently present in the collection.
784    pub fn all_ids(&self) -> Vec<u64> {
785        self.inner.all_ids()
786    }
787
788    /// Creates a secondary metadata index for a payload field.
789    pub fn create_index(&self, field_name: String) -> Result<(), VelesError> {
790        self.inner.create_index(&field_name)?;
791        Ok(())
792    }
793
794    /// Checks whether a secondary metadata index exists for a field.
795    pub fn has_secondary_index(&self, field_name: String) -> bool {
796        self.inner.has_secondary_index(&field_name)
797    }
798
799    /// Creates a graph/property index for equality lookups.
800    pub fn create_property_index(&self, label: String, property: String) -> Result<(), VelesError> {
801        self.inner.create_property_index(&label, &property)?;
802        Ok(())
803    }
804
805    /// Creates a graph/range index for range queries.
806    pub fn create_range_index(&self, label: String, property: String) -> Result<(), VelesError> {
807        self.inner.create_range_index(&label, &property)?;
808        Ok(())
809    }
810
811    /// Checks if a property index exists.
812    pub fn has_property_index(&self, label: String, property: String) -> bool {
813        self.inner.has_property_index(&label, &property)
814    }
815
816    /// Checks if a range index exists.
817    pub fn has_range_index(&self, label: String, property: String) -> bool {
818        self.inner.has_range_index(&label, &property)
819    }
820
821    /// Lists all index definitions on this collection.
822    pub fn list_indexes(&self) -> Vec<MobileIndexInfo> {
823        self.inner
824            .list_indexes()
825            .into_iter()
826            .map(MobileIndexInfo::from)
827            .collect()
828    }
829
830    /// Drops an index and returns true when something was removed.
831    pub fn drop_index(&self, label: String, property: String) -> Result<bool, VelesError> {
832        Ok(self.inner.drop_index(&label, &property)?)
833    }
834
835    /// Returns total memory usage used by indexes.
836    pub fn indexes_memory_usage(&self) -> u64 {
837        u64::try_from(self.inner.indexes_memory_usage()).unwrap_or(u64::MAX)
838    }
839
840    /// Runs ANALYZE and returns fresh statistics for this collection.
841    pub fn analyze(&self) -> Result<MobileCollectionStats, VelesError> {
842        Ok(self.inner.analyze()?.into())
843    }
844
845    /// Returns the latest known collection statistics snapshot.
846    pub fn get_stats(&self) -> MobileCollectionStats {
847        self.inner.get_stats().into()
848    }
849
850    // ========================================================================
851    // Sparse Vector Operations
852    // ========================================================================
853
854    /// Performs sparse-only search using an inverted index.
855    ///
856    /// # Arguments
857    ///
858    /// * `sparse_vector` - Query sparse vector (parallel arrays of indices/values)
859    /// * `limit` - Maximum number of results
860    /// * `index_name` - Name of the sparse index (empty string for default)
861    ///
862    /// # Returns
863    ///
864    /// Vector of search results sorted by sparse similarity.
865    pub fn sparse_search(
866        &self,
867        sparse_vector: VelesSparseVector,
868        limit: u32,
869        index_name: Option<String>,
870    ) -> Result<Vec<SearchResult>, VelesError> {
871        let core_sv = Self::to_core_sparse_vector(&sparse_vector);
872        let idx_name = index_name.unwrap_or_default();
873
874        let results = self
875            .inner
876            .sparse_search(
877                &core_sv,
878                usize::try_from(limit).unwrap_or(usize::MAX),
879                &idx_name,
880            )
881            .map_err(|e| VelesError::Database {
882                message: format!("Sparse search failed: {e}"),
883            })?;
884
885        Ok(results
886            .into_iter()
887            .map(|r| SearchResult {
888                id: r.point.id,
889                score: r.score,
890            })
891            .collect())
892    }
893
894    /// Performs hybrid dense+sparse search with RRF fusion.
895    ///
896    /// Combines vector similarity search with sparse (keyword) search
897    /// using Reciprocal Rank Fusion.
898    ///
899    /// # Arguments
900    ///
901    /// * `vector` - Dense query vector
902    /// * `sparse_vector` - Sparse query vector (parallel arrays)
903    /// * `limit` - Maximum number of results
904    /// * `index_name` - Name of the sparse index (empty string or `None` for default)
905    ///
906    /// # Returns
907    ///
908    /// Vector of fused search results.
909    pub fn hybrid_sparse_search(
910        &self,
911        vector: Vec<f32>,
912        sparse_vector: VelesSparseVector,
913        limit: u32,
914        index_name: Option<String>,
915    ) -> Result<Vec<SearchResult>, VelesError> {
916        let core_sv = Self::to_core_sparse_vector(&sparse_vector);
917        let strategy = velesdb_core::fusion::FusionStrategy::RRF { k: 60 };
918        let idx_name = index_name.unwrap_or_default();
919
920        let results = self
921            .inner
922            .hybrid_sparse_search(
923                &vector,
924                &core_sv,
925                usize::try_from(limit).unwrap_or(usize::MAX),
926                &idx_name,
927                &strategy,
928            )
929            .map_err(|e| VelesError::Database {
930                message: format!("Hybrid sparse search failed: {e}"),
931            })?;
932
933        Ok(results
934            .into_iter()
935            .map(|r| SearchResult {
936                id: r.point.id,
937                score: r.score,
938            })
939            .collect())
940    }
941
942    /// Inserts or updates a point with an associated sparse vector.
943    ///
944    /// # Arguments
945    ///
946    /// * `point` - The point to upsert (dense vector + payload)
947    /// * `sparse_vector` - Sparse vector to associate with this point
948    pub fn upsert_with_sparse(
949        &self,
950        point: VelesPoint,
951        sparse_vector: VelesSparseVector,
952    ) -> Result<(), VelesError> {
953        let payload = point
954            .payload
955            .map(|s| serde_json::from_str(&s))
956            .transpose()
957            .map_err(|e| VelesError::Database {
958                message: format!("Invalid JSON payload: {e}"),
959            })?;
960
961        let core_sv = Self::to_core_sparse_vector(&sparse_vector);
962        let mut sparse_map = std::collections::BTreeMap::new();
963        sparse_map.insert(String::new(), core_sv);
964
965        let core_point =
966            velesdb_core::Point::with_sparse(point.id, point.vector, payload, Some(sparse_map));
967        self.inner.upsert(vec![core_point])?;
968        Ok(())
969    }
970}
971
972impl VelesCollection {
973    /// Converts a `VelesSparseVector` (UniFFI-safe parallel arrays) to the
974    /// core `SparseVector` type.
975    fn to_core_sparse_vector(sv: &VelesSparseVector) -> velesdb_core::sparse_index::SparseVector {
976        let pairs: Vec<(u32, f32)> = sv
977            .indices
978            .iter()
979            .copied()
980            .zip(sv.values.iter().copied())
981            .collect();
982        velesdb_core::sparse_index::SparseVector::new(pairs)
983    }
984}
985
986// ============================================================================
987// Tests
988// ============================================================================
989
990#[cfg(test)]
991#[path = "lib_tests.rs"]
992mod tests;