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}