use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Observation {
pub sequence_id: String,
pub step: u32,
pub state: String,
pub action: String,
pub outcome: Outcome,
pub score: Option<f64>,
pub weight: f64,
pub tags: Vec<String>,
pub observed_at_unix_seconds: Option<i64>,
pub source: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Outcome {
Success,
Failure,
Draw,
#[default]
Unknown,
}
impl Outcome {
pub fn parse(s: &str) -> Option<Self> {
match s {
"success" => Some(Outcome::Success),
"failure" => Some(Outcome::Failure),
"draw" => Some(Outcome::Draw),
"unknown" => Some(Outcome::Unknown),
_ => None,
}
}
}
pub const DEFAULT_CONFIDENCE_K: f64 = 20.0;
pub const DEFAULT_DRAW_VALUE: f64 = 0.5;
pub const DEFAULT_CONFIDENCE_Z: f64 = 1.96;
pub const DEFAULT_SOURCE_WEIGHT: f64 = 1.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MissingTimestampPolicy {
#[default]
KeepBaseWeight,
Drop,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConfidenceMode {
#[default]
Heuristic,
WilsonLowerBound,
Hybrid,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct BuildConfig {
pub min_count: u64,
pub min_weighted_count: f64,
pub min_confidence: f64,
pub max_step: Option<u32>,
pub smoothing_alpha: f64,
pub score_weight: f64,
pub success_weight: f64,
pub count_weight: f64,
pub max_actions_per_state: Option<usize>,
pub confidence_k: f64,
pub confidence_mode: ConfidenceMode,
pub confidence_z: f64,
pub draw_value: f64,
pub tag_filter: Option<Vec<String>>,
pub time_decay_half_life_days: Option<f64>,
pub time_decay_reference_unix_seconds: Option<i64>,
pub missing_timestamp_policy: MissingTimestampPolicy,
pub source_weights: std::collections::BTreeMap<String, f64>,
pub default_source_weight: f64,
}
impl Default for BuildConfig {
fn default() -> Self {
Self {
min_count: 1,
min_weighted_count: 0.0,
min_confidence: 0.0,
max_step: None,
smoothing_alpha: 5.0,
score_weight: 1.0,
success_weight: 1.0,
count_weight: 1.0,
max_actions_per_state: None,
confidence_k: DEFAULT_CONFIDENCE_K,
confidence_mode: ConfidenceMode::Heuristic,
confidence_z: DEFAULT_CONFIDENCE_Z,
draw_value: DEFAULT_DRAW_VALUE,
tag_filter: None,
time_decay_half_life_days: None,
time_decay_reference_unix_seconds: None,
missing_timestamp_policy: MissingTimestampPolicy::KeepBaseWeight,
source_weights: std::collections::BTreeMap::new(),
default_source_weight: DEFAULT_SOURCE_WEIGHT,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PriorAction {
pub action: String,
pub count: u64,
pub weighted_count: f64,
pub success_rate: Option<f64>,
pub mean_score: Option<f64>,
pub prior: f64,
pub confidence: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PriorEntry {
pub state: String,
pub actions: Vec<PriorAction>,
}
#[derive(Debug, Clone, Default)]
pub struct PriorBook {
pub entries: HashMap<String, Vec<PriorAction>>,
}
fn sort_actions(actions: &mut [PriorAction]) {
actions.sort_by(|a, b| {
b.prior
.partial_cmp(&a.prior)
.unwrap_or(Ordering::Equal)
.then_with(|| a.action.cmp(&b.action))
});
}
impl PriorBook {
pub fn entries_sorted(&self) -> Vec<PriorEntry> {
let mut states: Vec<&String> = self.entries.keys().collect();
states.sort();
states
.into_iter()
.map(|state| {
let mut actions = self.entries[state].clone();
sort_actions(&mut actions);
PriorEntry {
state: state.clone(),
actions,
}
})
.collect()
}
pub fn query(&self, state: &str, top_k: Option<usize>) -> Vec<PriorAction> {
let Some(actions) = self.entries.get(state) else {
return Vec::new();
};
let mut actions = actions.clone();
sort_actions(&mut actions);
if let Some(k) = top_k {
actions.truncate(k);
}
actions
}
pub fn candidates(&self) -> Vec<(String, PriorAction)> {
self.entries_sorted()
.into_iter()
.flat_map(|entry| {
let state = entry.state;
entry
.actions
.into_iter()
.map(move |action| (state.clone(), action))
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn action(name: &str, count: u64, prior: f64, confidence: f64) -> PriorAction {
PriorAction {
action: name.to_string(),
count,
weighted_count: count as f64,
success_rate: None,
mean_score: None,
prior,
confidence,
}
}
#[test]
fn candidates_flattens_every_state_action_pair_in_entries_sorted_order() {
let mut entries = HashMap::new();
entries.insert(
"s2".to_string(),
vec![action("y", 3, 0.6, 0.3), action("z", 1, 0.4, 0.1)],
);
entries.insert("s1".to_string(), vec![action("x", 5, 1.0, 0.5)]);
let book = PriorBook { entries };
let expected: Vec<(String, PriorAction)> = book
.entries_sorted()
.into_iter()
.flat_map(|entry| {
entry
.actions
.into_iter()
.map(move |action| (entry.state.clone(), action))
})
.collect();
assert_eq!(book.candidates(), expected);
assert_eq!(book.candidates().len(), 3);
assert_eq!(
book.candidates()[0],
("s1".to_string(), action("x", 5, 1.0, 0.5))
);
}
}