use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[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)]
#[serde(default)]
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 {
MissingAssetArea { section_index: usize },
InvalidSalienceThreshold,
AssetAreaOverweight {
asset_area: String,
rows: usize,
cap: usize,
},
MissingCounterbalance { asset_area: String },
PerformanceSortedTable,
UnspecifiedTableOrdering,
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();
if !policy.max_area_salience.is_finite() || !(0.0..=1.0).contains(&policy.max_area_salience) {
violations.push(BriefingViolation::InvalidSalienceThreshold);
}
let total_rows: usize = briefing.sections.iter().map(|s| s.rows.len()).sum();
let mut areas: BTreeMap<String, Vec<&BriefingRow>> = BTreeMap::new();
for (section_index, section) in briefing.sections.iter().enumerate() {
let area = section
.asset_area
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_lowercase();
if area.is_empty() {
violations.push(BriefingViolation::MissingAssetArea { section_index });
}
areas.entry(area).or_default().extend(§ion.rows);
}
let mut salience = Vec::with_capacity(areas.len());
for (asset_area, rows) in areas {
let n = rows.len();
let share = if total_rows == 0 {
0.0
} else {
n as f64 / total_rows as f64
};
salience.push(AreaSalience {
asset_area: asset_area.clone(),
row_count: n,
salience: share,
});
if n > policy.max_rows_per_area {
violations.push(BriefingViolation::AssetAreaOverweight {
asset_area: asset_area.clone(),
rows: n,
cap: policy.max_rows_per_area,
});
}
if policy.require_counterbalance {
let has_fact = rows.iter().any(|r| r.kind == RowKind::Fact);
let has_balance = rows.iter().any(|r| r.kind.is_counterbalance());
if has_fact && !has_balance {
violations.push(BriefingViolation::MissingCounterbalance {
asset_area: asset_area.clone(),
});
}
}
if share > policy.max_area_salience {
violations.push(BriefingViolation::SalienceImbalance {
asset_area,
salience: share,
});
}
}
if policy.require_option_order_sort {
if let Some(table) = &briefing.return_table {
match table.ordering {
TableOrdering::Performance => {
violations.push(BriefingViolation::PerformanceSortedTable)
}
TableOrdering::Unspecified => {
violations.push(BriefingViolation::UnspecifiedTableOrdering)
}
TableOrdering::OptionOrder => (),
}
}
}
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());
}
#[test]
fn splitting_one_normalized_area_cannot_evade_caps() {
let briefing = Briefing {
sections: vec![
balanced_section(" Energy "),
balanced_section("ENERGY"),
balanced_section("energy"),
balanced_section("rates"),
],
return_table: None,
};
let audit = audit_briefing(&briefing, &BriefingPolicy::default());
assert!(!audit.balanced);
assert_eq!(audit.salience.len(), 2);
assert_eq!(
audit.salience[0],
AreaSalience {
asset_area: "energy".into(),
row_count: 6,
salience: 0.75,
}
);
assert!(audit
.violations
.contains(&BriefingViolation::AssetAreaOverweight {
asset_area: "energy".into(),
rows: 6,
cap: 5,
}));
assert!(audit
.violations
.contains(&BriefingViolation::SalienceImbalance {
asset_area: "energy".into(),
salience: 0.75,
}));
}
#[test]
fn normalization_and_section_order_do_not_change_area_accounting() {
let mut briefing = Briefing {
sections: vec![
balanced_section(" Fixed\t Income "),
balanced_section("fixed income"),
balanced_section("\u{00a0}ÉQUITIES"),
],
return_table: None,
};
let first = audit_briefing(&briefing, &BriefingPolicy::default());
briefing.sections.reverse();
let second = audit_briefing(&briefing, &BriefingPolicy::default());
assert_eq!(first.salience, second.salience);
assert_eq!(first.violations, second.violations);
assert_eq!(
first
.salience
.iter()
.map(|a| a.asset_area.as_str())
.collect::<Vec<_>>(),
vec!["fixed income", "équities"]
);
}
#[test]
fn counterbalance_can_be_in_another_section_of_the_same_area() {
let briefing = Briefing {
sections: vec![
BriefingSection {
asset_area: "energy".into(),
rows: vec![row("fact", RowKind::Fact)],
},
BriefingSection {
asset_area: "ENERGY".into(),
rows: vec![row("risk", RowKind::Uncertainty)],
},
balanced_section("rates"),
],
return_table: None,
};
assert!(audit_briefing(&briefing, &BriefingPolicy::default()).balanced);
}
#[test]
fn a_single_area_still_obeys_the_declared_salience_cap() {
let briefing = Briefing {
sections: vec![balanced_section("energy")],
return_table: None,
};
let mut policy = BriefingPolicy::default();
assert!(!audit_briefing(&briefing, &policy).balanced);
policy.max_area_salience = 1.0;
assert!(audit_briefing(&briefing, &policy).balanced);
}
#[test]
fn an_empty_area_identity_cannot_be_certified_balanced() {
let briefing = Briefing {
sections: vec![balanced_section(" \t"), balanced_section("rates")],
return_table: None,
};
let audit = audit_briefing(&briefing, &BriefingPolicy::default());
assert!(!audit.balanced);
assert!(serde_json::to_value(&audit).unwrap()["violations"]
.as_array()
.unwrap()
.iter()
.any(|v| v["violation"] == "missing_asset_area"));
}
#[test]
fn unspecified_table_order_is_not_verified_option_order() {
let briefing = Briefing {
sections: vec![balanced_section("energy"), balanced_section("rates")],
return_table: Some(ReturnTable {
ordering: TableOrdering::Unspecified,
entries: vec![],
}),
};
let mut policy = BriefingPolicy::default();
assert!(!audit_briefing(&briefing, &policy).balanced);
policy.require_option_order_sort = false;
assert!(audit_briefing(&briefing, &policy).balanced);
}
#[test]
fn invalid_salience_threshold_is_a_serializable_violation() {
for threshold in [f64::NAN, f64::INFINITY, -0.01, 1.01] {
let policy = BriefingPolicy {
max_area_salience: threshold,
..BriefingPolicy::default()
};
let audit = audit_briefing(&Briefing::default(), &policy);
assert!(!audit.balanced);
let value = serde_json::to_value(audit).unwrap();
assert!(value["violations"]
.as_array()
.unwrap()
.iter()
.any(|v| v["violation"] == "invalid_salience_threshold"));
}
}
#[test]
fn partial_policy_json_retains_all_other_defaults() {
let policy: BriefingPolicy = serde_json::from_str(r#"{"max_area_salience":1.0}"#).unwrap();
assert_eq!(policy.max_area_salience, 1.0);
assert_eq!(policy.max_rows_per_area, 5);
assert!(policy.require_option_order_sort && policy.require_counterbalance);
}
}