Skip to main content

jev_repl/
trend.rs

1//! A rubric followed across a conversation: every question asked again after each turn, and what
2//! it said drawn as one line per question.
3//!
4//! A single call over a thread says where the answer ended up. It does not say when it got there —
5//! whether urgency was clear from the first message or only arrived with the third — and that is
6//! usually the thing worth knowing about a rubric meant to run on a live conversation. This module
7//! is the pure half: the prefixes to ask, and the lines to draw from what came back.
8
9use ratatui::style::Style;
10use ratatui::text::{Line, Span};
11use typesafe::{Answer, Question};
12
13use crate::evaluate::two;
14use crate::format::{bold, color_for, dim};
15use crate::headless::Answered;
16use crate::session::{Session, turns_to_json};
17
18/// The session once per turn: the first turn, the first two, and so on up to the whole thread.
19/// Empty when the state is not a conversation.
20///
21/// Each prefix is written with `turns_to_json`, the same way `cost::thread` prices it and a cases
22/// file labelled `by_turn` sends it, so the three agree on what "the conversation after turn 2" is.
23pub fn prefixes(session: &Session) -> Vec<Session> {
24    let Some(turns) = session.turns() else {
25        return Vec::new();
26    };
27    (1..=turns.len())
28        .map(|n| {
29            let mut so_far = session.clone();
30            so_far.state = turns_to_json(&turns[..n]);
31            so_far
32        })
33        .collect()
34}
35
36/// One question across the turns.
37#[derive(Debug, Clone, PartialEq)]
38pub struct Series {
39    pub name: String,
40    pub kind: &'static str,
41    /// The number at each turn: a noul's probability, the probability of the label a choice ended
42    /// on, a score's weighted level. Empty when some turn came back without an answer.
43    pub values: Vec<f64>,
44    /// What a full bar means: 1 for a probability, the highest level for a score.
45    pub top: f64,
46    /// What the question said at each turn, in words: `yes`, a label, `level 2`.
47    pub readings: Vec<String>,
48    /// For a choice, the label it chose at the last turn — the one `values` follows.
49    pub label: Option<String>,
50}
51
52/// The wire `type` of a question, with a hand-built object as `raw`.
53fn kind_of(question: &Question) -> &'static str {
54    match question {
55        Question::Noul(_) => "noul",
56        Question::Choice(_) => "choice",
57        Question::Score(_) => "score",
58        _ => "raw",
59    }
60}
61
62/// Line each question up across the turns.
63///
64/// A choice is followed through the label it ended on, so the line shows that label gaining ground
65/// (or not) rather than jumping between whichever label led at each turn; the readings still name
66/// the leader turn by turn, which is where a change of mind shows.
67pub fn series(session: &Session, per_turn: &[Vec<Answered>], threshold: f64) -> Vec<Series> {
68    session
69        .questions
70        .iter()
71        .map(|(name, question)| {
72            let kind = kind_of(question);
73            let missing = Series {
74                name: name.clone(),
75                kind,
76                values: Vec::new(),
77                top: 1.0,
78                readings: Vec::new(),
79                label: None,
80            };
81            let answers: Option<Vec<&Answer>> = per_turn
82                .iter()
83                .map(|answered| {
84                    answered
85                        .iter()
86                        .find(|(n, _)| n == name)
87                        .and_then(|(_, a)| a.as_ref())
88                        .filter(|a| a.kind() == kind)
89                })
90                .collect();
91            let Some(all) = answers.filter(|all| !all.is_empty()) else {
92                return missing;
93            };
94            match all[all.len() - 1] {
95                Answer::Noul(_) => {
96                    let at = session.threshold_of(name, threshold);
97                    let values: Vec<f64> = all
98                        .iter()
99                        .map(|a| match a {
100                            Answer::Noul(a) => a.noul,
101                            _ => 0.0,
102                        })
103                        .collect();
104                    let readings = values
105                        .iter()
106                        .map(|p| if *p >= at { "yes" } else { "no" }.to_owned())
107                        .collect();
108                    Series {
109                        values,
110                        readings,
111                        ..missing
112                    }
113                }
114                Answer::Choice(last) => {
115                    let label = last.choice.clone();
116                    Series {
117                        values: all
118                            .iter()
119                            .map(|a| match a {
120                                Answer::Choice(a) => a.probability(&label).unwrap_or(0.0),
121                                _ => 0.0,
122                            })
123                            .collect(),
124                        readings: all
125                            .iter()
126                            .map(|a| match a {
127                                Answer::Choice(a) => a.choice.clone(),
128                                _ => String::new(),
129                            })
130                            .collect(),
131                        label: Some(label),
132                        ..missing
133                    }
134                }
135                Answer::Score(last) => Series {
136                    values: all
137                        .iter()
138                        .map(|a| match a {
139                            Answer::Score(a) => a.score,
140                            _ => 0.0,
141                        })
142                        .collect(),
143                    top: last.legend.len().saturating_sub(1) as f64,
144                    readings: all
145                        .iter()
146                        .map(|a| match a {
147                            Answer::Score(a) => format!("level {}", a.rounded_level()),
148                            _ => String::new(),
149                        })
150                        .collect(),
151                    ..missing
152                },
153                _ => missing,
154            }
155        })
156        .collect()
157}
158
159const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
160
161/// One block per value, as tall as the value is of `top`. The scale is fixed rather than fitted to
162/// the values, so a noul that sits at 0.9 all the way through reads as high and flat, not as noise.
163pub fn sparkline(values: &[f64], top: f64) -> String {
164    values
165        .iter()
166        .map(|v| {
167            let share = if top <= 0.0 {
168                0.0
169            } else {
170                (v / top).clamp(0.0, 1.0)
171            };
172            BLOCKS[(share * 7.0).round() as usize]
173        })
174        .collect()
175}
176
177/// `turn 3 yes · turn 4 no`, or `no throughout` when it never changed its mind.
178pub fn changes(readings: &[String]) -> String {
179    let out: Vec<String> = readings
180        .windows(2)
181        .enumerate()
182        .filter(|(_, pair)| pair[0] != pair[1])
183        .map(|(i, pair)| format!("turn {} {}", i + 2, pair[1]))
184        .collect();
185    if out.is_empty() {
186        format!(
187            "{} throughout",
188            readings.first().map(String::as_str).unwrap_or("")
189        )
190    } else {
191        out.join(" · ")
192    }
193}
194
195/// `0.12 → 0.91`, with a choice's label in front and a score's scale behind.
196fn summary(one: &Series) -> String {
197    let first = two(one.values.first().copied().unwrap_or(0.0));
198    let last = two(one.values.last().copied().unwrap_or(0.0));
199    match &one.label {
200        Some(label) => format!("{label} {first} → {last}"),
201        None if one.kind == "score" => format!("{first} → {last} of {}", one.top),
202        None => format!("{first} → {last}"),
203    }
204}
205
206/// One line per question: the spark, where it started and ended, and every turn it changed.
207pub fn trend_lines(all: &[Series]) -> Vec<Line<'static>> {
208    let width = all
209        .iter()
210        .map(|one| one.name.chars().count())
211        .max()
212        .unwrap_or(0);
213    let summaries: Vec<String> = all
214        .iter()
215        .map(|one| {
216            if one.values.is_empty() {
217                String::new()
218            } else {
219                summary(one)
220            }
221        })
222        .collect();
223    let summary_width = summaries
224        .iter()
225        .map(|text| text.chars().count())
226        .max()
227        .unwrap_or(0);
228    all.iter()
229        .zip(&summaries)
230        .map(|(one, summary)| {
231            let color = Style::new().fg(color_for(one.kind));
232            let mut spans = vec![
233                Span::raw("  "),
234                bold(pad_end(&one.name, width)),
235                Span::raw("  "),
236                Span::styled(pad_end(one.kind, 8), color),
237            ];
238            if one.values.is_empty() {
239                spans.push(dim("no answer to follow"));
240                return Line::from(spans);
241            }
242            spans.extend([
243                Span::styled(sparkline(&one.values, one.top), color),
244                Span::raw("  "),
245                Span::raw(pad_end(summary, summary_width)),
246                Span::raw("  "),
247                dim(changes(&one.readings)),
248            ]);
249            Line::from(spans)
250        })
251        .collect()
252}
253
254fn pad_end(text: &str, width: usize) -> String {
255    let length = text.chars().count();
256    if length >= width {
257        text.to_owned()
258    } else {
259        format!("{text}{}", " ".repeat(width - length))
260    }
261}