use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::{Error, Result};
use crate::model::memory::{MemoryType, Scope, SourceType};
use crate::query::MnemoEngine;
use crate::query::remember::RememberRequest;
use crate::storage::MemoryFilter;
pub const EXPERIENCE_PLAN_TAG: &str = "__experience_plan__";
pub const PLAN_METADATA_KEY: &str = "experience_plan";
pub const DEFAULT_SIMILARITY_THRESHOLD: f32 = 0.7;
pub const DEFAULT_SUCCESS_THRESHOLD: f32 = 0.5;
const PLAN_SCAN_LIMIT: usize = 1000;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PlanPayload {
pub query: String,
pub signature_tokens: Vec<String>,
pub steps: Vec<String>,
pub chunk_ids: Vec<String>,
pub outcome_score: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RememberPlanRequest {
pub query: String,
pub steps: Vec<String>,
pub chunk_ids: Vec<String>,
pub outcome_score: f32,
pub agent_id: Option<String>,
pub scope: Option<Scope>,
pub org_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RememberPlanResponse {
pub id: Option<Uuid>,
pub signature: String,
pub stored: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecallPlanRequest {
pub query: String,
pub agent_id: Option<String>,
pub org_id: Option<String>,
pub similarity_threshold: Option<f32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedPlan {
pub id: Uuid,
pub query: String,
pub steps: Vec<String>,
pub chunk_ids: Vec<String>,
pub outcome_score: f32,
pub similarity: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecallPlanResponse {
pub plan: Option<CachedPlan>,
pub candidates_considered: usize,
}
pub fn signature_tokens(query: &str) -> Vec<String> {
let mut tokens: Vec<String> = query
.split(|c: char| !c.is_alphanumeric())
.filter(|t| t.chars().count() >= 3)
.map(|t| t.to_lowercase())
.collect();
tokens.sort();
tokens.dedup();
tokens
}
pub fn jaccard(a: &[String], b: &[String]) -> f32 {
if a.is_empty() && b.is_empty() {
return 0.0;
}
let mut intersection = 0usize;
for t in a {
if b.contains(t) {
intersection += 1;
}
}
let union = a.len() + b.len() - intersection;
if union == 0 {
0.0
} else {
intersection as f32 / union as f32
}
}
async fn plan_visible_to(
engine: &MnemoEngine,
record: &crate::model::memory::MemoryRecord,
agent_id: &str,
) -> bool {
match record.scope {
Scope::Public | Scope::Global => true,
Scope::Private => record.agent_id == agent_id,
Scope::Shared => {
record.agent_id == agent_id
|| engine
.storage
.check_permission(record.id, agent_id, crate::model::acl::Permission::Read)
.await
.unwrap_or(false)
}
}
}
pub async fn execute_remember_plan(
engine: &MnemoEngine,
request: RememberPlanRequest,
) -> Result<RememberPlanResponse> {
if !engine.experience_memory_enabled {
return Err(Error::Validation(
"experience memory mode is disabled; enable it with MnemoEngine::with_experience_memory()".to_string(),
));
}
let tokens = signature_tokens(&request.query);
let signature = tokens.join(" ");
let is_success = request.outcome_score >= DEFAULT_SUCCESS_THRESHOLD;
if !is_success {
return Ok(RememberPlanResponse {
id: None,
signature,
stored: false,
});
}
let payload = PlanPayload {
query: request.query.clone(),
signature_tokens: tokens,
steps: request.steps,
chunk_ids: request.chunk_ids,
outcome_score: request.outcome_score.clamp(0.0, 1.0),
};
let metadata = serde_json::json!({ PLAN_METADATA_KEY: payload });
let mut rr = RememberRequest::new(request.query);
rr.agent_id = request.agent_id;
rr.org_id = request.org_id;
rr.scope = Some(request.scope.unwrap_or(Scope::Private));
rr.memory_type = Some(MemoryType::Procedural);
rr.importance = Some(request.outcome_score.clamp(0.0, 1.0));
rr.tags = Some(vec![EXPERIENCE_PLAN_TAG.to_string()]);
rr.metadata = Some(metadata);
rr.source_type = Some(SourceType::System);
let resp = engine.remember(rr).await?;
Ok(RememberPlanResponse {
id: Some(resp.id),
signature,
stored: true,
})
}
pub async fn execute_recall_plan(
engine: &MnemoEngine,
request: RecallPlanRequest,
) -> Result<RecallPlanResponse> {
if !engine.experience_memory_enabled {
return Ok(RecallPlanResponse {
plan: None,
candidates_considered: 0,
});
}
let agent_id = request
.agent_id
.clone()
.unwrap_or_else(|| engine.default_agent_id.clone());
let threshold = request
.similarity_threshold
.unwrap_or(DEFAULT_SIMILARITY_THRESHOLD);
let query_sig = signature_tokens(&request.query);
let filter = MemoryFilter {
agent_id: None,
memory_type: None,
scope: None,
tags: Some(vec![EXPERIENCE_PLAN_TAG.to_string()]),
min_importance: None,
org_id: request.org_id.clone(),
thread_id: None,
include_deleted: false,
};
let records = engine
.storage
.list_memories(&filter, PLAN_SCAN_LIMIT, 0)
.await?;
let mut considered = 0usize;
let mut best: Option<CachedPlan> = None;
for record in &records {
if record.is_deleted() || record.quarantined {
continue;
}
if !plan_visible_to(engine, record, &agent_id).await {
continue;
}
let Some(raw) = record.metadata.get(PLAN_METADATA_KEY) else {
continue;
};
let Ok(payload) = serde_json::from_value::<PlanPayload>(raw.clone()) else {
continue;
};
considered += 1;
let sim = jaccard(&query_sig, &payload.signature_tokens);
if sim >= threshold && best.as_ref().map(|b| sim > b.similarity).unwrap_or(true) {
best = Some(CachedPlan {
id: record.id,
query: payload.query,
steps: payload.steps,
chunk_ids: payload.chunk_ids,
outcome_score: payload.outcome_score,
similarity: sim,
});
}
}
Ok(RecallPlanResponse {
plan: best,
candidates_considered: considered,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn signature_is_order_independent_and_normalized() {
let a = signature_tokens("How do I Reset my PASSWORD?");
let b = signature_tokens("password reset — how to do it");
assert!(a.contains(&"reset".to_string()));
assert!(a.contains(&"password".to_string()));
assert!(jaccard(&a, &b) > 0.0);
let mut sorted = a.clone();
sorted.sort();
sorted.dedup();
assert_eq!(a, sorted);
}
#[test]
fn jaccard_bounds() {
let a = signature_tokens("alpha bravo charlie");
assert_eq!(jaccard(&a, &a), 1.0);
let disjoint = signature_tokens("xray yankee zulu");
assert_eq!(jaccard(&a, &disjoint), 0.0);
assert_eq!(jaccard(&[], &[]), 0.0);
}
}