Skip to main content

zeph_memory/semantic/
graph.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use 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
27/// Callback type for post-extraction validation.
28///
29/// A generic predicate opaque to zeph-memory — callers (zeph-core) provide security
30/// validation without introducing a dependency on security policy in this crate.
31pub type PostExtractValidator = Option<Box<dyn Fn(&ExtractorResult) -> Result<(), String> + Send>>;
32
33/// Shared post-extraction validator for concurrent batch ingest (spec-067 FR-022, C3).
34///
35/// `Arc<dyn Fn + Send + Sync>` allows cheap cloning across `buffer_unordered` tasks without
36/// requiring `unsafe`. Pass `None` to skip validation.
37pub type SharedPostExtractValidator =
38    Option<Arc<dyn Fn(&ExtractorResult) -> Result<(), String> + Send + Sync>>;
39
40/// Config for the spawned background extraction task.
41///
42/// Owned clone of the relevant fields from `GraphConfig` — no references, safe to send to
43/// spawned tasks.
44#[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    /// A-MEM note linking config, cloned from `GraphConfig.note_linking`.
56    pub note_linking: NoteLinkingConfig,
57    /// A-MEM link weight decay lambda. Range: `(0.0, 1.0]`. Default: `0.95`.
58    pub link_weight_decay_lambda: f64,
59    /// Seconds between link weight decay passes. Default: `86400`.
60    pub link_weight_decay_interval_secs: u64,
61    /// Kumiho belief revision: enable semantic contradiction detection for edges.
62    pub belief_revision_enabled: bool,
63    /// Cosine similarity threshold for belief revision contradiction detection.
64    pub belief_revision_similarity_threshold: f32,
65    /// GAAMA episode linking: `conversation_id` to link extracted entities to their episode.
66    /// `None` disables episode linking for this extraction pass.
67    pub conversation_id: Option<i64>,
68    /// APEX-MEM: use `insert_or_supersede` instead of `resolve_edge_typed`. Default: `false`.
69    pub apex_mem_enabled: bool,
70    /// LLM call timeout for extraction, in seconds. Default: `30`.
71    pub llm_timeout_secs: u64,
72    /// Per-call timeout for every `embed()` invocation, in seconds. Default: `5`.
73    pub embed_timeout_secs: u64,
74    /// Turn index within the episode for edges inserted during this extraction pass (#3710).
75    ///
76    /// `None` disables turn-level provenance recording for this pass.
77    pub turn_index: Option<u32>,
78    /// `MemORAI` write-gate prefilter: minimum confidence for low-signal-relation edges (#3709).
79    ///
80    /// `None` disables the gate (default behaviour, always writes).
81    pub write_gate_min_relevance: Option<f32>,
82    /// Benna-Fusi fast-variable learning rate for confidence merges (#4711).
83    ///
84    /// Passed to [`crate::graph::GraphStore::with_benna_rates`]. Default: `0.5`.
85    pub benna_fast_rate: f32,
86    /// Benna-Fusi slow-variable learning rate for confidence merges (#4711).
87    ///
88    /// Passed to [`crate::graph::GraphStore::with_benna_rates`]. Default: `0.05`.
89    pub benna_slow_rate: f32,
90    /// Provenance stamped on every entity and edge written in this pass (spec-067 INV-2).
91    ///
92    /// `None` means conversation origin (default for conversation callers).
93    /// The ingest path passes `Some(prov)` with the appropriate origin.
94    pub provenance: Option<GraphProvenance>,
95    /// When `false`, recall queries exclude edges with any imported (non-conversation) origin
96    /// (spec-067 §3 Phase 0).
97    ///
98    /// Default: `true` (include imported edges in recall).
99    pub recall_include_imported: bool,
100    /// Override system prompt for [`crate::graph::GraphExtractor`].
101    ///
102    /// `None` (the default) uses the built-in conversational prompt — all existing callers
103    /// are unaffected. The ingest path sets this to
104    /// [`crate::graph::ingest::prompt::TECH_DOC_SYSTEM_PROMPT`] via
105    /// [`crate::graph::GraphExtractor::with_system_prompt`].
106    ///
107    /// The type is `&'static str` — prompts must be compile-time constants (spec-067 C7).
108    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/// Stats returned from a completed extraction.
144#[derive(Debug, Default)]
145pub struct ExtractionStats {
146    pub entities_upserted: usize,
147    pub edges_inserted: usize,
148}
149
150/// Result returned from `extract_and_store`, combining stats with entity IDs needed for linking.
151#[derive(Debug, Default)]
152pub struct ExtractionResult {
153    pub stats: ExtractionStats,
154    /// IDs of entities upserted during this extraction pass. Passed to `link_memory_notes`.
155    pub entity_ids: Vec<i64>,
156}
157
158/// Stats returned from a completed note-linking pass.
159#[derive(Debug, Default)]
160pub struct LinkingStats {
161    pub entities_processed: usize,
162    pub edges_created: usize,
163}
164
165/// Qdrant collection name for entity embeddings (mirrors the constant in `resolver.rs`).
166const ENTITY_COLLECTION: &str = "zeph_graph_entities";
167
168/// Fallback confidence used when the LLM omits the `confidence` field in an extracted edge.
169const DEFAULT_EDGE_CONFIDENCE: f32 = 0.8;
170
171/// Work item for a single entity during a note-linking pass.
172struct EntityWorkItem {
173    entity_id: i64,
174    canonical_name: String,
175    embed_text: String,
176    self_point_id: Option<String>,
177}
178
179/// Link newly extracted entities to semantically similar entities in the graph.
180///
181/// For each entity in `entity_ids`:
182/// 1. Load the entity name + summary from `SQLite`.
183/// 2. Embed all entity texts in parallel.
184/// 3. Search the entity embedding collection in parallel for the `top_k + 1` most similar points.
185/// 4. Filter out the entity itself (by `qdrant_point_id` or `entity_id` payload) and points
186///    below `similarity_threshold`.
187/// 5. Insert a unidirectional `similar_to` edge where `source_id < target_id` to avoid
188///    double-counting in BFS recall while still being traversable via the OR clause in
189///    `edges_for_entity`. The edge confidence is set to the cosine similarity score.
190/// 6. Deduplicate pairs within a single pass so that a pair encountered from both A→B and B→A
191///    directions is only inserted once, keeping `edges_created` accurate.
192///
193/// Errors are logged and not propagated — this is a best-effort background enrichment step.
194pub 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; // +1 to account for self-match
214    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
229/// Phase 1: load entities from the DB and build work items for embedding.
230///
231/// Processes entities sequentially to avoid connection-pool contention.
232async 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
263/// Phase 2: embed all entity texts in parallel.
264///
265/// Returns `(work_idx, embedding)` pairs for successfully embedded items.
266/// Items that fail to embed are logged and dropped.
267async 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
302/// Phase 3: search the embedding store for similar entities for each embedded work item.
303async 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
321/// Phase 4: insert similarity edges, deduplicating pairs seen from both A→B and B→A.
322///
323/// Without deduplication, both directions would call `insert_edge` for the same normalised
324/// pair and both return `Ok`, inflating `edges_created` by the number of bidirectional hits.
325async 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; // secondary self-guard when qdrant_point_id is null
378            }
379
380            // Normalise direction: always store source_id < target_id.
381            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                    // `target_id` came straight from a Qdrant payload (no local-existence
400                    // check) — a stale cross-DB id silently drops this link edge (#5801).
401                    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
415/// Re-resolve a Qdrant search-result candidate's entity id against the *local* `SQLite`
416/// database instead of trusting the payload's `entity_id` verbatim.
417///
418/// Mirrors the canonical-name-keyed correction `EntityResolver::merge_entity` applies to
419/// entity resolution (#5801): the payload's `entity_id` may reference a row in a different
420/// `SQLite` instance (e.g. after a `SQLite` reset/restore performed independently of a
421/// shared Qdrant collection), which would otherwise be used as an edge FK target and fail
422/// with a foreign-key violation — or, worse, silently collide with an unrelated local row
423/// reusing the same id. The fast path requires BOTH id-existence AND a matching
424/// `canonical_name`/`entity_type` (identity, not just existence) before trusting the payload
425/// id: under the reset trigger, local ids restart from 1, so a stale `payload_entity_id` can
426/// easily coincide with a valid but *different* local entity, and existence alone would
427/// silently link to the wrong entity. The correction path only triggers when the id is
428/// absent or its identity doesn't match, and falls back to creating the entity locally when
429/// no local row exists under its canonical name either.
430///
431/// Returns `None` when the candidate should be skipped (malformed payload, or a DB error
432/// while re-resolving/creating it) — logged at `debug!`, matching the existing skip
433/// behavior for a missing `entity_id` payload field.
434async 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    // Fast path: the payload id exists locally AND its canonical identity matches the
454    // candidate — the expected, synced case.
455    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    // Slow path: the payload id is stale, absent, or identifies a different local entity.
473    // Re-resolve by canonical_name — the same key merge_entity uses — falling back to local
474    // entity creation only when no local row exists under that name either.
475    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/// Extract entities and edges from `content` and persist them to the graph store.
519///
520/// This function runs inside a spawned task — it receives owned data only.
521///
522/// The optional `embedding_store` enables entity embedding storage in Qdrant, which is
523/// required for A-MEM note linking to find semantically similar entities across sessions.
524///
525/// # Errors
526///
527/// Returns an error if the database query fails or LLM extraction fails.
528#[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    // Post-extraction validation callback. zeph-memory does not know the callback is a
565    // security validator — it is a generic predicate opaque to this crate (design decision D1).
566    // Returns Err(ValidationRejected) so callers can distinguish sanitizer drops from
567    // hard failures (INV-4, spec-067 §G-5).
568    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
617/// Increment the extraction counter in `graph_metadata`.
618async 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
635/// Upsert all extracted entities and return the name-to-id map and upsert count.
636async 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
668/// Returns `true` when `relation` is a generic, low-information connector.
669///
670/// Used by the `MemORAI` write-gate to avoid storing vacuous edges (#3709).
671fn 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/// Insert extracted edges that have both endpoints in `name_to_id`.
691///
692/// Returns the number of edges actually inserted.
693#[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        // MemORAI write-gate: drop low-signal edges below the relevance threshold (#3709).
703        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        // Parse LLM-provided edge_type; default to Semantic on any parse failure so
734        // edges are never dropped due to classification errors.
735        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            // APEX-MEM: append-only write path with supersession chains.
747            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                    // A resolved entity id was rejected as an FK target — this drops a
774                    // user-requested fact permanently and must not be silent (#5801).
775                    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) => {} // deduplicated
809                Err(e) if e.is_foreign_key_violation() => {
810                    // A resolved entity id was rejected as an FK target — this drops a
811                    // user-requested fact permanently and must not be silent (#5801).
812                    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
827/// Link extracted entities to their GAAMA episode when a conversation ID is configured.
828async 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    /// Spawn background graph extraction for a message. Fire-and-forget — never blocks.
852    ///
853    /// Extraction runs in a separate tokio task with a timeout. Any error or timeout is
854    /// logged and the task exits silently; the agent response is never blocked.
855    ///
856    /// The optional `post_extract_validator` is called after extraction, before upsert.
857    /// It is a generic predicate opaque to zeph-memory (design decision D1).
858    ///
859    /// When `config.note_linking.enabled` is `true` and an embedding store is available,
860    /// `link_memory_notes` runs after successful extraction inside the same task, bounded
861    /// by `config.note_linking.timeout_secs`.
862    ///
863    /// # Panics
864    ///
865    /// Panics if the internal `graph_cancel` mutex is poisoned (another thread panicked
866    /// while holding the lock).
867    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) // EXEMPT: JoinHandle returned to caller; awaited inside BackgroundSupervisor task in zeph-core
911    }
912
913    /// Signal cooperative cancellation to every in-flight background graph-extraction task.
914    ///
915    /// Fires every [`CancellationToken`] tracked since the last drain — i.e. one per
916    /// [`spawn_graph_extraction`] call whose task has not yet completed or already been
917    /// cancelled. Each task checks its token at community-refresh boundaries, so it exits
918    /// cleanly rather than being hard-aborted. This should be called before the supervisor
919    /// calls `abort()` on the underlying `JoinHandle`s to give the tasks a chance to flush
920    /// state.
921    ///
922    /// No-op if no extraction has been spawned or all tracked tokens have already fired.
923    ///
924    /// # Panics
925    ///
926    /// Panics if the internal `graph_cancel` mutex is poisoned (another thread panicked
927    /// while holding the lock).
928    ///
929    /// [`spawn_graph_extraction`]: SemanticMemory::spawn_graph_extraction
930    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
943/// Owned context bundled for the spawned extraction task.
944///
945/// Bundles the Arcs that must be cloned before entering `tokio::spawn`.
946struct 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    /// Cancellation signal propagated into background sub-tasks (community refresh).
954    cancel: CancellationToken,
955}
956
957/// Body of the spawned graph-extraction task.
958async 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    // Mark this task's token as fired now that it has run to completion, so
1024    // `spawn_graph_extraction` can prune its slot from `graph_cancel` on the next
1025    // call instead of tracking it for the rest of the process lifetime.
1026    cancel.cancel();
1027}
1028
1029/// Run A-MEM note linking after successful extraction when enabled.
1030async 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
1064/// Trigger community detection, graph eviction, and link-weight decay when the extraction
1065/// count hits the configured refresh interval.
1066///
1067/// Runs inline within the caller's task (no nested `tokio::spawn`). Each long-running step
1068/// is guarded by `tokio::select!` on `cancel` so shutdown aborts immediately at the next
1069/// yield point without leaving orphaned tasks.
1070async 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    // Time-based link weight decay — independent of eviction cycle.
1150    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
1184/// Per-document processing outcome for the `ingest_documents` fan-out stream.
1185///
1186/// Using an explicit enum avoids `?` short-circuiting the stream — each closure returns
1187/// `DocOutcome` rather than `Result`, so one failed document never aborts the batch (FR-028).
1188enum 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    /// The post-extract validator rejected this document (sanitizer drop).
1200    ///
1201    /// A rejected document writes zero rows and is NOT marked in the ledger, so a
1202    /// later policy change can re-admit it. Counted separately from `Failed` in the
1203    /// report so operators can distinguish sanitizer drops from extraction errors.
1204    Rejected {
1205        uri: String,
1206        reason: String,
1207    },
1208    /// Dry-run projection: uri, counts, and per-entity degree contributions.
1209    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    /// Extract graph entities and edges from a batch of pre-validated documents.
1220    ///
1221    /// This is the Phase-2 graph-sink entry point for `zeph knowledge ingest` (spec-067
1222    /// FR-020..028). It handles:
1223    ///
1224    /// - Intra-batch deduplication by `(source_uri, content_hash)` before building the stream
1225    ///   (C1: prevents concurrent fan-out from submitting the same document twice).
1226    /// - Ledger filtering: documents already recorded in the idempotency ledger are skipped.
1227    /// - Per-document content size cap: documents exceeding `config.max_content_bytes` are
1228    ///   rejected before any LLM call and recorded in `IngestReport::failed`.
1229    /// - Bounded concurrent extraction via `futures::stream::buffer_unordered(concurrency)`.
1230    /// - Collect-errors-and-continue: a failed document does NOT abort the batch (FR-028).
1231    /// - Progress events sent on `progress` (advisory — a dropped or full receiver is ignored).
1232    /// - Dry-run mode: when `config.dry_run` is `true`, extraction runs but nothing is written
1233    ///   to the graph or the ledger. Use this to project entity/edge counts before a live run.
1234    ///
1235    /// # Caller responsibility
1236    ///
1237    /// The caller is responsible for truncating `documents` to `max_documents` before calling
1238    /// this method (spec-067 FR-027). This method applies no cap on total document count.
1239    ///
1240    /// # Invariants
1241    ///
1242    /// Spec INV-4 ("NEVER persist an unsanitized ingest fact") requires that
1243    /// `post_extract_validator` is `Some(...)` on the live write path. Passing `None`
1244    /// is only permitted for dry-run or test scenarios. The binary caller MUST pass
1245    /// the production sanitizer.
1246    ///
1247    /// # Errors
1248    ///
1249    /// Returns [`MemoryError::Ingest`] only for batch-level failures (e.g. ledger DB error at
1250    /// startup). Per-document failures are collected in `IngestReport::failed`.
1251    ///
1252    /// # Examples
1253    ///
1254    /// ```no_run
1255    /// use std::sync::Arc;
1256    /// use tokio::sync::mpsc;
1257    /// use zeph_memory::graph::ingest::{ImportBatchId, IngestDocument, IngestProgress};
1258    /// use zeph_memory::semantic::{GraphExtractionConfig, IngestBatchConfig, SemanticMemory};
1259    ///
1260    /// # async fn example(memory: SemanticMemory, docs: Vec<IngestDocument>) {
1261    /// let (tx, mut rx) = mpsc::channel(64);
1262    /// let batch_id = ImportBatchId::new();
1263    /// let config = IngestBatchConfig::default();
1264    /// let report = memory
1265    ///     .ingest_documents(docs, config, batch_id, 4, None, Some(tx))
1266    ///     .await
1267    ///     .unwrap();
1268    /// println!("succeeded={} failed={}", report.succeeded, report.failed.len());
1269    /// # }
1270    /// ```
1271    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        // C3: SharedPostExtractValidator (Arc<dyn Fn + Send + Sync>) for cheap fan-out cloning.
1278        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        // Intra-batch deduplication (C1): keep the first occurrence of each
1307        // (source_uri, content_hash) pair; emit DocumentSkipped for duplicates.
1308        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        // Emit skipped events for intra-batch duplicates.
1327        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        // C3: validator is Arc<dyn Fn + Send + Sync> — cheap clone across buffer_unordered tasks.
1339        let shared_validator = post_extract_validator;
1340
1341        let concurrency = concurrency.max(1); // C4: clamp to avoid buffer_unordered(0) stall
1342
1343        // S2: allocate ledger once here; each task clones only the cheap Arc<Pool> inside.
1344        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                // M2: reject oversized documents before any LLM call.
1362                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                // Ledger filter.
1373                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                // Build per-document config clone with provenance + tech-doc prompt.
1385                // Origin is derived from the document's own provenance (set by the adapter),
1386                // so subagent transcripts get GraphOrigin::Subagent, external-agent imports get
1387                // GraphOrigin::ExternalAgent, and static artifacts get GraphOrigin::Ingest
1388                // without any extra threading (spec-067 §G-1).
1389                let mut doc_config = base_config.clone();
1390                doc_config.provenance = Some(doc.provenance().clone());
1391                // Keep the ingest batch ID aligned with the caller's batch_id (the provenance
1392                // from the adapter was stamped at parse time with the same batch_id).
1393                if let Some(ref mut prov) = doc_config.provenance {
1394                    prov.import_batch_id.clone_from(&batch_id_str);
1395                }
1396                // Select tech-doc prompt via IngestSourceKind.
1397                // All ingest source kinds use the same tech-doc prompt for extraction.
1398                doc_config.system_prompt = Some(IngestSourceKind::SubagentTranscript.system_prompt());
1399
1400                // Build PostExtractValidator by cloning the Arc (C3: cheap, Send+Sync).
1401                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                    // FR-026: dry-run calls GraphExtractor::extract() directly — NOT
1410                    // extract_and_store — to guarantee zero writes (including bump_extraction_count).
1411                    // This invariant is enforced by the test `dry_run_writes_nothing` (C2).
1412                    // S3: validator is also run in dry-run so projected counts are post-sanitization.
1413                    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                            // S3: run validator in dry-run to get post-sanitization projections.
1430                            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                            // Build per-entity degree map.
1438                            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                        // ValidationRejected is a sanitizer drop — not a hard failure.
1498                        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        // Collect outcomes with bounded concurrency.
1513        let outcomes: Vec<DocOutcome> = stream
1514            .buffer_unordered(concurrency)
1515            .collect()
1516            .instrument(span)
1517            .await;
1518
1519        // Build the report.
1520        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        // Per-doc degree accumulator for dry-run hub-degree projection (FR-026).
1529        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            // Top-N by degree for hub-degree health check (spec-067 §7).
1586            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/// Configuration for [`SemanticMemory::ingest_documents`].
1602///
1603/// Wraps the per-extraction [`GraphExtractionConfig`] and adds batch-level options.
1604#[derive(Debug, Clone)]
1605pub struct IngestBatchConfig {
1606    /// Per-document extraction config. Quality gates, timeouts, and model settings.
1607    ///
1608    /// The `provenance` and `system_prompt` fields are overwritten per document inside
1609    /// `ingest_documents` — callers do not need to set them.
1610    pub extraction: GraphExtractionConfig,
1611    /// When `true`, run LLM extraction but write nothing to the graph or the ledger.
1612    ///
1613    /// The report will contain projected entity/edge counts and hub-degree distribution.
1614    /// This is the spec-067 FR-026 dry-run mode.
1615    pub dry_run: bool,
1616    /// Number of top entities to include in the dry-run hub-degree report.
1617    ///
1618    /// `None` defaults to 10.
1619    pub dry_run_hub_top_n: Option<usize>,
1620    /// Maximum allowed content size in bytes for a single document.
1621    ///
1622    /// Documents whose `content` exceeds this limit are rejected with
1623    /// `DocOutcome::Failed` before any LLM call is made. This is a security
1624    /// measure to prevent arbitrarily large payloads from reaching the provider.
1625    ///
1626    /// Defaults to 512 KiB (`512 * 1024`).
1627    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    /// Regression test for #1829: `extract_and_store()` must pass the provider to `EntityResolver`
1665    /// so that `store_entity_embedding()` is called and `qdrant_point_id` is set in `SQLite`.
1666    #[tokio::test]
1667    async fn extract_and_store_sets_qdrant_point_id_when_embedding_store_provided() {
1668        let (gs, emb) = setup().await;
1669
1670        // MockProvider: supports embeddings, returns a valid extraction JSON for chat
1671        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        // The entity must have a qdrant_point_id — this proves store_entity_embedding() was called.
1703        // Before the fix, EntityResolver was built without a provider, so embed() was never called
1704        // and qdrant_point_id remained NULL.
1705        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    /// When no `embedding_store` is provided, `extract_and_store()` must still work correctly
1718    /// (no embeddings stored, but entities are still upserted).
1719    #[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, // no embedding_store
1742        )
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    /// Regression test for #2166: FTS5 entity writes must be visible to a new connection pool
1761    /// opened after extraction completes. Without `checkpoint_wal()` in `extract_and_store`,
1762    /// a fresh pool sees stale FTS5 shadow tables and `find_entities_fuzzy` returns empty.
1763    #[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        // Session A: run extract_and_store on a file DB (not :memory:) so WAL is used.
1769        {
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        // Session B: new pool — FTS5 must see the entity extracted in session A.
1795        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    /// Regression test for #2215: self-loop edges (source == target entity) must be silently
1805    /// skipped; no edge row should be inserted.
1806    #[tokio::test]
1807    async fn extract_and_store_skips_self_loop_edges() {
1808        let (gs, _emb) = setup().await;
1809
1810        // LLM returns one entity and one self-loop edge (source == target).
1811        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    /// When `apex_mem_enabled = true`, edges must be inserted via `insert_or_supersede`
1845    /// (the APEX-MEM append-only path) instead of the legacy `resolve_edge_typed` path.
1846    /// Verifies that edges are still counted as inserted and that the supersession row
1847    /// is created in the database.
1848    #[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        // Verify the edge row exists and its relation preserves display casing.
1891        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        // canonical_relation is lowercased; relation field preserves original casing post-strip
1908        assert_eq!(
1909            edges[0].relation, "KNOWS",
1910            "display relation must preserve original casing"
1911        );
1912    }
1913
1914    /// Regression for #4297: `embed_work_items` must return an empty Vec (fail-open) when the
1915    /// batch `join_all` embed call exceeds the 30 s global timeout.
1916    #[tokio::test]
1917    async fn embed_work_items_timeout_returns_empty() {
1918        use zeph_llm::mock::MockProvider;
1919
1920        // embed_delay_ms > 30_000 ms would make the test too slow; we rely on tokio::time::pause
1921        // to advance virtual time instantly, so the timeout fires without real delay.
1922        tokio::time::pause();
1923
1924        // Delay longer than the 30 s timeout (in virtual time).
1925        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    /// Regression for #4622: `maybe_refresh_communities` must return immediately when the
1949    /// `CancellationToken` is already cancelled, without hanging or panicking.
1950    ///
1951    /// Before the fix a nested `tokio::spawn` was used with no `CancellationToken`, so shutdown
1952    /// could not interrupt community detection.  The inline `tokio::select!` path now exits at
1953    /// the first select arm when the token is pre-cancelled.
1954    #[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        // Seed extraction_count=1 so the interval check passes (1 % 1 == 0).
1966        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(); // pre-cancelled — all select! arms must short-circuit immediately
1975
1976        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        // Must complete promptly — if the fix regresses and a blocking call is made this will
1983        // hang forever (caught by tokio::time::timeout in CI or a test runtime timeout).
1984        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    /// Regression test for #4711: configured Benna-Fusi rates must produce different
2038    /// `confidence_fast`/`confidence_slow` values than the hardcoded defaults.
2039    ///
2040    /// `extract_and_store` builds `GraphStore::new(pool).with_benna_rates(fast, slow)` using
2041    /// `config.benna_fast_rate` / `config.benna_slow_rate`.  Before the fix it called
2042    /// `GraphStore::new(pool)` only, so the configured rates were silently ignored.
2043    ///
2044    /// This test calls `GraphStore::insert_edge_typed` directly (bypassing the resolver dedup
2045    /// layer) to exercise the Benna-Fusi UPDATE path with two confidence levels (0.6 → 0.8).
2046    ///
2047    /// Math:
2048    ///   default (0.5/0.05):  fast = 0.6 + 0.5*(0.8-0.6) = 0.7;  slow ≈ 0.605
2049    ///   custom  (0.1/0.02):  fast = 0.6 + 0.1*(0.8-0.6) = 0.62; slow ≈ 0.6004
2050    #[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            // Pass 1: INSERT — seeds confidence_fast = confidence_slow = 0.6.
2075            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            // Pass 2: UPDATE — triggers Benna-Fusi merge with incoming confidence = 0.8.
2090            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        // Different rates → different fast/slow.  Before the fix extract_and_store ignored the
2113        // config fields; all edges would have been identical regardless of configured rates.
2114        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        // Higher fast_rate → fast variable grows more aggressively: 0.7 (default) > 0.62 (custom).
2123        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    /// Regression test for #4723: `extract_and_store` must forward `ExtractedEdge.confidence`
2130    /// to the graph resolver instead of always using the hardcoded fallback 0.8.
2131    ///
2132    /// The LLM JSON sets `confidence: 0.3`. Before the fix, line 628 passed `0.8` unconditionally;
2133    /// after the fix it passes `edge.confidence.unwrap_or(0.8)` which is `0.3` when present.
2134    /// A freshly-inserted edge sets `confidence_fast = confidence`, so we compare against 0.3.
2135    #[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        // confidence = 0.3 is far enough from 0.8 that float imprecision cannot mask the bug.
2143        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        // Before fix: confidence_fast would be ~0.8 (hardcoded); after fix: ~0.3 (from JSON).
2194        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    /// Regression for #4723 (APEX-MEM path): `extract_and_store` must forward
2202    /// `ExtractedEdge.confidence` to `insert_or_supersede_with_turn_index_and_metrics` instead
2203    /// of using the hardcoded literal `0.8`.
2204    #[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        // confidence = 0.3 is far enough from 0.8 that float imprecision cannot mask the bug.
2212        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        // Before fix: confidence_fast would be ~0.8 (hardcoded); after fix: ~0.3 (from JSON).
2264        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    /// Regression for #5784 (APEX-MEM path): `GraphExtractionConfig::turn_index` must be
2272    /// forwarded all the way through `extract_and_store` to the persisted
2273    /// `graph_edges.turn_index` column, not just threaded through the config-builder signature.
2274    ///
2275    /// The sibling test `extract_and_store_non_apex_path_persists_turn_index` below covers the
2276    /// same invariant for the default (`apex_mem.enabled = false`) path, which originally had a
2277    /// separate gap: `resolve_edge_typed`/`GraphStore::insert_edge_typed` had no `turn_index`
2278    /// parameter at all and silently dropped it even when this APEX-MEM test passed.
2279    #[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    /// #5784 follow-up: with `apex_mem_enabled: false` (the out-of-the-box default — see
2347    /// `ApexMemConfig::default()` and the live call site's `build_graph_extraction_config`),
2348    /// `insert_edges` routes through `resolve_edge_typed`. This used to have no `turn_index`
2349    /// parameter at all, so `config.turn_index` was silently dropped and `graph_edges.turn_index`
2350    /// stayed `NULL` even after the initial #5784 fix, for any deployment that has not explicitly
2351    /// opted into `[memory.graph.apex_mem] enabled = true`. `resolve_edge_typed` and
2352    /// `GraphStore::insert_edge_typed` now both accept and forward `turn_index`, closing the gap
2353    /// for the default configuration too.
2354    #[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            // apex_mem_enabled defaults to false — matches production default config.
2376            ..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    /// Verify `GraphExtractionConfig` default benna rates match `GraphStore::new` defaults.
2422    ///
2423    /// If either default is changed in one place but not the other, behavior silently diverges
2424    /// between paths that call `with_benna_rates` and paths that don't.
2425    #[test]
2426    fn graph_extraction_config_benna_defaults_match_graph_store_defaults() {
2427        let cfg = GraphExtractionConfig::default();
2428        // GraphStore::new uses 0.5 / 0.05 — these must stay in sync.
2429        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        // One ledger row must exist after a live write.
2493        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        // One response — only one LLM call should happen (the duplicate is filtered before streaming).
2507        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        // Two documents with identical (uri, content) — same content_hash by construction.
2512        // Same content + same task_id → same source_uri → same content_hash → C1 dedup fires.
2513        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    /// Regression for #5467: `global_degrees` in `ingest_documents` must sum an entity's
2585    /// per-document degree across *every* document in the batch, not just the last one —
2586    /// this is exactly the accumulation that let an incidentally-mentioned language/tool
2587    /// name balloon into a hub entity when it recurred across many documents in the corpus.
2588    #[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        // concurrency=1: documents are processed in order, so MockProvider's queued
2624        // responses (json1, json2) line up deterministically with (doc1, doc2).
2625        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    /// Regression for #5467: `dry_run_hub_top_n` must truncate the projected hub-degree
2649    /// list to the configured size, keeping only the highest-degree entities.
2650    #[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        // Single document, 4 entities: A=3 (edges to B, C, D), B=2, C=2, D=1.
2656        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        // Second document exceeds size cap — guaranteed failure without LLM call.
2714        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(); // max_content_bytes = 512 KiB
2719
2720        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    /// The invariant is that `ingest_documents(dry_run=true)` routes to `extract()` directly
2735    /// (no `bump_extraction_count`) and never calls `mark_ingested`. This test enforces it
2736    /// so a future refactor cannot silently violate FR-026.
2737    #[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        // Create the tables needed by the test (subset of full migrations).
2752        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        // Seed a known extraction_count so we can detect any write.
2774        sqlx::query("INSERT INTO graph_metadata (key, value) VALUES ('extraction_count', '0')")
2775            .execute(&pool)
2776            .await
2777            .unwrap();
2778
2779        // Build a mock provider that returns an empty extraction result.
2780        let provider = AnyProvider::Mock(MockProvider::default());
2781
2782        // Build a minimal SemanticMemory using a helper that only needs pool + provider.
2783        // We directly call the public function extract_and_store to validate our assumption,
2784        // then verify the DB state post-ingest_documents.
2785
2786        // Build two documents via SubagentJsonl adapter.
2787        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        // Invoke dry-run extraction directly (bypasses SemanticMemory for simplicity).
2798        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        // Manually replicate the dry-run branch: call GraphExtractor::extract() only.
2810        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        // Assert: extraction_count must still be 0 (dry-run never calls bump_extraction_count).
2826        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        // Assert: ledger must be empty (dry-run never calls mark_ingested).
2838        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    // ── #5816 (gap 3): insert_edges FK-violation WARN branches ──────────────────
2849    //
2850    // Both the APEX-MEM and legacy branches of `insert_edges` catch a genuine FK
2851    // constraint violation and log it at WARN (#5801) instead of silently dropping the
2852    // edge at DEBUG. Unlike `insert_similarity_edges` (covered by the stale-cross-DB-id
2853    // tests in `semantic/tests/graph.rs`), no test manufactured a real FK violation on
2854    // these specific call sites — these two do, by pointing `name_to_id` at ids that do
2855    // not exist locally, mirroring the manufactured violation in
2856    // `error::tests::is_foreign_key_violation_true_for_real_fk_violation`.
2857
2858    #[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        // Neither id exists in this fresh database — a genuine FK violation.
2868        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        // Neither id exists in this fresh database — a genuine FK violation.
2909        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}