Skip to main content

prosaic_core/
engine.rs

1use core::sync::atomic::{AtomicUsize, Ordering};
2
3#[cfg(not(feature = "std"))]
4use alloc::boxed::Box;
5#[cfg(not(feature = "std"))]
6use alloc::format;
7#[cfg(not(feature = "std"))]
8use alloc::string::{String, ToString};
9#[cfg(not(feature = "std"))]
10use alloc::vec;
11#[cfg(not(feature = "std"))]
12use alloc::vec::Vec;
13
14use crate::collections::{HashMap, HashSet, new_map, new_set};
15
16use crate::faithfulness::score_faithfulness;
17use crate::session::Session;
18
19use crate::agreement::AgreementFeatures;
20use crate::antonyms::{AntonymRegistry, insert_not};
21use crate::context::{Context, IntoContext, Value};
22use crate::discourse::{ListStyle, ReferenceForm, Transition};
23use crate::error::ProsaicError;
24use crate::hedge::{HedgeMode, hedge as hedge_fn, parse_mode as parse_hedge_mode};
25use crate::language::{Conjunction, Language, Person, PluralCategory, VerbForm};
26#[cfg(feature = "polish")]
27use crate::length::split_long_in_place;
28#[cfg(feature = "polish")]
29use crate::punctuation::smart_quotes_in_place;
30use crate::quantify::{QuantifyMode, parse_mode as parse_quantify_mode, quantify as quantify_fn};
31#[cfg(feature = "reg")]
32use crate::reg::{
33    EntityDescriptor, EntityRegistry, distinguishing_attributes, distinguishing_subgraph,
34};
35use crate::salience::{Salience, SalienceThresholds};
36use crate::synonyms::SynonymRegistry;
37use crate::template::{Pipe, PipeArg, Segment, Template};
38#[cfg(feature = "time")]
39use crate::time::format_relative;
40
41/// Controls how missing slots are handled during rendering.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
43pub enum Strictness {
44    /// Missing slot produces an error.
45    #[default]
46    Strict,
47    /// Missing slot renders as `[missing: slot_name]`.
48    Lenient,
49    /// Missing slot renders as an empty string.
50    Silent,
51}
52
53/// Controls how template alternatives are selected.
54///
55/// `Fixed` and `RoundRobin` are literal: they honour the contract exactly
56/// (first alternative every time / strict rotation in registration order).
57/// `Seeded` and `Random` additionally layer discourse-aware choose-best
58/// scoring on top, so candidates that repeat recent words or flatten recent
59/// sentence cadence are penalised.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
61pub enum Variation {
62    /// Always pick the first registered template, every render.
63    #[default]
64    Fixed,
65    /// Deterministic hash-based selection. Same seed + key = same index.
66    /// Further refined by discourse-aware choose-best scoring.
67    Seeded(u64),
68    /// Cycle through alternatives in registration order, strictly.
69    RoundRobin,
70    /// Select randomly (non-deterministic). Layered with choose-best scoring.
71    Random,
72}
73
74/// Selects the REG (Referring Expression Generation) algorithm used by
75/// the `{name|refer}` pipe when rendering the Full form of a reference.
76///
77/// The default is [`DaleReiter`][RegAlgorithm::DaleReiter], which matches
78/// the historical behaviour of the engine.
79#[cfg(feature = "reg")]
80#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
81#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
82pub enum RegAlgorithm {
83    /// Dale & Reiter 1995 Incremental Algorithm.
84    ///
85    /// Disambiguates same-type entities using unary attributes only —
86    /// e.g. "the domain class UserService" vs "the infra class AuthService".
87    /// Fast, well-understood, and the default.
88    #[default]
89    DaleReiter,
90    /// Krahmer et al. 2003 graph-based greedy algorithm.
91    ///
92    /// Handles both unary attributes AND binary relations between entities.
93    /// When attributes alone do not disambiguate, the algorithm appends one
94    /// relation clause — e.g. "the api function LoginHandler that calls
95    /// AuthService". Falls back silently to D&R behaviour when no relations
96    /// are registered.
97    GraphBased,
98}
99
100/// A template registered under a key, with its salience level plus
101/// optional BCP-47 language and free-form style tags. Untagged variants
102/// are fallbacks; tagged variants are filtered to match the engine's
103/// configured preferences.
104#[derive(Debug, Clone)]
105pub struct SalientTemplate {
106    pub salience: Salience,
107    pub template: Template,
108    pub language: Option<String>,
109    pub style: Option<String>,
110}
111
112impl SalientTemplate {
113    pub fn new(
114        salience: Salience,
115        template: Template,
116        language: Option<String>,
117        style: Option<String>,
118    ) -> Self {
119        Self {
120            salience,
121            template,
122            language,
123            style,
124        }
125    }
126}
127
128/// Per-render diagnostics — everything the engine decided along the
129/// way to produce the final output. Returned by
130/// [`Engine::render_explained`]. Useful for template-author debugging
131/// ("why did variant B win?") and for vocab-module linting.
132#[derive(Debug, Clone)]
133#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
134pub struct RenderExplanation {
135    /// The final rendered string after all post-processing.
136    pub output: String,
137    /// Template key that was rendered.
138    pub template_key: String,
139    /// Index (within the salience-filtered alternative set) of the
140    /// variant that was emitted.
141    pub variant_index: usize,
142    /// Source string of the selected variant.
143    pub variant_source: String,
144    /// Salience bucket used for filtering alternatives.
145    pub salience: Salience,
146    /// Choose-best discourse scores for each alternative that was considered
147    /// (in the same order as the filtered alternative set). `None` when
148    /// choose-best wasn't applicable (first render, or Fixed / RoundRobin
149    /// variation).
150    pub candidate_scores: Option<Vec<f64>>,
151    /// Reference form chosen by `{name|refer}` on the primary entity,
152    /// if a refer pipe fired.
153    pub reference_form: Option<ReferenceForm>,
154    /// Discourse connective prepended to the output, if any.
155    pub connective: Option<&'static str>,
156    /// List style used, if a join pipe fired.
157    pub list_style: Option<ListStyle>,
158    /// Whether the focus subject was plural (compound) at render time.
159    pub focus_is_plural: bool,
160    /// Whether the sentence was split by the length-budgeting pass.
161    pub length_split_applied: bool,
162    /// Whether the silent-mode cleanup stripped any trailing orphan
163    /// words from the output.
164    pub cleanup_stripped_tail: bool,
165    /// Centering Theory transition class for this render. Reflects how the
166    /// discourse center moved relative to the previous render. `NoCb` on the
167    /// first render or after a session reset.
168    pub centering_transition: Transition,
169}
170
171/// Iterator returned by [`Engine::render_iter`]. Wraps the batch
172/// rendering logic so callers can consume the output sentence-by-sentence
173/// without waiting for the full batch to complete.
174pub struct RenderIter<'a> {
175    engine: &'a Engine,
176    session: &'a mut Session,
177    events: &'a [(&'a str, Context)],
178    i: usize,
179}
180
181impl<'a> Iterator for RenderIter<'a> {
182    type Item = Result<String, ProsaicError>;
183
184    fn next(&mut self) -> Option<Self::Item> {
185        if self.i >= self.events.len() {
186            return None;
187        }
188
189        // Terminal-error helper: any render failure forces the iterator
190        // to report `None` on subsequent calls. Continuing past an error
191        // would replay the same failing run and — inside aggregated or
192        // gapping runs — compound session state from earlier successful
193        // renders. See `render_iter`'s doc comment.
194        let fail = |this: &mut RenderIter<'_>, e: ProsaicError| -> Option<Self::Item> {
195            this.i = this.events.len();
196            Some(Err(e))
197        };
198
199        // Mirror the logic in `render_batch` but emit one sentence per
200        // `.next()` call.
201        let action_end = self.engine.find_same_action_run(self.events, self.i);
202        if action_end > self.i + 1 {
203            let key = self.events[self.i].0;
204            let run = &self.events[self.i..action_end];
205            let sentence = match self
206                .engine
207                .render_aggregated_subjects(self.session, key, run)
208            {
209                Ok(s) => s,
210                Err(e) => return fail(self, e),
211            };
212            self.i = action_end;
213            return Some(Ok(sentence));
214        }
215
216        // Gapping: same template key, different subjects, incompatible
217        // non-subject context (different objects/complements).
218        let gap_end = self.engine.find_gapping_run(self.events, self.i);
219        if gap_end > self.i + 1 {
220            let mut rendered: Vec<String> = Vec::with_capacity(gap_end - self.i);
221            for (key, ctx) in &self.events[self.i..gap_end] {
222                match self.engine.render(self.session, key, ctx) {
223                    Ok(s) => rendered.push(s),
224                    Err(e) => return fail(self, e),
225                }
226            }
227            self.i = gap_end;
228            if let Some(gapped) = reduce_gapping(&rendered) {
229                return Some(Ok(gapped));
230            }
231            return Some(Ok(rendered.join(" ")));
232        }
233
234        let entity_end = self.engine.find_same_entity_run(self.events, self.i);
235        if entity_end > self.i + 1 {
236            let mut run_rendered: Vec<String> = Vec::with_capacity(entity_end - self.i);
237            for (key, ctx) in &self.events[self.i..entity_end] {
238                match self.engine.render(self.session, key, ctx) {
239                    Ok(s) => run_rendered.push(s),
240                    Err(e) => return fail(self, e),
241                }
242            }
243            self.i = entity_end;
244            if let Some(reduced) = reduce_same_entity_clauses(&run_rendered) {
245                return Some(Ok(reduced));
246            }
247            // No reduction: emit the first, stash the rest back into the
248            // queue by rewinding `i`. Simpler: join with spaces so this
249            // single `.next()` still corresponds to the same logical run.
250            return Some(Ok(run_rendered.join(" ")));
251        }
252
253        let (key, ctx) = &self.events[self.i];
254        self.i += 1;
255        match self.engine.render(self.session, key, ctx) {
256            Ok(s) => Some(Ok(s)),
257            Err(e) => {
258                self.i = self.events.len();
259                Some(Err(e))
260            }
261        }
262    }
263}
264
265/// Diagnostic output from [`Engine::score_variants`]: one entry per
266/// variant that would be considered for the given key and context, with
267/// the choose-best score the engine would assign and a flag marking the
268/// variant that `render()` would currently emit.
269#[derive(Debug, Clone)]
270#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
271pub struct VariantScore {
272    /// Index within the salience-filtered alternative set.
273    pub index: usize,
274    /// Original template source string.
275    pub source: String,
276    /// What the variant renders to with the given context.
277    pub rendered: String,
278    /// Choose-best discourse score; combines repetition and cadence penalties.
279    /// Lower is better.
280    pub score: f64,
281    /// Salience bucket this variant was registered at.
282    pub salience: Salience,
283    /// Whether this variant was the most recently selected for the same
284    /// key (anti-repeat will try to avoid it on the next render).
285    pub is_last_selected: bool,
286    /// Whether `render()` would emit this variant right now.
287    pub selected: bool,
288}
289
290/// The core NLG engine. Holds a language implementation, template registry,
291/// and immutable configuration. All per-render mutable state lives in
292/// [`Session`], which callers pass into render methods.
293pub struct Engine {
294    language: Box<dyn Language>,
295    templates: HashMap<String, Vec<SalientTemplate>>,
296    strictness: Strictness,
297    variation: Variation,
298    salience_thresholds: SalienceThresholds,
299    /// Per-key initial counters for RoundRobin variation. These are
300    /// initialized at `register_template` time and read-only thereafter;
301    /// the live counter lives in `Session::round_robin_counters`.
302    rr_initial: HashMap<String, usize>,
303    #[cfg(feature = "reg")]
304    entity_registry: EntityRegistry,
305    #[cfg(feature = "reg")]
306    reg_preference: Vec<String>,
307    #[cfg(feature = "reg")]
308    reg_algorithm: RegAlgorithm,
309    synonyms: SynonymRegistry,
310    #[cfg(feature = "time")]
311    reference_time: Option<i64>,
312    antonyms: AntonymRegistry,
313    #[cfg(feature = "polish")]
314    max_sentence_length: Option<usize>,
315    #[cfg(feature = "polish")]
316    smart_quotes: bool,
317    /// When `true`, choose-best scoring layers a sentence-rhythm cadence
318    /// penalty on top of word-repetition. Defaults to `true` so prose
319    /// generated under `Variation::Seeded`/`Random` exhibits human-like
320    /// burstiness rather than a flat mid-length cadence.
321    sentence_rhythm_enabled: bool,
322    partials: HashMap<String, Template>,
323    /// BCP-47 language code that variant selection should prefer when
324    /// language-tagged variants are registered for a key. `None` means
325    /// no preference (engine picks among all alternatives).
326    language_preference: Option<String>,
327    /// Free-form style tag that variant selection should prefer when
328    /// style-tagged variants are registered for a key. `None` means no
329    /// explicit style preference; unstyled variants remain preferred
330    /// over styled variants as the conservative default.
331    style_preference: Option<String>,
332    /// Optional faithfulness gate. When `Some(threshold)`, each rendered output
333    /// is scored via PARENT precision + polarity check. If the score does not
334    /// pass the threshold or polarity mismatches, the render returns
335    /// `ProsaicError::FaithfulnessRejection` and session state is restored.
336    faithfulness_threshold: Option<f32>,
337    /// Declarative voice profile that biases the engine's existing rendering
338    /// choices. `StyleProfile::neutral()` is the byte-for-byte-equivalent
339    /// baseline: an engine with the neutral profile produces identical
340    /// output to one with no profile applied.
341    style_profile: crate::style::StyleProfile,
342    /// Retrospective-pass configuration. `RefineConfig::off()` (the
343    /// default) is a no-op; `DocumentPlan::render` produces byte-identical
344    /// output to its non-refined form.
345    refine_config: crate::refine::RefineConfig,
346}
347
348/// Per-call render options used by internal paths that need to suppress
349/// specific engine behaviours. Not part of the public API — exposed only
350/// through wrapping methods like `render_batch_with_relations`.
351#[derive(Debug, Clone, Copy, Default)]
352struct RenderOptions {
353    /// Skip automatic discourse connective selection and prepending.
354    /// The session's `connective_history` ring buffer is **not** advanced.
355    /// Used when an explicit RST marker will replace the auto-connective
356    /// so session state stays consistent with the emitted prose.
357    suppress_auto_connective: bool,
358}
359
360/// Bundle of an immutable engine reference and mutable session state,
361/// used internally to thread session through all render helpers without
362/// duplicating parameters everywhere.
363struct RenderCtx<'e, 's> {
364    engine: &'e Engine,
365    session: &'s mut Session,
366}
367
368impl<'e, 's> RenderCtx<'e, 's> {
369    fn new(engine: &'e Engine, session: &'s mut Session) -> Self {
370        Self { engine, session }
371    }
372
373    /// The body of a render call, performed against live session state.
374    /// Callers snapshot state beforehand and restore on error.
375    ///
376    /// Per-call options (currently only whether to suppress the engine's
377    /// automatic discourse connective) are used by
378    /// `render_batch_with_relations` when an explicit RST marker will be
379    /// applied, so the session never records a connective that isn't
380    /// actually emitted.
381    fn render_tx_with_options(
382        &mut self,
383        key: &str,
384        all_alternatives: &[SalientTemplate],
385        context: &Context,
386        options: RenderOptions,
387    ) -> Result<String, ProsaicError> {
388        // Advance discourse state
389        self.session.discourse.begin_render();
390
391        // Extract entity info from context for discourse tracking
392        let entity_name = context
393            .get("name")
394            .or_else(|| context.get("old_name"))
395            .map(|v| v.as_display());
396        let entity_type = context.get("entity_type").map(|v| v.as_display());
397
398        // Detect discourse connective — but only select (and advance the
399        // no-repeat ring buffer) when the caller hasn't asked us to
400        // suppress it. Skipping both the detect and select avoids
401        // polluting `connective_history` with a connective that will
402        // never reach the output.
403        let connective = if options.suppress_auto_connective {
404            None
405        } else {
406            let relation = self
407                .session
408                .discourse
409                .detect_relation(key, entity_name.as_deref());
410            let prefs = &self.engine.style_profile.connectives;
411            let rst_key = rst_for_discourse(&relation);
412            // Profile's `allowed` is a soft filter (empty → fall back to
413            // base pool); the refine-pass blacklist is a strict
414            // subtractive filter (empty → emit None). Pass them as
415            // separate parameters so the discourse layer applies the
416            // correct semantics to each.
417            let allow_owned: Option<Vec<&str>> = rst_key
418                .and_then(|rst| prefs.allowed.get(&rst))
419                .map(|v| v.iter().map(String::as_str).collect());
420            let prefer_owned: Option<Vec<(&str, f32)>> = rst_key
421                .and_then(|rst| prefs.preferred.get(&rst))
422                .map(|v| v.iter().map(|(s, w)| (s.as_str(), *w)).collect());
423            let forbid_owned: Option<Vec<&str>> =
424                if self.session.refine_blacklist_connectives.is_empty() {
425                    None
426                } else {
427                    Some(
428                        self.session
429                            .refine_blacklist_connectives
430                            .iter()
431                            .map(String::as_str)
432                            .collect(),
433                    )
434                };
435            self.session.discourse.select_connective_filtered(
436                &relation,
437                allow_owned.as_deref(),
438                prefer_owned.as_deref(),
439                forbid_owned.as_deref(),
440            )
441        };
442
443        // Filter templates by salience level matching the context magnitude,
444        // honouring the engine's language preference. The retrospective
445        // refine pass can override the bias path two ways: a
446        // `ForceVariantTier` override short-circuits the calculation
447        // entirely for the matched template key, and an
448        // `OverrideSalienceBias` override is consulted in place of the
449        // active style profile's bias dial.
450        let target_salience = self.resolve_target_salience(key, context);
451        let alternatives = filter_alternatives(
452            all_alternatives,
453            target_salience,
454            self.engine.language_preference.as_deref(),
455            self.engine.style_preference.as_deref(),
456        );
457
458        // Select template with choosebest scoring and anti-repeat
459        let (template, variant_index) =
460            self.select_alternative_scored(key, &alternatives, context)?;
461
462        // Record template choice
463        self.session
464            .discourse
465            .record_template_choice(key, variant_index);
466
467        // Render the selected template into a preallocated buffer.
468        let mut output = String::with_capacity(128);
469        self.render_template_into(&mut output, key, template, context)?;
470
471        // Prepend discourse connective if applicable
472        if let Some(conn) = connective {
473            if conn.starts_with("It ") {
474                prepend_replacing_subject_in_place(&mut output, conn, entity_name.as_deref());
475            } else {
476                lowercase_first_in_place(&mut output);
477                let mut buf = String::with_capacity(conn.len() + 1 + output.len());
478                buf.push_str(conn);
479                buf.push(' ');
480                buf.push_str(&output);
481                core::mem::swap(&mut output, &mut buf);
482            }
483        }
484
485        // Capitalize if the template starts with a reference pipe.
486        if starts_with_refer_pipe(template) {
487            capitalize_first_in_place(&mut output);
488        }
489
490        // Clean up whitespace and silent-mode gaps. Record whether the
491        // orphan-tail pass removed anything so RenderExplanation can
492        // surface it.
493        let cleanup_stripped = cleanup_artifacts_in_place(&mut output, self.engine.strictness);
494        self.session
495            .discourse
496            .set_cleanup_stripped_tail(cleanup_stripped);
497
498        // Terminate the sentence
499        terminate_sentence_in_place(&mut output);
500
501        // Length budget
502        #[cfg(feature = "polish")]
503        if let Some(max_chars) = self.engine.max_sentence_length {
504            split_long_in_place(&mut output, max_chars);
505        }
506
507        // Typographic polish
508        #[cfg(feature = "polish")]
509        if self.engine.smart_quotes {
510            smart_quotes_in_place(&mut output);
511        }
512
513        // Faithfulness gate — checked against the fully-polished output.
514        // If the gate is active and the output fails, propagate the error;
515        // the caller's snapshot/restore in `render()` will undo session state.
516        if let Some(threshold) = self.engine.faithfulness_threshold {
517            let literals = template.literal_tokens();
518            let score = score_faithfulness(&output, context, &literals, &*self.engine.language);
519            if !score.passes(threshold) {
520                return Err(ProsaicError::FaithfulnessRejection {
521                    precision: score.precision,
522                    polarity_match: score.polarity_match,
523                });
524            }
525        }
526
527        // Record entity mention in discourse state
528        if let (Some(name), Some(etype)) = (&entity_name, &entity_type) {
529            self.session.discourse.mention_entity(name, etype);
530        }
531
532        // Record output words for future repetition scoring
533        self.session.discourse.record_output_words(&output);
534        self.session.discourse.record_sentence_rhythm(&output);
535
536        // Advance Cb (backward-looking center) for the next render.
537        // Must be the last mutation so failed renders don't advance Cb
538        // — the snapshot/restore path in render() rolls back via Clone.
539        self.session.discourse.advance_cb();
540
541        Ok(output)
542    }
543
544    /// Resolve the target salience tier for a render. Honors refine-pass
545    /// overrides — `ForceVariantTier` short-circuits the calculation for
546    /// the matched key, otherwise the salience bias is sourced from the
547    /// `OverrideSalienceBias` override when present, falling through to
548    /// the active style profile's bias. Delegates to the free function
549    /// so the explain path stays in lockstep.
550    fn resolve_target_salience(&self, key: &str, context: &Context) -> Salience {
551        resolve_target_salience_for(self.engine, self.session, key, context)
552    }
553
554    fn candidate_discourse_score(&self, candidate: &str) -> f64 {
555        let mut score = self.session.discourse.repetition_score(candidate);
556        if self.engine.sentence_rhythm_enabled {
557            score += self.session.discourse.sentence_rhythm_score(candidate);
558        }
559        // Refine-pass `TightenLengthDistribution` overrides the active
560        // profile's distribution for this iteration; otherwise fall
561        // through to the profile's bias target.
562        let target_distribution = self
563            .session
564            .refine_length_distribution
565            .as_ref()
566            .unwrap_or(&self.engine.style_profile.sentence_length);
567        score += profile_length_bias_score(candidate, &self.session.discourse, target_distribution);
568        score
569    }
570
571    fn select_alternative_scored<'a>(
572        &mut self,
573        key: &str,
574        alternatives: &[&'a Template],
575        context: &Context,
576    ) -> Result<(&'a Template, usize), ProsaicError> {
577        if alternatives.len() == 1 {
578            return Ok((alternatives[0], 0));
579        }
580
581        let allow_choose_best = matches!(
582            self.engine.variation,
583            Variation::Seeded(_) | Variation::Random
584        );
585
586        if !allow_choose_best {
587            let index = self.select_variant_index(key, alternatives.len());
588            return Ok((alternatives[index], index));
589        }
590
591        let last_variant = self.session.discourse.last_template_variant(key);
592        let is_first = self.session.discourse.is_first_render();
593
594        if is_first {
595            let index = self.select_variant_index(key, alternatives.len());
596            return Ok((alternatives[index], index));
597        }
598
599        // Snapshot-and-restore around candidate rendering so state
600        // is untouched by alternatives that aren't emitted.
601        let snapshot = self.session.clone();
602
603        let mut candidates: Vec<(usize, String)> = Vec::new();
604        let mut scratch = String::with_capacity(128);
605        for (i, template) in alternatives.iter().enumerate() {
606            if Some(i) == last_variant {
607                continue;
608            }
609            scratch.clear();
610            match self.render_template_into(&mut scratch, key, template, context) {
611                Ok(()) => {}
612                Err(e) => {
613                    *self.session = snapshot;
614                    return Err(e);
615                }
616            }
617            candidates.push((i, scratch.clone()));
618        }
619
620        *self.session = snapshot;
621
622        if candidates.is_empty() {
623            let index = last_variant.unwrap_or(0).min(alternatives.len() - 1);
624            return Ok((alternatives[index], index));
625        }
626
627        // Score against discourse history (immutable access only)
628        let mut best_index = candidates[0].0;
629        let mut best_score = f64::MAX;
630
631        for (i, candidate) in &candidates {
632            let score = self.candidate_discourse_score(candidate);
633            if score < best_score {
634                best_score = score;
635                best_index = *i;
636            }
637        }
638
639        Ok((alternatives[best_index], best_index))
640    }
641
642    fn select_variant_index(&mut self, key: &str, count: usize) -> usize {
643        match self.engine.variation {
644            Variation::Fixed => 0,
645            Variation::Seeded(seed) => {
646                let hash = simple_hash(key, seed);
647                hash as usize % count
648            }
649            Variation::RoundRobin => {
650                let counter = self
651                    .session
652                    .round_robin_counters
653                    .entry(key.to_string())
654                    .or_insert_with(|| AtomicUsize::new(0))
655                    .fetch_add(1, Ordering::Relaxed);
656                counter % count
657            }
658            Variation::Random => {
659                #[cfg(feature = "std")]
660                {
661                    let nanos = std::time::SystemTime::now()
662                        .duration_since(std::time::UNIX_EPOCH)
663                        .unwrap_or_default()
664                        .subsec_nanos() as usize;
665                    nanos % count
666                }
667                #[cfg(not(feature = "std"))]
668                {
669                    // Without std, fall back to variant 0 (deterministic).
670                    let _ = count;
671                    0
672                }
673            }
674        }
675    }
676
677    fn render_template_into(
678        &mut self,
679        out: &mut String,
680        key: &str,
681        template: &Template,
682        context: &Context,
683    ) -> Result<(), ProsaicError> {
684        self.render_segments_into(out, key, &template.segments, context)
685    }
686
687    fn render_segments_into(
688        &mut self,
689        out: &mut String,
690        key: &str,
691        segments: &[Segment],
692        context: &Context,
693    ) -> Result<(), ProsaicError> {
694        for segment in segments {
695            match segment {
696                Segment::Literal(text) => out.push_str(text),
697                Segment::Slot {
698                    key: slot_key,
699                    pipes,
700                } => {
701                    self.render_slot_into(out, key, slot_key, pipes, context)?;
702                }
703                Segment::Conditional {
704                    condition_key,
705                    inner,
706                } => {
707                    if is_truthy(context.get(condition_key)) {
708                        self.render_segments_into(out, key, inner, context)?;
709                    }
710                }
711                Segment::Partial { name } => {
712                    // Clone the partial segments to avoid borrow conflicts
713                    let partial_segments = self.engine.partials.get(name).ok_or_else(|| {
714                        ProsaicError::TemplateParseError {
715                            template: key.to_string(),
716                            position: 0,
717                            reason: format!(
718                                "unknown partial `{name}` — register it with `engine.register_partial`"
719                            ),
720                        }
721                    })?.segments.clone();
722                    self.render_segments_into(out, key, &partial_segments, context)?;
723                }
724            }
725        }
726
727        Ok(())
728    }
729
730    fn render_slot_into(
731        &mut self,
732        out: &mut String,
733        template_key: &str,
734        slot_key: &str,
735        pipes: &[Pipe],
736        context: &Context,
737    ) -> Result<(), ProsaicError> {
738        let value = match context.get(slot_key) {
739            Some(v) => v.clone(),
740            None => {
741                let s = self.handle_missing_slot(template_key, slot_key)?;
742                out.push_str(&s);
743                return Ok(());
744            }
745        };
746
747        if pipes.is_empty() {
748            out.push_str(&value.as_display());
749            return Ok(());
750        }
751
752        let mut current = value;
753        for pipe in pipes {
754            current = self.apply_pipe(pipe, &current, context)?;
755        }
756
757        out.push_str(&current.as_display());
758        Ok(())
759    }
760
761    fn handle_missing_slot(
762        &self,
763        template_key: &str,
764        slot_key: &str,
765    ) -> Result<String, ProsaicError> {
766        match self.engine.strictness {
767            Strictness::Strict => Err(ProsaicError::MissingSlot {
768                template: template_key.to_string(),
769                slot: slot_key.to_string(),
770            }),
771            Strictness::Lenient => Ok(format!("[missing: {slot_key}]")),
772            Strictness::Silent => Ok(String::new()),
773        }
774    }
775
776    fn apply_pipe(
777        &mut self,
778        pipe: &Pipe,
779        value: &Value,
780        context: &Context,
781    ) -> Result<Value, ProsaicError> {
782        match pipe.name.as_str() {
783            "plural" => self.pipe_plural(pipe, value),
784            "pluralize" => self.pipe_pluralize(pipe, value, context),
785            "article" => self.pipe_article(value),
786            "join" => self.pipe_join(pipe, value),
787            "ordinal" => self.pipe_ordinal(value),
788            "words" => self.pipe_words(value),
789            "truncate" => self.pipe_truncate(pipe, value),
790            "capitalize" => self.pipe_capitalize(value),
791            "refer" => self.pipe_refer(pipe, value, context),
792            "possessive" => self.pipe_possessive(pipe, value, context),
793            "verb" => self.pipe_verb(pipe, value),
794            "syn" => self.pipe_syn(value),
795            #[cfg(feature = "time")]
796            "relative" => self.pipe_relative(value),
797            #[cfg(feature = "time")]
798            "since_last" => self.pipe_since_last(value),
799            "quantify" => self.pipe_quantify(pipe, value),
800            "proportion" => self.pipe_proportion(pipe, value, context),
801            "demonstrative" => self.pipe_demonstrative(value),
802            "hedge" => self.pipe_hedge(pipe, value),
803            "negated" => self.pipe_negated(value),
804            "choose" => self.pipe_choose(pipe, value),
805            _ => Err(ProsaicError::InvalidPipe {
806                pipe: pipe.name.clone(),
807                reason: "unknown pipe".to_string(),
808            }),
809        }
810    }
811
812    fn pipe_choose(&self, pipe: &Pipe, value: &Value) -> Result<Value, ProsaicError> {
813        let arg_str = match &pipe.arg {
814            Some(PipeArg::String(s)) => s.as_str(),
815            Some(PipeArg::Number(_)) | None => {
816                return Err(ProsaicError::InvalidPipe {
817                    pipe: "choose".to_string(),
818                    reason: "choose requires an argument of the form \
819                             'key=value,key=value,default=value'"
820                        .to_string(),
821                });
822            }
823        };
824
825        let pairs = parse_choose_pairs(arg_str)?;
826        if pairs.is_empty() {
827            return Err(ProsaicError::InvalidPipe {
828                pipe: "choose".to_string(),
829                reason: "choose argument is empty".to_string(),
830            });
831        }
832
833        let display = value.as_display();
834        let normalized = display.trim().to_lowercase();
835
836        for (k, v) in &pairs {
837            if k.to_lowercase() == normalized {
838                return Ok(Value::String(v.clone()));
839            }
840        }
841
842        // Fallback to default key
843        for (k, v) in &pairs {
844            if k.eq_ignore_ascii_case("default") {
845                return Ok(Value::String(v.clone()));
846            }
847        }
848
849        // No match, no default — dispatch on strictness
850        match self.engine.strictness {
851            Strictness::Strict => Err(ProsaicError::InvalidPipe {
852                pipe: "choose".to_string(),
853                reason: format!("no matching key for value `{display}` and no default"),
854            }),
855            Strictness::Lenient => Ok(Value::String(format!("[choose: no match for {display}]"))),
856            Strictness::Silent => Ok(Value::String(String::new())),
857        }
858    }
859
860    fn pipe_refer(
861        &mut self,
862        pipe: &Pipe,
863        value: &Value,
864        context: &Context,
865    ) -> Result<Value, ProsaicError> {
866        // Plural REG: list of same-type entities — dispatch before the
867        // single-entity path so Value::List never falls through to as_display().
868        if let Value::List(names) = value {
869            return self.pipe_refer_plural(pipe, names, context);
870        }
871        self.pipe_refer_single(pipe, value, context)
872    }
873
874    fn pipe_possessive(
875        &self,
876        pipe: &Pipe,
877        value: &Value,
878        context: &Context,
879    ) -> Result<Value, ProsaicError> {
880        if let Value::List(names) = value {
881            return self.pipe_possessive_plural(pipe, names, context);
882        }
883        self.pipe_possessive_single(value)
884    }
885
886    /// Single-entity refer path (existing logic, extracted for reuse by the
887    /// plural dispatch).
888    fn pipe_refer_single(
889        &self,
890        pipe: &Pipe,
891        value: &Value,
892        context: &Context,
893    ) -> Result<Value, ProsaicError> {
894        let name = value.as_display();
895
896        let entity_type = match &pipe.arg {
897            Some(PipeArg::String(t)) => t.clone(),
898            _ => context
899                .get("entity_type")
900                .map(|v| v.as_display())
901                .unwrap_or_default(),
902        };
903
904        let form = self.session.discourse.reference_form_with_density(
905            &name,
906            matches!(
907                self.engine.style_profile.pronoun_density,
908                crate::style::PronounDensity::Low
909            ),
910            matches!(
911                self.engine.style_profile.pronoun_density,
912                crate::style::PronounDensity::High
913            ),
914        );
915
916        let rendered = match form {
917            ReferenceForm::Full => self.engine.render_full_reference(&name, &entity_type),
918            ReferenceForm::ShortName => name,
919            ReferenceForm::Pronoun
920            | ReferenceForm::Possessive
921            | ReferenceForm::Demonstrative
922            | ReferenceForm::Zero => {
923                let features = reference_features(value, self.session.discourse.focus_is_plural());
924                self.engine
925                    .language
926                    .realize_reference(form, &features)
927                    .unwrap_or_default()
928            }
929        };
930
931        Ok(Value::String(rendered))
932    }
933
934    fn pipe_possessive_single(&self, value: &Value) -> Result<Value, ProsaicError> {
935        let name = value.as_display();
936        let form = self.session.discourse.reference_form_with_density(
937            &name,
938            matches!(
939                self.engine.style_profile.pronoun_density,
940                crate::style::PronounDensity::Low
941            ),
942            matches!(
943                self.engine.style_profile.pronoun_density,
944                crate::style::PronounDensity::High
945            ),
946        );
947        let rendered = match form {
948            ReferenceForm::Pronoun | ReferenceForm::Demonstrative | ReferenceForm::Zero => {
949                let features = reference_features(value, self.session.discourse.focus_is_plural());
950                self.engine
951                    .language
952                    .realize_reference(ReferenceForm::Possessive, &features)
953                    .unwrap_or_else(|| self.engine.language.possessive_name(&name))
954            }
955            ReferenceForm::Full | ReferenceForm::ShortName | ReferenceForm::Possessive => {
956                self.engine.language.possessive_name(&name)
957            }
958        };
959
960        Ok(Value::String(rendered))
961    }
962
963    fn pipe_possessive_plural(
964        &self,
965        pipe: &Pipe,
966        names: &[String],
967        context: &Context,
968    ) -> Result<Value, ProsaicError> {
969        match names.len() {
970            0 => Ok(Value::String(String::new())),
971            1 => {
972                let v = Value::String(names[0].clone());
973                self.pipe_possessive_single(&v)
974            }
975            n => {
976                let entity_type = match &pipe.arg {
977                    Some(PipeArg::String(t)) => t.clone(),
978                    _ => context
979                        .get("entity_type")
980                        .map(|v| v.as_display())
981                        .unwrap_or_default(),
982                };
983                let owner = if entity_type.is_empty() {
984                    let items: Vec<&str> = names.iter().map(|s| s.as_str()).collect();
985                    self.engine
986                        .language
987                        .join_list(&items, crate::language::Conjunction::And)
988                } else {
989                    self.engine.language.plural_description(
990                        &entity_type,
991                        n,
992                        &crate::agreement::AgreementFeatures::default(),
993                    )
994                };
995                Ok(Value::String(self.engine.language.possessive_name(&owner)))
996            }
997        }
998    }
999
1000    /// Plural REG path: collapses a list of same-type entities to a plural
1001    /// description using [`crate::language::Language::plural_description`].
1002    ///
1003    /// - Empty list  → empty string (silent-mode convention).
1004    /// - Single item → delegates to single-entity path (`pipe_refer_single`).
1005    /// - Multi-item  → calls `plural_description` and updates discourse state.
1006    fn pipe_refer_plural(
1007        &mut self,
1008        pipe: &Pipe,
1009        names: &[String],
1010        context: &Context,
1011    ) -> Result<Value, ProsaicError> {
1012        match names.len() {
1013            0 => Ok(Value::String(String::new())),
1014            1 => {
1015                let v = Value::String(names[0].clone());
1016                self.pipe_refer_single(pipe, &v, context)
1017            }
1018            n => {
1019                let entity_type = match &pipe.arg {
1020                    Some(PipeArg::String(t)) => t.clone(),
1021                    _ => context
1022                        .get("entity_type")
1023                        .map(|v| v.as_display())
1024                        .unwrap_or_default(),
1025                };
1026
1027                // Register each entity in discourse for future singular tracking.
1028                // mention_entity also resets focus_is_plural to false for each
1029                // individual mention, so we call set_focus_plural after the loop.
1030                if !entity_type.is_empty() {
1031                    for name in names {
1032                        self.session.discourse.mention_entity(name, &entity_type);
1033                    }
1034                }
1035
1036                // Mark the discourse focus as plural so subsequent pronoun
1037                // references emit "they" rather than "it".
1038                // Note: this sets focus on the set as a whole; future work can
1039                // distinguish subject-vs-object position if needed.
1040                self.session.discourse.set_focus_plural(true);
1041
1042                // Use default AgreementFeatures for v1; a future Value::EntityList
1043                // variant can carry per-entity features for richer agreement.
1044                let features = crate::agreement::AgreementFeatures::default();
1045
1046                let output = self
1047                    .engine
1048                    .language
1049                    .plural_description(&entity_type, n, &features);
1050                Ok(Value::String(output))
1051            }
1052        }
1053    }
1054
1055    fn pipe_demonstrative(&self, value: &Value) -> Result<Value, ProsaicError> {
1056        let noun = value.as_display();
1057        if noun.is_empty() {
1058            return Ok(Value::String(noun));
1059        }
1060
1061        let determiner = if self.session.discourse.has_prior_render() {
1062            "this"
1063        } else {
1064            "the"
1065        };
1066
1067        Ok(Value::String(format!("{determiner} {noun}")))
1068    }
1069
1070    fn pipe_syn(&self, value: &Value) -> Result<Value, ProsaicError> {
1071        let word = value.as_display();
1072        let synonyms = match self.engine.synonyms.synonyms_for(&word) {
1073            Some(s) => s,
1074            None => return Ok(Value::String(word)),
1075        };
1076
1077        if synonyms.is_empty() {
1078            return Ok(Value::String(word));
1079        }
1080
1081        let mut best = &synonyms[0];
1082        let mut best_score = self.session.discourse.word_frequency(&synonyms[0]);
1083        for syn in &synonyms[1..] {
1084            let score = self.session.discourse.word_frequency(syn);
1085            if score < best_score {
1086                best_score = score;
1087                best = syn;
1088            }
1089        }
1090
1091        let result = if word
1092            .chars()
1093            .next()
1094            .map(|c| c.is_uppercase())
1095            .unwrap_or(false)
1096        {
1097            let mut s = best.clone();
1098            capitalize_first_in_place(&mut s);
1099            s
1100        } else {
1101            best.clone()
1102        };
1103
1104        Ok(Value::String(result))
1105    }
1106
1107    /// Join a list value into a prose-formatted string.
1108    ///
1109    /// # Styles
1110    ///
1111    /// | Syntax | Style | Example output |
1112    /// |--------|-------|----------------|
1113    /// | `{items\|join}` | Cycling (auto) | Rotates through Including → SuchAs → Dash → Bracketed across renders |
1114    /// | `{items\|join:including}` | Including | "including A, B, and C among others" |
1115    /// | `{items\|join:such_as}` | SuchAs | "such as A, B, and C" |
1116    /// | `{items\|join:dash}` | Dash | "— A, B, and C" |
1117    /// | `{items\|join:bracketed}` | Bracketed | "[A, B, and C]" or "[A, B, and 2 more]" |
1118    /// | `{items\|join:or}` | Or-conjunction | "A, B, or C" |
1119    ///
1120    /// # Choosing a style
1121    ///
1122    /// When your surrounding template text already contains a framing
1123    /// word like "impacting", "affecting", "across", or "including",
1124    /// use `join:bracketed` to avoid doubling up with the cycling
1125    /// list prefix. For example:
1126    ///
1127    /// - **Bad:** `"impacting {endpoints|join}"` → *"impacting including A, B, and C"*
1128    /// - **Good:** `"impacting {endpoints|join:bracketed}"` → *"impacting [A, B, and C]"*
1129    ///
1130    /// The cycling auto-style is best when the slot appears without
1131    /// a framing word, so the list prefix provides its own context:
1132    /// `"{consumers|join}"` → *"including A, B, and C"* on one render,
1133    /// *"such as A, B, and C"* on the next.
1134    fn pipe_join(&mut self, pipe: &Pipe, value: &Value) -> Result<Value, ProsaicError> {
1135        let items = value.as_list().ok_or_else(|| ProsaicError::InvalidPipe {
1136            pipe: "join".to_string(),
1137            reason: "value must be a list".to_string(),
1138        })?;
1139
1140        let forced_style = match &pipe.arg {
1141            Some(PipeArg::String(s)) if s == "bracketed" => Some(ListStyle::Bracketed),
1142            Some(PipeArg::String(s)) if s == "including" => Some(ListStyle::Including),
1143            Some(PipeArg::String(s)) if s == "such_as" => Some(ListStyle::SuchAs),
1144            Some(PipeArg::String(s)) if s == "dash" => Some(ListStyle::Dash),
1145            Some(PipeArg::String(s)) if s == "among_others" => Some(ListStyle::AmongOthers),
1146            Some(PipeArg::String(s)) if s == "to_name_a_few" => Some(ListStyle::ToNameAFew),
1147            Some(PipeArg::String(s)) if s == "plus_more" => Some(ListStyle::PlusMore),
1148            _ => None,
1149        };
1150
1151        let conjunction = match &pipe.arg {
1152            Some(PipeArg::String(s)) if s == "or" => Conjunction::Or,
1153            _ => Conjunction::And,
1154        };
1155
1156        let style = match forced_style {
1157            Some(s) => {
1158                // Still record the chosen style so render_explained can
1159                // report it even when the template forced one.
1160                self.session.discourse.record_list_style_used(s);
1161                s
1162            }
1163            None => {
1164                let mut bias_target =
1165                    list_style_bias_target(self.engine.style_profile.list_style_bias);
1166                // If a refine blacklist is active and includes the bias
1167                // target, drop the bias for this render so the cycle's
1168                // anti-repeat naturally lands on something else.
1169                if let Some(target) = bias_target
1170                    && self.session.refine_blacklist_list_styles.contains(&target)
1171                {
1172                    bias_target = None;
1173                }
1174                let chosen = self
1175                    .session
1176                    .discourse
1177                    .next_list_style_with_bias(bias_target);
1178                // If anti-repeat picked a blacklisted style anyway (e.g.,
1179                // the recent window forced its hand), advance the cycle
1180                // until we find a non-blacklisted slot. Worst case the
1181                // cycle exhausts and we accept the original pick.
1182                if self.session.refine_blacklist_list_styles.contains(&chosen) {
1183                    let mut next = chosen;
1184                    for _ in 0..crate::discourse::list_styles_count() {
1185                        next = self.session.discourse.next_list_style_with_bias(None);
1186                        if !self.session.refine_blacklist_list_styles.contains(&next) {
1187                            break;
1188                        }
1189                    }
1190                    next
1191                } else {
1192                    chosen
1193                }
1194            }
1195        };
1196
1197        let refs: Vec<&str> = items.iter().map(|s| s.as_str()).collect();
1198
1199        let has_truncation = items.last().is_some_and(|last| {
1200            last.ends_with(" more")
1201                && last
1202                    .split_whitespace()
1203                    .next()
1204                    .is_some_and(|w| w.parse::<usize>().is_ok())
1205        });
1206
1207        if has_truncation && items.len() >= 2 {
1208            let shown = &refs[..refs.len() - 1];
1209            let remainder = &items[items.len() - 1];
1210            Ok(Value::String(format_truncated_list(
1211                shown,
1212                remainder,
1213                style,
1214                conjunction,
1215                &*self.engine.language,
1216            )))
1217        } else {
1218            let joined = self.engine.language.join_list(&refs, conjunction);
1219            Ok(Value::String(joined))
1220        }
1221    }
1222
1223    fn pipe_pluralize(
1224        &self,
1225        pipe: &Pipe,
1226        value: &Value,
1227        _context: &Context,
1228    ) -> Result<Value, ProsaicError> {
1229        let word = match &pipe.arg {
1230            Some(PipeArg::String(w)) => w.as_str(),
1231            _ => {
1232                return Err(ProsaicError::InvalidPipe {
1233                    pipe: "pluralize".to_string(),
1234                    reason: "requires a word argument, e.g., {count|pluralize:item}".to_string(),
1235                });
1236            }
1237        };
1238
1239        let count = value.as_number().ok_or_else(|| ProsaicError::InvalidPipe {
1240            pipe: "pluralize".to_string(),
1241            reason: "value must be a number".to_string(),
1242        })? as usize;
1243
1244        Ok(Value::String(self.engine.language.pluralize(word, count)))
1245    }
1246
1247    /// CLDR-aware plural pipe: `{count|plural:noun}`.
1248    ///
1249    /// Reads the slot as an integer, classifies it with
1250    /// [`Language::plural_category`], then returns the correct word form
1251    /// via [`Language::pluralize_with_category`]. Unlike `|pluralize`, this
1252    /// pipe is category-aware and ready for non-English grammars that
1253    /// distinguish more than two number categories.
1254    fn pipe_plural(&self, pipe: &Pipe, value: &Value) -> Result<Value, ProsaicError> {
1255        let noun = match &pipe.arg {
1256            Some(PipeArg::String(s)) => s.as_str(),
1257            _ => {
1258                return Err(ProsaicError::InvalidPipe {
1259                    pipe: "plural".to_string(),
1260                    reason: "requires a singular noun argument, e.g., {count|plural:service}"
1261                        .to_string(),
1262                });
1263            }
1264        };
1265
1266        let count = value.as_number().ok_or_else(|| ProsaicError::InvalidPipe {
1267            pipe: "plural".to_string(),
1268            reason: "requires a numeric slot value".to_string(),
1269        })?;
1270
1271        let category: PluralCategory = self.engine.language.plural_category(count);
1272        Ok(Value::String(
1273            self.engine.language.pluralize_with_category(noun, category),
1274        ))
1275    }
1276
1277    fn pipe_article(&self, value: &Value) -> Result<Value, ProsaicError> {
1278        let word = value.as_display();
1279        let article = self.engine.language.article(&word);
1280        Ok(Value::String(format!("{article} {word}")))
1281    }
1282
1283    fn pipe_ordinal(&self, value: &Value) -> Result<Value, ProsaicError> {
1284        let n = value.as_number().ok_or_else(|| ProsaicError::InvalidPipe {
1285            pipe: "ordinal".to_string(),
1286            reason: "value must be a number".to_string(),
1287        })? as usize;
1288
1289        Ok(Value::String(self.engine.language.ordinal(n)))
1290    }
1291
1292    fn pipe_words(&self, value: &Value) -> Result<Value, ProsaicError> {
1293        let n = value.as_number().ok_or_else(|| ProsaicError::InvalidPipe {
1294            pipe: "words".to_string(),
1295            reason: "value must be a number".to_string(),
1296        })? as usize;
1297
1298        Ok(Value::String(self.engine.language.number_to_words(n)))
1299    }
1300
1301    fn pipe_truncate(&self, pipe: &Pipe, value: &Value) -> Result<Value, ProsaicError> {
1302        let max = match &pipe.arg {
1303            Some(PipeArg::Number(n)) => *n,
1304            _ => {
1305                return Err(ProsaicError::InvalidPipe {
1306                    pipe: "truncate".to_string(),
1307                    reason: "requires a numeric argument, e.g., {items|truncate:3}".to_string(),
1308                });
1309            }
1310        };
1311
1312        let items = value.as_list().ok_or_else(|| ProsaicError::InvalidPipe {
1313            pipe: "truncate".to_string(),
1314            reason: "value must be a list".to_string(),
1315        })?;
1316
1317        if items.len() <= max {
1318            return Ok(value.clone());
1319        }
1320
1321        let remaining = items.len() - max;
1322        let mut truncated: Vec<String> = items[..max].to_vec();
1323        let suffix = format!("{remaining} more");
1324        truncated.push(suffix);
1325
1326        Ok(Value::List(truncated))
1327    }
1328
1329    fn pipe_capitalize(&self, value: &Value) -> Result<Value, ProsaicError> {
1330        let mut s = value.as_display();
1331        capitalize_first_in_place(&mut s);
1332        Ok(Value::String(s))
1333    }
1334
1335    fn pipe_negated(&self, value: &Value) -> Result<Value, ProsaicError> {
1336        let phrase = value.as_display();
1337        if let Some(positive) = self.engine.antonyms.lookup(&phrase) {
1338            return Ok(Value::String(positive.to_string()));
1339        }
1340        Ok(Value::String(insert_not(&phrase)))
1341    }
1342
1343    fn pipe_hedge(&self, pipe: &Pipe, value: &Value) -> Result<Value, ProsaicError> {
1344        let score = value.as_number().ok_or_else(|| ProsaicError::InvalidPipe {
1345            pipe: "hedge".to_string(),
1346            reason: "value must be a 0..=100 integer confidence score".to_string(),
1347        })?;
1348
1349        let mode = match &pipe.arg {
1350            None => HedgeMode::Adverb,
1351            Some(PipeArg::String(s)) => {
1352                parse_hedge_mode(s).ok_or_else(|| ProsaicError::InvalidPipe {
1353                    pipe: "hedge".to_string(),
1354                    reason: format!(
1355                        "unknown hedge mode `{s}` — expected one of adverb, modal, prefix"
1356                    ),
1357                })?
1358            }
1359            Some(PipeArg::Number(_)) => {
1360                return Err(ProsaicError::InvalidPipe {
1361                    pipe: "hedge".to_string(),
1362                    reason: "hedge argument must be a mode name, not a number".to_string(),
1363                });
1364            }
1365        };
1366
1367        Ok(Value::String(
1368            hedge_with_calibration(score, mode, &self.engine.style_profile.hedging).to_string(),
1369        ))
1370    }
1371
1372    fn pipe_proportion(
1373        &self,
1374        pipe: &Pipe,
1375        value: &Value,
1376        context: &Context,
1377    ) -> Result<Value, ProsaicError> {
1378        let arg_str = match &pipe.arg {
1379            Some(PipeArg::String(s)) => s.as_str(),
1380            Some(PipeArg::Number(_)) | None => {
1381                return Err(ProsaicError::InvalidPipe {
1382                    pipe: "proportion".to_string(),
1383                    reason: "requires an argument of the form \
1384                             `proportion:total_key[:singular_noun]`"
1385                        .to_string(),
1386                });
1387            }
1388        };
1389
1390        // Split into total_key and optional noun. The noun may itself contain
1391        // spaces (e.g. "modified file"), so we only split on the first colon.
1392        let (total_key, noun) = match arg_str.split_once(':') {
1393            Some((k, n)) => {
1394                let n = n.trim();
1395                (k.trim(), if n.is_empty() { None } else { Some(n) })
1396            }
1397            None => (arg_str.trim(), None),
1398        };
1399
1400        if total_key.is_empty() {
1401            return Err(ProsaicError::InvalidPipe {
1402                pipe: "proportion".to_string(),
1403                reason: "missing total context key — use `proportion:total_key[:noun]`".to_string(),
1404            });
1405        }
1406
1407        let matching = value.as_number().ok_or_else(|| ProsaicError::InvalidPipe {
1408            pipe: "proportion".to_string(),
1409            reason: "value must be a number".to_string(),
1410        })?;
1411
1412        let total_value = context
1413            .get(total_key)
1414            .ok_or_else(|| ProsaicError::InvalidPipe {
1415                pipe: "proportion".to_string(),
1416                reason: format!("total context key `{total_key}` not found"),
1417            })?;
1418
1419        let total = total_value
1420            .as_number()
1421            .ok_or_else(|| ProsaicError::InvalidPipe {
1422                pipe: "proportion".to_string(),
1423                reason: format!("total context key `{total_key}` is not a number"),
1424            })?;
1425
1426        let features = AgreementFeatures::default();
1427        let phrase = self
1428            .engine
1429            .language
1430            .proportion_phrase(matching, total, noun, &features);
1431        Ok(Value::String(phrase))
1432    }
1433
1434    fn pipe_quantify(&self, pipe: &Pipe, value: &Value) -> Result<Value, ProsaicError> {
1435        let count = value.as_number().ok_or_else(|| ProsaicError::InvalidPipe {
1436            pipe: "quantify".to_string(),
1437            reason: "value must be a number".to_string(),
1438        })?;
1439
1440        let mode = match &pipe.arg {
1441            None => QuantifyMode::Natural,
1442            Some(PipeArg::String(s)) => {
1443                parse_quantify_mode(s).ok_or_else(|| ProsaicError::InvalidPipe {
1444                    pipe: "quantify".to_string(),
1445                    reason: format!(
1446                        "unknown quantify mode `{s}` — expected one of natural, exact, hedged"
1447                    ),
1448                })?
1449            }
1450            Some(PipeArg::Number(_)) => {
1451                return Err(ProsaicError::InvalidPipe {
1452                    pipe: "quantify".to_string(),
1453                    reason: "quantify argument must be a mode name, not a number".to_string(),
1454                });
1455            }
1456        };
1457
1458        Ok(Value::String(quantify_fn(
1459            count,
1460            mode,
1461            &*self.engine.language,
1462        )))
1463    }
1464
1465    fn pipe_verb(&self, pipe: &Pipe, value: &Value) -> Result<Value, ProsaicError> {
1466        let spec = match &pipe.arg {
1467            Some(PipeArg::String(s)) => s.as_str(),
1468            _ => {
1469                return Err(ProsaicError::InvalidPipe {
1470                    pipe: "verb".to_string(),
1471                    reason: "requires a form spec argument, e.g., \
1472                             {rename|verb:present_perfect}"
1473                        .to_string(),
1474                });
1475            }
1476        };
1477
1478        let (form, voice) =
1479            VerbForm::parse_spec(spec).ok_or_else(|| ProsaicError::InvalidPipe {
1480                pipe: "verb".to_string(),
1481                reason: format!(
1482                    "unknown verb form spec `{spec}` — expected one of past, present, future, \
1483                 present_perfect, past_perfect, future_perfect, present_progressive, \
1484                 past_progressive, conditional, conditional_perfect \
1485                 (optionally prefixed with `active_` or `passive_`)"
1486                ),
1487            })?;
1488
1489        let verb = value.as_display();
1490        let phrase = self
1491            .engine
1492            .language
1493            .verb_phrase(&verb, form, voice, Person::Third);
1494        Ok(Value::String(phrase))
1495    }
1496
1497    #[cfg(feature = "time")]
1498    fn pipe_relative(&self, value: &Value) -> Result<Value, ProsaicError> {
1499        let ts = value.as_number().ok_or_else(|| ProsaicError::InvalidPipe {
1500            pipe: "relative".to_string(),
1501            reason: "value must be a Unix-epoch integer (seconds)".to_string(),
1502        })?;
1503
1504        let now = match self.engine.reference_time {
1505            Some(n) => n,
1506            None => {
1507                // `time` feature implies `std` (see Cargo.toml), so
1508                // `SystemTime::now()` is always available here.
1509                std::time::SystemTime::now()
1510                    .duration_since(std::time::UNIX_EPOCH)
1511                    .map(|d| d.as_secs() as i64)
1512                    .unwrap_or(0)
1513            }
1514        };
1515
1516        let diff = now - ts;
1517        Ok(Value::String(format_relative(diff)))
1518    }
1519
1520    #[cfg(feature = "time")]
1521    fn pipe_since_last(&mut self, value: &Value) -> Result<Value, ProsaicError> {
1522        let Some(ts) = value.as_number() else {
1523            return Err(ProsaicError::InvalidPipe {
1524                pipe: "since_last".to_string(),
1525                reason: "expected numeric Unix-seconds timestamp".to_string(),
1526            });
1527        };
1528
1529        let marker = match self.session.last_temporal_anchor {
1530            Some(anchor) => self.engine.language.since_last_marker(ts - anchor),
1531            None => {
1532                // Fall back to absolute-relative behavior anchored at now/reference_time.
1533                // This makes the first event in a narrative read like "3 days ago",
1534                // and subsequent events read like anchored deltas ("the next day").
1535                let now = match self.engine.reference_time {
1536                    Some(n) => n,
1537                    None => {
1538                        // `time` feature implies `std` (see Cargo.toml), so
1539                        // `SystemTime::now()` is always available here.
1540                        std::time::SystemTime::now()
1541                            .duration_since(std::time::UNIX_EPOCH)
1542                            .map(|d| d.as_secs() as i64)
1543                            .unwrap_or(0)
1544                    }
1545                };
1546                format_relative(now - ts)
1547            }
1548        };
1549
1550        Ok(Value::String(marker))
1551    }
1552
1553    /// Score every variant for a key using this session state (for diagnostics).
1554    fn score_all_variants(
1555        &mut self,
1556        key: &str,
1557        all: &[SalientTemplate],
1558        ctx: &Context,
1559    ) -> Result<Vec<VariantScore>, ProsaicError> {
1560        let target_salience = self.resolve_target_salience(key, ctx);
1561        let alternatives = filter_alternatives(
1562            all,
1563            target_salience,
1564            self.engine.language_preference.as_deref(),
1565            self.engine.style_preference.as_deref(),
1566        );
1567
1568        // Snapshot so candidate renders leave no residue.
1569        let snapshot = self.session.clone();
1570
1571        let last_variant = self.session.discourse.last_template_variant(key);
1572        let mut scores: Vec<VariantScore> = Vec::with_capacity(alternatives.len());
1573        let mut scratch = String::with_capacity(128);
1574
1575        for (i, template) in alternatives.iter().enumerate() {
1576            scratch.clear();
1577            match self.render_template_into(&mut scratch, key, template, ctx) {
1578                Ok(()) => {}
1579                Err(e) => {
1580                    *self.session = snapshot;
1581                    return Err(e);
1582                }
1583            }
1584            scores.push(VariantScore {
1585                index: i,
1586                source: template.source.clone(),
1587                rendered: scratch.clone(),
1588                score: 0.0,
1589                salience: target_salience,
1590                is_last_selected: Some(i) == last_variant,
1591                selected: false,
1592            });
1593        }
1594
1595        for s in scores.iter_mut() {
1596            s.score = self.candidate_discourse_score(&s.rendered);
1597        }
1598
1599        // Determine the selected variant
1600        let selected_idx = self.pick_variant_index(key, &alternatives, last_variant, &scores);
1601        if let Some(idx) = selected_idx
1602            && let Some(s) = scores.get_mut(idx)
1603        {
1604            s.selected = true;
1605        }
1606
1607        *self.session = snapshot;
1608        Ok(scores)
1609    }
1610
1611    fn pick_variant_index(
1612        &self,
1613        key: &str,
1614        alternatives: &[&Template],
1615        last_variant: Option<usize>,
1616        scores: &[VariantScore],
1617    ) -> Option<usize> {
1618        if alternatives.is_empty() {
1619            return None;
1620        }
1621        if alternatives.len() == 1 {
1622            return Some(0);
1623        }
1624
1625        let allow_choose_best = matches!(
1626            self.engine.variation,
1627            Variation::Seeded(_) | Variation::Random
1628        );
1629
1630        let is_first = self.session.discourse.is_first_render();
1631        if !allow_choose_best || is_first {
1632            return Some(
1633                self.engine
1634                    .pick_variant_index_static(key, alternatives.len()),
1635            );
1636        }
1637
1638        let mut best_idx: Option<usize> = None;
1639        let mut best_score = f64::MAX;
1640        for (i, s) in scores.iter().enumerate() {
1641            if Some(i) == last_variant && scores.len() > 1 {
1642                continue;
1643            }
1644            if s.score < best_score {
1645                best_score = s.score;
1646                best_idx = Some(i);
1647            }
1648        }
1649        best_idx.or(Some(0))
1650    }
1651}
1652
1653impl Engine {
1654    /// Create a new engine with the given language implementation.
1655    pub fn new(language: impl Language + 'static) -> Self {
1656        Self {
1657            language: Box::new(language),
1658            templates: new_map(),
1659            strictness: Strictness::default(),
1660            variation: Variation::default(),
1661            salience_thresholds: SalienceThresholds::default(),
1662            rr_initial: new_map(),
1663            #[cfg(feature = "reg")]
1664            entity_registry: EntityRegistry::new(),
1665            #[cfg(feature = "reg")]
1666            reg_preference: Vec::new(),
1667            #[cfg(feature = "reg")]
1668            reg_algorithm: RegAlgorithm::default(),
1669            synonyms: SynonymRegistry::new(),
1670            #[cfg(feature = "time")]
1671            reference_time: None,
1672            antonyms: AntonymRegistry::new(),
1673            #[cfg(feature = "polish")]
1674            max_sentence_length: None,
1675            #[cfg(feature = "polish")]
1676            smart_quotes: false,
1677            sentence_rhythm_enabled: true,
1678            partials: new_map(),
1679            language_preference: None,
1680            style_preference: None,
1681            faithfulness_threshold: None,
1682            style_profile: crate::style::StyleProfile::neutral(),
1683            refine_config: crate::refine::RefineConfig::off(),
1684        }
1685    }
1686
1687    /// Set the BCP-47 language code that variant selection should prefer.
1688    /// When templates are registered with [`Engine::register_template_with_language`],
1689    /// the engine picks variants whose language matches this preference; if
1690    /// none match, it falls back to language-untagged variants, then to any
1691    /// registered variant.
1692    pub fn language_preference(mut self, lang: impl Into<String>) -> Self {
1693        self.language_preference = Some(lang.into());
1694        self
1695    }
1696
1697    /// Update the BCP-47 language code used to prefer language-tagged
1698    /// variants without rebuilding the engine or dropping registered
1699    /// templates.
1700    pub fn set_language_preference(&mut self, lang: impl Into<String>) {
1701        self.language_preference = Some(lang.into());
1702    }
1703
1704    /// Set the free-form style tag that variant selection should prefer.
1705    /// Style tags are application-defined strings such as `"executive"`,
1706    /// `"technical"`, or `"customer"`. The fallback chain mirrors language:
1707    /// preferred style first, then unstyled variants, then any registered
1708    /// variant for the already-selected language bucket.
1709    pub fn style_preference(mut self, style: impl Into<String>) -> Self {
1710        self.style_preference = Some(style.into());
1711        self
1712    }
1713
1714    /// Update the style tag used to prefer style-tagged variants without
1715    /// rebuilding the engine or dropping registered templates.
1716    pub fn set_style_preference(&mut self, style: impl Into<String>) {
1717        self.style_preference = Some(style.into());
1718    }
1719
1720    /// Set the strictness mode for missing slot handling.
1721    pub fn strictness(mut self, strictness: Strictness) -> Self {
1722        self.strictness = strictness;
1723        self
1724    }
1725
1726    /// Set the variation strategy for template selection.
1727    pub fn variation(mut self, variation: Variation) -> Self {
1728        self.variation = variation;
1729        self
1730    }
1731
1732    /// Set the thresholds for automatic salience derivation from context.
1733    pub fn salience_thresholds(mut self, thresholds: SalienceThresholds) -> Self {
1734        self.salience_thresholds = thresholds;
1735        self
1736    }
1737
1738    /// Apply a [`StyleProfile`](crate::StyleProfile) — a declarative voice
1739    /// configuration that biases the engine's existing rendering decisions
1740    /// toward a target register without breaking determinism. Setting the
1741    /// neutral profile (`StyleProfile::neutral()`) is byte-for-byte
1742    /// equivalent to never calling this method.
1743    pub fn style_profile(mut self, profile: crate::style::StyleProfile) -> Self {
1744        self.style_profile = profile;
1745        self
1746    }
1747
1748    /// Read the currently-applied [`StyleProfile`](crate::StyleProfile).
1749    /// Returns the neutral profile if none was explicitly set.
1750    pub fn current_style_profile(&self) -> &crate::style::StyleProfile {
1751        &self.style_profile
1752    }
1753
1754    /// Apply a [`RefineConfig`](crate::RefineConfig) — opts the engine
1755    /// into the retrospective refine pass on `DocumentPlan::render`.
1756    /// `RefineConfig::off()` (the default) is a no-op; the render path
1757    /// produces byte-identical output to its non-refined form when the
1758    /// config is off.
1759    pub fn refine(mut self, config: crate::refine::RefineConfig) -> Self {
1760        self.refine_config = config;
1761        self
1762    }
1763
1764    /// Read the currently-applied [`RefineConfig`](crate::RefineConfig).
1765    pub fn current_refine_config(&self) -> &crate::refine::RefineConfig {
1766        &self.refine_config
1767    }
1768
1769    /// Register an entity descriptor for referring-expression generation
1770    /// (REG). When the engine produces a *Full form* reference via the
1771    /// `{name|refer}` pipe, it consults registered entities — if the
1772    /// target shares its type with other registered entities, the Dale
1773    /// & Reiter incremental algorithm selects the shortest set of
1774    /// distinguishing attributes to include as premodifiers.
1775    ///
1776    /// Entities not registered here still render with just type + name.
1777    ///
1778    /// # Example
1779    ///
1780    /// ```
1781    /// use prosaic_core::{Context, Engine, EntityDescriptor, Value};
1782    /// use prosaic_grammar_en::English;
1783    ///
1784    /// let mut engine = Engine::new(English::new());
1785    /// engine.register_entity(
1786    ///     EntityDescriptor::new("UserService", "class")
1787    ///         .with_attribute("layer", "domain"),
1788    /// );
1789    /// engine.register_entity(
1790    ///     EntityDescriptor::new("AuthService", "class")
1791    ///         .with_attribute("layer", "infra"),
1792    /// );
1793    ///
1794    /// engine.register_template("t", "{name|refer} was modified").unwrap();
1795    /// let mut ctx = Context::new();
1796    /// ctx.insert("entity_type", Value::String("class".into()));
1797    /// ctx.insert("name", Value::String("UserService".into()));
1798    ///
1799    /// let mut session = prosaic_core::Session::new();
1800    /// assert_eq!(
1801    ///     engine.render(&mut session, "t", &ctx).unwrap(),
1802    ///     "The domain class UserService was modified."
1803    /// );
1804    /// ```
1805    #[cfg(feature = "reg")]
1806    pub fn register_entity(&mut self, descriptor: EntityDescriptor) {
1807        self.entity_registry.insert(descriptor);
1808    }
1809
1810    /// Set the preferred attribute walking order for REG. Attributes
1811    /// earlier in the list are tried first; unknown attributes are
1812    /// ignored. Attributes not mentioned here still participate but fall
1813    /// to the end, in the order they were registered on the target entity.
1814    ///
1815    /// Typical use: prefer semantic attributes ("layer", "scope") over
1816    /// physical ones ("color") when disambiguating code entities.
1817    #[cfg(feature = "reg")]
1818    pub fn attribute_preference(mut self, order: Vec<String>) -> Self {
1819        self.reg_preference = order;
1820        self
1821    }
1822
1823    /// Select the REG algorithm used when rendering Full-form references via
1824    /// `{name|refer}`.
1825    ///
1826    /// The default is [`RegAlgorithm::DaleReiter`], which selects
1827    /// distinguishing unary attributes only. Use [`RegAlgorithm::GraphBased`]
1828    /// to enable the Krahmer 2003 greedy algorithm, which also considers
1829    /// labeled relations registered via
1830    /// [`EntityDescriptor::with_relation`](crate::EntityDescriptor::with_relation).
1831    #[cfg(feature = "reg")]
1832    pub fn reg_algorithm(mut self, algo: RegAlgorithm) -> Self {
1833        self.reg_algorithm = algo;
1834        self
1835    }
1836
1837    /// Override the engine's "now" reference for relative-time rendering.
1838    /// Useful for tests and for rendering a report "as of" a specific
1839    /// point in time. The value is seconds since Unix epoch.
1840    ///
1841    /// If not set, the engine reads `SystemTime::now()` on each call to
1842    /// the `{timestamp|relative}` pipe.
1843    ///
1844    /// # Example
1845    ///
1846    /// ```
1847    /// use prosaic_core::{Context, Engine, Value};
1848    /// use prosaic_grammar_en::English;
1849    ///
1850    /// let now = 1_700_000_000;
1851    /// let mut engine = Engine::new(English::new()).reference_time(now);
1852    /// engine.register_template("t", "The change landed {ts|relative}").unwrap();
1853    ///
1854    /// let mut ctx = Context::new();
1855    /// ctx.insert("ts", Value::Number(now - 86400 - 3600));
1856    /// let mut session = prosaic_core::Session::new();
1857    /// assert_eq!(engine.render(&mut session, "t", &ctx).unwrap(), "The change landed yesterday.");
1858    /// ```
1859    #[cfg(feature = "time")]
1860    pub fn reference_time(mut self, unix_secs: i64) -> Self {
1861        self.reference_time = Some(unix_secs);
1862        self
1863    }
1864
1865    /// Register a reusable template fragment under `name`. Inside any
1866    /// template, `{>name}` expands inline to the partial's content at
1867    /// render time. Partials share the same template syntax as the rest
1868    /// of the engine — slots, pipes, conditionals, and nested partials
1869    /// all work inside a partial.
1870    ///
1871    /// Example:
1872    ///
1873    /// ```ignore
1874    /// engine.register_partial(
1875    ///     "impact_tail",
1876    ///     "{?consumer_count}, affecting {consumer_count} \
1877    ///      {consumer_count|pluralize:consumer}{/?}",
1878    /// )?;
1879    /// engine.register_template("code.modified", "{name|refer} was modified{>impact_tail}")?;
1880    /// engine.register_template("code.renamed",  "{name|refer} was renamed to {new_name}{>impact_tail}")?;
1881    /// ```
1882    pub fn register_partial(&mut self, name: &str, source: &str) -> Result<(), ProsaicError> {
1883        let template = Template::parse(source)?;
1884
1885        // Insert, then verify the resulting partial graph is acyclic starting
1886        // from the new entry point. On any cycle, restore the prior entry (or
1887        // remove the new one) and return a descriptive error.
1888        let previous = self.partials.insert(name.to_string(), template);
1889        if let Err(cycle) = detect_partial_cycle(&self.partials, name) {
1890            match previous {
1891                Some(prior) => {
1892                    self.partials.insert(name.to_string(), prior);
1893                }
1894                None => {
1895                    self.partials.remove(name);
1896                }
1897            }
1898            return Err(ProsaicError::RecursivePartial { cycle });
1899        }
1900        Ok(())
1901    }
1902
1903    /// Enable typographic ("smart") quote substitution on rendered output.
1904    /// Straight `"` becomes curly `\u{201C}`/`\u{201D}`; straight `'`
1905    /// becomes `\u{2018}`/`\u{2019}`; apostrophes inside words (Alice's,
1906    /// it's) become U+2019. Off by default — opt in for human-readable
1907    /// prose output, leave disabled for code-like outputs.
1908    ///
1909    /// # Example
1910    ///
1911    /// ```
1912    /// use prosaic_core::{Context, Engine, Value};
1913    /// use prosaic_grammar_en::English;
1914    ///
1915    /// let mut engine = Engine::new(English::new()).smart_quotes(true);
1916    /// engine.register_template("t", r#"Alice said "hello""#).unwrap();
1917    /// let mut session = prosaic_core::Session::new();
1918    /// let out = engine.render(&mut session, "t", Context::new()).unwrap();
1919    /// assert!(out.contains('\u{201C}'));
1920    /// assert!(out.contains('\u{201D}'));
1921    /// ```
1922    #[cfg(feature = "polish")]
1923    pub fn smart_quotes(mut self, enabled: bool) -> Self {
1924        self.smart_quotes = enabled;
1925        self
1926    }
1927
1928    /// Cap the character length of any single rendered sentence. When a
1929    /// sentence exceeds `max_chars`, the engine splits it at the latest
1930    /// natural boundary (subordinate clauses introduced by "which",
1931    /// "affecting", "impacting", "requiring"; list prefixes like
1932    /// "including"; em-dashes; explicit sentence breaks) and wraps the
1933    /// remainder as a follow-up sentence with a light grammatical fix-up.
1934    ///
1935    /// If no natural boundary exists inside the budget the sentence
1936    /// passes through unchanged — we never chop mid-word.
1937    ///
1938    /// # Example
1939    ///
1940    /// ```
1941    /// use prosaic_core::{Context, Engine};
1942    /// use prosaic_grammar_en::English;
1943    ///
1944    /// let mut engine = Engine::new(English::new()).max_sentence_length(60);
1945    /// engine.register_template(
1946    ///     "t",
1947    ///     "The class UserService was renamed to AccountService, \
1948    ///      which impacts 6 consumers",
1949    /// ).unwrap();
1950    ///
1951    /// let mut session = prosaic_core::Session::new();
1952    /// let out = engine.render(&mut session, "t", Context::new()).unwrap();
1953    /// assert!(out.contains("This impacts 6 consumers"));
1954    /// ```
1955    #[cfg(feature = "polish")]
1956    pub fn max_sentence_length(mut self, max_chars: usize) -> Self {
1957        self.max_sentence_length = Some(max_chars);
1958        self
1959    }
1960
1961    /// Toggle the sentence-rhythm cadence penalty layered on top of
1962    /// choose-best scoring (`Variation::Seeded`/`Random`). Enabled by
1963    /// default — the penalty discourages picking template variants whose
1964    /// length closely matches recently emitted sentences, biasing prose
1965    /// toward higher length variance / burstiness.
1966    ///
1967    /// Disable this when downstream tooling needs the historical scoring
1968    /// behaviour (word-repetition only) — e.g. golden-output regressions
1969    /// taken before the rhythm pass landed.
1970    pub fn sentence_rhythm(mut self, enabled: bool) -> Self {
1971        self.sentence_rhythm_enabled = enabled;
1972        self
1973    }
1974
1975    /// Register a positive-framing antonym for a negative verb phrase.
1976    /// The `{phrase|negated}` pipe will prefer the registered positive
1977    /// form (e.g. "remained unchanged") over the default "not {phrase}"
1978    /// fallback ("was not modified").
1979    ///
1980    /// Matching is case-insensitive.
1981    pub fn register_antonym(&mut self, negative: &str, positive: &str) {
1982        self.antonyms.register(negative, positive);
1983    }
1984
1985    /// Register a group of synonym words for elegant variation. The
1986    /// `{word|syn}` pipe will look up the input word in the registered
1987    /// groups and pick whichever synonym from the group has appeared
1988    /// least recently in output. Ties break toward registration order.
1989    ///
1990    /// Example:
1991    ///
1992    /// ```ignore
1993    /// engine.register_synonyms(&["consumer", "dependent", "caller"]);
1994    /// // Template: "{count} {consumer|syn}{count|pluralize:}"
1995    /// // First render emits "consumer(s)"; next "dependent(s)"; next "caller(s)".
1996    /// ```
1997    pub fn register_synonyms(&mut self, group: &[&str]) {
1998        self.synonyms.register_group(group);
1999    }
2000
2001    /// Enable a runtime faithfulness gate on every `render*` call.
2002    ///
2003    /// When set, each rendered output is scored against its input Context
2004    /// and the selected template's literal tokens using PARENT-style
2005    /// precision + polarity checking. If the score's precision falls below
2006    /// `threshold` OR polarity tokens mismatch between source and output,
2007    /// the render returns [`ProsaicError::FaithfulnessRejection`] and the
2008    /// session state is restored as if the render had not occurred.
2009    ///
2010    /// `threshold` is typically `1.0` (strict: every content token in the
2011    /// output must be sourced from the context or template literals).
2012    /// Values below `1.0` tolerate a fraction of unentailed tokens — useful
2013    /// when the engine legitimately emits hedged phrasings that introduce
2014    /// words not present in the input (e.g. "approximately", "likely").
2015    ///
2016    /// Default: no gate (all renders pass).
2017    ///
2018    /// # Example
2019    ///
2020    /// ```
2021    /// use prosaic_core::{Context, Engine, ProsaicError, Value};
2022    /// use prosaic_grammar_en::English;
2023    ///
2024    /// let mut engine = Engine::new(English::new())
2025    ///     .with_faithfulness_gate(1.0);
2026    ///
2027    /// engine.register_template("t", "{name} was modified").unwrap();
2028    ///
2029    /// let mut ctx = Context::new();
2030    /// ctx.insert("name", Value::String("UserService".into()));
2031    /// let mut session = prosaic_core::Session::new();
2032    /// // "modified" is in the template literal — renders faithfully.
2033    /// assert!(engine.render(&mut session, "t", &ctx).is_ok());
2034    /// ```
2035    pub fn with_faithfulness_gate(mut self, threshold: f32) -> Self {
2036        self.faithfulness_threshold = Some(threshold);
2037        self
2038    }
2039
2040    /// Get a reference to the language implementation.
2041    pub fn language(&self) -> &dyn Language {
2042        &*self.language
2043    }
2044
2045    /// Register a template string under a key with Medium salience.
2046    /// Multiple templates registered under the same key become alternatives
2047    /// for variation at that salience level.
2048    ///
2049    /// # Example
2050    ///
2051    /// ```
2052    /// use prosaic_core::{Context, Engine, Session, Value};
2053    /// use prosaic_grammar_en::English;
2054    ///
2055    /// let mut engine = Engine::new(English::new());
2056    /// engine.register_template(
2057    ///     "count.items",
2058    ///     "You have {n} {n|pluralize:item}",
2059    /// ).unwrap();
2060    ///
2061    /// let mut ctx = Context::new();
2062    /// ctx.insert("n", Value::Number(3));
2063    /// let mut session = Session::new();
2064    /// assert_eq!(engine.render(&mut session, "count.items", &ctx).unwrap(), "You have 3 items.");
2065    /// ```
2066    pub fn register_template(&mut self, key: &str, source: &str) -> Result<(), ProsaicError> {
2067        self.register_template_at(key, source, Salience::Medium)
2068    }
2069
2070    /// Register a template at a specific salience level. The engine selects
2071    /// templates at the salience matching the rendered event's magnitude.
2072    pub fn register_template_at(
2073        &mut self,
2074        key: &str,
2075        source: &str,
2076        salience: Salience,
2077    ) -> Result<(), ProsaicError> {
2078        self.register_template_with_language_and_style_at(key, source, salience, None, None)
2079    }
2080
2081    /// Register a template variant tagged with a BCP-47 language code.
2082    /// Variants registered with `None` language are language-agnostic
2083    /// fallbacks. The engine's [`Engine::language_preference`] biases
2084    /// variant selection: when a preference is set and any variant
2085    /// matches it, the engine picks among matching variants only.
2086    pub fn register_template_with_language(
2087        &mut self,
2088        key: &str,
2089        source: &str,
2090        language: Option<&str>,
2091    ) -> Result<(), ProsaicError> {
2092        self.register_template_with_language_and_style_at(
2093            key,
2094            source,
2095            Salience::Medium,
2096            language,
2097            None,
2098        )
2099    }
2100
2101    /// Register a template variant tagged with a free-form style.
2102    /// Variants registered with `None` style are unstyled fallbacks. The
2103    /// engine's [`Engine::style_preference`] biases variant selection
2104    /// after language filtering and before salience filtering.
2105    pub fn register_template_with_style(
2106        &mut self,
2107        key: &str,
2108        source: &str,
2109        style: Option<&str>,
2110    ) -> Result<(), ProsaicError> {
2111        self.register_template_with_language_and_style_at(
2112            key,
2113            source,
2114            Salience::Medium,
2115            None,
2116            style,
2117        )
2118    }
2119
2120    /// Register a template variant tagged with both language and style.
2121    pub fn register_template_with_language_and_style(
2122        &mut self,
2123        key: &str,
2124        source: &str,
2125        language: Option<&str>,
2126        style: Option<&str>,
2127    ) -> Result<(), ProsaicError> {
2128        self.register_template_with_language_and_style_at(
2129            key,
2130            source,
2131            Salience::Medium,
2132            language,
2133            style,
2134        )
2135    }
2136
2137    /// Load a project from its bundled JSON manifest (produced by
2138    /// `prosaic build --target=json`). Registers all partials and
2139    /// template variants, applies engine settings, and sets the
2140    /// language/style preferences.
2141    ///
2142    /// Available with the `serde` feature.
2143    #[cfg(feature = "serde")]
2144    pub fn load_manifest(&mut self, json: &str) -> Result<(), ProsaicError> {
2145        let bundle: manifest_loader::ManifestBundle =
2146            serde_json::from_str(json).map_err(|e| ProsaicError::TemplateParseError {
2147                template: "(manifest)".to_string(),
2148                position: 0,
2149                reason: format!("manifest JSON parse error: {e}"),
2150            })?;
2151        if bundle.schema_version != 1 {
2152            return Err(ProsaicError::TemplateParseError {
2153                template: "(manifest)".to_string(),
2154                position: 0,
2155                reason: format!(
2156                    "unsupported manifest schema version {}",
2157                    bundle.schema_version
2158                ),
2159            });
2160        }
2161        apply_manifest_engine_settings(self, &bundle.engine)?;
2162        self.language_preference = Some(bundle.language);
2163        for partial in bundle.partials {
2164            self.register_partial(&partial.name, &partial.body)?;
2165        }
2166        for template in bundle.templates {
2167            for variant in template.variants {
2168                let salience = match variant.salience.as_str() {
2169                    "low" => Salience::Low,
2170                    "medium" => Salience::Medium,
2171                    "high" => Salience::High,
2172                    other => {
2173                        return Err(ProsaicError::TemplateParseError {
2174                            template: "(manifest)".to_string(),
2175                            position: 0,
2176                            reason: format!(
2177                                "unknown salience `{other}` for template `{}`",
2178                                template.key
2179                            ),
2180                        });
2181                    }
2182                };
2183                self.register_template_with_language_and_style_at(
2184                    &template.key,
2185                    &variant.body,
2186                    salience,
2187                    variant.language.as_deref(),
2188                    variant.style.as_deref(),
2189                )?;
2190            }
2191        }
2192        Ok(())
2193    }
2194
2195    /// Salience-aware companion to [`Engine::register_template_with_language`].
2196    pub fn register_template_with_language_at(
2197        &mut self,
2198        key: &str,
2199        source: &str,
2200        salience: Salience,
2201        language: Option<&str>,
2202    ) -> Result<(), ProsaicError> {
2203        self.register_template_with_language_and_style_at(key, source, salience, language, None)
2204    }
2205
2206    /// Salience-aware companion to [`Engine::register_template_with_style`].
2207    pub fn register_template_with_style_at(
2208        &mut self,
2209        key: &str,
2210        source: &str,
2211        salience: Salience,
2212        style: Option<&str>,
2213    ) -> Result<(), ProsaicError> {
2214        self.register_template_with_language_and_style_at(key, source, salience, None, style)
2215    }
2216
2217    /// Register a template variant with explicit salience, language, and style tags.
2218    pub fn register_template_with_language_and_style_at(
2219        &mut self,
2220        key: &str,
2221        source: &str,
2222        salience: Salience,
2223        language: Option<&str>,
2224        style: Option<&str>,
2225    ) -> Result<(), ProsaicError> {
2226        let template = Template::parse(source)?;
2227
2228        // Chain-level sanity check: pipe n's output must match pipe n+1's input,
2229        // and multi-mention slots must unify. This turns what used to be a
2230        // render-time `InvalidPipe` into a register-time `TemplateParseError`.
2231        template
2232            .infer_types()
2233            .map_err(|reason| ProsaicError::TemplateParseError {
2234                template: source.to_string(),
2235                position: 0,
2236                reason,
2237            })?;
2238
2239        self.templates
2240            .entry(key.to_string())
2241            .or_default()
2242            .push(SalientTemplate::new(
2243                salience,
2244                template,
2245                language.map(|s| s.to_string()),
2246                style.map(|s| s.to_string()),
2247            ));
2248        self.rr_initial.entry(key.to_string()).or_insert(0);
2249        Ok(())
2250    }
2251
2252    /// Register a template and cross-check every slot's inferred type
2253    /// against the static schema of `T`, a type that implements
2254    /// `IntoContext + HasProsaicSchema`.
2255    ///
2256    /// Slot types inferred from pipe chains (e.g. `{count|pluralize:item}`
2257    /// implies `count: Number`) must be compatible with `T`'s schema. Use
2258    /// this when loading templates dynamically (from JSON, disk, etc.) and
2259    /// you want the same strong guarantees the `prosaic_template!` macro
2260    /// provides at compile time.
2261    pub fn register_template_with_schema<T>(
2262        &mut self,
2263        key: &str,
2264        source: &str,
2265    ) -> Result<(), ProsaicError>
2266    where
2267        T: crate::HasProsaicSchema + crate::IntoContext,
2268    {
2269        let template = Template::parse(source)?;
2270        let inferred =
2271            template
2272                .infer_types()
2273                .map_err(|reason| ProsaicError::TemplateParseError {
2274                    template: source.to_string(),
2275                    position: 0,
2276                    reason,
2277                })?;
2278
2279        let ty = core::any::type_name::<T>();
2280        for (slot, expected) in &inferred {
2281            let actual = crate::schema_lookup(T::PROSAIC_SCHEMA, slot).ok_or_else(|| {
2282                ProsaicError::TemplateParseError {
2283                    template: source.to_string(),
2284                    position: 0,
2285                    reason: format!(
2286                        "slot `{slot}` required by template is not declared in context `{ty}`"
2287                    ),
2288                }
2289            })?;
2290            if !crate::types_compatible(actual, *expected) {
2291                return Err(ProsaicError::TemplateParseError {
2292                    template: source.to_string(),
2293                    position: 0,
2294                    reason: format!(
2295                        "slot `{slot}` in context `{ty}` has type {actual:?} but template pipe chain expects {expected:?}"
2296                    ),
2297                });
2298            }
2299        }
2300
2301        self.templates
2302            .entry(key.to_string())
2303            .or_default()
2304            .push(SalientTemplate::new(Salience::Medium, template, None, None));
2305        self.rr_initial.entry(key.to_string()).or_insert(0);
2306        Ok(())
2307    }
2308
2309    /// Check whether a template is registered under the given key.
2310    ///
2311    /// Pure read, no mutation. Useful for callers that want to skip
2312    /// rendering when no template matches (e.g. tracing bridges where
2313    /// not every event type has a registered template).
2314    ///
2315    /// # Example
2316    ///
2317    /// ```
2318    /// use prosaic_core::Engine;
2319    /// use prosaic_grammar_en::English;
2320    ///
2321    /// let mut engine = Engine::new(English::new());
2322    /// engine.register_template("greet", "Hello {name}").unwrap();
2323    ///
2324    /// assert!(engine.has_template("greet"));
2325    /// assert!(!engine.has_template("farewell"));
2326    /// ```
2327    pub fn has_template(&self, key: &str) -> bool {
2328        self.templates.contains_key(key)
2329    }
2330
2331    /// Compute the salience for a context using this engine's thresholds.
2332    pub fn context_salience(&self, ctx: &Context) -> Salience {
2333        let thresholds = apply_salience_bias(self.salience_thresholds, self.style_profile.salience);
2334        Salience::from_context(ctx, thresholds)
2335    }
2336
2337    /// Render a registered template with the given context.
2338    ///
2339    /// The session tracks discourse state across calls: entity mentions,
2340    /// template history, word frequency. Each call benefits from context
2341    /// established by previous calls. Use `session.reset()` between unrelated
2342    /// sequences.
2343    ///
2344    /// Render is transactional: if any step fails (missing slot in Strict mode,
2345    /// unknown pipe, etc.), the discourse state is rolled back to what it was
2346    /// before the call. A caller that catches the error sees no residue from
2347    /// the failed render in subsequent output.
2348    ///
2349    /// # Example
2350    ///
2351    /// ```
2352    /// use prosaic_core::{Context, Engine, Session, Value, Variation};
2353    /// use prosaic_grammar_en::English;
2354    ///
2355    /// let mut engine = Engine::new(English::new()).variation(Variation::Fixed);
2356    /// engine.register_template("greet", "Hello {name}").unwrap();
2357    ///
2358    /// let mut session = Session::new();
2359    /// let mut ctx = Context::new();
2360    /// ctx.insert("name", Value::String("world".into()));
2361    /// assert_eq!(engine.render(&mut session, "greet", &ctx).unwrap(), "Hello world");
2362    /// ```
2363    pub fn render(
2364        &self,
2365        session: &mut Session,
2366        key: &str,
2367        context: impl IntoContext,
2368    ) -> Result<String, ProsaicError> {
2369        self.render_with_options(session, key, context, RenderOptions::default())
2370    }
2371
2372    /// Internal: render with explicit options. Used by batch rendering
2373    /// paths that need to adjust default behaviours (e.g. suppressing
2374    /// the automatic discourse connective when an RST marker is being
2375    /// applied). Mirrors `render`'s snapshot/restore semantics.
2376    fn render_with_options(
2377        &self,
2378        session: &mut Session,
2379        key: &str,
2380        context: impl IntoContext,
2381        options: RenderOptions,
2382    ) -> Result<String, ProsaicError> {
2383        let all_alternatives = self
2384            .templates
2385            .get(key)
2386            .ok_or_else(|| ProsaicError::UnknownTemplate(key.to_string()))?;
2387        let context = context.into_context();
2388
2389        let snapshot = session.clone();
2390        match RenderCtx::new(self, session).render_tx_with_options(
2391            key,
2392            all_alternatives,
2393            &context,
2394            options,
2395        ) {
2396            Ok(output) => {
2397                // Update temporal anchor after a successful render so that
2398                // render errors don't corrupt state. The anchor is set whenever
2399                // the event context carries a `timestamp` slot.
2400                #[cfg(feature = "time")]
2401                if let Some(Value::Number(ts)) = context.get("timestamp") {
2402                    session.last_temporal_anchor = Some(*ts);
2403                }
2404                Ok(output)
2405            }
2406            Err(e) => {
2407                *session = snapshot;
2408                Err(e)
2409            }
2410        }
2411    }
2412
2413    /// Score every registered variant for `key` against `context`, without
2414    /// committing any render. Returns one [`VariantScore`] per variant
2415    /// that matches the context's salience bucket, with the
2416    /// choose-best score the engine would compute and a `selected` flag
2417    /// marking which one `render()` would currently emit.
2418    ///
2419    /// Useful for template-author diagnostics ("why does variant B
2420    /// always win?") and vocab-module lints. Does not mutate discourse
2421    /// state — the function snapshots and restores state around candidate
2422    /// rendering.
2423    pub fn score_variants(
2424        &self,
2425        session: &mut Session,
2426        key: &str,
2427        context: impl IntoContext,
2428    ) -> Result<Vec<VariantScore>, ProsaicError> {
2429        let all = self
2430            .templates
2431            .get(key)
2432            .ok_or_else(|| ProsaicError::UnknownTemplate(key.to_string()))?;
2433
2434        let ctx = context.into_context();
2435        // State is always restored by score_all_variants internally.
2436        RenderCtx::new(self, session).score_all_variants(key, all, &ctx)
2437    }
2438
2439    /// Render a one-off template string (not registered) with the given context.
2440    ///
2441    /// Inline templates are **state-isolated**: they do not participate in
2442    /// discourse tracking (no connectives, no entity mentions, no list-style
2443    /// cycle advancement, no plural-focus flag changes) and do not consume
2444    /// template-variant / round-robin counters. The only side effect on the
2445    /// real session is that output words are recorded for repetition scoring,
2446    /// and only when the render succeeds — a failed inline render leaves the
2447    /// session exactly as it was before the call.
2448    ///
2449    /// Implementation: render into a cloned session and discard it. On
2450    /// success, record output words on the caller's session.
2451    pub fn render_inline(
2452        &self,
2453        session: &mut Session,
2454        source: &str,
2455        context: impl IntoContext,
2456    ) -> Result<String, ProsaicError> {
2457        let template = Template::parse(source)?;
2458        let context = context.into_context();
2459
2460        // Render into a scratch session clone so any stateful pipes
2461        // (list-style cycle, plural `refer` mention_entity calls, etc.)
2462        // mutate the clone rather than the caller's session.
2463        let mut scratch = session.clone();
2464        let mut output = String::with_capacity(128);
2465        RenderCtx::new(self, &mut scratch).render_template_into(
2466            &mut output,
2467            "<inline>",
2468            &template,
2469            &context,
2470        )?;
2471
2472        // Success: the only mutation allowed to escape is the repetition
2473        // scoring word history, so callers see inline output in anti-repeat
2474        // decisions for subsequent registered renders.
2475        session.discourse.record_output_words(&output);
2476        Ok(output)
2477    }
2478
2479    /// Render a batch of events as a cohesive paragraph.
2480    ///
2481    /// Each event is rendered sequentially through `render()`, which means
2482    /// the discourse system produces natural cross-sentence flow via
2483    /// referring expressions, connectives, template anti-repeat, and
2484    /// list style cycling.
2485    ///
2486    /// Additionally, consecutive events sharing a template key but with
2487    /// different entities are aggregated by combining subjects:
2488    /// "UserService and AuthService were renamed" instead of two sentences.
2489    ///
2490    /// # Example — clause reduction across same-entity events
2491    ///
2492    /// ```
2493    /// use prosaic_core::{Context, Engine, Value};
2494    /// use prosaic_grammar_en::English;
2495    ///
2496    /// let mut engine = Engine::new(English::new());
2497    /// engine.register_template("renamed", "{name|refer} was renamed").unwrap();
2498    /// engine.register_template("modified", "{name|refer} was modified").unwrap();
2499    /// engine.register_template("moved", "{name|refer} was moved").unwrap();
2500    ///
2501    /// let mut ctx = Context::new();
2502    /// ctx.insert("entity_type", Value::String("class".into()));
2503    /// ctx.insert("name", Value::String("UserService".into()));
2504    /// let events: Vec<(&str, Context)> = vec![
2505    ///     ("renamed", ctx.clone()),
2506    ///     ("modified", ctx.clone()),
2507    ///     ("moved", ctx.clone()),
2508    /// ];
2509    ///
2510    /// let mut session = prosaic_core::Session::new();
2511    /// let out = engine.render_batch(&mut session, &events).unwrap();
2512    /// assert_eq!(
2513    ///     out,
2514    ///     "The class UserService was renamed, modified, and moved."
2515    /// );
2516    /// ```
2517    pub fn render_batch(
2518        &self,
2519        session: &mut Session,
2520        events: &[(&str, Context)],
2521    ) -> Result<String, ProsaicError> {
2522        if events.is_empty() {
2523            return Ok(String::new());
2524        }
2525
2526        let mut sentences: Vec<String> = Vec::new();
2527        let mut i = 0;
2528
2529        while i < events.len() {
2530            // Look for same-action-different-subject aggregation opportunity.
2531            let action_end = self.find_same_action_run(events, i);
2532
2533            if action_end > i + 1 {
2534                // Multiple consecutive events with same template key but
2535                // different entities — aggregate their subjects.
2536                let sentence =
2537                    self.render_aggregated_subjects(session, events[i].0, &events[i..action_end])?;
2538                sentences.push(sentence);
2539                i = action_end;
2540                continue;
2541            }
2542
2543            // Look for a gapping opportunity: same template key, different
2544            // subjects, AND incompatible non-subject context (different objects
2545            // or complements). Produces "Foo was moved to core, Bar to util,
2546            // and Baz to api." from three separate events.
2547            let gap_end = self.find_gapping_run(events, i);
2548            if gap_end > i + 1 {
2549                let mut rendered: Vec<String> = Vec::with_capacity(gap_end - i);
2550                for (key, ctx) in &events[i..gap_end] {
2551                    rendered.push(self.render(session, key, ctx)?);
2552                }
2553                if let Some(gapped) = reduce_gapping(&rendered) {
2554                    sentences.push(gapped);
2555                } else {
2556                    sentences.extend(rendered);
2557                }
2558                i = gap_end;
2559                continue;
2560            }
2561
2562            // Look for same-entity-different-action aggregation opportunity.
2563            // "The class X was renamed. It was modified. It was moved." reduces
2564            // to "The class X was renamed, modified, and moved" when the voice
2565            // and tense line up and each predicate is simple.
2566            let entity_end = self.find_same_entity_run(events, i);
2567            if entity_end > i + 1 {
2568                let mut run_rendered: Vec<String> = Vec::with_capacity(entity_end - i);
2569                for (key, ctx) in &events[i..entity_end] {
2570                    run_rendered.push(self.render(session, key, ctx)?);
2571                }
2572
2573                if let Some(reduced) = reduce_same_entity_clauses(&run_rendered) {
2574                    sentences.push(reduced);
2575                } else {
2576                    sentences.extend(run_rendered);
2577                }
2578                i = entity_end;
2579                continue;
2580            }
2581
2582            // Single event — render normally with full discourse benefits.
2583            let (key, ref ctx) = events[i];
2584            sentences.push(self.render(session, key, ctx)?);
2585            i += 1;
2586        }
2587
2588        Ok(sentences.join(" "))
2589    }
2590
2591    /// Render a batch of events where each event carries an optional RST
2592    /// relation describing its rhetorical link to the preceding event.
2593    ///
2594    /// When a relation is present on `events[i]` (i ≥ 1), the corresponding
2595    /// discourse marker is prepended to that sentence instead of a plain
2596    /// space — e.g. "However, the class Foo was modified." — and the leading
2597    /// determiner of the rendered sentence is lowercased so the marker's
2598    /// capitalisation leads naturally.
2599    ///
2600    /// When **all** relations are `None`, this method delegates to
2601    /// [`Engine::render_batch`] so aggregation (clause-reduction,
2602    /// subject-aggregation) still applies.
2603    pub fn render_batch_with_relations(
2604        &self,
2605        session: &mut Session,
2606        events: &[(&str, Context, Option<crate::rst::RstRelation>)],
2607    ) -> Result<String, ProsaicError> {
2608        if events.is_empty() {
2609            return Ok(String::new());
2610        }
2611
2612        // If every relation is None, delegate to render_batch to preserve
2613        // aggregation benefits.
2614        if events.iter().all(|(_, _, r)| r.is_none()) {
2615            let pairs: Vec<(&str, Context)> =
2616                events.iter().map(|(k, c, _)| (*k, c.clone())).collect();
2617            return self.render_batch(session, &pairs);
2618        }
2619
2620        let mut output = String::new();
2621        for (i, (key, ctx, relation)) in events.iter().enumerate() {
2622            if i > 0 {
2623                if let Some(rel) = relation {
2624                    if let Some(marker) = self.language.discourse_marker(*rel) {
2625                        output.push(' ');
2626                        output.push_str(marker);
2627                    } else {
2628                        output.push(' ');
2629                    }
2630                } else {
2631                    output.push(' ');
2632                }
2633            }
2634            // When an explicit RST marker is being applied, suppress the
2635            // engine's automatic discourse connective at the source —
2636            // otherwise `connective_history` advances and output words
2637            // include text ("Similarly,", "However,") that was never
2638            // emitted, subtly poisoning anti-repetition scoring and
2639            // later connective selection.
2640            let options = if i > 0 && relation.is_some() {
2641                RenderOptions {
2642                    suppress_auto_connective: true,
2643                }
2644            } else {
2645                RenderOptions::default()
2646            };
2647            let sentence = self.render_with_options(session, key, ctx, options)?;
2648
2649            // If an RST marker was prepended AND the sentence starts with
2650            // a capitalised determiner, lowercase the first letter so the
2651            // marker's capitalisation leads naturally.
2652            if i > 0 && relation.is_some() {
2653                output.push_str(&lowercase_first_if_determiner(&sentence));
2654            } else {
2655                output.push_str(&sentence);
2656            }
2657        }
2658
2659        Ok(output)
2660    }
2661
2662    /// Find the end index (exclusive) of a run of consecutive events that
2663    /// share the same entity (name + entity_type) but potentially differ in
2664    /// template key. Used by clause-reduction aggregation to turn a series
2665    /// of same-subject sentences into one conjunction-reduced sentence.
2666    ///
2667    /// Returns `start + 1` if no multi-event run exists.
2668    fn find_same_entity_run(&self, events: &[(&str, Context)], start: usize) -> usize {
2669        if start >= events.len() {
2670            return start;
2671        }
2672
2673        let first_ctx = &events[start].1;
2674        let first_name = match entity_name_from_context(first_ctx) {
2675            Some(n) => n,
2676            None => return start + 1,
2677        };
2678        let first_type = first_ctx.get("entity_type").map(|v| v.as_display());
2679
2680        let mut end = start + 1;
2681        while end < events.len() {
2682            let ctx = &events[end].1;
2683            let name = match entity_name_from_context(ctx) {
2684                Some(n) => n,
2685                None => break,
2686            };
2687            if name != first_name {
2688                break;
2689            }
2690            let ty = ctx.get("entity_type").map(|v| v.as_display());
2691            if ty != first_type {
2692                break;
2693            }
2694            end += 1;
2695        }
2696
2697        end
2698    }
2699
2700    /// Render a template and return both the output and a
2701    /// [`RenderExplanation`] describing the decisions the engine made.
2702    ///
2703    /// Functionally equivalent to `render()` — discourse state advances
2704    /// the same way; any error rolls back the same way — but each step's
2705    /// diagnostic is also captured. Use this for debugging template
2706    /// behavior: "why did variant B win?", "was this entity reference a
2707    /// pronoun or short-name?", "did the length budget split the
2708    /// output?".
2709    pub fn render_explained(
2710        &self,
2711        session: &mut Session,
2712        key: &str,
2713        context: impl IntoContext,
2714    ) -> Result<RenderExplanation, ProsaicError> {
2715        let all_alternatives = self
2716            .templates
2717            .get(key)
2718            .ok_or_else(|| ProsaicError::UnknownTemplate(key.to_string()))?;
2719
2720        let context = context.into_context();
2721        let target_salience = resolve_target_salience_for(self, session, key, &context);
2722        let alternatives = filter_alternatives(
2723            all_alternatives,
2724            target_salience,
2725            self.language_preference.as_deref(),
2726            self.style_preference.as_deref(),
2727        );
2728
2729        // Pre-compute candidate scores for diagnostics when choose-best
2730        // would apply. Run in a snapshot/restore bubble so main session is
2731        // untouched until the real render below.
2732        let candidate_scores = {
2733            let allow_choose_best =
2734                matches!(self.variation, Variation::Seeded(_) | Variation::Random);
2735            let is_first = session.discourse.is_first_render();
2736            if !allow_choose_best || is_first || alternatives.len() < 2 {
2737                None
2738            } else {
2739                let mut scoring_session = session.clone();
2740                let snapshot = scoring_session.clone();
2741                let mut scored: Vec<f64> = Vec::with_capacity(alternatives.len());
2742                let mut scoring_failed = false;
2743                let mut scratch = String::with_capacity(128);
2744                for template in &alternatives {
2745                    scratch.clear();
2746                    match RenderCtx::new(self, &mut scoring_session).render_template_into(
2747                        &mut scratch,
2748                        key,
2749                        template,
2750                        &context,
2751                    ) {
2752                        Ok(()) => {
2753                            let mut score = scoring_session.discourse.repetition_score(&scratch);
2754                            if self.sentence_rhythm_enabled {
2755                                score += scoring_session.discourse.sentence_rhythm_score(&scratch);
2756                            }
2757                            scored.push(score);
2758                        }
2759                        Err(_) => {
2760                            scoring_failed = true;
2761                            break;
2762                        }
2763                    }
2764                    scoring_session = snapshot.clone();
2765                }
2766                if scoring_failed { None } else { Some(scored) }
2767            }
2768        };
2769
2770        // Capture the pre-render ref form for the primary entity (if any).
2771        let entity_name = context
2772            .get("name")
2773            .or_else(|| context.get("old_name"))
2774            .map(|v| v.as_display());
2775        let reference_form = entity_name.as_ref().map(|n| {
2776            session.discourse.reference_form_with_density(
2777                n,
2778                matches!(
2779                    self.style_profile.pronoun_density,
2780                    crate::style::PronounDensity::Low
2781                ),
2782                matches!(
2783                    self.style_profile.pronoun_density,
2784                    crate::style::PronounDensity::High
2785                ),
2786            )
2787        });
2788
2789        // Run the real render. Discourse state advances normally.
2790        let output = self.render(session, key, &context)?;
2791
2792        // Recover diagnostic info from the (now-advanced) discourse state.
2793        let variant_index = session
2794            .discourse
2795            .last_template_variant(key)
2796            .unwrap_or(0)
2797            .min(alternatives.len().saturating_sub(1));
2798        let variant_source = alternatives
2799            .get(variant_index)
2800            .map(|t| t.source.clone())
2801            .unwrap_or_default();
2802
2803        let focus_is_plural = session.discourse.focus_is_plural();
2804        let centering_transition = session.discourse.last_transition();
2805
2806        #[cfg(feature = "polish")]
2807        let length_split_applied = self
2808            .max_sentence_length
2809            .is_some_and(|max| output.chars().count() > max && output.contains(". "));
2810        #[cfg(not(feature = "polish"))]
2811        let length_split_applied = false;
2812
2813        let connective = detect_leading_connective(&output);
2814
2815        let list_style = session.discourse.last_list_style_used();
2816        let cleanup_stripped_tail = session.discourse.last_cleanup_stripped_tail();
2817
2818        Ok(RenderExplanation {
2819            output,
2820            template_key: key.to_string(),
2821            variant_index,
2822            variant_source,
2823            salience: target_salience,
2824            candidate_scores,
2825            reference_form,
2826            connective,
2827            list_style,
2828            focus_is_plural,
2829            length_split_applied,
2830            cleanup_stripped_tail,
2831            centering_transition,
2832        })
2833    }
2834
2835    /// Iterator form of [`Engine::render_batch`]. Yields each sentence
2836    /// (or aggregated run) as it is produced, so callers concerned with
2837    /// time-to-first-sentence can stream output instead of waiting for
2838    /// the full batch. Each `.next()` call produces one sentence —
2839    /// which may correspond to multiple events (when aggregation or
2840    /// clause reduction fires) — and returns `None` once the events
2841    /// are exhausted.
2842    ///
2843    /// **Errors are terminal.** If any render inside the run fails, the
2844    /// iterator yields `Some(Err(_))` exactly once and then returns `None`
2845    /// on every subsequent call. This keeps semantics predictable even
2846    /// when the failing event sits inside an aggregated, gapped, or
2847    /// same-entity run whose earlier sentences already mutated session
2848    /// state — replaying the run after partial success would compound
2849    /// pronoun / anti-repetition state in unsafe ways. If you need
2850    /// error-skipping behaviour, validate templates and contexts up
2851    /// front (e.g. with [`Engine::score_variants`]) rather than relying
2852    /// on the iterator to recover.
2853    pub fn render_iter<'a>(
2854        &'a self,
2855        session: &'a mut Session,
2856        events: &'a [(&'a str, Context)],
2857    ) -> RenderIter<'a> {
2858        RenderIter {
2859            engine: self,
2860            session,
2861            events,
2862            i: 0,
2863        }
2864    }
2865
2866    /// Find the end index (exclusive) of a run of consecutive events that
2867    /// share the same template key AND matching non-subject context, but
2868    /// have different entity names.
2869    ///
2870    /// Only aggregates when the surrounding context (everything except the
2871    /// entity name) is identical — otherwise we'd lose information like
2872    /// different new_name targets or different consumer counts.
2873    ///
2874    /// Returns `start + 1` if no aggregation opportunity exists.
2875    fn find_same_action_run(&self, events: &[(&str, Context)], start: usize) -> usize {
2876        if start >= events.len() {
2877            return start;
2878        }
2879
2880        let (first_key, ref first_ctx) = events[start];
2881        let first_name = entity_name_from_context(first_ctx);
2882
2883        if first_name.is_none() {
2884            return start + 1;
2885        }
2886
2887        let mut end = start + 1;
2888        let mut seen_names: crate::collections::HashSet<String> = crate::collections::new_set();
2889        seen_names.insert(first_name.unwrap());
2890
2891        while end < events.len() {
2892            let (key, ref ctx) = events[end];
2893            if key != first_key {
2894                break;
2895            }
2896            let name = match entity_name_from_context(ctx) {
2897                Some(n) => n,
2898                None => break,
2899            };
2900            if seen_names.contains(&name) {
2901                break;
2902            }
2903            // Only aggregate if the non-subject context matches.
2904            // If new_name, consumer_count, consumers, or location differ,
2905            // sequential rendering preserves more information.
2906            if !contexts_compatible_for_aggregation(first_ctx, ctx) {
2907                break;
2908            }
2909            seen_names.insert(name);
2910            end += 1;
2911        }
2912
2913        end
2914    }
2915
2916    /// Find the end index (exclusive) of a run of consecutive events that
2917    /// are candidates for gapping reduction:
2918    /// - Same template key as `events[start]`.
2919    /// - Each event has a distinct, extractable entity name.
2920    /// - Each event's context is **incompatible** with the first event's
2921    ///   context (if it were compatible, `find_same_action_run` would have
2922    ///   already grabbed it for subject-aggregation).
2923    ///
2924    /// Returns `start + 1` when no gapping opportunity exists.
2925    fn find_gapping_run(&self, events: &[(&str, Context)], start: usize) -> usize {
2926        if start >= events.len() {
2927            return start;
2928        }
2929
2930        let (first_key, first_ctx) = (events[start].0, &events[start].1);
2931        let Some(first_name) = entity_name_from_context(first_ctx) else {
2932            return start + 1;
2933        };
2934
2935        let mut end = start + 1;
2936        let mut seen: crate::collections::HashSet<String> = core::iter::once(first_name).collect();
2937
2938        while end < events.len() {
2939            let (k, ctx) = (events[end].0, &events[end].1);
2940            if k != first_key {
2941                break;
2942            }
2943            let Some(name) = entity_name_from_context(ctx) else {
2944                break;
2945            };
2946            if seen.contains(&name) {
2947                break;
2948            }
2949            // Bail if the contexts are compatible — the aggregated-subjects
2950            // path must win in that case. We only gap incompatible contexts.
2951            if contexts_compatible_for_aggregation(first_ctx, ctx) {
2952                break;
2953            }
2954            seen.insert(name);
2955            end += 1;
2956        }
2957
2958        end
2959    }
2960
2961    /// Render an aggregated sentence combining multiple subjects for the
2962    /// same action: "UserService and AuthService were renamed."
2963    fn render_aggregated_subjects(
2964        &self,
2965        session: &mut Session,
2966        key: &str,
2967        events: &[(&str, Context)],
2968    ) -> Result<String, ProsaicError> {
2969        // Collect entity names
2970        let names: Vec<String> = events
2971            .iter()
2972            .filter_map(|(_, ctx)| entity_name_from_context(ctx))
2973            .collect();
2974
2975        if names.is_empty() {
2976            // Fallback to sequential rendering
2977            let mut sentences = Vec::new();
2978            for (k, ctx) in events {
2979                sentences.push(self.render(session, k, ctx)?);
2980            }
2981            return Ok(sentences.join(" "));
2982        }
2983
2984        // Build a synthetic context that uses the combined name
2985        // "UserService and AuthService" as the entity name
2986        let refs: Vec<&str> = names.iter().map(|s| s.as_str()).collect();
2987        let combined_name = self
2988            .language
2989            .join_list(&refs, crate::language::Conjunction::And);
2990
2991        // Use the first event's context as the base
2992        let mut combined_ctx = events[0].1.clone();
2993
2994        // Override the name/old_name with the combined form
2995        if combined_ctx.get("old_name").is_some() {
2996            combined_ctx.insert("old_name", Value::String(combined_name.clone()));
2997        }
2998        if combined_ctx.get("name").is_some() {
2999            combined_ctx.insert("name", Value::String(combined_name.clone()));
3000        }
3001
3002        // Render with combined subject, then apply plural agreement
3003        let rendered = self.render(session, key, combined_ctx)?;
3004
3005        // Mark the discourse focus as plural so any subsequent pronoun
3006        // reference uses "they" instead of "it".
3007        session.discourse.set_focus_plural(true);
3008
3009        Ok(pluralize_agreement(&rendered, &*self.language))
3010    }
3011
3012    /// Pure stateless variant index selection (no session needed).
3013    /// Used by RenderCtx::pick_variant_index for the non-scored path.
3014    fn pick_variant_index_static(&self, key: &str, count: usize) -> usize {
3015        match self.variation {
3016            Variation::Fixed => 0,
3017            Variation::Seeded(seed) => {
3018                let hash = simple_hash(key, seed);
3019                hash as usize % count
3020            }
3021            Variation::Random => {
3022                #[cfg(feature = "std")]
3023                {
3024                    let nanos = std::time::SystemTime::now()
3025                        .duration_since(std::time::UNIX_EPOCH)
3026                        .unwrap_or_default()
3027                        .subsec_nanos() as usize;
3028                    nanos % count
3029                }
3030                #[cfg(not(feature = "std"))]
3031                {
3032                    let _ = count;
3033                    0
3034                }
3035            }
3036            // RoundRobin requires mutable state — callers that need RoundRobin
3037            // must go through RenderCtx::select_variant_index instead.
3038            Variation::RoundRobin => 0,
3039        }
3040    }
3041
3042    /// Build a *Full form* reference. If the entity is in the registry,
3043    /// run the configured REG algorithm against registered entities of the
3044    /// same type and include distinguishing attributes as premodifiers. When
3045    /// the graph-based algorithm is configured and attributes alone do not
3046    /// disambiguate, one relation clause is appended as a postmodifier.
3047    ///
3048    /// Registry lookup uses `(entity_type, name)` so the same name can
3049    /// refer to distinct entities of different types. The context-supplied
3050    /// type is authoritative here — if the render says "type=class", we
3051    /// never substitute a registered trait of the same name.
3052    ///
3053    /// Fallbacks:
3054    /// - Unregistered entity with known type → "the <type> <name>".
3055    /// - Unregistered entity without a type  → just the name.
3056    fn render_full_reference(&self, name: &str, fallback_type: &str) -> String {
3057        // With the `reg` feature: look up by (type, name) and run the
3058        // selected REG algorithm. Without it: degrade gracefully to
3059        // "the <type> <name>" / just the name.
3060        #[cfg(feature = "reg")]
3061        let (attrs, relation): (Vec<String>, Option<(String, String)>) = {
3062            let registered = if fallback_type.is_empty() {
3063                None
3064            } else {
3065                self.entity_registry.get(fallback_type, name)
3066            };
3067            let target = match registered {
3068                Some(d) => d.clone(),
3069                None => {
3070                    if fallback_type.is_empty() {
3071                        return name.to_string();
3072                    }
3073                    EntityDescriptor::new(name, fallback_type)
3074                }
3075            };
3076            match self.reg_algorithm {
3077                RegAlgorithm::DaleReiter => (
3078                    distinguishing_attributes(&target, &self.entity_registry, &self.reg_preference),
3079                    None,
3080                ),
3081                RegAlgorithm::GraphBased => {
3082                    let desc = distinguishing_subgraph(
3083                        &target,
3084                        &self.entity_registry,
3085                        &self.reg_preference,
3086                    );
3087                    (desc.attributes, desc.relation)
3088                }
3089            }
3090        };
3091
3092        #[cfg(not(feature = "reg"))]
3093        let (attrs, relation): (Vec<String>, Option<(String, String)>) = {
3094            if fallback_type.is_empty() {
3095                return name.to_string();
3096            }
3097            (Vec::new(), None)
3098        };
3099
3100        // The context-supplied type is authoritative. Only fall through to
3101        // a registered-descriptor type when context provides nothing.
3102        #[cfg(feature = "reg")]
3103        let entity_type = if fallback_type.is_empty() {
3104            self.entity_registry
3105                .get("", name)
3106                .map(|d| d.entity_type.clone())
3107                .unwrap_or_default()
3108        } else {
3109            fallback_type.to_string()
3110        };
3111        #[cfg(not(feature = "reg"))]
3112        let entity_type = fallback_type.to_string();
3113
3114        if entity_type.is_empty() {
3115            if attrs.is_empty() {
3116                return name.to_string();
3117            }
3118            return format!("the {} {}", attrs.join(" "), name);
3119        }
3120
3121        let lower_type = entity_type.to_lowercase();
3122        let base = if attrs.is_empty() {
3123            format!("the {lower_type} {name}")
3124        } else {
3125            format!("the {} {lower_type} {name}", attrs.join(" "))
3126        };
3127
3128        // Append relation clause when the graph-based algorithm selected one.
3129        if let Some((label, target_name)) = relation {
3130            format!("{base} {label} {target_name}")
3131        } else {
3132            base
3133        }
3134    }
3135
3136    /// Create a new [`Session`] compatible with this engine.
3137    ///
3138    /// Sugar for `Session::new()`. Equivalent, but clarifies intent at call
3139    /// sites where a reader might wonder which session type to construct.
3140    ///
3141    /// # Example
3142    ///
3143    /// ```
3144    /// use prosaic_core::{Context, Engine, Value};
3145    /// use prosaic_grammar_en::English;
3146    ///
3147    /// let mut engine = Engine::new(English::new());
3148    /// engine.register_template("hello", "Hello {name}!").unwrap();
3149    ///
3150    /// let mut session = engine.new_session();
3151    /// let mut ctx = Context::new();
3152    /// ctx.insert("name", Value::String("world".into()));
3153    /// assert_eq!(engine.render(&mut session, "hello", &ctx).unwrap(), "Hello world!");
3154    /// ```
3155    pub fn new_session(&self) -> Session {
3156        Session::new()
3157    }
3158}
3159
3160/// Format a truncated list with natural style.
3161fn format_truncated_list(
3162    shown: &[&str],
3163    remainder: &str,
3164    style: ListStyle,
3165    conjunction: Conjunction,
3166    language: &dyn Language,
3167) -> String {
3168    let joined = language.join_list(shown, conjunction);
3169    match style {
3170        ListStyle::Including => {
3171            format!("including {joined} among others")
3172        }
3173        ListStyle::SuchAs => {
3174            format!("such as {joined}")
3175        }
3176        ListStyle::Dash => {
3177            format!("\u{2014} notably {joined}, plus {remainder}")
3178        }
3179        ListStyle::Bracketed => {
3180            let refs: Vec<&str> = shown
3181                .iter()
3182                .copied()
3183                .chain(core::iter::once(remainder.trim()))
3184                .collect();
3185            let all_joined = language.join_list(&refs, conjunction);
3186            format!("[{all_joined}]")
3187        }
3188        ListStyle::AmongOthers => {
3189            // Postfix qualifier; remainder count is dropped to keep the
3190            // phrasing natural ("…, among others" reads worse with a
3191            // numeric quantifier appended).
3192            format!("{joined}, among others")
3193        }
3194        ListStyle::ToNameAFew => {
3195            format!("{joined}, to name a few")
3196        }
3197        ListStyle::PlusMore => {
3198            // remainder is the "+N more" sentinel (e.g. "2 more"); use it
3199            // as a numeric quantifier without the em-dash framing of Dash.
3200            format!("{joined}, plus {remainder}")
3201        }
3202    }
3203}
3204
3205/// Auxiliary prefixes that signal a simple passive/perfect/progressive
3206/// verb phrase. Listed longest-first so prefix matching grabs "has been"
3207/// before "has" and "would have been" before "would have".
3208const AUX_PREFIXES: &[&str] = &[
3209    "would have been",
3210    "will have been",
3211    "would have",
3212    "will have",
3213    "has been",
3214    "had been",
3215    "have been",
3216    "is being",
3217    "was being",
3218    "are being",
3219    "were being",
3220    "will be",
3221    "would be",
3222    "is",
3223    "are",
3224    "was",
3225    "were",
3226    "has",
3227    "have",
3228    "had",
3229    "will",
3230];
3231
3232/// Attempt conjunction reduction across a run of same-entity renders.
3233///
3234/// Given a list of rendered sentences all about the same subject, produce
3235/// a single sentence that shares the subject and auxiliary across all
3236/// clauses. Example:
3237///
3238/// ```text
3239/// [
3240///   "The class UserService was renamed to AccountService.",
3241///   "It was modified.",
3242///   "It was moved from src/ to lib/.",
3243/// ]
3244/// ```
3245///
3246/// reduces to:
3247///
3248/// ```text
3249/// "The class UserService was renamed to AccountService, modified, and moved from src/ to lib/."
3250/// ```
3251///
3252/// Returns `None` and leaves the caller to emit the sentences separately
3253/// when reduction would be lossy: mixed auxiliaries, embedded `which`
3254/// clauses, connectives that anchor to a previous sentence, or anything
3255/// the heuristic can't confidently parse.
3256/// Detect a cycle in the partial graph reachable from `entry_name`.
3257///
3258/// Returns `Ok(())` if no cycle exists. Returns `Err(cycle)` on a cycle,
3259/// where `cycle` is the traversal path from the first cycling node back
3260/// to itself (e.g. `["a", "b", "a"]` for a `a → b → a` loop).
3261///
3262/// Unknown partial references (templates that reference a partial not yet
3263/// registered) are ignored — they are validated separately at render time.
3264fn detect_partial_cycle(
3265    partials: &HashMap<String, Template>,
3266    entry_name: &str,
3267) -> Result<(), Vec<String>> {
3268    // DFS with an on-stack path so we can return the offending cycle.
3269    let mut path: Vec<String> = Vec::new();
3270    let mut on_stack: HashSet<String> = new_set();
3271    let mut fully_explored: HashSet<String> = new_set();
3272
3273    visit(
3274        partials,
3275        entry_name,
3276        &mut path,
3277        &mut on_stack,
3278        &mut fully_explored,
3279    )
3280}
3281
3282fn visit(
3283    partials: &HashMap<String, Template>,
3284    name: &str,
3285    path: &mut Vec<String>,
3286    on_stack: &mut HashSet<String>,
3287    fully_explored: &mut HashSet<String>,
3288) -> Result<(), Vec<String>> {
3289    if fully_explored.contains(name) {
3290        return Ok(());
3291    }
3292    if on_stack.contains(name) {
3293        // Build the cycle slice: from first occurrence of `name` in path
3294        // through the tail, plus `name` again to close the loop visibly.
3295        let start = path.iter().position(|n| n == name).unwrap_or(0);
3296        let mut cycle: Vec<String> = path[start..].to_vec();
3297        cycle.push(name.to_string());
3298        return Err(cycle);
3299    }
3300    let template = match partials.get(name) {
3301        Some(t) => t,
3302        // Unknown partial — render-time concern, not a cycle. Skip silently
3303        // here so that registering a partial that points at a not-yet-declared
3304        // partial stays valid.
3305        None => return Ok(()),
3306    };
3307
3308    path.push(name.to_string());
3309    on_stack.insert(name.to_string());
3310
3311    for child in template.partial_names() {
3312        visit(partials, &child, path, on_stack, fully_explored)?;
3313    }
3314
3315    on_stack.remove(name);
3316    path.pop();
3317    fully_explored.insert(name.to_string());
3318    Ok(())
3319}
3320
3321fn reduce_same_entity_clauses(sentences: &[String]) -> Option<String> {
3322    if sentences.len() < 2 {
3323        return None;
3324    }
3325
3326    // First sentence: find and keep the subject + aux + first predicate.
3327    let head = sentences[0].trim_end();
3328    let head_body = head.trim_end_matches(['.', '!', '?']);
3329    let (head_subject_aux, head_aux, head_predicate) = split_subject_aux(head_body)?;
3330
3331    if predicate_has_embedded_clause(head_predicate) {
3332        return None;
3333    }
3334
3335    // Each subsequent sentence must start with "It <aux> " where the aux
3336    // matches the first sentence, and the remaining predicate must be a
3337    // simple clause (no embedded "which", no connective prefix spillover).
3338    let mut predicates: Vec<String> = vec![head_predicate.to_string()];
3339
3340    for s in &sentences[1..] {
3341        let trimmed = s.trim_end();
3342        // Connectives ("Additionally,", "Similarly,", …) get prepended by
3343        // the discourse system before we know a same-entity run is about
3344        // to be reduced. Strip them and then try to match the pronoun
3345        // pattern — the final conjunction ("and") subsumes the
3346        // connective's linking role.
3347        let without_conn = strip_leading_connective(trimmed);
3348        let without_conn_str: &str = &without_conn;
3349        let body = without_conn_str.trim_end_matches(['.', '!', '?']);
3350
3351        // Try pronoun form first ("It was …" / "it was …").
3352        let (aux, predicate) = match strip_it_aux_prefix(body) {
3353            Some(parsed) => parsed,
3354            None => {
3355                // Fallback: full-NP repetition — the head's subject+aux
3356                // prefix appears verbatim in the follower. This covers the
3357                // case where Centering Rule 1 demoted the pronoun (e.g.
3358                // after a session reset or long entity gap). FCR Phase 2.
3359                let remainder = strip_head_subject_prefix(body, head_subject_aux)?;
3360                if remainder.is_empty() {
3361                    return None;
3362                }
3363                // aux is implicitly head_aux since we matched head_subject_aux.
3364                (head_aux, remainder)
3365            }
3366        };
3367        if aux != head_aux {
3368            return None;
3369        }
3370        if predicate_has_embedded_clause(predicate) {
3371            return None;
3372        }
3373
3374        predicates.push(predicate.to_string());
3375    }
3376
3377    // Join predicates with Oxford comma: "a, b, and c" / "a and b".
3378    let joined = match predicates.len() {
3379        0 => return None,
3380        1 => predicates.into_iter().next().unwrap(),
3381        2 => format!("{} and {}", predicates[0], predicates[1]),
3382        _ => {
3383            let last = predicates.pop().unwrap();
3384            let head = predicates.join(", ");
3385            format!("{head}, and {last}")
3386        }
3387    };
3388
3389    Some(format!("{head_subject_aux} {joined}."))
3390}
3391
3392// ── Gapping (ELLEIPO) ──────────────────────────────────────────────────────
3393
3394/// Split a rendered sentence into its subject word and the remaining tokens.
3395///
3396/// The "subject" is the first word that precedes a known auxiliary verb.
3397/// The returned `rest_tokens` **include** the auxiliary and everything
3398/// after it, so the longest-common-prefix search operates on the full
3399/// post-subject span.
3400///
3401/// Returns `None` when no auxiliary is found (can't gap safely).
3402fn split_subject_and_rest(s: &str) -> Option<(&str, Vec<&str>)> {
3403    for aux in AUX_PREFIXES {
3404        let marker = format!(" {aux} ");
3405        if let Some(pos) = s.find(&marker) {
3406            let subject = &s[..pos];
3407            // Skip leading space so rest starts at the aux word.
3408            let rest_str = &s[pos + 1..];
3409            let rest_tokens: Vec<&str> = rest_str.split_whitespace().collect();
3410            return Some((subject, rest_tokens));
3411        }
3412    }
3413    None
3414}
3415
3416/// Longest common prefix length across the `rest_tokens` vectors in
3417/// `parsed`.  Returns 0 when `parsed` is empty.
3418fn longest_common_prefix_len(parsed: &[(&str, Vec<&str>)]) -> usize {
3419    if parsed.is_empty() {
3420        return 0;
3421    }
3422    let min_len = parsed.iter().map(|(_, t)| t.len()).min().unwrap_or(0);
3423    for i in 0..min_len {
3424        let candidate = parsed[0].1[i];
3425        if !parsed.iter().all(|(_, t)| t[i] == candidate) {
3426            return i;
3427        }
3428    }
3429    min_len
3430}
3431
3432/// Attempt gapping reduction across a run of same-template renders where
3433/// every event shares the same verb anchor but differs in object/complement.
3434///
3435/// Example input sentences:
3436/// ```text
3437/// ["Foo was moved to core", "Bar was moved to util", "Baz was moved to api"]
3438/// ```
3439/// Produces:
3440/// ```text
3441/// "Foo was moved to core, Bar to util, and Baz to api."
3442/// ```
3443///
3444/// Returns `None` and leaves the caller to emit the sentences separately
3445/// when any guard fires: anchor too short, duplicate subjects, empty
3446/// divergent suffix, or embedded subordinate clause.
3447fn reduce_gapping(sentences: &[String]) -> Option<String> {
3448    if sentences.len() < 2 {
3449        return None;
3450    }
3451
3452    // No embedded clauses in any sentence.
3453    if sentences.iter().any(|s| predicate_has_embedded_clause(s)) {
3454        return None;
3455    }
3456
3457    // Strip trailing punctuation and leading connectives, then split each
3458    // sentence into (subject_word, rest_tokens).
3459    let parsed: Vec<(&str, Vec<&str>)> = sentences
3460        .iter()
3461        .map(|s| {
3462            let trimmed = s.trim_end();
3463            let stripped = strip_leading_connective(trimmed.trim_end_matches(['.', '!', '?']));
3464            // SAFETY: the Cow borrows from `trimmed` which lives as long as
3465            // this closure scope — but we need to return `&str` referencing
3466            // the original `s`. We compute the byte offset instead.
3467            let _ = stripped; // keep for borrow-checker
3468            // Re-derive without Cow: strip connective from trimmed-punctuation slice.
3469            let body = trimmed.trim_end_matches(['.', '!', '?']);
3470            let body_stripped: &str = {
3471                const CONNECTIVES: &[&str] = &[
3472                    "Additionally,",
3473                    "Furthermore,",
3474                    "Similarly,",
3475                    "Likewise,",
3476                    "Meanwhile,",
3477                    "However,",
3478                    "On the other hand,",
3479                ];
3480                let mut result = body;
3481                for conn in CONNECTIVES {
3482                    if let Some(rest) = body.strip_prefix(conn) {
3483                        result = rest.trim_start();
3484                        break;
3485                    }
3486                }
3487                // "It also was …" → "It was …" can't be represented as a
3488                // plain &str rewrite without allocation; treat as no-strip.
3489                result
3490            };
3491            split_subject_and_rest(body_stripped)
3492        })
3493        .collect::<Option<Vec<_>>>()?;
3494
3495    // Subjects must all be distinct.
3496    {
3497        let mut seen: crate::collections::HashSet<&str> = crate::collections::new_set();
3498        for (subj, _) in &parsed {
3499            if !seen.insert(*subj) {
3500                return None;
3501            }
3502        }
3503    }
3504
3505    // Longest common prefix across all rest_tokens vectors = the raw anchor.
3506    let raw_anchor_len = longest_common_prefix_len(&parsed);
3507
3508    // Trim trailing prepositions from the anchor so that "was moved to"
3509    // becomes "was moved" — we want to gap the verbal complex only, keeping
3510    // any preposition together with the divergent complement.
3511    // E.g. "Foo was moved to core" + "Bar was moved to util"
3512    //   → anchor = "was moved", suffixes = "to core" / "to util"
3513    //   → "Foo was moved to core, and Bar to util."
3514    const PREPOSITIONS: &[&str] = &[
3515        "to", "from", "at", "in", "on", "by", "for", "with", "into", "onto", "out", "off", "over",
3516        "under", "above", "below", "through", "across", "against", "along", "around", "behind",
3517        "beside", "between", "during", "inside", "outside", "toward", "towards", "upon", "within",
3518        "without",
3519    ];
3520    let anchor_len = {
3521        let mut len = raw_anchor_len;
3522        while len > 0 && PREPOSITIONS.contains(&parsed[0].1[len - 1]) {
3523            len -= 1;
3524        }
3525        len
3526    };
3527
3528    // Anchor must be at least 2 tokens (e.g. "was moved") to be meaningful.
3529    if anchor_len < 2 {
3530        return None;
3531    }
3532
3533    // Every divergent suffix must be non-empty (something to gap into).
3534    if parsed.iter().any(|(_, toks)| toks.len() <= anchor_len) {
3535        return None;
3536    }
3537
3538    let anchor = parsed[0].1[..anchor_len].join(" ");
3539
3540    // Divergent suffixes (the "objects").
3541    let suffixes: Vec<String> = parsed
3542        .iter()
3543        .map(|(_, toks)| toks[anchor_len..].join(" "))
3544        .collect();
3545
3546    // Helper to capitalize the first letter of a subject that the discourse
3547    // system may have lowercased when prepending a connective.
3548    let capitalize = |s: &str| -> String {
3549        let mut cs = s.chars();
3550        match cs.next() {
3551            None => String::new(),
3552            Some(c) => c.to_uppercase().collect::<String>() + cs.as_str(),
3553        }
3554    };
3555
3556    // First full sentence: "Foo was moved to core"
3557    let first = format!("{} {} {}", capitalize(parsed[0].0), anchor, suffixes[0]);
3558    // Follower fragments: "Bar to util", "Baz to api"
3559    let tail: Vec<String> = parsed
3560        .iter()
3561        .skip(1)
3562        .zip(suffixes.iter().skip(1))
3563        .map(|((subj, _), suf)| format!("{} {suf}", capitalize(subj)))
3564        .collect();
3565
3566    let joined = match tail.len() {
3567        1 => format!("{first}, and {}", tail[0]),
3568        _ => {
3569            let (last, rest) = tail.split_last().unwrap();
3570            format!("{first}, {}, and {last}", rest.join(", "))
3571        }
3572    };
3573
3574    Some(format!("{joined}."))
3575}
3576
3577/// Detect whether a predicate string would be clumsy to reduce because
3578/// it carries an embedded subordinate clause or a long list.
3579fn predicate_has_embedded_clause(predicate: &str) -> bool {
3580    // ", which ..." is the common case to avoid — reducing across a
3581    // sentence like "was renamed, which affects 6 consumers" would
3582    // produce "was renamed, which affects 6 consumers, modified, and moved"
3583    // which parses as the subordinate clause continuing into the next
3584    // verb. Same for a handful of other subordinators.
3585    let lower = predicate.to_lowercase();
3586    const MARKERS: &[&str] = &[
3587        ", which",
3588        ", affecting",
3589        ", impacting",
3590        ", requiring",
3591        ", including",
3592    ];
3593    MARKERS.iter().any(|m| lower.contains(m))
3594}
3595
3596/// Detect a leading discourse connective, returning its canonical form
3597/// if present. Used by [`Engine::render_explained`] to report which
3598/// connective the discourse system prepended.
3599fn detect_leading_connective(s: &str) -> Option<&'static str> {
3600    const CONNECTIVES: &[&str] = &[
3601        "Additionally,",
3602        "Furthermore,",
3603        "Similarly,",
3604        "Likewise,",
3605        "Meanwhile,",
3606        "However,",
3607        "On the other hand,",
3608        "It also",
3609    ];
3610    CONNECTIVES.iter().copied().find(|c| s.starts_with(c))
3611}
3612
3613/// Strip a leading discourse connective that the engine may have
3614/// prepended (e.g. "Additionally, …", "Similarly, …"). Returns the
3615/// original string as `Borrowed` when none of the known connectives
3616/// match, or an `Owned` rewrite for connectives that require synthesis
3617/// (currently only `"It also"`, which becomes `"It …"`).
3618fn strip_leading_connective(s: &str) -> alloc::borrow::Cow<'_, str> {
3619    const CONNECTIVES: &[&str] = &[
3620        "Additionally,",
3621        "Furthermore,",
3622        "Similarly,",
3623        "Likewise,",
3624        "Meanwhile,",
3625        "However,",
3626        "On the other hand,",
3627    ];
3628
3629    for conn in CONNECTIVES {
3630        if let Some(rest) = s.strip_prefix(conn) {
3631            return alloc::borrow::Cow::Borrowed(rest.trim_start());
3632        }
3633    }
3634
3635    // "It also was modified" — rewrite to "It was modified" so the
3636    // pronoun+aux matcher can find its prefix. This requires an
3637    // allocation because we are synthesising a new prefix.
3638    if let Some(rest) = s.strip_prefix("It also ") {
3639        return alloc::borrow::Cow::Owned(format!("It {}", rest.trim_start()));
3640    }
3641
3642    alloc::borrow::Cow::Borrowed(s)
3643}
3644
3645/// Split a head sentence into (full subject + aux prefix, aux word, rest).
3646/// Returns None when the sentence doesn't follow the "The X Y was …" or
3647/// "X was …" pattern that reduction expects.
3648fn split_subject_aux(body: &str) -> Option<(&str, &str, &str)> {
3649    for aux in AUX_PREFIXES {
3650        let marker = format!(" {aux} ");
3651        if let Some(pos) = body.find(&marker) {
3652            let subject_aux_end = pos + 1 + aux.len(); // include the aux word
3653            let subject_aux = &body[..subject_aux_end];
3654            let predicate = body[subject_aux_end..].trim_start();
3655            return Some((subject_aux, aux, predicate));
3656        }
3657    }
3658    None
3659}
3660
3661/// Strip a leading "It <aux> " (or "it <aux> ") prefix and return the
3662/// aux word along with the remaining predicate. Returns None when the
3663/// sentence doesn't follow the pronoun-continuation pattern.
3664fn strip_it_aux_prefix(body: &str) -> Option<(&str, &str)> {
3665    let rest = body
3666        .strip_prefix("It ")
3667        .or_else(|| body.strip_prefix("it "))?;
3668
3669    for aux in AUX_PREFIXES {
3670        let marker_with_space = format!("{aux} ");
3671        if let Some(tail) = rest.strip_prefix(&marker_with_space) {
3672            return Some((aux, tail.trim_start()));
3673        }
3674        // Aux at end of sentence (no tail content) — skip reduction.
3675        if rest == *aux {
3676            return None;
3677        }
3678    }
3679    None
3680}
3681
3682/// If `body` begins with `subject_aux` followed by a space, return the
3683/// remaining predicate. Used as a fallback path in
3684/// [`reduce_same_entity_clauses`] when the pronoun matcher declines — e.g.
3685/// when Centering Rule 1 demoted the follower to a full NP or a session
3686/// reset broke the pronoun chain.
3687///
3688/// The match is tried both verbatim and with the first character of
3689/// `subject_aux` lowercased. The discourse system lowercases the first
3690/// letter of a rendered sentence when it prepends a comma-style connective
3691/// (`"Additionally, the class X was …"`), so the verbatim capital-T match
3692/// would otherwise fail in that path.
3693///
3694/// Example: `body = "the class Foo was modified"`, `subject_aux = "The class Foo was"` →
3695/// returns `Some("modified")`.
3696fn strip_head_subject_prefix<'a>(body: &'a str, subject_aux: &str) -> Option<&'a str> {
3697    let with_space = format!("{subject_aux} ");
3698    if let Some(rest) = body.strip_prefix(with_space.as_str()) {
3699        return Some(rest.trim_start());
3700    }
3701    // The discourse system lowercases the first char of the sentence when
3702    // prepending a comma-style connective. Try the lowercase-first variant.
3703    let mut lowercased = subject_aux.to_string();
3704    if let Some(first) = lowercased.chars().next()
3705        && first.is_uppercase()
3706    {
3707        let first_len = first.len_utf8();
3708        let lower: String = first.to_lowercase().collect();
3709        lowercased.replace_range(0..first_len, &lower);
3710        let with_space_lower = format!("{lowercased} ");
3711        if let Some(rest) = body.strip_prefix(with_space_lower.as_str()) {
3712            return Some(rest.trim_start());
3713        }
3714    }
3715    None
3716}
3717
3718/// Clean up rendering artifacts caused by omitted slots.
3719///
3720/// Two passes:
3721/// 1. **Always**: collapse runs of internal whitespace into a single space,
3722///    strip whitespace before common punctuation (`,`, `.`, `!`, `?`, `:`,
3723///    `;`, `)`, `]`), and trim leading/trailing whitespace. These are safe
3724///    transformations no matter which strictness mode is active.
3725/// 2. **Silent-mode only**: strip trailing orphan prepositions and
3726///    connectives left dangling by missing slots (e.g. `"was modified by "`
3727///    → `"was modified"`, `"renamed to "` → `"renamed"`). We only do this
3728///    under `Strictness::Silent` because those gaps are the user's
3729///    explicit choice to swallow missing slots — the dangling fragments
3730///    are artifacts of that choice, not of the template's intent.
3731///
3732/// Returns `true` when the orphan-tail pass removed any dangling tail
3733/// words — surfaced via [`RenderExplanation::cleanup_stripped_tail`].
3734fn cleanup_artifacts_in_place(output: &mut String, strictness: Strictness) -> bool {
3735    collapse_and_tidy_in_place(output);
3736
3737    if strictness == Strictness::Silent {
3738        strip_dangling_tail_words_in_place(output)
3739    } else {
3740        false
3741    }
3742}
3743
3744/// Collapse multi-space runs, strip whitespace before closing punctuation,
3745/// and trim outer whitespace — mutates in place using a single scratch buffer swap.
3746fn collapse_and_tidy_in_place(output: &mut String) {
3747    // First pass: collapse whitespace runs, trim leading whitespace, and
3748    // strip space before closing punctuation — all in one scan.
3749    let mut scratch = String::with_capacity(output.len());
3750    let mut last_was_space = false;
3751    let mut started = false;
3752
3753    let chars: Vec<char> = output.chars().collect();
3754    let len = chars.len();
3755    let mut i = 0;
3756    while i < len {
3757        let c = chars[i];
3758        if c.is_whitespace() {
3759            if started {
3760                last_was_space = true;
3761            }
3762        } else {
3763            // If there's a pending space, only emit it if the next non-space
3764            // char is not a closing-punctuation character.
3765            if last_was_space && !matches!(c, ',' | '.' | '!' | '?' | ':' | ';' | ')' | ']') {
3766                scratch.push(' ');
3767            }
3768            scratch.push(c);
3769            last_was_space = false;
3770            started = true;
3771        }
3772        i += 1;
3773    }
3774
3775    core::mem::swap(output, &mut scratch);
3776}
3777
3778/// Words that are almost always followed by an argument — if they're
3779/// stranded at the very end of an output (optionally before terminal
3780/// punctuation), the argument must have been swallowed by Silent mode
3781/// and we strip the orphan.
3782const ORPHAN_TAIL_WORDS: &[&str] = &[
3783    // Prepositions taking an object
3784    "by", "to", "from", "in", "on", "at", "of", "with", "for", "into", "onto", "upon", "about",
3785    "between", "among", "through", "across",
3786    // Coordinating & correlative words that need another clause
3787    "and", "or", "but", "nor", "yet", // Subordinating words that need a clause
3788    "because", "since", "while", "when", "where", "whether", "unless", "until", "than",
3789];
3790
3791/// Strip trailing words that were left orphaned by omitted slots. Repeats
3792/// until no more matching tails remain — handles chained gaps like
3793/// `"modified by in"`.
3794///
3795/// Returns `true` if any tail word was stripped.
3796fn strip_dangling_tail_words_in_place(output: &mut String) -> bool {
3797    let mut stripped_any = false;
3798    loop {
3799        // Consider any trailing punctuation separately — we'll preserve it.
3800        let (body, _) = split_trailing_punct(output);
3801        let body_len = body.len();
3802        let trimmed_body = body.trim_end();
3803
3804        // Grab the last word
3805        let last_word_start = match trimmed_body.rfind(char::is_whitespace) {
3806            Some(idx) => idx + 1,
3807            None => {
3808                // Single word output — don't touch.
3809                return stripped_any;
3810            }
3811        };
3812        let last_word = &trimmed_body[last_word_start..];
3813        let last_word_lower = last_word.to_lowercase();
3814
3815        if ORPHAN_TAIL_WORDS.contains(&last_word_lower.as_str()) {
3816            let new_body_end = trimmed_body[..last_word_start].trim_end().len();
3817            if new_body_end == 0 {
3818                // The whole output was orphans — bail out to avoid erasing content.
3819                return stripped_any;
3820            }
3821            // Build the new string: new_body + tail_punct
3822            // tail_punct starts at byte offset body_len in `output`
3823            let tail_punct_owned = output[body_len..].to_string();
3824            output.truncate(new_body_end);
3825            output.push_str(&tail_punct_owned);
3826            stripped_any = true;
3827            continue;
3828        }
3829
3830        return stripped_any;
3831    }
3832}
3833
3834/// Split off a run of terminal sentence punctuation so we can preserve it
3835/// around tail-word stripping. Returns `(body, tail_punct)`.
3836fn split_trailing_punct(s: &str) -> (&str, &str) {
3837    let punct_start = s
3838        .char_indices()
3839        .rev()
3840        .take_while(|(_, c)| matches!(c, '.' | '!' | '?' | ','))
3841        .last()
3842        .map(|(i, _)| i)
3843        .unwrap_or(s.len());
3844    (&s[..punct_start], &s[punct_start..])
3845}
3846
3847/// Append a period to the output if it appears to be a sentence without
3848/// terminal punctuation. A sentence starts with a capital letter and has
3849/// multiple words. Fragments (single words, lists) are not terminated.
3850fn terminate_sentence_in_place(output: &mut String) {
3851    let trimmed_end = output.trim_end();
3852    if trimmed_end.is_empty() {
3853        return;
3854    }
3855
3856    // Already ends with sentence-ending punctuation? Leave alone.
3857    let last = trimmed_end.chars().last().unwrap();
3858    if matches!(last, '.' | '!' | '?') {
3859        return;
3860    }
3861
3862    // Looks like a fragment (doesn't start with capital, or is short)?
3863    let first = trimmed_end.chars().next().unwrap();
3864    if !first.is_uppercase() {
3865        return;
3866    }
3867
3868    // Count words — single words or very short outputs are likely fragments
3869    let word_count = trimmed_end.split_whitespace().count();
3870    if word_count < 3 {
3871        return;
3872    }
3873
3874    // Trim trailing whitespace, then append period.
3875    let trimmed_len = output.trim_end().len();
3876    output.truncate(trimmed_len);
3877    output.push('.');
3878}
3879
3880fn reference_features(value: &Value, focus_is_plural: bool) -> crate::agreement::AgreementFeatures {
3881    let mut features = match value {
3882        Value::Entity { features, .. } => *features,
3883        _ => crate::agreement::AgreementFeatures::default(),
3884    };
3885
3886    if focus_is_plural {
3887        features.number = crate::agreement::Number::Plural;
3888    } else if matches!(features.number, crate::agreement::Number::Unknown) {
3889        features.number = crate::agreement::Number::Singular;
3890    }
3891
3892    features
3893}
3894
3895/// Check if a template's first segment is a reference pipe, meaning the
3896/// rendered output may start with a lowercase word that needs capitalization.
3897fn starts_with_refer_pipe(template: &Template) -> bool {
3898    match template.segments.first() {
3899        Some(Segment::Slot { pipes, .. }) => pipes
3900            .iter()
3901            .any(|p| p.name == "refer" || p.name == "possessive"),
3902        _ => false,
3903    }
3904}
3905
3906fn capitalize_first_in_place(output: &mut String) {
3907    let first = match output.chars().next() {
3908        Some(c) if c.is_lowercase() => c,
3909        _ => return,
3910    };
3911    let first_len = first.len_utf8();
3912    let upper: String = first.to_uppercase().collect();
3913    output.replace_range(0..first_len, &upper);
3914}
3915
3916fn lowercase_first_in_place(output: &mut String) {
3917    let first = match output.chars().next() {
3918        Some(c) if c.is_uppercase() => c,
3919        _ => return,
3920    };
3921    let first_len = first.len_utf8();
3922    let lower: String = first.to_lowercase().collect();
3923    output.replace_range(0..first_len, &lower);
3924}
3925
3926/// If `s` starts with a common determiner or article (e.g. "The ", "A ", "El "),
3927/// lowercase the first character and return the result. Otherwise return `s`
3928/// unchanged. Used by [`Engine::render_batch_with_relations`] so that a
3929/// discourse marker ("Furthermore, ") naturally leads a sentence that would
3930/// otherwise start with a capital article ("The class Foo …" →
3931/// "Furthermore, the class Foo …").
3932fn lowercase_first_if_determiner(s: &str) -> String {
3933    let first_word_end = s.find(char::is_whitespace).unwrap_or(s.len());
3934    let first = &s[..first_word_end];
3935    const DETERMINERS: &[&str] = &[
3936        "The", "A", "An", // English
3937        "El", "La", "Los", "Las", "Un", "Una", // Spanish
3938        "Der", "Die", "Das", // German
3939    ];
3940    if DETERMINERS.contains(&first) {
3941        let mut result = String::with_capacity(s.len());
3942        let mut chars = first.chars();
3943        if let Some(c) = chars.next() {
3944            result.extend(c.to_lowercase());
3945        }
3946        result.push_str(chars.as_str());
3947        result.push_str(&s[first_word_end..]);
3948        result
3949    } else {
3950        s.to_string()
3951    }
3952}
3953
3954/// Try to replace "The {type} {name} was ..." with a connective like "It also was ..."
3955fn prepend_replacing_subject_in_place(
3956    output: &mut String,
3957    connective: &str,
3958    entity_name: Option<&str>,
3959) {
3960    // Case 1: full NP subject "The <type> <name> …" — strip the NP and
3961    // replace with the connective. Only safe when the entity name is a
3962    // single token: the NP boundary is "The " + one type word + one name
3963    // word. Multi-word names (e.g. "Login flow") make the boundary
3964    // ambiguous from the rendered string alone, so we fall through to the
3965    // comma-style prepending instead of chopping mid-name.
3966    let name_is_single_token = entity_name
3967        .map(|n| !n.trim().is_empty() && !n.contains(char::is_whitespace))
3968        .unwrap_or(false);
3969
3970    if name_is_single_token && let Some(rest) = output.strip_prefix("The ") {
3971        // Skip entity_type and name (two words)
3972        let words: Vec<&str> = rest.splitn(3, ' ').collect();
3973        if words.len() >= 3 {
3974            let tail = words[2..].join(" ");
3975            let mut buf = String::with_capacity(connective.len() + 1 + tail.len());
3976            buf.push_str(connective);
3977            buf.push(' ');
3978            buf.push_str(&tail);
3979            core::mem::swap(output, &mut buf);
3980            return;
3981        }
3982    }
3983
3984    // Case 2: the render already emitted a pronoun subject ("it was …").
3985    // Replace the leading "it " so the connective doesn't duplicate the
3986    // subject — e.g. "It also " + "it was archived" → "It also was archived".
3987    if let Some(rest) = output.strip_prefix("it ") {
3988        let mut buf = String::with_capacity(connective.len() + 1 + rest.len());
3989        buf.push_str(connective);
3990        buf.push(' ');
3991        buf.push_str(rest);
3992        core::mem::swap(output, &mut buf);
3993        return;
3994    }
3995
3996    // Fallback: lowercase the first char then prepend the connective.
3997    lowercase_first_in_place(output);
3998    let mut buf = String::with_capacity(connective.len() + 1 + output.len());
3999    buf.push_str(connective);
4000    buf.push(' ');
4001    buf.push_str(output);
4002    core::mem::swap(output, &mut buf);
4003}
4004
4005/// Three-stage filter: language preference first, style preference second,
4006/// then salience. Language and style are intersected, not ORed: style is
4007/// resolved only inside the selected language bucket.
4008/// Apply a `HedgingCalibration` to the bare hedge mapping. The offset
4009/// shifts the input confidence (clamped to `0..=100`) before the bucket
4010/// lookup; the `forbid` list, if it would otherwise emit a forbidden
4011/// hedge, walks the buckets *upward* toward firmer phrasing per the
4012/// resolved decision in the design spec — falling back to the original
4013/// hedge only when every higher bucket is also forbidden.
4014fn hedge_with_calibration(
4015    score: i64,
4016    mode: HedgeMode,
4017    calibration: &crate::style::HedgingCalibration,
4018) -> &'static str {
4019    let calibrated = (score + calibration.offset as i64).clamp(0, 100);
4020    let initial = hedge_fn(calibrated, mode);
4021    if !is_forbidden(initial, &calibration.forbid) {
4022        return initial;
4023    }
4024    // Walk upward through bucket centers toward more confident phrasing.
4025    const BUCKET_CENTERS: [i64; 5] = [10, 40, 60, 80, 95];
4026    let start_idx = BUCKET_CENTERS
4027        .iter()
4028        .position(|&c| c >= calibrated)
4029        .unwrap_or(0);
4030    for &c in BUCKET_CENTERS.iter().skip(start_idx + 1) {
4031        let candidate = hedge_fn(c, mode);
4032        if !is_forbidden(candidate, &calibration.forbid) {
4033            return candidate;
4034        }
4035    }
4036    initial
4037}
4038
4039fn is_forbidden(candidate: &str, forbid: &[String]) -> bool {
4040    forbid.iter().any(|f| f.eq_ignore_ascii_case(candidate))
4041}
4042
4043/// Map a `ListStyleBias` dial onto a concrete `ListStyle` target, or
4044/// `None` for `Auto` (the no-bias default that preserves the existing
4045/// rotation). Used by the join pipe to nudge the anti-repeat cycle without
4046/// breaking determinism.
4047fn list_style_bias_target(bias: crate::style::ListStyleBias) -> Option<ListStyle> {
4048    match bias {
4049        crate::style::ListStyleBias::Auto => None,
4050        crate::style::ListStyleBias::Including => Some(ListStyle::Including),
4051        crate::style::ListStyleBias::SuchAs => Some(ListStyle::SuchAs),
4052        crate::style::ListStyleBias::Dash => Some(ListStyle::Dash),
4053        crate::style::ListStyleBias::Bracketed => Some(ListStyle::Bracketed),
4054    }
4055}
4056
4057/// Map an internal `DiscourseRelation` to the closest `RstRelation` for
4058/// `StyleProfile.connectives` lookup. The internal relation taxonomy
4059/// (continuation / similarity / contrast) is coarser than the RST
4060/// taxonomy; this is the canonical bridge between them.
4061fn rst_for_discourse(
4062    relation: &crate::discourse::DiscourseRelation,
4063) -> Option<crate::rst::RstRelation> {
4064    match relation {
4065        crate::discourse::DiscourseRelation::SameEntityDifferentAction => {
4066            Some(crate::rst::RstRelation::Elaboration)
4067        }
4068        crate::discourse::DiscourseRelation::DifferentEntitySameAction => {
4069            Some(crate::rst::RstRelation::Sequence)
4070        }
4071        crate::discourse::DiscourseRelation::Contrast => Some(crate::rst::RstRelation::Contrast),
4072        crate::discourse::DiscourseRelation::None => None,
4073    }
4074}
4075
4076/// Profile-aware sentence-length bias score. Buckets observed (history +
4077/// candidate) sentences into short/medium/long, computes the resulting
4078/// distribution as proportions, and returns the L1 distance to the
4079/// profile's normalized target distribution scaled by a weight chosen to
4080/// be in the same ballpark as the existing rhythm penalty.
4081///
4082/// Returns `0.0` when the target distribution is the neutral default —
4083/// the profile-aware path is then a no-op, preserving byte equality with
4084/// no-profile renders.
4085fn profile_length_bias_score(
4086    candidate: &str,
4087    discourse: &crate::discourse::DiscourseState,
4088    target: &crate::style::LengthDistribution,
4089) -> f64 {
4090    if target.is_neutral() {
4091        return 0.0;
4092    }
4093
4094    let candidate_lengths = crate::discourse::sentence_word_counts(candidate);
4095
4096    // Aggregate bucket counts over (history + candidate).
4097    let mut counts = [0usize; 3]; // [short, medium, long]
4098    let bucket_for = |len: usize| -> usize {
4099        if len <= target.short_max_words as usize {
4100            0
4101        } else if len <= target.medium_max_words as usize {
4102            1
4103        } else {
4104            2
4105        }
4106    };
4107    for len in discourse.sentence_length_iter() {
4108        counts[bucket_for(len)] += 1;
4109    }
4110    for &len in &candidate_lengths {
4111        counts[bucket_for(len)] += 1;
4112    }
4113
4114    let total: usize = counts.iter().sum();
4115    if total == 0 {
4116        return 0.0;
4117    }
4118    let observed = [
4119        counts[0] as f32 / total as f32,
4120        counts[1] as f32 / total as f32,
4121        counts[2] as f32 / total as f32,
4122    ];
4123
4124    let target_sum = target.short + target.medium + target.long;
4125    if target_sum <= 0.0 || !target_sum.is_finite() {
4126        return 0.0;
4127    }
4128    let target_norm = [
4129        target.short / target_sum,
4130        target.medium / target_sum,
4131        target.long / target_sum,
4132    ];
4133
4134    let distance = (observed[0] - target_norm[0]).abs()
4135        + (observed[1] - target_norm[1]).abs()
4136        + (observed[2] - target_norm[2]).abs();
4137
4138    // Weight chosen so the L1 distance (≤ 2.0) lands roughly inside the
4139    // working range of the rhythm scorer, which already produces values in
4140    // the single-digit ones for moderate violations.
4141    const PROFILE_LENGTH_WEIGHT: f64 = 3.0;
4142    PROFILE_LENGTH_WEIGHT * distance as f64
4143}
4144
4145/// Shift the salience-classification thresholds per the active
4146/// `SalienceBias` dial. `Lower` bias shrinks the bands so the same numeric
4147/// `consumer_count` lands in *higher* tiers (more impactful framing);
4148/// `Higher` bias widens them so inputs land in *lower* tiers (more conservative
4149/// framing). The deltas are chosen so a typical mid-range `consumer_count`
4150/// crosses one tier under either direction.
4151///
4152/// Composition order: this runs *first* (in `Engine::context_salience`),
4153/// then [`apply_verbosity_bias`] runs on the resulting tier. Both are
4154/// preferences — the `filter_alternatives` cascade smoothly falls back if
4155/// no variant exists in the biased tier.
4156fn apply_salience_bias(
4157    thresholds: SalienceThresholds,
4158    bias: crate::style::SalienceBias,
4159) -> SalienceThresholds {
4160    match bias {
4161        crate::style::SalienceBias::Auto => thresholds,
4162        crate::style::SalienceBias::Lower => {
4163            let low_max = (thresholds.low_max - 1).max(0);
4164            let high_min = (thresholds.high_min - 5).max(low_max + 1);
4165            SalienceThresholds { low_max, high_min }
4166        }
4167        crate::style::SalienceBias::Higher => {
4168            let low_max = thresholds.low_max + 2;
4169            let high_min = thresholds.high_min + 10;
4170            SalienceThresholds { low_max, high_min }
4171        }
4172    }
4173}
4174
4175/// Resolve the target salience tier honoring active refine-pass overrides
4176/// (`ForceVariantTier`, `OverrideSalienceBias`). Free function so the
4177/// render and explain paths reuse the exact same resolution logic.
4178fn resolve_target_salience_for(
4179    engine: &Engine,
4180    session: &Session,
4181    key: &str,
4182    context: &Context,
4183) -> Salience {
4184    if let Some(forced) = session.refine_forced_tier_for(key) {
4185        return forced;
4186    }
4187    let salience_bias = session
4188        .refine_salience_bias
4189        .unwrap_or(engine.style_profile.salience);
4190    let thresholds = apply_salience_bias(engine.salience_thresholds, salience_bias);
4191    apply_verbosity_bias(
4192        Salience::from_context(context, thresholds),
4193        engine.style_profile.verbosity,
4194    )
4195}
4196
4197/// Shift the target salience tier per the active `Verbosity` dial. The
4198/// existing `filter_alternatives` cascade (exact tier → Medium → any)
4199/// gracefully degrades when the shifted tier has no registered variants,
4200/// so the bias is a preference rather than a hard constraint.
4201fn apply_verbosity_bias(target: Salience, verbosity: crate::style::Verbosity) -> Salience {
4202    match (verbosity, target) {
4203        (crate::style::Verbosity::Neutral, t) => t,
4204        (crate::style::Verbosity::Terse, Salience::High) => Salience::Medium,
4205        (crate::style::Verbosity::Terse, Salience::Medium) => Salience::Low,
4206        (crate::style::Verbosity::Terse, Salience::Low) => Salience::Low,
4207        (crate::style::Verbosity::Verbose, Salience::Low) => Salience::Medium,
4208        (crate::style::Verbosity::Verbose, Salience::Medium) => Salience::High,
4209        (crate::style::Verbosity::Verbose, Salience::High) => Salience::High,
4210    }
4211}
4212
4213fn filter_alternatives<'a>(
4214    alternatives: &'a [SalientTemplate],
4215    target: Salience,
4216    language_preference: Option<&str>,
4217    style_preference: Option<&str>,
4218) -> Vec<&'a Template> {
4219    let all: Vec<&'a SalientTemplate> = alternatives.iter().collect();
4220    let lang_filtered = prefer_tag(all, language_preference, |s| s.language.as_deref());
4221    let style_filtered = prefer_tag(lang_filtered, style_preference, |s| s.style.as_deref());
4222
4223    let exact: Vec<&'a Template> = style_filtered
4224        .iter()
4225        .filter(|s| s.salience == target)
4226        .map(|s| &s.template)
4227        .collect();
4228    if !exact.is_empty() {
4229        return exact;
4230    }
4231
4232    let medium: Vec<&'a Template> = style_filtered
4233        .iter()
4234        .filter(|s| s.salience == Salience::Medium)
4235        .map(|s| &s.template)
4236        .collect();
4237    if !medium.is_empty() {
4238        return medium;
4239    }
4240
4241    style_filtered.iter().map(|s| &s.template).collect()
4242}
4243
4244fn prefer_tag<'a>(
4245    alternatives: Vec<&'a SalientTemplate>,
4246    preference: Option<&str>,
4247    tag: impl Fn(&SalientTemplate) -> Option<&str>,
4248) -> Vec<&'a SalientTemplate> {
4249    if let Some(pref) = preference {
4250        let matching: Vec<&'a SalientTemplate> = alternatives
4251            .iter()
4252            .copied()
4253            .filter(|s| tag(s) == Some(pref))
4254            .collect();
4255        if !matching.is_empty() {
4256            return matching;
4257        }
4258    }
4259
4260    let untagged: Vec<&'a SalientTemplate> = alternatives
4261        .iter()
4262        .copied()
4263        .filter(|s| tag(s).is_none())
4264        .collect();
4265    if !untagged.is_empty() {
4266        untagged
4267    } else {
4268        alternatives
4269    }
4270}
4271
4272/// Determine if a value is "truthy" for conditional rendering.
4273/// - `None` → false
4274/// - Number 0 → false, any other number → true
4275/// - Empty string → false, non-empty → true
4276/// - Empty list → false, non-empty → true
4277fn is_truthy(value: Option<&Value>) -> bool {
4278    match value {
4279        None => false,
4280        Some(Value::Number(n)) => *n != 0,
4281        Some(Value::String(s)) => !s.is_empty(),
4282        Some(Value::List(items)) => !items.is_empty(),
4283        // An entity is truthy if its name is non-empty.
4284        Some(Value::Entity { name, .. }) => !name.is_empty(),
4285    }
4286}
4287
4288/// Extract the primary entity name from a render context.
4289/// Checks "name" first, falls back to "old_name".
4290fn entity_name_from_context(context: &Context) -> Option<String> {
4291    context
4292        .get("name")
4293        .or_else(|| context.get("old_name"))
4294        .map(|v| v.as_display())
4295}
4296
4297/// Check if two contexts match on all fields except the entity name.
4298/// Used to decide whether events can be safely aggregated without losing
4299/// information (different new_name targets, different consumer counts, etc.).
4300fn contexts_compatible_for_aggregation(a: &Context, b: &Context) -> bool {
4301    // Collect all keys from both contexts
4302    let entity_keys = ["name", "old_name"];
4303
4304    // Get all keys that need to match
4305    let a_keys: Vec<&String> = a
4306        .keys()
4307        .filter(|k| !entity_keys.contains(&k.as_str()))
4308        .collect();
4309    let b_keys: Vec<&String> = b
4310        .keys()
4311        .filter(|k| !entity_keys.contains(&k.as_str()))
4312        .collect();
4313
4314    // Same set of keys?
4315    if a_keys.len() != b_keys.len() {
4316        return false;
4317    }
4318    for key in &a_keys {
4319        if !b_keys.contains(key) {
4320            return false;
4321        }
4322        if a.get(key) != b.get(key) {
4323            return false;
4324        }
4325    }
4326    true
4327}
4328
4329/// Adjust rendered output for plural subject agreement.
4330/// When aggregating multiple subjects, forms like "was" → "were" and
4331/// singular entity types like "class" → "classes" need to change.
4332fn pluralize_agreement(output: &str, lang: &dyn Language) -> String {
4333    let mut result = output.to_string();
4334
4335    // "The class Foo, Bar, and Baz was" → "The classes Foo, Bar, and Baz were"
4336    // Common singular-to-plural verb patterns
4337    let verb_replacements = &[(" was ", " were "), (" has ", " have "), (" is ", " are ")];
4338    for (singular, plural) in verb_replacements {
4339        result = result.replace(singular, plural);
4340    }
4341
4342    // Pluralize entity type after "The": "The class UserService, Foo, and Bar"
4343    // This is fragile — only apply when pattern matches exactly.
4344    if let Some(rest) = result.strip_prefix("The ")
4345        && let Some(space_idx) = rest.find(' ')
4346    {
4347        let type_word = &rest[..space_idx];
4348        // Only pluralize if it's a known simple noun (lowercase word)
4349        if type_word.chars().all(|c| c.is_lowercase()) && type_word.len() < 15 {
4350            let plural = lang.pluralize(type_word, 2);
4351            if plural != type_word {
4352                result = format!("The {} {}", plural, &rest[space_idx + 1..]);
4353            }
4354        }
4355    }
4356
4357    result
4358}
4359
4360/// Simple non-cryptographic hash for seeded variation.
4361fn simple_hash(key: &str, seed: u64) -> u64 {
4362    let mut hash = seed;
4363    for byte in key.bytes() {
4364        hash = hash.wrapping_mul(31).wrapping_add(byte as u64);
4365    }
4366    hash
4367}
4368
4369/// Parse the argument string of a `|choose` pipe into an ordered list of
4370/// `(key, value)` pairs.
4371///
4372/// Grammar: `pair ("," pair)*` where `pair ::= key "=" value`.
4373/// Keys and values are trimmed of surrounding whitespace.
4374/// Empty segments (e.g. trailing comma) are silently skipped.
4375/// A pair missing `=` returns [`ProsaicError::InvalidPipe`].
4376/// A pair whose key is empty after trimming returns [`ProsaicError::InvalidPipe`].
4377fn parse_choose_pairs(arg: &str) -> Result<Vec<(String, String)>, ProsaicError> {
4378    let mut out = Vec::new();
4379    for raw_pair in arg.split(',') {
4380        let pair = raw_pair.trim();
4381        if pair.is_empty() {
4382            continue;
4383        }
4384        let eq = pair.find('=').ok_or_else(|| ProsaicError::InvalidPipe {
4385            pipe: "choose".to_string(),
4386            reason: format!("pair `{pair}` is missing `=` separator"),
4387        })?;
4388        let key = pair[..eq].trim().to_string();
4389        let value = pair[eq + 1..].trim().to_string();
4390        if key.is_empty() {
4391            return Err(ProsaicError::InvalidPipe {
4392                pipe: "choose".to_string(),
4393                reason: format!("pair `{pair}` has empty key"),
4394            });
4395        }
4396        out.push((key, value));
4397    }
4398    Ok(out)
4399}
4400
4401#[cfg(test)]
4402mod tests {
4403    use super::*;
4404    use crate::language::{Conjunction, Language, Person, Tense};
4405
4406    /// Minimal language implementation for testing the engine in isolation.
4407    struct TestLang;
4408
4409    impl Language for TestLang {
4410        fn pluralize(&self, word: &str, count: usize) -> String {
4411            if count == 1 {
4412                word.to_string()
4413            } else {
4414                format!("{word}s")
4415            }
4416        }
4417        fn singularize(&self, word: &str) -> String {
4418            word.strip_suffix('s').unwrap_or(word).to_string()
4419        }
4420        fn article(&self, word: &str) -> &str {
4421            if word.starts_with(|c: char| "aeiou".contains(c.to_ascii_lowercase())) {
4422                "an"
4423            } else {
4424                "a"
4425            }
4426        }
4427        fn conjugate(&self, verb: &str, tense: Tense, _person: Person) -> String {
4428            match (verb, tense) {
4429                ("be", Tense::Past) => "was".to_string(),
4430                ("be", Tense::Present) => "is".to_string(),
4431                ("have", Tense::Present) => "has".to_string(),
4432                (_, Tense::Past) => format!("{verb}ed"),
4433                (_, Tense::Present) => verb.to_string(),
4434                (_, Tense::Future) => format!("will {verb}"),
4435            }
4436        }
4437        fn past_participle(&self, verb: &str) -> String {
4438            format!("{verb}ed")
4439        }
4440        fn present_participle(&self, verb: &str) -> String {
4441            format!("{verb}ing")
4442        }
4443        fn join_list(&self, items: &[&str], conjunction: Conjunction) -> String {
4444            let conj = match conjunction {
4445                Conjunction::And => "and",
4446                Conjunction::Or => "or",
4447            };
4448            match items.len() {
4449                0 => String::new(),
4450                1 => items[0].to_string(),
4451                2 => format!("{} {conj} {}", items[0], items[1]),
4452                _ => {
4453                    let head = items[..items.len() - 1].join(", ");
4454                    format!("{head}, {conj} {}", items[items.len() - 1])
4455                }
4456            }
4457        }
4458        fn ordinal(&self, n: usize) -> String {
4459            let suffix = match n % 10 {
4460                1 if n % 100 != 11 => "st",
4461                2 if n % 100 != 12 => "nd",
4462                3 if n % 100 != 13 => "rd",
4463                _ => "th",
4464            };
4465            format!("{n}{suffix}")
4466        }
4467        fn number_to_words(&self, n: usize) -> String {
4468            format!("<{n}>") // stub
4469        }
4470    }
4471
4472    fn test_engine() -> Engine {
4473        Engine::new(TestLang)
4474    }
4475
4476    fn test_session() -> Session {
4477        Session::new()
4478    }
4479
4480    // Style-aware variant selection
4481
4482    #[test]
4483    fn style_preference_selects_matching_style() {
4484        let mut engine = test_engine().style_preference("executive");
4485        engine.register_template("t", "technical {name}").unwrap();
4486        engine
4487            .register_template_with_style("t", "executive {name}", Some("executive"))
4488            .unwrap();
4489
4490        let mut ctx = Context::new();
4491        ctx.insert("name", Value::String("summary".into()));
4492        let out = engine.render(&mut test_session(), "t", &ctx).unwrap();
4493        assert_eq!(out, "executive summary");
4494    }
4495
4496    #[test]
4497    fn style_preference_falls_back_to_unstyled_before_any_style() {
4498        let mut engine = test_engine().style_preference("customer");
4499        engine
4500            .register_template_with_style("t", "executive {name}", Some("executive"))
4501            .unwrap();
4502        engine.register_template("t", "plain {name}").unwrap();
4503
4504        let mut ctx = Context::new();
4505        ctx.insert("name", Value::String("summary".into()));
4506        let out = engine.render(&mut test_session(), "t", &ctx).unwrap();
4507        assert_eq!(out, "plain summary");
4508    }
4509
4510    #[test]
4511    fn style_filter_runs_inside_language_filter() {
4512        let mut engine = test_engine()
4513            .language_preference("en")
4514            .style_preference("executive");
4515        engine
4516            .register_template_with_language_and_style(
4517                "t",
4518                "english executive {name}",
4519                Some("en"),
4520                Some("executive"),
4521            )
4522            .unwrap();
4523        engine
4524            .register_template_with_language_and_style(
4525                "t",
4526                "spanish executive {name}",
4527                Some("es"),
4528                Some("executive"),
4529            )
4530            .unwrap();
4531
4532        let mut ctx = Context::new();
4533        ctx.insert("name", Value::String("summary".into()));
4534        let out = engine.render(&mut test_session(), "t", &ctx).unwrap();
4535        assert_eq!(out, "english executive summary");
4536    }
4537
4538    #[test]
4539    fn no_style_preference_prefers_unstyled_variants() {
4540        let mut engine = test_engine();
4541        engine
4542            .register_template_with_style("t", "styled {name}", Some("executive"))
4543            .unwrap();
4544        engine.register_template("t", "unstyled {name}").unwrap();
4545
4546        let mut ctx = Context::new();
4547        ctx.insert("name", Value::String("summary".into()));
4548        let out = engine.render(&mut test_session(), "t", &ctx).unwrap();
4549        assert_eq!(out, "unstyled summary");
4550    }
4551
4552    // ── Template existence ──────────────────────────────────────────────────
4553
4554    #[test]
4555    fn has_template_returns_true_for_registered() {
4556        let mut engine = test_engine();
4557        engine.register_template("t", "hello").unwrap();
4558        assert!(engine.has_template("t"));
4559        assert!(!engine.has_template("nope"));
4560    }
4561
4562    // ── Basic rendering (backward compatibility) ─────────────────────────
4563
4564    #[test]
4565    fn render_simple_substitution() {
4566        let mut engine = test_engine();
4567        engine.register_template("greet", "Hello {name}!").unwrap();
4568
4569        let mut ctx = Context::new();
4570        ctx.insert("name", Value::String("world".into()));
4571
4572        let mut session = test_session();
4573        assert_eq!(
4574            engine.render(&mut session, "greet", &ctx).unwrap(),
4575            "Hello world!"
4576        );
4577    }
4578
4579    #[test]
4580    fn render_missing_slot_strict() {
4581        let mut engine = test_engine();
4582        engine.register_template("greet", "Hello {name}!").unwrap();
4583        let ctx = Context::new();
4584
4585        let mut session = test_session();
4586        let result = engine.render(&mut session, "greet", &ctx);
4587        assert!(matches!(result, Err(ProsaicError::MissingSlot { .. })));
4588    }
4589
4590    #[test]
4591    fn render_missing_slot_lenient() {
4592        let mut engine = test_engine().strictness(Strictness::Lenient);
4593        engine.register_template("greet", "Hello {name}!").unwrap();
4594        let ctx = Context::new();
4595
4596        let mut session = test_session();
4597        assert_eq!(
4598            engine.render(&mut session, "greet", &ctx).unwrap(),
4599            "Hello [missing: name]!"
4600        );
4601    }
4602
4603    #[test]
4604    fn render_missing_slot_silent() {
4605        let mut engine = test_engine().strictness(Strictness::Silent);
4606        engine.register_template("greet", "Hello {name}!").unwrap();
4607        let ctx = Context::new();
4608
4609        let mut session = test_session();
4610        // Silent-mode cleanup collapses the " " before "!" produced by
4611        // the omitted slot.
4612        assert_eq!(
4613            engine.render(&mut session, "greet", &ctx).unwrap(),
4614            "Hello!"
4615        );
4616    }
4617
4618    #[test]
4619    fn render_unknown_template() {
4620        let engine = test_engine();
4621        let ctx = Context::new();
4622
4623        let mut session = test_session();
4624        let result = engine.render(&mut session, "nonexistent", &ctx);
4625        assert!(matches!(result, Err(ProsaicError::UnknownTemplate(_))));
4626    }
4627
4628    #[test]
4629    fn render_pluralize_pipe() {
4630        let mut engine = test_engine();
4631        engine
4632            .register_template("count", "{n} {n|pluralize:item}")
4633            .unwrap();
4634
4635        let mut session = test_session();
4636        let mut ctx = Context::new();
4637        ctx.insert("n", Value::Number(1));
4638        assert_eq!(
4639            engine.render(&mut session, "count", &ctx).unwrap(),
4640            "1 item"
4641        );
4642
4643        session.reset();
4644        ctx.insert("n", Value::Number(5));
4645        assert_eq!(
4646            engine.render(&mut session, "count", &ctx).unwrap(),
4647            "5 items"
4648        );
4649    }
4650
4651    #[test]
4652    fn render_article_pipe() {
4653        let mut engine = test_engine();
4654        engine.register_template("a", "{thing|article}").unwrap();
4655
4656        let mut session = test_session();
4657        let mut ctx = Context::new();
4658        ctx.insert("thing", Value::String("apple".into()));
4659        assert_eq!(engine.render(&mut session, "a", &ctx).unwrap(), "an apple");
4660
4661        session.reset();
4662        ctx.insert("thing", Value::String("banana".into()));
4663        assert_eq!(engine.render(&mut session, "a", &ctx).unwrap(), "a banana");
4664    }
4665
4666    #[test]
4667    fn render_join_pipe() {
4668        let mut engine = test_engine();
4669        engine.register_template("list", "{items|join}").unwrap();
4670
4671        let mut session = test_session();
4672        let mut ctx = Context::new();
4673        ctx.insert(
4674            "items",
4675            Value::List(vec!["a".into(), "b".into(), "c".into()]),
4676        );
4677        assert_eq!(
4678            engine.render(&mut session, "list", &ctx).unwrap(),
4679            "a, b, and c"
4680        );
4681    }
4682
4683    #[test]
4684    fn render_join_or_pipe() {
4685        let mut engine = test_engine();
4686        engine.register_template("list", "{items|join:or}").unwrap();
4687
4688        let mut session = test_session();
4689        let mut ctx = Context::new();
4690        ctx.insert(
4691            "items",
4692            Value::List(vec!["a".into(), "b".into(), "c".into()]),
4693        );
4694        assert_eq!(
4695            engine.render(&mut session, "list", &ctx).unwrap(),
4696            "a, b, or c"
4697        );
4698    }
4699
4700    #[test]
4701    fn render_truncate_then_join_bracketed() {
4702        let mut engine = test_engine();
4703        engine
4704            .register_template("t", "{items|truncate:2|join:bracketed}")
4705            .unwrap();
4706
4707        let mut session = test_session();
4708        let mut ctx = Context::new();
4709        ctx.insert(
4710            "items",
4711            Value::List(vec![
4712                "a".into(),
4713                "b".into(),
4714                "c".into(),
4715                "d".into(),
4716                "e".into(),
4717            ]),
4718        );
4719        assert_eq!(
4720            engine.render(&mut session, "t", &ctx).unwrap(),
4721            "[a, b, and 3 more]"
4722        );
4723    }
4724
4725    #[test]
4726    fn render_capitalize_pipe() {
4727        let mut engine = test_engine();
4728        engine
4729            .register_template("cap", "{word|capitalize}")
4730            .unwrap();
4731
4732        let mut session = test_session();
4733        let mut ctx = Context::new();
4734        ctx.insert("word", Value::String("hello".into()));
4735        assert_eq!(engine.render(&mut session, "cap", &ctx).unwrap(), "Hello");
4736    }
4737
4738    #[test]
4739    fn render_ordinal_pipe() {
4740        let mut engine = test_engine();
4741        engine.register_template("o", "{n|ordinal}").unwrap();
4742
4743        let mut session = test_session();
4744        let mut ctx = Context::new();
4745        ctx.insert("n", Value::Number(3));
4746        assert_eq!(engine.render(&mut session, "o", &ctx).unwrap(), "3rd");
4747    }
4748
4749    #[test]
4750    fn render_inline_template() {
4751        let engine = test_engine();
4752        let mut session = test_session();
4753        let mut ctx = Context::new();
4754        ctx.insert("name", Value::String("world".into()));
4755
4756        assert_eq!(
4757            engine
4758                .render_inline(&mut session, "Hello {name}!", &ctx)
4759                .unwrap(),
4760            "Hello world!"
4761        );
4762    }
4763
4764    #[test]
4765    fn render_inline_does_not_advance_list_style_cycle() {
4766        // Regression: `{items|join}` advances session.discourse.last_list_style.
4767        // An inline render must not leak that mutation into a subsequent
4768        // registered render — otherwise the caller gets a different list
4769        // style than it would without the inline render.
4770        let mut engine = test_engine();
4771        engine.register_template("t", "{items|join}").unwrap();
4772
4773        let mut s_ref = test_session();
4774        let mut ctx = Context::new();
4775        ctx.insert(
4776            "items",
4777            Value::List(vec!["a".into(), "b".into(), "c".into()]),
4778        );
4779
4780        // Do a reference render — captures which style cycle picks first.
4781        let ref_out = engine.render(&mut s_ref, "t", &ctx).unwrap();
4782
4783        // Now: on a FRESH session, do an inline `{|join}` first, then a
4784        // registered render. If inline leaked the cycle, the registered
4785        // render would pick a different style than the reference.
4786        let mut s_test = test_session();
4787        engine
4788            .render_inline(&mut s_test, "{items|join}", &ctx)
4789            .unwrap();
4790        let after_inline = engine.render(&mut s_test, "t", &ctx).unwrap();
4791
4792        assert_eq!(
4793            ref_out, after_inline,
4794            "inline render leaked list-style cycle into a later registered render"
4795        );
4796    }
4797
4798    #[test]
4799    fn render_inline_failure_leaves_session_unchanged() {
4800        // A failing inline render must not record output words or any
4801        // other partial mutation on the caller's session.
4802        let engine = test_engine();
4803        let mut session = test_session();
4804
4805        // Before the failure, snapshot the discourse state for comparison.
4806        let snapshot = session.clone();
4807
4808        // Strict mode: missing slot → render_template_into returns Err
4809        // before it would otherwise record output words.
4810        let result = engine.render_inline(&mut session, "Hello {nope}!", Context::new());
4811        assert!(result.is_err(), "expected missing-slot error");
4812
4813        // render_index and focus_entity should match pre-call state.
4814        // Using visible accessors; if new state is added the snapshot
4815        // Clone catches it transitively via other tests.
4816        assert_eq!(
4817            session.discourse.focus_is_plural(),
4818            snapshot.discourse.focus_is_plural()
4819        );
4820    }
4821
4822    #[test]
4823    fn render_inline_does_not_mention_entities_via_plural_refer() {
4824        // Plural `refer` would ordinarily call mention_entity for each
4825        // name in the list. Inline renders must not leave those entity
4826        // mentions on the caller's session.
4827        let mut engine = test_engine();
4828        engine.register_template("t", "{name|refer}").unwrap();
4829
4830        let mut session = test_session();
4831        let mut ctx = Context::new();
4832        ctx.insert("entity_type", Value::String("class".into()));
4833        ctx.insert("name", Value::String("Alpha".into()));
4834
4835        // Inline render that would mention Alpha.
4836        let _ = engine
4837            .render_inline(&mut session, "{name|refer}", &ctx)
4838            .unwrap();
4839
4840        // Now a registered `refer` on the same name should still use
4841        // Full form (no prior in-session mention from the inline render).
4842        let out = engine.render(&mut session, "t", &ctx).unwrap();
4843        assert!(
4844            out.contains("The class Alpha"),
4845            "expected Full form (no leaked entity mention); got: {out}"
4846        );
4847    }
4848
4849    #[test]
4850    fn variation_fixed_always_picks_first() {
4851        let mut engine = test_engine().variation(Variation::Fixed);
4852        engine.register_template("t", "first").unwrap();
4853        engine.register_template("t", "second").unwrap();
4854
4855        let mut session = test_session();
4856        let ctx = Context::new();
4857        // First render always picks first (no discourse history yet)
4858        let result = engine.render(&mut session, "t", &ctx).unwrap();
4859        assert_eq!(result, "first");
4860    }
4861
4862    #[test]
4863    fn variation_seeded_is_deterministic() {
4864        let mut engine = test_engine().variation(Variation::Seeded(42));
4865        engine.register_template("t", "first").unwrap();
4866        engine.register_template("t", "second").unwrap();
4867
4868        let ctx = Context::new();
4869        let mut session1 = test_session();
4870        let result1 = engine.render(&mut session1, "t", &ctx).unwrap();
4871        let mut session2 = test_session();
4872        let result2 = engine.render(&mut session2, "t", &ctx).unwrap();
4873        assert_eq!(result1, result2);
4874    }
4875
4876    #[test]
4877    fn unknown_pipe_is_error() {
4878        // Since infer_types now runs at register time, an unknown pipe is caught
4879        // before the template is stored — the error surfaces as a TemplateParseError
4880        // at register_template, not as an InvalidPipe at render time.
4881        let mut engine = test_engine();
4882        let err = engine
4883            .register_template("t", "{name|nonexistent}")
4884            .unwrap_err();
4885        assert!(
4886            matches!(err, ProsaicError::TemplateParseError { .. }),
4887            "expected TemplateParseError for unknown pipe, got {err:?}"
4888        );
4889    }
4890
4891    #[test]
4892    fn complex_template_end_to_end() {
4893        let mut engine = test_engine();
4894        engine
4895            .register_template(
4896                "entity.renamed",
4897                "The {entity_type} {old_name} was renamed to {new_name} \
4898                 which impacts {count} direct {count|pluralize:consumer}",
4899            )
4900            .unwrap();
4901
4902        let mut session = test_session();
4903        let mut ctx = Context::new();
4904        ctx.insert("entity_type", Value::String("class".into()));
4905        ctx.insert("old_name", Value::String("Foo".into()));
4906        ctx.insert("new_name", Value::String("Foobar".into()));
4907        ctx.insert("count", Value::Number(6));
4908
4909        assert_eq!(
4910            engine.render(&mut session, "entity.renamed", &ctx).unwrap(),
4911            "The class Foo was renamed to Foobar which impacts 6 direct consumers."
4912        );
4913    }
4914
4915    // ── Discourse-aware tests ────────────────────────────────────────────
4916
4917    #[test]
4918    fn reset_clears_discourse_state() {
4919        let mut engine = test_engine();
4920        engine
4921            .register_template("t", "The {entity_type} {name} was modified")
4922            .unwrap();
4923
4924        let mut ctx = Context::new();
4925        ctx.insert("entity_type", Value::String("class".into()));
4926        ctx.insert("name", Value::String("Foo".into()));
4927
4928        let mut session = test_session();
4929        engine.render(&mut session, "t", &ctx).unwrap();
4930        session.reset();
4931
4932        // After reset, should behave as if first render
4933        let result = engine.render(&mut session, "t", &ctx).unwrap();
4934        assert!(result.starts_with("The class Foo"));
4935    }
4936
4937    #[test]
4938    fn template_anti_repeat_with_multiple_variants() {
4939        // Choose-best scoring only runs under Seeded/Random variation
4940        // (Fixed/RoundRobin are literal by contract). Anti-repeat is
4941        // discourse-driven: candidate variants whose words overlap with
4942        // recent output are scored worse, so consecutive renders tend to
4943        // pick different variants.
4944        let mut engine = test_engine().variation(Variation::Seeded(1));
4945        engine
4946            .register_template("t", "alpha distinct tokens")
4947            .unwrap();
4948        engine
4949            .register_template("t", "beta different tokens")
4950            .unwrap();
4951        engine
4952            .register_template("t", "gamma unique tokens")
4953            .unwrap();
4954
4955        let mut session = test_session();
4956        let ctx = Context::new();
4957        let r1 = engine.render(&mut session, "t", &ctx).unwrap();
4958        let r2 = engine.render(&mut session, "t", &ctx).unwrap();
4959
4960        // Second render must pick a different variant than the first —
4961        // choose-best plus explicit last-variant exclusion guarantees it.
4962        assert_ne!(r1, r2);
4963    }
4964
4965    #[test]
4966    fn list_style_cycles_across_renders() {
4967        let mut engine = test_engine();
4968        engine
4969            .register_template("t", "{items|truncate:1|join}")
4970            .unwrap();
4971
4972        let mut session = test_session();
4973        let mut ctx = Context::new();
4974        ctx.insert(
4975            "items",
4976            Value::List(vec!["alpha".into(), "beta".into(), "gamma".into()]),
4977        );
4978
4979        let r1 = engine.render(&mut session, "t", &ctx).unwrap();
4980        let r2 = engine.render(&mut session, "t", &ctx).unwrap();
4981        let r3 = engine.render(&mut session, "t", &ctx).unwrap();
4982        let r4 = engine.render(&mut session, "t", &ctx).unwrap();
4983
4984        // Each should use a different list style
4985        let results = vec![r1, r2, r3, r4];
4986        let unique: std::collections::HashSet<&String> = results.iter().collect();
4987        assert!(
4988            unique.len() >= 3,
4989            "Expected at least 3 unique list styles, got {}: {:?}",
4990            unique.len(),
4991            results
4992        );
4993    }
4994
4995    #[test]
4996    fn bracketed_style_forced() {
4997        let mut engine = test_engine();
4998        engine
4999            .register_template("t", "{items|truncate:1|join:bracketed}")
5000            .unwrap();
5001
5002        let mut session = test_session();
5003        let mut ctx = Context::new();
5004        ctx.insert(
5005            "items",
5006            Value::List(vec!["alpha".into(), "beta".into(), "gamma".into()]),
5007        );
5008
5009        let result = engine.render(&mut session, "t", &ctx).unwrap();
5010        assert!(
5011            result.starts_with('[') && result.ends_with(']'),
5012            "Expected bracketed format, got: {result}"
5013        );
5014    }
5015
5016    // ── Refer pipe tests ────────────────────────────────────────────────
5017
5018    #[test]
5019    fn refer_first_mention_uses_full_form() {
5020        let mut engine = test_engine();
5021        engine
5022            .register_template("t", "{name|refer} was updated")
5023            .unwrap();
5024
5025        let mut session = test_session();
5026        let mut ctx = Context::new();
5027        ctx.insert("entity_type", Value::String("class".into()));
5028        ctx.insert("name", Value::String("UserService".into()));
5029
5030        let result = engine.render(&mut session, "t", &ctx).unwrap();
5031        assert_eq!(result, "The class UserService was updated.");
5032    }
5033
5034    #[test]
5035    fn refer_second_mention_uses_pronoun() {
5036        let mut engine = test_engine();
5037        engine
5038            .register_template("first", "{name|refer} was modified")
5039            .unwrap();
5040        engine
5041            .register_template("second", "{name|refer} now has new behavior")
5042            .unwrap();
5043
5044        let mut session = test_session();
5045        let mut ctx = Context::new();
5046        ctx.insert("entity_type", Value::String("class".into()));
5047        ctx.insert("name", Value::String("Foo".into()));
5048
5049        let r1 = engine.render(&mut session, "first", &ctx).unwrap();
5050        let r2 = engine.render(&mut session, "second", &ctx).unwrap();
5051
5052        assert_eq!(r1, "The class Foo was modified.");
5053        // Second render: pronoun + possibly a discourse connective prepended
5054        assert!(
5055            r2.contains("it now has new behavior") || r2.contains("It now has new behavior"),
5056            "Expected pronoun reference, got: {r2}"
5057        );
5058    }
5059
5060    #[test]
5061    fn refer_ambiguity_prevents_pronoun() {
5062        let mut engine = test_engine();
5063        engine
5064            .register_template("t", "{name|refer} changed")
5065            .unwrap();
5066
5067        let mut session = test_session();
5068
5069        // Render with entity A
5070        let mut ctx_a = Context::new();
5071        ctx_a.insert("entity_type", Value::String("class".into()));
5072        ctx_a.insert("name", Value::String("ServiceA".into()));
5073        engine.render(&mut session, "t", &ctx_a).unwrap();
5074
5075        // Render with entity B (ambiguity introduced)
5076        let mut ctx_b = Context::new();
5077        ctx_b.insert("entity_type", Value::String("class".into()));
5078        ctx_b.insert("name", Value::String("ServiceB".into()));
5079        engine.render(&mut session, "t", &ctx_b).unwrap();
5080
5081        // Back to entity A — ambiguous context, should not use "It"
5082        let result = engine.render(&mut session, "t", &ctx_a).unwrap();
5083        // May have a discourse connective prepended, but the key is NO pronoun
5084        assert!(
5085            result.contains("ServiceA changed") || result.contains("serviceA changed"),
5086            "Expected short name (not pronoun), got: {result}"
5087        );
5088        assert!(
5089            !result.contains("It changed") && !result.contains("it changed"),
5090            "Should not use pronoun with ambiguity, got: {result}"
5091        );
5092    }
5093
5094    #[test]
5095    fn refer_explicit_entity_type() {
5096        let mut engine = test_engine();
5097        engine
5098            .register_template("t", "{name|refer:method} was called")
5099            .unwrap();
5100
5101        let mut session = test_session();
5102        let mut ctx = Context::new();
5103        ctx.insert("name", Value::String("processOrder".into()));
5104
5105        let result = engine.render(&mut session, "t", &ctx).unwrap();
5106        assert_eq!(result, "The method processOrder was called.");
5107    }
5108
5109    #[test]
5110    fn refer_reset_reintroduces_full_form() {
5111        let mut engine = test_engine();
5112        engine
5113            .register_template("t", "{name|refer} updated")
5114            .unwrap();
5115
5116        let mut session = test_session();
5117        let mut ctx = Context::new();
5118        ctx.insert("entity_type", Value::String("class".into()));
5119        ctx.insert("name", Value::String("Foo".into()));
5120
5121        engine.render(&mut session, "t", &ctx).unwrap();
5122        session.reset();
5123
5124        // After reset, should use full form again
5125        let result = engine.render(&mut session, "t", &ctx).unwrap();
5126        assert_eq!(result, "The class Foo updated.");
5127    }
5128
5129    #[test]
5130    fn refer_distant_mention_reintroduces_full() {
5131        let mut engine = test_engine();
5132        engine
5133            .register_template("track", "{name|refer} was tracked")
5134            .unwrap();
5135        engine
5136            .register_template("other", "Something else happened")
5137            .unwrap();
5138
5139        let mut session = test_session();
5140        let mut ctx = Context::new();
5141        ctx.insert("entity_type", Value::String("class".into()));
5142        ctx.insert("name", Value::String("Foo".into()));
5143
5144        let mut other_ctx = Context::new();
5145        other_ctx.insert("entity_type", Value::String("method".into()));
5146        other_ctx.insert("name", Value::String("bar".into()));
5147
5148        // Mention Foo
5149        engine.render(&mut session, "track", &ctx).unwrap();
5150
5151        // Three unrelated renders
5152        engine.render(&mut session, "other", &other_ctx).unwrap();
5153        engine.render(&mut session, "other", &other_ctx).unwrap();
5154        engine.render(&mut session, "other", &other_ctx).unwrap();
5155
5156        // Foo should be re-introduced with full form
5157        let result = engine.render(&mut session, "track", &ctx).unwrap();
5158        assert_eq!(result, "The class Foo was tracked.");
5159    }
5160
5161    // ── Explain output ───────────────────────────────────────────────────
5162
5163    #[test]
5164    fn explain_reports_variant_index_and_source() {
5165        let mut engine = test_engine();
5166        engine.register_template("t", "alpha").unwrap();
5167        engine.register_template("t", "beta").unwrap();
5168
5169        let mut session = test_session();
5170        let exp = engine
5171            .render_explained(&mut session, "t", Context::new())
5172            .unwrap();
5173        assert_eq!(exp.template_key, "t");
5174        assert_eq!(exp.variant_index, 0);
5175        assert_eq!(exp.variant_source, "alpha");
5176        assert_eq!(exp.salience, Salience::Medium);
5177    }
5178
5179    #[test]
5180    fn explain_reports_reference_form_when_refer_pipe_fires() {
5181        let mut engine = test_engine();
5182        engine
5183            .register_template("t", "{name|refer} was modified")
5184            .unwrap();
5185        let mut ctx = Context::new();
5186        ctx.insert("entity_type", Value::String("class".into()));
5187        ctx.insert("name", Value::String("Foo".into()));
5188
5189        let mut session = test_session();
5190        let exp = engine.render_explained(&mut session, "t", &ctx).unwrap();
5191        // First mention → Full form.
5192        assert_eq!(exp.reference_form, Some(ReferenceForm::Full));
5193    }
5194
5195    #[test]
5196    fn explain_reports_centering_transition() {
5197        let mut engine = test_engine();
5198        engine
5199            .register_template("t", "{name|refer} was modified")
5200            .unwrap();
5201        let mut s = test_session();
5202
5203        let mut c = Context::new();
5204        c.insert("entity_type", Value::String("class".into()));
5205        c.insert("name", Value::String("Foo".into()));
5206
5207        // First render: no prior Cb → NoCb.
5208        let e1 = engine.render_explained(&mut s, "t", &c).unwrap();
5209        assert_eq!(e1.centering_transition, Transition::NoCb);
5210
5211        // Second render of same entity: Cb == prev_Cb (Foo) AND Cb == Cp (Foo) → Continue.
5212        let e2 = engine.render_explained(&mut s, "t", &c).unwrap();
5213        assert_eq!(e2.centering_transition, Transition::Continue);
5214
5215        // Third render of a new entity: introduces Bar for the first time.
5216        // previous_cf = [{Foo,0}], current_cf = [{Bar,0}]. No overlap.
5217        // Bar is new (mention_count == 1), fallback → new_cb = Foo (previous_focus).
5218        // classify_transition(Foo, Foo, Bar) → Retain.
5219        let mut c2 = Context::new();
5220        c2.insert("entity_type", Value::String("class".into()));
5221        c2.insert("name", Value::String("Bar".into()));
5222        let e3 = engine.render_explained(&mut s, "t", &c2).unwrap();
5223        assert_eq!(e3.centering_transition, Transition::Retain);
5224    }
5225
5226    #[test]
5227    fn explain_captures_connective_on_continuation() {
5228        let mut engine = test_engine();
5229        engine
5230            .register_template("t", "The {entity_type} {name} was renamed")
5231            .unwrap();
5232        engine
5233            .register_template("u", "The {entity_type} {name} was modified")
5234            .unwrap();
5235        let mut ctx = Context::new();
5236        ctx.insert("entity_type", Value::String("class".into()));
5237        ctx.insert("name", Value::String("Foo".into()));
5238
5239        let mut session = test_session();
5240        // Prime.
5241        engine.render(&mut session, "t", &ctx).unwrap();
5242        // Same entity, different action → "Additionally," prepended.
5243        let exp = engine.render_explained(&mut session, "u", &ctx).unwrap();
5244        assert_eq!(exp.connective, Some("Additionally,"));
5245    }
5246
5247    #[test]
5248    fn render_explained_reports_list_style_when_join_fires() {
5249        let mut engine = test_engine();
5250        engine
5251            .register_template("list", "{items|join:bracketed}")
5252            .unwrap();
5253        let mut session = test_session();
5254        let mut ctx = Context::new();
5255        ctx.insert(
5256            "items",
5257            Value::List(vec!["a".into(), "b".into(), "c".into()]),
5258        );
5259        let exp = engine.render_explained(&mut session, "list", &ctx).unwrap();
5260        assert_eq!(
5261            exp.list_style,
5262            Some(ListStyle::Bracketed),
5263            "render_explained should report the forced list style; got: {:?}",
5264            exp.list_style
5265        );
5266    }
5267
5268    #[test]
5269    fn render_explained_list_style_none_when_no_join_fired() {
5270        let mut engine = test_engine();
5271        engine
5272            .register_template("plain", "The {entity_type} {name} was renamed")
5273            .unwrap();
5274        let mut session = test_session();
5275        let mut ctx = Context::new();
5276        ctx.insert("entity_type", Value::String("class".into()));
5277        ctx.insert("name", Value::String("Foo".into()));
5278        let exp = engine
5279            .render_explained(&mut session, "plain", &ctx)
5280            .unwrap();
5281        assert_eq!(exp.list_style, None);
5282    }
5283
5284    #[test]
5285    fn render_explained_reports_cleanup_stripped_tail_in_silent_mode() {
5286        // Silent strictness: omitted `location` slot leaves a dangling " in "
5287        // that the orphan-tail pass should strip, flipping
5288        // cleanup_stripped_tail to true.
5289        let mut engine = test_engine().strictness(Strictness::Silent);
5290        engine
5291            .register_template("add", "A new {entity_type} was added in {location}")
5292            .unwrap();
5293        let mut session = test_session();
5294        let mut ctx = Context::new();
5295        ctx.insert("entity_type", Value::String("class".into()));
5296        // location intentionally omitted
5297        let exp = engine.render_explained(&mut session, "add", &ctx).unwrap();
5298        assert!(
5299            exp.cleanup_stripped_tail,
5300            "Silent-mode render with dangling tail should report cleanup_stripped_tail=true; got output: {:?}",
5301            exp.output
5302        );
5303    }
5304
5305    #[test]
5306    fn render_explained_cleanup_stripped_tail_false_for_clean_render() {
5307        let mut engine = test_engine();
5308        engine
5309            .register_template("plain", "The {entity_type} {name} was renamed")
5310            .unwrap();
5311        let mut session = test_session();
5312        let mut ctx = Context::new();
5313        ctx.insert("entity_type", Value::String("class".into()));
5314        ctx.insert("name", Value::String("Foo".into()));
5315        let exp = engine
5316            .render_explained(&mut session, "plain", &ctx)
5317            .unwrap();
5318        assert!(!exp.cleanup_stripped_tail);
5319    }
5320
5321    // ── Streaming render iterator ────────────────────────────────────────
5322
5323    #[test]
5324    fn render_iter_yields_one_sentence_per_event_when_no_aggregation() {
5325        let mut engine = test_engine();
5326        engine.register_template("a", "Alpha was seen").unwrap();
5327        engine.register_template("b", "Beta was found").unwrap();
5328
5329        let mut session = test_session();
5330        let events: Vec<(&str, Context)> = vec![("a", Context::new()), ("b", Context::new())];
5331        let results: Vec<_> = engine
5332            .render_iter(&mut session, &events)
5333            .collect::<Result<Vec<_>, _>>()
5334            .unwrap();
5335        // Two different templates without shared entities — each yields
5336        // its own sentence, no aggregation.
5337        assert_eq!(results.len(), 2);
5338        assert!(results[0].contains("Alpha"));
5339        assert!(results[1].contains("Beta"));
5340    }
5341
5342    #[test]
5343    fn render_iter_error_is_terminal_for_single_events() {
5344        // Strict mode with a missing slot errors. After the error, the
5345        // iterator must report None, not replay the failing event.
5346        let mut engine = test_engine();
5347        engine
5348            .register_template("bad", "{missing_slot} was lost")
5349            .unwrap();
5350
5351        let mut session = test_session();
5352        let events: Vec<(&str, Context)> = vec![("bad", Context::new())];
5353        let mut iter = engine.render_iter(&mut session, &events);
5354        let first = iter.next();
5355        assert!(matches!(first, Some(Err(_))));
5356        let second = iter.next();
5357        assert!(
5358            second.is_none(),
5359            "iterator must return None after a terminal error"
5360        );
5361    }
5362
5363    #[test]
5364    fn render_iter_error_is_terminal_inside_aggregated_run() {
5365        // Two events with the same template key but the second one's
5366        // context is missing a slot the template references. The first
5367        // aggregated render call will fail — the iterator must end, not
5368        // replay the run.
5369        let mut engine = test_engine();
5370        engine
5371            .register_template("saw", "{name} saw {target}")
5372            .unwrap();
5373
5374        let mut good = Context::new();
5375        good.insert("entity_type", Value::String("class".into()));
5376        good.insert("name", Value::String("Alpha".into()));
5377        good.insert("target", Value::String("X".into()));
5378
5379        let mut bad = Context::new();
5380        bad.insert("entity_type", Value::String("class".into()));
5381        bad.insert("name", Value::String("Beta".into()));
5382        // target slot omitted → missing-slot error under Strict.
5383
5384        let mut session = test_session();
5385        let events: Vec<(&str, Context)> = vec![("saw", good), ("saw", bad)];
5386        let mut iter = engine.render_iter(&mut session, &events);
5387        // The aggregation path renders both subjects at once; the missing
5388        // slot on the second context surfaces as an error.
5389        let first = iter.next();
5390        assert!(
5391            matches!(first, Some(Err(_))),
5392            "expected the aggregated render to fail; got: {first:?}"
5393        );
5394        assert!(
5395            iter.next().is_none(),
5396            "iterator must be terminal after an aggregated-run error"
5397        );
5398    }
5399
5400    #[test]
5401    fn render_iter_collapses_same_entity_run_into_one_sentence() {
5402        let mut engine = test_engine();
5403        engine
5404            .register_template("renamed", "{name|refer} was renamed")
5405            .unwrap();
5406        engine
5407            .register_template("modified", "{name|refer} was modified")
5408            .unwrap();
5409
5410        let mut session = test_session();
5411        let mut ctx = Context::new();
5412        ctx.insert("entity_type", Value::String("class".into()));
5413        ctx.insert("name", Value::String("Foo".into()));
5414        let events: Vec<(&str, Context)> =
5415            vec![("renamed", ctx.clone()), ("modified", ctx.clone())];
5416        let iter_results: Vec<_> = engine
5417            .render_iter(&mut session, &events)
5418            .collect::<Result<Vec<_>, _>>()
5419            .unwrap();
5420        // Expected: exactly one reduced sentence for the same-entity run.
5421        assert_eq!(iter_results.len(), 1);
5422        assert!(
5423            iter_results[0].contains("renamed and modified"),
5424            "got: {}",
5425            iter_results[0]
5426        );
5427    }
5428
5429    // ── Score variants harness ───────────────────────────────────────────
5430
5431    #[test]
5432    fn score_variants_returns_one_entry_per_alternative() {
5433        let mut engine = test_engine();
5434        engine.register_template("t", "alpha").unwrap();
5435        engine.register_template("t", "beta").unwrap();
5436        engine.register_template("t", "gamma").unwrap();
5437
5438        let mut session = test_session();
5439        let scores = engine
5440            .score_variants(&mut session, "t", Context::new())
5441            .unwrap();
5442        assert_eq!(scores.len(), 3);
5443        let sources: Vec<_> = scores.iter().map(|s| s.source.as_str()).collect();
5444        assert_eq!(sources, vec!["alpha", "beta", "gamma"]);
5445    }
5446
5447    #[test]
5448    fn score_variants_marks_one_as_selected() {
5449        let mut engine = test_engine();
5450        engine.register_template("t", "alpha").unwrap();
5451        engine.register_template("t", "beta").unwrap();
5452
5453        let mut session = test_session();
5454        let scores = engine
5455            .score_variants(&mut session, "t", Context::new())
5456            .unwrap();
5457        assert_eq!(scores.iter().filter(|s| s.selected).count(), 1);
5458    }
5459
5460    #[test]
5461    fn score_variants_does_not_mutate_discourse() {
5462        let mut engine = test_engine();
5463        engine.register_template("t", "alpha").unwrap();
5464        engine.register_template("t", "beta").unwrap();
5465
5466        // Confirm that scoring doesn't advance render_index or any other
5467        // discourse state — a follow-up render must behave as if the
5468        // score call never happened.
5469        let mut session = test_session();
5470        let _ = engine
5471            .score_variants(&mut session, "t", Context::new())
5472            .unwrap();
5473        let r1 = engine.render(&mut session, "t", Context::new()).unwrap();
5474        // Fresh discourse: expected Fixed variation returns the first
5475        // variant (index 0). Single-word output, so no sentence-end period.
5476        assert_eq!(r1, "alpha");
5477    }
5478
5479    #[test]
5480    fn score_variants_unknown_key_errors() {
5481        let engine = test_engine();
5482        let mut session = test_session();
5483        let result = engine.score_variants(&mut session, "never_registered", Context::new());
5484        assert!(matches!(result, Err(ProsaicError::UnknownTemplate(_))));
5485    }
5486
5487    // ── Template partials ────────────────────────────────────────────────
5488
5489    #[test]
5490    fn partial_expands_inline() {
5491        let mut engine = test_engine();
5492        engine
5493            .register_partial("tail", ", affecting {count} {count|pluralize:consumer}")
5494            .unwrap();
5495        engine
5496            .register_template("t", "The class Foo was modified{>tail}")
5497            .unwrap();
5498
5499        let mut session = test_session();
5500        let mut ctx = Context::new();
5501        ctx.insert("count", Value::Number(3));
5502        assert_eq!(
5503            engine.render(&mut session, "t", &ctx).unwrap(),
5504            "The class Foo was modified, affecting 3 consumers."
5505        );
5506    }
5507
5508    #[test]
5509    fn partial_shared_across_templates() {
5510        let mut engine = test_engine();
5511        engine
5512            .register_partial("tail", ", affecting {count} {count|pluralize:consumer}")
5513            .unwrap();
5514        engine
5515            .register_template("modified", "The class {name} was modified{>tail}")
5516            .unwrap();
5517        engine
5518            .register_template("renamed", "The class {name} was renamed{>tail}")
5519            .unwrap();
5520
5521        let mut session = test_session();
5522        let mut ctx = Context::new();
5523        ctx.insert("name", Value::String("Foo".into()));
5524        ctx.insert("count", Value::Number(2));
5525
5526        assert_eq!(
5527            engine.render(&mut session, "modified", &ctx).unwrap(),
5528            "The class Foo was modified, affecting 2 consumers."
5529        );
5530        assert_eq!(
5531            engine.render(&mut session, "renamed", &ctx).unwrap(),
5532            "The class Foo was renamed, affecting 2 consumers."
5533        );
5534    }
5535
5536    #[test]
5537    fn unknown_partial_errors() {
5538        let mut engine = test_engine();
5539        engine
5540            .register_template("t", "Hello{>missing_partial}")
5541            .unwrap();
5542        let mut session = test_session();
5543        let result = engine.render(&mut session, "t", Context::new());
5544        assert!(matches!(
5545            result,
5546            Err(ProsaicError::TemplateParseError { .. })
5547        ));
5548    }
5549
5550    #[test]
5551    fn direct_recursive_partial_is_rejected() {
5552        let mut engine = test_engine();
5553        let result = engine.register_partial("a", "{>a}");
5554        match result {
5555            Err(ProsaicError::RecursivePartial { cycle }) => {
5556                assert_eq!(cycle, vec!["a".to_string(), "a".to_string()]);
5557            }
5558            other => panic!("expected RecursivePartial, got {other:?}"),
5559        }
5560        // The partial must NOT be stored (otherwise future lookups reach
5561        // a cyclic definition).
5562        assert!(!engine.partials.contains_key("a"));
5563    }
5564
5565    #[test]
5566    fn indirect_recursive_partial_is_rejected() {
5567        let mut engine = test_engine();
5568        // Register `a` referring to a not-yet-existing `b` — that's fine.
5569        engine.register_partial("a", "{>b}").unwrap();
5570        // Now registering `b` that refers back to `a` must fail with the
5571        // a -> b -> a cycle reported, and `b` must NOT be stored.
5572        let result = engine.register_partial("b", "{>a}");
5573        match result {
5574            Err(ProsaicError::RecursivePartial { cycle }) => {
5575                assert!(
5576                    cycle.contains(&"a".to_string()) && cycle.contains(&"b".to_string()),
5577                    "cycle should include both partials; got {cycle:?}"
5578                );
5579                // First and last entries should match (cycle closes).
5580                assert_eq!(cycle.first(), cycle.last());
5581            }
5582            other => panic!("expected RecursivePartial, got {other:?}"),
5583        }
5584        assert!(!engine.partials.contains_key("b"));
5585        // Partial `a` is still present (registered successfully earlier).
5586        assert!(engine.partials.contains_key("a"));
5587    }
5588
5589    #[test]
5590    fn non_cyclic_partial_chain_is_accepted() {
5591        let mut engine = test_engine();
5592        engine.register_partial("inner", "-inner-").unwrap();
5593        engine.register_partial("middle", "[{>inner}]").unwrap();
5594        engine.register_partial("outer", "<{>middle}>").unwrap();
5595        engine
5596            .register_template("t", "prefix {>outer} suffix")
5597            .unwrap();
5598
5599        let mut session = test_session();
5600        let out = engine.render(&mut session, "t", Context::new()).unwrap();
5601        assert!(out.contains("<[-inner-]>"), "got: {out}");
5602    }
5603
5604    #[test]
5605    fn updating_partial_to_introduce_cycle_rolls_back() {
5606        let mut engine = test_engine();
5607        engine.register_partial("a", "literal-a").unwrap();
5608        engine.register_partial("b", "{>a}").unwrap();
5609
5610        // Attempt to overwrite `a` with a reference to `b` — would form
5611        // a -> b -> a cycle and must be rejected. The previous body must
5612        // remain intact.
5613        let result = engine.register_partial("a", "{>b}");
5614        assert!(matches!(result, Err(ProsaicError::RecursivePartial { .. })));
5615
5616        // Render via `a` — should still produce the prior literal body.
5617        engine.register_template("t", "see {>a} here").unwrap();
5618        let mut session = test_session();
5619        let out = engine.render(&mut session, "t", Context::new()).unwrap();
5620        assert!(
5621            out.contains("literal-a"),
5622            "expected prior partial body to be restored; got: {out}"
5623        );
5624    }
5625
5626    // ── Sentence-length budget ───────────────────────────────────────────
5627
5628    #[test]
5629    fn length_budget_splits_long_sentence_at_which() {
5630        let mut engine = test_engine().max_sentence_length(50);
5631        engine
5632            .register_template(
5633                "t",
5634                "The class UserService was renamed to AccountService, \
5635                 which impacts 6 consumers",
5636            )
5637            .unwrap();
5638
5639        let mut session = test_session();
5640        let out = engine.render(&mut session, "t", Context::new()).unwrap();
5641        assert!(out.contains("This impacts 6 consumers"), "got: {out}");
5642        assert!(out.contains(". "), "expected a sentence break, got: {out}");
5643    }
5644
5645    #[test]
5646    fn length_budget_does_nothing_when_sentence_fits() {
5647        let mut engine = test_engine().max_sentence_length(200);
5648        engine
5649            .register_template("t", "The class Foo was modified")
5650            .unwrap();
5651
5652        let mut session = test_session();
5653        let out = engine.render(&mut session, "t", Context::new()).unwrap();
5654        assert_eq!(out, "The class Foo was modified.");
5655    }
5656
5657    // ── Negation pipe ────────────────────────────────────────────────────
5658
5659    #[test]
5660    fn negated_pipe_uses_registered_antonym() {
5661        let mut engine = test_engine();
5662        engine.register_antonym("was modified", "remained unchanged");
5663        engine
5664            .register_template("t", "The class Foo {p|negated}")
5665            .unwrap();
5666
5667        let mut session = test_session();
5668        let mut ctx = Context::new();
5669        ctx.insert("p", Value::String("was modified".into()));
5670        assert_eq!(
5671            engine.render(&mut session, "t", &ctx).unwrap(),
5672            "The class Foo remained unchanged."
5673        );
5674    }
5675
5676    #[test]
5677    fn negated_pipe_inserts_not_when_no_antonym() {
5678        let mut engine = test_engine();
5679        engine
5680            .register_template("t", "The class Foo {p|negated}")
5681            .unwrap();
5682
5683        let mut session = test_session();
5684        let mut ctx = Context::new();
5685        ctx.insert("p", Value::String("was modified".into()));
5686        assert_eq!(
5687            engine.render(&mut session, "t", &ctx).unwrap(),
5688            "The class Foo was not modified."
5689        );
5690    }
5691
5692    #[test]
5693    fn negated_pipe_handles_perfect_aux() {
5694        let mut engine = test_engine();
5695        engine
5696            .register_template("t", "The class Foo {p|negated}")
5697            .unwrap();
5698
5699        let mut session = test_session();
5700        let mut ctx = Context::new();
5701        ctx.insert("p", Value::String("has been renamed".into()));
5702        assert_eq!(
5703            engine.render(&mut session, "t", &ctx).unwrap(),
5704            "The class Foo has not been renamed."
5705        );
5706    }
5707
5708    // ── Hedge pipe ───────────────────────────────────────────────────────
5709
5710    #[test]
5711    fn hedge_pipe_default_adverb() {
5712        let mut engine = test_engine();
5713        engine
5714            .register_template("t", "The change {conf|hedge} broke the build")
5715            .unwrap();
5716
5717        let mut session = test_session();
5718        let mut ctx = Context::new();
5719        ctx.insert("conf", Value::Number(60));
5720        assert_eq!(
5721            engine.render(&mut session, "t", &ctx).unwrap(),
5722            "The change probably broke the build."
5723        );
5724    }
5725
5726    #[test]
5727    fn hedge_pipe_modal_mode() {
5728        let mut engine = test_engine();
5729        engine
5730            .register_template("t", "The change {conf|hedge:modal} break things")
5731            .unwrap();
5732
5733        let mut session = test_session();
5734        let mut ctx = Context::new();
5735        ctx.insert("conf", Value::Number(40));
5736        assert_eq!(
5737            engine.render(&mut session, "t", &ctx).unwrap(),
5738            "The change might break things."
5739        );
5740    }
5741
5742    #[test]
5743    fn hedge_pipe_rejects_unknown_mode() {
5744        let mut engine = test_engine();
5745        engine.register_template("t", "{c|hedge:bogus}").unwrap();
5746        let mut session = test_session();
5747        let mut ctx = Context::new();
5748        ctx.insert("c", Value::Number(60));
5749        assert!(matches!(
5750            engine.render(&mut session, "t", &ctx),
5751            Err(ProsaicError::InvalidPipe { .. })
5752        ));
5753    }
5754
5755    // ── Anaphora: plural pronouns & demonstratives ───────────────────────
5756
5757    #[test]
5758    fn demonstrative_uses_the_on_first_render() {
5759        let mut engine = test_engine();
5760        engine
5761            .register_template("t", "{noun|demonstrative}")
5762            .unwrap();
5763        let mut session = test_session();
5764        let mut ctx = Context::new();
5765        ctx.insert("noun", Value::String("change".into()));
5766        // Lowercase — demonstrative never capitalizes; callers that want
5767        // the demonstrative at a sentence start combine it with a
5768        // leading template word that already capitalizes, or with the
5769        // engine's refer-pipe capitalization path.
5770        assert_eq!(
5771            engine.render(&mut session, "t", &ctx).unwrap(),
5772            "the change"
5773        );
5774    }
5775
5776    #[test]
5777    fn demonstrative_uses_this_on_continuation() {
5778        let mut engine = test_engine();
5779        engine.register_template("prime", "setup").unwrap();
5780        engine
5781            .register_template("t", "{noun|demonstrative}")
5782            .unwrap();
5783
5784        let mut session = test_session();
5785        // Prior render establishes discourse.
5786        engine
5787            .render(&mut session, "prime", Context::new())
5788            .unwrap();
5789
5790        let mut ctx = Context::new();
5791        ctx.insert("noun", Value::String("change".into()));
5792        let result = engine.render(&mut session, "t", &ctx).unwrap();
5793        // Mid-sentence capitalization isn't applied (template doesn't
5794        // start with refer), so the value comes out lowercase.
5795        assert_eq!(result, "this change");
5796    }
5797
5798    #[test]
5799    fn demonstrative_resets_to_the_after_reset() {
5800        let mut engine = test_engine();
5801        engine.register_template("prime", "setup").unwrap();
5802        engine
5803            .register_template("t", "{noun|demonstrative}")
5804            .unwrap();
5805
5806        let mut session = test_session();
5807        engine
5808            .render(&mut session, "prime", Context::new())
5809            .unwrap();
5810        session.reset();
5811
5812        let mut ctx = Context::new();
5813        ctx.insert("noun", Value::String("change".into()));
5814        assert_eq!(
5815            engine.render(&mut session, "t", &ctx).unwrap(),
5816            "the change"
5817        );
5818    }
5819
5820    // ── Quantify pipe ────────────────────────────────────────────────────
5821
5822    #[test]
5823    fn quantify_pipe_natural_defaults() {
5824        // Uses the crate's own TestLang for number_to_words via
5825        // "<N>"-style stub. We still exercise the small-number spelling
5826        // path via QuantifyMode::Natural's language callback.
5827        let mut engine = test_engine();
5828        engine
5829            .register_template("t", "{n|quantify} consumer")
5830            .unwrap();
5831
5832        let mut session = test_session();
5833        let mut ctx = Context::new();
5834        ctx.insert("n", Value::Number(0));
5835        assert_eq!(
5836            engine.render(&mut session, "t", &ctx).unwrap(),
5837            "no consumer"
5838        );
5839        session.reset();
5840
5841        ctx.insert("n", Value::Number(1));
5842        assert_eq!(
5843            engine.render(&mut session, "t", &ctx).unwrap(),
5844            "a single consumer"
5845        );
5846        session.reset();
5847
5848        ctx.insert("n", Value::Number(300));
5849        assert_eq!(
5850            engine.render(&mut session, "t", &ctx).unwrap(),
5851            "hundreds of consumer"
5852        );
5853    }
5854
5855    #[test]
5856    fn quantify_pipe_exact_mode() {
5857        let mut engine = test_engine();
5858        engine
5859            .register_template("t", "{n|quantify:exact} callers")
5860            .unwrap();
5861
5862        let mut session = test_session();
5863        let mut ctx = Context::new();
5864        ctx.insert("n", Value::Number(47));
5865        assert_eq!(
5866            engine.render(&mut session, "t", &ctx).unwrap(),
5867            "47 callers"
5868        );
5869    }
5870
5871    #[test]
5872    fn quantify_pipe_hedged_mode() {
5873        let mut engine = test_engine();
5874        engine
5875            .register_template("t", "{n|quantify:hedged} dependents")
5876            .unwrap();
5877
5878        let mut session = test_session();
5879        let mut ctx = Context::new();
5880        ctx.insert("n", Value::Number(4));
5881        assert_eq!(
5882            engine.render(&mut session, "t", &ctx).unwrap(),
5883            "a few dependents"
5884        );
5885    }
5886
5887    #[test]
5888    fn quantify_pipe_rejects_unknown_mode() {
5889        let mut engine = test_engine();
5890        engine.register_template("t", "{n|quantify:bogus}").unwrap();
5891        let mut session = test_session();
5892        let mut ctx = Context::new();
5893        ctx.insert("n", Value::Number(5));
5894        assert!(matches!(
5895            engine.render(&mut session, "t", &ctx),
5896            Err(ProsaicError::InvalidPipe { .. })
5897        ));
5898    }
5899
5900    // ── Relative time pipe ───────────────────────────────────────────────
5901
5902    #[test]
5903    fn relative_pipe_renders_past_phrases() {
5904        // Fix the reference time so tests are deterministic.
5905        let now: i64 = 1_700_000_000;
5906        let mut engine = test_engine().reference_time(now);
5907        engine.register_template("t", "{ts|relative}").unwrap();
5908
5909        let cases = [
5910            (now, "just now"),
5911            (now - 60, "1 minute ago"),
5912            (now - 3600, "an hour ago"),
5913            (now - 86400 - 3600, "yesterday"),
5914            (now - 3 * 86400, "3 days ago"),
5915            (now - 10 * 86400, "last week"),
5916            (now - 3 * 30 * 86400, "3 months ago"),
5917            (now - 2 * 365 * 86400, "2 years ago"),
5918        ];
5919
5920        for (ts, expected) in cases {
5921            let mut session = test_session();
5922            let mut ctx = Context::new();
5923            ctx.insert("ts", Value::Number(ts));
5924            let rendered = engine.render(&mut session, "t", &ctx).unwrap();
5925            assert_eq!(rendered, expected, "for ts={ts}");
5926        }
5927    }
5928
5929    #[test]
5930    fn relative_pipe_renders_future_phrases() {
5931        let now: i64 = 1_700_000_000;
5932        let mut engine = test_engine().reference_time(now);
5933        engine.register_template("t", "{ts|relative}").unwrap();
5934
5935        let cases = [
5936            (now + 3600, "in an hour"),
5937            (now + 86400 + 3600, "tomorrow"),
5938            (now + 3 * 86400, "in 3 days"),
5939            (now + 10 * 86400, "next week"),
5940        ];
5941
5942        for (ts, expected) in cases {
5943            let mut session = test_session();
5944            let mut ctx = Context::new();
5945            ctx.insert("ts", Value::Number(ts));
5946            let rendered = engine.render(&mut session, "t", &ctx).unwrap();
5947            assert_eq!(rendered, expected, "for ts={ts}");
5948        }
5949    }
5950
5951    #[test]
5952    fn relative_pipe_rejects_non_numeric() {
5953        let mut engine = test_engine().reference_time(1_700_000_000);
5954        engine.register_template("t", "{x|relative}").unwrap();
5955        let mut session = test_session();
5956        let mut ctx = Context::new();
5957        ctx.insert("x", Value::String("not a number".into()));
5958        let result = engine.render(&mut session, "t", &ctx);
5959        assert!(matches!(result, Err(ProsaicError::InvalidPipe { .. })));
5960    }
5961
5962    // ── since_last pipe ──────────────────────────────────────────────────
5963
5964    #[cfg(feature = "time")]
5965    #[test]
5966    fn since_last_first_event_falls_back_to_relative() {
5967        let now = 1_700_000_000;
5968        let mut engine = test_engine().reference_time(now);
5969        engine.register_template("t", "{ts|since_last}").unwrap();
5970        let mut s = Session::new();
5971        let mut ctx = Context::new();
5972        ctx.insert("ts", Value::Number(now - 3 * 86400)); // 3 days ago
5973        ctx.insert("timestamp", Value::Number(now - 3 * 86400));
5974        let out = engine.render(&mut s, "t", &ctx).unwrap();
5975        assert!(out.contains("3 days ago"), "got: {out}");
5976    }
5977
5978    #[cfg(feature = "time")]
5979    #[test]
5980    fn since_last_subsequent_event_uses_anchor() {
5981        let now = 1_700_000_000;
5982        let mut engine = test_engine().reference_time(now);
5983        engine.register_template("t", "{ts|since_last}").unwrap();
5984        let mut s = Session::new();
5985
5986        // First event sets the anchor.
5987        let mut c1 = Context::new();
5988        let t1 = now - 3 * 86400;
5989        c1.insert("ts", Value::Number(t1));
5990        c1.insert("timestamp", Value::Number(t1));
5991        engine.render(&mut s, "t", &c1).unwrap();
5992
5993        // Second event, one day later.
5994        let mut c2 = Context::new();
5995        let t2 = t1 + 86400;
5996        c2.insert("ts", Value::Number(t2));
5997        c2.insert("timestamp", Value::Number(t2));
5998        let out = engine.render(&mut s, "t", &c2).unwrap();
5999        assert!(out.contains("the next day"), "got: {out}");
6000    }
6001
6002    #[cfg(feature = "time")]
6003    #[test]
6004    fn since_last_survives_session_reset() {
6005        let now = 1_700_000_000;
6006        let mut engine = test_engine().reference_time(now);
6007        engine.register_template("t", "{ts|since_last}").unwrap();
6008        let mut s = Session::new();
6009
6010        let mut c1 = Context::new();
6011        let t1 = now - 3 * 86400;
6012        c1.insert("ts", Value::Number(t1));
6013        c1.insert("timestamp", Value::Number(t1));
6014        engine.render(&mut s, "t", &c1).unwrap();
6015
6016        s.reset(); // Reset discourse, but NOT temporal anchor.
6017        assert_eq!(s.last_temporal_anchor, Some(t1));
6018
6019        let mut c2 = Context::new();
6020        let t2 = t1 + 86400;
6021        c2.insert("ts", Value::Number(t2));
6022        c2.insert("timestamp", Value::Number(t2));
6023        let out = engine.render(&mut s, "t", &c2).unwrap();
6024        assert!(out.contains("the next day"), "got: {out}");
6025    }
6026
6027    #[cfg(feature = "time")]
6028    #[test]
6029    fn since_last_reset_temporal_restarts_narrative() {
6030        let now = 1_700_000_000;
6031        let mut engine = test_engine().reference_time(now);
6032        engine.register_template("t", "{ts|since_last}").unwrap();
6033        let mut s = Session::new();
6034
6035        let mut c1 = Context::new();
6036        let t1 = now - 3 * 86400;
6037        c1.insert("ts", Value::Number(t1));
6038        c1.insert("timestamp", Value::Number(t1));
6039        engine.render(&mut s, "t", &c1).unwrap();
6040        s.reset_temporal();
6041
6042        let mut c2 = Context::new();
6043        let t2 = t1 + 86400;
6044        c2.insert("ts", Value::Number(t2));
6045        c2.insert("timestamp", Value::Number(t2));
6046        let out = engine.render(&mut s, "t", &c2).unwrap();
6047        // now - t2 = 2 days ago (absolute fallback)
6048        assert!(out.contains("2 days ago"), "got: {out}");
6049    }
6050
6051    #[cfg(feature = "time")]
6052    #[test]
6053    fn since_last_anchor_set_after_successful_render() {
6054        let now = 1_700_000_000;
6055        let mut engine = test_engine().reference_time(now);
6056        engine.register_template("t", "{ts|since_last}").unwrap();
6057        let mut s = Session::new();
6058        assert_eq!(s.last_temporal_anchor, None);
6059
6060        let mut ctx = Context::new();
6061        let ts = now - 86400;
6062        ctx.insert("ts", Value::Number(ts));
6063        ctx.insert("timestamp", Value::Number(ts));
6064        engine.render(&mut s, "t", &ctx).unwrap();
6065        assert_eq!(s.last_temporal_anchor, Some(ts));
6066    }
6067
6068    #[cfg(feature = "time")]
6069    #[test]
6070    fn since_last_rejects_non_numeric() {
6071        let mut engine = test_engine().reference_time(1_700_000_000);
6072        engine.register_template("t", "{x|since_last}").unwrap();
6073        let mut s = Session::new();
6074        let mut ctx = Context::new();
6075        ctx.insert("x", Value::String("not a number".into()));
6076        let result = engine.render(&mut s, "t", &ctx);
6077        assert!(matches!(result, Err(ProsaicError::InvalidPipe { .. })));
6078    }
6079
6080    // ── Synonym pipe (elegant variation) ─────────────────────────────────
6081
6082    #[test]
6083    fn syn_pipe_passes_through_unregistered_words() {
6084        let mut engine = test_engine();
6085        engine.register_template("t", "{word|syn}").unwrap();
6086
6087        let mut session = test_session();
6088        let mut ctx = Context::new();
6089        ctx.insert("word", Value::String("unregistered".into()));
6090        assert_eq!(
6091            engine.render(&mut session, "t", &ctx).unwrap(),
6092            "unregistered"
6093        );
6094    }
6095
6096    #[test]
6097    fn syn_pipe_rotates_across_renders() {
6098        let mut engine = test_engine();
6099        engine.register_synonyms(&["class", "type", "kind"]);
6100        engine
6101            .register_template("t", "the {word|syn} was seen")
6102            .unwrap();
6103
6104        let mut session = test_session();
6105        let mut ctx = Context::new();
6106        ctx.insert("word", Value::String("class".into()));
6107
6108        let r1 = engine.render(&mut session, "t", &ctx).unwrap();
6109        let r2 = engine.render(&mut session, "t", &ctx).unwrap();
6110        let r3 = engine.render(&mut session, "t", &ctx).unwrap();
6111
6112        // All three synonyms should appear across three renders —
6113        // least-recently-used scoring rotates them.
6114        let combined = format!("{r1} | {r2} | {r3}");
6115        assert!(combined.contains("class"), "got: {combined}");
6116        assert!(combined.contains("type"), "got: {combined}");
6117        assert!(combined.contains("kind"), "got: {combined}");
6118    }
6119
6120    #[test]
6121    fn syn_pipe_preserves_capitalization() {
6122        let mut engine = test_engine();
6123        engine.register_synonyms(&["class", "type"]);
6124        engine.register_template("t", "{word|syn}").unwrap();
6125
6126        let mut session = test_session();
6127        // Uppercase input → uppercase output synonym.
6128        let mut ctx = Context::new();
6129        ctx.insert("word", Value::String("Class".into()));
6130        let first = engine.render(&mut session, "t", &ctx).unwrap();
6131        assert!(
6132            first.chars().next().unwrap().is_uppercase(),
6133            "expected capitalized output, got: {first}"
6134        );
6135    }
6136
6137    #[test]
6138    fn syn_pipe_deterministic_tie_break_first_registered_wins() {
6139        let mut engine = test_engine();
6140        engine.register_synonyms(&["alpha", "beta", "gamma"]);
6141        engine.register_template("t", "{word|syn}").unwrap();
6142
6143        let mut session = test_session();
6144        // First render, no history → all tied at frequency 0. The
6145        // first-registered entry wins.
6146        let mut ctx = Context::new();
6147        ctx.insert("word", Value::String("alpha".into()));
6148        assert_eq!(engine.render(&mut session, "t", &ctx).unwrap(), "alpha");
6149    }
6150
6151    // ── Clause aggregation / conjunction reduction ───────────────────────
6152
6153    #[test]
6154    fn reduce_merges_three_simple_same_entity_passives() {
6155        let reduced = reduce_same_entity_clauses(&[
6156            "The class UserService was renamed to AccountService.".to_string(),
6157            "It was modified.".to_string(),
6158            "It was moved from src/ to lib/.".to_string(),
6159        ]);
6160        assert_eq!(
6161            reduced.as_deref(),
6162            Some(
6163                "The class UserService was renamed to AccountService, \
6164                 modified, and moved from src/ to lib/."
6165            )
6166        );
6167    }
6168
6169    #[test]
6170    fn reduce_two_clauses_uses_and_without_oxford_comma() {
6171        let reduced = reduce_same_entity_clauses(&[
6172            "The class Foo was renamed.".to_string(),
6173            "It was modified.".to_string(),
6174        ]);
6175        assert_eq!(
6176            reduced.as_deref(),
6177            Some("The class Foo was renamed and modified.")
6178        );
6179    }
6180
6181    #[test]
6182    fn reduce_rejects_mixed_auxiliaries() {
6183        // First sentence uses "was" (simple past passive), second uses
6184        // "has been" (present perfect passive). Merging would produce an
6185        // ungrammatical "was renamed and been modified".
6186        let reduced = reduce_same_entity_clauses(&[
6187            "The class Foo was renamed.".to_string(),
6188            "It has been modified.".to_string(),
6189        ]);
6190        assert!(reduced.is_none());
6191    }
6192
6193    #[test]
6194    fn reduce_rejects_embedded_which_clauses() {
6195        // An embedded subordinate clause would absorb the following
6196        // predicate into its own scope if we fused.
6197        let reduced = reduce_same_entity_clauses(&[
6198            "The class Foo was renamed, which impacts 6 consumers.".to_string(),
6199            "It was modified.".to_string(),
6200        ]);
6201        assert!(reduced.is_none());
6202    }
6203
6204    #[test]
6205    fn reduce_strips_connectives_and_merges() {
6206        // When the engine's discourse system has prepended "Additionally,"
6207        // / "Similarly," / etc. to a follow-up same-entity render, the
6208        // reducer strips those connectives — the final conjunction
6209        // ("and") linking the predicates subsumes their linking role.
6210        let reduced = reduce_same_entity_clauses(&[
6211            "The class Foo was renamed.".to_string(),
6212            "Additionally, it was modified.".to_string(),
6213            "Furthermore, it was moved.".to_string(),
6214        ]);
6215        assert_eq!(
6216            reduced.as_deref(),
6217            Some("The class Foo was renamed, modified, and moved.")
6218        );
6219    }
6220
6221    #[test]
6222    fn reduce_rejects_single_sentence() {
6223        let reduced = reduce_same_entity_clauses(&["The class Foo was renamed.".to_string()]);
6224        assert!(reduced.is_none());
6225    }
6226
6227    #[test]
6228    fn reduce_accepts_full_np_repetition_same_entity() {
6229        // Full-NP repetition is now accepted by FCR Phase 2: the follower
6230        // repeats the head's subject+aux prefix verbatim (e.g. after a
6231        // session reset that demoted the pronoun). The two predicates fuse.
6232        let reduced = reduce_same_entity_clauses(&[
6233            "The class Foo was renamed.".to_string(),
6234            "The class Foo was modified.".to_string(),
6235        ]);
6236        assert_eq!(
6237            reduced.as_deref(),
6238            Some("The class Foo was renamed and modified.")
6239        );
6240    }
6241
6242    #[test]
6243    fn reduce_handles_has_been_perfect_passive() {
6244        let reduced = reduce_same_entity_clauses(&[
6245            "The class Foo has been renamed.".to_string(),
6246            "It has been modified.".to_string(),
6247            "It has been moved.".to_string(),
6248        ]);
6249        assert_eq!(
6250            reduced.as_deref(),
6251            Some("The class Foo has been renamed, modified, and moved.")
6252        );
6253    }
6254
6255    // ── FCR: "It also" connective handling (Phase 1) ────────────────────
6256
6257    #[test]
6258    fn reduce_accepts_it_also_connective() {
6259        // "It also was modified" should strip to "It was modified" so the
6260        // pronoun+aux matcher accepts it — FCR Phase 1.
6261        let reduced = reduce_same_entity_clauses(&[
6262            "The class UserService was renamed.".to_string(),
6263            "It also was modified.".to_string(),
6264        ]);
6265        assert_eq!(
6266            reduced.as_deref(),
6267            Some("The class UserService was renamed and modified.")
6268        );
6269    }
6270
6271    #[test]
6272    fn prepend_replacing_subject_single_word_name_strips_np() {
6273        // With a single-token name the "The <type> <name> " prefix is
6274        // unambiguous and the connective replaces it.
6275        let mut out = String::from("The class Foo was modified");
6276        prepend_replacing_subject_in_place(&mut out, "It also", Some("Foo"));
6277        assert_eq!(out, "It also was modified");
6278    }
6279
6280    #[test]
6281    fn prepend_replacing_subject_multiword_name_falls_back() {
6282        // With a multi-word name the NP boundary is ambiguous from the
6283        // rendered string alone; we must NOT chop mid-name. Fall back to
6284        // the lowercased-first-char + comma-style prepending path.
6285        let mut out = String::from("The feature Login flow was modified");
6286        prepend_replacing_subject_in_place(&mut out, "It also", Some("Login flow"));
6287        // Fallback lowercases leading letter then prepends connective + space.
6288        assert_eq!(out, "It also the feature Login flow was modified");
6289        assert!(
6290            !out.contains("flow was modified") || out.starts_with("It also the feature"),
6291            "must not chop 'Login' off the subject; got: {out}"
6292        );
6293    }
6294
6295    #[test]
6296    fn prepend_replacing_subject_unknown_name_falls_back() {
6297        // No entity name supplied → conservative fallback, not an NP strip.
6298        let mut out = String::from("The class Foo was modified");
6299        prepend_replacing_subject_in_place(&mut out, "It also", None);
6300        assert_eq!(out, "It also the class Foo was modified");
6301    }
6302
6303    #[test]
6304    fn render_sequence_with_multiword_name_produces_valid_prose() {
6305        // End-to-end: two renders on a multi-word-named entity must not
6306        // produce a corrupted follower sentence via the "It also" path.
6307        let mut engine = test_engine();
6308        engine
6309            .register_template("renamed", "{name|refer} was renamed")
6310            .unwrap();
6311        engine
6312            .register_template("modified", "{name|refer} was modified")
6313            .unwrap();
6314
6315        let mut session = test_session();
6316        let mut ctx = Context::new();
6317        ctx.insert("entity_type", Value::String("feature".into()));
6318        ctx.insert("name", Value::String("Login flow".into()));
6319
6320        let r1 = engine.render(&mut session, "renamed", &ctx).unwrap();
6321        assert!(r1.contains("Login flow"), "got: {r1}");
6322        let r2 = engine.render(&mut session, "modified", &ctx).unwrap();
6323        // Must not produce "flow was modified" (mid-name chop).
6324        assert!(
6325            !r2.starts_with("flow ") && !r2.contains("also flow "),
6326            "follow-up render corrupted multi-word name; got: {r2}"
6327        );
6328    }
6329
6330    #[test]
6331    fn reduce_accepts_mixed_discourse_connectives() {
6332        // "Additionally," on one sentence, "It also" on the next — both
6333        // must be stripped before the pronoun+aux match fires.
6334        let reduced = reduce_same_entity_clauses(&[
6335            "The class Foo was renamed.".to_string(),
6336            "Additionally, it was modified.".to_string(),
6337            "It also was moved.".to_string(),
6338        ]);
6339        assert_eq!(
6340            reduced.as_deref(),
6341            Some("The class Foo was renamed, modified, and moved.")
6342        );
6343    }
6344
6345    // ── FCR: full-NP repetition (Phase 2) ───────────────────────────────
6346
6347    #[test]
6348    fn reduce_accepts_full_np_repetition() {
6349        let reduced = reduce_same_entity_clauses(&[
6350            "The class Foo was renamed.".to_string(),
6351            "The class Foo was modified.".to_string(),
6352        ]);
6353        assert_eq!(
6354            reduced.as_deref(),
6355            Some("The class Foo was renamed and modified.")
6356        );
6357    }
6358
6359    #[test]
6360    fn reduce_accepts_full_np_repetition_three_clauses() {
6361        let reduced = reduce_same_entity_clauses(&[
6362            "The class Foo was renamed.".to_string(),
6363            "The class Foo was modified.".to_string(),
6364            "The class Foo was moved.".to_string(),
6365        ]);
6366        assert_eq!(
6367            reduced.as_deref(),
6368            Some("The class Foo was renamed, modified, and moved.")
6369        );
6370    }
6371
6372    #[test]
6373    fn reduce_mixed_np_and_pronoun_accepted() {
6374        // Head full NP, follower 1 pronoun, follower 2 full NP — all reduce.
6375        let reduced = reduce_same_entity_clauses(&[
6376            "The class Foo was renamed.".to_string(),
6377            "It was modified.".to_string(),
6378            "The class Foo was moved.".to_string(),
6379        ]);
6380        assert_eq!(
6381            reduced.as_deref(),
6382            Some("The class Foo was renamed, modified, and moved.")
6383        );
6384    }
6385
6386    #[test]
6387    fn reduce_rejects_different_np_repetition() {
6388        // Head is "The class Foo was renamed", follower is "The class Bar was modified" —
6389        // different NPs; the subject+aux prefix doesn't match, so no fusion.
6390        let reduced = reduce_same_entity_clauses(&[
6391            "The class Foo was renamed.".to_string(),
6392            "The class Bar was modified.".to_string(),
6393        ]);
6394        assert_eq!(reduced, None);
6395    }
6396
6397    #[test]
6398    fn reduce_rejects_full_np_with_embedded_clause() {
6399        // The predicate_has_embedded_clause safety check must still fire
6400        // on the full-NP fallback path — FCR Phase 2.
6401        let reduced = reduce_same_entity_clauses(&[
6402            "The class Foo was renamed.".to_string(),
6403            "The class Foo was modified, which affects 6 consumers.".to_string(),
6404        ]);
6405        assert_eq!(reduced, None);
6406    }
6407
6408    // ── Gapping (ELLEIPO) unit tests ─────────────────────────────────────
6409
6410    #[test]
6411    fn reduce_gapping_two_events() {
6412        let ss = vec![
6413            "Foo was moved to core".to_string(),
6414            "Bar was moved to util".to_string(),
6415        ];
6416        let out = reduce_gapping(&ss).unwrap();
6417        assert_eq!(out, "Foo was moved to core, and Bar to util.");
6418    }
6419
6420    #[test]
6421    fn reduce_gapping_three_events() {
6422        let ss = vec![
6423            "Foo was moved to core".to_string(),
6424            "Bar was moved to util".to_string(),
6425            "Baz was moved to api".to_string(),
6426        ];
6427        let out = reduce_gapping(&ss).unwrap();
6428        assert_eq!(out, "Foo was moved to core, Bar to util, and Baz to api.");
6429    }
6430
6431    #[test]
6432    fn reduce_gapping_rejects_single() {
6433        let ss = vec!["Foo was moved to core".to_string()];
6434        assert!(reduce_gapping(&ss).is_none());
6435    }
6436
6437    #[test]
6438    fn reduce_gapping_rejects_short_anchor() {
6439        // Anchor = ["was"] — length 1, below the 2-token threshold.
6440        let ss = vec!["Foo was moved".to_string(), "Bar was modified".to_string()];
6441        assert!(reduce_gapping(&ss).is_none());
6442    }
6443
6444    #[test]
6445    fn reduce_gapping_rejects_embedded_clause() {
6446        let ss = vec![
6447            "Foo was moved, affecting 3 consumers, to core".to_string(),
6448            "Bar was moved to util".to_string(),
6449        ];
6450        assert!(reduce_gapping(&ss).is_none());
6451    }
6452
6453    #[test]
6454    fn reduce_gapping_rejects_identical_subjects() {
6455        // Identical subjects → no gapping opportunity.
6456        let ss = vec![
6457            "Foo was moved to core".to_string(),
6458            "Foo was moved to core".to_string(),
6459        ];
6460        assert!(reduce_gapping(&ss).is_none());
6461    }
6462
6463    #[test]
6464    fn reduce_gapping_rejects_empty_suffix() {
6465        // Anchor consumes everything; no divergent tail.
6466        let ss = vec!["Foo was moved".to_string(), "Bar was moved".to_string()];
6467        assert!(reduce_gapping(&ss).is_none());
6468    }
6469
6470    // ── Gapping integration tests (render_batch / render_iter) ──────────
6471
6472    #[test]
6473    fn render_batch_applies_gapping_when_objects_differ() {
6474        let mut engine = test_engine();
6475        engine
6476            .register_template("code.moved", "{name} was moved to {new_location}")
6477            .unwrap();
6478
6479        let make = |name: &str, loc: &str| {
6480            let mut c = Context::new();
6481            c.insert("entity_type", Value::String("class".into()));
6482            c.insert("name", Value::String(name.into()));
6483            c.insert("new_location", Value::String(loc.into()));
6484            c
6485        };
6486
6487        let events = vec![
6488            ("code.moved", make("Foo", "core")),
6489            ("code.moved", make("Bar", "util")),
6490            ("code.moved", make("Baz", "api")),
6491        ];
6492
6493        let mut s = Session::new();
6494        let out = engine.render_batch(&mut s, &events).unwrap();
6495        assert_eq!(out, "Foo was moved to core, Bar to util, and Baz to api.");
6496    }
6497
6498    #[test]
6499    fn render_batch_gapping_does_not_apply_when_objects_match() {
6500        // Same template + same non-subject slots → subject aggregation wins,
6501        // not gapping. Verifies the aggregated-subjects path is not regressed.
6502        let mut engine = test_engine();
6503        engine
6504            .register_template("code.moved", "{name} was moved to {new_location}")
6505            .unwrap();
6506
6507        let make = |name: &str| {
6508            let mut c = Context::new();
6509            c.insert("entity_type", Value::String("class".into()));
6510            c.insert("name", Value::String(name.into()));
6511            c.insert("new_location", Value::String("core".into()));
6512            c
6513        };
6514
6515        let events = vec![("code.moved", make("Foo")), ("code.moved", make("Bar"))];
6516
6517        let mut s = Session::new();
6518        let out = engine.render_batch(&mut s, &events).unwrap();
6519        // Subject aggregation path: "Foo and Bar were moved to core".
6520        assert!(
6521            out.contains("Foo and Bar") && out.contains("core"),
6522            "got: {out}"
6523        );
6524        // NOT gapping-style output.
6525        assert!(!out.contains(", and Bar to "), "got: {out}");
6526    }
6527
6528    #[test]
6529    fn render_iter_applies_gapping() {
6530        let mut engine = test_engine();
6531        engine
6532            .register_template("code.moved", "{name} was moved to {new_location}")
6533            .unwrap();
6534
6535        let make = |name: &str, loc: &str| {
6536            let mut c = Context::new();
6537            c.insert("entity_type", Value::String("class".into()));
6538            c.insert("name", Value::String(name.into()));
6539            c.insert("new_location", Value::String(loc.into()));
6540            c
6541        };
6542
6543        let events = vec![
6544            ("code.moved", make("Foo", "core")),
6545            ("code.moved", make("Bar", "util")),
6546            ("code.moved", make("Baz", "api")),
6547        ];
6548
6549        let mut s = Session::new();
6550        let collected: Result<Vec<_>, _> = engine.render_iter(&mut s, &events).collect();
6551        let collected = collected.unwrap();
6552        // One sentence emitted for the gapped run.
6553        assert_eq!(collected.len(), 1);
6554        assert_eq!(
6555            collected[0],
6556            "Foo was moved to core, Bar to util, and Baz to api."
6557        );
6558    }
6559
6560    // ── Silent-mode cleanup ─────────────────────────────────────────────
6561
6562    #[test]
6563    fn silent_strips_trailing_dangling_preposition() {
6564        let mut engine = test_engine().strictness(Strictness::Silent);
6565        engine
6566            .register_template("t", "The file was modified by {author}")
6567            .unwrap();
6568        let mut session = test_session();
6569        let ctx = Context::new();
6570        assert_eq!(
6571            engine.render(&mut session, "t", &ctx).unwrap(),
6572            "The file was modified."
6573        );
6574    }
6575
6576    #[test]
6577    fn silent_strips_dangling_preposition_with_punct() {
6578        let mut engine = test_engine().strictness(Strictness::Silent);
6579        engine
6580            .register_template("t", "The class was renamed to {new_name}.")
6581            .unwrap();
6582        let mut session = test_session();
6583        let ctx = Context::new();
6584        assert_eq!(
6585            engine.render(&mut session, "t", &ctx).unwrap(),
6586            "The class was renamed."
6587        );
6588    }
6589
6590    #[test]
6591    fn silent_strips_orphan_conjunction() {
6592        let mut engine = test_engine().strictness(Strictness::Silent);
6593        engine
6594            .register_template("t", "The module exports {a} and {b}")
6595            .unwrap();
6596        let mut session = test_session();
6597        let ctx = Context::new();
6598        assert_eq!(
6599            engine.render(&mut session, "t", &ctx).unwrap(),
6600            "The module exports."
6601        );
6602    }
6603
6604    #[test]
6605    fn silent_strips_chained_orphans() {
6606        let mut engine = test_engine().strictness(Strictness::Silent);
6607        engine
6608            .register_template("t", "The job was scheduled by {a} at {b}")
6609            .unwrap();
6610        let mut session = test_session();
6611        let ctx = Context::new();
6612        // Strips "at" then "by" in sequence.
6613        assert_eq!(
6614            engine.render(&mut session, "t", &ctx).unwrap(),
6615            "The job was scheduled."
6616        );
6617    }
6618
6619    #[test]
6620    fn silent_preserves_content_when_orphans_would_empty_output() {
6621        let mut engine = test_engine().strictness(Strictness::Silent);
6622        // A template that's only a preposition + slot — nothing to keep.
6623        engine.register_template("t", "by {author}").unwrap();
6624        let mut session = test_session();
6625        let ctx = Context::new();
6626        // We refuse to empty the output; the orphan stays.
6627        assert_eq!(engine.render(&mut session, "t", &ctx).unwrap(), "by");
6628    }
6629
6630    #[test]
6631    fn whitespace_collapsing_runs_regardless_of_strictness() {
6632        // Even in Strict mode, internal whitespace runs get normalized
6633        // (a safe transformation that doesn't depend on missing slots).
6634        let mut engine = test_engine();
6635        engine
6636            .register_template("t", "The  quick   brown fox")
6637            .unwrap();
6638        let mut session = test_session();
6639        let ctx = Context::new();
6640        assert_eq!(
6641            engine.render(&mut session, "t", &ctx).unwrap(),
6642            "The quick brown fox."
6643        );
6644    }
6645
6646    #[test]
6647    fn strict_mode_unaffected_by_cleanup_tail_stripping() {
6648        // In Strict mode, a missing slot is an error, not an artifact,
6649        // so the dangling-preposition strip never runs.
6650        let mut engine = test_engine();
6651        engine
6652            .register_template("t", "modified by {author}")
6653            .unwrap();
6654        let mut session = test_session();
6655        let ctx = Context::new();
6656        assert!(engine.render(&mut session, "t", &ctx).is_err());
6657    }
6658
6659    // ── Referring Expression Generation (Dale & Reiter) ─────────────────
6660
6661    #[test]
6662    fn reg_with_no_registry_behaves_as_before() {
6663        let mut engine = test_engine();
6664        engine
6665            .register_template("t", "{name|refer} was modified")
6666            .unwrap();
6667
6668        let mut session = test_session();
6669        let mut ctx = Context::new();
6670        ctx.insert("entity_type", Value::String("class".into()));
6671        ctx.insert("name", Value::String("UserService".into()));
6672
6673        let result = engine.render(&mut session, "t", &ctx).unwrap();
6674        assert_eq!(result, "The class UserService was modified.");
6675    }
6676
6677    #[test]
6678    fn reg_adds_distinguisher_when_same_type_registered() {
6679        let mut engine = test_engine();
6680        engine.register_entity(
6681            crate::EntityDescriptor::new("UserService", "class").with_attribute("layer", "domain"),
6682        );
6683        engine.register_entity(
6684            crate::EntityDescriptor::new("AuthService", "class").with_attribute("layer", "infra"),
6685        );
6686        engine
6687            .register_template("t", "{name|refer} was modified")
6688            .unwrap();
6689
6690        let mut session = test_session();
6691        let mut ctx = Context::new();
6692        ctx.insert("entity_type", Value::String("class".into()));
6693        ctx.insert("name", Value::String("UserService".into()));
6694
6695        let result = engine.render(&mut session, "t", &ctx).unwrap();
6696        assert_eq!(result, "The domain class UserService was modified.");
6697    }
6698
6699    #[test]
6700    fn reg_no_distinguisher_needed_when_types_differ() {
6701        let mut engine = test_engine();
6702        engine.register_entity(
6703            crate::EntityDescriptor::new("UserService", "class").with_attribute("layer", "domain"),
6704        );
6705        engine.register_entity(
6706            crate::EntityDescriptor::new("UserModule", "module").with_attribute("layer", "infra"),
6707        );
6708        engine
6709            .register_template("t", "{name|refer} was modified")
6710            .unwrap();
6711
6712        let mut session = test_session();
6713        let mut ctx = Context::new();
6714        ctx.insert("entity_type", Value::String("class".into()));
6715        ctx.insert("name", Value::String("UserService".into()));
6716
6717        let result = engine.render(&mut session, "t", &ctx).unwrap();
6718        // Different types → head noun alone disambiguates, no attribute added.
6719        assert_eq!(result, "The class UserService was modified.");
6720    }
6721
6722    #[test]
6723    fn reg_preference_order_steers_attribute_choice() {
6724        let mut engine = test_engine().attribute_preference(vec!["size".to_string()]);
6725        engine.register_entity(
6726            crate::EntityDescriptor::new("Foo", "widget")
6727                .with_attribute("color", "red")
6728                .with_attribute("size", "small"),
6729        );
6730        engine.register_entity(
6731            crate::EntityDescriptor::new("Bar", "widget")
6732                .with_attribute("color", "blue")
6733                .with_attribute("size", "large"),
6734        );
6735        engine
6736            .register_template("t", "{name|refer} appeared")
6737            .unwrap();
6738
6739        let mut session = test_session();
6740        let mut ctx = Context::new();
6741        ctx.insert("entity_type", Value::String("widget".into()));
6742        ctx.insert("name", Value::String("Foo".into()));
6743
6744        // Preference says size first; size alone disambiguates.
6745        let result = engine.render(&mut session, "t", &ctx).unwrap();
6746        assert_eq!(result, "The small widget Foo appeared.");
6747    }
6748
6749    /// Regression: two entities sharing a name but not a type must stay
6750    /// independent in the registry. Rendering one must not substitute the
6751    /// other's type.
6752    #[test]
6753    fn reg_same_name_different_type_does_not_cross_contaminate() {
6754        let mut engine = test_engine();
6755        engine.register_entity(
6756            crate::EntityDescriptor::new("UserService", "class").with_attribute("layer", "domain"),
6757        );
6758        engine.register_entity(
6759            crate::EntityDescriptor::new("UserService", "trait").with_attribute("scope", "public"),
6760        );
6761
6762        engine
6763            .register_template("t", "{name|refer} was modified")
6764            .unwrap();
6765
6766        let mut session = test_session();
6767        let mut ctx_class = Context::new();
6768        ctx_class.insert("entity_type", Value::String("class".into()));
6769        ctx_class.insert("name", Value::String("UserService".into()));
6770
6771        // Context says class → must render as class, never as trait.
6772        let r = engine.render(&mut session, "t", &ctx_class).unwrap();
6773        assert!(r.contains("class UserService"), "got: {r}");
6774        assert!(!r.contains("trait"), "got: {r}");
6775        // With only one class-typed UserService registered (the trait is
6776        // a different type), no distinguishing attribute is needed.
6777        assert_eq!(r, "The class UserService was modified.");
6778
6779        let mut session2 = test_session();
6780        let mut ctx_trait = Context::new();
6781        ctx_trait.insert("entity_type", Value::String("trait".into()));
6782        ctx_trait.insert("name", Value::String("UserService".into()));
6783        let r2 = engine.render(&mut session2, "t", &ctx_trait).unwrap();
6784        assert_eq!(r2, "The trait UserService was modified.");
6785    }
6786
6787    #[test]
6788    fn reg_multiple_attributes_needed() {
6789        let mut engine = test_engine();
6790        engine.register_entity(
6791            crate::EntityDescriptor::new("A", "widget")
6792                .with_attribute("color", "red")
6793                .with_attribute("size", "small"),
6794        );
6795        engine.register_entity(
6796            crate::EntityDescriptor::new("B", "widget")
6797                .with_attribute("color", "red")
6798                .with_attribute("size", "large"),
6799        );
6800        engine.register_entity(
6801            crate::EntityDescriptor::new("C", "widget")
6802                .with_attribute("color", "blue")
6803                .with_attribute("size", "small"),
6804        );
6805        engine = engine.attribute_preference(vec!["color".to_string(), "size".to_string()]);
6806        engine
6807            .register_template("t", "{name|refer} appeared")
6808            .unwrap();
6809
6810        let mut session = test_session();
6811        let mut ctx = Context::new();
6812        ctx.insert("entity_type", Value::String("widget".into()));
6813        ctx.insert("name", Value::String("A".into()));
6814
6815        // color rules out C; size then rules out B.
6816        let result = engine.render(&mut session, "t", &ctx).unwrap();
6817        assert_eq!(result, "The red small widget A appeared.");
6818    }
6819
6820    #[test]
6821    fn refer_no_entity_type_falls_back_to_name() {
6822        let mut engine = test_engine();
6823        engine
6824            .register_template("t", "{name|refer} appeared")
6825            .unwrap();
6826
6827        let mut session = test_session();
6828        let mut ctx = Context::new();
6829        // No entity_type provided
6830        ctx.insert("name", Value::String("something".into()));
6831
6832        let result = engine.render(&mut session, "t", &ctx).unwrap();
6833        // Falls back to just the name, with sentence-start capitalization
6834        // Note: "Something appeared" is 2 words so no period is added
6835        assert_eq!(result, "Something appeared");
6836    }
6837
6838    // ── Regression tests for codex review findings ───────────────────────
6839
6840    /// Failed renders must not leave traces in discourse state.
6841    #[test]
6842    fn failed_render_does_not_mutate_discourse() {
6843        let mut engine = test_engine();
6844        engine
6845            .register_template("ok", "{name|refer} was updated")
6846            .unwrap();
6847        engine
6848            .register_template("bad", "{missing_slot} fails here")
6849            .unwrap();
6850
6851        let mut session = test_session();
6852        let mut ctx = Context::new();
6853        ctx.insert("entity_type", Value::String("class".into()));
6854        ctx.insert("name", Value::String("Foo".into()));
6855
6856        // Successful render — Foo is now known, render index is 1.
6857        let r1 = engine.render(&mut session, "ok", &ctx).unwrap();
6858        assert!(r1.contains("class Foo"), "r1 = {r1}");
6859
6860        // Attempt a failing render. The discourse state must NOT advance.
6861        let bad_ctx = Context::new();
6862        assert!(engine.render(&mut session, "bad", &bad_ctx).is_err());
6863
6864        // Next successful render should behave as if the failure never
6865        // happened: Foo is still the focus entity at distance 1, so
6866        // the pronoun form fires.
6867        let r2 = engine.render(&mut session, "ok", &ctx).unwrap();
6868        assert!(
6869            r2.contains("it") || r2.contains("It"),
6870            "Expected pronoun reference after failed render was rolled back, got: {r2}"
6871        );
6872    }
6873
6874    /// Regression: a failed render under RoundRobin must not advance the
6875    /// rotation counter. The next successful render must pick up exactly
6876    /// where the last successful one left off.
6877    #[test]
6878    fn round_robin_counter_is_transactional_on_failure() {
6879        let mut engine = test_engine().variation(Variation::RoundRobin);
6880        engine.register_template("ok", "alpha {name}").unwrap();
6881        engine.register_template("ok", "beta {name}").unwrap();
6882        engine.register_template("ok", "gamma {name}").unwrap();
6883
6884        let mut session = test_session();
6885        let mut ctx = Context::new();
6886        ctx.insert("name", Value::String("x".into()));
6887        let empty = Context::new();
6888
6889        // First successful render: alpha
6890        assert!(
6891            engine
6892                .render(&mut session, "ok", &ctx)
6893                .unwrap()
6894                .contains("alpha")
6895        );
6896
6897        // A failed render between the two should NOT advance the counter
6898        // for "ok" — the missing slot aborts before commit.
6899        assert!(engine.render(&mut session, "ok", &empty).is_err());
6900
6901        // Next successful render must be beta, not gamma.
6902        assert!(
6903            engine
6904                .render(&mut session, "ok", &ctx)
6905                .unwrap()
6906                .contains("beta")
6907        );
6908    }
6909
6910    /// RoundRobin must rotate through every alternative in order.
6911    #[test]
6912    fn round_robin_actually_rotates() {
6913        let mut engine = test_engine().variation(Variation::RoundRobin);
6914        engine.register_template("t", "alpha").unwrap();
6915        engine.register_template("t", "beta").unwrap();
6916        engine.register_template("t", "gamma").unwrap();
6917
6918        let mut session = test_session();
6919        let ctx = Context::new();
6920        let r1 = engine.render(&mut session, "t", &ctx).unwrap();
6921        let r2 = engine.render(&mut session, "t", &ctx).unwrap();
6922        let r3 = engine.render(&mut session, "t", &ctx).unwrap();
6923        let r4 = engine.render(&mut session, "t", &ctx).unwrap();
6924
6925        // First three should be the three alternatives, in order.
6926        assert!(r1.starts_with("alpha"), "r1 = {r1}");
6927        // Second and third may pick up connectives; check the template body.
6928        assert!(r2.contains("beta"), "r2 = {r2}");
6929        assert!(r3.contains("gamma"), "r3 = {r3}");
6930        // Fourth wraps back to alpha.
6931        assert!(r4.contains("alpha"), "r4 = {r4}");
6932    }
6933
6934    /// Variation::Fixed must always emit the first-registered template body,
6935    /// even after discourse history has accumulated.
6936    #[test]
6937    fn fixed_variation_stays_fixed_across_renders() {
6938        let mut engine = test_engine().variation(Variation::Fixed);
6939        engine.register_template("t", "alpha body here").unwrap();
6940        engine.register_template("t", "beta body here").unwrap();
6941
6942        let mut session = test_session();
6943        let ctx = Context::new();
6944        for _ in 0..5 {
6945            let rendered = engine.render(&mut session, "t", &ctx).unwrap();
6946            assert!(
6947                rendered.contains("alpha body here"),
6948                "Fixed should always pick the first-registered template, got: {rendered}"
6949            );
6950            assert!(
6951                !rendered.contains("beta"),
6952                "Fixed must never emit a later-registered alternative, got: {rendered}"
6953            );
6954        }
6955    }
6956
6957    // ── Verb pipe tests ──────────────────────────────────────────────────
6958
6959    #[test]
6960    fn verb_pipe_simple_past_passive() {
6961        let mut engine = test_engine();
6962        engine.register_template("t", "{action|verb:past}").unwrap();
6963
6964        let mut session = test_session();
6965        let mut ctx = Context::new();
6966        ctx.insert("action", Value::String("rename".into()));
6967        assert_eq!(
6968            engine.render(&mut session, "t", &ctx).unwrap(),
6969            "was renameed"
6970        );
6971    }
6972
6973    #[test]
6974    fn verb_pipe_present_perfect_passive() {
6975        let mut engine = test_engine();
6976        engine
6977            .register_template("t", "{action|verb:present_perfect}")
6978            .unwrap();
6979
6980        let mut session = test_session();
6981        let mut ctx = Context::new();
6982        ctx.insert("action", Value::String("rename".into()));
6983        assert_eq!(
6984            engine.render(&mut session, "t", &ctx).unwrap(),
6985            "has been renameed"
6986        );
6987    }
6988
6989    #[test]
6990    fn verb_pipe_present_progressive_passive() {
6991        let mut engine = test_engine();
6992        engine
6993            .register_template("t", "{action|verb:present_progressive}")
6994            .unwrap();
6995
6996        let mut session = test_session();
6997        let mut ctx = Context::new();
6998        ctx.insert("action", Value::String("rename".into()));
6999        assert_eq!(
7000            engine.render(&mut session, "t", &ctx).unwrap(),
7001            "is being renameed"
7002        );
7003    }
7004
7005    #[test]
7006    fn verb_pipe_active_voice_prefix() {
7007        let mut engine = test_engine();
7008        engine
7009            .register_template("t", "{action|verb:active_present_perfect}")
7010            .unwrap();
7011
7012        let mut session = test_session();
7013        let mut ctx = Context::new();
7014        ctx.insert("action", Value::String("rename".into()));
7015        assert_eq!(
7016            engine.render(&mut session, "t", &ctx).unwrap(),
7017            "has renameed"
7018        );
7019    }
7020
7021    #[test]
7022    fn verb_pipe_conditional() {
7023        let mut engine = test_engine();
7024        engine
7025            .register_template("t", "{action|verb:conditional}")
7026            .unwrap();
7027
7028        let mut session = test_session();
7029        let mut ctx = Context::new();
7030        ctx.insert("action", Value::String("rename".into()));
7031        assert_eq!(
7032            engine.render(&mut session, "t", &ctx).unwrap(),
7033            "would be renameed"
7034        );
7035    }
7036
7037    #[test]
7038    fn verb_pipe_unknown_spec_is_error() {
7039        let mut engine = test_engine();
7040        engine
7041            .register_template("t", "{action|verb:bogus_form}")
7042            .unwrap();
7043
7044        let mut session = test_session();
7045        let mut ctx = Context::new();
7046        ctx.insert("action", Value::String("rename".into()));
7047        let result = engine.render(&mut session, "t", &ctx);
7048        assert!(matches!(result, Err(ProsaicError::InvalidPipe { .. })));
7049    }
7050
7051    #[test]
7052    fn verb_pipe_missing_spec_is_error() {
7053        let mut engine = test_engine();
7054        engine.register_template("t", "{action|verb}").unwrap();
7055
7056        let mut session = test_session();
7057        let mut ctx = Context::new();
7058        ctx.insert("action", Value::String("rename".into()));
7059        let result = engine.render(&mut session, "t", &ctx);
7060        assert!(matches!(result, Err(ProsaicError::InvalidPipe { .. })));
7061    }
7062
7063    /// Choose-best scoring must not advance list-style state via candidate
7064    /// rendering — only the emitted render counts.
7065    #[test]
7066    fn candidate_scoring_does_not_advance_list_style() {
7067        // Seeded variation triggers choose-best on render 2.
7068        let mut engine = test_engine().variation(Variation::Seeded(1));
7069        // Two alternatives both consume a list style each; if candidate
7070        // rendering mutates state, the cycle is wrong.
7071        engine
7072            .register_template("t", "alpha uses {items|truncate:1|join}")
7073            .unwrap();
7074        engine
7075            .register_template("t", "beta uses {items|truncate:1|join}")
7076            .unwrap();
7077
7078        let mut session = test_session();
7079        let mut ctx = Context::new();
7080        ctx.insert(
7081            "items",
7082            Value::List(vec!["a".into(), "b".into(), "c".into()]),
7083        );
7084
7085        let r1 = engine.render(&mut session, "t", &ctx).unwrap();
7086        let r2 = engine.render(&mut session, "t", &ctx).unwrap();
7087        let r3 = engine.render(&mut session, "t", &ctx).unwrap();
7088
7089        // Three renders should show three consecutive list styles.
7090        // If candidate scoring leaked state, we'd see the cycle skip ahead
7091        // (e.g., render 2's candidate would consume a style, pushing render 3
7092        // onto the 4th style instead of the 3rd).
7093        let styles: std::collections::HashSet<&str> = [r1.as_str(), r2.as_str(), r3.as_str()]
7094            .into_iter()
7095            .collect();
7096        assert_eq!(
7097            styles.len(),
7098            3,
7099            "Expected three distinct list styles across three renders, got: {r1} / {r2} / {r3}"
7100        );
7101    }
7102
7103    // ── choose pipe ──────────────────────────────────────────────────────────
7104
7105    #[test]
7106    fn choose_pipe_exact_match() {
7107        let engine = test_engine();
7108        let mut session = test_session();
7109        let mut ctx = Context::new();
7110        ctx.insert("level", Value::String("critical".into()));
7111        let out = engine
7112            .render_inline(
7113                &mut session,
7114                "{level|choose: critical=URGENT, warn=WARN, default=INFO}",
7115                &ctx,
7116            )
7117            .unwrap();
7118        assert_eq!(out, "URGENT");
7119    }
7120
7121    #[test]
7122    fn choose_pipe_case_insensitive_match() {
7123        let engine = test_engine();
7124        let mut session = test_session();
7125        let mut ctx = Context::new();
7126        ctx.insert("level", Value::String("CRITICAL".into()));
7127        let out = engine
7128            .render_inline(
7129                &mut session,
7130                "{level|choose: critical=URGENT, default=INFO}",
7131                &ctx,
7132            )
7133            .unwrap();
7134        assert_eq!(out, "URGENT");
7135    }
7136
7137    #[test]
7138    fn choose_pipe_default_fallback() {
7139        let engine = test_engine();
7140        let mut session = test_session();
7141        let mut ctx = Context::new();
7142        ctx.insert("level", Value::String("info".into()));
7143        let out = engine
7144            .render_inline(
7145                &mut session,
7146                "{level|choose: critical=URGENT, default=INFO}",
7147                &ctx,
7148            )
7149            .unwrap();
7150        assert_eq!(out, "INFO");
7151    }
7152
7153    #[test]
7154    fn choose_pipe_number_slot() {
7155        let engine = test_engine();
7156        let mut session = test_session();
7157        let mut ctx = Context::new();
7158        ctx.insert("count", Value::Number(1));
7159        let out = engine
7160            .render_inline(&mut session, "{count|choose: 1=is, default=are}", &ctx)
7161            .unwrap();
7162        assert_eq!(out, "is");
7163
7164        let mut session2 = test_session();
7165        let mut ctx2 = Context::new();
7166        ctx2.insert("count", Value::Number(5));
7167        let out2 = engine
7168            .render_inline(&mut session2, "{count|choose: 1=is, default=are}", &ctx2)
7169            .unwrap();
7170        assert_eq!(out2, "are");
7171    }
7172
7173    #[test]
7174    fn choose_pipe_chains_with_other_pipes() {
7175        let engine = test_engine();
7176        let mut session = test_session();
7177        let mut ctx = Context::new();
7178        ctx.insert("action", Value::String("modify".into()));
7179        let out = engine
7180            .render_inline(
7181                &mut session,
7182                "{action|choose: rename=renamed, modify=modified, default=changed|capitalize}",
7183                &ctx,
7184            )
7185            .unwrap();
7186        assert!(out.contains("Modified"), "got: {out}");
7187    }
7188
7189    #[test]
7190    fn choose_pipe_strict_no_match_no_default_errors() {
7191        let engine = test_engine().strictness(Strictness::Strict);
7192        let mut session = test_session();
7193        let mut ctx = Context::new();
7194        ctx.insert("level", Value::String("info".into()));
7195        let err = engine
7196            .render_inline(&mut session, "{level|choose: critical=URGENT}", &ctx)
7197            .unwrap_err();
7198        assert!(matches!(err, ProsaicError::InvalidPipe { .. }));
7199    }
7200
7201    #[test]
7202    fn choose_pipe_lenient_no_match_returns_placeholder() {
7203        let engine = test_engine().strictness(Strictness::Lenient);
7204        let mut session = test_session();
7205        let mut ctx = Context::new();
7206        ctx.insert("level", Value::String("info".into()));
7207        let out = engine
7208            .render_inline(&mut session, "{level|choose: critical=URGENT}", &ctx)
7209            .unwrap();
7210        assert!(out.contains("[choose: no match for info]"), "got: {out}");
7211    }
7212
7213    #[test]
7214    fn choose_pipe_silent_no_match_returns_empty() {
7215        let engine = test_engine().strictness(Strictness::Silent);
7216        let mut session = test_session();
7217        let mut ctx = Context::new();
7218        ctx.insert("level", Value::String("info".into()));
7219        let out = engine
7220            .render_inline(&mut session, "{level|choose: critical=URGENT}", &ctx)
7221            .unwrap();
7222        assert_eq!(out, "");
7223    }
7224
7225    #[test]
7226    fn choose_pipe_missing_arg_errors() {
7227        let engine = test_engine().strictness(Strictness::Strict);
7228        let mut session = test_session();
7229        let mut ctx = Context::new();
7230        ctx.insert("level", Value::String("info".into()));
7231        let err = engine
7232            .render_inline(&mut session, "{level|choose}", &ctx)
7233            .unwrap_err();
7234        assert!(matches!(err, ProsaicError::InvalidPipe { .. }));
7235    }
7236
7237    #[test]
7238    fn choose_pipe_malformed_arg_errors() {
7239        let engine = test_engine().strictness(Strictness::Strict);
7240        let mut session = test_session();
7241        let mut ctx = Context::new();
7242        ctx.insert("level", Value::String("info".into()));
7243        let err = engine
7244            .render_inline(&mut session, "{level|choose: no_equals_here}", &ctx)
7245            .unwrap_err();
7246        assert!(matches!(err, ProsaicError::InvalidPipe { .. }));
7247    }
7248
7249    // ── |plural pipe ─────────────────────────────────────────────────────────
7250
7251    #[test]
7252    fn plural_pipe_singular_for_one() {
7253        let engine = test_engine();
7254        let mut session = test_session();
7255        let mut ctx = Context::new();
7256        ctx.insert("count", Value::Number(1));
7257        let out = engine
7258            .render_inline(&mut session, "{count|plural:service}", &ctx)
7259            .unwrap();
7260        assert!(out.contains("service"));
7261        assert!(!out.contains("services"));
7262    }
7263
7264    #[test]
7265    fn plural_pipe_plural_for_many() {
7266        let engine = test_engine();
7267        let mut session = test_session();
7268        let mut ctx = Context::new();
7269        ctx.insert("count", Value::Number(5));
7270        let out = engine
7271            .render_inline(&mut session, "{count|plural:service}", &ctx)
7272            .unwrap();
7273        assert!(out.contains("services"));
7274    }
7275
7276    #[test]
7277    fn plural_pipe_plural_for_zero() {
7278        // English: 0 → Other → plural form
7279        let engine = test_engine();
7280        let mut session = test_session();
7281        let mut ctx = Context::new();
7282        ctx.insert("count", Value::Number(0));
7283        let out = engine
7284            .render_inline(&mut session, "{count|plural:service}", &ctx)
7285            .unwrap();
7286        assert!(out.contains("services"));
7287    }
7288
7289    #[test]
7290    fn plural_pipe_requires_noun_arg() {
7291        let engine = test_engine().strictness(Strictness::Strict);
7292        let mut session = test_session();
7293        let mut ctx = Context::new();
7294        ctx.insert("count", Value::Number(3));
7295        let err = engine
7296            .render_inline(&mut session, "{count|plural}", &ctx)
7297            .unwrap_err();
7298        assert!(matches!(err, ProsaicError::InvalidPipe { .. }));
7299    }
7300
7301    #[test]
7302    fn plural_pipe_requires_numeric_value() {
7303        let engine = test_engine().strictness(Strictness::Strict);
7304        let mut session = test_session();
7305        let mut ctx = Context::new();
7306        ctx.insert("word", Value::String("hello".into()));
7307        let err = engine
7308            .render_inline(&mut session, "{word|plural:service}", &ctx)
7309            .unwrap_err();
7310        assert!(matches!(err, ProsaicError::InvalidPipe { .. }));
7311    }
7312
7313    #[test]
7314    fn pronoun_realization_routes_through_language_trait() {
7315        // This test exists to assert that the refactor preserved the English
7316        // pronoun output. If it fails, the trait default impl doesn't match
7317        // the old inline logic.
7318        let mut engine = test_engine();
7319        engine
7320            .register_template("t", "{name|refer} was modified")
7321            .unwrap();
7322        let mut session = test_session();
7323        let mut ctx = Context::new();
7324        ctx.insert("entity_type", Value::String("class".into()));
7325        ctx.insert("name", Value::String("Foo".into()));
7326
7327        // First render: Full form — "The class Foo was modified."
7328        let r1 = engine.render(&mut session, "t", &ctx).unwrap();
7329        assert!(r1.contains("The class Foo"), "got: {r1}");
7330
7331        // Second render: Pronoun — should contain "it" (English default).
7332        let r2 = engine.render(&mut session, "t", &ctx).unwrap();
7333        assert!(
7334            r2.to_lowercase().contains("it was") || r2.to_lowercase().contains("it "),
7335            "got: {r2}"
7336        );
7337    }
7338
7339    #[test]
7340    fn plural_pipe_and_pluralize_pipe_coexist() {
7341        // Both pipes must remain functional — neither replaces the other.
7342        let engine = test_engine();
7343        let mut session = test_session();
7344        let mut ctx = Context::new();
7345        ctx.insert("count", Value::Number(2));
7346        let plural_out = engine
7347            .render_inline(&mut session, "{count|plural:item}", &ctx)
7348            .unwrap();
7349        session.reset();
7350        let pluralize_out = engine
7351            .render_inline(&mut session, "{count|pluralize:item}", &ctx)
7352            .unwrap();
7353        // Under English (TestLang) both pipes should produce "items" for count=2.
7354        assert_eq!(plural_out, pluralize_out);
7355    }
7356}
7357
7358#[cfg(test)]
7359mod render_batch_with_relations_tests {
7360    use super::*;
7361    use crate::language::{Conjunction, Language, Person, Tense};
7362    use crate::rst::RstRelation;
7363
7364    struct SimpleLang;
7365
7366    impl Language for SimpleLang {
7367        fn pluralize(&self, word: &str, count: usize) -> String {
7368            if count == 1 {
7369                word.to_string()
7370            } else {
7371                format!("{word}s")
7372            }
7373        }
7374        fn singularize(&self, word: &str) -> String {
7375            word.strip_suffix('s').unwrap_or(word).to_string()
7376        }
7377        fn article(&self, _word: &str) -> &str {
7378            "the"
7379        }
7380        fn conjugate(&self, verb: &str, _t: Tense, _p: Person) -> String {
7381            verb.to_string()
7382        }
7383        fn past_participle(&self, verb: &str) -> String {
7384            format!("{verb}ed")
7385        }
7386        fn present_participle(&self, verb: &str) -> String {
7387            format!("{verb}ing")
7388        }
7389        fn join_list(&self, items: &[&str], _c: Conjunction) -> String {
7390            items.join(", ")
7391        }
7392        fn ordinal(&self, n: usize) -> String {
7393            format!("{n}th")
7394        }
7395        fn number_to_words(&self, n: usize) -> String {
7396            n.to_string()
7397        }
7398    }
7399
7400    fn make_engine() -> Engine {
7401        Engine::new(SimpleLang)
7402            .strictness(Strictness::Strict)
7403            .variation(Variation::Fixed)
7404    }
7405
7406    fn ctx_with_name(name: &str) -> Context {
7407        let mut c = Context::new();
7408        c.insert("name", Value::String(name.into()));
7409        c
7410    }
7411
7412    #[test]
7413    fn render_batch_with_relations_inserts_marker() {
7414        let mut engine = make_engine();
7415        engine
7416            .register_template("t", "The class {name} was modified")
7417            .unwrap();
7418        let mut s = Session::new();
7419        let ctx = ctx_with_name("Foo");
7420        let events = vec![
7421            ("t", ctx.clone(), None),
7422            ("t", ctx, Some(RstRelation::Elaboration)),
7423        ];
7424        let out = engine.render_batch_with_relations(&mut s, &events).unwrap();
7425        assert!(out.contains("Furthermore, "), "got: {out}");
7426    }
7427
7428    #[test]
7429    fn render_batch_with_relations_lowercases_determiner_after_marker() {
7430        let mut engine = make_engine();
7431        engine
7432            .register_template("t", "The class {name} was modified")
7433            .unwrap();
7434        let mut s = Session::new();
7435        let ctx = ctx_with_name("Foo");
7436        let events = vec![
7437            ("t", ctx.clone(), None),
7438            ("t", ctx, Some(RstRelation::Contrast)),
7439        ];
7440        let out = engine.render_batch_with_relations(&mut s, &events).unwrap();
7441        // "However, the class Foo..." — note lowercase "the"
7442        assert!(out.contains("However, the class"), "got: {out}");
7443    }
7444
7445    #[test]
7446    fn render_batch_with_all_none_delegates_to_render_batch() {
7447        let mut engine = make_engine();
7448        engine
7449            .register_template("t", "{name} was modified")
7450            .unwrap();
7451        let mut s = Session::new();
7452        let ctx = ctx_with_name("Foo");
7453        let triples = vec![("t", ctx.clone(), None), ("t", ctx.clone(), None)];
7454        let pairs: Vec<_> = triples.iter().map(|(k, c, _)| (*k, c.clone())).collect();
7455        let mut s2 = Session::new();
7456        let from_triples = engine
7457            .render_batch_with_relations(&mut s, &triples)
7458            .unwrap();
7459        let from_pairs = engine.render_batch(&mut s2, &pairs).unwrap();
7460        assert_eq!(from_triples, from_pairs);
7461    }
7462
7463    #[test]
7464    fn render_batch_with_relations_empty_is_empty_string() {
7465        let engine = make_engine();
7466        let mut s = Session::new();
7467        let events: Vec<(&str, Context, Option<RstRelation>)> = vec![];
7468        let out = engine.render_batch_with_relations(&mut s, &events).unwrap();
7469        assert_eq!(out, "");
7470    }
7471
7472    #[test]
7473    fn rst_marker_strips_auto_connective_to_avoid_double_prepend() {
7474        // Two renders that would normally trigger an automatic "Similarly,"
7475        // connective (different entity, same action). With an explicit RST
7476        // Elaboration marker, the output should start with "Furthermore, "
7477        // — not "Furthermore, Similarly, ".
7478        let mut engine = make_engine();
7479        engine
7480            .register_template("t", "The class {name} was modified")
7481            .unwrap();
7482        let mut s = Session::new();
7483        let events = vec![
7484            ("t", ctx_with_name("Foo"), None),
7485            ("t", ctx_with_name("Bar"), Some(RstRelation::Elaboration)),
7486        ];
7487        let out = engine.render_batch_with_relations(&mut s, &events).unwrap();
7488        assert!(out.contains("Furthermore, "), "got: {out}");
7489        assert!(
7490            !out.contains("Furthermore, Similarly,") && !out.contains("Furthermore, Likewise,"),
7491            "RST marker should suppress / strip auto-connective; got: {out}"
7492        );
7493    }
7494
7495    fn ctx_with_entity(name: &str) -> Context {
7496        // Like ctx_with_name but also sets entity_type so that
7497        // discourse.mention_entity fires and detect_relation can
7498        // classify the inter-render link.
7499        let mut c = Context::new();
7500        c.insert("entity_type", Value::String("class".into()));
7501        c.insert("name", Value::String(name.into()));
7502        c
7503    }
7504
7505    #[test]
7506    fn rst_render_leaves_session_free_of_unemitted_connective() {
7507        // Regression: render_batch_with_relations previously let the
7508        // underlying render() select an auto-connective (advancing the
7509        // no-repeat ring buffer) and record its words, then stripped
7510        // the connective from the surface. Session state was then
7511        // inconsistent with emitted prose.
7512        //
7513        // Post-fix: a follow-up render that shares the same discourse
7514        // relation should be free to pick the connective that would
7515        // have been used during the RST render, because the RST call
7516        // never advanced the history.
7517        let mut engine = make_engine();
7518        engine
7519            .register_template("t", "The class {name} was modified")
7520            .unwrap();
7521        let mut s = Session::new();
7522
7523        // First two events: the second carries an RST marker that
7524        // should suppress the auto-connective selection.
7525        let events = vec![
7526            ("t", ctx_with_entity("Foo"), None),
7527            ("t", ctx_with_entity("Bar"), Some(RstRelation::Elaboration)),
7528        ];
7529        let _ = engine.render_batch_with_relations(&mut s, &events).unwrap();
7530
7531        // A subsequent plain render with a different entity on the same
7532        // template key would classify as DifferentEntitySameAction →
7533        // connective pool SAME_ACTION_CONNECTIVES = ["Similarly,", "Likewise,"].
7534        // Because the RST render did NOT consume any connective, the
7535        // next render must still pick the first available ("Similarly,").
7536        let exp = engine
7537            .render_explained(&mut s, "t", ctx_with_entity("Baz"))
7538            .unwrap();
7539        assert_eq!(
7540            exp.connective,
7541            Some("Similarly,"),
7542            "RST render leaked connective history; got connective={:?}, output={}",
7543            exp.connective,
7544            exp.output
7545        );
7546    }
7547
7548    #[test]
7549    fn rst_render_does_not_record_unemitted_connective_words() {
7550        // Regression: repetition scoring previously saw the stripped
7551        // auto-connective's words ("Similarly") in word_history. After
7552        // the fix, a follow-up render's repetition scoring must not be
7553        // biased against words that never reached the output.
7554        let mut engine = make_engine().variation(Variation::Seeded(42));
7555        engine
7556            .register_template("t", "The class {name} was modified")
7557            .unwrap();
7558        let mut s = Session::new();
7559
7560        let events = vec![
7561            ("t", ctx_with_entity("Foo"), None),
7562            ("t", ctx_with_entity("Bar"), Some(RstRelation::Elaboration)),
7563        ];
7564        let _ = engine.render_batch_with_relations(&mut s, &events).unwrap();
7565
7566        // "similarly" should never have been recorded in word history
7567        // by the RST render — its word_frequency must be zero.
7568        assert_eq!(
7569            s.discourse.word_frequency("similarly"),
7570            0.0,
7571            "RST render leaked 'similarly' into word history"
7572        );
7573    }
7574}
7575
7576#[cfg(test)]
7577mod engine_thread_safety {
7578    use super::Engine;
7579
7580    // Compile-time assert: Engine is Send + Sync post-refactor.
7581    // If this ever breaks (e.g. RefCell re-introduced), compilation fails here.
7582    const _: fn() = || {
7583        fn assert_send_sync<T: Send + Sync>() {}
7584        assert_send_sync::<Engine>();
7585    };
7586}
7587
7588#[cfg(feature = "serde")]
7589fn apply_manifest_engine_settings(
7590    engine: &mut Engine,
7591    settings: &manifest_loader::ManifestEngineSettings,
7592) -> Result<(), ProsaicError> {
7593    engine.strictness = match settings.strictness.as_str() {
7594        "" | "strict" => Strictness::Strict,
7595        "lenient" => Strictness::Lenient,
7596        "silent" => Strictness::Silent,
7597        other => {
7598            return Err(ProsaicError::TemplateParseError {
7599                template: "(manifest)".to_string(),
7600                position: 0,
7601                reason: format!("unknown strictness `{other}`"),
7602            });
7603        }
7604    };
7605
7606    engine.variation = match settings.variation.as_str() {
7607        "" | "fixed" => Variation::Fixed,
7608        "round_robin" | "round-robin" => Variation::RoundRobin,
7609        "random" => Variation::Random,
7610        other => {
7611            return Err(ProsaicError::TemplateParseError {
7612                template: "(manifest)".to_string(),
7613                position: 0,
7614                reason: format!("unknown variation `{other}`"),
7615            });
7616        }
7617    };
7618
7619    #[cfg(feature = "polish")]
7620    {
7621        engine.smart_quotes = settings.smart_quotes;
7622        engine.max_sentence_length = if settings.max_sentence_length > 0 {
7623            Some(settings.max_sentence_length)
7624        } else {
7625            None
7626        };
7627    }
7628
7629    #[cfg(not(feature = "polish"))]
7630    if settings.smart_quotes || settings.max_sentence_length > 0 {
7631        return Err(ProsaicError::TemplateParseError {
7632            template: "(manifest)".to_string(),
7633            position: 0,
7634            reason: "manifest uses polish settings, but prosaic-core was built without the `polish` feature".to_string(),
7635        });
7636    }
7637
7638    if settings.faithfulness_min < 0.0 {
7639        return Err(ProsaicError::TemplateParseError {
7640            template: "(manifest)".to_string(),
7641            position: 0,
7642            reason: format!(
7643                "faithfulness_min must be non-negative, got {}",
7644                settings.faithfulness_min
7645            ),
7646        });
7647    }
7648    engine.faithfulness_threshold = if settings.faithfulness_min > 0.0 {
7649        Some(settings.faithfulness_min as f32)
7650    } else {
7651        None
7652    };
7653
7654    if let Some(thresholds) = &settings.salience_thresholds {
7655        engine.salience_thresholds = SalienceThresholds {
7656            low_max: thresholds.low_max,
7657            high_min: thresholds.high_min,
7658        };
7659    }
7660
7661    engine.style_preference = settings.style.clone();
7662
7663    Ok(())
7664}
7665
7666#[cfg(feature = "serde")]
7667mod manifest_loader {
7668    #[cfg(not(feature = "std"))]
7669    use alloc::string::{String, ToString};
7670    #[cfg(not(feature = "std"))]
7671    use alloc::vec::Vec;
7672    use serde::Deserialize;
7673
7674    #[derive(Deserialize)]
7675    pub struct ManifestBundle {
7676        pub schema_version: u32,
7677        #[allow(dead_code)]
7678        pub name: String,
7679        #[allow(dead_code)]
7680        pub version: String,
7681        pub language: String,
7682        #[allow(dead_code)]
7683        #[serde(default)]
7684        pub engine: ManifestEngineSettings,
7685        pub templates: Vec<ManifestTemplate>,
7686        pub partials: Vec<ManifestPartial>,
7687    }
7688
7689    // Fields are deserialized from manifest JSON and applied by
7690    // `Engine::load_manifest`.
7691    #[allow(dead_code)]
7692    #[derive(Deserialize, Default)]
7693    pub struct ManifestEngineSettings {
7694        #[serde(default)]
7695        pub strictness: String,
7696        #[serde(default)]
7697        pub variation: String,
7698        #[serde(default)]
7699        pub smart_quotes: bool,
7700        #[serde(default)]
7701        pub max_sentence_length: usize,
7702        #[serde(default)]
7703        pub faithfulness_min: f64,
7704        #[serde(default)]
7705        pub salience_thresholds: Option<ManifestSalienceThresholds>,
7706        #[serde(default)]
7707        pub style: Option<String>,
7708    }
7709
7710    #[derive(Deserialize)]
7711    pub struct ManifestSalienceThresholds {
7712        pub low_max: i64,
7713        pub high_min: i64,
7714    }
7715
7716    #[derive(Deserialize)]
7717    pub struct ManifestTemplate {
7718        pub key: String,
7719        #[serde(default)]
7720        #[allow(dead_code)]
7721        pub description: String,
7722        pub variants: Vec<ManifestVariant>,
7723    }
7724
7725    #[derive(Deserialize)]
7726    pub struct ManifestVariant {
7727        #[serde(default = "default_salience")]
7728        pub salience: String,
7729        #[serde(default)]
7730        pub language: Option<String>,
7731        #[serde(default)]
7732        pub style: Option<String>,
7733        pub body: String,
7734    }
7735
7736    fn default_salience() -> String {
7737        "medium".to_string()
7738    }
7739
7740    #[derive(Deserialize)]
7741    pub struct ManifestPartial {
7742        pub name: String,
7743        pub body: String,
7744    }
7745}
7746
7747#[cfg(test)]
7748mod register_template_type_tests {
7749    use super::*;
7750    use crate::language::{Conjunction, Language, Person, Tense};
7751
7752    /// Minimal language for register-time type-checking tests.
7753    /// These tests never render, so the implementation bodies are irrelevant.
7754    struct TestLang;
7755
7756    impl Language for TestLang {
7757        fn pluralize(&self, word: &str, count: usize) -> String {
7758            if count == 1 {
7759                word.to_string()
7760            } else {
7761                format!("{word}s")
7762            }
7763        }
7764        fn singularize(&self, word: &str) -> String {
7765            word.strip_suffix('s').unwrap_or(word).to_string()
7766        }
7767        fn article(&self, _word: &str) -> &str {
7768            "a"
7769        }
7770        fn conjugate(&self, verb: &str, _t: Tense, _p: Person) -> String {
7771            verb.to_string()
7772        }
7773        fn past_participle(&self, verb: &str) -> String {
7774            format!("{verb}ed")
7775        }
7776        fn present_participle(&self, verb: &str) -> String {
7777            format!("{verb}ing")
7778        }
7779        fn join_list(&self, items: &[&str], conj: Conjunction) -> String {
7780            let c = match conj {
7781                Conjunction::And => "and",
7782                Conjunction::Or => "or",
7783            };
7784            items.join(&format!(" {c} "))
7785        }
7786        fn ordinal(&self, n: usize) -> String {
7787            format!("{n}th")
7788        }
7789        fn number_to_words(&self, n: usize) -> String {
7790            format!("<{n}>")
7791        }
7792    }
7793
7794    #[test]
7795    fn register_template_rejects_chain_mismatch() {
7796        let mut engine = Engine::new(TestLang);
7797        let err = engine
7798            .register_template("bad", "{x|capitalize|pluralize}")
7799            .unwrap_err();
7800        match err {
7801            ProsaicError::TemplateParseError { reason, .. } => {
7802                assert!(
7803                    reason.contains("chain mismatch"),
7804                    "unexpected reason: {reason}"
7805                );
7806            }
7807            other => panic!("expected TemplateParseError, got {other:?}"),
7808        }
7809    }
7810
7811    #[test]
7812    fn register_template_rejects_multi_mention_conflict() {
7813        let mut engine = Engine::new(TestLang);
7814        let err = engine
7815            .register_template("bad", "{x|pluralize:item} and {x|join}")
7816            .unwrap_err();
7817        assert!(matches!(err, ProsaicError::TemplateParseError { .. }));
7818    }
7819
7820    #[test]
7821    fn register_template_accepts_valid_template() {
7822        let mut engine = Engine::new(TestLang);
7823        engine
7824            .register_template("good", "The {name} has {count|pluralize:item}")
7825            .unwrap();
7826    }
7827
7828    #[test]
7829    fn register_template_accepts_bare_slots() {
7830        // No pipes => no chain checks to fail.
7831        let mut engine = Engine::new(TestLang);
7832        engine.register_template("bare", "Hello {name}").unwrap();
7833    }
7834}