ruvector-gnn-node 2.0.6

Node.js bindings for Ruvector GNN via NAPI-RS
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
//! Node.js bindings for Ruvector GNN via NAPI-RS
//!
//! This module provides JavaScript bindings for the Ruvector GNN library,
//! enabling graph neural network operations, tensor compression, and
//! differentiable search in Node.js applications.

#![deny(clippy::all)]

use napi::bindgen_prelude::*;
use napi_derive::napi;
use ruvector_gnn::{
    compress::{
        CompressedTensor as RustCompressedTensor, CompressionLevel as RustCompressionLevel,
        TensorCompress as RustTensorCompress,
    },
    layer::RuvectorLayer as RustRuvectorLayer,
    search::{
        differentiable_search as rust_differentiable_search,
        hierarchical_forward as rust_hierarchical_forward,
    },
};

// ==================== RuvectorLayer Bindings ====================

/// Graph Neural Network layer for HNSW topology
#[napi]
pub struct RuvectorLayer {
    inner: RustRuvectorLayer,
}

#[napi]
impl RuvectorLayer {
    /// Create a new Ruvector GNN layer
    ///
    /// # Arguments
    /// * `input_dim` - Dimension of input node embeddings
    /// * `hidden_dim` - Dimension of hidden representations
    /// * `heads` - Number of attention heads
    /// * `dropout` - Dropout rate (0.0 to 1.0)
    ///
    /// # Example
    /// ```javascript
    /// const layer = new RuvectorLayer(128, 256, 4, 0.1);
    /// ```
    #[napi(constructor)]
    pub fn new(input_dim: u32, hidden_dim: u32, heads: u32, dropout: f64) -> Result<Self> {
        let inner = RustRuvectorLayer::new(
            input_dim as usize,
            hidden_dim as usize,
            heads as usize,
            dropout as f32,
        )
        .map_err(|e| Error::new(Status::InvalidArg, e.to_string()))?;

        Ok(Self { inner })
    }

    /// Forward pass through the GNN layer
    ///
    /// # Arguments
    /// * `node_embedding` - Current node's embedding (Float32Array)
    /// * `neighbor_embeddings` - Embeddings of neighbor nodes (Array of Float32Array)
    /// * `edge_weights` - Weights of edges to neighbors (Float32Array)
    ///
    /// # Returns
    /// Updated node embedding as Float32Array
    ///
    /// # Example
    /// ```javascript
    /// const node = new Float32Array([1.0, 2.0, 3.0, 4.0]);
    /// const neighbors = [new Float32Array([0.5, 1.0, 1.5, 2.0]), new Float32Array([2.0, 3.0, 4.0, 5.0])];
    /// const weights = new Float32Array([0.3, 0.7]);
    /// const output = layer.forward(node, neighbors, weights);
    /// ```
    #[napi]
    pub fn forward(
        &self,
        node_embedding: Float32Array,
        neighbor_embeddings: Vec<Float32Array>,
        edge_weights: Float32Array,
    ) -> Result<Float32Array> {
        let node_slice = node_embedding.as_ref();
        let neighbors_vec: Vec<Vec<f32>> = neighbor_embeddings
            .into_iter()
            .map(|arr| arr.to_vec())
            .collect();
        let weights_slice = edge_weights.as_ref();

        let result = self
            .inner
            .forward(node_slice, &neighbors_vec, weights_slice);

        Ok(Float32Array::new(result))
    }

    /// Serialize the layer to JSON
    #[napi]
    pub fn to_json(&self) -> Result<String> {
        serde_json::to_string(&self.inner).map_err(|e| {
            Error::new(
                Status::GenericFailure,
                format!("Serialization error: {}", e),
            )
        })
    }

    /// Deserialize the layer from JSON
    #[napi(factory)]
    pub fn from_json(json: String) -> Result<Self> {
        let inner: RustRuvectorLayer = serde_json::from_str(&json).map_err(|e| {
            Error::new(
                Status::GenericFailure,
                format!("Deserialization error: {}", e),
            )
        })?;
        Ok(Self { inner })
    }
}

// ==================== TensorCompress Bindings ====================

/// Compression level for tensor compression
#[napi(object)]
pub struct CompressionLevelConfig {
    /// Type of compression: "none", "half", "pq8", "pq4", "binary"
    pub level_type: String,
    /// Scale factor (for "half" compression)
    pub scale: Option<f64>,
    /// Number of subvectors (for PQ compression)
    pub subvectors: Option<u32>,
    /// Number of centroids (for PQ8)
    pub centroids: Option<u32>,
    /// Outlier threshold (for PQ4)
    pub outlier_threshold: Option<f64>,
    /// Binary threshold (for binary compression)
    pub threshold: Option<f64>,
}

