use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::{fs, io};
pub use enwiro_daemon::meta::{
EnvStats, load_env_meta, now_timestamp, record_activation_per_env, record_cook_metadata_per_env,
};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UsageStats {
pub envs: HashMap<String, EnvStats>,
}
pub fn stats_path() -> Option<PathBuf> {
dirs::data_dir().map(|d| d.join("enwiro").join("usage-stats.json"))
}
pub fn load_stats(path: &Path) -> UsageStats {
fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
pub fn load_stats_default() -> UsageStats {
match stats_path() {
Some(path) => load_stats(&path),
None => UsageStats::default(),
}
}
fn save_stats(path: &Path, stats: &UsageStats) -> io::Result<()> {
enwiro_sdk::fs::atomic_write(path, serde_json::to_string(stats)?.as_bytes())
}
pub fn record_activation(env_name: &str) {
let Some(path) = stats_path() else { return };
record_activation_to(&path, env_name);
}
fn record_activation_to(path: &Path, env_name: &str) {
let mut stats = load_stats(path);
let entry = stats.envs.entry(env_name.to_string()).or_default();
entry.signals.activation_buffer.push((now_timestamp(), 1.0));
entry
.signals
.activation_buffer
.sort_by_key(|b| std::cmp::Reverse(b.0));
entry.signals.activation_buffer.truncate(10);
if let Err(e) = save_stats(path, &stats) {
tracing::warn!(error = %e, "Could not save usage stats");
}
}
pub fn frecency_score(stats: &EnvStats, now: i64) -> f64 {
let lambda = std::f64::consts::LN_2 / (48.0 * 3600.0);
stats
.signals
.activation_buffer
.iter()
.map(|&(ts, weight)| {
let age = (now - ts).max(0) as f64;
weight * (-lambda * age).exp()
})
.sum()
}
#[allow(dead_code)]
pub fn switch_score(buffer: &[(i64, f64)], now: i64) -> f64 {
let lambda_fast = std::f64::consts::LN_2 / (6.0 * 3600.0);
let lambda_slow = std::f64::consts::LN_2 / (48.0 * 3600.0);
let fast_sum: f64 = buffer
.iter()
.map(|&(ts, weight)| {
let age = (now - ts).max(0) as f64;
weight * (-lambda_fast * age).exp()
})
.sum();
let slow_sum: f64 = buffer
.iter()
.map(|&(ts, weight)| {
let age = (now - ts).max(0) as f64;
weight * (-lambda_slow * age).exp()
})
.sum();
0.5 * fast_sum + 0.5 * slow_sum
}
pub fn activation_percentile_scores(
all_stats: &HashMap<String, EnvStats>,
now: i64,
) -> HashMap<String, f64> {
let total = all_stats.len();
if total == 0 {
return HashMap::new();
}
let scores: HashMap<&str, f64> = all_stats
.iter()
.map(|(name, stats)| (name.as_str(), frecency_score(stats, now)))
.collect();
scores
.iter()
.map(|(&name, &score)| {
let count_below = scores.values().filter(|&&s| s < score).count();
(name.to_string(), count_below as f64 / total as f64)
})
.collect()
}
fn switch_percentile_scores(
all_stats: &HashMap<String, EnvStats>,
now: i64,
) -> HashMap<String, f64> {
let total = all_stats.len();
if total == 0 {
return HashMap::new();
}
let scores: HashMap<&str, f64> = all_stats
.iter()
.map(|(name, stats)| {
(
name.as_str(),
switch_score(&stats.signals.switch_buffer, now),
)
})
.collect();
scores
.iter()
.map(|(&name, &score)| {
let count_below = scores.values().filter(|&&s| s < score).count();
(name.to_string(), count_below as f64 / total as f64)
})
.collect()
}
pub fn launcher_score(all_stats: &HashMap<String, EnvStats>, now: i64) -> HashMap<String, f64> {
let activation = activation_percentile_scores(all_stats, now);
let switch = switch_percentile_scores(all_stats, now);
activation
.into_iter()
.map(|(name, act)| {
let sw = switch.get(&name).copied().unwrap_or(0.0);
(name, 0.8 * act + 0.2 * sw)
})
.collect()
}
pub fn slot_scores(all_stats: &HashMap<String, EnvStats>, now: i64) -> HashMap<String, f64> {
let activation = activation_percentile_scores(all_stats, now);
let switch = switch_percentile_scores(all_stats, now);
activation
.into_iter()
.map(|(name, act)| {
let sw = switch.get(&name).copied().unwrap_or(0.0);
(name, 0.2 * act + 0.8 * sw)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use enwiro_daemon::meta::UserIntentSignals;
#[test]
fn test_record_and_load() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("stats.json");
record_activation_to(&path, "my-project");
let stats = load_stats(&path);
assert_eq!(stats.envs.len(), 1);
let entry = &stats.envs["my-project"];
assert_eq!(entry.signals.activation_buffer.len(), 1);
assert!(entry.signals.activation_buffer[0].0 > 0);
}
#[test]
fn test_record_increments_count() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("stats.json");
record_activation_to(&path, "my-project");
record_activation_to(&path, "my-project");
let stats = load_stats(&path);
assert_eq!(stats.envs["my-project"].signals.activation_buffer.len(), 2);
}
#[test]
fn test_record_multiple_environments() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("stats.json");
record_activation_to(&path, "project-a");
record_activation_to(&path, "project-b");
record_activation_to(&path, "project-a");
let stats = load_stats(&path);
assert_eq!(stats.envs["project-a"].signals.activation_buffer.len(), 2);
assert_eq!(stats.envs["project-b"].signals.activation_buffer.len(), 1);
}
#[test]
fn test_load_missing_file_returns_empty() {
let stats = load_stats(Path::new("/nonexistent/path/stats.json"));
assert!(stats.envs.is_empty());
}
#[test]
fn test_load_corrupt_file_returns_empty() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("stats.json");
fs::write(&path, "not valid json{{{").unwrap();
let stats = load_stats(&path);
assert!(stats.envs.is_empty());
}
#[test]
fn test_frecency_score_empty_buffer_is_zero() {
let stats = EnvStats::default();
let now = now_timestamp();
let score = frecency_score(&stats, now);
assert!(
score.abs() < 1e-10,
"frecency_score for an empty buffer must be 0.0, got {score}"
);
}
#[test]
fn test_frecency_score_single_entry_at_now_is_one() {
let now: i64 = 1_700_000_000;
let stats = EnvStats {
signals: UserIntentSignals {
activation_buffer: vec![(now, 1.0)],
..Default::default()
},
..Default::default()
};
let score = frecency_score(&stats, now);
assert!(
(score - 1.0).abs() < 1e-6,
"score for a single entry at now must be ≈ 1.0, got {score}"
);
}
#[test]
fn test_frecency_score_single_entry_48h_ago_is_half() {
let now: i64 = 1_700_000_000;
let forty_eight_hours: i64 = 48 * 3600;
let stats = EnvStats {
signals: UserIntentSignals {
activation_buffer: vec![(now - forty_eight_hours, 1.0)],
..Default::default()
},
..Default::default()
};
let score = frecency_score(&stats, now);
assert!(
(score - 0.5).abs() < 1e-6,
"score for a single entry 48h ago must be ≈ 0.5, got {score}"
);
}
#[test]
fn test_frecency_score_sums_multiple_entries() {
let now: i64 = 1_700_000_000;
let stats = EnvStats {
signals: UserIntentSignals {
activation_buffer: vec![(now, 1.0), (now, 1.0)],
..Default::default()
},
..Default::default()
};
let score = frecency_score(&stats, now);
assert!(
(score - 2.0).abs() < 1e-6,
"score for two entries at now must be ≈ 2.0, got {score}"
);
}
#[test]
fn test_old_json_with_activation_count_only_gives_empty_buffer() {
let dir = tempfile::tempdir().unwrap();
let env_dir = dir.path().join("my-project");
fs::create_dir(&env_dir).unwrap();
fs::write(
env_dir.join("meta.json"),
r#"{"last_activated":1700000000,"activation_count":99}"#,
)
.unwrap();
let meta = load_env_meta(&env_dir);
assert!(
meta.signals.activation_buffer.is_empty(),
"old JSON without activation_buffer key must deserialize to an empty buffer"
);
}
#[test]
fn test_old_centralized_json_gives_empty_buffer() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("stats.json");
fs::write(
&path,
r#"{"envs":{"my-project":{"last_activated":1700000000,"activation_count":42}}}"#,
)
.unwrap();
let stats = load_stats(&path);
assert!(
stats.envs["my-project"]
.signals
.activation_buffer
.is_empty(),
"old centralized JSON without activation_buffer must deserialize to an empty buffer"
);
}
#[test]
fn test_activation_percentile_scores_empty_input() {
let all_stats: HashMap<String, EnvStats> = HashMap::new();
let now: i64 = 1_700_000_000;
let result = activation_percentile_scores(&all_stats, now);
assert!(
result.is_empty(),
"empty input must produce empty output, got {result:?}"
);
}
#[test]
fn test_activation_percentile_scores_all_zeros() {
let now: i64 = 1_700_000_000;
let mut all_stats: HashMap<String, EnvStats> = HashMap::new();
all_stats.insert("alpha".to_string(), EnvStats::default());
all_stats.insert("beta".to_string(), EnvStats::default());
let result: HashMap<String, f64> = activation_percentile_scores(&all_stats, now);
assert_eq!(result.len(), 2);
for (name, pct) in result.iter() {
let pct: f64 = *pct;
assert!(
pct.abs() < 1e-10,
"env '{name}' has no activations so percentile must be 0.0, got {pct}"
);
}
}
#[test]
fn test_activation_percentile_scores_three_varied_scores() {
let now: i64 = 1_700_000_000;
let forty_eight_hours: i64 = 48 * 3600;
let mut all_stats: HashMap<String, EnvStats> = HashMap::new();
all_stats.insert("low".to_string(), EnvStats::default());
all_stats.insert(
"mid".to_string(),
EnvStats {
signals: UserIntentSignals {
activation_buffer: vec![(now - forty_eight_hours, 1.0)],
..Default::default()
},
..Default::default()
},
);
all_stats.insert(
"high".to_string(),
EnvStats {
signals: UserIntentSignals {
activation_buffer: vec![(now, 1.0)],
..Default::default()
},
..Default::default()
},
);
let result = activation_percentile_scores(&all_stats, now);
assert_eq!(result.len(), 3);
let total = 3.0_f64;
assert!(
result["low"].abs() < 1e-10,
"low must have percentile 0/3 = 0.0, got {}",
result["low"]
);
assert!(
(result["mid"] - 1.0 / total).abs() < 1e-10,
"mid must have percentile 1/3, got {}",
result["mid"]
);
assert!(
(result["high"] - 2.0 / total).abs() < 1e-10,
"high must have percentile 2/3, got {}",
result["high"]
);
}
#[test]
fn test_activation_percentile_scores_ties_get_same_rank() {
let now: i64 = 1_700_000_000;
let mut all_stats: HashMap<String, EnvStats> = HashMap::new();
all_stats.insert("zero".to_string(), EnvStats::default());
all_stats.insert(
"tied-a".to_string(),
EnvStats {
signals: UserIntentSignals {
activation_buffer: vec![(now, 1.0)],
..Default::default()
},
..Default::default()
},
);
all_stats.insert(
"tied-b".to_string(),
EnvStats {
signals: UserIntentSignals {
activation_buffer: vec![(now, 1.0)],
..Default::default()
},
..Default::default()
},
);
let result = activation_percentile_scores(&all_stats, now);
assert_eq!(result.len(), 3);
assert!(
result["zero"].abs() < 1e-10,
"zero must have percentile 0/3 = 0.0, got {}",
result["zero"]
);
assert!(
(result["tied-a"] - 1.0 / 3.0).abs() < 1e-10,
"tied-a must have percentile 1/3, got {}",
result["tied-a"]
);
assert!(
(result["tied-b"] - 1.0 / 3.0).abs() < 1e-10,
"tied-b must have percentile 1/3 (same as tied-a), got {}",
result["tied-b"]
);
assert!(
(result["tied-a"] - result["tied-b"]).abs() < 1e-10,
"tied envs must have identical percentile ranks"
);
}
#[test]
fn test_launcher_score_empty_input_returns_empty() {
let all_stats: HashMap<String, EnvStats> = HashMap::new();
let now: i64 = 1_700_000_000;
let result = launcher_score(&all_stats, now);
assert!(
result.is_empty(),
"launcher_score with empty input must return an empty map, got {result:?}"
);
}
#[test]
fn test_launcher_score_ordering_high_beats_low() {
let now: i64 = 1_700_000_000;
let mut all_stats: HashMap<String, EnvStats> = HashMap::new();
all_stats.insert("never-used".to_string(), EnvStats::default());
all_stats.insert(
"recently-used".to_string(),
EnvStats {
signals: UserIntentSignals {
activation_buffer: vec![(now, 1.0)],
..Default::default()
},
..Default::default()
},
);
let result = launcher_score(&all_stats, now);
assert!(
result["recently-used"] > result["never-used"],
"recently-used must have a higher launcher_score than never-used; \
recently-used={}, never-used={}",
result["recently-used"],
result["never-used"]
);
}
#[test]
fn test_slot_scores_empty_input_returns_empty() {
let all_stats: HashMap<String, EnvStats> = HashMap::new();
let now: i64 = 1_700_000_000;
let result = slot_scores(&all_stats, now);
assert!(
result.is_empty(),
"slot_scores with empty input must return an empty map, got {result:?}"
);
}
#[test]
fn test_slot_scores_ordering_high_beats_low() {
let now: i64 = 1_700_000_000;
let mut all_stats: HashMap<String, EnvStats> = HashMap::new();
all_stats.insert("never-used".to_string(), EnvStats::default());
all_stats.insert(
"recently-used".to_string(),
EnvStats {
signals: UserIntentSignals {
activation_buffer: vec![(now, 1.0)],
..Default::default()
},
..Default::default()
},
);
let result = slot_scores(&all_stats, now);
assert!(
result["recently-used"] > result["never-used"],
"recently-used must have a higher slot_score than never-used; \
recently-used={}, never-used={}",
result["recently-used"],
result["never-used"]
);
}
#[test]
fn test_slot_scores_weights_switch_4x_more_than_activation() {
let now: i64 = 1_700_000_000;
let mut all_stats: HashMap<String, EnvStats> = HashMap::new();
all_stats.insert(
"activation-only".to_string(),
EnvStats {
signals: UserIntentSignals {
activation_buffer: vec![(now, 1.0)],
switch_buffer: vec![],
},
..Default::default()
},
);
all_stats.insert(
"switch-only".to_string(),
EnvStats {
signals: UserIntentSignals {
activation_buffer: vec![],
switch_buffer: vec![(now, 1.0)],
},
..Default::default()
},
);
let result = slot_scores(&all_stats, now);
assert!(
result["switch-only"] > result["activation-only"],
"switch-only must outscore activation-only in slot_scores \
(switch weight=0.8 > activation weight=0.2); \
switch-only={}, activation-only={}",
result["switch-only"],
result["activation-only"]
);
}
#[test]
fn test_launcher_score_weights_activation_4x_more_than_switch() {
let now: i64 = 1_700_000_000;
let mut all_stats: HashMap<String, EnvStats> = HashMap::new();
all_stats.insert(
"activation-only".to_string(),
EnvStats {
signals: UserIntentSignals {
activation_buffer: vec![(now, 1.0)],
switch_buffer: vec![],
},
..Default::default()
},
);
all_stats.insert(
"switch-only".to_string(),
EnvStats {
signals: UserIntentSignals {
activation_buffer: vec![],
switch_buffer: vec![(now, 1.0)],
},
..Default::default()
},
);
let result = launcher_score(&all_stats, now);
assert!(
result["activation-only"] > result["switch-only"],
"activation-only must outscore switch-only in launcher_score \
(activation weight=0.8 > switch weight=0.2); \
activation-only={}, switch-only={}",
result["activation-only"],
result["switch-only"]
);
}
#[test]
fn test_activation_only_env_scores_higher_in_launcher_than_slot() {
let now: i64 = 1_700_000_000;
let mut all_stats: HashMap<String, EnvStats> = HashMap::new();
all_stats.insert(
"activation-only".to_string(),
EnvStats {
signals: UserIntentSignals {
activation_buffer: vec![(now, 1.0)],
switch_buffer: vec![],
},
..Default::default()
},
);
all_stats.insert(
"switch-only".to_string(),
EnvStats {
signals: UserIntentSignals {
activation_buffer: vec![],
switch_buffer: vec![(now, 1.0)],
},
..Default::default()
},
);
let slot = slot_scores(&all_stats, now);
let launcher = launcher_score(&all_stats, now);
assert!(
launcher["activation-only"] > slot["activation-only"],
"activation-only must score higher under launcher_score than slot_scores; \
launcher={}, slot={}",
launcher["activation-only"],
slot["activation-only"]
);
}
#[test]
fn test_switch_only_env_scores_higher_in_slot_than_launcher() {
let now: i64 = 1_700_000_000;
let mut all_stats: HashMap<String, EnvStats> = HashMap::new();
all_stats.insert(
"activation-only".to_string(),
EnvStats {
signals: UserIntentSignals {
activation_buffer: vec![(now, 1.0)],
switch_buffer: vec![],
},
..Default::default()
},
);
all_stats.insert(
"switch-only".to_string(),
EnvStats {
signals: UserIntentSignals {
activation_buffer: vec![],
switch_buffer: vec![(now, 1.0)],
},
..Default::default()
},
);
let slot = slot_scores(&all_stats, now);
let launcher = launcher_score(&all_stats, now);
assert!(
slot["switch-only"] > launcher["switch-only"],
"switch-only must score higher under slot_scores than launcher_score; \
slot={}, launcher={}",
slot["switch-only"],
launcher["switch-only"]
);
}
#[test]
fn test_switch_score_empty_buffer_is_zero() {
let now: i64 = 1_700_000_000;
let score = switch_score(&[], now);
assert!(
score.abs() < 1e-10,
"switch_score for an empty buffer must be 0.0, got {score}"
);
}
#[test]
fn test_switch_score_single_entry_at_now_is_one() {
let now: i64 = 1_700_000_000;
let score = switch_score(&[(now, 1.0)], now);
assert!(
(score - 1.0).abs() < 1e-6,
"switch_score for a single entry at now must be ≈ 1.0, got {score}"
);
}
#[test]
fn test_switch_score_single_entry_6h_ago_blends_correctly() {
let now: i64 = 1_700_000_000;
let six_hours: i64 = 6 * 3600;
let buffer = [(now - six_hours, 1.0)];
let score = switch_score(&buffer, now);
let fast_sum = 0.5_f64; let slow_lambda = std::f64::consts::LN_2 / (48.0 * 3600.0);
let slow_sum = (-slow_lambda * six_hours as f64).exp();
let expected = 0.5 * fast_sum + 0.5 * slow_sum;
assert!(
(score - expected).abs() < 1e-6,
"switch_score for single entry 6h ago must be ≈ {expected:.6}, got {score:.6}"
);
}
#[test]
fn test_switch_score_single_entry_48h_ago_blends_correctly() {
let now: i64 = 1_700_000_000;
let forty_eight_hours: i64 = 48 * 3600;
let buffer = [(now - forty_eight_hours, 1.0)];
let score = switch_score(&buffer, now);
let fast_lambda = std::f64::consts::LN_2 / (6.0 * 3600.0);
let fast_sum = (-fast_lambda * forty_eight_hours as f64).exp(); let slow_sum = 0.5_f64; let expected = 0.5 * fast_sum + 0.5 * slow_sum;
assert!(
(score - expected).abs() < 1e-6,
"switch_score for single entry 48h ago must be ≈ {expected:.8}, got {score:.8}"
);
}
#[test]
fn test_switch_score_weight_doubles_score() {
let now: i64 = 1_700_000_000;
let score_w1 = switch_score(&[(now, 1.0)], now);
let score_w2 = switch_score(&[(now, 2.0)], now);
assert!(
(score_w2 - 2.0 * score_w1).abs() < 1e-10,
"weight=2.0 must yield exactly twice weight=1.0: got {score_w1} vs {score_w2}"
);
}
#[test]
fn test_switch_score_two_entries_at_now_sum_to_two() {
let now: i64 = 1_700_000_000;
let buffer = [(now, 1.0), (now, 1.0)];
let score = switch_score(&buffer, now);
assert!(
(score - 2.0).abs() < 1e-10,
"two entries at now with weight=1.0 each must sum to 2.0, got {score}"
);
}
#[test]
fn test_switch_score_future_timestamp_clamped_to_now() {
let now: i64 = 1_700_000_000;
let score_now = switch_score(&[(now, 1.0)], now);
let score_future = switch_score(&[(now + 3600, 1.0)], now);
assert!(
(score_future - score_now).abs() < 1e-10,
"future timestamp must clamp to elapsed=0; expected ≈{score_now}, got {score_future}"
);
}
}