creature_feature 0.2.0

Composable n-gram combinators that are ergonomic and bare-metal fast.
Documentation
//! Regression tests for the 0.2.0 fixes. Each test names the failure it pins.

use creature_feature::convert::Merged;
use creature_feature::featurizers;
use creature_feature::ftzrs::misc::FrontBack;
use creature_feature::ftzrs::{bigram, bislice, bookends, for_each, gap_gram, n_slice, trigram};
use creature_feature::traits::{Ftzr, IterFtzr};
use creature_feature::HashedAs;

/// gap_gram used to panic (slice out of bounds) when the input was shorter
/// than `a.chunk_size() + gap`. It must yield nothing instead.
#[test]
fn gap_gram_short_input_yields_nothing() {
    let g = gap_gram(bislice(), 3, bislice());
    let v: Vec<(&str, &str)> = g.featurize("abc");
    assert!(v.is_empty());
    let v: Vec<(&str, &str)> = g.featurize("");
    assert!(v.is_empty());
    // boundary: exactly one fit — and the clamp must not change correct output
    let v: Vec<(&str, &str)> = g.featurize("abcdefg");
    assert_eq!(v, vec![("ab", "fg")]);
}

/// bookends used to panic when the input was shorter than either window.
/// Now both windows clamp to the available input.
#[test]
fn bookends_short_input_clamps() {
    let b = bookends((bislice(), 4), (trigram(), 4));
    let v: Vec<FrontBack<&str, String>> = b.featurize("ab");
    assert_eq!(v, vec![FrontBack::Front("ab")]);
    let v: Vec<FrontBack<&str, String>> = b.featurize("");
    assert!(v.is_empty());
}

/// ...and the normal case still matches the documented output.
#[test]
fn bookends_normal_case_unchanged() {
    let ftzr = bookends((bislice(), 4), (trigram(), 4));
    let feats: Vec<FrontBack<&str, String>> = ftzr.featurize("sesquipedalian");
    assert_eq!(
        feats,
        vec![
            FrontBack::Front("se"),
            FrontBack::Front("es"),
            FrontBack::Front("sq"),
            FrontBack::Back("lia".to_string()),
            FrontBack::Back("ian".to_string()),
        ]
    );
}

/// ForEach::iterate_features was `unimplemented!()` — a compile-fine runtime
/// trap. It must now agree with the visitor (push_tokens) path.
#[test]
fn for_each_iterftzr_matches_visitor_path() {
    let words = "one fish two fish red fish blue fish";
    let fe = for_each(bislice());
    let via_push: Vec<&str> = fe.featurize(words.split_ascii_whitespace());
    let via_iter: Vec<&str> = fe
        .iterate_features(words.split_ascii_whitespace())
        .map(|g: &[u8]| std::str::from_utf8(g).unwrap())
        .collect();
    assert_eq!(via_push, via_iter);
    assert!(!via_push.is_empty());
}

/// featurizers! used to expand to the hardcoded path
/// `creature_feature::ftzrs::misc::MultiFtzr` instead of `$crate::...`,
/// and its nested expansion had a stray-comma tuple. Exercise 2- and 3-arity.
#[test]
fn featurizers_macro_hygiene() {
    let two = featurizers!(bigram(), bislice());
    let feats: Vec<Merged<String>> = two.featurize("abcd");
    assert_eq!(feats.len(), 6); // 3 bigrams + 3 bislices

    let three = featurizers!(bigram(), bislice(), n_slice(3));
    let feats: Vec<Merged<HashedAs<u64>>> = three.featurize("abcd");
    assert_eq!(feats.len(), 8); // 3 + 3 + 2
}

/// The `Accumulates<&[&str]> for String` impl joined tokens with a
/// double space ("  "); it must be a single space.
#[test]
fn string_accumulator_single_space() {
    let words: Vec<&str> = "a b c".split_ascii_whitespace().collect();
    let s = n_slice(2).featurize::<&[&str], String>(&words[..]);
    assert_eq!(s, "a bb c"); // groups ["a","b"] then ["b","c"], concatenated
}

