use crate::graph::{MemKind, MemNode, MemoryGraph};
use car_eventlog::{Event, EventKind};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
const DEFAULT_MAX_CANDIDATES: usize = 8;
const MIN_INJECT_SCORE: f64 = 1.10;
const STATUS_ID_PREFIX: &str = "proactive-status:";
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ProactiveMemoryEntryKind {
Status,
Knowledge,
Procedural,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProactiveStatus {
pub id: String,
pub body: String,
#[serde(default)]
pub tenant_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProactiveMemorySave {
#[serde(default)]
pub id: Option<String>,
pub subject: String,
pub body: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub confidence: Option<String>,
#[serde(default)]
pub tenant_id: Option<String>,
#[serde(default)]
pub is_constraint: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProactiveMemorySaved {
pub id: String,
pub kind: ProactiveMemoryEntryKind,
pub fact_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProactiveMemoryDeleted {
pub id: String,
pub deleted: bool,
pub kind: Option<ProactiveMemoryEntryKind>,
pub fact_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProactiveMaintenanceRequest {
#[serde(default = "default_maintenance_recent")]
pub max_recent: usize,
#[serde(default)]
pub tenant_id: Option<String>,
}
impl Default for ProactiveMaintenanceRequest {
fn default() -> Self {
Self {
max_recent: default_maintenance_recent(),
tenant_id: None,
}
}
}
fn default_maintenance_recent() -> usize {
32
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProactiveMaintenanceReport {
pub trigger: ProactiveMemoryTrigger,
pub saved: Vec<ProactiveMemorySaved>,
pub skipped_existing: usize,
#[serde(default)]
pub status: Option<ProactiveStatus>,
}
pub fn status_id(tenant_id: Option<&str>) -> String {
format!(
"{STATUS_ID_PREFIX}{}",
tenant_id.filter(|s| !s.is_empty()).unwrap_or("global")
)
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProactiveMemoryTrigger {
#[serde(default)]
pub repeated_failures: u32,
#[serde(default)]
pub tool_error: bool,
#[serde(default)]
pub explicit_uncertainty: bool,
#[serde(default)]
pub high_risk_action: bool,
#[serde(default)]
pub context_shift: bool,
}
impl ProactiveMemoryTrigger {
fn has_pressure(&self) -> bool {
self.repeated_failures > 0
|| self.tool_error
|| self.explicit_uncertainty
|| self.high_risk_action
|| self.context_shift
}
pub fn merge(&mut self, other: ProactiveMemoryTrigger) {
self.repeated_failures = self.repeated_failures.max(other.repeated_failures);
self.tool_error |= other.tool_error;
self.explicit_uncertainty |= other.explicit_uncertainty;
self.high_risk_action |= other.high_risk_action;
self.context_shift |= other.context_shift;
}
}
pub fn trigger_from_events(events: &[Event], max_recent: usize) -> ProactiveMemoryTrigger {
let mut trigger = ProactiveMemoryTrigger::default();
let start = events.len().saturating_sub(max_recent.max(1));
for ev in &events[start..] {
match ev.kind {
EventKind::ActionFailed => {
trigger.tool_error = true;
trigger.repeated_failures = trigger.repeated_failures.saturating_add(1);
}
EventKind::ActionRetrying | EventKind::ReplanExhausted => {
trigger.repeated_failures = trigger.repeated_failures.saturating_add(1);
}
EventKind::GoalEvaluated => {
let met = ev
.data
.get("met")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let grounded = ev
.data
.get("grounded")
.and_then(|v| v.as_bool())
.unwrap_or(true);
if met && !grounded {
trigger.explicit_uncertainty = true;
trigger.context_shift = true;
}
}
EventKind::TurnCompleted => {
let decision = ev
.data
.get("decision")
.and_then(|v| v.as_str())
.unwrap_or("");
let truncated = ev
.data
.get("was_truncated")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if truncated || matches!(decision, "max_turns" | "stalled") {
trigger.explicit_uncertainty = true;
trigger.context_shift = true;
}
}
_ => {}
}
}
trigger
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProactiveMemoryRequest {
#[serde(default)]
pub query: String,
#[serde(default)]
pub recent: Vec<String>,
#[serde(default)]
pub trigger: ProactiveMemoryTrigger,
#[serde(default)]
pub force: bool,
#[serde(default = "default_max_candidates")]
pub max_candidates: usize,
#[serde(default)]
pub tenant_id: Option<String>,
}
impl Default for ProactiveMemoryRequest {
fn default() -> Self {
Self {
query: String::new(),
recent: Vec::new(),
trigger: ProactiveMemoryTrigger::default(),
force: false,
max_candidates: DEFAULT_MAX_CANDIDATES,
tenant_id: None,
}
}
}
fn default_max_candidates() -> usize {
DEFAULT_MAX_CANDIDATES
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum InterventionKind {
Requirement,
Environment,
Procedural,
Diagnostic,
OpenSubgoal,
Knowledge,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ProactiveMemoryCandidate {
pub id: String,
pub kind: InterventionKind,
pub subject: String,
pub body: String,
pub score: f64,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProactiveMemoryBankSummary {
pub status_like: usize,
pub knowledge: usize,
pub procedural: usize,
pub open_subgoals: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProactiveEvaluationCase {
pub id: String,
#[serde(default)]
pub request: ProactiveMemoryRequest,
#[serde(default)]
pub relevant_fact_ids: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProactiveEvaluationRequest {
#[serde(default)]
pub cases: Vec<ProactiveEvaluationCase>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ProactiveEvaluationReport {
pub cases: usize,
pub selective: ProactiveEvaluationMetrics,
pub always_inject: ProactiveEvaluationMetrics,
pub passive_retrieval: ProactiveEvaluationMetrics,
pub no_memory: ProactiveEvaluationMetrics,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ProactiveEvaluationMetrics {
pub mode: ProactiveEvaluationMode,
pub cases: usize,
pub interventions: usize,
pub true_positives: usize,
pub false_positives: usize,
pub false_negatives: usize,
pub precision: f64,
pub recall: f64,
pub interruption_rate: f64,
pub avg_candidates_exposed: f64,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ProactiveEvaluationMode {
Selective,
AlwaysInject,
PassiveRetrieval,
NoMemory,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "decision", rename_all = "snake_case")]
pub enum ProactiveMemoryDecision {
Inject {
reminder: String,
selected: ProactiveMemoryCandidate,
candidates: Vec<ProactiveMemoryCandidate>,
bank: ProactiveMemoryBankSummary,
},
Silent {
reason: String,
candidates: Vec<ProactiveMemoryCandidate>,
bank: ProactiveMemoryBankSummary,
},
}
impl ProactiveMemoryDecision {
pub fn selected_fact_id(&self) -> Option<&str> {
match self {
ProactiveMemoryDecision::Inject { selected, .. } => Some(selected.id.as_str()),
ProactiveMemoryDecision::Silent { .. } => None,
}
}
}
pub fn decide_intervention(
graph: &MemoryGraph,
request: &ProactiveMemoryRequest,
) -> ProactiveMemoryDecision {
let bank = summarize_bank(graph, request.tenant_id.as_deref());
let mut candidates = score_candidates(graph, request);
candidates.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.id.cmp(&b.id))
});
candidates.truncate(request.max_candidates.clamp(1, 32));
let Some(best) = candidates.first().cloned() else {
return ProactiveMemoryDecision::Silent {
reason: "no memory candidates matched the next action".to_string(),
candidates,
bank,
};
};
if request.force || should_inject(&best, request) {
let reminder = format!(
"Memory reminder ({:?}, {}): {}: {}",
best.kind, best.id, best.subject, best.body
);
ProactiveMemoryDecision::Inject {
reminder,
selected: best,
candidates,
bank,
}
} else {
ProactiveMemoryDecision::Silent {
reason: "best memory was not strong enough to interrupt the next action".to_string(),
candidates,
bank,
}
}
}
pub fn evaluate_interventions(
graph: &MemoryGraph,
request: &ProactiveEvaluationRequest,
) -> ProactiveEvaluationReport {
let selective = evaluate_mode(graph, &request.cases, ProactiveEvaluationMode::Selective);
let always_inject = evaluate_mode(graph, &request.cases, ProactiveEvaluationMode::AlwaysInject);
let passive_retrieval = evaluate_mode(
graph,
&request.cases,
ProactiveEvaluationMode::PassiveRetrieval,
);
let no_memory = evaluate_mode(graph, &request.cases, ProactiveEvaluationMode::NoMemory);
ProactiveEvaluationReport {
cases: request.cases.len(),
selective,
always_inject,
passive_retrieval,
no_memory,
}
}
fn evaluate_mode(
graph: &MemoryGraph,
cases: &[ProactiveEvaluationCase],
mode: ProactiveEvaluationMode,
) -> ProactiveEvaluationMetrics {
let mut interventions = 0usize;
let mut true_positives = 0usize;
let mut false_positives = 0usize;
let mut false_negatives = 0usize;
let mut candidates_exposed = 0usize;
for case in cases {
let relevant: HashSet<&str> = case.relevant_fact_ids.iter().map(String::as_str).collect();
let expected_positive = !relevant.is_empty();
let mut candidates = score_candidates(graph, &case.request);
candidates.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.id.cmp(&b.id))
});
candidates.truncate(case.request.max_candidates.clamp(1, 32));
match mode {
ProactiveEvaluationMode::Selective => {
let decision = decide_intervention(graph, &case.request);
let selected = decision.selected_fact_id();
if let Some(fid) = selected {
interventions += 1;
candidates_exposed += 1;
if relevant.contains(fid) {
true_positives += 1;
} else {
false_positives += 1;
}
} else if expected_positive {
false_negatives += 1;
}
}
ProactiveEvaluationMode::AlwaysInject => {
if let Some(best) = candidates.first() {
interventions += 1;
candidates_exposed += 1;
if relevant.contains(best.id.as_str()) {
true_positives += 1;
} else {
false_positives += 1;
}
} else if expected_positive {
false_negatives += 1;
}
}
ProactiveEvaluationMode::PassiveRetrieval => {
if candidates.is_empty() {
if expected_positive {
false_negatives += 1;
}
continue;
}
interventions += 1;
candidates_exposed += candidates.len();
if candidates.iter().any(|c| relevant.contains(c.id.as_str())) {
true_positives += 1;
} else if expected_positive {
false_negatives += 1;
} else {
false_positives += 1;
}
}
ProactiveEvaluationMode::NoMemory => {
if expected_positive {
false_negatives += 1;
}
}
}
}
let precision = ratio(true_positives, true_positives + false_positives);
let recall = ratio(true_positives, true_positives + false_negatives);
ProactiveEvaluationMetrics {
mode,
cases: cases.len(),
interventions,
true_positives,
false_positives,
false_negatives,
precision,
recall,
interruption_rate: ratio(interventions, cases.len()),
avg_candidates_exposed: ratio(candidates_exposed, cases.len()),
}
}
fn should_inject(candidate: &ProactiveMemoryCandidate, request: &ProactiveMemoryRequest) -> bool {
if candidate.score >= MIN_INJECT_SCORE {
return true;
}
request.trigger.has_pressure()
&& candidate.score >= 0.75
&& matches!(
candidate.kind,
InterventionKind::Requirement
| InterventionKind::Procedural
| InterventionKind::Diagnostic
| InterventionKind::OpenSubgoal
)
}
fn score_candidates(
graph: &MemoryGraph,
request: &ProactiveMemoryRequest,
) -> Vec<ProactiveMemoryCandidate> {
let query_text = std::iter::once(request.query.as_str())
.chain(request.recent.iter().map(String::as_str))
.collect::<Vec<_>>()
.join(" ");
let query_terms = terms(&query_text);
let empty_query = query_terms.is_empty();
graph
.valid_facts()
.into_iter()
.filter(|(nix, _)| graph.partition_of(*nix).is_project())
.filter(|(_, node)| !node.value.contains("[RESTRICTED:"))
.filter(|(_, node)| match request.tenant_id.as_deref() {
Some(t) => node.metadata.tenant_id.as_deref() == Some(t),
None => node.metadata.tenant_id.is_none(),
})
.filter_map(|(_, node)| {
let kind = classify(node);
let mut score = base_score(node, kind, &request.trigger);
let lexical = lexical_overlap(node, &query_terms);
score += lexical;
if lexical == 0.0 && !request.trigger.has_pressure() {
score -= 0.35;
}
if empty_query && !request.trigger.has_pressure() && !node.is_constraint {
score -= 0.35;
}
score -= node.metadata.staleness_ratio().min(1.0) * 0.45;
if score <= 0.0 {
return None;
}
Some(ProactiveMemoryCandidate {
id: node
.fact_id
.clone()
.unwrap_or_else(|| format!("{}:{}", node.key, node.created_at.timestamp())),
kind,
subject: node.key.clone(),
body: node.value.clone(),
score: round_score(score),
reason: reason(node, kind, lexical, &request.trigger),
})
})
.collect()
}
fn summarize_bank(graph: &MemoryGraph, tenant_id: Option<&str>) -> ProactiveMemoryBankSummary {
let mut summary = ProactiveMemoryBankSummary {
status_like: 0,
knowledge: 0,
procedural: 0,
open_subgoals: 0,
};
for (_, node) in graph.valid_facts() {
if !matches_tenant(node, tenant_id) {
continue;
}
let kind = classify(node);
match kind {
InterventionKind::Procedural | InterventionKind::Diagnostic => summary.procedural += 1,
InterventionKind::OpenSubgoal => summary.open_subgoals += 1,
_ => summary.knowledge += 1,
}
if is_status_like(node) {
summary.status_like += 1;
}
}
summary
}
fn matches_tenant(node: &MemNode, tenant_id: Option<&str>) -> bool {
match tenant_id {
Some(t) => node.metadata.tenant_id.as_deref() == Some(t),
None => node.metadata.tenant_id.is_none(),
}
}
fn classify(node: &MemNode) -> InterventionKind {
let hay = marker_text(node);
if node.is_constraint || contains_any(&hay, &["requirement", "policy", "constraint", "must"]) {
InterventionKind::Requirement
} else if contains_any(
&hay,
&["open_subgoal", "subgoal", "todo", "risk", "blocked"],
) {
InterventionKind::OpenSubgoal
} else if contains_any(&hay, &["diagnosis", "root_cause", "error_pattern"]) {
InterventionKind::Diagnostic
} else if contains_any(
&hay,
&[
"procedural",
"attempt",
"failed",
"failure",
"worked",
"fix",
"gotcha",
"anti_pattern",
],
) {
InterventionKind::Procedural
} else if matches!(node.kind, MemKind::Environment)
|| contains_any(&hay, &["environment", "path", "runtime", "tool"])
{
InterventionKind::Environment
} else {
InterventionKind::Knowledge
}
}
fn base_score(node: &MemNode, kind: InterventionKind, trigger: &ProactiveMemoryTrigger) -> f64 {
let mut score = match kind {
InterventionKind::Requirement => 0.95,
InterventionKind::Procedural => 0.70,
InterventionKind::Diagnostic => 0.78,
InterventionKind::OpenSubgoal => 0.74,
InterventionKind::Environment => 0.58,
InterventionKind::Knowledge => 0.35,
};
if node.is_constraint {
score += 0.35;
}
if trigger.high_risk_action && matches!(kind, InterventionKind::Requirement) {
score += 0.35;
}
if (trigger.tool_error || trigger.repeated_failures > 0)
&& matches!(
kind,
InterventionKind::Procedural
| InterventionKind::Diagnostic
| InterventionKind::Environment
)
{
score += 0.35 + (trigger.repeated_failures.min(3) as f64 * 0.08);
}
if trigger.explicit_uncertainty
&& matches!(
kind,
InterventionKind::Diagnostic | InterventionKind::Knowledge
)
{
score += 0.15;
}
if trigger.context_shift
&& matches!(
kind,
InterventionKind::OpenSubgoal | InterventionKind::Requirement
)
{
score += 0.20;
}
score += (node.metadata.helpfulness_ratio() - 0.5) * 0.20;
score
}
fn lexical_overlap(node: &MemNode, query_terms: &HashSet<String>) -> f64 {
if query_terms.is_empty() {
return 0.0;
}
let text_terms = terms(&format!(
"{} {} {} {}",
node.key,
node.value,
node.metadata.category,
node.metadata.tags.join(" ")
));
let overlap = text_terms.intersection(query_terms).count();
(overlap as f64 * 0.12).min(0.72)
}
fn reason(
node: &MemNode,
kind: InterventionKind,
lexical: f64,
trigger: &ProactiveMemoryTrigger,
) -> String {
let mut parts = vec![format!("{kind:?} memory")];
if node.is_constraint {
parts.push("active constraint".to_string());
}
if lexical > 0.0 {
parts.push("matches recent trajectory".to_string());
}
if trigger.has_pressure() {
parts.push("trigger pressure present".to_string());
}
parts.join("; ")
}
fn is_status_like(node: &MemNode) -> bool {
contains_any(
&marker_text(node),
&["status", "progress", "risk", "open_subgoal", "blocked"],
)
}
fn marker_text(node: &MemNode) -> String {
format!(
"{} {} {} {}",
node.key.to_lowercase(),
node.metadata.category.to_lowercase(),
node.metadata.tags.join(" ").to_lowercase(),
node.value.to_lowercase()
)
}
fn contains_any(text: &str, needles: &[&str]) -> bool {
needles.iter().any(|needle| text.contains(needle))
}
fn terms(text: &str) -> HashSet<String> {
text.split(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-')
.filter_map(|s| {
let s = s.trim().to_ascii_lowercase();
if s.len() >= 3 {
Some(s)
} else {
None
}
})
.collect()
}
fn round_score(score: f64) -> f64 {
(score * 1000.0).round() / 1000.0
}
fn ratio(num: usize, den: usize) -> f64 {
if den == 0 {
0.0
} else {
round_score(num as f64 / den as f64)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{FactMetadata, MemgineEngine};
use car_eventlog::Event;
use chrono::Utc;
use serde_json::json;
use std::collections::HashMap;
fn ev(kind: EventKind) -> Event {
Event {
kind,
action_id: Some("a".into()),
proposal_id: Some("p".into()),
data: HashMap::new(),
timestamp: Utc::now(),
prev_hash: None,
hash: None,
}
}
fn fact(engine: &mut MemgineEngine, id: &str, key: &str, value: &str, constraint: bool) {
engine.ingest_fact(
id,
key,
value,
"test",
"test",
Utc::now(),
"global",
None,
vec![],
constraint,
);
}
#[test]
fn injection_and_deliberate_recall_use_separate_counters() {
let mut engine = MemgineEngine::new(None);
fact(
&mut engine,
"policy-1",
"refund policy",
"Must verify account status before issuing compensation",
true,
);
engine.proactive_intervention(&ProactiveMemoryRequest {
query: "issue compensation".into(),
trigger: ProactiveMemoryTrigger {
high_risk_action: true,
..Default::default()
},
..Default::default()
});
let (_, node) = engine.graph.get_by_fact_id("policy-1").expect("fact");
assert_eq!(
node.metadata.usage_count, 0,
"proactive injection incremented the DELIBERATE-recall counter, which feeds ranking"
);
assert_eq!(
node.metadata.proactive_injections, 1,
"proactive injection was not recorded on its own observability counter"
);
engine.record_fact_usage("policy-1");
let (_, node) = engine.graph.get_by_fact_id("policy-1").expect("fact");
assert_eq!(node.metadata.usage_count, 1);
assert_eq!(node.metadata.proactive_injections, 1);
}
#[test]
fn injection_does_not_permanently_demote_the_injected_fact() {
let mut engine = MemgineEngine::new(None);
fact(
&mut engine,
"policy-1",
"refund policy",
"Must verify account status before issuing compensation",
true,
);
let trigger = ProactiveMemoryTrigger {
high_risk_action: true,
..Default::default()
};
let (_, node) = engine.graph.get_by_fact_id("policy-1").expect("fact");
let kind = classify(node);
let before = base_score(node, kind, &trigger);
engine.record_fact_usage("policy-1");
let (_, node) = engine.graph.get_by_fact_id("policy-1").expect("fact");
let after = base_score(node, kind, &trigger);
assert!(
after >= before - f64::EPSILON,
"being injected once dropped this fact's proactive score from {before} to {after} \
(delta {:.3}); injection must not demote the fact it used",
after - before
);
}
#[test]
fn injects_relevant_requirement_before_high_risk_action() {
let mut engine = MemgineEngine::new(None);
fact(
&mut engine,
"policy-1",
"refund policy",
"Must verify account status before issuing compensation",
true,
);
let decision = decide_intervention(
&engine.graph,
&ProactiveMemoryRequest {
query: "issue compensation".into(),
trigger: ProactiveMemoryTrigger {
high_risk_action: true,
..Default::default()
},
..Default::default()
},
);
match decision {
ProactiveMemoryDecision::Inject {
selected, reminder, ..
} => {
assert_eq!(selected.id, "policy-1");
assert!(reminder.contains("Must verify account status"));
}
other => panic!("expected injection, got {other:?}"),
}
}
#[test]
fn remains_silent_when_memory_is_weak_and_no_trigger_pressure() {
let mut engine = MemgineEngine::new(None);
fact(
&mut engine,
"note-1",
"theme",
"User likes compact dashboards",
false,
);
let decision = decide_intervention(
&engine.graph,
&ProactiveMemoryRequest {
query: "run cargo test".into(),
..Default::default()
},
);
assert!(matches!(decision, ProactiveMemoryDecision::Silent { .. }));
}
#[test]
fn procedural_memory_wins_on_repeated_failures() {
let mut engine = MemgineEngine::new(None);
fact(&mut engine, "env-1", "cwd", "Project is in /repo", false);
fact(
&mut engine,
"attempt-1",
"failed cargo test",
"Previous attempt failed because the package name was wrong",
false,
);
if let Some((nix, _)) = engine.graph.get_by_fact_id("attempt-1") {
engine.graph.inner.node_weight_mut(nix).unwrap().metadata = FactMetadata {
category: "attempt".into(),
tags: vec!["failed".into(), "procedural".into()],
..Default::default()
};
}
let decision = decide_intervention(
&engine.graph,
&ProactiveMemoryRequest {
query: "cargo test package".into(),
trigger: ProactiveMemoryTrigger {
repeated_failures: 2,
tool_error: true,
..Default::default()
},
..Default::default()
},
);
match decision {
ProactiveMemoryDecision::Inject { selected, .. } => {
assert_eq!(selected.id, "attempt-1");
assert_eq!(selected.kind, InterventionKind::Procedural);
}
other => panic!("expected procedural injection, got {other:?}"),
}
}
#[test]
fn save_procedural_stamps_metadata_for_intervention() {
let mut engine = MemgineEngine::new(None);
let saved = engine.save_proactive_procedural(ProactiveMemorySave {
id: Some("attempt-42".into()),
subject: "cargo test attempt".into(),
body: "Failed because the package selector was wrong".into(),
tags: vec!["failed".into()],
confidence: Some("high".into()),
tenant_id: None,
is_constraint: false,
});
assert_eq!(saved.kind, ProactiveMemoryEntryKind::Procedural);
let (_, node) = engine.graph.get_by_fact_id("attempt-42").unwrap();
assert_eq!(node.metadata.category, "procedural");
assert!(node.metadata.tags.contains(&"procedural".to_string()));
assert!(node.metadata.tags.contains(&"failed".to_string()));
let decision = engine.proactive_intervention(&ProactiveMemoryRequest {
query: "cargo test package".into(),
trigger: ProactiveMemoryTrigger {
tool_error: true,
repeated_failures: 1,
..Default::default()
},
..Default::default()
});
assert!(matches!(
decision,
ProactiveMemoryDecision::Inject { selected, .. }
if selected.id == "attempt-42"
&& selected.kind == InterventionKind::Procedural
));
}
#[test]
fn private_status_does_not_enter_action_context() {
let mut engine = MemgineEngine::new(None);
let status = engine.update_proactive_status(
"Open risk: the current branch still needs verification",
None,
);
assert_eq!(status.id, "proactive-status:global");
assert!(engine
.proactive_status(None)
.unwrap()
.body
.contains("Open risk"));
let context = engine.build_context("verification");
assert!(
!context.contains("Open risk"),
"private proactive status must not leak into action context: {context}"
);
}
#[test]
fn delete_proactive_memory_removes_graph_fact_and_status() {
let mut engine = MemgineEngine::new(None);
engine.save_proactive_knowledge(ProactiveMemorySave {
id: Some("knowledge-1".into()),
subject: "policy".into(),
body: "Must verify before shipping".into(),
tags: vec![],
confidence: None,
tenant_id: None,
is_constraint: true,
});
assert_eq!(engine.valid_fact_count(), 1);
let deleted = engine.delete_proactive_memory("knowledge-1");
assert!(deleted.deleted);
assert_eq!(deleted.kind, Some(ProactiveMemoryEntryKind::Knowledge));
assert_eq!(engine.valid_fact_count(), 0);
engine.update_proactive_status("pending", Some("acme".into()));
let status_deleted = engine.delete_proactive_memory("proactive-status:acme");
assert!(status_deleted.deleted);
assert_eq!(status_deleted.kind, Some(ProactiveMemoryEntryKind::Status));
assert!(engine.proactive_status(Some("acme")).is_none());
}
#[test]
fn maintenance_saves_recent_failure_once_and_marks_status_private() {
let mut engine = MemgineEngine::new(None);
let mut failed = ev(EventKind::ActionFailed);
failed.action_id = Some("cargo-test".into());
failed.proposal_id = Some("verify".into());
failed.data.insert("tool".into(), json!("cargo"));
failed
.data
.insert("error".into(), json!("package selector did not match"));
let report = engine.maintain_proactive_memory_from_events(
&[failed.clone()],
&ProactiveMaintenanceRequest::default(),
);
assert_eq!(report.saved.len(), 1);
assert_eq!(report.saved[0].kind, ProactiveMemoryEntryKind::Procedural);
assert!(report.trigger.tool_error);
assert!(report.status.is_some());
let context = engine.build_context("package selector did not match");
assert!(
!context.contains("Recent trajectory pressure"),
"maintenance status is private and must not leak into action context"
);
let second = engine.maintain_proactive_memory_from_events(
&[failed],
&ProactiveMaintenanceRequest::default(),
);
assert!(second.saved.is_empty());
assert_eq!(second.skipped_existing, 1);
}
#[test]
fn maintenance_output_can_drive_intervention_selection() {
let mut engine = MemgineEngine::new(None);
let mut failed = ev(EventKind::ActionFailed);
failed.action_id = Some("pytest".into());
failed.proposal_id = Some("verify".into());
failed.data.insert("tool".into(), json!("pytest"));
failed
.data
.insert("error".into(), json!("missing dependency"));
let report = engine.maintain_proactive_memory_from_events(
&[failed],
&ProactiveMaintenanceRequest::default(),
);
assert_eq!(report.saved.len(), 1);
let decision = engine.proactive_intervention(&ProactiveMemoryRequest {
query: "run pytest verification".into(),
trigger: report.trigger,
..Default::default()
});
assert!(matches!(
decision,
ProactiveMemoryDecision::Inject { reminder, selected, .. }
if matches!(selected.kind, InterventionKind::Procedural | InterventionKind::Diagnostic)
&& reminder.contains("missing dependency")
));
}
#[test]
fn evaluation_compares_selective_against_baselines() {
let mut engine = MemgineEngine::new(None);
fact(
&mut engine,
"policy-ship",
"shipping policy",
"Must run verification before shipping",
true,
);
fact(
&mut engine,
"dashboard-note",
"dashboard theme",
"User likes compact dashboard density",
false,
);
let report = engine.evaluate_proactive_memory(&ProactiveEvaluationRequest {
cases: vec![
ProactiveEvaluationCase {
id: "ship".into(),
request: ProactiveMemoryRequest {
query: "ship release".into(),
trigger: ProactiveMemoryTrigger {
high_risk_action: true,
..Default::default()
},
..Default::default()
},
relevant_fact_ids: vec!["policy-ship".into()],
},
ProactiveEvaluationCase {
id: "unrelated".into(),
request: ProactiveMemoryRequest {
query: "summarize meeting transcript".into(),
..Default::default()
},
relevant_fact_ids: vec![],
},
],
});
assert_eq!(report.cases, 2);
assert_eq!(report.selective.true_positives, 1);
assert_eq!(report.selective.false_positives, 0);
assert_eq!(report.selective.false_negatives, 0);
assert_eq!(report.selective.precision, 1.0);
assert_eq!(report.selective.recall, 1.0);
assert!(
report.always_inject.false_positives >= 1,
"always-inject should interrupt on at least one irrelevant case"
);
assert_eq!(report.no_memory.false_negatives, 1);
assert!(
report.passive_retrieval.avg_candidates_exposed
> report.selective.avg_candidates_exposed
);
}
#[test]
fn trigger_from_recent_events_detects_failure_pressure() {
let trigger = trigger_from_events(
&[
ev(EventKind::ActionFailed),
ev(EventKind::ActionRetrying),
ev(EventKind::ReplanExhausted),
],
8,
);
assert!(trigger.tool_error);
assert_eq!(trigger.repeated_failures, 3);
}
#[test]
fn trigger_from_events_detects_ungrounded_and_truncated_turns() {
let mut goal = ev(EventKind::GoalEvaluated);
goal.data.insert("met".into(), json!(true));
goal.data.insert("grounded".into(), json!(false));
let mut turn = ev(EventKind::TurnCompleted);
turn.data.insert("decision".into(), json!("max_turns"));
turn.data.insert("was_truncated".into(), json!(false));
let trigger = trigger_from_events(&[goal, turn], 8);
assert!(trigger.explicit_uncertainty);
assert!(trigger.context_shift);
}
#[test]
fn trigger_from_events_only_reads_recent_window() {
let old = ev(EventKind::ActionFailed);
let recent = ev(EventKind::ActionSucceeded);
let trigger = trigger_from_events(&[old, recent], 1);
assert!(!trigger.tool_error);
assert_eq!(trigger.repeated_failures, 0);
}
}