Skip to main content

runmat_runtime/builtins/strings/text_analytics/
encode.rs

1//! Count-matrix encoding for Text Analytics bag models.
2
3use std::collections::{BTreeMap, HashMap, HashSet};
4
5use runmat_builtins::{
6    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
7    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
8    CellArray, ObjectInstance, ResolveContext, SparseTensor, Type, Value,
9};
10use runmat_macros::runtime_builtin;
11
12use crate::builtins::strings::core::compat::scalar_text;
13use crate::builtins::strings::text_analytics::documents::{
14    documents_from_object, vocabulary_from_bag, words_from_word_vector, BAG_OF_WORDS_CLASS,
15    TOKENIZED_DOCUMENT_CLASS,
16};
17use crate::builtins::strings::text_analytics::ngrams::{ngrams_from_bag, BAG_OF_NGRAMS_CLASS};
18use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult};
19
20const OUT_COUNTS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
21    name: "counts",
22    ty: BuiltinParamType::Any,
23    arity: BuiltinParamArity::Required,
24    default: None,
25    description: "Sparse word or n-gram count matrix.",
26}];
27
28const IN_BAG_INPUT_REST: [BuiltinParamDescriptor; 3] = [
29    BuiltinParamDescriptor {
30        name: "bag",
31        ty: BuiltinParamType::Any,
32        arity: BuiltinParamArity::Required,
33        default: None,
34        description: "bagOfWords or bagOfNgrams model.",
35    },
36    BuiltinParamDescriptor {
37        name: "documentsOrWords",
38        ty: BuiltinParamType::Any,
39        arity: BuiltinParamArity::Required,
40        default: None,
41        description: "tokenizedDocument object or row word vector.",
42    },
43    BuiltinParamDescriptor {
44        name: "NameValue",
45        ty: BuiltinParamType::Any,
46        arity: BuiltinParamArity::Variadic,
47        default: None,
48        description: "Name-value options: DocumentsIn, ForceCellOutput.",
49    },
50];
51
52const ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
53    code: "RM.ENCODE.INVALID_INPUT",
54    identifier: Some("RunMat:encode:InvalidInput"),
55    when: "Inputs do not match a supported Text Analytics encode form.",
56    message: "encode: invalid input",
57};
58
59const ERRORS: [BuiltinErrorDescriptor; 1] = [ERROR_INVALID_INPUT];
60
61pub const ENCODE_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
62    signatures: &[BuiltinSignatureDescriptor {
63        label: "counts = encode(bag, documentsOrWords, Name, Value, ...)",
64        inputs: &IN_BAG_INPUT_REST,
65        outputs: &OUT_COUNTS,
66    }],
67    output_mode: BuiltinOutputMode::Fixed,
68    completion_policy: BuiltinCompletionPolicy::Public,
69    errors: &ERRORS,
70};
71
72fn any_type(_args: &[Type], _ctx: &ResolveContext) -> Type {
73    Type::Unknown
74}
75
76fn encode_error(message: impl Into<String>) -> crate::RuntimeError {
77    let mut builder = build_runtime_error(message).with_builtin("encode");
78    if let Some(identifier) = ERROR_INVALID_INPUT.identifier {
79        builder = builder.with_identifier(identifier);
80    }
81    builder.build()
82}
83
84#[runtime_builtin(
85    name = "encode",
86    category = "strings/text_analytics",
87    summary = "Encode documents as sparse word or n-gram count matrices.",
88    keywords = "encode,text analytics,bagOfWords,bagOfNgrams,count matrix",
89    accel = "sink",
90    type_resolver(any_type),
91    descriptor(crate::builtins::strings::text_analytics::encode::ENCODE_DESCRIPTOR),
92    builtin_path = "crate::builtins::strings::text_analytics::encode"
93)]
94async fn encode_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
95    let gathered = gather_args(args).await?;
96    let (bag, input, options) = parse_args(gathered)?;
97    let sparse = match bag {
98        Value::Object(object) if object.is_class(BAG_OF_WORDS_CLASS) => {
99            encode_words(&object, input, options.documents_in)?
100        }
101        Value::Object(object) if object.is_class(BAG_OF_NGRAMS_CLASS) => {
102            encode_ngrams(&object, input, options.documents_in)?
103        }
104        Value::Object(object) => {
105            return Err(encode_error(format!(
106                "encode: expected bagOfWords or bagOfNgrams object, got {}",
107                object.class_name
108            )))
109        }
110        other => {
111            return Err(encode_error(format!(
112                "encode: expected bagOfWords or bagOfNgrams object, got {other:?}"
113            )))
114        }
115    };
116
117    let output = Value::SparseTensor(sparse);
118    if options.force_cell_output {
119        return CellArray::new(vec![output], 1, 1)
120            .map(Value::Cell)
121            .map_err(encode_error);
122    }
123    Ok(output)
124}
125
126async fn gather_args(args: Vec<Value>) -> BuiltinResult<Vec<Value>> {
127    let mut out = Vec::with_capacity(args.len());
128    for arg in args {
129        out.push(
130            gather_if_needed_async(&arg)
131                .await
132                .map_err(|err| encode_error(format!("encode: failed to gather input: {err}")))?,
133        );
134    }
135    Ok(out)
136}
137
138#[derive(Clone, Copy)]
139enum DocumentsIn {
140    Rows,
141    Columns,
142}
143
144struct EncodeOptions {
145    documents_in: DocumentsIn,
146    force_cell_output: bool,
147}
148
149impl Default for EncodeOptions {
150    fn default() -> Self {
151        Self {
152            documents_in: DocumentsIn::Rows,
153            force_cell_output: false,
154        }
155    }
156}
157
158fn parse_args(mut args: Vec<Value>) -> BuiltinResult<(Value, Value, EncodeOptions)> {
159    if args.len() < 2 {
160        return Err(encode_error(
161            "encode: expected bag model and documents or words input",
162        ));
163    }
164    if !(args.len() - 2).is_multiple_of(2) {
165        return Err(encode_error(
166            "encode: name-value options must appear in pairs",
167        ));
168    }
169    let bag = args.remove(0);
170    let input = args.remove(0);
171    let mut options = EncodeOptions::default();
172    let mut idx = 0;
173    while idx < args.len() {
174        let name =
175            scalar_text(&args[idx], "encode").map_err(|err| encode_error(err.to_string()))?;
176        match name.to_ascii_lowercase().as_str() {
177            "documentsin" => {
178                let value = scalar_text(&args[idx + 1], "encode")
179                    .map_err(|err| encode_error(err.to_string()))?;
180                options.documents_in = match value.to_ascii_lowercase().as_str() {
181                    "rows" => DocumentsIn::Rows,
182                    "columns" => DocumentsIn::Columns,
183                    other => {
184                        return Err(encode_error(format!(
185                            "encode: DocumentsIn must be 'rows' or 'columns', got '{other}'"
186                        )))
187                    }
188                };
189            }
190            "forcecelloutput" => {
191                options.force_cell_output = parse_bool_scalar(&args[idx + 1])?;
192            }
193            other => {
194                return Err(encode_error(format!(
195                    "encode: unsupported option '{other}'"
196                )))
197            }
198        }
199        idx += 2;
200    }
201    Ok((bag, input, options))
202}
203
204fn parse_bool_scalar(value: &Value) -> BuiltinResult<bool> {
205    match value {
206        Value::Bool(value) => Ok(*value),
207        Value::LogicalArray(array) if array.data.len() == 1 => Ok(array.data[0] != 0),
208        Value::Num(value) if *value == 0.0 || *value == 1.0 => Ok(*value != 0.0),
209        other => Err(encode_error(format!(
210            "encode: ForceCellOutput must be a logical scalar, got {other:?}"
211        ))),
212    }
213}
214
215fn encode_words(
216    object: &ObjectInstance,
217    input: Value,
218    documents_in: DocumentsIn,
219) -> BuiltinResult<SparseTensor> {
220    let vocabulary = vocabulary_from_bag(object, "encode").map_err(|err| {
221        encode_error(format!(
222            "encode: failed to read bagOfWords Vocabulary property: {err}"
223        ))
224    })?;
225    let documents = documents_from_input(input, "bagOfWords")?;
226    let positions = vocabulary
227        .iter()
228        .enumerate()
229        .map(|(idx, word)| (word.as_str(), idx))
230        .collect::<HashMap<_, _>>();
231    let counts = documents
232        .iter()
233        .map(|document| {
234            let mut row = BTreeMap::new();
235            for token in document {
236                if let Some(&col) = positions.get(token.as_str()) {
237                    *row.entry(col).or_insert(0.0) += 1.0;
238                }
239            }
240            row
241        })
242        .collect::<Vec<_>>();
243    sparse_from_document_counts(counts, vocabulary.len(), documents_in)
244}
245
246fn encode_ngrams(
247    object: &ObjectInstance,
248    input: Value,
249    documents_in: DocumentsIn,
250) -> BuiltinResult<SparseTensor> {
251    let ngrams = ngrams_from_bag(object, "encode").map_err(|err| {
252        encode_error(format!(
253            "encode: failed to read bagOfNgrams Ngrams property: {err}"
254        ))
255    })?;
256    let lengths = unique_ngram_lengths(&ngrams);
257    let documents = documents_from_input(input, "bagOfNgrams")?;
258    let positions = ngrams
259        .iter()
260        .enumerate()
261        .map(|(idx, ngram)| (ngram.as_slice(), idx))
262        .collect::<HashMap<_, _>>();
263    let counts = documents
264        .iter()
265        .map(|document| {
266            let mut row = BTreeMap::new();
267            for &length in &lengths {
268                if length > document.len() {
269                    continue;
270                }
271                for start in 0..=document.len() - length {
272                    let key = &document[start..start + length];
273                    if let Some(&col) = positions.get(key) {
274                        *row.entry(col).or_insert(0.0) += 1.0;
275                    }
276                }
277            }
278            row
279        })
280        .collect::<Vec<_>>();
281    sparse_from_document_counts(counts, ngrams.len(), documents_in)
282}
283
284fn unique_ngram_lengths(ngrams: &[Vec<String>]) -> Vec<usize> {
285    let mut seen = HashSet::new();
286    let mut lengths = Vec::new();
287    for ngram in ngrams {
288        let length = ngram.len();
289        if seen.insert(length) {
290            lengths.push(length);
291        }
292    }
293    lengths
294}
295
296fn documents_from_input(input: Value, model_name: &str) -> BuiltinResult<Vec<Vec<String>>> {
297    match input {
298        Value::Object(object) if object.is_class(TOKENIZED_DOCUMENT_CLASS) => {
299            documents_from_object(&object, "encode").map_err(|err| {
300                encode_error(format!(
301                    "encode: failed to read tokenizedDocument input: {err}"
302                ))
303            })
304        }
305        Value::Object(object) => Err(encode_error(format!(
306            "encode: expected tokenizedDocument or word vector for {model_name}, got {}",
307            object.class_name
308        ))),
309        other => {
310            validate_row_word_vector(&other, model_name)?;
311            Ok(vec![words_from_word_vector(&other, "encode").map_err(
312                |err| encode_error(format!("encode: failed to read word vector input: {err}")),
313            )?])
314        }
315    }
316}
317
318fn validate_row_word_vector(value: &Value, model_name: &str) -> BuiltinResult<()> {
319    match value {
320        Value::String(_) => Ok(()),
321        Value::StringArray(array) if array.rows <= 1 => Ok(()),
322        Value::CharArray(array) if array.rows <= 1 => Ok(()),
323        Value::Cell(cell) if cell.rows <= 1 => Ok(()),
324        Value::StringArray(array) => Err(encode_error(format!(
325            "encode: non-tokenized {model_name} input must be a row word vector; got string array with shape {}x{}",
326            array.rows, array.cols
327        ))),
328        Value::CharArray(array) => Err(encode_error(format!(
329            "encode: non-tokenized {model_name} input must be a row word vector; got char array with shape {}x{}",
330            array.rows, array.cols
331        ))),
332        Value::Cell(cell) => Err(encode_error(format!(
333            "encode: non-tokenized {model_name} input must be a row word vector; got cell array with shape {}x{}",
334            cell.rows, cell.cols
335        ))),
336        other => Err(encode_error(format!(
337            "encode: expected tokenizedDocument or word vector for {model_name}, got {other:?}"
338        ))),
339    }
340}
341
342fn sparse_from_document_counts(
343    counts: Vec<BTreeMap<usize, f64>>,
344    term_count: usize,
345    documents_in: DocumentsIn,
346) -> BuiltinResult<SparseTensor> {
347    match documents_in {
348        DocumentsIn::Rows => sparse_rows(counts, term_count),
349        DocumentsIn::Columns => sparse_columns(counts, term_count),
350    }
351}
352
353fn sparse_rows(
354    counts: Vec<BTreeMap<usize, f64>>,
355    term_count: usize,
356) -> BuiltinResult<SparseTensor> {
357    let rows = counts.len();
358    let cols = term_count;
359    let col_ptr_capacity = cols
360        .checked_add(1)
361        .ok_or_else(|| encode_error("encode: sparse output column count overflows"))?;
362    let mut columns = vec![Vec::<(usize, f64)>::new(); cols];
363    for (doc_idx, doc_counts) in counts.iter().enumerate() {
364        for (&term_idx, &value) in doc_counts {
365            if term_idx >= term_count {
366                return Err(encode_error(
367                    "encode: internal sparse term index exceeds model size",
368                ));
369            }
370            if value != 0.0 {
371                columns[term_idx].push((doc_idx, value));
372            }
373        }
374    }
375    let mut col_ptrs = Vec::with_capacity(col_ptr_capacity);
376    let mut row_indices = Vec::new();
377    let mut values = Vec::new();
378    col_ptrs.push(0);
379    for entries in columns {
380        for (row, value) in entries {
381            row_indices.push(row);
382            values.push(value);
383        }
384        col_ptrs.push(values.len());
385    }
386    SparseTensor::new(rows, cols, col_ptrs, row_indices, values).map_err(encode_error)
387}
388
389fn sparse_columns(
390    counts: Vec<BTreeMap<usize, f64>>,
391    term_count: usize,
392) -> BuiltinResult<SparseTensor> {
393    let rows = term_count;
394    let cols = counts.len();
395    let col_ptr_capacity = cols
396        .checked_add(1)
397        .ok_or_else(|| encode_error("encode: sparse output column count overflows"))?;
398    let mut col_ptrs = Vec::with_capacity(col_ptr_capacity);
399    let mut row_indices = Vec::new();
400    let mut values = Vec::new();
401    col_ptrs.push(0);
402    for doc_counts in &counts {
403        for (&row, &value) in doc_counts {
404            if row >= term_count {
405                return Err(encode_error(
406                    "encode: internal sparse term index exceeds model size",
407                ));
408            }
409            if value != 0.0 {
410                row_indices.push(row);
411                values.push(value);
412            }
413        }
414        col_ptrs.push(values.len());
415    }
416    SparseTensor::new(rows, cols, col_ptrs, row_indices, values).map_err(encode_error)
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422    use runmat_builtins::{StringArray, Tensor};
423
424    fn run_encode(args: Vec<Value>) -> BuiltinResult<Value> {
425        futures::executor::block_on(encode_builtin(args))
426    }
427
428    fn sparse(value: Value) -> SparseTensor {
429        match value {
430            Value::SparseTensor(sparse) => sparse,
431            other => panic!("expected sparse tensor, got {other:?}"),
432        }
433    }
434
435    fn string_array(values: &[&str], rows: usize, cols: usize) -> Value {
436        Value::StringArray(
437            StringArray::new(
438                values.iter().map(|value| (*value).to_string()).collect(),
439                vec![rows, cols],
440            )
441            .expect("string array"),
442        )
443    }
444
445    fn tokenized(docs: &[&[&str]]) -> Value {
446        let mut data = Vec::with_capacity(docs.len());
447        for doc in docs {
448            let row = doc
449                .iter()
450                .map(|token| Value::from(*token))
451                .collect::<Vec<_>>();
452            data.push(Value::Cell(
453                CellArray::new(row, 1, doc.len()).expect("row cell"),
454            ));
455        }
456        let mut object = ObjectInstance::new(TOKENIZED_DOCUMENT_CLASS.to_string());
457        object.properties.insert(
458            "Documents".to_string(),
459            Value::Cell(CellArray::new(data, docs.len(), 1).expect("documents cell")),
460        );
461        object
462            .properties
463            .insert("NumDocuments".to_string(), Value::Num(docs.len() as f64));
464        Value::Object(object)
465    }
466
467    fn bag_of_words(vocabulary: &[&str]) -> Value {
468        let mut object = ObjectInstance::new(BAG_OF_WORDS_CLASS.to_string());
469        object.properties.insert(
470            "Vocabulary".to_string(),
471            string_array(vocabulary, 1, vocabulary.len()),
472        );
473        object.properties.insert(
474            "Counts".to_string(),
475            Value::Tensor(Tensor::zeros(vec![0, vocabulary.len()])),
476        );
477        object
478            .properties
479            .insert("NumWords".to_string(), Value::Num(vocabulary.len() as f64));
480        object
481            .properties
482            .insert("NumDocuments".to_string(), Value::Num(0.0));
483        Value::Object(object)
484    }
485
486    fn bag_of_ngrams(ngrams: &[&[&str]], lengths: &[usize]) -> Value {
487        let rows = ngrams.len();
488        let cols = ngrams.iter().map(|ngram| ngram.len()).max().unwrap_or(0);
489        let mut data = Vec::with_capacity(rows * cols);
490        for col in 0..cols {
491            for ngram in ngrams {
492                data.push(ngram.get(col).copied().unwrap_or_default().to_string());
493            }
494        }
495        let mut object = ObjectInstance::new(BAG_OF_NGRAMS_CLASS.to_string());
496        object.properties.insert(
497            "Ngrams".to_string(),
498            Value::StringArray(StringArray::new(data, vec![rows, cols]).expect("ngrams")),
499        );
500        object.properties.insert(
501            "NgramLengths".to_string(),
502            Value::Tensor(
503                Tensor::new(
504                    lengths.iter().map(|length| *length as f64).collect(),
505                    vec![1, lengths.len()],
506                )
507                .expect("lengths"),
508            ),
509        );
510        object.properties.insert(
511            "Counts".to_string(),
512            Value::Tensor(Tensor::zeros(vec![0, ngrams.len()])),
513        );
514        object
515            .properties
516            .insert("NumNgrams".to_string(), Value::Num(ngrams.len() as f64));
517        object
518            .properties
519            .insert("NumDocuments".to_string(), Value::Num(0.0));
520        Value::Object(object)
521    }
522
523    #[test]
524    fn encodes_tokenized_documents_against_bag_of_words_rows() {
525        let bag = bag_of_words(&["alpha", "beta", "gamma"]);
526        let docs = tokenized(&[&["beta", "beta", "delta"], &["alpha", "gamma"]]);
527
528        let out = sparse(run_encode(vec![bag, docs]).expect("encode"));
529        assert_eq!((out.rows, out.cols), (2, 3));
530        let dense = out.to_dense().unwrap();
531        assert_eq!(dense.shape, vec![2, 3]);
532        assert_eq!(dense.data, vec![0.0, 1.0, 2.0, 0.0, 0.0, 1.0]);
533    }
534
535    #[test]
536    fn encodes_word_vector_with_documents_in_columns() {
537        let bag = bag_of_words(&["alpha", "beta", "gamma"]);
538
539        let out = sparse(
540            run_encode(vec![
541                bag,
542                string_array(&["beta", "gamma", "beta"], 1, 3),
543                Value::from("DocumentsIn"),
544                Value::from("columns"),
545            ])
546            .expect("encode"),
547        );
548        assert_eq!((out.rows, out.cols), (3, 1));
549        let dense = out.to_dense().unwrap();
550        assert_eq!(dense.shape, vec![3, 1]);
551        assert_eq!(dense.data, vec![0.0, 2.0, 1.0]);
552    }
553
554    #[test]
555    fn encodes_multiple_documents_in_columns() {
556        let bag = bag_of_words(&["alpha", "beta", "gamma"]);
557        let docs = tokenized(&[&["beta", "beta", "delta"], &["alpha", "gamma"]]);
558
559        let out = sparse(
560            run_encode(vec![
561                bag,
562                docs,
563                Value::from("DocumentsIn"),
564                Value::from("columns"),
565            ])
566            .expect("encode"),
567        );
568        assert_eq!((out.rows, out.cols), (3, 2));
569        let dense = out.to_dense().unwrap();
570        assert_eq!(dense.shape, vec![3, 2]);
571        assert_eq!(dense.data, vec![0.0, 2.0, 0.0, 1.0, 0.0, 1.0]);
572    }
573
574    #[test]
575    fn returns_sparse_zeros_for_empty_bag_and_unknown_terms() {
576        let empty = sparse(run_encode(vec![bag_of_words(&[]), tokenized(&[&["alpha"]])]).unwrap());
577        assert_eq!((empty.rows, empty.cols), (1, 0));
578        assert_eq!(empty.col_ptrs, vec![0]);
579        assert!(empty.row_indices.is_empty());
580        assert!(empty.values.is_empty());
581
582        let unknown = sparse(
583            run_encode(vec![bag_of_words(&["alpha", "beta"]), Value::from("gamma")]).unwrap(),
584        );
585        assert_eq!((unknown.rows, unknown.cols), (1, 2));
586        assert_eq!(unknown.col_ptrs, vec![0, 0, 0]);
587        assert!(unknown.row_indices.is_empty());
588        assert!(unknown.values.is_empty());
589    }
590
591    #[test]
592    fn force_cell_output_wraps_sparse_result() {
593        let bag = bag_of_words(&["alpha", "beta"]);
594        let out = run_encode(vec![
595            bag,
596            Value::from("alpha"),
597            Value::from("ForceCellOutput"),
598            Value::Bool(true),
599        ])
600        .expect("encode");
601        let Value::Cell(cell) = out else {
602            panic!("expected cell");
603        };
604        assert_eq!((cell.rows, cell.cols), (1, 1));
605        let Value::SparseTensor(sparse) = &cell.data[0] else {
606            panic!("expected sparse cell element");
607        };
608        assert_eq!((sparse.rows, sparse.cols), (1, 2));
609        let dense = sparse.to_dense().unwrap();
610        assert_eq!(dense.shape, vec![1, 2]);
611        assert_eq!(dense.data, vec![1.0, 0.0]);
612    }
613
614    #[test]
615    fn encodes_bag_of_ngrams_documents() {
616        let bag = bag_of_ngrams(&[&["a"], &["b"], &["a", "b"], &["b", "a"]], &[1, 2]);
617        let docs = tokenized(&[&["a", "b", "a", "b"]]);
618
619        let out = sparse(run_encode(vec![bag, docs]).expect("encode"));
620        assert_eq!(out.rows, 1);
621        assert_eq!(out.cols, 4);
622        let dense = out.to_dense().unwrap();
623        assert_eq!(dense.shape, vec![1, 4]);
624        assert_eq!(dense.data, vec![2.0, 2.0, 2.0, 1.0]);
625    }
626
627    #[test]
628    fn rejects_malformed_bag_of_ngrams_metadata() {
629        let err = run_encode(vec![bag_of_ngrams(&[&[]], &[]), tokenized(&[&["a"]])])
630            .expect_err("expected empty ngram rejection");
631        assert!(err.to_string().contains("empty n-gram"));
632
633        let err = run_encode(vec![
634            bag_of_ngrams(&[&["a"], &["a"]], &[1]),
635            tokenized(&[&["a"]]),
636        ])
637        .expect_err("expected duplicate ngram rejection");
638        assert!(err.to_string().contains("duplicate n-gram"));
639    }
640
641    #[test]
642    fn rejects_bad_options_and_column_word_vectors() {
643        let bag = bag_of_words(&["alpha"]);
644        let err = run_encode(vec![
645            bag.clone(),
646            Value::from("alpha"),
647            Value::from("DocumentsIn"),
648            Value::from("pages"),
649        ])
650        .expect_err("expected bad option");
651        assert!(err.to_string().contains("DocumentsIn"));
652
653        let err = run_encode(vec![bag, string_array(&["alpha", "beta"], 2, 1)])
654            .expect_err("expected column rejection");
655        assert!(err.to_string().contains("row word vector"));
656    }
657
658    #[test]
659    fn rejects_invalid_force_cell_output_and_odd_options() {
660        let bag = bag_of_words(&["alpha"]);
661        let err = run_encode(vec![
662            bag.clone(),
663            Value::from("alpha"),
664            Value::from("ForceCellOutput"),
665            Value::from("yes"),
666        ])
667        .expect_err("expected invalid force cell output");
668        assert!(err.to_string().contains("ForceCellOutput"));
669
670        let err = run_encode(vec![bag, Value::from("alpha"), Value::from("DocumentsIn")])
671            .expect_err("expected odd options rejection");
672        assert!(err.to_string().contains("name-value options"));
673    }
674}