1use serde::{Deserialize, Serialize};
12
13use crate::verdict::Proposal;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum Reflection {
25 Absent,
29 Faint,
32 Strong,
35}
36
37impl Default for Reflection {
38 fn default() -> Self {
43 Self::Absent
44 }
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct AdvisorRecord {
51 pub seat: String,
53 pub agent: String,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub proposal: Option<Proposal>,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub error: Option<String>,
61 pub duration_ms: u64,
63 #[serde(default)]
68 pub reflection: Reflection,
69}
70
71impl AdvisorRecord {
72 pub fn proposed(seat_num: usize, agent: String, proposal: Proposal, duration_ms: u64) -> Self {
74 Self {
75 seat: format!("advisor-{seat_num}"),
76 agent,
77 proposal: Some(proposal),
78 error: None,
79 duration_ms,
80 reflection: Reflection::Absent,
81 }
82 }
83
84 pub fn failed(seat_num: usize, agent: String, error: String) -> Self {
87 Self {
88 seat: format!("advisor-{seat_num}"),
89 agent,
90 proposal: None,
91 error: Some(error),
92 duration_ms: 0,
93 reflection: Reflection::Absent,
94 }
95 }
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize, Default)]
101pub struct Advice {
102 pub records: Vec<AdvisorRecord>,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub synthesis: Option<String>,
111}
112
113impl Advice {
114 pub fn proposals(&self) -> Vec<(&str, &Proposal)> {
116 self.records
117 .iter()
118 .filter_map(|r| r.proposal.as_ref().map(|p| (r.seat.as_str(), p)))
119 .collect()
120 }
121}
122
123const MIN_TOKEN_LEN: usize = 5;
127
128const STRONG_OVERLAP: f64 = 0.25;
133
134fn tokens(text: &str) -> Vec<String> {
136 let mut words: Vec<String> = text
137 .split(|c: char| !c.is_alphanumeric())
138 .map(str::to_lowercase)
139 .filter(|w| w.len() >= MIN_TOKEN_LEN)
140 .collect();
141 words.sort();
142 words.dedup();
143 words
144}
145
146fn classify(record: &AdvisorRecord, synthesis_lower: &str) -> Reflection {
154 let Some(proposal) = record.proposal.as_ref() else {
155 return Reflection::Absent;
156 };
157 if synthesis_lower.is_empty() {
158 return Reflection::Faint;
159 }
160 if synthesis_lower.contains(&record.seat.to_lowercase()) {
161 return Reflection::Strong;
162 }
163 let mut words = tokens(&proposal.approach);
164 words.extend(tokens(&proposal.key_tradeoff));
165 for touch in &proposal.touches {
166 words.extend(tokens(touch));
167 }
168 words.sort();
169 words.dedup();
170 if words.is_empty() {
171 return Reflection::Faint;
172 }
173 let hits = words
174 .iter()
175 .filter(|w| synthesis_lower.contains(w.as_str()))
176 .count();
177 if (hits as f64) / (words.len() as f64) >= STRONG_OVERLAP {
178 Reflection::Strong
179 } else {
180 Reflection::Faint
181 }
182}
183
184pub fn apply_reflection(advice: &mut Advice) {
190 let synthesis_lower = advice
191 .synthesis
192 .as_deref()
193 .unwrap_or_default()
194 .to_lowercase();
195 for record in &mut advice.records {
196 record.reflection = classify(record, &synthesis_lower);
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203
204 fn proposal(approach: &str, key_tradeoff: &str, touches: &[&str]) -> Proposal {
205 Proposal {
206 approach: approach.to_owned(),
207 key_tradeoff: key_tradeoff.to_owned(),
208 risks: Vec::new(),
209 touches: touches.iter().map(|s| (*s).to_owned()).collect(),
210 why_not_naive: "because the naive version breaks under load".to_owned(),
211 }
212 }
213
214 #[test]
215 fn advice_proposals_skips_failed_records() {
216 let advice = Advice {
217 records: vec![
218 AdvisorRecord::proposed(1, "a".to_owned(), proposal("do X", "t", &[]), 10),
219 AdvisorRecord::failed(2, "b".to_owned(), "timed out".to_owned()),
220 ],
221 synthesis: None,
222 };
223 let proposals = advice.proposals();
224 assert_eq!(proposals.len(), 1);
225 assert_eq!(proposals[0].0, "advisor-1");
226 }
227
228 #[test]
229 fn a_failed_seat_is_classified_absent_regardless_of_synthesis() {
230 let record = AdvisorRecord::failed(1, "a".to_owned(), "crashed".to_owned());
231 assert_eq!(
232 classify(&record, "a synthesis that mentions advisor-1 by name"),
233 Reflection::Absent
234 );
235 }
236
237 #[test]
238 fn an_explicit_seat_mention_is_strong_even_with_no_word_overlap() {
239 let record = AdvisorRecord::proposed(
240 1,
241 "a".to_owned(),
242 proposal("switch to polling", "latency", &["src/watch.rs"]),
243 10,
244 );
245 let synthesis = "advisor-1 argued for a completely different rewrite.".to_lowercase();
246 assert_eq!(classify(&record, &synthesis), Reflection::Strong);
247 }
248
249 #[test]
250 fn strong_word_overlap_counts_without_a_seat_mention() {
251 let record = AdvisorRecord::proposed(
252 1,
253 "a".to_owned(),
254 proposal(
255 "switch the poller to exponential backoff",
256 "latency versus battery",
257 &["src/watch.rs"],
258 ),
259 10,
260 );
261 let synthesis =
262 "the plan settles on exponential backoff in the poller, touching src/watch.rs."
263 .to_lowercase();
264 assert_eq!(classify(&record, &synthesis), Reflection::Strong);
265 }
266
267 #[test]
268 fn no_overlap_and_no_mention_is_faint_not_absent() {
269 let record = AdvisorRecord::proposed(
270 1,
271 "a".to_owned(),
272 proposal("switch to polling", "latency", &["src/watch.rs"]),
273 10,
274 );
275 let synthesis = "the brief goes an entirely unrelated direction.".to_lowercase();
276 assert_eq!(classify(&record, &synthesis), Reflection::Faint);
277 }
278
279 #[test]
280 fn no_synthesis_at_all_is_faint_for_every_proposal() {
281 let record = AdvisorRecord::proposed(1, "a".to_owned(), proposal("do X", "t", &[]), 10);
282 assert_eq!(classify(&record, ""), Reflection::Faint);
283 }
284
285 #[test]
286 fn apply_reflection_covers_every_record_including_failed_ones() {
287 let mut advice = Advice {
288 records: vec![
289 AdvisorRecord::proposed(
290 1,
291 "a".to_owned(),
292 proposal("switch to polling", "latency", &["src/watch.rs"]),
293 10,
294 ),
295 AdvisorRecord::failed(2, "b".to_owned(), "timed out".to_owned()),
296 ],
297 synthesis: Some("advisor-1 argued for polling, which the brief adopts.".to_owned()),
298 };
299 apply_reflection(&mut advice);
300 assert_eq!(advice.records[0].reflection, Reflection::Strong);
301 assert_eq!(advice.records[1].reflection, Reflection::Absent);
302 }
303}