velesdb_memory/reinforce.rs
1//! RL Memory: a persistent, learned confidence per fact that [`feedback`]
2//! reinforces and [`recall`] uses to re-rank — the loop that lets an agent's
3//! memory *improve with use* without retraining the model behind it.
4//!
5//! The confidence lives in the fact's payload under a reserved
6//! (`_veles_rl_*`) key, so it survives restarts and never leaks into the
7//! caller-visible metadata (the storage layer strips reserved keys on the way
8//! out). The reinforcement math is not reinvented here: it reuses the
9//! [`ReinforcementStrategy`] trait from `velesdb-core`'s agent SDK
10//! (`FixedRate` by default), the same machinery procedural memory uses.
11//!
12//! [`feedback`]: MemoryService::feedback
13//! [`recall`]: MemoryService::recall
14
15use serde_json::{json, Value};
16use velesdb_core::agent::{FixedRate, ReinforcementContext, ReinforcementStrategy};
17
18use super::{MemoryService, Metadata};
19use crate::embedder::Embedder;
20use crate::error::MemoryError;
21use crate::storage::MemoryStore;
22
23/// Reserved payload key holding a fact's learned confidence in `[0.0, 1.0]`.
24/// Absent means the fact has never received feedback.
25pub(crate) const RL_CONFIDENCE_KEY: &str = "_veles_rl_confidence";
26/// Reserved payload key: running count of positive feedbacks on a fact.
27const RL_SUCCESS_KEY: &str = "_veles_rl_success";
28/// Reserved payload key: running count of negative feedbacks on a fact.
29const RL_FAILURE_KEY: &str = "_veles_rl_failure";
30
31/// Confidence assumed for a fact with no feedback yet — the neutral midpoint.
32/// Chosen so re-ranking leaves never-reinforced facts in their original
33/// similarity order (their re-rank factor is exactly `1.0`).
34pub(crate) const RL_NEUTRAL_CONFIDENCE: f32 = 0.5;
35
36/// How hard learned confidence bends the similarity score during re-ranking.
37/// A fact reinforced to `1.0` gets its score scaled by `1 + W`; one punished
38/// to `0.0` by `1 - W`. Kept modest so semantic similarity stays the dominant
39/// signal and feedback only tips genuinely close calls.
40const RL_RERANK_WEIGHT: f32 = 0.5;
41
42/// One recalled hit: `(id, similarity, content)`.
43type Hit = (u64, f32, String);
44/// A recalled hit paired with its raw payload and the blended re-rank key, as
45/// sorted by [`MemoryService::rl_rerank`].
46type RankedHit = (Hit, Option<Metadata>, f32);
47/// Reordered hits and their raw payloads, returned by [`MemoryService::rl_rerank`].
48type RerankedHits = (Vec<Hit>, Vec<Option<Metadata>>);
49
50impl<E: Embedder, S: MemoryStore> MemoryService<E, S> {
51 /// Record an outcome for a recalled fact and return its new confidence.
52 ///
53 /// `success = true` reinforces the fact (it was useful), `false` weakens it
54 /// (it was noise). The update is applied by a [`ReinforcementStrategy`]
55 /// (`FixedRate` by default) over the fact's current confidence and its
56 /// success/failure history, then persisted durably. Over repeated
57 /// feedback the fact drifts up or down the [`Self::recall`] ranking — the
58 /// agent's memory learns which facts are worth surfacing.
59 ///
60 /// # Concurrency
61 /// The update is a read-modify-write that is **not** atomic across the
62 /// `get_metadata`/`update_metadata` pair. Two `feedback` calls racing on the
63 /// same `id` are last-writer-wins: one increment can be lost. This is
64 /// acceptable for a soft, approximate ranking signal — feedback still moves
65 /// confidence in the right direction — but callers needing exact tallies
66 /// must serialize their own calls per id.
67 ///
68 /// # Errors
69 /// Returns [`MemoryError::UnknownMemory`] if `id` is not a live fact, or a
70 /// storage error if the read-back or persist fails.
71 pub fn feedback(&self, id: u64, success: bool) -> Result<f32, MemoryError> {
72 // Raw payload (reserved keys included) so we can read the current RL
73 // state the caller-facing metadata hides.
74 let payload = self
75 .store
76 .get_metadata(id)?
77 .ok_or(MemoryError::UnknownMemory(id))?;
78
79 let confidence = read_confidence(&payload);
80 let mut success_count = read_count(&payload, RL_SUCCESS_KEY);
81 let mut failure_count = read_count(&payload, RL_FAILURE_KEY);
82 if success {
83 success_count += 1;
84 } else {
85 failure_count += 1;
86 }
87
88 let total = success_count + failure_count;
89 let mut context = ReinforcementContext::new().with_usage_count(total);
90 if let Some(rate) = success_rate(success_count, total) {
91 context = context.with_success_rate(rate);
92 }
93 let new_confidence = FixedRate::default().update_confidence(confidence, success, &context);
94
95 let mut updates = Metadata::new();
96 updates.insert(RL_CONFIDENCE_KEY.to_owned(), json!(new_confidence));
97 updates.insert(RL_SUCCESS_KEY.to_owned(), json!(success_count));
98 updates.insert(RL_FAILURE_KEY.to_owned(), json!(failure_count));
99 // update_metadata merges into the existing payload, preserving content,
100 // caller metadata and the durable TTL.
101 self.store.update_metadata(id, &updates)?;
102
103 Ok(new_confidence)
104 }
105
106 /// Re-rank vector hits by blending similarity with each fact's learned
107 /// confidence, reordering the hits **and** their raw payloads together.
108 ///
109 /// Takes the payloads the caller already fetched (reserved keys included,
110 /// same order as `hits`) so no extra storage round trip is needed, and
111 /// returns both reordered so the caller can strip and attach metadata in
112 /// the final order. The reported `score` stays the true similarity; only
113 /// the *order* changes. A fact with neutral (or absent) confidence keeps a
114 /// blend factor of exactly `1.0`, so a result set with no feedback is
115 /// returned untouched — the stable sort preserves the incoming similarity
116 /// order exactly.
117 pub(crate) fn rl_rerank(hits: Vec<Hit>, payloads: Vec<Option<Metadata>>) -> RerankedHits {
118 if hits.len() < 2 {
119 return (hits, payloads);
120 }
121 let mut ranked: Vec<RankedHit> = hits
122 .into_iter()
123 .zip(payloads)
124 .map(|(hit, payload)| {
125 let confidence = payload
126 .as_ref()
127 .map_or(RL_NEUTRAL_CONFIDENCE, read_confidence);
128 let blended = blended_score(hit.1, confidence);
129 (hit, payload, blended)
130 })
131 .collect();
132 // Stable sort: equal blended scores (e.g. all-neutral) keep input order.
133 ranked.sort_by(|a, b| b.2.total_cmp(&a.2));
134
135 let mut out_hits = Vec::with_capacity(ranked.len());
136 let mut out_payloads = Vec::with_capacity(ranked.len());
137 for (hit, payload, _) in ranked {
138 out_hits.push(hit);
139 out_payloads.push(payload);
140 }
141 (out_hits, out_payloads)
142 }
143}
144
145/// Blend a raw similarity with a learned confidence into a re-rank key.
146///
147/// The cosine similarity (range `[-1, 1]` — a real embedder produces negative
148/// values for dissimilar pairs) is mapped to a **non-negative** `[0, 1]` base
149/// *before* the confidence factor is applied, so reinforcement can never invert
150/// the ranking: multiplying a negative score by a `> 1` factor would push a
151/// reinforced fact *down*. The factor `1 + W·(2c − 1) ∈ [1−W, 1+W]` scales the
152/// base up for confident facts and down for doubted ones; neutral confidence
153/// (`0.5`) gives factor `1.0`, leaving the base — and thus the order — untouched.
154fn blended_score(similarity: f32, confidence: f32) -> f32 {
155 let base = f32::midpoint(similarity, 1.0);
156 let factor = 1.0 + RL_RERANK_WEIGHT * (2.0 * confidence - 1.0);
157 base * factor
158}
159
160/// Read a fact's persisted confidence, clamped to `[0.0, 1.0]`; neutral when
161/// absent or malformed (a corrupt value never poisons ranking). Shared with
162/// the context memory bridge, whose importance blend reads the same learned
163/// signal off the raw payload batch (`_veles_rl_confidence` stays the single
164/// source of truth).
165#[allow(
166 clippy::cast_possible_truncation,
167 reason = "confidence is a bounded [0,1] weight; f64→f32 rounding is immaterial and the result is clamped"
168)]
169pub(crate) fn read_confidence(payload: &Metadata) -> f32 {
170 payload
171 .get(RL_CONFIDENCE_KEY)
172 .and_then(Value::as_f64)
173 .map_or(RL_NEUTRAL_CONFIDENCE, |v| (v as f32).clamp(0.0, 1.0))
174}
175
176/// Read a non-negative feedback tally, defaulting to `0` when absent/malformed.
177fn read_count(payload: &Metadata, key: &str) -> u64 {
178 payload.get(key).and_then(Value::as_u64).unwrap_or(0)
179}
180
181/// Positive-feedback rate over total feedbacks, or `None` before any feedback.
182#[allow(
183 clippy::cast_precision_loss,
184 reason = "feedback tallies are small counters; an approximate rate is all the strategy needs"
185)]
186fn success_rate(success_count: u64, total: u64) -> Option<f32> {
187 if total == 0 {
188 None
189 } else {
190 Some(success_count as f32 / total as f32)
191 }
192}
193
194#[cfg(all(test, feature = "persistence"))]
195mod tests {
196 use crate::embedder::HashEmbedder;
197 use crate::service::MemoryService;
198 use crate::DEFAULT_DIMENSION;
199 use tempfile::TempDir;
200
201 fn service() -> (TempDir, MemoryService<HashEmbedder>) {
202 let dir = TempDir::new().expect("tempdir");
203 let embedder = HashEmbedder::new(DEFAULT_DIMENSION);
204 let svc = MemoryService::open(dir.path(), embedder).expect("open store");
205 (dir, svc)
206 }
207
208 #[test]
209 fn feedback_raises_confidence_on_success_and_lowers_on_failure() {
210 let (_dir, svc) = service();
211 let id = svc.remember("rust prevents data races", &[], None).unwrap();
212
213 // First success lifts confidence above the neutral midpoint.
214 let up = svc.feedback(id, true).unwrap();
215 assert!(up > 0.5, "success should raise confidence, got {up}");
216
217 // A failure pulls it back down below the previous value.
218 let down = svc.feedback(id, false).unwrap();
219 assert!(down < up, "failure should lower confidence, got {down}");
220 }
221
222 #[test]
223 fn feedback_is_clamped_and_monotonic_under_repeated_success() {
224 let (_dir, svc) = service();
225 let id = svc.remember("clamp me", &[], None).unwrap();
226
227 let mut last = 0.5_f32;
228 for _ in 0..50 {
229 let c = svc.feedback(id, true).unwrap();
230 assert!(c >= last - f32::EPSILON, "confidence must not decrease");
231 assert!(c <= 1.0, "confidence must stay clamped to 1.0, got {c}");
232 last = c;
233 }
234 assert!(
235 last > 0.99,
236 "many successes should saturate near 1.0, got {last}"
237 );
238 }
239
240 #[test]
241 fn feedback_persists_across_reopen() {
242 let dir = TempDir::new().expect("tempdir");
243 let id;
244 let after;
245 {
246 let svc =
247 MemoryService::open(dir.path(), HashEmbedder::new(DEFAULT_DIMENSION)).unwrap();
248 id = svc.remember("durable confidence", &[], None).unwrap();
249 svc.feedback(id, true).unwrap();
250 after = svc.feedback(id, true).unwrap();
251 }
252 // Reopen the same store: one more success must continue from the
253 // persisted confidence, not restart from neutral.
254 let svc = MemoryService::open(dir.path(), HashEmbedder::new(DEFAULT_DIMENSION)).unwrap();
255 let resumed = svc.feedback(id, true).unwrap();
256 assert!(
257 resumed > after,
258 "confidence must resume from persisted {after}, got {resumed}"
259 );
260 }
261
262 #[test]
263 fn feedback_teaches_recall_to_prefer_the_authoritative_answer() {
264 // Business scenario: a coding agent's memory holds two facts about the
265 // same API. One is the CURRENT, correct usage; the other is a
266 // deprecated pattern whose wording superficially matches the query, so
267 // a plain vector recall keeps surfacing the wrong one first. The team
268 // marks the correct fact useful and the deprecated one noise; recall
269 // must learn to lead with the authoritative answer.
270 let (_dir, svc) = service();
271 svc.remember(
272 "Use `Client::builder().timeout(d).build()` to configure the HTTP client timeout",
273 &[],
274 None,
275 )
276 .unwrap();
277 svc.remember(
278 "Deprecated: set the HTTP client timeout via the global `CLIENT_TIMEOUT` env var",
279 &[],
280 None,
281 )
282 .unwrap();
283
284 let query = "how to configure the http client timeout";
285 let baseline = svc.recall(query, 2, None).unwrap();
286 assert_eq!(baseline.len(), 2, "both facts should be recalled");
287
288 // Whatever recall ranks first at baseline, the team reinforces the
289 // *authoritative* fact and flags the other as noise, session after
290 // session, until the learned confidence overrides the surface-form gap.
291 let authoritative = baseline[1].id; // the one recall under-ranked
292 let deprecated = baseline[0].id;
293 for _ in 0..15 {
294 svc.feedback(authoritative, true).unwrap();
295 svc.feedback(deprecated, false).unwrap();
296 }
297
298 let after = svc.recall(query, 2, None).unwrap();
299 assert_eq!(
300 after[0].id, authoritative,
301 "recall must now lead with the fact the team kept marking useful"
302 );
303 // The reported score stays the raw similarity — only the order learned.
304 let sim_before = baseline
305 .iter()
306 .find(|r| r.id == authoritative)
307 .unwrap()
308 .score;
309 let sim_after = after.iter().find(|r| r.id == authoritative).unwrap().score;
310 assert!(
311 (sim_before - sim_after).abs() < 1e-6,
312 "feedback re-orders results; it must not fabricate a different similarity score"
313 );
314 }
315
316 #[test]
317 fn recall_order_is_untouched_without_feedback() {
318 let (_dir, svc) = service();
319 for fact in ["alpha fact", "beta fact", "gamma fact", "delta fact"] {
320 svc.remember(fact, &[], None).unwrap();
321 }
322 // With no feedback every confidence is neutral, so recall must return
323 // exactly the similarity order (re-rank factor 1.0, stable sort).
324 let a = svc.recall("fact", 4, None).unwrap();
325 let b = svc.recall("fact", 4, None).unwrap();
326 let ids_a: Vec<u64> = a.iter().map(|r| r.id).collect();
327 let ids_b: Vec<u64> = b.iter().map(|r| r.id).collect();
328 assert_eq!(ids_a, ids_b, "recall must be deterministic and unreordered");
329 }
330
331 #[test]
332 fn feedback_on_unknown_id_errors() {
333 let (_dir, svc) = service();
334 assert!(svc.feedback(999, true).is_err(), "unknown id must error");
335 }
336
337 #[test]
338 fn blend_never_inverts_ranking_even_on_negative_similarity() {
339 use super::blended_score;
340 // Regression guard for the cosine sign bug: a real embedder produces
341 // negative similarities for dissimilar pairs. At a fixed similarity,
342 // more confidence must never yield a *lower* blended score, whatever
343 // the sign — otherwise reinforcing a fact would demote it.
344 for &sim in &[-0.99_f32, -0.5, -0.12, 0.0, 0.3, 0.95] {
345 let punished = blended_score(sim, 0.0);
346 let neutral = blended_score(sim, 0.5);
347 let reinforced = blended_score(sim, 1.0);
348 assert!(
349 reinforced >= neutral && neutral >= punished,
350 "sim={sim}: confidence inverted the ranking ({punished} <= {neutral} <= {reinforced})"
351 );
352 }
353 // The review's failing case: a reinforced fact at sim -0.12 must
354 // outrank a neutral, *more* similar fact at sim -0.10.
355 assert!(
356 blended_score(-0.12, 1.0) > blended_score(-0.10, 0.5),
357 "reinforcement must overcome a small similarity gap even when negative"
358 );
359 }
360}