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(|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                // 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.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 (&current_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        // Sort paragraphs by highest salience first (stable to preserve tie order)
367        plan.paragraphs.sort_by(|a, b| b.salience.cmp(&a.salience));
368
369        plan
370    }
371
372    /// Render paragraphs in parallel using rayon.
373    ///
374    /// Each paragraph gets its own freshly-reset clone of `initial_session`.
375    /// Paragraphs render concurrently and the results are joined with `"\n\n"`
376    /// in the original paragraph order.
377    ///
378    /// **Trade-off:** temporal-anchor threading and auto list-style rotation
379    /// across paragraphs are lost; each paragraph renders from an independently
380    /// cloned session. For coherent narratives (e.g. when you rely on
381    /// `{ts|since_last}` or cross-paragraph `{items|join}` variety) use
382    /// [`render`][DocumentPlan::render] instead.
383    ///
384    /// For paragraphs independent of temporal anchors and auto list-style
385    /// cycling this produces byte-identical output to the sequential `render`.
386    ///
387    /// Requires the `parallel` feature.
388    #[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                // Reset paragraph-local discourse while preserving the
406                // initial session's narrative-level style seed.
407                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    /// Render the document plan into a narrative.
433    ///
434    /// Paragraphs are separated by a double newline. Between paragraphs the
435    /// paragraph-local discourse state is reset so pronouns don't span paragraph
436    /// boundaries, but narrative-level style rotation is preserved.
437    ///
438    /// When any event in a paragraph carries an RST relation, the paragraph
439    /// is rendered via [`Engine::render_batch_with_relations`] which inserts
440    /// discourse markers ("Furthermore, ", "However, ", etc.) between events.
441    /// Paragraphs whose relations are all `None` fall back to the standard
442    /// [`Engine::render_batch`] path so aggregation still applies.
443    /// Render this plan into a [`RenderedDocument`] — the structured
444    /// intermediate consumed by retrospective-pass diagnosers and the
445    /// composite scorer.
446    ///
447    /// Behaviorally identical to [`Self::render`] for the **flat text**:
448    /// `render_structured(engine, session)?.text == render(engine, session)?`
449    /// holds when no inter-event gapping (forward conjunction reduction)
450    /// applies inside any paragraph. When gapping does apply, this method
451    /// produces sentence-by-sentence text without the gapped form, which
452    /// is what diagnosers want — they reason at sentence granularity, not
453    /// at the gapped-clause level. Callers that need the gapped flat
454    /// string should keep using `render`.
455    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    /// Run the retrospective refine loop over this plan. Equivalent to
490    /// [`Self::render`] when the engine's [`crate::RefineConfig`] is off,
491    /// otherwise iterates with structural diagnosers per the loop spec.
492    /// Always produces a complete output; faithfulness-failing iterations
493    /// are silently rejected and the loop falls back to the previous best.
494    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
565/// Extract the primary entity-name key from a render context.
566fn 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
572// Verify Engine and Session are Send + Sync at compile time.
573// This is a zero-cost assertion; the const fn is never called.
574const 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        // Highest salience paragraph comes first
690        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    // ── Rhetorical grouping ──────────────────────────────────────────────
720
721    #[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        // RoundRobin variation cycles through registered variants by index.
774        // Before the paragraph-reset fix, `Session::reset_for_paragraph` wiped
775        // the per-key counter, so paragraph 2's first event always re-picked
776        // index 0 — replaying paragraph 1's opener verbatim.
777        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        // Different entities → two paragraphs.
794        assert_eq!(plan.paragraphs.len(), 2);
795
796        let mut session = Session::new();
797        let rendered = plan.render(&engine, &mut session).unwrap();
798
799        // Paragraph 1 → "First Alpha"; paragraph 2 must rotate to the next
800        // variant, not restart at "First Beta".
801        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        // Paragraph 1 establishes Foo as the focus entity. After the
818        // paragraph break the entity table is cleared, so paragraph 2's
819        // reference to Foo must reintroduce it in full form rather than
820        // resolve as the lingering pronoun antecedent.
821        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        // Two distinct entity contexts → two paragraphs.
839        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        // After the second paragraph renders, Foo's prior-paragraph mention
847        // must NOT survive in the entity table — querying the discourse
848        // state for Foo must return Full so any subsequent reference would
849        // reintroduce the entity rather than emit a stranded pronoun.
850        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        // Removal first, then Addition, then Modification.
913        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        // Two modifications, different entities → two paragraphs in the
933        // Modification section so pronouns don't cross-link them.
934        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        // Both are Modification category, same entity → one paragraph, two events.
960        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        // Default remains the ByEntity strategy — same-entity consecutive
1003        // events end up in one paragraph.
1004        assert_eq!(plan.paragraphs.len(), 1);
1005        assert!(plan.paragraphs[0].category.is_none());
1006    }
1007
1008    // ── Phase 3: Paragraph relations ────────────────────────────────────────
1009
1010    #[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    // ── Phase 4: from_events_with_relations ─────────────────────────────────
1046
1047    #[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        // Different entities → two paragraphs; the relation on e2 is dropped
1071        // because e2 starts a new paragraph.
1072        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        // Both paragraphs have a single event with None relation.
1079        for p in &plan.paragraphs {
1080            assert_eq!(p.relations, vec![None]);
1081        }
1082    }
1083
1084    // ── Phase 6: DocumentPlan::render with relations ─────────────────────────
1085
1086    // ── Temporal anchor spans paragraphs ────────────────────────────────────
1087
1088    #[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        // Two different entities → two paragraphs.
1113        assert_eq!(plan.paragraphs.len(), 2);
1114
1115        let mut s = Session::new();
1116        let out = plan.render(&engine, &mut s).unwrap();
1117
1118        // The temporal anchor threads through session.reset() between paragraphs —
1119        // Bar's paragraph reads "the next day", not an absolute-now-based phrase.
1120        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    // ── Phase 3: parallel rendering ─────────────────────────────────────────
1141
1142    #[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        // When paragraphs don't need temporal threading, parallel and sequential
1153        // must produce byte-identical output.
1154        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}