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
279#[must_use]
281pub fn generate_memory_section(
282 graph: &PheromoneGraph,
283 options: Option<GenerateMemorySectionOptions<'_>>,
284) -> String {
285 let mut hot_nodes: Vec<_> = graph
286 .nodes
287 .iter()
288 .filter(|(_, n)| !n.dormant.unwrap_or(false) && n.strength >= 0.1)
289 .collect();
290 hot_nodes.sort_by(|a, b| {
291 b.1.strength
292 .partial_cmp(&a.1.strength)
293 .unwrap_or(std::cmp::Ordering::Equal)
294 });
295 hot_nodes.truncate(MAX_HOT_NODES);
296
297 let mut hot_edges: Vec<_> = graph.edges.iter().collect();
298 hot_edges.sort_by(|a, b| {
299 b.1.weight
300 .partial_cmp(&a.1.weight)
301 .unwrap_or(std::cmp::Ordering::Equal)
302 });
303 hot_edges.truncate(6);
304
305 let header_tail = options
306 .and_then(|o| o.attribution)
307 .map(|a| format!(" (auto-generated by {a} · do not edit this section)"))
308 .unwrap_or_else(|| " (auto-generated · do not edit this section)".to_string());
309
310 let mut lines = vec![
311 format!("## User Cognitive Map{header_tail}"),
312 String::new(),
313 "### Frequent Topics".to_string(),
314 ];
315
316 if hot_nodes.is_empty() {
317 lines.push("- (not enough data yet)".to_string());
318 } else {
319 for (topic, n) in hot_nodes {
320 let bar_len = (n.strength * 5.0).round() as usize;
321 let bar = "█".repeat(bar_len);
322 lines.push(format!(
323 "- **{topic}** {bar} (depth {}, {} mentions)",
324 n.depth.round() as i32,
325 n.count
326 ));
327 }
328 }
329
330 if !hot_edges.is_empty() {
331 lines.push(String::new());
332 lines.push("### Common Associations".to_string());
333 for (edge, _) in hot_edges {
334 lines.push(format!("- {}", edge.replace('→', " → ")));
335 }
336 }
337
338 let active_blocked: Vec<_> = graph.blocked_points.iter().rev().take(5).collect();
339 if !active_blocked.is_empty() {
340 lines.push(String::new());
341 lines.push("### Knowledge Boundaries (user indicated uncertainty)".to_string());
342 for b in active_blocked {
343 let ctx: String = b.context.chars().take(60).collect();
344 lines.push(format!("- **{}**: {ctx}…", b.node));
345 }
346 }
347
348 if let Some(trails) = &graph.trails {
349 let recent: Vec<_> = trails.iter().rev().take(8).collect();
350 if !recent.is_empty() {
351 lines.push(String::new());
352 lines.push("### Cognitive Trails (entry → exit per run)".to_string());
353 for tr in recent {
354 let icon = match tr.emotion {
355 EmotionMode::Angry => "⚡",
356 EmotionMode::Happy => "✨",
357 EmotionMode::Sad => "🌧",
358 EmotionMode::Neutral => "·",
359 };
360 lines.push(format!(
361 "- {icon} **{}** → **{}** _({})_",
362 tr.entry, tr.exit, tr.date
363 ));
364 }
365 }
366 }
367
368 if let Some(emotions) = &graph.recent_emotions
369 && !emotions.is_empty()
370 {
371 let mut counts = [0u32; 4];
372 for e in emotions {
373 match e {
374 EmotionMode::Angry => counts[0] += 1,
375 EmotionMode::Happy => counts[1] += 1,
376 EmotionMode::Sad => counts[2] += 1,
377 EmotionMode::Neutral => counts[3] += 1,
378 }
379 }
380 let dominant = [
381 (EmotionMode::Angry, counts[0]),
382 (EmotionMode::Happy, counts[1]),
383 (EmotionMode::Sad, counts[2]),
384 (EmotionMode::Neutral, counts[3]),
385 ]
386 .into_iter()
387 .max_by_key(|(_, c)| *c)
388 .map(|(m, _)| m)
389 .unwrap_or(EmotionMode::Neutral);
390 let label = match dominant {
391 EmotionMode::Angry => "focused/intense (A)",
392 EmotionMode::Happy => "expansive/positive (B)",
393 EmotionMode::Sad => "ruminant/low-energy (C)",
394 EmotionMode::Neutral => "neutral (N)",
395 };
396 lines.push(String::new());
397 lines.push("### Recent Mood Tendency".to_string());
398 lines.push(format!("- {label} across last {} turns", emotions.len()));
399 }
400
401 lines.push(String::new());
402 lines.push(format!(
403 "_Updated {} · {} active topics_",
404 today_str(),
405 graph
406 .nodes
407 .values()
408 .filter(|n| !n.dormant.unwrap_or(false) && n.strength >= 0.1)
409 .count()
410 ));
411
412 lines.join("\n")
413}
414
415#[must_use]
417pub fn should_inject_memory(
418 graph: &PheromoneGraph,
419 runs_since_last_inject: u32,
420 min_runs_between_inject: u32,
421) -> bool {
422 let has_data = graph.nodes.values().any(|n| n.count >= 2);
423 has_data && runs_since_last_inject >= min_runs_between_inject
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429
430 #[test]
431 fn update_and_inject_flow() {
432 let mut g = empty_graph();
433 for _ in 0..3 {
434 g = update_graph(&g, "我们在讨论 Rust 异步编程", "好的,async await 很重要");
435 }
436 assert!(g.nodes.values().any(|n| n.count >= 2));
437 let section = generate_memory_section(&g, None);
438 assert!(section.contains("User Cognitive Map"));
439 assert!(should_inject_memory(
440 &g,
441 DEFAULT_INJECT_INTERVAL_RUNS,
442 DEFAULT_INJECT_INTERVAL_RUNS
443 ));
444 }
445}