use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RowKind {
Fact,
Uncertainty,
Counterpoint,
}
impl RowKind {
fn is_counterbalance(self) -> bool {
matches!(self, RowKind::Uncertainty | RowKind::Counterpoint)
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BriefingRow {
pub text: String,
pub kind: RowKind,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BriefingSection {
pub asset_area: String,
pub rows: Vec<BriefingRow>,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TableOrdering {
OptionOrder,
Performance,
Unspecified,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ReturnTable {
pub ordering: TableOrdering,
pub entries: Vec<ReturnEntry>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ReturnEntry {
pub label: String,
pub trailing_return: f64,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct Briefing {
pub sections: Vec<BriefingSection>,
pub return_table: Option<ReturnTable>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BriefingPolicy {
pub max_rows_per_area: usize,
pub require_counterbalance: bool,
pub require_option_order_sort: bool,
pub max_area_salience: f64,
}
impl Default for BriefingPolicy {
fn default() -> Self {
BriefingPolicy {
max_rows_per_area: 5,
require_counterbalance: true,
require_option_order_sort: true,
max_area_salience: 0.5,
}
}
}
#[derive(Clone, Debug, Serialize, PartialEq)]
#[serde(tag = "violation", rename_all = "snake_case")]
pub enum BriefingViolation {
AssetAreaOverweight {
asset_area: String,
rows: usize,
cap: usize,
},
MissingCounterbalance { asset_area: String },
PerformanceSortedTable,
SalienceImbalance { asset_area: String, salience: f64 },
}
#[derive(Clone, Debug, Serialize, PartialEq)]
pub struct AreaSalience {
pub asset_area: String,
pub row_count: usize,
pub salience: f64,
}
#[derive(Clone, Debug, Serialize)]
pub struct BriefingAudit {
pub balanced: bool,
pub violations: Vec<BriefingViolation>,
pub salience: Vec<AreaSalience>,
}
pub fn audit_briefing(briefing: &Briefing, policy: &BriefingPolicy) -> BriefingAudit {
let mut violations = Vec::new();
let total_rows: usize = briefing.sections.iter().map(|s| s.rows.len()).sum();
let mut salience = Vec::with_capacity(briefing.sections.len());
for section in &briefing.sections {
let n = section.rows.len();
let share = if total_rows == 0 {
0.0
} else {
n as f64 / total_rows as f64
};
salience.push(AreaSalience {
asset_area: section.asset_area.clone(),
row_count: n,
salience: share,
});
if n > policy.max_rows_per_area {
violations.push(BriefingViolation::AssetAreaOverweight {
asset_area: section.asset_area.clone(),
rows: n,
cap: policy.max_rows_per_area,
});
}
if policy.require_counterbalance {
let has_fact = section.rows.iter().any(|r| r.kind == RowKind::Fact);
let has_balance = section.rows.iter().any(|r| r.kind.is_counterbalance());
if has_fact && !has_balance {
violations.push(BriefingViolation::MissingCounterbalance {
asset_area: section.asset_area.clone(),
});
}
}
if briefing.sections.len() > 1 && share > policy.max_area_salience {
violations.push(BriefingViolation::SalienceImbalance {
asset_area: section.asset_area.clone(),
salience: share,
});
}
}
if policy.require_option_order_sort {
if let Some(table) = &briefing.return_table {
if table.ordering == TableOrdering::Performance {
violations.push(BriefingViolation::PerformanceSortedTable);
}
}
}
BriefingAudit {
balanced: violations.is_empty(),
violations,
salience,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn row(text: &str, kind: RowKind) -> BriefingRow {
BriefingRow {
text: text.to_string(),
kind,
}
}
fn balanced_section(area: &str) -> BriefingSection {
BriefingSection {
asset_area: area.to_string(),
rows: vec![
row("earnings beat", RowKind::Fact),
row("but guidance is soft", RowKind::Uncertainty),
],
}
}
#[test]
fn a_balanced_briefing_passes() {
let b = Briefing {
sections: vec![balanced_section("energy"), balanced_section("rates")],
return_table: Some(ReturnTable {
ordering: TableOrdering::OptionOrder,
entries: vec![
ReturnEntry {
label: "A".into(),
trailing_return: 0.1,
},
ReturnEntry {
label: "B".into(),
trailing_return: 0.2,
},
],
}),
};
let audit = audit_briefing(&b, &BriefingPolicy::default());
assert!(audit.balanced, "violations: {:?}", audit.violations);
assert_eq!(audit.salience.len(), 2);
}
#[test]
fn asset_area_overweight_flags() {
let mut heavy = balanced_section("energy");
heavy.rows = vec![
row("f1", RowKind::Fact),
row("f2", RowKind::Fact),
row("f3", RowKind::Fact),
row("f4", RowKind::Fact),
row("f5", RowKind::Fact),
row("u1", RowKind::Uncertainty),
];
let b = Briefing {
sections: vec![heavy, balanced_section("rates")],
return_table: None,
};
let audit = audit_briefing(&b, &BriefingPolicy::default());
assert!(!audit.balanced);
assert!(audit.violations.iter().any(|v| matches!(
v,
BriefingViolation::AssetAreaOverweight { asset_area, rows: 6, cap: 5 }
if asset_area == "energy"
)));
}
#[test]
fn performance_sorted_table_flags() {
let b = Briefing {
sections: vec![balanced_section("energy"), balanced_section("rates")],
return_table: Some(ReturnTable {
ordering: TableOrdering::Performance,
entries: vec![ReturnEntry {
label: "A".into(),
trailing_return: 0.3,
}],
}),
};
let audit = audit_briefing(&b, &BriefingPolicy::default());
assert!(!audit.balanced);
assert!(audit
.violations
.contains(&BriefingViolation::PerformanceSortedTable));
}
#[test]
fn missing_counterbalance_flags() {
let one_sided = BriefingSection {
asset_area: "energy".into(),
rows: vec![row("bullish fact", RowKind::Fact)],
};
let b = Briefing {
sections: vec![one_sided, balanced_section("rates")],
return_table: None,
};
let audit = audit_briefing(&b, &BriefingPolicy::default());
assert!(!audit.balanced);
assert!(audit.violations.iter().any(|v| matches!(
v,
BriefingViolation::MissingCounterbalance { asset_area } if asset_area == "energy"
)));
}
#[test]
fn one_area_dominating_attention_flags_salience() {
let energy = BriefingSection {
asset_area: "energy".into(),
rows: vec![
row("f1", RowKind::Fact),
row("f2", RowKind::Fact),
row("u1", RowKind::Uncertainty),
row("c1", RowKind::Counterpoint),
],
};
let b = Briefing {
sections: vec![energy, balanced_section("rates")],
return_table: None,
};
let audit = audit_briefing(&b, &BriefingPolicy::default());
assert!(!audit.balanced);
assert!(audit.violations.iter().any(|v| matches!(
v,
BriefingViolation::SalienceImbalance { asset_area, .. } if asset_area == "energy"
)));
}
#[test]
fn empty_briefing_is_trivially_balanced() {
let audit = audit_briefing(&Briefing::default(), &BriefingPolicy::default());
assert!(audit.balanced);
assert!(audit.salience.is_empty());
}
}