impl CompressionLevelConfig {
    fn to_rust(&self) -> Result<RustCompressionLevel> {
        match self.level_type.as_str() {
            "none" => Ok(RustCompressionLevel::None),
            "half" => Ok(RustCompressionLevel::Half {
                scale: self.scale.unwrap_or(1.0) as f32,
            }),
            "pq8" => Ok(RustCompressionLevel::PQ8 {
                subvectors: self.subvectors.unwrap_or(8) as u8,
                centroids: self.centroids.unwrap_or(16) as u8,
            }),
            "pq4" => Ok(RustCompressionLevel::PQ4 {
                subvectors: self.subvectors.unwrap_or(8) as u8,
                outlier_threshold: self.outlier_threshold.unwrap_or(3.0) as f32,
            }),
            "binary" => Ok(RustCompressionLevel::Binary {
                threshold: self.threshold.unwrap_or(0.0) as f32,
            }),
            _ => Err(Error::new(
                Status::InvalidArg,
                format!("Invalid compression level: {}", self.level_type),
            )),
        }
    }
}

/// Tensor compressor with adaptive level selection
#[napi]
pub struct TensorCompress {
    inner: RustTensorCompress,
}

#[napi]
impl TensorCompress {
    /// Create a new tensor compressor
    ///
    /// # Example
    /// ```javascript
    /// const compressor = new TensorCompress();
    /// ```
    #[napi(constructor)]
    pub fn new() -> Self {
        Self {
            inner: RustTensorCompress::new(),
        }
    }

    /// Compress an embedding based on access frequency
    ///
    /// # Arguments
    /// * `embedding` - The input embedding vector (Float32Array)
    /// * `access_freq` - Access frequency in range [0.0, 1.0]
    ///
    /// # Returns
    /// Compressed tensor as JSON string
    ///
    /// # Example
    /// ```javascript
    /// const embedding = new Float32Array([1.0, 2.0, 3.0, 4.0]);
    /// const compressed = compressor.compress(embedding, 0.5);
    /// ```
    #[napi]
    pub fn compress(&self, embedding: Float32Array, access_freq: f64) -> Result<String> {
        let embedding_slice = embedding.as_ref();

        let compressed = self
            .inner
            .compress(embedding_slice, access_freq as f32)
            .map_err(|e| Error::new(Status::GenericFailure, format!("Compression error: {}", e)))?;

        serde_json::to_string(&compressed).map_err(|e| {
            Error::new(
                Status::GenericFailure,
                format!("Serialization error: {}", e),
            )
        })
    }

    /// Compress with explicit compression level
    ///
    /// # Arguments
    /// * `embedding` - The input embedding vector (Float32Array)
    /// * `level` - Compression level configuration
    ///
    /// # Returns
    /// Compressed tensor as JSON string
    ///
    /// # Example
    /// ```javascript
    /// const embedding = new Float32Array([1.0, 2.0, 3.0, 4.0]);
    /// const level = { level_type: "half", scale: 1.0 };
    /// const compressed = compressor.compressWithLevel(embedding, level);
    /// ```
    #[napi]
    pub fn compress_with_level(
        &self,
        embedding: Float32Array,
        level: CompressionLevelConfig,
    ) -> Result<String> {
        let embedding_slice = embedding.as_ref();
        let rust_level = level.to_rust()?;

        let compressed = self
            .inner
            .compress_with_level(embedding_slice, &rust_level)
            .map_err(|e| Error::new(Status::GenericFailure, format!("Compression error: {}", e)))?;

        serde_json::to_string(&compressed).map_err(|e| {
            Error::new(
                Status::GenericFailure,
                format!("Serialization error: {}", e),
            )
        })
    }

    /// Decompress a compressed tensor
    ///
    /// # Arguments
    /// * `compressed_json` - Compressed tensor as JSON string
    ///
    /// # Returns
    /// Decompressed embedding vector as Float32Array
    ///
    /// # Example
    /// ```javascript
    /// const decompressed = compressor.decompress(compressed);
    /// ```
    #[napi]
    pub fn decompress(&self, compressed_json: String) -> Result<Float32Array> {
        let compressed: RustCompressedTensor =
            serde_json::from_str(&compressed_json).map_err(|e| {
                Error::new(
                    Status::GenericFailure,
                    format!("Deserialization error: {}", e),
                )
            })?;

        let result = self.inner.decompress(&compressed).map_err(|e| {
            Error::new(
                Status::GenericFailure,
                format!("Decompression error: {}", e),
            )
        })?;

        Ok(Float32Array::new(result))
    }
}

