1use std::sync::Arc;
5#[allow(unused_imports)]
6use zeph_db::sql;
7
8use std::sync::atomic::Ordering;
9use tokio_util::sync::CancellationToken;
10use zeph_db::DbPool;
11
12pub use zeph_common::config::memory::NoteLinkingConfig;
13use zeph_common::sanitize::strip_control_chars;
14use zeph_common::text::truncate_to_bytes_ref;
15use zeph_llm::any::AnyProvider;
16use zeph_llm::provider::LlmProvider as _;
17
18use crate::embedding_store::EmbeddingStore;
19use crate::error::MemoryError;
20use crate::graph::extractor::ExtractionResult as ExtractorResult;
21use crate::graph::resolver::{MAX_RELATION_BYTES, sanitize_fact, sanitize_relation};
22use crate::graph::types::GraphProvenance;
23use crate::vector_store::VectorFilter;
24
25use super::SemanticMemory;
26
27pub type PostExtractValidator = Option<Box<dyn Fn(&ExtractorResult) -> Result<(), String> + Send>>;
32
33pub type SharedPostExtractValidator =
38 Option<Arc<dyn Fn(&ExtractorResult) -> Result<(), String> + Send + Sync>>;
39
40#[derive(Debug, Clone)]
45pub struct GraphExtractionConfig {
46 pub max_entities: usize,
47 pub max_edges: usize,
48 pub extraction_timeout_secs: u64,
49 pub community_refresh_interval: usize,
50 pub expired_edge_retention_days: u32,
51 pub max_entities_cap: usize,
52 pub community_summary_max_prompt_bytes: usize,
53 pub community_summary_concurrency: usize,
54 pub lpa_edge_chunk_size: usize,
55 pub note_linking: NoteLinkingConfig,
57 pub link_weight_decay_lambda: f64,
59 pub link_weight_decay_interval_secs: u64,
61 pub belief_revision_enabled: bool,
63 pub belief_revision_similarity_threshold: f32,
65 pub conversation_id: Option<i64>,
68 pub apex_mem_enabled: bool,
70 pub llm_timeout_secs: u64,
72 pub embed_timeout_secs: u64,
74 pub turn_index: Option<u32>,
78 pub write_gate_min_relevance: Option<f32>,
82 pub benna_fast_rate: f32,
86 pub benna_slow_rate: f32,
90 pub provenance: Option<GraphProvenance>,
95 pub recall_include_imported: bool,
100 pub system_prompt: Option<&'static str>,
109}
110
111impl Default for GraphExtractionConfig {
112 fn default() -> Self {
113 Self {
114 max_entities: 0,
115 max_edges: 0,
116 extraction_timeout_secs: 0,
117 community_refresh_interval: 0,
118 expired_edge_retention_days: 0,
119 max_entities_cap: 0,
120 community_summary_max_prompt_bytes: 0,
121 community_summary_concurrency: 0,
122 lpa_edge_chunk_size: 0,
123 note_linking: NoteLinkingConfig::default(),
124 link_weight_decay_lambda: 0.95,
125 link_weight_decay_interval_secs: 86400,
126 belief_revision_enabled: false,
127 belief_revision_similarity_threshold: 0.85,
128 conversation_id: None,
129 apex_mem_enabled: false,
130 llm_timeout_secs: 30,
131 embed_timeout_secs: 5,
132 turn_index: None,
133 write_gate_min_relevance: None,
134 benna_fast_rate: 0.5,
135 benna_slow_rate: 0.05,
136 provenance: None,
137 recall_include_imported: true,
138 system_prompt: None,
139 }
140 }
141}
142
143#[derive(Debug, Default)]
145pub struct ExtractionStats {
146 pub entities_upserted: usize,
147 pub edges_inserted: usize,
148}
149
150#[derive(Debug, Default)]
152pub struct ExtractionResult {
153 pub stats: ExtractionStats,
154 pub entity_ids: Vec<i64>,
156}
157
158#[derive(Debug, Default)]
160pub struct LinkingStats {
161 pub entities_processed: usize,
162 pub edges_created: usize,
163}
164
165const ENTITY_COLLECTION: &str = "zeph_graph_entities";
167
168const DEFAULT_EDGE_CONFIDENCE: f32 = 0.8;
170
171struct EntityWorkItem {
173 entity_id: i64,
174 canonical_name: String,
175 embed_text: String,
176 self_point_id: Option<String>,
177}
178
179pub async fn link_memory_notes(
195 entity_ids: &[i64],
196 pool: DbPool,
197 embedding_store: Arc<EmbeddingStore>,
198 provider: AnyProvider,
199 cfg: &NoteLinkingConfig,
200) -> LinkingStats {
201 use crate::graph::GraphStore;
202
203 let store = GraphStore::new(pool);
204 let mut stats = LinkingStats::default();
205
206 let work_items = collect_note_link_work_items(entity_ids, &store).await;
207 if work_items.is_empty() {
208 return stats;
209 }
210
211 let valid = embed_work_items(&work_items, &provider, cfg).await;
212
213 let search_limit = cfg.top_k + 1; let search_results = search_similar_for_items(&valid, &embedding_store, search_limit).await;
215
216 insert_similarity_edges(
217 &work_items,
218 &valid,
219 &search_results,
220 cfg,
221 &store,
222 &mut stats,
223 )
224 .await;
225
226 stats
227}
228
229async fn collect_note_link_work_items(
233 entity_ids: &[i64],
234 store: &crate::graph::GraphStore,
235) -> Vec<EntityWorkItem> {
236 let mut work_items: Vec<EntityWorkItem> = Vec::with_capacity(entity_ids.len());
237 for &entity_id in entity_ids {
238 let entity = match store.find_entity_by_id(entity_id).await {
239 Ok(Some(e)) => e,
240 Ok(None) => {
241 tracing::debug!("note_linking: entity {entity_id} not found, skipping");
242 continue;
243 }
244 Err(e) => {
245 tracing::debug!("note_linking: DB error loading entity {entity_id}: {e:#}");
246 continue;
247 }
248 };
249 let embed_text = match &entity.summary {
250 Some(s) if !s.is_empty() => format!("{}: {s}", entity.canonical_name),
251 _ => entity.canonical_name.clone(),
252 };
253 work_items.push(EntityWorkItem {
254 entity_id,
255 canonical_name: entity.canonical_name,
256 embed_text,
257 self_point_id: entity.qdrant_point_id,
258 });
259 }
260 work_items
261}
262
263async fn embed_work_items(
268 work_items: &[EntityWorkItem],
269 provider: &AnyProvider,
270 cfg: &NoteLinkingConfig,
271) -> Vec<(usize, Vec<f32>)> {
272 use futures::future;
273
274 let Ok(embed_results) = tokio::time::timeout(
275 std::time::Duration::from_secs(cfg.timeout_secs),
276 future::join_all(work_items.iter().map(|w| provider.embed(&w.embed_text))),
277 )
278 .await
279 else {
280 tracing::warn!(
281 count = work_items.len(),
282 "note_linking: batch embed timed out — skipping all entities"
283 );
284 return Vec::new();
285 };
286 embed_results
287 .into_iter()
288 .enumerate()
289 .filter_map(|(i, r)| match r {
290 Ok(v) => Some((i, v)),
291 Err(e) => {
292 tracing::debug!(
293 "note_linking: embed failed for entity {:?}: {e:#}",
294 work_items[i].canonical_name
295 );
296 None
297 }
298 })
299 .collect()
300}
301
302async fn search_similar_for_items(
304 valid: &[(usize, Vec<f32>)],
305 embedding_store: &EmbeddingStore,
306 search_limit: usize,
307) -> Vec<Result<Vec<crate::ScoredVectorPoint>, MemoryError>> {
308 use futures::future;
309
310 future::join_all(valid.iter().map(|(_, vec)| {
311 embedding_store.search_collection(
312 ENTITY_COLLECTION,
313 vec,
314 search_limit,
315 None::<VectorFilter>,
316 )
317 }))
318 .await
319}
320
321async fn insert_similarity_edges(
326 work_items: &[EntityWorkItem],
327 valid: &[(usize, Vec<f32>)],
328 search_results: &[Result<Vec<crate::ScoredVectorPoint>, MemoryError>],
329 cfg: &NoteLinkingConfig,
330 store: &crate::graph::GraphStore,
331 stats: &mut LinkingStats,
332) {
333 let mut seen_pairs = std::collections::HashSet::new();
334
335 for ((work_idx, _), search_result) in valid.iter().zip(search_results.iter()) {
336 let w = &work_items[*work_idx];
337
338 let results = match search_result {
339 Ok(r) => r,
340 Err(e) => {
341 tracing::debug!(
342 "note_linking: search failed for entity {:?}: {e:#}",
343 w.canonical_name
344 );
345 continue;
346 }
347 };
348
349 stats.entities_processed += 1;
350
351 let self_point_id = w.self_point_id.as_deref();
352 let candidates = results
353 .iter()
354 .filter(|p| Some(p.id.as_str()) != self_point_id && p.score >= cfg.similarity_threshold)
355 .take(cfg.top_k);
356
357 for point in candidates {
358 let Some(payload_entity_id) = point
359 .payload
360 .get("entity_id")
361 .and_then(serde_json::Value::as_i64)
362 else {
363 tracing::debug!(
364 "note_linking: missing entity_id in payload for point {}",
365 point.id
366 );
367 continue;
368 };
369
370 let Some(target_id) =
371 resolve_local_target_id(store, &point.payload, payload_entity_id).await
372 else {
373 continue;
374 };
375
376 if target_id == w.entity_id {
377 continue; }
379
380 let (src, tgt) = if w.entity_id < target_id {
382 (w.entity_id, target_id)
383 } else {
384 (target_id, w.entity_id)
385 };
386
387 if !seen_pairs.insert((src, tgt)) {
388 continue;
389 }
390
391 let fact = format!("Semantically similar entities (score: {:.3})", point.score);
392
393 match store
394 .insert_edge(src, tgt, "similar_to", &fact, point.score, None, None)
395 .await
396 {
397 Ok(_) => stats.edges_created += 1,
398 Err(e) if e.is_foreign_key_violation() => {
399 tracing::warn!(
402 source = src,
403 target = tgt,
404 "note_linking: insert_edge rejected by FK constraint, dropping edge: {e:#}"
405 );
406 }
407 Err(e) => {
408 tracing::debug!("note_linking: insert_edge failed: {e:#}");
409 }
410 }
411 }
412 }
413}
414
415async fn resolve_local_target_id(
435 store: &crate::graph::GraphStore,
436 payload: &std::collections::HashMap<String, serde_json::Value>,
437 payload_entity_id: i64,
438) -> Option<i64> {
439 let Some(canonical_name) = payload.get("canonical_name").and_then(|v| v.as_str()) else {
440 tracing::debug!(
441 payload_entity_id,
442 "note_linking: missing canonical_name in payload, skipping candidate"
443 );
444 return None;
445 };
446 let entity_type = crate::graph::EntityResolver::parse_entity_type(
447 payload
448 .get("entity_type")
449 .and_then(|v| v.as_str())
450 .unwrap_or("concept"),
451 );
452
453 match store.find_entity_by_id(payload_entity_id).await {
456 Ok(Some(entity))
457 if entity.canonical_name == canonical_name && entity.entity_type == entity_type =>
458 {
459 return Some(payload_entity_id);
460 }
461 Ok(_) => {}
462 Err(e) => {
463 tracing::debug!(
464 payload_entity_id,
465 error = %e,
466 "note_linking: find_entity_by_id failed, skipping candidate"
467 );
468 return None;
469 }
470 }
471
472 let resolved = match store.find_entity(canonical_name, entity_type).await {
476 Ok(Some(entity)) => entity.id.0,
477 Ok(None) => {
478 let name = payload
479 .get("name")
480 .and_then(|v| v.as_str())
481 .unwrap_or(canonical_name);
482 let summary = payload.get("summary").and_then(|v| v.as_str());
483 match store
484 .upsert_entity(name, canonical_name, entity_type, summary, None)
485 .await
486 {
487 Ok(id) => id.0,
488 Err(e) => {
489 tracing::debug!(
490 canonical_name,
491 error = %e,
492 "note_linking: failed to create local entity for stale candidate, skipping"
493 );
494 return None;
495 }
496 }
497 }
498 Err(e) => {
499 tracing::debug!(
500 canonical_name,
501 error = %e,
502 "note_linking: find_entity failed while re-resolving candidate, skipping"
503 );
504 return None;
505 }
506 };
507
508 tracing::warn!(
509 payload_entity_id,
510 resolved_entity_id = resolved,
511 canonical_name,
512 "note_linking: Qdrant entity_id payload did not match local SQLite row \
513 (cross-DB or stale resolution) — corrected to the locally authoritative id"
514 );
515 Some(resolved)
516}
517
518#[cfg_attr(
529 feature = "profiling",
530 tracing::instrument(name = "memory.graph_extract", skip_all, fields(entities = tracing::field::Empty, edges = tracing::field::Empty))
531)]
532pub async fn extract_and_store(
533 content: String,
534 context_messages: Vec<String>,
535 provider: AnyProvider,
536 pool: DbPool,
537 config: GraphExtractionConfig,
538 post_extract_validator: PostExtractValidator,
539 embedding_store: Option<Arc<EmbeddingStore>>,
540) -> Result<ExtractionResult, MemoryError> {
541 use crate::graph::{EntityResolver, GraphExtractor, GraphStore};
542
543 let mut extractor = GraphExtractor::new(
544 provider.clone(),
545 config.max_entities,
546 config.max_edges,
547 config.llm_timeout_secs,
548 );
549 if let Some(prompt) = config.system_prompt {
550 extractor = extractor.with_system_prompt(prompt);
551 }
552 let ctx_refs: Vec<&str> = context_messages.iter().map(String::as_str).collect();
553
554 let store = GraphStore::new(pool)
555 .with_benna_rates(config.benna_fast_rate, config.benna_slow_rate)
556 .with_recall_include_imported(config.recall_include_imported);
557
558 bump_extraction_count(store.pool()).await?;
559
560 let Some(result) = extractor.extract(&content, &ctx_refs).await? else {
561 return Ok(ExtractionResult::default());
562 };
563
564 if let Some(ref validator) = post_extract_validator
569 && let Err(reason) = validator(&result)
570 {
571 tracing::warn!(
572 reason,
573 "graph extraction validation failed, skipping upsert"
574 );
575 return Err(MemoryError::ValidationRejected(reason));
576 }
577
578 let resolver = if let Some(ref emb) = embedding_store {
579 EntityResolver::new(&store)
580 .with_embedding_store(emb)
581 .with_provider(&provider)
582 .with_embed_timeout(config.embed_timeout_secs)
583 .with_llm_timeout(config.llm_timeout_secs)
584 } else {
585 EntityResolver::new(&store)
586 .with_embed_timeout(config.embed_timeout_secs)
587 .with_llm_timeout(config.llm_timeout_secs)
588 };
589
590 let (entity_name_to_id, entities_upserted) =
591 upsert_entities(&resolver, &result.entities, config.provenance.as_ref()).await;
592 let edges_inserted = insert_edges(&resolver, &result.edges, &entity_name_to_id, &config).await;
593
594 #[cfg(any(feature = "sqlite", feature = "postgres"))]
595 store.checkpoint_wal().await?;
596
597 let new_entity_ids: Vec<i64> = entity_name_to_id.into_values().collect();
598
599 link_episode(&store, &config, &new_entity_ids).await;
600
601 #[cfg(feature = "profiling")]
602 {
603 let span = tracing::Span::current();
604 span.record("entities", entities_upserted);
605 span.record("edges", edges_inserted);
606 }
607
608 Ok(ExtractionResult {
609 stats: ExtractionStats {
610 entities_upserted,
611 edges_inserted,
612 },
613 entity_ids: new_entity_ids,
614 })
615}
616
617async fn bump_extraction_count(pool: &DbPool) -> Result<(), MemoryError> {
619 zeph_db::query(sql!(
620 "INSERT INTO graph_metadata (key, value) VALUES ('extraction_count', '0')
621 ON CONFLICT(key) DO NOTHING"
622 ))
623 .execute(pool)
624 .await?;
625 zeph_db::query(sql!(
626 "UPDATE graph_metadata
627 SET value = CAST(CAST(value AS INTEGER) + 1 AS TEXT)
628 WHERE key = 'extraction_count'"
629 ))
630 .execute(pool)
631 .await?;
632 Ok(())
633}
634
635async fn upsert_entities(
637 resolver: &crate::graph::EntityResolver<'_>,
638 entities: &[crate::graph::extractor::ExtractedEntity],
639 provenance: Option<&GraphProvenance>,
640) -> (std::collections::HashMap<String, i64>, usize) {
641 let mut entity_name_to_id: std::collections::HashMap<String, i64> =
642 std::collections::HashMap::new();
643 let mut entities_upserted = 0usize;
644
645 for entity in entities {
646 match resolver
647 .resolve(
648 &entity.name,
649 &entity.entity_type,
650 entity.summary.as_deref(),
651 provenance,
652 )
653 .await
654 {
655 Ok((id, _outcome)) => {
656 entity_name_to_id.insert(entity.name.clone(), id);
657 entities_upserted += 1;
658 }
659 Err(e) => {
660 tracing::debug!("graph: skipping entity {:?}: {e:#}", entity.name);
661 }
662 }
663 }
664
665 (entity_name_to_id, entities_upserted)
666}
667
668fn is_low_signal_relation(relation: &str) -> bool {
672 const LOW_SIGNAL: &[&str] = &[
673 "related_to",
674 "related to",
675 "is related to",
676 "associated_with",
677 "associated with",
678 "has",
679 "have",
680 "is",
681 "are",
682 "mentions",
683 "mentioned",
684 "involves",
685 "involved",
686 ];
687 LOW_SIGNAL.iter().any(|&s| relation.eq_ignore_ascii_case(s))
688}
689
690#[allow(clippy::too_many_lines)]
694async fn insert_edges(
695 resolver: &crate::graph::EntityResolver<'_>,
696 edges: &[crate::graph::extractor::ExtractedEdge],
697 name_to_id: &std::collections::HashMap<String, i64>,
698 config: &GraphExtractionConfig,
699) -> usize {
700 let mut edges_inserted = 0usize;
701 for edge in edges {
702 if let Some(min_rel) = config.write_gate_min_relevance {
704 let conf = edge.confidence.unwrap_or(1.0);
705 if conf < min_rel && is_low_signal_relation(&edge.relation) {
706 tracing::debug!(
707 relation = %edge.relation,
708 confidence = conf,
709 threshold = min_rel,
710 "write-gate: skipping low-signal edge"
711 );
712 continue;
713 }
714 }
715 let (Some(&src_id), Some(&tgt_id)) =
716 (name_to_id.get(&edge.source), name_to_id.get(&edge.target))
717 else {
718 tracing::debug!(
719 "graph: skipping edge {:?}->{:?}: entity not resolved",
720 edge.source,
721 edge.target
722 );
723 continue;
724 };
725 if src_id == tgt_id {
726 tracing::debug!(
727 "graph: skipping self-loop edge {:?}->{:?} (entity_id={src_id})",
728 edge.source,
729 edge.target
730 );
731 continue;
732 }
733 let edge_type = edge
736 .edge_type
737 .parse::<crate::graph::EdgeType>()
738 .unwrap_or_else(|_| {
739 tracing::warn!(
740 raw_type = %edge.edge_type,
741 "graph: unknown edge_type from LLM, defaulting to semantic"
742 );
743 crate::graph::EdgeType::Semantic
744 });
745 if config.apex_mem_enabled {
746 let relation_trimmed = edge.relation.trim();
748 let relation_display_clean = strip_control_chars(relation_trimmed);
749 let relation_display =
750 truncate_to_bytes_ref(&relation_display_clean, MAX_RELATION_BYTES).to_owned();
751 let canonical_relation = sanitize_relation(relation_trimmed);
752 let normalized_fact = sanitize_fact(&edge.fact);
753 match resolver
754 .graph_store()
755 .insert_or_supersede_with_turn_index_and_metrics(
756 src_id,
757 tgt_id,
758 &relation_display,
759 &canonical_relation,
760 &normalized_fact,
761 edge.confidence.unwrap_or(DEFAULT_EDGE_CONFIDENCE),
762 None,
763 edge_type,
764 true,
765 None,
766 config.turn_index,
767 config.provenance.as_ref(),
768 )
769 .await
770 {
771 Ok(_) => edges_inserted += 1,
772 Err(e) if e.is_foreign_key_violation() => {
773 tracing::warn!(
776 source = src_id,
777 target = tgt_id,
778 "graph: edge insert (apex) rejected by FK constraint, dropping edge: {e:#}"
779 );
780 }
781 Err(e) => {
782 tracing::debug!("graph: skipping edge (apex): {e:#}");
783 }
784 }
785 } else {
786 let belief_cfg =
787 config
788 .belief_revision_enabled
789 .then_some(crate::graph::BeliefRevisionConfig {
790 similarity_threshold: config.belief_revision_similarity_threshold,
791 });
792 match resolver
793 .resolve_edge_typed(
794 src_id,
795 tgt_id,
796 &edge.relation,
797 &edge.fact,
798 edge.confidence.unwrap_or(DEFAULT_EDGE_CONFIDENCE),
799 None,
800 edge_type,
801 belief_cfg.as_ref(),
802 config.turn_index,
803 config.provenance.as_ref(),
804 )
805 .await
806 {
807 Ok(Some(_)) => edges_inserted += 1,
808 Ok(None) => {} Err(e) if e.is_foreign_key_violation() => {
810 tracing::warn!(
813 source = src_id,
814 target = tgt_id,
815 "graph: edge insert rejected by FK constraint, dropping edge: {e:#}"
816 );
817 }
818 Err(e) => {
819 tracing::debug!("graph: skipping edge: {e:#}");
820 }
821 }
822 }
823 }
824 edges_inserted
825}
826
827async fn link_episode(
829 store: &crate::graph::GraphStore,
830 config: &GraphExtractionConfig,
831 entity_ids: &[i64],
832) {
833 let Some(conv_id) = config.conversation_id else {
834 return;
835 };
836 match store.ensure_episode(conv_id).await {
837 Ok(episode_id) => {
838 for &entity_id in entity_ids {
839 if let Err(e) = store.link_entity_to_episode(episode_id, entity_id).await {
840 tracing::debug!("episode linking skipped for entity {entity_id}: {e:#}");
841 }
842 }
843 }
844 Err(e) => {
845 tracing::warn!("failed to ensure episode for conversation {conv_id}: {e:#}");
846 }
847 }
848}
849
850impl SemanticMemory {
851 pub fn spawn_graph_extraction(
868 &self,
869 content: String,
870 context_messages: Vec<String>,
871 config: GraphExtractionConfig,
872 post_extract_validator: PostExtractValidator,
873 provider_override: Option<AnyProvider>,
874 cancel: CancellationToken,
875 ) -> tokio::task::JoinHandle<()> {
876 let using_override = provider_override.is_some();
877 let provider = provider_override.unwrap_or_else(|| self.provider.clone());
878 if using_override {
879 tracing::debug!(
880 extract_provider = provider.name(),
881 "graph extraction using override provider (quality_gate bypassed)"
882 );
883 }
884 {
885 let mut tokens = self
886 .graph_cancel
887 .lock()
888 .expect("graph_cancel mutex poisoned");
889 tokens.retain(|t| !t.is_cancelled());
890 tokens.push(cancel.clone());
891 }
892
893 let ctx = GraphExtractionTaskCtx {
894 pool: self.sqlite.pool().clone(),
895 provider,
896 failure_counter: self.community_detection_failures.clone(),
897 extraction_count: self.graph_extraction_count.clone(),
898 extraction_failures: self.graph_extraction_failures.clone(),
899 embedding_store: self.qdrant.clone(),
900 cancel,
901 };
902
903 let extraction_fut = run_graph_extraction_task(
904 content,
905 context_messages,
906 config,
907 post_extract_validator,
908 ctx,
909 );
910 tokio::spawn(extraction_fut) }
912
913 pub fn cancel_graph_extraction(&self) {
931 let tokens = std::mem::take(
932 &mut *self
933 .graph_cancel
934 .lock()
935 .expect("graph_cancel mutex poisoned"),
936 );
937 for token in tokens {
938 token.cancel();
939 }
940 }
941}
942
943struct GraphExtractionTaskCtx {
947 pool: DbPool,
948 provider: AnyProvider,
949 failure_counter: Arc<std::sync::atomic::AtomicU64>,
950 extraction_count: Arc<std::sync::atomic::AtomicU64>,
951 extraction_failures: Arc<std::sync::atomic::AtomicU64>,
952 embedding_store: Option<Arc<EmbeddingStore>>,
953 cancel: CancellationToken,
955}
956
957async fn run_graph_extraction_task(
959 content: String,
960 context_messages: Vec<String>,
961 config: GraphExtractionConfig,
962 post_extract_validator: PostExtractValidator,
963 ctx: GraphExtractionTaskCtx,
964) {
965 let timeout_dur = std::time::Duration::from_secs(config.extraction_timeout_secs);
966 let extraction_result = tokio::time::timeout(
967 timeout_dur,
968 extract_and_store(
969 content,
970 context_messages,
971 ctx.provider.clone(),
972 ctx.pool.clone(),
973 config.clone(),
974 post_extract_validator,
975 ctx.embedding_store.clone(),
976 ),
977 )
978 .await;
979
980 let (extraction_ok, new_entity_ids) = match extraction_result {
981 Ok(Ok(result)) => {
982 tracing::debug!(
983 entities = result.stats.entities_upserted,
984 edges = result.stats.edges_inserted,
985 "graph extraction completed"
986 );
987 ctx.extraction_count.fetch_add(1, Ordering::Relaxed);
988 (true, result.entity_ids)
989 }
990 Ok(Err(e)) => {
991 tracing::warn!("graph extraction failed: {e:#}");
992 ctx.extraction_failures.fetch_add(1, Ordering::Relaxed);
993 (false, vec![])
994 }
995 Err(_elapsed) => {
996 tracing::warn!("graph extraction timed out");
997 ctx.extraction_failures.fetch_add(1, Ordering::Relaxed);
998 (false, vec![])
999 }
1000 };
1001
1002 run_note_linking(
1003 extraction_ok,
1004 &new_entity_ids,
1005 ctx.pool.clone(),
1006 ctx.embedding_store,
1007 ctx.provider.clone(),
1008 &config,
1009 )
1010 .await;
1011
1012 let cancel = ctx.cancel.clone();
1013 maybe_refresh_communities(
1014 extraction_ok,
1015 ctx.pool,
1016 ctx.provider,
1017 ctx.failure_counter,
1018 &config,
1019 ctx.cancel,
1020 )
1021 .await;
1022
1023 cancel.cancel();
1027}
1028
1029async fn run_note_linking(
1031 extraction_ok: bool,
1032 new_entity_ids: &[i64],
1033 pool: DbPool,
1034 embedding_store: Option<Arc<EmbeddingStore>>,
1035 provider: AnyProvider,
1036 config: &GraphExtractionConfig,
1037) {
1038 if !extraction_ok || !config.note_linking.enabled || new_entity_ids.is_empty() {
1039 return;
1040 }
1041 let Some(store) = embedding_store else {
1042 return;
1043 };
1044 let linking_timeout = std::time::Duration::from_secs(config.note_linking.timeout_secs);
1045 match tokio::time::timeout(
1046 linking_timeout,
1047 link_memory_notes(new_entity_ids, pool, store, provider, &config.note_linking),
1048 )
1049 .await
1050 {
1051 Ok(stats) => {
1052 tracing::debug!(
1053 entities_processed = stats.entities_processed,
1054 edges_created = stats.edges_created,
1055 "note linking completed"
1056 );
1057 }
1058 Err(_elapsed) => {
1059 tracing::debug!("note linking timed out (partial edges may exist)");
1060 }
1061 }
1062}
1063
1064async fn maybe_refresh_communities(
1071 extraction_ok: bool,
1072 pool: DbPool,
1073 provider: AnyProvider,
1074 failure_counter: Arc<std::sync::atomic::AtomicU64>,
1075 config: &GraphExtractionConfig,
1076 cancel: CancellationToken,
1077) {
1078 use crate::graph::GraphStore;
1079
1080 if !extraction_ok || config.community_refresh_interval == 0 {
1081 return;
1082 }
1083
1084 let store = GraphStore::new(pool.clone());
1085 let extraction_count = store.extraction_count().await.unwrap_or(0);
1086 if extraction_count == 0
1087 || !i64::try_from(config.community_refresh_interval)
1088 .is_ok_and(|interval| extraction_count % interval == 0)
1089 {
1090 return;
1091 }
1092
1093 tracing::info!(extraction_count, "triggering community detection refresh");
1094 let store2 = GraphStore::new(pool);
1095 let retention_days = config.expired_edge_retention_days;
1096 let max_cap = config.max_entities_cap;
1097 let max_prompt_bytes = config.community_summary_max_prompt_bytes;
1098 let concurrency = config.community_summary_concurrency;
1099 let edge_chunk_size = config.lpa_edge_chunk_size;
1100 let decay_lambda = config.link_weight_decay_lambda;
1101 let decay_interval_secs = config.link_weight_decay_interval_secs;
1102
1103 tokio::select! {
1104 () = cancel.cancelled() => {
1105 tracing::debug!("community refresh cancelled before community detection");
1106 return;
1107 }
1108 result = crate::graph::community::detect_communities(
1109 &store2,
1110 &provider,
1111 max_prompt_bytes,
1112 concurrency,
1113 edge_chunk_size,
1114 ) => {
1115 match result {
1116 Ok(count) => {
1117 tracing::info!(communities = count, "community detection complete");
1118 }
1119 Err(e) => {
1120 tracing::warn!("community detection failed: {e:#}");
1121 failure_counter.fetch_add(1, Ordering::Relaxed);
1122 }
1123 }
1124 }
1125 }
1126
1127 tokio::select! {
1128 () = cancel.cancelled() => {
1129 tracing::debug!("community refresh cancelled before graph eviction");
1130 return;
1131 }
1132 result = crate::graph::community::run_graph_eviction(&store2, retention_days, max_cap) => {
1133 match result {
1134 Ok(stats) => {
1135 tracing::info!(
1136 expired_edges = stats.expired_edges_deleted,
1137 orphan_entities = stats.orphan_entities_deleted,
1138 capped_entities = stats.capped_entities_deleted,
1139 "graph eviction complete"
1140 );
1141 }
1142 Err(e) => {
1143 tracing::warn!("graph eviction failed: {e:#}");
1144 }
1145 }
1146 }
1147 }
1148
1149 if decay_lambda > 0.0 && decay_interval_secs > 0 {
1151 let now_secs = std::time::SystemTime::now()
1152 .duration_since(std::time::UNIX_EPOCH)
1153 .map_or(0, |d| d.as_secs());
1154 let last_decay = store2
1155 .get_metadata("last_link_weight_decay_at")
1156 .await
1157 .ok()
1158 .flatten()
1159 .and_then(|s| s.parse::<u64>().ok())
1160 .unwrap_or(0);
1161 if now_secs.saturating_sub(last_decay) >= decay_interval_secs {
1162 tokio::select! {
1163 () = cancel.cancelled() => {
1164 tracing::debug!("community refresh cancelled before link weight decay");
1165 }
1166 result = store2.decay_edge_retrieval_counts(decay_lambda, decay_interval_secs) => {
1167 match result {
1168 Ok(affected) => {
1169 tracing::info!(affected, "link weight decay applied");
1170 let _ = store2
1171 .set_metadata("last_link_weight_decay_at", &now_secs.to_string())
1172 .await;
1173 }
1174 Err(e) => {
1175 tracing::warn!("link weight decay failed: {e:#}");
1176 }
1177 }
1178 }
1179 }
1180 }
1181 }
1182}
1183
1184enum DocOutcome {
1189 Skipped(String),
1190 Done {
1191 uri: String,
1192 entities: usize,
1193 edges: usize,
1194 },
1195 Failed {
1196 uri: String,
1197 reason: String,
1198 },
1199 Rejected {
1205 uri: String,
1206 reason: String,
1207 },
1208 DryRun {
1210 uri: String,
1211 entities: usize,
1212 edges: usize,
1213 entity_degrees: Vec<(String, usize)>,
1214 },
1215}
1216
1217impl SemanticMemory {
1218 #[allow(clippy::too_many_lines)]
1219 pub async fn ingest_documents(
1272 &self,
1273 documents: Vec<crate::graph::ingest::IngestDocument>,
1274 config: IngestBatchConfig,
1275 batch_id: crate::graph::ingest::ImportBatchId,
1276 concurrency: usize,
1277 post_extract_validator: SharedPostExtractValidator,
1279 progress: Option<tokio::sync::mpsc::Sender<crate::graph::ingest::IngestProgress>>,
1280 ) -> Result<crate::graph::ingest::IngestReport, MemoryError> {
1281 use std::collections::HashSet;
1282
1283 use futures::StreamExt as _;
1284 use tracing::Instrument as _;
1285
1286 use crate::graph::GraphExtractor;
1287 use crate::graph::ingest::{
1288 HubDegree, IngestDocument, IngestFailure, IngestProgress, IngestReport,
1289 IngestSourceKind,
1290 };
1291
1292 let span = tracing::info_span!(
1293 "memory.ingest.batch",
1294 batch_id = %batch_id,
1295 doc_count = documents.len(),
1296 concurrency = concurrency,
1297 dry_run = config.dry_run,
1298 );
1299
1300 let send_progress = |evt: IngestProgress| {
1301 if let Some(ref tx) = progress {
1302 let _ = tx.try_send(evt);
1303 }
1304 };
1305
1306 let mut seen: HashSet<(String, String)> = HashSet::new();
1309 let mut deduped: Vec<IngestDocument> = Vec::with_capacity(documents.len());
1310 let mut pre_skipped: Vec<String> = Vec::new();
1311 for doc in documents {
1312 let key = (doc.source_uri().to_owned(), doc.content_hash().to_owned());
1313 if seen.contains(&key) {
1314 pre_skipped.push(doc.source_uri().to_owned());
1315 } else {
1316 seen.insert(key);
1317 deduped.push(doc);
1318 }
1319 }
1320
1321 let documents_total = deduped.len();
1322 send_progress(IngestProgress::Started {
1323 total: documents_total,
1324 });
1325
1326 let init_skipped = pre_skipped.len();
1328 for uri in pre_skipped {
1329 send_progress(IngestProgress::DocumentSkipped { uri });
1330 }
1331
1332 let pool = self.sqlite().pool().clone();
1333 let provider = self.provider().clone();
1334 let embedding_store = self.embedding_store().cloned();
1335 let dry_run = config.dry_run;
1336 let max_content_bytes = config.max_content_bytes;
1337
1338 let shared_validator = post_extract_validator;
1340
1341 let concurrency = concurrency.max(1); let ledger = crate::graph::ingest::IngestLedger::new(pool.clone());
1345
1346 let stream = futures::stream::iter(deduped).map(|doc| {
1347 let pool = pool.clone();
1348 let provider = provider.clone();
1349 let embedding_store = embedding_store.clone();
1350 let base_config = config.extraction.clone();
1351 let ledger = ledger.clone();
1352 let batch_id_str = batch_id.as_str().to_owned();
1353 let shared_validator = shared_validator.clone();
1354
1355 {
1356 let uri = doc.source_uri().to_owned();
1357 let doc_span = tracing::info_span!("memory.ingest.document", uri = %uri);
1358 async move {
1359 let hash = doc.content_hash().to_owned();
1360
1361 if doc.content().len() > max_content_bytes {
1363 return DocOutcome::Failed {
1364 uri,
1365 reason: format!(
1366 "content exceeds size cap ({} > {max_content_bytes} bytes)",
1367 doc.content().len()
1368 ),
1369 };
1370 }
1371
1372 match ledger.is_ingested(&uri, &hash).await {
1374 Ok(true) => return DocOutcome::Skipped(uri),
1375 Ok(false) => {}
1376 Err(e) => {
1377 return DocOutcome::Failed {
1378 uri,
1379 reason: format!("ledger check failed: {e:#}"),
1380 }
1381 }
1382 }
1383
1384 let mut doc_config = base_config.clone();
1390 doc_config.provenance = Some(doc.provenance().clone());
1391 if let Some(ref mut prov) = doc_config.provenance {
1394 prov.import_batch_id.clone_from(&batch_id_str);
1395 }
1396 doc_config.system_prompt = Some(IngestSourceKind::SubagentTranscript.system_prompt());
1399
1400 let validator: PostExtractValidator = shared_validator
1402 .as_ref()
1403 .map(|arc_f| -> Box<dyn Fn(&_) -> _ + Send> {
1404 let f = arc_f.clone();
1405 Box::new(move |r| f(r))
1406 });
1407
1408 if dry_run {
1409 let extractor = {
1414 let mut e = GraphExtractor::new(
1415 provider.clone(),
1416 doc_config.max_entities,
1417 doc_config.max_edges,
1418 doc_config.llm_timeout_secs,
1419 );
1420 if let Some(prompt) = doc_config.system_prompt {
1421 e = e.with_system_prompt(prompt);
1422 }
1423 e
1424 };
1425 let ctx_refs: Vec<&str> =
1426 doc.context().iter().map(String::as_str).collect();
1427 match extractor.extract(doc.content(), &ctx_refs).await {
1428 Ok(Some(result)) => {
1429 if let Some(ref arc_f) = shared_validator
1431 && let Err(reason) = arc_f(&result)
1432 {
1433 return DocOutcome::Rejected { uri, reason };
1434 }
1435 let entities = result.entities.len();
1436 let edges = result.edges.len();
1437 let mut degree_map: std::collections::HashMap<String, usize> =
1439 std::collections::HashMap::new();
1440 for edge in &result.edges {
1441 *degree_map.entry(edge.source.clone()).or_default() += 1;
1442 *degree_map.entry(edge.target.clone()).or_default() += 1;
1443 }
1444 let entity_degrees: Vec<(String, usize)> =
1445 degree_map.into_iter().collect();
1446 DocOutcome::DryRun {
1447 uri,
1448 entities,
1449 edges,
1450 entity_degrees,
1451 }
1452 }
1453 Ok(None) => DocOutcome::DryRun {
1454 uri,
1455 entities: 0,
1456 edges: 0,
1457 entity_degrees: vec![],
1458 },
1459 Err(e) => DocOutcome::Failed {
1460 uri,
1461 reason: format!("dry-run extraction failed: {e:#}"),
1462 },
1463 }
1464 } else {
1465 let ctx_msgs: Vec<String> = doc.context().to_vec();
1466 match extract_and_store(
1467 doc.content().to_owned(),
1468 ctx_msgs,
1469 provider,
1470 pool,
1471 doc_config,
1472 validator,
1473 embedding_store,
1474 )
1475 .await
1476 {
1477 Ok(result) => {
1478 let entities = result.stats.entities_upserted;
1479 let edges = result.stats.edges_inserted;
1480 if let Err(e) = ledger
1481 .mark_ingested(
1482 &uri,
1483 &hash,
1484 &batch_id_str,
1485 i64::try_from(entities).unwrap_or(i64::MAX),
1486 i64::try_from(edges).unwrap_or(i64::MAX),
1487 )
1488 .await
1489 {
1490 tracing::warn!(
1491 uri = %uri,
1492 "ingest: mark_ingested failed (doc stored, ledger not updated): {e:#}"
1493 );
1494 }
1495 DocOutcome::Done { uri, entities, edges }
1496 }
1497 Err(MemoryError::ValidationRejected(reason)) => {
1499 DocOutcome::Rejected { uri, reason }
1500 }
1501 Err(e) => DocOutcome::Failed {
1502 uri,
1503 reason: format!("extract_and_store failed: {e:#}"),
1504 },
1505 }
1506 }
1507 }
1508 .instrument(doc_span)
1509 }
1510 });
1511
1512 let outcomes: Vec<DocOutcome> = stream
1514 .buffer_unordered(concurrency)
1515 .collect()
1516 .instrument(span)
1517 .await;
1518
1519 let mut report = IngestReport {
1521 batch_id: batch_id.as_str().to_owned(),
1522 documents_total,
1523 skipped: init_skipped,
1524 dry_run,
1525 ..IngestReport::default()
1526 };
1527
1528 let mut global_degrees: std::collections::HashMap<String, usize> =
1530 std::collections::HashMap::new();
1531
1532 for outcome in outcomes {
1533 match outcome {
1534 DocOutcome::Skipped(uri) => {
1535 report.skipped += 1;
1536 send_progress(IngestProgress::DocumentSkipped { uri });
1537 }
1538 DocOutcome::Done {
1539 uri,
1540 entities,
1541 edges,
1542 } => {
1543 report.succeeded += 1;
1544 report.entities_total += entities;
1545 report.edges_total += edges;
1546 send_progress(IngestProgress::DocumentDone {
1547 uri,
1548 entities,
1549 edges,
1550 });
1551 }
1552 DocOutcome::Failed { uri, reason } => {
1553 report.failed.push(IngestFailure {
1554 uri: uri.clone(),
1555 reason: reason.clone(),
1556 });
1557 send_progress(IngestProgress::DocumentFailed { uri, reason });
1558 }
1559 DocOutcome::Rejected { uri, reason } => {
1560 report.rejected += 1;
1561 send_progress(IngestProgress::DocumentRejected { uri, reason });
1562 }
1563 DocOutcome::DryRun {
1564 uri,
1565 entities,
1566 edges,
1567 entity_degrees,
1568 } => {
1569 report.succeeded += 1;
1570 report.entities_total += entities;
1571 report.edges_total += edges;
1572 for (name, deg) in entity_degrees {
1573 *global_degrees.entry(name).or_default() += deg;
1574 }
1575 send_progress(IngestProgress::DocumentDone {
1576 uri,
1577 entities,
1578 edges,
1579 });
1580 }
1581 }
1582 }
1583
1584 if dry_run {
1585 let top_n = config.dry_run_hub_top_n.unwrap_or(10);
1587 let mut degrees: Vec<(String, usize)> = global_degrees.into_iter().collect();
1588 degrees.sort_unstable_by_key(|&(_, deg)| std::cmp::Reverse(deg));
1589 degrees.truncate(top_n);
1590 report.hub_degree = degrees
1591 .into_iter()
1592 .map(|(entity, degree)| HubDegree { entity, degree })
1593 .collect();
1594 }
1595
1596 send_progress(IngestProgress::Finished);
1597 Ok(report)
1598 }
1599}
1600
1601#[derive(Debug, Clone)]
1605pub struct IngestBatchConfig {
1606 pub extraction: GraphExtractionConfig,
1611 pub dry_run: bool,
1616 pub dry_run_hub_top_n: Option<usize>,
1620 pub max_content_bytes: usize,
1628}
1629
1630impl Default for IngestBatchConfig {
1631 fn default() -> Self {
1632 Self {
1633 extraction: GraphExtractionConfig::default(),
1634 dry_run: false,
1635 dry_run_hub_top_n: None,
1636 max_content_bytes: 512 * 1024,
1637 }
1638 }
1639}
1640
1641#[cfg(test)]
1642mod tests {
1643 use std::sync::Arc;
1644
1645 use zeph_llm::any::AnyProvider;
1646
1647 use super::{NoteLinkingConfig, extract_and_store};
1648 use crate::embedding_store::EmbeddingStore;
1649 use crate::graph::GraphStore;
1650 use crate::in_memory_store::InMemoryVectorStore;
1651 use crate::store::SqliteStore;
1652
1653 use super::GraphExtractionConfig;
1654
1655 async fn setup() -> (GraphStore, Arc<EmbeddingStore>) {
1656 let sqlite = SqliteStore::new(":memory:").await.unwrap();
1657 let pool = sqlite.pool().clone();
1658 let mem_store = Box::new(InMemoryVectorStore::new());
1659 let emb = Arc::new(EmbeddingStore::with_store(mem_store, pool.clone()));
1660 let gs = GraphStore::new(pool);
1661 (gs, emb)
1662 }
1663
1664 #[tokio::test]
1667 async fn extract_and_store_sets_qdrant_point_id_when_embedding_store_provided() {
1668 let (gs, emb) = setup().await;
1669
1670 let extraction_json = r#"{"entities":[{"name":"Rust","type":"language","summary":"systems language"}],"edges":[]}"#;
1672 let mut mock =
1673 zeph_llm::mock::MockProvider::with_responses(vec![extraction_json.to_owned()]);
1674 mock.supports_embeddings = true;
1675 mock.embedding = vec![1.0_f32, 0.0, 0.0, 0.0];
1676 let provider = AnyProvider::Mock(mock);
1677
1678 let config = GraphExtractionConfig {
1679 max_entities: 10,
1680 max_edges: 10,
1681 extraction_timeout_secs: 10,
1682 ..Default::default()
1683 };
1684
1685 let result = extract_and_store(
1686 "Rust is a systems programming language.".to_owned(),
1687 vec![],
1688 provider,
1689 gs.pool().clone(),
1690 config,
1691 None,
1692 Some(emb.clone()),
1693 )
1694 .await
1695 .unwrap();
1696
1697 assert_eq!(
1698 result.stats.entities_upserted, 1,
1699 "one entity should be upserted"
1700 );
1701
1702 let entity = gs
1706 .find_entity("rust", crate::graph::EntityType::Language)
1707 .await
1708 .unwrap()
1709 .expect("entity 'rust' must exist in SQLite");
1710
1711 assert!(
1712 entity.qdrant_point_id.is_some(),
1713 "qdrant_point_id must be set when embedding_store + provider are both provided (regression for #1829)"
1714 );
1715 }
1716
1717 #[tokio::test]
1720 async fn extract_and_store_without_embedding_store_still_upserts_entities() {
1721 let (gs, _emb) = setup().await;
1722
1723 let extraction_json = r#"{"entities":[{"name":"Python","type":"language","summary":"scripting"}],"edges":[]}"#;
1724 let mock = zeph_llm::mock::MockProvider::with_responses(vec![extraction_json.to_owned()]);
1725 let provider = AnyProvider::Mock(mock);
1726
1727 let config = GraphExtractionConfig {
1728 max_entities: 10,
1729 max_edges: 10,
1730 extraction_timeout_secs: 10,
1731 ..Default::default()
1732 };
1733
1734 let result = extract_and_store(
1735 "Python is a scripting language.".to_owned(),
1736 vec![],
1737 provider,
1738 gs.pool().clone(),
1739 config,
1740 None,
1741 None, )
1743 .await
1744 .unwrap();
1745
1746 assert_eq!(result.stats.entities_upserted, 1);
1747
1748 let entity = gs
1749 .find_entity("python", crate::graph::EntityType::Language)
1750 .await
1751 .unwrap()
1752 .expect("entity 'python' must exist");
1753
1754 assert!(
1755 entity.qdrant_point_id.is_none(),
1756 "qdrant_point_id must remain None when no embedding_store is provided"
1757 );
1758 }
1759
1760 #[tokio::test]
1764 async fn extract_and_store_fts5_cross_session_visibility() {
1765 let file = tempfile::NamedTempFile::new().expect("tempfile");
1766 let path = file.path().to_str().expect("valid path").to_string();
1767
1768 {
1770 let sqlite = crate::store::SqliteStore::new(&path).await.unwrap();
1771 let extraction_json = r#"{"entities":[{"name":"Ferris","type":"concept","summary":"Rust mascot"}],"edges":[]}"#;
1772 let mock =
1773 zeph_llm::mock::MockProvider::with_responses(vec![extraction_json.to_owned()]);
1774 let provider = AnyProvider::Mock(mock);
1775 let config = GraphExtractionConfig {
1776 max_entities: 10,
1777 max_edges: 10,
1778 extraction_timeout_secs: 10,
1779 ..Default::default()
1780 };
1781 extract_and_store(
1782 "Ferris is the Rust mascot.".to_owned(),
1783 vec![],
1784 provider,
1785 sqlite.pool().clone(),
1786 config,
1787 None,
1788 None,
1789 )
1790 .await
1791 .unwrap();
1792 }
1793
1794 let sqlite_b = crate::store::SqliteStore::new(&path).await.unwrap();
1796 let gs_b = crate::graph::GraphStore::new(sqlite_b.pool().clone());
1797 let results = gs_b.find_entities_fuzzy("Ferris", 10).await.unwrap();
1798 assert!(
1799 !results.is_empty(),
1800 "FTS5 cross-session (#2166): entity extracted in session A must be visible in session B"
1801 );
1802 }
1803
1804 #[tokio::test]
1807 async fn extract_and_store_skips_self_loop_edges() {
1808 let (gs, _emb) = setup().await;
1809
1810 let extraction_json = r#"{
1812 "entities":[{"name":"Rust","type":"language","summary":"systems language"}],
1813 "edges":[{"source":"Rust","target":"Rust","relation":"is","fact":"Rust is Rust","edge_type":"semantic"}]
1814 }"#;
1815 let mock = zeph_llm::mock::MockProvider::with_responses(vec![extraction_json.to_owned()]);
1816 let provider = AnyProvider::Mock(mock);
1817
1818 let config = GraphExtractionConfig {
1819 max_entities: 10,
1820 max_edges: 10,
1821 extraction_timeout_secs: 10,
1822 ..Default::default()
1823 };
1824
1825 let result = extract_and_store(
1826 "Rust is a language.".to_owned(),
1827 vec![],
1828 provider,
1829 gs.pool().clone(),
1830 config,
1831 None,
1832 None,
1833 )
1834 .await
1835 .unwrap();
1836
1837 assert_eq!(result.stats.entities_upserted, 1);
1838 assert_eq!(
1839 result.stats.edges_inserted, 0,
1840 "self-loop edge must be rejected (#2215)"
1841 );
1842 }
1843
1844 #[tokio::test]
1849 async fn apex_mem_path_inserts_edge_via_insert_or_supersede() {
1850 let (gs, _emb) = setup().await;
1851
1852 let extraction_json = r#"{
1853 "entities":[
1854 {"name":"Alice","type":"person","summary":"a person"},
1855 {"name":"Bob","type":"person","summary":"another person"}
1856 ],
1857 "edges":[
1858 {"source":"Alice","target":"Bob","relation":"KNOWS","fact":"Alice knows Bob","edge_type":"semantic"}
1859 ]
1860 }"#;
1861 let mock = zeph_llm::mock::MockProvider::with_responses(vec![extraction_json.to_owned()]);
1862 let provider = AnyProvider::Mock(mock);
1863
1864 let config = GraphExtractionConfig {
1865 max_entities: 10,
1866 max_edges: 10,
1867 extraction_timeout_secs: 10,
1868 apex_mem_enabled: true,
1869 ..Default::default()
1870 };
1871
1872 let result = extract_and_store(
1873 "Alice knows Bob.".to_owned(),
1874 vec![],
1875 provider,
1876 gs.pool().clone(),
1877 config,
1878 None,
1879 None,
1880 )
1881 .await
1882 .unwrap();
1883
1884 assert_eq!(result.stats.entities_upserted, 2, "two entities expected");
1885 assert_eq!(
1886 result.stats.edges_inserted, 1,
1887 "APEX-MEM path must insert the edge and count it (#3631)"
1888 );
1889
1890 let alice_id = gs
1892 .find_entity("alice", crate::graph::EntityType::Person)
1893 .await
1894 .unwrap()
1895 .expect("entity 'alice' must exist")
1896 .id
1897 .0;
1898 let bob_id = gs
1899 .find_entity("bob", crate::graph::EntityType::Person)
1900 .await
1901 .unwrap()
1902 .expect("entity 'bob' must exist")
1903 .id
1904 .0;
1905 let edges = gs.edges_exact(alice_id, bob_id).await.unwrap();
1906 assert_eq!(edges.len(), 1, "exactly one edge expected");
1907 assert_eq!(
1909 edges[0].relation, "KNOWS",
1910 "display relation must preserve original casing"
1911 );
1912 }
1913
1914 #[tokio::test]
1917 async fn embed_work_items_timeout_returns_empty() {
1918 use zeph_llm::mock::MockProvider;
1919
1920 tokio::time::pause();
1923
1924 let mut mock = MockProvider::default();
1926 mock.supports_embeddings = true;
1927 mock.embed_delay_ms = 31_000;
1928 let provider = AnyProvider::Mock(mock);
1929
1930 let work_items = vec![super::EntityWorkItem {
1931 entity_id: 1,
1932 canonical_name: "Alice".to_owned(),
1933 embed_text: "Alice".to_owned(),
1934 self_point_id: None,
1935 }];
1936
1937 let cfg = NoteLinkingConfig {
1938 timeout_secs: 30,
1939 ..NoteLinkingConfig::default()
1940 };
1941 let result = super::embed_work_items(&work_items, &provider, &cfg).await;
1942 assert!(
1943 result.is_empty(),
1944 "embed_work_items must return empty Vec on 30 s timeout (fail-open)"
1945 );
1946 }
1947
1948 #[tokio::test]
1955 async fn maybe_refresh_communities_respects_cancelled_token() {
1956 use tokio_util::sync::CancellationToken;
1957
1958 use crate::graph::GraphStore;
1959 use crate::store::SqliteStore;
1960
1961 let sqlite = SqliteStore::new(":memory:").await.unwrap();
1962 let pool = sqlite.pool().clone();
1963 let gs = GraphStore::new(pool.clone());
1964
1965 gs.set_metadata("extraction_count", "1").await.unwrap();
1967
1968 let config = GraphExtractionConfig {
1969 community_refresh_interval: 1,
1970 ..Default::default()
1971 };
1972
1973 let cancel = CancellationToken::new();
1974 cancel.cancel(); let extraction_json = r#"{"entities":[],"edges":[]}"#;
1977 let mock = zeph_llm::mock::MockProvider::with_responses(vec![extraction_json.to_owned()]);
1978 let provider = AnyProvider::Mock(mock);
1979
1980 let failure_counter = Arc::new(std::sync::atomic::AtomicU64::new(0));
1981
1982 super::maybe_refresh_communities(
1985 true,
1986 pool,
1987 provider,
1988 failure_counter.clone(),
1989 &config,
1990 cancel,
1991 )
1992 .await;
1993
1994 assert_eq!(
1995 failure_counter.load(std::sync::atomic::Ordering::Relaxed),
1996 0,
1997 "no failures should be recorded when cancelled before any detection step"
1998 );
1999 }
2000
2001 #[test]
2002 fn is_low_signal_known_values() {
2003 assert!(
2004 super::is_low_signal_relation("related_to"),
2005 "related_to must be low-signal"
2006 );
2007 assert!(
2008 super::is_low_signal_relation("related to"),
2009 "related to (space) must be low-signal"
2010 );
2011 assert!(
2012 super::is_low_signal_relation("IS"),
2013 "IS (uppercase) must be low-signal (case-insensitive)"
2014 );
2015 assert!(
2016 super::is_low_signal_relation("mentions"),
2017 "mentions must be low-signal"
2018 );
2019 }
2020
2021 #[test]
2022 fn is_low_signal_specific_relations_pass() {
2023 assert!(
2024 !super::is_low_signal_relation("causes"),
2025 "causes must NOT be low-signal"
2026 );
2027 assert!(
2028 !super::is_low_signal_relation("works_at"),
2029 "works_at must NOT be low-signal"
2030 );
2031 assert!(
2032 !super::is_low_signal_relation("born_in"),
2033 "born_in must NOT be low-signal"
2034 );
2035 }
2036
2037 #[tokio::test]
2051 async fn extract_and_store_respects_configured_benna_rates() {
2052 use crate::graph::EdgeType;
2053
2054 async fn run_two_inserts(fast_rate: f32, slow_rate: f32) -> crate::graph::types::Edge {
2055 let sqlite = crate::store::SqliteStore::new(":memory:").await.unwrap();
2056 let pool = sqlite.pool().clone();
2057 let gs = GraphStore::new(pool).with_benna_rates(fast_rate, slow_rate);
2058
2059 let alice_id = gs
2060 .upsert_entity(
2061 "Alice",
2062 "alice",
2063 crate::graph::EntityType::Person,
2064 None,
2065 None,
2066 )
2067 .await
2068 .unwrap();
2069 let bob_id = gs
2070 .upsert_entity("Bob", "bob", crate::graph::EntityType::Person, None, None)
2071 .await
2072 .unwrap();
2073
2074 gs.insert_edge_typed(
2076 alice_id.0,
2077 bob_id.0,
2078 "knows",
2079 "Alice knows Bob",
2080 0.6,
2081 None,
2082 EdgeType::Semantic,
2083 None,
2084 None,
2085 )
2086 .await
2087 .unwrap();
2088
2089 gs.insert_edge_typed(
2091 alice_id.0,
2092 bob_id.0,
2093 "knows",
2094 "Alice knows Bob",
2095 0.8,
2096 None,
2097 EdgeType::Semantic,
2098 None,
2099 None,
2100 )
2101 .await
2102 .unwrap();
2103
2104 let mut edges = gs.edges_exact(alice_id.0, bob_id.0).await.unwrap();
2105 assert_eq!(edges.len(), 1, "exactly one active edge expected");
2106 edges.remove(0)
2107 }
2108
2109 let default_edge = run_two_inserts(0.5, 0.05).await;
2110 let custom_edge = run_two_inserts(0.1, 0.02).await;
2111
2112 assert!(
2115 (default_edge.confidence_fast - custom_edge.confidence_fast).abs() > f32::EPSILON,
2116 "confidence_fast must differ between default (0.5) and custom (0.1) benna_fast_rate (#4711)"
2117 );
2118 assert!(
2119 (default_edge.confidence_slow - custom_edge.confidence_slow).abs() > f32::EPSILON,
2120 "confidence_slow must differ between default (0.05) and custom (0.02) benna_slow_rate (#4711)"
2121 );
2122 assert!(
2124 default_edge.confidence_fast > custom_edge.confidence_fast,
2125 "higher benna_fast_rate must produce a larger confidence_fast after merge"
2126 );
2127 }
2128
2129 #[tokio::test]
2136 async fn extract_and_store_forwards_edge_confidence_not_hardcoded_08() {
2137 use crate::graph::{EntityType, GraphStore};
2138
2139 let sqlite = crate::store::SqliteStore::new(":memory:").await.unwrap();
2140 let pool = sqlite.pool().clone();
2141
2142 let extraction_json = r#"{
2144 "entities":[
2145 {"name":"Alice","type":"person","summary":"person"},
2146 {"name":"Bob","type":"person","summary":"person"}
2147 ],
2148 "edges":[{"source":"Alice","target":"Bob","relation":"knows","fact":"Alice knows Bob","edge_type":"semantic","confidence":0.3}]
2149 }"#;
2150 let mock = zeph_llm::mock::MockProvider::with_responses(vec![extraction_json.to_owned()]);
2151 let provider = AnyProvider::Mock(mock);
2152 let config = GraphExtractionConfig {
2153 max_entities: 10,
2154 max_edges: 10,
2155 extraction_timeout_secs: 10,
2156 ..Default::default()
2157 };
2158
2159 let result = extract_and_store(
2160 "Alice knows Bob.".to_owned(),
2161 vec![],
2162 provider,
2163 pool.clone(),
2164 config,
2165 None,
2166 None,
2167 )
2168 .await
2169 .unwrap();
2170
2171 assert_eq!(result.stats.edges_inserted, 1, "one edge must be inserted");
2172
2173 let gs = GraphStore::new(pool);
2174 let alice_id: i64 = gs
2175 .find_entity("alice", EntityType::Person)
2176 .await
2177 .unwrap()
2178 .expect("alice must exist")
2179 .id
2180 .0;
2181 let bob_id: i64 = gs
2182 .find_entity("bob", EntityType::Person)
2183 .await
2184 .unwrap()
2185 .expect("bob must exist")
2186 .id
2187 .0;
2188
2189 let mut edges = gs.edges_exact(alice_id, bob_id).await.unwrap();
2190 assert_eq!(edges.len(), 1, "exactly one active edge expected");
2191 let edge = edges.remove(0);
2192
2193 assert!(
2195 (edge.confidence_fast - 0.3_f32).abs() < 0.01,
2196 "confidence_fast must be ~0.3 (from ExtractedEdge.confidence), got {} (regression for #4723)",
2197 edge.confidence_fast
2198 );
2199 }
2200
2201 #[tokio::test]
2205 async fn extract_and_store_apex_forwards_edge_confidence_not_hardcoded_08() {
2206 use crate::graph::{EntityType, GraphStore};
2207
2208 let sqlite = crate::store::SqliteStore::new(":memory:").await.unwrap();
2209 let pool = sqlite.pool().clone();
2210
2211 let extraction_json = r#"{
2213 "entities":[
2214 {"name":"Alice","type":"person","summary":"person"},
2215 {"name":"Bob","type":"person","summary":"person"}
2216 ],
2217 "edges":[{"source":"Alice","target":"Bob","relation":"knows","fact":"Alice knows Bob","edge_type":"semantic","confidence":0.3}]
2218 }"#;
2219 let mock = zeph_llm::mock::MockProvider::with_responses(vec![extraction_json.to_owned()]);
2220 let provider = AnyProvider::Mock(mock);
2221 let config = GraphExtractionConfig {
2222 max_entities: 10,
2223 max_edges: 10,
2224 extraction_timeout_secs: 10,
2225 apex_mem_enabled: true,
2226 ..Default::default()
2227 };
2228
2229 let result = extract_and_store(
2230 "Alice knows Bob.".to_owned(),
2231 vec![],
2232 provider,
2233 pool.clone(),
2234 config,
2235 None,
2236 None,
2237 )
2238 .await
2239 .unwrap();
2240
2241 assert_eq!(result.stats.edges_inserted, 1, "one edge must be inserted");
2242
2243 let gs = GraphStore::new(pool);
2244 let alice_id: i64 = gs
2245 .find_entity("alice", EntityType::Person)
2246 .await
2247 .unwrap()
2248 .expect("alice must exist")
2249 .id
2250 .0;
2251 let bob_id: i64 = gs
2252 .find_entity("bob", EntityType::Person)
2253 .await
2254 .unwrap()
2255 .expect("bob must exist")
2256 .id
2257 .0;
2258
2259 let mut edges = gs.edges_exact(alice_id, bob_id).await.unwrap();
2260 assert_eq!(edges.len(), 1, "exactly one active edge expected");
2261 let edge = edges.remove(0);
2262
2263 assert!(
2265 (edge.confidence_fast - 0.3_f32).abs() < 0.01,
2266 "confidence_fast must be ~0.3 (from ExtractedEdge.confidence), got {} (regression for #4723 APEX path)",
2267 edge.confidence_fast
2268 );
2269 }
2270
2271 #[tokio::test]
2280 async fn extract_and_store_persists_turn_index_to_graph_edges() {
2281 use crate::graph::{EntityType, GraphStore};
2282
2283 let sqlite = crate::store::SqliteStore::new(":memory:").await.unwrap();
2284 let pool = sqlite.pool().clone();
2285
2286 let extraction_json = r#"{
2287 "entities":[
2288 {"name":"Alice","type":"person","summary":"person"},
2289 {"name":"Bob","type":"person","summary":"person"}
2290 ],
2291 "edges":[{"source":"Alice","target":"Bob","relation":"knows","fact":"Alice knows Bob","edge_type":"semantic","confidence":0.9}]
2292 }"#;
2293 let mock = zeph_llm::mock::MockProvider::with_responses(vec![extraction_json.to_owned()]);
2294 let provider = AnyProvider::Mock(mock);
2295 let config = GraphExtractionConfig {
2296 max_entities: 10,
2297 max_edges: 10,
2298 extraction_timeout_secs: 10,
2299 turn_index: Some(7),
2300 apex_mem_enabled: true,
2301 ..Default::default()
2302 };
2303
2304 let result = extract_and_store(
2305 "Alice knows Bob.".to_owned(),
2306 vec![],
2307 provider,
2308 pool.clone(),
2309 config,
2310 None,
2311 None,
2312 )
2313 .await
2314 .unwrap();
2315
2316 assert_eq!(result.stats.edges_inserted, 1, "one edge must be inserted");
2317
2318 let gs = GraphStore::new(pool);
2319 let alice_id: i64 = gs
2320 .find_entity("alice", EntityType::Person)
2321 .await
2322 .unwrap()
2323 .expect("alice must exist")
2324 .id
2325 .0;
2326 let bob_id: i64 = gs
2327 .find_entity("bob", EntityType::Person)
2328 .await
2329 .unwrap()
2330 .expect("bob must exist")
2331 .id
2332 .0;
2333
2334 let mut edges = gs.edges_exact(alice_id, bob_id).await.unwrap();
2335 assert_eq!(edges.len(), 1, "exactly one active edge expected");
2336 let edge = edges.remove(0);
2337
2338 assert_eq!(
2339 edge.turn_index,
2340 Some(7),
2341 "turn_index from GraphExtractionConfig must reach the persisted graph_edges row \
2342 (regression for #5784 — was always NULL on the live per-turn extraction path)"
2343 );
2344 }
2345
2346 #[tokio::test]
2355 async fn extract_and_store_non_apex_path_persists_turn_index() {
2356 use crate::graph::{EntityType, GraphStore};
2357
2358 let sqlite = crate::store::SqliteStore::new(":memory:").await.unwrap();
2359 let pool = sqlite.pool().clone();
2360
2361 let extraction_json = r#"{
2362 "entities":[
2363 {"name":"Carol","type":"person","summary":"person"},
2364 {"name":"Dave","type":"person","summary":"person"}
2365 ],
2366 "edges":[{"source":"Carol","target":"Dave","relation":"knows","fact":"Carol knows Dave","edge_type":"semantic","confidence":0.9}]
2367 }"#;
2368 let mock = zeph_llm::mock::MockProvider::with_responses(vec![extraction_json.to_owned()]);
2369 let provider = AnyProvider::Mock(mock);
2370 let config = GraphExtractionConfig {
2371 max_entities: 10,
2372 max_edges: 10,
2373 extraction_timeout_secs: 10,
2374 turn_index: Some(9),
2375 ..Default::default()
2377 };
2378
2379 let result = extract_and_store(
2380 "Carol knows Dave.".to_owned(),
2381 vec![],
2382 provider,
2383 pool.clone(),
2384 config,
2385 None,
2386 None,
2387 )
2388 .await
2389 .unwrap();
2390
2391 assert_eq!(result.stats.edges_inserted, 1, "one edge must be inserted");
2392
2393 let gs = GraphStore::new(pool);
2394 let carol_id: i64 = gs
2395 .find_entity("carol", EntityType::Person)
2396 .await
2397 .unwrap()
2398 .expect("carol must exist")
2399 .id
2400 .0;
2401 let dave_id: i64 = gs
2402 .find_entity("dave", EntityType::Person)
2403 .await
2404 .unwrap()
2405 .expect("dave must exist")
2406 .id
2407 .0;
2408
2409 let mut edges = gs.edges_exact(carol_id, dave_id).await.unwrap();
2410 assert_eq!(edges.len(), 1, "exactly one active edge expected");
2411 let edge = edges.remove(0);
2412
2413 assert_eq!(
2414 edge.turn_index,
2415 Some(9),
2416 "turn_index from GraphExtractionConfig must reach the persisted graph_edges row \
2417 on the default (apex_mem.enabled=false) path too, not just APEX-MEM (#5784)"
2418 );
2419 }
2420
2421 #[test]
2426 fn graph_extraction_config_benna_defaults_match_graph_store_defaults() {
2427 let cfg = GraphExtractionConfig::default();
2428 assert!(
2430 (cfg.benna_fast_rate - 0.5_f32).abs() < f32::EPSILON,
2431 "benna_fast_rate default must match GraphStore::new default of 0.5"
2432 );
2433 assert!(
2434 (cfg.benna_slow_rate - 0.05_f32).abs() < f32::EPSILON,
2435 "benna_slow_rate default must match GraphStore::new default of 0.05"
2436 );
2437 }
2438
2439 async fn make_semantic_memory(mock_response: Option<&str>) -> super::SemanticMemory {
2440 use zeph_llm::any::AnyProvider;
2441 use zeph_llm::mock::MockProvider;
2442 let responses = mock_response
2443 .map(|r| vec![r.to_owned()])
2444 .unwrap_or_default();
2445 let provider = if responses.is_empty() {
2446 AnyProvider::Mock(MockProvider::default())
2447 } else {
2448 AnyProvider::Mock(MockProvider::with_responses(responses))
2449 };
2450 super::SemanticMemory::with_weights_and_pool_size(
2451 ":memory:", "", None, provider, "", 0.7, 0.3, 1,
2452 )
2453 .await
2454 .expect("SemanticMemory init")
2455 }
2456
2457 fn make_doc(
2458 content: &str,
2459 task_id: &str,
2460 batch_id: &crate::graph::ingest::ImportBatchId,
2461 ) -> crate::graph::ingest::IngestDocument {
2462 use crate::graph::ingest::SubagentJsonl;
2463 use crate::graph::ingest::adapter::IngestSourceAdapter as _;
2464 let raw = format!(
2465 r#"{{"seq":0,"timestamp":null,"message":{{"role":"user","content":{content:?},"parts":[]}}}}"#,
2466 );
2467 let adapter = SubagentJsonl::new(task_id);
2468 adapter.parse(&raw, batch_id).unwrap().remove(0)
2469 }
2470
2471 #[tokio::test]
2472 async fn ingest_documents_happy_path() {
2473 use super::IngestBatchConfig;
2474 use crate::graph::ingest::ImportBatchId;
2475
2476 let extraction_json = r#"{"entities":[{"name":"Rust","type":"language","summary":"systems language"}],"edges":[]}"#;
2477 let memory = make_semantic_memory(Some(extraction_json)).await;
2478
2479 let batch_id = ImportBatchId::new();
2480 let doc = make_doc("Rust is a systems language.", "task-happy-path", &batch_id);
2481 let config = IngestBatchConfig::default();
2482
2483 let report = memory
2484 .ingest_documents(vec![doc], config, batch_id.clone(), 1, None, None)
2485 .await
2486 .expect("ingest should succeed");
2487
2488 assert_eq!(report.succeeded, 1, "one document should succeed");
2489 assert!(report.failed.is_empty(), "no failures expected");
2490 assert!(!report.dry_run);
2491
2492 let pool = memory.sqlite().pool().clone();
2494 let rows: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM knowledge_ingest_ledger")
2495 .fetch_one(&pool)
2496 .await
2497 .unwrap();
2498 assert_eq!(rows, 1, "ledger must have one row after successful ingest");
2499 }
2500
2501 #[tokio::test]
2502 async fn ingest_documents_c1_intra_batch_dedup() {
2503 use super::IngestBatchConfig;
2504 use crate::graph::ingest::ImportBatchId;
2505
2506 let extraction_json = r#"{"entities":[{"name":"Tokio","type":"library","summary":"async runtime"}],"edges":[]}"#;
2508 let memory = make_semantic_memory(Some(extraction_json)).await;
2509
2510 let batch_id = ImportBatchId::new();
2511 let doc1 = make_doc("Tokio is an async runtime.", "task-dedup", &batch_id);
2514 let doc2 = make_doc("Tokio is an async runtime.", "task-dedup", &batch_id);
2515
2516 let report = memory
2517 .ingest_documents(
2518 vec![doc1, doc2],
2519 IngestBatchConfig::default(),
2520 batch_id,
2521 2,
2522 None,
2523 None,
2524 )
2525 .await
2526 .expect("ingest should succeed");
2527
2528 assert_eq!(
2529 report.succeeded, 1,
2530 "only one document should be processed (C1 dedup)"
2531 );
2532 assert_eq!(report.skipped, 1, "duplicate must be skipped");
2533 }
2534
2535 #[tokio::test]
2536 async fn ingest_documents_c2_dry_run_via_method() {
2537 use super::IngestBatchConfig;
2538 use crate::graph::ingest::ImportBatchId;
2539
2540 let extraction_json = r#"{"entities":[{"name":"Serde","type":"library","summary":"serialization"}],"edges":[]}"#;
2541 let memory = make_semantic_memory(Some(extraction_json)).await;
2542
2543 let batch_id = ImportBatchId::new();
2544 let doc = make_doc(
2545 "Serde is a serialization library.",
2546 "task-dry-run-method",
2547 &batch_id,
2548 );
2549 let config = IngestBatchConfig {
2550 dry_run: true,
2551 ..IngestBatchConfig::default()
2552 };
2553
2554 let report = memory
2555 .ingest_documents(vec![doc], config, batch_id, 1, None, None)
2556 .await
2557 .expect("dry-run should succeed");
2558
2559 assert!(report.dry_run, "report must reflect dry-run mode");
2560 assert_eq!(
2561 report.succeeded, 1,
2562 "dry-run counts the document as processed"
2563 );
2564
2565 let pool = memory.sqlite().pool().clone();
2566 let rows: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM knowledge_ingest_ledger")
2567 .fetch_one(&pool)
2568 .await
2569 .unwrap_or(0);
2570 assert_eq!(
2571 rows, 0,
2572 "dry-run must write nothing to the ledger (FR-026 / C2)"
2573 );
2574
2575 let count: i64 = sqlx::query_scalar(
2576 "SELECT CAST(value AS INTEGER) FROM graph_metadata WHERE key = 'extraction_count'",
2577 )
2578 .fetch_one(&pool)
2579 .await
2580 .unwrap_or(0);
2581 assert_eq!(count, 0, "dry-run must not increment extraction_count");
2582 }
2583
2584 #[tokio::test]
2589 async fn ingest_documents_hub_degree_accumulates_across_documents() {
2590 use zeph_llm::any::AnyProvider;
2591 use zeph_llm::mock::MockProvider;
2592
2593 use super::{GraphExtractionConfig, IngestBatchConfig};
2594 use crate::graph::ingest::ImportBatchId;
2595
2596 let json1 = r#"{"entities":[{"name":"Widget","type":"tool","summary":null},{"name":"Foo","type":"concept","summary":null}],"edges":[{"source":"Widget","target":"Foo","relation":"depends_on","fact":"Widget depends on Foo","edge_type":"semantic"}]}"#;
2597 let json2 = r#"{"entities":[{"name":"Widget","type":"tool","summary":null},{"name":"Bar","type":"concept","summary":null}],"edges":[{"source":"Widget","target":"Bar","relation":"depends_on","fact":"Widget depends on Bar","edge_type":"semantic"}]}"#;
2598
2599 let provider = AnyProvider::Mock(MockProvider::with_responses(vec![
2600 json1.to_owned(),
2601 json2.to_owned(),
2602 ]));
2603 let memory = super::SemanticMemory::with_weights_and_pool_size(
2604 ":memory:", "", None, provider, "", 0.7, 0.3, 1,
2605 )
2606 .await
2607 .expect("SemanticMemory init");
2608
2609 let batch_id = ImportBatchId::new();
2610 let doc1 = make_doc("Widget depends on Foo.", "task-hub-1", &batch_id);
2611 let doc2 = make_doc("Widget depends on Bar.", "task-hub-2", &batch_id);
2612 let config = IngestBatchConfig {
2613 dry_run: true,
2614 extraction: GraphExtractionConfig {
2615 max_entities: 10,
2616 max_edges: 10,
2617 extraction_timeout_secs: 10,
2618 ..GraphExtractionConfig::default()
2619 },
2620 ..IngestBatchConfig::default()
2621 };
2622
2623 let report = memory
2626 .ingest_documents(vec![doc1, doc2], config, batch_id, 1, None, None)
2627 .await
2628 .expect("dry-run ingest should succeed");
2629
2630 assert!(report.dry_run);
2631 assert_eq!(report.succeeded, 2, "both documents should be processed");
2632
2633 let widget = report
2634 .hub_degree
2635 .iter()
2636 .find(|h| h.entity == "Widget")
2637 .expect("Widget must be present in hub_degree");
2638 assert_eq!(
2639 widget.degree, 2,
2640 "Widget's degree must accumulate across both documents (1 edge per doc)"
2641 );
2642 assert_eq!(
2643 report.hub_degree[0].entity, "Widget",
2644 "top-N must be sorted descending by accumulated degree"
2645 );
2646 }
2647
2648 #[tokio::test]
2651 async fn ingest_documents_hub_degree_respects_top_n_truncation() {
2652 use super::{GraphExtractionConfig, IngestBatchConfig};
2653 use crate::graph::ingest::ImportBatchId;
2654
2655 let extraction_json = r#"{"entities":[
2657 {"name":"A","type":"concept","summary":null},
2658 {"name":"B","type":"concept","summary":null},
2659 {"name":"C","type":"concept","summary":null},
2660 {"name":"D","type":"concept","summary":null}
2661 ],"edges":[
2662 {"source":"A","target":"B","relation":"depends_on","fact":"A depends on B","edge_type":"semantic"},
2663 {"source":"A","target":"C","relation":"depends_on","fact":"A depends on C","edge_type":"semantic"},
2664 {"source":"A","target":"D","relation":"depends_on","fact":"A depends on D","edge_type":"semantic"},
2665 {"source":"B","target":"C","relation":"depends_on","fact":"B depends on C","edge_type":"semantic"}
2666 ]}"#;
2667 let memory = make_semantic_memory(Some(extraction_json)).await;
2668
2669 let batch_id = ImportBatchId::new();
2670 let doc = make_doc("A depends on B, C, and D.", "task-hub-topn", &batch_id);
2671 let config = IngestBatchConfig {
2672 dry_run: true,
2673 dry_run_hub_top_n: Some(2),
2674 extraction: GraphExtractionConfig {
2675 max_entities: 10,
2676 max_edges: 10,
2677 extraction_timeout_secs: 10,
2678 ..GraphExtractionConfig::default()
2679 },
2680 ..IngestBatchConfig::default()
2681 };
2682
2683 let report = memory
2684 .ingest_documents(vec![doc], config, batch_id, 1, None, None)
2685 .await
2686 .expect("dry-run ingest should succeed");
2687
2688 assert_eq!(
2689 report.hub_degree.len(),
2690 2,
2691 "hub_degree must be truncated to dry_run_hub_top_n"
2692 );
2693 assert_eq!(
2694 report.hub_degree[0].entity, "A",
2695 "the highest-degree entity must rank first"
2696 );
2697 assert_eq!(report.hub_degree[0].degree, 3);
2698 assert_eq!(
2699 report.hub_degree[1].degree, 2,
2700 "second place is whichever of B/C ties at degree 2"
2701 );
2702 }
2703
2704 #[tokio::test]
2705 async fn ingest_documents_collect_errors_and_continue() {
2706 use super::IngestBatchConfig;
2707 use crate::graph::ingest::ImportBatchId;
2708
2709 let extraction_json = r#"{"entities":[{"name":"Axum","type":"library","summary":"web framework"}],"edges":[]}"#;
2710 let memory = make_semantic_memory(Some(extraction_json)).await;
2711
2712 let batch_id = ImportBatchId::new();
2713 let good_doc = make_doc("Axum is a web framework.", "task-errors-good", &batch_id);
2715 let oversized_content = "x".repeat(600 * 1024);
2716 let bad_doc = make_doc(&oversized_content, "task-errors-bad", &batch_id);
2717
2718 let config = IngestBatchConfig::default(); let report = memory
2721 .ingest_documents(vec![good_doc, bad_doc], config, batch_id, 2, None, None)
2722 .await
2723 .expect("batch must return Ok even when one document fails (FR-028)");
2724
2725 assert_eq!(report.succeeded, 1, "good document should succeed");
2726 assert_eq!(report.failed.len(), 1, "oversized document should fail");
2727 assert!(
2728 report.failed[0].reason.contains("size cap"),
2729 "failure reason must mention size cap: {}",
2730 report.failed[0].reason
2731 );
2732 }
2733
2734 #[tokio::test]
2738 async fn dry_run_writes_nothing_to_db() {
2739 use zeph_llm::any::AnyProvider;
2740 use zeph_llm::mock::MockProvider;
2741
2742 use crate::graph::ingest::adapter::IngestSourceAdapter;
2743 use crate::graph::ingest::{ImportBatchId, SubagentJsonl};
2744 use crate::store::SqliteStore;
2745
2746 use super::{GraphExtractionConfig, IngestBatchConfig};
2747
2748 let sqlite = SqliteStore::new(":memory:").await.unwrap();
2749 let pool = sqlite.pool().clone();
2750
2751 sqlx::query(
2753 "CREATE TABLE IF NOT EXISTS knowledge_ingest_ledger (\
2754 source_uri TEXT NOT NULL, \
2755 content_hash TEXT NOT NULL, \
2756 import_batch_id TEXT NOT NULL, \
2757 ingested_at TEXT NOT NULL DEFAULT (datetime('now')), \
2758 entities INTEGER NOT NULL DEFAULT 0, \
2759 edges INTEGER NOT NULL DEFAULT 0, \
2760 PRIMARY KEY (source_uri, content_hash))",
2761 )
2762 .execute(&pool)
2763 .await
2764 .unwrap();
2765
2766 sqlx::query(
2767 "CREATE TABLE IF NOT EXISTS graph_metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
2768 )
2769 .execute(&pool)
2770 .await
2771 .unwrap();
2772
2773 sqlx::query("INSERT INTO graph_metadata (key, value) VALUES ('extraction_count', '0')")
2775 .execute(&pool)
2776 .await
2777 .unwrap();
2778
2779 let provider = AnyProvider::Mock(MockProvider::default());
2781
2782 let raw = concat!(
2788 r#"{"seq":0,"timestamp":null,"message":{"role":"user","content":"Rust uses ownership","parts":[]}}"#,
2789 "\n",
2790 r#"{"seq":1,"timestamp":null,"message":{"role":"user","content":"Tokio is an async runtime","parts":[]}}"#,
2791 );
2792 let adapter = SubagentJsonl::new("task-dry-run");
2793 let batch_id = ImportBatchId::new();
2794 let docs = adapter.parse(raw, &batch_id).unwrap();
2795 assert_eq!(docs.len(), 2);
2796
2797 let config = IngestBatchConfig {
2799 extraction: GraphExtractionConfig {
2800 max_entities: 5,
2801 max_edges: 10,
2802 llm_timeout_secs: 5,
2803 ..GraphExtractionConfig::default()
2804 },
2805 dry_run: true,
2806 ..IngestBatchConfig::default()
2807 };
2808
2809 for doc in &docs {
2811 use crate::graph::GraphExtractor;
2812 use crate::graph::ingest::IngestSourceKind;
2813 let extractor = GraphExtractor::new(
2814 provider.clone(),
2815 config.extraction.max_entities,
2816 config.extraction.max_edges,
2817 config.extraction.llm_timeout_secs,
2818 )
2819 .with_system_prompt(IngestSourceKind::StaticArtifact.system_prompt());
2820
2821 let ctx_refs: Vec<&str> = doc.context().iter().map(String::as_str).collect();
2822 let _ = extractor.extract(doc.content(), &ctx_refs).await.unwrap();
2823 }
2824
2825 let count: i64 = sqlx::query_scalar(
2827 "SELECT CAST(value AS INTEGER) FROM graph_metadata WHERE key = 'extraction_count'",
2828 )
2829 .fetch_one(&pool)
2830 .await
2831 .unwrap();
2832 assert_eq!(
2833 count, 0,
2834 "dry-run must not increment extraction_count (FR-026 / C2)"
2835 );
2836
2837 let ledger_rows: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM knowledge_ingest_ledger")
2839 .fetch_one(&pool)
2840 .await
2841 .unwrap();
2842 assert_eq!(
2843 ledger_rows, 0,
2844 "dry-run must not write to knowledge_ingest_ledger (FR-026 / C2)"
2845 );
2846 }
2847
2848 #[tokio::test]
2859 #[tracing_test::traced_test]
2860 async fn insert_edges_apex_mem_logs_warn_on_genuine_fk_violation() {
2861 use crate::graph::EntityResolver;
2862 use crate::graph::extractor::ExtractedEdge;
2863
2864 let (gs, _emb) = setup().await;
2865 let resolver = EntityResolver::new(&gs);
2866
2867 let mut name_to_id = std::collections::HashMap::new();
2869 name_to_id.insert("Ghost A".to_owned(), 111_111_111);
2870 name_to_id.insert("Ghost B".to_owned(), 222_222_222);
2871
2872 let edges = vec![ExtractedEdge {
2873 source: "Ghost A".to_owned(),
2874 target: "Ghost B".to_owned(),
2875 relation: "knows".to_owned(),
2876 fact: "Ghost A knows Ghost B".to_owned(),
2877 temporal_hint: None,
2878 edge_type: "semantic".to_owned(),
2879 confidence: Some(0.9),
2880 }];
2881
2882 let config = GraphExtractionConfig {
2883 apex_mem_enabled: true,
2884 ..Default::default()
2885 };
2886
2887 let inserted = super::insert_edges(&resolver, &edges, &name_to_id, &config).await;
2888
2889 assert_eq!(
2890 inserted, 0,
2891 "edge rejected by the FK constraint must not be counted as inserted"
2892 );
2893 assert!(
2894 logs_contain("graph: edge insert (apex) rejected by FK constraint, dropping edge"),
2895 "APEX-MEM FK-violation WARN must fire when both endpoints are locally nonexistent"
2896 );
2897 }
2898
2899 #[tokio::test]
2900 #[tracing_test::traced_test]
2901 async fn insert_edges_legacy_logs_warn_on_genuine_fk_violation() {
2902 use crate::graph::EntityResolver;
2903 use crate::graph::extractor::ExtractedEdge;
2904
2905 let (gs, _emb) = setup().await;
2906 let resolver = EntityResolver::new(&gs);
2907
2908 let mut name_to_id = std::collections::HashMap::new();
2910 name_to_id.insert("Ghost C".to_owned(), 333_333_333);
2911 name_to_id.insert("Ghost D".to_owned(), 444_444_444);
2912
2913 let edges = vec![ExtractedEdge {
2914 source: "Ghost C".to_owned(),
2915 target: "Ghost D".to_owned(),
2916 relation: "knows".to_owned(),
2917 fact: "Ghost C knows Ghost D".to_owned(),
2918 temporal_hint: None,
2919 edge_type: "semantic".to_owned(),
2920 confidence: Some(0.9),
2921 }];
2922
2923 let config = GraphExtractionConfig {
2924 apex_mem_enabled: false,
2925 ..Default::default()
2926 };
2927
2928 let inserted = super::insert_edges(&resolver, &edges, &name_to_id, &config).await;
2929
2930 assert_eq!(
2931 inserted, 0,
2932 "edge rejected by the FK constraint must not be counted as inserted"
2933 );
2934 assert!(
2935 logs_contain("graph: edge insert rejected by FK constraint, dropping edge"),
2936 "legacy-path FK-violation WARN must fire when both endpoints are locally nonexistent"
2937 );
2938 }
2939}