use serde::{Deserialize, Serialize};
use crate::verdict::Proposal;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Reflection {
Absent,
Faint,
Strong,
}
impl Default for Reflection {
fn default() -> Self {
Self::Absent
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdvisorRecord {
pub seat: String,
pub agent: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub proposal: Option<Proposal>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
pub duration_ms: u64,
#[serde(default)]
pub reflection: Reflection,
}
impl AdvisorRecord {
pub fn proposed(seat_num: usize, agent: String, proposal: Proposal, duration_ms: u64) -> Self {
Self {
seat: format!("advisor-{seat_num}"),
agent,
proposal: Some(proposal),
error: None,
duration_ms,
reflection: Reflection::Absent,
}
}
pub fn failed(seat_num: usize, agent: String, error: String) -> Self {
Self {
seat: format!("advisor-{seat_num}"),
agent,
proposal: None,
error: Some(error),
duration_ms: 0,
reflection: Reflection::Absent,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Advice {
pub records: Vec<AdvisorRecord>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub synthesis: Option<String>,
}
impl Advice {
pub fn proposals(&self) -> Vec<(&str, &Proposal)> {
self.records
.iter()
.filter_map(|r| r.proposal.as_ref().map(|p| (r.seat.as_str(), p)))
.collect()
}
}
const MIN_TOKEN_LEN: usize = 5;
const STRONG_OVERLAP: f64 = 0.25;
fn tokens(text: &str) -> Vec<String> {
let mut words: Vec<String> = text
.split(|c: char| !c.is_alphanumeric())
.map(str::to_lowercase)
.filter(|w| w.len() >= MIN_TOKEN_LEN)
.collect();
words.sort();
words.dedup();
words
}
fn classify(record: &AdvisorRecord, synthesis_lower: &str) -> Reflection {
let Some(proposal) = record.proposal.as_ref() else {
return Reflection::Absent;
};
if synthesis_lower.is_empty() {
return Reflection::Faint;
}
if synthesis_lower.contains(&record.seat.to_lowercase()) {
return Reflection::Strong;
}
let mut words = tokens(&proposal.approach);
words.extend(tokens(&proposal.key_tradeoff));
for touch in &proposal.touches {
words.extend(tokens(touch));
}
words.sort();
words.dedup();
if words.is_empty() {
return Reflection::Faint;
}
let hits = words
.iter()
.filter(|w| synthesis_lower.contains(w.as_str()))
.count();
if (hits as f64) / (words.len() as f64) >= STRONG_OVERLAP {
Reflection::Strong
} else {
Reflection::Faint
}
}
pub fn apply_reflection(advice: &mut Advice) {
let synthesis_lower = advice
.synthesis
.as_deref()
.unwrap_or_default()
.to_lowercase();
for record in &mut advice.records {
record.reflection = classify(record, &synthesis_lower);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn proposal(approach: &str, key_tradeoff: &str, touches: &[&str]) -> Proposal {
Proposal {
approach: approach.to_owned(),
key_tradeoff: key_tradeoff.to_owned(),
risks: Vec::new(),
touches: touches.iter().map(|s| (*s).to_owned()).collect(),
why_not_naive: "because the naive version breaks under load".to_owned(),
}
}
#[test]
fn advice_proposals_skips_failed_records() {
let advice = Advice {
records: vec![
AdvisorRecord::proposed(1, "a".to_owned(), proposal("do X", "t", &[]), 10),
AdvisorRecord::failed(2, "b".to_owned(), "timed out".to_owned()),
],
synthesis: None,
};
let proposals = advice.proposals();
assert_eq!(proposals.len(), 1);
assert_eq!(proposals[0].0, "advisor-1");
}
#[test]
fn a_failed_seat_is_classified_absent_regardless_of_synthesis() {
let record = AdvisorRecord::failed(1, "a".to_owned(), "crashed".to_owned());
assert_eq!(
classify(&record, "a synthesis that mentions advisor-1 by name"),
Reflection::Absent
);
}
#[test]
fn an_explicit_seat_mention_is_strong_even_with_no_word_overlap() {
let record = AdvisorRecord::proposed(
1,
"a".to_owned(),
proposal("switch to polling", "latency", &["src/watch.rs"]),
10,
);
let synthesis = "advisor-1 argued for a completely different rewrite.".to_lowercase();
assert_eq!(classify(&record, &synthesis), Reflection::Strong);
}
#[test]
fn strong_word_overlap_counts_without_a_seat_mention() {
let record = AdvisorRecord::proposed(
1,
"a".to_owned(),
proposal(
"switch the poller to exponential backoff",
"latency versus battery",
&["src/watch.rs"],
),
10,
);
let synthesis =
"the plan settles on exponential backoff in the poller, touching src/watch.rs."
.to_lowercase();
assert_eq!(classify(&record, &synthesis), Reflection::Strong);
}
#[test]
fn no_overlap_and_no_mention_is_faint_not_absent() {
let record = AdvisorRecord::proposed(
1,
"a".to_owned(),
proposal("switch to polling", "latency", &["src/watch.rs"]),
10,
);
let synthesis = "the brief goes an entirely unrelated direction.".to_lowercase();
assert_eq!(classify(&record, &synthesis), Reflection::Faint);
}
#[test]
fn no_synthesis_at_all_is_faint_for_every_proposal() {
let record = AdvisorRecord::proposed(1, "a".to_owned(), proposal("do X", "t", &[]), 10);
assert_eq!(classify(&record, ""), Reflection::Faint);
}
#[test]
fn apply_reflection_covers_every_record_including_failed_ones() {
let mut advice = Advice {
records: vec![
AdvisorRecord::proposed(
1,
"a".to_owned(),
proposal("switch to polling", "latency", &["src/watch.rs"]),
10,
),
AdvisorRecord::failed(2, "b".to_owned(), "timed out".to_owned()),
],
synthesis: Some("advisor-1 argued for polling, which the brief adopts.".to_owned()),
};
apply_reflection(&mut advice);
assert_eq!(advice.records[0].reflection, Reflection::Strong);
assert_eq!(advice.records[1].reflection, Reflection::Absent);
}
}