Skip to main content

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}