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