use crate::core::bayes::{BetaPosterior, FeedbackSignal};
use crate::db::{CreateAuditInput, DbConnection, audit_actions, generate_audit_id};
use crate::models::DomainError;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum BackfillMode {
FromUtility { weight_hundredths: u32 },
FromFeedbackEvents,
}
impl BackfillMode {
#[must_use]
pub const fn audit_source(&self) -> &'static str {
match self {
Self::FromUtility { .. } => "backfill_from_utility",
Self::FromFeedbackEvents => "backfill_from_feedback_events",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct BackfillReport {
pub scanned: usize,
pub updated: usize,
pub skipped: usize,
}
pub fn backfill_workspace(
conn: &DbConnection,
workspace_id: &str,
mode: BackfillMode,
harmful_weight: f64,
actor: Option<&str>,
) -> Result<BackfillReport, DomainError> {
let memories = conn
.list_memories(workspace_id, None, false)
.map_err(|error| DomainError::Storage {
message: format!("Failed to list memories for Bayes backfill: {error}"),
repair: Some("ee doctor".to_string()),
})?;
let mut report = BackfillReport {
scanned: memories.len(),
..BackfillReport::default()
};
tracing::info!(
target: "ee::trust::bayes",
mode = mode.audit_source(),
n_memories = report.scanned,
harmful_weight,
"starting Bayes posterior backfill"
);
for memory in &memories {
let derived = match mode {
BackfillMode::FromUtility { weight_hundredths } => {
let weight = f64::from(weight_hundredths) / 100.0;
BetaPosterior::from_utility_inverse(f64::from(memory.confidence), weight)
}
BackfillMode::FromFeedbackEvents => {
let events = conn
.list_feedback_events_for_target("memory", &memory.id)
.map_err(|error| DomainError::Storage {
message: format!(
"Failed to list feedback events for memory {}: {error}",
memory.id
),
repair: Some("ee doctor".to_string()),
})?;
let replay = events.into_iter().map(|ev| {
let signal = FeedbackSignal::from_signal_str(&ev.signal);
let weight = backfill_event_weight_or_default(
signal,
f64::from(ev.weight),
harmful_weight,
);
(signal, weight)
});
Some(BetaPosterior::from_feedback_events(replay))
}
};
let Some(posterior) = derived else {
report.skipped += 1;
continue;
};
let already_matches = match conn.get_memory_bayes_posterior(&memory.id) {
Ok(Some((existing_alpha, existing_beta))) => {
approx_eq(existing_alpha, posterior.alpha())
&& approx_eq(existing_beta, posterior.beta())
}
Ok(None) => false,
Err(_) => false,
};
if already_matches {
continue;
}
let prior_alpha = posterior.alpha();
let prior_beta = posterior.beta();
let changed = conn
.update_memory_bayes_posterior(&memory.id, posterior.alpha(), posterior.beta())
.map_err(|error| DomainError::Storage {
message: format!(
"Failed to update Bayes posterior for memory {}: {error}",
memory.id
),
repair: Some("ee doctor".to_string()),
})?;
if !changed {
report.skipped += 1;
continue;
}
let details = serde_json::json!({
"schema": "ee.audit.bayes_posterior_updated.v1",
"backfillSource": mode.audit_source(),
"posteriorAlpha": prior_alpha,
"posteriorBeta": prior_beta,
"posteriorMean": posterior.mean(),
"effectiveSampleSize": posterior.effective_sample_size(),
})
.to_string();
conn.insert_audit(
&generate_audit_id(),
&CreateAuditInput {
workspace_id: Some(memory.workspace_id.clone()),
actor: actor.map(str::to_string),
action: audit_actions::MEMORY_BAYES_POSTERIOR_UPDATED.to_string(),
target_type: Some("memory".to_string()),
target_id: Some(memory.id.clone()),
details: Some(details),
},
)
.map_err(|error| DomainError::Storage {
message: format!(
"Failed to audit Bayes backfill for memory {}: {error}",
memory.id
),
repair: Some("ee doctor".to_string()),
})?;
report.updated += 1;
}
tracing::info!(
target: "ee::trust::bayes",
mode = mode.audit_source(),
n_memories = report.scanned,
n_migrated = report.updated,
n_skipped = report.skipped,
"completed Bayes posterior backfill"
);
Ok(report)
}
fn approx_eq(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-12
}
fn backfill_event_weight_or_default(
signal: FeedbackSignal,
event_weight: f64,
harmful_weight: f64,
) -> f64 {
if event_weight.is_finite() && event_weight > 0.0 {
return event_weight;
}
match signal {
FeedbackSignal::Helpful => 1.0,
FeedbackSignal::Harmful => harmful_weight,
FeedbackSignal::Neutral => 0.0,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::bayes::DEFAULT_HARMFUL_WEIGHT;
#[test]
fn audit_source_strings_are_canonical() {
assert_eq!(
BackfillMode::FromUtility {
weight_hundredths: 200
}
.audit_source(),
"backfill_from_utility"
);
assert_eq!(
BackfillMode::FromFeedbackEvents.audit_source(),
"backfill_from_feedback_events"
);
}
#[test]
fn default_harmful_weight_re_exported_for_callers() {
assert!((DEFAULT_HARMFUL_WEIGHT - 2.5).abs() < 1e-12);
}
#[test]
fn approx_eq_helper_matches_within_tolerance() {
assert!(approx_eq(1.0, 1.0));
assert!(approx_eq(0.5, 0.5 + 1e-13));
assert!(!approx_eq(0.5, 0.5 + 1e-6));
}
#[test]
fn invalid_feedback_event_weight_defaults_by_signal() {
assert!(approx_eq(
backfill_event_weight_or_default(FeedbackSignal::Helpful, 0.0, DEFAULT_HARMFUL_WEIGHT),
1.0
));
assert!(approx_eq(
backfill_event_weight_or_default(
FeedbackSignal::Harmful,
f64::NAN,
DEFAULT_HARMFUL_WEIGHT,
),
DEFAULT_HARMFUL_WEIGHT
));
assert!(approx_eq(
backfill_event_weight_or_default(FeedbackSignal::Neutral, 0.0, DEFAULT_HARMFUL_WEIGHT),
0.0
));
}
#[test]
fn invalid_helpful_backfill_weight_replays_as_one_helpful_event() {
let weight =
backfill_event_weight_or_default(FeedbackSignal::Helpful, 0.0, DEFAULT_HARMFUL_WEIGHT);
let posterior = BetaPosterior::from_feedback_events([(FeedbackSignal::Helpful, weight)]);
assert!(approx_eq(posterior.alpha(), 1.5));
assert!(approx_eq(posterior.beta(), 0.5));
}
#[test]
fn invalid_configured_harmful_weight_still_replays_as_default_harmful_event() {
let weight = backfill_event_weight_or_default(FeedbackSignal::Harmful, 0.0, f64::NAN);
let posterior = BetaPosterior::from_feedback_events([(FeedbackSignal::Harmful, weight)]);
assert!(approx_eq(posterior.alpha(), 0.5));
assert!(approx_eq(posterior.beta(), 0.5 + DEFAULT_HARMFUL_WEIGHT));
}
}