/// The min-hash accumulator ([HashedAs<u32>; N]) must be deterministic.
#[test]
fn minhash_accumulator_deterministic() {
    let a = bigram().featurize::<[u8; 2], [HashedAs<u32>; 8]>("one fish two fish");
    let b = bigram().featurize::<[u8; 2], [HashedAs<u32>; 8]>("one fish two fish");
    assert_eq!(a, b);
}

/// 0.2.0 AsTokens rewrite: a custom input type, opted in with one `AsTokens`
/// impl, must featurize through both leaf featurizers and combinators — and
/// string types must still expose their bytes (Token = u8), unchanged.
#[test]
fn astokens_custom_type_flows_through_all_featurizers() {
    use creature_feature::traits::AsTokens;

    struct Dna(Vec<u8>);
    impl AsTokens for Dna {
        type Token = u8;
        fn as_tokens(&self) -> &[u8] {
            &self.0
        }
    }

    let dna = Dna(b"ACGTACGT".to_vec());

    // leaf: bislice over the custom type
    let slices: Vec<&[u8]> = bislice().featurize(&dna);
    assert_eq!(slices.len(), 7);

    // combinator: gap_gram over the custom type
    let gaps: Vec<(&[u8], &[u8])> = gap_gram(bislice(), 1, bislice()).featurize(&dna);
    assert_eq!(gaps[0], (&b"AC"[..], &b"TA"[..]));

    // the same featurizer on &str yields the byte view (Token = u8), unchanged
    let via_str: Vec<&[u8]> = bislice().featurize("ACGTACGT");
    assert_eq!(slices, via_str);
}

/// The `heapless` feature provides an `Accumulates` impl for a fixed-capacity,
/// no-alloc `BinaryHeap`. This test only compiles/runs under `--features heapless`;
/// it pins that the feature actually works (compiling is not the same as working).
#[cfg(feature = "heapless")]
#[test]
fn heapless_binary_heap_accumulator() {
    use heapless::binary_heap::{BinaryHeap, Max};

    // 11 chars -> 10 bigrams, well within capacity 16.
    let heap: BinaryHeap<HashedAs<u64>, Max, 16> = bislice().featurize("hello world");
    assert!(!heap.is_empty());
    assert!(heap.len() <= 16);

    // determinism: same input, same multiset of hashes
    let heap2: BinaryHeap<HashedAs<u64>, Max, 16> = bislice().featurize("hello world");
    let mut a: Vec<_> = heap.into_iter().collect();
    let mut b: Vec<_> = heap2.into_iter().collect();
    a.sort();
    b.sort();
    assert_eq!(a, b);
}

/// Ports the char-token exercises from the old `main.rs` (which used the
/// now-deleted `chars_of` helper) as real assertions. Featurization must work
/// over `&[char]` (`Token = char`), not just byte/usize slices — this is the
/// documented "convert to `Vec<char>` for unicode" path.
#[test]
fn char_token_featurization() {
    use creature_feature::ftzrs::{bigram, gap_gram};

    let chars: Vec<char> = "abcde".chars().collect();

    // leaf, owned array tokens: NGram<2> over chars -> [char; 2]
    let arrs: Vec<[char; 2]> = bigram().featurize(&chars);
    assert_eq!(arrs, vec![['a', 'b'], ['b', 'c'], ['c', 'd'], ['d', 'e']]);

    // leaf, borrowed slice tokens: SliceGram over chars -> &[char]
    let slices: Vec<&[char]> = bislice().featurize(&chars);
    assert_eq!(slices.len(), 4);
    assert_eq!(slices[0], &['a', 'b'][..]);

    // owned String output built from char n-grams
    let strs: Vec<String> = bigram().featurize(&chars);
    assert_eq!(strs, vec!["ab", "bc", "cd", "de"]);

    // combinator over chars: array-pair and String-pair outputs
    let g = gap_gram(bigram(), 1, bigram());
    let pairs: Vec<([char; 2], [char; 2])> = g.featurize(&chars);
    assert_eq!(pairs[0], (['a', 'b'], ['d', 'e']));
    let spairs: Vec<(String, String)> = g.featurize(&chars);
    assert_eq!(spairs[0], ("ab".to_string(), "de".to_string()));
}