1use std::cell::Cell;
4use std::collections::{BTreeMap, HashMap, HashSet};
5
6use once_cell::sync::Lazy;
7use regex::Regex;
8use runmat_builtins::{
9 Access, BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
10 BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
11 CellArray, ClassDef, LogicalArray, ObjectInstance, PropertyDef, ResolveContext, StringArray,
12 Tensor, Type, Value,
13};
14use runmat_macros::runtime_builtin;
15
16use crate::builtins::strings::common::{char_row_to_string_slice, is_missing_string};
17use crate::builtins::strings::core::compat::scalar_text;
18use crate::builtins::strings::text_analytics::stopwords::{
19 stop_words_for_language, StopWordsLanguage,
20};
21use crate::builtins::table::table_variables;
22use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult};
23
24pub const TOKENIZED_DOCUMENT_CLASS: &str = "tokenizedDocument";
25pub const BAG_OF_WORDS_CLASS: &str = "bagOfWords";
26const MAX_DENSE_BAG_COUNT_CELLS: usize = 50_000_000;
27
28thread_local! {
29 static TOKENIZED_DOCUMENT_CLASS_REGISTERED: Cell<bool> = const { Cell::new(false) };
30 static BAG_OF_WORDS_CLASS_REGISTERED: Cell<bool> = const { Cell::new(false) };
31}
32
33const OUT_DOCUMENTS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
34 name: "documents",
35 ty: BuiltinParamType::Any,
36 arity: BuiltinParamArity::Required,
37 default: None,
38 description: "Tokenized document object.",
39}];
40
41const OUT_BAG: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
42 name: "bag",
43 ty: BuiltinParamType::Any,
44 arity: BuiltinParamArity::Required,
45 default: None,
46 description: "Bag-of-words model object.",
47}];
48
49const OUT_DOCUMENTS_OR_BAG: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
50 name: "newDocumentsOrBag",
51 ty: BuiltinParamType::Any,
52 arity: BuiltinParamArity::Required,
53 default: None,
54 description: "Filtered tokenizedDocument or bagOfWords object.",
55}];
56
57const OUT_DOCUMENTS_FILTERED: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
58 name: "newDocuments",
59 ty: BuiltinParamType::Any,
60 arity: BuiltinParamArity::Required,
61 default: None,
62 description: "Filtered tokenizedDocument object.",
63}];
64
65const IN_TEXT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
66 name: "str",
67 ty: BuiltinParamType::Any,
68 arity: BuiltinParamArity::Required,
69 default: None,
70 description: "Input text or pre-tokenized words.",
71}];
72
73const IN_TEXT_REST: [BuiltinParamDescriptor; 2] = [
74 BuiltinParamDescriptor {
75 name: "str",
76 ty: BuiltinParamType::Any,
77 arity: BuiltinParamArity::Required,
78 default: None,
79 description: "Input text or pre-tokenized words.",
80 },
81 BuiltinParamDescriptor {
82 name: "NameValue",
83 ty: BuiltinParamType::Any,
84 arity: BuiltinParamArity::Variadic,
85 default: None,
86 description:
87 "Name-value options: TokenizeMethod, Language, DetectPatterns, TopLevelDomains, CustomTokens, RegularExpressions.",
88 },
89];
90
91const IN_DOCUMENTS_OR_WORDS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
92 name: "documentsOrWords",
93 ty: BuiltinParamType::Any,
94 arity: BuiltinParamArity::Required,
95 default: None,
96 description: "Tokenized documents, word vector, or unique vocabulary.",
97}];
98
99const IN_WORDS_COUNTS: [BuiltinParamDescriptor; 2] = [
100 BuiltinParamDescriptor {
101 name: "uniqueWords",
102 ty: BuiltinParamType::Any,
103 arity: BuiltinParamArity::Required,
104 default: None,
105 description: "Unique words.",
106 },
107 BuiltinParamDescriptor {
108 name: "counts",
109 ty: BuiltinParamType::Any,
110 arity: BuiltinParamArity::Required,
111 default: None,
112 description: "Word counts per document.",
113 },
114];
115
116const IN_REMOVE_SHORT: [BuiltinParamDescriptor; 2] = [
117 BuiltinParamDescriptor {
118 name: "documentsOrBag",
119 ty: BuiltinParamType::Any,
120 arity: BuiltinParamArity::Required,
121 default: None,
122 description: "tokenizedDocument or bagOfWords object.",
123 },
124 BuiltinParamDescriptor {
125 name: "len",
126 ty: BuiltinParamType::NumericScalar,
127 arity: BuiltinParamArity::Required,
128 default: None,
129 description: "Maximum word length to remove.",
130 },
131];
132
133const IN_REMOVE_LONG: [BuiltinParamDescriptor; 2] = [
134 BuiltinParamDescriptor {
135 name: "documentsOrBag",
136 ty: BuiltinParamType::Any,
137 arity: BuiltinParamArity::Required,
138 default: None,
139 description: "tokenizedDocument or bagOfWords object.",
140 },
141 BuiltinParamDescriptor {
142 name: "len",
143 ty: BuiltinParamType::NumericScalar,
144 arity: BuiltinParamArity::Required,
145 default: None,
146 description: "Minimum word length to remove.",
147 },
148];
149
150const IN_REMOVE_WORDS: [BuiltinParamDescriptor; 3] = [
151 BuiltinParamDescriptor {
152 name: "documentsOrBag",
153 ty: BuiltinParamType::Any,
154 arity: BuiltinParamArity::Required,
155 default: None,
156 description: "tokenizedDocument or bagOfWords object.",
157 },
158 BuiltinParamDescriptor {
159 name: "wordsOrIdx",
160 ty: BuiltinParamType::Any,
161 arity: BuiltinParamArity::Required,
162 default: None,
163 description: "Words to remove or indices into the object's Vocabulary.",
164 },
165 BuiltinParamDescriptor {
166 name: "NameValue",
167 ty: BuiltinParamType::Any,
168 arity: BuiltinParamArity::Variadic,
169 default: None,
170 description: "Name-value options: IgnoreCase.",
171 },
172];
173
174const IN_REMOVE_STOP: [BuiltinParamDescriptor; 2] = [
175 BuiltinParamDescriptor {
176 name: "documents",
177 ty: BuiltinParamType::Any,
178 arity: BuiltinParamArity::Required,
179 default: None,
180 description: "tokenizedDocument object.",
181 },
182 BuiltinParamDescriptor {
183 name: "NameValue",
184 ty: BuiltinParamType::Any,
185 arity: BuiltinParamArity::Variadic,
186 default: None,
187 description: "Name-value options: IgnoreCase.",
188 },
189];
190
191const IN_REMOVE_STOP_DOCUMENTS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
192 name: "documents",
193 ty: BuiltinParamType::Any,
194 arity: BuiltinParamArity::Required,
195 default: None,
196 description: "tokenizedDocument object.",
197}];
198
199const ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
200 code: "RM.TEXT_ANALYTICS_DOCUMENTS.INVALID_INPUT",
201 identifier: Some("RunMat:textAnalyticsDocuments:InvalidInput"),
202 when: "Inputs do not match a supported Text Analytics document or model helper form.",
203 message: "Text Analytics document helper received invalid input",
204};
205
206const ERRORS: [BuiltinErrorDescriptor; 1] = [ERROR_INVALID_INPUT];
207
208const ERROR_REMOVE_LONG_WORDS_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
209 code: "RM.REMOVELONGWORDS.INVALID_INPUT",
210 identifier: Some("RunMat:removeLongWords:InvalidInput"),
211 when: "Inputs do not match a supported removeLongWords form.",
212 message: "removeLongWords: invalid input",
213};
214
215const REMOVE_LONG_WORDS_ERRORS: [BuiltinErrorDescriptor; 1] =
216 [ERROR_REMOVE_LONG_WORDS_INVALID_INPUT];
217
218const ERROR_REMOVE_WORDS_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
219 code: "RM.REMOVEWORDS.INVALID_INPUT",
220 identifier: Some("RunMat:removeWords:InvalidInput"),
221 when: "Inputs do not match a supported removeWords form.",
222 message: "removeWords: invalid input",
223};
224
225const REMOVE_WORDS_ERRORS: [BuiltinErrorDescriptor; 1] = [ERROR_REMOVE_WORDS_INVALID_INPUT];
226
227const ERROR_REMOVE_STOP_WORDS_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
228 code: "RM.REMOVESTOPWORDS.INVALID_INPUT",
229 identifier: Some("RunMat:removeStopWords:InvalidInput"),
230 when: "Inputs do not match a supported removeStopWords form.",
231 message: "removeStopWords: invalid input",
232};
233
234const REMOVE_STOP_WORDS_ERRORS: [BuiltinErrorDescriptor; 1] =
235 [ERROR_REMOVE_STOP_WORDS_INVALID_INPUT];
236
237const ERROR_TOKEN_DETAILS_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
238 code: "RM.TOKENDETAILS.INVALID_INPUT",
239 identifier: Some("RunMat:tokenDetails:InvalidInput"),
240 when: "Inputs do not match a supported tokenDetails form.",
241 message: "tokenDetails received invalid input",
242};
243const ERROR_ADD_TYPE_DETAILS_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
244 code: "RM.ADDTYPEDETAILS.INVALID_INPUT",
245 identifier: Some("RunMat:addTypeDetails:InvalidInput"),
246 when: "Inputs do not match a supported addTypeDetails form.",
247 message: "addTypeDetails received invalid input",
248};
249const ERROR_ADD_SENTENCE_DETAILS_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
250 code: "RM.ADDSENTENCEDETAILS.INVALID_INPUT",
251 identifier: Some("RunMat:addSentenceDetails:InvalidInput"),
252 when: "Inputs do not match a supported addSentenceDetails form.",
253 message: "addSentenceDetails received invalid input",
254};
255const ERROR_ADD_LEMMA_DETAILS_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
256 code: "RM.ADDLEMMADETAILS.INVALID_INPUT",
257 identifier: Some("RunMat:addLemmaDetails:InvalidInput"),
258 when: "Inputs do not match a supported addLemmaDetails form.",
259 message: "addLemmaDetails received invalid input",
260};
261const ERROR_ADD_PART_OF_SPEECH_DETAILS_INVALID_INPUT: BuiltinErrorDescriptor =
262 BuiltinErrorDescriptor {
263 code: "RM.ADDPARTOFSPEECHDETAILS.INVALID_INPUT",
264 identifier: Some("RunMat:addPartOfSpeechDetails:InvalidInput"),
265 when: "Inputs do not match a supported addPartOfSpeechDetails form.",
266 message: "addPartOfSpeechDetails received invalid input",
267 };
268const ERROR_ADD_ENTITY_DETAILS_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
269 code: "RM.ADDENTITYDETAILS.INVALID_INPUT",
270 identifier: Some("RunMat:addEntityDetails:InvalidInput"),
271 when: "Inputs do not match a supported addEntityDetails form.",
272 message: "addEntityDetails received invalid input",
273};
274const ERROR_ADD_DEPENDENCY_DETAILS_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
275 code: "RM.ADDDEPENDENCYDETAILS.INVALID_INPUT",
276 identifier: Some("RunMat:addDependencyDetails:InvalidInput"),
277 when: "Inputs do not match a supported addDependencyDetails form.",
278 message: "addDependencyDetails received invalid input",
279};
280const ERROR_VADER_SENTIMENT_SCORES_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
281 code: "RM.VADERSENTIMENTSCORES.INVALID_INPUT",
282 identifier: Some("RunMat:vaderSentimentScores:InvalidInput"),
283 when: "Inputs do not match a supported vaderSentimentScores form.",
284 message: "vaderSentimentScores received invalid input",
285};
286
287pub const TOKENIZED_DOCUMENT_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
288 signatures: &[
289 BuiltinSignatureDescriptor {
290 label: "documents = tokenizedDocument",
291 inputs: &[],
292 outputs: &OUT_DOCUMENTS,
293 },
294 BuiltinSignatureDescriptor {
295 label: "documents = tokenizedDocument(str)",
296 inputs: &IN_TEXT,
297 outputs: &OUT_DOCUMENTS,
298 },
299 BuiltinSignatureDescriptor {
300 label: "documents = tokenizedDocument(str, Name, Value, ...)",
301 inputs: &IN_TEXT_REST,
302 outputs: &OUT_DOCUMENTS,
303 },
304 ],
305 output_mode: BuiltinOutputMode::Fixed,
306 completion_policy: BuiltinCompletionPolicy::Public,
307 errors: &ERRORS,
308};
309
310pub const BAG_OF_WORDS_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
311 signatures: &[
312 BuiltinSignatureDescriptor {
313 label: "bag = bagOfWords",
314 inputs: &[],
315 outputs: &OUT_BAG,
316 },
317 BuiltinSignatureDescriptor {
318 label: "bag = bagOfWords(documents)",
319 inputs: &IN_DOCUMENTS_OR_WORDS,
320 outputs: &OUT_BAG,
321 },
322 BuiltinSignatureDescriptor {
323 label: "bag = bagOfWords(uniqueWords, counts)",
324 inputs: &IN_WORDS_COUNTS,
325 outputs: &OUT_BAG,
326 },
327 ],
328 output_mode: BuiltinOutputMode::Fixed,
329 completion_policy: BuiltinCompletionPolicy::Public,
330 errors: &ERRORS,
331};
332
333pub const REMOVE_SHORT_WORDS_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
334 signatures: &[BuiltinSignatureDescriptor {
335 label: "newDocumentsOrBag = removeShortWords(documentsOrBag, len)",
336 inputs: &IN_REMOVE_SHORT,
337 outputs: &OUT_DOCUMENTS_OR_BAG,
338 }],
339 output_mode: BuiltinOutputMode::Fixed,
340 completion_policy: BuiltinCompletionPolicy::Public,
341 errors: &ERRORS,
342};
343
344pub const REMOVE_LONG_WORDS_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
345 signatures: &[BuiltinSignatureDescriptor {
346 label: "newDocumentsOrBag = removeLongWords(documentsOrBag, len)",
347 inputs: &IN_REMOVE_LONG,
348 outputs: &OUT_DOCUMENTS_OR_BAG,
349 }],
350 output_mode: BuiltinOutputMode::Fixed,
351 completion_policy: BuiltinCompletionPolicy::Public,
352 errors: &REMOVE_LONG_WORDS_ERRORS,
353};
354
355pub const REMOVE_WORDS_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
356 signatures: &[
357 BuiltinSignatureDescriptor {
358 label: "newDocumentsOrBag = removeWords(documentsOrBag, words)",
359 inputs: &IN_REMOVE_WORDS,
360 outputs: &OUT_DOCUMENTS_OR_BAG,
361 },
362 BuiltinSignatureDescriptor {
363 label: "newDocumentsOrBag = removeWords(documentsOrBag, idx)",
364 inputs: &IN_REMOVE_WORDS,
365 outputs: &OUT_DOCUMENTS_OR_BAG,
366 },
367 BuiltinSignatureDescriptor {
368 label: "newDocumentsOrBag = removeWords(___, 'IgnoreCase', tf)",
369 inputs: &IN_REMOVE_WORDS,
370 outputs: &OUT_DOCUMENTS_OR_BAG,
371 },
372 ],
373 output_mode: BuiltinOutputMode::Fixed,
374 completion_policy: BuiltinCompletionPolicy::Public,
375 errors: &REMOVE_WORDS_ERRORS,
376};
377
378pub const REMOVE_STOP_WORDS_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
379 signatures: &[
380 BuiltinSignatureDescriptor {
381 label: "newDocuments = removeStopWords(documents)",
382 inputs: &IN_REMOVE_STOP_DOCUMENTS,
383 outputs: &OUT_DOCUMENTS_FILTERED,
384 },
385 BuiltinSignatureDescriptor {
386 label: "newDocuments = removeStopWords(documents, Name, Value, ...)",
387 inputs: &IN_REMOVE_STOP,
388 outputs: &OUT_DOCUMENTS_FILTERED,
389 },
390 ],
391 output_mode: BuiltinOutputMode::Fixed,
392 completion_policy: BuiltinCompletionPolicy::Public,
393 errors: &REMOVE_STOP_WORDS_ERRORS,
394};
395
396fn any_type(_args: &[Type], _ctx: &ResolveContext) -> Type {
397 Type::Unknown
398}
399
400pub(in crate::builtins::strings::text_analytics) fn text_analytics_error(
401 fn_name: &str,
402 message: impl Into<String>,
403) -> crate::RuntimeError {
404 let descriptor = match fn_name {
405 "removeStopWords" => ERROR_REMOVE_STOP_WORDS_INVALID_INPUT,
406 "removeWords" => ERROR_REMOVE_WORDS_INVALID_INPUT,
407 "removeLongWords" => ERROR_REMOVE_LONG_WORDS_INVALID_INPUT,
408 "tokenDetails" => ERROR_TOKEN_DETAILS_INVALID_INPUT,
409 "addTypeDetails" => ERROR_ADD_TYPE_DETAILS_INVALID_INPUT,
410 "addSentenceDetails" => ERROR_ADD_SENTENCE_DETAILS_INVALID_INPUT,
411 "addLemmaDetails" => ERROR_ADD_LEMMA_DETAILS_INVALID_INPUT,
412 "addPartOfSpeechDetails" => ERROR_ADD_PART_OF_SPEECH_DETAILS_INVALID_INPUT,
413 "addEntityDetails" => ERROR_ADD_ENTITY_DETAILS_INVALID_INPUT,
414 "addDependencyDetails" => ERROR_ADD_DEPENDENCY_DETAILS_INVALID_INPUT,
415 "vaderSentimentScores" => ERROR_VADER_SENTIMENT_SCORES_INVALID_INPUT,
416 _ => ERROR_INVALID_INPUT,
417 };
418 let builder = build_runtime_error(message).with_builtin(fn_name);
419 match descriptor.identifier {
420 Some(identifier) => builder.with_identifier(identifier).build(),
421 None => builder.build(),
422 }
423}
424
425fn ensure_tokenized_document_class_registered() {
426 TOKENIZED_DOCUMENT_CLASS_REGISTERED.with(|registered| {
427 if registered.get() {
428 return;
429 }
430 let mut properties = HashMap::new();
431 for name in [
432 "Documents",
433 "Vocabulary",
434 "NumDocuments",
435 "DocumentLengths",
436 "Shape",
437 "TokenizeMethod",
438 "Language",
439 "DetectPatterns",
440 "TopLevelDomains",
441 "TopLevelDomainsCustom",
442 "TypeDetails",
443 "SentenceNumbers",
444 "LemmaDetails",
445 "PartOfSpeechDetails",
446 "EntityDetails",
447 "HeadDetails",
448 "DependencyDetails",
449 ] {
450 properties.insert(name.to_string(), property_def(name));
451 }
452 runmat_builtins::register_class(ClassDef {
453 name: TOKENIZED_DOCUMENT_CLASS.to_string(),
454 parent: None,
455 properties,
456 methods: HashMap::new(),
457 });
458 registered.set(true);
459 });
460}
461
462fn ensure_bag_of_words_class_registered() {
463 BAG_OF_WORDS_CLASS_REGISTERED.with(|registered| {
464 if registered.get() {
465 return;
466 }
467 let mut properties = HashMap::new();
468 for name in ["Counts", "Vocabulary", "NumWords", "NumDocuments"] {
469 properties.insert(name.to_string(), property_def(name));
470 }
471 runmat_builtins::register_class(ClassDef {
472 name: BAG_OF_WORDS_CLASS.to_string(),
473 parent: None,
474 properties,
475 methods: HashMap::new(),
476 });
477 registered.set(true);
478 });
479}
480
481fn property_def(name: &str) -> PropertyDef {
482 PropertyDef {
483 name: name.to_string(),
484 is_static: false,
485 is_constant: false,
486 is_dependent: false,
487 get_access: Access::Public,
488 set_access: Access::Public,
489 default_value: None,
490 }
491}
492
493#[runtime_builtin(
494 name = "tokenizedDocument",
495 category = "strings/text_analytics",
496 summary = "Create tokenized document objects for Text Analytics workflows.",
497 keywords = "tokenizedDocument,text analytics,tokenize,document",
498 accel = "sink",
499 type_resolver(any_type),
500 descriptor(crate::builtins::strings::text_analytics::documents::TOKENIZED_DOCUMENT_DESCRIPTOR),
501 builtin_path = "crate::builtins::strings::text_analytics::documents"
502)]
503pub(in crate::builtins::strings::text_analytics) async fn tokenized_document_builtin(
504 args: Vec<Value>,
505) -> BuiltinResult<Value> {
506 let gathered = gather_args(args, "tokenizedDocument").await?;
507 let (input, options) = parse_tokenized_document_args(gathered)?;
508 let parsed = match input {
509 Some(value) => documents_from_value(value, &options)?,
510 None => ParsedDocuments {
511 documents: vec![Vec::new()],
512 shape: vec![1, 1],
513 type_details: None,
514 },
515 };
516 tokenized_document_value(parsed.documents, parsed.shape, options, parsed.type_details)
517}
518
519#[runtime_builtin(
520 name = "bagOfWords",
521 category = "strings/text_analytics",
522 summary = "Create bag-of-words model objects.",
523 keywords = "bagOfWords,text analytics,word counts,vocabulary",
524 accel = "sink",
525 type_resolver(any_type),
526 descriptor(crate::builtins::strings::text_analytics::documents::BAG_OF_WORDS_DESCRIPTOR),
527 builtin_path = "crate::builtins::strings::text_analytics::documents"
528)]
529async fn bag_of_words_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
530 let gathered = gather_args(args, "bagOfWords").await?;
531 match gathered.as_slice() {
532 [] => bag_from_documents(Vec::new()),
533 [value] => match value {
534 Value::Object(object) if object.is_class(TOKENIZED_DOCUMENT_CLASS) => {
535 bag_from_documents(documents_from_object(object, "bagOfWords")?)
536 }
537 Value::Object(object) => Err(text_analytics_error(
538 "bagOfWords",
539 format!(
540 "bagOfWords: expected tokenizedDocument object, got {}",
541 object.class_name
542 ),
543 )),
544 other => bag_from_documents(vec![words_from_word_vector(other, "bagOfWords")?]),
545 },
546 [words, counts] => bag_from_unique_words_and_counts(words, counts),
547 _ => Err(text_analytics_error(
548 "bagOfWords",
549 "bagOfWords: expected zero, one, or two inputs",
550 )),
551 }
552}
553
554#[runtime_builtin(
555 name = "removeShortWords",
556 category = "strings/text_analytics",
557 summary = "Remove short words from tokenized documents or bag-of-words models.",
558 keywords = "removeShortWords,text analytics,tokenizedDocument,bagOfWords",
559 accel = "sink",
560 type_resolver(any_type),
561 descriptor(crate::builtins::strings::text_analytics::documents::REMOVE_SHORT_WORDS_DESCRIPTOR),
562 builtin_path = "crate::builtins::strings::text_analytics::documents"
563)]
564async fn remove_short_words_builtin(value: Value, len: Value) -> BuiltinResult<Value> {
565 let value = gather_if_needed_async(&value).await.map_err(|err| {
566 text_analytics_error("removeShortWords", format!("removeShortWords: {err}"))
567 })?;
568 let len = gather_if_needed_async(&len).await.map_err(|err| {
569 text_analytics_error("removeShortWords", format!("removeShortWords: {err}"))
570 })?;
571 let max_len = parse_positive_integer(&len, "removeShortWords")?;
572 match value {
573 Value::Object(object) if object.is_class(TOKENIZED_DOCUMENT_CLASS) => {
574 let documents = documents_from_object(&object, "removeShortWords")?
575 .into_iter()
576 .map(|doc| {
577 doc.into_iter()
578 .filter(|token| token.chars().count() > max_len)
579 .collect::<Vec<_>>()
580 })
581 .collect::<Vec<_>>();
582 let shape = shape_from_object(&object);
583 let options = options_from_document_object(&object);
584 tokenized_document_value(documents, shape, options, None)
585 }
586 Value::Object(object) if object.is_class(BAG_OF_WORDS_CLASS) => {
587 remove_short_words_from_bag(object, max_len)
588 }
589 Value::Object(object) => Err(text_analytics_error(
590 "removeShortWords",
591 format!(
592 "removeShortWords: expected tokenizedDocument or bagOfWords object, got {}",
593 object.class_name
594 ),
595 )),
596 other => Err(text_analytics_error(
597 "removeShortWords",
598 format!(
599 "removeShortWords: expected tokenizedDocument or bagOfWords object, got {other:?}"
600 ),
601 )),
602 }
603}
604
605#[runtime_builtin(
606 name = "removeLongWords",
607 category = "strings/text_analytics",
608 summary = "Remove long words from tokenized documents or bag-of-words models.",
609 keywords = "removeLongWords,text analytics,tokenizedDocument,bagOfWords",
610 accel = "sink",
611 type_resolver(any_type),
612 descriptor(crate::builtins::strings::text_analytics::documents::REMOVE_LONG_WORDS_DESCRIPTOR),
613 builtin_path = "crate::builtins::strings::text_analytics::documents"
614)]
615async fn remove_long_words_builtin(value: Value, len: Value) -> BuiltinResult<Value> {
616 let value = gather_if_needed_async(&value).await.map_err(|err| {
617 text_analytics_error("removeLongWords", format!("removeLongWords: {err}"))
618 })?;
619 let len = gather_if_needed_async(&len).await.map_err(|err| {
620 text_analytics_error("removeLongWords", format!("removeLongWords: {err}"))
621 })?;
622 let min_len = parse_positive_integer(&len, "removeLongWords")?;
623 match value {
624 Value::Object(object) if object.is_class(TOKENIZED_DOCUMENT_CLASS) => {
625 transform_tokenized_document(&object, "removeLongWords", |token, _| {
626 Ok((token.chars().count() < min_len).then(|| token.to_string()))
627 })
628 }
629 Value::Object(object) if object.is_class(BAG_OF_WORDS_CLASS) => {
630 filter_bag_columns_by_predicate(object, "removeLongWords", |word| {
631 word.chars().count() < min_len
632 })
633 }
634 Value::Object(object) => Err(text_analytics_error(
635 "removeLongWords",
636 format!(
637 "removeLongWords: expected tokenizedDocument or bagOfWords object, got {}",
638 object.class_name
639 ),
640 )),
641 other => Err(text_analytics_error(
642 "removeLongWords",
643 format!(
644 "removeLongWords: expected tokenizedDocument or bagOfWords object, got {other:?}"
645 ),
646 )),
647 }
648}
649
650#[runtime_builtin(
651 name = "removeWords",
652 category = "strings/text_analytics",
653 summary = "Remove selected words from tokenized documents or bag-of-words models.",
654 keywords = "removeWords,text analytics,tokenizedDocument,bagOfWords,filter",
655 accel = "sink",
656 type_resolver(any_type),
657 descriptor(crate::builtins::strings::text_analytics::documents::REMOVE_WORDS_DESCRIPTOR),
658 builtin_path = "crate::builtins::strings::text_analytics::documents"
659)]
660async fn remove_words_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
661 let gathered = gather_args(args, "removeWords").await?;
662 let (value, selector, options) = parse_remove_words_args(gathered)?;
663 match value {
664 Value::Object(object) if object.is_class(TOKENIZED_DOCUMENT_CLASS) => {
665 let vocabulary = documents_vocabulary(&object, "removeWords")?;
666 let words = selector.words(&vocabulary, "removeWords")?;
667 let remove_set = word_set(words, options.ignore_case);
668 transform_tokenized_document(&object, "removeWords", |token, _| {
669 let key = comparable_word(token, options.ignore_case);
670 Ok((!remove_set.contains(&key)).then(|| token.to_string()))
671 })
672 }
673 Value::Object(object) if object.is_class(BAG_OF_WORDS_CLASS) => {
674 let vocabulary = vocabulary_from_bag(&object, "removeWords")?;
675 let words = selector.words(&vocabulary, "removeWords")?;
676 let remove_set = word_set(words, options.ignore_case);
677 filter_bag_columns(object, "removeWords", |word| {
678 let key = comparable_word(word, options.ignore_case);
679 !remove_set.contains(&key)
680 })
681 }
682 Value::Object(object) => Err(text_analytics_error(
683 "removeWords",
684 format!(
685 "removeWords: expected tokenizedDocument or bagOfWords object, got {}",
686 object.class_name
687 ),
688 )),
689 other => Err(text_analytics_error(
690 "removeWords",
691 format!("removeWords: expected tokenizedDocument or bagOfWords object, got {other:?}"),
692 )),
693 }
694}
695
696#[runtime_builtin(
697 name = "removeStopWords",
698 category = "strings/text_analytics",
699 summary = "Remove stop words from tokenized documents.",
700 keywords = "removeStopWords,stop words,text analytics,tokenizedDocument",
701 accel = "sink",
702 type_resolver(any_type),
703 descriptor(crate::builtins::strings::text_analytics::documents::REMOVE_STOP_WORDS_DESCRIPTOR),
704 builtin_path = "crate::builtins::strings::text_analytics::documents"
705)]
706async fn remove_stop_words_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
707 let gathered = gather_args(args, "removeStopWords").await?;
708 let (value, options) = parse_remove_stop_words_args(gathered)?;
709 let Value::Object(object) = value else {
710 return Err(text_analytics_error(
711 "removeStopWords",
712 format!("removeStopWords: expected tokenizedDocument object, got {value:?}"),
713 ));
714 };
715 if !object.is_class(TOKENIZED_DOCUMENT_CLASS) {
716 return Err(text_analytics_error(
717 "removeStopWords",
718 format!(
719 "removeStopWords: expected tokenizedDocument object, got {}",
720 object.class_name
721 ),
722 ));
723 }
724 let language = stop_words_language_from_document_object(&object, "removeStopWords")?;
725 let words = stop_words_for_language(language);
726 let stop_words = words
727 .iter()
728 .map(|word| {
729 if options.ignore_case {
730 word.to_lowercase()
731 } else {
732 (*word).to_string()
733 }
734 })
735 .collect::<HashSet<_>>();
736 transform_tokenized_document(&object, "removeStopWords", |token, token_type| {
737 if !matches!(
738 token_type,
739 DocumentTokenType::Letters | DocumentTokenType::Other
740 ) {
741 return Ok(Some(token.to_string()));
742 }
743 let key = if options.ignore_case {
744 token.to_lowercase()
745 } else {
746 token.to_string()
747 };
748 Ok((!stop_words.contains(&key)).then(|| token.to_string()))
749 })
750}
751
752async fn gather_args(args: Vec<Value>, fn_name: &str) -> BuiltinResult<Vec<Value>> {
753 let mut out = Vec::with_capacity(args.len());
754 for arg in args {
755 out.push(gather_if_needed_async(&arg).await.map_err(|err| {
756 text_analytics_error(fn_name, format!("{fn_name}: failed to gather input: {err}"))
757 })?);
758 }
759 Ok(out)
760}
761
762#[derive(Clone, Debug)]
763pub(in crate::builtins::strings::text_analytics) struct DocumentOptions {
764 pub(in crate::builtins::strings::text_analytics) tokenize_method: TokenizeMethod,
765 pub(in crate::builtins::strings::text_analytics) language: String,
766 pub(in crate::builtins::strings::text_analytics) detect_patterns: DetectPatterns,
767 pub(in crate::builtins::strings::text_analytics) top_level_domains: Vec<String>,
768 pub(in crate::builtins::strings::text_analytics) top_level_domains_custom: bool,
769 custom_tokens: Vec<CustomTokenRule>,
770 regular_expressions: Vec<RegexTokenRule>,
771}
772
773impl Default for DocumentOptions {
774 fn default() -> Self {
775 Self {
776 tokenize_method: TokenizeMethod::Unicode,
777 language: "en".to_string(),
778 detect_patterns: DetectPatterns::All,
779 top_level_domains: default_top_level_domains(),
780 top_level_domains_custom: false,
781 custom_tokens: Vec::new(),
782 regular_expressions: Vec::new(),
783 }
784 }
785}
786
787impl DocumentOptions {
788 fn requires_type_details(&self) -> bool {
789 !self.custom_tokens.is_empty() || !self.regular_expressions.is_empty()
790 }
791}
792
793#[derive(Clone, Debug)]
794struct CustomTokenRule {
795 token: String,
796 token_type: String,
797}
798
799#[derive(Clone, Debug)]
800struct RegexTokenRule {
801 regex: Regex,
802 token_type: String,
803}
804
805#[derive(Clone, Copy, Debug, PartialEq, Eq)]
806pub(in crate::builtins::strings::text_analytics) enum TokenizeMethod {
807 Unicode,
808 None,
809}
810
811#[derive(Clone, Copy, Debug, PartialEq, Eq)]
812pub(in crate::builtins::strings::text_analytics) enum DetectPatterns {
813 All,
814 None,
815 Selected(ComplexPatternSet),
816}
817
818#[derive(Clone, Copy, Debug, PartialEq, Eq)]
819pub(in crate::builtins::strings::text_analytics) struct ComplexPatternSet {
820 email_address: bool,
821 web_address: bool,
822 hashtag: bool,
823 at_mention: bool,
824 emoticon: bool,
825}
826
827impl ComplexPatternSet {
828 fn empty() -> Self {
829 Self {
830 email_address: false,
831 web_address: false,
832 hashtag: false,
833 at_mention: false,
834 emoticon: false,
835 }
836 }
837}
838
839impl DetectPatterns {
840 fn detects_email_address(self) -> bool {
841 matches!(
842 self,
843 Self::All
844 | Self::Selected(ComplexPatternSet {
845 email_address: true,
846 ..
847 })
848 )
849 }
850
851 fn detects_web_address(self) -> bool {
852 matches!(
853 self,
854 Self::All
855 | Self::Selected(ComplexPatternSet {
856 web_address: true,
857 ..
858 })
859 )
860 }
861
862 fn detects_hashtag(self) -> bool {
863 matches!(
864 self,
865 Self::All | Self::Selected(ComplexPatternSet { hashtag: true, .. })
866 )
867 }
868
869 fn detects_at_mention(self) -> bool {
870 matches!(
871 self,
872 Self::All
873 | Self::Selected(ComplexPatternSet {
874 at_mention: true,
875 ..
876 })
877 )
878 }
879
880 fn detects_emoticon(self) -> bool {
881 matches!(
882 self,
883 Self::All | Self::Selected(ComplexPatternSet { emoticon: true, .. })
884 )
885 }
886}
887
888fn parse_tokenized_document_args(
889 args: Vec<Value>,
890) -> BuiltinResult<(Option<Value>, DocumentOptions)> {
891 if args.is_empty() {
892 return Ok((None, DocumentOptions::default()));
893 }
894 if !(args.len() - 1).is_multiple_of(2) {
895 return Err(text_analytics_error(
896 "tokenizedDocument",
897 "tokenizedDocument: name-value options must appear in pairs",
898 ));
899 }
900 let input = args[0].clone();
901 let mut options = DocumentOptions::default();
902 let mut idx = 1;
903 while idx < args.len() {
904 let name = scalar_text(&args[idx], "tokenizedDocument")
905 .map_err(|err| text_analytics_error("tokenizedDocument", err.to_string()))?
906 .to_ascii_lowercase();
907 match name.as_str() {
908 "tokenizemethod" => {
909 let value = scalar_text(&args[idx + 1], "tokenizedDocument")
910 .map_err(|err| text_analytics_error("tokenizedDocument", err.to_string()))?;
911 options.tokenize_method = parse_tokenize_method(&value)?;
912 }
913 "language" => {
914 let value = scalar_text(&args[idx + 1], "tokenizedDocument")
915 .map_err(|err| text_analytics_error("tokenizedDocument", err.to_string()))?;
916 options.language = parse_language(&value)?;
917 }
918 "detectpatterns" => {
919 options.detect_patterns =
920 parse_detect_patterns(&args[idx + 1], "tokenizedDocument")?;
921 }
922 "topleveldomains" => {
923 options.top_level_domains =
924 parse_top_level_domains(&args[idx + 1], "tokenizedDocument")?;
925 options.top_level_domains_custom = true;
926 }
927 "customtokens" => {
928 options.custom_tokens = parse_custom_tokens(&args[idx + 1])?;
929 }
930 "regularexpressions" => {
931 options.regular_expressions = parse_regular_expressions(&args[idx + 1])?;
932 }
933 _ => {
934 return Err(text_analytics_error(
935 "tokenizedDocument",
936 format!("tokenizedDocument: unsupported option '{name}'"),
937 ));
938 }
939 }
940 idx += 2;
941 }
942 Ok((Some(input), options))
943}
944
945fn parse_tokenize_method(value: &str) -> BuiltinResult<TokenizeMethod> {
946 match value.trim().to_ascii_lowercase().as_str() {
947 "unicode" => Ok(TokenizeMethod::Unicode),
948 "none" => Ok(TokenizeMethod::None),
949 "mecab" => Err(text_analytics_error(
950 "tokenizedDocument",
951 "tokenizedDocument: TokenizeMethod 'mecab' requires Japanese/Korean tokenizer support and remains tracked",
952 )),
953 other => Err(text_analytics_error(
954 "tokenizedDocument",
955 format!("tokenizedDocument: unsupported TokenizeMethod '{other}'"),
956 )),
957 }
958}
959
960fn parse_language(value: &str) -> BuiltinResult<String> {
961 match value.trim().to_ascii_lowercase().as_str() {
962 "en" | "de" => Ok(value.trim().to_ascii_lowercase()),
963 "ja" | "ko" => Err(text_analytics_error(
964 "tokenizedDocument",
965 "tokenizedDocument: Japanese/Korean tokenization requires MeCab-compatible support and remains tracked",
966 )),
967 other => Err(text_analytics_error(
968 "tokenizedDocument",
969 format!("tokenizedDocument: Language must be 'en' or 'de' in this slice, got '{other}'"),
970 )),
971 }
972}
973
974fn parse_detect_patterns(value: &Value, fn_name: &str) -> BuiltinResult<DetectPatterns> {
975 let patterns = words_from_word_vector(value, fn_name)?
976 .into_iter()
977 .map(|pattern| pattern.trim().to_ascii_lowercase())
978 .filter(|pattern| !pattern.is_empty())
979 .collect::<Vec<_>>();
980 if patterns.is_empty() {
981 return Err(text_analytics_error(
982 fn_name,
983 format!("{fn_name}: DetectPatterns must contain at least one pattern"),
984 ));
985 }
986 if patterns.len() == 1 {
987 match patterns[0].as_str() {
988 "all" => return Ok(DetectPatterns::All),
989 "none" => return Ok(DetectPatterns::None),
990 _ => {}
991 }
992 }
993
994 let mut selected = ComplexPatternSet::empty();
995 for pattern in patterns {
996 match pattern.as_str() {
997 "email-address" => selected.email_address = true,
998 "web-address" => selected.web_address = true,
999 "hashtag" => selected.hashtag = true,
1000 "at-mention" => selected.at_mention = true,
1001 "emoticon" => selected.emoticon = true,
1002 "all" | "none" => {
1003 return Err(text_analytics_error(
1004 fn_name,
1005 format!("{fn_name}: DetectPatterns '{pattern}' must be specified alone"),
1006 ))
1007 }
1008 other => {
1009 return Err(text_analytics_error(
1010 fn_name,
1011 format!("{fn_name}: unsupported DetectPatterns value '{other}'"),
1012 ))
1013 }
1014 }
1015 }
1016 Ok(DetectPatterns::Selected(selected))
1017}
1018
1019pub(in crate::builtins::strings::text_analytics) fn parse_top_level_domains(
1020 value: &Value,
1021 fn_name: &str,
1022) -> BuiltinResult<Vec<String>> {
1023 let domains = words_from_word_vector_preserving_missing(value, fn_name)?;
1024 let mut out = Vec::new();
1025 let mut seen = HashSet::new();
1026 for raw in domains {
1027 if is_missing_string(&raw) {
1028 continue;
1029 }
1030 let domain = raw
1031 .trim()
1032 .trim_start_matches('.')
1033 .trim_end_matches('.')
1034 .to_ascii_lowercase();
1035 if domain.is_empty()
1036 || domain.contains('.')
1037 || !domain
1038 .chars()
1039 .all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
1040 || domain.starts_with('-')
1041 || domain.ends_with('-')
1042 {
1043 return Err(text_analytics_error(
1044 fn_name,
1045 format!("{fn_name}: TopLevelDomains contains invalid domain '{raw}'"),
1046 ));
1047 }
1048 if seen.insert(domain.clone()) {
1049 out.push(domain);
1050 }
1051 }
1052 if out.is_empty() {
1053 return Err(text_analytics_error(
1054 fn_name,
1055 format!("{fn_name}: TopLevelDomains must contain at least one domain"),
1056 ));
1057 }
1058 Ok(out)
1059}
1060
1061fn parse_custom_tokens(value: &Value) -> BuiltinResult<Vec<CustomTokenRule>> {
1062 if let Value::Object(object) = value {
1063 let variables = table_variables(object).map_err(|err| {
1064 text_analytics_error(
1065 "tokenizedDocument",
1066 format!("tokenizedDocument: invalid CustomTokens table: {err}"),
1067 )
1068 })?;
1069 if variables.fields.contains_key("Token") || variables.fields.contains_key("Type") {
1070 let tokens = text_column(&variables, "Token", "CustomTokens")?;
1071 let types = optional_text_column(&variables, "Type", "CustomTokens", tokens.len())?;
1072 return custom_token_rules_from_columns(tokens, types);
1073 }
1074 }
1075 let tokens = words_from_word_vector_preserving_missing(value, "tokenizedDocument")?;
1076 custom_token_rules_from_columns(tokens, None)
1077}
1078
1079fn parse_regular_expressions(value: &Value) -> BuiltinResult<Vec<RegexTokenRule>> {
1080 if let Value::Object(object) = value {
1081 let variables = table_variables(object).map_err(|err| {
1082 text_analytics_error(
1083 "tokenizedDocument",
1084 format!("tokenizedDocument: invalid RegularExpressions table: {err}"),
1085 )
1086 })?;
1087 if variables.fields.contains_key("Pattern") || variables.fields.contains_key("Type") {
1088 let patterns = text_column(&variables, "Pattern", "RegularExpressions")?;
1089 let types =
1090 optional_text_column(&variables, "Type", "RegularExpressions", patterns.len())?;
1091 return regex_token_rules_from_columns(patterns, types);
1092 }
1093 }
1094 let patterns = words_from_word_vector_preserving_missing(value, "tokenizedDocument")?;
1095 regex_token_rules_from_columns(patterns, None)
1096}
1097
1098fn text_column(
1099 variables: &runmat_builtins::StructValue,
1100 name: &str,
1101 option_name: &str,
1102) -> BuiltinResult<Vec<String>> {
1103 let Some(value) = variables.fields.get(name) else {
1104 return Err(text_analytics_error(
1105 "tokenizedDocument",
1106 format!("tokenizedDocument: {option_name} table must contain a {name} variable"),
1107 ));
1108 };
1109 words_from_word_vector_preserving_missing(value, "tokenizedDocument")
1110}
1111
1112fn optional_text_column(
1113 variables: &runmat_builtins::StructValue,
1114 name: &str,
1115 option_name: &str,
1116 expected_len: usize,
1117) -> BuiltinResult<Option<Vec<String>>> {
1118 let Some(value) = variables.fields.get(name) else {
1119 return Ok(None);
1120 };
1121 let values = words_from_word_vector_preserving_missing(value, "tokenizedDocument")?;
1122 if values.len() != expected_len {
1123 return Err(text_analytics_error(
1124 "tokenizedDocument",
1125 format!(
1126 "tokenizedDocument: {option_name} table variable {name} has {} rows but expected {expected_len}",
1127 values.len()
1128 ),
1129 ));
1130 }
1131 Ok(Some(values))
1132}
1133
1134fn custom_token_rules_from_columns(
1135 tokens: Vec<String>,
1136 types: Option<Vec<String>>,
1137) -> BuiltinResult<Vec<CustomTokenRule>> {
1138 let mut rules = Vec::new();
1139 let mut seen = HashSet::new();
1140 for (idx, token) in tokens.into_iter().enumerate() {
1141 if is_missing_string(&token) {
1142 continue;
1143 }
1144 if token.is_empty() {
1145 return Err(text_analytics_error(
1146 "tokenizedDocument",
1147 "tokenizedDocument: CustomTokens cannot contain empty tokens",
1148 ));
1149 }
1150 let token_type = types
1151 .as_ref()
1152 .and_then(|values| values.get(idx))
1153 .filter(|value| !is_missing_string(value) && !value.is_empty())
1154 .cloned()
1155 .unwrap_or_else(|| "custom".to_string());
1156 if seen.insert(token.clone()) {
1157 rules.push(CustomTokenRule { token, token_type });
1158 }
1159 }
1160 Ok(rules)
1161}
1162
1163fn regex_token_rules_from_columns(
1164 patterns: Vec<String>,
1165 types: Option<Vec<String>>,
1166) -> BuiltinResult<Vec<RegexTokenRule>> {
1167 let mut rules = Vec::new();
1168 for (idx, pattern) in patterns.into_iter().enumerate() {
1169 if is_missing_string(&pattern) {
1170 continue;
1171 }
1172 if pattern.is_empty() {
1173 return Err(text_analytics_error(
1174 "tokenizedDocument",
1175 "tokenizedDocument: RegularExpressions cannot contain empty patterns",
1176 ));
1177 }
1178 let regex = Regex::new(&pattern).map_err(|err| {
1179 text_analytics_error(
1180 "tokenizedDocument",
1181 format!("tokenizedDocument: invalid RegularExpressions pattern '{pattern}': {err}"),
1182 )
1183 })?;
1184 if regex
1185 .find("")
1186 .is_some_and(|mat| mat.start() == 0 && mat.end() == 0)
1187 {
1188 return Err(text_analytics_error(
1189 "tokenizedDocument",
1190 format!(
1191 "tokenizedDocument: RegularExpressions pattern '{pattern}' can match empty text"
1192 ),
1193 ));
1194 }
1195 let token_type = types
1196 .as_ref()
1197 .and_then(|values| values.get(idx))
1198 .filter(|value| !is_missing_string(value) && !value.is_empty())
1199 .cloned()
1200 .unwrap_or_else(|| "custom".to_string());
1201 rules.push(RegexTokenRule { regex, token_type });
1202 }
1203 Ok(rules)
1204}
1205
1206#[derive(Clone, Copy, Debug)]
1207struct RemoveStopWordsOptions {
1208 ignore_case: bool,
1209}
1210
1211impl Default for RemoveStopWordsOptions {
1212 fn default() -> Self {
1213 Self { ignore_case: true }
1214 }
1215}
1216
1217#[derive(Clone, Copy, Debug, Default)]
1218struct RemoveWordsOptions {
1219 ignore_case: bool,
1220}
1221
1222#[derive(Clone, Debug)]
1223enum RemoveWordsSelector {
1224 Words(Vec<String>),
1225 Indices(Vec<usize>),
1226 LogicalMask { indices: Vec<usize>, len: usize },
1227}
1228
1229impl RemoveWordsSelector {
1230 fn words(self, vocabulary: &[String], fn_name: &str) -> BuiltinResult<Vec<String>> {
1231 match self {
1232 Self::Words(words) => Ok(words),
1233 Self::LogicalMask { indices, len } => {
1234 if len != vocabulary.len() {
1235 return Err(text_analytics_error(
1236 fn_name,
1237 format!(
1238 "{fn_name}: logical index length {len} must match vocabulary length {}",
1239 vocabulary.len()
1240 ),
1241 ));
1242 }
1243 indices
1244 .into_iter()
1245 .map(|idx| {
1246 vocabulary.get(idx).cloned().ok_or_else(|| {
1247 text_analytics_error(
1248 fn_name,
1249 format!(
1250 "{fn_name}: vocabulary index {} exceeds vocabulary length {}",
1251 idx + 1,
1252 vocabulary.len()
1253 ),
1254 )
1255 })
1256 })
1257 .collect()
1258 }
1259 Self::Indices(indices) => indices
1260 .into_iter()
1261 .map(|idx| {
1262 vocabulary.get(idx).cloned().ok_or_else(|| {
1263 text_analytics_error(
1264 fn_name,
1265 format!(
1266 "{fn_name}: vocabulary index {} exceeds vocabulary length {}",
1267 idx + 1,
1268 vocabulary.len()
1269 ),
1270 )
1271 })
1272 })
1273 .collect(),
1274 }
1275 }
1276}
1277
1278fn parse_remove_stop_words_args(
1279 args: Vec<Value>,
1280) -> BuiltinResult<(Value, RemoveStopWordsOptions)> {
1281 if args.is_empty() {
1282 return Err(text_analytics_error(
1283 "removeStopWords",
1284 "removeStopWords: expected tokenizedDocument input",
1285 ));
1286 }
1287 if !(args.len() - 1).is_multiple_of(2) {
1288 return Err(text_analytics_error(
1289 "removeStopWords",
1290 "removeStopWords: name-value options must appear in pairs",
1291 ));
1292 }
1293 let mut options = RemoveStopWordsOptions::default();
1294 let mut idx = 1;
1295 while idx < args.len() {
1296 let name = scalar_text(&args[idx], "removeStopWords")
1297 .map_err(|err| text_analytics_error("removeStopWords", err.to_string()))?
1298 .to_ascii_lowercase();
1299 match name.as_str() {
1300 "ignorecase" => {
1301 options.ignore_case = parse_bool_scalar(&args[idx + 1], "removeStopWords")?;
1302 }
1303 other => {
1304 return Err(text_analytics_error(
1305 "removeStopWords",
1306 format!("removeStopWords: unsupported option '{other}'"),
1307 ));
1308 }
1309 }
1310 idx += 2;
1311 }
1312 Ok((args[0].clone(), options))
1313}
1314
1315fn parse_remove_words_args(
1316 args: Vec<Value>,
1317) -> BuiltinResult<(Value, RemoveWordsSelector, RemoveWordsOptions)> {
1318 if args.len() < 2 {
1319 return Err(text_analytics_error(
1320 "removeWords",
1321 "removeWords: expected input object and words or indices",
1322 ));
1323 }
1324 if !(args.len() - 2).is_multiple_of(2) {
1325 return Err(text_analytics_error(
1326 "removeWords",
1327 "removeWords: name-value options must appear in pairs",
1328 ));
1329 }
1330 let mut options = RemoveWordsOptions::default();
1331 let mut idx = 2;
1332 while idx < args.len() {
1333 let name = scalar_text(&args[idx], "removeWords")
1334 .map_err(|err| text_analytics_error("removeWords", err.to_string()))?
1335 .to_ascii_lowercase();
1336 match name.as_str() {
1337 "ignorecase" => {
1338 options.ignore_case = parse_bool_scalar(&args[idx + 1], "removeWords")?;
1339 }
1340 other => {
1341 return Err(text_analytics_error(
1342 "removeWords",
1343 format!("removeWords: unsupported option '{other}'"),
1344 ));
1345 }
1346 }
1347 idx += 2;
1348 }
1349 let selector = parse_remove_words_selector(&args[1])?;
1350 Ok((args[0].clone(), selector, options))
1351}
1352
1353fn parse_remove_words_selector(value: &Value) -> BuiltinResult<RemoveWordsSelector> {
1354 match value {
1355 Value::Bool(value) => Ok(RemoveWordsSelector::LogicalMask {
1356 indices: (*value).then_some(0).into_iter().collect(),
1357 len: 1,
1358 }),
1359 Value::LogicalArray(array) => {
1360 let indices = logical_indices(array);
1361 Ok(RemoveWordsSelector::LogicalMask {
1362 indices,
1363 len: array.data.len(),
1364 })
1365 }
1366 Value::Num(_) | Value::Tensor(_) => {
1367 parse_remove_words_indices(value).map(RemoveWordsSelector::Indices)
1368 }
1369 _ => words_from_word_vector(value, "removeWords").map(RemoveWordsSelector::Words),
1370 }
1371}
1372
1373fn parse_remove_words_indices(value: &Value) -> BuiltinResult<Vec<usize>> {
1374 let raw = match value {
1375 Value::Num(value) => vec![*value],
1376 Value::Tensor(tensor) => tensor.data.clone(),
1377 other => {
1378 return Err(text_analytics_error(
1379 "removeWords",
1380 format!("removeWords: expected numeric or logical indices, got {other:?}"),
1381 ))
1382 }
1383 };
1384 if raw.is_empty() {
1385 return Ok(Vec::new());
1386 }
1387 let mut seen = HashSet::new();
1388 let mut indices = Vec::new();
1389 for value in raw {
1390 if !value.is_finite() || value <= 0.0 || value.fract() != 0.0 {
1391 return Err(text_analytics_error(
1392 "removeWords",
1393 format!("removeWords: vocabulary indices must be positive integers, got {value}"),
1394 ));
1395 }
1396 let idx = value as usize - 1;
1397 if seen.insert(idx) {
1398 indices.push(idx);
1399 }
1400 }
1401 Ok(indices)
1402}
1403
1404fn logical_indices(array: &LogicalArray) -> Vec<usize> {
1405 array
1406 .data
1407 .iter()
1408 .enumerate()
1409 .filter_map(|(idx, flag)| (*flag != 0).then_some(idx))
1410 .collect()
1411}
1412
1413fn word_set(words: Vec<String>, ignore_case: bool) -> HashSet<String> {
1414 words
1415 .into_iter()
1416 .map(|word| comparable_word(&word, ignore_case))
1417 .collect()
1418}
1419
1420fn comparable_word(word: &str, ignore_case: bool) -> String {
1421 if ignore_case {
1422 word.to_lowercase()
1423 } else {
1424 word.to_string()
1425 }
1426}
1427
1428struct ParsedDocuments {
1429 documents: Vec<Vec<String>>,
1430 shape: Vec<usize>,
1431 type_details: Option<Vec<Vec<String>>>,
1432}
1433
1434struct TokenizedText {
1435 tokens: Vec<String>,
1436 types: Option<Vec<String>>,
1437}
1438
1439fn documents_from_value(value: Value, options: &DocumentOptions) -> BuiltinResult<ParsedDocuments> {
1440 match options.tokenize_method {
1441 TokenizeMethod::Unicode => text_documents(value, options),
1442 TokenizeMethod::None => pretokenized_documents(value),
1443 }
1444}
1445
1446fn text_documents(value: Value, options: &DocumentOptions) -> BuiltinResult<ParsedDocuments> {
1447 match value {
1448 Value::String(text) => {
1449 let tokenized = tokenize_text(&text, options);
1450 Ok(ParsedDocuments {
1451 type_details: tokenized.types.clone().map(|types| vec![types]),
1452 documents: vec![tokenized.tokens],
1453 shape: vec![1, 1],
1454 })
1455 }
1456 Value::StringArray(array) => {
1457 let shape = array.shape.clone();
1458 let mut docs = Vec::with_capacity(array.data.len());
1459 let mut type_details = options.requires_type_details().then(Vec::new);
1460 for text in array.data {
1461 let tokenized = if is_missing_string(&text) {
1462 TokenizedText {
1463 tokens: Vec::new(),
1464 types: type_details.as_ref().map(|_| Vec::new()),
1465 }
1466 } else {
1467 tokenize_text(&text, options)
1468 };
1469 if let Some(details) = &mut type_details {
1470 details.push(tokenized.types.unwrap_or_default());
1471 }
1472 docs.push(tokenized.tokens);
1473 }
1474 Ok(ParsedDocuments {
1475 documents: docs,
1476 shape,
1477 type_details,
1478 })
1479 }
1480 Value::CharArray(array) if array.rows <= 1 => {
1481 let text = if array.rows == 0 {
1482 String::new()
1483 } else {
1484 char_row_to_string_slice(&array.data, array.cols, 0)
1485 };
1486 let tokenized = tokenize_text(&text, options);
1487 Ok(ParsedDocuments {
1488 type_details: tokenized.types.clone().map(|types| vec![types]),
1489 documents: vec![tokenized.tokens],
1490 shape: vec![1, 1],
1491 })
1492 }
1493 Value::CharArray(array) => {
1494 let mut docs = Vec::with_capacity(array.rows);
1495 let mut type_details = options.requires_type_details().then(Vec::new);
1496 for row in 0..array.rows {
1497 let tokenized = tokenize_text(
1498 &char_row_to_string_slice(&array.data, array.cols, row),
1499 options,
1500 );
1501 if let Some(details) = &mut type_details {
1502 details.push(tokenized.types.unwrap_or_default());
1503 }
1504 docs.push(tokenized.tokens);
1505 }
1506 Ok(ParsedDocuments {
1507 documents: docs,
1508 shape: vec![array.rows, 1],
1509 type_details,
1510 })
1511 }
1512 Value::Cell(cell) => {
1513 let shape = cell.shape.clone();
1514 let mut docs = Vec::with_capacity(cell.data.len());
1515 let mut type_details = options.requires_type_details().then(Vec::new);
1516 for item in cell.data {
1517 let text = scalar_text(&item, "tokenizedDocument")
1518 .map_err(|err| text_analytics_error("tokenizedDocument", err.to_string()))?;
1519 let tokenized = tokenize_text(&text, options);
1520 if let Some(details) = &mut type_details {
1521 details.push(tokenized.types.unwrap_or_default());
1522 }
1523 docs.push(tokenized.tokens);
1524 }
1525 Ok(ParsedDocuments {
1526 documents: docs,
1527 shape,
1528 type_details,
1529 })
1530 }
1531 other => Err(text_analytics_error(
1532 "tokenizedDocument",
1533 format!("tokenizedDocument: expected text input, got {other:?}"),
1534 )),
1535 }
1536}
1537
1538fn pretokenized_documents(value: Value) -> BuiltinResult<ParsedDocuments> {
1539 match value {
1540 Value::String(text) => Ok(ParsedDocuments {
1541 documents: vec![vec![text]],
1542 shape: vec![1, 1],
1543 type_details: None,
1544 }),
1545 Value::StringArray(array) => Ok(ParsedDocuments {
1546 documents: vec![array
1547 .data
1548 .into_iter()
1549 .filter(|text| !is_missing_string(text))
1550 .collect()],
1551 shape: vec![1, 1],
1552 type_details: None,
1553 }),
1554 Value::CharArray(array) if array.rows <= 1 => {
1555 let text = if array.rows == 0 {
1556 String::new()
1557 } else {
1558 char_row_to_string_slice(&array.data, array.cols, 0)
1559 };
1560 Ok(ParsedDocuments {
1561 documents: vec![vec![text]],
1562 shape: vec![1, 1],
1563 type_details: None,
1564 })
1565 }
1566 Value::Cell(cell) => {
1567 let shape = cell.shape.clone();
1568 if cell.data.len() == 1 {
1569 if let Value::StringArray(array) = &cell.data[0] {
1570 return Ok(ParsedDocuments {
1571 documents: vec![array
1572 .data
1573 .iter()
1574 .filter(|text| !is_missing_string(text))
1575 .cloned()
1576 .collect()],
1577 shape: vec![1, 1],
1578 type_details: None,
1579 });
1580 }
1581 }
1582 let mut all_string_arrays = true;
1583 let mut docs = Vec::with_capacity(cell.data.len());
1584 for item in cell.data {
1585 match item {
1586 Value::StringArray(array) => docs.push(
1587 array
1588 .data
1589 .into_iter()
1590 .filter(|text| !is_missing_string(text))
1591 .collect(),
1592 ),
1593 other => {
1594 all_string_arrays = false;
1595 docs.push(vec![scalar_text(&other, "tokenizedDocument").map_err(
1596 |err| text_analytics_error("tokenizedDocument", err.to_string()),
1597 )?]);
1598 }
1599 }
1600 }
1601 if all_string_arrays {
1602 Ok(ParsedDocuments {
1603 documents: docs,
1604 shape,
1605 type_details: None,
1606 })
1607 } else {
1608 Ok(ParsedDocuments {
1609 documents: vec![docs.into_iter().flatten().collect()],
1610 shape: vec![1, 1],
1611 type_details: None,
1612 })
1613 }
1614 }
1615 other => Err(text_analytics_error(
1616 "tokenizedDocument",
1617 format!("tokenizedDocument: expected pre-tokenized word vector, got {other:?}"),
1618 )),
1619 }
1620}
1621
1622fn tokenize_text(text: &str, options: &DocumentOptions) -> TokenizedText {
1623 let mut tokens = Vec::new();
1624 let mut types = options.requires_type_details().then(Vec::new);
1625 let mut pos = 0;
1626 while pos < text.len() {
1627 let rest = &text[pos..];
1628 if let Some(ch) = rest.chars().next() {
1629 if ch.is_whitespace() {
1630 pos += ch.len_utf8();
1631 continue;
1632 }
1633 }
1634 if let Some((token, token_type, end)) = custom_or_regex_token_at(text, pos, options) {
1635 if let Some(types) = &mut types {
1636 types.push(token_type);
1637 }
1638 tokens.push(token);
1639 pos = end;
1640 continue;
1641 }
1642 if let Some((token, end)) = complex_token_at(text, pos, options) {
1643 if let Some(types) = &mut types {
1644 types.push(
1645 document_token_type_with_options(&token, options)
1646 .as_str()
1647 .to_string(),
1648 );
1649 }
1650 tokens.push(token);
1651 pos = end;
1652 continue;
1653 }
1654 let Some(ch) = rest.chars().next() else {
1655 break;
1656 };
1657 if ch.is_alphanumeric() || ch == '_' {
1658 let start = pos;
1659 pos += ch.len_utf8();
1660 while pos < text.len() {
1661 let next = text[pos..].chars().next().unwrap();
1662 if next.is_alphanumeric() || next == '_' || next == '\'' {
1663 pos += next.len_utf8();
1664 } else {
1665 break;
1666 }
1667 }
1668 let token = text[start..pos].to_string();
1669 if let Some(types) = &mut types {
1670 types.push(
1671 document_token_type_with_options(&token, options)
1672 .as_str()
1673 .to_string(),
1674 );
1675 }
1676 tokens.push(token);
1677 continue;
1678 }
1679 let token = ch.to_string();
1680 if let Some(types) = &mut types {
1681 types.push(
1682 document_token_type_with_options(&token, options)
1683 .as_str()
1684 .to_string(),
1685 );
1686 }
1687 tokens.push(token);
1688 pos += ch.len_utf8();
1689 }
1690 TokenizedText { tokens, types }
1691}
1692
1693fn custom_or_regex_token_at(
1694 text: &str,
1695 pos: usize,
1696 options: &DocumentOptions,
1697) -> Option<(String, String, usize)> {
1698 let rest = &text[pos..];
1699 let mut regex_match = None;
1700 for rule in &options.regular_expressions {
1701 let Some(mat) = rule.regex.find(rest) else {
1702 continue;
1703 };
1704 if mat.start() == 0 && mat.end() > 0 {
1705 regex_match = Some((
1706 rest[..mat.end()].to_string(),
1707 rule.token_type.clone(),
1708 pos + mat.end(),
1709 ));
1710 }
1711 }
1712 if regex_match.is_some() {
1713 return regex_match;
1714 }
1715
1716 options
1717 .custom_tokens
1718 .iter()
1719 .filter(|rule| rest.starts_with(&rule.token))
1720 .max_by_key(|rule| rule.token.len())
1721 .map(|rule| {
1722 (
1723 rule.token.clone(),
1724 rule.token_type.clone(),
1725 pos + rule.token.len(),
1726 )
1727 })
1728}
1729
1730fn complex_token_at(text: &str, pos: usize, options: &DocumentOptions) -> Option<(String, usize)> {
1731 let rest = &text[pos..];
1732 if options.detect_patterns.detects_web_address() {
1733 if let Some((token, end)) = web_token_at(text, pos, options) {
1734 return Some((token, end));
1735 }
1736 }
1737 if options.detect_patterns.detects_email_address() {
1738 if let Some((token, end)) = email_token_at(text, pos) {
1739 return Some((token, end));
1740 }
1741 }
1742 if (options.detect_patterns.detects_hashtag() && rest.starts_with('#'))
1743 || (options.detect_patterns.detects_at_mention() && rest.starts_with('@'))
1744 {
1745 let mut end = pos + 1;
1746 while end < text.len() {
1747 let ch = text[end..].chars().next().unwrap();
1748 if ch.is_ascii_alphanumeric() || ch == '_' {
1749 end += ch.len_utf8();
1750 } else {
1751 break;
1752 }
1753 }
1754 if end > pos + 1 {
1755 return Some((text[pos..end].to_string(), end));
1756 }
1757 }
1758 if options.detect_patterns.detects_emoticon()
1759 && (rest.starts_with(":-)")
1760 || rest.starts_with(":-D")
1761 || rest.starts_with(":)")
1762 || rest.starts_with(":D"))
1763 {
1764 let len = if rest.starts_with(":-)") || rest.starts_with(":-D") {
1765 3
1766 } else {
1767 2
1768 };
1769 let end = pos + len;
1770 return Some((text[pos..end].to_string(), end));
1771 }
1772 None
1773}
1774
1775fn web_token_at(text: &str, pos: usize, options: &DocumentOptions) -> Option<(String, usize)> {
1776 let rest = &text[pos..];
1777 if !(has_web_scheme(rest)
1778 || starts_with_ascii_ci(rest, "www.")
1779 || rest
1780 .chars()
1781 .next()
1782 .is_some_and(|ch| ch.is_ascii_alphanumeric()))
1783 {
1784 return None;
1785 }
1786 let end = pos + take_while_nonspace(rest);
1787 let token = text[pos..end].trim_end_matches(is_trailing_punctuation);
1788 if !is_web_address_token(token, options) {
1789 return None;
1790 }
1791 let token_end = pos + token.len();
1792 Some((token.to_string(), token_end))
1793}
1794
1795fn email_token_at(text: &str, pos: usize) -> Option<(String, usize)> {
1796 let rest = &text[pos..];
1797 let at = rest.find('@')?;
1798 if at == 0 {
1799 return None;
1800 }
1801 let local = &rest[..at];
1802 if !local
1803 .chars()
1804 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '%' | '+' | '-'))
1805 {
1806 return None;
1807 }
1808 let after_at = &rest[at + 1..];
1809 let domain_len = after_at
1810 .char_indices()
1811 .take_while(|(_, ch)| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-'))
1812 .map(|(idx, ch)| idx + ch.len_utf8())
1813 .last()?;
1814 let raw_end = pos + at + 1 + domain_len;
1815 let token = text[pos..raw_end].trim_end_matches(is_trailing_punctuation);
1816 if !token.contains('.') || token.ends_with('@') {
1817 return None;
1818 }
1819 let token_end = pos + token.len();
1820 Some((token.to_string(), token_end))
1821}
1822
1823fn is_web_address_token(token: &str, options: &DocumentOptions) -> bool {
1824 if !web_address_candidate(token) {
1825 return false;
1826 }
1827 let Some(host) = web_address_host(token) else {
1828 return false;
1829 };
1830 let has_explicit_prefix = has_web_scheme(token) || starts_with_ascii_ci(token, "www.");
1831 if has_explicit_prefix && !options.top_level_domains_custom {
1832 return domain_host_has_valid_tld_shape(host);
1833 }
1834 domain_host_has_allowed_tld(host, &options.top_level_domains)
1835}
1836
1837fn web_address_host(token: &str) -> Option<&str> {
1838 let stripped = strip_ascii_prefix(token, "http://")
1839 .or_else(|| strip_ascii_prefix(token, "https://"))
1840 .unwrap_or(token);
1841 let host_and_port = stripped
1842 .split(['/', '?', '#'])
1843 .next()
1844 .unwrap_or(stripped)
1845 .trim_end_matches(is_trailing_punctuation);
1846 let host = host_and_port.split(':').next().unwrap_or(host_and_port);
1847 if host.is_empty() || host.contains('@') {
1848 return None;
1849 }
1850 Some(host)
1851}
1852
1853fn web_address_candidate(token: &str) -> bool {
1854 token.contains('.')
1855 && (has_web_scheme(token)
1856 || starts_with_ascii_ci(token, "www.")
1857 || token
1858 .chars()
1859 .next()
1860 .is_some_and(|ch| ch.is_ascii_alphanumeric()))
1861}
1862
1863fn has_web_scheme(text: &str) -> bool {
1864 starts_with_ascii_ci(text, "http://") || starts_with_ascii_ci(text, "https://")
1865}
1866
1867fn starts_with_ascii_ci(text: &str, prefix: &str) -> bool {
1868 text.as_bytes()
1869 .get(..prefix.len())
1870 .is_some_and(|bytes| bytes.eq_ignore_ascii_case(prefix.as_bytes()))
1871}
1872
1873fn strip_ascii_prefix<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
1874 starts_with_ascii_ci(text, prefix).then(|| &text[prefix.len()..])
1875}
1876
1877fn domain_host_has_valid_tld_shape(host: &str) -> bool {
1878 domain_host_tld(host).is_some_and(|tld| tld.len() >= 2)
1879}
1880
1881fn domain_host_has_allowed_tld(host: &str, top_level_domains: &[String]) -> bool {
1882 domain_host_tld(host)
1883 .map(|tld| top_level_domains.iter().any(|domain| domain == &tld))
1884 .unwrap_or(false)
1885}
1886
1887fn domain_host_tld(host: &str) -> Option<String> {
1888 let host = host.trim_matches('.');
1889 let mut saw_dot = false;
1890 let mut tld = None;
1891 for label in host.split('.') {
1892 if label.is_empty()
1893 || label.starts_with('-')
1894 || label.ends_with('-')
1895 || !label
1896 .chars()
1897 .all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
1898 {
1899 return None;
1900 }
1901 if tld.is_some() {
1902 saw_dot = true;
1903 }
1904 tld = Some(label);
1905 }
1906 saw_dot.then(|| {
1907 tld.expect("tld present after label loop")
1908 .to_ascii_lowercase()
1909 })
1910}
1911
1912fn default_top_level_domains() -> Vec<String> {
1913 [
1914 "com", "org", "net", "edu", "gov", "mil", "int", "io", "co", "ai", "dev", "app", "info",
1915 "biz", "name", "pro", "us", "uk", "ca", "au", "de", "fr", "jp", "kr", "cn", "in", "br",
1916 "mx", "es", "it", "nl", "se", "no", "fi", "dk", "ch", "at", "be", "ie", "nz", "za", "sg",
1917 "hk", "tw", "ru", "pl", "cz", "me", "tv", "ly", "xyz", "site", "online", "tech",
1918 ]
1919 .into_iter()
1920 .map(str::to_string)
1921 .collect()
1922}
1923
1924fn take_while_nonspace(text: &str) -> usize {
1925 text.char_indices()
1926 .find_map(|(idx, ch)| ch.is_whitespace().then_some(idx))
1927 .unwrap_or(text.len())
1928}
1929
1930fn is_trailing_punctuation(ch: char) -> bool {
1931 matches!(ch, '.' | ',' | ';' | ':' | '!' | '?')
1932}
1933
1934fn tokenized_document_value(
1935 documents: Vec<Vec<String>>,
1936 shape: Vec<usize>,
1937 options: DocumentOptions,
1938 type_details: Option<Vec<Vec<String>>>,
1939) -> BuiltinResult<Value> {
1940 ensure_tokenized_document_class_registered();
1941 let mut object = ObjectInstance::new(TOKENIZED_DOCUMENT_CLASS.to_string());
1942 object
1943 .properties
1944 .insert("Documents".to_string(), documents_cell(&documents)?);
1945 object
1946 .properties
1947 .insert("Vocabulary".to_string(), vocabulary_value(&documents)?);
1948 object.properties.insert(
1949 "NumDocuments".to_string(),
1950 Value::Num(documents.len() as f64),
1951 );
1952 object.properties.insert(
1953 "DocumentLengths".to_string(),
1954 Value::Tensor(
1955 Tensor::new(
1956 documents.iter().map(|doc| doc.len() as f64).collect(),
1957 vec![documents.len(), 1],
1958 )
1959 .map_err(|err| text_analytics_error("tokenizedDocument", err))?,
1960 ),
1961 );
1962 object.properties.insert(
1963 "Shape".to_string(),
1964 Value::Tensor(
1965 Tensor::new(
1966 shape.iter().map(|dim| *dim as f64).collect(),
1967 vec![1, shape.len()],
1968 )
1969 .map_err(|err| text_analytics_error("tokenizedDocument", err))?,
1970 ),
1971 );
1972 object.properties.insert(
1973 "TokenizeMethod".to_string(),
1974 Value::String(
1975 match options.tokenize_method {
1976 TokenizeMethod::Unicode => "unicode",
1977 TokenizeMethod::None => "none",
1978 }
1979 .to_string(),
1980 ),
1981 );
1982 object
1983 .properties
1984 .insert("Language".to_string(), Value::String(options.language));
1985 object.properties.insert(
1986 "DetectPatterns".to_string(),
1987 detect_patterns_value(options.detect_patterns)?,
1988 );
1989 object.properties.insert(
1990 "TopLevelDomains".to_string(),
1991 top_level_domains_value(&options.top_level_domains, "tokenizedDocument")?,
1992 );
1993 object.properties.insert(
1994 "TopLevelDomainsCustom".to_string(),
1995 Value::Bool(options.top_level_domains_custom),
1996 );
1997 if let Some(type_details) = type_details {
1998 object
1999 .properties
2000 .insert("TypeDetails".to_string(), type_details_cell(&type_details)?);
2001 }
2002 Ok(Value::Object(object))
2003}
2004
2005fn type_details_cell(type_details: &[Vec<String>]) -> BuiltinResult<Value> {
2006 let values = type_details
2007 .iter()
2008 .map(|types| {
2009 StringArray::new(types.clone(), vec![1, types.len()])
2010 .map(Value::StringArray)
2011 .map_err(|err| text_analytics_error("tokenizedDocument", err))
2012 })
2013 .collect::<BuiltinResult<Vec<_>>>()?;
2014 Ok(Value::Cell(
2015 CellArray::new(values, type_details.len(), 1)
2016 .map_err(|err| text_analytics_error("tokenizedDocument", err))?,
2017 ))
2018}
2019
2020fn detect_patterns_value(patterns: DetectPatterns) -> BuiltinResult<Value> {
2021 match patterns {
2022 DetectPatterns::All => Ok(Value::String("all".to_string())),
2023 DetectPatterns::None => Ok(Value::String("none".to_string())),
2024 DetectPatterns::Selected(selected) => {
2025 let mut values = Vec::new();
2026 if selected.email_address {
2027 values.push("email-address".to_string());
2028 }
2029 if selected.web_address {
2030 values.push("web-address".to_string());
2031 }
2032 if selected.hashtag {
2033 values.push("hashtag".to_string());
2034 }
2035 if selected.at_mention {
2036 values.push("at-mention".to_string());
2037 }
2038 if selected.emoticon {
2039 values.push("emoticon".to_string());
2040 }
2041 StringArray::new(values.clone(), vec![1, values.len()])
2042 .map(Value::StringArray)
2043 .map_err(|err| text_analytics_error("tokenizedDocument", err))
2044 }
2045 }
2046}
2047
2048pub(in crate::builtins::strings::text_analytics) fn top_level_domains_value(
2049 domains: &[String],
2050 fn_name: &str,
2051) -> BuiltinResult<Value> {
2052 StringArray::new(domains.to_vec(), vec![1, domains.len()])
2053 .map(Value::StringArray)
2054 .map_err(|err| text_analytics_error(fn_name, err))
2055}
2056
2057pub(in crate::builtins::strings::text_analytics) fn tokenized_document_language(
2058 object: &ObjectInstance,
2059) -> String {
2060 match object.properties.get("Language") {
2061 Some(Value::String(value)) => value.clone(),
2062 _ => "en".to_string(),
2063 }
2064}
2065
2066pub(in crate::builtins::strings::text_analytics) fn transform_tokenized_document(
2067 object: &ObjectInstance,
2068 fn_name: &str,
2069 mut transform: impl FnMut(&str, DocumentTokenType) -> BuiltinResult<Option<String>>,
2070) -> BuiltinResult<Value> {
2071 let options = options_from_document_object(object);
2072 let documents = documents_from_object(object, fn_name)?
2073 .into_iter()
2074 .map(|doc| {
2075 doc.into_iter()
2076 .filter_map(|token| {
2077 match transform(&token, document_token_type_with_options(&token, &options)) {
2078 Ok(Some(value)) => Some(Ok(value)),
2079 Ok(None) => None,
2080 Err(err) => Some(Err(err)),
2081 }
2082 })
2083 .collect::<BuiltinResult<Vec<_>>>()
2084 })
2085 .collect::<BuiltinResult<Vec<_>>>()?;
2086 tokenized_document_value(documents, shape_from_object(object), options, None)
2087}
2088
2089#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2090pub(in crate::builtins::strings::text_analytics) enum DocumentTokenType {
2091 Letters,
2092 Digits,
2093 Punctuation,
2094 Other,
2095 WebAddress,
2096 EmailAddress,
2097 Hashtag,
2098 AtMention,
2099 Emoticon,
2100 Emoji,
2101}
2102
2103impl DocumentTokenType {
2104 pub(in crate::builtins::strings::text_analytics) fn as_str(self) -> &'static str {
2105 match self {
2106 Self::Letters => "letters",
2107 Self::Digits => "digits",
2108 Self::Punctuation => "punctuation",
2109 Self::Other => "other",
2110 Self::WebAddress => "web-address",
2111 Self::EmailAddress => "email-address",
2112 Self::Hashtag => "hashtag",
2113 Self::AtMention => "at-mention",
2114 Self::Emoticon => "emoticon",
2115 Self::Emoji => "emoji",
2116 }
2117 }
2118}
2119
2120pub(in crate::builtins::strings) fn erase_punctuation_tokenized_document(
2121 object: ObjectInstance,
2122 args: Vec<Value>,
2123) -> BuiltinResult<Value> {
2124 if !object.is_class(TOKENIZED_DOCUMENT_CLASS) {
2125 return Err(text_analytics_error(
2126 "erasePunctuation",
2127 format!(
2128 "erasePunctuation: expected tokenizedDocument object, got {}",
2129 object.class_name
2130 ),
2131 ));
2132 }
2133 let selected_types = parse_erase_punctuation_args(args)?;
2134 transform_tokenized_document(&object, "erasePunctuation", |token, token_type| {
2135 if !selected_types.contains(token_type.as_str()) {
2136 return Ok(Some(token.to_string()));
2137 }
2138 let cleaned = remove_punctuation_and_symbols(token);
2139 Ok((!cleaned.is_empty()).then_some(cleaned))
2140 })
2141}
2142
2143fn parse_erase_punctuation_args(args: Vec<Value>) -> BuiltinResult<HashSet<String>> {
2144 match args.as_slice() {
2145 [] => Ok(["punctuation".to_string(), "other".to_string()]
2146 .into_iter()
2147 .collect()),
2148 [name, types] => {
2149 let option = scalar_text(name, "erasePunctuation")
2150 .map_err(|err| text_analytics_error("erasePunctuation", err.to_string()))?
2151 .to_ascii_lowercase();
2152 if option != "tokentypes" {
2153 return Err(text_analytics_error(
2154 "erasePunctuation",
2155 format!("erasePunctuation: unsupported option '{option}'"),
2156 ));
2157 }
2158 let parsed = words_from_word_vector(types, "erasePunctuation")?
2159 .into_iter()
2160 .map(|token_type| token_type.trim().to_ascii_lowercase())
2161 .filter(|token_type| !token_type.is_empty())
2162 .collect::<HashSet<_>>();
2163 if parsed.is_empty() {
2164 return Err(text_analytics_error(
2165 "erasePunctuation",
2166 "erasePunctuation: TokenTypes must contain at least one token type",
2167 ));
2168 }
2169 Ok(parsed)
2170 }
2171 _ => Err(text_analytics_error(
2172 "erasePunctuation",
2173 "erasePunctuation: expected erasePunctuation(documents) or erasePunctuation(documents,'TokenTypes',types)",
2174 )),
2175 }
2176}
2177
2178pub(in crate::builtins::strings::text_analytics) fn document_token_type(
2179 token: &str,
2180) -> DocumentTokenType {
2181 document_token_type_with_options(token, &DEFAULT_DOCUMENT_OPTIONS)
2182}
2183
2184pub(in crate::builtins::strings::text_analytics) fn document_token_type_with_options(
2185 token: &str,
2186 options: &DocumentOptions,
2187) -> DocumentTokenType {
2188 if is_web_address_token(token, options) {
2189 return DocumentTokenType::WebAddress;
2190 }
2191 if email_token_at(token, 0).is_some_and(|(_, end)| end == token.len()) {
2192 return DocumentTokenType::EmailAddress;
2193 }
2194 if let Some(tag) = token.strip_prefix('#') {
2195 if !tag.is_empty()
2196 && tag
2197 .chars()
2198 .next()
2199 .is_some_and(|ch| ch.is_ascii_alphabetic())
2200 && tag
2201 .chars()
2202 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
2203 {
2204 return DocumentTokenType::Hashtag;
2205 }
2206 }
2207 if let Some(mention) = token.strip_prefix('@') {
2208 let len = mention.chars().count();
2209 if (1..=15).contains(&len)
2210 && mention
2211 .chars()
2212 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
2213 {
2214 return DocumentTokenType::AtMention;
2215 }
2216 }
2217 if matches!(token, ":-)" | ":-D" | ":)" | ":D") {
2218 return DocumentTokenType::Emoticon;
2219 }
2220 if !token.is_empty() && token.chars().all(char::is_numeric) {
2221 return DocumentTokenType::Digits;
2222 }
2223 if !token.is_empty() && token.chars().all(is_emoji_char) {
2224 return DocumentTokenType::Emoji;
2225 }
2226 if token
2227 .chars()
2228 .all(|ch| !ch.is_alphanumeric() && !ch.is_whitespace())
2229 {
2230 return DocumentTokenType::Punctuation;
2231 }
2232 if token.chars().any(char::is_alphabetic)
2233 && token
2234 .chars()
2235 .all(|ch| ch.is_alphabetic() || ch == '\'' || ch == '-' || ch == '_')
2236 {
2237 return DocumentTokenType::Letters;
2238 }
2239 DocumentTokenType::Other
2240}
2241
2242fn is_emoji_char(ch: char) -> bool {
2243 matches!(
2244 ch as u32,
2245 0x1F300..=0x1FAFF | 0x2600..=0x27BF | 0x2300..=0x23FF | 0xFE0F
2246 )
2247}
2248
2249static DEFAULT_DOCUMENT_OPTIONS: Lazy<DocumentOptions> = Lazy::new(DocumentOptions::default);
2250
2251fn remove_punctuation_and_symbols(text: &str) -> String {
2252 static PUNCTUATION_OR_SYMBOL: Lazy<Regex> =
2253 Lazy::new(|| Regex::new(r"[\p{P}\p{S}]").expect("valid punctuation regex"));
2254 PUNCTUATION_OR_SYMBOL.replace_all(text, "").to_string()
2255}
2256
2257fn stop_words_language_from_document_object(
2258 object: &ObjectInstance,
2259 fn_name: &str,
2260) -> BuiltinResult<StopWordsLanguage> {
2261 match tokenized_document_language(object)
2262 .trim()
2263 .to_ascii_lowercase()
2264 .as_str()
2265 {
2266 "en" => Ok(StopWordsLanguage::English),
2267 "de" => Ok(StopWordsLanguage::German),
2268 "ja" => Ok(StopWordsLanguage::Japanese),
2269 "ko" => Ok(StopWordsLanguage::Korean),
2270 other => Err(text_analytics_error(
2271 fn_name,
2272 format!("{fn_name}: unsupported document language '{other}'"),
2273 )),
2274 }
2275}
2276
2277fn parse_bool_scalar(value: &Value, fn_name: &str) -> BuiltinResult<bool> {
2278 match value {
2279 Value::Bool(value) => Ok(*value),
2280 Value::Num(value) if *value == 0.0 || *value == 1.0 => Ok(*value != 0.0),
2281 Value::Tensor(tensor) if tensor.data.len() == 1 => match tensor.data[0] {
2282 0.0 => Ok(false),
2283 1.0 => Ok(true),
2284 other => Err(text_analytics_error(
2285 fn_name,
2286 format!("{fn_name}: logical scalar option must be true or false, got {other}"),
2287 )),
2288 },
2289 other => Err(text_analytics_error(
2290 fn_name,
2291 format!("{fn_name}: logical scalar option must be true or false, got {other:?}"),
2292 )),
2293 }
2294}
2295
2296fn documents_cell(documents: &[Vec<String>]) -> BuiltinResult<Value> {
2297 let values = documents
2298 .iter()
2299 .map(|doc| {
2300 StringArray::new(doc.clone(), vec![1, doc.len()])
2301 .map(Value::StringArray)
2302 .map_err(|err| text_analytics_error("tokenizedDocument", err))
2303 })
2304 .collect::<BuiltinResult<Vec<_>>>()?;
2305 Ok(Value::Cell(
2306 CellArray::new(values, documents.len(), 1)
2307 .map_err(|err| text_analytics_error("tokenizedDocument", err))?,
2308 ))
2309}
2310
2311fn vocabulary_value(documents: &[Vec<String>]) -> BuiltinResult<Value> {
2312 let mut seen = HashSet::new();
2313 let mut words = Vec::new();
2314 for token in documents.iter().flatten() {
2315 if seen.insert(token.clone()) {
2316 words.push(token.clone());
2317 }
2318 }
2319 StringArray::new(words.clone(), vec![1, words.len()])
2320 .map(Value::StringArray)
2321 .map_err(|err| text_analytics_error("tokenizedDocument", err))
2322}
2323
2324pub(in crate::builtins::strings::text_analytics) fn documents_from_object(
2325 object: &ObjectInstance,
2326 fn_name: &str,
2327) -> BuiltinResult<Vec<Vec<String>>> {
2328 let Some(Value::Cell(cell)) = object.properties.get("Documents") else {
2329 return Err(text_analytics_error(
2330 fn_name,
2331 format!("{fn_name}: tokenizedDocument object missing Documents property"),
2332 ));
2333 };
2334 let mut documents = Vec::with_capacity(cell.data.len());
2335 for item in &cell.data {
2336 documents.push(words_from_word_vector(item, fn_name)?);
2337 }
2338 Ok(documents)
2339}
2340
2341pub(in crate::builtins::strings::text_analytics) fn replace_tokenized_document_documents(
2342 object: &mut ObjectInstance,
2343 documents: Vec<Vec<String>>,
2344 fn_name: &str,
2345) -> BuiltinResult<()> {
2346 let shape = document_shape_from_object(object, fn_name)?;
2347 let expected = shape.iter().try_fold(1usize, |acc, dim| {
2348 acc.checked_mul(*dim).ok_or_else(|| {
2349 text_analytics_error(
2350 fn_name,
2351 format!("{fn_name}: tokenizedDocument Shape property overflows element count"),
2352 )
2353 })
2354 })?;
2355 if expected != documents.len() {
2356 return Err(text_analytics_error(
2357 fn_name,
2358 format!(
2359 "{fn_name}: replacement documents has {} documents but Shape requires {expected}",
2360 documents.len()
2361 ),
2362 ));
2363 }
2364 object
2365 .properties
2366 .insert("Documents".to_string(), documents_cell(&documents)?);
2367 object
2368 .properties
2369 .insert("Vocabulary".to_string(), vocabulary_value(&documents)?);
2370 object.properties.insert(
2371 "NumDocuments".to_string(),
2372 Value::Num(documents.len() as f64),
2373 );
2374 object.properties.insert(
2375 "DocumentLengths".to_string(),
2376 Value::Tensor(
2377 Tensor::new(
2378 documents.iter().map(|doc| doc.len() as f64).collect(),
2379 vec![documents.len(), 1],
2380 )
2381 .map_err(|err| text_analytics_error(fn_name, err))?,
2382 ),
2383 );
2384 Ok(())
2385}
2386
2387pub(in crate::builtins::strings::text_analytics) fn document_shape_from_object(
2388 object: &ObjectInstance,
2389 fn_name: &str,
2390) -> BuiltinResult<Vec<usize>> {
2391 let expected = object
2392 .properties
2393 .get("Documents")
2394 .and_then(|value| match value {
2395 Value::Cell(cell) => Some(cell.data.len()),
2396 _ => None,
2397 })
2398 .or_else(|| {
2399 object
2400 .properties
2401 .get("NumDocuments")
2402 .and_then(|value| match value {
2403 Value::Num(n) if n.is_finite() && *n >= 0.0 => Some(*n as usize),
2404 _ => None,
2405 })
2406 })
2407 .unwrap_or(1);
2408 let shape = shape_from_object(object);
2409 let cells = shape.iter().try_fold(1usize, |acc, dim| {
2410 acc.checked_mul(*dim).ok_or_else(|| {
2411 text_analytics_error(
2412 fn_name,
2413 format!("{fn_name}: tokenizedDocument Shape property overflows element count"),
2414 )
2415 })
2416 })?;
2417 if cells != expected {
2418 return Err(text_analytics_error(
2419 fn_name,
2420 format!(
2421 "{fn_name}: tokenizedDocument Shape property has {cells} elements but Documents has {expected}"
2422 ),
2423 ));
2424 }
2425 Ok(shape)
2426}
2427
2428fn shape_from_object(object: &ObjectInstance) -> Vec<usize> {
2429 if let Some(Value::Tensor(tensor)) = object.properties.get("Shape") {
2430 tensor.data.iter().map(|value| *value as usize).collect()
2431 } else {
2432 vec![
2433 object
2434 .properties
2435 .get("NumDocuments")
2436 .and_then(|value| match value {
2437 Value::Num(n) => Some(*n as usize),
2438 _ => None,
2439 })
2440 .unwrap_or(1),
2441 1,
2442 ]
2443 }
2444}
2445
2446pub(in crate::builtins::strings::text_analytics) fn options_from_document_object(
2447 object: &ObjectInstance,
2448) -> DocumentOptions {
2449 let tokenize_method = match object.properties.get("TokenizeMethod") {
2450 Some(Value::String(value)) if value == "none" => TokenizeMethod::None,
2451 _ => TokenizeMethod::Unicode,
2452 };
2453 let language = match object.properties.get("Language") {
2454 Some(Value::String(value)) => value.clone(),
2455 _ => "en".to_string(),
2456 };
2457 let detect_patterns = object
2458 .properties
2459 .get("DetectPatterns")
2460 .and_then(|value| parse_detect_patterns(value, "tokenizedDocument").ok())
2461 .unwrap_or(DetectPatterns::All);
2462 let top_level_domains = object
2463 .properties
2464 .get("TopLevelDomains")
2465 .and_then(|value| parse_top_level_domains(value, "tokenizedDocument").ok())
2466 .unwrap_or_else(default_top_level_domains);
2467 let top_level_domains_custom = match object.properties.get("TopLevelDomainsCustom") {
2468 Some(Value::Bool(value)) => *value,
2469 _ => false,
2470 };
2471 DocumentOptions {
2472 tokenize_method,
2473 language,
2474 detect_patterns,
2475 top_level_domains,
2476 top_level_domains_custom,
2477 custom_tokens: Vec::new(),
2478 regular_expressions: Vec::new(),
2479 }
2480}
2481
2482fn bag_from_documents(documents: Vec<Vec<String>>) -> BuiltinResult<Value> {
2483 let mut vocabulary = Vec::new();
2484 let mut positions = BTreeMap::new();
2485 for token in documents.iter().flatten() {
2486 if !positions.contains_key(token) {
2487 positions.insert(token.clone(), vocabulary.len());
2488 vocabulary.push(token.clone());
2489 }
2490 }
2491 let rows = documents.len();
2492 let cols = vocabulary.len();
2493 let mut counts = vec![0.0; checked_count_len(rows, cols, "bagOfWords")?];
2494 for (doc_idx, doc) in documents.iter().enumerate() {
2495 for token in doc {
2496 if let Some(col) = positions.get(token) {
2497 counts[doc_idx + col * rows] += 1.0;
2498 }
2499 }
2500 }
2501 bag_object(vocabulary, counts, rows)
2502}
2503
2504fn bag_from_unique_words_and_counts(words: &Value, counts: &Value) -> BuiltinResult<Value> {
2505 let raw_words = words_from_word_vector_preserving_missing(words, "bagOfWords")?;
2506 let Value::Tensor(tensor) = counts else {
2507 return Err(text_analytics_error(
2508 "bagOfWords",
2509 format!("bagOfWords: counts must be a numeric matrix, got {counts:?}"),
2510 ));
2511 };
2512 if tensor.cols != raw_words.len() {
2513 return Err(text_analytics_error(
2514 "bagOfWords",
2515 format!(
2516 "bagOfWords: counts columns ({}) must match uniqueWords length ({})",
2517 tensor.cols,
2518 raw_words.len()
2519 ),
2520 ));
2521 }
2522 if tensor
2523 .data
2524 .iter()
2525 .any(|value| !value.is_finite() || *value < 0.0 || value.fract() != 0.0)
2526 {
2527 return Err(text_analytics_error(
2528 "bagOfWords",
2529 "bagOfWords: counts must be nonnegative integers",
2530 ));
2531 }
2532 let mut seen = HashSet::new();
2533 let mut vocabulary = Vec::new();
2534 let mut keep_cols = Vec::new();
2535 for (col, word) in raw_words.iter().enumerate() {
2536 if is_missing_string(word) {
2537 continue;
2538 }
2539 if !seen.insert(word.clone()) {
2540 return Err(text_analytics_error(
2541 "bagOfWords",
2542 format!("bagOfWords: uniqueWords contains duplicate word '{word}'"),
2543 ));
2544 }
2545 vocabulary.push(word.clone());
2546 keep_cols.push(col);
2547 }
2548 let mut filtered_counts = Vec::with_capacity(checked_count_len(
2549 tensor.rows,
2550 keep_cols.len(),
2551 "bagOfWords",
2552 )?);
2553 for col in keep_cols {
2554 for row in 0..tensor.rows {
2555 filtered_counts.push(tensor.data[row + col * tensor.rows]);
2556 }
2557 }
2558 bag_object(vocabulary, filtered_counts, tensor.rows)
2559}
2560
2561fn bag_object(vocabulary: Vec<String>, counts: Vec<f64>, rows: usize) -> BuiltinResult<Value> {
2562 ensure_bag_of_words_class_registered();
2563 let cols = vocabulary.len();
2564 let expected = checked_count_len(rows, cols, "bagOfWords")?;
2565 if counts.len() != expected {
2566 return Err(text_analytics_error(
2567 "bagOfWords",
2568 format!(
2569 "bagOfWords: count storage has {} values but expected {} for a {}x{} model",
2570 counts.len(),
2571 expected,
2572 rows,
2573 cols
2574 ),
2575 ));
2576 }
2577 let mut object = ObjectInstance::new(BAG_OF_WORDS_CLASS.to_string());
2578 object.properties.insert(
2579 "Vocabulary".to_string(),
2580 Value::StringArray(
2581 StringArray::new(vocabulary.clone(), vec![1, vocabulary.len()])
2582 .map_err(|err| text_analytics_error("bagOfWords", err))?,
2583 ),
2584 );
2585 object.properties.insert(
2586 "Counts".to_string(),
2587 Value::Tensor(
2588 Tensor::new(counts, vec![rows, cols])
2589 .map_err(|err| text_analytics_error("bagOfWords", err))?,
2590 ),
2591 );
2592 object
2593 .properties
2594 .insert("NumWords".to_string(), Value::Num(cols as f64));
2595 object
2596 .properties
2597 .insert("NumDocuments".to_string(), Value::Num(rows as f64));
2598 Ok(Value::Object(object))
2599}
2600
2601pub(in crate::builtins::strings::text_analytics) fn checked_count_len(
2602 rows: usize,
2603 cols: usize,
2604 fn_name: &str,
2605) -> BuiltinResult<usize> {
2606 let len = rows.checked_mul(cols).ok_or_else(|| {
2607 text_analytics_error(
2608 fn_name,
2609 format!("{fn_name}: bag count matrix dimensions overflow"),
2610 )
2611 })?;
2612 if len > MAX_DENSE_BAG_COUNT_CELLS {
2613 return Err(text_analytics_error(
2614 fn_name,
2615 format!(
2616 "{fn_name}: dense bag count matrix would require {len} values; sparse bag storage remains tracked"
2617 ),
2618 ));
2619 }
2620 Ok(len)
2621}
2622
2623fn remove_short_words_from_bag(object: ObjectInstance, max_len: usize) -> BuiltinResult<Value> {
2624 filter_bag_columns_by_predicate(object, "removeShortWords", |word| {
2625 word.chars().count() > max_len
2626 })
2627}
2628
2629fn filter_bag_columns_by_predicate(
2630 object: ObjectInstance,
2631 fn_name: &str,
2632 mut keep_word: impl FnMut(&str) -> bool,
2633) -> BuiltinResult<Value> {
2634 filter_bag_columns(object, fn_name, |word| keep_word(word))
2635}
2636
2637fn filter_bag_columns(
2638 object: ObjectInstance,
2639 fn_name: &str,
2640 mut keep_word: impl FnMut(&str) -> bool,
2641) -> BuiltinResult<Value> {
2642 let vocabulary = vocabulary_from_bag(&object, fn_name)?;
2643 let counts = counts_from_bag(&object, fn_name)?;
2644 let keep = vocabulary
2645 .iter()
2646 .enumerate()
2647 .filter_map(|(idx, word)| keep_word(word).then_some(idx))
2648 .collect::<Vec<_>>();
2649 let mut new_vocab = Vec::with_capacity(keep.len());
2650 let mut new_counts = Vec::with_capacity(counts.rows * keep.len());
2651 for col in keep {
2652 new_vocab.push(vocabulary[col].clone());
2653 for row in 0..counts.rows {
2654 new_counts.push(counts.data[row + col * counts.rows]);
2655 }
2656 }
2657 bag_object(new_vocab, new_counts, counts.rows)
2658}
2659
2660fn documents_vocabulary(object: &ObjectInstance, fn_name: &str) -> BuiltinResult<Vec<String>> {
2661 match object.properties.get("Vocabulary") {
2662 Some(value) => words_from_word_vector(value, fn_name),
2663 None => Ok(vocabulary_from_documents(&documents_from_object(
2664 object, fn_name,
2665 )?)),
2666 }
2667}
2668
2669fn vocabulary_from_documents(documents: &[Vec<String>]) -> Vec<String> {
2670 let mut seen = HashSet::new();
2671 let mut vocabulary = Vec::new();
2672 for token in documents.iter().flatten() {
2673 if seen.insert(token.clone()) {
2674 vocabulary.push(token.clone());
2675 }
2676 }
2677 vocabulary
2678}
2679
2680pub(in crate::builtins::strings::text_analytics) fn vocabulary_from_bag(
2681 object: &ObjectInstance,
2682 fn_name: &str,
2683) -> BuiltinResult<Vec<String>> {
2684 match object.properties.get("Vocabulary") {
2685 Some(value) => words_from_word_vector(value, fn_name),
2686 None => Err(text_analytics_error(
2687 fn_name,
2688 format!("{fn_name}: bagOfWords object missing Vocabulary property"),
2689 )),
2690 }
2691}
2692
2693pub(in crate::builtins::strings::text_analytics) fn counts_from_bag(
2694 object: &ObjectInstance,
2695 fn_name: &str,
2696) -> BuiltinResult<Tensor> {
2697 match object.properties.get("Counts") {
2698 Some(Value::Tensor(tensor)) => Ok(tensor.clone()),
2699 _ => Err(text_analytics_error(
2700 fn_name,
2701 format!("{fn_name}: bagOfWords object missing Counts property"),
2702 )),
2703 }
2704}
2705
2706pub(in crate::builtins::strings::text_analytics) fn words_from_word_vector(
2707 value: &Value,
2708 fn_name: &str,
2709) -> BuiltinResult<Vec<String>> {
2710 match value {
2711 Value::String(text) => Ok(vec![text.clone()]),
2712 Value::StringArray(array) => Ok(array
2713 .data
2714 .iter()
2715 .filter(|text| !is_missing_string(text))
2716 .cloned()
2717 .collect()),
2718 Value::CharArray(array) if array.rows <= 1 => {
2719 let text = if array.rows == 0 {
2720 String::new()
2721 } else {
2722 char_row_to_string_slice(&array.data, array.cols, 0)
2723 };
2724 Ok(vec![text])
2725 }
2726 Value::CharArray(array) => {
2727 let mut words = Vec::with_capacity(array.rows);
2728 for row in 0..array.rows {
2729 words.push(char_row_to_string_slice(&array.data, array.cols, row));
2730 }
2731 Ok(words)
2732 }
2733 Value::Cell(cell) => cell
2734 .data
2735 .iter()
2736 .map(|item| {
2737 scalar_text(item, fn_name)
2738 .map_err(|err| text_analytics_error(fn_name, err.to_string()))
2739 })
2740 .collect(),
2741 other => Err(text_analytics_error(
2742 fn_name,
2743 format!("{fn_name}: expected word vector, got {other:?}"),
2744 )),
2745 }
2746}
2747
2748pub(in crate::builtins::strings::text_analytics) fn words_from_word_vector_preserving_missing(
2749 value: &Value,
2750 fn_name: &str,
2751) -> BuiltinResult<Vec<String>> {
2752 match value {
2753 Value::String(text) => Ok(vec![text.clone()]),
2754 Value::StringArray(array) => Ok(array.data.clone()),
2755 Value::CharArray(array) if array.rows <= 1 => {
2756 let text = if array.rows == 0 {
2757 String::new()
2758 } else {
2759 char_row_to_string_slice(&array.data, array.cols, 0)
2760 };
2761 Ok(vec![text])
2762 }
2763 Value::CharArray(array) => {
2764 let mut words = Vec::with_capacity(array.rows);
2765 for row in 0..array.rows {
2766 words.push(char_row_to_string_slice(&array.data, array.cols, row));
2767 }
2768 Ok(words)
2769 }
2770 Value::Cell(cell) => cell
2771 .data
2772 .iter()
2773 .map(|item| {
2774 scalar_text(item, fn_name)
2775 .map_err(|err| text_analytics_error(fn_name, err.to_string()))
2776 })
2777 .collect(),
2778 other => Err(text_analytics_error(
2779 fn_name,
2780 format!("{fn_name}: expected word vector, got {other:?}"),
2781 )),
2782 }
2783}
2784
2785fn parse_positive_integer(value: &Value, fn_name: &str) -> BuiltinResult<usize> {
2786 let n = match value {
2787 Value::Num(n) => *n,
2788 Value::Tensor(tensor) if tensor.data.len() == 1 => tensor.data[0],
2789 other => {
2790 return Err(text_analytics_error(
2791 fn_name,
2792 format!("{fn_name}: length must be a positive integer scalar, got {other:?}"),
2793 ))
2794 }
2795 };
2796 if !n.is_finite() || n <= 0.0 || n.fract() != 0.0 {
2797 return Err(text_analytics_error(
2798 fn_name,
2799 format!("{fn_name}: length must be a positive integer, got {n}"),
2800 ));
2801 }
2802 Ok(n as usize)
2803}
2804
2805#[cfg(test)]
2806mod tests {
2807 use super::*;
2808 use crate::builtins::table::table_from_columns;
2809
2810 fn run_tokenized(args: Vec<Value>) -> BuiltinResult<Value> {
2811 futures::executor::block_on(tokenized_document_builtin(args))
2812 }
2813
2814 fn run_bag(args: Vec<Value>) -> BuiltinResult<Value> {
2815 futures::executor::block_on(bag_of_words_builtin(args))
2816 }
2817
2818 fn run_remove_short(value: Value, len: Value) -> BuiltinResult<Value> {
2819 futures::executor::block_on(remove_short_words_builtin(value, len))
2820 }
2821
2822 fn run_remove_long(value: Value, len: Value) -> BuiltinResult<Value> {
2823 futures::executor::block_on(remove_long_words_builtin(value, len))
2824 }
2825
2826 fn run_remove_words(args: Vec<Value>) -> BuiltinResult<Value> {
2827 futures::executor::block_on(remove_words_builtin(args))
2828 }
2829
2830 fn run_remove_stop(args: Vec<Value>) -> BuiltinResult<Value> {
2831 futures::executor::block_on(remove_stop_words_builtin(args))
2832 }
2833
2834 fn object(value: Value) -> ObjectInstance {
2835 let Value::Object(object) = value else {
2836 panic!("expected object");
2837 };
2838 object
2839 }
2840
2841 fn string_array_property(object: &ObjectInstance, name: &str) -> Vec<String> {
2842 let Some(Value::StringArray(array)) = object.properties.get(name) else {
2843 panic!("expected string array property {name}");
2844 };
2845 array.data.clone()
2846 }
2847
2848 fn tensor_property(object: &ObjectInstance, name: &str) -> Tensor {
2849 let Some(Value::Tensor(tensor)) = object.properties.get(name) else {
2850 panic!("expected tensor property {name}");
2851 };
2852 tensor.clone()
2853 }
2854
2855 fn documents_property(object: &ObjectInstance) -> Vec<Vec<String>> {
2856 documents_from_object(object, "test").expect("documents property")
2857 }
2858
2859 fn type_details_property(object: &ObjectInstance) -> Vec<Vec<String>> {
2860 let Some(Value::Cell(cell)) = object.properties.get("TypeDetails") else {
2861 panic!("expected TypeDetails property");
2862 };
2863 cell.data
2864 .iter()
2865 .map(|value| {
2866 let Value::StringArray(array) = value else {
2867 panic!("expected TypeDetails string array");
2868 };
2869 array.data.clone()
2870 })
2871 .collect()
2872 }
2873
2874 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2875 #[test]
2876 fn tokenized_document_tokenizes_string_array_and_vocabulary() {
2877 let input = StringArray::new(
2878 vec![
2879 "an example of a short sentence".to_string(),
2880 "a second short sentence".to_string(),
2881 ],
2882 vec![2, 1],
2883 )
2884 .unwrap();
2885 let doc = object(run_tokenized(vec![Value::StringArray(input)]).expect("tokenized"));
2886 assert_eq!(doc.class_name, TOKENIZED_DOCUMENT_CLASS);
2887 assert_eq!(doc.properties.get("NumDocuments"), Some(&Value::Num(2.0)));
2888 assert_eq!(
2889 string_array_property(&doc, "Vocabulary"),
2890 vec!["an", "example", "of", "a", "short", "sentence", "second"]
2891 );
2892 let lengths = tensor_property(&doc, "DocumentLengths");
2893 assert_eq!(lengths.data, vec![6.0, 4.0]);
2894 let shape = tensor_property(&doc, "Shape");
2895 assert_eq!(shape.data, vec![2.0, 1.0]);
2896 }
2897
2898 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2899 #[test]
2900 fn tokenized_document_none_accepts_pretokenized_words() {
2901 let input = StringArray::new(
2902 vec![
2903 "For".to_string(),
2904 "more".to_string(),
2905 "information".to_string(),
2906 ],
2907 vec![1, 3],
2908 )
2909 .unwrap();
2910 let doc = object(
2911 run_tokenized(vec![
2912 Value::StringArray(input),
2913 Value::String("TokenizeMethod".to_string()),
2914 Value::String("none".to_string()),
2915 ])
2916 .expect("tokenized"),
2917 );
2918 assert_eq!(
2919 string_array_property(&doc, "Vocabulary"),
2920 vec!["For", "more", "information"]
2921 );
2922 }
2923
2924 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2925 #[test]
2926 fn tokenized_document_no_input_is_scalar_empty_document() {
2927 let doc = object(run_tokenized(Vec::new()).expect("tokenized"));
2928 assert_eq!(doc.properties.get("NumDocuments"), Some(&Value::Num(1.0)));
2929 assert_eq!(
2930 string_array_property(&doc, "Vocabulary"),
2931 Vec::<String>::new()
2932 );
2933 let lengths = tensor_property(&doc, "DocumentLengths");
2934 assert_eq!(lengths.shape, vec![1, 1]);
2935 assert_eq!(lengths.data, vec![0.0]);
2936 let shape = tensor_property(&doc, "Shape");
2937 assert_eq!(shape.data, vec![1.0, 1.0]);
2938
2939 let bag = object(run_bag(vec![Value::Object(doc)]).expect("bag"));
2940 assert_eq!(bag.properties.get("NumDocuments"), Some(&Value::Num(1.0)));
2941 assert_eq!(bag.properties.get("NumWords"), Some(&Value::Num(0.0)));
2942 let counts = tensor_property(&bag, "Counts");
2943 assert_eq!(counts.shape, vec![1, 0]);
2944 assert!(counts.data.is_empty());
2945 }
2946
2947 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2948 #[test]
2949 fn tokenized_document_detects_complex_tokens_by_default() {
2950 let doc = object(
2951 run_tokenized(vec![Value::String(
2952 "Analyze #MATLAB :-) at help@example.com or https://www.mathworks.com/help/"
2953 .to_string(),
2954 )])
2955 .expect("tokenized"),
2956 );
2957 let vocabulary = string_array_property(&doc, "Vocabulary");
2958 assert!(vocabulary.contains(&"#MATLAB".to_string()));
2959 assert!(vocabulary.contains(&":-)".to_string()));
2960 assert!(vocabulary.contains(&"help@example.com".to_string()));
2961 assert!(vocabulary.contains(&"https://www.mathworks.com/help/".to_string()));
2962 }
2963
2964 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2965 #[test]
2966 fn tokenized_document_preserves_trailing_punctuation_after_url_token() {
2967 let doc = object(
2968 run_tokenized(vec![Value::String(
2969 "Visit https://example.com.".to_string(),
2970 )])
2971 .expect("tokenized"),
2972 );
2973 let vocabulary = string_array_property(&doc, "Vocabulary");
2974 assert!(vocabulary.contains(&"https://example.com".to_string()));
2975 assert!(vocabulary.contains(&".".to_string()));
2976 }
2977
2978 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
2979 #[test]
2980 fn tokenized_document_preserves_explicit_urls_with_uncommon_default_tlds() {
2981 let doc = object(
2982 run_tokenized(vec![Value::String(
2983 "Visit HTTPS://example.software and www.example.community.".to_string(),
2984 )])
2985 .expect("tokenized"),
2986 );
2987 assert_eq!(
2988 documents_property(&doc),
2989 vec![vec![
2990 "Visit",
2991 "HTTPS://example.software",
2992 "and",
2993 "www.example.community",
2994 "."
2995 ]]
2996 );
2997 }
2998
2999 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3000 #[test]
3001 fn tokenized_document_detects_bare_domains_with_configured_tlds() {
3002 let tlds = StringArray::new(vec!["zz".into(), "dev".into()], vec![1, 2]).unwrap();
3003 let doc = object(
3004 run_tokenized(vec![
3005 Value::String("See example.zz, docs.example.dev/path and example.com".to_string()),
3006 Value::String("TopLevelDomains".to_string()),
3007 Value::StringArray(tlds),
3008 ])
3009 .expect("tokenized"),
3010 );
3011 assert_eq!(
3012 documents_property(&doc),
3013 vec![vec![
3014 "See",
3015 "example.zz",
3016 ",",
3017 "docs.example.dev/path",
3018 "and",
3019 "example",
3020 ".",
3021 "com"
3022 ]]
3023 );
3024 assert_eq!(
3025 string_array_property(&doc, "TopLevelDomains"),
3026 vec!["zz", "dev"]
3027 );
3028 }
3029
3030 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3031 #[test]
3032 fn tokenized_document_respects_detect_patterns_subset() {
3033 let patterns =
3034 StringArray::new(vec!["hashtag".into(), "email-address".into()], vec![1, 2]).unwrap();
3035 let doc = object(
3036 run_tokenized(vec![
3037 Value::String("Mail a@example.com #MATLAB at https://example.com :-D".to_string()),
3038 Value::String("DetectPatterns".to_string()),
3039 Value::StringArray(patterns),
3040 ])
3041 .expect("tokenized"),
3042 );
3043 assert_eq!(
3044 documents_property(&doc),
3045 vec![vec![
3046 "Mail",
3047 "a@example.com",
3048 "#MATLAB",
3049 "at",
3050 "https",
3051 ":",
3052 "/",
3053 "/",
3054 "example",
3055 ".",
3056 "com",
3057 ":",
3058 "-",
3059 "D"
3060 ]]
3061 );
3062 }
3063
3064 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3065 #[test]
3066 fn tokenized_document_supports_custom_tokens_and_longest_conflict() {
3067 let custom = StringArray::new(vec!["C++".into(), "C++17".into()], vec![1, 2]).unwrap();
3068 let doc = object(
3069 run_tokenized(vec![
3070 Value::String("Use C++17 and C++.".to_string()),
3071 Value::String("CustomTokens".to_string()),
3072 Value::StringArray(custom),
3073 ])
3074 .expect("tokenized"),
3075 );
3076 assert_eq!(
3077 documents_property(&doc),
3078 vec![vec!["Use", "C++17", "and", "C++", "."]]
3079 );
3080 assert_eq!(
3081 type_details_property(&doc),
3082 vec![vec![
3083 "letters",
3084 "custom",
3085 "letters",
3086 "custom",
3087 "punctuation"
3088 ]]
3089 );
3090 }
3091
3092 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3093 #[test]
3094 fn tokenized_document_supports_custom_token_table_types() {
3095 let table = table_from_columns(
3096 vec!["Token".into(), "Type".into()],
3097 vec![
3098 Value::StringArray(
3099 StringArray::new(vec!["Na+".into(), "H2O".into()], vec![2, 1]).unwrap(),
3100 ),
3101 Value::StringArray(
3102 StringArray::new(vec!["ion".into(), "formula".into()], vec![2, 1]).unwrap(),
3103 ),
3104 ],
3105 )
3106 .expect("custom table");
3107 let doc = object(
3108 run_tokenized(vec![
3109 Value::String("Na+ in H2O".to_string()),
3110 Value::String("CustomTokens".to_string()),
3111 table,
3112 ])
3113 .expect("tokenized"),
3114 );
3115 assert_eq!(documents_property(&doc), vec![vec!["Na+", "in", "H2O"]]);
3116 assert_eq!(
3117 type_details_property(&doc),
3118 vec![vec!["ion", "letters", "formula"]]
3119 );
3120 }
3121
3122 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3123 #[test]
3124 fn tokenized_document_supports_regex_table_and_regex_precedence() {
3125 let custom = StringArray::new(vec!["abc123".into()], vec![1, 1]).unwrap();
3126 let regex_table = table_from_columns(
3127 vec!["Pattern".into(), "Type".into()],
3128 vec![
3129 Value::StringArray(
3130 StringArray::new(vec![r"[a-z]+\d+".into(), r"abc\d+".into()], vec![2, 1])
3131 .unwrap(),
3132 ),
3133 Value::StringArray(
3134 StringArray::new(vec!["alnum".into(), "code".into()], vec![2, 1]).unwrap(),
3135 ),
3136 ],
3137 )
3138 .expect("regex table");
3139 let doc = object(
3140 run_tokenized(vec![
3141 Value::String("abc123 xyz9".to_string()),
3142 Value::String("CustomTokens".to_string()),
3143 Value::StringArray(custom),
3144 Value::String("RegularExpressions".to_string()),
3145 regex_table,
3146 ])
3147 .expect("tokenized"),
3148 );
3149 assert_eq!(documents_property(&doc), vec![vec!["abc123", "xyz9"]]);
3150 assert_eq!(type_details_property(&doc), vec![vec!["code", "alnum"]]);
3151 }
3152
3153 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3154 #[test]
3155 fn tokenized_document_supports_non_table_custom_and_regex_vectors() {
3156 let custom = CellArray::new(
3157 vec![
3158 Value::String("R&D".to_string()),
3159 Value::String("C#".to_string()),
3160 ],
3161 1,
3162 2,
3163 )
3164 .unwrap();
3165 let regexes = StringArray::new(vec![r"\d{4}-\d{2}-\d{2}".into()], vec![1, 1]).unwrap();
3166 let doc = object(
3167 run_tokenized(vec![
3168 Value::String("R&D shipped C# on 2026-07-17.".to_string()),
3169 Value::String("CustomTokens".to_string()),
3170 Value::Cell(custom),
3171 Value::String("RegularExpressions".to_string()),
3172 Value::StringArray(regexes),
3173 ])
3174 .expect("tokenized"),
3175 );
3176
3177 assert_eq!(
3178 documents_property(&doc),
3179 vec![vec!["R&D", "shipped", "C#", "on", "2026-07-17", "."]]
3180 );
3181 assert_eq!(
3182 type_details_property(&doc),
3183 vec![vec![
3184 "custom",
3185 "letters",
3186 "custom",
3187 "letters",
3188 "custom",
3189 "punctuation"
3190 ]]
3191 );
3192 }
3193
3194 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3195 #[test]
3196 fn tokenized_document_rejects_invalid_custom_and_regex_options() {
3197 let err = run_tokenized(vec![
3198 Value::String("abc".to_string()),
3199 Value::String("RegularExpressions".to_string()),
3200 Value::String("(".to_string()),
3201 ])
3202 .expect_err("invalid regex");
3203 assert!(err
3204 .to_string()
3205 .contains("invalid RegularExpressions pattern"));
3206
3207 let err = run_tokenized(vec![
3208 Value::String("abc".to_string()),
3209 Value::String("RegularExpressions".to_string()),
3210 Value::String(".*".to_string()),
3211 ])
3212 .expect_err("empty regex");
3213 assert!(err.to_string().contains("can match empty text"));
3214
3215 let table = table_from_columns(
3216 vec!["Type".into()],
3217 vec![Value::StringArray(
3218 StringArray::new(vec!["custom".into()], vec![1, 1]).unwrap(),
3219 )],
3220 )
3221 .expect("bad custom table");
3222 let err = run_tokenized(vec![
3223 Value::String("abc".to_string()),
3224 Value::String("CustomTokens".to_string()),
3225 table,
3226 ])
3227 .expect_err("missing token variable");
3228 assert!(err.to_string().contains("must contain a Token variable"));
3229 }
3230
3231 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3232 #[test]
3233 fn bag_of_words_counts_tokenized_documents() {
3234 let input = StringArray::new(
3235 vec![
3236 "an example of a short sentence".to_string(),
3237 "a second short sentence".to_string(),
3238 ],
3239 vec![2, 1],
3240 )
3241 .unwrap();
3242 let docs = run_tokenized(vec![Value::StringArray(input)]).expect("tokenized");
3243 let bag = object(run_bag(vec![docs]).expect("bag"));
3244 assert_eq!(bag.class_name, BAG_OF_WORDS_CLASS);
3245 assert_eq!(
3246 string_array_property(&bag, "Vocabulary"),
3247 vec!["an", "example", "of", "a", "short", "sentence", "second"]
3248 );
3249 let Some(Value::Tensor(counts)) = bag.properties.get("Counts") else {
3250 panic!("expected Counts");
3251 };
3252 assert_eq!(counts.shape, vec![2, 7]);
3253 assert_eq!(counts.data[0], 1.0);
3254 assert_eq!(counts.data[1], 0.0);
3255 assert_eq!(counts.data[3 * 2], 1.0);
3256 assert_eq!(counts.data[3 * 2 + 1], 1.0);
3257 }
3258
3259 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3260 #[test]
3261 fn bag_of_words_accepts_unique_words_and_counts() {
3262 let words = StringArray::new(vec!["alpha".into(), "beta".into()], vec![1, 2]).unwrap();
3263 let counts = Tensor::new(vec![2.0, 0.0, 1.0, 3.0], vec![2, 2]).unwrap();
3264 let bag =
3265 object(run_bag(vec![Value::StringArray(words), Value::Tensor(counts)]).expect("bag"));
3266 assert_eq!(bag.properties.get("NumDocuments"), Some(&Value::Num(2.0)));
3267 assert_eq!(bag.properties.get("NumWords"), Some(&Value::Num(2.0)));
3268 }
3269
3270 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3271 #[test]
3272 fn bag_of_words_rejects_duplicate_unique_words() {
3273 let words = StringArray::new(vec!["alpha".into(), "alpha".into()], vec![1, 2]).unwrap();
3274 let counts = Tensor::new(vec![1.0, 2.0], vec![1, 2]).unwrap();
3275 let err = run_bag(vec![Value::StringArray(words), Value::Tensor(counts)])
3276 .expect_err("expected duplicate rejection");
3277 assert!(err.to_string().contains("duplicate"));
3278 }
3279
3280 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3281 #[test]
3282 fn bag_of_words_drops_missing_unique_word_and_count_column() {
3283 let words = StringArray::new(
3284 vec!["alpha".into(), "<missing>".into(), "beta".into()],
3285 vec![1, 3],
3286 )
3287 .unwrap();
3288 let counts = Tensor::new(vec![1.0, 0.0, 9.0, 9.0, 2.0, 3.0], vec![2, 3]).unwrap();
3289 let bag =
3290 object(run_bag(vec![Value::StringArray(words), Value::Tensor(counts)]).expect("bag"));
3291 assert_eq!(
3292 string_array_property(&bag, "Vocabulary"),
3293 vec!["alpha", "beta"]
3294 );
3295 let counts = tensor_property(&bag, "Counts");
3296 assert_eq!(counts.shape, vec![2, 2]);
3297 assert_eq!(counts.data, vec![1.0, 0.0, 2.0, 3.0]);
3298 }
3299
3300 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3301 #[test]
3302 fn bag_of_words_checks_dense_count_size_before_allocation() {
3303 let err = checked_count_len(usize::MAX, 2, "bagOfWords")
3304 .expect_err("expected overflow rejection");
3305 assert!(err.to_string().contains("overflow"));
3306
3307 let err = checked_count_len(MAX_DENSE_BAG_COUNT_CELLS + 1, 1, "bagOfWords")
3308 .expect_err("expected dense size rejection");
3309 assert!(err.to_string().contains("sparse bag storage"));
3310 }
3311
3312 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3313 #[test]
3314 fn remove_short_words_filters_documents_and_bag() {
3315 let docs = run_tokenized(vec![Value::String(
3316 "an example of a short sentence".to_string(),
3317 )])
3318 .expect("tokenized");
3319 let filtered_docs =
3320 object(run_remove_short(docs.clone(), Value::Num(2.0)).expect("remove docs"));
3321 assert_eq!(
3322 string_array_property(&filtered_docs, "Vocabulary"),
3323 vec!["example", "short", "sentence"]
3324 );
3325
3326 let bag = run_bag(vec![docs]).expect("bag");
3327 let filtered_bag = object(run_remove_short(bag, Value::Num(2.0)).expect("remove bag"));
3328 assert_eq!(
3329 string_array_property(&filtered_bag, "Vocabulary"),
3330 vec!["example", "short", "sentence"]
3331 );
3332 let Some(Value::Tensor(counts)) = filtered_bag.properties.get("Counts") else {
3333 panic!("expected Counts");
3334 };
3335 assert_eq!(counts.shape, vec![1, 3]);
3336 assert_eq!(counts.data, vec![1.0, 1.0, 1.0]);
3337 }
3338
3339 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3340 #[test]
3341 fn remove_long_words_filters_documents_and_bag() {
3342 let input = StringArray::new(
3343 vec![
3344 "An example of a short sentence".to_string(),
3345 "A second compact note".to_string(),
3346 ],
3347 vec![2, 1],
3348 )
3349 .unwrap();
3350 let docs = run_tokenized(vec![Value::StringArray(input)]).expect("tokenized");
3351 let filtered_docs =
3352 object(run_remove_long(docs.clone(), Value::Num(7.0)).expect("remove docs"));
3353 assert_eq!(
3354 documents_property(&filtered_docs),
3355 vec![vec!["An", "of", "a", "short"], vec!["A", "second", "note"]]
3356 );
3357 assert_eq!(
3358 string_array_property(&filtered_docs, "Vocabulary"),
3359 vec!["An", "of", "a", "short", "A", "second", "note"]
3360 );
3361
3362 let bag = run_bag(vec![docs]).expect("bag");
3363 let filtered_bag = object(run_remove_long(bag, Value::Num(7.0)).expect("remove bag"));
3364 assert_eq!(
3365 string_array_property(&filtered_bag, "Vocabulary"),
3366 vec!["An", "of", "a", "short", "A", "second", "note"]
3367 );
3368 let counts = tensor_property(&filtered_bag, "Counts");
3369 assert_eq!(counts.shape, vec![2, 7]);
3370 assert_eq!(
3371 counts.data,
3372 vec![1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0]
3373 );
3374 }
3375
3376 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3377 #[test]
3378 fn remove_words_filters_by_word_list_indices_and_ignore_case() {
3379 let input = StringArray::new(
3380 vec![
3381 "Short second sentence".to_string(),
3382 "short example sentence".to_string(),
3383 ],
3384 vec![2, 1],
3385 )
3386 .unwrap();
3387 let docs = run_tokenized(vec![Value::StringArray(input)]).expect("tokenized");
3388 let words = StringArray::new(vec!["short".into(), "example".into()], vec![1, 2]).unwrap();
3389 let filtered = object(
3390 run_remove_words(vec![
3391 docs.clone(),
3392 Value::StringArray(words),
3393 Value::String("IgnoreCase".to_string()),
3394 Value::Bool(true),
3395 ])
3396 .expect("remove words"),
3397 );
3398 assert_eq!(
3399 documents_property(&filtered),
3400 vec![vec!["second", "sentence"], vec!["sentence"]]
3401 );
3402
3403 let filtered_by_index = object(
3404 run_remove_words(vec![
3405 docs.clone(),
3406 Value::Tensor(Tensor::new(vec![1.0, 3.0], vec![1, 2]).unwrap()),
3407 ])
3408 .expect("remove indexed words"),
3409 );
3410 assert_eq!(
3411 documents_property(&filtered_by_index),
3412 vec![vec!["second"], vec!["short", "example"]]
3413 );
3414
3415 let bag = run_bag(vec![docs]).expect("bag");
3416 let mask = LogicalArray::new(vec![0, 1, 0, 1, 0], vec![1, 5]).unwrap();
3417 let filtered_bag =
3418 object(run_remove_words(vec![bag, Value::LogicalArray(mask)]).expect("remove mask"));
3419 assert_eq!(
3420 string_array_property(&filtered_bag, "Vocabulary"),
3421 vec!["Short", "sentence", "example"]
3422 );
3423 let counts = tensor_property(&filtered_bag, "Counts");
3424 assert_eq!(counts.shape, vec![2, 3]);
3425 assert_eq!(counts.data, vec![1.0, 0.0, 1.0, 1.0, 0.0, 1.0]);
3426 }
3427
3428 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3429 #[test]
3430 fn remove_words_rejects_bad_indices_and_mask_lengths() {
3431 let docs = run_tokenized(vec![Value::String("alpha beta".to_string())]).expect("tokenized");
3432 let err = run_remove_words(vec![docs.clone(), Value::Num(0.0)])
3433 .expect_err("expected bad numeric index");
3434 assert!(err.to_string().contains("positive integers"));
3435
3436 let mask = LogicalArray::new(vec![1, 0, 1], vec![1, 3]).unwrap();
3437 let err = run_remove_words(vec![docs, Value::LogicalArray(mask)])
3438 .expect_err("expected mask length mismatch");
3439 assert!(err.to_string().contains("logical index length"));
3440 }
3441
3442 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3443 #[test]
3444 fn remove_stop_words_filters_tokenized_documents_with_case_option() {
3445 let input = StringArray::new(
3446 vec![
3447 "an example of a short sentence".to_string(),
3448 "The second short sentence".to_string(),
3449 ],
3450 vec![2, 1],
3451 )
3452 .unwrap();
3453 let docs = run_tokenized(vec![Value::StringArray(input)]).expect("tokenized");
3454 let filtered = object(run_remove_stop(vec![docs]).expect("remove stop words"));
3455 assert_eq!(
3456 documents_property(&filtered),
3457 vec![
3458 vec!["example", "short", "sentence"],
3459 vec!["second", "short", "sentence"],
3460 ]
3461 );
3462
3463 let words = StringArray::new(
3464 vec!["The".to_string(), "the".to_string(), "word".to_string()],
3465 vec![1, 3],
3466 )
3467 .unwrap();
3468 let docs = run_tokenized(vec![
3469 Value::StringArray(words),
3470 Value::String("TokenizeMethod".to_string()),
3471 Value::String("none".to_string()),
3472 ])
3473 .expect("tokenized");
3474 let filtered = object(
3475 run_remove_stop(vec![
3476 docs,
3477 Value::String("IgnoreCase".to_string()),
3478 Value::Bool(false),
3479 ])
3480 .expect("case-sensitive remove stop words"),
3481 );
3482 assert_eq!(documents_property(&filtered), vec![vec!["The", "word"]]);
3483 }
3484
3485 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3486 #[test]
3487 fn erase_punctuation_filters_document_tokens_by_token_type() {
3488 let docs = object(
3489 run_tokenized(vec![Value::String(
3490 "An example: email help@example.com, visit https://example.com.".to_string(),
3491 )])
3492 .expect("tokenized"),
3493 );
3494 let filtered =
3495 object(erase_punctuation_tokenized_document(docs, vec![]).expect("erase punctuation"));
3496 assert_eq!(
3497 documents_property(&filtered),
3498 vec![vec![
3499 "An",
3500 "example",
3501 "email",
3502 "help@example.com",
3503 "visit",
3504 "https://example.com"
3505 ]]
3506 );
3507
3508 let words = StringArray::new(
3509 vec![
3510 "it's".to_string(),
3511 "alpha-beta".to_string(),
3512 "help@example.com".to_string(),
3513 "https://example.com".to_string(),
3514 ],
3515 vec![1, 4],
3516 )
3517 .unwrap();
3518 let docs = object(
3519 run_tokenized(vec![
3520 Value::StringArray(words),
3521 Value::String("TokenizeMethod".to_string()),
3522 Value::String("none".to_string()),
3523 ])
3524 .expect("tokenized"),
3525 );
3526 let filtered = object(
3527 erase_punctuation_tokenized_document(
3528 docs,
3529 vec![
3530 Value::String("TokenTypes".to_string()),
3531 Value::String("letters".to_string()),
3532 ],
3533 )
3534 .expect("erase letters punctuation"),
3535 );
3536 assert_eq!(
3537 documents_property(&filtered),
3538 vec![vec![
3539 "its",
3540 "alphabeta",
3541 "help@example.com",
3542 "https://example.com"
3543 ]]
3544 );
3545 }
3546
3547 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3548 #[test]
3549 fn rejects_unimplemented_japanese_tokenization() {
3550 let err = run_tokenized(vec![
3551 Value::String("東京に行きます".to_string()),
3552 Value::String("Language".to_string()),
3553 Value::String("ja".to_string()),
3554 ])
3555 .expect_err("expected unsupported ja");
3556 assert!(err.to_string().contains("MeCab"));
3557 }
3558
3559 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3560 #[test]
3561 fn tokenized_document_rejects_invalid_detect_patterns() {
3562 let err = run_tokenized(vec![
3563 Value::String("email me at a@example.com".to_string()),
3564 Value::String("DetectPatterns".to_string()),
3565 Value::StringArray(
3566 StringArray::new(vec!["all".into(), "web-address".into()], vec![1, 2]).unwrap(),
3567 ),
3568 ])
3569 .expect_err("expected mixed all rejection");
3570 assert!(err.to_string().contains("specified alone"));
3571
3572 let err = run_tokenized(vec![
3573 Value::String("email me at a@example.com".to_string()),
3574 Value::String("DetectPatterns".to_string()),
3575 Value::String("url".to_string()),
3576 ])
3577 .expect_err("expected unsupported pattern");
3578 assert!(err.to_string().contains("unsupported DetectPatterns"));
3579 }
3580
3581 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3582 #[test]
3583 fn tokenized_document_rejects_invalid_top_level_domains() {
3584 let err = run_tokenized(vec![
3585 Value::String("visit example.com".to_string()),
3586 Value::String("TopLevelDomains".to_string()),
3587 Value::String("co.uk".to_string()),
3588 ])
3589 .expect_err("expected invalid tld");
3590 assert!(err.to_string().contains("TopLevelDomains"));
3591 }
3592
3593 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
3594 #[test]
3595 fn remove_short_words_requires_positive_integer_length() {
3596 let docs =
3597 run_tokenized(vec![Value::String("a short document".to_string())]).expect("tokenized");
3598 let zero =
3599 run_remove_short(docs.clone(), Value::Num(0.0)).expect_err("expected zero rejection");
3600 assert!(zero.to_string().contains("positive integer"));
3601
3602 let fractional =
3603 run_remove_short(docs, Value::Num(1.5)).expect_err("expected fractional rejection");
3604 assert!(fractional.to_string().contains("positive integer"));
3605 }
3606}