Skip to main content

zagens_topic_memory/
engine.rs

1//! Graph update, decay, and bridging.
2
3use std::collections::HashSet;
4
5use chrono::NaiveDate;
6
7use crate::extract::{detect_blocked_topics, detect_emotion, extract_topics};
8use crate::graph::{
9    BlockedPoint, CognitiveTrail, EmotionMode, GRAPH_SCHEMA_VERSION, PheromoneEdge, PheromoneGraph,
10    PheromoneNode,
11};
12
13const DECAY_RATE: f64 = 0.97;
14const DORMANT_THRESHOLD: f64 = 0.05;
15const STRENGTH_GAIN: f64 = 0.06;
16const MAX_TOPICS_PER_TURN: usize = 6;
17const MAX_HOT_NODES: usize = 12;
18const BRIDGE_WEIGHT_THRESHOLD: f64 = 2.5;
19const BRIDGE_INITIAL_WEIGHT: f64 = 0.4;
20
21/// Default: inject after this many completed runs.
22pub const DEFAULT_INJECT_INTERVAL_RUNS: u32 = 5;
23
24#[must_use]
25pub fn today_str() -> String {
26    chrono::Local::now()
27        .date_naive()
28        .format("%Y-%m-%d")
29        .to_string()
30}
31
32fn days_between(iso_a: &str, iso_b: &str) -> i64 {
33    let parse = |s: &str| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok();
34    match (parse(iso_a), parse(iso_b)) {
35        (Some(a), Some(b)) => (b - a).num_days().unsigned_abs() as i64,
36        _ => 0,
37    }
38}
39
40#[must_use]
41pub fn empty_graph() -> PheromoneGraph {
42    PheromoneGraph {
43        version: GRAPH_SCHEMA_VERSION.to_string(),
44        last_decay: today_str(),
45        nodes: Default::default(),
46        edges: Default::default(),
47        blocked_points: Vec::new(),
48        recent_emotions: None,
49        trails: None,
50    }
51}
52
53fn node_gain_multiplier(
54    graph: &PheromoneGraph,
55    emotion: EmotionMode,
56    topic: &str,
57    idx: usize,
58) -> f64 {
59    match emotion {
60        EmotionMode::Angry => {
61            if idx == 0 {
62                3.0
63            } else {
64                0.2
65            }
66        }
67        EmotionMode::Happy => 1.5,
68        EmotionMode::Sad => {
69            if graph.nodes.contains_key(topic) {
70                1.0
71            } else {
72                0.0
73            }
74        }
75        EmotionMode::Neutral => 1.0,
76    }
77}
78
79fn apply_transitive_bridging(graph: &mut PheromoneGraph, today: &str) {
80    let strong: Vec<(String, String)> = graph
81        .edges
82        .iter()
83        .filter(|(_, e)| e.weight >= BRIDGE_WEIGHT_THRESHOLD)
84        .filter_map(|(key, _)| {
85            let mut parts = key.split('→');
86            Some((parts.next()?.to_string(), parts.next()?.to_string()))
87        })
88        .collect();
89
90    let mut adj: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
91    for (a, b) in &strong {
92        adj.entry(a.clone()).or_default().push(b.clone());
93    }
94
95    for (a, b) in strong {
96        for c in adj.get(&b).into_iter().flatten() {
97            if c == &a {
98                continue;
99            }
100            let bridge_key = format!("{a}→{c}");
101            graph
102                .edges
103                .entry(bridge_key)
104                .or_insert_with(|| PheromoneEdge {
105                    weight: BRIDGE_INITIAL_WEIGHT,
106                    last_seen: today.to_string(),
107                });
108        }
109    }
110}
111
112/// Update graph after one conversation turn.
113#[must_use]
114pub fn update_graph(
115    graph: &PheromoneGraph,
116    user_text: &str,
117    assistant_text: &str,
118) -> PheromoneGraph {
119    let today = today_str();
120    let mut g = graph.clone();
121    let emotion = detect_emotion(user_text);
122
123    let user_topics = extract_topics(user_text);
124    let assistant_topics = extract_topics(assistant_text);
125    let mut all_topics: Vec<String> = user_topics
126        .iter()
127        .chain(assistant_topics.iter())
128        .cloned()
129        .collect::<HashSet<_>>()
130        .into_iter()
131        .collect();
132    all_topics.truncate(MAX_TOPICS_PER_TURN);
133
134    for (idx, topic) in all_topics.iter().enumerate() {
135        let mult = node_gain_multiplier(&g, emotion, topic, idx);
136        if mult == 0.0 {
137            continue;
138        }
139        let gain = STRENGTH_GAIN * mult;
140        if let Some(existing) = g.nodes.get_mut(topic) {
141            existing.count += 1;
142            existing.strength = (existing.strength + gain).min(1.0);
143            existing.last_seen = today.clone();
144            existing.dormant = Some(false);
145            if user_topics.contains(topic) && assistant_topics.contains(topic) {
146                existing.depth = (existing.depth + 0.2).min(5.0);
147            }
148        } else {
149            g.nodes.insert(
150                topic.clone(),
151                PheromoneNode {
152                    count: 1,
153                    last_seen: today.clone(),
154                    strength: gain,
155                    depth: if user_topics.contains(topic) {
156                        2.0
157                    } else {
158                        1.0
159                    },
160                    blocked: None,
161                    dormant: None,
162                },
163            );
164        }
165    }
166
167    for i in 0..user_topics.len() {
168        for j in (i + 1)..user_topics.len() {
169            let key = format!("{}→{}", user_topics[i], user_topics[j]);
170            match emotion {
171                EmotionMode::Angry => continue,
172                EmotionMode::Sad => {
173                    if let Some(e) = g.edges.get_mut(&key) {
174                        e.weight += 1.5;
175                        e.last_seen = today.clone();
176                    }
177                    continue;
178                }
179                EmotionMode::Happy | EmotionMode::Neutral => {
180                    if let Some(e) = g.edges.get_mut(&key) {
181                        e.weight += if emotion == EmotionMode::Happy {
182                            1.5
183                        } else {
184                            1.0
185                        };
186                        e.last_seen = today.clone();
187                    } else {
188                        g.edges.insert(
189                            key,
190                            PheromoneEdge {
191                                weight: 1.0,
192                                last_seen: today.clone(),
193                            },
194                        );
195                    }
196                }
197            }
198        }
199    }
200
201    let emotions = g.recent_emotions.get_or_insert_with(Vec::new);
202    emotions.push(emotion);
203    if emotions.len() > 10 {
204        emotions.remove(0);
205    }
206
207    let unique_user: Vec<String> = user_topics
208        .into_iter()
209        .collect::<HashSet<_>>()
210        .into_iter()
211        .collect();
212    if unique_user.len() >= 2 {
213        let trails = g.trails.get_or_insert_with(Vec::new);
214        trails.push(CognitiveTrail {
215            entry: unique_user.first().cloned().unwrap_or_default(),
216            exit: unique_user.last().cloned().unwrap_or_default(),
217            date: today.clone(),
218            emotion,
219        });
220        if trails.len() > 20 {
221            trails.remove(0);
222        }
223    }
224
225    apply_transitive_bridging(&mut g, &today);
226
227    for b_topic in detect_blocked_topics(user_text) {
228        if !g.blocked_points.iter().any(|b| b.node == b_topic) {
229            g.blocked_points.push(BlockedPoint {
230                node: b_topic.clone(),
231                context: user_text
232                    .chars()
233                    .take(80)
234                    .collect::<String>()
235                    .trim()
236                    .to_string(),
237                since: today.clone(),
238            });
239            if let Some(n) = g.nodes.get_mut(&b_topic) {
240                n.blocked = Some(true);
241            }
242        }
243    }
244
245    g
246}
247
248/// Apply time-based decay (at most once per calendar day).
249#[must_use]
250pub fn apply_decay(graph: &PheromoneGraph) -> PheromoneGraph {
251    let today = today_str();
252    let mut g = graph.clone();
253    let days = days_between(&g.last_decay, &today);
254    if days == 0 {
255        return g;
256    }
257    let factor = DECAY_RATE.powi(days as i32);
258
259    for node in g.nodes.values_mut() {
260        node.strength *= factor;
261        if node.strength < DORMANT_THRESHOLD {
262            node.dormant = Some(true);
263        }
264    }
265
266    g.edges.retain(|_, e| {
267        e.weight *= factor;
268        e.weight >= 0.5
269    });
270
271    g.last_decay = today;
272    g
273}
274
275pub struct GenerateMemorySectionOptions<'a> {
276    pub attribution: Option<&'a str>,
277}
278
279/// Escape user-derived text before embedding in a Markdown inline context
280/// (inside `**…**`, `: …`, etc.) to prevent prompt injection via markdown
281/// metacharacters.
282fn sanitize_markdown_inline(s: &str) -> String {
283    s.chars()
284        .map(|c| match c {
285            '*' | '_' | '`' | '|' | '[' | ']' | '<' | '>' | '#' | '~' | '{' | '}' | '/' => {
286                format!("\\{c}")
287            }
288            other => other.to_string(),
289        })
290        .collect()
291}
292
293/// Markdown section for system prompt injection.
294#[must_use]
295pub fn generate_memory_section(
296    graph: &PheromoneGraph,
297    options: Option<GenerateMemorySectionOptions<'_>>,
298) -> String {
299    let mut hot_nodes: Vec<_> = graph
300        .nodes
301        .iter()
302        .filter(|(_, n)| !n.dormant.unwrap_or(false) && n.strength >= 0.1)
303        .collect();
304    hot_nodes.sort_by(|a, b| {
305        b.1.strength
306            .partial_cmp(&a.1.strength)
307            .unwrap_or(std::cmp::Ordering::Equal)
308    });
309    hot_nodes.truncate(MAX_HOT_NODES);
310
311    let mut hot_edges: Vec<_> = graph.edges.iter().collect();
312    hot_edges.sort_by(|a, b| {
313        b.1.weight
314            .partial_cmp(&a.1.weight)
315            .unwrap_or(std::cmp::Ordering::Equal)
316    });
317    hot_edges.truncate(6);
318
319    let header_tail = options
320        .and_then(|o| o.attribution)
321        .map(|a| format!(" (auto-generated by {a} · do not edit this section)"))
322        .unwrap_or_else(|| " (auto-generated · do not edit this section)".to_string());
323
324    let mut lines = vec![
325        format!("## User Cognitive Map{header_tail}"),
326        String::new(),
327        "### Frequent Topics".to_string(),
328    ];
329
330    if hot_nodes.is_empty() {
331        lines.push("- (not enough data yet)".to_string());
332    } else {
333        for (topic, n) in hot_nodes {
334            let bar_len = (n.strength * 5.0).round() as usize;
335            let bar = "█".repeat(bar_len);
336            lines.push(format!(
337                "- **{}** {bar} (depth {}, {} mentions)",
338                sanitize_markdown_inline(topic),
339                n.depth.round() as i32,
340                n.count
341            ));
342        }
343    }
344
345    if !hot_edges.is_empty() {
346        lines.push(String::new());
347        lines.push("### Common Associations".to_string());
348        for (edge, _) in hot_edges {
349            lines.push(format!("- {}", edge.replace('→', " → ")));
350        }
351    }
352
353    let active_blocked: Vec<_> = graph.blocked_points.iter().rev().take(5).collect();
354    if !active_blocked.is_empty() {
355        lines.push(String::new());
356        lines.push("### Knowledge Boundaries (user indicated uncertainty)".to_string());
357        for b in active_blocked {
358            let ctx: String = b.context.chars().take(60).collect();
359            lines.push(format!(
360                "- **{}**: {}…",
361                sanitize_markdown_inline(&b.node),
362                sanitize_markdown_inline(&ctx)
363            ));
364        }
365    }
366
367    if let Some(trails) = &graph.trails {
368        let recent: Vec<_> = trails.iter().rev().take(8).collect();
369        if !recent.is_empty() {
370            lines.push(String::new());
371            lines.push("### Cognitive Trails (entry → exit per run)".to_string());
372            for tr in recent {
373                let icon = match tr.emotion {
374                    EmotionMode::Angry => "⚡",
375                    EmotionMode::Happy => "✨",
376                    EmotionMode::Sad => "🌧",
377                    EmotionMode::Neutral => "·",
378                };
379                lines.push(format!(
380                    "- {icon} **{}** → **{}** _({})_",
381                    sanitize_markdown_inline(&tr.entry),
382                    sanitize_markdown_inline(&tr.exit),
383                    tr.date
384                ));
385            }
386        }
387    }
388
389    if let Some(emotions) = &graph.recent_emotions
390        && !emotions.is_empty()
391    {
392        let mut counts = [0u32; 4];
393        for e in emotions {
394            match e {
395                EmotionMode::Angry => counts[0] += 1,
396                EmotionMode::Happy => counts[1] += 1,
397                EmotionMode::Sad => counts[2] += 1,
398                EmotionMode::Neutral => counts[3] += 1,
399            }
400        }
401        let dominant = [
402            (EmotionMode::Angry, counts[0]),
403            (EmotionMode::Happy, counts[1]),
404            (EmotionMode::Sad, counts[2]),
405            (EmotionMode::Neutral, counts[3]),
406        ]
407        .into_iter()
408        .max_by_key(|(_, c)| *c)
409        .map(|(m, _)| m)
410        .unwrap_or(EmotionMode::Neutral);
411        let label = match dominant {
412            EmotionMode::Angry => "focused/intense (A)",
413            EmotionMode::Happy => "expansive/positive (B)",
414            EmotionMode::Sad => "ruminant/low-energy (C)",
415            EmotionMode::Neutral => "neutral (N)",
416        };
417        lines.push(String::new());
418        lines.push("### Recent Mood Tendency".to_string());
419        lines.push(format!("- {label} across last {} turns", emotions.len()));
420    }
421
422    lines.push(String::new());
423    lines.push(format!(
424        "_Updated {} · {} active topics_",
425        today_str(),
426        graph
427            .nodes
428            .values()
429            .filter(|n| !n.dormant.unwrap_or(false) && n.strength >= 0.1)
430            .count()
431    ));
432
433    lines.join("\n")
434}
435
436/// Whether enough runs have passed to inject memory into the prompt.
437#[must_use]
438pub fn should_inject_memory(
439    graph: &PheromoneGraph,
440    runs_since_last_inject: u32,
441    min_runs_between_inject: u32,
442) -> bool {
443    let has_data = graph.nodes.values().any(|n| n.count >= 2);
444    has_data && runs_since_last_inject >= min_runs_between_inject
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    #[test]
452    fn update_and_inject_flow() {
453        let mut g = empty_graph();
454        for _ in 0..3 {
455            g = update_graph(&g, "我们在讨论 Rust 异步编程", "好的,async await 很重要");
456        }
457        assert!(g.nodes.values().any(|n| n.count >= 2));
458        let section = generate_memory_section(&g, None);
459        assert!(section.contains("User Cognitive Map"));
460        assert!(should_inject_memory(
461            &g,
462            DEFAULT_INJECT_INTERVAL_RUNS,
463            DEFAULT_INJECT_INTERVAL_RUNS
464        ));
465    }
466}