use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[repr(u8)]
pub enum TimeBin {
EarlyMorning = 0,
Morning = 1,
Midday = 2,
Afternoon = 3,
Evening = 4,
Night = 5,
}
impl TimeBin {
pub fn from_hour(hour: u8) -> Self {
match hour {
0..=5 => Self::EarlyMorning,
6..=9 => Self::Morning,
10..=13 => Self::Midday,
14..=17 => Self::Afternoon,
18..=21 => Self::Evening,
_ => Self::Night,
}
}
pub fn from_timestamp(ts: f64) -> Self {
let hour = ((ts % 86400.0) / 3600.0) as u8;
Self::from_hour(hour)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[repr(u8)]
pub enum ReceptivityBin {
Low = 0,
Medium = 1,
High = 2,
VeryHigh = 3,
}
impl ReceptivityBin {
pub fn from_value(v: f64) -> Self {
if v >= 0.8 {
Self::VeryHigh
} else if v >= 0.6 {
Self::High
} else if v >= 0.3 {
Self::Medium
} else {
Self::Low
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[repr(u8)]
pub enum SessionStage {
Early = 0,
Mid = 1,
Late = 2,
}
impl SessionStage {
pub fn from_duration_secs(d: f64) -> Self {
if d < 300.0 {
Self::Early
} else if d < 1800.0 {
Self::Mid
} else {
Self::Late
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[repr(u8)]
pub enum ErrorRateBin {
None = 0,
Low = 1,
High = 2,
}
impl ErrorRateBin {
pub fn from_rate(rate: f64) -> Self {
if rate <= 0.0 {
Self::None
} else if rate <= 1.0 {
Self::Low
} else {
Self::High
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct StateFeatures {
pub time_bin: TimeBin,
pub receptivity_bin: ReceptivityBin,
pub session_stage: SessionStage,
pub error_rate_bin: ErrorRateBin,
pub active_goal_count_bin: u8, }
impl StateFeatures {
pub fn discretize(
timestamp: f64,
receptivity: f64,
session_duration_secs: f64,
error_rate_per_min: f64,
active_goal_count: usize,
) -> Self {
Self {
time_bin: TimeBin::from_timestamp(timestamp),
receptivity_bin: ReceptivityBin::from_value(receptivity),
session_stage: SessionStage::from_duration_secs(session_duration_secs),
error_rate_bin: ErrorRateBin::from_rate(error_rate_per_min),
active_goal_count_bin: (active_goal_count as u8).min(2),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ActionKind {
SurfaceSuggestion,
SendNotification,
ExecuteTool,
InvokeLlm,
OfferSkill,
RunExperiment,
BackgroundMaintenance,
}
impl ActionKind {
pub fn as_str(self) -> &'static str {
match self {
Self::SurfaceSuggestion => "surface_suggestion",
Self::SendNotification => "send_notification",
Self::ExecuteTool => "execute_tool",
Self::InvokeLlm => "invoke_llm",
Self::OfferSkill => "offer_skill",
Self::RunExperiment => "run_experiment",
Self::BackgroundMaintenance => "background_maintenance",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ActionOutcome {
Accepted,
Rejected,
Ignored,
Succeeded,
Failed,
}
impl ActionOutcome {
pub fn is_positive(self) -> bool {
matches!(self, Self::Accepted | Self::Succeeded)
}
pub fn is_negative(self) -> bool {
matches!(self, Self::Rejected | Self::Failed)
}
fn index(self) -> usize {
match self {
Self::Accepted => 0,
Self::Rejected => 1,
Self::Ignored => 2,
Self::Succeeded => 3,
Self::Failed => 4,
}
}
const COUNT: usize = 5;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutcomeDistribution {
pub(crate) counts: [u32; ActionOutcome::COUNT],
prior: [f64; ActionOutcome::COUNT],
}
impl OutcomeDistribution {
pub fn uninformative_prior() -> Self {
Self {
counts: [0; ActionOutcome::COUNT],
prior: [1.0; ActionOutcome::COUNT],
}
}
pub fn with_prior(prior: [f64; ActionOutcome::COUNT]) -> Self {
Self {
counts: [0; ActionOutcome::COUNT],
prior,
}
}
pub fn observe(&mut self, outcome: ActionOutcome) {
self.counts[outcome.index()] += 1;
}
pub fn total_observations(&self) -> u32 {
self.counts.iter().sum()
}
fn prior_mass(&self) -> f64 {
self.prior.iter().sum()
}
pub fn posterior_mean(&self, outcome: ActionOutcome) -> f64 {
let idx = outcome.index();
let total = self.total_observations() as f64 + self.prior_mass();
if total <= 0.0 {
return 1.0 / ActionOutcome::COUNT as f64;
}
(self.counts[idx] as f64 + self.prior[idx]) / total
}
pub fn all_posteriors(&self) -> [f64; ActionOutcome::COUNT] {
let total = self.total_observations() as f64 + self.prior_mass();
let mut result = [0.0; ActionOutcome::COUNT];
if total <= 0.0 {
let uniform = 1.0 / ActionOutcome::COUNT as f64;
result.fill(uniform);
return result;
}
for i in 0..ActionOutcome::COUNT {
result[i] = (self.counts[i] as f64 + self.prior[i]) / total;
}
result
}
pub fn positive_rate(&self) -> f64 {
self.posterior_mean(ActionOutcome::Accepted) + self.posterior_mean(ActionOutcome::Succeeded)
}
pub fn negative_rate(&self) -> f64 {
self.posterior_mean(ActionOutcome::Rejected) + self.posterior_mean(ActionOutcome::Failed)
}
pub fn entropy(&self) -> f64 {
let posteriors = self.all_posteriors();
let mut h = 0.0;
for &p in &posteriors {
if p > 0.0 {
h -= p * p.ln();
}
}
h
}
pub fn is_informative(&self, min_observations: u32) -> bool {
self.total_observations() >= min_observations
}
pub fn mode(&self) -> ActionOutcome {
let posteriors = self.all_posteriors();
let idx = posteriors
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
.map(|(i, _)| i)
.unwrap_or(0);
match idx {
0 => ActionOutcome::Accepted,
1 => ActionOutcome::Rejected,
2 => ActionOutcome::Ignored,
3 => ActionOutcome::Succeeded,
_ => ActionOutcome::Failed,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransitionEntry {
pub features: StateFeatures,
pub action: ActionKind,
pub distribution: OutcomeDistribution,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransitionModel {
entries: Vec<TransitionEntry>,
#[serde(skip)]
index: HashMap<(StateFeatures, ActionKind), usize>,
pub total_transitions: u64,
pub global_outcomes: OutcomeDistribution,
}
impl TransitionModel {
pub fn new() -> Self {
Self {
entries: Vec::new(),
index: HashMap::new(),
total_transitions: 0,
global_outcomes: OutcomeDistribution::uninformative_prior(),
}
}
pub fn rebuild_index(&mut self) {
self.index.clear();
for (i, entry) in self.entries.iter().enumerate() {
self.index.insert((entry.features, entry.action), i);
}
}
pub fn record(
&mut self,
features: StateFeatures,
action: ActionKind,
outcome: ActionOutcome,
) {
let key = (features, action);
if let Some(&idx) = self.index.get(&key) {
self.entries[idx].distribution.observe(outcome);
} else {
let idx = self.entries.len();
let mut dist = OutcomeDistribution::uninformative_prior();
dist.observe(outcome);
self.entries.push(TransitionEntry {
features,
action,
distribution: dist,
});
self.index.insert(key, idx);
}
self.global_outcomes.observe(outcome);
self.total_transitions += 1;
}
pub fn predict(
&self,
features: &StateFeatures,
action: ActionKind,
) -> OutcomeDistribution {
self.index
.get(&(*features, action))
.map(|&idx| self.entries[idx].distribution.clone())
.unwrap_or_else(OutcomeDistribution::uninformative_prior)
}
pub fn predict_blended(
&self,
features: &StateFeatures,
action: ActionKind,
min_observations: u32,
) -> OutcomeDistribution {
let specific = self.predict(features, action);
if specific.total_observations() >= min_observations {
return specific;
}
let specific_weight =
specific.total_observations() as f64 / min_observations as f64;
let global_weight = 1.0 - specific_weight;
let s_posteriors = specific.all_posteriors();
let g_posteriors = self.global_outcomes.all_posteriors();
let mut blended_prior = [0.0f64; ActionOutcome::COUNT];
for i in 0..ActionOutcome::COUNT {
blended_prior[i] =
(s_posteriors[i] * specific_weight + g_posteriors[i] * global_weight)
.max(0.001);
}
let mut result = OutcomeDistribution::with_prior(blended_prior);
result.counts = specific.counts;
result
}
pub fn expected_success(&self, features: &StateFeatures, action: ActionKind) -> f64 {
self.predict(features, action).positive_rate()
}
pub fn best_action(&self, features: &StateFeatures, actions: &[ActionKind]) -> Option<ActionKind> {
actions
.iter()
.max_by(|&&a, &&b| {
let ra = self.expected_success(features, a);
let rb = self.expected_success(features, b);
ra.partial_cmp(&rb).unwrap()
})
.copied()
}
pub fn unique_pairs(&self) -> usize {
self.entries.len()
}
pub fn top_pairs(&self, limit: usize) -> Vec<((StateFeatures, ActionKind), u32)> {
let mut pairs: Vec<_> = self
.entries
.iter()
.map(|e| ((e.features, e.action), e.distribution.total_observations()))
.collect();
pairs.sort_by(|a, b| b.1.cmp(&a.1));
pairs.truncate(limit);
pairs
}
pub fn prediction_accuracy(&self, min_observations: u32) -> f64 {
let informative: Vec<_> = self
.entries
.iter()
.map(|e| &e.distribution)
.filter(|d| d.is_informative(min_observations))
.collect();
if informative.is_empty() {
return 0.5; }
let total_accuracy: f64 = informative
.iter()
.map(|d| {
let mode = d.mode();
d.posterior_mean(mode)
})
.sum();
total_accuracy / informative.len() as f64
}
}
impl Default for TransitionModel {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorldModelSummary {
pub total_transitions: u64,
pub unique_pairs: usize,
pub global_positive_rate: f64,
pub global_negative_rate: f64,
pub prediction_accuracy: f64,
pub global_entropy: f64,
}
pub fn summarize_world_model(model: &TransitionModel) -> WorldModelSummary {
WorldModelSummary {
total_transitions: model.total_transitions,
unique_pairs: model.unique_pairs(),
global_positive_rate: model.global_outcomes.positive_rate(),
global_negative_rate: model.global_outcomes.negative_rate(),
prediction_accuracy: model.prediction_accuracy(5),
global_entropy: model.global_outcomes.entropy(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn noon_features() -> StateFeatures {
StateFeatures::discretize(43200.0, 0.7, 600.0, 0.0, 1)
}
fn morning_features() -> StateFeatures {
StateFeatures::discretize(28800.0, 0.5, 300.0, 0.5, 2)
}
#[test]
fn test_time_bin_discretization() {
assert_eq!(TimeBin::from_hour(3), TimeBin::EarlyMorning);
assert_eq!(TimeBin::from_hour(8), TimeBin::Morning);
assert_eq!(TimeBin::from_hour(12), TimeBin::Midday);
assert_eq!(TimeBin::from_hour(15), TimeBin::Afternoon);
assert_eq!(TimeBin::from_hour(19), TimeBin::Evening);
assert_eq!(TimeBin::from_hour(23), TimeBin::Night);
}
#[test]
fn test_receptivity_bin() {
assert_eq!(ReceptivityBin::from_value(0.1), ReceptivityBin::Low);
assert_eq!(ReceptivityBin::from_value(0.5), ReceptivityBin::Medium);
assert_eq!(ReceptivityBin::from_value(0.7), ReceptivityBin::High);
assert_eq!(ReceptivityBin::from_value(0.9), ReceptivityBin::VeryHigh);
}
#[test]
fn test_session_stage() {
assert_eq!(SessionStage::from_duration_secs(60.0), SessionStage::Early);
assert_eq!(SessionStage::from_duration_secs(600.0), SessionStage::Mid);
assert_eq!(SessionStage::from_duration_secs(3600.0), SessionStage::Late);
}
#[test]
fn test_state_features_discretize() {
let f = StateFeatures::discretize(43200.0, 0.7, 600.0, 0.0, 3);
assert_eq!(f.time_bin, TimeBin::Midday);
assert_eq!(f.receptivity_bin, ReceptivityBin::High);
assert_eq!(f.session_stage, SessionStage::Mid);
assert_eq!(f.error_rate_bin, ErrorRateBin::None);
assert_eq!(f.active_goal_count_bin, 2); }
#[test]
fn test_uninformative_prior() {
let dist = OutcomeDistribution::uninformative_prior();
let posteriors = dist.all_posteriors();
for &p in &posteriors {
assert!((p - 0.2).abs() < 0.01);
}
}
#[test]
fn test_posterior_update() {
let mut dist = OutcomeDistribution::uninformative_prior();
for _ in 0..10 {
dist.observe(ActionOutcome::Accepted);
}
for _ in 0..2 {
dist.observe(ActionOutcome::Rejected);
}
let p_accepted = dist.posterior_mean(ActionOutcome::Accepted);
let p_rejected = dist.posterior_mean(ActionOutcome::Rejected);
assert!(
p_accepted > p_rejected,
"P(Accepted)={:.3} should exceed P(Rejected)={:.3}",
p_accepted,
p_rejected,
);
assert!(p_accepted > 0.5);
}
#[test]
fn test_bayesian_smoothing() {
let mut dist = OutcomeDistribution::uninformative_prior();
dist.observe(ActionOutcome::Accepted);
let p_accepted = dist.posterior_mean(ActionOutcome::Accepted);
assert!(
(p_accepted - 0.333).abs() < 0.01,
"Single obs should be smoothed: P={:.3}",
p_accepted,
);
}
#[test]
fn test_positive_negative_rates() {
let mut dist = OutcomeDistribution::uninformative_prior();
for _ in 0..8 {
dist.observe(ActionOutcome::Accepted);
}
for _ in 0..2 {
dist.observe(ActionOutcome::Rejected);
}
assert!(dist.positive_rate() > dist.negative_rate());
}
#[test]
fn test_entropy() {
let uniform = OutcomeDistribution::uninformative_prior();
let h_uniform = uniform.entropy();
let mut peaked = OutcomeDistribution::uninformative_prior();
for _ in 0..100 {
peaked.observe(ActionOutcome::Accepted);
}
let h_peaked = peaked.entropy();
assert!(
h_peaked < h_uniform,
"Peaked entropy ({:.3}) should be lower than uniform ({:.3})",
h_peaked,
h_uniform,
);
}
#[test]
fn test_mode() {
let mut dist = OutcomeDistribution::uninformative_prior();
for _ in 0..20 {
dist.observe(ActionOutcome::Succeeded);
}
for _ in 0..3 {
dist.observe(ActionOutcome::Failed);
}
assert_eq!(dist.mode(), ActionOutcome::Succeeded);
}
#[test]
fn test_model_record_and_predict() {
let mut model = TransitionModel::new();
let features = noon_features();
for _ in 0..10 {
model.record(features, ActionKind::ExecuteTool, ActionOutcome::Succeeded);
}
for _ in 0..2 {
model.record(features, ActionKind::ExecuteTool, ActionOutcome::Failed);
}
let prediction = model.predict(&features, ActionKind::ExecuteTool);
assert!(
prediction.posterior_mean(ActionOutcome::Succeeded)
> prediction.posterior_mean(ActionOutcome::Failed),
);
assert_eq!(model.total_transitions, 12);
}
#[test]
fn test_model_fallback_to_uninformative() {
let model = TransitionModel::new();
let features = noon_features();
let prediction = model.predict(&features, ActionKind::SurfaceSuggestion);
let posteriors = prediction.all_posteriors();
for &p in &posteriors {
assert!((p - 0.2).abs() < 0.01);
}
}
#[test]
fn test_model_blended_prediction() {
let mut model = TransitionModel::new();
let features = noon_features();
model.record(features, ActionKind::SurfaceSuggestion, ActionOutcome::Accepted);
for _ in 0..50 {
model.record(
morning_features(),
ActionKind::SurfaceSuggestion,
ActionOutcome::Rejected,
);
}
let blended = model.predict_blended(&features, ActionKind::SurfaceSuggestion, 10);
let specific = model.predict(&features, ActionKind::SurfaceSuggestion);
assert!(
blended.posterior_mean(ActionOutcome::Rejected)
> specific.posterior_mean(ActionOutcome::Rejected),
"Blended Rejected ({:.4}) should exceed specific Rejected ({:.4}) — global influence",
blended.posterior_mean(ActionOutcome::Rejected),
specific.posterior_mean(ActionOutcome::Rejected),
);
}
#[test]
fn test_best_action() {
let mut model = TransitionModel::new();
let features = noon_features();
for _ in 0..10 {
model.record(features, ActionKind::SurfaceSuggestion, ActionOutcome::Accepted);
}
for _ in 0..10 {
model.record(features, ActionKind::SendNotification, ActionOutcome::Rejected);
}
let actions = [ActionKind::SurfaceSuggestion, ActionKind::SendNotification];
let best = model.best_action(&features, &actions);
assert_eq!(best, Some(ActionKind::SurfaceSuggestion));
}
#[test]
fn test_prediction_accuracy() {
let mut model = TransitionModel::new();
let features = noon_features();
for _ in 0..20 {
model.record(features, ActionKind::ExecuteTool, ActionOutcome::Succeeded);
}
let accuracy = model.prediction_accuracy(5);
assert!(
accuracy > 0.7,
"Accuracy ({:.3}) should be high for consistent outcomes",
accuracy,
);
}
#[test]
fn test_global_outcomes() {
let mut model = TransitionModel::new();
model.record(noon_features(), ActionKind::ExecuteTool, ActionOutcome::Succeeded);
model.record(morning_features(), ActionKind::SurfaceSuggestion, ActionOutcome::Accepted);
model.record(noon_features(), ActionKind::SendNotification, ActionOutcome::Rejected);
assert_eq!(model.global_outcomes.total_observations(), 3);
}
#[test]
fn test_top_pairs() {
let mut model = TransitionModel::new();
for _ in 0..10 {
model.record(noon_features(), ActionKind::ExecuteTool, ActionOutcome::Succeeded);
}
for _ in 0..5 {
model.record(morning_features(), ActionKind::SurfaceSuggestion, ActionOutcome::Accepted);
}
let top = model.top_pairs(2);
assert_eq!(top.len(), 2);
assert_eq!(top[0].1, 10); }
#[test]
fn test_world_model_summary() {
let mut model = TransitionModel::new();
for _ in 0..20 {
model.record(noon_features(), ActionKind::ExecuteTool, ActionOutcome::Succeeded);
}
let summary = summarize_world_model(&model);
assert_eq!(summary.total_transitions, 20);
assert_eq!(summary.unique_pairs, 1);
assert!(summary.global_positive_rate > 0.5);
}
}