Skip to main content

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::FactStore;
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: FactStore> 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        let _generation = self.enter_generation();
73        // Raw payload (reserved keys included) so we can read the current RL
74        // state the caller-facing metadata hides.
75        let payload = self
76            .store
77            .get_metadata(id)?
78            .ok_or(MemoryError::UnknownMemory(id))?;
79
80        let confidence = read_confidence(&payload);
81        let mut success_count = read_count(&payload, RL_SUCCESS_KEY);
82        let mut failure_count = read_count(&payload, RL_FAILURE_KEY);
83        if success {
84            success_count += 1;
85        } else {
86            failure_count += 1;
87        }
88
89        let total = success_count + failure_count;
90        let mut context = ReinforcementContext::new().with_usage_count(total);
91        if let Some(rate) = success_rate(success_count, total) {
92            context = context.with_success_rate(rate);
93        }
94        let new_confidence = FixedRate::default().update_confidence(confidence, success, &context);
95
96        let mut updates = Metadata::new();
97        updates.insert(RL_CONFIDENCE_KEY.to_owned(), json!(new_confidence));
98        updates.insert(RL_SUCCESS_KEY.to_owned(), json!(success_count));
99        updates.insert(RL_FAILURE_KEY.to_owned(), json!(failure_count));
100        // update_metadata merges into the existing payload, preserving content,
101        // caller metadata and the durable TTL.
102        self.store.update_metadata(id, &updates)?;
103
104        Ok(new_confidence)
105    }
106
107    /// Re-rank vector hits by blending similarity with each fact's learned
108    /// confidence, reordering the hits **and** their raw payloads together.
109    ///
110    /// Takes the payloads the caller already fetched (reserved keys included,
111    /// same order as `hits`) so no extra storage round trip is needed, and
112    /// returns both reordered so the caller can strip and attach metadata in
113    /// the final order. The reported `score` stays the true similarity; only
114    /// the *order* changes. A fact with neutral (or absent) confidence keeps a
115    /// blend factor of exactly `1.0`, so a result set with no feedback is
116    /// returned untouched — the stable sort preserves the incoming similarity
117    /// order exactly.
118    pub(crate) fn rl_rerank(hits: Vec<Hit>, payloads: Vec<Option<Metadata>>) -> RerankedHits {
119        if hits.len() < 2 {
120            return (hits, payloads);
121        }
122        let mut ranked: Vec<RankedHit> = hits
123            .into_iter()
124            .zip(payloads)
125            .map(|(hit, payload)| {
126                let confidence = payload
127                    .as_ref()
128                    .map_or(RL_NEUTRAL_CONFIDENCE, read_confidence);
129                let blended = blended_score(hit.1, confidence);
130                (hit, payload, blended)
131            })
132            .collect();
133        // Stable sort: equal blended scores (e.g. all-neutral) keep input order.
134        ranked.sort_by(|a, b| b.2.total_cmp(&a.2));
135
136        let mut out_hits = Vec::with_capacity(ranked.len());
137        let mut out_payloads = Vec::with_capacity(ranked.len());
138        for (hit, payload, _) in ranked {
139            out_hits.push(hit);
140            out_payloads.push(payload);
141        }
142        (out_hits, out_payloads)
143    }
144}
145
146/// Blend a raw similarity with a learned confidence into a re-rank key.
147///
148/// The cosine similarity (range `[-1, 1]` — a real embedder produces negative
149/// values for dissimilar pairs) is mapped to a **non-negative** `[0, 1]` base
150/// *before* the confidence factor is applied, so reinforcement can never invert
151/// the ranking: multiplying a negative score by a `> 1` factor would push a
152/// reinforced fact *down*. The factor `1 + W·(2c − 1) ∈ [1−W, 1+W]` scales the
153/// base up for confident facts and down for doubted ones; neutral confidence
154/// (`0.5`) gives factor `1.0`, leaving the base — and thus the order — untouched.
155fn blended_score(similarity: f32, confidence: f32) -> f32 {
156    let base = f32::midpoint(similarity, 1.0);
157    let factor = 1.0 + RL_RERANK_WEIGHT * (2.0 * confidence - 1.0);
158    base * factor
159}
160
161/// Read a fact's persisted confidence, clamped to `[0.0, 1.0]`; neutral when
162/// absent or malformed (a corrupt value never poisons ranking). Shared with
163/// the context memory bridge, whose importance blend reads the same learned
164/// signal off the raw payload batch (`_veles_rl_confidence` stays the single
165/// source of truth).
166#[allow(
167    clippy::cast_possible_truncation,
168    reason = "confidence is a bounded [0,1] weight; f64→f32 rounding is immaterial and the result is clamped"
169)]
170pub(crate) fn read_confidence(payload: &Metadata) -> f32 {
171    payload
172        .get(RL_CONFIDENCE_KEY)
173        .and_then(Value::as_f64)
174        .map_or(RL_NEUTRAL_CONFIDENCE, |v| (v as f32).clamp(0.0, 1.0))
175}
176
177/// Read a non-negative feedback tally, defaulting to `0` when absent/malformed.
178fn read_count(payload: &Metadata, key: &str) -> u64 {
179    payload.get(key).and_then(Value::as_u64).unwrap_or(0)
180}
181
182/// Positive-feedback rate over total feedbacks, or `None` before any feedback.
183#[allow(
184    clippy::cast_precision_loss,
185    reason = "feedback tallies are small counters; an approximate rate is all the strategy needs"
186)]
187fn success_rate(success_count: u64, total: u64) -> Option<f32> {
188    if total == 0 {
189        None
190    } else {
191        Some(success_count as f32 / total as f32)
192    }
193}
194
195#[cfg(all(test, feature = "persistence"))]
196#[path = "reinforce_tests.rs"]
197mod tests;