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,
pub context_order: usize,
}
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,
context_order: 0,
}
}
}
#[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,
}
pub(crate) fn outcome_credit(outcome: Outcome, draw_value: f64) -> f64 {
match outcome {
Outcome::Success => 1.0,
Outcome::Draw => draw_value,
Outcome::Failure | Outcome::Unknown => 0.0,
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PriorEntry {
pub state: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub context: Vec<String>,
pub actions: Vec<PriorAction>,
}
#[derive(Debug, Clone, Default)]
pub struct PriorBook {
pub entries: HashMap<String, Vec<PriorAction>>,
pub context_entries: HashMap<(Vec<String>, String), Vec<PriorAction>>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ContextQueryResult {
pub matched_order: usize,
pub candidates: Vec<PriorAction>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct StepScore {
pub state: String,
pub action: String,
pub matched_order: usize,
pub found: bool,
pub prior: Option<f64>,
pub confidence: Option<f64>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct SequencePriorScore {
pub steps: Vec<StepScore>,
pub min_confidence: Option<f64>,
pub unseen_steps: usize,
}
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(),
context: Vec::new(),
actions,
}
})
.collect()
}
pub fn context_entries_sorted(&self) -> Vec<PriorEntry> {
let mut keys: Vec<&(Vec<String>, String)> = self.context_entries.keys().collect();
keys.sort_by(|(a_ctx, a_state), (b_ctx, b_state)| {
a_ctx
.len()
.cmp(&b_ctx.len())
.then_with(|| a_ctx.cmp(b_ctx))
.then_with(|| a_state.cmp(b_state))
});
keys.into_iter()
.map(|key @ (context, state)| {
let mut actions = self.context_entries[key].clone();
sort_actions(&mut actions);
PriorEntry {
state: state.clone(),
context: context.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 query_with_context(
&self,
state: &str,
recent_actions: &[String],
top_k: Option<usize>,
) -> ContextQueryResult {
for len in (1..=recent_actions.len()).rev() {
let context = recent_actions[recent_actions.len() - len..].to_vec();
if let Some(actions) = self.context_entries.get(&(context, state.to_string())) {
let mut actions = actions.clone();
sort_actions(&mut actions);
if let Some(k) = top_k {
actions.truncate(k);
}
return ContextQueryResult {
matched_order: len,
candidates: actions,
};
}
}
ContextQueryResult {
matched_order: 0,
candidates: self.query(state, top_k),
}
}
pub fn score_sequence(&self, path: &[(String, String)]) -> SequencePriorScore {
let mut steps = Vec::with_capacity(path.len());
let mut context: Vec<String> = Vec::new();
for (state, action) in path {
let result = self.query_with_context(state, &context, None);
let matched = result.candidates.iter().find(|c| &c.action == action);
steps.push(StepScore {
state: state.clone(),
action: action.clone(),
matched_order: result.matched_order,
found: matched.is_some(),
prior: matched.map(|c| c.prior),
confidence: matched.map(|c| c.confidence),
});
context.push(action.clone());
}
let unseen_steps = steps.iter().filter(|s| !s.found).count();
let min_confidence = steps
.iter()
.filter_map(|s| s.confidence)
.fold(None, |acc: Option<f64>, c| {
Some(acc.map_or(c, |a| a.min(c)))
});
SequencePriorScore {
steps,
min_confidence,
unseen_steps,
}
}
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,
..Default::default()
};
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))
);
}
#[test]
fn score_sequence_all_steps_found_reports_min_confidence_and_zero_unseen() {
let mut entries = HashMap::new();
entries.insert("s0".to_string(), vec![action("a0", 5, 0.9, 0.8)]);
let mut context_entries = HashMap::new();
context_entries.insert(
(vec!["a0".to_string()], "s1".to_string()),
vec![action("a1", 3, 0.7, 0.5)],
);
let book = PriorBook {
entries,
context_entries,
};
let result = book.score_sequence(&[
("s0".to_string(), "a0".to_string()),
("s1".to_string(), "a1".to_string()),
]);
assert_eq!(result.unseen_steps, 0);
assert_eq!(result.min_confidence, Some(0.5));
assert_eq!(result.steps[0].matched_order, 0);
assert!(result.steps[0].found);
assert_eq!(result.steps[0].confidence, Some(0.8));
assert_eq!(result.steps[1].matched_order, 1);
assert!(result.steps[1].found);
assert_eq!(result.steps[1].confidence, Some(0.5));
}
#[test]
fn score_sequence_one_unseen_step_is_reflected_in_unseen_steps_and_excluded_from_min_confidence()
{
let mut entries = HashMap::new();
entries.insert("s0".to_string(), vec![action("a0", 5, 0.9, 0.8)]);
let book = PriorBook {
entries,
..Default::default()
};
let result = book.score_sequence(&[
("s0".to_string(), "a0".to_string()),
("s1".to_string(), "a1".to_string()),
]);
assert_eq!(result.unseen_steps, 1);
assert!(!result.steps[1].found);
assert_eq!(result.steps[1].prior, None);
assert_eq!(result.steps[1].confidence, None);
assert_eq!(result.min_confidence, Some(0.8));
}
#[test]
fn score_sequence_step_zero_always_uses_empty_context() {
let mut context_entries = HashMap::new();
context_entries.insert(
(vec!["x".to_string()], "s0".to_string()),
vec![action("a0", 1, 0.5, 0.5)],
);
let book = PriorBook {
context_entries,
..Default::default()
};
let result = book.score_sequence(&[("s0".to_string(), "a0".to_string())]);
assert_eq!(result.steps[0].matched_order, 0);
assert!(!result.steps[0].found);
}
#[test]
fn score_sequence_context_grows_from_own_earlier_steps_not_caller_supplied_history() {
let mut entries = HashMap::new();
entries.insert("sb".to_string(), vec![action("az", 1, 0.5, 0.5)]);
let mut context_entries = HashMap::new();
context_entries.insert(
(vec!["ax".to_string()], "sb".to_string()),
vec![action("ay", 4, 0.9, 0.9)],
);
let book = PriorBook {
entries,
context_entries,
};
let result = book.score_sequence(&[
("sa".to_string(), "ax".to_string()),
("sb".to_string(), "ay".to_string()),
]);
assert_eq!(result.steps[1].matched_order, 1);
assert!(result.steps[1].found);
}
#[test]
fn score_sequence_backoff_pinning_deep_sparse_context_can_shadow_order_zero_support() {
let mut entries = HashMap::new();
entries.insert(
"state_b".to_string(),
vec![action("action_z", 10, 0.9, 0.9)],
);
let mut context_entries = HashMap::new();
context_entries.insert(
(vec!["action_x".to_string()], "state_b".to_string()),
vec![action("action_other", 1, 0.3, 0.2)],
);
let book = PriorBook {
entries,
context_entries,
};
let result = book.score_sequence(&[
("state_a".to_string(), "action_x".to_string()),
("state_b".to_string(), "action_z".to_string()),
]);
assert_eq!(result.steps[1].matched_order, 1);
assert!(!result.steps[1].found);
}
#[test]
fn score_sequence_ignores_top_k_style_truncation_finds_low_ranked_action() {
let mut entries = HashMap::new();
entries.insert(
"s0".to_string(),
vec![
action("a1", 9, 0.9, 0.9),
action("a2", 8, 0.8, 0.8),
action("a3", 7, 0.7, 0.7),
action("a4", 6, 0.6, 0.6),
action("a_target", 1, 0.1, 0.1),
],
);
let book = PriorBook {
entries,
..Default::default()
};
let result = book.score_sequence(&[("s0".to_string(), "a_target".to_string())]);
assert!(result.steps[0].found);
assert_eq!(result.steps[0].confidence, Some(0.1));
}
#[test]
fn score_sequence_empty_path_returns_empty_score() {
let book = PriorBook::default();
let result = book.score_sequence(&[]);
assert_eq!(
result,
SequencePriorScore {
steps: vec![],
min_confidence: None,
unseen_steps: 0,
}
);
}
#[test]
fn score_sequence_all_steps_unseen_min_confidence_is_none() {
let book = PriorBook::default();
let result = book.score_sequence(&[
("s0".to_string(), "a0".to_string()),
("s1".to_string(), "a1".to_string()),
]);
assert_eq!(result.unseen_steps, 2);
assert_eq!(result.min_confidence, None);
}
}