Skip to main content

csm_core_lib/
encoder.rs

1//! Text-to-Hypervector Encoding using Hyperdimensional Computing (HDC) principles.
2//!
3//! This module provides a deterministic text encoder that converts text strings into
4//! `HVec10240` hypervectors without requiring external ML dependencies or embeddings.
5//!
6//! # Algorithm
7//!
8//! 1. **Tokenize**: Split on whitespace, lowercase, optional unicode segmentation
9//! 2. **Token → base HVec**: FNV-1a hash → seeded PRNG → random HVec10240
10//! 3. **Position encoding**: `token_hv.permute(position * stride)`
11//! 4. **Bundle**: Majority-rule bundling of all position-encoded token vectors
12//! 5. **Optional**: Character n-gram overlay for typo robustness
13//!
14//! # Hash Stability
15//!
16//! Token hashing uses FNV-1a (Fowler–Noll–Vo 1a, 64-bit), implemented inline with
17//! no external dependencies. FNV-1a is guaranteed stable across Rust versions and
18//! platforms, unlike `std::collections::hash_map::DefaultHasher` (SipHash), which
19//! is explicitly documented as non-stable. This ensures encoded vectors are
20//! reproducible across Rust upgrades and different builds.
21//!
22//! # Example
23//!
24//! ```
25//! use csm_core_lib::encoder::{TextEncoder, TextEncoderConfig};
26//! use csm_core_lib::HVec10240;
27//!
28//! let encoder = TextEncoder::new();
29//! let hv1 = encoder.encode("hello world");
30//! let hv2 = encoder.encode("hello world");
31//! assert!(hv1.cosine_similarity(&hv2) > 0.99); // Deterministic
32//! ```
33
34use crate::hyperdim::HVec10240;
35use std::borrow::Cow;
36
37/// Returns a lowercased version of the input string, avoiding allocation if it is already lowercase.
38#[inline]
39fn to_lowercase_cow(s: &str) -> Cow<'_, str> {
40    if s.chars().any(|c| c.is_uppercase()) {
41        Cow::Owned(s.to_lowercase())
42    } else {
43        Cow::Borrowed(s)
44    }
45}
46
47/// FNV-1a 64-bit offset basis and prime (Fowler–Noll–Vo).
48const FNV1A_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
49const FNV1A_PRIME: u64 = 0x0000_0100_0000_01b3;
50
51/// Compute a stable FNV-1a 64-bit hash for a byte slice.
52///
53/// This is guaranteed stable across Rust versions and platforms, unlike
54/// `DefaultHasher` (SipHash), which is explicitly non-stable.
55#[inline]
56fn fnv1a_hash(bytes: &[u8]) -> u64 {
57    let mut hash = FNV1A_OFFSET_BASIS;
58    for &byte in bytes {
59        hash ^= byte as u64;
60        hash = hash.wrapping_mul(FNV1A_PRIME);
61    }
62    hash
63}
64
65/// Configuration for the text encoder.
66#[derive(Debug, Clone)]
67pub struct TextEncoderConfig {
68    /// Number of positions to shift for position encoding.
69    /// Default: 1 (each token position shifts by 1 permutation).
70    pub position_stride: usize,
71
72    /// Whether to include character n-grams for typo robustness.
73    /// Default: false.
74    pub ngram_size: Option<usize>,
75
76    /// Whether to lowercase text before encoding.
77    /// Default: true.
78    pub lowercase: bool,
79
80    /// Enable code-aware tokenization (split on `_`, `-`, `.`, `/`, `::`).
81    /// Default: false.
82    pub code_aware: bool,
83}
84
85impl Default for TextEncoderConfig {
86    fn default() -> Self {
87        Self {
88            position_stride: 1,
89            ngram_size: None,
90            lowercase: true,
91            code_aware: false,
92        }
93    }
94}
95
96/// Deterministic text-to-hypervector encoder using HDC principles.
97///
98/// Produces consistent `HVec10240` vectors from text input without external dependencies.
99/// The encoding is:
100/// - **Deterministic**: Same input always produces same output
101/// - **Similarity-preserving**: Similar texts produce similar vectors
102/// - **WASM-compatible**: No external dependencies
103#[derive(Debug, Clone, Default)]
104pub struct TextEncoder {
105    config: TextEncoderConfig,
106}
107
108impl TextEncoder {
109    /// Create a new encoder with default configuration.
110    pub fn new() -> Self {
111        Self {
112            config: TextEncoderConfig::default(),
113        }
114    }
115
116    /// Create an encoder with custom configuration.
117    pub const fn with_config(config: TextEncoderConfig) -> Self {
118        Self { config }
119    }
120
121    /// Create a code-aware encoder with character trigram overlay.
122    /// This is the recommended configuration for CLI memory-context integration.
123    pub fn new_code_aware() -> Self {
124        Self {
125            config: TextEncoderConfig {
126                ngram_size: Some(3), // Character trigram overlay
127                code_aware: true,
128                ..Default::default()
129            },
130        }
131    }
132
133    /// Get the encoder configuration.
134    pub const fn config(&self) -> &TextEncoderConfig {
135        &self.config
136    }
137
138    /// Tokenize text with code-aware splitting.
139    ///
140    /// Splits on: `_`, `-`, `.`, `/`, `::` in addition to whitespace.
141    /// This improves retrieval for identifiers like `my_function_name`, `MyClass.method`.
142    fn tokenize_code(text: &str) -> Vec<&str> {
143        let mut tokens = Vec::with_capacity(text.len() / 8);
144
145        // First split on whitespace
146        for word in text.split_whitespace() {
147            // Then split on code separators: `::`, `_`, `-`, `.`, `/`
148            // Process `::` first since it's multi-char
149            Self::push_split_on_separators(word, &mut tokens);
150        }
151
152        tokens
153    }
154
155    /// Split a single word on code separators and append to result vector.
156    fn push_split_on_separators<'a>(word: &'a str, result: &mut Vec<&'a str>) {
157        let mut start = 0;
158        let mut char_indices = word.char_indices().peekable();
159
160        while let Some((i, c)) = char_indices.next() {
161            let is_sep = match c {
162                ':' => {
163                    if let Some(&(_, next_c)) = char_indices.peek() {
164                        if next_c == ':' {
165                            char_indices.next(); // consume second ':'
166                            if i > start {
167                                result.push(&word[start..i]);
168                            }
169                            start = i + 2; // '::' is 2 bytes
170                            continue;
171                        }
172                    }
173                    false
174                }
175                '_' | '-' | '.' | '/' => true,
176                _ => false,
177            };
178
179            if is_sep {
180                if i > start {
181                    result.push(&word[start..i]);
182                }
183                start = i + 1; // these are all 1-byte ASCII
184            }
185        }
186
187        if start < word.len() {
188            result.push(&word[start..]);
189        }
190    }
191
192    /// Encode text into a hypervector.
193    ///
194    /// The encoding process:
195    /// 1. Tokenize (whitespace split, optional lowercase, optional code-aware)
196    /// 2. Generate deterministic base vector for each token
197    /// 3. Apply position encoding via permutation
198    /// 4. Bundle all position-encoded vectors
199    /// 5. Optionally add n-gram overlay
200    pub fn encode(&self, text: &str) -> HVec10240 {
201        let processed = if self.config.lowercase {
202            to_lowercase_cow(text)
203        } else {
204            Cow::Borrowed(text)
205        };
206
207        let tokens = if self.config.code_aware {
208            Self::tokenize_code(&processed)
209        } else {
210            processed.split_whitespace().collect()
211        };
212
213        if tokens.is_empty() {
214            return HVec10240::zero();
215        }
216
217        // Generate position-encoded vectors for each token
218        let encoded_vectors: Vec<HVec10240> = tokens
219            .iter()
220            .enumerate()
221            .map(|(pos, &token)| {
222                let base = self.token_to_hvec(token);
223                base.permute(pos * self.config.position_stride)
224            })
225            .collect();
226
227        // Bundle all position-encoded vectors.
228        // `HVec10240::bundle` only fails on empty input; we guard against that above,
229        // so the fallback to zero is a defensive no-op that avoids propagating an
230        // unreachable error through the public API.
231        let mut result = HVec10240::bundle(&encoded_vectors).unwrap_or_else(|_| HVec10240::zero());
232
233        // Optionally add n-gram overlay.
234        // Same reasoning: bundle of non-empty slice is infallible in practice.
235        if let Some(n) = self.config.ngram_size {
236            let ngram_hv = self.encode_ngrams(&processed, n);
237            // Blend n-gram encoding with token encoding
238            result = HVec10240::bundle(&[result, ngram_hv]).unwrap_or_else(|_| HVec10240::zero());
239        }
240
241        result
242    }
243
244    /// Encode text with character n-grams for typo robustness.
245    ///
246    /// This is equivalent to setting `ngram_size` in the config.
247    pub fn encode_with_ngrams(&self, text: &str, n: usize) -> HVec10240 {
248        let config = TextEncoderConfig {
249            ngram_size: Some(n),
250            ..self.config.clone()
251        };
252        let encoder = Self::with_config(config);
253        encoder.encode(text)
254    }
255
256    /// Tokenize text into a vector of tokens.
257    ///
258    /// This is a convenience function for reuse by other modules that need
259    /// tokenization consistent with the encoder's logic.
260    ///
261    /// # Arguments
262    /// * `text` - Input text to tokenize
263    /// * `code_aware` - Enable code-aware splitting (on `_`, `-`, `.`, `/`, `::`)
264    /// * `lowercase` - Convert tokens to lowercase
265    pub fn tokenize(text: &str, code_aware: bool, lowercase: bool) -> Vec<String> {
266        let processed = if lowercase {
267            to_lowercase_cow(text)
268        } else {
269            Cow::Borrowed(text)
270        };
271
272        if code_aware {
273            Self::tokenize_code(&processed)
274                .into_iter()
275                .map(|s| s.to_string())
276                .collect()
277        } else {
278            processed
279                .split_whitespace()
280                .map(|s| s.to_string())
281                .collect()
282        }
283    }
284
285    /// Convert a token to a deterministic hypervector.
286    ///
287    /// Uses FNV-1a hash → seeded PRNG → random HVec10240 for reproducibility.
288    fn token_to_hvec(&self, token: &str) -> HVec10240 {
289        // Compute stable hash
290        let hash = self.stable_hash(token);
291
292        // Use hash as seed for deterministic PRNG
293        HVec10240::new_seeded(hash)
294    }
295
296    /// Compute a stable FNV-1a hash for a token.
297    ///
298    /// Uses FNV-1a (64-bit) for guaranteed cross-version stability.
299    /// `DefaultHasher` (SipHash) is explicitly non-stable across Rust versions.
300    fn stable_hash(&self, token: &str) -> u64 {
301        fnv1a_hash(token.as_bytes())
302    }
303
304    /// Encode text using character n-grams.
305    ///
306    /// Generates n-grams, encodes each, and bundles them together.
307    fn encode_ngrams(&self, text: &str, n: usize) -> HVec10240 {
308        // Optimization: store only byte offsets for character windows.
309        // Reduces memory overhead and improves cache locality compared to (usize, char) pairs.
310        let mut char_offsets = Vec::with_capacity(text.len());
311        for (i, _) in text.char_indices() {
312            char_offsets.push(i);
313        }
314
315        if char_offsets.len() < n || n == 0 {
316            return HVec10240::zero();
317        }
318
319        let last_char_end = text.len();
320        char_offsets.push(last_char_end);
321
322        let ngram_vectors: Vec<HVec10240> = char_offsets
323            .windows(n + 1)
324            .map(|window| {
325                let start = window[0];
326                let end = window[n];
327                self.token_to_hvec(&text[start..end])
328            })
329            .collect();
330
331        HVec10240::bundle(&ngram_vectors).unwrap_or_else(|_| HVec10240::zero())
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
338    use super::*;
339
340    #[test]
341    fn encode_deterministic() {
342        let encoder = TextEncoder::new();
343        let text = "hello world";
344        let v1 = encoder.encode(text);
345        let v2 = encoder.encode(text);
346        // Same text → same vector (deterministic)
347        assert_eq!(v1, v2);
348    }
349
350    #[test]
351    fn encode_position_aware() {
352        let encoder = TextEncoder::new();
353        let v1 = encoder.encode("cat sat");
354        let v2 = encoder.encode("sat cat");
355        // Different order → different vectors
356        assert_ne!(v1, v2);
357    }
358
359    #[test]
360    fn tokenize_splits_whitespace() {
361        let tokens = TextEncoder::tokenize("hello world test", false, true);
362        assert_eq!(tokens, vec!["hello", "world", "test"]);
363    }
364
365    #[test]
366    fn tokenize_lowercase() {
367        let tokens = TextEncoder::tokenize("HELLO World", false, true);
368        assert_eq!(tokens, vec!["hello", "world"]);
369    }
370
371    #[test]
372    fn tokenize_code_aware() {
373        let tokens = TextEncoder::tokenize("my_var::method", true, true);
374        // Code-aware splits on :: and _ (underscore is a separator)
375        // "my_var::method" → ["my", "var", "method"]
376        assert!(tokens.contains(&"my".to_string()));
377        assert!(tokens.contains(&"var".to_string()));
378        assert!(tokens.contains(&"method".to_string()));
379    }
380
381    #[test]
382    fn encode_with_ngrams() {
383        let encoder = TextEncoder::new();
384        let v = encoder.encode_with_ngrams("abc", 2);
385        // N-gram encoding should produce a non-zero vector
386        let zero = HVec10240::zero();
387        assert!(v.hamming_distance(&zero) > 0);
388    }
389
390    #[test]
391    fn stable_hash_consistent() {
392        let encoder = TextEncoder::new();
393        let h1 = encoder.stable_hash("test_token");
394        let h2 = encoder.stable_hash("test_token");
395        assert_eq!(h1, h2);
396    }
397
398    #[test]
399    fn encode_ngrams_boundary_conditions() {
400        let encoder = TextEncoder::new();
401        let zero = HVec10240::zero();
402
403        // len < n: should return zero
404        assert_eq!(encoder.encode_ngrams("a", 2), zero);
405
406        // len == n: should NOT return zero (one n-gram)
407        assert_ne!(encoder.encode_ngrams("ab", 2), zero);
408
409        // n == 0: should return zero
410        assert_eq!(encoder.encode_ngrams("abc", 0), zero);
411
412        // len < n - 1: should return zero
413        assert_eq!(encoder.encode_ngrams("", 2), zero);
414    }
415}