use rusqlite::Connection;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use crate::eval::EvalCase;
use kimetsu_core::KimetsuResult;
#[derive(Debug, Clone, Default)]
pub struct PersonalEval {
pub cases: Vec<EvalCase>,
pub noise_count: usize,
pub oldest: Option<String>,
pub newest: Option<String>,
}
pub fn build_personal_eval(conn: &Connection, window_secs: i64) -> KimetsuResult<PersonalEval> {
let mut stmt = conn.prepare(
"SELECT payload_json, ts FROM events
WHERE kind = 'context.served'
ORDER BY ts DESC",
)?;
let rows = stmt.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?;
let mut seen_queries: std::collections::HashMap<String, (String, serde_json::Value)> =
std::collections::HashMap::new();
for row in rows {
let (payload_json, ts) = row?;
let payload: serde_json::Value =
serde_json::from_str(&payload_json).unwrap_or(serde_json::Value::Null);
let Some(query) = payload.get("query").and_then(|v| v.as_str()) else {
continue; };
if !seen_queries.contains_key(query) {
seen_queries.insert(query.to_string(), (ts, payload));
}
}
if seen_queries.is_empty() {
return Ok(PersonalEval::default());
}
let mut cases: Vec<EvalCase> = Vec::new();
let mut noise_count = 0usize;
let mut oldest: Option<String> = None;
let mut newest: Option<String> = None;
for (query, (ts, payload)) in &seen_queries {
let session_id = payload.get("session_id").and_then(|v| v.as_str());
let relevant = citations_for_served(conn, ts, session_id, window_secs)?;
if relevant.is_empty() {
noise_count += 1;
} else {
if oldest.as_deref().map(|o| ts.as_str() < o).unwrap_or(true) {
oldest = Some(ts.clone());
}
if newest.as_deref().map(|n| ts.as_str() > n).unwrap_or(true) {
newest = Some(ts.clone());
}
cases.push(EvalCase {
query: query.clone(),
relevant,
});
}
}
Ok(PersonalEval {
cases,
noise_count,
oldest,
newest,
})
}
fn citations_for_served(
conn: &Connection,
served_ts: &str,
session_id: Option<&str>,
window_secs: i64,
) -> KimetsuResult<Vec<String>> {
if let Some(sid) = session_id {
let mut stmt = conn.prepare(
"SELECT DISTINCT mc.memory_id
FROM memory_citations mc
JOIN events e ON e.run_id = mc.run_id
AND e.kind = 'memory.cited'
WHERE json_extract(e.payload_json, '$.session_id') = ?1",
)?;
let ids: Vec<String> = stmt
.query_map([sid], |row| row.get(0))?
.filter_map(|r| r.ok())
.collect();
if !ids.is_empty() {
return Ok(ids);
}
}
let served_dt = OffsetDateTime::parse(served_ts, &Rfc3339)
.map_err(|e| format!("parse served_ts {served_ts:?}: {e}"))?;
let lo = (served_dt - time::Duration::seconds(window_secs))
.format(&Rfc3339)
.map_err(|e| format!("format lo: {e}"))?;
let hi = (served_dt + time::Duration::seconds(window_secs))
.format(&Rfc3339)
.map_err(|e| format!("format hi: {e}"))?;
let mut stmt = conn.prepare(
"SELECT DISTINCT memory_id FROM memory_citations
WHERE cited_at >= ?1 AND cited_at <= ?2",
)?;
let ids: Vec<String> = stmt
.query_map([&lo, &hi], |row| row.get(0))?
.filter_map(|r| r.ok())
.collect();
Ok(ids)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
project::{add_memory, init_project},
projector,
user_brain::with_user_brain_disabled,
};
use kimetsu_core::{
event::Event,
ids::RunId,
memory::{MemoryKind, MemoryScope},
};
use ulid::Ulid;
fn test_root() -> std::path::PathBuf {
let root = std::env::temp_dir().join(format!("kimetsu-tuneset-test-{}", Ulid::new()));
kimetsu_core::paths::git_init_boundary(&root);
root
}
fn seed_context_served_with_query(
conn: &Connection,
query: &str,
session_id: Option<&str>,
ts_offset_secs: i64,
) -> String {
let now = OffsetDateTime::now_utc() + time::Duration::seconds(ts_offset_secs);
let ts = now.format(&Rfc3339).unwrap();
let run_id = RunId::new();
let mut payload = serde_json::json!({
"query_hash": format!("{:016x}", query.len()),
"query": query,
"capsule_count": 2,
"top_score": 0.8,
"skipped": false,
"stage": "localization",
"retrieval_path": "fts",
});
if let Some(sid) = session_id {
payload["session_id"] = serde_json::json!(sid);
}
let event = Event {
event_id: kimetsu_core::ids::EventId(Ulid::new()),
run_id,
ts: now,
parent_event_id: None,
kind: "context.served".to_string(),
schema_version: 1,
payload,
};
projector::apply_events(conn, &[event]).expect("seed served");
ts
}
fn seed_memory_cited(conn: &Connection, memory_id: &str, ts_offset_secs: i64) {
let now = OffsetDateTime::now_utc() + time::Duration::seconds(ts_offset_secs);
let run_id = RunId::new();
let event = Event {
event_id: kimetsu_core::ids::EventId(Ulid::new()),
run_id,
ts: now,
parent_event_id: None,
kind: "memory.cited".to_string(),
schema_version: 1,
payload: serde_json::json!({
"memory_id": memory_id,
"turn": 1,
}),
};
projector::apply_events(conn, &[event]).expect("seed cited");
}
#[test]
fn build_personal_eval_empty_when_no_queries_stored() {
with_user_brain_disabled(|| {
let root = test_root();
std::fs::create_dir_all(&root).expect("create");
init_project(&root, false).expect("init");
let (_, _, conn) = crate::project::load_project(&root).expect("load");
let eval = build_personal_eval(&conn, 1800).expect("build");
assert!(eval.cases.is_empty(), "no served events → empty cases");
assert_eq!(eval.noise_count, 0);
std::fs::remove_dir_all(&root).ok();
});
}
#[test]
fn build_personal_eval_positive_case_from_time_window() {
with_user_brain_disabled(|| {
let root = test_root();
std::fs::create_dir_all(&root).expect("create");
init_project(&root, false).expect("init");
let mid = add_memory(
&root,
MemoryScope::Project,
MemoryKind::Fact,
"tuneset window test memory",
)
.expect("add memory");
let (_, _, conn) = crate::project::load_project(&root).expect("load");
seed_context_served_with_query(&conn, "find files fast", None, 0);
seed_memory_cited(&conn, &mid, 5 * 60);
let eval = build_personal_eval(&conn, 1800).expect("build");
assert_eq!(eval.cases.len(), 1, "should have 1 positive case");
assert_eq!(eval.cases[0].query, "find files fast");
assert!(
eval.cases[0].relevant.contains(&mid),
"memory_id must be in relevant"
);
assert_eq!(eval.noise_count, 0);
std::fs::remove_dir_all(&root).ok();
});
}
#[test]
fn build_personal_eval_noise_when_no_citation_in_window() {
with_user_brain_disabled(|| {
let root = test_root();
std::fs::create_dir_all(&root).expect("create");
init_project(&root, false).expect("init");
let (_, _, conn) = crate::project::load_project(&root).expect("load");
let mid = add_memory(&root, MemoryScope::Project, MemoryKind::Fact, "noise test")
.expect("add memory");
seed_context_served_with_query(&conn, "some query", None, 0);
seed_memory_cited(&conn, &mid, 3 * 3600);
let eval = build_personal_eval(&conn, 1800).expect("build");
assert_eq!(
eval.cases.len(),
0,
"out-of-window citation → no positive case"
);
assert_eq!(eval.noise_count, 1, "should count 1 noise entry");
std::fs::remove_dir_all(&root).ok();
});
}
#[test]
fn build_personal_eval_deduplicates_same_query() {
with_user_brain_disabled(|| {
let root = test_root();
std::fs::create_dir_all(&root).expect("create");
init_project(&root, false).expect("init");
let mid = add_memory(&root, MemoryScope::Project, MemoryKind::Fact, "dedup test")
.expect("add");
let (_, _, conn) = crate::project::load_project(&root).expect("load");
seed_context_served_with_query(&conn, "duplicate query", None, -100);
seed_context_served_with_query(&conn, "duplicate query", None, 0);
seed_memory_cited(&conn, &mid, 60);
let eval = build_personal_eval(&conn, 1800).expect("build");
assert_eq!(eval.cases.len(), 1, "dedup must produce exactly 1 case");
std::fs::remove_dir_all(&root).ok();
});
}
}