1use 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
21pub 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#[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#[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
279fn 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#[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#[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}