use std::borrow::Cow;
use super::error::TextError;
use super::limits::TextLimits;
fn ngram_count_upper_bound(tokens: usize, n: usize) -> Result<usize, TextError> {
tokens
.checked_mul(n)
.ok_or(TextError::NgramCountOverflow { tokens, n })
}
pub(crate) fn ngram_count(
tokens: usize,
n: usize,
limits: &TextLimits,
) -> Result<usize, TextError> {
limits.check_ngrams(n)?;
ngram_count_upper_bound(tokens, n)?;
let mut total: usize = 0;
for k in 1..=n.min(tokens) {
total = total
.checked_add(tokens - k + 1)
.ok_or(TextError::NgramCountOverflow { tokens, n })?;
}
Ok(total)
}
pub(crate) fn ngrams<'a>(
tokens: &[&'a str],
n: usize,
limits: &TextLimits,
) -> Result<Vec<Cow<'a, str>>, TextError> {
limits.check_ngrams(n)?;
let cap = ngram_count_upper_bound(tokens.len(), n)?;
let t = tokens.len();
let exact = ngram_count(t, n, limits)?;
let mut out: Vec<Cow<'a, str>> = Vec::with_capacity(exact.min(cap));
for k in 1..=n {
for w in tokens.windows(k) {
if k == 1 {
out.push(Cow::Borrowed(w[0]));
} else {
out.push(Cow::Owned(w.join(" ")));
}
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::text::split::{Split, split};
fn run(tokens: &[&str], n: usize) -> Result<Vec<String>, TextError> {
ngrams(tokens, n, &TextLimits::default())
.map(|v| v.into_iter().map(Cow::into_owned).collect())
}
fn ok(tokens: &[&str], n: usize) -> Vec<String> {
run(tokens, n).unwrap_or_default()
}
fn strs(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
#[test]
fn order_and_join() {
assert_eq!(
ok(&["a", "b", "c"], 3),
strs(&["a", "b", "c", "a b", "b c", "a b c"])
);
assert_eq!(ok(&["a", "b"], 1), strs(&["a", "b"]));
}
#[test]
fn fewer_tokens_than_n() {
assert_eq!(ok(&["a", "b"], 3), strs(&["a", "b", "a b"]));
assert_eq!(ok(&["a"], 8), strs(&["a"]));
}
#[test]
fn empty_input() {
assert!(matches!(run(&[], 1), Ok(v) if v.is_empty()));
assert!(matches!(run(&[], 8), Ok(v) if v.is_empty()));
assert!(matches!(
run(&[], 0),
Err(TextError::InvalidNgrams { n: 0, max: 8 })
));
}
#[test]
fn n_boundary() {
assert!(matches!(
run(&["a"], 0),
Err(TextError::InvalidNgrams { n: 0, max: 8 })
));
assert!(matches!(
run(&["a"], 9),
Err(TextError::InvalidNgrams { n: 9, max: 8 })
));
assert!(run(&["a"], 1).is_ok());
assert!(run(&["a"], 8).is_ok());
}
#[test]
fn lowered_limit() {
let Ok(l) = TextLimits::default().with_max_ngrams(2) else {
panic!("with_max_ngrams(2) must succeed");
};
assert!(matches!(
ngrams(&["a", "b", "c"], 3, &l),
Err(TextError::InvalidNgrams { n: 3, max: 2 })
));
assert!(ngrams(&["a", "b", "c"], 2, &l).is_ok());
}
#[test]
fn ngram_count_matches_ngrams() {
let l = TextLimits::default();
for t in 0..=10usize {
let owned: Vec<String> = (0..t).map(|i| format!("w{i}")).collect();
let tokens: Vec<&str> = owned.iter().map(String::as_str).collect();
for n in 1..=8usize {
let Ok(out) = ngrams(&tokens, n, &l) else {
panic!("ngrams must succeed");
};
assert!(matches!(ngram_count(t, n, &l), Ok(c) if c == out.len()));
}
}
assert!(matches!(
ngram_count(1, 0, &l),
Err(TextError::InvalidNgrams { .. })
));
assert!(matches!(
ngram_count(usize::MAX, 2, &l),
Err(TextError::NgramCountOverflow { .. })
));
}
#[test]
fn count_upper_bound() {
assert!(matches!(
ngram_count_upper_bound(usize::MAX, 2),
Err(TextError::NgramCountOverflow { n: 2, .. })
));
assert!(matches!(
ngram_count_upper_bound(usize::MAX, 1),
Ok(usize::MAX)
));
assert!(matches!(ngram_count_upper_bound(0, 8), Ok(0)));
assert!(matches!(ngram_count_upper_bound(3, 3), Ok(9)));
}
#[test]
fn count_identity_and_structure() {
for t in 0..=10usize {
let owned: Vec<String> = (0..t).map(|i| format!("w{i}")).collect();
let tokens: Vec<&str> = owned.iter().map(String::as_str).collect();
for n in 1..=8usize {
let Ok(out) = ngrams(&tokens, n, &TextLimits::default()) else {
panic!("ngrams must succeed for t={t} n={n}");
};
let expected = if t >= n {
n * t - n * (n - 1) / 2
} else {
t * (t + 1) / 2
};
assert_eq!(out.len(), expected, "t={t} n={n}");
assert!(matches!(ngram_count_upper_bound(t, n), Ok(b) if out.len() <= b));
let mut idx = 0;
for k in 1..=n.min(t) {
for _ in 0..(t - k + 1) {
let g = &out[idx];
assert_eq!(g.matches(' ').count(), k - 1);
assert_eq!(k == 1, matches!(g, Cow::Borrowed(_)));
idx += 1;
}
}
}
}
}
#[test]
fn multibyte() {
assert_eq!(
ok(&["日本", "語", "é"], 2),
strs(&["日本", "語", "é", "日本 語", "語 é"])
);
}
#[test]
fn with_split() {
let toks: Vec<&str> = split("a b c", Split::Whitespace).collect();
assert_eq!(ok(&toks, 2), strs(&["a", "b", "c", "a b", "b c"]));
let toks: Vec<&str> = split("", Split::None).collect();
assert_eq!(ok(&toks, 2), strs(&[""]));
}
}