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}