1#[cfg(not(feature = "std"))]
2use alloc::string::{String, ToString};
3#[cfg(not(feature = "std"))]
4use alloc::vec::Vec;
5
6use crate::context::Context;
7use crate::engine::Engine;
8use crate::error::ProsaicError;
9use crate::rst::RstRelation;
10use crate::salience::Salience;
11use crate::session::Session;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
19#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
20pub enum RhetoricalCategory {
21 Removal,
23 Addition,
25 Modification,
28 Other,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
34#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
35pub enum GroupingStrategy {
36 #[default]
40 ByEntity,
41 ByAction,
47}
48
49pub fn default_classifier(key: &str) -> RhetoricalCategory {
63 let action = key.rsplit('.').next().unwrap_or("");
64 match action {
65 "deleted" | "removed" => RhetoricalCategory::Removal,
66 "added" | "created" | "introduced" => RhetoricalCategory::Addition,
67 "modified" | "updated" | "renamed" | "moved" | "signature_changed" => {
68 RhetoricalCategory::Modification
69 }
70 _ => RhetoricalCategory::Other,
71 }
72}
73
74fn category_order() -> [RhetoricalCategory; 4] {
77 [
78 RhetoricalCategory::Removal,
79 RhetoricalCategory::Addition,
80 RhetoricalCategory::Modification,
81 RhetoricalCategory::Other,
82 ]
83}
84
85#[derive(Debug, Clone)]
87pub struct Paragraph {
88 pub events: Vec<(String, Context)>,
90 pub relations: Vec<Option<RstRelation>>,
95 pub salience: Salience,
97 pub category: Option<RhetoricalCategory>,
100}
101
102impl Paragraph {
103 pub fn new() -> Self {
104 Self {
105 events: Vec::new(),
106 relations: Vec::new(),
107 salience: Salience::Low,
108 category: None,
109 }
110 }
111
112 pub fn push(&mut self, key: String, ctx: Context, salience: Salience) {
114 self.push_with_relation(key, ctx, salience, None);
115 }
116
117 pub fn push_with_relation(
123 &mut self,
124 key: String,
125 ctx: Context,
126 salience: Salience,
127 relation: Option<RstRelation>,
128 ) {
129 self.events.push((key, ctx));
130 self.relations.push(relation);
131 if salience > self.salience {
132 self.salience = salience;
133 }
134 }
135
136 pub fn is_empty(&self) -> bool {
137 self.events.is_empty()
138 }
139}
140
141impl Default for Paragraph {
142 fn default() -> Self {
143 Self::new()
144 }
145}
146
147#[derive(Debug, Clone)]
152pub struct DocumentPlan {
153 pub paragraphs: Vec<Paragraph>,
154}
155
156impl DocumentPlan {
157 pub fn new() -> Self {
158 Self {
159 paragraphs: Vec::new(),
160 }
161 }
162
163 pub fn from_events(events: &[(&str, Context)], engine: &Engine) -> Self {
178 Self::from_events_grouped(events, engine, GroupingStrategy::ByEntity)
179 }
180
181 pub fn from_events_grouped(
183 events: &[(&str, Context)],
184 engine: &Engine,
185 strategy: GroupingStrategy,
186 ) -> Self {
187 match strategy {
188 GroupingStrategy::ByEntity => Self::build_by_entity(events, engine),
189 GroupingStrategy::ByAction => {
190 Self::from_events_classified(events, engine, default_classifier)
191 }
192 }
193 }
194
195 pub fn from_events_classified<F>(
199 events: &[(&str, Context)],
200 engine: &Engine,
201 classifier: F,
202 ) -> Self
203 where
204 F: Fn(&str) -> RhetoricalCategory,
205 {
206 let mut plan = Self::new();
207 if events.is_empty() {
208 return plan;
209 }
210
211 use crate::collections::BTreeMap;
213 let mut buckets: BTreeMap<RhetoricalCategory, Vec<(String, Context)>> = BTreeMap::new();
214
215 for (key, ctx) in events {
216 let category = classifier(key);
217 buckets
218 .entry(category)
219 .or_default()
220 .push((key.to_string(), ctx.clone()));
221 }
222
223 for category in category_order() {
227 let bucket = match buckets.remove(&category) {
228 Some(b) if !b.is_empty() => b,
229 _ => continue,
230 };
231
232 let mut para = Paragraph::new();
233 para.category = Some(category);
234 let mut current_entity: Option<String> = None;
235
236 let mut sorted = bucket;
240 sorted.sort_by(|a, b| entity_key(&a.1).cmp(&entity_key(&b.1)));
241
242 for (key, ctx) in sorted {
243 let salience = engine.context_salience(&ctx);
244 let entity_name = entity_key(&ctx);
245
246 let same_entity = match (¤t_entity, &entity_name) {
252 (Some(a), Some(b)) => a == b,
253 (None, None) => true,
254 _ => false,
255 };
256
257 if !same_entity && !para.is_empty() {
258 plan.paragraphs.push(core::mem::take(&mut para));
259 para.category = Some(category);
260 }
261
262 para.push(key, ctx, salience);
263 current_entity = entity_name;
264 }
265
266 if !para.is_empty() {
267 plan.paragraphs.push(para);
268 }
269 }
270
271 for (category, bucket) in buckets {
274 let mut para = Paragraph::new();
275 para.category = Some(category);
276 for (key, ctx) in bucket {
277 let salience = engine.context_salience(&ctx);
278 para.push(key, ctx, salience);
279 }
280 plan.paragraphs.push(para);
281 }
282
283 plan
284 }
285
286 pub fn from_events_with_relations(
294 events: &[(&str, Context, Option<RstRelation>)],
295 engine: &Engine,
296 ) -> Self {
297 let mut plan = Self::new();
298 if events.is_empty() {
299 return plan;
300 }
301
302 let mut current = Paragraph::new();
303 let mut current_entity: Option<String> = None;
304
305 for (key, ctx, relation) in events {
306 let ctx = ctx.clone();
307 let salience = engine.context_salience(&ctx);
308 let entity_name = entity_key(&ctx);
309
310 let same_entity = match (¤t_entity, &entity_name) {
311 (Some(a), Some(b)) => a == b,
312 _ => false,
313 };
314
315 if !same_entity && !current.is_empty() {
316 plan.paragraphs.push(core::mem::take(&mut current));
317 }
318
319 let effective_relation = if current.is_empty() { None } else { *relation };
322
323 current.push_with_relation(key.to_string(), ctx, salience, effective_relation);
324 current_entity = entity_name;
325 }
326
327 if !current.is_empty() {
328 plan.paragraphs.push(current);
329 }
330
331 plan.paragraphs.sort_by(|a, b| b.salience.cmp(&a.salience));
332 plan
333 }
334
335 fn build_by_entity(events: &[(&str, Context)], engine: &Engine) -> Self {
336 let mut plan = Self::new();
337 if events.is_empty() {
338 return plan;
339 }
340
341 let mut current = Paragraph::new();
342 let mut current_entity: Option<String> = None;
343
344 for (key, ctx) in events {
345 let ctx = ctx.clone();
346 let salience = engine.context_salience(&ctx);
347 let entity_name = entity_key(&ctx);
348
349 let same_entity = match (¤t_entity, &entity_name) {
350 (Some(a), Some(b)) => a == b,
351 _ => false,
352 };
353
354 if !same_entity && !current.is_empty() {
355 plan.paragraphs.push(core::mem::take(&mut current));
356 }
357
358 current.push(key.to_string(), ctx, salience);
359 current_entity = entity_name;
360 }
361
362 if !current.is_empty() {
363 plan.paragraphs.push(current);
364 }
365
366 plan.paragraphs.sort_by(|a, b| b.salience.cmp(&a.salience));
368
369 plan
370 }
371
372 #[cfg(feature = "parallel")]
389 pub fn render_parallel(
390 &self,
391 engine: &Engine,
392 initial_session: &Session,
393 ) -> Result<String, ProsaicError>
394 where
395 Engine: Sync,
396 Session: Send,
397 {
398 use rayon::prelude::*;
399
400 let rendered: Result<Vec<String>, ProsaicError> = self
401 .paragraphs
402 .par_iter()
403 .map(|p| {
404 let mut session = initial_session.clone();
405 session.reset_for_paragraph();
408
409 if p.relations.iter().any(|r| r.is_some()) {
410 let triples: Vec<(&str, Context, Option<RstRelation>)> = p
411 .events
412 .iter()
413 .zip(p.relations.iter())
414 .map(|((k, c), r)| (k.as_str(), c.clone(), *r))
415 .collect();
416 engine.render_batch_with_relations(&mut session, &triples)
417 } else {
418 let events: Vec<(&str, Context)> = p
419 .events
420 .iter()
421 .map(|(k, c)| (k.as_str(), c.clone()))
422 .collect();
423 engine.render_batch(&mut session, &events)
424 }
425 })
426 .filter(|r| !matches!(r, Ok(s) if s.is_empty()))
427 .collect();
428
429 Ok(rendered?.join("\n\n"))
430 }
431
432 pub fn render_structured(
456 &self,
457 engine: &Engine,
458 session: &mut Session,
459 ) -> Result<crate::refine::RenderedDocument, ProsaicError> {
460 use crate::refine::{EventMeta, ParagraphRender};
461 let mut paragraphs = Vec::with_capacity(self.paragraphs.len());
462
463 for (idx, p) in self.paragraphs.iter().enumerate() {
464 if idx > 0 {
465 session.reset_for_paragraph();
466 }
467 let mut paragraph_text = String::new();
468 let mut events = Vec::with_capacity(p.events.len());
469 for (event_idx, (key, ctx)) in p.events.iter().enumerate() {
470 if event_idx > 0 {
471 paragraph_text.push(' ');
472 }
473 let exp = engine.render_explained(session, key, ctx)?;
474 paragraph_text.push_str(&exp.output);
475 events.push(EventMeta {
476 connective: exp.connective.map(|s| s.to_string()),
477 list_style: exp.list_style,
478 });
479 }
480 paragraphs.push(ParagraphRender {
481 text: paragraph_text,
482 events,
483 });
484 }
485
486 Ok(crate::refine::RenderedDocument::from_paragraphs(paragraphs))
487 }
488
489 pub fn render_refined(
495 &self,
496 engine: &Engine,
497 session: &mut Session,
498 ) -> Result<crate::refine::RefineOutcome, ProsaicError> {
499 let config = engine.current_refine_config();
500 let initial_session = session.clone();
501 let initial = self.render_structured(engine, session)?;
502 if config.is_off() {
503 let final_score = crate::refine_score::score_document(
504 &initial,
505 &config.weights,
506 Some(engine.current_style_profile()).filter(|p| !p.is_neutral()),
507 );
508 return Ok(crate::refine::RefineOutcome {
509 text: initial.text,
510 iterations_run: 0,
511 final_score,
512 converged_clean: true,
513 });
514 }
515 let profile_ref = Some(engine.current_style_profile()).filter(|p| !p.is_neutral());
516 crate::refine::run_refine_loop(
517 config,
518 profile_ref,
519 initial,
520 initial_session,
521 session,
522 |s| self.render_structured(engine, s),
523 )
524 }
525
526 pub fn render(&self, engine: &Engine, session: &mut Session) -> Result<String, ProsaicError> {
527 if !engine.current_refine_config().is_off() {
528 return self
529 .render_refined(engine, session)
530 .map(|outcome| outcome.text);
531 }
532 let mut paragraphs = Vec::new();
533
534 for (idx, p) in self.paragraphs.iter().enumerate() {
535 if idx > 0 {
536 session.reset_for_paragraph();
537 }
538
539 let rendered = if p.relations.iter().any(|r| r.is_some()) {
540 let triples: Vec<(&str, Context, Option<RstRelation>)> = p
541 .events
542 .iter()
543 .zip(p.relations.iter())
544 .map(|((k, c), r)| (k.as_str(), c.clone(), *r))
545 .collect();
546 engine.render_batch_with_relations(session, &triples)?
547 } else {
548 let events: Vec<(&str, Context)> = p
549 .events
550 .iter()
551 .map(|(k, c)| (k.as_str(), c.clone()))
552 .collect();
553 engine.render_batch(session, &events)?
554 };
555
556 if !rendered.is_empty() {
557 paragraphs.push(rendered);
558 }
559 }
560
561 Ok(paragraphs.join("\n\n"))
562 }
563}
564
565fn entity_key(ctx: &Context) -> Option<String> {
567 ctx.get("name")
568 .or_else(|| ctx.get("old_name"))
569 .map(|v| v.as_display())
570}
571
572const fn _assert_engine_session_send_sync() {
575 const fn check<T: Send + Sync>() {}
576 check::<Engine>();
577 check::<Session>();
578}
579
580impl Default for DocumentPlan {
581 fn default() -> Self {
582 Self::new()
583 }
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589 use crate::context::Value;
590 use crate::engine::{Engine, Strictness, Variation};
591 use crate::language::{Conjunction, Language, Person, Tense};
592 use crate::rst::RstRelation;
593 use crate::session::Session;
594
595 struct TestLang;
596
597 impl Language for TestLang {
598 fn pluralize(&self, word: &str, count: usize) -> String {
599 if count == 1 {
600 word.to_string()
601 } else {
602 format!("{word}s")
603 }
604 }
605 fn singularize(&self, word: &str) -> String {
606 word.strip_suffix('s').unwrap_or(word).to_string()
607 }
608 fn article(&self, _word: &str) -> &str {
609 "a"
610 }
611 fn conjugate(&self, verb: &str, _t: Tense, _p: Person) -> String {
612 verb.to_string()
613 }
614 fn past_participle(&self, verb: &str) -> String {
615 format!("{verb}ed")
616 }
617 fn present_participle(&self, verb: &str) -> String {
618 format!("{verb}ing")
619 }
620 fn join_list(&self, items: &[&str], _c: Conjunction) -> String {
621 items.join(", ")
622 }
623 fn ordinal(&self, n: usize) -> String {
624 format!("{n}th")
625 }
626 fn number_to_words(&self, n: usize) -> String {
627 n.to_string()
628 }
629 }
630
631 fn test_engine() -> Engine {
632 let mut engine = Engine::new(TestLang)
633 .strictness(Strictness::Strict)
634 .variation(Variation::Fixed);
635 engine.register_template("t", "{name} changed").unwrap();
636 engine
637 }
638
639 #[test]
640 fn empty_events_produces_empty_plan() {
641 let engine = test_engine();
642 let plan = DocumentPlan::from_events(&[], &engine);
643 assert!(plan.paragraphs.is_empty());
644 let mut session = Session::new();
645 assert_eq!(plan.render(&engine, &mut session).unwrap(), "");
646 }
647
648 #[test]
649 fn groups_consecutive_same_entity_events() {
650 let engine = test_engine();
651 let mut c1 = Context::new();
652 c1.insert("entity_type", Value::String("class".into()));
653 c1.insert("name", Value::String("Foo".into()));
654 c1.insert("consumer_count", Value::Number(1));
655 let mut c2 = Context::new();
656 c2.insert("entity_type", Value::String("class".into()));
657 c2.insert("name", Value::String("Foo".into()));
658 c2.insert("consumer_count", Value::Number(1));
659 let mut c3 = Context::new();
660 c3.insert("entity_type", Value::String("class".into()));
661 c3.insert("name", Value::String("Bar".into()));
662 c3.insert("consumer_count", Value::Number(1));
663
664 let events: Vec<(&str, Context)> = vec![("t", c1), ("t", c2), ("t", c3)];
665
666 let plan = DocumentPlan::from_events(&events, &engine);
667 assert_eq!(plan.paragraphs.len(), 2);
668 assert_eq!(plan.paragraphs[0].events.len(), 2);
669 assert_eq!(plan.paragraphs[1].events.len(), 1);
670 }
671
672 #[test]
673 fn orders_paragraphs_by_highest_salience() {
674 let engine = test_engine();
675 let mut low = Context::new();
676 low.insert("entity_type", Value::String("class".into()));
677 low.insert("name", Value::String("Small".into()));
678 low.insert("consumer_count", Value::Number(1));
679
680 let mut high = Context::new();
681 high.insert("entity_type", Value::String("class".into()));
682 high.insert("name", Value::String("Big".into()));
683 high.insert("consumer_count", Value::Number(50));
684
685 let events: Vec<(&str, Context)> = vec![("t", low), ("t", high)];
686
687 let plan = DocumentPlan::from_events(&events, &engine);
688 assert_eq!(plan.paragraphs.len(), 2);
689 assert_eq!(plan.paragraphs[0].salience, Salience::High);
691 assert_eq!(plan.paragraphs[1].salience, Salience::Low);
692 }
693
694 #[test]
695 fn renders_paragraphs_separated_by_blank_line() {
696 let engine = test_engine();
697 let mut c1 = Context::new();
698 c1.insert("entity_type", Value::String("class".into()));
699 c1.insert("name", Value::String("Alpha".into()));
700 c1.insert("consumer_count", Value::Number(5));
701
702 let mut c2 = Context::new();
703 c2.insert("entity_type", Value::String("class".into()));
704 c2.insert("name", Value::String("Beta".into()));
705 c2.insert("consumer_count", Value::Number(5));
706
707 let events: Vec<(&str, Context)> = vec![("t", c1), ("t", c2)];
708
709 let plan = DocumentPlan::from_events(&events, &engine);
710 let mut session = Session::new();
711 let rendered = plan.render(&engine, &mut session).unwrap();
712
713 assert!(
714 rendered.contains("\n\n"),
715 "Expected paragraph break, got: {rendered}"
716 );
717 }
718
719 #[test]
722 fn document_render_preserves_list_style_cycle_across_paragraphs() {
723 let mut engine = Engine::new(TestLang)
724 .strictness(Strictness::Strict)
725 .variation(Variation::Fixed);
726 engine
727 .register_template(
728 "t",
729 "The {entity_type} {name} touched {items|truncate:1|join}",
730 )
731 .unwrap();
732
733 fn list_ctx(name: &str, first_item: &str) -> Context {
734 let mut ctx = Context::new();
735 ctx.insert("entity_type", Value::String("class".into()));
736 ctx.insert("name", Value::String(name.into()));
737 ctx.insert(
738 "items",
739 Value::List(vec![first_item.into(), "cache".into(), "metrics".into()]),
740 );
741 ctx
742 }
743
744 let events: Vec<(&str, Context)> = vec![
745 ("t", list_ctx("Alpha", "auth")),
746 ("t", list_ctx("Beta", "billing")),
747 ("t", list_ctx("Gamma", "search")),
748 ("t", list_ctx("Delta", "alerts")),
749 ];
750
751 let plan = DocumentPlan::from_events(&events, &engine);
752 let mut session = Session::new();
753 let rendered = plan.render(&engine, &mut session).unwrap();
754
755 assert_eq!(
756 rendered,
757 concat!(
758 "The class Alpha touched including auth among others.\n\n",
759 "The class Beta touched such as billing.\n\n",
760 "The class Gamma touched \u{2014} notably search, plus 2 more.\n\n",
761 "The class Delta touched [alerts, 2 more].",
762 )
763 );
764 assert_eq!(
765 rendered.matches("including ").count(),
766 1,
767 "paragraph resets must not restart every truncated list with the same style: {rendered}",
768 );
769 }
770
771 #[test]
772 fn document_render_does_not_replay_round_robin_variant_after_paragraph_break() {
773 let mut engine = Engine::new(TestLang)
778 .strictness(Strictness::Strict)
779 .variation(Variation::RoundRobin);
780 engine.register_template("t", "First {name}").unwrap();
781 engine.register_template("t", "Second {name}").unwrap();
782 engine.register_template("t", "Third {name}").unwrap();
783
784 let mut c1 = Context::new();
785 c1.insert("entity_type", Value::String("class".into()));
786 c1.insert("name", Value::String("Alpha".into()));
787 let mut c2 = Context::new();
788 c2.insert("entity_type", Value::String("class".into()));
789 c2.insert("name", Value::String("Beta".into()));
790
791 let events: Vec<(&str, Context)> = vec![("t", c1), ("t", c2)];
792 let plan = DocumentPlan::from_events(&events, &engine);
793 assert_eq!(plan.paragraphs.len(), 2);
795
796 let mut session = Session::new();
797 let rendered = plan.render(&engine, &mut session).unwrap();
798
799 assert!(
802 rendered.starts_with("First Alpha"),
803 "expected paragraph 1 to use variant 0: {rendered}"
804 );
805 assert!(
806 rendered.contains("\n\nSecond Beta"),
807 "expected paragraph 2 to advance to variant 1, not replay \"First\": {rendered}"
808 );
809 assert!(
810 !rendered.contains("First Beta"),
811 "round-robin counter must survive the paragraph reset: {rendered}"
812 );
813 }
814
815 #[test]
816 fn document_render_does_not_pronominalize_across_paragraph_break() {
817 let mut engine = Engine::new(TestLang)
822 .strictness(Strictness::Strict)
823 .variation(Variation::Fixed);
824 engine
825 .register_template("p1", "{name} was modified")
826 .unwrap();
827 engine
828 .register_template("p2", "{other} also changed")
829 .unwrap();
830
831 let mut c1 = Context::new();
832 c1.insert("entity_type", Value::String("class".into()));
833 c1.insert("name", Value::String("Foo".into()));
834 let mut c2 = Context::new();
835 c2.insert("entity_type", Value::String("class".into()));
836 c2.insert("other", Value::String("Bar".into()));
837
838 let events: Vec<(&str, Context)> = vec![("p1", c1), ("p2", c2)];
840 let plan = DocumentPlan::from_events(&events, &engine);
841 assert_eq!(plan.paragraphs.len(), 2);
842
843 let mut session = Session::new();
844 let _ = plan.render(&engine, &mut session).unwrap();
845
846 use crate::discourse::ReferenceForm;
851 assert_eq!(
852 session.discourse().reference_form("Foo"),
853 ReferenceForm::Full,
854 "paragraph reset must clear entity table to prevent anaphora leak",
855 );
856 }
857
858 fn ctx_with_entity(name: &str, count: i64) -> Context {
859 let mut c = Context::new();
860 c.insert("entity_type", Value::String("class".into()));
861 c.insert("name", Value::String(name.into()));
862 c.insert("consumer_count", Value::Number(count));
863 c
864 }
865
866 #[test]
867 fn default_classifier_buckets_common_keys() {
868 assert_eq!(
869 default_classifier("code.deleted"),
870 RhetoricalCategory::Removal
871 );
872 assert_eq!(
873 default_classifier("code.removed"),
874 RhetoricalCategory::Removal
875 );
876 assert_eq!(
877 default_classifier("code.added"),
878 RhetoricalCategory::Addition
879 );
880 assert_eq!(
881 default_classifier("code.introduced"),
882 RhetoricalCategory::Addition
883 );
884 assert_eq!(
885 default_classifier("code.modified"),
886 RhetoricalCategory::Modification,
887 );
888 assert_eq!(
889 default_classifier("code.renamed"),
890 RhetoricalCategory::Modification,
891 );
892 assert_eq!(
893 default_classifier("code.signature_changed"),
894 RhetoricalCategory::Modification,
895 );
896 assert_eq!(default_classifier("random"), RhetoricalCategory::Other);
897 assert_eq!(default_classifier(""), RhetoricalCategory::Other);
898 }
899
900 #[test]
901 fn by_action_groups_removals_before_additions_before_modifications() {
902 let engine = test_engine();
903
904 let events: Vec<(&str, Context)> = vec![
905 ("code.modified", ctx_with_entity("A", 1)),
906 ("code.added", ctx_with_entity("B", 1)),
907 ("code.deleted", ctx_with_entity("C", 1)),
908 ];
909
910 let plan = DocumentPlan::from_events_grouped(&events, &engine, GroupingStrategy::ByAction);
911
912 assert_eq!(plan.paragraphs.len(), 3);
914 assert_eq!(
915 plan.paragraphs[0].category,
916 Some(RhetoricalCategory::Removal)
917 );
918 assert_eq!(
919 plan.paragraphs[1].category,
920 Some(RhetoricalCategory::Addition)
921 );
922 assert_eq!(
923 plan.paragraphs[2].category,
924 Some(RhetoricalCategory::Modification)
925 );
926 }
927
928 #[test]
929 fn by_action_splits_paragraphs_within_category_on_entity_change() {
930 let engine = test_engine();
931
932 let events: Vec<(&str, Context)> = vec![
935 ("code.modified", ctx_with_entity("Alpha", 1)),
936 ("code.modified", ctx_with_entity("Beta", 1)),
937 ];
938
939 let plan = DocumentPlan::from_events_grouped(&events, &engine, GroupingStrategy::ByAction);
940
941 assert_eq!(plan.paragraphs.len(), 2);
942 for p in &plan.paragraphs {
943 assert_eq!(p.category, Some(RhetoricalCategory::Modification));
944 assert_eq!(p.events.len(), 1);
945 }
946 }
947
948 #[test]
949 fn by_action_keeps_same_entity_events_together_within_category() {
950 let engine = test_engine();
951
952 let events: Vec<(&str, Context)> = vec![
953 ("code.modified", ctx_with_entity("Alpha", 1)),
954 ("code.renamed", ctx_with_entity("Alpha", 1)),
955 ];
956
957 let plan = DocumentPlan::from_events_grouped(&events, &engine, GroupingStrategy::ByAction);
958
959 assert_eq!(plan.paragraphs.len(), 1);
961 assert_eq!(plan.paragraphs[0].events.len(), 2);
962 }
963
964 #[test]
965 fn from_events_classified_accepts_custom_classifier() {
966 let engine = test_engine();
967
968 let events: Vec<(&str, Context)> = vec![
969 ("issue.closed", ctx_with_entity("Bug1", 1)),
970 ("issue.opened", ctx_with_entity("Bug2", 1)),
971 ];
972
973 let plan = DocumentPlan::from_events_classified(&events, &engine, |key| {
974 match key.rsplit('.').next().unwrap_or("") {
975 "closed" => RhetoricalCategory::Removal,
976 "opened" => RhetoricalCategory::Addition,
977 _ => RhetoricalCategory::Other,
978 }
979 });
980
981 assert_eq!(plan.paragraphs.len(), 2);
982 assert_eq!(
983 plan.paragraphs[0].category,
984 Some(RhetoricalCategory::Removal)
985 );
986 assert_eq!(
987 plan.paragraphs[1].category,
988 Some(RhetoricalCategory::Addition)
989 );
990 }
991
992 #[test]
993 fn by_entity_grouping_still_default() {
994 let engine = test_engine();
995
996 let events: Vec<(&str, Context)> = vec![
997 ("code.modified", ctx_with_entity("Alpha", 1)),
998 ("code.modified", ctx_with_entity("Alpha", 1)),
999 ];
1000
1001 let plan = DocumentPlan::from_events(&events, &engine);
1002 assert_eq!(plan.paragraphs.len(), 1);
1005 assert!(plan.paragraphs[0].category.is_none());
1006 }
1007
1008 #[test]
1011 fn paragraph_push_adds_none_relation() {
1012 let mut p = Paragraph::new();
1013 p.push("t".into(), Context::new(), Salience::Low);
1014 assert_eq!(p.relations.len(), 1);
1015 assert_eq!(p.relations[0], None);
1016 }
1017
1018 #[test]
1019 fn paragraph_push_with_relation_records_it() {
1020 let mut p = Paragraph::new();
1021 p.push_with_relation(
1022 "t".into(),
1023 Context::new(),
1024 Salience::Low,
1025 Some(RstRelation::Contrast),
1026 );
1027 assert_eq!(p.relations, vec![Some(RstRelation::Contrast)]);
1028 }
1029
1030 #[test]
1031 fn paragraph_relations_len_matches_events_len() {
1032 let mut p = Paragraph::new();
1033 p.push("t".into(), Context::new(), Salience::Low);
1034 p.push_with_relation(
1035 "t".into(),
1036 Context::new(),
1037 Salience::Low,
1038 Some(RstRelation::Elaboration),
1039 );
1040 p.push("t".into(), Context::new(), Salience::Medium);
1041 assert_eq!(p.events.len(), p.relations.len());
1042 assert_eq!(p.relations.len(), 3);
1043 }
1044
1045 #[test]
1048 fn from_events_with_relations_threads_rel() {
1049 let engine = test_engine();
1050 let events = vec![
1051 ("t", ctx_with_entity("Foo", 1), None),
1052 (
1053 "t",
1054 ctx_with_entity("Foo", 1),
1055 Some(RstRelation::Elaboration),
1056 ),
1057 ];
1058 let plan = DocumentPlan::from_events_with_relations(&events, &engine);
1059 assert_eq!(plan.paragraphs.len(), 1);
1060 assert_eq!(plan.paragraphs[0].relations[0], None);
1061 assert_eq!(
1062 plan.paragraphs[0].relations[1],
1063 Some(RstRelation::Elaboration)
1064 );
1065 }
1066
1067 #[test]
1068 fn relations_are_dropped_at_paragraph_boundary() {
1069 let engine = test_engine();
1070 let events = vec![
1073 ("t", ctx_with_entity("Foo", 1), None),
1074 ("t", ctx_with_entity("Bar", 1), Some(RstRelation::Contrast)),
1075 ];
1076 let plan = DocumentPlan::from_events_with_relations(&events, &engine);
1077 assert_eq!(plan.paragraphs.len(), 2);
1078 for p in &plan.paragraphs {
1080 assert_eq!(p.relations, vec![None]);
1081 }
1082 }
1083
1084 #[cfg(feature = "time")]
1089 #[test]
1090 fn document_plan_temporal_anchor_spans_paragraphs() {
1091 let mut engine = Engine::new(TestLang)
1092 .strictness(Strictness::Strict)
1093 .variation(Variation::Fixed)
1094 .reference_time(1_700_000_000);
1095 engine
1096 .register_template("t", "{name} changed {ts|since_last}")
1097 .unwrap();
1098
1099 let t1: i64 = 1_700_000_000;
1100 let t2: i64 = t1 + 86400;
1101
1102 let mut c1 = ctx_with_entity("Foo", 1);
1103 c1.insert("ts", Value::Number(t1));
1104 c1.insert("timestamp", Value::Number(t1));
1105
1106 let mut c2 = ctx_with_entity("Bar", 1);
1107 c2.insert("ts", Value::Number(t2));
1108 c2.insert("timestamp", Value::Number(t2));
1109
1110 let events: Vec<(&str, Context)> = vec![("t", c1), ("t", c2)];
1111 let plan = DocumentPlan::from_events(&events, &engine);
1112 assert_eq!(plan.paragraphs.len(), 2);
1114
1115 let mut s = Session::new();
1116 let out = plan.render(&engine, &mut s).unwrap();
1117
1118 assert!(out.contains("the next day"), "got: {out}");
1121 }
1122
1123 #[test]
1124 fn document_render_uses_marker_when_paragraph_has_relation() {
1125 let mut engine = test_engine();
1126 engine
1127 .register_template("t", "The class {name} was modified")
1128 .unwrap();
1129
1130 let events = vec![
1131 ("t", ctx_with_entity("Foo", 1), None),
1132 ("t", ctx_with_entity("Foo", 1), Some(RstRelation::Contrast)),
1133 ];
1134 let plan = DocumentPlan::from_events_with_relations(&events, &engine);
1135 let mut s = Session::new();
1136 let rendered = plan.render(&engine, &mut s).unwrap();
1137 assert!(rendered.contains("However, "), "got: {rendered}");
1138 }
1139
1140 #[test]
1143 fn engine_and_session_are_send_sync() {
1144 fn assert_send_sync<T: Send + Sync>() {}
1145 assert_send_sync::<Engine>();
1146 assert_send_sync::<Session>();
1147 }
1148
1149 #[cfg(feature = "parallel")]
1150 #[test]
1151 fn render_parallel_produces_same_output_for_independent_paragraphs() {
1152 let mut engine = Engine::new(TestLang)
1155 .strictness(Strictness::Strict)
1156 .variation(Variation::Fixed);
1157 engine.register_template("t", "{name} changed").unwrap();
1158
1159 let events: Vec<(&str, Context)> = vec![
1160 ("t", ctx_with_entity("Alpha", 1)),
1161 ("t", ctx_with_entity("Beta", 1)),
1162 ];
1163 let plan = DocumentPlan::from_events(&events, &engine);
1164
1165 let mut s1 = Session::new();
1166 let seq = plan.render(&engine, &mut s1).unwrap();
1167
1168 let s2 = Session::new();
1169 let par = plan.render_parallel(&engine, &s2).unwrap();
1170
1171 assert_eq!(seq, par);
1172 }
1173
1174 #[cfg(feature = "parallel")]
1175 #[test]
1176 fn render_parallel_empty_plan_returns_empty_string() {
1177 let engine = test_engine();
1178 let plan = DocumentPlan::new();
1179 let s = Session::new();
1180 let out = plan.render_parallel(&engine, &s).unwrap();
1181 assert_eq!(out, "");
1182 }
1183
1184 #[cfg(feature = "parallel")]
1185 #[test]
1186 fn render_parallel_single_paragraph_matches_sequential() {
1187 let engine = test_engine();
1188 let events: Vec<(&str, Context)> = vec![("t", ctx_with_entity("Foo", 5))];
1189 let plan = DocumentPlan::from_events(&events, &engine);
1190
1191 let mut s1 = Session::new();
1192 let seq = plan.render(&engine, &mut s1).unwrap();
1193
1194 let s2 = Session::new();
1195 let par = plan.render_parallel(&engine, &s2).unwrap();
1196
1197 assert_eq!(seq, par);
1198 }
1199}