Skip to main content

runmat_runtime/builtins/strings/text_analytics/
pos.rs

1//! Part-of-speech detail helpers for Text Analytics tokenized documents.
2
3use runmat_builtins::{
4    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
5    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
6    CellArray, ObjectInstance, ResolveContext, StringArray, Type, Value,
7};
8use runmat_macros::runtime_builtin;
9
10use crate::builtins::strings::core::compat::scalar_text;
11use crate::builtins::strings::text_analytics::details::add_sentence_details_builtin;
12use crate::builtins::strings::text_analytics::documents::{
13    document_token_type_with_options, documents_from_object, options_from_document_object,
14    replace_tokenized_document_documents, text_analytics_error, tokenized_document_language,
15    DocumentTokenType, TOKENIZED_DOCUMENT_CLASS,
16};
17use crate::{gather_if_needed_async, BuiltinResult};
18
19pub(in crate::builtins::strings::text_analytics) const POS_DETAILS_PROPERTY: &str =
20    "PartOfSpeechDetails";
21
22const OUT_DOCUMENTS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
23    name: "updatedDocuments",
24    ty: BuiltinParamType::Any,
25    arity: BuiltinParamArity::Required,
26    default: None,
27    description: "Updated tokenized document object.",
28}];
29
30const IN_DOCUMENTS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
31    name: "documents",
32    ty: BuiltinParamType::Any,
33    arity: BuiltinParamArity::Required,
34    default: None,
35    description: "tokenizedDocument object.",
36}];
37
38const IN_DOCUMENTS_REST: [BuiltinParamDescriptor; 2] = [
39    BuiltinParamDescriptor {
40        name: "documents",
41        ty: BuiltinParamType::Any,
42        arity: BuiltinParamArity::Required,
43        default: None,
44        description: "tokenizedDocument object.",
45    },
46    BuiltinParamDescriptor {
47        name: "NameValue",
48        ty: BuiltinParamType::Any,
49        arity: BuiltinParamArity::Variadic,
50        default: None,
51        description: "Name-value options: RetokenizeMethod, Abbreviations, DiscardKnownValues.",
52    },
53];
54
55const ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
56    code: "RM.TEXT_ANALYTICS.ADD_PART_OF_SPEECH_DETAILS.INVALID_INPUT",
57    identifier: Some("RunMat:addPartOfSpeechDetails:InvalidInput"),
58    when: "Input is not a supported tokenizedDocument object or option form.",
59    message: "addPartOfSpeechDetails: invalid input",
60};
61
62const ERRORS: [BuiltinErrorDescriptor; 1] = [ERROR_INVALID_INPUT];
63
64pub const ADD_PART_OF_SPEECH_DETAILS_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
65    signatures: &[
66        BuiltinSignatureDescriptor {
67            label: "updatedDocuments = addPartOfSpeechDetails(documents)",
68            inputs: &IN_DOCUMENTS,
69            outputs: &OUT_DOCUMENTS,
70        },
71        BuiltinSignatureDescriptor {
72            label: "updatedDocuments = addPartOfSpeechDetails(documents,Name,Value)",
73            inputs: &IN_DOCUMENTS_REST,
74            outputs: &OUT_DOCUMENTS,
75        },
76    ],
77    output_mode: BuiltinOutputMode::Fixed,
78    completion_policy: BuiltinCompletionPolicy::Public,
79    errors: &ERRORS,
80};
81
82fn any_type(_args: &[Type], _ctx: &ResolveContext) -> Type {
83    Type::Unknown
84}
85
86#[runtime_builtin(
87    name = "addPartOfSpeechDetails",
88    category = "strings/text_analytics",
89    summary = "Add part-of-speech details to tokenizedDocument objects.",
90    keywords = "addPartOfSpeechDetails,text analytics,tokenizedDocument,part of speech,pos",
91    accel = "sink",
92    type_resolver(any_type),
93    descriptor(
94        crate::builtins::strings::text_analytics::pos::ADD_PART_OF_SPEECH_DETAILS_DESCRIPTOR
95    ),
96    builtin_path = "crate::builtins::strings::text_analytics::pos"
97)]
98pub(in crate::builtins::strings::text_analytics) async fn add_part_of_speech_details_builtin(
99    args: Vec<Value>,
100) -> BuiltinResult<Value> {
101    let gathered = gather_args(args).await?;
102    let (documents, options) = parse_args(gathered)?;
103    let mut object = tokenized_document_object(documents)?;
104    let language = PosLanguage::from_document(&tokenized_document_language(&object))?;
105
106    if options.retokenize_method == RetokenizeMethod::PartOfSpeech {
107        let documents = documents_from_object(&object, "addPartOfSpeechDetails")?;
108        let retokenized = documents
109            .iter()
110            .map(|doc| retokenize_for_pos(doc))
111            .collect::<Vec<_>>();
112        if retokenized != documents {
113            replace_tokenized_document_documents(
114                &mut object,
115                retokenized,
116                "addPartOfSpeechDetails",
117            )?;
118            clear_token_aligned_details(&mut object);
119        }
120    }
121
122    if !object.properties.contains_key("SentenceNumbers") {
123        object = add_sentence_details_for_pos(object, &options).await?;
124    }
125
126    let documents = documents_from_object(&object, "addPartOfSpeechDetails")?;
127    let stored = if options.discard_known_values {
128        None
129    } else {
130        part_of_speech_details_from_object(&object, "addPartOfSpeechDetails")?
131    };
132    let document_options = options_from_document_object(&object);
133    let pos = if options.discard_known_values {
134        part_of_speech_details_cell(&documents, language, &document_options)?
135    } else {
136        part_of_speech_details_cell_preserving_known(
137            &documents,
138            stored.as_deref(),
139            language,
140            &document_options,
141        )?
142    };
143    object
144        .properties
145        .insert(POS_DETAILS_PROPERTY.to_string(), pos);
146    Ok(Value::Object(object))
147}
148
149async fn gather_args(args: Vec<Value>) -> BuiltinResult<Vec<Value>> {
150    let mut out = Vec::with_capacity(args.len());
151    for arg in args {
152        out.push(gather_if_needed_async(&arg).await.map_err(|err| {
153            text_analytics_error(
154                "addPartOfSpeechDetails",
155                format!("addPartOfSpeechDetails: failed to gather input: {err}"),
156            )
157        })?);
158    }
159    Ok(out)
160}
161
162#[derive(Clone, Debug)]
163struct AddPosOptions {
164    discard_known_values: bool,
165    retokenize_method: RetokenizeMethod,
166    abbreviations: Option<Value>,
167}
168
169impl Default for AddPosOptions {
170    fn default() -> Self {
171        Self {
172            discard_known_values: false,
173            retokenize_method: RetokenizeMethod::PartOfSpeech,
174            abbreviations: None,
175        }
176    }
177}
178
179#[derive(Clone, Copy, Debug, PartialEq, Eq)]
180enum RetokenizeMethod {
181    PartOfSpeech,
182    None,
183}
184
185impl RetokenizeMethod {
186    fn parse(value: &str) -> BuiltinResult<Self> {
187        match value.trim().to_ascii_lowercase().as_str() {
188            "part-of-speech" | "partofspeech" => Ok(Self::PartOfSpeech),
189            "none" => Ok(Self::None),
190            other => Err(text_analytics_error(
191                "addPartOfSpeechDetails",
192                format!("addPartOfSpeechDetails: unsupported RetokenizeMethod '{other}'"),
193            )),
194        }
195    }
196}
197
198#[derive(Clone, Copy, Debug, PartialEq, Eq)]
199pub(in crate::builtins::strings::text_analytics) enum PosLanguage {
200    English,
201    Japanese,
202    German,
203    Korean,
204}
205
206impl PosLanguage {
207    fn from_document(language: &str) -> BuiltinResult<Self> {
208        Self::from_document_for(language, "addPartOfSpeechDetails")
209    }
210
211    pub(in crate::builtins::strings::text_analytics) fn from_document_for(
212        language: &str,
213        fn_name: &str,
214    ) -> BuiltinResult<Self> {
215        match language.trim().to_ascii_lowercase().as_str() {
216            "en" => Ok(Self::English),
217            "ja" => Ok(Self::Japanese),
218            "de" => Ok(Self::German),
219            "ko" => Ok(Self::Korean),
220            other => Err(text_analytics_error(
221                fn_name,
222                format!("{fn_name}: unsupported document language '{other}'"),
223            )),
224        }
225    }
226}
227
228fn parse_args(args: Vec<Value>) -> BuiltinResult<(Value, AddPosOptions)> {
229    if args.is_empty() {
230        return Err(text_analytics_error(
231            "addPartOfSpeechDetails",
232            "addPartOfSpeechDetails: expected tokenizedDocument input",
233        ));
234    }
235    if !(args.len() - 1).is_multiple_of(2) {
236        return Err(text_analytics_error(
237            "addPartOfSpeechDetails",
238            "addPartOfSpeechDetails: name-value options must appear in pairs",
239        ));
240    }
241    let mut options = AddPosOptions::default();
242    let mut idx = 1usize;
243    while idx < args.len() {
244        let name = scalar_text(&args[idx], "addPartOfSpeechDetails")
245            .map_err(|err| text_analytics_error("addPartOfSpeechDetails", err.to_string()))?;
246        if name.eq_ignore_ascii_case("DiscardKnownValues") {
247            options.discard_known_values = logical_scalar(&args[idx + 1])?;
248        } else if name.eq_ignore_ascii_case("RetokenizeMethod") {
249            let value = scalar_text(&args[idx + 1], "addPartOfSpeechDetails")
250                .map_err(|err| text_analytics_error("addPartOfSpeechDetails", err.to_string()))?;
251            options.retokenize_method = RetokenizeMethod::parse(&value)?;
252        } else if name.eq_ignore_ascii_case("Abbreviations") {
253            options.abbreviations = Some(args[idx + 1].clone());
254        } else {
255            return Err(text_analytics_error(
256                "addPartOfSpeechDetails",
257                format!("addPartOfSpeechDetails: unsupported option '{name}'"),
258            ));
259        }
260        idx += 2;
261    }
262    Ok((args[0].clone(), options))
263}
264
265fn tokenized_document_object(value: Value) -> BuiltinResult<ObjectInstance> {
266    match value {
267        Value::Object(object) if object.is_class(TOKENIZED_DOCUMENT_CLASS) => Ok(object),
268        Value::Object(object) => Err(text_analytics_error(
269            "addPartOfSpeechDetails",
270            format!(
271                "addPartOfSpeechDetails: expected tokenizedDocument object, got {}",
272                object.class_name
273            ),
274        )),
275        other => Err(text_analytics_error(
276            "addPartOfSpeechDetails",
277            format!("addPartOfSpeechDetails: expected tokenizedDocument object, got {other:?}"),
278        )),
279    }
280}
281
282fn clear_token_aligned_details(object: &mut ObjectInstance) {
283    for property in [
284        "TypeDetails",
285        "SentenceNumbers",
286        "LemmaDetails",
287        POS_DETAILS_PROPERTY,
288        "EntityDetails",
289        "HeadDetails",
290        "DependencyDetails",
291    ] {
292        object.properties.remove(property);
293    }
294}
295
296async fn add_sentence_details_for_pos(
297    object: ObjectInstance,
298    options: &AddPosOptions,
299) -> BuiltinResult<ObjectInstance> {
300    let mut args = vec![Value::Object(object)];
301    if let Some(abbreviations) = &options.abbreviations {
302        args.push(Value::String("Abbreviations".to_string()));
303        args.push(abbreviations.clone());
304    }
305    let Value::Object(object) = add_sentence_details_builtin(args).await? else {
306        return Err(text_analytics_error(
307            "addPartOfSpeechDetails",
308            "addPartOfSpeechDetails: addSentenceDetails did not return tokenizedDocument",
309        ));
310    };
311    Ok(object)
312}
313
314fn part_of_speech_details_cell(
315    documents: &[Vec<String>],
316    language: PosLanguage,
317    options: &crate::builtins::strings::text_analytics::documents::DocumentOptions,
318) -> BuiltinResult<Value> {
319    let values = documents
320        .iter()
321        .map(|doc| {
322            let tags = doc
323                .iter()
324                .map(|token| part_of_speech_for_token(token, language, options).to_string())
325                .collect::<Vec<_>>();
326            StringArray::new(tags, vec![1, doc.len()])
327                .map(Value::StringArray)
328                .map_err(|err| text_analytics_error("addPartOfSpeechDetails", err))
329        })
330        .collect::<BuiltinResult<Vec<_>>>()?;
331    Ok(Value::Cell(
332        CellArray::new(values, documents.len(), 1)
333            .map_err(|err| text_analytics_error("addPartOfSpeechDetails", err))?,
334    ))
335}
336
337fn part_of_speech_details_cell_preserving_known(
338    documents: &[Vec<String>],
339    stored: Option<&[Vec<String>]>,
340    language: PosLanguage,
341    options: &crate::builtins::strings::text_analytics::documents::DocumentOptions,
342) -> BuiltinResult<Value> {
343    let values = documents
344        .iter()
345        .enumerate()
346        .map(|(doc_idx, doc)| {
347            let tags = doc
348                .iter()
349                .enumerate()
350                .map(|(token_idx, token)| {
351                    stored
352                        .and_then(|values| values.get(doc_idx))
353                        .and_then(|values| values.get(token_idx))
354                        .filter(|tag| is_known_part_of_speech(tag))
355                        .cloned()
356                        .unwrap_or_else(|| {
357                            part_of_speech_for_token(token, language, options).to_string()
358                        })
359                })
360                .collect::<Vec<_>>();
361            StringArray::new(tags, vec![1, doc.len()])
362                .map(Value::StringArray)
363                .map_err(|err| text_analytics_error("addPartOfSpeechDetails", err))
364        })
365        .collect::<BuiltinResult<Vec<_>>>()?;
366    Ok(Value::Cell(
367        CellArray::new(values, documents.len(), 1)
368            .map_err(|err| text_analytics_error("addPartOfSpeechDetails", err))?,
369    ))
370}
371
372pub(in crate::builtins::strings::text_analytics) fn part_of_speech_for_token(
373    token: &str,
374    language: PosLanguage,
375    options: &crate::builtins::strings::text_analytics::documents::DocumentOptions,
376) -> &'static str {
377    match document_token_type_with_options(token, options) {
378        DocumentTokenType::Punctuation => "punctuation",
379        DocumentTokenType::Digits => "numeral",
380        DocumentTokenType::WebAddress
381        | DocumentTokenType::EmailAddress
382        | DocumentTokenType::Hashtag
383        | DocumentTokenType::AtMention
384        | DocumentTokenType::Emoticon
385        | DocumentTokenType::Emoji
386        | DocumentTokenType::Other => "other",
387        DocumentTokenType::Letters => match language {
388            PosLanguage::English => english_part_of_speech(token),
389            PosLanguage::German => german_part_of_speech(token),
390            PosLanguage::Japanese | PosLanguage::Korean => "noun",
391        },
392    }
393}
394
395fn english_part_of_speech(token: &str) -> &'static str {
396    let lower = token.to_ascii_lowercase();
397    let word = lower.trim_matches('\'');
398    if word.is_empty() {
399        return "other";
400    }
401    if matches!(
402        word,
403        "i" | "me"
404            | "my"
405            | "mine"
406            | "myself"
407            | "we"
408            | "us"
409            | "our"
410            | "ours"
411            | "ourselves"
412            | "you"
413            | "your"
414            | "yours"
415            | "yourself"
416            | "yourselves"
417            | "he"
418            | "him"
419            | "his"
420            | "himself"
421            | "she"
422            | "her"
423            | "hers"
424            | "herself"
425            | "it"
426            | "its"
427            | "itself"
428            | "they"
429            | "them"
430            | "their"
431            | "theirs"
432            | "themselves"
433            | "who"
434            | "whom"
435            | "whose"
436            | "which"
437            | "that"
438            | "this"
439            | "these"
440            | "those"
441    ) {
442        return "pronoun";
443    }
444    if matches!(
445        word,
446        "a" | "an" | "the" | "some" | "any" | "each" | "every" | "no"
447    ) {
448        return "determiner";
449    }
450    if matches!(word, "and" | "or" | "but" | "nor" | "yet" | "so" | "for") {
451        return "coord-conjunction";
452    }
453    if matches!(
454        word,
455        "because" | "although" | "while" | "if" | "when" | "since" | "unless"
456    ) {
457        return "subord-conjunction";
458    }
459    if matches!(
460        word,
461        "in" | "on"
462            | "at"
463            | "by"
464            | "from"
465            | "with"
466            | "without"
467            | "under"
468            | "over"
469            | "into"
470            | "onto"
471            | "through"
472            | "during"
473            | "before"
474            | "after"
475            | "of"
476            | "as"
477            | "than"
478    ) {
479        return "adposition";
480    }
481    if matches!(word, "to" | "n't" | "not") {
482        return "particle";
483    }
484    if matches!(
485        word,
486        "am" | "are"
487            | "is"
488            | "was"
489            | "were"
490            | "be"
491            | "been"
492            | "being"
493            | "have"
494            | "has"
495            | "had"
496            | "do"
497            | "does"
498            | "did"
499            | "can"
500            | "could"
501            | "may"
502            | "might"
503            | "must"
504            | "shall"
505            | "should"
506            | "will"
507            | "would"
508            | "'m"
509            | "'re"
510            | "'ve"
511            | "'ll"
512            | "'d"
513            | "'s"
514    ) {
515        return "auxiliary-verb";
516    }
517    if matches!(
518        word,
519        "run"
520            | "runs"
521            | "ran"
522            | "walk"
523            | "walks"
524            | "go"
525            | "goes"
526            | "went"
527            | "make"
528            | "makes"
529            | "made"
530            | "build"
531            | "builds"
532            | "built"
533            | "read"
534            | "write"
535            | "writes"
536            | "wrote"
537            | "see"
538            | "sees"
539            | "saw"
540            | "want"
541            | "wants"
542            | "chase"
543            | "chases"
544            | "chased"
545            | "attend"
546            | "attends"
547            | "attended"
548            | "going"
549            | "sleep"
550            | "sleeps"
551            | "fly"
552            | "flies"
553            | "using"
554            | "use"
555            | "uses"
556    ) || word.ends_with("ing")
557        || word.ends_with("ed")
558    {
559        return "verb";
560    }
561    if word.ends_with("ly") {
562        return "adverb";
563    }
564    if word.ends_with("ive")
565        || word.ends_with("ous")
566        || word.ends_with("ful")
567        || word.ends_with("less")
568        || word.ends_with("able")
569        || word.ends_with("ible")
570        || matches!(word, "good" | "bad" | "new" | "old" | "large" | "small")
571    {
572        return "adjective";
573    }
574    "noun"
575}
576
577fn german_part_of_speech(token: &str) -> &'static str {
578    let lower = token.to_ascii_lowercase();
579    let word = lower.trim_matches('\'');
580    if word.is_empty() {
581        return "other";
582    }
583    if matches!(
584        word,
585        "ich"
586            | "mich"
587            | "mir"
588            | "du"
589            | "dich"
590            | "dir"
591            | "er"
592            | "ihn"
593            | "ihm"
594            | "sie"
595            | "ihr"
596            | "es"
597            | "wir"
598            | "uns"
599            | "euch"
600            | "mein"
601            | "dein"
602            | "sein"
603            | "dieser"
604            | "diese"
605            | "dieses"
606            | "wer"
607            | "was"
608    ) {
609        return "pronoun";
610    }
611    if matches!(
612        word,
613        "der"
614            | "die"
615            | "das"
616            | "den"
617            | "dem"
618            | "des"
619            | "ein"
620            | "eine"
621            | "einen"
622            | "einem"
623            | "einer"
624            | "eines"
625            | "kein"
626            | "keine"
627            | "jeder"
628            | "jede"
629            | "jedes"
630    ) {
631        return "determiner";
632    }
633    if matches!(word, "und" | "oder" | "aber" | "denn") {
634        return "coord-conjunction";
635    }
636    if matches!(word, "weil" | "wenn" | "dass" | "obwohl" | "während") {
637        return "subord-conjunction";
638    }
639    if matches!(
640        word,
641        "in" | "auf"
642            | "an"
643            | "bei"
644            | "von"
645            | "mit"
646            | "ohne"
647            | "unter"
648            | "über"
649            | "durch"
650            | "vor"
651            | "nach"
652            | "zu"
653            | "aus"
654            | "für"
655            | "gegen"
656            | "als"
657    ) {
658        return "adposition";
659    }
660    if word.ends_with("lich")
661        || word.ends_with("ig")
662        || word.ends_with("isch")
663        || matches!(
664            word,
665            "gut" | "gute" | "guter" | "guten" | "neu" | "alte" | "groß" | "klein"
666        )
667    {
668        return "adjective";
669    }
670    if token.chars().next().is_some_and(char::is_uppercase) {
671        return "noun";
672    }
673    if word.ends_with("weise") || matches!(word, "heute" | "morgen" | "wie" | "gern") {
674        return "adverb";
675    }
676    if matches!(
677        word,
678        "bin"
679            | "bist"
680            | "ist"
681            | "sind"
682            | "seid"
683            | "war"
684            | "waren"
685            | "sein"
686            | "gewesen"
687            | "habe"
688            | "hast"
689            | "hat"
690            | "haben"
691            | "hatte"
692            | "hatten"
693            | "werde"
694            | "wird"
695            | "werden"
696            | "wurde"
697            | "kann"
698            | "können"
699            | "muss"
700            | "müssen"
701            | "soll"
702            | "sollen"
703    ) {
704        return "auxiliary-verb";
705    }
706    if matches!(
707        word,
708        "geht"
709            | "gehen"
710            | "ging"
711            | "laufen"
712            | "läuft"
713            | "machen"
714            | "macht"
715            | "sagen"
716            | "sagt"
717            | "kommt"
718            | "kommen"
719    ) || word.ends_with("en")
720        || word.ends_with("st")
721        || word.ends_with("te")
722    {
723        return "verb";
724    }
725    "noun"
726}
727
728fn retokenize_for_pos(tokens: &[String]) -> Vec<String> {
729    let mut out = Vec::new();
730    let mut idx = 0usize;
731    while idx < tokens.len() {
732        if let Some((merged, consumed)) = dotted_initialism_at(tokens, idx) {
733            out.push(merged);
734            idx += consumed;
735            continue;
736        }
737        if tokens[idx] == "." && tokens.get(idx + 1).is_some_and(|token| token == ".") {
738            let mut consumed = 1usize;
739            while tokens.get(idx + consumed).is_some_and(|token| token == ".") {
740                consumed += 1;
741            }
742            out.push(".".repeat(consumed));
743            idx += consumed;
744            continue;
745        }
746        if let Some(split) = split_pos_token(&tokens[idx]) {
747            out.extend(split);
748        } else {
749            out.push(tokens[idx].clone());
750        }
751        idx += 1;
752    }
753    out
754}
755
756fn dotted_initialism_at(tokens: &[String], idx: usize) -> Option<(String, usize)> {
757    let mut cursor = idx;
758    let mut letters = Vec::new();
759    while cursor + 1 < tokens.len()
760        && is_single_letter(&tokens[cursor])
761        && tokens[cursor + 1] == "."
762    {
763        letters.push(tokens[cursor].clone());
764        cursor += 2;
765    }
766    if letters.len() < 2 {
767        return None;
768    }
769    Some((format!("{}.", letters.join(".")), cursor - idx))
770}
771
772fn is_single_letter(token: &str) -> bool {
773    token.chars().count() == 1 && token.chars().all(char::is_alphabetic)
774}
775
776fn split_pos_token(token: &str) -> Option<Vec<String>> {
777    let lower = token.to_ascii_lowercase();
778    match lower.as_str() {
779        "can't" => Some(vec!["can".to_string(), "n't".to_string()]),
780        "cannot" => Some(vec!["can".to_string(), "not".to_string()]),
781        "won't" => Some(vec!["will".to_string(), "not".to_string()]),
782        "wanna" => Some(vec!["want".to_string(), "to".to_string()]),
783        "gonna" => Some(vec!["going".to_string(), "to".to_string()]),
784        _ => {
785            for suffix in ["n't", "'re", "'ve", "'ll", "'d", "'m", "'s"] {
786                if lower.ends_with(suffix) && lower.len() > suffix.len() {
787                    let stem = token[..token.len() - suffix.len()].to_string();
788                    return Some(vec![stem, suffix.to_string()]);
789                }
790            }
791            None
792        }
793    }
794}
795
796fn is_known_part_of_speech(value: &str) -> bool {
797    let trimmed = value.trim();
798    !trimmed.is_empty()
799        && trimmed != "unknown"
800        && !crate::builtins::strings::common::is_missing_string(trimmed)
801}
802
803pub(in crate::builtins::strings::text_analytics) fn part_of_speech_details_from_object(
804    object: &ObjectInstance,
805    fn_name: &str,
806) -> BuiltinResult<Option<Vec<Vec<String>>>> {
807    let Some(value) = object.properties.get(POS_DETAILS_PROPERTY) else {
808        return Ok(None);
809    };
810    let Value::Cell(cell) = value else {
811        return Err(text_analytics_error(
812            fn_name,
813            format!("{fn_name}: tokenizedDocument object has invalid PartOfSpeechDetails property"),
814        ));
815    };
816    if cell.cols != 1 {
817        return Err(text_analytics_error(
818            fn_name,
819            format!("{fn_name}: tokenizedDocument object has invalid PartOfSpeechDetails shape"),
820        ));
821    }
822    let mut out = Vec::with_capacity(cell.data.len());
823    for item in &cell.data {
824        let Value::StringArray(array) = item else {
825            return Err(text_analytics_error(
826                fn_name,
827                format!(
828                    "{fn_name}: tokenizedDocument object has invalid PartOfSpeechDetails entry"
829                ),
830            ));
831        };
832        if array.rows != 1 {
833            return Err(text_analytics_error(
834                fn_name,
835                format!(
836                    "{fn_name}: tokenizedDocument object has invalid PartOfSpeechDetails entry shape"
837                ),
838            ));
839        }
840        out.push(array.data.clone());
841    }
842    Ok(Some(out))
843}
844
845fn logical_scalar(value: &Value) -> BuiltinResult<bool> {
846    match value {
847        Value::Bool(value) => Ok(*value),
848        Value::Num(value) if *value == 0.0 || *value == 1.0 => Ok(*value != 0.0),
849        Value::Tensor(tensor) if tensor.data.len() == 1 => match tensor.data[0] {
850            0.0 => Ok(false),
851            1.0 => Ok(true),
852            other => Err(text_analytics_error(
853                "addPartOfSpeechDetails",
854                format!(
855                    "addPartOfSpeechDetails: logical scalar option must be true or false, got {other}"
856                ),
857            )),
858        },
859        Value::LogicalArray(array) if array.data.len() == 1 => Ok(array.data[0] != 0),
860        other => Err(text_analytics_error(
861            "addPartOfSpeechDetails",
862            format!(
863                "addPartOfSpeechDetails: logical scalar option must be true or false, got {other:?}"
864            ),
865        )),
866    }
867}
868
869#[cfg(test)]
870mod tests {
871    use super::*;
872    use crate::builtins::strings::text_analytics::details::token_details_builtin;
873    use crate::builtins::strings::text_analytics::documents::tokenized_document_builtin;
874    use crate::builtins::table::{table_variable_names_from_object, table_variables};
875    use runmat_builtins::{LogicalArray, Tensor};
876
877    fn run_tokenized(args: Vec<Value>) -> BuiltinResult<Value> {
878        futures::executor::block_on(tokenized_document_builtin(args))
879    }
880
881    fn run_add_pos(args: Vec<Value>) -> BuiltinResult<Value> {
882        futures::executor::block_on(add_part_of_speech_details_builtin(args))
883    }
884
885    fn run_token_details(value: Value) -> BuiltinResult<Value> {
886        futures::executor::block_on(token_details_builtin(value))
887    }
888
889    fn object(value: Value) -> ObjectInstance {
890        let Value::Object(object) = value else {
891            panic!("expected object");
892        };
893        object
894    }
895
896    fn table_column(table: &ObjectInstance, name: &str) -> Value {
897        table_variables(table)
898            .expect("table variables")
899            .fields
900            .get(name)
901            .cloned()
902            .unwrap_or_else(|| panic!("missing table column {name}"))
903    }
904
905    fn string_column(table: &ObjectInstance, name: &str) -> Vec<String> {
906        match table_column(table, name) {
907            Value::StringArray(array) => array.data,
908            other => panic!("expected string column {name}, got {other:?}"),
909        }
910    }
911
912    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
913    #[test]
914    fn add_part_of_speech_details_adds_sentence_and_pos_columns() {
915        let docs =
916            run_tokenized(vec![Value::String("The dogs are running.".into())]).expect("tokenized");
917        let updated = run_add_pos(vec![docs]).expect("pos");
918        let table = object(run_token_details(updated).expect("details"));
919
920        assert_eq!(
921            table_variable_names_from_object(&table).expect("names"),
922            vec![
923                "Token",
924                "DocumentNumber",
925                "SentenceNumber",
926                "LineNumber",
927                "Type",
928                "Language",
929                "PartOfSpeech"
930            ]
931        );
932        assert_eq!(
933            string_column(&table, "PartOfSpeech"),
934            vec![
935                "determiner",
936                "noun",
937                "auxiliary-verb",
938                "verb",
939                "punctuation"
940            ]
941        );
942    }
943
944    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
945    #[test]
946    fn add_part_of_speech_details_retokenizes_common_pos_forms() {
947        let docs = run_tokenized(vec![Value::String(
948            "I can't attend the U.S.A. event.".into(),
949        )])
950        .expect("tokenized");
951        let updated = run_add_pos(vec![docs]).expect("pos");
952        let table = object(run_token_details(updated).expect("details"));
953        assert_eq!(
954            string_column(&table, "Token"),
955            vec!["I", "can", "n't", "attend", "the", "U.S.A.", "event", "."]
956        );
957        assert_eq!(
958            string_column(&table, "PartOfSpeech"),
959            vec![
960                "pronoun",
961                "auxiliary-verb",
962                "particle",
963                "verb",
964                "determiner",
965                "other",
966                "noun",
967                "punctuation"
968            ]
969        );
970    }
971
972    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
973    #[test]
974    fn add_part_of_speech_details_preserves_existing_known_values_unless_discarding() {
975        let docs = object(
976            run_tokenized(vec![
977                Value::StringArray(
978                    StringArray::new(vec!["dogs".into(), "run".into()], vec![1, 2]).unwrap(),
979                ),
980                Value::String("TokenizeMethod".into()),
981                Value::String("none".into()),
982            ])
983            .expect("tokenized"),
984        );
985        let mut stale = docs.clone();
986        stale.properties.insert(
987            "SentenceNumbers".to_string(),
988            Value::Cell(
989                CellArray::new(
990                    vec![Value::Tensor(
991                        Tensor::new(vec![1.0, 1.0], vec![1, 2]).unwrap(),
992                    )],
993                    1,
994                    1,
995                )
996                .unwrap(),
997            ),
998        );
999        stale.properties.insert(
1000            POS_DETAILS_PROPERTY.to_string(),
1001            Value::Cell(
1002                CellArray::new(
1003                    vec![Value::StringArray(
1004                        StringArray::new(vec!["custom".into(), "".into()], vec![1, 2]).unwrap(),
1005                    )],
1006                    1,
1007                    1,
1008                )
1009                .unwrap(),
1010            ),
1011        );
1012
1013        let preserved = run_add_pos(vec![
1014            Value::Object(stale.clone()),
1015            Value::String("RetokenizeMethod".into()),
1016            Value::String("none".into()),
1017        ])
1018        .expect("preserve");
1019        let table = object(run_token_details(preserved).expect("details"));
1020        assert_eq!(
1021            string_column(&table, "PartOfSpeech"),
1022            vec!["custom", "verb"]
1023        );
1024
1025        let recomputed = run_add_pos(vec![
1026            Value::Object(stale),
1027            Value::String("RetokenizeMethod".into()),
1028            Value::String("none".into()),
1029            Value::String("DiscardKnownValues".into()),
1030            Value::LogicalArray(LogicalArray::new(vec![1], vec![1, 1]).unwrap()),
1031        ])
1032        .expect("recompute");
1033        let table = object(run_token_details(recomputed).expect("details"));
1034        assert_eq!(string_column(&table, "PartOfSpeech"), vec!["noun", "verb"]);
1035    }
1036
1037    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1038    #[test]
1039    fn add_part_of_speech_details_rejects_bad_options_and_languages() {
1040        let docs = run_tokenized(vec![Value::String("dogs run".into())]).expect("tokenized");
1041        let err = run_add_pos(vec![
1042            docs.clone(),
1043            Value::String("Unknown".into()),
1044            Value::Bool(true),
1045        ])
1046        .expect_err("expected bad option");
1047        assert!(err.to_string().contains("unsupported option"));
1048
1049        let err = run_add_pos(vec![
1050            docs.clone(),
1051            Value::String("RetokenizeMethod".into()),
1052            Value::String("bogus".into()),
1053        ])
1054        .expect_err("expected bad retokenizer");
1055        assert!(err.to_string().contains("unsupported RetokenizeMethod"));
1056
1057        let mut unsupported = object(docs);
1058        unsupported
1059            .properties
1060            .insert("Language".into(), Value::String("fr".into()));
1061        let err = run_add_pos(vec![Value::Object(unsupported)]).expect_err("expected bad language");
1062        assert!(err.to_string().contains("unsupported document language"));
1063    }
1064
1065    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1066    #[test]
1067    fn add_part_of_speech_details_supports_documented_tags_and_german() {
1068        assert_eq!(english_part_of_speech("and"), "coord-conjunction");
1069        assert_eq!(english_part_of_speech("because"), "subord-conjunction");
1070
1071        let docs = object(run_tokenized(vec![Value::String("Guten Morgen.".into())]).unwrap());
1072        let mut german = docs;
1073        german
1074            .properties
1075            .insert("Language".into(), Value::String("de".into()));
1076        let updated = run_add_pos(vec![Value::Object(german)]).expect("german pos");
1077        let table = object(run_token_details(updated).expect("details"));
1078        assert_eq!(
1079            string_column(&table, "PartOfSpeech"),
1080            vec!["adjective", "noun", "punctuation"]
1081        );
1082    }
1083
1084    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1085    #[test]
1086    fn add_part_of_speech_details_discard_known_values_ignores_malformed_stored_tags() {
1087        let docs =
1088            object(run_tokenized(vec![Value::String("dogs run".into())]).expect("tokenized"));
1089        let mut malformed = docs.clone();
1090        malformed.properties.insert(
1091            POS_DETAILS_PROPERTY.to_string(),
1092            Value::String("bad".into()),
1093        );
1094
1095        let updated = run_add_pos(vec![
1096            Value::Object(malformed),
1097            Value::String("DiscardKnownValues".into()),
1098            Value::Bool(true),
1099        ])
1100        .expect("discard malformed stored tags");
1101        let table = object(run_token_details(updated).expect("details"));
1102        assert_eq!(string_column(&table, "PartOfSpeech"), vec!["noun", "verb"]);
1103    }
1104
1105    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1106    #[test]
1107    fn token_details_rejects_malformed_part_of_speech_details() {
1108        let docs =
1109            object(run_tokenized(vec![Value::String("dogs run".into())]).expect("tokenized"));
1110        let mut malformed = docs.clone();
1111        malformed.properties.insert(
1112            POS_DETAILS_PROPERTY.to_string(),
1113            Value::Cell(
1114                CellArray::new(
1115                    vec![Value::StringArray(
1116                        StringArray::new(vec!["noun".into()], vec![1, 1]).unwrap(),
1117                    )],
1118                    1,
1119                    1,
1120                )
1121                .unwrap(),
1122            ),
1123        );
1124        let err = run_token_details(Value::Object(malformed)).expect_err("expected error");
1125        assert!(err.to_string().contains("PartOfSpeechDetails entry"));
1126    }
1127}