1use core::sync::atomic::{AtomicUsize, Ordering};
2
3#[cfg(not(feature = "std"))]
4use alloc::boxed::Box;
5#[cfg(not(feature = "std"))]
6use alloc::format;
7#[cfg(not(feature = "std"))]
8use alloc::string::{String, ToString};
9#[cfg(not(feature = "std"))]
10use alloc::vec;
11#[cfg(not(feature = "std"))]
12use alloc::vec::Vec;
13
14use crate::collections::{HashMap, HashSet, new_map, new_set};
15
16use crate::faithfulness::score_faithfulness;
17use crate::session::Session;
18
19use crate::agreement::AgreementFeatures;
20use crate::antonyms::{AntonymRegistry, insert_not};
21use crate::context::{Context, IntoContext, Value};
22use crate::discourse::{ListStyle, ReferenceForm, Transition};
23use crate::error::ProsaicError;
24use crate::hedge::{HedgeMode, hedge as hedge_fn, parse_mode as parse_hedge_mode};
25use crate::language::{Conjunction, Language, Person, PluralCategory, VerbForm};
26#[cfg(feature = "polish")]
27use crate::length::split_long_in_place;
28#[cfg(feature = "polish")]
29use crate::punctuation::smart_quotes_in_place;
30use crate::quantify::{QuantifyMode, parse_mode as parse_quantify_mode, quantify as quantify_fn};
31#[cfg(feature = "reg")]
32use crate::reg::{
33 EntityDescriptor, EntityRegistry, distinguishing_attributes, distinguishing_subgraph,
34};
35use crate::salience::{Salience, SalienceThresholds};
36use crate::synonyms::SynonymRegistry;
37use crate::template::{Pipe, PipeArg, Segment, Template};
38#[cfg(feature = "time")]
39use crate::time::format_relative;
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
43pub enum Strictness {
44 #[default]
46 Strict,
47 Lenient,
49 Silent,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
61pub enum Variation {
62 #[default]
64 Fixed,
65 Seeded(u64),
68 RoundRobin,
70 Random,
72}
73
74#[cfg(feature = "reg")]
80#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
81#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
82pub enum RegAlgorithm {
83 #[default]
89 DaleReiter,
90 GraphBased,
98}
99
100#[derive(Debug, Clone)]
105pub struct SalientTemplate {
106 pub salience: Salience,
107 pub template: Template,
108 pub language: Option<String>,
109 pub style: Option<String>,
110}
111
112impl SalientTemplate {
113 pub fn new(
114 salience: Salience,
115 template: Template,
116 language: Option<String>,
117 style: Option<String>,
118 ) -> Self {
119 Self {
120 salience,
121 template,
122 language,
123 style,
124 }
125 }
126}
127
128#[derive(Debug, Clone)]
133#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
134pub struct RenderExplanation {
135 pub output: String,
137 pub template_key: String,
139 pub variant_index: usize,
142 pub variant_source: String,
144 pub salience: Salience,
146 pub candidate_scores: Option<Vec<f64>>,
151 pub reference_form: Option<ReferenceForm>,
154 pub connective: Option<&'static str>,
156 pub list_style: Option<ListStyle>,
158 pub focus_is_plural: bool,
160 pub length_split_applied: bool,
162 pub cleanup_stripped_tail: bool,
165 pub centering_transition: Transition,
169}
170
171pub struct RenderIter<'a> {
175 engine: &'a Engine,
176 session: &'a mut Session,
177 events: &'a [(&'a str, Context)],
178 i: usize,
179}
180
181impl<'a> Iterator for RenderIter<'a> {
182 type Item = Result<String, ProsaicError>;
183
184 fn next(&mut self) -> Option<Self::Item> {
185 if self.i >= self.events.len() {
186 return None;
187 }
188
189 let fail = |this: &mut RenderIter<'_>, e: ProsaicError| -> Option<Self::Item> {
195 this.i = this.events.len();
196 Some(Err(e))
197 };
198
199 let action_end = self.engine.find_same_action_run(self.events, self.i);
202 if action_end > self.i + 1 {
203 let key = self.events[self.i].0;
204 let run = &self.events[self.i..action_end];
205 let sentence = match self
206 .engine
207 .render_aggregated_subjects(self.session, key, run)
208 {
209 Ok(s) => s,
210 Err(e) => return fail(self, e),
211 };
212 self.i = action_end;
213 return Some(Ok(sentence));
214 }
215
216 let gap_end = self.engine.find_gapping_run(self.events, self.i);
219 if gap_end > self.i + 1 {
220 let mut rendered: Vec<String> = Vec::with_capacity(gap_end - self.i);
221 for (key, ctx) in &self.events[self.i..gap_end] {
222 match self.engine.render(self.session, key, ctx) {
223 Ok(s) => rendered.push(s),
224 Err(e) => return fail(self, e),
225 }
226 }
227 self.i = gap_end;
228 if let Some(gapped) = reduce_gapping(&rendered) {
229 return Some(Ok(gapped));
230 }
231 return Some(Ok(rendered.join(" ")));
232 }
233
234 let entity_end = self.engine.find_same_entity_run(self.events, self.i);
235 if entity_end > self.i + 1 {
236 let mut run_rendered: Vec<String> = Vec::with_capacity(entity_end - self.i);
237 for (key, ctx) in &self.events[self.i..entity_end] {
238 match self.engine.render(self.session, key, ctx) {
239 Ok(s) => run_rendered.push(s),
240 Err(e) => return fail(self, e),
241 }
242 }
243 self.i = entity_end;
244 if let Some(reduced) = reduce_same_entity_clauses(&run_rendered) {
245 return Some(Ok(reduced));
246 }
247 return Some(Ok(run_rendered.join(" ")));
251 }
252
253 let (key, ctx) = &self.events[self.i];
254 self.i += 1;
255 match self.engine.render(self.session, key, ctx) {
256 Ok(s) => Some(Ok(s)),
257 Err(e) => {
258 self.i = self.events.len();
259 Some(Err(e))
260 }
261 }
262 }
263}
264
265#[derive(Debug, Clone)]
270#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
271pub struct VariantScore {
272 pub index: usize,
274 pub source: String,
276 pub rendered: String,
278 pub score: f64,
281 pub salience: Salience,
283 pub is_last_selected: bool,
286 pub selected: bool,
288}
289
290pub struct Engine {
294 language: Box<dyn Language>,
295 templates: HashMap<String, Vec<SalientTemplate>>,
296 strictness: Strictness,
297 variation: Variation,
298 salience_thresholds: SalienceThresholds,
299 rr_initial: HashMap<String, usize>,
303 #[cfg(feature = "reg")]
304 entity_registry: EntityRegistry,
305 #[cfg(feature = "reg")]
306 reg_preference: Vec<String>,
307 #[cfg(feature = "reg")]
308 reg_algorithm: RegAlgorithm,
309 synonyms: SynonymRegistry,
310 #[cfg(feature = "time")]
311 reference_time: Option<i64>,
312 antonyms: AntonymRegistry,
313 #[cfg(feature = "polish")]
314 max_sentence_length: Option<usize>,
315 #[cfg(feature = "polish")]
316 smart_quotes: bool,
317 sentence_rhythm_enabled: bool,
322 partials: HashMap<String, Template>,
323 language_preference: Option<String>,
327 style_preference: Option<String>,
332 faithfulness_threshold: Option<f32>,
337 style_profile: crate::style::StyleProfile,
342 refine_config: crate::refine::RefineConfig,
346}
347
348#[derive(Debug, Clone, Copy, Default)]
352struct RenderOptions {
353 suppress_auto_connective: bool,
358}
359
360struct RenderCtx<'e, 's> {
364 engine: &'e Engine,
365 session: &'s mut Session,
366}
367
368impl<'e, 's> RenderCtx<'e, 's> {
369 fn new(engine: &'e Engine, session: &'s mut Session) -> Self {
370 Self { engine, session }
371 }
372
373 fn render_tx_with_options(
382 &mut self,
383 key: &str,
384 all_alternatives: &[SalientTemplate],
385 context: &Context,
386 options: RenderOptions,
387 ) -> Result<String, ProsaicError> {
388 self.session.discourse.begin_render();
390
391 let entity_name = context
393 .get("name")
394 .or_else(|| context.get("old_name"))
395 .map(|v| v.as_display());
396 let entity_type = context.get("entity_type").map(|v| v.as_display());
397
398 let connective = if options.suppress_auto_connective {
404 None
405 } else {
406 let relation = self
407 .session
408 .discourse
409 .detect_relation(key, entity_name.as_deref());
410 let prefs = &self.engine.style_profile.connectives;
411 let rst_key = rst_for_discourse(&relation);
412 let allow_owned: Option<Vec<&str>> = rst_key
418 .and_then(|rst| prefs.allowed.get(&rst))
419 .map(|v| v.iter().map(String::as_str).collect());
420 let prefer_owned: Option<Vec<(&str, f32)>> = rst_key
421 .and_then(|rst| prefs.preferred.get(&rst))
422 .map(|v| v.iter().map(|(s, w)| (s.as_str(), *w)).collect());
423 let forbid_owned: Option<Vec<&str>> =
424 if self.session.refine_blacklist_connectives.is_empty() {
425 None
426 } else {
427 Some(
428 self.session
429 .refine_blacklist_connectives
430 .iter()
431 .map(String::as_str)
432 .collect(),
433 )
434 };
435 self.session.discourse.select_connective_filtered(
436 &relation,
437 allow_owned.as_deref(),
438 prefer_owned.as_deref(),
439 forbid_owned.as_deref(),
440 )
441 };
442
443 let target_salience = self.resolve_target_salience(key, context);
451 let alternatives = filter_alternatives(
452 all_alternatives,
453 target_salience,
454 self.engine.language_preference.as_deref(),
455 self.engine.style_preference.as_deref(),
456 );
457
458 let (template, variant_index) =
460 self.select_alternative_scored(key, &alternatives, context)?;
461
462 self.session
464 .discourse
465 .record_template_choice(key, variant_index);
466
467 let mut output = String::with_capacity(128);
469 self.render_template_into(&mut output, key, template, context)?;
470
471 if let Some(conn) = connective {
473 if conn.starts_with("It ") {
474 prepend_replacing_subject_in_place(&mut output, conn, entity_name.as_deref());
475 } else {
476 lowercase_first_in_place(&mut output);
477 let mut buf = String::with_capacity(conn.len() + 1 + output.len());
478 buf.push_str(conn);
479 buf.push(' ');
480 buf.push_str(&output);
481 core::mem::swap(&mut output, &mut buf);
482 }
483 }
484
485 if starts_with_refer_pipe(template) {
487 capitalize_first_in_place(&mut output);
488 }
489
490 let cleanup_stripped = cleanup_artifacts_in_place(&mut output, self.engine.strictness);
494 self.session
495 .discourse
496 .set_cleanup_stripped_tail(cleanup_stripped);
497
498 terminate_sentence_in_place(&mut output);
500
501 #[cfg(feature = "polish")]
503 if let Some(max_chars) = self.engine.max_sentence_length {
504 split_long_in_place(&mut output, max_chars);
505 }
506
507 #[cfg(feature = "polish")]
509 if self.engine.smart_quotes {
510 smart_quotes_in_place(&mut output);
511 }
512
513 if let Some(threshold) = self.engine.faithfulness_threshold {
517 let literals = template.literal_tokens();
518 let score = score_faithfulness(&output, context, &literals, &*self.engine.language);
519 if !score.passes(threshold) {
520 return Err(ProsaicError::FaithfulnessRejection {
521 precision: score.precision,
522 polarity_match: score.polarity_match,
523 });
524 }
525 }
526
527 if let (Some(name), Some(etype)) = (&entity_name, &entity_type) {
529 self.session.discourse.mention_entity(name, etype);
530 }
531
532 self.session.discourse.record_output_words(&output);
534 self.session.discourse.record_sentence_rhythm(&output);
535
536 self.session.discourse.advance_cb();
540
541 Ok(output)
542 }
543
544 fn resolve_target_salience(&self, key: &str, context: &Context) -> Salience {
551 resolve_target_salience_for(self.engine, self.session, key, context)
552 }
553
554 fn candidate_discourse_score(&self, candidate: &str) -> f64 {
555 let mut score = self.session.discourse.repetition_score(candidate);
556 if self.engine.sentence_rhythm_enabled {
557 score += self.session.discourse.sentence_rhythm_score(candidate);
558 }
559 let target_distribution = self
563 .session
564 .refine_length_distribution
565 .as_ref()
566 .unwrap_or(&self.engine.style_profile.sentence_length);
567 score += profile_length_bias_score(candidate, &self.session.discourse, target_distribution);
568 score
569 }
570
571 fn select_alternative_scored<'a>(
572 &mut self,
573 key: &str,
574 alternatives: &[&'a Template],
575 context: &Context,
576 ) -> Result<(&'a Template, usize), ProsaicError> {
577 if alternatives.len() == 1 {
578 return Ok((alternatives[0], 0));
579 }
580
581 let allow_choose_best = matches!(
582 self.engine.variation,
583 Variation::Seeded(_) | Variation::Random
584 );
585
586 if !allow_choose_best {
587 let index = self.select_variant_index(key, alternatives.len());
588 return Ok((alternatives[index], index));
589 }
590
591 let last_variant = self.session.discourse.last_template_variant(key);
592 let is_first = self.session.discourse.is_first_render();
593
594 if is_first {
595 let index = self.select_variant_index(key, alternatives.len());
596 return Ok((alternatives[index], index));
597 }
598
599 let snapshot = self.session.clone();
602
603 let mut candidates: Vec<(usize, String)> = Vec::new();
604 let mut scratch = String::with_capacity(128);
605 for (i, template) in alternatives.iter().enumerate() {
606 if Some(i) == last_variant {
607 continue;
608 }
609 scratch.clear();
610 match self.render_template_into(&mut scratch, key, template, context) {
611 Ok(()) => {}
612 Err(e) => {
613 *self.session = snapshot;
614 return Err(e);
615 }
616 }
617 candidates.push((i, scratch.clone()));
618 }
619
620 *self.session = snapshot;
621
622 if candidates.is_empty() {
623 let index = last_variant.unwrap_or(0).min(alternatives.len() - 1);
624 return Ok((alternatives[index], index));
625 }
626
627 let mut best_index = candidates[0].0;
629 let mut best_score = f64::MAX;
630
631 for (i, candidate) in &candidates {
632 let score = self.candidate_discourse_score(candidate);
633 if score < best_score {
634 best_score = score;
635 best_index = *i;
636 }
637 }
638
639 Ok((alternatives[best_index], best_index))
640 }
641
642 fn select_variant_index(&mut self, key: &str, count: usize) -> usize {
643 match self.engine.variation {
644 Variation::Fixed => 0,
645 Variation::Seeded(seed) => {
646 let hash = simple_hash(key, seed);
647 hash as usize % count
648 }
649 Variation::RoundRobin => {
650 let counter = self
651 .session
652 .round_robin_counters
653 .entry(key.to_string())
654 .or_insert_with(|| AtomicUsize::new(0))
655 .fetch_add(1, Ordering::Relaxed);
656 counter % count
657 }
658 Variation::Random => {
659 #[cfg(feature = "std")]
660 {
661 let nanos = std::time::SystemTime::now()
662 .duration_since(std::time::UNIX_EPOCH)
663 .unwrap_or_default()
664 .subsec_nanos() as usize;
665 nanos % count
666 }
667 #[cfg(not(feature = "std"))]
668 {
669 let _ = count;
671 0
672 }
673 }
674 }
675 }
676
677 fn render_template_into(
678 &mut self,
679 out: &mut String,
680 key: &str,
681 template: &Template,
682 context: &Context,
683 ) -> Result<(), ProsaicError> {
684 self.render_segments_into(out, key, &template.segments, context)
685 }
686
687 fn render_segments_into(
688 &mut self,
689 out: &mut String,
690 key: &str,
691 segments: &[Segment],
692 context: &Context,
693 ) -> Result<(), ProsaicError> {
694 for segment in segments {
695 match segment {
696 Segment::Literal(text) => out.push_str(text),
697 Segment::Slot {
698 key: slot_key,
699 pipes,
700 } => {
701 self.render_slot_into(out, key, slot_key, pipes, context)?;
702 }
703 Segment::Conditional {
704 condition_key,
705 inner,
706 } => {
707 if is_truthy(context.get(condition_key)) {
708 self.render_segments_into(out, key, inner, context)?;
709 }
710 }
711 Segment::Partial { name } => {
712 let partial_segments = self.engine.partials.get(name).ok_or_else(|| {
714 ProsaicError::TemplateParseError {
715 template: key.to_string(),
716 position: 0,
717 reason: format!(
718 "unknown partial `{name}` — register it with `engine.register_partial`"
719 ),
720 }
721 })?.segments.clone();
722 self.render_segments_into(out, key, &partial_segments, context)?;
723 }
724 }
725 }
726
727 Ok(())
728 }
729
730 fn render_slot_into(
731 &mut self,
732 out: &mut String,
733 template_key: &str,
734 slot_key: &str,
735 pipes: &[Pipe],
736 context: &Context,
737 ) -> Result<(), ProsaicError> {
738 let value = match context.get(slot_key) {
739 Some(v) => v.clone(),
740 None => {
741 let s = self.handle_missing_slot(template_key, slot_key)?;
742 out.push_str(&s);
743 return Ok(());
744 }
745 };
746
747 if pipes.is_empty() {
748 out.push_str(&value.as_display());
749 return Ok(());
750 }
751
752 let mut current = value;
753 for pipe in pipes {
754 current = self.apply_pipe(pipe, ¤t, context)?;
755 }
756
757 out.push_str(¤t.as_display());
758 Ok(())
759 }
760
761 fn handle_missing_slot(
762 &self,
763 template_key: &str,
764 slot_key: &str,
765 ) -> Result<String, ProsaicError> {
766 match self.engine.strictness {
767 Strictness::Strict => Err(ProsaicError::MissingSlot {
768 template: template_key.to_string(),
769 slot: slot_key.to_string(),
770 }),
771 Strictness::Lenient => Ok(format!("[missing: {slot_key}]")),
772 Strictness::Silent => Ok(String::new()),
773 }
774 }
775
776 fn apply_pipe(
777 &mut self,
778 pipe: &Pipe,
779 value: &Value,
780 context: &Context,
781 ) -> Result<Value, ProsaicError> {
782 match pipe.name.as_str() {
783 "plural" => self.pipe_plural(pipe, value),
784 "pluralize" => self.pipe_pluralize(pipe, value, context),
785 "article" => self.pipe_article(value),
786 "join" => self.pipe_join(pipe, value),
787 "ordinal" => self.pipe_ordinal(value),
788 "words" => self.pipe_words(value),
789 "truncate" => self.pipe_truncate(pipe, value),
790 "capitalize" => self.pipe_capitalize(value),
791 "refer" => self.pipe_refer(pipe, value, context),
792 "possessive" => self.pipe_possessive(pipe, value, context),
793 "verb" => self.pipe_verb(pipe, value),
794 "syn" => self.pipe_syn(value),
795 #[cfg(feature = "time")]
796 "relative" => self.pipe_relative(value),
797 #[cfg(feature = "time")]
798 "since_last" => self.pipe_since_last(value),
799 "quantify" => self.pipe_quantify(pipe, value),
800 "proportion" => self.pipe_proportion(pipe, value, context),
801 "demonstrative" => self.pipe_demonstrative(value),
802 "hedge" => self.pipe_hedge(pipe, value),
803 "negated" => self.pipe_negated(value),
804 "choose" => self.pipe_choose(pipe, value),
805 _ => Err(ProsaicError::InvalidPipe {
806 pipe: pipe.name.clone(),
807 reason: "unknown pipe".to_string(),
808 }),
809 }
810 }
811
812 fn pipe_choose(&self, pipe: &Pipe, value: &Value) -> Result<Value, ProsaicError> {
813 let arg_str = match &pipe.arg {
814 Some(PipeArg::String(s)) => s.as_str(),
815 Some(PipeArg::Number(_)) | None => {
816 return Err(ProsaicError::InvalidPipe {
817 pipe: "choose".to_string(),
818 reason: "choose requires an argument of the form \
819 'key=value,key=value,default=value'"
820 .to_string(),
821 });
822 }
823 };
824
825 let pairs = parse_choose_pairs(arg_str)?;
826 if pairs.is_empty() {
827 return Err(ProsaicError::InvalidPipe {
828 pipe: "choose".to_string(),
829 reason: "choose argument is empty".to_string(),
830 });
831 }
832
833 let display = value.as_display();
834 let normalized = display.trim().to_lowercase();
835
836 for (k, v) in &pairs {
837 if k.to_lowercase() == normalized {
838 return Ok(Value::String(v.clone()));
839 }
840 }
841
842 for (k, v) in &pairs {
844 if k.eq_ignore_ascii_case("default") {
845 return Ok(Value::String(v.clone()));
846 }
847 }
848
849 match self.engine.strictness {
851 Strictness::Strict => Err(ProsaicError::InvalidPipe {
852 pipe: "choose".to_string(),
853 reason: format!("no matching key for value `{display}` and no default"),
854 }),
855 Strictness::Lenient => Ok(Value::String(format!("[choose: no match for {display}]"))),
856 Strictness::Silent => Ok(Value::String(String::new())),
857 }
858 }
859
860 fn pipe_refer(
861 &mut self,
862 pipe: &Pipe,
863 value: &Value,
864 context: &Context,
865 ) -> Result<Value, ProsaicError> {
866 if let Value::List(names) = value {
869 return self.pipe_refer_plural(pipe, names, context);
870 }
871 self.pipe_refer_single(pipe, value, context)
872 }
873
874 fn pipe_possessive(
875 &self,
876 pipe: &Pipe,
877 value: &Value,
878 context: &Context,
879 ) -> Result<Value, ProsaicError> {
880 if let Value::List(names) = value {
881 return self.pipe_possessive_plural(pipe, names, context);
882 }
883 self.pipe_possessive_single(value)
884 }
885
886 fn pipe_refer_single(
889 &self,
890 pipe: &Pipe,
891 value: &Value,
892 context: &Context,
893 ) -> Result<Value, ProsaicError> {
894 let name = value.as_display();
895
896 let entity_type = match &pipe.arg {
897 Some(PipeArg::String(t)) => t.clone(),
898 _ => context
899 .get("entity_type")
900 .map(|v| v.as_display())
901 .unwrap_or_default(),
902 };
903
904 let form = self.session.discourse.reference_form_with_density(
905 &name,
906 matches!(
907 self.engine.style_profile.pronoun_density,
908 crate::style::PronounDensity::Low
909 ),
910 matches!(
911 self.engine.style_profile.pronoun_density,
912 crate::style::PronounDensity::High
913 ),
914 );
915
916 let rendered = match form {
917 ReferenceForm::Full => self.engine.render_full_reference(&name, &entity_type),
918 ReferenceForm::ShortName => name,
919 ReferenceForm::Pronoun
920 | ReferenceForm::Possessive
921 | ReferenceForm::Demonstrative
922 | ReferenceForm::Zero => {
923 let features = reference_features(value, self.session.discourse.focus_is_plural());
924 self.engine
925 .language
926 .realize_reference(form, &features)
927 .unwrap_or_default()
928 }
929 };
930
931 Ok(Value::String(rendered))
932 }
933
934 fn pipe_possessive_single(&self, value: &Value) -> Result<Value, ProsaicError> {
935 let name = value.as_display();
936 let form = self.session.discourse.reference_form_with_density(
937 &name,
938 matches!(
939 self.engine.style_profile.pronoun_density,
940 crate::style::PronounDensity::Low
941 ),
942 matches!(
943 self.engine.style_profile.pronoun_density,
944 crate::style::PronounDensity::High
945 ),
946 );
947 let rendered = match form {
948 ReferenceForm::Pronoun | ReferenceForm::Demonstrative | ReferenceForm::Zero => {
949 let features = reference_features(value, self.session.discourse.focus_is_plural());
950 self.engine
951 .language
952 .realize_reference(ReferenceForm::Possessive, &features)
953 .unwrap_or_else(|| self.engine.language.possessive_name(&name))
954 }
955 ReferenceForm::Full | ReferenceForm::ShortName | ReferenceForm::Possessive => {
956 self.engine.language.possessive_name(&name)
957 }
958 };
959
960 Ok(Value::String(rendered))
961 }
962
963 fn pipe_possessive_plural(
964 &self,
965 pipe: &Pipe,
966 names: &[String],
967 context: &Context,
968 ) -> Result<Value, ProsaicError> {
969 match names.len() {
970 0 => Ok(Value::String(String::new())),
971 1 => {
972 let v = Value::String(names[0].clone());
973 self.pipe_possessive_single(&v)
974 }
975 n => {
976 let entity_type = match &pipe.arg {
977 Some(PipeArg::String(t)) => t.clone(),
978 _ => context
979 .get("entity_type")
980 .map(|v| v.as_display())
981 .unwrap_or_default(),
982 };
983 let owner = if entity_type.is_empty() {
984 let items: Vec<&str> = names.iter().map(|s| s.as_str()).collect();
985 self.engine
986 .language
987 .join_list(&items, crate::language::Conjunction::And)
988 } else {
989 self.engine.language.plural_description(
990 &entity_type,
991 n,
992 &crate::agreement::AgreementFeatures::default(),
993 )
994 };
995 Ok(Value::String(self.engine.language.possessive_name(&owner)))
996 }
997 }
998 }
999
1000 fn pipe_refer_plural(
1007 &mut self,
1008 pipe: &Pipe,
1009 names: &[String],
1010 context: &Context,
1011 ) -> Result<Value, ProsaicError> {
1012 match names.len() {
1013 0 => Ok(Value::String(String::new())),
1014 1 => {
1015 let v = Value::String(names[0].clone());
1016 self.pipe_refer_single(pipe, &v, context)
1017 }
1018 n => {
1019 let entity_type = match &pipe.arg {
1020 Some(PipeArg::String(t)) => t.clone(),
1021 _ => context
1022 .get("entity_type")
1023 .map(|v| v.as_display())
1024 .unwrap_or_default(),
1025 };
1026
1027 if !entity_type.is_empty() {
1031 for name in names {
1032 self.session.discourse.mention_entity(name, &entity_type);
1033 }
1034 }
1035
1036 self.session.discourse.set_focus_plural(true);
1041
1042 let features = crate::agreement::AgreementFeatures::default();
1045
1046 let output = self
1047 .engine
1048 .language
1049 .plural_description(&entity_type, n, &features);
1050 Ok(Value::String(output))
1051 }
1052 }
1053 }
1054
1055 fn pipe_demonstrative(&self, value: &Value) -> Result<Value, ProsaicError> {
1056 let noun = value.as_display();
1057 if noun.is_empty() {
1058 return Ok(Value::String(noun));
1059 }
1060
1061 let determiner = if self.session.discourse.has_prior_render() {
1062 "this"
1063 } else {
1064 "the"
1065 };
1066
1067 Ok(Value::String(format!("{determiner} {noun}")))
1068 }
1069
1070 fn pipe_syn(&self, value: &Value) -> Result<Value, ProsaicError> {
1071 let word = value.as_display();
1072 let synonyms = match self.engine.synonyms.synonyms_for(&word) {
1073 Some(s) => s,
1074 None => return Ok(Value::String(word)),
1075 };
1076
1077 if synonyms.is_empty() {
1078 return Ok(Value::String(word));
1079 }
1080
1081 let mut best = &synonyms[0];
1082 let mut best_score = self.session.discourse.word_frequency(&synonyms[0]);
1083 for syn in &synonyms[1..] {
1084 let score = self.session.discourse.word_frequency(syn);
1085 if score < best_score {
1086 best_score = score;
1087 best = syn;
1088 }
1089 }
1090
1091 let result = if word
1092 .chars()
1093 .next()
1094 .map(|c| c.is_uppercase())
1095 .unwrap_or(false)
1096 {
1097 let mut s = best.clone();
1098 capitalize_first_in_place(&mut s);
1099 s
1100 } else {
1101 best.clone()
1102 };
1103
1104 Ok(Value::String(result))
1105 }
1106
1107 fn pipe_join(&mut self, pipe: &Pipe, value: &Value) -> Result<Value, ProsaicError> {
1135 let items = value.as_list().ok_or_else(|| ProsaicError::InvalidPipe {
1136 pipe: "join".to_string(),
1137 reason: "value must be a list".to_string(),
1138 })?;
1139
1140 let forced_style = match &pipe.arg {
1141 Some(PipeArg::String(s)) if s == "bracketed" => Some(ListStyle::Bracketed),
1142 Some(PipeArg::String(s)) if s == "including" => Some(ListStyle::Including),
1143 Some(PipeArg::String(s)) if s == "such_as" => Some(ListStyle::SuchAs),
1144 Some(PipeArg::String(s)) if s == "dash" => Some(ListStyle::Dash),
1145 Some(PipeArg::String(s)) if s == "among_others" => Some(ListStyle::AmongOthers),
1146 Some(PipeArg::String(s)) if s == "to_name_a_few" => Some(ListStyle::ToNameAFew),
1147 Some(PipeArg::String(s)) if s == "plus_more" => Some(ListStyle::PlusMore),
1148 _ => None,
1149 };
1150
1151 let conjunction = match &pipe.arg {
1152 Some(PipeArg::String(s)) if s == "or" => Conjunction::Or,
1153 _ => Conjunction::And,
1154 };
1155
1156 let style = match forced_style {
1157 Some(s) => {
1158 self.session.discourse.record_list_style_used(s);
1161 s
1162 }
1163 None => {
1164 let mut bias_target =
1165 list_style_bias_target(self.engine.style_profile.list_style_bias);
1166 if let Some(target) = bias_target
1170 && self.session.refine_blacklist_list_styles.contains(&target)
1171 {
1172 bias_target = None;
1173 }
1174 let chosen = self
1175 .session
1176 .discourse
1177 .next_list_style_with_bias(bias_target);
1178 if self.session.refine_blacklist_list_styles.contains(&chosen) {
1183 let mut next = chosen;
1184 for _ in 0..crate::discourse::list_styles_count() {
1185 next = self.session.discourse.next_list_style_with_bias(None);
1186 if !self.session.refine_blacklist_list_styles.contains(&next) {
1187 break;
1188 }
1189 }
1190 next
1191 } else {
1192 chosen
1193 }
1194 }
1195 };
1196
1197 let refs: Vec<&str> = items.iter().map(|s| s.as_str()).collect();
1198
1199 let has_truncation = items.last().is_some_and(|last| {
1200 last.ends_with(" more")
1201 && last
1202 .split_whitespace()
1203 .next()
1204 .is_some_and(|w| w.parse::<usize>().is_ok())
1205 });
1206
1207 if has_truncation && items.len() >= 2 {
1208 let shown = &refs[..refs.len() - 1];
1209 let remainder = &items[items.len() - 1];
1210 Ok(Value::String(format_truncated_list(
1211 shown,
1212 remainder,
1213 style,
1214 conjunction,
1215 &*self.engine.language,
1216 )))
1217 } else {
1218 let joined = self.engine.language.join_list(&refs, conjunction);
1219 Ok(Value::String(joined))
1220 }
1221 }
1222
1223 fn pipe_pluralize(
1224 &self,
1225 pipe: &Pipe,
1226 value: &Value,
1227 _context: &Context,
1228 ) -> Result<Value, ProsaicError> {
1229 let word = match &pipe.arg {
1230 Some(PipeArg::String(w)) => w.as_str(),
1231 _ => {
1232 return Err(ProsaicError::InvalidPipe {
1233 pipe: "pluralize".to_string(),
1234 reason: "requires a word argument, e.g., {count|pluralize:item}".to_string(),
1235 });
1236 }
1237 };
1238
1239 let count = value.as_number().ok_or_else(|| ProsaicError::InvalidPipe {
1240 pipe: "pluralize".to_string(),
1241 reason: "value must be a number".to_string(),
1242 })? as usize;
1243
1244 Ok(Value::String(self.engine.language.pluralize(word, count)))
1245 }
1246
1247 fn pipe_plural(&self, pipe: &Pipe, value: &Value) -> Result<Value, ProsaicError> {
1255 let noun = match &pipe.arg {
1256 Some(PipeArg::String(s)) => s.as_str(),
1257 _ => {
1258 return Err(ProsaicError::InvalidPipe {
1259 pipe: "plural".to_string(),
1260 reason: "requires a singular noun argument, e.g., {count|plural:service}"
1261 .to_string(),
1262 });
1263 }
1264 };
1265
1266 let count = value.as_number().ok_or_else(|| ProsaicError::InvalidPipe {
1267 pipe: "plural".to_string(),
1268 reason: "requires a numeric slot value".to_string(),
1269 })?;
1270
1271 let category: PluralCategory = self.engine.language.plural_category(count);
1272 Ok(Value::String(
1273 self.engine.language.pluralize_with_category(noun, category),
1274 ))
1275 }
1276
1277 fn pipe_article(&self, value: &Value) -> Result<Value, ProsaicError> {
1278 let word = value.as_display();
1279 let article = self.engine.language.article(&word);
1280 Ok(Value::String(format!("{article} {word}")))
1281 }
1282
1283 fn pipe_ordinal(&self, value: &Value) -> Result<Value, ProsaicError> {
1284 let n = value.as_number().ok_or_else(|| ProsaicError::InvalidPipe {
1285 pipe: "ordinal".to_string(),
1286 reason: "value must be a number".to_string(),
1287 })? as usize;
1288
1289 Ok(Value::String(self.engine.language.ordinal(n)))
1290 }
1291
1292 fn pipe_words(&self, value: &Value) -> Result<Value, ProsaicError> {
1293 let n = value.as_number().ok_or_else(|| ProsaicError::InvalidPipe {
1294 pipe: "words".to_string(),
1295 reason: "value must be a number".to_string(),
1296 })? as usize;
1297
1298 Ok(Value::String(self.engine.language.number_to_words(n)))
1299 }
1300
1301 fn pipe_truncate(&self, pipe: &Pipe, value: &Value) -> Result<Value, ProsaicError> {
1302 let max = match &pipe.arg {
1303 Some(PipeArg::Number(n)) => *n,
1304 _ => {
1305 return Err(ProsaicError::InvalidPipe {
1306 pipe: "truncate".to_string(),
1307 reason: "requires a numeric argument, e.g., {items|truncate:3}".to_string(),
1308 });
1309 }
1310 };
1311
1312 let items = value.as_list().ok_or_else(|| ProsaicError::InvalidPipe {
1313 pipe: "truncate".to_string(),
1314 reason: "value must be a list".to_string(),
1315 })?;
1316
1317 if items.len() <= max {
1318 return Ok(value.clone());
1319 }
1320
1321 let remaining = items.len() - max;
1322 let mut truncated: Vec<String> = items[..max].to_vec();
1323 let suffix = format!("{remaining} more");
1324 truncated.push(suffix);
1325
1326 Ok(Value::List(truncated))
1327 }
1328
1329 fn pipe_capitalize(&self, value: &Value) -> Result<Value, ProsaicError> {
1330 let mut s = value.as_display();
1331 capitalize_first_in_place(&mut s);
1332 Ok(Value::String(s))
1333 }
1334
1335 fn pipe_negated(&self, value: &Value) -> Result<Value, ProsaicError> {
1336 let phrase = value.as_display();
1337 if let Some(positive) = self.engine.antonyms.lookup(&phrase) {
1338 return Ok(Value::String(positive.to_string()));
1339 }
1340 Ok(Value::String(insert_not(&phrase)))
1341 }
1342
1343 fn pipe_hedge(&self, pipe: &Pipe, value: &Value) -> Result<Value, ProsaicError> {
1344 let score = value.as_number().ok_or_else(|| ProsaicError::InvalidPipe {
1345 pipe: "hedge".to_string(),
1346 reason: "value must be a 0..=100 integer confidence score".to_string(),
1347 })?;
1348
1349 let mode = match &pipe.arg {
1350 None => HedgeMode::Adverb,
1351 Some(PipeArg::String(s)) => {
1352 parse_hedge_mode(s).ok_or_else(|| ProsaicError::InvalidPipe {
1353 pipe: "hedge".to_string(),
1354 reason: format!(
1355 "unknown hedge mode `{s}` — expected one of adverb, modal, prefix"
1356 ),
1357 })?
1358 }
1359 Some(PipeArg::Number(_)) => {
1360 return Err(ProsaicError::InvalidPipe {
1361 pipe: "hedge".to_string(),
1362 reason: "hedge argument must be a mode name, not a number".to_string(),
1363 });
1364 }
1365 };
1366
1367 Ok(Value::String(
1368 hedge_with_calibration(score, mode, &self.engine.style_profile.hedging).to_string(),
1369 ))
1370 }
1371
1372 fn pipe_proportion(
1373 &self,
1374 pipe: &Pipe,
1375 value: &Value,
1376 context: &Context,
1377 ) -> Result<Value, ProsaicError> {
1378 let arg_str = match &pipe.arg {
1379 Some(PipeArg::String(s)) => s.as_str(),
1380 Some(PipeArg::Number(_)) | None => {
1381 return Err(ProsaicError::InvalidPipe {
1382 pipe: "proportion".to_string(),
1383 reason: "requires an argument of the form \
1384 `proportion:total_key[:singular_noun]`"
1385 .to_string(),
1386 });
1387 }
1388 };
1389
1390 let (total_key, noun) = match arg_str.split_once(':') {
1393 Some((k, n)) => {
1394 let n = n.trim();
1395 (k.trim(), if n.is_empty() { None } else { Some(n) })
1396 }
1397 None => (arg_str.trim(), None),
1398 };
1399
1400 if total_key.is_empty() {
1401 return Err(ProsaicError::InvalidPipe {
1402 pipe: "proportion".to_string(),
1403 reason: "missing total context key — use `proportion:total_key[:noun]`".to_string(),
1404 });
1405 }
1406
1407 let matching = value.as_number().ok_or_else(|| ProsaicError::InvalidPipe {
1408 pipe: "proportion".to_string(),
1409 reason: "value must be a number".to_string(),
1410 })?;
1411
1412 let total_value = context
1413 .get(total_key)
1414 .ok_or_else(|| ProsaicError::InvalidPipe {
1415 pipe: "proportion".to_string(),
1416 reason: format!("total context key `{total_key}` not found"),
1417 })?;
1418
1419 let total = total_value
1420 .as_number()
1421 .ok_or_else(|| ProsaicError::InvalidPipe {
1422 pipe: "proportion".to_string(),
1423 reason: format!("total context key `{total_key}` is not a number"),
1424 })?;
1425
1426 let features = AgreementFeatures::default();
1427 let phrase = self
1428 .engine
1429 .language
1430 .proportion_phrase(matching, total, noun, &features);
1431 Ok(Value::String(phrase))
1432 }
1433
1434 fn pipe_quantify(&self, pipe: &Pipe, value: &Value) -> Result<Value, ProsaicError> {
1435 let count = value.as_number().ok_or_else(|| ProsaicError::InvalidPipe {
1436 pipe: "quantify".to_string(),
1437 reason: "value must be a number".to_string(),
1438 })?;
1439
1440 let mode = match &pipe.arg {
1441 None => QuantifyMode::Natural,
1442 Some(PipeArg::String(s)) => {
1443 parse_quantify_mode(s).ok_or_else(|| ProsaicError::InvalidPipe {
1444 pipe: "quantify".to_string(),
1445 reason: format!(
1446 "unknown quantify mode `{s}` — expected one of natural, exact, hedged"
1447 ),
1448 })?
1449 }
1450 Some(PipeArg::Number(_)) => {
1451 return Err(ProsaicError::InvalidPipe {
1452 pipe: "quantify".to_string(),
1453 reason: "quantify argument must be a mode name, not a number".to_string(),
1454 });
1455 }
1456 };
1457
1458 Ok(Value::String(quantify_fn(
1459 count,
1460 mode,
1461 &*self.engine.language,
1462 )))
1463 }
1464
1465 fn pipe_verb(&self, pipe: &Pipe, value: &Value) -> Result<Value, ProsaicError> {
1466 let spec = match &pipe.arg {
1467 Some(PipeArg::String(s)) => s.as_str(),
1468 _ => {
1469 return Err(ProsaicError::InvalidPipe {
1470 pipe: "verb".to_string(),
1471 reason: "requires a form spec argument, e.g., \
1472 {rename|verb:present_perfect}"
1473 .to_string(),
1474 });
1475 }
1476 };
1477
1478 let (form, voice) =
1479 VerbForm::parse_spec(spec).ok_or_else(|| ProsaicError::InvalidPipe {
1480 pipe: "verb".to_string(),
1481 reason: format!(
1482 "unknown verb form spec `{spec}` — expected one of past, present, future, \
1483 present_perfect, past_perfect, future_perfect, present_progressive, \
1484 past_progressive, conditional, conditional_perfect \
1485 (optionally prefixed with `active_` or `passive_`)"
1486 ),
1487 })?;
1488
1489 let verb = value.as_display();
1490 let phrase = self
1491 .engine
1492 .language
1493 .verb_phrase(&verb, form, voice, Person::Third);
1494 Ok(Value::String(phrase))
1495 }
1496
1497 #[cfg(feature = "time")]
1498 fn pipe_relative(&self, value: &Value) -> Result<Value, ProsaicError> {
1499 let ts = value.as_number().ok_or_else(|| ProsaicError::InvalidPipe {
1500 pipe: "relative".to_string(),
1501 reason: "value must be a Unix-epoch integer (seconds)".to_string(),
1502 })?;
1503
1504 let now = match self.engine.reference_time {
1505 Some(n) => n,
1506 None => {
1507 std::time::SystemTime::now()
1510 .duration_since(std::time::UNIX_EPOCH)
1511 .map(|d| d.as_secs() as i64)
1512 .unwrap_or(0)
1513 }
1514 };
1515
1516 let diff = now - ts;
1517 Ok(Value::String(format_relative(diff)))
1518 }
1519
1520 #[cfg(feature = "time")]
1521 fn pipe_since_last(&mut self, value: &Value) -> Result<Value, ProsaicError> {
1522 let Some(ts) = value.as_number() else {
1523 return Err(ProsaicError::InvalidPipe {
1524 pipe: "since_last".to_string(),
1525 reason: "expected numeric Unix-seconds timestamp".to_string(),
1526 });
1527 };
1528
1529 let marker = match self.session.last_temporal_anchor {
1530 Some(anchor) => self.engine.language.since_last_marker(ts - anchor),
1531 None => {
1532 let now = match self.engine.reference_time {
1536 Some(n) => n,
1537 None => {
1538 std::time::SystemTime::now()
1541 .duration_since(std::time::UNIX_EPOCH)
1542 .map(|d| d.as_secs() as i64)
1543 .unwrap_or(0)
1544 }
1545 };
1546 format_relative(now - ts)
1547 }
1548 };
1549
1550 Ok(Value::String(marker))
1551 }
1552
1553 fn score_all_variants(
1555 &mut self,
1556 key: &str,
1557 all: &[SalientTemplate],
1558 ctx: &Context,
1559 ) -> Result<Vec<VariantScore>, ProsaicError> {
1560 let target_salience = self.resolve_target_salience(key, ctx);
1561 let alternatives = filter_alternatives(
1562 all,
1563 target_salience,
1564 self.engine.language_preference.as_deref(),
1565 self.engine.style_preference.as_deref(),
1566 );
1567
1568 let snapshot = self.session.clone();
1570
1571 let last_variant = self.session.discourse.last_template_variant(key);
1572 let mut scores: Vec<VariantScore> = Vec::with_capacity(alternatives.len());
1573 let mut scratch = String::with_capacity(128);
1574
1575 for (i, template) in alternatives.iter().enumerate() {
1576 scratch.clear();
1577 match self.render_template_into(&mut scratch, key, template, ctx) {
1578 Ok(()) => {}
1579 Err(e) => {
1580 *self.session = snapshot;
1581 return Err(e);
1582 }
1583 }
1584 scores.push(VariantScore {
1585 index: i,
1586 source: template.source.clone(),
1587 rendered: scratch.clone(),
1588 score: 0.0,
1589 salience: target_salience,
1590 is_last_selected: Some(i) == last_variant,
1591 selected: false,
1592 });
1593 }
1594
1595 for s in scores.iter_mut() {
1596 s.score = self.candidate_discourse_score(&s.rendered);
1597 }
1598
1599 let selected_idx = self.pick_variant_index(key, &alternatives, last_variant, &scores);
1601 if let Some(idx) = selected_idx
1602 && let Some(s) = scores.get_mut(idx)
1603 {
1604 s.selected = true;
1605 }
1606
1607 *self.session = snapshot;
1608 Ok(scores)
1609 }
1610
1611 fn pick_variant_index(
1612 &self,
1613 key: &str,
1614 alternatives: &[&Template],
1615 last_variant: Option<usize>,
1616 scores: &[VariantScore],
1617 ) -> Option<usize> {
1618 if alternatives.is_empty() {
1619 return None;
1620 }
1621 if alternatives.len() == 1 {
1622 return Some(0);
1623 }
1624
1625 let allow_choose_best = matches!(
1626 self.engine.variation,
1627 Variation::Seeded(_) | Variation::Random
1628 );
1629
1630 let is_first = self.session.discourse.is_first_render();
1631 if !allow_choose_best || is_first {
1632 return Some(
1633 self.engine
1634 .pick_variant_index_static(key, alternatives.len()),
1635 );
1636 }
1637
1638 let mut best_idx: Option<usize> = None;
1639 let mut best_score = f64::MAX;
1640 for (i, s) in scores.iter().enumerate() {
1641 if Some(i) == last_variant && scores.len() > 1 {
1642 continue;
1643 }
1644 if s.score < best_score {
1645 best_score = s.score;
1646 best_idx = Some(i);
1647 }
1648 }
1649 best_idx.or(Some(0))
1650 }
1651}
1652
1653impl Engine {
1654 pub fn new(language: impl Language + 'static) -> Self {
1656 Self {
1657 language: Box::new(language),
1658 templates: new_map(),
1659 strictness: Strictness::default(),
1660 variation: Variation::default(),
1661 salience_thresholds: SalienceThresholds::default(),
1662 rr_initial: new_map(),
1663 #[cfg(feature = "reg")]
1664 entity_registry: EntityRegistry::new(),
1665 #[cfg(feature = "reg")]
1666 reg_preference: Vec::new(),
1667 #[cfg(feature = "reg")]
1668 reg_algorithm: RegAlgorithm::default(),
1669 synonyms: SynonymRegistry::new(),
1670 #[cfg(feature = "time")]
1671 reference_time: None,
1672 antonyms: AntonymRegistry::new(),
1673 #[cfg(feature = "polish")]
1674 max_sentence_length: None,
1675 #[cfg(feature = "polish")]
1676 smart_quotes: false,
1677 sentence_rhythm_enabled: true,
1678 partials: new_map(),
1679 language_preference: None,
1680 style_preference: None,
1681 faithfulness_threshold: None,
1682 style_profile: crate::style::StyleProfile::neutral(),
1683 refine_config: crate::refine::RefineConfig::off(),
1684 }
1685 }
1686
1687 pub fn language_preference(mut self, lang: impl Into<String>) -> Self {
1693 self.language_preference = Some(lang.into());
1694 self
1695 }
1696
1697 pub fn set_language_preference(&mut self, lang: impl Into<String>) {
1701 self.language_preference = Some(lang.into());
1702 }
1703
1704 pub fn style_preference(mut self, style: impl Into<String>) -> Self {
1710 self.style_preference = Some(style.into());
1711 self
1712 }
1713
1714 pub fn set_style_preference(&mut self, style: impl Into<String>) {
1717 self.style_preference = Some(style.into());
1718 }
1719
1720 pub fn strictness(mut self, strictness: Strictness) -> Self {
1722 self.strictness = strictness;
1723 self
1724 }
1725
1726 pub fn variation(mut self, variation: Variation) -> Self {
1728 self.variation = variation;
1729 self
1730 }
1731
1732 pub fn salience_thresholds(mut self, thresholds: SalienceThresholds) -> Self {
1734 self.salience_thresholds = thresholds;
1735 self
1736 }
1737
1738 pub fn style_profile(mut self, profile: crate::style::StyleProfile) -> Self {
1744 self.style_profile = profile;
1745 self
1746 }
1747
1748 pub fn current_style_profile(&self) -> &crate::style::StyleProfile {
1751 &self.style_profile
1752 }
1753
1754 pub fn refine(mut self, config: crate::refine::RefineConfig) -> Self {
1760 self.refine_config = config;
1761 self
1762 }
1763
1764 pub fn current_refine_config(&self) -> &crate::refine::RefineConfig {
1766 &self.refine_config
1767 }
1768
1769 #[cfg(feature = "reg")]
1806 pub fn register_entity(&mut self, descriptor: EntityDescriptor) {
1807 self.entity_registry.insert(descriptor);
1808 }
1809
1810 #[cfg(feature = "reg")]
1818 pub fn attribute_preference(mut self, order: Vec<String>) -> Self {
1819 self.reg_preference = order;
1820 self
1821 }
1822
1823 #[cfg(feature = "reg")]
1832 pub fn reg_algorithm(mut self, algo: RegAlgorithm) -> Self {
1833 self.reg_algorithm = algo;
1834 self
1835 }
1836
1837 #[cfg(feature = "time")]
1860 pub fn reference_time(mut self, unix_secs: i64) -> Self {
1861 self.reference_time = Some(unix_secs);
1862 self
1863 }
1864
1865 pub fn register_partial(&mut self, name: &str, source: &str) -> Result<(), ProsaicError> {
1883 let template = Template::parse(source)?;
1884
1885 let previous = self.partials.insert(name.to_string(), template);
1889 if let Err(cycle) = detect_partial_cycle(&self.partials, name) {
1890 match previous {
1891 Some(prior) => {
1892 self.partials.insert(name.to_string(), prior);
1893 }
1894 None => {
1895 self.partials.remove(name);
1896 }
1897 }
1898 return Err(ProsaicError::RecursivePartial { cycle });
1899 }
1900 Ok(())
1901 }
1902
1903 #[cfg(feature = "polish")]
1923 pub fn smart_quotes(mut self, enabled: bool) -> Self {
1924 self.smart_quotes = enabled;
1925 self
1926 }
1927
1928 #[cfg(feature = "polish")]
1956 pub fn max_sentence_length(mut self, max_chars: usize) -> Self {
1957 self.max_sentence_length = Some(max_chars);
1958 self
1959 }
1960
1961 pub fn sentence_rhythm(mut self, enabled: bool) -> Self {
1971 self.sentence_rhythm_enabled = enabled;
1972 self
1973 }
1974
1975 pub fn register_antonym(&mut self, negative: &str, positive: &str) {
1982 self.antonyms.register(negative, positive);
1983 }
1984
1985 pub fn register_synonyms(&mut self, group: &[&str]) {
1998 self.synonyms.register_group(group);
1999 }
2000
2001 pub fn with_faithfulness_gate(mut self, threshold: f32) -> Self {
2036 self.faithfulness_threshold = Some(threshold);
2037 self
2038 }
2039
2040 pub fn language(&self) -> &dyn Language {
2042 &*self.language
2043 }
2044
2045 pub fn register_template(&mut self, key: &str, source: &str) -> Result<(), ProsaicError> {
2067 self.register_template_at(key, source, Salience::Medium)
2068 }
2069
2070 pub fn register_template_at(
2073 &mut self,
2074 key: &str,
2075 source: &str,
2076 salience: Salience,
2077 ) -> Result<(), ProsaicError> {
2078 self.register_template_with_language_and_style_at(key, source, salience, None, None)
2079 }
2080
2081 pub fn register_template_with_language(
2087 &mut self,
2088 key: &str,
2089 source: &str,
2090 language: Option<&str>,
2091 ) -> Result<(), ProsaicError> {
2092 self.register_template_with_language_and_style_at(
2093 key,
2094 source,
2095 Salience::Medium,
2096 language,
2097 None,
2098 )
2099 }
2100
2101 pub fn register_template_with_style(
2106 &mut self,
2107 key: &str,
2108 source: &str,
2109 style: Option<&str>,
2110 ) -> Result<(), ProsaicError> {
2111 self.register_template_with_language_and_style_at(
2112 key,
2113 source,
2114 Salience::Medium,
2115 None,
2116 style,
2117 )
2118 }
2119
2120 pub fn register_template_with_language_and_style(
2122 &mut self,
2123 key: &str,
2124 source: &str,
2125 language: Option<&str>,
2126 style: Option<&str>,
2127 ) -> Result<(), ProsaicError> {
2128 self.register_template_with_language_and_style_at(
2129 key,
2130 source,
2131 Salience::Medium,
2132 language,
2133 style,
2134 )
2135 }
2136
2137 #[cfg(feature = "serde")]
2144 pub fn load_manifest(&mut self, json: &str) -> Result<(), ProsaicError> {
2145 let bundle: manifest_loader::ManifestBundle =
2146 serde_json::from_str(json).map_err(|e| ProsaicError::TemplateParseError {
2147 template: "(manifest)".to_string(),
2148 position: 0,
2149 reason: format!("manifest JSON parse error: {e}"),
2150 })?;
2151 if bundle.schema_version != 1 {
2152 return Err(ProsaicError::TemplateParseError {
2153 template: "(manifest)".to_string(),
2154 position: 0,
2155 reason: format!(
2156 "unsupported manifest schema version {}",
2157 bundle.schema_version
2158 ),
2159 });
2160 }
2161 apply_manifest_engine_settings(self, &bundle.engine)?;
2162 self.language_preference = Some(bundle.language);
2163 for partial in bundle.partials {
2164 self.register_partial(&partial.name, &partial.body)?;
2165 }
2166 for template in bundle.templates {
2167 for variant in template.variants {
2168 let salience = match variant.salience.as_str() {
2169 "low" => Salience::Low,
2170 "medium" => Salience::Medium,
2171 "high" => Salience::High,
2172 other => {
2173 return Err(ProsaicError::TemplateParseError {
2174 template: "(manifest)".to_string(),
2175 position: 0,
2176 reason: format!(
2177 "unknown salience `{other}` for template `{}`",
2178 template.key
2179 ),
2180 });
2181 }
2182 };
2183 self.register_template_with_language_and_style_at(
2184 &template.key,
2185 &variant.body,
2186 salience,
2187 variant.language.as_deref(),
2188 variant.style.as_deref(),
2189 )?;
2190 }
2191 }
2192 Ok(())
2193 }
2194
2195 pub fn register_template_with_language_at(
2197 &mut self,
2198 key: &str,
2199 source: &str,
2200 salience: Salience,
2201 language: Option<&str>,
2202 ) -> Result<(), ProsaicError> {
2203 self.register_template_with_language_and_style_at(key, source, salience, language, None)
2204 }
2205
2206 pub fn register_template_with_style_at(
2208 &mut self,
2209 key: &str,
2210 source: &str,
2211 salience: Salience,
2212 style: Option<&str>,
2213 ) -> Result<(), ProsaicError> {
2214 self.register_template_with_language_and_style_at(key, source, salience, None, style)
2215 }
2216
2217 pub fn register_template_with_language_and_style_at(
2219 &mut self,
2220 key: &str,
2221 source: &str,
2222 salience: Salience,
2223 language: Option<&str>,
2224 style: Option<&str>,
2225 ) -> Result<(), ProsaicError> {
2226 let template = Template::parse(source)?;
2227
2228 template
2232 .infer_types()
2233 .map_err(|reason| ProsaicError::TemplateParseError {
2234 template: source.to_string(),
2235 position: 0,
2236 reason,
2237 })?;
2238
2239 self.templates
2240 .entry(key.to_string())
2241 .or_default()
2242 .push(SalientTemplate::new(
2243 salience,
2244 template,
2245 language.map(|s| s.to_string()),
2246 style.map(|s| s.to_string()),
2247 ));
2248 self.rr_initial.entry(key.to_string()).or_insert(0);
2249 Ok(())
2250 }
2251
2252 pub fn register_template_with_schema<T>(
2262 &mut self,
2263 key: &str,
2264 source: &str,
2265 ) -> Result<(), ProsaicError>
2266 where
2267 T: crate::HasProsaicSchema + crate::IntoContext,
2268 {
2269 let template = Template::parse(source)?;
2270 let inferred =
2271 template
2272 .infer_types()
2273 .map_err(|reason| ProsaicError::TemplateParseError {
2274 template: source.to_string(),
2275 position: 0,
2276 reason,
2277 })?;
2278
2279 let ty = core::any::type_name::<T>();
2280 for (slot, expected) in &inferred {
2281 let actual = crate::schema_lookup(T::PROSAIC_SCHEMA, slot).ok_or_else(|| {
2282 ProsaicError::TemplateParseError {
2283 template: source.to_string(),
2284 position: 0,
2285 reason: format!(
2286 "slot `{slot}` required by template is not declared in context `{ty}`"
2287 ),
2288 }
2289 })?;
2290 if !crate::types_compatible(actual, *expected) {
2291 return Err(ProsaicError::TemplateParseError {
2292 template: source.to_string(),
2293 position: 0,
2294 reason: format!(
2295 "slot `{slot}` in context `{ty}` has type {actual:?} but template pipe chain expects {expected:?}"
2296 ),
2297 });
2298 }
2299 }
2300
2301 self.templates
2302 .entry(key.to_string())
2303 .or_default()
2304 .push(SalientTemplate::new(Salience::Medium, template, None, None));
2305 self.rr_initial.entry(key.to_string()).or_insert(0);
2306 Ok(())
2307 }
2308
2309 pub fn has_template(&self, key: &str) -> bool {
2328 self.templates.contains_key(key)
2329 }
2330
2331 pub fn context_salience(&self, ctx: &Context) -> Salience {
2333 let thresholds = apply_salience_bias(self.salience_thresholds, self.style_profile.salience);
2334 Salience::from_context(ctx, thresholds)
2335 }
2336
2337 pub fn render(
2364 &self,
2365 session: &mut Session,
2366 key: &str,
2367 context: impl IntoContext,
2368 ) -> Result<String, ProsaicError> {
2369 self.render_with_options(session, key, context, RenderOptions::default())
2370 }
2371
2372 fn render_with_options(
2377 &self,
2378 session: &mut Session,
2379 key: &str,
2380 context: impl IntoContext,
2381 options: RenderOptions,
2382 ) -> Result<String, ProsaicError> {
2383 let all_alternatives = self
2384 .templates
2385 .get(key)
2386 .ok_or_else(|| ProsaicError::UnknownTemplate(key.to_string()))?;
2387 let context = context.into_context();
2388
2389 let snapshot = session.clone();
2390 match RenderCtx::new(self, session).render_tx_with_options(
2391 key,
2392 all_alternatives,
2393 &context,
2394 options,
2395 ) {
2396 Ok(output) => {
2397 #[cfg(feature = "time")]
2401 if let Some(Value::Number(ts)) = context.get("timestamp") {
2402 session.last_temporal_anchor = Some(*ts);
2403 }
2404 Ok(output)
2405 }
2406 Err(e) => {
2407 *session = snapshot;
2408 Err(e)
2409 }
2410 }
2411 }
2412
2413 pub fn score_variants(
2424 &self,
2425 session: &mut Session,
2426 key: &str,
2427 context: impl IntoContext,
2428 ) -> Result<Vec<VariantScore>, ProsaicError> {
2429 let all = self
2430 .templates
2431 .get(key)
2432 .ok_or_else(|| ProsaicError::UnknownTemplate(key.to_string()))?;
2433
2434 let ctx = context.into_context();
2435 RenderCtx::new(self, session).score_all_variants(key, all, &ctx)
2437 }
2438
2439 pub fn render_inline(
2452 &self,
2453 session: &mut Session,
2454 source: &str,
2455 context: impl IntoContext,
2456 ) -> Result<String, ProsaicError> {
2457 let template = Template::parse(source)?;
2458 let context = context.into_context();
2459
2460 let mut scratch = session.clone();
2464 let mut output = String::with_capacity(128);
2465 RenderCtx::new(self, &mut scratch).render_template_into(
2466 &mut output,
2467 "<inline>",
2468 &template,
2469 &context,
2470 )?;
2471
2472 session.discourse.record_output_words(&output);
2476 Ok(output)
2477 }
2478
2479 pub fn render_batch(
2518 &self,
2519 session: &mut Session,
2520 events: &[(&str, Context)],
2521 ) -> Result<String, ProsaicError> {
2522 if events.is_empty() {
2523 return Ok(String::new());
2524 }
2525
2526 let mut sentences: Vec<String> = Vec::new();
2527 let mut i = 0;
2528
2529 while i < events.len() {
2530 let action_end = self.find_same_action_run(events, i);
2532
2533 if action_end > i + 1 {
2534 let sentence =
2537 self.render_aggregated_subjects(session, events[i].0, &events[i..action_end])?;
2538 sentences.push(sentence);
2539 i = action_end;
2540 continue;
2541 }
2542
2543 let gap_end = self.find_gapping_run(events, i);
2548 if gap_end > i + 1 {
2549 let mut rendered: Vec<String> = Vec::with_capacity(gap_end - i);
2550 for (key, ctx) in &events[i..gap_end] {
2551 rendered.push(self.render(session, key, ctx)?);
2552 }
2553 if let Some(gapped) = reduce_gapping(&rendered) {
2554 sentences.push(gapped);
2555 } else {
2556 sentences.extend(rendered);
2557 }
2558 i = gap_end;
2559 continue;
2560 }
2561
2562 let entity_end = self.find_same_entity_run(events, i);
2567 if entity_end > i + 1 {
2568 let mut run_rendered: Vec<String> = Vec::with_capacity(entity_end - i);
2569 for (key, ctx) in &events[i..entity_end] {
2570 run_rendered.push(self.render(session, key, ctx)?);
2571 }
2572
2573 if let Some(reduced) = reduce_same_entity_clauses(&run_rendered) {
2574 sentences.push(reduced);
2575 } else {
2576 sentences.extend(run_rendered);
2577 }
2578 i = entity_end;
2579 continue;
2580 }
2581
2582 let (key, ref ctx) = events[i];
2584 sentences.push(self.render(session, key, ctx)?);
2585 i += 1;
2586 }
2587
2588 Ok(sentences.join(" "))
2589 }
2590
2591 pub fn render_batch_with_relations(
2604 &self,
2605 session: &mut Session,
2606 events: &[(&str, Context, Option<crate::rst::RstRelation>)],
2607 ) -> Result<String, ProsaicError> {
2608 if events.is_empty() {
2609 return Ok(String::new());
2610 }
2611
2612 if events.iter().all(|(_, _, r)| r.is_none()) {
2615 let pairs: Vec<(&str, Context)> =
2616 events.iter().map(|(k, c, _)| (*k, c.clone())).collect();
2617 return self.render_batch(session, &pairs);
2618 }
2619
2620 let mut output = String::new();
2621 for (i, (key, ctx, relation)) in events.iter().enumerate() {
2622 if i > 0 {
2623 if let Some(rel) = relation {
2624 if let Some(marker) = self.language.discourse_marker(*rel) {
2625 output.push(' ');
2626 output.push_str(marker);
2627 } else {
2628 output.push(' ');
2629 }
2630 } else {
2631 output.push(' ');
2632 }
2633 }
2634 let options = if i > 0 && relation.is_some() {
2641 RenderOptions {
2642 suppress_auto_connective: true,
2643 }
2644 } else {
2645 RenderOptions::default()
2646 };
2647 let sentence = self.render_with_options(session, key, ctx, options)?;
2648
2649 if i > 0 && relation.is_some() {
2653 output.push_str(&lowercase_first_if_determiner(&sentence));
2654 } else {
2655 output.push_str(&sentence);
2656 }
2657 }
2658
2659 Ok(output)
2660 }
2661
2662 fn find_same_entity_run(&self, events: &[(&str, Context)], start: usize) -> usize {
2669 if start >= events.len() {
2670 return start;
2671 }
2672
2673 let first_ctx = &events[start].1;
2674 let first_name = match entity_name_from_context(first_ctx) {
2675 Some(n) => n,
2676 None => return start + 1,
2677 };
2678 let first_type = first_ctx.get("entity_type").map(|v| v.as_display());
2679
2680 let mut end = start + 1;
2681 while end < events.len() {
2682 let ctx = &events[end].1;
2683 let name = match entity_name_from_context(ctx) {
2684 Some(n) => n,
2685 None => break,
2686 };
2687 if name != first_name {
2688 break;
2689 }
2690 let ty = ctx.get("entity_type").map(|v| v.as_display());
2691 if ty != first_type {
2692 break;
2693 }
2694 end += 1;
2695 }
2696
2697 end
2698 }
2699
2700 pub fn render_explained(
2710 &self,
2711 session: &mut Session,
2712 key: &str,
2713 context: impl IntoContext,
2714 ) -> Result<RenderExplanation, ProsaicError> {
2715 let all_alternatives = self
2716 .templates
2717 .get(key)
2718 .ok_or_else(|| ProsaicError::UnknownTemplate(key.to_string()))?;
2719
2720 let context = context.into_context();
2721 let target_salience = resolve_target_salience_for(self, session, key, &context);
2722 let alternatives = filter_alternatives(
2723 all_alternatives,
2724 target_salience,
2725 self.language_preference.as_deref(),
2726 self.style_preference.as_deref(),
2727 );
2728
2729 let candidate_scores = {
2733 let allow_choose_best =
2734 matches!(self.variation, Variation::Seeded(_) | Variation::Random);
2735 let is_first = session.discourse.is_first_render();
2736 if !allow_choose_best || is_first || alternatives.len() < 2 {
2737 None
2738 } else {
2739 let mut scoring_session = session.clone();
2740 let snapshot = scoring_session.clone();
2741 let mut scored: Vec<f64> = Vec::with_capacity(alternatives.len());
2742 let mut scoring_failed = false;
2743 let mut scratch = String::with_capacity(128);
2744 for template in &alternatives {
2745 scratch.clear();
2746 match RenderCtx::new(self, &mut scoring_session).render_template_into(
2747 &mut scratch,
2748 key,
2749 template,
2750 &context,
2751 ) {
2752 Ok(()) => {
2753 let mut score = scoring_session.discourse.repetition_score(&scratch);
2754 if self.sentence_rhythm_enabled {
2755 score += scoring_session.discourse.sentence_rhythm_score(&scratch);
2756 }
2757 scored.push(score);
2758 }
2759 Err(_) => {
2760 scoring_failed = true;
2761 break;
2762 }
2763 }
2764 scoring_session = snapshot.clone();
2765 }
2766 if scoring_failed { None } else { Some(scored) }
2767 }
2768 };
2769
2770 let entity_name = context
2772 .get("name")
2773 .or_else(|| context.get("old_name"))
2774 .map(|v| v.as_display());
2775 let reference_form = entity_name.as_ref().map(|n| {
2776 session.discourse.reference_form_with_density(
2777 n,
2778 matches!(
2779 self.style_profile.pronoun_density,
2780 crate::style::PronounDensity::Low
2781 ),
2782 matches!(
2783 self.style_profile.pronoun_density,
2784 crate::style::PronounDensity::High
2785 ),
2786 )
2787 });
2788
2789 let output = self.render(session, key, &context)?;
2791
2792 let variant_index = session
2794 .discourse
2795 .last_template_variant(key)
2796 .unwrap_or(0)
2797 .min(alternatives.len().saturating_sub(1));
2798 let variant_source = alternatives
2799 .get(variant_index)
2800 .map(|t| t.source.clone())
2801 .unwrap_or_default();
2802
2803 let focus_is_plural = session.discourse.focus_is_plural();
2804 let centering_transition = session.discourse.last_transition();
2805
2806 #[cfg(feature = "polish")]
2807 let length_split_applied = self
2808 .max_sentence_length
2809 .is_some_and(|max| output.chars().count() > max && output.contains(". "));
2810 #[cfg(not(feature = "polish"))]
2811 let length_split_applied = false;
2812
2813 let connective = detect_leading_connective(&output);
2814
2815 let list_style = session.discourse.last_list_style_used();
2816 let cleanup_stripped_tail = session.discourse.last_cleanup_stripped_tail();
2817
2818 Ok(RenderExplanation {
2819 output,
2820 template_key: key.to_string(),
2821 variant_index,
2822 variant_source,
2823 salience: target_salience,
2824 candidate_scores,
2825 reference_form,
2826 connective,
2827 list_style,
2828 focus_is_plural,
2829 length_split_applied,
2830 cleanup_stripped_tail,
2831 centering_transition,
2832 })
2833 }
2834
2835 pub fn render_iter<'a>(
2854 &'a self,
2855 session: &'a mut Session,
2856 events: &'a [(&'a str, Context)],
2857 ) -> RenderIter<'a> {
2858 RenderIter {
2859 engine: self,
2860 session,
2861 events,
2862 i: 0,
2863 }
2864 }
2865
2866 fn find_same_action_run(&self, events: &[(&str, Context)], start: usize) -> usize {
2876 if start >= events.len() {
2877 return start;
2878 }
2879
2880 let (first_key, ref first_ctx) = events[start];
2881 let first_name = entity_name_from_context(first_ctx);
2882
2883 if first_name.is_none() {
2884 return start + 1;
2885 }
2886
2887 let mut end = start + 1;
2888 let mut seen_names: crate::collections::HashSet<String> = crate::collections::new_set();
2889 seen_names.insert(first_name.unwrap());
2890
2891 while end < events.len() {
2892 let (key, ref ctx) = events[end];
2893 if key != first_key {
2894 break;
2895 }
2896 let name = match entity_name_from_context(ctx) {
2897 Some(n) => n,
2898 None => break,
2899 };
2900 if seen_names.contains(&name) {
2901 break;
2902 }
2903 if !contexts_compatible_for_aggregation(first_ctx, ctx) {
2907 break;
2908 }
2909 seen_names.insert(name);
2910 end += 1;
2911 }
2912
2913 end
2914 }
2915
2916 fn find_gapping_run(&self, events: &[(&str, Context)], start: usize) -> usize {
2926 if start >= events.len() {
2927 return start;
2928 }
2929
2930 let (first_key, first_ctx) = (events[start].0, &events[start].1);
2931 let Some(first_name) = entity_name_from_context(first_ctx) else {
2932 return start + 1;
2933 };
2934
2935 let mut end = start + 1;
2936 let mut seen: crate::collections::HashSet<String> = core::iter::once(first_name).collect();
2937
2938 while end < events.len() {
2939 let (k, ctx) = (events[end].0, &events[end].1);
2940 if k != first_key {
2941 break;
2942 }
2943 let Some(name) = entity_name_from_context(ctx) else {
2944 break;
2945 };
2946 if seen.contains(&name) {
2947 break;
2948 }
2949 if contexts_compatible_for_aggregation(first_ctx, ctx) {
2952 break;
2953 }
2954 seen.insert(name);
2955 end += 1;
2956 }
2957
2958 end
2959 }
2960
2961 fn render_aggregated_subjects(
2964 &self,
2965 session: &mut Session,
2966 key: &str,
2967 events: &[(&str, Context)],
2968 ) -> Result<String, ProsaicError> {
2969 let names: Vec<String> = events
2971 .iter()
2972 .filter_map(|(_, ctx)| entity_name_from_context(ctx))
2973 .collect();
2974
2975 if names.is_empty() {
2976 let mut sentences = Vec::new();
2978 for (k, ctx) in events {
2979 sentences.push(self.render(session, k, ctx)?);
2980 }
2981 return Ok(sentences.join(" "));
2982 }
2983
2984 let refs: Vec<&str> = names.iter().map(|s| s.as_str()).collect();
2987 let combined_name = self
2988 .language
2989 .join_list(&refs, crate::language::Conjunction::And);
2990
2991 let mut combined_ctx = events[0].1.clone();
2993
2994 if combined_ctx.get("old_name").is_some() {
2996 combined_ctx.insert("old_name", Value::String(combined_name.clone()));
2997 }
2998 if combined_ctx.get("name").is_some() {
2999 combined_ctx.insert("name", Value::String(combined_name.clone()));
3000 }
3001
3002 let rendered = self.render(session, key, combined_ctx)?;
3004
3005 session.discourse.set_focus_plural(true);
3008
3009 Ok(pluralize_agreement(&rendered, &*self.language))
3010 }
3011
3012 fn pick_variant_index_static(&self, key: &str, count: usize) -> usize {
3015 match self.variation {
3016 Variation::Fixed => 0,
3017 Variation::Seeded(seed) => {
3018 let hash = simple_hash(key, seed);
3019 hash as usize % count
3020 }
3021 Variation::Random => {
3022 #[cfg(feature = "std")]
3023 {
3024 let nanos = std::time::SystemTime::now()
3025 .duration_since(std::time::UNIX_EPOCH)
3026 .unwrap_or_default()
3027 .subsec_nanos() as usize;
3028 nanos % count
3029 }
3030 #[cfg(not(feature = "std"))]
3031 {
3032 let _ = count;
3033 0
3034 }
3035 }
3036 Variation::RoundRobin => 0,
3039 }
3040 }
3041
3042 fn render_full_reference(&self, name: &str, fallback_type: &str) -> String {
3057 #[cfg(feature = "reg")]
3061 let (attrs, relation): (Vec<String>, Option<(String, String)>) = {
3062 let registered = if fallback_type.is_empty() {
3063 None
3064 } else {
3065 self.entity_registry.get(fallback_type, name)
3066 };
3067 let target = match registered {
3068 Some(d) => d.clone(),
3069 None => {
3070 if fallback_type.is_empty() {
3071 return name.to_string();
3072 }
3073 EntityDescriptor::new(name, fallback_type)
3074 }
3075 };
3076 match self.reg_algorithm {
3077 RegAlgorithm::DaleReiter => (
3078 distinguishing_attributes(&target, &self.entity_registry, &self.reg_preference),
3079 None,
3080 ),
3081 RegAlgorithm::GraphBased => {
3082 let desc = distinguishing_subgraph(
3083 &target,
3084 &self.entity_registry,
3085 &self.reg_preference,
3086 );
3087 (desc.attributes, desc.relation)
3088 }
3089 }
3090 };
3091
3092 #[cfg(not(feature = "reg"))]
3093 let (attrs, relation): (Vec<String>, Option<(String, String)>) = {
3094 if fallback_type.is_empty() {
3095 return name.to_string();
3096 }
3097 (Vec::new(), None)
3098 };
3099
3100 #[cfg(feature = "reg")]
3103 let entity_type = if fallback_type.is_empty() {
3104 self.entity_registry
3105 .get("", name)
3106 .map(|d| d.entity_type.clone())
3107 .unwrap_or_default()
3108 } else {
3109 fallback_type.to_string()
3110 };
3111 #[cfg(not(feature = "reg"))]
3112 let entity_type = fallback_type.to_string();
3113
3114 if entity_type.is_empty() {
3115 if attrs.is_empty() {
3116 return name.to_string();
3117 }
3118 return format!("the {} {}", attrs.join(" "), name);
3119 }
3120
3121 let lower_type = entity_type.to_lowercase();
3122 let base = if attrs.is_empty() {
3123 format!("the {lower_type} {name}")
3124 } else {
3125 format!("the {} {lower_type} {name}", attrs.join(" "))
3126 };
3127
3128 if let Some((label, target_name)) = relation {
3130 format!("{base} {label} {target_name}")
3131 } else {
3132 base
3133 }
3134 }
3135
3136 pub fn new_session(&self) -> Session {
3156 Session::new()
3157 }
3158}
3159
3160fn format_truncated_list(
3162 shown: &[&str],
3163 remainder: &str,
3164 style: ListStyle,
3165 conjunction: Conjunction,
3166 language: &dyn Language,
3167) -> String {
3168 let joined = language.join_list(shown, conjunction);
3169 match style {
3170 ListStyle::Including => {
3171 format!("including {joined} among others")
3172 }
3173 ListStyle::SuchAs => {
3174 format!("such as {joined}")
3175 }
3176 ListStyle::Dash => {
3177 format!("\u{2014} notably {joined}, plus {remainder}")
3178 }
3179 ListStyle::Bracketed => {
3180 let refs: Vec<&str> = shown
3181 .iter()
3182 .copied()
3183 .chain(core::iter::once(remainder.trim()))
3184 .collect();
3185 let all_joined = language.join_list(&refs, conjunction);
3186 format!("[{all_joined}]")
3187 }
3188 ListStyle::AmongOthers => {
3189 format!("{joined}, among others")
3193 }
3194 ListStyle::ToNameAFew => {
3195 format!("{joined}, to name a few")
3196 }
3197 ListStyle::PlusMore => {
3198 format!("{joined}, plus {remainder}")
3201 }
3202 }
3203}
3204
3205const AUX_PREFIXES: &[&str] = &[
3209 "would have been",
3210 "will have been",
3211 "would have",
3212 "will have",
3213 "has been",
3214 "had been",
3215 "have been",
3216 "is being",
3217 "was being",
3218 "are being",
3219 "were being",
3220 "will be",
3221 "would be",
3222 "is",
3223 "are",
3224 "was",
3225 "were",
3226 "has",
3227 "have",
3228 "had",
3229 "will",
3230];
3231
3232fn detect_partial_cycle(
3265 partials: &HashMap<String, Template>,
3266 entry_name: &str,
3267) -> Result<(), Vec<String>> {
3268 let mut path: Vec<String> = Vec::new();
3270 let mut on_stack: HashSet<String> = new_set();
3271 let mut fully_explored: HashSet<String> = new_set();
3272
3273 visit(
3274 partials,
3275 entry_name,
3276 &mut path,
3277 &mut on_stack,
3278 &mut fully_explored,
3279 )
3280}
3281
3282fn visit(
3283 partials: &HashMap<String, Template>,
3284 name: &str,
3285 path: &mut Vec<String>,
3286 on_stack: &mut HashSet<String>,
3287 fully_explored: &mut HashSet<String>,
3288) -> Result<(), Vec<String>> {
3289 if fully_explored.contains(name) {
3290 return Ok(());
3291 }
3292 if on_stack.contains(name) {
3293 let start = path.iter().position(|n| n == name).unwrap_or(0);
3296 let mut cycle: Vec<String> = path[start..].to_vec();
3297 cycle.push(name.to_string());
3298 return Err(cycle);
3299 }
3300 let template = match partials.get(name) {
3301 Some(t) => t,
3302 None => return Ok(()),
3306 };
3307
3308 path.push(name.to_string());
3309 on_stack.insert(name.to_string());
3310
3311 for child in template.partial_names() {
3312 visit(partials, &child, path, on_stack, fully_explored)?;
3313 }
3314
3315 on_stack.remove(name);
3316 path.pop();
3317 fully_explored.insert(name.to_string());
3318 Ok(())
3319}
3320
3321fn reduce_same_entity_clauses(sentences: &[String]) -> Option<String> {
3322 if sentences.len() < 2 {
3323 return None;
3324 }
3325
3326 let head = sentences[0].trim_end();
3328 let head_body = head.trim_end_matches(['.', '!', '?']);
3329 let (head_subject_aux, head_aux, head_predicate) = split_subject_aux(head_body)?;
3330
3331 if predicate_has_embedded_clause(head_predicate) {
3332 return None;
3333 }
3334
3335 let mut predicates: Vec<String> = vec![head_predicate.to_string()];
3339
3340 for s in &sentences[1..] {
3341 let trimmed = s.trim_end();
3342 let without_conn = strip_leading_connective(trimmed);
3348 let without_conn_str: &str = &without_conn;
3349 let body = without_conn_str.trim_end_matches(['.', '!', '?']);
3350
3351 let (aux, predicate) = match strip_it_aux_prefix(body) {
3353 Some(parsed) => parsed,
3354 None => {
3355 let remainder = strip_head_subject_prefix(body, head_subject_aux)?;
3360 if remainder.is_empty() {
3361 return None;
3362 }
3363 (head_aux, remainder)
3365 }
3366 };
3367 if aux != head_aux {
3368 return None;
3369 }
3370 if predicate_has_embedded_clause(predicate) {
3371 return None;
3372 }
3373
3374 predicates.push(predicate.to_string());
3375 }
3376
3377 let joined = match predicates.len() {
3379 0 => return None,
3380 1 => predicates.into_iter().next().unwrap(),
3381 2 => format!("{} and {}", predicates[0], predicates[1]),
3382 _ => {
3383 let last = predicates.pop().unwrap();
3384 let head = predicates.join(", ");
3385 format!("{head}, and {last}")
3386 }
3387 };
3388
3389 Some(format!("{head_subject_aux} {joined}."))
3390}
3391
3392fn split_subject_and_rest(s: &str) -> Option<(&str, Vec<&str>)> {
3403 for aux in AUX_PREFIXES {
3404 let marker = format!(" {aux} ");
3405 if let Some(pos) = s.find(&marker) {
3406 let subject = &s[..pos];
3407 let rest_str = &s[pos + 1..];
3409 let rest_tokens: Vec<&str> = rest_str.split_whitespace().collect();
3410 return Some((subject, rest_tokens));
3411 }
3412 }
3413 None
3414}
3415
3416fn longest_common_prefix_len(parsed: &[(&str, Vec<&str>)]) -> usize {
3419 if parsed.is_empty() {
3420 return 0;
3421 }
3422 let min_len = parsed.iter().map(|(_, t)| t.len()).min().unwrap_or(0);
3423 for i in 0..min_len {
3424 let candidate = parsed[0].1[i];
3425 if !parsed.iter().all(|(_, t)| t[i] == candidate) {
3426 return i;
3427 }
3428 }
3429 min_len
3430}
3431
3432fn reduce_gapping(sentences: &[String]) -> Option<String> {
3448 if sentences.len() < 2 {
3449 return None;
3450 }
3451
3452 if sentences.iter().any(|s| predicate_has_embedded_clause(s)) {
3454 return None;
3455 }
3456
3457 let parsed: Vec<(&str, Vec<&str>)> = sentences
3460 .iter()
3461 .map(|s| {
3462 let trimmed = s.trim_end();
3463 let stripped = strip_leading_connective(trimmed.trim_end_matches(['.', '!', '?']));
3464 let _ = stripped; let body = trimmed.trim_end_matches(['.', '!', '?']);
3470 let body_stripped: &str = {
3471 const CONNECTIVES: &[&str] = &[
3472 "Additionally,",
3473 "Furthermore,",
3474 "Similarly,",
3475 "Likewise,",
3476 "Meanwhile,",
3477 "However,",
3478 "On the other hand,",
3479 ];
3480 let mut result = body;
3481 for conn in CONNECTIVES {
3482 if let Some(rest) = body.strip_prefix(conn) {
3483 result = rest.trim_start();
3484 break;
3485 }
3486 }
3487 result
3490 };
3491 split_subject_and_rest(body_stripped)
3492 })
3493 .collect::<Option<Vec<_>>>()?;
3494
3495 {
3497 let mut seen: crate::collections::HashSet<&str> = crate::collections::new_set();
3498 for (subj, _) in &parsed {
3499 if !seen.insert(*subj) {
3500 return None;
3501 }
3502 }
3503 }
3504
3505 let raw_anchor_len = longest_common_prefix_len(&parsed);
3507
3508 const PREPOSITIONS: &[&str] = &[
3515 "to", "from", "at", "in", "on", "by", "for", "with", "into", "onto", "out", "off", "over",
3516 "under", "above", "below", "through", "across", "against", "along", "around", "behind",
3517 "beside", "between", "during", "inside", "outside", "toward", "towards", "upon", "within",
3518 "without",
3519 ];
3520 let anchor_len = {
3521 let mut len = raw_anchor_len;
3522 while len > 0 && PREPOSITIONS.contains(&parsed[0].1[len - 1]) {
3523 len -= 1;
3524 }
3525 len
3526 };
3527
3528 if anchor_len < 2 {
3530 return None;
3531 }
3532
3533 if parsed.iter().any(|(_, toks)| toks.len() <= anchor_len) {
3535 return None;
3536 }
3537
3538 let anchor = parsed[0].1[..anchor_len].join(" ");
3539
3540 let suffixes: Vec<String> = parsed
3542 .iter()
3543 .map(|(_, toks)| toks[anchor_len..].join(" "))
3544 .collect();
3545
3546 let capitalize = |s: &str| -> String {
3549 let mut cs = s.chars();
3550 match cs.next() {
3551 None => String::new(),
3552 Some(c) => c.to_uppercase().collect::<String>() + cs.as_str(),
3553 }
3554 };
3555
3556 let first = format!("{} {} {}", capitalize(parsed[0].0), anchor, suffixes[0]);
3558 let tail: Vec<String> = parsed
3560 .iter()
3561 .skip(1)
3562 .zip(suffixes.iter().skip(1))
3563 .map(|((subj, _), suf)| format!("{} {suf}", capitalize(subj)))
3564 .collect();
3565
3566 let joined = match tail.len() {
3567 1 => format!("{first}, and {}", tail[0]),
3568 _ => {
3569 let (last, rest) = tail.split_last().unwrap();
3570 format!("{first}, {}, and {last}", rest.join(", "))
3571 }
3572 };
3573
3574 Some(format!("{joined}."))
3575}
3576
3577fn predicate_has_embedded_clause(predicate: &str) -> bool {
3580 let lower = predicate.to_lowercase();
3586 const MARKERS: &[&str] = &[
3587 ", which",
3588 ", affecting",
3589 ", impacting",
3590 ", requiring",
3591 ", including",
3592 ];
3593 MARKERS.iter().any(|m| lower.contains(m))
3594}
3595
3596fn detect_leading_connective(s: &str) -> Option<&'static str> {
3600 const CONNECTIVES: &[&str] = &[
3601 "Additionally,",
3602 "Furthermore,",
3603 "Similarly,",
3604 "Likewise,",
3605 "Meanwhile,",
3606 "However,",
3607 "On the other hand,",
3608 "It also",
3609 ];
3610 CONNECTIVES.iter().copied().find(|c| s.starts_with(c))
3611}
3612
3613fn strip_leading_connective(s: &str) -> alloc::borrow::Cow<'_, str> {
3619 const CONNECTIVES: &[&str] = &[
3620 "Additionally,",
3621 "Furthermore,",
3622 "Similarly,",
3623 "Likewise,",
3624 "Meanwhile,",
3625 "However,",
3626 "On the other hand,",
3627 ];
3628
3629 for conn in CONNECTIVES {
3630 if let Some(rest) = s.strip_prefix(conn) {
3631 return alloc::borrow::Cow::Borrowed(rest.trim_start());
3632 }
3633 }
3634
3635 if let Some(rest) = s.strip_prefix("It also ") {
3639 return alloc::borrow::Cow::Owned(format!("It {}", rest.trim_start()));
3640 }
3641
3642 alloc::borrow::Cow::Borrowed(s)
3643}
3644
3645fn split_subject_aux(body: &str) -> Option<(&str, &str, &str)> {
3649 for aux in AUX_PREFIXES {
3650 let marker = format!(" {aux} ");
3651 if let Some(pos) = body.find(&marker) {
3652 let subject_aux_end = pos + 1 + aux.len(); let subject_aux = &body[..subject_aux_end];
3654 let predicate = body[subject_aux_end..].trim_start();
3655 return Some((subject_aux, aux, predicate));
3656 }
3657 }
3658 None
3659}
3660
3661fn strip_it_aux_prefix(body: &str) -> Option<(&str, &str)> {
3665 let rest = body
3666 .strip_prefix("It ")
3667 .or_else(|| body.strip_prefix("it "))?;
3668
3669 for aux in AUX_PREFIXES {
3670 let marker_with_space = format!("{aux} ");
3671 if let Some(tail) = rest.strip_prefix(&marker_with_space) {
3672 return Some((aux, tail.trim_start()));
3673 }
3674 if rest == *aux {
3676 return None;
3677 }
3678 }
3679 None
3680}
3681
3682fn strip_head_subject_prefix<'a>(body: &'a str, subject_aux: &str) -> Option<&'a str> {
3697 let with_space = format!("{subject_aux} ");
3698 if let Some(rest) = body.strip_prefix(with_space.as_str()) {
3699 return Some(rest.trim_start());
3700 }
3701 let mut lowercased = subject_aux.to_string();
3704 if let Some(first) = lowercased.chars().next()
3705 && first.is_uppercase()
3706 {
3707 let first_len = first.len_utf8();
3708 let lower: String = first.to_lowercase().collect();
3709 lowercased.replace_range(0..first_len, &lower);
3710 let with_space_lower = format!("{lowercased} ");
3711 if let Some(rest) = body.strip_prefix(with_space_lower.as_str()) {
3712 return Some(rest.trim_start());
3713 }
3714 }
3715 None
3716}
3717
3718fn cleanup_artifacts_in_place(output: &mut String, strictness: Strictness) -> bool {
3735 collapse_and_tidy_in_place(output);
3736
3737 if strictness == Strictness::Silent {
3738 strip_dangling_tail_words_in_place(output)
3739 } else {
3740 false
3741 }
3742}
3743
3744fn collapse_and_tidy_in_place(output: &mut String) {
3747 let mut scratch = String::with_capacity(output.len());
3750 let mut last_was_space = false;
3751 let mut started = false;
3752
3753 let chars: Vec<char> = output.chars().collect();
3754 let len = chars.len();
3755 let mut i = 0;
3756 while i < len {
3757 let c = chars[i];
3758 if c.is_whitespace() {
3759 if started {
3760 last_was_space = true;
3761 }
3762 } else {
3763 if last_was_space && !matches!(c, ',' | '.' | '!' | '?' | ':' | ';' | ')' | ']') {
3766 scratch.push(' ');
3767 }
3768 scratch.push(c);
3769 last_was_space = false;
3770 started = true;
3771 }
3772 i += 1;
3773 }
3774
3775 core::mem::swap(output, &mut scratch);
3776}
3777
3778const ORPHAN_TAIL_WORDS: &[&str] = &[
3783 "by", "to", "from", "in", "on", "at", "of", "with", "for", "into", "onto", "upon", "about",
3785 "between", "among", "through", "across",
3786 "and", "or", "but", "nor", "yet", "because", "since", "while", "when", "where", "whether", "unless", "until", "than",
3789];
3790
3791fn strip_dangling_tail_words_in_place(output: &mut String) -> bool {
3797 let mut stripped_any = false;
3798 loop {
3799 let (body, _) = split_trailing_punct(output);
3801 let body_len = body.len();
3802 let trimmed_body = body.trim_end();
3803
3804 let last_word_start = match trimmed_body.rfind(char::is_whitespace) {
3806 Some(idx) => idx + 1,
3807 None => {
3808 return stripped_any;
3810 }
3811 };
3812 let last_word = &trimmed_body[last_word_start..];
3813 let last_word_lower = last_word.to_lowercase();
3814
3815 if ORPHAN_TAIL_WORDS.contains(&last_word_lower.as_str()) {
3816 let new_body_end = trimmed_body[..last_word_start].trim_end().len();
3817 if new_body_end == 0 {
3818 return stripped_any;
3820 }
3821 let tail_punct_owned = output[body_len..].to_string();
3824 output.truncate(new_body_end);
3825 output.push_str(&tail_punct_owned);
3826 stripped_any = true;
3827 continue;
3828 }
3829
3830 return stripped_any;
3831 }
3832}
3833
3834fn split_trailing_punct(s: &str) -> (&str, &str) {
3837 let punct_start = s
3838 .char_indices()
3839 .rev()
3840 .take_while(|(_, c)| matches!(c, '.' | '!' | '?' | ','))
3841 .last()
3842 .map(|(i, _)| i)
3843 .unwrap_or(s.len());
3844 (&s[..punct_start], &s[punct_start..])
3845}
3846
3847fn terminate_sentence_in_place(output: &mut String) {
3851 let trimmed_end = output.trim_end();
3852 if trimmed_end.is_empty() {
3853 return;
3854 }
3855
3856 let last = trimmed_end.chars().last().unwrap();
3858 if matches!(last, '.' | '!' | '?') {
3859 return;
3860 }
3861
3862 let first = trimmed_end.chars().next().unwrap();
3864 if !first.is_uppercase() {
3865 return;
3866 }
3867
3868 let word_count = trimmed_end.split_whitespace().count();
3870 if word_count < 3 {
3871 return;
3872 }
3873
3874 let trimmed_len = output.trim_end().len();
3876 output.truncate(trimmed_len);
3877 output.push('.');
3878}
3879
3880fn reference_features(value: &Value, focus_is_plural: bool) -> crate::agreement::AgreementFeatures {
3881 let mut features = match value {
3882 Value::Entity { features, .. } => *features,
3883 _ => crate::agreement::AgreementFeatures::default(),
3884 };
3885
3886 if focus_is_plural {
3887 features.number = crate::agreement::Number::Plural;
3888 } else if matches!(features.number, crate::agreement::Number::Unknown) {
3889 features.number = crate::agreement::Number::Singular;
3890 }
3891
3892 features
3893}
3894
3895fn starts_with_refer_pipe(template: &Template) -> bool {
3898 match template.segments.first() {
3899 Some(Segment::Slot { pipes, .. }) => pipes
3900 .iter()
3901 .any(|p| p.name == "refer" || p.name == "possessive"),
3902 _ => false,
3903 }
3904}
3905
3906fn capitalize_first_in_place(output: &mut String) {
3907 let first = match output.chars().next() {
3908 Some(c) if c.is_lowercase() => c,
3909 _ => return,
3910 };
3911 let first_len = first.len_utf8();
3912 let upper: String = first.to_uppercase().collect();
3913 output.replace_range(0..first_len, &upper);
3914}
3915
3916fn lowercase_first_in_place(output: &mut String) {
3917 let first = match output.chars().next() {
3918 Some(c) if c.is_uppercase() => c,
3919 _ => return,
3920 };
3921 let first_len = first.len_utf8();
3922 let lower: String = first.to_lowercase().collect();
3923 output.replace_range(0..first_len, &lower);
3924}
3925
3926fn lowercase_first_if_determiner(s: &str) -> String {
3933 let first_word_end = s.find(char::is_whitespace).unwrap_or(s.len());
3934 let first = &s[..first_word_end];
3935 const DETERMINERS: &[&str] = &[
3936 "The", "A", "An", "El", "La", "Los", "Las", "Un", "Una", "Der", "Die", "Das", ];
3940 if DETERMINERS.contains(&first) {
3941 let mut result = String::with_capacity(s.len());
3942 let mut chars = first.chars();
3943 if let Some(c) = chars.next() {
3944 result.extend(c.to_lowercase());
3945 }
3946 result.push_str(chars.as_str());
3947 result.push_str(&s[first_word_end..]);
3948 result
3949 } else {
3950 s.to_string()
3951 }
3952}
3953
3954fn prepend_replacing_subject_in_place(
3956 output: &mut String,
3957 connective: &str,
3958 entity_name: Option<&str>,
3959) {
3960 let name_is_single_token = entity_name
3967 .map(|n| !n.trim().is_empty() && !n.contains(char::is_whitespace))
3968 .unwrap_or(false);
3969
3970 if name_is_single_token && let Some(rest) = output.strip_prefix("The ") {
3971 let words: Vec<&str> = rest.splitn(3, ' ').collect();
3973 if words.len() >= 3 {
3974 let tail = words[2..].join(" ");
3975 let mut buf = String::with_capacity(connective.len() + 1 + tail.len());
3976 buf.push_str(connective);
3977 buf.push(' ');
3978 buf.push_str(&tail);
3979 core::mem::swap(output, &mut buf);
3980 return;
3981 }
3982 }
3983
3984 if let Some(rest) = output.strip_prefix("it ") {
3988 let mut buf = String::with_capacity(connective.len() + 1 + rest.len());
3989 buf.push_str(connective);
3990 buf.push(' ');
3991 buf.push_str(rest);
3992 core::mem::swap(output, &mut buf);
3993 return;
3994 }
3995
3996 lowercase_first_in_place(output);
3998 let mut buf = String::with_capacity(connective.len() + 1 + output.len());
3999 buf.push_str(connective);
4000 buf.push(' ');
4001 buf.push_str(output);
4002 core::mem::swap(output, &mut buf);
4003}
4004
4005fn hedge_with_calibration(
4015 score: i64,
4016 mode: HedgeMode,
4017 calibration: &crate::style::HedgingCalibration,
4018) -> &'static str {
4019 let calibrated = (score + calibration.offset as i64).clamp(0, 100);
4020 let initial = hedge_fn(calibrated, mode);
4021 if !is_forbidden(initial, &calibration.forbid) {
4022 return initial;
4023 }
4024 const BUCKET_CENTERS: [i64; 5] = [10, 40, 60, 80, 95];
4026 let start_idx = BUCKET_CENTERS
4027 .iter()
4028 .position(|&c| c >= calibrated)
4029 .unwrap_or(0);
4030 for &c in BUCKET_CENTERS.iter().skip(start_idx + 1) {
4031 let candidate = hedge_fn(c, mode);
4032 if !is_forbidden(candidate, &calibration.forbid) {
4033 return candidate;
4034 }
4035 }
4036 initial
4037}
4038
4039fn is_forbidden(candidate: &str, forbid: &[String]) -> bool {
4040 forbid.iter().any(|f| f.eq_ignore_ascii_case(candidate))
4041}
4042
4043fn list_style_bias_target(bias: crate::style::ListStyleBias) -> Option<ListStyle> {
4048 match bias {
4049 crate::style::ListStyleBias::Auto => None,
4050 crate::style::ListStyleBias::Including => Some(ListStyle::Including),
4051 crate::style::ListStyleBias::SuchAs => Some(ListStyle::SuchAs),
4052 crate::style::ListStyleBias::Dash => Some(ListStyle::Dash),
4053 crate::style::ListStyleBias::Bracketed => Some(ListStyle::Bracketed),
4054 }
4055}
4056
4057fn rst_for_discourse(
4062 relation: &crate::discourse::DiscourseRelation,
4063) -> Option<crate::rst::RstRelation> {
4064 match relation {
4065 crate::discourse::DiscourseRelation::SameEntityDifferentAction => {
4066 Some(crate::rst::RstRelation::Elaboration)
4067 }
4068 crate::discourse::DiscourseRelation::DifferentEntitySameAction => {
4069 Some(crate::rst::RstRelation::Sequence)
4070 }
4071 crate::discourse::DiscourseRelation::Contrast => Some(crate::rst::RstRelation::Contrast),
4072 crate::discourse::DiscourseRelation::None => None,
4073 }
4074}
4075
4076fn profile_length_bias_score(
4086 candidate: &str,
4087 discourse: &crate::discourse::DiscourseState,
4088 target: &crate::style::LengthDistribution,
4089) -> f64 {
4090 if target.is_neutral() {
4091 return 0.0;
4092 }
4093
4094 let candidate_lengths = crate::discourse::sentence_word_counts(candidate);
4095
4096 let mut counts = [0usize; 3]; let bucket_for = |len: usize| -> usize {
4099 if len <= target.short_max_words as usize {
4100 0
4101 } else if len <= target.medium_max_words as usize {
4102 1
4103 } else {
4104 2
4105 }
4106 };
4107 for len in discourse.sentence_length_iter() {
4108 counts[bucket_for(len)] += 1;
4109 }
4110 for &len in &candidate_lengths {
4111 counts[bucket_for(len)] += 1;
4112 }
4113
4114 let total: usize = counts.iter().sum();
4115 if total == 0 {
4116 return 0.0;
4117 }
4118 let observed = [
4119 counts[0] as f32 / total as f32,
4120 counts[1] as f32 / total as f32,
4121 counts[2] as f32 / total as f32,
4122 ];
4123
4124 let target_sum = target.short + target.medium + target.long;
4125 if target_sum <= 0.0 || !target_sum.is_finite() {
4126 return 0.0;
4127 }
4128 let target_norm = [
4129 target.short / target_sum,
4130 target.medium / target_sum,
4131 target.long / target_sum,
4132 ];
4133
4134 let distance = (observed[0] - target_norm[0]).abs()
4135 + (observed[1] - target_norm[1]).abs()
4136 + (observed[2] - target_norm[2]).abs();
4137
4138 const PROFILE_LENGTH_WEIGHT: f64 = 3.0;
4142 PROFILE_LENGTH_WEIGHT * distance as f64
4143}
4144
4145fn apply_salience_bias(
4157 thresholds: SalienceThresholds,
4158 bias: crate::style::SalienceBias,
4159) -> SalienceThresholds {
4160 match bias {
4161 crate::style::SalienceBias::Auto => thresholds,
4162 crate::style::SalienceBias::Lower => {
4163 let low_max = (thresholds.low_max - 1).max(0);
4164 let high_min = (thresholds.high_min - 5).max(low_max + 1);
4165 SalienceThresholds { low_max, high_min }
4166 }
4167 crate::style::SalienceBias::Higher => {
4168 let low_max = thresholds.low_max + 2;
4169 let high_min = thresholds.high_min + 10;
4170 SalienceThresholds { low_max, high_min }
4171 }
4172 }
4173}
4174
4175fn resolve_target_salience_for(
4179 engine: &Engine,
4180 session: &Session,
4181 key: &str,
4182 context: &Context,
4183) -> Salience {
4184 if let Some(forced) = session.refine_forced_tier_for(key) {
4185 return forced;
4186 }
4187 let salience_bias = session
4188 .refine_salience_bias
4189 .unwrap_or(engine.style_profile.salience);
4190 let thresholds = apply_salience_bias(engine.salience_thresholds, salience_bias);
4191 apply_verbosity_bias(
4192 Salience::from_context(context, thresholds),
4193 engine.style_profile.verbosity,
4194 )
4195}
4196
4197fn apply_verbosity_bias(target: Salience, verbosity: crate::style::Verbosity) -> Salience {
4202 match (verbosity, target) {
4203 (crate::style::Verbosity::Neutral, t) => t,
4204 (crate::style::Verbosity::Terse, Salience::High) => Salience::Medium,
4205 (crate::style::Verbosity::Terse, Salience::Medium) => Salience::Low,
4206 (crate::style::Verbosity::Terse, Salience::Low) => Salience::Low,
4207 (crate::style::Verbosity::Verbose, Salience::Low) => Salience::Medium,
4208 (crate::style::Verbosity::Verbose, Salience::Medium) => Salience::High,
4209 (crate::style::Verbosity::Verbose, Salience::High) => Salience::High,
4210 }
4211}
4212
4213fn filter_alternatives<'a>(
4214 alternatives: &'a [SalientTemplate],
4215 target: Salience,
4216 language_preference: Option<&str>,
4217 style_preference: Option<&str>,
4218) -> Vec<&'a Template> {
4219 let all: Vec<&'a SalientTemplate> = alternatives.iter().collect();
4220 let lang_filtered = prefer_tag(all, language_preference, |s| s.language.as_deref());
4221 let style_filtered = prefer_tag(lang_filtered, style_preference, |s| s.style.as_deref());
4222
4223 let exact: Vec<&'a Template> = style_filtered
4224 .iter()
4225 .filter(|s| s.salience == target)
4226 .map(|s| &s.template)
4227 .collect();
4228 if !exact.is_empty() {
4229 return exact;
4230 }
4231
4232 let medium: Vec<&'a Template> = style_filtered
4233 .iter()
4234 .filter(|s| s.salience == Salience::Medium)
4235 .map(|s| &s.template)
4236 .collect();
4237 if !medium.is_empty() {
4238 return medium;
4239 }
4240
4241 style_filtered.iter().map(|s| &s.template).collect()
4242}
4243
4244fn prefer_tag<'a>(
4245 alternatives: Vec<&'a SalientTemplate>,
4246 preference: Option<&str>,
4247 tag: impl Fn(&SalientTemplate) -> Option<&str>,
4248) -> Vec<&'a SalientTemplate> {
4249 if let Some(pref) = preference {
4250 let matching: Vec<&'a SalientTemplate> = alternatives
4251 .iter()
4252 .copied()
4253 .filter(|s| tag(s) == Some(pref))
4254 .collect();
4255 if !matching.is_empty() {
4256 return matching;
4257 }
4258 }
4259
4260 let untagged: Vec<&'a SalientTemplate> = alternatives
4261 .iter()
4262 .copied()
4263 .filter(|s| tag(s).is_none())
4264 .collect();
4265 if !untagged.is_empty() {
4266 untagged
4267 } else {
4268 alternatives
4269 }
4270}
4271
4272fn is_truthy(value: Option<&Value>) -> bool {
4278 match value {
4279 None => false,
4280 Some(Value::Number(n)) => *n != 0,
4281 Some(Value::String(s)) => !s.is_empty(),
4282 Some(Value::List(items)) => !items.is_empty(),
4283 Some(Value::Entity { name, .. }) => !name.is_empty(),
4285 }
4286}
4287
4288fn entity_name_from_context(context: &Context) -> Option<String> {
4291 context
4292 .get("name")
4293 .or_else(|| context.get("old_name"))
4294 .map(|v| v.as_display())
4295}
4296
4297fn contexts_compatible_for_aggregation(a: &Context, b: &Context) -> bool {
4301 let entity_keys = ["name", "old_name"];
4303
4304 let a_keys: Vec<&String> = a
4306 .keys()
4307 .filter(|k| !entity_keys.contains(&k.as_str()))
4308 .collect();
4309 let b_keys: Vec<&String> = b
4310 .keys()
4311 .filter(|k| !entity_keys.contains(&k.as_str()))
4312 .collect();
4313
4314 if a_keys.len() != b_keys.len() {
4316 return false;
4317 }
4318 for key in &a_keys {
4319 if !b_keys.contains(key) {
4320 return false;
4321 }
4322 if a.get(key) != b.get(key) {
4323 return false;
4324 }
4325 }
4326 true
4327}
4328
4329fn pluralize_agreement(output: &str, lang: &dyn Language) -> String {
4333 let mut result = output.to_string();
4334
4335 let verb_replacements = &[(" was ", " were "), (" has ", " have "), (" is ", " are ")];
4338 for (singular, plural) in verb_replacements {
4339 result = result.replace(singular, plural);
4340 }
4341
4342 if let Some(rest) = result.strip_prefix("The ")
4345 && let Some(space_idx) = rest.find(' ')
4346 {
4347 let type_word = &rest[..space_idx];
4348 if type_word.chars().all(|c| c.is_lowercase()) && type_word.len() < 15 {
4350 let plural = lang.pluralize(type_word, 2);
4351 if plural != type_word {
4352 result = format!("The {} {}", plural, &rest[space_idx + 1..]);
4353 }
4354 }
4355 }
4356
4357 result
4358}
4359
4360fn simple_hash(key: &str, seed: u64) -> u64 {
4362 let mut hash = seed;
4363 for byte in key.bytes() {
4364 hash = hash.wrapping_mul(31).wrapping_add(byte as u64);
4365 }
4366 hash
4367}
4368
4369fn parse_choose_pairs(arg: &str) -> Result<Vec<(String, String)>, ProsaicError> {
4378 let mut out = Vec::new();
4379 for raw_pair in arg.split(',') {
4380 let pair = raw_pair.trim();
4381 if pair.is_empty() {
4382 continue;
4383 }
4384 let eq = pair.find('=').ok_or_else(|| ProsaicError::InvalidPipe {
4385 pipe: "choose".to_string(),
4386 reason: format!("pair `{pair}` is missing `=` separator"),
4387 })?;
4388 let key = pair[..eq].trim().to_string();
4389 let value = pair[eq + 1..].trim().to_string();
4390 if key.is_empty() {
4391 return Err(ProsaicError::InvalidPipe {
4392 pipe: "choose".to_string(),
4393 reason: format!("pair `{pair}` has empty key"),
4394 });
4395 }
4396 out.push((key, value));
4397 }
4398 Ok(out)
4399}
4400
4401#[cfg(test)]
4402mod tests {
4403 use super::*;
4404 use crate::language::{Conjunction, Language, Person, Tense};
4405
4406 struct TestLang;
4408
4409 impl Language for TestLang {
4410 fn pluralize(&self, word: &str, count: usize) -> String {
4411 if count == 1 {
4412 word.to_string()
4413 } else {
4414 format!("{word}s")
4415 }
4416 }
4417 fn singularize(&self, word: &str) -> String {
4418 word.strip_suffix('s').unwrap_or(word).to_string()
4419 }
4420 fn article(&self, word: &str) -> &str {
4421 if word.starts_with(|c: char| "aeiou".contains(c.to_ascii_lowercase())) {
4422 "an"
4423 } else {
4424 "a"
4425 }
4426 }
4427 fn conjugate(&self, verb: &str, tense: Tense, _person: Person) -> String {
4428 match (verb, tense) {
4429 ("be", Tense::Past) => "was".to_string(),
4430 ("be", Tense::Present) => "is".to_string(),
4431 ("have", Tense::Present) => "has".to_string(),
4432 (_, Tense::Past) => format!("{verb}ed"),
4433 (_, Tense::Present) => verb.to_string(),
4434 (_, Tense::Future) => format!("will {verb}"),
4435 }
4436 }
4437 fn past_participle(&self, verb: &str) -> String {
4438 format!("{verb}ed")
4439 }
4440 fn present_participle(&self, verb: &str) -> String {
4441 format!("{verb}ing")
4442 }
4443 fn join_list(&self, items: &[&str], conjunction: Conjunction) -> String {
4444 let conj = match conjunction {
4445 Conjunction::And => "and",
4446 Conjunction::Or => "or",
4447 };
4448 match items.len() {
4449 0 => String::new(),
4450 1 => items[0].to_string(),
4451 2 => format!("{} {conj} {}", items[0], items[1]),
4452 _ => {
4453 let head = items[..items.len() - 1].join(", ");
4454 format!("{head}, {conj} {}", items[items.len() - 1])
4455 }
4456 }
4457 }
4458 fn ordinal(&self, n: usize) -> String {
4459 let suffix = match n % 10 {
4460 1 if n % 100 != 11 => "st",
4461 2 if n % 100 != 12 => "nd",
4462 3 if n % 100 != 13 => "rd",
4463 _ => "th",
4464 };
4465 format!("{n}{suffix}")
4466 }
4467 fn number_to_words(&self, n: usize) -> String {
4468 format!("<{n}>") }
4470 }
4471
4472 fn test_engine() -> Engine {
4473 Engine::new(TestLang)
4474 }
4475
4476 fn test_session() -> Session {
4477 Session::new()
4478 }
4479
4480 #[test]
4483 fn style_preference_selects_matching_style() {
4484 let mut engine = test_engine().style_preference("executive");
4485 engine.register_template("t", "technical {name}").unwrap();
4486 engine
4487 .register_template_with_style("t", "executive {name}", Some("executive"))
4488 .unwrap();
4489
4490 let mut ctx = Context::new();
4491 ctx.insert("name", Value::String("summary".into()));
4492 let out = engine.render(&mut test_session(), "t", &ctx).unwrap();
4493 assert_eq!(out, "executive summary");
4494 }
4495
4496 #[test]
4497 fn style_preference_falls_back_to_unstyled_before_any_style() {
4498 let mut engine = test_engine().style_preference("customer");
4499 engine
4500 .register_template_with_style("t", "executive {name}", Some("executive"))
4501 .unwrap();
4502 engine.register_template("t", "plain {name}").unwrap();
4503
4504 let mut ctx = Context::new();
4505 ctx.insert("name", Value::String("summary".into()));
4506 let out = engine.render(&mut test_session(), "t", &ctx).unwrap();
4507 assert_eq!(out, "plain summary");
4508 }
4509
4510 #[test]
4511 fn style_filter_runs_inside_language_filter() {
4512 let mut engine = test_engine()
4513 .language_preference("en")
4514 .style_preference("executive");
4515 engine
4516 .register_template_with_language_and_style(
4517 "t",
4518 "english executive {name}",
4519 Some("en"),
4520 Some("executive"),
4521 )
4522 .unwrap();
4523 engine
4524 .register_template_with_language_and_style(
4525 "t",
4526 "spanish executive {name}",
4527 Some("es"),
4528 Some("executive"),
4529 )
4530 .unwrap();
4531
4532 let mut ctx = Context::new();
4533 ctx.insert("name", Value::String("summary".into()));
4534 let out = engine.render(&mut test_session(), "t", &ctx).unwrap();
4535 assert_eq!(out, "english executive summary");
4536 }
4537
4538 #[test]
4539 fn no_style_preference_prefers_unstyled_variants() {
4540 let mut engine = test_engine();
4541 engine
4542 .register_template_with_style("t", "styled {name}", Some("executive"))
4543 .unwrap();
4544 engine.register_template("t", "unstyled {name}").unwrap();
4545
4546 let mut ctx = Context::new();
4547 ctx.insert("name", Value::String("summary".into()));
4548 let out = engine.render(&mut test_session(), "t", &ctx).unwrap();
4549 assert_eq!(out, "unstyled summary");
4550 }
4551
4552 #[test]
4555 fn has_template_returns_true_for_registered() {
4556 let mut engine = test_engine();
4557 engine.register_template("t", "hello").unwrap();
4558 assert!(engine.has_template("t"));
4559 assert!(!engine.has_template("nope"));
4560 }
4561
4562 #[test]
4565 fn render_simple_substitution() {
4566 let mut engine = test_engine();
4567 engine.register_template("greet", "Hello {name}!").unwrap();
4568
4569 let mut ctx = Context::new();
4570 ctx.insert("name", Value::String("world".into()));
4571
4572 let mut session = test_session();
4573 assert_eq!(
4574 engine.render(&mut session, "greet", &ctx).unwrap(),
4575 "Hello world!"
4576 );
4577 }
4578
4579 #[test]
4580 fn render_missing_slot_strict() {
4581 let mut engine = test_engine();
4582 engine.register_template("greet", "Hello {name}!").unwrap();
4583 let ctx = Context::new();
4584
4585 let mut session = test_session();
4586 let result = engine.render(&mut session, "greet", &ctx);
4587 assert!(matches!(result, Err(ProsaicError::MissingSlot { .. })));
4588 }
4589
4590 #[test]
4591 fn render_missing_slot_lenient() {
4592 let mut engine = test_engine().strictness(Strictness::Lenient);
4593 engine.register_template("greet", "Hello {name}!").unwrap();
4594 let ctx = Context::new();
4595
4596 let mut session = test_session();
4597 assert_eq!(
4598 engine.render(&mut session, "greet", &ctx).unwrap(),
4599 "Hello [missing: name]!"
4600 );
4601 }
4602
4603 #[test]
4604 fn render_missing_slot_silent() {
4605 let mut engine = test_engine().strictness(Strictness::Silent);
4606 engine.register_template("greet", "Hello {name}!").unwrap();
4607 let ctx = Context::new();
4608
4609 let mut session = test_session();
4610 assert_eq!(
4613 engine.render(&mut session, "greet", &ctx).unwrap(),
4614 "Hello!"
4615 );
4616 }
4617
4618 #[test]
4619 fn render_unknown_template() {
4620 let engine = test_engine();
4621 let ctx = Context::new();
4622
4623 let mut session = test_session();
4624 let result = engine.render(&mut session, "nonexistent", &ctx);
4625 assert!(matches!(result, Err(ProsaicError::UnknownTemplate(_))));
4626 }
4627
4628 #[test]
4629 fn render_pluralize_pipe() {
4630 let mut engine = test_engine();
4631 engine
4632 .register_template("count", "{n} {n|pluralize:item}")
4633 .unwrap();
4634
4635 let mut session = test_session();
4636 let mut ctx = Context::new();
4637 ctx.insert("n", Value::Number(1));
4638 assert_eq!(
4639 engine.render(&mut session, "count", &ctx).unwrap(),
4640 "1 item"
4641 );
4642
4643 session.reset();
4644 ctx.insert("n", Value::Number(5));
4645 assert_eq!(
4646 engine.render(&mut session, "count", &ctx).unwrap(),
4647 "5 items"
4648 );
4649 }
4650
4651 #[test]
4652 fn render_article_pipe() {
4653 let mut engine = test_engine();
4654 engine.register_template("a", "{thing|article}").unwrap();
4655
4656 let mut session = test_session();
4657 let mut ctx = Context::new();
4658 ctx.insert("thing", Value::String("apple".into()));
4659 assert_eq!(engine.render(&mut session, "a", &ctx).unwrap(), "an apple");
4660
4661 session.reset();
4662 ctx.insert("thing", Value::String("banana".into()));
4663 assert_eq!(engine.render(&mut session, "a", &ctx).unwrap(), "a banana");
4664 }
4665
4666 #[test]
4667 fn render_join_pipe() {
4668 let mut engine = test_engine();
4669 engine.register_template("list", "{items|join}").unwrap();
4670
4671 let mut session = test_session();
4672 let mut ctx = Context::new();
4673 ctx.insert(
4674 "items",
4675 Value::List(vec!["a".into(), "b".into(), "c".into()]),
4676 );
4677 assert_eq!(
4678 engine.render(&mut session, "list", &ctx).unwrap(),
4679 "a, b, and c"
4680 );
4681 }
4682
4683 #[test]
4684 fn render_join_or_pipe() {
4685 let mut engine = test_engine();
4686 engine.register_template("list", "{items|join:or}").unwrap();
4687
4688 let mut session = test_session();
4689 let mut ctx = Context::new();
4690 ctx.insert(
4691 "items",
4692 Value::List(vec!["a".into(), "b".into(), "c".into()]),
4693 );
4694 assert_eq!(
4695 engine.render(&mut session, "list", &ctx).unwrap(),
4696 "a, b, or c"
4697 );
4698 }
4699
4700 #[test]
4701 fn render_truncate_then_join_bracketed() {
4702 let mut engine = test_engine();
4703 engine
4704 .register_template("t", "{items|truncate:2|join:bracketed}")
4705 .unwrap();
4706
4707 let mut session = test_session();
4708 let mut ctx = Context::new();
4709 ctx.insert(
4710 "items",
4711 Value::List(vec![
4712 "a".into(),
4713 "b".into(),
4714 "c".into(),
4715 "d".into(),
4716 "e".into(),
4717 ]),
4718 );
4719 assert_eq!(
4720 engine.render(&mut session, "t", &ctx).unwrap(),
4721 "[a, b, and 3 more]"
4722 );
4723 }
4724
4725 #[test]
4726 fn render_capitalize_pipe() {
4727 let mut engine = test_engine();
4728 engine
4729 .register_template("cap", "{word|capitalize}")
4730 .unwrap();
4731
4732 let mut session = test_session();
4733 let mut ctx = Context::new();
4734 ctx.insert("word", Value::String("hello".into()));
4735 assert_eq!(engine.render(&mut session, "cap", &ctx).unwrap(), "Hello");
4736 }
4737
4738 #[test]
4739 fn render_ordinal_pipe() {
4740 let mut engine = test_engine();
4741 engine.register_template("o", "{n|ordinal}").unwrap();
4742
4743 let mut session = test_session();
4744 let mut ctx = Context::new();
4745 ctx.insert("n", Value::Number(3));
4746 assert_eq!(engine.render(&mut session, "o", &ctx).unwrap(), "3rd");
4747 }
4748
4749 #[test]
4750 fn render_inline_template() {
4751 let engine = test_engine();
4752 let mut session = test_session();
4753 let mut ctx = Context::new();
4754 ctx.insert("name", Value::String("world".into()));
4755
4756 assert_eq!(
4757 engine
4758 .render_inline(&mut session, "Hello {name}!", &ctx)
4759 .unwrap(),
4760 "Hello world!"
4761 );
4762 }
4763
4764 #[test]
4765 fn render_inline_does_not_advance_list_style_cycle() {
4766 let mut engine = test_engine();
4771 engine.register_template("t", "{items|join}").unwrap();
4772
4773 let mut s_ref = test_session();
4774 let mut ctx = Context::new();
4775 ctx.insert(
4776 "items",
4777 Value::List(vec!["a".into(), "b".into(), "c".into()]),
4778 );
4779
4780 let ref_out = engine.render(&mut s_ref, "t", &ctx).unwrap();
4782
4783 let mut s_test = test_session();
4787 engine
4788 .render_inline(&mut s_test, "{items|join}", &ctx)
4789 .unwrap();
4790 let after_inline = engine.render(&mut s_test, "t", &ctx).unwrap();
4791
4792 assert_eq!(
4793 ref_out, after_inline,
4794 "inline render leaked list-style cycle into a later registered render"
4795 );
4796 }
4797
4798 #[test]
4799 fn render_inline_failure_leaves_session_unchanged() {
4800 let engine = test_engine();
4803 let mut session = test_session();
4804
4805 let snapshot = session.clone();
4807
4808 let result = engine.render_inline(&mut session, "Hello {nope}!", Context::new());
4811 assert!(result.is_err(), "expected missing-slot error");
4812
4813 assert_eq!(
4817 session.discourse.focus_is_plural(),
4818 snapshot.discourse.focus_is_plural()
4819 );
4820 }
4821
4822 #[test]
4823 fn render_inline_does_not_mention_entities_via_plural_refer() {
4824 let mut engine = test_engine();
4828 engine.register_template("t", "{name|refer}").unwrap();
4829
4830 let mut session = test_session();
4831 let mut ctx = Context::new();
4832 ctx.insert("entity_type", Value::String("class".into()));
4833 ctx.insert("name", Value::String("Alpha".into()));
4834
4835 let _ = engine
4837 .render_inline(&mut session, "{name|refer}", &ctx)
4838 .unwrap();
4839
4840 let out = engine.render(&mut session, "t", &ctx).unwrap();
4843 assert!(
4844 out.contains("The class Alpha"),
4845 "expected Full form (no leaked entity mention); got: {out}"
4846 );
4847 }
4848
4849 #[test]
4850 fn variation_fixed_always_picks_first() {
4851 let mut engine = test_engine().variation(Variation::Fixed);
4852 engine.register_template("t", "first").unwrap();
4853 engine.register_template("t", "second").unwrap();
4854
4855 let mut session = test_session();
4856 let ctx = Context::new();
4857 let result = engine.render(&mut session, "t", &ctx).unwrap();
4859 assert_eq!(result, "first");
4860 }
4861
4862 #[test]
4863 fn variation_seeded_is_deterministic() {
4864 let mut engine = test_engine().variation(Variation::Seeded(42));
4865 engine.register_template("t", "first").unwrap();
4866 engine.register_template("t", "second").unwrap();
4867
4868 let ctx = Context::new();
4869 let mut session1 = test_session();
4870 let result1 = engine.render(&mut session1, "t", &ctx).unwrap();
4871 let mut session2 = test_session();
4872 let result2 = engine.render(&mut session2, "t", &ctx).unwrap();
4873 assert_eq!(result1, result2);
4874 }
4875
4876 #[test]
4877 fn unknown_pipe_is_error() {
4878 let mut engine = test_engine();
4882 let err = engine
4883 .register_template("t", "{name|nonexistent}")
4884 .unwrap_err();
4885 assert!(
4886 matches!(err, ProsaicError::TemplateParseError { .. }),
4887 "expected TemplateParseError for unknown pipe, got {err:?}"
4888 );
4889 }
4890
4891 #[test]
4892 fn complex_template_end_to_end() {
4893 let mut engine = test_engine();
4894 engine
4895 .register_template(
4896 "entity.renamed",
4897 "The {entity_type} {old_name} was renamed to {new_name} \
4898 which impacts {count} direct {count|pluralize:consumer}",
4899 )
4900 .unwrap();
4901
4902 let mut session = test_session();
4903 let mut ctx = Context::new();
4904 ctx.insert("entity_type", Value::String("class".into()));
4905 ctx.insert("old_name", Value::String("Foo".into()));
4906 ctx.insert("new_name", Value::String("Foobar".into()));
4907 ctx.insert("count", Value::Number(6));
4908
4909 assert_eq!(
4910 engine.render(&mut session, "entity.renamed", &ctx).unwrap(),
4911 "The class Foo was renamed to Foobar which impacts 6 direct consumers."
4912 );
4913 }
4914
4915 #[test]
4918 fn reset_clears_discourse_state() {
4919 let mut engine = test_engine();
4920 engine
4921 .register_template("t", "The {entity_type} {name} was modified")
4922 .unwrap();
4923
4924 let mut ctx = Context::new();
4925 ctx.insert("entity_type", Value::String("class".into()));
4926 ctx.insert("name", Value::String("Foo".into()));
4927
4928 let mut session = test_session();
4929 engine.render(&mut session, "t", &ctx).unwrap();
4930 session.reset();
4931
4932 let result = engine.render(&mut session, "t", &ctx).unwrap();
4934 assert!(result.starts_with("The class Foo"));
4935 }
4936
4937 #[test]
4938 fn template_anti_repeat_with_multiple_variants() {
4939 let mut engine = test_engine().variation(Variation::Seeded(1));
4945 engine
4946 .register_template("t", "alpha distinct tokens")
4947 .unwrap();
4948 engine
4949 .register_template("t", "beta different tokens")
4950 .unwrap();
4951 engine
4952 .register_template("t", "gamma unique tokens")
4953 .unwrap();
4954
4955 let mut session = test_session();
4956 let ctx = Context::new();
4957 let r1 = engine.render(&mut session, "t", &ctx).unwrap();
4958 let r2 = engine.render(&mut session, "t", &ctx).unwrap();
4959
4960 assert_ne!(r1, r2);
4963 }
4964
4965 #[test]
4966 fn list_style_cycles_across_renders() {
4967 let mut engine = test_engine();
4968 engine
4969 .register_template("t", "{items|truncate:1|join}")
4970 .unwrap();
4971
4972 let mut session = test_session();
4973 let mut ctx = Context::new();
4974 ctx.insert(
4975 "items",
4976 Value::List(vec!["alpha".into(), "beta".into(), "gamma".into()]),
4977 );
4978
4979 let r1 = engine.render(&mut session, "t", &ctx).unwrap();
4980 let r2 = engine.render(&mut session, "t", &ctx).unwrap();
4981 let r3 = engine.render(&mut session, "t", &ctx).unwrap();
4982 let r4 = engine.render(&mut session, "t", &ctx).unwrap();
4983
4984 let results = vec![r1, r2, r3, r4];
4986 let unique: std::collections::HashSet<&String> = results.iter().collect();
4987 assert!(
4988 unique.len() >= 3,
4989 "Expected at least 3 unique list styles, got {}: {:?}",
4990 unique.len(),
4991 results
4992 );
4993 }
4994
4995 #[test]
4996 fn bracketed_style_forced() {
4997 let mut engine = test_engine();
4998 engine
4999 .register_template("t", "{items|truncate:1|join:bracketed}")
5000 .unwrap();
5001
5002 let mut session = test_session();
5003 let mut ctx = Context::new();
5004 ctx.insert(
5005 "items",
5006 Value::List(vec!["alpha".into(), "beta".into(), "gamma".into()]),
5007 );
5008
5009 let result = engine.render(&mut session, "t", &ctx).unwrap();
5010 assert!(
5011 result.starts_with('[') && result.ends_with(']'),
5012 "Expected bracketed format, got: {result}"
5013 );
5014 }
5015
5016 #[test]
5019 fn refer_first_mention_uses_full_form() {
5020 let mut engine = test_engine();
5021 engine
5022 .register_template("t", "{name|refer} was updated")
5023 .unwrap();
5024
5025 let mut session = test_session();
5026 let mut ctx = Context::new();
5027 ctx.insert("entity_type", Value::String("class".into()));
5028 ctx.insert("name", Value::String("UserService".into()));
5029
5030 let result = engine.render(&mut session, "t", &ctx).unwrap();
5031 assert_eq!(result, "The class UserService was updated.");
5032 }
5033
5034 #[test]
5035 fn refer_second_mention_uses_pronoun() {
5036 let mut engine = test_engine();
5037 engine
5038 .register_template("first", "{name|refer} was modified")
5039 .unwrap();
5040 engine
5041 .register_template("second", "{name|refer} now has new behavior")
5042 .unwrap();
5043
5044 let mut session = test_session();
5045 let mut ctx = Context::new();
5046 ctx.insert("entity_type", Value::String("class".into()));
5047 ctx.insert("name", Value::String("Foo".into()));
5048
5049 let r1 = engine.render(&mut session, "first", &ctx).unwrap();
5050 let r2 = engine.render(&mut session, "second", &ctx).unwrap();
5051
5052 assert_eq!(r1, "The class Foo was modified.");
5053 assert!(
5055 r2.contains("it now has new behavior") || r2.contains("It now has new behavior"),
5056 "Expected pronoun reference, got: {r2}"
5057 );
5058 }
5059
5060 #[test]
5061 fn refer_ambiguity_prevents_pronoun() {
5062 let mut engine = test_engine();
5063 engine
5064 .register_template("t", "{name|refer} changed")
5065 .unwrap();
5066
5067 let mut session = test_session();
5068
5069 let mut ctx_a = Context::new();
5071 ctx_a.insert("entity_type", Value::String("class".into()));
5072 ctx_a.insert("name", Value::String("ServiceA".into()));
5073 engine.render(&mut session, "t", &ctx_a).unwrap();
5074
5075 let mut ctx_b = Context::new();
5077 ctx_b.insert("entity_type", Value::String("class".into()));
5078 ctx_b.insert("name", Value::String("ServiceB".into()));
5079 engine.render(&mut session, "t", &ctx_b).unwrap();
5080
5081 let result = engine.render(&mut session, "t", &ctx_a).unwrap();
5083 assert!(
5085 result.contains("ServiceA changed") || result.contains("serviceA changed"),
5086 "Expected short name (not pronoun), got: {result}"
5087 );
5088 assert!(
5089 !result.contains("It changed") && !result.contains("it changed"),
5090 "Should not use pronoun with ambiguity, got: {result}"
5091 );
5092 }
5093
5094 #[test]
5095 fn refer_explicit_entity_type() {
5096 let mut engine = test_engine();
5097 engine
5098 .register_template("t", "{name|refer:method} was called")
5099 .unwrap();
5100
5101 let mut session = test_session();
5102 let mut ctx = Context::new();
5103 ctx.insert("name", Value::String("processOrder".into()));
5104
5105 let result = engine.render(&mut session, "t", &ctx).unwrap();
5106 assert_eq!(result, "The method processOrder was called.");
5107 }
5108
5109 #[test]
5110 fn refer_reset_reintroduces_full_form() {
5111 let mut engine = test_engine();
5112 engine
5113 .register_template("t", "{name|refer} updated")
5114 .unwrap();
5115
5116 let mut session = test_session();
5117 let mut ctx = Context::new();
5118 ctx.insert("entity_type", Value::String("class".into()));
5119 ctx.insert("name", Value::String("Foo".into()));
5120
5121 engine.render(&mut session, "t", &ctx).unwrap();
5122 session.reset();
5123
5124 let result = engine.render(&mut session, "t", &ctx).unwrap();
5126 assert_eq!(result, "The class Foo updated.");
5127 }
5128
5129 #[test]
5130 fn refer_distant_mention_reintroduces_full() {
5131 let mut engine = test_engine();
5132 engine
5133 .register_template("track", "{name|refer} was tracked")
5134 .unwrap();
5135 engine
5136 .register_template("other", "Something else happened")
5137 .unwrap();
5138
5139 let mut session = test_session();
5140 let mut ctx = Context::new();
5141 ctx.insert("entity_type", Value::String("class".into()));
5142 ctx.insert("name", Value::String("Foo".into()));
5143
5144 let mut other_ctx = Context::new();
5145 other_ctx.insert("entity_type", Value::String("method".into()));
5146 other_ctx.insert("name", Value::String("bar".into()));
5147
5148 engine.render(&mut session, "track", &ctx).unwrap();
5150
5151 engine.render(&mut session, "other", &other_ctx).unwrap();
5153 engine.render(&mut session, "other", &other_ctx).unwrap();
5154 engine.render(&mut session, "other", &other_ctx).unwrap();
5155
5156 let result = engine.render(&mut session, "track", &ctx).unwrap();
5158 assert_eq!(result, "The class Foo was tracked.");
5159 }
5160
5161 #[test]
5164 fn explain_reports_variant_index_and_source() {
5165 let mut engine = test_engine();
5166 engine.register_template("t", "alpha").unwrap();
5167 engine.register_template("t", "beta").unwrap();
5168
5169 let mut session = test_session();
5170 let exp = engine
5171 .render_explained(&mut session, "t", Context::new())
5172 .unwrap();
5173 assert_eq!(exp.template_key, "t");
5174 assert_eq!(exp.variant_index, 0);
5175 assert_eq!(exp.variant_source, "alpha");
5176 assert_eq!(exp.salience, Salience::Medium);
5177 }
5178
5179 #[test]
5180 fn explain_reports_reference_form_when_refer_pipe_fires() {
5181 let mut engine = test_engine();
5182 engine
5183 .register_template("t", "{name|refer} was modified")
5184 .unwrap();
5185 let mut ctx = Context::new();
5186 ctx.insert("entity_type", Value::String("class".into()));
5187 ctx.insert("name", Value::String("Foo".into()));
5188
5189 let mut session = test_session();
5190 let exp = engine.render_explained(&mut session, "t", &ctx).unwrap();
5191 assert_eq!(exp.reference_form, Some(ReferenceForm::Full));
5193 }
5194
5195 #[test]
5196 fn explain_reports_centering_transition() {
5197 let mut engine = test_engine();
5198 engine
5199 .register_template("t", "{name|refer} was modified")
5200 .unwrap();
5201 let mut s = test_session();
5202
5203 let mut c = Context::new();
5204 c.insert("entity_type", Value::String("class".into()));
5205 c.insert("name", Value::String("Foo".into()));
5206
5207 let e1 = engine.render_explained(&mut s, "t", &c).unwrap();
5209 assert_eq!(e1.centering_transition, Transition::NoCb);
5210
5211 let e2 = engine.render_explained(&mut s, "t", &c).unwrap();
5213 assert_eq!(e2.centering_transition, Transition::Continue);
5214
5215 let mut c2 = Context::new();
5220 c2.insert("entity_type", Value::String("class".into()));
5221 c2.insert("name", Value::String("Bar".into()));
5222 let e3 = engine.render_explained(&mut s, "t", &c2).unwrap();
5223 assert_eq!(e3.centering_transition, Transition::Retain);
5224 }
5225
5226 #[test]
5227 fn explain_captures_connective_on_continuation() {
5228 let mut engine = test_engine();
5229 engine
5230 .register_template("t", "The {entity_type} {name} was renamed")
5231 .unwrap();
5232 engine
5233 .register_template("u", "The {entity_type} {name} was modified")
5234 .unwrap();
5235 let mut ctx = Context::new();
5236 ctx.insert("entity_type", Value::String("class".into()));
5237 ctx.insert("name", Value::String("Foo".into()));
5238
5239 let mut session = test_session();
5240 engine.render(&mut session, "t", &ctx).unwrap();
5242 let exp = engine.render_explained(&mut session, "u", &ctx).unwrap();
5244 assert_eq!(exp.connective, Some("Additionally,"));
5245 }
5246
5247 #[test]
5248 fn render_explained_reports_list_style_when_join_fires() {
5249 let mut engine = test_engine();
5250 engine
5251 .register_template("list", "{items|join:bracketed}")
5252 .unwrap();
5253 let mut session = test_session();
5254 let mut ctx = Context::new();
5255 ctx.insert(
5256 "items",
5257 Value::List(vec!["a".into(), "b".into(), "c".into()]),
5258 );
5259 let exp = engine.render_explained(&mut session, "list", &ctx).unwrap();
5260 assert_eq!(
5261 exp.list_style,
5262 Some(ListStyle::Bracketed),
5263 "render_explained should report the forced list style; got: {:?}",
5264 exp.list_style
5265 );
5266 }
5267
5268 #[test]
5269 fn render_explained_list_style_none_when_no_join_fired() {
5270 let mut engine = test_engine();
5271 engine
5272 .register_template("plain", "The {entity_type} {name} was renamed")
5273 .unwrap();
5274 let mut session = test_session();
5275 let mut ctx = Context::new();
5276 ctx.insert("entity_type", Value::String("class".into()));
5277 ctx.insert("name", Value::String("Foo".into()));
5278 let exp = engine
5279 .render_explained(&mut session, "plain", &ctx)
5280 .unwrap();
5281 assert_eq!(exp.list_style, None);
5282 }
5283
5284 #[test]
5285 fn render_explained_reports_cleanup_stripped_tail_in_silent_mode() {
5286 let mut engine = test_engine().strictness(Strictness::Silent);
5290 engine
5291 .register_template("add", "A new {entity_type} was added in {location}")
5292 .unwrap();
5293 let mut session = test_session();
5294 let mut ctx = Context::new();
5295 ctx.insert("entity_type", Value::String("class".into()));
5296 let exp = engine.render_explained(&mut session, "add", &ctx).unwrap();
5298 assert!(
5299 exp.cleanup_stripped_tail,
5300 "Silent-mode render with dangling tail should report cleanup_stripped_tail=true; got output: {:?}",
5301 exp.output
5302 );
5303 }
5304
5305 #[test]
5306 fn render_explained_cleanup_stripped_tail_false_for_clean_render() {
5307 let mut engine = test_engine();
5308 engine
5309 .register_template("plain", "The {entity_type} {name} was renamed")
5310 .unwrap();
5311 let mut session = test_session();
5312 let mut ctx = Context::new();
5313 ctx.insert("entity_type", Value::String("class".into()));
5314 ctx.insert("name", Value::String("Foo".into()));
5315 let exp = engine
5316 .render_explained(&mut session, "plain", &ctx)
5317 .unwrap();
5318 assert!(!exp.cleanup_stripped_tail);
5319 }
5320
5321 #[test]
5324 fn render_iter_yields_one_sentence_per_event_when_no_aggregation() {
5325 let mut engine = test_engine();
5326 engine.register_template("a", "Alpha was seen").unwrap();
5327 engine.register_template("b", "Beta was found").unwrap();
5328
5329 let mut session = test_session();
5330 let events: Vec<(&str, Context)> = vec![("a", Context::new()), ("b", Context::new())];
5331 let results: Vec<_> = engine
5332 .render_iter(&mut session, &events)
5333 .collect::<Result<Vec<_>, _>>()
5334 .unwrap();
5335 assert_eq!(results.len(), 2);
5338 assert!(results[0].contains("Alpha"));
5339 assert!(results[1].contains("Beta"));
5340 }
5341
5342 #[test]
5343 fn render_iter_error_is_terminal_for_single_events() {
5344 let mut engine = test_engine();
5347 engine
5348 .register_template("bad", "{missing_slot} was lost")
5349 .unwrap();
5350
5351 let mut session = test_session();
5352 let events: Vec<(&str, Context)> = vec![("bad", Context::new())];
5353 let mut iter = engine.render_iter(&mut session, &events);
5354 let first = iter.next();
5355 assert!(matches!(first, Some(Err(_))));
5356 let second = iter.next();
5357 assert!(
5358 second.is_none(),
5359 "iterator must return None after a terminal error"
5360 );
5361 }
5362
5363 #[test]
5364 fn render_iter_error_is_terminal_inside_aggregated_run() {
5365 let mut engine = test_engine();
5370 engine
5371 .register_template("saw", "{name} saw {target}")
5372 .unwrap();
5373
5374 let mut good = Context::new();
5375 good.insert("entity_type", Value::String("class".into()));
5376 good.insert("name", Value::String("Alpha".into()));
5377 good.insert("target", Value::String("X".into()));
5378
5379 let mut bad = Context::new();
5380 bad.insert("entity_type", Value::String("class".into()));
5381 bad.insert("name", Value::String("Beta".into()));
5382 let mut session = test_session();
5385 let events: Vec<(&str, Context)> = vec![("saw", good), ("saw", bad)];
5386 let mut iter = engine.render_iter(&mut session, &events);
5387 let first = iter.next();
5390 assert!(
5391 matches!(first, Some(Err(_))),
5392 "expected the aggregated render to fail; got: {first:?}"
5393 );
5394 assert!(
5395 iter.next().is_none(),
5396 "iterator must be terminal after an aggregated-run error"
5397 );
5398 }
5399
5400 #[test]
5401 fn render_iter_collapses_same_entity_run_into_one_sentence() {
5402 let mut engine = test_engine();
5403 engine
5404 .register_template("renamed", "{name|refer} was renamed")
5405 .unwrap();
5406 engine
5407 .register_template("modified", "{name|refer} was modified")
5408 .unwrap();
5409
5410 let mut session = test_session();
5411 let mut ctx = Context::new();
5412 ctx.insert("entity_type", Value::String("class".into()));
5413 ctx.insert("name", Value::String("Foo".into()));
5414 let events: Vec<(&str, Context)> =
5415 vec![("renamed", ctx.clone()), ("modified", ctx.clone())];
5416 let iter_results: Vec<_> = engine
5417 .render_iter(&mut session, &events)
5418 .collect::<Result<Vec<_>, _>>()
5419 .unwrap();
5420 assert_eq!(iter_results.len(), 1);
5422 assert!(
5423 iter_results[0].contains("renamed and modified"),
5424 "got: {}",
5425 iter_results[0]
5426 );
5427 }
5428
5429 #[test]
5432 fn score_variants_returns_one_entry_per_alternative() {
5433 let mut engine = test_engine();
5434 engine.register_template("t", "alpha").unwrap();
5435 engine.register_template("t", "beta").unwrap();
5436 engine.register_template("t", "gamma").unwrap();
5437
5438 let mut session = test_session();
5439 let scores = engine
5440 .score_variants(&mut session, "t", Context::new())
5441 .unwrap();
5442 assert_eq!(scores.len(), 3);
5443 let sources: Vec<_> = scores.iter().map(|s| s.source.as_str()).collect();
5444 assert_eq!(sources, vec!["alpha", "beta", "gamma"]);
5445 }
5446
5447 #[test]
5448 fn score_variants_marks_one_as_selected() {
5449 let mut engine = test_engine();
5450 engine.register_template("t", "alpha").unwrap();
5451 engine.register_template("t", "beta").unwrap();
5452
5453 let mut session = test_session();
5454 let scores = engine
5455 .score_variants(&mut session, "t", Context::new())
5456 .unwrap();
5457 assert_eq!(scores.iter().filter(|s| s.selected).count(), 1);
5458 }
5459
5460 #[test]
5461 fn score_variants_does_not_mutate_discourse() {
5462 let mut engine = test_engine();
5463 engine.register_template("t", "alpha").unwrap();
5464 engine.register_template("t", "beta").unwrap();
5465
5466 let mut session = test_session();
5470 let _ = engine
5471 .score_variants(&mut session, "t", Context::new())
5472 .unwrap();
5473 let r1 = engine.render(&mut session, "t", Context::new()).unwrap();
5474 assert_eq!(r1, "alpha");
5477 }
5478
5479 #[test]
5480 fn score_variants_unknown_key_errors() {
5481 let engine = test_engine();
5482 let mut session = test_session();
5483 let result = engine.score_variants(&mut session, "never_registered", Context::new());
5484 assert!(matches!(result, Err(ProsaicError::UnknownTemplate(_))));
5485 }
5486
5487 #[test]
5490 fn partial_expands_inline() {
5491 let mut engine = test_engine();
5492 engine
5493 .register_partial("tail", ", affecting {count} {count|pluralize:consumer}")
5494 .unwrap();
5495 engine
5496 .register_template("t", "The class Foo was modified{>tail}")
5497 .unwrap();
5498
5499 let mut session = test_session();
5500 let mut ctx = Context::new();
5501 ctx.insert("count", Value::Number(3));
5502 assert_eq!(
5503 engine.render(&mut session, "t", &ctx).unwrap(),
5504 "The class Foo was modified, affecting 3 consumers."
5505 );
5506 }
5507
5508 #[test]
5509 fn partial_shared_across_templates() {
5510 let mut engine = test_engine();
5511 engine
5512 .register_partial("tail", ", affecting {count} {count|pluralize:consumer}")
5513 .unwrap();
5514 engine
5515 .register_template("modified", "The class {name} was modified{>tail}")
5516 .unwrap();
5517 engine
5518 .register_template("renamed", "The class {name} was renamed{>tail}")
5519 .unwrap();
5520
5521 let mut session = test_session();
5522 let mut ctx = Context::new();
5523 ctx.insert("name", Value::String("Foo".into()));
5524 ctx.insert("count", Value::Number(2));
5525
5526 assert_eq!(
5527 engine.render(&mut session, "modified", &ctx).unwrap(),
5528 "The class Foo was modified, affecting 2 consumers."
5529 );
5530 assert_eq!(
5531 engine.render(&mut session, "renamed", &ctx).unwrap(),
5532 "The class Foo was renamed, affecting 2 consumers."
5533 );
5534 }
5535
5536 #[test]
5537 fn unknown_partial_errors() {
5538 let mut engine = test_engine();
5539 engine
5540 .register_template("t", "Hello{>missing_partial}")
5541 .unwrap();
5542 let mut session = test_session();
5543 let result = engine.render(&mut session, "t", Context::new());
5544 assert!(matches!(
5545 result,
5546 Err(ProsaicError::TemplateParseError { .. })
5547 ));
5548 }
5549
5550 #[test]
5551 fn direct_recursive_partial_is_rejected() {
5552 let mut engine = test_engine();
5553 let result = engine.register_partial("a", "{>a}");
5554 match result {
5555 Err(ProsaicError::RecursivePartial { cycle }) => {
5556 assert_eq!(cycle, vec!["a".to_string(), "a".to_string()]);
5557 }
5558 other => panic!("expected RecursivePartial, got {other:?}"),
5559 }
5560 assert!(!engine.partials.contains_key("a"));
5563 }
5564
5565 #[test]
5566 fn indirect_recursive_partial_is_rejected() {
5567 let mut engine = test_engine();
5568 engine.register_partial("a", "{>b}").unwrap();
5570 let result = engine.register_partial("b", "{>a}");
5573 match result {
5574 Err(ProsaicError::RecursivePartial { cycle }) => {
5575 assert!(
5576 cycle.contains(&"a".to_string()) && cycle.contains(&"b".to_string()),
5577 "cycle should include both partials; got {cycle:?}"
5578 );
5579 assert_eq!(cycle.first(), cycle.last());
5581 }
5582 other => panic!("expected RecursivePartial, got {other:?}"),
5583 }
5584 assert!(!engine.partials.contains_key("b"));
5585 assert!(engine.partials.contains_key("a"));
5587 }
5588
5589 #[test]
5590 fn non_cyclic_partial_chain_is_accepted() {
5591 let mut engine = test_engine();
5592 engine.register_partial("inner", "-inner-").unwrap();
5593 engine.register_partial("middle", "[{>inner}]").unwrap();
5594 engine.register_partial("outer", "<{>middle}>").unwrap();
5595 engine
5596 .register_template("t", "prefix {>outer} suffix")
5597 .unwrap();
5598
5599 let mut session = test_session();
5600 let out = engine.render(&mut session, "t", Context::new()).unwrap();
5601 assert!(out.contains("<[-inner-]>"), "got: {out}");
5602 }
5603
5604 #[test]
5605 fn updating_partial_to_introduce_cycle_rolls_back() {
5606 let mut engine = test_engine();
5607 engine.register_partial("a", "literal-a").unwrap();
5608 engine.register_partial("b", "{>a}").unwrap();
5609
5610 let result = engine.register_partial("a", "{>b}");
5614 assert!(matches!(result, Err(ProsaicError::RecursivePartial { .. })));
5615
5616 engine.register_template("t", "see {>a} here").unwrap();
5618 let mut session = test_session();
5619 let out = engine.render(&mut session, "t", Context::new()).unwrap();
5620 assert!(
5621 out.contains("literal-a"),
5622 "expected prior partial body to be restored; got: {out}"
5623 );
5624 }
5625
5626 #[test]
5629 fn length_budget_splits_long_sentence_at_which() {
5630 let mut engine = test_engine().max_sentence_length(50);
5631 engine
5632 .register_template(
5633 "t",
5634 "The class UserService was renamed to AccountService, \
5635 which impacts 6 consumers",
5636 )
5637 .unwrap();
5638
5639 let mut session = test_session();
5640 let out = engine.render(&mut session, "t", Context::new()).unwrap();
5641 assert!(out.contains("This impacts 6 consumers"), "got: {out}");
5642 assert!(out.contains(". "), "expected a sentence break, got: {out}");
5643 }
5644
5645 #[test]
5646 fn length_budget_does_nothing_when_sentence_fits() {
5647 let mut engine = test_engine().max_sentence_length(200);
5648 engine
5649 .register_template("t", "The class Foo was modified")
5650 .unwrap();
5651
5652 let mut session = test_session();
5653 let out = engine.render(&mut session, "t", Context::new()).unwrap();
5654 assert_eq!(out, "The class Foo was modified.");
5655 }
5656
5657 #[test]
5660 fn negated_pipe_uses_registered_antonym() {
5661 let mut engine = test_engine();
5662 engine.register_antonym("was modified", "remained unchanged");
5663 engine
5664 .register_template("t", "The class Foo {p|negated}")
5665 .unwrap();
5666
5667 let mut session = test_session();
5668 let mut ctx = Context::new();
5669 ctx.insert("p", Value::String("was modified".into()));
5670 assert_eq!(
5671 engine.render(&mut session, "t", &ctx).unwrap(),
5672 "The class Foo remained unchanged."
5673 );
5674 }
5675
5676 #[test]
5677 fn negated_pipe_inserts_not_when_no_antonym() {
5678 let mut engine = test_engine();
5679 engine
5680 .register_template("t", "The class Foo {p|negated}")
5681 .unwrap();
5682
5683 let mut session = test_session();
5684 let mut ctx = Context::new();
5685 ctx.insert("p", Value::String("was modified".into()));
5686 assert_eq!(
5687 engine.render(&mut session, "t", &ctx).unwrap(),
5688 "The class Foo was not modified."
5689 );
5690 }
5691
5692 #[test]
5693 fn negated_pipe_handles_perfect_aux() {
5694 let mut engine = test_engine();
5695 engine
5696 .register_template("t", "The class Foo {p|negated}")
5697 .unwrap();
5698
5699 let mut session = test_session();
5700 let mut ctx = Context::new();
5701 ctx.insert("p", Value::String("has been renamed".into()));
5702 assert_eq!(
5703 engine.render(&mut session, "t", &ctx).unwrap(),
5704 "The class Foo has not been renamed."
5705 );
5706 }
5707
5708 #[test]
5711 fn hedge_pipe_default_adverb() {
5712 let mut engine = test_engine();
5713 engine
5714 .register_template("t", "The change {conf|hedge} broke the build")
5715 .unwrap();
5716
5717 let mut session = test_session();
5718 let mut ctx = Context::new();
5719 ctx.insert("conf", Value::Number(60));
5720 assert_eq!(
5721 engine.render(&mut session, "t", &ctx).unwrap(),
5722 "The change probably broke the build."
5723 );
5724 }
5725
5726 #[test]
5727 fn hedge_pipe_modal_mode() {
5728 let mut engine = test_engine();
5729 engine
5730 .register_template("t", "The change {conf|hedge:modal} break things")
5731 .unwrap();
5732
5733 let mut session = test_session();
5734 let mut ctx = Context::new();
5735 ctx.insert("conf", Value::Number(40));
5736 assert_eq!(
5737 engine.render(&mut session, "t", &ctx).unwrap(),
5738 "The change might break things."
5739 );
5740 }
5741
5742 #[test]
5743 fn hedge_pipe_rejects_unknown_mode() {
5744 let mut engine = test_engine();
5745 engine.register_template("t", "{c|hedge:bogus}").unwrap();
5746 let mut session = test_session();
5747 let mut ctx = Context::new();
5748 ctx.insert("c", Value::Number(60));
5749 assert!(matches!(
5750 engine.render(&mut session, "t", &ctx),
5751 Err(ProsaicError::InvalidPipe { .. })
5752 ));
5753 }
5754
5755 #[test]
5758 fn demonstrative_uses_the_on_first_render() {
5759 let mut engine = test_engine();
5760 engine
5761 .register_template("t", "{noun|demonstrative}")
5762 .unwrap();
5763 let mut session = test_session();
5764 let mut ctx = Context::new();
5765 ctx.insert("noun", Value::String("change".into()));
5766 assert_eq!(
5771 engine.render(&mut session, "t", &ctx).unwrap(),
5772 "the change"
5773 );
5774 }
5775
5776 #[test]
5777 fn demonstrative_uses_this_on_continuation() {
5778 let mut engine = test_engine();
5779 engine.register_template("prime", "setup").unwrap();
5780 engine
5781 .register_template("t", "{noun|demonstrative}")
5782 .unwrap();
5783
5784 let mut session = test_session();
5785 engine
5787 .render(&mut session, "prime", Context::new())
5788 .unwrap();
5789
5790 let mut ctx = Context::new();
5791 ctx.insert("noun", Value::String("change".into()));
5792 let result = engine.render(&mut session, "t", &ctx).unwrap();
5793 assert_eq!(result, "this change");
5796 }
5797
5798 #[test]
5799 fn demonstrative_resets_to_the_after_reset() {
5800 let mut engine = test_engine();
5801 engine.register_template("prime", "setup").unwrap();
5802 engine
5803 .register_template("t", "{noun|demonstrative}")
5804 .unwrap();
5805
5806 let mut session = test_session();
5807 engine
5808 .render(&mut session, "prime", Context::new())
5809 .unwrap();
5810 session.reset();
5811
5812 let mut ctx = Context::new();
5813 ctx.insert("noun", Value::String("change".into()));
5814 assert_eq!(
5815 engine.render(&mut session, "t", &ctx).unwrap(),
5816 "the change"
5817 );
5818 }
5819
5820 #[test]
5823 fn quantify_pipe_natural_defaults() {
5824 let mut engine = test_engine();
5828 engine
5829 .register_template("t", "{n|quantify} consumer")
5830 .unwrap();
5831
5832 let mut session = test_session();
5833 let mut ctx = Context::new();
5834 ctx.insert("n", Value::Number(0));
5835 assert_eq!(
5836 engine.render(&mut session, "t", &ctx).unwrap(),
5837 "no consumer"
5838 );
5839 session.reset();
5840
5841 ctx.insert("n", Value::Number(1));
5842 assert_eq!(
5843 engine.render(&mut session, "t", &ctx).unwrap(),
5844 "a single consumer"
5845 );
5846 session.reset();
5847
5848 ctx.insert("n", Value::Number(300));
5849 assert_eq!(
5850 engine.render(&mut session, "t", &ctx).unwrap(),
5851 "hundreds of consumer"
5852 );
5853 }
5854
5855 #[test]
5856 fn quantify_pipe_exact_mode() {
5857 let mut engine = test_engine();
5858 engine
5859 .register_template("t", "{n|quantify:exact} callers")
5860 .unwrap();
5861
5862 let mut session = test_session();
5863 let mut ctx = Context::new();
5864 ctx.insert("n", Value::Number(47));
5865 assert_eq!(
5866 engine.render(&mut session, "t", &ctx).unwrap(),
5867 "47 callers"
5868 );
5869 }
5870
5871 #[test]
5872 fn quantify_pipe_hedged_mode() {
5873 let mut engine = test_engine();
5874 engine
5875 .register_template("t", "{n|quantify:hedged} dependents")
5876 .unwrap();
5877
5878 let mut session = test_session();
5879 let mut ctx = Context::new();
5880 ctx.insert("n", Value::Number(4));
5881 assert_eq!(
5882 engine.render(&mut session, "t", &ctx).unwrap(),
5883 "a few dependents"
5884 );
5885 }
5886
5887 #[test]
5888 fn quantify_pipe_rejects_unknown_mode() {
5889 let mut engine = test_engine();
5890 engine.register_template("t", "{n|quantify:bogus}").unwrap();
5891 let mut session = test_session();
5892 let mut ctx = Context::new();
5893 ctx.insert("n", Value::Number(5));
5894 assert!(matches!(
5895 engine.render(&mut session, "t", &ctx),
5896 Err(ProsaicError::InvalidPipe { .. })
5897 ));
5898 }
5899
5900 #[test]
5903 fn relative_pipe_renders_past_phrases() {
5904 let now: i64 = 1_700_000_000;
5906 let mut engine = test_engine().reference_time(now);
5907 engine.register_template("t", "{ts|relative}").unwrap();
5908
5909 let cases = [
5910 (now, "just now"),
5911 (now - 60, "1 minute ago"),
5912 (now - 3600, "an hour ago"),
5913 (now - 86400 - 3600, "yesterday"),
5914 (now - 3 * 86400, "3 days ago"),
5915 (now - 10 * 86400, "last week"),
5916 (now - 3 * 30 * 86400, "3 months ago"),
5917 (now - 2 * 365 * 86400, "2 years ago"),
5918 ];
5919
5920 for (ts, expected) in cases {
5921 let mut session = test_session();
5922 let mut ctx = Context::new();
5923 ctx.insert("ts", Value::Number(ts));
5924 let rendered = engine.render(&mut session, "t", &ctx).unwrap();
5925 assert_eq!(rendered, expected, "for ts={ts}");
5926 }
5927 }
5928
5929 #[test]
5930 fn relative_pipe_renders_future_phrases() {
5931 let now: i64 = 1_700_000_000;
5932 let mut engine = test_engine().reference_time(now);
5933 engine.register_template("t", "{ts|relative}").unwrap();
5934
5935 let cases = [
5936 (now + 3600, "in an hour"),
5937 (now + 86400 + 3600, "tomorrow"),
5938 (now + 3 * 86400, "in 3 days"),
5939 (now + 10 * 86400, "next week"),
5940 ];
5941
5942 for (ts, expected) in cases {
5943 let mut session = test_session();
5944 let mut ctx = Context::new();
5945 ctx.insert("ts", Value::Number(ts));
5946 let rendered = engine.render(&mut session, "t", &ctx).unwrap();
5947 assert_eq!(rendered, expected, "for ts={ts}");
5948 }
5949 }
5950
5951 #[test]
5952 fn relative_pipe_rejects_non_numeric() {
5953 let mut engine = test_engine().reference_time(1_700_000_000);
5954 engine.register_template("t", "{x|relative}").unwrap();
5955 let mut session = test_session();
5956 let mut ctx = Context::new();
5957 ctx.insert("x", Value::String("not a number".into()));
5958 let result = engine.render(&mut session, "t", &ctx);
5959 assert!(matches!(result, Err(ProsaicError::InvalidPipe { .. })));
5960 }
5961
5962 #[cfg(feature = "time")]
5965 #[test]
5966 fn since_last_first_event_falls_back_to_relative() {
5967 let now = 1_700_000_000;
5968 let mut engine = test_engine().reference_time(now);
5969 engine.register_template("t", "{ts|since_last}").unwrap();
5970 let mut s = Session::new();
5971 let mut ctx = Context::new();
5972 ctx.insert("ts", Value::Number(now - 3 * 86400)); ctx.insert("timestamp", Value::Number(now - 3 * 86400));
5974 let out = engine.render(&mut s, "t", &ctx).unwrap();
5975 assert!(out.contains("3 days ago"), "got: {out}");
5976 }
5977
5978 #[cfg(feature = "time")]
5979 #[test]
5980 fn since_last_subsequent_event_uses_anchor() {
5981 let now = 1_700_000_000;
5982 let mut engine = test_engine().reference_time(now);
5983 engine.register_template("t", "{ts|since_last}").unwrap();
5984 let mut s = Session::new();
5985
5986 let mut c1 = Context::new();
5988 let t1 = now - 3 * 86400;
5989 c1.insert("ts", Value::Number(t1));
5990 c1.insert("timestamp", Value::Number(t1));
5991 engine.render(&mut s, "t", &c1).unwrap();
5992
5993 let mut c2 = Context::new();
5995 let t2 = t1 + 86400;
5996 c2.insert("ts", Value::Number(t2));
5997 c2.insert("timestamp", Value::Number(t2));
5998 let out = engine.render(&mut s, "t", &c2).unwrap();
5999 assert!(out.contains("the next day"), "got: {out}");
6000 }
6001
6002 #[cfg(feature = "time")]
6003 #[test]
6004 fn since_last_survives_session_reset() {
6005 let now = 1_700_000_000;
6006 let mut engine = test_engine().reference_time(now);
6007 engine.register_template("t", "{ts|since_last}").unwrap();
6008 let mut s = Session::new();
6009
6010 let mut c1 = Context::new();
6011 let t1 = now - 3 * 86400;
6012 c1.insert("ts", Value::Number(t1));
6013 c1.insert("timestamp", Value::Number(t1));
6014 engine.render(&mut s, "t", &c1).unwrap();
6015
6016 s.reset(); assert_eq!(s.last_temporal_anchor, Some(t1));
6018
6019 let mut c2 = Context::new();
6020 let t2 = t1 + 86400;
6021 c2.insert("ts", Value::Number(t2));
6022 c2.insert("timestamp", Value::Number(t2));
6023 let out = engine.render(&mut s, "t", &c2).unwrap();
6024 assert!(out.contains("the next day"), "got: {out}");
6025 }
6026
6027 #[cfg(feature = "time")]
6028 #[test]
6029 fn since_last_reset_temporal_restarts_narrative() {
6030 let now = 1_700_000_000;
6031 let mut engine = test_engine().reference_time(now);
6032 engine.register_template("t", "{ts|since_last}").unwrap();
6033 let mut s = Session::new();
6034
6035 let mut c1 = Context::new();
6036 let t1 = now - 3 * 86400;
6037 c1.insert("ts", Value::Number(t1));
6038 c1.insert("timestamp", Value::Number(t1));
6039 engine.render(&mut s, "t", &c1).unwrap();
6040 s.reset_temporal();
6041
6042 let mut c2 = Context::new();
6043 let t2 = t1 + 86400;
6044 c2.insert("ts", Value::Number(t2));
6045 c2.insert("timestamp", Value::Number(t2));
6046 let out = engine.render(&mut s, "t", &c2).unwrap();
6047 assert!(out.contains("2 days ago"), "got: {out}");
6049 }
6050
6051 #[cfg(feature = "time")]
6052 #[test]
6053 fn since_last_anchor_set_after_successful_render() {
6054 let now = 1_700_000_000;
6055 let mut engine = test_engine().reference_time(now);
6056 engine.register_template("t", "{ts|since_last}").unwrap();
6057 let mut s = Session::new();
6058 assert_eq!(s.last_temporal_anchor, None);
6059
6060 let mut ctx = Context::new();
6061 let ts = now - 86400;
6062 ctx.insert("ts", Value::Number(ts));
6063 ctx.insert("timestamp", Value::Number(ts));
6064 engine.render(&mut s, "t", &ctx).unwrap();
6065 assert_eq!(s.last_temporal_anchor, Some(ts));
6066 }
6067
6068 #[cfg(feature = "time")]
6069 #[test]
6070 fn since_last_rejects_non_numeric() {
6071 let mut engine = test_engine().reference_time(1_700_000_000);
6072 engine.register_template("t", "{x|since_last}").unwrap();
6073 let mut s = Session::new();
6074 let mut ctx = Context::new();
6075 ctx.insert("x", Value::String("not a number".into()));
6076 let result = engine.render(&mut s, "t", &ctx);
6077 assert!(matches!(result, Err(ProsaicError::InvalidPipe { .. })));
6078 }
6079
6080 #[test]
6083 fn syn_pipe_passes_through_unregistered_words() {
6084 let mut engine = test_engine();
6085 engine.register_template("t", "{word|syn}").unwrap();
6086
6087 let mut session = test_session();
6088 let mut ctx = Context::new();
6089 ctx.insert("word", Value::String("unregistered".into()));
6090 assert_eq!(
6091 engine.render(&mut session, "t", &ctx).unwrap(),
6092 "unregistered"
6093 );
6094 }
6095
6096 #[test]
6097 fn syn_pipe_rotates_across_renders() {
6098 let mut engine = test_engine();
6099 engine.register_synonyms(&["class", "type", "kind"]);
6100 engine
6101 .register_template("t", "the {word|syn} was seen")
6102 .unwrap();
6103
6104 let mut session = test_session();
6105 let mut ctx = Context::new();
6106 ctx.insert("word", Value::String("class".into()));
6107
6108 let r1 = engine.render(&mut session, "t", &ctx).unwrap();
6109 let r2 = engine.render(&mut session, "t", &ctx).unwrap();
6110 let r3 = engine.render(&mut session, "t", &ctx).unwrap();
6111
6112 let combined = format!("{r1} | {r2} | {r3}");
6115 assert!(combined.contains("class"), "got: {combined}");
6116 assert!(combined.contains("type"), "got: {combined}");
6117 assert!(combined.contains("kind"), "got: {combined}");
6118 }
6119
6120 #[test]
6121 fn syn_pipe_preserves_capitalization() {
6122 let mut engine = test_engine();
6123 engine.register_synonyms(&["class", "type"]);
6124 engine.register_template("t", "{word|syn}").unwrap();
6125
6126 let mut session = test_session();
6127 let mut ctx = Context::new();
6129 ctx.insert("word", Value::String("Class".into()));
6130 let first = engine.render(&mut session, "t", &ctx).unwrap();
6131 assert!(
6132 first.chars().next().unwrap().is_uppercase(),
6133 "expected capitalized output, got: {first}"
6134 );
6135 }
6136
6137 #[test]
6138 fn syn_pipe_deterministic_tie_break_first_registered_wins() {
6139 let mut engine = test_engine();
6140 engine.register_synonyms(&["alpha", "beta", "gamma"]);
6141 engine.register_template("t", "{word|syn}").unwrap();
6142
6143 let mut session = test_session();
6144 let mut ctx = Context::new();
6147 ctx.insert("word", Value::String("alpha".into()));
6148 assert_eq!(engine.render(&mut session, "t", &ctx).unwrap(), "alpha");
6149 }
6150
6151 #[test]
6154 fn reduce_merges_three_simple_same_entity_passives() {
6155 let reduced = reduce_same_entity_clauses(&[
6156 "The class UserService was renamed to AccountService.".to_string(),
6157 "It was modified.".to_string(),
6158 "It was moved from src/ to lib/.".to_string(),
6159 ]);
6160 assert_eq!(
6161 reduced.as_deref(),
6162 Some(
6163 "The class UserService was renamed to AccountService, \
6164 modified, and moved from src/ to lib/."
6165 )
6166 );
6167 }
6168
6169 #[test]
6170 fn reduce_two_clauses_uses_and_without_oxford_comma() {
6171 let reduced = reduce_same_entity_clauses(&[
6172 "The class Foo was renamed.".to_string(),
6173 "It was modified.".to_string(),
6174 ]);
6175 assert_eq!(
6176 reduced.as_deref(),
6177 Some("The class Foo was renamed and modified.")
6178 );
6179 }
6180
6181 #[test]
6182 fn reduce_rejects_mixed_auxiliaries() {
6183 let reduced = reduce_same_entity_clauses(&[
6187 "The class Foo was renamed.".to_string(),
6188 "It has been modified.".to_string(),
6189 ]);
6190 assert!(reduced.is_none());
6191 }
6192
6193 #[test]
6194 fn reduce_rejects_embedded_which_clauses() {
6195 let reduced = reduce_same_entity_clauses(&[
6198 "The class Foo was renamed, which impacts 6 consumers.".to_string(),
6199 "It was modified.".to_string(),
6200 ]);
6201 assert!(reduced.is_none());
6202 }
6203
6204 #[test]
6205 fn reduce_strips_connectives_and_merges() {
6206 let reduced = reduce_same_entity_clauses(&[
6211 "The class Foo was renamed.".to_string(),
6212 "Additionally, it was modified.".to_string(),
6213 "Furthermore, it was moved.".to_string(),
6214 ]);
6215 assert_eq!(
6216 reduced.as_deref(),
6217 Some("The class Foo was renamed, modified, and moved.")
6218 );
6219 }
6220
6221 #[test]
6222 fn reduce_rejects_single_sentence() {
6223 let reduced = reduce_same_entity_clauses(&["The class Foo was renamed.".to_string()]);
6224 assert!(reduced.is_none());
6225 }
6226
6227 #[test]
6228 fn reduce_accepts_full_np_repetition_same_entity() {
6229 let reduced = reduce_same_entity_clauses(&[
6233 "The class Foo was renamed.".to_string(),
6234 "The class Foo was modified.".to_string(),
6235 ]);
6236 assert_eq!(
6237 reduced.as_deref(),
6238 Some("The class Foo was renamed and modified.")
6239 );
6240 }
6241
6242 #[test]
6243 fn reduce_handles_has_been_perfect_passive() {
6244 let reduced = reduce_same_entity_clauses(&[
6245 "The class Foo has been renamed.".to_string(),
6246 "It has been modified.".to_string(),
6247 "It has been moved.".to_string(),
6248 ]);
6249 assert_eq!(
6250 reduced.as_deref(),
6251 Some("The class Foo has been renamed, modified, and moved.")
6252 );
6253 }
6254
6255 #[test]
6258 fn reduce_accepts_it_also_connective() {
6259 let reduced = reduce_same_entity_clauses(&[
6262 "The class UserService was renamed.".to_string(),
6263 "It also was modified.".to_string(),
6264 ]);
6265 assert_eq!(
6266 reduced.as_deref(),
6267 Some("The class UserService was renamed and modified.")
6268 );
6269 }
6270
6271 #[test]
6272 fn prepend_replacing_subject_single_word_name_strips_np() {
6273 let mut out = String::from("The class Foo was modified");
6276 prepend_replacing_subject_in_place(&mut out, "It also", Some("Foo"));
6277 assert_eq!(out, "It also was modified");
6278 }
6279
6280 #[test]
6281 fn prepend_replacing_subject_multiword_name_falls_back() {
6282 let mut out = String::from("The feature Login flow was modified");
6286 prepend_replacing_subject_in_place(&mut out, "It also", Some("Login flow"));
6287 assert_eq!(out, "It also the feature Login flow was modified");
6289 assert!(
6290 !out.contains("flow was modified") || out.starts_with("It also the feature"),
6291 "must not chop 'Login' off the subject; got: {out}"
6292 );
6293 }
6294
6295 #[test]
6296 fn prepend_replacing_subject_unknown_name_falls_back() {
6297 let mut out = String::from("The class Foo was modified");
6299 prepend_replacing_subject_in_place(&mut out, "It also", None);
6300 assert_eq!(out, "It also the class Foo was modified");
6301 }
6302
6303 #[test]
6304 fn render_sequence_with_multiword_name_produces_valid_prose() {
6305 let mut engine = test_engine();
6308 engine
6309 .register_template("renamed", "{name|refer} was renamed")
6310 .unwrap();
6311 engine
6312 .register_template("modified", "{name|refer} was modified")
6313 .unwrap();
6314
6315 let mut session = test_session();
6316 let mut ctx = Context::new();
6317 ctx.insert("entity_type", Value::String("feature".into()));
6318 ctx.insert("name", Value::String("Login flow".into()));
6319
6320 let r1 = engine.render(&mut session, "renamed", &ctx).unwrap();
6321 assert!(r1.contains("Login flow"), "got: {r1}");
6322 let r2 = engine.render(&mut session, "modified", &ctx).unwrap();
6323 assert!(
6325 !r2.starts_with("flow ") && !r2.contains("also flow "),
6326 "follow-up render corrupted multi-word name; got: {r2}"
6327 );
6328 }
6329
6330 #[test]
6331 fn reduce_accepts_mixed_discourse_connectives() {
6332 let reduced = reduce_same_entity_clauses(&[
6335 "The class Foo was renamed.".to_string(),
6336 "Additionally, it was modified.".to_string(),
6337 "It also was moved.".to_string(),
6338 ]);
6339 assert_eq!(
6340 reduced.as_deref(),
6341 Some("The class Foo was renamed, modified, and moved.")
6342 );
6343 }
6344
6345 #[test]
6348 fn reduce_accepts_full_np_repetition() {
6349 let reduced = reduce_same_entity_clauses(&[
6350 "The class Foo was renamed.".to_string(),
6351 "The class Foo was modified.".to_string(),
6352 ]);
6353 assert_eq!(
6354 reduced.as_deref(),
6355 Some("The class Foo was renamed and modified.")
6356 );
6357 }
6358
6359 #[test]
6360 fn reduce_accepts_full_np_repetition_three_clauses() {
6361 let reduced = reduce_same_entity_clauses(&[
6362 "The class Foo was renamed.".to_string(),
6363 "The class Foo was modified.".to_string(),
6364 "The class Foo was moved.".to_string(),
6365 ]);
6366 assert_eq!(
6367 reduced.as_deref(),
6368 Some("The class Foo was renamed, modified, and moved.")
6369 );
6370 }
6371
6372 #[test]
6373 fn reduce_mixed_np_and_pronoun_accepted() {
6374 let reduced = reduce_same_entity_clauses(&[
6376 "The class Foo was renamed.".to_string(),
6377 "It was modified.".to_string(),
6378 "The class Foo was moved.".to_string(),
6379 ]);
6380 assert_eq!(
6381 reduced.as_deref(),
6382 Some("The class Foo was renamed, modified, and moved.")
6383 );
6384 }
6385
6386 #[test]
6387 fn reduce_rejects_different_np_repetition() {
6388 let reduced = reduce_same_entity_clauses(&[
6391 "The class Foo was renamed.".to_string(),
6392 "The class Bar was modified.".to_string(),
6393 ]);
6394 assert_eq!(reduced, None);
6395 }
6396
6397 #[test]
6398 fn reduce_rejects_full_np_with_embedded_clause() {
6399 let reduced = reduce_same_entity_clauses(&[
6402 "The class Foo was renamed.".to_string(),
6403 "The class Foo was modified, which affects 6 consumers.".to_string(),
6404 ]);
6405 assert_eq!(reduced, None);
6406 }
6407
6408 #[test]
6411 fn reduce_gapping_two_events() {
6412 let ss = vec![
6413 "Foo was moved to core".to_string(),
6414 "Bar was moved to util".to_string(),
6415 ];
6416 let out = reduce_gapping(&ss).unwrap();
6417 assert_eq!(out, "Foo was moved to core, and Bar to util.");
6418 }
6419
6420 #[test]
6421 fn reduce_gapping_three_events() {
6422 let ss = vec![
6423 "Foo was moved to core".to_string(),
6424 "Bar was moved to util".to_string(),
6425 "Baz was moved to api".to_string(),
6426 ];
6427 let out = reduce_gapping(&ss).unwrap();
6428 assert_eq!(out, "Foo was moved to core, Bar to util, and Baz to api.");
6429 }
6430
6431 #[test]
6432 fn reduce_gapping_rejects_single() {
6433 let ss = vec!["Foo was moved to core".to_string()];
6434 assert!(reduce_gapping(&ss).is_none());
6435 }
6436
6437 #[test]
6438 fn reduce_gapping_rejects_short_anchor() {
6439 let ss = vec!["Foo was moved".to_string(), "Bar was modified".to_string()];
6441 assert!(reduce_gapping(&ss).is_none());
6442 }
6443
6444 #[test]
6445 fn reduce_gapping_rejects_embedded_clause() {
6446 let ss = vec![
6447 "Foo was moved, affecting 3 consumers, to core".to_string(),
6448 "Bar was moved to util".to_string(),
6449 ];
6450 assert!(reduce_gapping(&ss).is_none());
6451 }
6452
6453 #[test]
6454 fn reduce_gapping_rejects_identical_subjects() {
6455 let ss = vec![
6457 "Foo was moved to core".to_string(),
6458 "Foo was moved to core".to_string(),
6459 ];
6460 assert!(reduce_gapping(&ss).is_none());
6461 }
6462
6463 #[test]
6464 fn reduce_gapping_rejects_empty_suffix() {
6465 let ss = vec!["Foo was moved".to_string(), "Bar was moved".to_string()];
6467 assert!(reduce_gapping(&ss).is_none());
6468 }
6469
6470 #[test]
6473 fn render_batch_applies_gapping_when_objects_differ() {
6474 let mut engine = test_engine();
6475 engine
6476 .register_template("code.moved", "{name} was moved to {new_location}")
6477 .unwrap();
6478
6479 let make = |name: &str, loc: &str| {
6480 let mut c = Context::new();
6481 c.insert("entity_type", Value::String("class".into()));
6482 c.insert("name", Value::String(name.into()));
6483 c.insert("new_location", Value::String(loc.into()));
6484 c
6485 };
6486
6487 let events = vec![
6488 ("code.moved", make("Foo", "core")),
6489 ("code.moved", make("Bar", "util")),
6490 ("code.moved", make("Baz", "api")),
6491 ];
6492
6493 let mut s = Session::new();
6494 let out = engine.render_batch(&mut s, &events).unwrap();
6495 assert_eq!(out, "Foo was moved to core, Bar to util, and Baz to api.");
6496 }
6497
6498 #[test]
6499 fn render_batch_gapping_does_not_apply_when_objects_match() {
6500 let mut engine = test_engine();
6503 engine
6504 .register_template("code.moved", "{name} was moved to {new_location}")
6505 .unwrap();
6506
6507 let make = |name: &str| {
6508 let mut c = Context::new();
6509 c.insert("entity_type", Value::String("class".into()));
6510 c.insert("name", Value::String(name.into()));
6511 c.insert("new_location", Value::String("core".into()));
6512 c
6513 };
6514
6515 let events = vec![("code.moved", make("Foo")), ("code.moved", make("Bar"))];
6516
6517 let mut s = Session::new();
6518 let out = engine.render_batch(&mut s, &events).unwrap();
6519 assert!(
6521 out.contains("Foo and Bar") && out.contains("core"),
6522 "got: {out}"
6523 );
6524 assert!(!out.contains(", and Bar to "), "got: {out}");
6526 }
6527
6528 #[test]
6529 fn render_iter_applies_gapping() {
6530 let mut engine = test_engine();
6531 engine
6532 .register_template("code.moved", "{name} was moved to {new_location}")
6533 .unwrap();
6534
6535 let make = |name: &str, loc: &str| {
6536 let mut c = Context::new();
6537 c.insert("entity_type", Value::String("class".into()));
6538 c.insert("name", Value::String(name.into()));
6539 c.insert("new_location", Value::String(loc.into()));
6540 c
6541 };
6542
6543 let events = vec![
6544 ("code.moved", make("Foo", "core")),
6545 ("code.moved", make("Bar", "util")),
6546 ("code.moved", make("Baz", "api")),
6547 ];
6548
6549 let mut s = Session::new();
6550 let collected: Result<Vec<_>, _> = engine.render_iter(&mut s, &events).collect();
6551 let collected = collected.unwrap();
6552 assert_eq!(collected.len(), 1);
6554 assert_eq!(
6555 collected[0],
6556 "Foo was moved to core, Bar to util, and Baz to api."
6557 );
6558 }
6559
6560 #[test]
6563 fn silent_strips_trailing_dangling_preposition() {
6564 let mut engine = test_engine().strictness(Strictness::Silent);
6565 engine
6566 .register_template("t", "The file was modified by {author}")
6567 .unwrap();
6568 let mut session = test_session();
6569 let ctx = Context::new();
6570 assert_eq!(
6571 engine.render(&mut session, "t", &ctx).unwrap(),
6572 "The file was modified."
6573 );
6574 }
6575
6576 #[test]
6577 fn silent_strips_dangling_preposition_with_punct() {
6578 let mut engine = test_engine().strictness(Strictness::Silent);
6579 engine
6580 .register_template("t", "The class was renamed to {new_name}.")
6581 .unwrap();
6582 let mut session = test_session();
6583 let ctx = Context::new();
6584 assert_eq!(
6585 engine.render(&mut session, "t", &ctx).unwrap(),
6586 "The class was renamed."
6587 );
6588 }
6589
6590 #[test]
6591 fn silent_strips_orphan_conjunction() {
6592 let mut engine = test_engine().strictness(Strictness::Silent);
6593 engine
6594 .register_template("t", "The module exports {a} and {b}")
6595 .unwrap();
6596 let mut session = test_session();
6597 let ctx = Context::new();
6598 assert_eq!(
6599 engine.render(&mut session, "t", &ctx).unwrap(),
6600 "The module exports."
6601 );
6602 }
6603
6604 #[test]
6605 fn silent_strips_chained_orphans() {
6606 let mut engine = test_engine().strictness(Strictness::Silent);
6607 engine
6608 .register_template("t", "The job was scheduled by {a} at {b}")
6609 .unwrap();
6610 let mut session = test_session();
6611 let ctx = Context::new();
6612 assert_eq!(
6614 engine.render(&mut session, "t", &ctx).unwrap(),
6615 "The job was scheduled."
6616 );
6617 }
6618
6619 #[test]
6620 fn silent_preserves_content_when_orphans_would_empty_output() {
6621 let mut engine = test_engine().strictness(Strictness::Silent);
6622 engine.register_template("t", "by {author}").unwrap();
6624 let mut session = test_session();
6625 let ctx = Context::new();
6626 assert_eq!(engine.render(&mut session, "t", &ctx).unwrap(), "by");
6628 }
6629
6630 #[test]
6631 fn whitespace_collapsing_runs_regardless_of_strictness() {
6632 let mut engine = test_engine();
6635 engine
6636 .register_template("t", "The quick brown fox")
6637 .unwrap();
6638 let mut session = test_session();
6639 let ctx = Context::new();
6640 assert_eq!(
6641 engine.render(&mut session, "t", &ctx).unwrap(),
6642 "The quick brown fox."
6643 );
6644 }
6645
6646 #[test]
6647 fn strict_mode_unaffected_by_cleanup_tail_stripping() {
6648 let mut engine = test_engine();
6651 engine
6652 .register_template("t", "modified by {author}")
6653 .unwrap();
6654 let mut session = test_session();
6655 let ctx = Context::new();
6656 assert!(engine.render(&mut session, "t", &ctx).is_err());
6657 }
6658
6659 #[test]
6662 fn reg_with_no_registry_behaves_as_before() {
6663 let mut engine = test_engine();
6664 engine
6665 .register_template("t", "{name|refer} was modified")
6666 .unwrap();
6667
6668 let mut session = test_session();
6669 let mut ctx = Context::new();
6670 ctx.insert("entity_type", Value::String("class".into()));
6671 ctx.insert("name", Value::String("UserService".into()));
6672
6673 let result = engine.render(&mut session, "t", &ctx).unwrap();
6674 assert_eq!(result, "The class UserService was modified.");
6675 }
6676
6677 #[test]
6678 fn reg_adds_distinguisher_when_same_type_registered() {
6679 let mut engine = test_engine();
6680 engine.register_entity(
6681 crate::EntityDescriptor::new("UserService", "class").with_attribute("layer", "domain"),
6682 );
6683 engine.register_entity(
6684 crate::EntityDescriptor::new("AuthService", "class").with_attribute("layer", "infra"),
6685 );
6686 engine
6687 .register_template("t", "{name|refer} was modified")
6688 .unwrap();
6689
6690 let mut session = test_session();
6691 let mut ctx = Context::new();
6692 ctx.insert("entity_type", Value::String("class".into()));
6693 ctx.insert("name", Value::String("UserService".into()));
6694
6695 let result = engine.render(&mut session, "t", &ctx).unwrap();
6696 assert_eq!(result, "The domain class UserService was modified.");
6697 }
6698
6699 #[test]
6700 fn reg_no_distinguisher_needed_when_types_differ() {
6701 let mut engine = test_engine();
6702 engine.register_entity(
6703 crate::EntityDescriptor::new("UserService", "class").with_attribute("layer", "domain"),
6704 );
6705 engine.register_entity(
6706 crate::EntityDescriptor::new("UserModule", "module").with_attribute("layer", "infra"),
6707 );
6708 engine
6709 .register_template("t", "{name|refer} was modified")
6710 .unwrap();
6711
6712 let mut session = test_session();
6713 let mut ctx = Context::new();
6714 ctx.insert("entity_type", Value::String("class".into()));
6715 ctx.insert("name", Value::String("UserService".into()));
6716
6717 let result = engine.render(&mut session, "t", &ctx).unwrap();
6718 assert_eq!(result, "The class UserService was modified.");
6720 }
6721
6722 #[test]
6723 fn reg_preference_order_steers_attribute_choice() {
6724 let mut engine = test_engine().attribute_preference(vec!["size".to_string()]);
6725 engine.register_entity(
6726 crate::EntityDescriptor::new("Foo", "widget")
6727 .with_attribute("color", "red")
6728 .with_attribute("size", "small"),
6729 );
6730 engine.register_entity(
6731 crate::EntityDescriptor::new("Bar", "widget")
6732 .with_attribute("color", "blue")
6733 .with_attribute("size", "large"),
6734 );
6735 engine
6736 .register_template("t", "{name|refer} appeared")
6737 .unwrap();
6738
6739 let mut session = test_session();
6740 let mut ctx = Context::new();
6741 ctx.insert("entity_type", Value::String("widget".into()));
6742 ctx.insert("name", Value::String("Foo".into()));
6743
6744 let result = engine.render(&mut session, "t", &ctx).unwrap();
6746 assert_eq!(result, "The small widget Foo appeared.");
6747 }
6748
6749 #[test]
6753 fn reg_same_name_different_type_does_not_cross_contaminate() {
6754 let mut engine = test_engine();
6755 engine.register_entity(
6756 crate::EntityDescriptor::new("UserService", "class").with_attribute("layer", "domain"),
6757 );
6758 engine.register_entity(
6759 crate::EntityDescriptor::new("UserService", "trait").with_attribute("scope", "public"),
6760 );
6761
6762 engine
6763 .register_template("t", "{name|refer} was modified")
6764 .unwrap();
6765
6766 let mut session = test_session();
6767 let mut ctx_class = Context::new();
6768 ctx_class.insert("entity_type", Value::String("class".into()));
6769 ctx_class.insert("name", Value::String("UserService".into()));
6770
6771 let r = engine.render(&mut session, "t", &ctx_class).unwrap();
6773 assert!(r.contains("class UserService"), "got: {r}");
6774 assert!(!r.contains("trait"), "got: {r}");
6775 assert_eq!(r, "The class UserService was modified.");
6778
6779 let mut session2 = test_session();
6780 let mut ctx_trait = Context::new();
6781 ctx_trait.insert("entity_type", Value::String("trait".into()));
6782 ctx_trait.insert("name", Value::String("UserService".into()));
6783 let r2 = engine.render(&mut session2, "t", &ctx_trait).unwrap();
6784 assert_eq!(r2, "The trait UserService was modified.");
6785 }
6786
6787 #[test]
6788 fn reg_multiple_attributes_needed() {
6789 let mut engine = test_engine();
6790 engine.register_entity(
6791 crate::EntityDescriptor::new("A", "widget")
6792 .with_attribute("color", "red")
6793 .with_attribute("size", "small"),
6794 );
6795 engine.register_entity(
6796 crate::EntityDescriptor::new("B", "widget")
6797 .with_attribute("color", "red")
6798 .with_attribute("size", "large"),
6799 );
6800 engine.register_entity(
6801 crate::EntityDescriptor::new("C", "widget")
6802 .with_attribute("color", "blue")
6803 .with_attribute("size", "small"),
6804 );
6805 engine = engine.attribute_preference(vec!["color".to_string(), "size".to_string()]);
6806 engine
6807 .register_template("t", "{name|refer} appeared")
6808 .unwrap();
6809
6810 let mut session = test_session();
6811 let mut ctx = Context::new();
6812 ctx.insert("entity_type", Value::String("widget".into()));
6813 ctx.insert("name", Value::String("A".into()));
6814
6815 let result = engine.render(&mut session, "t", &ctx).unwrap();
6817 assert_eq!(result, "The red small widget A appeared.");
6818 }
6819
6820 #[test]
6821 fn refer_no_entity_type_falls_back_to_name() {
6822 let mut engine = test_engine();
6823 engine
6824 .register_template("t", "{name|refer} appeared")
6825 .unwrap();
6826
6827 let mut session = test_session();
6828 let mut ctx = Context::new();
6829 ctx.insert("name", Value::String("something".into()));
6831
6832 let result = engine.render(&mut session, "t", &ctx).unwrap();
6833 assert_eq!(result, "Something appeared");
6836 }
6837
6838 #[test]
6842 fn failed_render_does_not_mutate_discourse() {
6843 let mut engine = test_engine();
6844 engine
6845 .register_template("ok", "{name|refer} was updated")
6846 .unwrap();
6847 engine
6848 .register_template("bad", "{missing_slot} fails here")
6849 .unwrap();
6850
6851 let mut session = test_session();
6852 let mut ctx = Context::new();
6853 ctx.insert("entity_type", Value::String("class".into()));
6854 ctx.insert("name", Value::String("Foo".into()));
6855
6856 let r1 = engine.render(&mut session, "ok", &ctx).unwrap();
6858 assert!(r1.contains("class Foo"), "r1 = {r1}");
6859
6860 let bad_ctx = Context::new();
6862 assert!(engine.render(&mut session, "bad", &bad_ctx).is_err());
6863
6864 let r2 = engine.render(&mut session, "ok", &ctx).unwrap();
6868 assert!(
6869 r2.contains("it") || r2.contains("It"),
6870 "Expected pronoun reference after failed render was rolled back, got: {r2}"
6871 );
6872 }
6873
6874 #[test]
6878 fn round_robin_counter_is_transactional_on_failure() {
6879 let mut engine = test_engine().variation(Variation::RoundRobin);
6880 engine.register_template("ok", "alpha {name}").unwrap();
6881 engine.register_template("ok", "beta {name}").unwrap();
6882 engine.register_template("ok", "gamma {name}").unwrap();
6883
6884 let mut session = test_session();
6885 let mut ctx = Context::new();
6886 ctx.insert("name", Value::String("x".into()));
6887 let empty = Context::new();
6888
6889 assert!(
6891 engine
6892 .render(&mut session, "ok", &ctx)
6893 .unwrap()
6894 .contains("alpha")
6895 );
6896
6897 assert!(engine.render(&mut session, "ok", &empty).is_err());
6900
6901 assert!(
6903 engine
6904 .render(&mut session, "ok", &ctx)
6905 .unwrap()
6906 .contains("beta")
6907 );
6908 }
6909
6910 #[test]
6912 fn round_robin_actually_rotates() {
6913 let mut engine = test_engine().variation(Variation::RoundRobin);
6914 engine.register_template("t", "alpha").unwrap();
6915 engine.register_template("t", "beta").unwrap();
6916 engine.register_template("t", "gamma").unwrap();
6917
6918 let mut session = test_session();
6919 let ctx = Context::new();
6920 let r1 = engine.render(&mut session, "t", &ctx).unwrap();
6921 let r2 = engine.render(&mut session, "t", &ctx).unwrap();
6922 let r3 = engine.render(&mut session, "t", &ctx).unwrap();
6923 let r4 = engine.render(&mut session, "t", &ctx).unwrap();
6924
6925 assert!(r1.starts_with("alpha"), "r1 = {r1}");
6927 assert!(r2.contains("beta"), "r2 = {r2}");
6929 assert!(r3.contains("gamma"), "r3 = {r3}");
6930 assert!(r4.contains("alpha"), "r4 = {r4}");
6932 }
6933
6934 #[test]
6937 fn fixed_variation_stays_fixed_across_renders() {
6938 let mut engine = test_engine().variation(Variation::Fixed);
6939 engine.register_template("t", "alpha body here").unwrap();
6940 engine.register_template("t", "beta body here").unwrap();
6941
6942 let mut session = test_session();
6943 let ctx = Context::new();
6944 for _ in 0..5 {
6945 let rendered = engine.render(&mut session, "t", &ctx).unwrap();
6946 assert!(
6947 rendered.contains("alpha body here"),
6948 "Fixed should always pick the first-registered template, got: {rendered}"
6949 );
6950 assert!(
6951 !rendered.contains("beta"),
6952 "Fixed must never emit a later-registered alternative, got: {rendered}"
6953 );
6954 }
6955 }
6956
6957 #[test]
6960 fn verb_pipe_simple_past_passive() {
6961 let mut engine = test_engine();
6962 engine.register_template("t", "{action|verb:past}").unwrap();
6963
6964 let mut session = test_session();
6965 let mut ctx = Context::new();
6966 ctx.insert("action", Value::String("rename".into()));
6967 assert_eq!(
6968 engine.render(&mut session, "t", &ctx).unwrap(),
6969 "was renameed"
6970 );
6971 }
6972
6973 #[test]
6974 fn verb_pipe_present_perfect_passive() {
6975 let mut engine = test_engine();
6976 engine
6977 .register_template("t", "{action|verb:present_perfect}")
6978 .unwrap();
6979
6980 let mut session = test_session();
6981 let mut ctx = Context::new();
6982 ctx.insert("action", Value::String("rename".into()));
6983 assert_eq!(
6984 engine.render(&mut session, "t", &ctx).unwrap(),
6985 "has been renameed"
6986 );
6987 }
6988
6989 #[test]
6990 fn verb_pipe_present_progressive_passive() {
6991 let mut engine = test_engine();
6992 engine
6993 .register_template("t", "{action|verb:present_progressive}")
6994 .unwrap();
6995
6996 let mut session = test_session();
6997 let mut ctx = Context::new();
6998 ctx.insert("action", Value::String("rename".into()));
6999 assert_eq!(
7000 engine.render(&mut session, "t", &ctx).unwrap(),
7001 "is being renameed"
7002 );
7003 }
7004
7005 #[test]
7006 fn verb_pipe_active_voice_prefix() {
7007 let mut engine = test_engine();
7008 engine
7009 .register_template("t", "{action|verb:active_present_perfect}")
7010 .unwrap();
7011
7012 let mut session = test_session();
7013 let mut ctx = Context::new();
7014 ctx.insert("action", Value::String("rename".into()));
7015 assert_eq!(
7016 engine.render(&mut session, "t", &ctx).unwrap(),
7017 "has renameed"
7018 );
7019 }
7020
7021 #[test]
7022 fn verb_pipe_conditional() {
7023 let mut engine = test_engine();
7024 engine
7025 .register_template("t", "{action|verb:conditional}")
7026 .unwrap();
7027
7028 let mut session = test_session();
7029 let mut ctx = Context::new();
7030 ctx.insert("action", Value::String("rename".into()));
7031 assert_eq!(
7032 engine.render(&mut session, "t", &ctx).unwrap(),
7033 "would be renameed"
7034 );
7035 }
7036
7037 #[test]
7038 fn verb_pipe_unknown_spec_is_error() {
7039 let mut engine = test_engine();
7040 engine
7041 .register_template("t", "{action|verb:bogus_form}")
7042 .unwrap();
7043
7044 let mut session = test_session();
7045 let mut ctx = Context::new();
7046 ctx.insert("action", Value::String("rename".into()));
7047 let result = engine.render(&mut session, "t", &ctx);
7048 assert!(matches!(result, Err(ProsaicError::InvalidPipe { .. })));
7049 }
7050
7051 #[test]
7052 fn verb_pipe_missing_spec_is_error() {
7053 let mut engine = test_engine();
7054 engine.register_template("t", "{action|verb}").unwrap();
7055
7056 let mut session = test_session();
7057 let mut ctx = Context::new();
7058 ctx.insert("action", Value::String("rename".into()));
7059 let result = engine.render(&mut session, "t", &ctx);
7060 assert!(matches!(result, Err(ProsaicError::InvalidPipe { .. })));
7061 }
7062
7063 #[test]
7066 fn candidate_scoring_does_not_advance_list_style() {
7067 let mut engine = test_engine().variation(Variation::Seeded(1));
7069 engine
7072 .register_template("t", "alpha uses {items|truncate:1|join}")
7073 .unwrap();
7074 engine
7075 .register_template("t", "beta uses {items|truncate:1|join}")
7076 .unwrap();
7077
7078 let mut session = test_session();
7079 let mut ctx = Context::new();
7080 ctx.insert(
7081 "items",
7082 Value::List(vec!["a".into(), "b".into(), "c".into()]),
7083 );
7084
7085 let r1 = engine.render(&mut session, "t", &ctx).unwrap();
7086 let r2 = engine.render(&mut session, "t", &ctx).unwrap();
7087 let r3 = engine.render(&mut session, "t", &ctx).unwrap();
7088
7089 let styles: std::collections::HashSet<&str> = [r1.as_str(), r2.as_str(), r3.as_str()]
7094 .into_iter()
7095 .collect();
7096 assert_eq!(
7097 styles.len(),
7098 3,
7099 "Expected three distinct list styles across three renders, got: {r1} / {r2} / {r3}"
7100 );
7101 }
7102
7103 #[test]
7106 fn choose_pipe_exact_match() {
7107 let engine = test_engine();
7108 let mut session = test_session();
7109 let mut ctx = Context::new();
7110 ctx.insert("level", Value::String("critical".into()));
7111 let out = engine
7112 .render_inline(
7113 &mut session,
7114 "{level|choose: critical=URGENT, warn=WARN, default=INFO}",
7115 &ctx,
7116 )
7117 .unwrap();
7118 assert_eq!(out, "URGENT");
7119 }
7120
7121 #[test]
7122 fn choose_pipe_case_insensitive_match() {
7123 let engine = test_engine();
7124 let mut session = test_session();
7125 let mut ctx = Context::new();
7126 ctx.insert("level", Value::String("CRITICAL".into()));
7127 let out = engine
7128 .render_inline(
7129 &mut session,
7130 "{level|choose: critical=URGENT, default=INFO}",
7131 &ctx,
7132 )
7133 .unwrap();
7134 assert_eq!(out, "URGENT");
7135 }
7136
7137 #[test]
7138 fn choose_pipe_default_fallback() {
7139 let engine = test_engine();
7140 let mut session = test_session();
7141 let mut ctx = Context::new();
7142 ctx.insert("level", Value::String("info".into()));
7143 let out = engine
7144 .render_inline(
7145 &mut session,
7146 "{level|choose: critical=URGENT, default=INFO}",
7147 &ctx,
7148 )
7149 .unwrap();
7150 assert_eq!(out, "INFO");
7151 }
7152
7153 #[test]
7154 fn choose_pipe_number_slot() {
7155 let engine = test_engine();
7156 let mut session = test_session();
7157 let mut ctx = Context::new();
7158 ctx.insert("count", Value::Number(1));
7159 let out = engine
7160 .render_inline(&mut session, "{count|choose: 1=is, default=are}", &ctx)
7161 .unwrap();
7162 assert_eq!(out, "is");
7163
7164 let mut session2 = test_session();
7165 let mut ctx2 = Context::new();
7166 ctx2.insert("count", Value::Number(5));
7167 let out2 = engine
7168 .render_inline(&mut session2, "{count|choose: 1=is, default=are}", &ctx2)
7169 .unwrap();
7170 assert_eq!(out2, "are");
7171 }
7172
7173 #[test]
7174 fn choose_pipe_chains_with_other_pipes() {
7175 let engine = test_engine();
7176 let mut session = test_session();
7177 let mut ctx = Context::new();
7178 ctx.insert("action", Value::String("modify".into()));
7179 let out = engine
7180 .render_inline(
7181 &mut session,
7182 "{action|choose: rename=renamed, modify=modified, default=changed|capitalize}",
7183 &ctx,
7184 )
7185 .unwrap();
7186 assert!(out.contains("Modified"), "got: {out}");
7187 }
7188
7189 #[test]
7190 fn choose_pipe_strict_no_match_no_default_errors() {
7191 let engine = test_engine().strictness(Strictness::Strict);
7192 let mut session = test_session();
7193 let mut ctx = Context::new();
7194 ctx.insert("level", Value::String("info".into()));
7195 let err = engine
7196 .render_inline(&mut session, "{level|choose: critical=URGENT}", &ctx)
7197 .unwrap_err();
7198 assert!(matches!(err, ProsaicError::InvalidPipe { .. }));
7199 }
7200
7201 #[test]
7202 fn choose_pipe_lenient_no_match_returns_placeholder() {
7203 let engine = test_engine().strictness(Strictness::Lenient);
7204 let mut session = test_session();
7205 let mut ctx = Context::new();
7206 ctx.insert("level", Value::String("info".into()));
7207 let out = engine
7208 .render_inline(&mut session, "{level|choose: critical=URGENT}", &ctx)
7209 .unwrap();
7210 assert!(out.contains("[choose: no match for info]"), "got: {out}");
7211 }
7212
7213 #[test]
7214 fn choose_pipe_silent_no_match_returns_empty() {
7215 let engine = test_engine().strictness(Strictness::Silent);
7216 let mut session = test_session();
7217 let mut ctx = Context::new();
7218 ctx.insert("level", Value::String("info".into()));
7219 let out = engine
7220 .render_inline(&mut session, "{level|choose: critical=URGENT}", &ctx)
7221 .unwrap();
7222 assert_eq!(out, "");
7223 }
7224
7225 #[test]
7226 fn choose_pipe_missing_arg_errors() {
7227 let engine = test_engine().strictness(Strictness::Strict);
7228 let mut session = test_session();
7229 let mut ctx = Context::new();
7230 ctx.insert("level", Value::String("info".into()));
7231 let err = engine
7232 .render_inline(&mut session, "{level|choose}", &ctx)
7233 .unwrap_err();
7234 assert!(matches!(err, ProsaicError::InvalidPipe { .. }));
7235 }
7236
7237 #[test]
7238 fn choose_pipe_malformed_arg_errors() {
7239 let engine = test_engine().strictness(Strictness::Strict);
7240 let mut session = test_session();
7241 let mut ctx = Context::new();
7242 ctx.insert("level", Value::String("info".into()));
7243 let err = engine
7244 .render_inline(&mut session, "{level|choose: no_equals_here}", &ctx)
7245 .unwrap_err();
7246 assert!(matches!(err, ProsaicError::InvalidPipe { .. }));
7247 }
7248
7249 #[test]
7252 fn plural_pipe_singular_for_one() {
7253 let engine = test_engine();
7254 let mut session = test_session();
7255 let mut ctx = Context::new();
7256 ctx.insert("count", Value::Number(1));
7257 let out = engine
7258 .render_inline(&mut session, "{count|plural:service}", &ctx)
7259 .unwrap();
7260 assert!(out.contains("service"));
7261 assert!(!out.contains("services"));
7262 }
7263
7264 #[test]
7265 fn plural_pipe_plural_for_many() {
7266 let engine = test_engine();
7267 let mut session = test_session();
7268 let mut ctx = Context::new();
7269 ctx.insert("count", Value::Number(5));
7270 let out = engine
7271 .render_inline(&mut session, "{count|plural:service}", &ctx)
7272 .unwrap();
7273 assert!(out.contains("services"));
7274 }
7275
7276 #[test]
7277 fn plural_pipe_plural_for_zero() {
7278 let engine = test_engine();
7280 let mut session = test_session();
7281 let mut ctx = Context::new();
7282 ctx.insert("count", Value::Number(0));
7283 let out = engine
7284 .render_inline(&mut session, "{count|plural:service}", &ctx)
7285 .unwrap();
7286 assert!(out.contains("services"));
7287 }
7288
7289 #[test]
7290 fn plural_pipe_requires_noun_arg() {
7291 let engine = test_engine().strictness(Strictness::Strict);
7292 let mut session = test_session();
7293 let mut ctx = Context::new();
7294 ctx.insert("count", Value::Number(3));
7295 let err = engine
7296 .render_inline(&mut session, "{count|plural}", &ctx)
7297 .unwrap_err();
7298 assert!(matches!(err, ProsaicError::InvalidPipe { .. }));
7299 }
7300
7301 #[test]
7302 fn plural_pipe_requires_numeric_value() {
7303 let engine = test_engine().strictness(Strictness::Strict);
7304 let mut session = test_session();
7305 let mut ctx = Context::new();
7306 ctx.insert("word", Value::String("hello".into()));
7307 let err = engine
7308 .render_inline(&mut session, "{word|plural:service}", &ctx)
7309 .unwrap_err();
7310 assert!(matches!(err, ProsaicError::InvalidPipe { .. }));
7311 }
7312
7313 #[test]
7314 fn pronoun_realization_routes_through_language_trait() {
7315 let mut engine = test_engine();
7319 engine
7320 .register_template("t", "{name|refer} was modified")
7321 .unwrap();
7322 let mut session = test_session();
7323 let mut ctx = Context::new();
7324 ctx.insert("entity_type", Value::String("class".into()));
7325 ctx.insert("name", Value::String("Foo".into()));
7326
7327 let r1 = engine.render(&mut session, "t", &ctx).unwrap();
7329 assert!(r1.contains("The class Foo"), "got: {r1}");
7330
7331 let r2 = engine.render(&mut session, "t", &ctx).unwrap();
7333 assert!(
7334 r2.to_lowercase().contains("it was") || r2.to_lowercase().contains("it "),
7335 "got: {r2}"
7336 );
7337 }
7338
7339 #[test]
7340 fn plural_pipe_and_pluralize_pipe_coexist() {
7341 let engine = test_engine();
7343 let mut session = test_session();
7344 let mut ctx = Context::new();
7345 ctx.insert("count", Value::Number(2));
7346 let plural_out = engine
7347 .render_inline(&mut session, "{count|plural:item}", &ctx)
7348 .unwrap();
7349 session.reset();
7350 let pluralize_out = engine
7351 .render_inline(&mut session, "{count|pluralize:item}", &ctx)
7352 .unwrap();
7353 assert_eq!(plural_out, pluralize_out);
7355 }
7356}
7357
7358#[cfg(test)]
7359mod render_batch_with_relations_tests {
7360 use super::*;
7361 use crate::language::{Conjunction, Language, Person, Tense};
7362 use crate::rst::RstRelation;
7363
7364 struct SimpleLang;
7365
7366 impl Language for SimpleLang {
7367 fn pluralize(&self, word: &str, count: usize) -> String {
7368 if count == 1 {
7369 word.to_string()
7370 } else {
7371 format!("{word}s")
7372 }
7373 }
7374 fn singularize(&self, word: &str) -> String {
7375 word.strip_suffix('s').unwrap_or(word).to_string()
7376 }
7377 fn article(&self, _word: &str) -> &str {
7378 "the"
7379 }
7380 fn conjugate(&self, verb: &str, _t: Tense, _p: Person) -> String {
7381 verb.to_string()
7382 }
7383 fn past_participle(&self, verb: &str) -> String {
7384 format!("{verb}ed")
7385 }
7386 fn present_participle(&self, verb: &str) -> String {
7387 format!("{verb}ing")
7388 }
7389 fn join_list(&self, items: &[&str], _c: Conjunction) -> String {
7390 items.join(", ")
7391 }
7392 fn ordinal(&self, n: usize) -> String {
7393 format!("{n}th")
7394 }
7395 fn number_to_words(&self, n: usize) -> String {
7396 n.to_string()
7397 }
7398 }
7399
7400 fn make_engine() -> Engine {
7401 Engine::new(SimpleLang)
7402 .strictness(Strictness::Strict)
7403 .variation(Variation::Fixed)
7404 }
7405
7406 fn ctx_with_name(name: &str) -> Context {
7407 let mut c = Context::new();
7408 c.insert("name", Value::String(name.into()));
7409 c
7410 }
7411
7412 #[test]
7413 fn render_batch_with_relations_inserts_marker() {
7414 let mut engine = make_engine();
7415 engine
7416 .register_template("t", "The class {name} was modified")
7417 .unwrap();
7418 let mut s = Session::new();
7419 let ctx = ctx_with_name("Foo");
7420 let events = vec![
7421 ("t", ctx.clone(), None),
7422 ("t", ctx, Some(RstRelation::Elaboration)),
7423 ];
7424 let out = engine.render_batch_with_relations(&mut s, &events).unwrap();
7425 assert!(out.contains("Furthermore, "), "got: {out}");
7426 }
7427
7428 #[test]
7429 fn render_batch_with_relations_lowercases_determiner_after_marker() {
7430 let mut engine = make_engine();
7431 engine
7432 .register_template("t", "The class {name} was modified")
7433 .unwrap();
7434 let mut s = Session::new();
7435 let ctx = ctx_with_name("Foo");
7436 let events = vec![
7437 ("t", ctx.clone(), None),
7438 ("t", ctx, Some(RstRelation::Contrast)),
7439 ];
7440 let out = engine.render_batch_with_relations(&mut s, &events).unwrap();
7441 assert!(out.contains("However, the class"), "got: {out}");
7443 }
7444
7445 #[test]
7446 fn render_batch_with_all_none_delegates_to_render_batch() {
7447 let mut engine = make_engine();
7448 engine
7449 .register_template("t", "{name} was modified")
7450 .unwrap();
7451 let mut s = Session::new();
7452 let ctx = ctx_with_name("Foo");
7453 let triples = vec![("t", ctx.clone(), None), ("t", ctx.clone(), None)];
7454 let pairs: Vec<_> = triples.iter().map(|(k, c, _)| (*k, c.clone())).collect();
7455 let mut s2 = Session::new();
7456 let from_triples = engine
7457 .render_batch_with_relations(&mut s, &triples)
7458 .unwrap();
7459 let from_pairs = engine.render_batch(&mut s2, &pairs).unwrap();
7460 assert_eq!(from_triples, from_pairs);
7461 }
7462
7463 #[test]
7464 fn render_batch_with_relations_empty_is_empty_string() {
7465 let engine = make_engine();
7466 let mut s = Session::new();
7467 let events: Vec<(&str, Context, Option<RstRelation>)> = vec![];
7468 let out = engine.render_batch_with_relations(&mut s, &events).unwrap();
7469 assert_eq!(out, "");
7470 }
7471
7472 #[test]
7473 fn rst_marker_strips_auto_connective_to_avoid_double_prepend() {
7474 let mut engine = make_engine();
7479 engine
7480 .register_template("t", "The class {name} was modified")
7481 .unwrap();
7482 let mut s = Session::new();
7483 let events = vec![
7484 ("t", ctx_with_name("Foo"), None),
7485 ("t", ctx_with_name("Bar"), Some(RstRelation::Elaboration)),
7486 ];
7487 let out = engine.render_batch_with_relations(&mut s, &events).unwrap();
7488 assert!(out.contains("Furthermore, "), "got: {out}");
7489 assert!(
7490 !out.contains("Furthermore, Similarly,") && !out.contains("Furthermore, Likewise,"),
7491 "RST marker should suppress / strip auto-connective; got: {out}"
7492 );
7493 }
7494
7495 fn ctx_with_entity(name: &str) -> Context {
7496 let mut c = Context::new();
7500 c.insert("entity_type", Value::String("class".into()));
7501 c.insert("name", Value::String(name.into()));
7502 c
7503 }
7504
7505 #[test]
7506 fn rst_render_leaves_session_free_of_unemitted_connective() {
7507 let mut engine = make_engine();
7518 engine
7519 .register_template("t", "The class {name} was modified")
7520 .unwrap();
7521 let mut s = Session::new();
7522
7523 let events = vec![
7526 ("t", ctx_with_entity("Foo"), None),
7527 ("t", ctx_with_entity("Bar"), Some(RstRelation::Elaboration)),
7528 ];
7529 let _ = engine.render_batch_with_relations(&mut s, &events).unwrap();
7530
7531 let exp = engine
7537 .render_explained(&mut s, "t", ctx_with_entity("Baz"))
7538 .unwrap();
7539 assert_eq!(
7540 exp.connective,
7541 Some("Similarly,"),
7542 "RST render leaked connective history; got connective={:?}, output={}",
7543 exp.connective,
7544 exp.output
7545 );
7546 }
7547
7548 #[test]
7549 fn rst_render_does_not_record_unemitted_connective_words() {
7550 let mut engine = make_engine().variation(Variation::Seeded(42));
7555 engine
7556 .register_template("t", "The class {name} was modified")
7557 .unwrap();
7558 let mut s = Session::new();
7559
7560 let events = vec![
7561 ("t", ctx_with_entity("Foo"), None),
7562 ("t", ctx_with_entity("Bar"), Some(RstRelation::Elaboration)),
7563 ];
7564 let _ = engine.render_batch_with_relations(&mut s, &events).unwrap();
7565
7566 assert_eq!(
7569 s.discourse.word_frequency("similarly"),
7570 0.0,
7571 "RST render leaked 'similarly' into word history"
7572 );
7573 }
7574}
7575
7576#[cfg(test)]
7577mod engine_thread_safety {
7578 use super::Engine;
7579
7580 const _: fn() = || {
7583 fn assert_send_sync<T: Send + Sync>() {}
7584 assert_send_sync::<Engine>();
7585 };
7586}
7587
7588#[cfg(feature = "serde")]
7589fn apply_manifest_engine_settings(
7590 engine: &mut Engine,
7591 settings: &manifest_loader::ManifestEngineSettings,
7592) -> Result<(), ProsaicError> {
7593 engine.strictness = match settings.strictness.as_str() {
7594 "" | "strict" => Strictness::Strict,
7595 "lenient" => Strictness::Lenient,
7596 "silent" => Strictness::Silent,
7597 other => {
7598 return Err(ProsaicError::TemplateParseError {
7599 template: "(manifest)".to_string(),
7600 position: 0,
7601 reason: format!("unknown strictness `{other}`"),
7602 });
7603 }
7604 };
7605
7606 engine.variation = match settings.variation.as_str() {
7607 "" | "fixed" => Variation::Fixed,
7608 "round_robin" | "round-robin" => Variation::RoundRobin,
7609 "random" => Variation::Random,
7610 other => {
7611 return Err(ProsaicError::TemplateParseError {
7612 template: "(manifest)".to_string(),
7613 position: 0,
7614 reason: format!("unknown variation `{other}`"),
7615 });
7616 }
7617 };
7618
7619 #[cfg(feature = "polish")]
7620 {
7621 engine.smart_quotes = settings.smart_quotes;
7622 engine.max_sentence_length = if settings.max_sentence_length > 0 {
7623 Some(settings.max_sentence_length)
7624 } else {
7625 None
7626 };
7627 }
7628
7629 #[cfg(not(feature = "polish"))]
7630 if settings.smart_quotes || settings.max_sentence_length > 0 {
7631 return Err(ProsaicError::TemplateParseError {
7632 template: "(manifest)".to_string(),
7633 position: 0,
7634 reason: "manifest uses polish settings, but prosaic-core was built without the `polish` feature".to_string(),
7635 });
7636 }
7637
7638 if settings.faithfulness_min < 0.0 {
7639 return Err(ProsaicError::TemplateParseError {
7640 template: "(manifest)".to_string(),
7641 position: 0,
7642 reason: format!(
7643 "faithfulness_min must be non-negative, got {}",
7644 settings.faithfulness_min
7645 ),
7646 });
7647 }
7648 engine.faithfulness_threshold = if settings.faithfulness_min > 0.0 {
7649 Some(settings.faithfulness_min as f32)
7650 } else {
7651 None
7652 };
7653
7654 if let Some(thresholds) = &settings.salience_thresholds {
7655 engine.salience_thresholds = SalienceThresholds {
7656 low_max: thresholds.low_max,
7657 high_min: thresholds.high_min,
7658 };
7659 }
7660
7661 engine.style_preference = settings.style.clone();
7662
7663 Ok(())
7664}
7665
7666#[cfg(feature = "serde")]
7667mod manifest_loader {
7668 #[cfg(not(feature = "std"))]
7669 use alloc::string::{String, ToString};
7670 #[cfg(not(feature = "std"))]
7671 use alloc::vec::Vec;
7672 use serde::Deserialize;
7673
7674 #[derive(Deserialize)]
7675 pub struct ManifestBundle {
7676 pub schema_version: u32,
7677 #[allow(dead_code)]
7678 pub name: String,
7679 #[allow(dead_code)]
7680 pub version: String,
7681 pub language: String,
7682 #[allow(dead_code)]
7683 #[serde(default)]
7684 pub engine: ManifestEngineSettings,
7685 pub templates: Vec<ManifestTemplate>,
7686 pub partials: Vec<ManifestPartial>,
7687 }
7688
7689 #[allow(dead_code)]
7692 #[derive(Deserialize, Default)]
7693 pub struct ManifestEngineSettings {
7694 #[serde(default)]
7695 pub strictness: String,
7696 #[serde(default)]
7697 pub variation: String,
7698 #[serde(default)]
7699 pub smart_quotes: bool,
7700 #[serde(default)]
7701 pub max_sentence_length: usize,
7702 #[serde(default)]
7703 pub faithfulness_min: f64,
7704 #[serde(default)]
7705 pub salience_thresholds: Option<ManifestSalienceThresholds>,
7706 #[serde(default)]
7707 pub style: Option<String>,
7708 }
7709
7710 #[derive(Deserialize)]
7711 pub struct ManifestSalienceThresholds {
7712 pub low_max: i64,
7713 pub high_min: i64,
7714 }
7715
7716 #[derive(Deserialize)]
7717 pub struct ManifestTemplate {
7718 pub key: String,
7719 #[serde(default)]
7720 #[allow(dead_code)]
7721 pub description: String,
7722 pub variants: Vec<ManifestVariant>,
7723 }
7724
7725 #[derive(Deserialize)]
7726 pub struct ManifestVariant {
7727 #[serde(default = "default_salience")]
7728 pub salience: String,
7729 #[serde(default)]
7730 pub language: Option<String>,
7731 #[serde(default)]
7732 pub style: Option<String>,
7733 pub body: String,
7734 }
7735
7736 fn default_salience() -> String {
7737 "medium".to_string()
7738 }
7739
7740 #[derive(Deserialize)]
7741 pub struct ManifestPartial {
7742 pub name: String,
7743 pub body: String,
7744 }
7745}
7746
7747#[cfg(test)]
7748mod register_template_type_tests {
7749 use super::*;
7750 use crate::language::{Conjunction, Language, Person, Tense};
7751
7752 struct TestLang;
7755
7756 impl Language for TestLang {
7757 fn pluralize(&self, word: &str, count: usize) -> String {
7758 if count == 1 {
7759 word.to_string()
7760 } else {
7761 format!("{word}s")
7762 }
7763 }
7764 fn singularize(&self, word: &str) -> String {
7765 word.strip_suffix('s').unwrap_or(word).to_string()
7766 }
7767 fn article(&self, _word: &str) -> &str {
7768 "a"
7769 }
7770 fn conjugate(&self, verb: &str, _t: Tense, _p: Person) -> String {
7771 verb.to_string()
7772 }
7773 fn past_participle(&self, verb: &str) -> String {
7774 format!("{verb}ed")
7775 }
7776 fn present_participle(&self, verb: &str) -> String {
7777 format!("{verb}ing")
7778 }
7779 fn join_list(&self, items: &[&str], conj: Conjunction) -> String {
7780 let c = match conj {
7781 Conjunction::And => "and",
7782 Conjunction::Or => "or",
7783 };
7784 items.join(&format!(" {c} "))
7785 }
7786 fn ordinal(&self, n: usize) -> String {
7787 format!("{n}th")
7788 }
7789 fn number_to_words(&self, n: usize) -> String {
7790 format!("<{n}>")
7791 }
7792 }
7793
7794 #[test]
7795 fn register_template_rejects_chain_mismatch() {
7796 let mut engine = Engine::new(TestLang);
7797 let err = engine
7798 .register_template("bad", "{x|capitalize|pluralize}")
7799 .unwrap_err();
7800 match err {
7801 ProsaicError::TemplateParseError { reason, .. } => {
7802 assert!(
7803 reason.contains("chain mismatch"),
7804 "unexpected reason: {reason}"
7805 );
7806 }
7807 other => panic!("expected TemplateParseError, got {other:?}"),
7808 }
7809 }
7810
7811 #[test]
7812 fn register_template_rejects_multi_mention_conflict() {
7813 let mut engine = Engine::new(TestLang);
7814 let err = engine
7815 .register_template("bad", "{x|pluralize:item} and {x|join}")
7816 .unwrap_err();
7817 assert!(matches!(err, ProsaicError::TemplateParseError { .. }));
7818 }
7819
7820 #[test]
7821 fn register_template_accepts_valid_template() {
7822 let mut engine = Engine::new(TestLang);
7823 engine
7824 .register_template("good", "The {name} has {count|pluralize:item}")
7825 .unwrap();
7826 }
7827
7828 #[test]
7829 fn register_template_accepts_bare_slots() {
7830 let mut engine = Engine::new(TestLang);
7832 engine.register_template("bare", "Hello {name}").unwrap();
7833 }
7834}