Skip to main content

magi/
advise.rs

1//! Data and pure logic for the design-deliberation stage.
2//!
3//! Gathering the advisor proposals and running the synthesis seat is
4//! [`crate::graph::Runner::advise`]'s job - a graph node, wired the same way
5//! `judge` and `review` already are, run once a run has a settled task
6//! instruction and before any implementer touches the repository. This
7//! module only holds what that node produces ([`AdvisorRecord`],
8//! [`Advice`]) and the pure, process-free logic for reading it back
9//! ([`Advice::proposals`], [`apply_reflection`]) - split out so both are unit
10//! testable without spawning an agent.
11use serde::{Deserialize, Serialize};
12
13use crate::verdict::Proposal;
14
15/// How much of an advisor's proposal the synthesis brief appears to carry
16/// forward.
17///
18/// Approximate by construction: there is no explicit data saying which
19/// sentence of a blended brief came from which seat, only whether the
20/// brief's own text names the seat or carries recognisable traces of its
21/// proposal. See [`apply_reflection`].
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum Reflection {
25    /// The seat produced no proposal at all (crashed, timed out, or answered
26    /// with nothing a [`Proposal`] could be parsed out of) - nothing to
27    /// reflect.
28    Absent,
29    /// A proposal exists, but the synthesis brief carries little or no
30    /// recognisable trace of it.
31    Faint,
32    /// The synthesis brief names this seat outright, or carries enough of
33    /// its proposal's own wording to be a clear match.
34    Strong,
35}
36
37impl Default for Reflection {
38    /// The safe reading for a record nothing has classified yet: until
39    /// [`apply_reflection`] runs, or when there never was a synthesis to
40    /// compare against, "nothing shown as reflected" is correct whether or
41    /// not a proposal exists.
42    fn default() -> Self {
43        Self::Absent
44    }
45}
46
47/// One advisor seat's outcome, kept even on failure so a synthesis that only
48/// had some of the seats to work with is not a mystery later.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct AdvisorRecord {
51    /// Seat name, e.g. `advisor-1`.
52    pub seat: String,
53    /// Agent id occupying the seat.
54    pub agent: String,
55    /// The proposal, when the seat produced a usable one.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub proposal: Option<Proposal>,
58    /// Why there is no proposal, when there is not one.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub error: Option<String>,
61    /// Wall-clock duration.
62    pub duration_ms: u64,
63    /// How much of this proposal the synthesis appears to carry forward.
64    /// `#[serde(default)]` so a record predating this field, or one written
65    /// before [`apply_reflection`] ran, reads as [`Reflection::Absent`]
66    /// rather than failing to deserialize.
67    #[serde(default)]
68    pub reflection: Reflection,
69}
70
71impl AdvisorRecord {
72    /// A seat that produced a usable, validated proposal.
73    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    /// A seat that crashed, timed out, or answered with nothing a
85    /// [`Proposal`] could be read out of.
86    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/// The whole design-deliberation stage: one record per advisor seat, plus
99/// the synthesis blended from whichever seats produced a usable proposal.
100#[derive(Debug, Clone, Serialize, Deserialize, Default)]
101pub struct Advice {
102    /// One record per advisor seat asked.
103    pub records: Vec<AdvisorRecord>,
104    /// The blended design brief carried in the implementer's prompt, when a
105    /// synthesis seat produced one. `None` when no advisor produced a usable
106    /// proposal, or the synthesis seat itself failed - the implementer then
107    /// gets the task instruction alone, same as a run with `[graph] advise`
108    /// off.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub synthesis: Option<String>,
111}
112
113impl Advice {
114    /// The seats that produced a usable proposal, in seat order.
115    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
123/// Tokens shorter than this are common enough to turn up in any prose by
124/// chance ("the", "with", "into"), which would inflate every proposal's
125/// overlap score regardless of whether the synthesis actually drew on it.
126const MIN_TOKEN_LEN: usize = 5;
127
128/// Fraction of an advisor's own significant words that must show up in the
129/// synthesis text, verbatim, before [`classify`] counts the overlap alone as
130/// [`Reflection::Strong`]. An explicit mention of the seat's own name always
131/// counts on its own, regardless of this ratio - see [`classify`].
132const STRONG_OVERLAP: f64 = 0.25;
133
134/// Lowercased, deduplicated words of at least [`MIN_TOKEN_LEN`] characters.
135fn 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
146/// One record's [`Reflection`] against a lowercased synthesis text.
147///
148/// Two signals, checked in order: an outright mention of the seat's own name
149/// (the synthesis prompt asks the seat to attribute ideas that way, so this
150/// is the strong, unambiguous case), then a fallback word-overlap ratio over
151/// the proposal's own significant vocabulary (`approach`, `key_tradeoff`,
152/// `touches`) for a synthesis that paraphrased without naming anyone.
153fn 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
184/// Classify every record's [`Reflection`] against `advice.synthesis`, once
185/// the synthesis text (or its absence) is known.
186///
187/// Idempotent and process-free: safe to call once after the synthesis call
188/// settles, whatever it produced.
189pub 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}