agentstatedeveloper_core/feedback.rs
1//! Feedback store — durable (query, symbol, verdict) triples.
2//!
3//! Agents and users record verdicts on search results via `asd feedback mark`
4//! or the MCP `feedback_mark` tool. Verdicts are stored in the ASD sidecar
5//! and applied as score adjustments in `apply_feedback_adjustments`.
6//!
7//! ## SQLite write-through cache
8//!
9//! `AsgFeedbackStore` optionally holds a borrowed `fts` connection — when
10//! present, `list_all` returns the SQLite cache (fast, ~0 git reads) and
11//! `record` additionally writes to SQLite after the git commit. The git
12//! object store remains authoritative: if SQLite is empty (e.g. first run
13//! after `git pull`), we fall back to the git tree walk and re-populate the
14//! cache as a side effect. Running `asd index` / `asd reindex` calls
15//! `sync_feedback_entries` for a full reconciliation.
16
17use crate::AsdError;
18use agentstategraph::{CommitOptions, Repository};
19use agentstategraph_core::IntentCategory;
20use chrono::{DateTime, Utc};
21
22use crate::engine::Engine;
23use crate::error::Result;
24use crate::paths;
25use crate::schema::{FeedbackEntry, FeedbackVerdict};
26use crate::search_fts::SearchFtsDb;
27
28// ---------------------------------------------------------------------------
29// Plan J t-016: feedback decay
30// ---------------------------------------------------------------------------
31
32/// Default half-life for feedback boost decay, in days.
33///
34/// At 90 days, a verdict 3 months old gets 50% of its original weight,
35/// 6 months → 25%, 1 year → 6%. Matches the spec's intuition that
36/// "Useful +1.5 boosts stay forever" was wrong: an opinion about a
37/// codebase from 6 months ago is much less likely to still apply than
38/// one from yesterday. Code drifts, ownership changes, names get
39/// refactored, mental models age out.
40///
41/// Picking 90 days specifically: half-life of one quarter aligns with
42/// most teams' planning cadence — a verdict survives one quarter at
43/// near-full weight, then meaningfully fades. Tunable via the
44/// half-life parameter of `decay_factor` for callers that want a
45/// different curve (longer half-life for slow-moving codebases, shorter
46/// for high-churn ones).
47pub const DEFAULT_FEEDBACK_HALF_LIFE_DAYS: f64 = 90.0;
48
49/// Pure decay function. Returns a multiplier in `[0.0, 1.0]` for an
50/// observation of `age_days` against `half_life_days`. Uses the
51/// classical exponential decay: `factor = 0.5 ^ (age / half_life)`.
52///
53/// Properties (locked by unit tests):
54/// - age = 0 → factor = 1.0 (full weight)
55/// - age = half_life → factor = 0.5
56/// - age = 2*half_life → factor = 0.25
57/// - age = 10*half_life → factor ≈ 0.001 (effectively gone)
58/// - negative age (clock skew, future-dated entry) → factor = 1.0
59/// (defensive: don't *amplify* boosts for future timestamps)
60/// - half_life ≤ 0 → factor = 1.0 (decay disabled)
61pub fn decay_factor(age_days: f64, half_life_days: f64) -> f64 {
62 if half_life_days <= 0.0 {
63 return 1.0;
64 }
65 if age_days <= 0.0 {
66 return 1.0;
67 }
68 0.5_f64.powf(age_days / half_life_days)
69}
70
71/// Compute the decay multiplier for an entry whose `created_at` is
72/// known, given the current time. Used at feedback-boost application
73/// time to scale `Useful` verdicts by age.
74///
75/// `now` is taken as a parameter (rather than calling `Utc::now()`
76/// internally) so tests can pass fixed timestamps and so multiple
77/// decay computations in the same scoring pass share a single
78/// consistent "now".
79pub fn decay_for_entry(created_at: DateTime<Utc>, now: DateTime<Utc>, half_life_days: f64) -> f64 {
80 let age_secs = (now - created_at).num_seconds() as f64;
81 let age_days = age_secs / 86_400.0;
82 decay_factor(age_days, half_life_days)
83}
84
85// ---------------------------------------------------------------------------
86// Trait
87// ---------------------------------------------------------------------------
88
89pub trait FeedbackStore {
90 /// Record a verdict for a (query, symbol) pair.
91 fn record(&self, ref_name: &str, entry: &FeedbackEntry, agent_id: &str) -> Result<()>;
92
93 /// All feedback entries recorded for a specific symbol.
94 fn list_for_symbol(&self, ref_name: &str, symbol_id: &str) -> Result<Vec<FeedbackEntry>>;
95
96 /// Every feedback entry in the store.
97 fn list_all(&self, ref_name: &str) -> Result<Vec<FeedbackEntry>>;
98
99 /// Hard-delete a verdict from both stores. Returns the entry as it was,
100 /// or `None` if no such entry existed.
101 ///
102 /// The escape hatch a tombstone cannot serve: test data written by
103 /// mistake, or a secret pasted into a `--note`. For anything else
104 /// `withdraw` is the right verb — it retracts the verdict while keeping
105 /// the record, which still explains a past ranking.
106 ///
107 /// Deletes the authoritative store first, then the SQLite cache, and
108 /// fails loudly if the second step does not happen: a purge that clears
109 /// only the tree leaves `list_all`'s cache fast path still serving the
110 /// verdict.
111 fn purge(&self, ref_name: &str, entry_id: &str) -> Result<Option<FeedbackEntry>>;
112
113 /// Retract a verdict so it stops influencing ranking, keeping the record.
114 ///
115 /// Default implementation re-records the entry with the withdrawal stamped
116 /// on it. That deliberately reuses [`FeedbackStore::record`], which is the
117 /// only write path that updates BOTH the authoritative store and the
118 /// SQLite write-through cache — a bespoke write touching one would leave
119 /// the other serving the verdict as live. See DESIGN.md, "Feedback
120 /// withdrawal — tombstone shape".
121 ///
122 /// Idempotent: withdrawing an already-withdrawn entry keeps the original
123 /// timestamp rather than moving it.
124 fn withdraw(
125 &self,
126 ref_name: &str,
127 entry_id: &str,
128 by: &str,
129 reason: Option<&str>,
130 ) -> Result<Option<FeedbackEntry>> {
131 let Some(existing) = self
132 .list_all(ref_name)?
133 .into_iter()
134 .find(|e| e.entry_id == entry_id)
135 else {
136 return Ok(None);
137 };
138 if existing.is_withdrawn() {
139 return Ok(Some(existing));
140 }
141 let mut withdrawn = existing;
142 withdrawn.withdrawn_at = Some(Utc::now());
143 withdrawn.withdrawn_by = Some(by.to_string());
144 withdrawn.withdrawn_reason = reason.map(str::to_string);
145 self.record(ref_name, &withdrawn, by)?;
146 Ok(Some(withdrawn))
147 }
148
149 /// Flatten all feedback into (symbol_id, query, verdict, created_at)
150 /// tuples for use in `apply_feedback_adjustments`. Plan J t-016 added
151 /// `created_at` so the boost arithmetic can decay Useful verdicts by
152 /// age — a 6-month-old opinion about a codebase shouldn't carry the
153 /// same weight as yesterday's.
154 fn flat_verdicts(
155 &self,
156 ref_name: &str,
157 ) -> Result<Vec<(String, String, FeedbackVerdict, DateTime<Utc>)>> {
158 Ok(self
159 .list_all(ref_name)?
160 .into_iter()
161 // The one place a verdict stops influencing ranking. `is_inert`
162 // covers both lifecycle states (expired, withdrawn) so a third
163 // has one place to register rather than two call sites to
164 // remember. `list_all` deliberately keeps them — they still
165 // explain a past ranking.
166 //
167 // Plan J t-014 added `expires_at`, `is_expired()` and
168 // `--ttl-days`, and documented on both the field and the helper
169 // that lapsed verdicts were ignored here — but never wired the
170 // filter, so they never were until t-001.
171 .filter(|e| !e.is_inert())
172 .filter(|e| e.file_scope.is_none())
173 .map(|e| (e.symbol_id, e.query, e.verdict, e.created_at))
174 .collect())
175 }
176
177 /// Flatten file-scoped feedback into (file_glob, verdict, query, created_at)
178 /// tuples for use in `apply_file_scope_feedback`. Plan J t-016: same
179 /// reason — decay file-scope Useful verdicts by age.
180 fn flat_file_scope_verdicts(
181 &self,
182 ref_name: &str,
183 ) -> Result<Vec<(String, FeedbackVerdict, String, DateTime<Utc>)>> {
184 Ok(self
185 .list_all(ref_name)?
186 .into_iter()
187 // Same predicate as `flat_verdicts` — see the note there.
188 .filter(|e| !e.is_inert())
189 .filter_map(|e| {
190 e.file_scope
191 .map(|glob| (glob, e.verdict, e.query, e.created_at))
192 })
193 .collect())
194 }
195}
196
197// ---------------------------------------------------------------------------
198// ASG-backed implementation
199// ---------------------------------------------------------------------------
200
201pub struct AsgFeedbackStore<'a> {
202 pub repo: &'a Repository,
203 /// Borrowed FTS connection from the owning `Engine`. When `Some`,
204 /// enables the SQLite write-through cache: `list_all` reads from SQLite
205 /// if populated, `record` writes to SQLite after the git commit.
206 pub fts: Option<&'a SearchFtsDb>,
207}
208
209impl<'a> AsgFeedbackStore<'a> {
210 /// Construct without SQLite caching (tests, internal calls).
211 pub fn new(repo: &'a Repository) -> Self {
212 Self { repo, fts: None }
213 }
214 /// Convenience: borrow the FTS connection already open in `engine`.
215 pub fn from_engine(engine: &'a Engine) -> Self {
216 Self {
217 repo: &engine.repo,
218 fts: engine.fts.as_ref(),
219 }
220 }
221}
222
223impl<'a> FeedbackStore for AsgFeedbackStore<'a> {
224 fn record(&self, ref_name: &str, entry: &FeedbackEntry, agent_id: &str) -> Result<()> {
225 // Git is always written first — it's the authoritative store.
226 let path = paths::feedback_entry_path(&entry.symbol_id, &entry.entry_id);
227 let value = serde_json::to_value(entry)?;
228 let opts = CommitOptions::new(
229 agent_id,
230 IntentCategory::Refine,
231 format!(
232 "feedback {} for {}",
233 entry.verdict.as_str(),
234 entry.symbol_qname
235 ),
236 );
237 self.repo.set_json(ref_name, &path, &value, opts)?;
238 // Best-effort SQLite write-through; failures are non-fatal.
239 if let Some(fts) = self.fts {
240 let _ = fts.upsert_feedback(entry);
241 }
242 Ok(())
243 }
244
245 /// Hard-delete a verdict from BOTH stores. See the trait method.
246 fn purge(&self, ref_name: &str, entry_id: &str) -> Result<Option<FeedbackEntry>> {
247 let Some(existing) = self
248 .list_all(ref_name)?
249 .into_iter()
250 .find(|e| e.entry_id == entry_id)
251 else {
252 return Ok(None);
253 };
254
255 // Authoritative store first. If this fails nothing has changed, which
256 // is the safe direction to fail in.
257 let path = paths::feedback_entry_path(&existing.symbol_id, entry_id);
258 let opts = CommitOptions::new(
259 "asd-purge",
260 IntentCategory::Rollback,
261 format!("purge feedback entry {entry_id}"),
262 );
263 self.repo
264 .delete(ref_name, &path, opts)
265 .map_err(|e| AsdError::Other(format!("purge: git delete failed: {e}")))?;
266
267 // Then the cache. A failure here is NOT best-effort: the tree no
268 // longer has the entry but `list_all`'s fast path would still serve
269 // it, which is the exact divergence this plan exists to prevent. Say
270 // so loudly and name the recovery, rather than returning Ok.
271 if let Some(fts) = self.fts {
272 fts.delete_feedback(entry_id).map_err(|e| {
273 AsdError::Other(format!(
274 "purge: removed {entry_id} from the store but FAILED to clear the \
275 SQLite cache ({e}); `asd feedback list` will still show it. \
276 Run `asd index` to resync the cache."
277 ))
278 })?;
279 }
280 Ok(Some(existing))
281 }
282
283 fn list_for_symbol(&self, ref_name: &str, symbol_id: &str) -> Result<Vec<FeedbackEntry>> {
284 let prefix = paths::feedback_symbol_path(symbol_id);
285 match self.repo.get_tree(ref_name, &prefix) {
286 Ok(serde_json::Value::Object(map)) => {
287 let mut entries: Vec<FeedbackEntry> = map
288 .values()
289 .filter_map(|v| serde_json::from_value(v.clone()).ok())
290 .collect();
291 entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
292 Ok(entries)
293 }
294 _ => Ok(vec![]),
295 }
296 }
297
298 fn list_all(&self, ref_name: &str) -> Result<Vec<FeedbackEntry>> {
299 // Fast path: SQLite cache — zero git tree walks when populated.
300 if let Some(fts) = self.fts {
301 if fts.feedback_count() > 0 {
302 if let Ok(entries) = fts.list_all_feedback() {
303 return Ok(entries);
304 }
305 }
306 }
307
308 // Authoritative git path — also runs when SQLite is empty (e.g. first
309 // run after `git pull`). Re-populate the cache as a side effect so
310 // subsequent calls are fast.
311 let prefix = format!("{}/feedback", paths::ASD_ROOT);
312 let mut entries = Vec::new();
313 if let Ok(serde_json::Value::Object(by_symbol)) = self.repo.get_tree(ref_name, &prefix) {
314 for symbol_val in by_symbol.values() {
315 if let serde_json::Value::Object(symbol_entries) = symbol_val {
316 for ev in symbol_entries.values() {
317 if let Ok(e) = serde_json::from_value::<FeedbackEntry>(ev.clone()) {
318 entries.push(e);
319 }
320 }
321 }
322 }
323 }
324 entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
325
326 // Populate the SQLite cache for the next call — best effort.
327 if !entries.is_empty() {
328 if let Some(fts) = self.fts {
329 let _ = fts.sync_feedback_entries(&entries);
330 }
331 }
332
333 Ok(entries)
334 }
335}
336
337#[cfg(test)]
338mod plan_j_t016_decay_tests {
339 //! Plan J t-016: feedback decay arithmetic.
340 //!
341 //! Locks the pure `decay_factor` + `decay_for_entry` helpers
342 //! against the spec'd properties (zero age → 1.0, half-life →
343 //! 0.5, multiple half-lives → geometric falloff, defensive
344 //! handling of clock skew and zero half_life).
345
346 use super::*;
347 use chrono::Duration;
348
349 fn approx(actual: f64, expected: f64) -> bool {
350 (actual - expected).abs() < 1e-6
351 }
352
353 #[test]
354 fn zero_age_returns_full_weight() {
355 assert!(approx(decay_factor(0.0, 90.0), 1.0));
356 }
357
358 #[test]
359 fn half_life_returns_one_half() {
360 assert!(approx(decay_factor(90.0, 90.0), 0.5));
361 }
362
363 #[test]
364 fn two_half_lives_returns_one_quarter() {
365 assert!(approx(decay_factor(180.0, 90.0), 0.25));
366 }
367
368 #[test]
369 fn ten_half_lives_effectively_zero() {
370 let f = decay_factor(900.0, 90.0);
371 assert!(f < 0.002, "10× half-life should decay to ~0.1%; got {f}");
372 assert!(f > 0.0, "but still strictly positive");
373 }
374
375 #[test]
376 fn negative_age_clamps_to_full_weight() {
377 // Defensive against clock skew or future-dated entries:
378 // a verdict from the "future" must not get AMPLIFIED above
379 // 1.0 — that would let a malicious or buggy timestamp turn
380 // a +1.5 boost into +1000.
381 assert_eq!(decay_factor(-1.0, 90.0), 1.0);
382 assert_eq!(decay_factor(-90.0, 90.0), 1.0);
383 }
384
385 #[test]
386 fn zero_or_negative_half_life_disables_decay() {
387 // Lets callers opt out of decay entirely without a
388 // wrapper or branch — pass half_life = 0 → all weights
389 // return 1.0 regardless of age.
390 assert_eq!(decay_factor(365.0, 0.0), 1.0);
391 assert_eq!(decay_factor(365.0, -10.0), 1.0);
392 }
393
394 #[test]
395 fn quarter_year_at_default_half_life_matches_spec() {
396 // 90-day half-life with a 90-day-old verdict → 50% weight.
397 // 6-month verdict → 25%. The DESIGN.md spec wording
398 // ("6 months should have less weight than yesterday's")
399 // is operationalized by this curve.
400 let day_old = decay_factor(1.0, DEFAULT_FEEDBACK_HALF_LIFE_DAYS);
401 let six_months = decay_factor(180.0, DEFAULT_FEEDBACK_HALF_LIFE_DAYS);
402 assert!(
403 day_old > 0.98,
404 "1-day-old must be near full weight; got {day_old}"
405 );
406 assert!(
407 approx(six_months, 0.25),
408 "6-month must be ~25%; got {six_months}"
409 );
410 assert!(
411 day_old > six_months * 3.0,
412 "yesterday's weight must dominate 6-month-old by >3×"
413 );
414 }
415
416 #[test]
417 fn decay_for_entry_threads_timestamps_correctly() {
418 // Test the timestamp wrapper — equivalent of decay_factor
419 // but takes DateTime<Utc> instead of pre-computed age.
420 let now = Utc::now();
421 let one_quarter_ago = now - Duration::days(90);
422 let f = decay_for_entry(one_quarter_ago, now, 90.0);
423 assert!(
424 approx(f, 0.5),
425 "90-day-old at 90-day half-life → 0.5; got {f}"
426 );
427 }
428
429 #[test]
430 fn decay_for_entry_handles_subday_resolution() {
431 // 12 hours old, 1 day half-life → 0.5^0.5 ≈ 0.707.
432 // Ensures we don't truncate age to whole days.
433 let now = Utc::now();
434 let twelve_hours_ago = now - Duration::hours(12);
435 let f = decay_for_entry(twelve_hours_ago, now, 1.0);
436 // 12h of a 1-day half-life is exactly 0.5^0.5 = 1/sqrt(2). Use the
437 // constant, not a 0.7071 literal (clippy::approx_constant).
438 assert!(
439 (f - std::f64::consts::FRAC_1_SQRT_2).abs() < 0.01,
440 "12h/1day should produce ~0.707 (1/sqrt2); got {f}"
441 );
442 }
443
444 #[test]
445 fn decay_for_entry_with_future_timestamp_clamps_to_one() {
446 // Clock skew defense via the wrapper.
447 let now = Utc::now();
448 let future = now + Duration::days(30);
449 assert_eq!(decay_for_entry(future, now, 90.0), 1.0);
450 }
451}