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_key(|(_, ctx)| entity_key(ctx));
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
332 .sort_by_key(|paragraph| core::cmp::Reverse(paragraph.salience));
333 plan
334 }
335
336 fn build_by_entity(events: &[(&str, Context)], engine: &Engine) -> Self {
337 let mut plan = Self::new();
338 if events.is_empty() {
339 return plan;
340 }
341
342 let mut current = Paragraph::new();
343 let mut current_entity: Option<String> = None;
344
345 for (key, ctx) in events {
346 let ctx = ctx.clone();
347 let salience = engine.context_salience(&ctx);
348 let entity_name = entity_key(&ctx);
349
350 let same_entity = match (¤t_entity, &entity_name) {
351 (Some(a), Some(b)) => a == b,
352 _ => false,
353 };
354
355 if !same_entity && !current.is_empty() {
356 plan.paragraphs.push(core::mem::take(&mut current));
357 }
358
359 current.push(key.to_string(), ctx, salience);
360 current_entity = entity_name;
361 }
362
363 if !current.is_empty() {
364 plan.paragraphs.push(current);
365 }
366
367 plan.paragraphs
369 .sort_by_key(|paragraph| core::cmp::Reverse(paragraph.salience));
370
371 plan
372 }
373
374 #[cfg(feature = "parallel")]
391 pub fn render_parallel(
392 &self,
393 engine: &Engine,
394 initial_session: &Session,
395 ) -> Result<String, ProsaicError>
396 where
397 Engine: Sync,
398 Session: Send,
399 {
400 use rayon::prelude::*;
401
402 let rendered: Result<Vec<String>, ProsaicError> = self
403 .paragraphs
404 .par_iter()
405 .map(|p| {
406 let mut session = initial_session.clone();
407 session.reset_for_paragraph();
410
411 if p.relations.iter().any(|r| r.is_some()) {
412 let triples: Vec<(&str, Context, Option<RstRelation>)> = p
413 .events
414 .iter()
415 .zip(p.relations.iter())
416 .map(|((k, c), r)| (k.as_str(), c.clone(), *r))
417 .collect();
418 engine.render_batch_with_relations(&mut session, &triples)
419 } else {
420 let events: Vec<(&str, Context)> = p
421 .events
422 .iter()
423 .map(|(k, c)| (k.as_str(), c.clone()))
424 .collect();
425 engine.render_batch(&mut session, &events)
426 }
427 })
428 .filter(|r| !matches!(r, Ok(s) if s.is_empty()))
429 .collect();
430
431 Ok(rendered?.join("\n\n"))
432 }
433
434 pub fn render_structured(
458 &self,
459 engine: &Engine,
460 session: &mut Session,
461 ) -> Result<crate::refine::RenderedDocument, ProsaicError> {
462 use crate::refine::{EventMeta, ParagraphRender};
463 let mut paragraphs = Vec::with_capacity(self.paragraphs.len());
464
465 for (idx, p) in self.paragraphs.iter().enumerate() {
466 if idx > 0 {
467 session.reset_for_paragraph();
468 }
469 let mut paragraph_text = String::new();
470 let mut events = Vec::with_capacity(p.events.len());
471 for (event_idx, (key, ctx)) in p.events.iter().enumerate() {
472 if event_idx > 0 {
473 paragraph_text.push(' ');
474 }
475 let exp = engine.render_explained(session, key, ctx)?;
476 paragraph_text.push_str(&exp.output);
477 events.push(EventMeta {
478 connective: exp.connective.map(|s| s.to_string()),
479 list_style: exp.list_style,
480 });
481 }
482 paragraphs.push(ParagraphRender {
483 text: paragraph_text,
484 events,
485 });
486 }
487
488 Ok(crate::refine::RenderedDocument::from_paragraphs(paragraphs))
489 }
490
491 pub fn render_refined(
497 &self,
498 engine: &Engine,
499 session: &mut Session,
500 ) -> Result<crate::refine::RefineOutcome, ProsaicError> {
501 let config = engine.current_refine_config();
502 let initial_session = session.clone();
503 let initial = self.render_structured(engine, session)?;
504 if config.is_off() {
505 let final_score = crate::refine_score::score_document(
506 &initial,
507 &config.weights,
508 Some(engine.current_style_profile()).filter(|p| !p.is_neutral()),
509 );
510 return Ok(crate::refine::RefineOutcome {
511 text: initial.text,
512 iterations_run: 0,
513 final_score,
514 converged_clean: true,
515 });
516 }
517 let profile_ref = Some(engine.current_style_profile()).filter(|p| !p.is_neutral());
518 crate::refine::run_refine_loop(
519 config,
520 profile_ref,
521 initial,
522 initial_session,
523 session,
524 |s| self.render_structured(engine, s),
525 )
526 }
527
528 pub fn render(&self, engine: &Engine, session: &mut Session) -> Result<String, ProsaicError> {
529 if !engine.current_refine_config().is_off() {
530 return self
531 .render_refined(engine, session)
532 .map(|outcome| outcome.text);
533 }
534 let mut paragraphs = Vec::new();
535
536 for (idx, p) in self.paragraphs.iter().enumerate() {
537 if idx > 0 {
538 session.reset_for_paragraph();
539 }
540
541 let rendered = if p.relations.iter().any(|r| r.is_some()) {
542 let triples: Vec<(&str, Context, Option<RstRelation>)> = p
543 .events
544 .iter()
545 .zip(p.relations.iter())
546 .map(|((k, c), r)| (k.as_str(), c.clone(), *r))
547 .collect();
548 engine.render_batch_with_relations(session, &triples)?
549 } else {
550 let events: Vec<(&str, Context)> = p
551 .events
552 .iter()
553 .map(|(k, c)| (k.as_str(), c.clone()))
554 .collect();
555 engine.render_batch(session, &events)?
556 };
557
558 if !rendered.is_empty() {
559 paragraphs.push(rendered);
560 }
561 }
562
563 Ok(paragraphs.join("\n\n"))
564 }
565}
566
567fn entity_key(ctx: &Context) -> Option<String> {
569 ctx.get("name")
570 .or_else(|| ctx.get("old_name"))
571 .map(|v| v.as_display())
572}
573
574const fn _assert_engine_session_send_sync() {
577 const fn check<T: Send + Sync>() {}
578 check::<Engine>();
579 check::<Session>();
580}
581
582impl Default for DocumentPlan {
583 fn default() -> Self {
584 Self::new()
585 }
586}
587
588#[cfg(test)]
589mod tests {
590 use super::*;
591 use crate::context::Value;
592 use crate::engine::{Engine, Strictness, Variation};
593 use crate::language::{Conjunction, Language, Person, Tense};
594 use crate::rst::RstRelation;
595 use crate::session::Session;
596
597 struct TestLang;
598
599 impl Language for TestLang {
600 fn pluralize(&self, word: &str, count: usize) -> String {
601 if count == 1 {
602 word.to_string()
603 } else {
604 format!("{word}s")
605 }
606 }
607 fn singularize(&self, word: &str) -> String {
608 word.strip_suffix('s').unwrap_or(word).to_string()
609 }
610 fn article(&self, _word: &str) -> &str {
611 "a"
612 }
613 fn conjugate(&self, verb: &str, _t: Tense, _p: Person) -> String {
614 verb.to_string()
615 }
616 fn past_participle(&self, verb: &str) -> String {
617 format!("{verb}ed")
618 }
619 fn present_participle(&self, verb: &str) -> String {
620 format!("{verb}ing")
621 }
622 fn join_list(&self, items: &[&str], _c: Conjunction) -> String {
623 items.join(", ")
624 }
625 fn ordinal(&self, n: usize) -> String {
626 format!("{n}th")
627 }
628 fn number_to_words(&self, n: usize) -> String {
629 n.to_string()
630 }
631 }
632
633 fn test_engine() -> Engine {
634 let mut engine = Engine::new(TestLang)
635 .strictness(Strictness::Strict)
636 .variation(Variation::Fixed);
637 engine.register_template("t", "{name} changed").unwrap();
638 engine
639 }
640
641 #[test]
642 fn empty_events_produces_empty_plan() {
643 let engine = test_engine();
644 let plan = DocumentPlan::from_events(&[], &engine);
645 assert!(plan.paragraphs.is_empty());
646 let mut session = Session::new();
647 assert_eq!(plan.render(&engine, &mut session).unwrap(), "");
648 }
649
650 #[test]
651 fn groups_consecutive_same_entity_events() {
652 let engine = test_engine();
653 let mut c1 = Context::new();
654 c1.insert("entity_type", Value::String("class".into()));
655 c1.insert("name", Value::String("Foo".into()));
656 c1.insert("consumer_count", Value::Number(1));
657 let mut c2 = Context::new();
658 c2.insert("entity_type", Value::String("class".into()));
659 c2.insert("name", Value::String("Foo".into()));
660 c2.insert("consumer_count", Value::Number(1));
661 let mut c3 = Context::new();
662 c3.insert("entity_type", Value::String("class".into()));
663 c3.insert("name", Value::String("Bar".into()));
664 c3.insert("consumer_count", Value::Number(1));
665
666 let events: Vec<(&str, Context)> = vec![("t", c1), ("t", c2), ("t", c3)];
667
668 let plan = DocumentPlan::from_events(&events, &engine);
669 assert_eq!(plan.paragraphs.len(), 2);
670 assert_eq!(plan.paragraphs[0].events.len(), 2);
671 assert_eq!(plan.paragraphs[1].events.len(), 1);
672 }
673
674 #[test]
675 fn orders_paragraphs_by_highest_salience() {
676 let engine = test_engine();
677 let mut low = Context::new();
678 low.insert("entity_type", Value::String("class".into()));
679 low.insert("name", Value::String("Small".into()));
680 low.insert("consumer_count", Value::Number(1));
681
682 let mut high = Context::new();
683 high.insert("entity_type", Value::String("class".into()));
684 high.insert("name", Value::String("Big".into()));
685 high.insert("consumer_count", Value::Number(50));
686
687 let events: Vec<(&str, Context)> = vec![("t", low), ("t", high)];
688
689 let plan = DocumentPlan::from_events(&events, &engine);
690 assert_eq!(plan.paragraphs.len(), 2);
691 assert_eq!(plan.paragraphs[0].salience, Salience::High);
693 assert_eq!(plan.paragraphs[1].salience, Salience::Low);
694 }
695
696 #[test]
697 fn renders_paragraphs_separated_by_blank_line() {
698 let engine = test_engine();
699 let mut c1 = Context::new();
700 c1.insert("entity_type", Value::String("class".into()));
701 c1.insert("name", Value::String("Alpha".into()));
702 c1.insert("consumer_count", Value::Number(5));
703
704 let mut c2 = Context::new();
705 c2.insert("entity_type", Value::String("class".into()));
706 c2.insert("name", Value::String("Beta".into()));
707 c2.insert("consumer_count", Value::Number(5));
708
709 let events: Vec<(&str, Context)> = vec![("t", c1), ("t", c2)];
710
711 let plan = DocumentPlan::from_events(&events, &engine);
712 let mut session = Session::new();
713 let rendered = plan.render(&engine, &mut session).unwrap();
714
715 assert!(
716 rendered.contains("\n\n"),
717 "Expected paragraph break, got: {rendered}"
718 );
719 }
720
721 #[test]
724 fn document_render_preserves_list_style_cycle_across_paragraphs() {
725 let mut engine = Engine::new(TestLang)
726 .strictness(Strictness::Strict)
727 .variation(Variation::Fixed);
728 engine
729 .register_template(
730 "t",
731 "The {entity_type} {name} touched {items|truncate:1|join}",
732 )
733 .unwrap();
734
735 fn list_ctx(name: &str, first_item: &str) -> Context {
736 let mut ctx = Context::new();
737 ctx.insert("entity_type", Value::String("class".into()));
738 ctx.insert("name", Value::String(name.into()));
739 ctx.insert(
740 "items",
741 Value::List(vec![first_item.into(), "cache".into(), "metrics".into()]),
742 );
743 ctx
744 }
745
746 let events: Vec<(&str, Context)> = vec![
747 ("t", list_ctx("Alpha", "auth")),
748 ("t", list_ctx("Beta", "billing")),
749 ("t", list_ctx("Gamma", "search")),
750 ("t", list_ctx("Delta", "alerts")),
751 ];
752
753 let plan = DocumentPlan::from_events(&events, &engine);
754 let mut session = Session::new();
755 let rendered = plan.render(&engine, &mut session).unwrap();
756
757 assert_eq!(
758 rendered,
759 concat!(
760 "The class Alpha touched including auth among others.\n\n",
761 "The class Beta touched such as billing.\n\n",
762 "The class Gamma touched \u{2014} notably search, plus 2 more.\n\n",
763 "The class Delta touched [alerts, 2 more].",
764 )
765 );
766 assert_eq!(
767 rendered.matches("including ").count(),
768 1,
769 "paragraph resets must not restart every truncated list with the same style: {rendered}",
770 );
771 }
772
773 #[test]
774 fn document_render_does_not_replay_round_robin_variant_after_paragraph_break() {
775 let mut engine = Engine::new(TestLang)
780 .strictness(Strictness::Strict)
781 .variation(Variation::RoundRobin);
782 engine.register_template("t", "First {name}").unwrap();
783 engine.register_template("t", "Second {name}").unwrap();
784 engine.register_template("t", "Third {name}").unwrap();
785
786 let mut c1 = Context::new();
787 c1.insert("entity_type", Value::String("class".into()));
788 c1.insert("name", Value::String("Alpha".into()));
789 let mut c2 = Context::new();
790 c2.insert("entity_type", Value::String("class".into()));
791 c2.insert("name", Value::String("Beta".into()));
792
793 let events: Vec<(&str, Context)> = vec![("t", c1), ("t", c2)];
794 let plan = DocumentPlan::from_events(&events, &engine);
795 assert_eq!(plan.paragraphs.len(), 2);
797
798 let mut session = Session::new();
799 let rendered = plan.render(&engine, &mut session).unwrap();
800
801 assert!(
804 rendered.starts_with("First Alpha"),
805 "expected paragraph 1 to use variant 0: {rendered}"
806 );
807 assert!(
808 rendered.contains("\n\nSecond Beta"),
809 "expected paragraph 2 to advance to variant 1, not replay \"First\": {rendered}"
810 );
811 assert!(
812 !rendered.contains("First Beta"),
813 "round-robin counter must survive the paragraph reset: {rendered}"
814 );
815 }
816
817 #[test]
818 fn document_render_does_not_pronominalize_across_paragraph_break() {
819 let mut engine = Engine::new(TestLang)
824 .strictness(Strictness::Strict)
825 .variation(Variation::Fixed);
826 engine
827 .register_template("p1", "{name} was modified")
828 .unwrap();
829 engine
830 .register_template("p2", "{other} also changed")
831 .unwrap();
832
833 let mut c1 = Context::new();
834 c1.insert("entity_type", Value::String("class".into()));
835 c1.insert("name", Value::String("Foo".into()));
836 let mut c2 = Context::new();
837 c2.insert("entity_type", Value::String("class".into()));
838 c2.insert("other", Value::String("Bar".into()));
839
840 let events: Vec<(&str, Context)> = vec![("p1", c1), ("p2", c2)];
842 let plan = DocumentPlan::from_events(&events, &engine);
843 assert_eq!(plan.paragraphs.len(), 2);
844
845 let mut session = Session::new();
846 let _ = plan.render(&engine, &mut session).unwrap();
847
848 use crate::discourse::ReferenceForm;
853 assert_eq!(
854 session.discourse().reference_form("Foo"),
855 ReferenceForm::Full,
856 "paragraph reset must clear entity table to prevent anaphora leak",
857 );
858 }
859
860 fn ctx_with_entity(name: &str, count: i64) -> Context {
861 let mut c = Context::new();
862 c.insert("entity_type", Value::String("class".into()));
863 c.insert("name", Value::String(name.into()));
864 c.insert("consumer_count", Value::Number(count));
865 c
866 }
867
868 #[test]
869 fn default_classifier_buckets_common_keys() {
870 assert_eq!(
871 default_classifier("code.deleted"),
872 RhetoricalCategory::Removal
873 );
874 assert_eq!(
875 default_classifier("code.removed"),
876 RhetoricalCategory::Removal
877 );
878 assert_eq!(
879 default_classifier("code.added"),
880 RhetoricalCategory::Addition
881 );
882 assert_eq!(
883 default_classifier("code.introduced"),
884 RhetoricalCategory::Addition
885 );
886 assert_eq!(
887 default_classifier("code.modified"),
888 RhetoricalCategory::Modification,
889 );
890 assert_eq!(
891 default_classifier("code.renamed"),
892 RhetoricalCategory::Modification,
893 );
894 assert_eq!(
895 default_classifier("code.signature_changed"),
896 RhetoricalCategory::Modification,
897 );
898 assert_eq!(default_classifier("random"), RhetoricalCategory::Other);
899 assert_eq!(default_classifier(""), RhetoricalCategory::Other);
900 }
901
902 #[test]
903 fn by_action_groups_removals_before_additions_before_modifications() {
904 let engine = test_engine();
905
906 let events: Vec<(&str, Context)> = vec![
907 ("code.modified", ctx_with_entity("A", 1)),
908 ("code.added", ctx_with_entity("B", 1)),
909 ("code.deleted", ctx_with_entity("C", 1)),
910 ];
911
912 let plan = DocumentPlan::from_events_grouped(&events, &engine, GroupingStrategy::ByAction);
913
914 assert_eq!(plan.paragraphs.len(), 3);
916 assert_eq!(
917 plan.paragraphs[0].category,
918 Some(RhetoricalCategory::Removal)
919 );
920 assert_eq!(
921 plan.paragraphs[1].category,
922 Some(RhetoricalCategory::Addition)
923 );
924 assert_eq!(
925 plan.paragraphs[2].category,
926 Some(RhetoricalCategory::Modification)
927 );
928 }
929
930 #[test]
931 fn by_action_splits_paragraphs_within_category_on_entity_change() {
932 let engine = test_engine();
933
934 let events: Vec<(&str, Context)> = vec![
937 ("code.modified", ctx_with_entity("Alpha", 1)),
938 ("code.modified", ctx_with_entity("Beta", 1)),
939 ];
940
941 let plan = DocumentPlan::from_events_grouped(&events, &engine, GroupingStrategy::ByAction);
942
943 assert_eq!(plan.paragraphs.len(), 2);
944 for p in &plan.paragraphs {
945 assert_eq!(p.category, Some(RhetoricalCategory::Modification));
946 assert_eq!(p.events.len(), 1);
947 }
948 }
949
950 #[test]
951 fn by_action_keeps_same_entity_events_together_within_category() {
952 let engine = test_engine();
953
954 let events: Vec<(&str, Context)> = vec![
955 ("code.modified", ctx_with_entity("Alpha", 1)),
956 ("code.renamed", ctx_with_entity("Alpha", 1)),
957 ];
958
959 let plan = DocumentPlan::from_events_grouped(&events, &engine, GroupingStrategy::ByAction);
960
961 assert_eq!(plan.paragraphs.len(), 1);
963 assert_eq!(plan.paragraphs[0].events.len(), 2);
964 }
965
966 #[test]
967 fn from_events_classified_accepts_custom_classifier() {
968 let engine = test_engine();
969
970 let events: Vec<(&str, Context)> = vec![
971 ("issue.closed", ctx_with_entity("Bug1", 1)),
972 ("issue.opened", ctx_with_entity("Bug2", 1)),
973 ];
974
975 let plan = DocumentPlan::from_events_classified(&events, &engine, |key| {
976 match key.rsplit('.').next().unwrap_or("") {
977 "closed" => RhetoricalCategory::Removal,
978 "opened" => RhetoricalCategory::Addition,
979 _ => RhetoricalCategory::Other,
980 }
981 });
982
983 assert_eq!(plan.paragraphs.len(), 2);
984 assert_eq!(
985 plan.paragraphs[0].category,
986 Some(RhetoricalCategory::Removal)
987 );
988 assert_eq!(
989 plan.paragraphs[1].category,
990 Some(RhetoricalCategory::Addition)
991 );
992 }
993
994 #[test]
995 fn by_entity_grouping_still_default() {
996 let engine = test_engine();
997
998 let events: Vec<(&str, Context)> = vec![
999 ("code.modified", ctx_with_entity("Alpha", 1)),
1000 ("code.modified", ctx_with_entity("Alpha", 1)),
1001 ];
1002
1003 let plan = DocumentPlan::from_events(&events, &engine);
1004 assert_eq!(plan.paragraphs.len(), 1);
1007 assert!(plan.paragraphs[0].category.is_none());
1008 }
1009
1010 #[test]
1013 fn paragraph_push_adds_none_relation() {
1014 let mut p = Paragraph::new();
1015 p.push("t".into(), Context::new(), Salience::Low);
1016 assert_eq!(p.relations.len(), 1);
1017 assert_eq!(p.relations[0], None);
1018 }
1019
1020 #[test]
1021 fn paragraph_push_with_relation_records_it() {
1022 let mut p = Paragraph::new();
1023 p.push_with_relation(
1024 "t".into(),
1025 Context::new(),
1026 Salience::Low,
1027 Some(RstRelation::Contrast),
1028 );
1029 assert_eq!(p.relations, vec![Some(RstRelation::Contrast)]);
1030 }
1031
1032 #[test]
1033 fn paragraph_relations_len_matches_events_len() {
1034 let mut p = Paragraph::new();
1035 p.push("t".into(), Context::new(), Salience::Low);
1036 p.push_with_relation(
1037 "t".into(),
1038 Context::new(),
1039 Salience::Low,
1040 Some(RstRelation::Elaboration),
1041 );
1042 p.push("t".into(), Context::new(), Salience::Medium);
1043 assert_eq!(p.events.len(), p.relations.len());
1044 assert_eq!(p.relations.len(), 3);
1045 }
1046
1047 #[test]
1050 fn from_events_with_relations_threads_rel() {
1051 let engine = test_engine();
1052 let events = vec![
1053 ("t", ctx_with_entity("Foo", 1), None),
1054 (
1055 "t",
1056 ctx_with_entity("Foo", 1),
1057 Some(RstRelation::Elaboration),
1058 ),
1059 ];
1060 let plan = DocumentPlan::from_events_with_relations(&events, &engine);
1061 assert_eq!(plan.paragraphs.len(), 1);
1062 assert_eq!(plan.paragraphs[0].relations[0], None);
1063 assert_eq!(
1064 plan.paragraphs[0].relations[1],
1065 Some(RstRelation::Elaboration)
1066 );
1067 }
1068
1069 #[test]
1070 fn relations_are_dropped_at_paragraph_boundary() {
1071 let engine = test_engine();
1072 let events = vec![
1075 ("t", ctx_with_entity("Foo", 1), None),
1076 ("t", ctx_with_entity("Bar", 1), Some(RstRelation::Contrast)),
1077 ];
1078 let plan = DocumentPlan::from_events_with_relations(&events, &engine);
1079 assert_eq!(plan.paragraphs.len(), 2);
1080 for p in &plan.paragraphs {
1082 assert_eq!(p.relations, vec![None]);
1083 }
1084 }
1085
1086 #[cfg(feature = "time")]
1091 #[test]
1092 fn document_plan_temporal_anchor_spans_paragraphs() {
1093 let mut engine = Engine::new(TestLang)
1094 .strictness(Strictness::Strict)
1095 .variation(Variation::Fixed)
1096 .reference_time(1_700_000_000);
1097 engine
1098 .register_template("t", "{name} changed {ts|since_last}")
1099 .unwrap();
1100
1101 let t1: i64 = 1_700_000_000;
1102 let t2: i64 = t1 + 86400;
1103
1104 let mut c1 = ctx_with_entity("Foo", 1);
1105 c1.insert("ts", Value::Number(t1));
1106 c1.insert("timestamp", Value::Number(t1));
1107
1108 let mut c2 = ctx_with_entity("Bar", 1);
1109 c2.insert("ts", Value::Number(t2));
1110 c2.insert("timestamp", Value::Number(t2));
1111
1112 let events: Vec<(&str, Context)> = vec![("t", c1), ("t", c2)];
1113 let plan = DocumentPlan::from_events(&events, &engine);
1114 assert_eq!(plan.paragraphs.len(), 2);
1116
1117 let mut s = Session::new();
1118 let out = plan.render(&engine, &mut s).unwrap();
1119
1120 assert!(out.contains("the next day"), "got: {out}");
1123 }
1124
1125 #[test]
1126 fn document_render_uses_marker_when_paragraph_has_relation() {
1127 let mut engine = test_engine();
1128 engine
1129 .register_template("t", "The class {name} was modified")
1130 .unwrap();
1131
1132 let events = vec![
1133 ("t", ctx_with_entity("Foo", 1), None),
1134 ("t", ctx_with_entity("Foo", 1), Some(RstRelation::Contrast)),
1135 ];
1136 let plan = DocumentPlan::from_events_with_relations(&events, &engine);
1137 let mut s = Session::new();
1138 let rendered = plan.render(&engine, &mut s).unwrap();
1139 assert!(rendered.contains("However, "), "got: {rendered}");
1140 }
1141
1142 #[test]
1145 fn engine_and_session_are_send_sync() {
1146 fn assert_send_sync<T: Send + Sync>() {}
1147 assert_send_sync::<Engine>();
1148 assert_send_sync::<Session>();
1149 }
1150
1151 #[cfg(feature = "parallel")]
1152 #[test]
1153 fn render_parallel_produces_same_output_for_independent_paragraphs() {
1154 let mut engine = Engine::new(TestLang)
1157 .strictness(Strictness::Strict)
1158 .variation(Variation::Fixed);
1159 engine.register_template("t", "{name} changed").unwrap();
1160
1161 let events: Vec<(&str, Context)> = vec![
1162 ("t", ctx_with_entity("Alpha", 1)),
1163 ("t", ctx_with_entity("Beta", 1)),
1164 ];
1165 let plan = DocumentPlan::from_events(&events, &engine);
1166
1167 let mut s1 = Session::new();
1168 let seq = plan.render(&engine, &mut s1).unwrap();
1169
1170 let s2 = Session::new();
1171 let par = plan.render_parallel(&engine, &s2).unwrap();
1172
1173 assert_eq!(seq, par);
1174 }
1175
1176 #[cfg(feature = "parallel")]
1177 #[test]
1178 fn render_parallel_empty_plan_returns_empty_string() {
1179 let engine = test_engine();
1180 let plan = DocumentPlan::new();
1181 let s = Session::new();
1182 let out = plan.render_parallel(&engine, &s).unwrap();
1183 assert_eq!(out, "");
1184 }
1185
1186 #[cfg(feature = "parallel")]
1187 #[test]
1188 fn render_parallel_single_paragraph_matches_sequential() {
1189 let engine = test_engine();
1190 let events: Vec<(&str, Context)> = vec![("t", ctx_with_entity("Foo", 5))];
1191 let plan = DocumentPlan::from_events(&events, &engine);
1192
1193 let mut s1 = Session::new();
1194 let seq = plan.render(&engine, &mut s1).unwrap();
1195
1196 let s2 = Session::new();
1197 let par = plan.render_parallel(&engine, &s2).unwrap();
1198
1199 assert_eq!(seq, par);
1200 }
1201}