// ==================== Search Functions ====================

/// Result from differentiable search
#[napi(object)]
pub struct SearchResult {
    /// Indices of top-k candidates
    pub indices: Vec<u32>,
    /// Soft weights for top-k candidates
    pub weights: Vec<f64>,
}

/// Differentiable search using soft attention mechanism
///
/// # Arguments
/// * `query` - The query vector (Float32Array)
/// * `candidate_embeddings` - List of candidate embedding vectors (Array of Float32Array)
/// * `k` - Number of top results to return
/// * `temperature` - Temperature for softmax (lower = sharper, higher = smoother)
///
/// # Returns
/// Search result with indices and soft weights
///
/// # Example
/// ```javascript
/// const query = new Float32Array([1.0, 0.0, 0.0]);
/// const candidates = [new Float32Array([1.0, 0.0, 0.0]), new Float32Array([0.9, 0.1, 0.0]), new Float32Array([0.0, 1.0, 0.0])];
/// const result = differentiableSearch(query, candidates, 2, 1.0);
/// console.log(result.indices); // [0, 1]
/// console.log(result.weights); // [0.x, 0.y]
/// ```
#[napi]
pub fn differentiable_search(
    query: Float32Array,
    candidate_embeddings: Vec<Float32Array>,
    k: u32,
    temperature: f64,
) -> Result<SearchResult> {
    let query_slice = query.as_ref();
    let candidates_vec: Vec<Vec<f32>> = candidate_embeddings
        .into_iter()
        .map(|arr| arr.to_vec())
        .collect();

    let (indices, weights) =
        rust_differentiable_search(query_slice, &candidates_vec, k as usize, temperature as f32);

    Ok(SearchResult {
        indices: indices.iter().map(|&i| i as u32).collect(),
        weights: weights.iter().map(|&w| w as f64).collect(),
    })
}

/// Hierarchical forward pass through GNN layers
///
/// # Arguments
/// * `query` - The query vector (Float32Array)
/// * `layer_embeddings` - Embeddings organized by layer (Array of Array of Float32Array)
/// * `gnn_layers_json` - JSON array of serialized GNN layers
///
/// # Returns
/// Final embedding after hierarchical processing as Float32Array
///
/// # Example
/// ```javascript
/// const query = new Float32Array([1.0, 0.0]);
/// const layerEmbeddings = [[new Float32Array([1.0, 0.0]), new Float32Array([0.0, 1.0])]];
/// const layer1 = new RuvectorLayer(2, 2, 1, 0.0);
/// const layers = [layer1.toJson()];
/// const result = hierarchicalForward(query, layerEmbeddings, layers);
/// ```
#[napi]
pub fn hierarchical_forward(
    query: Float32Array,
    layer_embeddings: Vec<Vec<Float32Array>>,
    gnn_layers_json: Vec<String>,
) -> Result<Float32Array> {
    let query_slice = query.as_ref();

    let embeddings_f32: Vec<Vec<Vec<f32>>> = layer_embeddings
        .into_iter()
        .map(|layer| layer.into_iter().map(|arr| arr.to_vec()).collect())
        .collect();

    let gnn_layers: Vec<RustRuvectorLayer> = gnn_layers_json
        .iter()
        .map(|json| {
            serde_json::from_str(json).map_err(|e| {
                Error::new(
                    Status::GenericFailure,
                    format!("Layer deserialization error: {}", e),
                )
            })
        })
        .collect::<Result<Vec<_>>>()?;

    let result = rust_hierarchical_forward(query_slice, &embeddings_f32, &gnn_layers);

    Ok(Float32Array::new(result))
}

// ==================== Helper Functions ====================

/// Get the compression level that would be selected for a given access frequency
///
/// # Arguments
/// * `access_freq` - Access frequency in range [0.0, 1.0]
///
/// # Returns
/// String describing the compression level: "none", "half", "pq8", "pq4", or "binary"
///
/// # Example
/// ```javascript
/// const level = getCompressionLevel(0.9); // "none" (hot data)
/// const level2 = getCompressionLevel(0.5); // "half" (warm data)
/// ```
#[napi]
pub fn get_compression_level(access_freq: f64) -> String {
    if access_freq > 0.8 {
        "none".to_string()
    } else if access_freq > 0.4 {
        "half".to_string()
    } else if access_freq > 0.1 {
        "pq8".to_string()
    } else if access_freq > 0.01 {
        "pq4".to_string()
    } else {
        "binary".to_string()
    }
}

/// Module initialization
#[napi]
pub fn init() -> String {
    "Ruvector GNN Node.js bindings initialized".to_string()
}