sharpebench_core/
roles.rs1use serde::{Deserialize, Serialize};
32
33use crate::attribution::alpha_beta;
34use crate::composite::Run;
35use crate::process::ProcessEvent;
36use crate::stats::mean;
37
38#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
40pub struct RoleContribution {
41 pub role: String,
42 pub beta_to_team: f64,
45 pub mean_return: f64,
46}
47
48pub 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
63const 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
86pub 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
116pub 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 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 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 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}