use std::cmp::Ordering;
use indexmap::IndexMap;
use crate::existence::families::SignalFamily;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EvidencePolarity {
Positive,
Contradictory,
}
#[derive(Debug, Clone)]
pub struct EvidenceEvent {
pub(crate) family: SignalFamily,
pub(crate) polarity: EvidencePolarity,
pub(crate) technique_id: String,
pub(crate) weight: f64,
pub(crate) signed_log_odds: f64,
}
impl EvidenceEvent {
#[must_use]
pub fn positive(
family: SignalFamily,
technique_id: impl Into<String>,
weight: f64,
log_odds_magnitude: f64,
) -> Self {
Self {
family,
polarity: EvidencePolarity::Positive,
technique_id: technique_id.into(),
weight,
signed_log_odds: log_odds_magnitude.abs(),
}
}
#[must_use]
pub fn contradictory(
family: SignalFamily,
technique_id: impl Into<String>,
weight: f64,
log_odds_magnitude: f64,
) -> Self {
Self {
family,
polarity: EvidencePolarity::Contradictory,
technique_id: technique_id.into(),
weight,
signed_log_odds: -log_odds_magnitude.abs(),
}
}
}
pub const POSITIVE_SCHEDULE: &[f64] = &[1.0, 0.5, 0.25, 0.1];
pub const CONTRADICTORY_SCHEDULE: &[f64] = &[1.0, 0.7, 0.5, 0.3, 0.1];
pub const PER_GROUP_CAP: f64 = 0.75;
#[must_use]
pub fn reduce_family_polarity(events: &[EvidenceEvent], schedule: &[f64], cap: f64) -> f64 {
if events.is_empty() || schedule.is_empty() {
return 0.0;
}
let mut sorted: Vec<&EvidenceEvent> = events.iter().collect();
sorted.sort_by(|a, b| cmp_event_desc(a, b));
let total: f64 = sorted
.iter()
.zip(schedule.iter())
.map(|(event, multiplier)| event.signed_log_odds * multiplier)
.sum();
clamp_magnitude(total, cap)
}
#[must_use]
pub fn reduce_all(events: &[EvidenceEvent]) -> f64 {
if events.is_empty() {
return 0.0;
}
let mut total = 0.0;
for (group, polarity) in group_indices_by_family_polarity(events) {
let schedule = schedule_for(polarity);
let unclamped: f64 = sorted_indices(events, &group)
.into_iter()
.zip(schedule.iter())
.map(|(i, m)| events[i].signed_log_odds * m)
.sum();
total += clamp_magnitude(unclamped, PER_GROUP_CAP);
}
total
}
fn sorted_indices(events: &[EvidenceEvent], indices: &[usize]) -> Vec<usize> {
let mut sorted = indices.to_vec();
sorted.sort_by(|&a, &b| cmp_event_desc(&events[a], &events[b]));
sorted
}
#[derive(Debug, Clone)]
pub struct ReductionResult {
pub total_log_odds: f64,
pub contributions: Vec<f64>,
}
#[must_use]
pub fn reduce_with_attribution(events: &[EvidenceEvent]) -> ReductionResult {
if events.is_empty() {
return ReductionResult {
total_log_odds: 0.0,
contributions: Vec::new(),
};
}
let mut contributions = vec![0.0_f64; events.len()];
let mut total = 0.0_f64;
for (group, polarity) in group_indices_by_family_polarity(events) {
let schedule = schedule_for(polarity);
let group_total = attribute_group(events, &group, schedule, &mut contributions);
total += group_total;
}
ReductionResult {
total_log_odds: total,
contributions,
}
}
fn attribute_group(
events: &[EvidenceEvent],
group_indices: &[usize],
schedule: &[f64],
contributions: &mut [f64],
) -> f64 {
if group_indices.is_empty() || schedule.is_empty() {
return 0.0;
}
let sorted = sorted_indices(events, group_indices);
let unclamped: f64 = sorted
.iter()
.zip(schedule.iter())
.map(|(&i, m)| events[i].signed_log_odds * m)
.sum();
let clamped = clamp_magnitude(unclamped, PER_GROUP_CAP);
let scale = if (clamped - unclamped).abs() <= f64::EPSILON || unclamped.abs() <= f64::EPSILON {
1.0
} else {
clamped / unclamped
};
for (slot, &idx) in sorted.iter().enumerate() {
let multiplier = schedule.get(slot).copied().unwrap_or(0.0);
contributions[idx] = events[idx].signed_log_odds * multiplier * scale;
}
clamped
}
fn group_indices_by_family_polarity(
events: &[EvidenceEvent],
) -> Vec<(Vec<usize>, EvidencePolarity)> {
let mut groups: IndexMap<(SignalFamily, EvidencePolarity), Vec<usize>> = IndexMap::new();
for (i, event) in events.iter().enumerate() {
groups
.entry((event.family, event.polarity))
.or_default()
.push(i);
}
groups
.into_iter()
.map(|((_, polarity), indices)| (indices, polarity))
.collect()
}
fn schedule_for(polarity: EvidencePolarity) -> &'static [f64] {
match polarity {
EvidencePolarity::Positive => POSITIVE_SCHEDULE,
EvidencePolarity::Contradictory => CONTRADICTORY_SCHEDULE,
}
}
fn cmp_event_desc(a: &EvidenceEvent, b: &EvidenceEvent) -> Ordering {
match b.weight.partial_cmp(&a.weight) {
Some(Ordering::Equal) | None => a.technique_id.cmp(&b.technique_id),
Some(other) => other,
}
}
fn clamp_magnitude(value: f64, cap: f64) -> f64 {
let cap = cap.abs();
if value > cap {
cap
} else if value < -cap {
-cap
} else {
value
}
}
#[cfg(test)]
#[path = "reducer_tests.rs"]
mod tests;