1use crate::error::MemoryError;
27use crate::types::{GraphEdge, GraphEdgeType};
28use chrono::{DateTime, NaiveDateTime, Utc};
29use rusqlite::{params, Connection};
30use std::collections::HashSet;
31
32const CANONICAL_TS_FMT: &str = "%Y-%m-%d %H:%M:%S%.6f";
36
37pub(crate) fn canonicalize_timestamp(s: &str) -> Option<String> {
46 if NaiveDateTime::parse_from_str(s, CANONICAL_TS_FMT).is_ok() {
48 return Some(s.to_string());
49 }
50 if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
52 return Some(dt.format(CANONICAL_TS_FMT).to_string());
53 }
54 if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
56 return Some(dt.naive_utc().format(CANONICAL_TS_FMT).to_string());
57 }
58 None
59}
60
61fn normalize_or_pass_through(s: &str) -> String {
64 canonicalize_timestamp(s).unwrap_or_else(|| s.to_string())
65}
66
67#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
69pub struct StoredGraphEdge {
70 pub id: String,
72 pub source: String,
74 pub target: String,
76 pub edge_type: String,
78 #[serde(skip)]
80 pub edge_type_parsed: Option<GraphEdgeType>,
81 pub weight: f64,
83 pub metadata: Option<String>,
85 pub content_digest: String,
87 pub recorded_at: String,
89 pub is_invalidated: bool,
91 pub invalidated_at: Option<String>,
93 pub invalidation_reason: Option<String>,
95 pub valid_time: Option<String>,
97 pub recorded_time: Option<String>,
99}
100
101#[derive(Debug, Clone)]
103pub struct AddGraphEdgeParams {
104 pub source: String,
106 pub target: String,
108 pub edge_type: GraphEdgeType,
110 pub weight: f64,
112 pub metadata: Option<serde_json::Value>,
114 pub valid_time: Option<String>,
116 pub recorded_time: Option<String>,
118}
119
120pub(crate) fn insert_graph_edge(
122 conn: &Connection,
123 params: &AddGraphEdgeParams,
124) -> Result<StoredGraphEdge, MemoryError> {
125 let edge_type_json = serde_json::to_string(¶ms.edge_type)
126 .map_err(|e| MemoryError::Other(format!("failed to serialize edge_type: {e}")))?;
127
128 let metadata_json = match ¶ms.metadata {
129 Some(v) => Some(
130 serde_json::to_string(v)
131 .map_err(|e| MemoryError::Other(format!("failed to serialize metadata: {e}")))?,
132 ),
133 None => None,
134 };
135
136 let content_digest = compute_edge_digest(
137 ¶ms.source,
138 ¶ms.target,
139 &edge_type_json,
140 params.weight,
141 &metadata_json,
142 params.valid_time.as_deref(),
143 params.recorded_time.as_deref(),
144 );
145
146 let existing: Option<(String, String)> = conn
148 .query_row(
149 "SELECT id, recorded_at FROM graph_edges WHERE content_digest = ?1 AND is_invalidated = 0",
150 params![&content_digest],
151 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
152 )
153 .ok();
154
155 if let Some((id, recorded_at)) = existing {
156 return Ok(StoredGraphEdge {
157 id,
158 source: params.source.clone(),
159 target: params.target.clone(),
160 edge_type: edge_type_json,
161 edge_type_parsed: Some(params.edge_type.clone()),
162 weight: params.weight,
163 metadata: metadata_json,
164 content_digest,
165 recorded_at: recorded_at.clone(),
166 is_invalidated: false,
167 invalidated_at: None,
168 invalidation_reason: None,
169 valid_time: params
170 .valid_time
171 .as_deref()
172 .map(normalize_or_pass_through)
173 .or_else(|| Some(recorded_at.clone())),
174 recorded_time: params
175 .recorded_time
176 .as_deref()
177 .map(normalize_or_pass_through)
178 .or_else(|| Some(recorded_at.clone())),
179 });
180 }
181
182 let id = uuid::Uuid::new_v4().to_string();
183 let recorded_at = Utc::now().format("%Y-%m-%d %H:%M:%S%.6f").to_string();
184 let valid_time = params
185 .valid_time
186 .as_deref()
187 .map(normalize_or_pass_through)
188 .unwrap_or_else(|| recorded_at.clone());
189 let recorded_time = params
190 .recorded_time
191 .as_deref()
192 .map(normalize_or_pass_through)
193 .unwrap_or_else(|| recorded_at.clone());
194
195 conn.execute(
196 "INSERT INTO graph_edges (id, source, target, edge_type, weight, metadata, content_digest, recorded_at, valid_time, recorded_time)
197 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
198 params![
199 &id,
200 ¶ms.source,
201 ¶ms.target,
202 &edge_type_json,
203 params.weight,
204 metadata_json.as_deref(),
205 &content_digest,
206 &recorded_at,
207 &valid_time,
208 &recorded_time,
209 ],
210 )
211 .map_err(|e| MemoryError::Database(e))?;
212
213 Ok(StoredGraphEdge {
214 id,
215 source: params.source.clone(),
216 target: params.target.clone(),
217 edge_type: edge_type_json,
218 edge_type_parsed: Some(params.edge_type.clone()),
219 weight: params.weight,
220 metadata: metadata_json,
221 content_digest,
222 recorded_at,
223 is_invalidated: false,
224 invalidated_at: None,
225 invalidation_reason: None,
226 valid_time: Some(valid_time),
227 recorded_time: Some(recorded_time),
228 })
229}
230
231pub(crate) fn list_graph_edges_for_node(
234 conn: &Connection,
235 node_id: &str,
236) -> Result<Vec<StoredGraphEdge>, MemoryError> {
237 let mut stmt = conn.prepare(
238 "SELECT id, source, target, edge_type, weight, metadata, content_digest, recorded_at,
239 is_invalidated, invalidated_at, invalidation_reason, valid_time, recorded_time
240 FROM graph_edges
241 WHERE (source = ?1 OR target = ?1) AND is_invalidated = 0
242 ORDER BY recorded_at ASC",
243 )?;
244 let rows = stmt
245 .query_map(params![node_id], |row| {
246 Ok(StoredGraphEdge {
247 id: row.get(0)?,
248 source: row.get(1)?,
249 target: row.get(2)?,
250 edge_type: row.get(3)?,
251 edge_type_parsed: None,
252 weight: row.get(4)?,
253 metadata: row.get(5)?,
254 content_digest: row.get(6)?,
255 recorded_at: row.get(7)?,
256 is_invalidated: row.get::<_, i64>(8)? != 0,
257 invalidated_at: row.get(9)?,
258 invalidation_reason: row.get(10)?,
259 valid_time: row.get(11)?,
260 recorded_time: row.get(12)?,
261 })
262 })?
263 .collect::<Result<Vec<_>, _>>()?;
264 Ok(rows)
265}
266
267pub(crate) fn list_all_graph_edges(conn: &Connection) -> Result<Vec<StoredGraphEdge>, MemoryError> {
269 let mut stmt = conn.prepare(
270 "SELECT id, source, target, edge_type, weight, metadata, content_digest, recorded_at,
271 is_invalidated, invalidated_at, invalidation_reason, valid_time, recorded_time
272 FROM graph_edges
273 WHERE is_invalidated = 0
274 ORDER BY recorded_at ASC",
275 )?;
276 let rows = stmt
277 .query_map([], |row| {
278 Ok(StoredGraphEdge {
279 id: row.get(0)?,
280 source: row.get(1)?,
281 target: row.get(2)?,
282 edge_type: row.get(3)?,
283 edge_type_parsed: None,
284 weight: row.get(4)?,
285 metadata: row.get(5)?,
286 content_digest: row.get(6)?,
287 recorded_at: row.get(7)?,
288 is_invalidated: row.get::<_, i64>(8)? != 0,
289 invalidated_at: row.get(9)?,
290 invalidation_reason: row.get(10)?,
291 valid_time: row.get(11)?,
292 recorded_time: row.get(12)?,
293 })
294 })?
295 .collect::<Result<Vec<_>, _>>()?;
296 Ok(rows)
297}
298
299pub(crate) fn list_graph_edges_for_node_as_of(
304 conn: &Connection,
305 node_id: &str,
306 as_of_valid_time: &str,
307 as_of_recorded_time: &str,
308) -> Result<Vec<StoredGraphEdge>, MemoryError> {
309 let as_of_valid_time = canonicalize_timestamp(as_of_valid_time).ok_or_else(|| {
311 MemoryError::Other(format!(
312 "invalid as_of_valid_time timestamp: {as_of_valid_time}"
313 ))
314 })?;
315 let as_of_recorded_time = canonicalize_timestamp(as_of_recorded_time).ok_or_else(|| {
316 MemoryError::Other(format!(
317 "invalid as_of_recorded_time timestamp: {as_of_recorded_time}"
318 ))
319 })?;
320
321 let mut stmt = conn.prepare(
322 "SELECT id, source, target, edge_type, weight, metadata, content_digest, recorded_at,
323 is_invalidated, invalidated_at, invalidation_reason, valid_time, recorded_time
324 FROM graph_edges
325 WHERE (source = ?1 OR target = ?1)
326 AND COALESCE(valid_time, recorded_at) <= ?2
327 AND COALESCE(recorded_time, recorded_at) <= ?3
328 AND (is_invalidated = 0 OR invalidated_at IS NULL OR invalidated_at > ?3)
329 ORDER BY COALESCE(recorded_time, recorded_at) ASC, id ASC",
330 )?;
331 let rows = stmt
332 .query_map(
333 params![node_id, as_of_valid_time, as_of_recorded_time],
334 |row| {
335 Ok(StoredGraphEdge {
336 id: row.get(0)?,
337 source: row.get(1)?,
338 target: row.get(2)?,
339 edge_type: row.get(3)?,
340 edge_type_parsed: None,
341 weight: row.get(4)?,
342 metadata: row.get(5)?,
343 content_digest: row.get(6)?,
344 recorded_at: row.get(7)?,
345 is_invalidated: row.get::<_, i64>(8)? != 0,
346 invalidated_at: row.get(9)?,
347 invalidation_reason: row.get(10)?,
348 valid_time: row.get(11)?,
349 recorded_time: row.get(12)?,
350 })
351 },
352 )?
353 .collect::<Result<Vec<_>, _>>()?;
354 Ok(rows)
355}
356
357pub(crate) fn list_graph_edges_for_neighborhood(
367 conn: &Connection,
368 seed_ids: &[String],
369 max_hops: usize,
370 max_nodes: usize,
371) -> Result<Vec<StoredGraphEdge>, MemoryError> {
372 if seed_ids.is_empty() || max_hops == 0 {
373 return Ok(Vec::new());
374 }
375
376 let mut visited: HashSet<String> = seed_ids.iter().cloned().collect();
377 let mut all_edges: Vec<StoredGraphEdge> = Vec::new();
378 let mut frontier: Vec<String> = seed_ids.iter().cloned().collect();
379
380 for _hop in 0..max_hops {
381 if frontier.is_empty() || visited.len() >= max_nodes {
382 break;
383 }
384
385 let mut next_frontier: Vec<String> = Vec::new();
386
387 for node_id in &frontier {
388 let edges = list_graph_edges_for_node(conn, node_id)?;
389 for edge in edges {
390 let other = if edge.source == *node_id {
392 &edge.target
393 } else {
394 &edge.source
395 };
396
397 if !visited.contains(other) {
398 visited.insert(other.clone());
399 next_frontier.push(other.clone());
400 }
401
402 if !all_edges.iter().any(|e: &StoredGraphEdge| e.id == edge.id) {
404 all_edges.push(edge);
405 }
406 }
407 }
408
409 frontier = next_frontier;
410 if visited.len() >= max_nodes {
411 break;
412 }
413 }
414
415 all_edges.sort_by(|a, b| a.recorded_at.cmp(&b.recorded_at));
417 Ok(all_edges)
418}
419
420pub(crate) fn invalidate_graph_edge(
422 conn: &Connection,
423 edge_id: &str,
424 reason: &str,
425) -> Result<(), MemoryError> {
426 let invalidated_at =
427 normalize_or_pass_through(&Utc::now().format("%Y-%m-%d %H:%M:%S%.6f").to_string());
428 let count = conn.execute(
429 "UPDATE graph_edges SET is_invalidated = 1, invalidated_at = ?1, invalidation_reason = ?2
430 WHERE id = ?3 AND is_invalidated = 0",
431 params![&invalidated_at, reason, edge_id],
432 )?;
433 if count == 0 {
434 return Err(MemoryError::Other(format!(
435 "graph edge {} not found or already invalidated",
436 edge_id
437 )));
438 }
439 Ok(())
440}
441
442#[allow(dead_code)] pub(crate) fn stored_edges_for_node(
446 conn: &Connection,
447 node_id: &str,
448) -> Result<Vec<GraphEdge>, MemoryError> {
449 let rows = list_graph_edges_for_node(conn, node_id)?;
450 let mut edges = Vec::new();
451 for row in rows {
452 let edge_type: GraphEdgeType = serde_json::from_str(&row.edge_type)
453 .map_err(|e| MemoryError::Other(format!("failed to deserialize edge_type: {e}")))?;
454 let metadata: Option<serde_json::Value> =
455 match &row.metadata {
456 Some(s) => Some(serde_json::from_str(s).map_err(|e| {
457 MemoryError::Other(format!("failed to deserialize metadata: {e}"))
458 })?),
459 None => None,
460 };
461 edges.push(GraphEdge {
462 source: row.source,
463 target: row.target,
464 edge_type,
465 weight: row.weight,
466 metadata,
467 });
468 }
469 Ok(edges)
470}
471
472pub(crate) fn stored_outgoing_edges(
475 conn: &Connection,
476 node_id: &str,
477) -> Result<Vec<GraphEdge>, MemoryError> {
478 let mut stmt = conn.prepare(
479 "SELECT id, source, target, edge_type, weight, metadata, content_digest, recorded_at,
480 is_invalidated, invalidated_at, invalidation_reason, valid_time, recorded_time
481 FROM graph_edges
482 WHERE source = ?1 AND is_invalidated = 0
483 ORDER BY recorded_at ASC",
484 )?;
485 let rows = stmt
486 .query_map(params![node_id], |row| {
487 Ok(StoredGraphEdge {
488 id: row.get(0)?,
489 source: row.get(1)?,
490 target: row.get(2)?,
491 edge_type: row.get(3)?,
492 edge_type_parsed: None,
493 weight: row.get(4)?,
494 metadata: row.get(5)?,
495 content_digest: row.get(6)?,
496 recorded_at: row.get(7)?,
497 is_invalidated: row.get::<_, i64>(8)? != 0,
498 invalidated_at: row.get(9)?,
499 invalidation_reason: row.get(10)?,
500 valid_time: row.get(11)?,
501 recorded_time: row.get(12)?,
502 })
503 })?
504 .collect::<Result<Vec<_>, _>>()?;
505 rows_to_graph_edges(rows)
506}
507
508pub(crate) fn stored_incoming_edges(
511 conn: &Connection,
512 node_id: &str,
513) -> Result<Vec<GraphEdge>, MemoryError> {
514 let mut stmt = conn.prepare(
515 "SELECT id, source, target, edge_type, weight, metadata, content_digest, recorded_at,
516 is_invalidated, invalidated_at, invalidation_reason, valid_time, recorded_time
517 FROM graph_edges
518 WHERE target = ?1 AND is_invalidated = 0
519 ORDER BY recorded_at ASC",
520 )?;
521 let rows = stmt
522 .query_map(params![node_id], |row| {
523 Ok(StoredGraphEdge {
524 id: row.get(0)?,
525 source: row.get(1)?,
526 target: row.get(2)?,
527 edge_type: row.get(3)?,
528 edge_type_parsed: None,
529 weight: row.get(4)?,
530 metadata: row.get(5)?,
531 content_digest: row.get(6)?,
532 recorded_at: row.get(7)?,
533 is_invalidated: row.get::<_, i64>(8)? != 0,
534 invalidated_at: row.get(9)?,
535 invalidation_reason: row.get(10)?,
536 valid_time: row.get(11)?,
537 recorded_time: row.get(12)?,
538 })
539 })?
540 .collect::<Result<Vec<_>, _>>()?;
541 rows_to_graph_edges(rows)
542}
543
544fn rows_to_graph_edges(rows: Vec<StoredGraphEdge>) -> Result<Vec<GraphEdge>, MemoryError> {
546 let mut edges = Vec::new();
547 for row in rows {
548 let edge_type: GraphEdgeType = serde_json::from_str(&row.edge_type)
549 .map_err(|e| MemoryError::Other(format!("failed to deserialize edge_type: {e}")))?;
550 let metadata: Option<serde_json::Value> =
551 match &row.metadata {
552 Some(s) => Some(serde_json::from_str(s).map_err(|e| {
553 MemoryError::Other(format!("failed to deserialize metadata: {e}"))
554 })?),
555 None => None,
556 };
557 edges.push(GraphEdge {
558 source: row.source,
559 target: row.target,
560 edge_type,
561 weight: row.weight,
562 metadata,
563 });
564 }
565 Ok(edges)
566}
567pub(crate) fn count_graph_edges(conn: &Connection) -> Result<usize, MemoryError> {
569 let count: i64 = conn
570 .query_row(
571 "SELECT COUNT(*) FROM graph_edges WHERE is_invalidated = 0",
572 [],
573 |row| row.get(0),
574 )
575 .map_err(|e| MemoryError::Database(e))?;
576 Ok(count as usize)
577}
578
579fn compute_edge_digest(
580 source: &str,
581 target: &str,
582 edge_type_json: &str,
583 weight: f64,
584 metadata_json: &Option<String>,
585 explicit_valid_time: Option<&str>,
586 explicit_recorded_time: Option<&str>,
587) -> String {
588 let mut builder = stack_ids::DigestBuilder::new();
589 builder.update(source.as_bytes());
590 builder.update(target.as_bytes());
591 builder.update(edge_type_json.as_bytes());
592 builder.update(&weight.to_le_bytes());
593 if let Some(meta) = metadata_json {
594 builder.update(meta.as_bytes());
595 }
596 if let Some(valid_time) = explicit_valid_time {
597 builder.update(valid_time.as_bytes());
598 }
599 if let Some(recorded_time) = explicit_recorded_time {
600 builder.update(recorded_time.as_bytes());
601 }
602 builder.finalize().0
603}
604
605#[cfg(test)]
606mod tests {
607 use super::*;
608 use crate::db::run_migrations;
609 use rusqlite::Connection;
610 use serde_json::json;
611
612 fn migrated_conn() -> Connection {
613 let conn = Connection::open_in_memory().unwrap();
614 run_migrations(&conn).unwrap();
615 conn
616 }
617
618 #[test]
619 fn graph_edges_have_bitemporal_columns_after_migration() {
620 let conn = migrated_conn();
621 let columns: Vec<String> = conn
622 .prepare("PRAGMA table_info(graph_edges)")
623 .unwrap()
624 .query_map([], |row| row.get::<_, String>(1))
625 .unwrap()
626 .collect::<Result<Vec<_>, _>>()
627 .unwrap();
628
629 assert!(columns.contains(&"valid_time".to_string()));
630 assert!(columns.contains(&"recorded_time".to_string()));
631 }
632
633 #[test]
634 fn graph_edges_as_of_filters_valid_and_recorded_time() {
635 let conn = migrated_conn();
636 let old_edge = AddGraphEdgeParams {
637 source: "fact:a".to_string(),
638 target: "fact:b".to_string(),
639 edge_type: GraphEdgeType::Entity {
640 relation: "supports".to_string(),
641 },
642 weight: 1.0,
643 metadata: Some(json!({"version": "old"})),
644 valid_time: Some("2026-01-01T00:00:00Z".to_string()),
645 recorded_time: Some("2026-01-02T00:00:00Z".to_string()),
646 };
647 let future_edge = AddGraphEdgeParams {
648 source: "fact:a".to_string(),
649 target: "fact:c".to_string(),
650 edge_type: GraphEdgeType::Entity {
651 relation: "supports".to_string(),
652 },
653 weight: 1.0,
654 metadata: Some(json!({"version": "future"})),
655 valid_time: Some("2026-03-01T00:00:00Z".to_string()),
656 recorded_time: Some("2026-03-02T00:00:00Z".to_string()),
657 };
658
659 insert_graph_edge(&conn, &old_edge).unwrap();
660 insert_graph_edge(&conn, &future_edge).unwrap();
661
662 let visible = list_graph_edges_for_node_as_of(
663 &conn,
664 "fact:a",
665 "2026-02-01T00:00:00Z",
666 "2026-02-02T00:00:00Z",
667 )
668 .unwrap();
669
670 assert_eq!(visible.len(), 1);
671 assert_eq!(visible[0].target, "fact:b");
672 assert_eq!(
674 visible[0].valid_time.as_deref(),
675 Some("2026-01-01 00:00:00.000000")
676 );
677 assert_eq!(
678 visible[0].recorded_time.as_deref(),
679 Some("2026-01-02 00:00:00.000000")
680 );
681 }
682
683 #[test]
684 fn graph_edges_as_of_handles_mixed_timestamp_formats() {
685 let conn = migrated_conn();
687 let edge = AddGraphEdgeParams {
688 source: "fact:x".to_string(),
689 target: "fact:y".to_string(),
690 edge_type: GraphEdgeType::Semantic {
691 cosine_similarity: 0.9,
692 },
693 weight: 1.0,
694 metadata: None,
695 valid_time: Some("2026-01-01 00:00:00.000000".to_string()),
696 recorded_time: Some("2026-01-01 00:00:00.000000".to_string()),
697 };
698 insert_graph_edge(&conn, &edge).unwrap();
699
700 let visible = list_graph_edges_for_node_as_of(
701 &conn,
702 "fact:x",
703 "2026-01-01T23:59:59Z",
704 "2026-01-01T23:59:59Z",
705 )
706 .unwrap();
707 assert_eq!(visible.len(), 1);
708 assert_eq!(visible[0].target, "fact:y");
709 }
710
711 #[test]
712 fn graph_edges_as_of_rejects_invalid_timestamp() {
713 let conn = migrated_conn();
714 let edge = AddGraphEdgeParams {
715 source: "fact:x".to_string(),
716 target: "fact:y".to_string(),
717 edge_type: GraphEdgeType::Semantic {
718 cosine_similarity: 0.9,
719 },
720 weight: 1.0,
721 metadata: None,
722 valid_time: None,
723 recorded_time: None,
724 };
725 insert_graph_edge(&conn, &edge).unwrap();
726
727 let err = list_graph_edges_for_node_as_of(
728 &conn,
729 "fact:x",
730 "not-a-timestamp",
731 "2026-01-01T00:00:00Z",
732 )
733 .unwrap_err();
734 assert!(err.to_string().contains("invalid as_of_valid_time"));
735 }
736}