use crate::models::episode::{
CounterfactualRun, EpisodeOutcome, Intervention, InterventionType, REGRET_ENTRY_SCHEMA_V1,
RegretCategory, RegretEntry,
};
pub const REGRET_SCORING_SCHEMA_V1: &str = "ee.regret_scoring.v1";
pub const DEFAULT_MISSED_WEIGHT: f64 = 1.0;
pub const DEFAULT_STALE_WEIGHT: f64 = 0.8;
pub const DEFAULT_NOISY_WEIGHT: f64 = 0.3;
pub const DEFAULT_HARMFUL_WEIGHT: f64 = 1.5;
pub const MIN_REGRET_CONFIDENCE: f64 = 0.5;
pub const MIN_ACTIONABLE_REGRET: f64 = 0.4;
#[derive(Clone, Debug, PartialEq)]
pub struct RegretScoringConfig {
pub missed_weight: f64,
pub stale_weight: f64,
pub noisy_weight: f64,
pub harmful_weight: f64,
pub min_confidence: f64,
pub min_actionable: f64,
}
impl Default for RegretScoringConfig {
fn default() -> Self {
Self {
missed_weight: DEFAULT_MISSED_WEIGHT,
stale_weight: DEFAULT_STALE_WEIGHT,
noisy_weight: DEFAULT_NOISY_WEIGHT,
harmful_weight: DEFAULT_HARMFUL_WEIGHT,
min_confidence: MIN_REGRET_CONFIDENCE,
min_actionable: MIN_ACTIONABLE_REGRET,
}
}
}
impl RegretScoringConfig {
#[must_use]
pub fn new(
missed_weight: f64,
stale_weight: f64,
noisy_weight: f64,
harmful_weight: f64,
) -> Self {
Self {
missed_weight,
stale_weight,
noisy_weight,
harmful_weight,
..Default::default()
}
}
#[must_use]
pub fn with_min_confidence(mut self, min: f64) -> Self {
self.min_confidence = min;
self
}
#[must_use]
pub fn with_min_actionable(mut self, min: f64) -> Self {
self.min_actionable = min;
self
}
#[must_use]
pub fn weight_for(&self, category: RegretCategory) -> f64 {
match category {
RegretCategory::MissingKnowledge => self.missed_weight,
RegretCategory::StaleInformation => self.stale_weight,
RegretCategory::RetrievalFailure => self.missed_weight,
RegretCategory::UnderutilizedMemory => self.noisy_weight,
RegretCategory::Misinformation => self.harmful_weight,
RegretCategory::Other => self.noisy_weight,
}
}
}
#[derive(Clone, Debug)]
pub struct RegretScoringInput {
pub entry_id: String,
pub episode_id: String,
pub counterfactual_run: CounterfactualRun,
pub interventions: Vec<Intervention>,
pub original_outcome: EpisodeOutcome,
pub timestamp: String,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RegretScoringOutput {
pub entry: Option<RegretEntry>,
pub raw_score: f64,
pub weighted_score: f64,
pub category: RegretCategory,
pub is_actionable: bool,
pub explanation: String,
}
#[must_use]
pub fn score_counterfactual(
input: &RegretScoringInput,
config: &RegretScoringConfig,
) -> RegretScoringOutput {
let cfr = &input.counterfactual_run;
let category = categorize_interventions(&input.interventions);
let raw_score = calculate_outcome_improvement(input.original_outcome, cfr.hypothetical_outcome);
let category_weight = config.weight_for(category);
let weighted_score = raw_score * category_weight * cfr.confidence;
let meets_confidence = cfr.confidence >= config.min_confidence;
let is_actionable = weighted_score >= config.min_actionable && meets_confidence;
let explanation = build_explanation(
input.original_outcome,
cfr.hypothetical_outcome,
category,
raw_score,
weighted_score,
cfr.confidence,
is_actionable,
);
let entry = if is_actionable {
let intervention_id = cfr
.intervention_ids
.first()
.cloned()
.unwrap_or_else(|| "unknown".to_string());
Some(RegretEntry {
schema: REGRET_ENTRY_SCHEMA_V1,
id: input.entry_id.clone(),
episode_id: input.episode_id.clone(),
counterfactual_run_id: cfr.id.clone(),
intervention_id,
regret_score: weighted_score,
confidence: cfr.confidence,
category,
promoted: false,
promoted_memory_id: None,
created_at: input.timestamp.clone(),
})
} else {
None
};
RegretScoringOutput {
entry,
raw_score,
weighted_score,
category,
is_actionable,
explanation,
}
}
fn categorize_interventions(interventions: &[Intervention]) -> RegretCategory {
if interventions.is_empty() {
return RegretCategory::Other;
}
let mut add_count = 0u32;
let mut remove_count = 0u32;
let mut stale_replace_count = 0u32;
let mut misinformation_replace_count = 0u32;
let mut strengthen_count = 0u32;
let mut weaken_count = 0u32;
let mut rerank_count = 0u32;
for intervention in interventions {
match intervention.intervention_type {
InterventionType::AddMemory => add_count += 1,
InterventionType::RemoveMemory => remove_count += 1,
InterventionType::ReplaceContent => {
if replacement_identifies_stale_content(intervention) {
stale_replace_count += 1;
} else {
misinformation_replace_count += 1;
}
}
InterventionType::Strengthen => strengthen_count += 1,
InterventionType::Weaken => weaken_count += 1,
InterventionType::Rerank => rerank_count += 1,
}
}
if misinformation_replace_count > 0 {
return RegretCategory::Misinformation;
}
if stale_replace_count > 0 {
return RegretCategory::StaleInformation;
}
if add_count > 0 {
return RegretCategory::MissingKnowledge;
}
if strengthen_count > 0 || rerank_count > 0 {
return RegretCategory::RetrievalFailure;
}
if remove_count > 0 || weaken_count > 0 {
return RegretCategory::UnderutilizedMemory;
}
RegretCategory::Other
}
fn replacement_identifies_stale_content(intervention: &Intervention) -> bool {
let mut haystack = intervention.description.to_ascii_lowercase();
if let Some(rationale) = &intervention.rationale {
haystack.push('\n');
haystack.push_str(&rationale.to_ascii_lowercase());
}
if let Some(content) = &intervention.hypothetical_content {
haystack.push('\n');
haystack.push_str(&content.to_ascii_lowercase());
}
[
"stale",
"outdated",
"obsolete",
"superseded",
"expired",
"no longer",
"contradicted",
]
.iter()
.any(|needle| haystack.contains(needle))
}
fn calculate_outcome_improvement(original: EpisodeOutcome, hypothetical: EpisodeOutcome) -> f64 {
let original_value = outcome_value(original);
let hypothetical_value = outcome_value(hypothetical);
let delta = hypothetical_value - original_value;
delta.clamp(0.0, 1.0)
}
fn outcome_value(outcome: EpisodeOutcome) -> f64 {
match outcome {
EpisodeOutcome::Success => 1.0,
EpisodeOutcome::Unknown => 0.5,
EpisodeOutcome::Cancelled => 0.3,
EpisodeOutcome::Timeout => 0.2,
EpisodeOutcome::Failure => 0.0,
}
}
fn build_explanation(
original: EpisodeOutcome,
hypothetical: EpisodeOutcome,
category: RegretCategory,
raw_score: f64,
weighted_score: f64,
confidence: f64,
is_actionable: bool,
) -> String {
let action_status = if is_actionable {
"actionable"
} else {
"not actionable"
};
format!(
"Outcome: {} -> {} (improvement: {:.2}). Category: {}. \
Weighted score: {:.3} (confidence: {:.2}). Status: {}.",
original.as_str(),
hypothetical.as_str(),
raw_score,
category.as_str(),
weighted_score,
confidence,
action_status,
)
}
#[must_use]
pub fn score_counterfactuals(
inputs: &[RegretScoringInput],
config: &RegretScoringConfig,
) -> Vec<RegretScoringOutput> {
inputs
.iter()
.map(|input| score_counterfactual(input, config))
.collect()
}
#[must_use]
pub fn filter_actionable(outputs: &[RegretScoringOutput]) -> Vec<&RegretEntry> {
outputs.iter().filter_map(|o| o.entry.as_ref()).collect()
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct RegretStatistics {
pub total_analyzed: u32,
pub actionable_count: u32,
pub average_score: f64,
pub max_score: f64,
pub by_category: Vec<(RegretCategory, u32)>,
}
impl RegretStatistics {
#[must_use]
pub fn from_outputs(outputs: &[RegretScoringOutput]) -> Self {
if outputs.is_empty() {
return Self::default();
}
let total_analyzed = outputs.len() as u32;
let actionable_count = outputs.iter().filter(|o| o.is_actionable).count() as u32;
let sum_score: f64 = outputs.iter().map(|o| o.weighted_score).sum();
let average_score = sum_score / outputs.len() as f64;
let max_score = outputs
.iter()
.map(|o| o.weighted_score)
.fold(0.0_f64, |a, b| if b.is_nan() { a } else { a.max(b) });
let mut category_counts = std::collections::HashMap::new();
for output in outputs {
*category_counts.entry(output.category).or_insert(0u32) += 1;
}
let mut by_category: Vec<_> = category_counts.into_iter().collect();
by_category.sort_by_key(|(category, _count)| category.as_str());
Self {
total_analyzed,
actionable_count,
average_score,
max_score,
by_category,
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum SuggestedCurationAction {
AddMemory,
PromoteConfidence,
DeprecateMemory,
SupersedeMemory,
TuneRetrieval,
None,
}
impl SuggestedCurationAction {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::AddMemory => "add_memory",
Self::PromoteConfidence => "promote_confidence",
Self::DeprecateMemory => "deprecate_memory",
Self::SupersedeMemory => "supersede_memory",
Self::TuneRetrieval => "tune_retrieval",
Self::None => "none",
}
}
}
#[must_use]
pub fn suggest_curation(entry: &RegretEntry) -> SuggestedCurationAction {
match entry.category {
RegretCategory::MissingKnowledge => SuggestedCurationAction::AddMemory,
RegretCategory::StaleInformation => SuggestedCurationAction::SupersedeMemory,
RegretCategory::RetrievalFailure => SuggestedCurationAction::TuneRetrieval,
RegretCategory::UnderutilizedMemory => SuggestedCurationAction::DeprecateMemory,
RegretCategory::Misinformation => SuggestedCurationAction::DeprecateMemory,
RegretCategory::Other => {
if entry.regret_score >= 0.7 {
SuggestedCurationAction::AddMemory
} else {
SuggestedCurationAction::None
}
}
}
}
pub const COUNTERFACTUAL_CANDIDATE_SCHEMA_V1: &str = "ee.counterfactual_candidate.v1";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CounterfactualMode {
DryRun,
Persist,
}
impl CounterfactualMode {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::DryRun => "dry_run",
Self::Persist => "persist",
}
}
#[must_use]
pub const fn is_dry_run(self) -> bool {
matches!(self, Self::DryRun)
}
}
#[derive(Clone, Debug)]
pub struct CounterfactualCandidate {
pub schema: &'static str,
pub regret_entry_id: String,
pub episode_id: String,
pub workspace_id: String,
pub target_memory_id: Option<String>,
pub candidate_type: super::CandidateType,
pub suggested_action: SuggestedCurationAction,
pub reason: String,
pub confidence: f64,
pub regret_score: f64,
pub category: RegretCategory,
pub dry_run: bool,
}
impl CounterfactualCandidate {
#[must_use]
pub fn is_actionable(&self) -> bool {
!matches!(self.suggested_action, SuggestedCurationAction::None)
}
}
#[must_use]
pub fn generate_candidate_from_regret(
entry: &RegretEntry,
workspace_id: &str,
mode: CounterfactualMode,
) -> Option<CounterfactualCandidate> {
let suggested_action = suggest_curation(entry);
if matches!(suggested_action, SuggestedCurationAction::None) {
return None;
}
let candidate_type = action_to_candidate_type(suggested_action);
let reason = build_candidate_reason(entry, suggested_action);
Some(CounterfactualCandidate {
schema: COUNTERFACTUAL_CANDIDATE_SCHEMA_V1,
regret_entry_id: entry.id.clone(),
episode_id: entry.episode_id.clone(),
workspace_id: workspace_id.to_string(),
target_memory_id: entry.promoted_memory_id.clone(),
candidate_type,
suggested_action,
reason,
confidence: entry.confidence,
regret_score: entry.regret_score,
category: entry.category,
dry_run: mode.is_dry_run(),
})
}
#[must_use]
pub fn generate_candidates_from_counterfactuals(
entries: &[RegretEntry],
workspace_id: &str,
mode: CounterfactualMode,
) -> Vec<CounterfactualCandidate> {
entries
.iter()
.filter_map(|e| generate_candidate_from_regret(e, workspace_id, mode))
.collect()
}
#[derive(Clone, Debug, Default)]
pub struct CounterfactualCandidateReport {
pub entries_analyzed: u32,
pub candidates_generated: u32,
pub by_type: Vec<(super::CandidateType, u32)>,
pub by_action: Vec<(SuggestedCurationAction, u32)>,
pub mode: Option<CounterfactualMode>,
}
impl CounterfactualCandidateReport {
#[must_use]
pub fn from_candidates(
entries_analyzed: usize,
candidates: &[CounterfactualCandidate],
mode: CounterfactualMode,
) -> Self {
let mut type_counts = std::collections::HashMap::new();
let mut action_counts = std::collections::HashMap::new();
for c in candidates {
*type_counts.entry(c.candidate_type).or_insert(0u32) += 1;
*action_counts.entry(c.suggested_action).or_insert(0u32) += 1;
}
let mut by_type: Vec<_> = type_counts.into_iter().collect();
by_type.sort_by_key(|(candidate_type, _count)| candidate_type.as_str());
let mut by_action: Vec<_> = action_counts.into_iter().collect();
by_action.sort_by_key(|(action, _count)| action.as_str());
Self {
entries_analyzed: entries_analyzed as u32,
candidates_generated: candidates.len() as u32,
by_type,
by_action,
mode: Some(mode),
}
}
}
fn action_to_candidate_type(action: SuggestedCurationAction) -> super::CandidateType {
match action {
SuggestedCurationAction::AddMemory => super::CandidateType::Consolidate,
SuggestedCurationAction::PromoteConfidence => super::CandidateType::Promote,
SuggestedCurationAction::DeprecateMemory => super::CandidateType::Deprecate,
SuggestedCurationAction::SupersedeMemory => super::CandidateType::Supersede,
SuggestedCurationAction::TuneRetrieval => super::CandidateType::Promote,
SuggestedCurationAction::None => unreachable!("None action filtered before type mapping"),
}
}
fn build_candidate_reason(entry: &RegretEntry, action: SuggestedCurationAction) -> String {
format!(
"Counterfactual analysis ({}) suggests {} for episode {}. Regret score: {:.3}, confidence: {:.2}.",
entry.category.as_str(),
action.as_str(),
entry.episode_id,
entry.regret_score,
entry.confidence,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::episode::CounterfactualMethod;
fn sample_cfr(
hypothetical: EpisodeOutcome,
confidence: f64,
analysis: Option<&str>,
) -> CounterfactualRun {
let mut cfr = CounterfactualRun::new(
"cfr_test_001",
"ep_test_001",
hypothetical,
confidence,
CounterfactualMethod::HeuristicEstimate,
"2026-04-30T12:00:00Z",
);
cfr.add_intervention("int_test_001");
if let Some(a) = analysis {
cfr.analysis = Some(a.to_string());
}
cfr
}
fn sample_intervention(intervention_type: InterventionType) -> Intervention {
Intervention::new(
"int_test_001",
intervention_type,
format!("Test {} intervention", intervention_type.as_str()),
"2026-04-30T12:00:00Z",
)
}
fn sample_input(
original: EpisodeOutcome,
cfr: CounterfactualRun,
interventions: Vec<Intervention>,
) -> RegretScoringInput {
RegretScoringInput {
entry_id: "reg_test_001".to_string(),
episode_id: "ep_test_001".to_string(),
counterfactual_run: cfr,
interventions,
original_outcome: original,
timestamp: "2026-04-30T12:00:00Z".to_string(),
}
}
#[test]
fn default_config_values() {
let config = RegretScoringConfig::default();
assert!((config.missed_weight - 1.0).abs() < f64::EPSILON);
assert!((config.stale_weight - 0.8).abs() < f64::EPSILON);
assert!((config.noisy_weight - 0.3).abs() < f64::EPSILON);
assert!((config.harmful_weight - 1.5).abs() < f64::EPSILON);
}
#[test]
fn config_weight_for_category() {
let config = RegretScoringConfig::default();
assert!((config.weight_for(RegretCategory::MissingKnowledge) - 1.0).abs() < f64::EPSILON);
assert!((config.weight_for(RegretCategory::StaleInformation) - 0.8).abs() < f64::EPSILON);
assert!((config.weight_for(RegretCategory::Misinformation) - 1.5).abs() < f64::EPSILON);
}
#[test]
fn outcome_improvement_failure_to_success() {
let improvement =
calculate_outcome_improvement(EpisodeOutcome::Failure, EpisodeOutcome::Success);
assert!((improvement - 1.0).abs() < f64::EPSILON);
}
#[test]
fn outcome_improvement_success_to_failure_is_zero() {
let improvement =
calculate_outcome_improvement(EpisodeOutcome::Success, EpisodeOutcome::Failure);
assert!(improvement.abs() < f64::EPSILON);
}
#[test]
fn outcome_improvement_failure_to_unknown() {
let improvement =
calculate_outcome_improvement(EpisodeOutcome::Failure, EpisodeOutcome::Unknown);
assert!((improvement - 0.5).abs() < f64::EPSILON);
}
#[test]
fn categorize_add_memory_as_missing_knowledge() {
let interventions = vec![sample_intervention(InterventionType::AddMemory)];
let category = categorize_interventions(&interventions);
assert_eq!(category, RegretCategory::MissingKnowledge);
}
#[test]
fn categorize_replace_content_as_misinformation() {
let interventions = vec![sample_intervention(InterventionType::ReplaceContent)];
let category = categorize_interventions(&interventions);
assert_eq!(category, RegretCategory::Misinformation);
}
#[test]
fn categorize_stale_replace_content_as_stale_information() {
let interventions = vec![
sample_intervention(InterventionType::ReplaceContent)
.with_rationale("Replace an outdated release rule with the current policy."),
];
let category = categorize_interventions(&interventions);
assert_eq!(category, RegretCategory::StaleInformation);
}
#[test]
fn categorize_strengthen_as_retrieval_failure() {
let interventions = vec![sample_intervention(InterventionType::Strengthen)];
let category = categorize_interventions(&interventions);
assert_eq!(category, RegretCategory::RetrievalFailure);
}
#[test]
fn categorize_rerank_as_retrieval_failure() {
let interventions = vec![sample_intervention(InterventionType::Rerank)];
let category = categorize_interventions(&interventions);
assert_eq!(category, RegretCategory::RetrievalFailure);
}
#[test]
fn categorize_remove_memory_as_underutilized() {
let interventions = vec![sample_intervention(InterventionType::RemoveMemory)];
let category = categorize_interventions(&interventions);
assert_eq!(category, RegretCategory::UnderutilizedMemory);
}
#[test]
fn categorize_weaken_as_underutilized() {
let interventions = vec![sample_intervention(InterventionType::Weaken)];
let category = categorize_interventions(&interventions);
assert_eq!(category, RegretCategory::UnderutilizedMemory);
}
#[test]
fn categorize_empty_interventions_as_other() {
let interventions: Vec<Intervention> = vec![];
let category = categorize_interventions(&interventions);
assert_eq!(category, RegretCategory::Other);
}
#[test]
fn categorize_prioritizes_add_over_remove() {
let interventions = vec![
sample_intervention(InterventionType::RemoveMemory),
sample_intervention(InterventionType::AddMemory),
];
let category = categorize_interventions(&interventions);
assert_eq!(category, RegretCategory::MissingKnowledge);
}
#[test]
fn score_actionable_regret() {
let config = RegretScoringConfig::default();
let cfr = sample_cfr(EpisodeOutcome::Success, 0.9, None);
let interventions = vec![sample_intervention(InterventionType::AddMemory)];
let input = sample_input(EpisodeOutcome::Failure, cfr, interventions);
let output = score_counterfactual(&input, &config);
assert!(output.is_actionable);
assert!(output.entry.is_some());
assert!((output.raw_score - 1.0).abs() < f64::EPSILON);
assert!(output.weighted_score > 0.4);
}
#[test]
#[allow(clippy::expect_used)]
fn score_stale_replacement_generates_supersede_candidate() {
let config = RegretScoringConfig::default();
let cfr = sample_cfr(EpisodeOutcome::Success, 0.9, None);
let interventions = vec![
sample_intervention(InterventionType::ReplaceContent)
.with_rationale("The old memory is stale and should be superseded."),
];
let input = sample_input(EpisodeOutcome::Failure, cfr, interventions);
let output = score_counterfactual(&input, &config);
assert!(output.is_actionable);
assert_eq!(output.category, RegretCategory::StaleInformation);
let entry = output
.entry
.as_ref()
.expect("stale replacement should create an actionable regret entry");
let candidate =
generate_candidate_from_regret(entry, "ws_test", CounterfactualMode::DryRun)
.expect("stale regret should generate a curation candidate");
assert_eq!(
candidate.suggested_action,
SuggestedCurationAction::SupersedeMemory
);
assert_eq!(
candidate.candidate_type,
super::super::CandidateType::Supersede
);
}
#[test]
fn score_low_confidence_not_actionable() {
let config = RegretScoringConfig::default();
let cfr = sample_cfr(EpisodeOutcome::Success, 0.3, None);
let interventions = vec![sample_intervention(InterventionType::AddMemory)];
let input = sample_input(EpisodeOutcome::Failure, cfr, interventions);
let output = score_counterfactual(&input, &config);
assert!(!output.is_actionable);
assert!(output.entry.is_none());
}
#[test]
fn score_no_improvement_not_actionable() {
let config = RegretScoringConfig::default();
let cfr = sample_cfr(EpisodeOutcome::Failure, 0.9, None);
let interventions = vec![sample_intervention(InterventionType::AddMemory)];
let input = sample_input(EpisodeOutcome::Failure, cfr, interventions);
let output = score_counterfactual(&input, &config);
assert!(!output.is_actionable);
assert!(output.raw_score.abs() < f64::EPSILON);
}
#[test]
fn filter_actionable_entries() {
let config = RegretScoringConfig::default();
let cfr1 = sample_cfr(EpisodeOutcome::Success, 0.9, None);
let cfr2 = sample_cfr(EpisodeOutcome::Failure, 0.9, None);
let inputs = vec![
sample_input(
EpisodeOutcome::Failure,
cfr1,
vec![sample_intervention(InterventionType::AddMemory)],
),
sample_input(
EpisodeOutcome::Failure,
cfr2,
vec![sample_intervention(InterventionType::RemoveMemory)],
),
];
let outputs = score_counterfactuals(&inputs, &config);
let actionable = filter_actionable(&outputs);
assert_eq!(actionable.len(), 1);
}
#[test]
fn statistics_from_outputs() {
let config = RegretScoringConfig::default();
let cfr1 = sample_cfr(EpisodeOutcome::Success, 0.9, None);
let cfr2 = sample_cfr(EpisodeOutcome::Unknown, 0.7, None);
let inputs = vec![
sample_input(
EpisodeOutcome::Failure,
cfr1,
vec![sample_intervention(InterventionType::AddMemory)],
),
sample_input(
EpisodeOutcome::Failure,
cfr2,
vec![sample_intervention(InterventionType::ReplaceContent)],
),
];
let outputs = score_counterfactuals(&inputs, &config);
let stats = RegretStatistics::from_outputs(&outputs);
assert_eq!(stats.total_analyzed, 2);
assert!(stats.actionable_count >= 1);
assert!(stats.average_score > 0.0);
}
#[test]
fn statistics_by_category_order_is_stable() {
let outputs = vec![
RegretScoringOutput {
entry: None,
raw_score: 0.1,
weighted_score: 0.1,
category: RegretCategory::Other,
is_actionable: false,
explanation: "other".to_string(),
},
RegretScoringOutput {
entry: None,
raw_score: 0.2,
weighted_score: 0.2,
category: RegretCategory::MissingKnowledge,
is_actionable: false,
explanation: "missing".to_string(),
},
RegretScoringOutput {
entry: None,
raw_score: 0.3,
weighted_score: 0.3,
category: RegretCategory::Misinformation,
is_actionable: false,
explanation: "misinformation".to_string(),
},
RegretScoringOutput {
entry: None,
raw_score: 0.4,
weighted_score: 0.4,
category: RegretCategory::MissingKnowledge,
is_actionable: true,
explanation: "missing again".to_string(),
},
];
let stats = RegretStatistics::from_outputs(&outputs);
assert_eq!(
stats.by_category,
vec![
(RegretCategory::Misinformation, 1),
(RegretCategory::MissingKnowledge, 2),
(RegretCategory::Other, 1),
]
);
}
#[test]
fn counterfactual_report_orders_type_and_action_counts() {
fn candidate(
candidate_type: super::super::CandidateType,
suggested_action: SuggestedCurationAction,
) -> CounterfactualCandidate {
CounterfactualCandidate {
schema: COUNTERFACTUAL_CANDIDATE_SCHEMA_V1,
regret_entry_id: "reg".to_string(),
episode_id: "ep".to_string(),
workspace_id: "workspace".to_string(),
target_memory_id: None,
candidate_type,
suggested_action,
reason: "test".to_string(),
confidence: 0.8,
regret_score: 0.7,
category: RegretCategory::Other,
dry_run: true,
}
}
let candidates = vec![
candidate(
super::super::CandidateType::Supersede,
SuggestedCurationAction::SupersedeMemory,
),
candidate(
super::super::CandidateType::Consolidate,
SuggestedCurationAction::AddMemory,
),
candidate(
super::super::CandidateType::Promote,
SuggestedCurationAction::PromoteConfidence,
),
candidate(
super::super::CandidateType::Consolidate,
SuggestedCurationAction::AddMemory,
),
];
let report = CounterfactualCandidateReport::from_candidates(
4,
&candidates,
CounterfactualMode::DryRun,
);
assert_eq!(
report.by_type,
vec![
(super::super::CandidateType::Consolidate, 2),
(super::super::CandidateType::Promote, 1),
(super::super::CandidateType::Supersede, 1),
]
);
assert_eq!(
report.by_action,
vec![
(SuggestedCurationAction::AddMemory, 2),
(SuggestedCurationAction::PromoteConfidence, 1),
(SuggestedCurationAction::SupersedeMemory, 1),
]
);
}
#[test]
fn suggest_curation_for_missing() {
let entry = RegretEntry::new(
"reg_001",
"ep_001",
"cfr_001",
"int_001",
0.8,
0.9,
RegretCategory::MissingKnowledge,
"2026-04-30T12:00:00Z",
);
let action = suggest_curation(&entry);
assert_eq!(action, SuggestedCurationAction::AddMemory);
}
#[test]
fn suggest_curation_for_stale() {
let entry = RegretEntry::new(
"reg_002",
"ep_001",
"cfr_001",
"int_001",
0.7,
0.85,
RegretCategory::StaleInformation,
"2026-04-30T12:00:00Z",
);
let action = suggest_curation(&entry);
assert_eq!(action, SuggestedCurationAction::SupersedeMemory);
}
#[test]
fn suggest_curation_for_harmful() {
let entry = RegretEntry::new(
"reg_003",
"ep_001",
"cfr_001",
"int_001",
0.9,
0.95,
RegretCategory::Misinformation,
"2026-04-30T12:00:00Z",
);
let action = suggest_curation(&entry);
assert_eq!(action, SuggestedCurationAction::DeprecateMemory);
}
#[test]
fn suggest_curation_for_noisy_memory_deprecates() {
let entry = RegretEntry::new(
"reg_noisy_001",
"ep_001",
"cfr_001",
"int_remove_noisy",
0.6,
0.8,
RegretCategory::UnderutilizedMemory,
"2026-04-30T12:00:00Z",
);
let action = suggest_curation(&entry);
assert_eq!(action, SuggestedCurationAction::DeprecateMemory);
}
#[test]
fn suggested_action_strings_are_stable() {
assert_eq!(SuggestedCurationAction::AddMemory.as_str(), "add_memory");
assert_eq!(
SuggestedCurationAction::PromoteConfidence.as_str(),
"promote_confidence"
);
assert_eq!(
SuggestedCurationAction::DeprecateMemory.as_str(),
"deprecate_memory"
);
assert_eq!(
SuggestedCurationAction::SupersedeMemory.as_str(),
"supersede_memory"
);
assert_eq!(
SuggestedCurationAction::TuneRetrieval.as_str(),
"tune_retrieval"
);
assert_eq!(SuggestedCurationAction::None.as_str(), "none");
}
#[test]
fn counterfactual_mode_dry_run() {
assert!(CounterfactualMode::DryRun.is_dry_run());
assert!(!CounterfactualMode::Persist.is_dry_run());
assert_eq!(CounterfactualMode::DryRun.as_str(), "dry_run");
assert_eq!(CounterfactualMode::Persist.as_str(), "persist");
}
#[test]
#[allow(clippy::expect_used)]
fn generate_candidate_from_actionable_regret() {
let entry = RegretEntry::new(
"reg_004",
"ep_test_001",
"cfr_test_001",
"int_test_001",
0.8,
0.9,
RegretCategory::MissingKnowledge,
"2026-04-30T12:00:00Z",
);
let candidate =
generate_candidate_from_regret(&entry, "ws_test", CounterfactualMode::DryRun);
assert!(candidate.is_some());
let c = candidate.expect("actionable regret should generate a curation candidate");
assert_eq!(c.regret_entry_id, "reg_004");
assert_eq!(c.workspace_id, "ws_test");
assert!(c.dry_run);
assert!(c.is_actionable());
assert_eq!(c.suggested_action, SuggestedCurationAction::AddMemory);
assert_eq!(c.candidate_type, super::super::CandidateType::Consolidate);
}
#[test]
fn generate_candidate_from_non_actionable_regret() {
let entry = RegretEntry::new(
"reg_005",
"ep_test_002",
"cfr_test_002",
"int_test_002",
0.2,
0.3,
RegretCategory::Other,
"2026-04-30T12:00:00Z",
);
let candidate =
generate_candidate_from_regret(&entry, "ws_test", CounterfactualMode::DryRun);
assert!(candidate.is_none());
}
#[test]
fn generate_candidates_from_multiple_entries() {
let entries = vec![
RegretEntry::new(
"reg_006",
"ep_001",
"cfr_001",
"int_001",
0.8,
0.9,
RegretCategory::MissingKnowledge,
"2026-04-30T12:00:00Z",
),
RegretEntry::new(
"reg_007",
"ep_002",
"cfr_002",
"int_002",
0.7,
0.85,
RegretCategory::StaleInformation,
"2026-04-30T12:00:00Z",
),
RegretEntry::new(
"reg_008",
"ep_003",
"cfr_003",
"int_003",
0.1,
0.2,
RegretCategory::Other,
"2026-04-30T12:00:00Z",
),
];
let candidates = generate_candidates_from_counterfactuals(
&entries,
"ws_test",
CounterfactualMode::DryRun,
);
assert_eq!(candidates.len(), 2);
}
#[test]
fn counterfactual_candidate_report_from_candidates() {
let entries = vec![
RegretEntry::new(
"reg_009",
"ep_001",
"cfr_001",
"int_001",
0.8,
0.9,
RegretCategory::MissingKnowledge,
"2026-04-30T12:00:00Z",
),
RegretEntry::new(
"reg_010",
"ep_002",
"cfr_002",
"int_002",
0.9,
0.95,
RegretCategory::Misinformation,
"2026-04-30T12:00:00Z",
),
];
let candidates = generate_candidates_from_counterfactuals(
&entries,
"ws_test",
CounterfactualMode::DryRun,
);
let report = CounterfactualCandidateReport::from_candidates(
entries.len(),
&candidates,
CounterfactualMode::DryRun,
);
assert_eq!(report.entries_analyzed, 2);
assert_eq!(report.candidates_generated, 2);
assert_eq!(report.mode, Some(CounterfactualMode::DryRun));
}
#[test]
#[allow(clippy::expect_used)]
fn candidate_reason_format() {
let entry = RegretEntry::new(
"reg_011",
"ep_reason_test",
"cfr_001",
"int_001",
0.75,
0.88,
RegretCategory::StaleInformation,
"2026-04-30T12:00:00Z",
);
let candidate =
generate_candidate_from_regret(&entry, "ws_test", CounterfactualMode::DryRun)
.expect("stale information regret should generate a candidate");
assert!(candidate.reason.contains("stale_information"));
assert!(candidate.reason.contains("supersede_memory"));
assert!(candidate.reason.contains("ep_reason_test"));
}
}