Skip to main content

fandhe_ai/text/
vectorization.rs

1//! 公開型 `TextVectorization`/`TextVectorizationConfig`(語彙 lookup 型のテキスト変換)。
2//!
3//! 役割: `fandhe_ai::text` の入口。内部部品(`vocab::Vocabulary`・`adapt::adapt`・
4//! `transform::transform`・`limits::TextLimits`)を設定 1 つにまとめて呼ぶだけの薄い
5//! 合成層で、新しいアルゴリズムは持たない(設計記録 §16.4)。呼び出し元は利用者
6//! コードで、出力の `Tensor<i32>` は `Var::embedding(.., Some(0))` へ変換なしで渡せる。
7//!
8//! 構築時検査(設計記録 §8・§16.4): `max_tokens`・`ngrams`・`output_sequence_length`
9//! は `from_vocabulary`/`adapt` の入口で、語彙やコーパスの確保・走査より先に検査し、
10//! 不正なら黙って無視も切り詰めもせず型付きエラーで返す(A03・A04)。
11//! `transform` 側の再検査は多重防御として残る。`Debug` は設定と語彙件数だけを出し、
12//! 語彙の全文は出さない(A09)。
13//!
14//! 出力モード(#3092。決定記録 `docs/facade-text-output-modes-string-lookup-decision.md`
15//! §3・§5): `transform_multi_hot`/`transform_count`/`transform_tf_idf` は
16//! `bag::bag` を呼ぶだけの薄い入口で、`[B, W]` の `Tensor<f32>`(列 i = id i)を返す。
17//! tf_idf の idf 重みは設定(`Eq` を保つため)ではなくインスタンスが持ち、
18//! `from_vocabulary_with_idf`/`adapt_tf_idf` で作ったものだけが `transform_tf_idf` を呼べる。
19
20use std::fmt;
21
22use super::adapt::{adapt, adapt_with_df};
23use super::bag::{BagKind, BagOptions, bag};
24use super::error::TextError;
25use super::limits::TextLimits;
26use super::split::Split;
27use super::standardize::Standardize;
28use super::transform::{TransformOptions, transform};
29use super::vocab::{
30    RESERVED_COUNT, Vocabulary, check_max_tokens, check_vocabulary_within_max_tokens,
31};
32use crate::Tensor;
33
34/// `TextVectorization` の設定。`Default` と `with_*` ビルダで組み立てる。
35///
36/// 既定値は `max_tokens: None`・`standardize: LowerAndStripPunctuation`・
37/// `split: Whitespace`・`ngrams: None`・`output_sequence_length: None`・
38/// `limits: TextLimits::default()`。`Option` のフィールドは `with_*` で `Some` を
39/// 設定する(`None` へ戻すには `Default` かフィールド代入)。値の検査は
40/// ビルダでは行わず、`TextVectorization::from_vocabulary`/`adapt` の構築時に行う。
41///
42/// - `max_tokens`: 予約 2 件を含む語彙数の上限。2 以下は `InvalidMaxTokens`
43/// - `ngrams`: `Some(n)` で 1〜n-gram を作る(`n` は 1 以上かつ `limits` の上限以下)
44/// - `output_sequence_length`: `Some(L)` で `L` へ切り詰め/0 埋め。`None` は
45///   バッチ内の最長に合わせる(出力モード系メソッドでは無視される)
46/// - `pad_to_max_tokens`: 既定 `false`。`true` なら出力モード系メソッドの列数を
47///   `max_tokens` に固定する(`max_tokens` が `None` だと構築時に
48///   `PadToMaxTokensWithoutMaxTokens`)。`false` は語彙数(予約 2 件を含む)
49#[non_exhaustive]
50#[derive(Debug, Clone, PartialEq, Eq, Default)]
51pub struct TextVectorizationConfig {
52    /// 予約 2 件を含む語彙数の上限。`None` は無制限(`limits` の上限は適用される)。
53    pub max_tokens: Option<usize>,
54    /// 標準化の方式(既定は ASCII。Unicode 小文字化は `UnicodeLower` で opt-in)。
55    pub standardize: Standardize,
56    /// 分割の方式(既定は ASCII 空白。Unicode White_Space は `UnicodeWhitespace` で opt-in)。
57    pub split: Split,
58    /// `Some(n)` なら 1〜n-gram を作る。
59    pub ngrams: Option<usize>,
60    /// `Some(L)` なら出力長を `L` に固定する。
61    pub output_sequence_length: Option<usize>,
62    /// 出力モード系メソッドの列数を `max_tokens` へ固定するか(既定 `false`)。
63    pub pad_to_max_tokens: bool,
64    /// 非信頼入力の上限。
65    pub limits: TextLimits,
66}
67
68impl TextVectorizationConfig {
69    /// `max_tokens` に `Some(max_tokens)` を設定する。
70    pub fn with_max_tokens(mut self, max_tokens: usize) -> Self {
71        self.max_tokens = Some(max_tokens);
72        self
73    }
74    /// 標準化の方式を設定する。
75    pub fn with_standardize(mut self, standardize: Standardize) -> Self {
76        self.standardize = standardize;
77        self
78    }
79    /// 分割の方式を設定する。
80    pub fn with_split(mut self, split: Split) -> Self {
81        self.split = split;
82        self
83    }
84    /// `ngrams` に `Some(n)` を設定する。
85    pub fn with_ngrams(mut self, n: usize) -> Self {
86        self.ngrams = Some(n);
87        self
88    }
89    /// `output_sequence_length` に `Some(len)` を設定する。
90    pub fn with_output_sequence_length(mut self, len: usize) -> Self {
91        self.output_sequence_length = Some(len);
92        self
93    }
94    /// `pad_to_max_tokens` を設定する。`true` には `max_tokens` の指定が必要。
95    pub fn with_pad_to_max_tokens(mut self, pad: bool) -> Self {
96        self.pad_to_max_tokens = pad;
97        self
98    }
99    /// 非信頼入力の上限を設定する。
100    pub fn with_limits(mut self, limits: TextLimits) -> Self {
101        self.limits = limits;
102        self
103    }
104}
105
106/// 語彙 lookup 型のテキスト変換(Keras `TextVectorization` 相当)。
107///
108/// 文字列バッチを標準化・分割(任意で n-gram)し、語彙で id 列へ引いて
109/// `[B, L]` の `Tensor<i32>` にする。id は 0 = パディング、1 = OOV、2.. = 語彙。
110/// 語彙は `from_vocabulary`(直接指定)か `adapt`(コーパスから構築)で作る。
111/// 契約の詳細はモジュール doc(`fandhe_ai::text`)を参照。
112#[derive(Clone)]
113pub struct TextVectorization {
114    config: TextVectorizationConfig,
115    vocabulary: Vocabulary,
116    /// id 順の idf 重み(長さ = 語彙数。`idf[0]` = 0.0、`idf[1]` = OOV、`idf[2..]` = 語彙)。
117    /// `from_vocabulary_with_idf`/`adapt_tf_idf` で作ったときだけ `Some`。
118    idf: Option<Vec<f32>>,
119}
120
121/// `pad_to_max_tokens` と `max_tokens` の整合検査(確保・走査より先)。
122fn check_pad(config: &TextVectorizationConfig) -> Result<(), TextError> {
123    if config.pad_to_max_tokens && config.max_tokens.is_none() {
124        return Err(TextError::PadToMaxTokensWithoutMaxTokens);
125    }
126    Ok(())
127}
128
129/// 語彙の idf 重み(長さ = 語彙の個数)から id 順の重みを組む。
130/// `idf[0]` = 0.0、`idf[1]`(OOV)= 語彙の重みの平均(f64 で合計し 1 回だけ f32 へ。空なら 0.0)、
131/// `idf[2..]` = 語彙の重み。`adapt_tf_idf`・`from_vocabulary_with_idf` が共通で使う。
132fn assemble_idf(vocab_weights: &[f32]) -> Vec<f32> {
133    let oov = if vocab_weights.is_empty() {
134        0.0f32
135    } else {
136        let sum: f64 = vocab_weights.iter().map(|&w| f64::from(w)).sum();
137        (sum / vocab_weights.len() as f64) as f32
138    };
139    let mut idf = Vec::with_capacity(vocab_weights.len() + RESERVED_COUNT);
140    idf.push(0.0f32);
141    idf.push(oov);
142    idf.extend_from_slice(vocab_weights);
143    idf
144}
145
146/// `ngrams`・`output_sequence_length` の構築時検査(確保より先)。
147fn check_config_numbers(config: &TextVectorizationConfig) -> Result<(), TextError> {
148    if let Some(n) = config.ngrams {
149        config.limits.check_ngrams(n)?;
150    }
151    if let Some(l) = config.output_sequence_length {
152        config.limits.check_output_sequence_length(l)?;
153    }
154    Ok(())
155}
156
157impl TextVectorization {
158    /// 語彙を直接指定して作る。id は入力順に 2 から振る(0 = `""`、1 = `"[UNK]"`)。
159    ///
160    /// 語彙は標準化されず、lookup はバイト完全一致(大小文字を区別する)。
161    /// `""` は `EmptyVocabularyToken`、`"[UNK]"` は `ReservedVocabularyToken`、
162    /// 重複は `DuplicateVocabularyToken`。`max_tokens` が `Some(m)` のとき、`m` が 2 以下は
163    /// `InvalidMaxTokens`、予約 2 件を含む語彙数が `m` を超えると
164    /// `VocabularyTooLarge`(黙って切り詰めない。確保より先に検査する)。
165    pub fn from_vocabulary<S: AsRef<str>>(
166        config: TextVectorizationConfig,
167        vocabulary: &[S],
168    ) -> Result<Self, TextError> {
169        check_pad(&config)?;
170        check_max_tokens(config.max_tokens)?;
171        check_vocabulary_within_max_tokens(config.max_tokens, vocabulary.len())?;
172        check_config_numbers(&config)?;
173        let vocabulary = Vocabulary::from_tokens(&config.limits, vocabulary)?;
174        Ok(Self {
175            config,
176            vocabulary,
177            idf: None,
178        })
179    }
180
181    /// 語彙と idf 重みを直接指定して作る(`transform_tf_idf` 用)。
182    ///
183    /// `idf_weights` は語彙と同じ順・同じ個数(予約 2 件を含まない)。各値は有限かつ 0 以上
184    /// (`-0.0` は可)。id 順の重みは `idf[0]` = 0.0、`idf[1]`(OOV)= 与えた重みの平均
185    /// (f64 で合計し 1 回だけ f32 へ。語彙が空なら 0.0)、`idf[2..]` = 与えた重み。
186    /// 検査順(確保より先): `pad_to_max_tokens` → `max_tokens`・語彙数・設定値 →
187    /// 個数の一致(`IdfWeightsLengthMismatch`)→ 各値(`InvalidIdfWeight`)→ 語彙。
188    pub fn from_vocabulary_with_idf<S: AsRef<str>>(
189        config: TextVectorizationConfig,
190        vocabulary: &[S],
191        idf_weights: &[f32],
192    ) -> Result<Self, TextError> {
193        check_pad(&config)?;
194        check_max_tokens(config.max_tokens)?;
195        check_vocabulary_within_max_tokens(config.max_tokens, vocabulary.len())?;
196        check_config_numbers(&config)?;
197        if idf_weights.len() != vocabulary.len() {
198            return Err(TextError::IdfWeightsLengthMismatch {
199                len: idf_weights.len(),
200                expected: vocabulary.len(),
201            });
202        }
203        if let Some(index) = idf_weights
204            .iter()
205            .position(|w| !w.is_finite() || w.is_sign_negative() && *w != 0.0)
206        {
207            return Err(TextError::InvalidIdfWeight { index });
208        }
209        let vocabulary = Vocabulary::from_tokens(&config.limits, vocabulary)?;
210        let idf = Some(assemble_idf(idf_weights));
211        Ok(Self {
212            config,
213            vocabulary,
214            idf,
215        })
216    }
217
218    /// コーパスから語彙を構築して作る。
219    ///
220    /// コーパスは設定の標準化・分割(・n-gram)を通して数える。予約トークン
221    /// (`""`・`"[UNK]"`)は数えない。語彙は頻度降順で、同頻度はバイト列の辞書順。
222    /// `max_tokens` が `Some(m)` なら上位 `m - 2` 件で切り詰める。語彙 1 件の
223    /// バイト数は検査しないため、結果を既定の `TextLimits` の `from_vocabulary` へ
224    /// 渡すと読み戻せない場合がある。
225    pub fn adapt<S: AsRef<str>>(
226        config: TextVectorizationConfig,
227        corpus: &[S],
228    ) -> Result<Self, TextError> {
229        check_pad(&config)?;
230        check_max_tokens(config.max_tokens)?;
231        check_config_numbers(&config)?;
232        let vocabulary = adapt(
233            corpus,
234            config.standardize,
235            config.split,
236            config.ngrams,
237            config.max_tokens,
238            &config.limits,
239        )?;
240        Ok(Self {
241            config,
242            vocabulary,
243            idf: None,
244        })
245    }
246
247    /// コーパスから語彙と idf 重みを構築して作る(`transform_tf_idf` 用)。
248    ///
249    /// 語彙は `adapt` と同一。idf は語 t について `ln(1 + N / (1 + df))` を f64 で計算して
250    /// 1 回だけ f32 へ丸める(`N` = コーパスの文書数、`df` = t を含む文書数で、文書内の
251    /// 重複は 1 と数える。自然対数)。`idf[0]` = 0.0、`idf[1]`(OOV)は丸めた後の語彙の idf の
252    /// 平均(f64 で合計し 1 回だけ f32 へ。語彙が空なら 0.0)で、`idf_weights()` の `[2..]`
253    /// を `from_vocabulary_with_idf` へ渡すと同じ重みへ往復できる。
254    pub fn adapt_tf_idf<S: AsRef<str>>(
255        config: TextVectorizationConfig,
256        corpus: &[S],
257    ) -> Result<Self, TextError> {
258        check_pad(&config)?;
259        check_max_tokens(config.max_tokens)?;
260        check_config_numbers(&config)?;
261        let (vocabulary, df) = adapt_with_df(
262            corpus,
263            config.standardize,
264            config.split,
265            config.ngrams,
266            config.max_tokens,
267            &config.limits,
268        )?;
269        // 語彙数の検査は adapt 内で済んでいる。ここで初めて idf を確保する。
270        let n = corpus.len() as f64;
271        let weights: Vec<f32> = df
272            .iter()
273            .map(|&d| (1.0f64 + n / (1.0f64 + d as f64)).ln() as f32)
274            .collect();
275        let idf = Some(assemble_idf(&weights));
276        Ok(Self {
277            config,
278            vocabulary,
279            idf,
280        })
281    }
282
283    /// 文字列バッチを `[B, L]` の `Tensor<i32>` へ変換する。
284    ///
285    /// `L` は `output_sequence_length` が `Some` ならその値、`None` ならバッチ内の
286    /// 最長トークン数。短い行は 0 で埋め、長い行は先頭 `L` 個を残す。語彙にない
287    /// トークンは 1(OOV)。`B = 0` は `[0, L]`(`None` なら `L = 0`)、`Some(0)` は
288    /// `[B, 0]`。`Split::None` と空入力 `""` は id 0(パディングと区別できない)。
289    pub fn transform<S: AsRef<str>>(&self, inputs: &[S]) -> Result<Tensor<i32>, TextError> {
290        let opts = TransformOptions {
291            standardize: self.config.standardize,
292            split: self.config.split,
293            ngrams: self.config.ngrams,
294            output_sequence_length: self.config.output_sequence_length,
295        };
296        transform(&self.vocabulary, &opts, &self.config.limits, inputs)
297    }
298
299    /// 出力モード系メソッドの出力列数 `W`(`pad_to_max_tokens` なら `max_tokens`、
300    /// そうでなければ語彙数)。構築時検査で `pad` なら `max_tokens` は `Some`。
301    fn bag_width(&self) -> usize {
302        match (self.config.pad_to_max_tokens, self.config.max_tokens) {
303            (true, Some(m)) => m,
304            _ => self.vocabulary.vocabulary_size(),
305        }
306    }
307
308    fn bag_options(&self) -> BagOptions {
309        BagOptions {
310            standardize: self.config.standardize,
311            split: self.config.split,
312            ngrams: self.config.ngrams,
313            width: self.bag_width(),
314        }
315    }
316
317    /// 文字列バッチを `[B, W]` の `Tensor<f32>`(出現すれば 1.0・しなければ 0.0)へ変換する。
318    ///
319    /// 列 i が id i に対応する(列 0 = パディングは常に 0、列 1 = OOV)。`W` は
320    /// `pad_to_max_tokens` が false なら `vocabulary_size()`、true なら `max_tokens`
321    /// (`vocabulary_size()` 以上の列は常に 0)。`output_sequence_length` は無視し、
322    /// `ngrams` は適用する。id 0 になるトークン(`Split::None` と `""`)は数えない。
323    /// `B = 0` は `[0, W]`。dtype を f32 にするのは `Linear`/`Sequential` へ直接渡すため。
324    /// 入力の件数・バイト数・出力要素数(`B × W`)は確保より先に検査し、1 行のトークン数が
325    /// 2^24 を超えると `TokenCountNotExact`。Keras と違い `W` は予約 2 件ぶん大きい。
326    pub fn transform_multi_hot<S: AsRef<str>>(
327        &self,
328        inputs: &[S],
329    ) -> Result<Tensor<f32>, TextError> {
330        bag(
331            &self.vocabulary,
332            &self.bag_options(),
333            &self.config.limits,
334            inputs,
335            BagKind::Presence,
336        )
337    }
338
339    /// 文字列バッチを `[B, W]` の `Tensor<f32>`(各 id の出現回数)へ変換する。
340    ///
341    /// 形状・列の意味・検査・`output_sequence_length` の無視は `transform_multi_hot` と同じ。
342    /// 回数は 2^24 以下に限って厳密に数える(超える行は `TokenCountNotExact`)。
343    pub fn transform_count<S: AsRef<str>>(&self, inputs: &[S]) -> Result<Tensor<f32>, TextError> {
344        bag(
345            &self.vocabulary,
346            &self.bag_options(),
347            &self.config.limits,
348            inputs,
349            BagKind::Count,
350        )
351    }
352
353    /// 文字列バッチを `[B, W]` の `Tensor<f32>`(出現回数 × idf 重み)へ変換する。
354    ///
355    /// 値は `count as f32 * idf[id]`(f32 の積 1 回)。idf を持たないインスタンス
356    /// (`from_vocabulary`/`adapt` で作ったもの)は、入力を見る前に `MissingIdfWeights`。
357    /// 形状・列の意味・検査・`output_sequence_length` の無視は `transform_multi_hot` と同じ。
358    /// 出現しない列は計算せず 0 のまま(pad 時の範囲外の列でも idf を引かない)。
359    pub fn transform_tf_idf<S: AsRef<str>>(&self, inputs: &[S]) -> Result<Tensor<f32>, TextError> {
360        let Some(idf) = self.idf.as_deref() else {
361            return Err(TextError::MissingIdfWeights);
362        };
363        bag(
364            &self.vocabulary,
365            &self.bag_options(),
366            &self.config.limits,
367            inputs,
368            BagKind::Weighted(idf),
369        )
370    }
371
372    /// id 順の idf 重み(長さ = `vocabulary_size()`。index 0 = 0.0、1 = OOV、2.. = 語彙)。
373    /// `from_vocabulary`/`adapt` で作ったインスタンスは `None`。
374    pub fn idf_weights(&self) -> Option<&[f32]> {
375        self.idf.as_deref()
376    }
377
378    /// id 順の語彙。index 0 = `""`、1 = `"[UNK]"`、2.. = 語彙。
379    pub fn vocabulary(&self) -> &[String] {
380        self.vocabulary.tokens()
381    }
382
383    /// 語彙数(予約 2 件を含む)。
384    pub fn vocabulary_size(&self) -> usize {
385        self.vocabulary.vocabulary_size()
386    }
387
388    /// 構築時の設定。
389    pub fn config(&self) -> &TextVectorizationConfig {
390        &self.config
391    }
392}
393
394/// 設定と語彙件数だけを出す。語彙の全文は出さない(A09)。
395impl fmt::Debug for TextVectorization {
396    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
397        f.debug_struct("TextVectorization")
398            .field("config", &self.config)
399            .field("vocabulary_size", &self.vocabulary.vocabulary_size())
400            // idf は件数だけ。値は出さない(A09)。
401            .field("idf_weights_len", &self.idf.as_ref().map(Vec::len))
402            .finish()
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    #[test]
411    fn default_config_matches_recorded_defaults() {
412        let c = TextVectorizationConfig::default();
413        assert_eq!(c.max_tokens, None);
414        assert_eq!(c.standardize, Standardize::LowerAndStripPunctuation);
415        assert_eq!(c.split, Split::Whitespace);
416        assert_eq!(c.ngrams, None);
417        assert_eq!(c.output_sequence_length, None);
418        assert_eq!(c.limits, TextLimits::default());
419    }
420
421    #[test]
422    fn builders_set_some_and_fields() {
423        let c = TextVectorizationConfig::default()
424            .with_max_tokens(10)
425            .with_standardize(Standardize::None)
426            .with_split(Split::Character)
427            .with_ngrams(2)
428            .with_output_sequence_length(5);
429        assert_eq!(c.max_tokens, Some(10));
430        assert_eq!(c.standardize, Standardize::None);
431        assert_eq!(c.split, Split::Character);
432        assert_eq!(c.ngrams, Some(2));
433        assert_eq!(c.output_sequence_length, Some(5));
434    }
435
436    #[test]
437    fn from_vocabulary_max_tokens_is_fail_closed() {
438        let cfg = |m| TextVectorizationConfig::default().with_max_tokens(m);
439        let v = ["a", "b", "c"];
440        assert!(matches!(
441            TextVectorization::from_vocabulary(cfg(2), &v),
442            Err(TextError::InvalidMaxTokens { max_tokens: 2 })
443        ));
444        assert!(matches!(
445            TextVectorization::from_vocabulary(cfg(4), &v),
446            Err(TextError::VocabularyTooLarge { len: 5, max: 4 })
447        ));
448        assert_eq!(
449            TextVectorization::from_vocabulary(cfg(5), &v)
450                .unwrap()
451                .vocabulary_size(),
452            5
453        );
454    }
455
456    #[test]
457    fn construction_checks_numbers_before_vocabulary() {
458        let empty: [&str; 0] = [];
459        let bad_n = TextVectorizationConfig::default().with_ngrams(0);
460        assert!(matches!(
461            TextVectorization::from_vocabulary(bad_n.clone(), &empty),
462            Err(TextError::InvalidNgrams { .. })
463        ));
464        assert!(matches!(
465            TextVectorization::adapt(bad_n, &empty),
466            Err(TextError::InvalidNgrams { .. })
467        ));
468        let too_long = TextVectorizationConfig::default().with_output_sequence_length(usize::MAX);
469        assert!(matches!(
470            TextVectorization::from_vocabulary(too_long.clone(), &empty),
471            Err(TextError::OutputSequenceLengthTooLarge { .. })
472        ));
473        assert!(matches!(
474            TextVectorization::adapt(too_long, &empty),
475            Err(TextError::OutputSequenceLengthTooLarge { .. })
476        ));
477    }
478
479    #[test]
480    fn debug_hides_vocabulary_contents() {
481        let tv = TextVectorization::from_vocabulary(
482            TextVectorizationConfig::default(),
483            &["SECRET_MARKER_TOKEN"],
484        )
485        .unwrap();
486        let s = format!("{tv:?}");
487        assert!(!s.contains("SECRET_MARKER_TOKEN"));
488        assert!(s.contains("vocabulary_size"));
489    }
490
491    fn idf_of(n: usize, df: usize) -> f32 {
492        (1.0f64 + n as f64 / (1.0 + df as f64)).ln() as f32
493    }
494
495    fn oov_mean(ws: &[f32]) -> f32 {
496        if ws.is_empty() {
497            return 0.0;
498        }
499        (ws.iter().map(|&w| f64::from(w)).sum::<f64>() / ws.len() as f64) as f32
500    }
501
502    #[test]
503    fn pad_field_defaults_false_and_builder_sets_it() {
504        assert!(!TextVectorizationConfig::default().pad_to_max_tokens);
505        let c = TextVectorizationConfig::default().with_pad_to_max_tokens(true);
506        assert!(c.pad_to_max_tokens);
507    }
508
509    #[test]
510    fn pad_without_max_tokens_is_rejected_by_all_constructors() {
511        let c = TextVectorizationConfig::default().with_pad_to_max_tokens(true);
512        let v = ["a"];
513        assert!(matches!(
514            TextVectorization::from_vocabulary(c.clone(), &v),
515            Err(TextError::PadToMaxTokensWithoutMaxTokens)
516        ));
517        assert!(matches!(
518            TextVectorization::adapt(c.clone(), &v),
519            Err(TextError::PadToMaxTokensWithoutMaxTokens)
520        ));
521        assert!(matches!(
522            TextVectorization::from_vocabulary_with_idf(c.clone(), &v, &[1.0]),
523            Err(TextError::PadToMaxTokensWithoutMaxTokens)
524        ));
525        assert!(matches!(
526            TextVectorization::adapt_tf_idf(c, &v),
527            Err(TextError::PadToMaxTokensWithoutMaxTokens)
528        ));
529    }
530
531    #[test]
532    fn width_follows_vocabulary_or_max_tokens_when_padded() {
533        let cfg = TextVectorizationConfig::default().with_max_tokens(8);
534        let tv = TextVectorization::from_vocabulary(cfg.clone(), &["a", "b"]).unwrap();
535        assert_eq!(tv.transform_count(&["a"]).unwrap().shape(), &[1, 4]);
536        let padded = cfg.with_pad_to_max_tokens(true);
537        let tv = TextVectorization::from_vocabulary(padded, &["a", "b"]).unwrap();
538        let out = tv.transform_count(&["a b b"]).unwrap();
539        assert_eq!(out.shape(), &[1, 8]);
540        assert_eq!(
541            out.host_slice().to_vec(),
542            [0.0, 0.0, 1.0, 2.0, 0.0, 0.0, 0.0, 0.0]
543        );
544        assert_eq!(
545            tv.transform_multi_hot::<&str>(&[]).unwrap().shape(),
546            &[0, 8]
547        );
548    }
549
550    #[test]
551    fn output_sequence_length_is_ignored_by_bag_methods() {
552        let a = TextVectorizationConfig::default();
553        let b = a.clone().with_output_sequence_length(1);
554        let v = ["x", "y"];
555        let ta = TextVectorization::from_vocabulary(a, &v).unwrap();
556        let tb = TextVectorization::from_vocabulary(b, &v).unwrap();
557        let ins = ["x y y z"];
558        assert_eq!(
559            ta.transform_count(&ins).unwrap().host_slice(),
560            tb.transform_count(&ins).unwrap().host_slice()
561        );
562    }
563
564    #[test]
565    fn tf_idf_without_weights_is_missing_even_for_bad_input() {
566        let tv =
567            TextVectorization::from_vocabulary(TextVectorizationConfig::default(), &["a"]).unwrap();
568        assert!(tv.idf_weights().is_none());
569        assert!(matches!(
570            tv.transform_tf_idf(&["a"]),
571            Err(TextError::MissingIdfWeights)
572        ));
573        let tv = TextVectorization::adapt(TextVectorizationConfig::default(), &["a"]).unwrap();
574        assert!(matches!(
575            tv.transform_tf_idf::<&str>(&[]),
576            Err(TextError::MissingIdfWeights)
577        ));
578    }
579
580    #[test]
581    fn idf_weights_are_validated_in_documented_order() {
582        let cfg = TextVectorizationConfig::default;
583        assert!(matches!(
584            TextVectorization::from_vocabulary_with_idf(cfg(), &["a", "b"], &[1.0]),
585            Err(TextError::IdfWeightsLengthMismatch {
586                len: 1,
587                expected: 2
588            })
589        ));
590        for (i, bad) in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -1.0]
591            .into_iter()
592            .enumerate()
593        {
594            let r = TextVectorization::from_vocabulary_with_idf(cfg(), &["a", "b"], &[0.5, bad]);
595            assert!(
596                matches!(r, Err(TextError::InvalidIdfWeight { index: 1 })),
597                "{i}"
598            );
599        }
600        assert!(
601            TextVectorization::from_vocabulary_with_idf(cfg(), &["a", "b"], &[-0.0, 0.0]).is_ok()
602        );
603        // 長さ不一致は値の検査より先、語彙の検査(重複)より先。
604        assert!(matches!(
605            TextVectorization::from_vocabulary_with_idf(cfg(), &["a", "a"], &[f32::NAN]),
606            Err(TextError::IdfWeightsLengthMismatch { .. })
607        ));
608        // 値の検査は語彙の検査(重複)より先。
609        assert!(matches!(
610            TextVectorization::from_vocabulary_with_idf(cfg(), &["a", "a"], &[f32::NAN, 1.0]),
611            Err(TextError::InvalidIdfWeight { index: 0 })
612        ));
613        // 設定側の検査(max_tokens・pad)は idf の検査より先。
614        let small = cfg().with_max_tokens(3);
615        assert!(matches!(
616            TextVectorization::from_vocabulary_with_idf(small, &["a", "b"], &[f32::NAN]),
617            Err(TextError::VocabularyTooLarge { .. })
618        ));
619        let pad = cfg().with_pad_to_max_tokens(true);
620        assert!(matches!(
621            TextVectorization::from_vocabulary_with_idf(pad, &["a"], &[f32::NAN, 1.0]),
622            Err(TextError::PadToMaxTokensWithoutMaxTokens)
623        ));
624    }
625
626    #[test]
627    fn explicit_idf_layout_and_oov_mean() {
628        let tv = TextVectorization::from_vocabulary_with_idf(
629            TextVectorizationConfig::default(),
630            &["a", "b"],
631            &[1.0, 2.0],
632        )
633        .unwrap();
634        assert_eq!(tv.idf_weights().unwrap(), [0.0, 1.5, 1.0, 2.0]);
635        let empty: [&str; 0] = [];
636        let tv = TextVectorization::from_vocabulary_with_idf(
637            TextVectorizationConfig::default(),
638            &empty,
639            &[],
640        )
641        .unwrap();
642        assert_eq!(tv.idf_weights().unwrap(), [0.0, 0.0]);
643    }
644
645    #[test]
646    fn adapt_tf_idf_matches_formula_bit_for_bit_and_round_trips() {
647        let corpus = ["a a b", "a c", "b b b d"];
648        let cfg = TextVectorizationConfig::default();
649        let tv = TextVectorization::adapt_tf_idf(cfg.clone(), &corpus).unwrap();
650        // freq: b=4, a=3, c=1, d=1 -> 並び b, a, c, d。df: b=2, a=2, c=1, d=1。
651        assert_eq!(tv.vocabulary(), ["", "[UNK]", "b", "a", "c", "d"]);
652        let dfs = [2usize, 2, 1, 1];
653        let ws: Vec<f32> = dfs.iter().map(|&d| idf_of(3, d)).collect();
654        let idf = tv.idf_weights().unwrap();
655        assert_eq!(idf.len(), tv.vocabulary_size());
656        assert_eq!(idf[0].to_bits(), 0.0f32.to_bits());
657        assert_eq!(idf[1].to_bits(), oov_mean(&ws).to_bits());
658        for (g, w) in idf[2..].iter().zip(&ws) {
659            assert_eq!(g.to_bits(), w.to_bits());
660        }
661        let out = tv.transform_tf_idf(&["b b a zzz"]).unwrap();
662        let o = out.host_slice();
663        assert_eq!(o[0], 0.0);
664        assert_eq!(o[1].to_bits(), (1.0f32 * oov_mean(&ws)).to_bits());
665        assert_eq!(o[2].to_bits(), (2.0f32 * ws[0]).to_bits());
666        assert_eq!(o[3].to_bits(), (1.0f32 * ws[1]).to_bits());
667        assert_eq!(o[4], 0.0);
668        // 往復: 語彙と idf[2..] から作り直すと idf・出力が bit 一致する。
669        let words: Vec<String> = tv.vocabulary()[2..].to_vec();
670        let back = TextVectorization::from_vocabulary_with_idf(cfg, &words, &idf[2..]).unwrap();
671        let bi = back.idf_weights().unwrap();
672        assert!(idf.iter().zip(bi).all(|(a, b)| a.to_bits() == b.to_bits()));
673        let again = back.transform_tf_idf(&["b b a zzz"]).unwrap();
674        assert!(
675            o.iter()
676                .zip(again.host_slice().iter())
677                .all(|(a, b)| a.to_bits() == b.to_bits())
678        );
679    }
680
681    #[test]
682    fn tf_idf_never_reads_idf_for_pad_columns() {
683        let cfg = TextVectorizationConfig::default()
684            .with_max_tokens(6)
685            .with_pad_to_max_tokens(true);
686        let tv = TextVectorization::from_vocabulary_with_idf(cfg, &["a"], &[2.0]).unwrap();
687        let out = tv.transform_tf_idf(&["a a q"]).unwrap();
688        assert_eq!(out.shape(), &[1, 6]);
689        assert_eq!(out.host_slice().to_vec(), [0.0, 2.0, 4.0, 0.0, 0.0, 0.0]);
690    }
691
692    #[test]
693    fn debug_shows_only_idf_count() {
694        let tv = TextVectorization::from_vocabulary_with_idf(
695            TextVectorizationConfig::default(),
696            &["SECRET_MARKER_TOKEN"],
697            &[0.123_456_79],
698        )
699        .unwrap();
700        let s = format!("{tv:?}");
701        assert!(!s.contains("SECRET_MARKER_TOKEN"));
702        assert!(!s.contains("0.1234"));
703        assert!(s.contains("idf_weights_len: Some(3)"));
704    }
705}