use crate::AsdError;
use agentstategraph::{CommitOptions, Repository};
use agentstategraph_core::IntentCategory;
use chrono::{DateTime, Utc};
use crate::engine::Engine;
use crate::error::Result;
use crate::paths;
use crate::schema::{FeedbackEntry, FeedbackVerdict};
use crate::search_fts::SearchFtsDb;
pub const DEFAULT_FEEDBACK_HALF_LIFE_DAYS: f64 = 90.0;
pub fn decay_factor(age_days: f64, half_life_days: f64) -> f64 {
if half_life_days <= 0.0 {
return 1.0;
}
if age_days <= 0.0 {
return 1.0;
}
0.5_f64.powf(age_days / half_life_days)
}
pub fn decay_for_entry(created_at: DateTime<Utc>, now: DateTime<Utc>, half_life_days: f64) -> f64 {
let age_secs = (now - created_at).num_seconds() as f64;
let age_days = age_secs / 86_400.0;
decay_factor(age_days, half_life_days)
}
pub trait FeedbackStore {
fn record(&self, ref_name: &str, entry: &FeedbackEntry, agent_id: &str) -> Result<()>;
fn list_for_symbol(&self, ref_name: &str, symbol_id: &str) -> Result<Vec<FeedbackEntry>>;
fn list_all(&self, ref_name: &str) -> Result<Vec<FeedbackEntry>>;
fn purge(&self, ref_name: &str, entry_id: &str) -> Result<Option<FeedbackEntry>>;
fn withdraw(
&self,
ref_name: &str,
entry_id: &str,
by: &str,
reason: Option<&str>,
) -> Result<Option<FeedbackEntry>> {
let Some(existing) = self
.list_all(ref_name)?
.into_iter()
.find(|e| e.entry_id == entry_id)
else {
return Ok(None);
};
if existing.is_withdrawn() {
return Ok(Some(existing));
}
let mut withdrawn = existing;
withdrawn.withdrawn_at = Some(Utc::now());
withdrawn.withdrawn_by = Some(by.to_string());
withdrawn.withdrawn_reason = reason.map(str::to_string);
self.record(ref_name, &withdrawn, by)?;
Ok(Some(withdrawn))
}
fn flat_verdicts(
&self,
ref_name: &str,
) -> Result<Vec<(String, String, FeedbackVerdict, DateTime<Utc>)>> {
Ok(self
.list_all(ref_name)?
.into_iter()
.filter(|e| !e.is_inert())
.filter(|e| e.file_scope.is_none())
.map(|e| (e.symbol_id, e.query, e.verdict, e.created_at))
.collect())
}
fn flat_file_scope_verdicts(
&self,
ref_name: &str,
) -> Result<Vec<(String, FeedbackVerdict, String, DateTime<Utc>)>> {
Ok(self
.list_all(ref_name)?
.into_iter()
.filter(|e| !e.is_inert())
.filter_map(|e| {
e.file_scope
.map(|glob| (glob, e.verdict, e.query, e.created_at))
})
.collect())
}
}
pub struct AsgFeedbackStore<'a> {
pub repo: &'a Repository,
pub fts: Option<&'a SearchFtsDb>,
}
impl<'a> AsgFeedbackStore<'a> {
pub fn new(repo: &'a Repository) -> Self {
Self { repo, fts: None }
}
pub fn from_engine(engine: &'a Engine) -> Self {
Self {
repo: &engine.repo,
fts: engine.fts.as_ref(),
}
}
}
impl<'a> FeedbackStore for AsgFeedbackStore<'a> {
fn record(&self, ref_name: &str, entry: &FeedbackEntry, agent_id: &str) -> Result<()> {
let path = paths::feedback_entry_path(&entry.symbol_id, &entry.entry_id);
let value = serde_json::to_value(entry)?;
let opts = CommitOptions::new(
agent_id,
IntentCategory::Refine,
format!(
"feedback {} for {}",
entry.verdict.as_str(),
entry.symbol_qname
),
);
self.repo.set_json(ref_name, &path, &value, opts)?;
if let Some(fts) = self.fts {
let _ = fts.upsert_feedback(entry);
}
Ok(())
}
fn purge(&self, ref_name: &str, entry_id: &str) -> Result<Option<FeedbackEntry>> {
let Some(existing) = self
.list_all(ref_name)?
.into_iter()
.find(|e| e.entry_id == entry_id)
else {
return Ok(None);
};
let path = paths::feedback_entry_path(&existing.symbol_id, entry_id);
let opts = CommitOptions::new(
"asd-purge",
IntentCategory::Rollback,
format!("purge feedback entry {entry_id}"),
);
self.repo
.delete(ref_name, &path, opts)
.map_err(|e| AsdError::Other(format!("purge: git delete failed: {e}")))?;
if let Some(fts) = self.fts {
fts.delete_feedback(entry_id).map_err(|e| {
AsdError::Other(format!(
"purge: removed {entry_id} from the store but FAILED to clear the \
SQLite cache ({e}); `asd feedback list` will still show it. \
Run `asd index` to resync the cache."
))
})?;
}
Ok(Some(existing))
}
fn list_for_symbol(&self, ref_name: &str, symbol_id: &str) -> Result<Vec<FeedbackEntry>> {
let prefix = paths::feedback_symbol_path(symbol_id);
match self.repo.get_tree(ref_name, &prefix) {
Ok(serde_json::Value::Object(map)) => {
let mut entries: Vec<FeedbackEntry> = map
.values()
.filter_map(|v| serde_json::from_value(v.clone()).ok())
.collect();
entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(entries)
}
_ => Ok(vec![]),
}
}
fn list_all(&self, ref_name: &str) -> Result<Vec<FeedbackEntry>> {
if let Some(fts) = self.fts {
if fts.feedback_count() > 0 {
if let Ok(entries) = fts.list_all_feedback() {
return Ok(entries);
}
}
}
let prefix = format!("{}/feedback", paths::ASD_ROOT);
let mut entries = Vec::new();
if let Ok(serde_json::Value::Object(by_symbol)) = self.repo.get_tree(ref_name, &prefix) {
for symbol_val in by_symbol.values() {
if let serde_json::Value::Object(symbol_entries) = symbol_val {
for ev in symbol_entries.values() {
if let Ok(e) = serde_json::from_value::<FeedbackEntry>(ev.clone()) {
entries.push(e);
}
}
}
}
}
entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
if !entries.is_empty() {
if let Some(fts) = self.fts {
let _ = fts.sync_feedback_entries(&entries);
}
}
Ok(entries)
}
}
#[cfg(test)]
mod plan_j_t016_decay_tests {
use super::*;
use chrono::Duration;
fn approx(actual: f64, expected: f64) -> bool {
(actual - expected).abs() < 1e-6
}
#[test]
fn zero_age_returns_full_weight() {
assert!(approx(decay_factor(0.0, 90.0), 1.0));
}
#[test]
fn half_life_returns_one_half() {
assert!(approx(decay_factor(90.0, 90.0), 0.5));
}
#[test]
fn two_half_lives_returns_one_quarter() {
assert!(approx(decay_factor(180.0, 90.0), 0.25));
}
#[test]
fn ten_half_lives_effectively_zero() {
let f = decay_factor(900.0, 90.0);
assert!(f < 0.002, "10× half-life should decay to ~0.1%; got {f}");
assert!(f > 0.0, "but still strictly positive");
}
#[test]
fn negative_age_clamps_to_full_weight() {
assert_eq!(decay_factor(-1.0, 90.0), 1.0);
assert_eq!(decay_factor(-90.0, 90.0), 1.0);
}
#[test]
fn zero_or_negative_half_life_disables_decay() {
assert_eq!(decay_factor(365.0, 0.0), 1.0);
assert_eq!(decay_factor(365.0, -10.0), 1.0);
}
#[test]
fn quarter_year_at_default_half_life_matches_spec() {
let day_old = decay_factor(1.0, DEFAULT_FEEDBACK_HALF_LIFE_DAYS);
let six_months = decay_factor(180.0, DEFAULT_FEEDBACK_HALF_LIFE_DAYS);
assert!(
day_old > 0.98,
"1-day-old must be near full weight; got {day_old}"
);
assert!(
approx(six_months, 0.25),
"6-month must be ~25%; got {six_months}"
);
assert!(
day_old > six_months * 3.0,
"yesterday's weight must dominate 6-month-old by >3×"
);
}
#[test]
fn decay_for_entry_threads_timestamps_correctly() {
let now = Utc::now();
let one_quarter_ago = now - Duration::days(90);
let f = decay_for_entry(one_quarter_ago, now, 90.0);
assert!(
approx(f, 0.5),
"90-day-old at 90-day half-life → 0.5; got {f}"
);
}
#[test]
fn decay_for_entry_handles_subday_resolution() {
let now = Utc::now();
let twelve_hours_ago = now - Duration::hours(12);
let f = decay_for_entry(twelve_hours_ago, now, 1.0);
assert!(
(f - std::f64::consts::FRAC_1_SQRT_2).abs() < 0.01,
"12h/1day should produce ~0.707 (1/sqrt2); got {f}"
);
}
#[test]
fn decay_for_entry_with_future_timestamp_clamps_to_one() {
let now = Utc::now();
let future = now + Duration::days(30);
assert_eq!(decay_for_entry(future, now, 90.0), 1.0);
}
}