Skip to main content

zeph_config/memory/
graph.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Knowledge-graph memory configuration.
5//!
6//! Covers entity/edge extraction, spreading activation, community summarization,
7//! implicit-conflict detection, APEX-MEM quality gating, and graph retrieval strategies.
8
9use crate::providers::ProviderName;
10use serde::{Deserialize, Serialize};
11
12use super::{BeliefRevisionConfig, ConflictRecencyConfig, RpeConfig, WriteGateConfig};
13
14fn default_graph_max_entities_per_message() -> usize {
15    10
16}
17
18fn default_graph_max_edges_per_message() -> usize {
19    15
20}
21
22fn default_graph_community_refresh_interval() -> usize {
23    100
24}
25
26fn default_graph_community_summary_max_prompt_bytes() -> usize {
27    8192
28}
29
30fn default_graph_community_summary_concurrency() -> usize {
31    4
32}
33
34fn default_lpa_edge_chunk_size() -> usize {
35    10_000
36}
37
38fn default_graph_entity_similarity_threshold() -> f32 {
39    0.85
40}
41
42fn default_graph_entity_ambiguous_threshold() -> f32 {
43    0.70
44}
45
46fn default_graph_extraction_timeout_secs() -> u64 {
47    15
48}
49
50fn default_graph_max_hops() -> u32 {
51    2
52}
53
54fn default_graph_recall_limit() -> usize {
55    10
56}
57
58fn default_graph_expired_edge_retention_days() -> u32 {
59    90
60}
61
62fn default_graph_temporal_decay_rate() -> f64 {
63    0.0
64}
65
66fn default_graph_edge_history_limit() -> usize {
67    100
68}
69
70fn default_spreading_activation_decay_lambda() -> f32 {
71    0.85
72}
73
74fn default_spreading_activation_max_hops() -> u32 {
75    3
76}
77
78fn default_spreading_activation_activation_threshold() -> f32 {
79    0.1
80}
81
82fn default_spreading_activation_inhibition_threshold() -> f32 {
83    0.8
84}
85
86fn default_spreading_activation_max_activated_nodes() -> usize {
87    50
88}
89
90fn default_spreading_activation_recall_timeout_ms() -> u64 {
91    1000
92}
93
94fn default_benna_alpha() -> f32 {
95    0.3
96}
97
98fn default_benna_fast_rate() -> f32 {
99    0.5
100}
101
102fn default_benna_slow_rate() -> f32 {
103    0.05
104}
105
106fn default_note_linking_similarity_threshold() -> f32 {
107    0.85
108}
109
110fn default_note_linking_top_k() -> usize {
111    10
112}
113
114fn default_note_linking_timeout_secs() -> u64 {
115    5
116}
117
118fn validate_temporal_decay_rate<'de, D>(deserializer: D) -> Result<f64, D::Error>
119where
120    D: serde::Deserializer<'de>,
121{
122    let value = <f64 as serde::Deserialize>::deserialize(deserializer)?;
123    if value.is_nan() || value.is_infinite() {
124        return Err(serde::de::Error::custom(
125            "temporal_decay_rate must be a finite number",
126        ));
127    }
128    if !(0.0..=10.0).contains(&value) {
129        return Err(serde::de::Error::custom(
130            "temporal_decay_rate must be in [0.0, 10.0]",
131        ));
132    }
133    Ok(value)
134}
135
136/// Configuration for SYNAPSE spreading activation retrieval over the entity graph.
137///
138/// When `enabled = true`, spreading activation replaces BFS-based graph recall.
139/// Seeds are initialized from fuzzy entity matches, then activation propagates
140/// hop-by-hop with exponential decay and lateral inhibition.
141///
142/// # Validation
143///
144/// Constraints enforced at deserialization time:
145/// - `0.0 < decay_lambda <= 1.0`
146/// - `max_hops >= 1`
147/// - `activation_threshold < inhibition_threshold`
148/// - `recall_timeout_ms >= 1` (clamped to 100 with a warning if set to 0)
149#[derive(Debug, Clone, Deserialize, Serialize)]
150#[serde(default)]
151pub struct SpreadingActivationConfig {
152    /// Enable spreading activation (replaces BFS in graph recall when `true`). Default: `false`.
153    pub enabled: bool,
154    /// Per-hop activation decay factor. Range: `(0.0, 1.0]`. Default: `0.85`.
155    #[serde(deserialize_with = "crate::de_helpers::de_unit_open")]
156    pub decay_lambda: f32,
157    /// Maximum propagation depth. Must be `>= 1`. Default: `3`.
158    #[serde(deserialize_with = "validate_max_hops")]
159    pub max_hops: u32,
160    /// Minimum activation score to include a node in results. Default: `0.1`.
161    pub activation_threshold: f32,
162    /// Activation level at which a node stops receiving more activation. Default: `0.8`.
163    pub inhibition_threshold: f32,
164    /// Cap on total activated nodes per spread pass. Default: `50`.
165    pub max_activated_nodes: usize,
166    /// Weight of structural score in hybrid seed ranking. Range: `[0.0, 1.0]`. Default: `0.4`.
167    #[serde(default = "default_seed_structural_weight")]
168    pub seed_structural_weight: f32,
169    /// Maximum seeds per community. `0` = unlimited. Default: `3`.
170    #[serde(default = "default_seed_community_cap")]
171    pub seed_community_cap: usize,
172    /// Timeout in milliseconds for a single spreading activation recall call. Default: `1000`.
173    /// Values below 1 are clamped to 100ms at runtime. Benchmark data shows FTS5 + graph
174    /// traversal completes within 200–400ms; 1000ms provides headroom for cold caches.
175    #[serde(default = "default_spreading_activation_recall_timeout_ms")]
176    pub recall_timeout_ms: u64,
177    /// SYNAPSE blend coefficient for Benna-Fusi fast/slow variables (#3709).
178    ///
179    /// `blended = alpha * confidence_fast + (1 - alpha) * confidence_slow`.
180    /// Range: `[0.0, 1.0]`. Default: `0.3`.
181    #[serde(
182        default = "default_benna_alpha",
183        deserialize_with = "validate_benna_alpha"
184    )]
185    pub alpha: f32,
186    /// Benna-Fusi fast-variable learning rate applied on each confidence merge (#3709).
187    ///
188    /// `fast' = fast + eta_f * (c - fast)`. Range: `(0.0, 1.0]`. Default: `0.5`.
189    #[serde(
190        default = "default_benna_fast_rate",
191        deserialize_with = "validate_benna_rate"
192    )]
193    pub benna_fast_rate: f32,
194    /// Benna-Fusi slow-variable learning rate applied on each confidence merge (#3709).
195    ///
196    /// `slow' = slow + eta_s * (fast' - slow)`. Range: `(0.0, 1.0]`. Default: `0.05`.
197    #[serde(
198        default = "default_benna_slow_rate",
199        deserialize_with = "validate_benna_rate"
200    )]
201    pub benna_slow_rate: f32,
202}
203
204fn validate_max_hops<'de, D>(deserializer: D) -> Result<u32, D::Error>
205where
206    D: serde::Deserializer<'de>,
207{
208    let value = <u32 as serde::Deserialize>::deserialize(deserializer)?;
209    if value == 0 {
210        return Err(serde::de::Error::custom("max_hops must be >= 1"));
211    }
212    Ok(value)
213}
214
215fn validate_benna_alpha<'de, D>(deserializer: D) -> Result<f32, D::Error>
216where
217    D: serde::Deserializer<'de>,
218{
219    let value = <f32 as serde::Deserialize>::deserialize(deserializer)?;
220    if !value.is_finite() {
221        return Err(serde::de::Error::custom("alpha must be a finite number"));
222    }
223    if !(0.0..=1.0).contains(&value) {
224        return Err(serde::de::Error::custom("alpha must be in [0.0, 1.0]"));
225    }
226    Ok(value)
227}
228
229fn validate_benna_rate<'de, D>(deserializer: D) -> Result<f32, D::Error>
230where
231    D: serde::Deserializer<'de>,
232{
233    let value = <f32 as serde::Deserialize>::deserialize(deserializer)?;
234    if !value.is_finite() {
235        return Err(serde::de::Error::custom(
236            "benna_fast_rate/benna_slow_rate must be a finite number",
237        ));
238    }
239    if !(value > 0.0 && value <= 1.0) {
240        return Err(serde::de::Error::custom(
241            "benna_fast_rate/benna_slow_rate must be in (0.0, 1.0]",
242        ));
243    }
244    Ok(value)
245}
246
247impl SpreadingActivationConfig {
248    /// Validate cross-field constraints that cannot be expressed in per-field validators.
249    ///
250    /// # Errors
251    ///
252    /// Returns an error string if `activation_threshold >= inhibition_threshold`.
253    #[must_use = "validation result must be checked"]
254    pub fn validate(&self) -> Result<(), String> {
255        if self.activation_threshold >= self.inhibition_threshold {
256            return Err(format!(
257                "activation_threshold ({}) must be < inhibition_threshold ({})",
258                self.activation_threshold, self.inhibition_threshold
259            ));
260        }
261        Ok(())
262    }
263}
264
265fn default_seed_structural_weight() -> f32 {
266    0.4
267}
268
269fn default_seed_community_cap() -> usize {
270    3
271}
272
273impl Default for SpreadingActivationConfig {
274    fn default() -> Self {
275        Self {
276            enabled: false,
277            decay_lambda: default_spreading_activation_decay_lambda(),
278            max_hops: default_spreading_activation_max_hops(),
279            activation_threshold: default_spreading_activation_activation_threshold(),
280            inhibition_threshold: default_spreading_activation_inhibition_threshold(),
281            max_activated_nodes: default_spreading_activation_max_activated_nodes(),
282            seed_structural_weight: default_seed_structural_weight(),
283            seed_community_cap: default_seed_community_cap(),
284            recall_timeout_ms: default_spreading_activation_recall_timeout_ms(),
285            alpha: default_benna_alpha(),
286            benna_fast_rate: default_benna_fast_rate(),
287            benna_slow_rate: default_benna_slow_rate(),
288        }
289    }
290}
291
292/// Configuration for A-MEM dynamic note linking.
293///
294/// When enabled, after each graph extraction pass, entities extracted from the message are
295/// compared against the entity embedding collection. Pairs with cosine similarity above
296/// `similarity_threshold` receive a `similar_to` edge in the graph.
297#[derive(Debug, Clone, Deserialize, Serialize)]
298#[serde(default)]
299pub struct NoteLinkingConfig {
300    /// Enable A-MEM note linking after graph extraction. Default: `false`.
301    pub enabled: bool,
302    /// Minimum cosine similarity score to create a `similar_to` edge. Default: `0.85`.
303    #[serde(deserialize_with = "crate::de_helpers::de_unit_closed")]
304    pub similarity_threshold: f32,
305    /// Maximum number of similar entities to link per extracted entity. Default: `10`.
306    pub top_k: usize,
307    /// Timeout for the entire linking pass in seconds. Default: `5`.
308    pub timeout_secs: u64,
309}
310
311impl Default for NoteLinkingConfig {
312    fn default() -> Self {
313        Self {
314            enabled: false,
315            similarity_threshold: default_note_linking_similarity_threshold(),
316            top_k: default_note_linking_top_k(),
317            timeout_secs: default_note_linking_timeout_secs(),
318        }
319    }
320}
321
322// ── EM-Graph config (issue #3713) ──────────────────────────────────────────────
323
324/// EM-Graph episodic event extraction and causal linking configuration.
325///
326/// When enabled, episodic events are extracted from conversation turns and linked
327/// via causal relationships stored in `episodic_events` and `causal_links` tables.
328///
329/// # Example (TOML)
330///
331/// ```toml
332/// [memory.em_graph]
333/// enabled = false
334/// extract_provider = ""
335/// max_chain_depth = 3
336/// ```
337#[derive(Debug, Clone, Deserialize, Serialize)]
338#[serde(default)]
339pub struct EmGraphConfig {
340    /// Enable EM-Graph event extraction and causal linking. Default: `false`.
341    pub enabled: bool,
342    /// Provider name from `[[llm.providers]]` for event extraction.
343    /// Falls back to the primary provider when empty.
344    pub extract_provider: ProviderName,
345    /// Maximum hops when traversing causal chains during recall. Default: `3`.
346    pub max_chain_depth: u32,
347}
348
349impl Default for EmGraphConfig {
350    fn default() -> Self {
351        Self {
352            enabled: false,
353            extract_provider: ProviderName::default(),
354            max_chain_depth: 3,
355        }
356    }
357}
358
359/// Graph retrieval strategy for `[memory.graph]`.
360///
361/// Selects the algorithm used to traverse the knowledge graph during recall.
362/// The default (`synapse`) preserves existing SYNAPSE spreading-activation behavior.
363#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
364#[serde(rename_all = "snake_case")]
365#[non_exhaustive]
366pub enum GraphRetrievalStrategy {
367    /// SYNAPSE spreading activation (default, existing behavior).
368    #[default]
369    Synapse,
370    /// Hop-limited BFS traversal (pre-SYNAPSE behavior).
371    Bfs,
372    /// A* shortest-path traversal via petgraph.
373    #[serde(rename = "astar")]
374    AStar,
375    /// Concentric BFS expanding outward from seed nodes.
376    WaterCircles,
377    /// Beam search: keep top-K candidates per hop.
378    BeamSearch,
379    /// Dynamic: LLM classifier selects strategy per query.
380    Hybrid,
381}
382
383fn default_beam_width() -> usize {
384    10
385}
386
387/// Beam search retrieval configuration for `[memory.graph.beam_search]`.
388///
389/// Controls the width of the beam during graph traversal: how many top candidates
390/// are retained at each hop.
391#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
392pub struct BeamSearchConfig {
393    /// Number of top candidates kept per hop. Default: `10`.
394    #[serde(default = "default_beam_width")]
395    pub beam_width: usize,
396}
397
398impl Default for BeamSearchConfig {
399    fn default() -> Self {
400        Self {
401            beam_width: default_beam_width(),
402        }
403    }
404}
405
406/// `WaterCircles` BFS configuration for `[memory.graph.watercircles]`.
407///
408/// Controls ring-by-ring concentric BFS traversal from seed nodes.
409#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
410pub struct WaterCirclesConfig {
411    /// Max facts per ring (hop). `0` = auto (`limit / max_hops`). Default: `0`.
412    #[serde(default)]
413    pub ring_limit: usize,
414}
415
416fn default_evolution_sweep_interval() -> usize {
417    50
418}
419
420fn default_confidence_prune_threshold() -> f32 {
421    0.1
422}
423
424/// Experience memory configuration for `[memory.graph.experience]`.
425///
426/// Controls recording of tool execution outcomes and graph evolution sweeps.
427#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
428pub struct ExperienceConfig {
429    /// Enable experience memory recording. Default: `false`.
430    #[serde(default)]
431    pub enabled: bool,
432    /// Enable graph evolution sweep (prune self-loops + low-confidence edges). Default: `false`.
433    #[serde(default)]
434    pub evolution_sweep_enabled: bool,
435    /// Confidence threshold below which zero-retrieval edges are pruned. Default: `0.1`.
436    #[serde(default = "default_confidence_prune_threshold")]
437    pub confidence_prune_threshold: f32,
438    /// Number of turns between evolution sweeps. Default: `50`.
439    #[serde(default = "default_evolution_sweep_interval")]
440    pub evolution_sweep_interval: usize,
441}
442
443impl Default for ExperienceConfig {
444    fn default() -> Self {
445        Self {
446            enabled: false,
447            evolution_sweep_enabled: false,
448            confidence_prune_threshold: default_confidence_prune_threshold(),
449            evolution_sweep_interval: default_evolution_sweep_interval(),
450        }
451    }
452}
453
454/// Configuration for the knowledge graph memory subsystem (`[memory.graph]` TOML section).
455///
456/// # Security
457///
458/// Entity names, relation labels, and fact strings extracted by the LLM are stored verbatim
459/// without PII redaction. This is a known pre-1.0 MVP limitation. Do not enable graph memory
460/// when processing conversations that may contain personal, medical, or sensitive data until
461/// a redaction pass is implemented on the write path.
462#[derive(Debug, Clone, Deserialize, Serialize)]
463#[serde(default)]
464#[allow(clippy::struct_excessive_bools)]
465pub struct GraphConfig {
466    pub enabled: bool,
467    pub extract_model: String,
468    #[serde(default = "default_graph_max_entities_per_message")]
469    pub max_entities_per_message: usize,
470    #[serde(default = "default_graph_max_edges_per_message")]
471    pub max_edges_per_message: usize,
472    #[serde(default = "default_graph_community_refresh_interval")]
473    pub community_refresh_interval: usize,
474    #[serde(default = "default_graph_entity_similarity_threshold")]
475    pub entity_similarity_threshold: f32,
476    #[serde(default = "default_graph_extraction_timeout_secs")]
477    pub extraction_timeout_secs: u64,
478    #[serde(default)]
479    pub use_embedding_resolution: bool,
480    #[serde(default = "default_graph_entity_ambiguous_threshold")]
481    pub entity_ambiguous_threshold: f32,
482    #[serde(default = "default_graph_max_hops")]
483    pub max_hops: u32,
484    #[serde(default = "default_graph_recall_limit")]
485    pub recall_limit: usize,
486    /// Days to retain expired (superseded) edges before deletion. Default: 90.
487    #[serde(default = "default_graph_expired_edge_retention_days")]
488    pub expired_edge_retention_days: u32,
489    /// Maximum entities to retain in the graph. 0 = unlimited.
490    #[serde(default)]
491    pub max_entities: usize,
492    /// Maximum prompt size in bytes for community summary generation. Default: 8192.
493    #[serde(default = "default_graph_community_summary_max_prompt_bytes")]
494    pub community_summary_max_prompt_bytes: usize,
495    /// Maximum concurrent LLM calls during community summarization. Default: 4.
496    #[serde(default = "default_graph_community_summary_concurrency")]
497    pub community_summary_concurrency: usize,
498    /// Number of edges fetched per chunk during community detection. Default: 10000.
499    /// Set to 0 to disable chunking and load all edges at once (legacy behavior).
500    #[serde(default = "default_lpa_edge_chunk_size")]
501    pub lpa_edge_chunk_size: usize,
502    /// Temporal recency decay rate for graph recall scoring (units: 1/day).
503    ///
504    /// When > 0, recent edges receive a small additive score boost over older edges.
505    /// The boost formula is `1 / (1 + age_days * rate)`, blended additively with the base
506    /// composite score. Default 0.0 preserves existing scoring behavior exactly.
507    #[serde(
508        default = "default_graph_temporal_decay_rate",
509        deserialize_with = "validate_temporal_decay_rate"
510    )]
511    pub temporal_decay_rate: f64,
512    /// Maximum number of historical edge versions returned by `edge_history()`. Default: 100.
513    ///
514    /// Caps the result set returned for a given source entity + predicate pair. Prevents
515    /// unbounded memory usage for high-churn predicates when this method is exposed via TUI
516    /// or API endpoints.
517    #[serde(default = "default_graph_edge_history_limit")]
518    pub edge_history_limit: usize,
519    /// A-MEM dynamic note linking configuration.
520    ///
521    /// When `note_linking.enabled = true`, entities extracted from each message are linked to
522    /// semantically similar entities via `similar_to` edges. Requires an embedding store
523    /// (`qdrant` or `sqlite` vector backend) to be configured.
524    #[serde(default)]
525    pub note_linking: NoteLinkingConfig,
526    /// SYNAPSE spreading activation retrieval configuration.
527    ///
528    /// When `spreading_activation.enabled = true`, graph recall uses spreading activation
529    /// with lateral inhibition and temporal decay instead of BFS.
530    #[serde(default)]
531    pub spreading_activation: SpreadingActivationConfig,
532    /// Graph retrieval strategy. Default: `synapse` (preserves existing behavior).
533    ///
534    /// When `spreading_activation.enabled = true` and `retrieval_strategy` is `synapse`,
535    /// SYNAPSE spreading activation is used. Set to `bfs` to revert to hop-limited BFS.
536    #[serde(default)]
537    pub retrieval_strategy: GraphRetrievalStrategy,
538    /// Named LLM provider from `[[llm.providers]]` for graph entity/relation extraction.
539    ///
540    /// When non-empty, graph extraction (and downstream note linking and community
541    /// summarization) use this provider instead of the primary `SemanticMemory.provider`.
542    /// This is the recommended fix for `quality_gate` false positives (#3601): JSON
543    /// extraction tasks produce structurally low prompt/response similarity (~0.55–0.70),
544    /// which causes systematic quality gate rejections. A named provider built via
545    /// `resolve_background_provider` bypasses `apply_routing_signals()` and therefore
546    /// has no quality gate attached.
547    ///
548    /// Falls back to the primary provider when empty. Default: `""` (use primary).
549    #[serde(default)]
550    pub extract_provider: ProviderName,
551    /// Named LLM provider for hybrid strategy classification.
552    /// Falls back to the default provider when `None`.
553    #[serde(default)]
554    pub strategy_classifier_provider: Option<ProviderName>,
555    /// Beam search configuration.
556    #[serde(default)]
557    pub beam_search: BeamSearchConfig,
558    /// `WaterCircles` BFS configuration.
559    #[serde(default)]
560    pub watercircles: WaterCirclesConfig,
561    /// Experience memory configuration.
562    #[serde(default)]
563    pub experience: ExperienceConfig,
564    /// A-MEM link weight decay: multiplicative factor applied to `retrieval_count`
565    /// for un-retrieved edges each decay pass. Range: `(0.0, 1.0]`. Default: `0.95`.
566    #[serde(
567        default = "default_link_weight_decay_lambda",
568        deserialize_with = "crate::de_helpers::de_unit_open"
569    )]
570    pub link_weight_decay_lambda: f64,
571    /// Seconds between link weight decay passes. Default: `86400` (24 hours).
572    #[serde(default = "default_link_weight_decay_interval_secs")]
573    pub link_weight_decay_interval_secs: u64,
574    /// Kumiho AGM-inspired belief revision configuration.
575    ///
576    /// When `belief_revision.enabled = true`, new edges that semantically contradict existing
577    /// edges for the same entity pair trigger revision: the old edge is invalidated with a
578    /// `superseded_by` pointer and the new edge becomes the current belief.
579    #[serde(default)]
580    pub belief_revision: BeliefRevisionConfig,
581    /// D-MEM RPE-based tiered graph extraction routing.
582    ///
583    /// When `rpe.enabled = true`, low-surprise turns skip the expensive MAGMA LLM extraction
584    /// pipeline. A consecutive-skip safety valve ensures no turn is silently skipped indefinitely.
585    #[serde(default)]
586    pub rpe: RpeConfig,
587    /// `SQLite` connection pool size dedicated to graph operations.
588    ///
589    /// Graph tables share the same database file as messages/embeddings but use a
590    /// separate pool to prevent pool starvation when community detection or spreading
591    /// activation runs concurrently with regular memory operations. Default: `3`.
592    #[serde(default = "default_graph_pool_size")]
593    pub pool_size: u32,
594    /// APEX-MEM append-only write path (#3631).
595    ///
596    /// When `apex_mem.enabled = true`, edge insertion uses `insert_or_supersede` with
597    /// supersession chains instead of the legacy destructive-update path.
598    #[serde(default)]
599    pub apex_mem: ApexMemConfig,
600    /// LLM call timeout per extraction request, in seconds. Default: `30`.
601    #[serde(default = "default_graph_llm_timeout_secs")]
602    pub llm_timeout_secs: u64,
603    /// PRISM query-sensitive edge costing in A* graph recall.
604    ///
605    /// When `true`, edge cost in the A\* graph recall function is modulated by the cosine similarity
606    /// between the query embedding and the target entity embedding:
607    /// `cost = (1.0 - confidence) * (1.0 - target_cosine).max(0.01)`.
608    /// Edges toward semantically relevant entities receive lower cost and are therefore
609    /// preferred by A*, producing query-aligned recall paths.
610    ///
611    /// Requires an embedding store (`qdrant` or `sqlite` vector backend). When the embedding
612    /// store is unavailable or a target entity has no stored embedding, falls back to the
613    /// baseline cost `1.0 - confidence`.
614    ///
615    /// Default: `false` (preserves existing A* behaviour).
616    #[serde(default)]
617    pub query_sensitive_cost: bool,
618
619    /// Implicit conflict detection for SYNAPSE recall (spec 004-17, STALE/CUPMem).
620    ///
621    /// When enabled, write-time fuzzy predicate matching detects implicit conflicts
622    /// between graph edges and annotates SYNAPSE recall results accordingly.
623    #[serde(default)]
624    pub implicit_conflict: ImplicitConflictConfig,
625    /// `MemORAI` write-gate prefilter (#3709).
626    ///
627    /// When `write_gate.enabled = true`, low-signal edges are dropped before graph write,
628    /// reducing noise. Opt-in; default is `false`.
629    #[serde(default)]
630    pub write_gate: WriteGateConfig,
631    /// Conflict resolver recency-fallback threshold (#3709).
632    ///
633    /// Controls when the recency strategy is allowed to override `valid_from` comparison.
634    #[serde(default)]
635    pub conflict_recency: ConflictRecencyConfig,
636    /// Include knowledge-ingest edges in graph recall (spec-067 FR-003, INV-3).
637    ///
638    /// When `true` (default), all active edges are returned regardless of origin.
639    /// When `false`, edges with `origin = 'ingest'` are excluded from recall so only
640    /// conversation-derived knowledge appears in agent responses.
641    ///
642    /// Note: this flag lives here rather than `[knowledge]` for Phase 0 — it will be
643    /// re-homed when the `[knowledge]` section is introduced in a later ingest issue.
644    #[serde(default = "default_true")]
645    pub recall_include_imported: bool,
646}
647
648/// Similarity method for implicit conflict detection.
649#[derive(
650    Debug,
651    Clone,
652    Copy,
653    PartialEq,
654    Eq,
655    Default,
656    serde::Serialize,
657    serde::Deserialize,
658    schemars::JsonSchema,
659)]
660#[serde(rename_all = "snake_case")]
661#[non_exhaustive]
662pub enum SimilarityMethod {
663    /// Normalized Levenshtein edit distance.
664    #[default]
665    Levenshtein,
666    /// Cosine similarity over pre-computed predicate embeddings.
667    Embedding,
668    /// Either method triggers detection.
669    Both,
670}
671
672/// Resolution strategy when an implicit conflict is detected.
673#[derive(
674    Debug,
675    Clone,
676    Copy,
677    PartialEq,
678    Eq,
679    Default,
680    serde::Serialize,
681    serde::Deserialize,
682    schemars::JsonSchema,
683)]
684#[serde(rename_all = "snake_case")]
685#[non_exhaustive]
686pub enum ConflictResolutionStrategy {
687    /// Mark the pair as a candidate but do not supersede either edge.
688    #[default]
689    FlagOnly,
690    /// Supersede the older edge via APEX-MEM `insert_or_supersede`.
691    Recency,
692    /// Supersede the lower-confidence edge.
693    Confidence,
694    /// Delegate resolution to an LLM provider; fall back to `flag_only` on timeout.
695    Llm,
696}
697
698/// Configuration for the optional background consolidation daemon (spec 004-17).
699#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
700#[serde(default)]
701pub struct ConsolidationDaemonConfig {
702    /// Enable the background consolidation daemon.
703    pub enabled: bool,
704    /// How often the daemon runs, in seconds. Default: 7200 (2 hours).
705    #[serde(default = "default_ic_daemon_interval_secs")]
706    pub interval_seconds: u64,
707    /// Maximum number of candidates processed per daemon run. Default: 100.
708    #[serde(default = "default_ic_daemon_batch_size")]
709    pub batch_size: usize,
710}
711
712impl Default for ConsolidationDaemonConfig {
713    fn default() -> Self {
714        Self {
715            enabled: false,
716            interval_seconds: default_ic_daemon_interval_secs(),
717            batch_size: default_ic_daemon_batch_size(),
718        }
719    }
720}
721
722fn default_ic_daemon_interval_secs() -> u64 {
723    7200
724}
725
726fn default_ic_daemon_batch_size() -> usize {
727    100
728}
729
730/// Configuration for implicit conflict detection (spec 004-17, STALE/CUPMem).
731///
732/// Controls write-time fuzzy predicate matching and SYNAPSE recall annotation.
733/// All detection is gated behind `enabled = false` by default — no overhead when disabled.
734///
735/// TOML path: `[memory.graph.implicit_conflict]`
736///
737/// # Examples
738///
739/// ```toml
740/// [memory.graph.implicit_conflict]
741/// enabled = true
742/// similarity_method = "levenshtein"
743/// conflict_similarity_threshold = 0.80
744/// resolution_strategy = "flag_only"
745/// candidate_ttl_days = 30
746/// propagation_depth = 2
747/// ```
748#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
749#[serde(default)]
750pub struct ImplicitConflictConfig {
751    /// Enable implicit conflict detection. Default: `false`.
752    pub enabled: bool,
753    /// Similarity method used to detect candidate pairs.
754    #[serde(default)]
755    pub similarity_method: SimilarityMethod,
756    /// Minimum similarity score to flag a pair as a conflict candidate. Default: 0.80.
757    #[serde(default = "default_ic_similarity_threshold")]
758    pub conflict_similarity_threshold: f64,
759    /// How to resolve detected conflicts. Default: `flag_only`.
760    #[serde(default)]
761    pub resolution_strategy: ConflictResolutionStrategy,
762    /// Provider name (from `[[llm.providers]]`) for LLM-mediated resolution.
763    #[serde(default)]
764    pub implicit_conflict_provider: crate::providers::ProviderName,
765    /// LLM resolution timeout in milliseconds. Default: 800.
766    #[serde(default = "default_ic_llm_timeout_ms")]
767    pub conflict_llm_timeout_ms: u64,
768    /// Days before an unresolved candidate entry expires. Default: 30.
769    #[serde(default = "default_ic_candidate_ttl_days")]
770    pub candidate_ttl_days: u32,
771    /// SYNAPSE propagation depth for surfacing superseding facts. Default: 2.
772    #[serde(default = "default_ic_propagation_depth")]
773    pub propagation_depth: u32,
774    /// Background consolidation daemon configuration.
775    #[serde(default)]
776    pub consolidation_daemon: ConsolidationDaemonConfig,
777}
778
779impl Default for ImplicitConflictConfig {
780    fn default() -> Self {
781        Self {
782            enabled: false,
783            similarity_method: SimilarityMethod::default(),
784            conflict_similarity_threshold: default_ic_similarity_threshold(),
785            resolution_strategy: ConflictResolutionStrategy::default(),
786            implicit_conflict_provider: crate::providers::ProviderName::default(),
787            conflict_llm_timeout_ms: default_ic_llm_timeout_ms(),
788            candidate_ttl_days: default_ic_candidate_ttl_days(),
789            propagation_depth: default_ic_propagation_depth(),
790            consolidation_daemon: ConsolidationDaemonConfig::default(),
791        }
792    }
793}
794
795fn default_ic_similarity_threshold() -> f64 {
796    0.80
797}
798
799fn default_ic_llm_timeout_ms() -> u64 {
800    800
801}
802
803fn default_ic_candidate_ttl_days() -> u32 {
804    30
805}
806
807fn default_ic_propagation_depth() -> u32 {
808    2
809}
810
811fn default_graph_pool_size() -> u32 {
812    3
813}
814
815fn default_graph_llm_timeout_secs() -> u64 {
816    30
817}
818
819/// APEX-MEM append-only write path configuration (`[memory.graph.apex_mem]`).
820///
821/// When `enabled = true`, graph edge insertion uses `insert_or_supersede`
822/// instead of the legacy destructive-update `resolve_edge_typed`. This preserves
823/// the full supersession chain and enables conflict resolution.
824///
825/// Spec: `/specs/004-memory/004-7-memory-apex-magma.md`
826#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
827#[serde(default)]
828pub struct ApexMemConfig {
829    /// Enable the APEX-MEM append-only write path. Default: `false`.
830    pub enabled: bool,
831}
832
833fn default_quality_gate_threshold() -> f32 {
834    0.55
835}
836
837fn default_quality_gate_recent_window() -> usize {
838    32
839}
840
841fn default_quality_gate_contradiction_grace_seconds() -> u64 {
842    300
843}
844
845fn default_quality_gate_information_value_weight() -> f32 {
846    0.4
847}
848
849fn default_quality_gate_reference_completeness_weight() -> f32 {
850    0.3
851}
852
853fn default_quality_gate_contradiction_weight() -> f32 {
854    0.3
855}
856
857fn default_quality_gate_rejection_rate_alarm_ratio() -> f32 {
858    0.35
859}
860
861fn default_quality_gate_llm_timeout_ms() -> u64 {
862    500
863}
864
865fn default_quality_gate_llm_weight() -> f32 {
866    0.5
867}
868
869fn default_quality_gate_reference_check_lang_en() -> bool {
870    true
871}
872
873/// Write quality gate configuration (`[memory.quality_gate]`).
874///
875/// When `enabled = true`, each `remember()` call is scored before persistence. Writes
876/// below `threshold` are rejected. Rule-based scoring is the default; LLM-assisted
877/// scoring is opt-in via `quality_gate_provider`.
878///
879/// Spec: `/specs/004-memory/004-9-memory-write-gate.md`
880#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
881#[serde(default)]
882pub struct WriteQualityGateConfig {
883    /// Enable the write quality gate. Default: `false`.
884    pub enabled: bool,
885    /// Combined score threshold below which writes are rejected. Default: `0.55`.
886    #[serde(default = "default_quality_gate_threshold")]
887    pub threshold: f32,
888    /// Number of recent writes compared for information-value scoring. Default: `32`.
889    #[serde(default = "default_quality_gate_recent_window")]
890    pub recent_window: usize,
891    /// Edges older than this (seconds) are stable for contradiction detection. Default: `300`.
892    #[serde(default = "default_quality_gate_contradiction_grace_seconds")]
893    pub contradiction_grace_seconds: u64,
894    /// Weight of `information_value` sub-score. Default: `0.4`.
895    #[serde(default = "default_quality_gate_information_value_weight")]
896    pub information_value_weight: f32,
897    /// Weight of `reference_completeness` sub-score. Default: `0.3`.
898    #[serde(default = "default_quality_gate_reference_completeness_weight")]
899    pub reference_completeness_weight: f32,
900    /// Weight of `contradiction` sub-score. Default: `0.3`.
901    #[serde(default = "default_quality_gate_contradiction_weight")]
902    pub contradiction_weight: f32,
903    /// Rolling rejection-rate alarm ratio. Default: `0.35`.
904    #[serde(default = "default_quality_gate_rejection_rate_alarm_ratio")]
905    pub rejection_rate_alarm_ratio: f32,
906    /// Named LLM provider for optional scoring path. Default: `""` (rule-based only).
907    #[serde(default)]
908    pub quality_gate_provider: ProviderName,
909    /// LLM timeout in milliseconds. Default: `500`.
910    #[serde(default = "default_quality_gate_llm_timeout_ms")]
911    pub llm_timeout_ms: u64,
912    /// LLM blend weight into final score. Default: `0.5`.
913    #[serde(default = "default_quality_gate_llm_weight")]
914    pub llm_weight: f32,
915    /// Enable pronoun/deictic reference checks (English only). Default: `true`.
916    #[serde(default = "default_quality_gate_reference_check_lang_en")]
917    pub reference_check_lang_en: bool,
918}
919
920impl Default for WriteQualityGateConfig {
921    fn default() -> Self {
922        Self {
923            enabled: false,
924            threshold: default_quality_gate_threshold(),
925            recent_window: default_quality_gate_recent_window(),
926            contradiction_grace_seconds: default_quality_gate_contradiction_grace_seconds(),
927            information_value_weight: default_quality_gate_information_value_weight(),
928            reference_completeness_weight: default_quality_gate_reference_completeness_weight(),
929            contradiction_weight: default_quality_gate_contradiction_weight(),
930            rejection_rate_alarm_ratio: default_quality_gate_rejection_rate_alarm_ratio(),
931            quality_gate_provider: ProviderName::default(),
932            llm_timeout_ms: default_quality_gate_llm_timeout_ms(),
933            llm_weight: default_quality_gate_llm_weight(),
934            reference_check_lang_en: default_quality_gate_reference_check_lang_en(),
935        }
936    }
937}
938
939impl Default for GraphConfig {
940    fn default() -> Self {
941        Self {
942            enabled: false,
943            extract_model: String::new(),
944            max_entities_per_message: default_graph_max_entities_per_message(),
945            max_edges_per_message: default_graph_max_edges_per_message(),
946            community_refresh_interval: default_graph_community_refresh_interval(),
947            entity_similarity_threshold: default_graph_entity_similarity_threshold(),
948            extraction_timeout_secs: default_graph_extraction_timeout_secs(),
949            use_embedding_resolution: false,
950            entity_ambiguous_threshold: default_graph_entity_ambiguous_threshold(),
951            max_hops: default_graph_max_hops(),
952            recall_limit: default_graph_recall_limit(),
953            expired_edge_retention_days: default_graph_expired_edge_retention_days(),
954            max_entities: 0,
955            community_summary_max_prompt_bytes: default_graph_community_summary_max_prompt_bytes(),
956            community_summary_concurrency: default_graph_community_summary_concurrency(),
957            lpa_edge_chunk_size: default_lpa_edge_chunk_size(),
958            temporal_decay_rate: default_graph_temporal_decay_rate(),
959            edge_history_limit: default_graph_edge_history_limit(),
960            note_linking: NoteLinkingConfig::default(),
961            spreading_activation: SpreadingActivationConfig::default(),
962            retrieval_strategy: GraphRetrievalStrategy::default(),
963            extract_provider: ProviderName::default(),
964            strategy_classifier_provider: None,
965            beam_search: BeamSearchConfig::default(),
966            watercircles: WaterCirclesConfig::default(),
967            experience: ExperienceConfig::default(),
968            link_weight_decay_lambda: default_link_weight_decay_lambda(),
969            link_weight_decay_interval_secs: default_link_weight_decay_interval_secs(),
970            belief_revision: BeliefRevisionConfig::default(),
971            rpe: RpeConfig::default(),
972            pool_size: default_graph_pool_size(),
973            apex_mem: ApexMemConfig::default(),
974            llm_timeout_secs: default_graph_llm_timeout_secs(),
975            query_sensitive_cost: false,
976            implicit_conflict: ImplicitConflictConfig::default(),
977            write_gate: WriteGateConfig::default(),
978            conflict_recency: ConflictRecencyConfig::default(),
979            recall_include_imported: true,
980        }
981    }
982}
983
984fn default_true() -> bool {
985    true
986}
987
988fn default_link_weight_decay_lambda() -> f64 {
989    0.95
990}
991
992fn default_link_weight_decay_interval_secs() -> u64 {
993    86400
994}