diff --git a/CHANGELOG.md b/CHANGELOG.md index 61302504..9825ce1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ ### Changed +- Internal: `[tier]`, `[dream]` and `[lifecycle]` are read straight into the core types, #1515 part 1 (round 2 of the architecture deepening, #1523; the first step of closing #1355). `uteke_core::{TierConfig, DreamConfig, LifecycleConfig}` now implement `serde::Deserialize` (each key optional, the core default for the rest; negative tier days are still rejected), so the CLI config uses them as its section types and the server drops its three `Option` mirror structs and the field-by-field default chains. `[recall]` and `[embed_fallback]` follow in the next parts. No behaviour change. - Internal, API: transports no longer reach into the store, #1521 (round 2 of the architecture deepening, #1523). `Uteke::store()` and `Uteke::graph_store()` are no longer public (a breaking change for crates that depend on `uteke-core`); `Uteke` offers `count_active`, `list_deprecated` and `graph()` (the knowledge graph) instead, and `store_for_tests()` exists behind the `test-support` feature for fixtures. The private module of the `Uteke` room operations is now `room_ops`, so it no longer shares a name with `memory::rooms`. No behaviour change. - **`remember` with `detect_contradiction` writes like plain `remember` (#1514, part 1)** - the two now share one embedding step and one commit step. The contradiction path used to skip the memory type check (an unknown type was stored), embed JSON content as raw text instead of the flattened text, try the embedding once, and fail the whole write when the embedding failed. It now rejects an unknown type, embeds the flattened text, retries like `remember`, and on a persistent embedding failure stores the memory FTS5-only with a warning (the contradiction check is skipped without a vector, as the duplicate check is). Import and consolidation move onto the same commit step in later parts, once uteke-cloud#242 is answered. - Internal: the redaction flag policy is its own module, #1520 (round 2 of the architecture deepening, #1523): `redaction_flags::resolve` decides which `redacted` / `redactions` flags a memory carries after an update as one pure function with a test per rule, and `update_memory_detailed` loses its inline five-arm `match`. The three flag helpers moved with it and are still reachable as `uteke_core::privacy::{with,without,carry}_redaction_flags`. No behaviour change. diff --git a/crates/uteke-cli/src/config.rs b/crates/uteke-cli/src/config.rs index c43ff9e2..8f986ad0 100644 --- a/crates/uteke-cli/src/config.rs +++ b/crates/uteke-cli/src/config.rs @@ -155,27 +155,9 @@ impl EmbedFallbackConfig { } } -/// Tier configuration for hot/warm/cold memory tiers. -#[derive(serde::Deserialize, Clone)] -#[serde(default)] -pub struct TierConfig { - /// Days before memory moves from hot to warm. - pub hot_days: u32, - /// Days before memory moves from warm to cold. - pub warm_days: u32, - /// Score boost for hot memories. - pub hot_boost: f64, -} - -impl Default for TierConfig { - fn default() -> Self { - Self { - hot_days: 7, - warm_days: 30, - hot_boost: 0.1, - } - } -} +/// Tier configuration for hot/warm/cold memory tiers: the core type, so the +/// keys and defaults live in one place (#1515). +pub type TierConfig = uteke_core::TierConfig; /// Logging configuration. #[derive(serde::Deserialize, Clone)] @@ -331,115 +313,11 @@ impl Default for MaintenanceConfig { } } -/// Dream pipeline thresholds (#731). All phases in the dream cycle -/// (contradiction scan, dedup/consolidate, orphan detection) read from -/// these values instead of hardcoded constants. -#[derive(serde::Deserialize, Clone, Copy)] -#[serde(default)] -pub struct DreamConfig { - /// Cosine similarity threshold for contradiction scan. Memories with - /// similarity ABOVE this value are NOT contradictions. Default: 0.6. - pub contradict_similarity_threshold: f32, - /// Minimum Jaccard index for tag overlap to consider contradiction. - /// Default: 0.4 (up from original 0.3 to reduce false positives). - pub contradict_tag_jaccard_min: f32, - /// Maximum memories loaded for O(n²) contradiction scan. Default: 200. - pub contradict_max_memories: usize, - /// Cosine similarity threshold for dedup/consolidate. Memories with - /// similarity ABOVE this value are merge candidates. Default: 0.92. - pub dedup_threshold: f32, - /// Importance threshold for orphan detection. Memories below this AND - /// with no edges are flagged as orphans. Default: 0.15 (safer than 0.3). - pub orphan_importance_threshold: f64, -} +/// Dream pipeline thresholds (#731): the core type (#1515). +pub type DreamConfig = uteke_core::DreamConfig; -impl Default for DreamConfig { - fn default() -> Self { - Self { - contradict_similarity_threshold: 0.6, - contradict_tag_jaccard_min: 0.4, - contradict_max_memories: 200, - dedup_threshold: 0.92, - orphan_importance_threshold: 0.15, - } - } -} - -/// Memory lifecycle configuration (#928). -/// -/// Controls how memories transition through their lifecycle: -/// `ACTIVE → DEPRECATED (hidden, restorable) → PRUNED (hard delete)`. -/// -/// All destructive operations route through soft-delete when -/// `soft_delete_only = true` (the default). -#[derive(serde::Deserialize, Clone)] -#[serde(default)] -pub struct LifecycleConfig { - /// Route all destructive ops through `deprecate()` instead of `delete()`. - /// Default: true. Set to `false` to restore pre-v0.13 hard-delete behavior. - pub soft_delete_only: bool, - /// Whether auto lifecycle cycles are enabled. Default: true. - pub auto_aging_enabled: bool, - /// Hours between automatic lifecycle cycles (server mode). Default: 168 (weekly). - pub auto_aging_interval_hours: u64, - /// Minimum age in days before eligible for deprecation. Default: 90. - pub min_age_days: u32, - /// Maximum access count for "cold" eligibility. Default: 3. - pub max_access_count: u32, - /// Max percentage of total memories deprecable per cycle. Default: 1.0. - pub max_deprecate_percent: f64, - /// Minimum deprecations per cycle. Default: 1. - pub min_deprecate_per_cycle: usize, - /// Hard ceiling on deprecations per cycle. Default: 50. - pub max_deprecate_per_cycle: usize, - /// Days after deprecation before eligible for pruning (hard delete). Default: 30. - pub deprecated_ttl_days: u32, - /// Auto-prune expired deprecated memories during cycles. Default: true. - pub auto_prune_enabled: bool, - /// Dream dedup phase uses soft-delete. Default: true. - pub dream_dedup_soft_delete: bool, - /// Dream compact phase uses soft-delete. Default: true. - pub dream_compact_soft_delete: bool, -} - -impl Default for LifecycleConfig { - fn default() -> Self { - Self { - soft_delete_only: true, - auto_aging_enabled: true, - auto_aging_interval_hours: 168, - min_age_days: 90, - max_access_count: 3, - max_deprecate_percent: 1.0, - min_deprecate_per_cycle: 1, - max_deprecate_per_cycle: 50, - deprecated_ttl_days: 30, - auto_prune_enabled: true, - dream_dedup_soft_delete: true, - dream_compact_soft_delete: true, - } - } -} - -impl LifecycleConfig { - /// Convert CLI config to core config. - pub fn to_core(&self) -> uteke_core::LifecycleConfig { - uteke_core::LifecycleConfig { - soft_delete_only: self.soft_delete_only, - auto_aging_enabled: self.auto_aging_enabled, - auto_aging_interval_hours: self.auto_aging_interval_hours, - min_age_days: self.min_age_days, - max_access_count: self.max_access_count, - max_deprecate_percent: self.max_deprecate_percent, - min_deprecate_per_cycle: self.min_deprecate_per_cycle, - max_deprecate_per_cycle: self.max_deprecate_per_cycle, - deprecated_ttl_days: self.deprecated_ttl_days, - auto_prune_enabled: self.auto_prune_enabled, - dream_dedup_soft_delete: self.dream_dedup_soft_delete, - dream_compact_soft_delete: self.dream_compact_soft_delete, - } - } -} +/// Memory lifecycle configuration (#928): the core type (#1515). +pub type LifecycleConfig = uteke_core::LifecycleConfig; /// Full uteke configuration, loaded from `uteke.toml`. #[derive(serde::Deserialize, Clone)] diff --git a/crates/uteke-cli/src/main.rs b/crates/uteke-cli/src/main.rs index 7f8d2b49..492f107a 100644 --- a/crates/uteke-cli/src/main.rs +++ b/crates/uteke-cli/src/main.rs @@ -217,11 +217,7 @@ fn main() { model: config.embedding.model.clone(), dims: config.embedding.dims, }, - uteke_core::TierConfig { - hot_days: config.tier.hot_days as i64, - warm_days: config.tier.warm_days as i64, - hot_boost: config.tier.hot_boost, - }, + config.tier, uteke_core::RecallConfig { min_score: config.recall.min_score as f32, }, @@ -238,15 +234,9 @@ fn main() { // #719: apply Jaccard weight from config u.set_jaccard_weight(config.recall.jaccard_weight); // #731: apply dream pipeline thresholds from config - u.set_dream_config(uteke_core::DreamConfig { - contradict_similarity_threshold: config.dream.contradict_similarity_threshold, - contradict_tag_jaccard_min: config.dream.contradict_tag_jaccard_min, - contradict_max_memories: config.dream.contradict_max_memories, - dedup_threshold: config.dream.dedup_threshold, - orphan_importance_threshold: config.dream.orphan_importance_threshold, - }); + u.set_dream_config(config.dream); // #928: apply lifecycle configuration from config - u.set_lifecycle_config(config.lifecycle.to_core()); + u.set_lifecycle_config(config.lifecycle); // #1464: secret filter on the write path ([privacy] in uteke.toml) u.set_privacy_config(uteke_core::privacy::load_config()); u diff --git a/crates/uteke-core/src/engine_settings_tests.rs b/crates/uteke-core/src/engine_settings_tests.rs new file mode 100644 index 00000000..30846fb5 --- /dev/null +++ b/crates/uteke-core/src/engine_settings_tests.rs @@ -0,0 +1,70 @@ +//! The `[tier]`, `[dream]` and `[lifecycle]` sections of `uteke.toml` are read +//! straight into the core types (#1515): one schema for the CLI and the server. + +use crate::{DreamConfig, LifecycleConfig, TierConfig}; + +fn parse(toml_text: &str) -> Result { + toml::from_str(toml_text) +} + +#[test] +fn an_empty_section_is_the_documented_default() { + let t: TierConfig = parse("").unwrap(); + assert_eq!((t.hot_days, t.warm_days), (7, 30)); + assert!((t.hot_boost - 0.1).abs() < f64::EPSILON); + + let d: DreamConfig = parse("").unwrap(); + assert_eq!(d.contradict_max_memories, 200); + assert!((d.contradict_similarity_threshold - 0.6).abs() < f32::EPSILON); + assert!((d.contradict_tag_jaccard_min - 0.4).abs() < f32::EPSILON); + assert!((d.dedup_threshold - 0.92).abs() < f32::EPSILON); + assert!((d.orphan_importance_threshold - 0.15).abs() < f64::EPSILON); + + let l: LifecycleConfig = parse("").unwrap(); + assert!(l.soft_delete_only && l.auto_aging_enabled && l.auto_prune_enabled); + assert!(l.dream_dedup_soft_delete && l.dream_compact_soft_delete); + assert_eq!( + ( + l.auto_aging_interval_hours, + l.min_age_days, + l.max_access_count + ), + (168, 90, 3) + ); + assert_eq!( + ( + l.min_deprecate_per_cycle, + l.max_deprecate_per_cycle, + l.deprecated_ttl_days + ), + (1, 50, 30) + ); + assert!((l.max_deprecate_percent - 1.0).abs() < f64::EPSILON); +} + +#[test] +fn a_partial_section_keeps_the_default_for_the_keys_it_omits() { + let t: TierConfig = parse("hot_days = 3").unwrap(); + assert_eq!((t.hot_days, t.warm_days), (3, 30)); + let d: DreamConfig = parse("dedup_threshold = 0.8").unwrap(); + assert!((d.dedup_threshold - 0.8).abs() < f32::EPSILON); + assert_eq!(d.contradict_max_memories, 200); + let l: LifecycleConfig = parse("soft_delete_only = false\ndeprecated_ttl_days = 5").unwrap(); + assert!(!l.soft_delete_only); + assert_eq!(l.deprecated_ttl_days, 5); + assert_eq!(l.min_age_days, 90); +} + +#[test] +fn negative_tier_days_are_rejected_as_before() { + assert!(parse::("hot_days = -1").is_err()); + assert!(parse::("warm_days = -30").is_err()); + // Zero is a number of days. + assert_eq!(parse::("hot_days = 0").unwrap().hot_days, 0); +} + +#[test] +fn a_wrong_type_is_an_error_not_a_silent_default() { + assert!(parse::("min_age_days = \"ninety\"").is_err()); + assert!(parse::("contradict_max_memories = -5").is_err()); +} diff --git a/crates/uteke-core/src/lib.rs b/crates/uteke-core/src/lib.rs index 440583e7..9b09998c 100644 --- a/crates/uteke-core/src/lib.rs +++ b/crates/uteke-core/src/lib.rs @@ -20,6 +20,8 @@ pub mod consolidation_plan; pub mod dream; mod edges; mod embed; +#[cfg(test)] +mod engine_settings_tests; mod error; #[cfg(test)] mod error_class_tests; @@ -211,15 +213,33 @@ use std::os::unix::fs::PermissionsExt; /// Configuration for memory tier thresholds. /// +/// A number of days read from `uteke.toml`: zero or more, as the `u32` the +/// config sections used before they shared this type. +fn non_negative_days<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let days = ::deserialize(deserializer)?; + if days < 0 { + return Err(serde::de::Error::custom( + "days must be zero or more (a negative value is not a number of days)", + )); + } + Ok(days) +} + /// Controls how memories are classified into hot/warm/cold tiers /// and how hot memories are boosted in recall scoring. /// /// Defaults match the hardcoded values used before config wiring (#127). -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, serde::Deserialize)] +#[serde(default)] pub struct TierConfig { /// Days before memory moves from hot → warm. + #[serde(deserialize_with = "non_negative_days")] pub hot_days: i64, /// Days before memory moves from warm → cold. + #[serde(deserialize_with = "non_negative_days")] pub warm_days: i64, /// Score boost added to hot memories during recall. pub hot_boost: f64, @@ -251,7 +271,8 @@ impl Default for RecallConfig { /// Dream pipeline thresholds (#731). All phases in the dream cycle /// (contradiction scan, dedup/consolidate, orphan detection) read from /// these values instead of hardcoded constants. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, serde::Deserialize)] +#[serde(default)] pub struct DreamConfig { /// Cosine similarity threshold for contradiction scan. Memories with /// similarity ABOVE this value are NOT contradictions. Default: 0.6. @@ -307,7 +328,8 @@ impl Default for DreamConfig { /// dream_dedup_soft_delete = true # dream dedup → deprecate, not delete /// dream_compact_soft_delete = true # dream compact → deprecate, not delete /// ``` -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, serde::Deserialize)] +#[serde(default)] pub struct LifecycleConfig { /// When `true`, all destructive operations route through `deprecate()` /// instead of `delete()`. Set to `false` to restore pre-v0.13 hard-delete diff --git a/crates/uteke-server/src/main.rs b/crates/uteke-server/src/main.rs index aab4b2aa..b19f0fef 100644 --- a/crates/uteke-server/src/main.rs +++ b/crates/uteke-server/src/main.rs @@ -257,7 +257,6 @@ fn main() { let db_path = home.join("uteke.db").to_string_lossy().to_string(); info!("Opening store at: {db_path}"); - let defaults = uteke_core::DreamConfig::default(); // Embedding backend + vector engine from uteke.toml / env (#1399). Fail // fast on an unsupported backend instead of opening the store with a // different embedder than the operator asked for. @@ -295,72 +294,20 @@ fn main() { u.set_fallback_settings(fallback); } // Apply dream pipeline thresholds from config (#731) - if let Some(ref dc) = config.dream { - u.set_dream_config(uteke_core::DreamConfig { - contradict_similarity_threshold: dc - .contradict_similarity_threshold - .unwrap_or(defaults.contradict_similarity_threshold), - contradict_tag_jaccard_min: dc - .contradict_tag_jaccard_min - .unwrap_or(defaults.contradict_tag_jaccard_min), - contradict_max_memories: dc - .contradict_max_memories - .unwrap_or(defaults.contradict_max_memories), - dedup_threshold: dc.dedup_threshold.unwrap_or(defaults.dedup_threshold), - orphan_importance_threshold: dc - .orphan_importance_threshold - .unwrap_or(defaults.orphan_importance_threshold), - }); + if let Some(dc) = config.dream { + u.set_dream_config(dc); info!( "Dream config loaded: dedup={:.2}, orphan_thresh={:.2}", - dc.dedup_threshold.unwrap_or(defaults.dedup_threshold), - dc.orphan_importance_threshold - .unwrap_or(defaults.orphan_importance_threshold), + dc.dedup_threshold, dc.orphan_importance_threshold, ); } // Apply lifecycle config from uteke.toml [lifecycle] section (#928) - if let Some(ref lc) = config.lifecycle { - let defaults = uteke_core::LifecycleConfig::default(); - u.set_lifecycle_config(uteke_core::LifecycleConfig { - soft_delete_only: lc.soft_delete_only.unwrap_or(defaults.soft_delete_only), - auto_aging_enabled: lc - .auto_aging_enabled - .unwrap_or(defaults.auto_aging_enabled), - auto_aging_interval_hours: lc - .auto_aging_interval_hours - .unwrap_or(defaults.auto_aging_interval_hours), - min_age_days: lc.min_age_days.unwrap_or(defaults.min_age_days), - max_access_count: lc.max_access_count.unwrap_or(defaults.max_access_count), - max_deprecate_percent: lc - .max_deprecate_percent - .unwrap_or(defaults.max_deprecate_percent), - min_deprecate_per_cycle: lc - .min_deprecate_per_cycle - .unwrap_or(defaults.min_deprecate_per_cycle), - max_deprecate_per_cycle: lc - .max_deprecate_per_cycle - .unwrap_or(defaults.max_deprecate_per_cycle), - deprecated_ttl_days: lc - .deprecated_ttl_days - .unwrap_or(defaults.deprecated_ttl_days), - auto_prune_enabled: lc - .auto_prune_enabled - .unwrap_or(defaults.auto_prune_enabled), - dream_dedup_soft_delete: lc - .dream_dedup_soft_delete - .unwrap_or(defaults.dream_dedup_soft_delete), - dream_compact_soft_delete: lc - .dream_compact_soft_delete - .unwrap_or(defaults.dream_compact_soft_delete), - }); + if let Some(lc) = config.lifecycle { + u.set_lifecycle_config(lc); info!( "Lifecycle config loaded: soft_delete_only={}, ttl={}d, max_deprecate={:.1}%", - lc.soft_delete_only.unwrap_or(defaults.soft_delete_only), - lc.deprecated_ttl_days - .unwrap_or(defaults.deprecated_ttl_days), - lc.max_deprecate_percent - .unwrap_or(defaults.max_deprecate_percent), + lc.soft_delete_only, lc.deprecated_ttl_days, lc.max_deprecate_percent, ); } Arc::new(Mutex::new(u)) @@ -449,17 +396,10 @@ fn main() { // Auto-lifecycle background thread (#934 — replaces auto-aging #442). // Runs lifecycle_cycle periodically: soft-deprecate aged memories (cap-limited) // + auto-prune expired deprecated memories. - let lifecycle_enabled = config - .lifecycle - .as_ref() - .and_then(|lc| lc.auto_aging_enabled) - .unwrap_or(true); - let lifecycle_hours = config - .lifecycle - .as_ref() - .and_then(|lc| lc.auto_aging_interval_hours) - .unwrap_or(168) // weekly by default - .max(1); // Minimum 1 hour to prevent busy loop + let lifecycle_config = config.lifecycle.unwrap_or_default(); + let lifecycle_enabled = lifecycle_config.auto_aging_enabled; + // Minimum 1 hour to prevent a busy loop. + let lifecycle_hours = lifecycle_config.auto_aging_interval_hours.max(1); let lifecycle_uteke = Arc::clone(&uteke); if lifecycle_enabled { info!("Auto-lifecycle: enabled (every {lifecycle_hours}h)"); @@ -706,7 +646,7 @@ struct ServerFileConfig { /// `[embed_fallback]`: second embedding endpoint used when the primary fails (#1355). embed_fallback: Option, /// `[tier]`: hot/warm/cold memory tiers (#1355). - tier: Option, + tier: Option, server: Option, recall: Option, extraction: Option, @@ -714,8 +654,8 @@ struct ServerFileConfig { /// Deprecated: superseded by [lifecycle] section (#934). Kept for backward-compat deserialization. #[allow(dead_code)] aging: Option, - dream: Option, - lifecycle: Option, + dream: Option, + lifecycle: Option, } /// `[embedding]` in uteke.toml, same keys as the CLI. Empty / missing values @@ -786,23 +726,9 @@ fn resolve_recall_tuning(config: &ServerFileConfig) -> RecallTuning { } } -/// `[tier]` in uteke.toml, same keys and defaults as the CLI. -#[derive(serde::Deserialize, Default, Clone)] -struct TierFileSection { - hot_days: Option, - warm_days: Option, - hot_boost: Option, -} - -/// Tier settings: file values over the core defaults (7 / 30 days, boost 0.1). +/// Tier settings: `[tier]` over the core defaults (7 / 30 days, boost 0.1). fn resolve_tier(config: &ServerFileConfig) -> uteke_core::TierConfig { - let default = uteke_core::TierConfig::default(); - let file = config.tier.clone().unwrap_or_default(); - uteke_core::TierConfig { - hot_days: file.hot_days.map_or(default.hot_days, i64::from), - warm_days: file.warm_days.map_or(default.warm_days, i64::from), - hot_boost: file.hot_boost.unwrap_or(default.hot_boost), - } + config.tier.unwrap_or_default() } /// Backends `Uteke` can initialize lazily (same list as the CLI validates). @@ -870,33 +796,6 @@ struct MaintenanceFileSection { auto_dream_interval_days: Option, } -/// Dream pipeline thresholds from uteke.toml [dream] section (#731). -#[derive(serde::Deserialize, Default, Clone)] -struct DreamFileSection { - contradict_similarity_threshold: Option, - contradict_tag_jaccard_min: Option, - contradict_max_memories: Option, - dedup_threshold: Option, - orphan_importance_threshold: Option, -} - -/// Memory lifecycle config from uteke.toml [lifecycle] section (#928). -#[derive(serde::Deserialize, Default, Clone)] -struct LifecycleFileSection { - soft_delete_only: Option, - auto_aging_enabled: Option, - auto_aging_interval_hours: Option, - min_age_days: Option, - max_access_count: Option, - max_deprecate_percent: Option, - min_deprecate_per_cycle: Option, - max_deprecate_per_cycle: Option, - deprecated_ttl_days: Option, - auto_prune_enabled: Option, - dream_dedup_soft_delete: Option, - dream_compact_soft_delete: Option, -} - #[derive(serde::Deserialize, Default)] struct ServerFileSection { host: Option,