Skip to main content

prosaic_core/
session.rs

1//! Per-render-sequence mutable state. See module docs.
2//!
3//! A `Session` owns the `DiscourseState` (focus stack, template history,
4//! word-frequency log, list-style cycle) and any other runtime-mutable
5//! counters associated with a render sequence. Callers create one per
6//! logical "document" — a batch, a DocumentPlan, a page of output — and
7//! pass `&mut Session` into render calls.
8//!
9//! A fresh session = a fresh narrative. Calling `reset()` on an existing
10//! session clears state without deallocating.
11
12use core::sync::atomic::{AtomicUsize, Ordering};
13
14#[cfg(not(feature = "std"))]
15use alloc::string::String;
16#[cfg(not(feature = "std"))]
17use alloc::vec::Vec;
18
19use crate::collections::{HashMap, map_with_capacity, new_map};
20
21use crate::discourse::DiscourseState;
22use crate::salience::Salience;
23use crate::style::{LengthDistribution, SalienceBias};
24
25/// Mutable state for a render sequence. See module docs.
26#[derive(Debug)]
27pub struct Session {
28    pub(crate) discourse: DiscourseState,
29    /// RoundRobin counters keyed by template key. Stored as AtomicUsize
30    /// so a future `&Session`-only code path (e.g. read-only scoring)
31    /// can still advance counters atomically without an outer borrow.
32    pub(crate) round_robin_counters: HashMap<String, AtomicUsize>,
33    /// Unix-seconds timestamp of the most recently-rendered event. Used by
34    /// the `{timestamp|since_last}` pipe to compute inter-event deltas
35    /// ("the next day", "moments later"). Persists across
36    /// [`Session::reset`] so narratives can span paragraphs. Starts as
37    /// `None`; set automatically whenever an event's context contains a
38    /// `timestamp` slot. Call [`Session::reset_temporal`] to clear it.
39    pub(crate) last_temporal_anchor: Option<i64>,
40    /// Connectives the engine must skip during the next render(s). Used by
41    /// the retrospective refine pass to apply `BlacklistConnective`
42    /// constraints without mutating the engine. Empty in normal use.
43    pub(crate) refine_blacklist_connectives: Vec<String>,
44    /// List styles the engine must skip during the next render(s). Used by
45    /// the retrospective refine pass to apply `BlacklistListStyle`
46    /// constraints without mutating the engine. Empty in normal use.
47    pub(crate) refine_blacklist_list_styles: Vec<crate::discourse::ListStyle>,
48    /// Refine-pass override for the active `SalienceBias`. When `Some`,
49    /// the engine ignores the active style profile's salience bias and
50    /// applies this one instead for the duration of the iteration.
51    /// Carries `OverrideSalienceBias` constraints. `None` in normal use.
52    pub(crate) refine_salience_bias: Option<SalienceBias>,
53    /// Refine-pass override for the active sentence-length distribution.
54    /// When `Some`, the candidate-scoring path uses this distribution as
55    /// the bias target instead of the active style profile's. Carries
56    /// `TightenLengthDistribution` constraints. `None` in normal use.
57    pub(crate) refine_length_distribution: Option<LengthDistribution>,
58    /// Refine-pass override forcing a specific variant tier per template
59    /// key. When a render's template key is present, the engine
60    /// short-circuits the salience+verbosity calculation and uses the
61    /// listed tier directly. Carries `ForceVariantTier` constraints.
62    /// Empty in normal use.
63    pub(crate) refine_force_variant_tier: Vec<(String, Salience)>,
64}
65
66impl Session {
67    pub fn new() -> Self {
68        Self {
69            discourse: DiscourseState::new(),
70            round_robin_counters: new_map(),
71            last_temporal_anchor: None,
72            refine_blacklist_connectives: Vec::new(),
73            refine_blacklist_list_styles: Vec::new(),
74            refine_salience_bias: None,
75            refine_length_distribution: None,
76            refine_force_variant_tier: Vec::new(),
77        }
78    }
79
80    /// Set the connectives + list styles the next render(s) must skip.
81    /// Called by the retrospective refine pass before each iteration; not
82    /// part of the public engine API.
83    pub(crate) fn set_refine_blacklists(
84        &mut self,
85        connectives: Vec<String>,
86        list_styles: Vec<crate::discourse::ListStyle>,
87    ) {
88        self.refine_blacklist_connectives = connectives;
89        self.refine_blacklist_list_styles = list_styles;
90    }
91
92    /// Push phantom history entries onto the discourse ring buffers so the
93    /// next render's anti-repeat treats these connectives / list styles as
94    /// recently used. Called by the retrospective refine pass to apply
95    /// `PrimeRecencyWindow` constraints. Pushes are bounded by the same
96    /// window caps the live emit path uses.
97    pub(crate) fn prime_refine_recency(
98        &mut self,
99        connectives: &[String],
100        list_styles: &[crate::discourse::ListStyle],
101    ) {
102        self.discourse.prime_connective_history(connectives);
103        self.discourse.prime_list_style_history(list_styles);
104    }
105
106    /// Set the refine-pass salience-bias override. `None` clears it.
107    pub(crate) fn set_refine_salience_bias(&mut self, bias: Option<SalienceBias>) {
108        self.refine_salience_bias = bias;
109    }
110
111    /// Set the refine-pass sentence-length-distribution override.
112    /// `None` clears it.
113    pub(crate) fn set_refine_length_distribution(
114        &mut self,
115        distribution: Option<LengthDistribution>,
116    ) {
117        self.refine_length_distribution = distribution;
118    }
119
120    /// Set the refine-pass forced-variant-tier mapping. Replaces any
121    /// existing mapping wholesale.
122    pub(crate) fn set_refine_force_variant_tiers(&mut self, tiers: Vec<(String, Salience)>) {
123        self.refine_force_variant_tier = tiers;
124    }
125
126    /// Look up a forced variant tier for the given template key, if any.
127    pub(crate) fn refine_forced_tier_for(&self, key: &str) -> Option<Salience> {
128        self.refine_force_variant_tier
129            .iter()
130            .find(|(k, _)| k == key)
131            .map(|(_, t)| *t)
132    }
133
134    /// Clear any active refine-pass overrides. Note: phantom entries
135    /// pushed onto discourse ring buffers via `prime_refine_recency` are
136    /// not undone — they're indistinguishable from real history once
137    /// pushed and decay naturally as new entries arrive. The iteration
138    /// controller restores from a clean snapshot before each iteration,
139    /// so primes never accumulate across iterations.
140    pub(crate) fn clear_refine_overrides(&mut self) {
141        self.refine_blacklist_connectives.clear();
142        self.refine_blacklist_list_styles.clear();
143        self.refine_salience_bias = None;
144        self.refine_length_distribution = None;
145        self.refine_force_variant_tier.clear();
146    }
147
148    /// Clear all session state. Equivalent to replacing with `Session::new()`
149    /// but preserves allocations. Use when starting a fully unrelated
150    /// narrative in the same session — most multi-paragraph callers want
151    /// [`Session::reset_for_paragraph`] instead so style rotation continues.
152    ///
153    /// NOTE: `last_temporal_anchor` survives so narratives can span paragraphs.
154    /// Call [`Session::reset_temporal`] to clear the anchor explicitly when
155    /// starting a temporally-disjoint narrative in the same session.
156    pub fn reset(&mut self) {
157        self.discourse.reset();
158        self.round_robin_counters.clear();
159        // Intentionally NOT clearing last_temporal_anchor — it must survive
160        // paragraph breaks so inter-paragraph temporal phrases ("two weeks later")
161        // work correctly.
162    }
163
164    /// Reset paragraph-local discourse while keeping narrative-level style
165    /// continuity. Pronoun, focus, and Centering Theory state are cleared so
166    /// anaphora cannot leak across the paragraph break, but every form of
167    /// stylistic anti-repeat — list-style rotation, template-variant history,
168    /// connective history, word-repetition scoring, and Round-Robin variant
169    /// counters — survives, along with the temporal anchor. Consecutive
170    /// paragraphs therefore rotate through `|join` phrasings, avoid replaying
171    /// the same template variant or connective, are penalized for repeating
172    /// recent vocabulary, and continue to support inter-paragraph temporal
173    /// references.
174    ///
175    /// This is the reset [`crate::DocumentPlan::render`] uses between
176    /// paragraphs. Library consumers driving their own paragraph loop should
177    /// prefer this over [`Session::reset`].
178    pub fn reset_for_paragraph(&mut self) {
179        self.discourse.reset_for_paragraph();
180        // round_robin_counters are intentionally retained: they back
181        // Variation::RoundRobin's variant cycling, and resetting them every
182        // paragraph would re-introduce the same opener after each break.
183        // See `reset`: temporal anchors intentionally survive paragraph breaks.
184    }
185
186    /// Clear the temporal anchor. Use when starting a temporally-disjoint
187    /// narrative in the same session.
188    pub fn reset_temporal(&mut self) {
189        self.last_temporal_anchor = None;
190    }
191
192    /// Clear the discourse list-style cycle counter so the next `|join` pipe
193    /// starts at the first style in the rotation. Use when starting a
194    /// stylistically-disjoint narrative in the same session without doing
195    /// a full [`Session::reset`].
196    pub fn reset_list_cycle(&mut self) {
197        self.discourse.reset_list_cycle();
198    }
199
200    /// Mutable access to the underlying discourse state. Use this to call
201    /// [`DiscourseState::mention_entity_ranked`] for templates where
202    /// grammatical role matters, or to read centering diagnostics such as
203    /// [`DiscourseState::cb`], [`DiscourseState::cf`], and
204    /// [`DiscourseState::last_transition`].
205    pub fn discourse_mut(&mut self) -> &mut DiscourseState {
206        &mut self.discourse
207    }
208
209    /// Read-only access to the underlying discourse state.
210    pub fn discourse(&self) -> &DiscourseState {
211        &self.discourse
212    }
213}
214
215impl Default for Session {
216    fn default() -> Self {
217        Self::new()
218    }
219}
220
221impl Clone for Session {
222    /// Deep clone. The RoundRobin counters are cloned by reading each
223    /// atomic with `Ordering::Relaxed` — fine because clones are used
224    /// as snapshot/restore checkpoints around fallible renders and there
225    /// is no concurrent writer during a clone.
226    ///
227    /// `last_temporal_anchor` is copied so snapshot/restore checkpoints
228    /// preserve the temporal state correctly.
229    fn clone(&self) -> Self {
230        let mut counters = map_with_capacity(self.round_robin_counters.len());
231        for (k, v) in &self.round_robin_counters {
232            counters.insert(k.clone(), AtomicUsize::new(v.load(Ordering::Relaxed)));
233        }
234        Self {
235            discourse: self.discourse.clone(),
236            round_robin_counters: counters,
237            last_temporal_anchor: self.last_temporal_anchor,
238            refine_blacklist_connectives: self.refine_blacklist_connectives.clone(),
239            refine_blacklist_list_styles: self.refine_blacklist_list_styles.clone(),
240            refine_salience_bias: self.refine_salience_bias,
241            refine_length_distribution: self.refine_length_distribution.clone(),
242            refine_force_variant_tier: self.refine_force_variant_tier.clone(),
243        }
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn session_new_has_no_temporal_anchor() {
253        let s = Session::new();
254        assert_eq!(s.last_temporal_anchor, None);
255    }
256
257    #[test]
258    fn session_reset_preserves_temporal_anchor() {
259        let mut s = Session::new();
260        s.last_temporal_anchor = Some(1_700_000_000);
261        s.reset();
262        assert_eq!(s.last_temporal_anchor, Some(1_700_000_000));
263    }
264
265    #[test]
266    fn paragraph_reset_preserves_list_style_cycle() {
267        let mut s = Session::new();
268        let first = s.discourse.next_list_style();
269
270        s.reset_for_paragraph();
271        let second = s.discourse.next_list_style();
272
273        assert_ne!(first, second);
274    }
275
276    #[test]
277    fn paragraph_reset_preserves_round_robin_counter() {
278        // Variation::RoundRobin uses these counters to cycle through template
279        // variants. Resetting them every paragraph would replay the same
280        // opener after every break — the realism-leak we're closing.
281        let mut s = Session::new();
282        s.round_robin_counters
283            .insert("code.renamed".to_string(), AtomicUsize::new(2));
284
285        s.reset_for_paragraph();
286
287        let counter = s
288            .round_robin_counters
289            .get("code.renamed")
290            .expect("round_robin counter must survive paragraph reset");
291        assert_eq!(counter.load(Ordering::Relaxed), 2);
292    }
293
294    #[test]
295    fn full_reset_clears_round_robin_counters() {
296        // Full session resets DO restart the rotation — the counter belongs
297        // to the narrative, not the session as a whole.
298        let mut s = Session::new();
299        s.round_robin_counters
300            .insert("code.renamed".to_string(), AtomicUsize::new(2));
301
302        s.reset();
303
304        assert!(s.round_robin_counters.is_empty());
305    }
306
307    #[test]
308    fn full_reset_restarts_list_style_cycle() {
309        let mut s = Session::new();
310        let first = s.discourse.next_list_style();
311
312        s.reset();
313        let second = s.discourse.next_list_style();
314
315        assert_eq!(first, second);
316    }
317
318    #[test]
319    fn reset_list_cycle_restarts_rotation_without_full_reset() {
320        let mut s = Session::new();
321        s.last_temporal_anchor = Some(1_700_000_000);
322        let first = s.discourse.next_list_style();
323        let _ = s.discourse.next_list_style();
324
325        s.reset_list_cycle();
326
327        // Rotation restarts...
328        assert_eq!(s.discourse.next_list_style(), first);
329        // ...but the temporal anchor is untouched.
330        assert_eq!(s.last_temporal_anchor, Some(1_700_000_000));
331    }
332
333    #[test]
334    fn session_reset_temporal_clears_anchor() {
335        let mut s = Session::new();
336        s.last_temporal_anchor = Some(1_700_000_000);
337        s.reset_temporal();
338        assert_eq!(s.last_temporal_anchor, None);
339    }
340
341    #[test]
342    fn session_clone_copies_temporal_anchor() {
343        let mut s = Session::new();
344        s.last_temporal_anchor = Some(1_700_000_000);
345        let cloned = s.clone();
346        assert_eq!(cloned.last_temporal_anchor, Some(1_700_000_000));
347    }
348
349    #[test]
350    fn session_clone_is_independent() {
351        // Mutating the clone must not affect the original.
352        let mut s = Session::new();
353        s.last_temporal_anchor = Some(1_700_000_000);
354        let mut cloned = s.clone();
355        cloned.last_temporal_anchor = Some(9_999_999_999);
356        assert_eq!(s.last_temporal_anchor, Some(1_700_000_000));
357    }
358}