use serde::{Deserialize, Serialize};
use crate::attribution::alpha_beta;
use crate::composite::Run;
use crate::process::ProcessEvent;
use crate::stats::mean;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RoleContribution {
pub role: String,
pub beta_to_team: f64,
pub mean_return: f64,
#[serde(default)]
pub periods: usize,
}
pub fn attribute_roles(team: &[f64], roles: &[(String, Vec<f64>)]) -> Vec<RoleContribution> {
roles
.iter()
.map(|(name, r)| {
let (_, beta) = alpha_beta(team, r);
RoleContribution {
role: name.clone(),
beta_to_team: beta,
mean_return: mean(r),
periods: team.len().min(r.len()),
}
})
.collect()
}
const BEHAVIOR_ROLES: [&str; 4] = ["clean_active", "idle", "warned", "block_violating"];
fn behavior_role(run: &Run) -> &'static str {
let events = &run.trace.events;
if events.iter().any(ProcessEvent::is_block_violation) {
return "block_violating";
}
if events.iter().any(ProcessEvent::is_warn_violation) {
return "warned";
}
let placed_order = events
.iter()
.any(|e| matches!(e, ProcessEvent::OrderPlaced { .. }));
if placed_order {
"clean_active"
} else {
"idle"
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct BehaviorRoleStream {
pub role: String,
pub team: Vec<f64>,
pub returns: Vec<f64>,
}
pub fn elicit_behavior_roles(runs: &[Run], seeds_per_window: usize) -> Vec<BehaviorRoleStream> {
let width = seeds_per_window.max(1);
if runs.is_empty() || !runs.len().is_multiple_of(width) {
return Vec::new();
}
let windows: Vec<&[Run]> = runs.chunks_exact(width).collect();
BEHAVIOR_ROLES
.iter()
.filter_map(|role| {
let mut team = Vec::new();
let mut returns = Vec::new();
let mut discriminating = false;
for window in &windows {
let members: Vec<&Run> = window
.iter()
.filter(|r| behavior_role(r) == *role)
.collect();
if members.is_empty() {
continue;
}
if members.len() < window.len() {
discriminating = true;
}
let len = window
.iter()
.map(|r| r.returns.len())
.min()
.unwrap_or_default();
let all = window.len() as f64;
let mine = members.len() as f64;
for t in 0..len {
team.push(window.iter().map(|r| r.returns[t]).sum::<f64>() / all);
returns.push(members.iter().map(|r| r.returns[t]).sum::<f64>() / mine);
}
}
if !discriminating || team.len() < 2 {
return None;
}
Some(BehaviorRoleStream {
role: (*role).to_string(),
team,
returns,
})
})
.collect()
}
pub fn attribute_behavior_roles(runs: &[Run], seeds_per_window: usize) -> Vec<RoleContribution> {
elicit_behavior_roles(runs, seeds_per_window)
.into_iter()
.map(|stream| {
debug_assert_eq!(
stream.team.len(),
stream.returns.len(),
"role {} and its team stream must cover the same retained sample",
stream.role
);
let paired = stream.team.len().min(stream.returns.len());
let (_, beta) = alpha_beta(&stream.team[..paired], &stream.returns[..paired]);
RoleContribution {
role: stream.role,
beta_to_team: beta,
mean_return: mean(&stream.returns),
periods: stream.returns.len(),
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::process::Trace;
#[test]
fn load_bearing_role_dominates() {
let team: Vec<f64> = (0..40).map(|i| 0.001 * (i as f64 * 0.3).sin()).collect();
let roles = vec![
("driver".to_string(), team.clone()),
(
"noise".to_string(),
(0..40).map(|i| 0.001 * (i as f64 * 1.7).cos()).collect(),
),
];
let attr = attribute_roles(&team, &roles);
assert!(
(attr[0].beta_to_team - 1.0).abs() < 1e-6,
"driver={:?}",
attr[0]
);
assert!(
attr[0].beta_to_team.abs() > attr[1].beta_to_team.abs(),
"driver should out-load noise"
);
}
fn run_with(returns: Vec<f64>, events: Vec<ProcessEvent>) -> Run {
Run {
returns,
trace: Trace { events },
..Run::default()
}
}
fn signal(n: usize) -> Vec<f64> {
(0..n)
.map(|i| 0.002 + 0.003 * (i as f64 * 0.7).sin())
.collect()
}
#[test]
fn behavior_classification_follows_severity_precedence() {
let ok_order = ProcessEvent::OrderPlaced {
risk_gate_passed: true,
};
assert_eq!(
behavior_role(&run_with(signal(4), vec![ok_order.clone()])),
"clean_active"
);
assert_eq!(behavior_role(&run_with(signal(4), vec![])), "idle");
assert_eq!(
behavior_role(&run_with(
signal(4),
vec![ok_order.clone(), ProcessEvent::ConcentrationBreach]
)),
"warned"
);
assert_eq!(
behavior_role(&run_with(
signal(4),
vec![
ProcessEvent::ConcentrationBreach,
ProcessEvent::DenylistBypass
]
)),
"block_violating"
);
}
#[test]
fn warned_runs_carrying_the_edge_are_load_bearing() {
let ok_order = ProcessEvent::OrderPlaced {
risk_gate_passed: true,
};
let runs = vec![
run_with(vec![0.0; 40], vec![ok_order.clone()]),
run_with(
signal(40),
vec![ok_order.clone(), ProcessEvent::ConcentrationBreach],
),
run_with(vec![0.0; 40], vec![ok_order.clone()]),
run_with(
signal(40),
vec![ok_order, ProcessEvent::ConcentrationBreach],
),
];
let attr = attribute_behavior_roles(&runs, 4);
assert_eq!(attr.len(), 2);
assert!(attr.iter().all(|c| c.periods == 40), "{attr:?}");
assert_eq!(attr[0].role, "clean_active");
assert_eq!(attr[1].role, "warned");
assert!((attr[1].beta_to_team - 0.5).abs() < 1e-9, "{attr:?}");
assert!(attr[0].beta_to_team.abs() < 1e-9, "{attr:?}");
assert!(
attr[1].beta_to_team.abs() > attr[0].beta_to_team.abs(),
"the warned role must out-load the clean one"
);
}
#[test]
fn elicitor_is_deterministic_and_declines_degenerate_input() {
let ok_order = ProcessEvent::OrderPlaced {
risk_gate_passed: true,
};
let runs = vec![
run_with(signal(30), vec![ok_order.clone()]),
run_with(signal(40), vec![]),
];
let a = attribute_behavior_roles(&runs, 2);
let b = attribute_behavior_roles(&runs, 2);
assert_eq!(a, b);
let elicited = elicit_behavior_roles(&runs, 2);
assert!(elicited.iter().all(|s| s.returns.len() == 30));
assert!(elicited.iter().all(|s| s.team.len() == 30));
assert!(attribute_behavior_roles(&[], 1).is_empty());
let short = vec![run_with(vec![0.01], vec![ok_order.clone()])];
assert!(attribute_behavior_roles(&short, 1).is_empty());
let ragged = vec![
run_with(signal(20), vec![ok_order.clone()]),
run_with(signal(20), vec![]),
run_with(signal(20), vec![ok_order]),
];
assert!(attribute_behavior_roles(&ragged, 2).is_empty());
}
#[test]
fn a_short_window_does_not_delete_a_long_windows_tail() {
let ok_order = ProcessEvent::OrderPlaced {
risk_gate_passed: true,
};
let tail: Vec<f64> = signal(40)
.into_iter()
.enumerate()
.map(|(i, v)| if i < 20 { 0.0 } else { v })
.collect();
let runs = vec![
run_with(vec![0.0; 40], vec![ok_order.clone()]),
run_with(
tail,
vec![ok_order.clone(), ProcessEvent::ConcentrationBreach],
),
run_with(vec![0.0; 8], vec![ok_order.clone()]),
run_with(vec![0.0; 8], vec![ok_order]),
];
let attr = attribute_behavior_roles(&runs, 2);
let warned = attr
.iter()
.find(|c| c.role == "warned")
.expect("warned is estimable in window 1");
assert_eq!(warned.periods, 40, "{attr:?}");
assert!((warned.beta_to_team - 0.5).abs() < 1e-9, "{attr:?}");
let clean = attr
.iter()
.find(|c| c.role == "clean_active")
.expect("clean_active occurs in both windows");
assert_eq!(clean.periods, 48, "{attr:?}");
}
#[test]
fn one_execution_per_window_is_not_estimable() {
let ok_order = ProcessEvent::OrderPlaced {
risk_gate_passed: true,
};
let runs = vec![
run_with(signal(40), vec![ok_order.clone()]),
run_with(
signal(40),
vec![ok_order.clone(), ProcessEvent::ConcentrationBreach],
),
run_with(vec![0.0; 40], vec![ok_order]),
run_with(signal(40), vec![]),
];
assert!(
attribute_behavior_roles(&runs, 1).is_empty(),
"{:?}",
attribute_behavior_roles(&runs, 1)
);
assert!(!attribute_behavior_roles(&runs, 4).is_empty());
}
}