Skip to main content

mnemo_core/query/
experience.rs

1//! Experience-memory tier (DocTrace, arXiv:2606.10921).
2//!
3//! DocTrace's two-tier idea: tier 1 is the raw memory store (everything
4//! mnemo already does); tier 2 is an **experience memory** that caches a
5//! *successful* retrieval/reasoning **plan** — the query signature, the
6//! steps taken, the chunks that led to a confirmed-good outcome, and an
7//! outcome score — and **replays** that plan when a structurally-similar
8//! query recurs, instead of re-running full retrieval from scratch.
9//!
10//! This is implemented as a **mode, not a new store**: plans are
11//! persisted as ordinary [`MemoryRecord`]s carrying the reserved
12//! [`EXPERIENCE_PLAN_TAG`] with the plan payload in `metadata`. That
13//! buys backend-agnosticism (DuckDB + PostgreSQL, unchanged schema) and
14//! RBAC/consent (scope + ACL) for free, and lets the existing
15//! `remember` write-path handle hashing, embedding, and audit events.
16//!
17//! # Two new ops (gated)
18//!
19//! - **`REMEMBER_PLAN`** ([`execute_remember_plan`]): persist a plan,
20//!   but only when its `outcome_score` clears [`DEFAULT_SUCCESS_THRESHOLD`]
21//!   — failures are never cached.
22//! - **`RECALL_PLAN`** ([`execute_recall_plan`]): on a new query, return
23//!   the best stored plan whose query signature matches above a
24//!   similarity threshold, or a miss.
25//!
26//! Both are gated behind [`MnemoEngine::with_experience_memory`]; with
27//! the mode **off** (the default) `REMEMBER_PLAN` is a validation error
28//! and `RECALL_PLAN` always misses, so default behaviour is unchanged.
29//! Plan records are also excluded from ordinary `recall` (they are
30//! replayed only via `RECALL_PLAN`).
31//!
32//! # Signature & similarity
33//!
34//! A query *signature* is its normalized significant-token set
35//! ([`signature_tokens`]); structural similarity is the
36//! [`jaccard`] overlap of two signatures. This is deterministic and
37//! backend-/embedder-agnostic (works under `NoopEmbedding`), which is
38//! what the v0 replay gate needs; a learned signature is a later knob.
39
40use serde::{Deserialize, Serialize};
41use uuid::Uuid;
42
43use crate::error::{Error, Result};
44use crate::model::memory::{MemoryType, Scope, SourceType};
45use crate::query::MnemoEngine;
46use crate::query::remember::RememberRequest;
47use crate::storage::MemoryFilter;
48
49/// Reserved tag stamped on every experience-tier plan record. Plans are
50/// excluded from ordinary `recall` and read back only via `RECALL_PLAN`.
51pub const EXPERIENCE_PLAN_TAG: &str = "__experience_plan__";
52/// Metadata key under which the [`PlanPayload`] is serialized.
53pub const PLAN_METADATA_KEY: &str = "experience_plan";
54/// Default Jaccard threshold above which a stored plan is replayed.
55pub const DEFAULT_SIMILARITY_THRESHOLD: f32 = 0.7;
56/// Plans scoring below this outcome are treated as failures and never
57/// cached.
58pub const DEFAULT_SUCCESS_THRESHOLD: f32 = 0.5;
59/// Cap on plan records scanned per `RECALL_PLAN`.
60const PLAN_SCAN_LIMIT: usize = 1000;
61
62/// The cached plan payload, serialized into the record `metadata`.
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64pub struct PlanPayload {
65    /// The original query the plan was confirmed good for.
66    pub query: String,
67    /// Normalized signature tokens (sorted, deduped).
68    pub signature_tokens: Vec<String>,
69    /// Ordered retrieval/reasoning steps taken.
70    pub steps: Vec<String>,
71    /// Ids of the chunks that led to the confirmed-good outcome.
72    pub chunk_ids: Vec<String>,
73    /// Confirmed outcome score in [0.0, 1.0].
74    pub outcome_score: f32,
75}
76
77/// `REMEMBER_PLAN` input.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct RememberPlanRequest {
80    /// The query the plan succeeded for.
81    pub query: String,
82    /// Ordered retrieval/reasoning steps to replay.
83    pub steps: Vec<String>,
84    /// Chunk ids that produced the confirmed-good outcome.
85    pub chunk_ids: Vec<String>,
86    /// Confirmed outcome score in [0.0, 1.0].
87    pub outcome_score: f32,
88    /// Owning agent (defaults to the engine default agent).
89    pub agent_id: Option<String>,
90    /// Visibility scope (defaults to `Private` — an agent replays its own
91    /// plans). `Shared` plans honour the ACL on read.
92    pub scope: Option<Scope>,
93    /// Organization id for multi-tenant scoping.
94    pub org_id: Option<String>,
95}
96
97/// `REMEMBER_PLAN` output.
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct RememberPlanResponse {
100    /// Id of the stored plan record, if it was persisted.
101    pub id: Option<Uuid>,
102    /// The computed signature (sorted token list, space-joined).
103    pub signature: String,
104    /// `true` if the plan cleared the success threshold and was stored;
105    /// `false` if it was rejected as a non-success (not cached).
106    pub stored: bool,
107}
108
109/// `RECALL_PLAN` input.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct RecallPlanRequest {
112    /// The new query to look up a replayable plan for.
113    pub query: String,
114    /// Requesting agent (defaults to the engine default agent).
115    pub agent_id: Option<String>,
116    /// Organization id for multi-tenant scoping.
117    pub org_id: Option<String>,
118    /// Override the replay similarity threshold
119    /// ([`DEFAULT_SIMILARITY_THRESHOLD`] when `None`).
120    pub similarity_threshold: Option<f32>,
121}
122
123/// A replayable plan plus the similarity that matched it.
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct CachedPlan {
126    /// Plan record id.
127    pub id: Uuid,
128    /// The query the plan was originally confirmed good for.
129    pub query: String,
130    /// Ordered steps to replay.
131    pub steps: Vec<String>,
132    /// Chunk ids to return instead of re-running retrieval.
133    pub chunk_ids: Vec<String>,
134    /// Original outcome score.
135    pub outcome_score: f32,
136    /// Jaccard similarity between the incoming query and this plan.
137    pub similarity: f32,
138}
139
140/// `RECALL_PLAN` output.
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct RecallPlanResponse {
143    /// The best replayable plan above threshold, or `None` on a miss.
144    pub plan: Option<CachedPlan>,
145    /// How many visible plan records were considered.
146    pub candidates_considered: usize,
147}
148
149/// Normalize a query into its signature token set: lowercase, split on
150/// non-alphanumerics, drop tokens shorter than 3 chars, dedup, and sort
151/// so the signature is order-independent.
152pub fn signature_tokens(query: &str) -> Vec<String> {
153    let mut tokens: Vec<String> = query
154        .split(|c: char| !c.is_alphanumeric())
155        .filter(|t| t.chars().count() >= 3)
156        .map(|t| t.to_lowercase())
157        .collect();
158    tokens.sort();
159    tokens.dedup();
160    tokens
161}
162
163/// Jaccard overlap `|A∩B| / |A∪B|` of two signature token sets. Returns
164/// `0.0` when both are empty.
165pub fn jaccard(a: &[String], b: &[String]) -> f32 {
166    if a.is_empty() && b.is_empty() {
167        return 0.0;
168    }
169    let mut intersection = 0usize;
170    for t in a {
171        if b.contains(t) {
172            intersection += 1;
173        }
174    }
175    let union = a.len() + b.len() - intersection;
176    if union == 0 {
177        0.0
178    } else {
179        intersection as f32 / union as f32
180    }
181}
182
183/// Whether `agent_id` may read `record` under mnemo's scope/ACL rules —
184/// the same consent gate ordinary recall enforces.
185async fn plan_visible_to(
186    engine: &MnemoEngine,
187    record: &crate::model::memory::MemoryRecord,
188    agent_id: &str,
189) -> bool {
190    match record.scope {
191        Scope::Public | Scope::Global => true,
192        Scope::Private => record.agent_id == agent_id,
193        Scope::Shared => {
194            record.agent_id == agent_id
195                || engine
196                    .storage
197                    .check_permission(record.id, agent_id, crate::model::acl::Permission::Read)
198                    .await
199                    .unwrap_or(false)
200        }
201    }
202}
203
204/// `REMEMBER_PLAN` — persist a successful plan into the experience tier.
205pub async fn execute_remember_plan(
206    engine: &MnemoEngine,
207    request: RememberPlanRequest,
208) -> Result<RememberPlanResponse> {
209    if !engine.experience_memory_enabled {
210        return Err(Error::Validation(
211            "experience memory mode is disabled; enable it with MnemoEngine::with_experience_memory()".to_string(),
212        ));
213    }
214    let tokens = signature_tokens(&request.query);
215    let signature = tokens.join(" ");
216
217    // Only confirmed-good outcomes are cached — failures must not be
218    // replayed. Binding the comparison first keeps NaN rejecting (NaN is
219    // never a success) without a negated partial-ord comparison.
220    let is_success = request.outcome_score >= DEFAULT_SUCCESS_THRESHOLD;
221    if !is_success {
222        return Ok(RememberPlanResponse {
223            id: None,
224            signature,
225            stored: false,
226        });
227    }
228
229    let payload = PlanPayload {
230        query: request.query.clone(),
231        signature_tokens: tokens,
232        steps: request.steps,
233        chunk_ids: request.chunk_ids,
234        outcome_score: request.outcome_score.clamp(0.0, 1.0),
235    };
236    let metadata = serde_json::json!({ PLAN_METADATA_KEY: payload });
237
238    // Persist via the ordinary write-path so hashing, embedding, audit
239    // events, encryption and RBAC scope all apply uniformly.
240    let mut rr = RememberRequest::new(request.query);
241    rr.agent_id = request.agent_id;
242    rr.org_id = request.org_id;
243    rr.scope = Some(request.scope.unwrap_or(Scope::Private));
244    rr.memory_type = Some(MemoryType::Procedural);
245    rr.importance = Some(request.outcome_score.clamp(0.0, 1.0));
246    rr.tags = Some(vec![EXPERIENCE_PLAN_TAG.to_string()]);
247    rr.metadata = Some(metadata);
248    rr.source_type = Some(SourceType::System);
249
250    let resp = engine.remember(rr).await?;
251    Ok(RememberPlanResponse {
252        id: Some(resp.id),
253        signature,
254        stored: true,
255    })
256}
257
258/// `RECALL_PLAN` — return the best replayable plan for a query, or a miss.
259pub async fn execute_recall_plan(
260    engine: &MnemoEngine,
261    request: RecallPlanRequest,
262) -> Result<RecallPlanResponse> {
263    if !engine.experience_memory_enabled {
264        return Ok(RecallPlanResponse {
265            plan: None,
266            candidates_considered: 0,
267        });
268    }
269    let agent_id = request
270        .agent_id
271        .clone()
272        .unwrap_or_else(|| engine.default_agent_id.clone());
273    let threshold = request
274        .similarity_threshold
275        .unwrap_or(DEFAULT_SIMILARITY_THRESHOLD);
276    let query_sig = signature_tokens(&request.query);
277
278    // List plan records by reserved tag (no agent filter — RBAC is
279    // applied per-record below so shared/public plans are visible too).
280    let filter = MemoryFilter {
281        agent_id: None,
282        memory_type: None,
283        scope: None,
284        tags: Some(vec![EXPERIENCE_PLAN_TAG.to_string()]),
285        min_importance: None,
286        org_id: request.org_id.clone(),
287        thread_id: None,
288        include_deleted: false,
289    };
290    let records = engine
291        .storage
292        .list_memories(&filter, PLAN_SCAN_LIMIT, 0)
293        .await?;
294
295    let mut considered = 0usize;
296    let mut best: Option<CachedPlan> = None;
297    for record in &records {
298        if record.is_deleted() || record.quarantined {
299            continue;
300        }
301        if !plan_visible_to(engine, record, &agent_id).await {
302            continue;
303        }
304        let Some(raw) = record.metadata.get(PLAN_METADATA_KEY) else {
305            continue;
306        };
307        let Ok(payload) = serde_json::from_value::<PlanPayload>(raw.clone()) else {
308            continue;
309        };
310        considered += 1;
311        let sim = jaccard(&query_sig, &payload.signature_tokens);
312        if sim >= threshold && best.as_ref().map(|b| sim > b.similarity).unwrap_or(true) {
313            best = Some(CachedPlan {
314                id: record.id,
315                query: payload.query,
316                steps: payload.steps,
317                chunk_ids: payload.chunk_ids,
318                outcome_score: payload.outcome_score,
319                similarity: sim,
320            });
321        }
322    }
323
324    Ok(RecallPlanResponse {
325        plan: best,
326        candidates_considered: considered,
327    })
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    #[test]
335    fn signature_is_order_independent_and_normalized() {
336        let a = signature_tokens("How do I Reset my PASSWORD?");
337        let b = signature_tokens("password reset — how to do it");
338        // Shared significant tokens regardless of order/case/punctuation.
339        assert!(a.contains(&"reset".to_string()));
340        assert!(a.contains(&"password".to_string()));
341        assert!(jaccard(&a, &b) > 0.0);
342        // Sorted + deduped.
343        let mut sorted = a.clone();
344        sorted.sort();
345        sorted.dedup();
346        assert_eq!(a, sorted);
347    }
348
349    #[test]
350    fn jaccard_bounds() {
351        let a = signature_tokens("alpha bravo charlie");
352        assert_eq!(jaccard(&a, &a), 1.0);
353        let disjoint = signature_tokens("xray yankee zulu");
354        assert_eq!(jaccard(&a, &disjoint), 0.0);
355        assert_eq!(jaccard(&[], &[]), 0.0);
356    }
357}