use chrono::Duration;
use agentstatedeveloper_core::{
AsgFeedbackStore, AsgIndexStore, Engine, FeedbackEntry, FeedbackStore, FeedbackVerdict,
IndexStore, Position, Symbol, SymbolKind, apply_feedback_adjustments,
};
fn mk_sym(sym_id: &str, qname: &str, file: &str) -> Symbol {
Symbol {
symbol_id: sym_id.into(),
symbol_fp: format!("fp-{sym_id}"),
qname: qname.into(),
language: "python".into(),
kind: SymbolKind::Function,
file: file.into(),
start: Position { line: 1, col: 0 },
end: Position { line: 5, col: 0 },
signature: Some(format!(
"def {}()",
qname.rsplit('.').next().unwrap_or(qname)
)),
doc: None,
}
}
fn mk_entry(entry_id: &str, sym_id: &str, qname: &str, age_days: i64) -> FeedbackEntry {
let created_at = chrono::Utc::now() - Duration::days(age_days);
FeedbackEntry {
entry_id: entry_id.into(),
symbol_id: sym_id.into(),
symbol_qname: qname.into(),
query: "discount".into(),
verdict: FeedbackVerdict::Useful,
author: "alice".into(),
created_at,
note: None,
file_scope: None,
expires_at: None,
withdrawn_at: None,
withdrawn_by: None,
withdrawn_reason: None,
}
}
#[test]
fn fresh_useful_boosts_more_than_nine_month_old() {
let tmp = tempfile::tempdir().unwrap();
let db = tmp.path().join(".asd-state.db");
let engine = Engine::open_sqlite(&db).expect("open");
let idx = AsgIndexStore::from_engine(&engine);
let fb = AsgFeedbackStore::from_engine(&engine);
let s_fresh = mk_sym("sym_fresh", "billing.calc.discount_fresh", "src/a.py");
let s_old = mk_sym("sym_old", "billing.calc.discount_old", "src/b.py");
idx.put_symbol(&engine.ref_name, &s_fresh, "t").unwrap();
idx.put_symbol(&engine.ref_name, &s_old, "t").unwrap();
let e_fresh = mk_entry("fb_fresh", "sym_fresh", &s_fresh.qname, 0);
let e_old = mk_entry("fb_old", "sym_old", &s_old.qname, 270); fb.record(&engine.ref_name, &e_fresh, "alice").unwrap();
fb.record(&engine.ref_name, &e_old, "alice").unwrap();
let tuples = fb.flat_verdicts(&engine.ref_name).unwrap();
assert_eq!(tuples.len(), 2, "got: {tuples:?}");
let mut fresh_scored = vec![(10.0_f64, s_fresh.qname.clone())];
apply_feedback_adjustments(&engine, &idx, "discount", &mut fresh_scored, &tuples);
let fresh_delta = fresh_scored[0].0 - 10.0;
let mut old_scored = vec![(10.0_f64, s_old.qname.clone())];
apply_feedback_adjustments(&engine, &idx, "discount", &mut old_scored, &tuples);
let old_delta = old_scored[0].0 - 10.0;
assert!(
(fresh_delta - 1.5).abs() < 0.01,
"fresh boost should be ~+1.5; got +{fresh_delta}"
);
assert!(
(old_delta - 0.1875).abs() < 0.01,
"9-month-old boost should be ~+0.1875 (3 half-lives at 90d default); got +{old_delta}"
);
assert!(
fresh_delta > old_delta * 3.0,
"fresh must dominate 9-month-old by >3×; got fresh={fresh_delta}, old={old_delta}"
);
}
#[test]
fn negative_verdicts_do_not_decay() {
let tmp = tempfile::tempdir().unwrap();
let db = tmp.path().join(".asd-state.db");
let engine = Engine::open_sqlite(&db).expect("open");
let idx = AsgIndexStore::from_engine(&engine);
let fb = AsgFeedbackStore::from_engine(&engine);
let s = mk_sym("sym_n", "pkg.noisy", "src/n.py");
idx.put_symbol(&engine.ref_name, &s, "t").unwrap();
let mut e = mk_entry("fb_n", "sym_n", &s.qname, 365); e.verdict = FeedbackVerdict::Noisy;
fb.record(&engine.ref_name, &e, "alice").unwrap();
let tuples = fb.flat_verdicts(&engine.ref_name).unwrap();
let mut scored = vec![(10.0_f64, s.qname.clone())];
apply_feedback_adjustments(&engine, &idx, "discount", &mut scored, &tuples);
assert!(
scored.is_empty(),
"1-year-old Noisy verdict must still suppress (no decay); got: {scored:?}"
);
}