1use super::entangled::{hash_u64, EntangledHVec, DEFAULT_RHO_DENOM};
39use serde::{Deserialize, Serialize};
40use std::cell::RefCell;
41
42#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
43pub enum TextEncoder {
44 #[default]
45 #[serde(rename = "multiscale-words-v1")]
46 MultiscaleWordsV1,
47 #[serde(rename = "multiscale-capped-union-v1")]
49 MultiscaleCappedUnionV1,
50 #[serde(rename = "local-embed-v1")]
53 LocalEmbedV1,
54}
55
56impl TextEncoder {
57 pub fn name(self) -> &'static str {
58 match self {
59 Self::MultiscaleWordsV1 => "multiscale-words-v1",
60 Self::MultiscaleCappedUnionV1 => "multiscale-capped-union-v1",
61 Self::LocalEmbedV1 => "local-embed-v1",
62 }
63 }
64
65 pub fn is_embedding(self) -> bool {
67 self == Self::LocalEmbedV1
68 }
69
70 pub(crate) fn encode(self, text: &str, dim: usize) -> EntangledHVec {
71 assert!(
72 !self.is_embedding(),
73 "embedding encoders are applied to embeddings by the engine, not to text"
74 );
75 ENCODE_STATE.with(|state| encode_cached(text, dim, self, &mut state.borrow_mut()))
76 }
77
78 #[doc(hidden)]
81 pub fn encode_text(self, text: &str, dim: usize) -> EntangledHVec {
82 self.encode(text, dim)
83 }
84
85 #[doc(hidden)]
89 pub fn encode_reference(self, text: &str, dim: usize) -> EntangledHVec {
90 assert!(
91 !self.is_embedding(),
92 "embedding encoders are applied to embeddings by the engine, not to text"
93 );
94 encode_reference(text, dim, self)
95 }
96
97 fn bundle(self, vectors: &[EntangledHVec]) -> EntangledHVec {
98 match self {
99 Self::MultiscaleWordsV1 | Self::LocalEmbedV1 => EntangledHVec::bundle(vectors),
100 Self::MultiscaleCappedUnionV1 => EntangledHVec::bundle_bloom(vectors),
101 }
102 }
103
104 fn bundles_by_union(self) -> bool {
106 self == Self::MultiscaleCappedUnionV1
107 }
108}
109
110impl std::str::FromStr for TextEncoder {
111 type Err = anyhow::Error;
112
113 fn from_str(name: &str) -> Result<Self, Self::Err> {
114 match name {
115 "multiscale-words-v1" => Ok(Self::MultiscaleWordsV1),
116 "multiscale-capped-union-v1" => Ok(Self::MultiscaleCappedUnionV1),
117 "local-embed-v1" => Ok(Self::LocalEmbedV1),
118 _ => anyhow::bail!("unknown text encoder {name:?}"),
119 }
120 }
121}
122
123const CHAR_NGRAM_SIZES: [usize; 4] = [2, 3, 4, 5];
147
148const MAX_CHAR_NGRAM: usize = 5;
150const _: () = assert!(CHAR_NGRAM_SIZES[CHAR_NGRAM_SIZES.len() - 1] == MAX_CHAR_NGRAM);
151
152const LEVEL_CHAR: u64 = 0x01;
154const LEVEL_WORD: u64 = 0x02;
155const LEVEL_PHRASE: u64 = 0x03;
156
157const TERM_ARENA_BUDGET: usize = 1 << 20;
160
161const NGRAM_ARENA_BUDGET: usize = 1 << 21;
163
164const COUNTING_RATIO: usize = 8;
167
168pub fn encode_text_internal(text: &str, dim: usize) -> EntangledHVec {
169 TextEncoder::MultiscaleWordsV1.encode(text, dim)
170}
171
172thread_local! {
173 static ENCODE_STATE: RefCell<EncodeState> = RefCell::new(EncodeState::default());
174}
175
176#[derive(Clone, Copy)]
178struct Span {
179 start: usize,
180 len: usize,
181}
182
183impl Span {
184 fn range(self) -> std::ops::Range<usize> {
185 self.start..self.start + self.len
186 }
187}
188
189#[derive(Clone, Copy, PartialEq, Eq, Hash)]
192struct NgramKey {
193 n: u8,
194 chars: [char; MAX_CHAR_NGRAM],
195}
196
197#[derive(Default)]
201struct EncodeState {
202 dim: usize,
203 terms: fxhash::FxHashMap<u64, Span>,
205 term_arena: Vec<u32>,
206 ngrams: fxhash::FxHashMap<NgramKey, Span>,
209 ngram_arena: Vec<u32>,
210 chars: Vec<char>,
211 flat: Vec<u32>,
213 acc: Vec<u32>,
214 acc_next: Vec<u32>,
215 rotated: Vec<u32>,
216 counts: Vec<u32>,
217 selected: Vec<(u32, u32)>,
218}
219
220impl EncodeState {
221 fn prepare(&mut self, dim: usize) {
223 if self.dim != dim {
224 self.dim = dim;
225 self.terms.clear();
226 self.term_arena.clear();
227 self.ngrams.clear();
228 self.ngram_arena.clear();
229 }
230 }
231
232 fn ngram_span(&mut self, n: usize, window: &[char]) -> Span {
234 let mut key = NgramKey {
235 n: n as u8,
236 chars: ['\0'; MAX_CHAR_NGRAM],
237 };
238 key.chars[..n].copy_from_slice(window);
239 if let Some(&span) = self.ngrams.get(&key) {
240 return span;
241 }
242 let dim = self.dim;
243 let first = term_span(
244 &mut self.terms,
245 &mut self.term_arena,
246 dim,
247 seeded(LEVEL_CHAR, n as u64, window[0] as u64),
248 );
249 self.acc.clear();
251 self.acc.extend_from_slice(&self.term_arena[first.range()]);
252 for (i, &c) in window.iter().enumerate().skip(1) {
253 let span = term_span(
254 &mut self.terms,
255 &mut self.term_arena,
256 dim,
257 seeded(LEVEL_CHAR, n as u64, c as u64),
258 );
259 rotate_sorted(
260 &self.term_arena[span.range()],
261 (i % dim) as u32,
262 dim as u32,
263 &mut self.rotated,
264 );
265 symmetric_difference(&self.acc, &self.rotated, &mut self.acc_next);
266 std::mem::swap(&mut self.acc, &mut self.acc_next);
267 }
268 if self.ngram_arena.len() + self.acc.len() > NGRAM_ARENA_BUDGET {
269 self.ngrams.clear();
270 self.ngram_arena.clear();
271 }
272 let span = Span {
273 start: self.ngram_arena.len(),
274 len: self.acc.len(),
275 };
276 self.ngram_arena.extend_from_slice(&self.acc);
277 self.ngrams.insert(key, span);
278 span
279 }
280
281 fn bundle_flat(&mut self, count: usize, encoder: TextEncoder) -> EntangledHVec {
284 let dim = self.dim;
285 if self.flat.is_empty() {
286 return EntangledHVec::from_indices(Vec::new(), dim);
287 }
288 let union = encoder.bundles_by_union();
289 let threshold = if union { 1 } else { (count as u32).div_ceil(2) };
290 self.selected.clear();
291 if dim <= self.flat.len().saturating_mul(COUNTING_RATIO) {
292 if self.counts.len() != dim {
293 self.counts.clear();
294 self.counts.resize(dim, 0);
295 }
296 debug_assert!(self.counts.iter().all(|&c| c == 0));
297 for &idx in &self.flat {
298 self.counts[idx as usize] += 1;
299 }
300 for (idx, slot) in self.counts.iter_mut().enumerate() {
301 let n = std::mem::take(slot);
302 if n >= threshold {
303 self.selected.push((idx as u32, n));
304 }
305 }
306 } else {
307 self.flat.sort_unstable();
308 let mut current = self.flat[0];
309 let mut n: u32 = 1;
310 for &idx in &self.flat[1..] {
311 if idx == current {
312 n += 1;
313 } else {
314 if n >= threshold {
315 self.selected.push((current, n));
316 }
317 current = idx;
318 n = 1;
319 }
320 }
321 if n >= threshold {
322 self.selected.push((current, n));
323 }
324 }
325 if union {
326 let indices: Vec<u32> = self.selected.iter().map(|&(idx, _)| idx).collect();
327 return EntangledHVec::from_indices(indices, dim);
328 }
329 let target_count = (dim / DEFAULT_RHO_DENOM).max(1);
330 if self.selected.len() > target_count {
333 self.selected
334 .select_nth_unstable_by(target_count - 1, |a, b| b.1.cmp(&a.1));
335 self.selected.truncate(target_count);
336 }
337 let mut indices: Vec<u32> = self.selected.iter().map(|&(idx, _)| idx).collect();
338 indices.sort_unstable();
339 EntangledHVec::from_indices(indices, dim)
340 }
341}
342
343fn term_span(
346 terms: &mut fxhash::FxHashMap<u64, Span>,
347 arena: &mut Vec<u32>,
348 dim: usize,
349 seed: u64,
350) -> Span {
351 if let Some(&span) = terms.get(&seed) {
352 return span;
353 }
354 let vector = EntangledHVec::new_deterministic(dim, seed);
355 let indices = vector.indices();
356 if arena.len() + indices.len() > TERM_ARENA_BUDGET {
357 terms.clear();
358 arena.clear();
359 }
360 let span = Span {
361 start: arena.len(),
362 len: indices.len(),
363 };
364 arena.extend_from_slice(indices);
365 terms.insert(seed, span);
366 span
367}
368
369fn rotate_sorted(base: &[u32], shift: u32, dim: u32, out: &mut Vec<u32>) {
373 out.clear();
374 if shift == 0 {
375 out.extend_from_slice(base);
376 return;
377 }
378 let wrap = dim - shift;
379 let split = base.partition_point(|&idx| idx < wrap);
380 out.extend(base[split..].iter().map(|&idx| idx - wrap));
381 out.extend(base[..split].iter().map(|&idx| idx + shift));
382}
383
384fn symmetric_difference(a: &[u32], b: &[u32], out: &mut Vec<u32>) {
386 out.clear();
387 let (mut i, mut j) = (0, 0);
388 while i < a.len() && j < b.len() {
389 match a[i].cmp(&b[j]) {
390 std::cmp::Ordering::Less => {
391 out.push(a[i]);
392 i += 1;
393 }
394 std::cmp::Ordering::Greater => {
395 out.push(b[j]);
396 j += 1;
397 }
398 std::cmp::Ordering::Equal => {
399 i += 1;
400 j += 1;
401 }
402 }
403 }
404 out.extend_from_slice(&a[i..]);
405 out.extend_from_slice(&b[j..]);
406}
407
408fn encode_cached(
411 text: &str,
412 dim: usize,
413 encoder: TextEncoder,
414 state: &mut EncodeState,
415) -> EntangledHVec {
416 let text = text.trim();
417 if text.is_empty() {
418 return EntangledHVec::from_indices(Vec::new(), dim);
419 }
420 state.prepare(dim);
421 state.chars.clear();
422 state.chars.extend(text.chars());
423 let words: Vec<&str> = text.split_whitespace().collect();
424 let mut level_bundles: Vec<EntangledHVec> = Vec::with_capacity(CHAR_NGRAM_SIZES.len() + 2);
425
426 for &n in &CHAR_NGRAM_SIZES {
428 let windows = match state.chars.len().checked_sub(n) {
429 Some(rest) => rest + 1,
430 None => continue,
431 };
432 state.flat.clear();
433 for start in 0..windows {
434 let mut window = ['\0'; MAX_CHAR_NGRAM];
435 window[..n].copy_from_slice(&state.chars[start..start + n]);
436 let span = state.ngram_span(n, &window[..n]);
437 state
438 .flat
439 .extend_from_slice(&state.ngram_arena[span.range()]);
440 }
441 level_bundles.push(state.bundle_flat(windows, encoder));
442 }
443
444 let d = dim as u32;
445
446 if !words.is_empty() {
448 state.flat.clear();
449 for (pos, word) in words.iter().enumerate() {
450 let span = term_span(
451 &mut state.terms,
452 &mut state.term_arena,
453 dim,
454 hash_str_seed(word, LEVEL_WORD),
455 );
456 let shift = (pos % dim) as u32;
457 state.flat.extend(
458 state.term_arena[span.range()]
459 .iter()
460 .map(|&idx| (idx + shift) % d),
461 );
462 }
463 level_bundles.push(state.bundle_flat(words.len(), encoder));
464 }
465
466 if words.len() >= 2 {
468 state.flat.clear();
469 for (pos, pair) in words.windows(2).enumerate() {
470 let left = term_span(
471 &mut state.terms,
472 &mut state.term_arena,
473 dim,
474 hash_str_seed(pair[0], LEVEL_PHRASE),
475 );
476 state.acc_next.clear();
478 state
479 .acc_next
480 .extend_from_slice(&state.term_arena[left.range()]);
481 let right = term_span(
482 &mut state.terms,
483 &mut state.term_arena,
484 dim,
485 hash_str_seed(pair[1], LEVEL_PHRASE),
486 );
487 rotate_sorted(
488 &state.term_arena[right.range()],
489 (1 % dim) as u32,
490 d,
491 &mut state.rotated,
492 );
493 symmetric_difference(&state.acc_next, &state.rotated, &mut state.acc);
494 let shift = (pos % dim) as u32;
495 state
496 .flat
497 .extend(state.acc.iter().map(|&idx| (idx + shift) % d));
498 }
499 level_bundles.push(state.bundle_flat(words.len() - 1, encoder));
500 }
501
502 if level_bundles.is_empty() {
503 return EntangledHVec::new_deterministic(dim, seeded(LEVEL_CHAR, 1, state.chars[0] as u64));
504 }
505 combine_levels(&level_bundles, dim)
506}
507
508fn encode_reference(text: &str, dim: usize, encoder: TextEncoder) -> EntangledHVec {
510 let text = text.trim();
511 if text.is_empty() {
512 return EntangledHVec::from_indices(vec![], dim);
513 }
514
515 let chars: Vec<char> = text.chars().collect();
516 let words: Vec<&str> = text.split_whitespace().collect();
517
518 let mut level_bundles: Vec<EntangledHVec> = Vec::new();
521
522 for &n in &CHAR_NGRAM_SIZES {
524 if chars.len() < n {
525 continue;
526 }
527 let ngrams: Vec<EntangledHVec> = chars
528 .windows(n)
529 .map(|window| {
530 let mut chunk = EntangledHVec::new_deterministic(
531 dim,
532 seeded(LEVEL_CHAR, n as u64, window[0] as u64),
533 )
534 .permute(0);
535 for (i, &c) in window.iter().enumerate().skip(1) {
536 let next = EntangledHVec::new_deterministic(
537 dim,
538 seeded(LEVEL_CHAR, n as u64, c as u64),
539 )
540 .permute(i);
541 chunk = chunk.bind(&next);
542 }
543 chunk
544 })
545 .collect();
546 level_bundles.push(encoder.bundle(&ngrams));
547 }
548
549 if !words.is_empty() {
551 let word_vecs: Vec<EntangledHVec> = words
552 .iter()
553 .enumerate()
554 .map(|(pos, word)| {
555 let word_hash = hash_str_seed(word, LEVEL_WORD);
556 EntangledHVec::new_deterministic(dim, word_hash).permute(pos)
557 })
558 .collect();
559 level_bundles.push(encoder.bundle(&word_vecs));
560 }
561
562 if words.len() >= 2 {
564 let phrase_vecs: Vec<EntangledHVec> = words
565 .windows(2)
566 .enumerate()
567 .map(|(pos, pair)| {
568 let h0 = hash_str_seed(pair[0], LEVEL_PHRASE);
569 let h1 = hash_str_seed(pair[1], LEVEL_PHRASE);
570 let v0 = EntangledHVec::new_deterministic(dim, h0).permute(0);
571 let v1 = EntangledHVec::new_deterministic(dim, h1).permute(1);
572 v0.bind(&v1).permute(pos)
573 })
574 .collect();
575 level_bundles.push(encoder.bundle(&phrase_vecs));
576 }
577
578 if level_bundles.is_empty() {
579 return EntangledHVec::new_deterministic(dim, seeded(LEVEL_CHAR, 1, chars[0] as u64));
580 }
581 combine_levels(&level_bundles, dim)
582}
583
584fn combine_levels(level_bundles: &[EntangledHVec], dim: usize) -> EntangledHVec {
589 let active_count = (dim / DEFAULT_RHO_DENOM).max(1);
590 let mut freq: fxhash::FxHashMap<u32, u32> =
591 fxhash::FxHashMap::with_capacity_and_hasher(active_count * 4, Default::default());
592 for bundle in level_bundles {
593 for &idx in bundle.indices() {
594 *freq.entry(idx).or_insert(0) += 1;
595 }
596 }
597
598 let mut scored: Vec<(u32, u32)> = freq.into_iter().collect();
599 if scored.len() > active_count {
600 scored.select_nth_unstable_by(active_count - 1, |a, b| {
601 b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))
602 });
603 scored.truncate(active_count);
604 }
605
606 let mut indices: Vec<u32> = scored.into_iter().map(|(idx, _)| idx).collect();
607 indices.sort_unstable();
608 EntangledHVec::from_indices(indices, dim)
609}
610
611fn seeded(level: u64, scale: u64, item: u64) -> u64 {
612 hash_u64(hash_u64(level, scale), item)
613}
614
615pub(crate) fn hash_str_seed(s: &str, level: u64) -> u64 {
616 let mut h = level;
617 for &byte in s.as_bytes() {
618 h = hash_u64(h, byte as u64);
619 }
620 h
621}
622
623#[cfg(test)]
624mod tests {
625 use super::*;
626
627 #[test]
628 fn test_determinism() {
629 let dim = 16384;
630 let v1 = encode_text_internal("hello world", dim);
631 let v2 = encode_text_internal("hello world", dim);
632 assert!(
633 (v1.similarity(&v2) - 1.0).abs() < f64::EPSILON,
634 "Same input must produce identical vectors"
635 );
636 }
637
638 #[test]
639 fn test_empty_input() {
640 let v = encode_text_internal("", 16384);
641 assert!(v.indices().is_empty());
642 }
643
644 #[test]
645 fn test_single_char() {
646 let v = encode_text_internal("x", 16384);
647 assert!(
648 !v.indices().is_empty(),
649 "Single char should produce a vector"
650 );
651 }
652
653 #[test]
654 fn test_morphological_similarity() {
655 let dim = 16384;
656 let cat = encode_text_internal("cat", dim);
657 let cats = encode_text_internal("cats", dim);
658 let dog = encode_text_internal("dog", dim);
659 let sim_cat_cats = cat.similarity(&cats);
660 let sim_cat_dog = cat.similarity(&dog);
661 assert!(
662 sim_cat_cats > sim_cat_dog,
663 "cat/cats ({:.4}) should be more similar than cat/dog ({:.4})",
664 sim_cat_cats,
665 sim_cat_dog
666 );
667 }
668
669 #[test]
670 fn test_word_identity_matters() {
671 let dim = 16384;
672 let v1 = encode_text_internal("the quick brown fox", dim);
673 let v2 = encode_text_internal("the quick brown fox", dim);
674 let v3 = encode_text_internal("completely different words here", dim);
675 assert!(
676 (v1.similarity(&v2) - 1.0).abs() < f64::EPSILON,
677 "Identical text must match"
678 );
679 assert!(
680 v1.similarity(&v3) < 0.3,
681 "Unrelated text should have low similarity"
682 );
683 }
684
685 #[test]
686 fn test_word_overlap_similarity() {
687 let dim = 16384;
688 let v1 = encode_text_internal("the cat sat on the mat", dim);
689 let v2 = encode_text_internal("the dog sat on the mat", dim);
690 let v3 = encode_text_internal("quantum physics is fascinating", dim);
691 let sim_close = v1.similarity(&v2);
692 let sim_far = v1.similarity(&v3);
693 assert!(
694 sim_close > sim_far,
695 "Sentences sharing most words ({:.4}) should be more similar than unrelated ({:.4})",
696 sim_close,
697 sim_far
698 );
699 }
700
701 #[test]
702 fn test_multi_scale_captures_short_text() {
703 let dim = 16384;
704 let v = encode_text_internal("hi", dim);
706 assert!(!v.indices().is_empty());
707 let v2 = encode_text_internal("his", dim);
709 let v3 = encode_text_internal("xyz", dim);
710 assert!(
711 v.similarity(&v2) > v.similarity(&v3),
712 "hi/his should be more similar than hi/xyz"
713 );
714 }
715
716 #[test]
717 fn rotation_matches_permute() {
718 for dim in [1usize, 2, 255, 256, 1024] {
719 for seed in 0..32u64 {
720 let base = EntangledHVec::new_deterministic(dim, seed);
721 for shift in 0..dim.min(7) {
722 let mut out = Vec::new();
723 rotate_sorted(base.indices(), shift as u32, dim as u32, &mut out);
724 assert_eq!(
725 out,
726 base.permute(shift).indices(),
727 "dim {dim} shift {shift}"
728 );
729 }
730 }
731 }
732 }
733
734 #[test]
735 fn memoized_path_matches_reference_at_density_boundaries() {
736 let texts = [
737 "x",
738 "hi",
739 "aa",
740 "aaaa aaaa aaaa",
741 "the cat sat on the mat",
742 "Café records explain backups. Restore deleted documents from verified snapshots.",
743 "ünïcödé text with punctuation!!! and NUMBERS 12345, repeated repeated repeated words",
744 "a b c d e f g h i j k l m n o p q r s t u v w x y z a b c d e f g h i j k l m n o p",
745 ];
746 for encoder in [
747 TextEncoder::MultiscaleWordsV1,
748 TextEncoder::MultiscaleCappedUnionV1,
749 ] {
750 for dim in [1usize, 2, 3, 255, 256, 257, 1024, 4096, 16384] {
751 for text in texts {
752 let reference = encoder.encode_reference(text, dim);
753 let cached = encoder.encode(text, dim);
754 assert_eq!(cached.dim, reference.dim);
755 assert_eq!(
756 cached.indices(),
757 reference.indices(),
758 "{} dim {dim} text {text:?}",
759 encoder.name()
760 );
761 assert_eq!(encoder.encode(text, dim).indices(), reference.indices());
763 }
764 }
765 }
766 }
767}