1use rusqlite::{params, Connection};
23use serde::{Deserialize, Serialize};
24
25use crate::embedding::{cosine_similarity, get_embedding, Embedder};
26use crate::error::Result;
27
28#[derive(Debug, Clone)]
34pub struct AutoLinkResult {
35 pub links_created: usize,
37 pub memories_processed: usize,
39 pub duration_ms: u64,
41}
42
43#[derive(Debug, Clone)]
45pub struct SemanticLinkOptions {
46 pub threshold: f32,
48 pub max_links_per_memory: usize,
50 pub workspace: Option<String>,
52 pub batch_size: usize,
54}
55
56impl Default for SemanticLinkOptions {
57 fn default() -> Self {
58 Self {
59 threshold: 0.75,
60 max_links_per_memory: 5,
61 workspace: None,
62 batch_size: 100,
63 }
64 }
65}
66
67#[derive(Debug, Clone)]
72pub struct TemporalLinkOptions {
73 pub window_minutes: u64,
76 pub max_links_per_memory: usize,
78 pub min_overlap_secs: Option<u64>,
83 pub workspace: Option<String>,
85}
86
87impl Default for TemporalLinkOptions {
88 fn default() -> Self {
89 Self {
90 window_minutes: 30,
91 max_links_per_memory: 5,
92 min_overlap_secs: None,
93 workspace: None,
94 }
95 }
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct AutoLink {
101 pub id: i64,
102 pub from_id: i64,
103 pub to_id: i64,
104 pub link_type: String,
105 pub score: f64,
106 pub created_at: String,
107}
108
109pub fn insert_auto_link(
117 conn: &Connection,
118 from_id: i64,
119 to_id: i64,
120 link_type: &str,
121 score: f64,
122) -> Result<bool> {
123 let rows = conn.execute(
124 "INSERT OR IGNORE INTO auto_links (from_id, to_id, link_type, score)
125 VALUES (?1, ?2, ?3, ?4)",
126 params![from_id, to_id, link_type, score],
127 )?;
128 Ok(rows > 0)
129}
130
131pub fn run_semantic_linker(
146 conn: &Connection,
147 _embedder: &dyn Embedder,
148 options: &SemanticLinkOptions,
149) -> Result<AutoLinkResult> {
150 let start = std::time::Instant::now();
151 let mut links_created = 0usize;
152
153 let ids: Vec<i64> = if let Some(ws) = &options.workspace {
155 let mut stmt = conn.prepare(
156 "SELECT m.id FROM memories m
157 WHERE m.has_embedding = 1 AND m.valid_to IS NULL
158 AND m.workspace = ?1
159 ORDER BY m.id ASC
160 LIMIT ?2",
161 )?;
162 let rows: Vec<i64> = stmt
163 .query_map(params![ws, options.batch_size as i64], |row| row.get(0))?
164 .filter_map(|r| r.ok())
165 .collect();
166 rows
167 } else {
168 let mut stmt = conn.prepare(
169 "SELECT m.id FROM memories m
170 WHERE m.has_embedding = 1 AND m.valid_to IS NULL
171 ORDER BY m.id ASC
172 LIMIT ?1",
173 )?;
174 let rows: Vec<i64> = stmt
175 .query_map(params![options.batch_size as i64], |row| row.get(0))?
176 .filter_map(|r| r.ok())
177 .collect();
178 rows
179 };
180
181 let memories_processed = ids.len();
182
183 let mut embeddings: Vec<(i64, Vec<f32>)> = Vec::with_capacity(ids.len());
185 for id in &ids {
186 if let Ok(Some(emb)) = get_embedding(conn, *id) {
187 embeddings.push((*id, emb));
188 }
189 }
190
191 let n = embeddings.len();
195 if n < 2 {
196 return Ok(AutoLinkResult {
197 links_created: 0,
198 memories_processed,
199 duration_ms: start.elapsed().as_millis() as u64,
200 });
201 }
202
203 let mut pairs: Vec<(f32, i64, i64)> = Vec::new();
205
206 for i in 0..n {
207 for j in (i + 1)..n {
208 let sim = cosine_similarity(&embeddings[i].1, &embeddings[j].1);
209 if sim >= options.threshold {
210 pairs.push((sim, embeddings[i].0, embeddings[j].0));
211 }
212 }
213 }
214
215 pairs.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
217
218 let mut link_counts: std::collections::HashMap<i64, usize> = std::collections::HashMap::new();
220
221 for (score, from_id, to_id) in pairs {
222 let from_count = link_counts.entry(from_id).or_insert(0);
223 if *from_count >= options.max_links_per_memory {
224 continue;
225 }
226 let to_count = link_counts.entry(to_id).or_insert(0);
227 if *to_count >= options.max_links_per_memory {
228 continue;
229 }
230
231 let inserted = insert_auto_link(conn, from_id, to_id, "semantic", score as f64)?;
233 if inserted {
234 links_created += 1;
235 *link_counts.entry(from_id).or_insert(0) += 1;
236 *link_counts.entry(to_id).or_insert(0) += 1;
237 }
238 }
239
240 Ok(AutoLinkResult {
241 links_created,
242 memories_processed,
243 duration_ms: start.elapsed().as_millis() as u64,
244 })
245}
246
247pub fn run_temporal_linker(
267 conn: &Connection,
268 options: &TemporalLinkOptions,
269) -> Result<AutoLinkResult> {
270 let start = std::time::Instant::now();
271 let mut links_created = 0usize;
272
273 let window_secs = options.window_minutes as f64 * 60.0;
274
275 fn collect_rows(raw: Vec<(i64, String)>) -> Vec<(i64, f64)> {
282 raw.into_iter()
283 .filter_map(|(id, ts)| parse_timestamp_to_secs(&ts).map(|s| (id, s)))
284 .collect()
285 }
286
287 let rows: Vec<(i64, f64)> = if let Some(ws) = &options.workspace {
288 let mut stmt = conn.prepare(
289 "SELECT id, created_at
290 FROM memories
291 WHERE valid_to IS NULL AND workspace = ?1
292 ORDER BY created_at ASC",
293 )?;
294 let raw: Vec<(i64, String)> = stmt
295 .query_map(params![ws], |row| Ok((row.get(0)?, row.get(1)?)))?
296 .filter_map(|r| r.ok())
297 .collect();
298 collect_rows(raw)
299 } else {
300 let mut stmt = conn.prepare(
301 "SELECT id, created_at
302 FROM memories
303 WHERE valid_to IS NULL
304 ORDER BY created_at ASC",
305 )?;
306 let raw: Vec<(i64, String)> = stmt
307 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
308 .filter_map(|r| r.ok())
309 .collect();
310 collect_rows(raw)
311 };
312
313 let memories_processed = rows.len();
314
315 if memories_processed < 2 {
316 return Ok(AutoLinkResult {
317 links_created: 0,
318 memories_processed,
319 duration_ms: start.elapsed().as_millis() as u64,
320 });
321 }
322
323 let mut candidates: Vec<(f64, i64, i64)> = Vec::new();
331
332 for i in 0..memories_processed {
333 for j in (i + 1)..memories_processed {
334 let diff_secs = rows[j].1 - rows[i].1;
335
336 if diff_secs > window_secs {
338 break;
339 }
340
341 if let Some(min_secs) = options.min_overlap_secs {
344 if diff_secs > min_secs as f64 {
345 continue;
346 }
347 }
348
349 let score = if window_secs > 0.0 {
351 1.0 - (diff_secs / window_secs)
352 } else {
353 1.0
354 };
355
356 candidates.push((score, rows[i].0, rows[j].0));
357 }
358 }
359
360 candidates.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
362
363 let mut link_counts: std::collections::HashMap<i64, usize> = std::collections::HashMap::new();
367
368 for (score, from_id, to_id) in candidates {
369 {
370 let from_count = link_counts.entry(from_id).or_insert(0);
371 if *from_count >= options.max_links_per_memory {
372 continue;
373 }
374 }
375 {
376 let to_count = link_counts.entry(to_id).or_insert(0);
377 if *to_count >= options.max_links_per_memory {
378 continue;
379 }
380 }
381
382 let inserted = insert_auto_link(conn, from_id, to_id, "temporal", score)?;
383 if inserted {
384 links_created += 1;
385 *link_counts.entry(from_id).or_insert(0) += 1;
386 *link_counts.entry(to_id).or_insert(0) += 1;
387 }
388 }
389
390 Ok(AutoLinkResult {
391 links_created,
392 memories_processed,
393 duration_ms: start.elapsed().as_millis() as u64,
394 })
395}
396
397pub fn list_auto_links(
402 conn: &Connection,
403 link_type: Option<&str>,
404 limit: usize,
405) -> Result<Vec<AutoLink>> {
406 let capped_limit = limit.min(1000);
407
408 let rows: Vec<AutoLink> = if let Some(lt) = link_type {
409 let mut stmt = conn.prepare(
410 "SELECT id, from_id, to_id, link_type, score, created_at
411 FROM auto_links
412 WHERE link_type = ?1
413 ORDER BY score DESC
414 LIMIT ?2",
415 )?;
416 let collected: Vec<AutoLink> = stmt
417 .query_map(params![lt, capped_limit as i64], row_to_auto_link)?
418 .filter_map(|r| r.ok())
419 .collect();
420 collected
421 } else {
422 let mut stmt = conn.prepare(
423 "SELECT id, from_id, to_id, link_type, score, created_at
424 FROM auto_links
425 ORDER BY score DESC
426 LIMIT ?1",
427 )?;
428 let collected: Vec<AutoLink> = stmt
429 .query_map(params![capped_limit as i64], row_to_auto_link)?
430 .filter_map(|r| r.ok())
431 .collect();
432 collected
433 };
434
435 Ok(rows)
436}
437
438pub fn auto_link_stats(conn: &Connection) -> Result<serde_json::Value> {
445 let mut stmt = conn.prepare(
446 "SELECT link_type, COUNT(*) as cnt
447 FROM auto_links
448 GROUP BY link_type
449 ORDER BY link_type ASC",
450 )?;
451
452 let mut map = serde_json::Map::new();
453 let rows = stmt.query_map([], |row| {
454 let lt: String = row.get(0)?;
455 let cnt: i64 = row.get(1)?;
456 Ok((lt, cnt))
457 })?;
458
459 for row in rows.filter_map(|r| r.ok()) {
460 map.insert(row.0, serde_json::Value::Number(row.1.into()));
461 }
462
463 Ok(serde_json::Value::Object(map))
464}
465
466fn parse_timestamp_to_secs(ts: &str) -> Option<f64> {
474 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts) {
476 return Some(dt.timestamp() as f64 + dt.timestamp_subsec_nanos() as f64 * 1e-9);
477 }
478 if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(ts, "%Y-%m-%d %H:%M:%S") {
480 return Some(ndt.and_utc().timestamp() as f64);
481 }
482 None
483}
484
485fn row_to_auto_link(row: &rusqlite::Row) -> rusqlite::Result<AutoLink> {
486 Ok(AutoLink {
487 id: row.get(0)?,
488 from_id: row.get(1)?,
489 to_id: row.get(2)?,
490 link_type: row.get(3)?,
491 score: row.get(4)?,
492 created_at: row.get(5)?,
493 })
494}
495
496#[cfg(test)]
501mod tests {
502 use super::*;
503 use crate::embedding::TfIdfEmbedder;
504 use crate::storage::migrations::run_migrations;
505 use rusqlite::Connection;
506
507 fn setup_db() -> Connection {
509 let conn = Connection::open_in_memory().expect("in-memory db");
510 run_migrations(&conn).expect("migrations");
511 conn
512 }
513
514 fn insert_memory_with_embedding(
516 conn: &Connection,
517 content: &str,
518 embedding: Option<&[f32]>,
519 ) -> i64 {
520 conn.execute(
521 "INSERT INTO memories (content, memory_type, has_embedding)
522 VALUES (?1, 'note', ?2)",
523 params![content, embedding.is_some() as i32],
524 )
525 .expect("insert memory");
526 let id = conn.last_insert_rowid();
527
528 if let Some(emb) = embedding {
529 let bytes: Vec<u8> = emb.iter().flat_map(|f| f.to_le_bytes()).collect();
531 conn.execute(
532 "INSERT INTO embeddings (memory_id, embedding, model, dimensions)
533 VALUES (?1, ?2, 'tfidf', ?3)",
534 params![id, bytes, emb.len() as i64],
535 )
536 .expect("insert embedding");
537 }
538
539 id
540 }
541
542 #[test]
547 fn test_insert_auto_link_creates_a_link() {
548 let conn = setup_db();
549 let a = insert_memory_with_embedding(&conn, "alpha", None);
550 let b = insert_memory_with_embedding(&conn, "beta", None);
551
552 let inserted = insert_auto_link(&conn, a, b, "semantic", 0.9).expect("insert");
553 assert!(inserted, "first insert should return true");
554
555 let count: i64 = conn
556 .query_row("SELECT COUNT(*) FROM auto_links", [], |r| r.get(0))
557 .unwrap();
558 assert_eq!(count, 1);
559 }
560
561 #[test]
562 fn test_insert_auto_link_is_idempotent() {
563 let conn = setup_db();
564 let a = insert_memory_with_embedding(&conn, "alpha", None);
565 let b = insert_memory_with_embedding(&conn, "beta", None);
566
567 let first = insert_auto_link(&conn, a, b, "semantic", 0.9).expect("first insert");
568 let second = insert_auto_link(&conn, a, b, "semantic", 0.9).expect("second insert");
569
570 assert!(first, "first insert should return true");
571 assert!(!second, "duplicate insert should return false");
572
573 let count: i64 = conn
574 .query_row("SELECT COUNT(*) FROM auto_links", [], |r| r.get(0))
575 .unwrap();
576 assert_eq!(count, 1, "only one row should exist after duplicate insert");
577 }
578
579 #[test]
580 fn test_insert_auto_link_different_type_is_not_duplicate() {
581 let conn = setup_db();
582 let a = insert_memory_with_embedding(&conn, "alpha", None);
583 let b = insert_memory_with_embedding(&conn, "beta", None);
584
585 insert_auto_link(&conn, a, b, "semantic", 0.9).unwrap();
586 let second = insert_auto_link(&conn, a, b, "temporal", 0.5).unwrap();
587
588 assert!(second, "different link_type should be a new row");
589 let count: i64 = conn
590 .query_row("SELECT COUNT(*) FROM auto_links", [], |r| r.get(0))
591 .unwrap();
592 assert_eq!(count, 2);
593 }
594
595 #[test]
600 fn test_run_semantic_linker_processes_memories_and_creates_links() {
601 let conn = setup_db();
602 let embedder = TfIdfEmbedder::new(4);
603
604 let emb = vec![1.0f32, 0.0, 0.0, 0.0];
606 let _a = insert_memory_with_embedding(&conn, "memory A", Some(&emb));
607 let _b = insert_memory_with_embedding(&conn, "memory B", Some(&emb));
608
609 let opts = SemanticLinkOptions {
610 threshold: 0.9,
611 max_links_per_memory: 5,
612 workspace: None,
613 batch_size: 100,
614 };
615
616 let result = run_semantic_linker(&conn, &embedder, &opts).expect("linker");
617
618 assert_eq!(result.memories_processed, 2);
619 assert_eq!(result.links_created, 1, "one link for the identical pair");
620 }
621
622 #[test]
623 fn test_threshold_filtering_lower_threshold_creates_more_links() {
624 let conn = setup_db();
625 let embedder = TfIdfEmbedder::new(4);
626
627 let emb_a = vec![1.0f32, 0.0, 0.0, 0.0];
629 let emb_b = vec![1.0f32, 0.0, 0.0, 0.0];
630 let emb_c = vec![0.0f32, 1.0, 0.0, 0.0]; insert_memory_with_embedding(&conn, "A", Some(&emb_a));
633 insert_memory_with_embedding(&conn, "B", Some(&emb_b));
634 insert_memory_with_embedding(&conn, "C", Some(&emb_c));
635
636 let high_opts = SemanticLinkOptions {
638 threshold: 0.9,
639 max_links_per_memory: 5,
640 workspace: None,
641 batch_size: 100,
642 };
643 let result_high = run_semantic_linker(&conn, &embedder, &high_opts).expect("high");
644 let count_high: i64 = conn
645 .query_row("SELECT COUNT(*) FROM auto_links", [], |r| r.get(0))
646 .unwrap();
647 assert_eq!(result_high.links_created, 1);
648
649 conn.execute("DELETE FROM auto_links", []).unwrap();
651
652 let low_opts = SemanticLinkOptions {
655 threshold: 0.0,
656 max_links_per_memory: 5,
657 workspace: None,
658 batch_size: 100,
659 };
660 let result_low = run_semantic_linker(&conn, &embedder, &low_opts).expect("low");
661 assert!(
664 result_low.links_created >= count_high as usize,
665 "lower threshold should create at least as many links"
666 );
667 }
668
669 #[test]
670 fn test_max_links_per_memory_is_respected() {
671 let conn = setup_db();
672 let embedder = TfIdfEmbedder::new(4);
673
674 let emb = vec![1.0f32, 0.0, 0.0, 0.0];
676 for i in 0..6 {
677 insert_memory_with_embedding(&conn, &format!("memory {}", i), Some(&emb));
678 }
679
680 let opts = SemanticLinkOptions {
681 threshold: 0.9,
682 max_links_per_memory: 2, workspace: None,
684 batch_size: 100,
685 };
686
687 run_semantic_linker(&conn, &embedder, &opts).expect("linker");
688
689 let mut stmt = conn
691 .prepare(
692 "SELECT mem_id, COUNT(*) as cnt FROM (
693 SELECT from_id AS mem_id FROM auto_links
694 UNION ALL
695 SELECT to_id AS mem_id FROM auto_links
696 ) GROUP BY mem_id",
697 )
698 .unwrap();
699
700 let counts: Vec<i64> = stmt
701 .query_map([], |r| r.get(1))
702 .unwrap()
703 .filter_map(|r| r.ok())
704 .collect();
705
706 for cnt in &counts {
707 assert!(
708 *cnt <= opts.max_links_per_memory as i64,
709 "memory exceeds max_links_per_memory: {} > {}",
710 cnt,
711 opts.max_links_per_memory
712 );
713 }
714 }
715
716 #[test]
721 fn test_list_auto_links_returns_results() {
722 let conn = setup_db();
723 let a = insert_memory_with_embedding(&conn, "A", None);
724 let b = insert_memory_with_embedding(&conn, "B", None);
725 let c = insert_memory_with_embedding(&conn, "C", None);
726
727 insert_auto_link(&conn, a, b, "semantic", 0.9).unwrap();
728 insert_auto_link(&conn, b, c, "temporal", 0.6).unwrap();
729
730 let all = list_auto_links(&conn, None, 10).expect("list all");
731 assert_eq!(all.len(), 2);
732
733 let semantic = list_auto_links(&conn, Some("semantic"), 10).expect("list semantic");
734 assert_eq!(semantic.len(), 1);
735 assert_eq!(semantic[0].link_type, "semantic");
736
737 let temporal = list_auto_links(&conn, Some("temporal"), 10).expect("list temporal");
738 assert_eq!(temporal.len(), 1);
739 assert_eq!(temporal[0].link_type, "temporal");
740 }
741
742 #[test]
743 fn test_list_auto_links_ordered_by_score_descending() {
744 let conn = setup_db();
745 let a = insert_memory_with_embedding(&conn, "A", None);
746 let b = insert_memory_with_embedding(&conn, "B", None);
747 let c = insert_memory_with_embedding(&conn, "C", None);
748
749 insert_auto_link(&conn, a, b, "semantic", 0.5).unwrap();
750 insert_auto_link(&conn, a, c, "semantic", 0.95).unwrap();
751
752 let links = list_auto_links(&conn, Some("semantic"), 10).unwrap();
753 assert_eq!(links.len(), 2);
754 assert!(
755 links[0].score >= links[1].score,
756 "results should be ordered by score desc"
757 );
758 }
759
760 #[test]
765 fn test_auto_link_stats_returns_counts() {
766 let conn = setup_db();
767 let a = insert_memory_with_embedding(&conn, "A", None);
768 let b = insert_memory_with_embedding(&conn, "B", None);
769 let c = insert_memory_with_embedding(&conn, "C", None);
770
771 insert_auto_link(&conn, a, b, "semantic", 0.8).unwrap();
772 insert_auto_link(&conn, a, c, "semantic", 0.7).unwrap();
773 insert_auto_link(&conn, b, c, "temporal", 0.5).unwrap();
774
775 let stats = auto_link_stats(&conn).expect("stats");
776
777 assert_eq!(stats["semantic"], serde_json::json!(2));
778 assert_eq!(stats["temporal"], serde_json::json!(1));
779 }
780
781 #[test]
782 fn test_auto_link_stats_empty_returns_empty_object() {
783 let conn = setup_db();
784 let stats = auto_link_stats(&conn).expect("stats");
785 assert!(stats.as_object().unwrap().is_empty());
786 }
787
788 fn insert_memory_at(conn: &Connection, content: &str, created_at: &str) -> i64 {
794 conn.execute(
795 "INSERT INTO memories (content, memory_type, created_at)
796 VALUES (?1, 'note', ?2)",
797 params![content, created_at],
798 )
799 .expect("insert memory with timestamp");
800 conn.last_insert_rowid()
801 }
802
803 #[test]
804 fn test_temporal_linker_creates_link_for_close_memories() {
805 let conn = setup_db();
806
807 let a = insert_memory_at(&conn, "first thought", "2024-01-01T10:00:00Z");
809 let b = insert_memory_at(&conn, "second thought", "2024-01-01T10:05:00Z");
810
811 let opts = TemporalLinkOptions::default();
812 let result = run_temporal_linker(&conn, &opts).expect("temporal linker");
813
814 assert_eq!(result.memories_processed, 2);
815 assert_eq!(
816 result.links_created, 1,
817 "one temporal link for the nearby pair"
818 );
819
820 let links = list_auto_links(&conn, Some("temporal"), 10).expect("list");
822 assert_eq!(links.len(), 1);
823 assert_eq!(links[0].from_id, a);
824 assert_eq!(links[0].to_id, b);
825 assert_eq!(links[0].link_type, "temporal");
826 }
827
828 #[test]
829 fn test_temporal_linker_no_link_outside_window() {
830 let conn = setup_db();
831
832 let _a = insert_memory_at(&conn, "morning thought", "2024-01-01T08:00:00Z");
834 let _b = insert_memory_at(&conn, "afternoon thought", "2024-01-01T09:00:00Z");
835
836 let opts = TemporalLinkOptions::default(); let result = run_temporal_linker(&conn, &opts).expect("temporal linker");
838
839 assert_eq!(result.memories_processed, 2);
840 assert_eq!(
841 result.links_created, 0,
842 "memories 60m apart should not be linked"
843 );
844 }
845
846 #[test]
847 fn test_temporal_score_formula_closer_means_higher_score() {
848 let conn = setup_db();
849
850 let _a = insert_memory_at(&conn, "A", "2024-01-01T10:00:00Z");
852 let _b = insert_memory_at(&conn, "B", "2024-01-01T10:05:00Z");
853 let _c = insert_memory_at(&conn, "C", "2024-01-01T10:25:00Z");
854
855 let opts = TemporalLinkOptions {
856 window_minutes: 30,
857 max_links_per_memory: 10,
858 min_overlap_secs: None,
859 workspace: None,
860 };
861 run_temporal_linker(&conn, &opts).expect("linker");
862
863 let links = list_auto_links(&conn, Some("temporal"), 10).expect("list");
867 assert_eq!(links.len(), 3, "all three pairs within window");
868
869 assert!(
871 links[0].score > links[1].score,
872 "higher score should come first"
873 );
874 assert!(
875 links[0].score > 0.8,
876 "A-B score should be ~0.833, got {}",
877 links[0].score
878 );
879 }
880
881 #[test]
882 fn test_temporal_linker_idempotent() {
883 let conn = setup_db();
884
885 let _a = insert_memory_at(&conn, "A", "2024-01-01T10:00:00Z");
886 let _b = insert_memory_at(&conn, "B", "2024-01-01T10:10:00Z");
887
888 let opts = TemporalLinkOptions::default();
889
890 let first = run_temporal_linker(&conn, &opts).expect("first run");
891 let second = run_temporal_linker(&conn, &opts).expect("second run");
892
893 assert_eq!(first.links_created, 1);
894 assert_eq!(
895 second.links_created, 0,
896 "second run should create no new links"
897 );
898
899 let count: i64 = conn
900 .query_row(
901 "SELECT COUNT(*) FROM auto_links WHERE link_type = 'temporal'",
902 [],
903 |r| r.get(0),
904 )
905 .unwrap();
906 assert_eq!(count, 1, "exactly one temporal link should exist");
907 }
908
909 #[test]
910 fn test_temporal_linker_max_links_per_memory_respected() {
911 let conn = setup_db();
912
913 for i in 0..5i64 {
916 let ts = format!("2024-01-01T10:{:02}:00Z", i * 2); insert_memory_at(&conn, &format!("mem {}", i), &ts);
918 }
919
920 let opts = TemporalLinkOptions {
921 window_minutes: 30,
922 max_links_per_memory: 2,
923 min_overlap_secs: None,
924 workspace: None,
925 };
926 run_temporal_linker(&conn, &opts).expect("linker");
927
928 let mut stmt = conn
930 .prepare(
931 "SELECT mem_id, COUNT(*) as cnt FROM (
932 SELECT from_id AS mem_id FROM auto_links WHERE link_type = 'temporal'
933 UNION ALL
934 SELECT to_id AS mem_id FROM auto_links WHERE link_type = 'temporal'
935 ) GROUP BY mem_id",
936 )
937 .unwrap();
938
939 let counts: Vec<i64> = stmt
940 .query_map([], |r| r.get(1))
941 .unwrap()
942 .filter_map(|r| r.ok())
943 .collect();
944
945 for cnt in &counts {
946 assert!(
947 *cnt <= 2,
948 "a memory has more than max_links_per_memory=2 links: {}",
949 cnt
950 );
951 }
952 }
953
954 #[test]
955 fn test_temporal_linker_workspace_filter() {
956 let conn = setup_db();
957
958 conn.execute(
960 "INSERT INTO memories (content, memory_type, workspace, created_at)
961 VALUES ('ws-alpha-1', 'note', 'alpha', '2024-01-01T10:00:00Z')",
962 [],
963 )
964 .unwrap();
965 conn.execute(
966 "INSERT INTO memories (content, memory_type, workspace, created_at)
967 VALUES ('ws-alpha-2', 'note', 'alpha', '2024-01-01T10:05:00Z')",
968 [],
969 )
970 .unwrap();
971 conn.execute(
972 "INSERT INTO memories (content, memory_type, workspace, created_at)
973 VALUES ('ws-beta-1', 'note', 'beta', '2024-01-01T10:02:00Z')",
974 [],
975 )
976 .unwrap();
977
978 let opts = TemporalLinkOptions {
980 workspace: Some("alpha".to_string()),
981 ..Default::default()
982 };
983 let result = run_temporal_linker(&conn, &opts).expect("linker");
984
985 assert_eq!(result.memories_processed, 2);
987 assert_eq!(result.links_created, 1);
988
989 let beta_id: i64 = conn
991 .query_row(
992 "SELECT id FROM memories WHERE workspace = 'beta'",
993 [],
994 |r| r.get(0),
995 )
996 .unwrap();
997 let beta_link_count: i64 = conn
998 .query_row(
999 "SELECT COUNT(*) FROM auto_links
1000 WHERE (from_id = ?1 OR to_id = ?1) AND link_type = 'temporal'",
1001 params![beta_id],
1002 |r| r.get(0),
1003 )
1004 .unwrap();
1005 assert_eq!(
1006 beta_link_count, 0,
1007 "beta workspace memory should not be linked"
1008 );
1009 }
1010
1011 #[test]
1012 fn test_temporal_linker_min_overlap_secs_restricts_candidates() {
1013 let conn = setup_db();
1014
1015 let _a = insert_memory_at(&conn, "A", "2024-01-01T10:00:00Z");
1019 let _b = insert_memory_at(&conn, "B", "2024-01-01T10:02:00Z");
1020 let _c = insert_memory_at(&conn, "C", "2024-01-01T10:10:00Z");
1021
1022 let opts = TemporalLinkOptions {
1023 window_minutes: 30,
1024 max_links_per_memory: 5,
1025 min_overlap_secs: Some(120), workspace: None,
1027 };
1028 let result = run_temporal_linker(&conn, &opts).expect("linker");
1029
1030 assert_eq!(result.memories_processed, 3);
1031 assert_eq!(
1034 result.links_created, 1,
1035 "only A-B qualifies under min_overlap_secs=120"
1036 );
1037 }
1038
1039 #[test]
1040 fn test_temporal_linker_empty_database_returns_zero() {
1041 let conn = setup_db();
1042 let opts = TemporalLinkOptions::default();
1043 let result = run_temporal_linker(&conn, &opts).expect("linker");
1044 assert_eq!(result.memories_processed, 0);
1045 assert_eq!(result.links_created, 0);
1046 }
1047
1048 #[test]
1049 fn test_parse_timestamp_to_secs_rfc3339() {
1050 let secs = parse_timestamp_to_secs("2024-01-01T10:00:00Z");
1051 assert!(secs.is_some(), "should parse RFC3339 timestamp");
1052
1053 let secs2 = parse_timestamp_to_secs("2024-01-01T10:05:00Z");
1054 assert!(secs2.is_some());
1055
1056 let diff = secs2.unwrap() - secs.unwrap();
1058 assert!(
1059 (diff - 300.0).abs() < 1.0,
1060 "difference should be ~300s, got {}",
1061 diff
1062 );
1063 }
1064
1065 #[test]
1066 fn test_parse_timestamp_to_secs_sqlite_format() {
1067 let secs = parse_timestamp_to_secs("2024-01-01 10:00:00");
1068 assert!(
1069 secs.is_some(),
1070 "should parse SQLite CURRENT_TIMESTAMP format"
1071 );
1072 }
1073
1074 #[test]
1075 fn test_parse_timestamp_to_secs_invalid_returns_none() {
1076 let result = parse_timestamp_to_secs("not-a-date");
1077 assert!(result.is_none(), "invalid timestamp should return None");
1078 }
1079}