use std::sync::Arc;
use async_trait::async_trait;
use brainwires_agents::eval::{EvaluationCase, TrialResult, ndcg_at_k};
use brainwires_storage::tiered_memory::{
MemoryAuthority, MemoryTier, MultiFactorScore, TierMetadata,
};
use chrono::Utc;
struct Scenario {
name: &'static str,
entries: Vec<(f32, f32, f32, bool)>,
ground_truth: Vec<usize>,
}
fn compute_scores(scenario: &Scenario) -> Vec<f64> {
scenario
.entries
.iter()
.map(|(sim, hours, imp, fast)| {
let recency = if *fast {
MultiFactorScore::recency_from_hours_fast(*hours)
} else {
MultiFactorScore::recency_from_hours(*hours)
};
MultiFactorScore::compute(*sim, recency, *imp).combined as f64
})
.collect()
}
pub struct MultiFactorRankingCase;
#[async_trait]
impl EvaluationCase for MultiFactorRankingCase {
fn name(&self) -> &str {
"multi_factor_score_ranking"
}
fn category(&self) -> &str {
"memory"
}
async fn run(&self, trial_id: usize) -> anyhow::Result<TrialResult> {
let start = std::time::Instant::now();
let scenarios = vec![
Scenario {
name: "A_similarity_dominance",
entries: vec![
(0.95, 5.0, 0.70, false), (0.60, 5.0, 0.70, false), (0.20, 5.0, 0.70, false), (0.05, 5.0, 0.70, false), ],
ground_truth: vec![3, 2, 1, 0],
},
Scenario {
name: "B_recency_decay",
entries: vec![
(0.70, 1.0, 0.60, false), (0.70, 24.0, 0.60, false), (0.70, 168.0, 0.60, false), (0.70, 720.0, 0.60, false), ],
ground_truth: vec![3, 2, 1, 0],
},
Scenario {
name: "C_fast_decay_temporal",
entries: vec![
(0.50, 1.0, 0.80, true), (0.85, 24.0, 0.80, true), (0.90, 72.0, 0.90, true), (0.95, 168.0, 0.95, true), ],
ground_truth: vec![3, 2, 0, 1], },
Scenario {
name: "D_importance_tiebreaker",
entries: vec![
(0.70, 5.0, 0.90, false), (0.70, 5.0, 0.60, false), (0.70, 5.0, 0.30, false), (0.70, 5.0, 0.05, false), ],
ground_truth: vec![3, 2, 1, 0],
},
];
let mut all_ndcg = Vec::new();
let mut failures = Vec::new();
for scenario in &scenarios {
let scores = compute_scores(scenario);
let ndcg = ndcg_at_k(&scores, &scenario.ground_truth, 0);
all_ndcg.push(ndcg);
if ndcg < 0.99 {
failures.push(format!(
"{}: NDCG={ndcg:.4} (scores={scores:?})",
scenario.name
));
}
}
let mean_ndcg = all_ndcg.iter().sum::<f64>() / all_ndcg.len() as f64;
let ms = start.elapsed().as_millis() as u64;
if failures.is_empty() {
Ok(TrialResult::success(trial_id, ms)
.with_meta("ndcg_mean", serde_json::json!(mean_ndcg))
.with_meta("ndcg_per_scenario", serde_json::json!(all_ndcg)))
} else {
Ok(TrialResult::failure(
trial_id,
ms,
format!("MultiFactorScore ranking failures: {}", failures.join("; ")),
)
.with_meta("ndcg_mean", serde_json::json!(mean_ndcg))
.with_meta("failures", serde_json::json!(failures)))
}
}
}
pub struct TierDemotionCase;
fn make_tier_metadata(importance: f32, age_secs: i64, access_count: u32) -> TierMetadata {
let now = Utc::now().timestamp();
TierMetadata {
message_id: uuid::Uuid::new_v4().to_string(),
tier: MemoryTier::Hot,
importance,
last_accessed: now - age_secs,
access_count,
created_at: now - age_secs,
authority: MemoryAuthority::Session,
}
}
#[async_trait]
impl EvaluationCase for TierDemotionCase {
fn name(&self) -> &str {
"tier_demotion_ordering"
}
fn category(&self) -> &str {
"memory"
}
async fn run(&self, trial_id: usize) -> anyhow::Result<TrialResult> {
let start = std::time::Instant::now();
let entries = vec![
("R1_keep", make_tier_metadata(0.90, 3_600, 10)), ("R2_medium", make_tier_metadata(0.50, 86_400, 3)), ("R3_demote", make_tier_metadata(0.20, 604_800, 1)), ("R4_demote_first", make_tier_metadata(0.05, 2_592_000, 0)), ];
let ground_truth: Vec<usize> = vec![3, 2, 1, 0];
let scores: Vec<f64> = entries
.iter()
.map(|(_, meta)| meta.retention_score() as f64)
.collect();
let ndcg = ndcg_at_k(&scores, &ground_truth, 4);
let ms = start.elapsed().as_millis() as u64;
let score_detail: Vec<String> = entries
.iter()
.zip(scores.iter())
.map(|((name, _), score)| format!("{name}={score:.4}"))
.collect();
let naming_note = "Note: TierMetadata::retention_score uses SIMILARITY_WEIGHT(0.50) \
for the importance term — semantically confusing but numerically correct. \
Consider renaming to IMPORTANCE_RETENTION_WEIGHT in a future refactor.";
if ndcg >= 0.99 {
Ok(TrialResult::success(trial_id, ms)
.with_meta("ndcg", serde_json::json!(ndcg))
.with_meta("retention_scores", serde_json::json!(score_detail))
.with_meta("naming_note", serde_json::json!(naming_note)))
} else {
Ok(TrialResult::failure(
trial_id,
ms,
format!(
"NDCG@4={ndcg:.4} < 0.99 — demotion ordering is incorrect. \
scores=[{}]",
score_detail.join(", ")
),
)
.with_meta("ndcg", serde_json::json!(ndcg)))
}
}
}
pub fn multi_factor_suite() -> Vec<Arc<dyn EvaluationCase>> {
vec![Arc::new(MultiFactorRankingCase), Arc::new(TierDemotionCase)]
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_multi_factor_ranking_passes() {
let case = MultiFactorRankingCase;
let result = case.run(0).await.unwrap();
assert!(
result.success,
"MultiFactorRankingCase failed: {:?}",
result.error
);
}
#[tokio::test]
async fn test_tier_demotion_passes() {
let case = TierDemotionCase;
let result = case.run(0).await.unwrap();
assert!(
result.success,
"TierDemotionCase failed: {:?}",
result.error
);
}
}