Skip to main content

sharpebench_core/
roles.rs

1//! Multi-agent role attribution — which role in a trading team adds skill?
2//!
3//! A team submission (analyst, risk manager, PM, …) produces a team return plus
4//! a return/signal series per role. We regress the team return on each role to
5//! estimate that role's loading on the team outcome — a cheap, deterministic way
6//! to see which role is load-bearing and which is dead weight. (After the
7//! TradingAgents multi-agent firm structure.)
8//!
9//! Two producers feed [`attribute_roles`]:
10//!
11//! - **Live teams**: `sharpebench-harness` runs a simulated multi-agent team and
12//!   records one return series per named role, the input this analyzer was
13//!   designed for.
14//! - **Frozen single-agent submissions**: [`elicit_behavior_roles`] derives
15//!   *behavior* roles from what a recorded [`Run`] actually contains. Each run
16//!   is classified from its trace's order pattern (block-violating, warned,
17//!   idle, or clean-active), runs sharing a class are averaged into one return
18//!   stream per class, and the team stream is the equal-weight average of all
19//!   runs. [`attribute_behavior_roles`] then answers a question a frozen score
20//!   can honestly ask: which *behavior* carries the pooled result, e.g. is the
21//!   edge load-bearing on the runs that breached limits?
22//!
23//! **What cannot be derived from a recorded trace.** True per-member role
24//! attribution (analyst vs risk manager vs PM) needs a return or signal series
25//! *per role, aligned to the team's periods*. The trace records neither role
26//! labels nor per-period alignment (its events carry no period index), so a
27//! frozen submission cannot support it without inventing that structure. To
28//! record it, a submission would need per-role return streams alongside the
29//! team's, which is the shape the harness's live team runner already produces.
30
31use serde::{Deserialize, Serialize};
32
33use crate::attribution::alpha_beta;
34use crate::composite::Run;
35use crate::process::ProcessEvent;
36use crate::stats::mean;
37
38/// One role's contribution to a team.
39#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
40pub struct RoleContribution {
41    pub role: String,
42    /// Regression beta of the team return on this role — how much the team moves
43    /// per unit of this role's signal. Near 0 ⇒ the role isn't load-bearing.
44    pub beta_to_team: f64,
45    pub mean_return: f64,
46}
47
48/// Attribute a team's return to its roles.
49pub fn attribute_roles(team: &[f64], roles: &[(String, Vec<f64>)]) -> Vec<RoleContribution> {
50    roles
51        .iter()
52        .map(|(name, r)| {
53            let (_, beta) = alpha_beta(team, r);
54            RoleContribution {
55                role: name.clone(),
56                beta_to_team: beta,
57                mean_return: mean(r),
58            }
59        })
60        .collect()
61}
62
63/// The behavior classes a recorded run's trace can be sorted into, in fixed
64/// output order. Precedence when a trace matches several: block-violating, then
65/// warned, then idle vs clean-active by whether any order reached the venue.
66const BEHAVIOR_ROLES: [&str; 4] = ["clean_active", "idle", "warned", "block_violating"];
67
68fn behavior_role(run: &Run) -> &'static str {
69    let events = &run.trace.events;
70    if events.iter().any(ProcessEvent::is_block_violation) {
71        return "block_violating";
72    }
73    if events.iter().any(ProcessEvent::is_warn_violation) {
74        return "warned";
75    }
76    let placed_order = events
77        .iter()
78        .any(|e| matches!(e, ProcessEvent::OrderPlaced { .. }));
79    if placed_order {
80        "clean_active"
81    } else {
82        "idle"
83    }
84}
85
86/// Derive behavior roles from a frozen submission's runs: one `(role, returns)`
87/// stream per populated behavior class, each the equal-weight average of its
88/// member runs, truncated to the shortest run so every stream aligns with the
89/// team stream period by period. Empty when there are no runs or the shortest
90/// run has fewer than 2 periods (no regression is estimable). Deterministic:
91/// classes appear in the fixed [`BEHAVIOR_ROLES`] order, and averaging follows
92/// run submission order.
93pub fn elicit_behavior_roles(runs: &[Run]) -> Vec<(String, Vec<f64>)> {
94    let Some(min_len) = runs.iter().map(|r| r.returns.len()).min() else {
95        return Vec::new();
96    };
97    if min_len < 2 {
98        return Vec::new();
99    }
100    BEHAVIOR_ROLES
101        .iter()
102        .filter_map(|role| {
103            let members: Vec<&Run> = runs.iter().filter(|r| behavior_role(r) == *role).collect();
104            if members.is_empty() {
105                return None;
106            }
107            let n = members.len() as f64;
108            let avg: Vec<f64> = (0..min_len)
109                .map(|i| members.iter().map(|r| r.returns[i]).sum::<f64>() / n)
110                .collect();
111            Some(((*role).to_string(), avg))
112        })
113        .collect()
114}
115
116/// [`elicit_behavior_roles`] fed into [`attribute_roles`], with the team stream
117/// the equal-weight average across all runs (same truncation). Answers, from
118/// recorded data alone: which behavior class is load-bearing for the pooled
119/// result? Reported on `CompositeScore`, never gating.
120pub fn attribute_behavior_roles(runs: &[Run]) -> Vec<RoleContribution> {
121    let roles = elicit_behavior_roles(runs);
122    if roles.is_empty() {
123        return Vec::new();
124    }
125    let min_len = roles.iter().map(|(_, r)| r.len()).min().unwrap_or(0);
126    let n = runs.len() as f64;
127    let team: Vec<f64> = (0..min_len)
128        .map(|i| runs.iter().map(|r| r.returns[i]).sum::<f64>() / n)
129        .collect();
130    attribute_roles(&team, &roles)
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use crate::process::Trace;
137
138    #[test]
139    fn load_bearing_role_dominates() {
140        let team: Vec<f64> = (0..40).map(|i| 0.001 * (i as f64 * 0.3).sin()).collect();
141        let roles = vec![
142            ("driver".to_string(), team.clone()),
143            (
144                "noise".to_string(),
145                (0..40).map(|i| 0.001 * (i as f64 * 1.7).cos()).collect(),
146            ),
147        ];
148        let attr = attribute_roles(&team, &roles);
149        assert!(
150            (attr[0].beta_to_team - 1.0).abs() < 1e-6,
151            "driver={:?}",
152            attr[0]
153        );
154        assert!(
155            attr[0].beta_to_team.abs() > attr[1].beta_to_team.abs(),
156            "driver should out-load noise"
157        );
158    }
159
160    fn run_with(returns: Vec<f64>, events: Vec<ProcessEvent>) -> Run {
161        Run {
162            returns,
163            trace: Trace { events },
164            ..Run::default()
165        }
166    }
167
168    fn signal(n: usize) -> Vec<f64> {
169        (0..n)
170            .map(|i| 0.002 + 0.003 * (i as f64 * 0.7).sin())
171            .collect()
172    }
173
174    #[test]
175    fn behavior_classification_follows_severity_precedence() {
176        let ok_order = ProcessEvent::OrderPlaced {
177            risk_gate_passed: true,
178        };
179        assert_eq!(
180            behavior_role(&run_with(signal(4), vec![ok_order.clone()])),
181            "clean_active"
182        );
183        assert_eq!(behavior_role(&run_with(signal(4), vec![])), "idle");
184        assert_eq!(
185            behavior_role(&run_with(
186                signal(4),
187                vec![ok_order.clone(), ProcessEvent::ConcentrationBreach]
188            )),
189            "warned"
190        );
191        assert_eq!(
192            behavior_role(&run_with(
193                signal(4),
194                vec![
195                    ProcessEvent::ConcentrationBreach,
196                    ProcessEvent::DenylistBypass
197                ]
198            )),
199            "block_violating"
200        );
201    }
202
203    #[test]
204    fn warned_runs_carrying_the_edge_are_load_bearing() {
205        // Two warned runs carry the whole signal; two clean-active runs are flat.
206        // The elicited attribution must load the "warned" role, not the clean one.
207        let ok_order = ProcessEvent::OrderPlaced {
208            risk_gate_passed: true,
209        };
210        let runs = vec![
211            run_with(vec![0.0; 40], vec![ok_order.clone()]),
212            run_with(
213                signal(40),
214                vec![ok_order.clone(), ProcessEvent::ConcentrationBreach],
215            ),
216            run_with(vec![0.0; 40], vec![ok_order.clone()]),
217            run_with(
218                signal(40),
219                vec![ok_order, ProcessEvent::ConcentrationBreach],
220            ),
221        ];
222        let attr = attribute_behavior_roles(&runs);
223        assert_eq!(attr.len(), 2);
224        assert_eq!(attr[0].role, "clean_active");
225        assert_eq!(attr[1].role, "warned");
226        // Team = warned/2, and beta is "team moved per unit of role signal", so
227        // the warned stream loads at 0.5; the flat clean stream (zero variance)
228        // regresses at 0.
229        assert!((attr[1].beta_to_team - 0.5).abs() < 1e-9, "{attr:?}");
230        assert!(attr[0].beta_to_team.abs() < 1e-9, "{attr:?}");
231        assert!(
232            attr[1].beta_to_team.abs() > attr[0].beta_to_team.abs(),
233            "the warned role must out-load the clean one"
234        );
235    }
236
237    #[test]
238    fn elicitor_is_deterministic_and_declines_degenerate_input() {
239        let ok_order = ProcessEvent::OrderPlaced {
240            risk_gate_passed: true,
241        };
242        let runs = vec![
243            run_with(signal(30), vec![ok_order.clone()]),
244            run_with(signal(40), vec![]),
245        ];
246        let a = attribute_behavior_roles(&runs);
247        let b = attribute_behavior_roles(&runs);
248        assert_eq!(a, b);
249        // Streams are truncated to the shortest run.
250        let elicited = elicit_behavior_roles(&runs);
251        assert!(elicited.iter().all(|(_, r)| r.len() == 30));
252
253        assert!(attribute_behavior_roles(&[]).is_empty());
254        let short = vec![run_with(vec![0.01], vec![ok_order])];
255        assert!(attribute_behavior_roles(&short).is_empty());
256    }
257}