1use 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}