Skip to main content

prosaic_core/
document.rs

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/// Rhetorical classification of an event based on its template key.
14///
15/// Used by [`DocumentPlan::from_events_grouped`] to organize a batch of
16/// events into thematic sections — a breaking-changes paragraph, an
17/// additions paragraph, etc. — instead of the default same-entity grouping.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
19#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
20pub enum RhetoricalCategory {
21    /// Deletions, removals — typically breaking changes. Leads the narrative.
22    Removal,
23    /// New entities, features, introductions.
24    Addition,
25    /// Modifications, renames, moves, signature changes — existing code
26    /// that was altered.
27    Modification,
28    /// Anything the default classifier doesn't recognize.
29    Other,
30}
31
32/// How events should be grouped into paragraphs.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
34#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
35pub enum GroupingStrategy {
36    /// Group consecutive events that share an entity name; sort paragraphs
37    /// by highest salience first. This is the default and produces tight,
38    /// entity-focused narratives.
39    #[default]
40    ByEntity,
41    /// Group events by rhetorical category (removals, additions, each
42    /// modification sub-type). Produces section-style narratives useful
43    /// for release notes or high-level change summaries. Within a
44    /// category, events are further grouped by entity so multiple changes
45    /// to the same entity still flow together.
46    ByAction,
47}
48
49/// Default template-key classifier used by [`GroupingStrategy::ByAction`].
50///
51/// Looks at the last dotted segment of the key and maps well-known action
52/// names to [`RhetoricalCategory`]:
53///
54/// - `deleted`, `removed` → [`RhetoricalCategory::Removal`]
55/// - `added`, `created`, `introduced` → [`RhetoricalCategory::Addition`]
56/// - `modified`, `updated`, `renamed`, `moved`, `signature_changed` →
57///   [`RhetoricalCategory::Modification`]
58/// - anything else → [`RhetoricalCategory::Other`]
59///
60/// Use [`DocumentPlan::from_events_classified`] to supply a custom
61/// classifier when the defaults don't fit the domain.
62pub 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
74/// Convention-ordered list of categories (Removal first — breaking changes
75/// are usually the most important signal in a change report).
76fn category_order() -> [RhetoricalCategory; 4] {
77    [
78        RhetoricalCategory::Removal,
79        RhetoricalCategory::Addition,
80        RhetoricalCategory::Modification,
81        RhetoricalCategory::Other,
82    ]
83}
84
85/// A paragraph in a document plan — a group of related events rendered together.
86#[derive(Debug, Clone)]
87pub struct Paragraph {
88    /// The events in this paragraph, in render order.
89    pub events: Vec<(String, Context)>,
90    /// Optional rhetorical relation for each event. `relations[i]` describes
91    /// the relation between `events[i]` and `events[i-1]`. `relations[0]`
92    /// is conventionally `None` (no predecessor within the paragraph).
93    /// Same length as `events`.
94    pub relations: Vec<Option<RstRelation>>,
95    /// The highest salience in this paragraph, used for ordering.
96    pub salience: Salience,
97    /// Rhetorical category, when the plan was built with
98    /// [`GroupingStrategy::ByAction`]. `None` for entity-grouped plans.
99    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    /// Push an event with no rhetorical relation (`None`).
113    pub fn push(&mut self, key: String, ctx: Context, salience: Salience) {
114        self.push_with_relation(key, ctx, salience, None);
115    }
116
117    /// Push an event with an optional RST relation to its predecessor.
118    ///
119    /// The relation describes how this event relates to the immediately
120    /// preceding event in the same paragraph. Pass `None` when there is
121    /// no predecessor or when the rhetorical link is unknown.
122    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/// A planned document — a structured narrative with paragraph breaks.
148///
149/// Use `DocumentPlan::from_events` to auto-organize events by salience and
150/// entity groupings, then `.render(&engine)` to produce the final narrative.
151#[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    /// Build a document plan from a flat set of events, using the default
164    /// entity-grouping strategy.
165    ///
166    /// Organization:
167    /// 1. Assign each event a salience (from context or explicit thresholds).
168    /// 2. Group consecutive events that share an entity into the same paragraph.
169    /// 3. Order paragraphs by highest-salience first.
170    ///
171    /// Within a paragraph, events keep their original order (which the engine's
172    /// discourse state can then leverage for pronouns and connectives).
173    ///
174    /// To group by action category instead, use
175    /// [`DocumentPlan::from_events_grouped`] or
176    /// [`DocumentPlan::from_events_classified`].
177    pub fn from_events(events: &[(&str, Context)], engine: &Engine) -> Self {
178        Self::from_events_grouped(events, engine, GroupingStrategy::ByEntity)
179    }
180
181    /// Build a document plan with an explicit grouping strategy.
182    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    /// Build a [`GroupingStrategy::ByAction`] plan with a custom classifier.
196    /// Useful when template keys don't match the default classifier's
197    /// vocabulary (e.g., domain-specific verbs like `"issue.closed"`).
198    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        // Bucket events by category, preserving input order within each.
212        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        // Walk categories in the canonical rhetorical order. For each
224        // non-empty bucket, sub-group by entity (so multiple changes to
225        // the same thing still cluster together) and emit a paragraph.
226        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            // Sort within the bucket so that events sharing an entity are
237            // adjacent — stable to preserve user-provided ordering among
238            // entity-free events.
239            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                // When the entity changes within a category, flush the
247                // current paragraph and start a new one. This keeps
248                // pronouns/connectives working within a run of same-entity
249                // events and avoids awkward co-reference across unrelated
250                // entities inside one paragraph.
251                let same_entity = match (&current_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        // Any categories Left over (shouldn't happen since we iterate all
272        // four, but defensive): append in arbitrary but stable order.
273        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    /// Build a [`GroupingStrategy::ByEntity`] plan where each event carries
287    /// an optional RST relation describing its rhetorical link to the
288    /// preceding event *within the same paragraph*.
289    ///
290    /// Events that start a new paragraph (different entity) have their
291    /// relation silently dropped — relations are meaningful only within
292    /// a paragraph, not across paragraph boundaries.
293    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 (&current_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            // When starting a new paragraph (current is empty) the relation
320            // doesn't apply — cross-paragraph relations aren't supported.
321            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 (&current_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        // Sort paragraphs by highest salience first (stable to preserve tie order)
368        plan.paragraphs
369            .sort_by_key(|paragraph| core::cmp::Reverse(paragraph.salience));
370
371        plan
372    }
373
374    /// Render paragraphs in parallel using rayon.
375    ///
376    /// Each paragraph gets its own freshly-reset clone of `initial_session`.
377    /// Paragraphs render concurrently and the results are joined with `"\n\n"`
378    /// in the original paragraph order.
379    ///
380    /// **Trade-off:** temporal-anchor threading and auto list-style rotation
381    /// across paragraphs are lost; each paragraph renders from an independently
382    /// cloned session. For coherent narratives (e.g. when you rely on
383    /// `{ts|since_last}` or cross-paragraph `{items|join}` variety) use
384    /// [`render`][DocumentPlan::render] instead.
385    ///
386    /// For paragraphs independent of temporal anchors and auto list-style
387    /// cycling this produces byte-identical output to the sequential `render`.
388    ///
389    /// Requires the `parallel` feature.
390    #[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                // Reset paragraph-local discourse while preserving the
408                // initial session's narrative-level style seed.
409                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    /// Render the document plan into a narrative.
435    ///
436    /// Paragraphs are separated by a double newline. Between paragraphs the
437    /// paragraph-local discourse state is reset so pronouns don't span paragraph
438    /// boundaries, but narrative-level style rotation is preserved.
439    ///
440    /// When any event in a paragraph carries an RST relation, the paragraph
441    /// is rendered via [`Engine::render_batch_with_relations`] which inserts
442    /// discourse markers ("Furthermore, ", "However, ", etc.) between events.
443    /// Paragraphs whose relations are all `None` fall back to the standard
444    /// [`Engine::render_batch`] path so aggregation still applies.
445    /// Render this plan into a [`RenderedDocument`] — the structured
446    /// intermediate consumed by retrospective-pass diagnosers and the
447    /// composite scorer.
448    ///
449    /// Behaviorally identical to [`Self::render`] for the **flat text**:
450    /// `render_structured(engine, session)?.text == render(engine, session)?`
451    /// holds when no inter-event gapping (forward conjunction reduction)
452    /// applies inside any paragraph. When gapping does apply, this method
453    /// produces sentence-by-sentence text without the gapped form, which
454    /// is what diagnosers want — they reason at sentence granularity, not
455    /// at the gapped-clause level. Callers that need the gapped flat
456    /// string should keep using `render`.
457    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    /// Run the retrospective refine loop over this plan. Equivalent to
492    /// [`Self::render`] when the engine's [`crate::RefineConfig`] is off,
493    /// otherwise iterates with structural diagnosers per the loop spec.
494    /// Always produces a complete output; faithfulness-failing iterations
495    /// are silently rejected and the loop falls back to the previous best.
496    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
567/// Extract the primary entity-name key from a render context.
568fn 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
574// Verify Engine and Session are Send + Sync at compile time.
575// This is a zero-cost assertion; the const fn is never called.
576const 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        // Highest salience paragraph comes first
692        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    // ── Rhetorical grouping ──────────────────────────────────────────────
722
723    #[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        // RoundRobin variation cycles through registered variants by index.
776        // Before the paragraph-reset fix, `Session::reset_for_paragraph` wiped
777        // the per-key counter, so paragraph 2's first event always re-picked
778        // index 0 — replaying paragraph 1's opener verbatim.
779        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        // Different entities → two paragraphs.
796        assert_eq!(plan.paragraphs.len(), 2);
797
798        let mut session = Session::new();
799        let rendered = plan.render(&engine, &mut session).unwrap();
800
801        // Paragraph 1 → "First Alpha"; paragraph 2 must rotate to the next
802        // variant, not restart at "First Beta".
803        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        // Paragraph 1 establishes Foo as the focus entity. After the
820        // paragraph break the entity table is cleared, so paragraph 2's
821        // reference to Foo must reintroduce it in full form rather than
822        // resolve as the lingering pronoun antecedent.
823        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        // Two distinct entity contexts → two paragraphs.
841        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        // After the second paragraph renders, Foo's prior-paragraph mention
849        // must NOT survive in the entity table — querying the discourse
850        // state for Foo must return Full so any subsequent reference would
851        // reintroduce the entity rather than emit a stranded pronoun.
852        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        // Removal first, then Addition, then Modification.
915        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        // Two modifications, different entities → two paragraphs in the
935        // Modification section so pronouns don't cross-link them.
936        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        // Both are Modification category, same entity → one paragraph, two events.
962        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        // Default remains the ByEntity strategy — same-entity consecutive
1005        // events end up in one paragraph.
1006        assert_eq!(plan.paragraphs.len(), 1);
1007        assert!(plan.paragraphs[0].category.is_none());
1008    }
1009
1010    // ── Phase 3: Paragraph relations ────────────────────────────────────────
1011
1012    #[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    // ── Phase 4: from_events_with_relations ─────────────────────────────────
1048
1049    #[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        // Different entities → two paragraphs; the relation on e2 is dropped
1073        // because e2 starts a new paragraph.
1074        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        // Both paragraphs have a single event with None relation.
1081        for p in &plan.paragraphs {
1082            assert_eq!(p.relations, vec![None]);
1083        }
1084    }
1085
1086    // ── Phase 6: DocumentPlan::render with relations ─────────────────────────
1087
1088    // ── Temporal anchor spans paragraphs ────────────────────────────────────
1089
1090    #[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        // Two different entities → two paragraphs.
1115        assert_eq!(plan.paragraphs.len(), 2);
1116
1117        let mut s = Session::new();
1118        let out = plan.render(&engine, &mut s).unwrap();
1119
1120        // The temporal anchor threads through session.reset() between paragraphs —
1121        // Bar's paragraph reads "the next day", not an absolute-now-based phrase.
1122        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    // ── Phase 3: parallel rendering ─────────────────────────────────────────
1143
1144    #[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        // When paragraphs don't need temporal threading, parallel and sequential
1155        // must produce byte-identical output.
1156        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}