use std::any::Any;
use std::collections::HashMap;
use khive_storage::types::{SqlStatement, SqlValue};
use khive_storage::{AtomicUnitOp, SqlAccess, StorageError};
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::persist::PersistError;
pub const WEIGHT_FLOOR: f32 = 0.1;
pub const WEIGHT_CEIL: f32 = 5.0;
pub fn init_weight_schema(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch(include_str!("../../sql/atom_weights_create.sql"))?;
conn.execute_batch(include_str!("../../sql/weight_events_create.sql"))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WeightChannel {
Ambient,
Explicit,
GroundTruth,
}
impl WeightChannel {
pub fn eta(self) -> f32 {
match self {
Self::Ambient => 0.003,
Self::Explicit => 0.10,
Self::GroundTruth => 0.50,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Ambient => "ambient",
Self::Explicit => "explicit",
Self::GroundTruth => "ground_truth",
}
}
}
pub async fn apply_weight_delta(
sql: &dyn SqlAccess,
namespace: &str,
atom_id: Uuid,
delta: f32,
channel: WeightChannel,
event_id: Option<Uuid>,
context_id: Option<&str>,
) -> Result<(f32, i64), PersistError> {
apply_weight_delta_with_eta(
sql,
namespace,
atom_id,
delta,
channel,
channel.eta(),
event_id,
context_id,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn apply_weight_delta_with_eta(
sql: &dyn SqlAccess,
namespace: &str,
atom_id: Uuid,
delta: f32,
channel: WeightChannel,
eta: f32,
event_id: Option<Uuid>,
context_id: Option<&str>,
) -> Result<(f32, i64), PersistError> {
if namespace.is_empty() {
tracing::warn!(
atom_id = %atom_id,
channel = %channel.as_str(),
"apply_weight_delta called with empty namespace — rejecting to avoid dead-namespace pollution"
);
return Err(PersistError::Validation(
"namespace must not be empty".to_string(),
));
}
if !(0.0..=1.0).contains(&eta) {
return Err(PersistError::Validation(format!(
"eta must be in [0.0, 1.0], got {eta}"
)));
}
if !delta.is_finite() {
return Err(PersistError::Validation(format!(
"delta must be finite, got {delta}"
)));
}
let namespace_str = namespace.to_string();
let atom_id_str = atom_id.to_string();
let channel_str = channel.as_str();
let event_id_str = event_id.map(|u| u.to_string());
let context_id = context_id.map(|s| s.to_string());
let now_us = chrono::Utc::now().timestamp_micros();
let op: AtomicUnitOp = Box::new(move |writer| {
Box::pin(async move {
let row = writer
.query_row(
SqlStatement::new(
include_str!("../../sql/atom_weights_select_weight.sql"),
vec![
SqlValue::Text(namespace_str.clone()),
SqlValue::Text(atom_id_str.clone()),
],
)
.labelled("weights.read_current"),
)
.await?;
let old_weight = row
.as_ref()
.map(|row| row.f64("weight"))
.transpose()
.map_err(|error| StorageError::Internal(format!("read atom weight: {error}")))?
.unwrap_or(1.0_f64) as f32;
let new_weight = (old_weight * (1.0 - eta) + delta).clamp(WEIGHT_FLOOR, WEIGHT_CEIL);
writer
.execute(
SqlStatement::new(
include_str!("../../sql/atom_weights_upsert.sql"),
vec![
SqlValue::Text(namespace_str.clone()),
SqlValue::Text(atom_id_str.clone()),
SqlValue::Float(new_weight as f64),
SqlValue::Integer(now_us),
],
)
.labelled("weights.upsert"),
)
.await?;
writer
.execute(
SqlStatement::new(
include_str!("../../sql/weight_events_insert.sql"),
vec![
SqlValue::Text(namespace_str),
SqlValue::Text(atom_id_str),
SqlValue::Float(delta as f64),
SqlValue::Float(new_weight as f64),
SqlValue::Text(channel_str.to_owned()),
SqlValue::Float(eta as f64),
event_id_str.map(SqlValue::Text).unwrap_or(SqlValue::Null),
context_id.map(SqlValue::Text).unwrap_or(SqlValue::Null),
SqlValue::Integer(now_us),
],
)
.labelled("weights.audit_insert"),
)
.await?;
let row = writer
.query_row(
SqlStatement::new(
include_str!("../../sql/weight_events_last_insert_rowid.sql"),
vec![],
)
.labelled("weights.audit_row_id"),
)
.await?
.ok_or_else(|| StorageError::Internal("missing weight audit row ID".into()))?;
let row_id = row.i64("row_id").map_err(|error| {
StorageError::Internal(format!("read weight audit row ID: {error}"))
})?;
Ok(Box::new((new_weight, row_id)) as Box<dyn Any + Send>)
})
});
let result = sql.atomic_unit(op).await?;
result
.downcast::<(f32, i64)>()
.map(|result| *result)
.map_err(|_| {
PersistError::Internal("weight atomic unit returned an unexpected result".into())
})
}
pub async fn batch_load_weights(
sql: &dyn SqlAccess,
namespace: &str,
atom_ids: &[Uuid],
) -> Result<HashMap<Uuid, f32>, PersistError> {
if atom_ids.is_empty() {
return Ok(HashMap::new());
}
let namespace_str = namespace.to_string();
let id_strs: Vec<String> = atom_ids.iter().map(|u| u.to_string()).collect();
let mut result = HashMap::with_capacity(id_strs.len());
let mut reader = sql.reader().await?;
const CHUNK_SIZE: usize = 998;
for chunk in id_strs.chunks(CHUNK_SIZE) {
let placeholders = chunk
.iter()
.enumerate()
.map(|(i, _)| format!("?{}", i + 2))
.collect::<Vec<_>>()
.join(", ");
let statement = format!(
"SELECT atom_id, weight FROM atom_weights \
WHERE namespace = ?1 AND atom_id IN ({placeholders})"
);
let mut param_values = Vec::with_capacity(chunk.len() + 1);
param_values.push(SqlValue::Text(namespace_str.clone()));
for id in chunk {
param_values.push(SqlValue::Text(id.clone()));
}
let rows = reader
.query_all(SqlStatement::new(statement, param_values).labelled("weights.batch_load"))
.await?;
for row in rows {
let aid = row
.text("atom_id")
.map_err(|error| StorageError::Internal(format!("read weight atom ID: {error}")))?;
let w = row
.f64("weight")
.map_err(|error| StorageError::Internal(format!("read atom weight: {error}")))?;
if let Ok(uuid) = aid.parse::<Uuid>() {
let clamped = (w as f32).clamp(WEIGHT_FLOOR, WEIGHT_CEIL);
result.insert(uuid, clamped);
}
}
}
Ok(result)
}
#[cfg(test)]
#[path = "engine_weights/tests.rs"]
mod tests;