Skip to main content

writerslogic_warm/core/
encoding.rs

1// Copyright 2024-2026 WritersLogic Contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Text encoders: text in, sparse hypervector out.
5//!
6//! `multiscale-words-v1` is specified by `encode_reference`: character n-grams at scales
7//! 2-5 (one hypervector per character, permuted by its offset in the window and bound by
8//! symmetric difference), word tokens permuted by position, and word bigrams, each level
9//! bundled by majority vote, the levels combined by cross-level support. That function is the
10//! contract (`docs/file-format/ENCODERS.md`: same text and dimension, same indices forever) and
11//! stays reachable for the equivalence test in `tests/ingest_encode.rs`.
12//!
13//! `TextEncoder::encode` computes the same function through per-thread memo tables and
14//! reused scratch buffers, which is where ingest spends its time (`new_deterministic` per
15//! character of every n-gram, a sort of every level's concatenated indices, and one heap
16//! allocation per intermediate vector). It produces identical indices because:
17//!
18//! 1. `EntangledHVec::new_deterministic(dim, seed)` is a pure function of `(dim, seed)`, so a
19//!    base vector is memoized by seed (`EncodeState::terms`); two words with equal seeds share
20//!    a vector in the reference as well, so keying by seed adds no collision.
21//! 2. `permute(shift)` maps a sorted index set through `(idx + shift) % dim`, a bijection; the
22//!    sorted result is the rotation `rotate_sorted` computes without sorting, and a multiset
23//!    that is only counted needs no order at all.
24//! 3. `bind` is the symmetric difference of two sorted sets: set-valued, so any correct
25//!    implementation yields the same indices. A character n-gram's bound vector depends only on
26//!    `(scale, characters)` and is memoized by that exact key (`EncodeState::ngrams`).
27//! 4. `EntangledHVec::bundle` depends on its inputs only through the ascending run-length list
28//!    `(index, count)` of the concatenated indices. `EncodeState::bundle_flat` builds the same
29//!    list from a histogram over `[0, dim)` (or by sorting when `dim` dwarfs the index total)
30//!    and then calls the identical `select_nth_unstable_by` on the identical slice; that call's
31//!    comparator has ties, so the retained set depends on input order and algorithm, both kept.
32//! 5. The cross-level selection orders by `(count desc, index asc)`, a total order, so it is a
33//!    function of the level bundles alone.
34//!
35//! The memo tables are a per-thread cache with a fixed budget (cleared when exceeded), never
36//! an input to the result: output is independent of thread, cache state and call history.
37//!
38//! `multiscale-words-v2` uses the same construction with a total order at each majority
39//! selection: frequency descending, coordinate ascending. V1 retains its historical behavior.
40
41use super::entangled::{hash_u64, EntangledHVec, DEFAULT_RHO_DENOM};
42use serde::{Deserialize, Serialize};
43use std::cell::RefCell;
44
45#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
46pub enum TextEncoder {
47    #[default]
48    #[serde(rename = "multiscale-words-v1")]
49    MultiscaleWordsV1,
50    /// Experimental union within each level, capped by cross-level frequency.
51    #[serde(rename = "multiscale-capped-union-v1")]
52    MultiscaleCappedUnionV1,
53    /// Sparse sketch of a local neural sentence embedding (feature `local-embed`); the store's
54    /// documents also get dense chunk embeddings from the same model.
55    #[serde(rename = "local-embed-v1")]
56    LocalEmbedV1,
57    /// Majority selection resolves equal frequencies by ascending coordinate.
58    #[serde(rename = "multiscale-words-v2")]
59    MultiscaleWordsV2,
60}
61
62impl TextEncoder {
63    pub fn name(self) -> &'static str {
64        match self {
65            Self::MultiscaleWordsV1 => "multiscale-words-v1",
66            Self::MultiscaleCappedUnionV1 => "multiscale-capped-union-v1",
67            Self::LocalEmbedV1 => "local-embed-v1",
68            Self::MultiscaleWordsV2 => "multiscale-words-v2",
69        }
70    }
71
72    /// Whether vectors come from a neural embedding instead of the text itself.
73    pub fn is_embedding(self) -> bool {
74        self == Self::LocalEmbedV1
75    }
76
77    pub(crate) fn encode(self, text: &str, dim: usize) -> EntangledHVec {
78        assert!(
79            !self.is_embedding(),
80            "embedding encoders are applied to embeddings by the engine, not to text"
81        );
82        ENCODE_STATE.with(|state| encode_cached(text, dim, self, &mut state.borrow_mut()))
83    }
84
85    /// The production path ([`Self::encode`]) for callers outside the crate, such as the
86    /// equivalence test; not part of the stable API.
87    #[doc(hidden)]
88    pub fn encode_text(self, text: &str, dim: usize) -> EntangledHVec {
89        self.encode(text, dim)
90    }
91
92    /// The reference formulation of the encoder: one `new_deterministic` per character of every
93    /// n-gram and sorting bundles, exactly as before the memoized path existed. Kept so the
94    /// equivalence test can assert bit-identical output; not part of the stable API.
95    #[doc(hidden)]
96    pub fn encode_reference(self, text: &str, dim: usize) -> EntangledHVec {
97        assert!(
98            !self.is_embedding(),
99            "embedding encoders are applied to embeddings by the engine, not to text"
100        );
101        encode_reference(text, dim, self)
102    }
103
104    fn bundle(self, vectors: &[EntangledHVec]) -> EntangledHVec {
105        match self {
106            Self::MultiscaleWordsV1 | Self::LocalEmbedV1 => EntangledHVec::bundle(vectors),
107            Self::MultiscaleCappedUnionV1 => EntangledHVec::bundle_bloom(vectors),
108            Self::MultiscaleWordsV2 => {
109                let dim = vectors.first().map_or(0, |v| v.dim);
110                let mut counts = std::collections::BTreeMap::<u32, usize>::new();
111                for vector in vectors {
112                    for &index in vector.indices() {
113                        *counts.entry(index).or_default() += 1;
114                    }
115                }
116                let threshold = vectors.len().div_ceil(2);
117                let mut selected: Vec<_> = counts
118                    .into_iter()
119                    .filter(|&(_, count)| count >= threshold)
120                    .collect();
121                selected.sort_unstable_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
122                selected.truncate((dim / DEFAULT_RHO_DENOM).max(1));
123                let mut indices: Vec<_> = selected.into_iter().map(|(index, _)| index).collect();
124                indices.sort_unstable();
125                EntangledHVec::from_indices(indices, dim)
126            }
127        }
128    }
129
130    /// Whether a level is bundled by set union (`bundle_bloom`) rather than majority vote.
131    fn bundles_by_union(self) -> bool {
132        self == Self::MultiscaleCappedUnionV1
133    }
134}
135
136impl std::str::FromStr for TextEncoder {
137    type Err = anyhow::Error;
138
139    fn from_str(name: &str) -> Result<Self, Self::Err> {
140        match name {
141            "multiscale-words-v1" => Ok(Self::MultiscaleWordsV1),
142            "multiscale-words-v2" => Ok(Self::MultiscaleWordsV2),
143            "multiscale-capped-union-v1" => Ok(Self::MultiscaleCappedUnionV1),
144            "local-embed-v1" => Ok(Self::LocalEmbedV1),
145            _ => anyhow::bail!("unknown text encoder {name:?}"),
146        }
147    }
148}
149
150/// Multi-scale text encoding combining character n-grams, word tokens, and
151/// word bigrams into a single sparse hypervector.
152///
153/// Architecture (based on Rahimi et al. 2016, Joshi et al. 2020):
154///
155/// 1. **Character n-grams** at scales 2-5 capture morphological similarity.
156///    "cat" and "cats" share most bigrams/trigrams; different n-gram sizes
157///    provide redundancy and cover different morphological features.
158///
159/// 2. **Word tokens** with positional binding capture lexical identity.
160///    Each word is hashed as a unit and bound with its sentence position,
161///    so word order is partially preserved.
162///
163/// 3. **Word bigrams** capture local phrase structure.
164///    Adjacent word pairs bound together represent collocations.
165///
166/// Each level uses a unique seed space (LEVEL_CHAR, LEVEL_WORD, LEVEL_PHRASE)
167/// to prevent cross-level hash collisions. Levels are bundled with
168/// empirically-tuned weights favoring word identity (highest signal-to-noise
169/// ratio for retrieval tasks).
170///
171/// Breaking change: vectors produced by this encoder are incompatible with
172/// the previous character-trigram-only encoder. Re-encode stored data.
173const CHAR_NGRAM_SIZES: [usize; 4] = [2, 3, 4, 5];
174
175/// The largest character n-gram scale; the memo key holds this many characters.
176const MAX_CHAR_NGRAM: usize = 5;
177const _: () = assert!(CHAR_NGRAM_SIZES[CHAR_NGRAM_SIZES.len() - 1] == MAX_CHAR_NGRAM);
178
179/// Level seed offsets prevent cross-level hash collisions.
180const LEVEL_CHAR: u64 = 0x01;
181const LEVEL_WORD: u64 = 0x02;
182const LEVEL_PHRASE: u64 = 0x03;
183
184/// Per-thread budget, in `u32` slots, of memoized base vectors (words, bigram words and
185/// characters, keyed by seed): 4 MiB. The table is cleared when a new entry would exceed it.
186const TERM_ARENA_BUDGET: usize = 1 << 20;
187
188/// Per-thread budget, in `u32` slots, of memoized character n-gram vectors: 8 MiB.
189const NGRAM_ARENA_BUDGET: usize = 1 << 21;
190
191/// A level is bundled through a histogram over `[0, dim)` when `dim` is at most this many
192/// times the number of indices to count; sorting them is cheaper beyond that.
193const COUNTING_RATIO: usize = 8;
194
195/// Below this dimension a majority-vote level is always counted in a `dim`-slot histogram
196/// (at most 256 KiB per thread) visited only at the counted indices, instead of sorted.
197const SPARSE_COUNTING_MAX_DIM: usize = 1 << 16;
198
199pub fn encode_text_internal(text: &str, dim: usize) -> EntangledHVec {
200    TextEncoder::MultiscaleWordsV1.encode(text, dim)
201}
202
203thread_local! {
204    static ENCODE_STATE: RefCell<EncodeState> = RefCell::new(EncodeState::default());
205}
206
207/// A memoized vector's location in an arena.
208#[derive(Clone, Copy)]
209struct Span {
210    start: usize,
211    len: usize,
212}
213
214impl Span {
215    fn range(self) -> std::ops::Range<usize> {
216        self.start..self.start + self.len
217    }
218}
219
220/// Exact identity of a character n-gram: its scale and characters (unused slots are NUL,
221/// which no window at that scale can contain in those positions).
222#[derive(Clone, Copy, PartialEq, Eq, Hash)]
223struct NgramKey {
224    n: u8,
225    chars: [char; MAX_CHAR_NGRAM],
226}
227
228/// Memo tables and scratch buffers of one thread. Invariants: every span in `terms` and
229/// `ngrams` lies inside its arena; both tables hold vectors for `dim` only; `counts` is
230/// all-zero outside [`EncodeState::bundle_flat`]; scratch vectors carry no state between calls.
231#[derive(Default)]
232struct EncodeState {
233    dim: usize,
234    /// seed -> sorted indices of `EntangledHVec::new_deterministic(dim, seed)`.
235    terms: crate::core::hash::FastMap<u64, Span>,
236    term_arena: Vec<u32>,
237    /// n-gram -> sorted indices of its bound vector (reference lines: permute each character
238    /// by its offset, bind left to right).
239    ngrams: crate::core::hash::FastMap<NgramKey, Span>,
240    ngram_arena: Vec<u32>,
241    chars: Vec<char>,
242    /// The multiset of (already permuted) indices of every vector in the level being bundled.
243    flat: Vec<u32>,
244    acc: Vec<u32>,
245    acc_next: Vec<u32>,
246    rotated: Vec<u32>,
247    counts: Vec<u32>,
248    selected: Vec<(u32, u32)>,
249}
250
251impl EncodeState {
252    /// Points the memo tables at `dim`, dropping vectors of another dimension.
253    fn prepare(&mut self, dim: usize) {
254        if self.dim != dim {
255            self.dim = dim;
256            self.terms.clear();
257            self.term_arena.clear();
258            self.ngrams.clear();
259            self.ngram_arena.clear();
260        }
261    }
262
263    /// The bound vector of the character n-gram `window` (length `n`), memoized.
264    fn ngram_span(&mut self, n: usize, window: &[char]) -> Span {
265        let mut key = NgramKey {
266            n: n as u8,
267            chars: ['\0'; MAX_CHAR_NGRAM],
268        };
269        key.chars[..n].copy_from_slice(window);
270        if let Some(&span) = self.ngrams.get(&key) {
271            return span;
272        }
273        let dim = self.dim;
274        let first = term_span(
275            &mut self.terms,
276            &mut self.term_arena,
277            dim,
278            seeded(LEVEL_CHAR, n as u64, window[0] as u64),
279        );
280        // Copied before the next lookup, which may clear the term arena.
281        self.acc.clear();
282        self.acc.extend_from_slice(&self.term_arena[first.range()]);
283        for (i, &c) in window.iter().enumerate().skip(1) {
284            let span = term_span(
285                &mut self.terms,
286                &mut self.term_arena,
287                dim,
288                seeded(LEVEL_CHAR, n as u64, c as u64),
289            );
290            rotate_sorted(
291                &self.term_arena[span.range()],
292                (i % dim) as u32,
293                dim as u32,
294                &mut self.rotated,
295            );
296            symmetric_difference(&self.acc, &self.rotated, &mut self.acc_next);
297            std::mem::swap(&mut self.acc, &mut self.acc_next);
298        }
299        if self.ngram_arena.len() + self.acc.len() > NGRAM_ARENA_BUDGET {
300            self.ngrams.clear();
301            self.ngram_arena.clear();
302        }
303        let span = Span {
304            start: self.ngram_arena.len(),
305            len: self.acc.len(),
306        };
307        self.ngram_arena.extend_from_slice(&self.acc);
308        self.ngrams.insert(key, span);
309        span
310    }
311
312    /// Sizes `counts` to `dim` zeroed slots; it is all-zero between calls already.
313    fn reset_counts(&mut self, dim: usize) {
314        if self.counts.len() != dim {
315            self.counts.clear();
316            self.counts.resize(dim, 0);
317        }
318        debug_assert!(self.counts.iter().all(|&c| c == 0));
319    }
320
321    /// The bundle (`EntangledHVec::bundle`, or `bundle_bloom` for union encoders) of `count`
322    /// vectors whose indices are concatenated in `self.flat`, in any order.
323    fn bundle_flat(&mut self, count: usize, encoder: TextEncoder) -> EntangledHVec {
324        let dim = self.dim;
325        if self.flat.is_empty() {
326            return EntangledHVec::from_indices(Vec::new(), dim);
327        }
328        let union = encoder.bundles_by_union();
329        let threshold = if union { 1 } else { (count as u32).div_ceil(2) };
330        self.selected.clear();
331        let dense = dim <= self.flat.len().saturating_mul(COUNTING_RATIO);
332        if !union && self.flat.len() < dim && (dense || dim <= SPARSE_COUNTING_MAX_DIM) {
333            // Majority voting keeps few indices: count every index, collect the ones that
334            // reach the threshold while zeroing each touched slot (so each index is collected
335            // once), then sort only those. Same (index, count) pairs in the same ascending
336            // order as the other two paths, at a cost linear in the multiset rather than in
337            // `dim` (the dense scan, cheaper once the multiset outgrows `dim`) or a full sort.
338            self.reset_counts(dim);
339            for &idx in &self.flat {
340                self.counts[idx as usize] += 1;
341            }
342            for &idx in &self.flat {
343                let n = std::mem::take(&mut self.counts[idx as usize]);
344                if n >= threshold {
345                    self.selected.push((idx, n));
346                }
347            }
348            self.selected.sort_unstable_by_key(|&(idx, _)| idx);
349        } else if dense {
350            self.reset_counts(dim);
351            for &idx in &self.flat {
352                self.counts[idx as usize] += 1;
353            }
354            for (idx, slot) in self.counts.iter_mut().enumerate() {
355                let n = std::mem::take(slot);
356                if n >= threshold {
357                    self.selected.push((idx as u32, n));
358                }
359            }
360        } else {
361            self.flat.sort_unstable();
362            let mut current = self.flat[0];
363            let mut n: u32 = 1;
364            for &idx in &self.flat[1..] {
365                if idx == current {
366                    n += 1;
367                } else {
368                    if n >= threshold {
369                        self.selected.push((current, n));
370                    }
371                    current = idx;
372                    n = 1;
373                }
374            }
375            if n >= threshold {
376                self.selected.push((current, n));
377            }
378        }
379        if union {
380            let indices: Vec<u32> = self.selected.iter().map(|&(idx, _)| idx).collect();
381            return EntangledHVec::from_indices(indices, dim);
382        }
383        let target_count = (dim / DEFAULT_RHO_DENOM).max(1);
384        // V1's comparator intentionally preserves the legacy algorithm-dependent ties.
385        if self.selected.len() > target_count {
386            self.selected
387                .select_nth_unstable_by(target_count - 1, |a, b| {
388                    let frequency = b.1.cmp(&a.1);
389                    if encoder == TextEncoder::MultiscaleWordsV2 {
390                        frequency.then_with(|| a.0.cmp(&b.0))
391                    } else {
392                        frequency
393                    }
394                });
395            self.selected.truncate(target_count);
396        }
397        let mut indices: Vec<u32> = self.selected.iter().map(|&(idx, _)| idx).collect();
398        indices.sort_unstable();
399        EntangledHVec::from_indices(indices, dim)
400    }
401}
402
403/// Sorted indices of `EntangledHVec::new_deterministic(dim, seed)`, memoized in `arena`.
404/// The returned span is valid until the next call, which may clear the arena.
405fn term_span(
406    terms: &mut crate::core::hash::FastMap<u64, Span>,
407    arena: &mut Vec<u32>,
408    dim: usize,
409    seed: u64,
410) -> Span {
411    if let Some(&span) = terms.get(&seed) {
412        return span;
413    }
414    let vector = EntangledHVec::new_deterministic(dim, seed);
415    let indices = vector.indices();
416    if arena.len() + indices.len() > TERM_ARENA_BUDGET {
417        terms.clear();
418        arena.clear();
419    }
420    let span = Span {
421        start: arena.len(),
422        len: indices.len(),
423    };
424    arena.extend_from_slice(indices);
425    terms.insert(seed, span);
426    span
427}
428
429/// `permute(shift)` of the sorted index set `base`: `(idx + shift) % dim` for each index, in
430/// ascending order. Indices at or above `dim - shift` wrap to the front, so the result is a
431/// rotation of the input and needs no sort. `shift` must be below `dim`.
432fn rotate_sorted(base: &[u32], shift: u32, dim: u32, out: &mut Vec<u32>) {
433    out.clear();
434    if shift == 0 {
435        out.extend_from_slice(base);
436        return;
437    }
438    let wrap = dim - shift;
439    let split = base.partition_point(|&idx| idx < wrap);
440    out.extend(base[split..].iter().map(|&idx| idx - wrap));
441    out.extend(base[..split].iter().map(|&idx| idx + shift));
442}
443
444/// Sorted symmetric difference of two sorted index sets (`EntangledHVec::bind`).
445fn symmetric_difference(a: &[u32], b: &[u32], out: &mut Vec<u32>) {
446    out.clear();
447    let (mut i, mut j) = (0, 0);
448    while i < a.len() && j < b.len() {
449        match a[i].cmp(&b[j]) {
450            std::cmp::Ordering::Less => {
451                out.push(a[i]);
452                i += 1;
453            }
454            std::cmp::Ordering::Greater => {
455                out.push(b[j]);
456                j += 1;
457            }
458            std::cmp::Ordering::Equal => {
459                i += 1;
460                j += 1;
461            }
462        }
463    }
464    out.extend_from_slice(&a[i..]);
465    out.extend_from_slice(&b[j..]);
466}
467
468/// [`encode_reference`] computed through the thread's memo tables; see the module head for
469/// why every step yields the same indices.
470fn encode_cached(
471    text: &str,
472    dim: usize,
473    encoder: TextEncoder,
474    state: &mut EncodeState,
475) -> EntangledHVec {
476    let text = text.trim();
477    if text.is_empty() {
478        return EntangledHVec::from_indices(Vec::new(), dim);
479    }
480    state.prepare(dim);
481    state.chars.clear();
482    state.chars.extend(text.chars());
483    let words: Vec<&str> = text.split_whitespace().collect();
484    let mut level_bundles: Vec<EntangledHVec> = Vec::with_capacity(CHAR_NGRAM_SIZES.len() + 2);
485
486    // Character n-grams at multiple scales.
487    for &n in &CHAR_NGRAM_SIZES {
488        let windows = match state.chars.len().checked_sub(n) {
489            Some(rest) => rest + 1,
490            None => continue,
491        };
492        state.flat.clear();
493        for start in 0..windows {
494            let mut window = ['\0'; MAX_CHAR_NGRAM];
495            window[..n].copy_from_slice(&state.chars[start..start + n]);
496            let span = state.ngram_span(n, &window[..n]);
497            state
498                .flat
499                .extend_from_slice(&state.ngram_arena[span.range()]);
500        }
501        level_bundles.push(state.bundle_flat(windows, encoder));
502    }
503
504    let d = dim as u32;
505
506    // Word-level encoding with positional binding.
507    if !words.is_empty() {
508        state.flat.clear();
509        for (pos, word) in words.iter().enumerate() {
510            let span = term_span(
511                &mut state.terms,
512                &mut state.term_arena,
513                dim,
514                hash_str_seed(word, LEVEL_WORD),
515            );
516            let shift = (pos % dim) as u32;
517            state.flat.extend(
518                state.term_arena[span.range()]
519                    .iter()
520                    .map(|&idx| (idx + shift) % d),
521            );
522        }
523        level_bundles.push(state.bundle_flat(words.len(), encoder));
524    }
525
526    // Word bigrams for phrase structure.
527    if words.len() >= 2 {
528        state.flat.clear();
529        for (pos, pair) in words.windows(2).enumerate() {
530            let left = term_span(
531                &mut state.terms,
532                &mut state.term_arena,
533                dim,
534                hash_str_seed(pair[0], LEVEL_PHRASE),
535            );
536            // Copied before the next lookup, which may clear the term arena.
537            state.acc_next.clear();
538            state
539                .acc_next
540                .extend_from_slice(&state.term_arena[left.range()]);
541            let right = term_span(
542                &mut state.terms,
543                &mut state.term_arena,
544                dim,
545                hash_str_seed(pair[1], LEVEL_PHRASE),
546            );
547            rotate_sorted(
548                &state.term_arena[right.range()],
549                (1 % dim) as u32,
550                d,
551                &mut state.rotated,
552            );
553            symmetric_difference(&state.acc_next, &state.rotated, &mut state.acc);
554            let shift = (pos % dim) as u32;
555            state
556                .flat
557                .extend(state.acc.iter().map(|&idx| (idx + shift) % d));
558        }
559        level_bundles.push(state.bundle_flat(words.len() - 1, encoder));
560    }
561
562    if level_bundles.is_empty() {
563        return EntangledHVec::new_deterministic(dim, seeded(LEVEL_CHAR, 1, state.chars[0] as u64));
564    }
565    combine_levels(&level_bundles, dim)
566}
567
568/// The reference formulation; see [`TextEncoder::encode_reference`].
569fn encode_reference(text: &str, dim: usize, encoder: TextEncoder) -> EntangledHVec {
570    let text = text.trim();
571    if text.is_empty() {
572        return EntangledHVec::from_indices(vec![], dim);
573    }
574
575    let chars: Vec<char> = text.chars().collect();
576    let words: Vec<&str> = text.split_whitespace().collect();
577
578    // Bundle within each level first, then equal-weight bundle across levels.
579    // This prevents any single level from dominating majority-vote thresholds.
580    let mut level_bundles: Vec<EntangledHVec> = Vec::new();
581
582    // Character n-grams at multiple scales.
583    for &n in &CHAR_NGRAM_SIZES {
584        if chars.len() < n {
585            continue;
586        }
587        let ngrams: Vec<EntangledHVec> = chars
588            .windows(n)
589            .map(|window| {
590                let mut chunk = EntangledHVec::new_deterministic(
591                    dim,
592                    seeded(LEVEL_CHAR, n as u64, window[0] as u64),
593                )
594                .permute(0);
595                for (i, &c) in window.iter().enumerate().skip(1) {
596                    let next = EntangledHVec::new_deterministic(
597                        dim,
598                        seeded(LEVEL_CHAR, n as u64, c as u64),
599                    )
600                    .permute(i);
601                    chunk = chunk.bind(&next);
602                }
603                chunk
604            })
605            .collect();
606        level_bundles.push(encoder.bundle(&ngrams));
607    }
608
609    // Word-level encoding with positional binding.
610    if !words.is_empty() {
611        let word_vecs: Vec<EntangledHVec> = words
612            .iter()
613            .enumerate()
614            .map(|(pos, word)| {
615                let word_hash = hash_str_seed(word, LEVEL_WORD);
616                EntangledHVec::new_deterministic(dim, word_hash).permute(pos)
617            })
618            .collect();
619        level_bundles.push(encoder.bundle(&word_vecs));
620    }
621
622    // Word bigrams for phrase structure.
623    if words.len() >= 2 {
624        let phrase_vecs: Vec<EntangledHVec> = words
625            .windows(2)
626            .enumerate()
627            .map(|(pos, pair)| {
628                let h0 = hash_str_seed(pair[0], LEVEL_PHRASE);
629                let h1 = hash_str_seed(pair[1], LEVEL_PHRASE);
630                let v0 = EntangledHVec::new_deterministic(dim, h0).permute(0);
631                let v1 = EntangledHVec::new_deterministic(dim, h1).permute(1);
632                v0.bind(&v1).permute(pos)
633            })
634            .collect();
635        level_bundles.push(encoder.bundle(&phrase_vecs));
636    }
637
638    if level_bundles.is_empty() {
639        return EntangledHVec::new_deterministic(dim, seeded(LEVEL_CHAR, 1, chars[0] as u64));
640    }
641    combine_levels(&level_bundles, dim)
642}
643
644/// Combine levels by frequency-weighted selection. Each index gets a count of how many level
645/// bundles contain it; indices with cross-level support are prioritized. Output is
646/// fixed-density (dim/256 active indices) to ensure consistent Jaccard comparisons across
647/// different-length texts.
648fn combine_levels(level_bundles: &[EntangledHVec], dim: usize) -> EntangledHVec {
649    let active_count = (dim / DEFAULT_RHO_DENOM).max(1);
650    let mut freq: crate::core::hash::FastMap<u32, u32> =
651        crate::core::hash::FastMap::with_capacity_and_hasher(active_count * 4, Default::default());
652    for bundle in level_bundles {
653        for &idx in bundle.indices() {
654            *freq.entry(idx).or_insert(0) += 1;
655        }
656    }
657
658    let mut scored: Vec<(u32, u32)> = freq.into_iter().collect();
659    if scored.len() > active_count {
660        scored.select_nth_unstable_by(active_count - 1, |a, b| {
661            b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))
662        });
663        scored.truncate(active_count);
664    }
665
666    let mut indices: Vec<u32> = scored.into_iter().map(|(idx, _)| idx).collect();
667    indices.sort_unstable();
668    EntangledHVec::from_indices(indices, dim)
669}
670
671fn seeded(level: u64, scale: u64, item: u64) -> u64 {
672    hash_u64(hash_u64(level, scale), item)
673}
674
675pub(crate) fn hash_str_seed(s: &str, level: u64) -> u64 {
676    let mut h = level;
677    for &byte in s.as_bytes() {
678        h = hash_u64(h, byte as u64);
679    }
680    h
681}
682
683#[cfg(test)]
684mod tests {
685    use super::*;
686
687    #[test]
688    fn words_v2_majority_uses_frequency_then_coordinate_independent_of_input_order() {
689        for dim in [256, 65_537] {
690            for repeated in [false, true] {
691                let target = (dim / DEFAULT_RHO_DENOM).max(1);
692                let mut even: Vec<_> = (0..target + 2).map(|i| (2 * i) as u32).collect();
693                let mut odd: Vec<_> = (0..target + 2).map(|i| (2 * i + 1) as u32).collect();
694                if repeated {
695                    even.push(dim as u32 - 1);
696                    odd.push(dim as u32 - 1);
697                }
698                let first = EntangledHVec::from_indices(even, dim);
699                let second = EntangledHVec::from_indices(odd, dim);
700                let vectors = vec![first, second];
701                let expected = TextEncoder::MultiscaleWordsV2.bundle(&vectors);
702                let mut correct: Vec<_> = (0..target - usize::from(repeated))
703                    .map(|i| i as u32)
704                    .collect();
705                if repeated {
706                    correct.push(dim as u32 - 1);
707                }
708                assert_eq!(expected.indices(), correct);
709                for reverse in [false, true] {
710                    let mut state = EncodeState {
711                        dim,
712                        ..Default::default()
713                    };
714                    state.flat = vectors
715                        .iter()
716                        .flat_map(|v| v.indices().iter().copied())
717                        .collect();
718                    if reverse {
719                        state.flat.reverse();
720                    }
721                    assert_eq!(
722                        state
723                            .bundle_flat(vectors.len(), TextEncoder::MultiscaleWordsV2)
724                            .indices(),
725                        expected.indices()
726                    );
727                }
728            }
729        }
730    }
731
732    #[test]
733    fn test_empty_input() {
734        let v = encode_text_internal("", 16384);
735        assert!(v.indices().is_empty());
736    }
737
738    #[test]
739    fn test_morphological_similarity() {
740        let dim = 16384;
741        let cat = encode_text_internal("cat", dim);
742        let cats = encode_text_internal("cats", dim);
743        let dog = encode_text_internal("dog", dim);
744        let sim_cat_cats = cat.similarity(&cats);
745        let sim_cat_dog = cat.similarity(&dog);
746        assert!(
747            sim_cat_cats > sim_cat_dog,
748            "cat/cats ({:.4}) should be more similar than cat/dog ({:.4})",
749            sim_cat_cats,
750            sim_cat_dog
751        );
752    }
753
754    #[test]
755    fn test_word_identity_matters() {
756        let dim = 16384;
757        let v1 = encode_text_internal("the quick brown fox", dim);
758        let v2 = encode_text_internal("the quick brown fox", dim);
759        let v3 = encode_text_internal("completely different words here", dim);
760        assert!(
761            (v1.similarity(&v2) - 1.0).abs() < f64::EPSILON,
762            "Identical text must match"
763        );
764        assert!(
765            v1.similarity(&v3) < 0.3,
766            "Unrelated text should have low similarity"
767        );
768    }
769
770    #[test]
771    fn test_word_overlap_similarity() {
772        let dim = 16384;
773        let v1 = encode_text_internal("the cat sat on the mat", dim);
774        let v2 = encode_text_internal("the dog sat on the mat", dim);
775        let v3 = encode_text_internal("quantum physics is fascinating", dim);
776        let sim_close = v1.similarity(&v2);
777        let sim_far = v1.similarity(&v3);
778        assert!(
779            sim_close > sim_far,
780            "Sentences sharing most words ({:.4}) should be more similar than unrelated ({:.4})",
781            sim_close,
782            sim_far
783        );
784    }
785
786    #[test]
787    fn test_multi_scale_captures_short_text() {
788        let dim = 16384;
789        assert!(!encode_text_internal("x", dim).indices().is_empty());
790        let v = encode_text_internal("hi", dim);
791        assert!(!v.indices().is_empty());
792        // Should be similar to "his" (shared bigram "hi")
793        let v2 = encode_text_internal("his", dim);
794        let v3 = encode_text_internal("xyz", dim);
795        assert!(
796            v.similarity(&v2) > v.similarity(&v3),
797            "hi/his should be more similar than hi/xyz"
798        );
799    }
800
801    #[test]
802    fn rotation_matches_permute() {
803        for dim in [1usize, 2, 255, 256, 1024] {
804            for seed in 0..32u64 {
805                let base = EntangledHVec::new_deterministic(dim, seed);
806                for shift in 0..dim.min(7) {
807                    let mut out = Vec::new();
808                    rotate_sorted(base.indices(), shift as u32, dim as u32, &mut out);
809                    assert_eq!(
810                        out,
811                        base.permute(shift).indices(),
812                        "dim {dim} shift {shift}"
813                    );
814                }
815            }
816        }
817    }
818
819    #[test]
820    fn memoized_path_matches_reference_at_density_boundaries() {
821        let texts = [
822            "x",
823            "hi",
824            "aa",
825            "aaaa aaaa aaaa",
826            "the cat sat on the mat",
827            "Café records explain backups. Restore deleted documents from verified snapshots.",
828            "ünïcödé text with punctuation!!! and NUMBERS 12345, repeated repeated repeated words",
829            "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",
830        ];
831        for encoder in [
832            TextEncoder::MultiscaleWordsV1,
833            TextEncoder::MultiscaleWordsV2,
834            TextEncoder::MultiscaleCappedUnionV1,
835        ] {
836            // 65535..=65537 straddle SPARSE_COUNTING_MAX_DIM.
837            for dim in [
838                1usize, 2, 3, 255, 256, 257, 1024, 4096, 10_000, 16384, 65535, 65536, 65537,
839            ] {
840                for text in texts {
841                    let reference = encoder.encode_reference(text, dim);
842                    let cached = encoder.encode(text, dim);
843                    assert_eq!(cached.dim, reference.dim);
844                    assert_eq!(
845                        cached.indices(),
846                        reference.indices(),
847                        "{} dim {dim} text {text:?}",
848                        encoder.name()
849                    );
850                    // A second call answers from the memo tables.
851                    assert_eq!(encoder.encode(text, dim).indices(), reference.indices());
852                }
853            }
854        }
855    }
856}