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
//! Data and pure logic for the design-deliberation stage.
//!
//! Gathering the advisor proposals and running the synthesis seat is
//! [`crate::graph::Runner::advise`]'s job - a graph node, wired the same way
//! `judge` and `review` already are, run once a run has a settled task
//! instruction and before any implementer touches the repository. This
//! module only holds what that node produces ([`AdvisorRecord`],
//! [`Advice`]) and the pure, process-free logic for reading it back
//! ([`Advice::proposals`], [`apply_reflection`]) - split out so both are unit
//! testable without spawning an agent.
use serde::{Deserialize, Serialize};
use crate::verdict::Proposal;
/// How much of an advisor's proposal the synthesis brief appears to carry
/// forward.
///
/// Approximate by construction: there is no explicit data saying which
/// sentence of a blended brief came from which seat, only whether the
/// brief's own text names the seat or carries recognisable traces of its
/// proposal. See [`apply_reflection`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Reflection {
/// The seat produced no proposal at all (crashed, timed out, or answered
/// with nothing a [`Proposal`] could be parsed out of) - nothing to
/// reflect.
Absent,
/// A proposal exists, but the synthesis brief carries little or no
/// recognisable trace of it.
Faint,
/// The synthesis brief names this seat outright, or carries enough of
/// its proposal's own wording to be a clear match.
Strong,
}
impl Default for Reflection {
/// The safe reading for a record nothing has classified yet: until
/// [`apply_reflection`] runs, or when there never was a synthesis to
/// compare against, "nothing shown as reflected" is correct whether or
/// not a proposal exists.
fn default() -> Self {
Self::Absent
}
}
/// One advisor seat's outcome, kept even on failure so a synthesis that only
/// had some of the seats to work with is not a mystery later.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdvisorRecord {
/// Seat name, e.g. `advisor-1`.
pub seat: String,
/// Agent id occupying the seat.
pub agent: String,
/// The proposal, when the seat produced a usable one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub proposal: Option<Proposal>,
/// Why there is no proposal, when there is not one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
/// Wall-clock duration.
pub duration_ms: u64,
/// How much of this proposal the synthesis appears to carry forward.
/// `#[serde(default)]` so a record predating this field, or one written
/// before [`apply_reflection`] ran, reads as [`Reflection::Absent`]
/// rather than failing to deserialize.
#[serde(default)]
pub reflection: Reflection,
}
impl AdvisorRecord {
/// A seat that produced a usable, validated proposal.
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,
}
}
/// A seat that crashed, timed out, or answered with nothing a
/// [`Proposal`] could be read out of.
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,
}
}
}
/// The whole design-deliberation stage: one record per advisor seat, plus
/// the synthesis blended from whichever seats produced a usable proposal.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Advice {
/// One record per advisor seat asked.
pub records: Vec<AdvisorRecord>,
/// The blended design brief carried in the implementer's prompt, when a
/// synthesis seat produced one. `None` when no advisor produced a usable
/// proposal, or the synthesis seat itself failed - the implementer then
/// gets the task instruction alone, same as a run with `[graph] advise`
/// off.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub synthesis: Option<String>,
}
impl Advice {
/// The seats that produced a usable proposal, in seat order.
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()
}
}
/// Tokens shorter than this are common enough to turn up in any prose by
/// chance ("the", "with", "into"), which would inflate every proposal's
/// overlap score regardless of whether the synthesis actually drew on it.
const MIN_TOKEN_LEN: usize = 5;
/// Fraction of an advisor's own significant words that must show up in the
/// synthesis text, verbatim, before [`classify`] counts the overlap alone as
/// [`Reflection::Strong`]. An explicit mention of the seat's own name always
/// counts on its own, regardless of this ratio - see [`classify`].
const STRONG_OVERLAP: f64 = 0.25;
/// Lowercased, deduplicated words of at least [`MIN_TOKEN_LEN`] characters.
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
}
/// One record's [`Reflection`] against a lowercased synthesis text.
///
/// Two signals, checked in order: an outright mention of the seat's own name
/// (the synthesis prompt asks the seat to attribute ideas that way, so this
/// is the strong, unambiguous case), then a fallback word-overlap ratio over
/// the proposal's own significant vocabulary (`approach`, `key_tradeoff`,
/// `touches`) for a synthesis that paraphrased without naming anyone.
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
}
}
/// Classify every record's [`Reflection`] against `advice.synthesis`, once
/// the synthesis text (or its absence) is known.
///
/// Idempotent and process-free: safe to call once after the synthesis call
/// settles, whatever it produced.
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);
}
}