use bevy::prelude::*;
#[derive(Debug, Clone, PartialEq)]
pub struct ScoredOption {
pub activity: String,
pub score: f32,
pub chosen: bool,
pub rank: u8,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Decision {
pub tick: u64,
pub person: Entity,
pub name: Option<String>,
pub signals: Vec<f32>,
pub options: Vec<ScoredOption>,
}
impl Decision {
pub fn chosen(&self) -> Option<&ScoredOption> {
self.options.iter().find(|option| option.chosen)
}
pub fn best(&self) -> Option<&ScoredOption> {
self.options.first()
}
pub fn describe(&self, names: &[(Entity, String)]) -> String {
let who = self
.name
.clone()
.or_else(|| {
names
.iter()
.find(|(entity, _)| *entity == self.person)
.map(|(_, name)| name.clone())
})
.unwrap_or_else(|| "someone".to_owned());
match self.chosen() {
Some(chosen) if self.took_the_best() => format!(
"tick {}: {who} chose {} ({}), the best option",
self.tick,
chosen.activity,
format_args!("{:.3}", chosen.score)
),
Some(chosen) if self.best().is_some_and(|best| chosen.rank > best.rank) => format!(
"tick {}: {who} chose {} ({:.3}, rank {}) over {} ({:.3}, rank {}): a higher rank outranks a higher score",
self.tick,
chosen.activity,
chosen.score,
chosen.rank,
self.best().map_or("the best", |b| b.activity.as_str()),
self.best().map_or(0.0, |b| b.score),
self.best().map_or(0, |b| b.rank)
),
Some(chosen) => format!(
"tick {}: {who} chose {} ({:.3}), {:.0}% below {} ({:.3})",
self.tick,
chosen.activity,
chosen.score,
self.shortfall() * 100.0,
self.best().map_or("the best", |b| b.activity.as_str()),
self.best().map_or(0.0, |b| b.score)
),
None => format!("tick {}: {who} weighed nothing", self.tick),
}
}
pub fn shortfall(&self) -> f32 {
match (self.chosen(), self.best()) {
(Some(chosen), Some(best)) if best.score > 0.0 => {
(best.score - chosen.score) / best.score
}
_ => 0.0,
}
}
pub fn took_the_best(&self) -> bool {
self.best().is_some_and(|option| option.chosen)
}
}
#[derive(Resource, Debug, Clone)]
pub struct DecisionTrace {
decisions: std::collections::VecDeque<Decision>,
capacity: usize,
options_per_decision: usize,
}
impl Default for DecisionTrace {
fn default() -> Self {
Self {
decisions: std::collections::VecDeque::new(),
capacity: 64,
options_per_decision: 8,
}
}
}
impl DecisionTrace {
pub fn with_capacity(capacity: usize, options_per_decision: usize) -> Self {
Self {
decisions: std::collections::VecDeque::new(),
capacity,
options_per_decision: options_per_decision.max(1),
}
}
pub fn recent(&self) -> impl DoubleEndedIterator<Item = &Decision> {
self.decisions.iter().rev()
}
pub fn last_for(&self, person: Entity) -> Option<&Decision> {
self.decisions.iter().rev().find(|d| d.person == person)
}
pub fn len(&self) -> usize {
self.decisions.len()
}
pub fn is_empty(&self) -> bool {
self.decisions.is_empty()
}
pub(crate) fn push(&mut self, mut decision: Decision) {
if self.capacity == 0 {
return;
}
decision.options.truncate(self.options_per_decision);
while self.decisions.len() >= self.capacity {
self.decisions.pop_front();
}
self.decisions.push_back(decision);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn person() -> Entity {
Entity::from_raw_u32(1).expect("a valid entity")
}
fn decision(tick: u64, who: Entity) -> Decision {
Decision {
tick,
person: who,
name: None,
signals: Vec::new(),
options: vec![
ScoredOption {
activity: "eat".to_owned(),
score: 0.8,
chosen: true,
rank: 0,
},
ScoredOption {
activity: "chat".to_owned(),
score: 0.2,
chosen: false,
rank: 0,
},
],
}
}
#[test]
fn the_ring_drops_the_oldest_and_keeps_memory_flat() {
let mut trace = DecisionTrace::with_capacity(2, 8);
for tick in 0..5 {
trace.push(decision(tick, person()));
}
assert_eq!(trace.len(), 2, "capacity is a hard bound");
let ticks: Vec<u64> = trace.recent().map(|d| d.tick).collect();
assert_eq!(ticks, vec![4, 3], "newest first");
}
#[test]
fn taking_the_best_option_has_no_shortfall() {
let mut trace = DecisionTrace::default();
trace.push(decision(0, person()));
let record = trace.last_for(person()).expect("just pushed");
assert!(record.took_the_best(), "eat at 0.8 was the best option");
assert_eq!(record.shortfall(), 0.0);
}
#[test]
fn a_weighted_draw_that_picked_a_lower_scorer_reports_a_shortfall() {
let mut trace = DecisionTrace::default();
trace.push(Decision {
tick: 0,
person: person(),
name: None,
signals: Vec::new(),
options: vec![
ScoredOption {
activity: "snub".to_owned(),
score: 1.0,
chosen: false,
rank: 0,
},
ScoredOption {
activity: "compliment".to_owned(),
score: 0.75,
chosen: true,
rank: 0,
},
],
});
let record = trace.last_for(person()).expect("just pushed");
assert!(!record.took_the_best());
assert!(
(record.shortfall() - 0.25).abs() < 1e-6,
"0.75 against a best of 1.0 is a 25% shortfall: {}",
record.shortfall()
);
}
#[test]
fn a_lone_option_is_the_best_option() {
let mut trace = DecisionTrace::default();
trace.push(Decision {
tick: 0,
person: person(),
name: None,
signals: Vec::new(),
options: vec![ScoredOption {
activity: "sleep".to_owned(),
score: 1.0,
chosen: true,
rank: 0,
}],
});
let only = trace.last_for(person()).expect("just pushed");
assert_eq!(only.shortfall(), 0.0);
assert!(only.took_the_best());
}
#[test]
fn a_higher_rank_is_named_when_it_beat_a_higher_score() {
let decision = Decision {
tick: 3,
person: person(),
name: Some("Ada".to_owned()),
signals: Vec::new(),
options: vec![
ScoredOption {
activity: "work".to_owned(),
score: 0.9,
chosen: false,
rank: 0,
},
ScoredOption {
activity: "eat".to_owned(),
score: 0.5,
chosen: true,
rank: 1,
},
],
};
let text = decision.describe(&[]);
assert!(
text.contains("eat (0.500, rank 1) over work (0.900, rank 0)"),
"{text}"
);
}
}