diff --git a/CHANGELOG.md b/CHANGELOG.md index 37e5e245..b1059ad9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ ### Changed +- Internal: one `EngineSettings` and one apply step, #1515 part 3 (round 2 of the architecture deepening, #1523). `uteke_core::EngineSettings` holds `[tier]`, `[recall]`, `[dream]`, `[lifecycle]` and `[embed_fallback]`, and `FallbackSettings` is deserializable like the others; the CLI config and the server config embed it with `#[serde(flatten)]` instead of writing the five sections twice, and `Uteke::apply_settings` replaces the sequence of setters that the CLI and the server each repeated (the CLI no longer applies the fallback in a separate block; the server logs the dream and lifecycle settings on every start, not only when the section exists). MCP does not use it yet (next part). No behaviour change, checked by running the CLI and the server built from `develop` and from this branch on the same config with all five sections, a partial file and an invalid one. - Internal: `[recall]` is read into one core type, #1515 part 2 (round 2 of the architecture deepening, #1523). `uteke_core::RecallSettings` owns the nine keys, their defaults and the `UTEKE_RECALL_*` / `UTEKE_GRAPH_*` overrides with their range checks (moved from the CLI, tested with an injected lookup); the CLI config uses it as its `[recall]` type and the server drops `RecallFileSection` and its `Option` chains. The server still does not read the `UTEKE_RECALL_*` variables: that is decided in the apply step of part 3. No behaviour change, checked by running the CLI and the server built from `develop` and from this branch with the same `[recall]` section and environment. - 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. diff --git a/crates/uteke-cli/src/commands/recall.rs b/crates/uteke-cli/src/commands/recall.rs index 76aaed39..6a14d32f 100644 --- a/crates/uteke-cli/src/commands/recall.rs +++ b/crates/uteke-cli/src/commands/recall.rs @@ -68,8 +68,8 @@ pub(crate) fn run_recall( // Resolve threshold: --min > --strict (→ config min_score_strict) > config min_score > 0.0 let min_score = match min { Some(m) => m, - None if strict => config.recall.min_score_strict as f32, - None => config.recall.min_score as f32, + None if strict => config.engine.recall.min_score_strict as f32, + None => config.engine.recall.min_score as f32, }; tracing::info!( @@ -86,7 +86,7 @@ pub(crate) fn run_recall( // Resolve strategy: --strategy flag > config [recall].default_strategy // > built-in default ("hybrid"). Unknown values fall back to vector with // a warning so a typo never silently changes recall semantics. - let strategy_name = strategy.unwrap_or(&config.recall.default_strategy); + let strategy_name = strategy.unwrap_or(&config.engine.recall.default_strategy); let resolved_strategy = match RecallStrategy::from_str_opt(strategy_name) { Some(s) => s, None => { @@ -101,13 +101,13 @@ pub(crate) fn run_recall( // Some(true) = use config weight (0.15), Some(false) / --no-* = 0.0. uteke.set_salience_recency_config(uteke_core::SalienceRecencyConfig { salience_weight: match salience { - Some(true) => config.recall.salience_weight, // explicit --salience - Some(false) => 0.0, // explicit --no-salience + Some(true) => config.engine.recall.salience_weight, // explicit --salience + Some(false) => 0.0, // explicit --no-salience None => uteke_core::SalienceRecencyConfig::default().salience_weight, // default 0.1 }, recency_weight: match recency { - Some(true) => config.recall.recency_weight, // explicit --recency - Some(false) => 0.0, // explicit --no-recency + Some(true) => config.engine.recall.recency_weight, // explicit --recency + Some(false) => 0.0, // explicit --no-recency None => uteke_core::SalienceRecencyConfig::default().recency_weight, // default 0.1 }, }); diff --git a/crates/uteke-cli/src/commands/room.rs b/crates/uteke-cli/src/commands/room.rs index b999d5bf..7bce959f 100644 --- a/crates/uteke-cli/src/commands/room.rs +++ b/crates/uteke-cli/src/commands/room.rs @@ -158,7 +158,7 @@ pub(crate) fn run( let effective_query = query.as_ref().or(query_flag.as_ref()); if let Some(q) = effective_query { // Semantic recall — rank by relevance - let min_score = min.unwrap_or(config.recall.min_score as f32); + let min_score = min.unwrap_or(config.engine.recall.min_score as f32); let results = uteke .recall_room_semantic(room_id, q, *limit, author.as_deref(), min_score) .map_err(|e| format!("Failed to recall room: {e}"))?; diff --git a/crates/uteke-cli/src/config.rs b/crates/uteke-cli/src/config.rs index 2f707547..f6b94282 100644 --- a/crates/uteke-cli/src/config.rs +++ b/crates/uteke-cli/src/config.rs @@ -103,62 +103,6 @@ impl EmbeddingConfig { /// Re-exported from uteke-core so the server can share the same config. pub type ExtractionConfig = uteke_core::extraction::ExtractionConfig; -/// Embedding fallback configuration for cloud API when local ONNX fails. -/// -/// Entirely optional — all fields default to empty. When all fields are empty, -/// no fallback is configured and local ONNX errors propagate normally. -/// Env vars (UTEKE_EMBED_FALLBACK_*) win over toml values. -#[derive(serde::Deserialize, Clone, Default)] -#[serde(default)] -pub struct EmbedFallbackConfig { - /// API key for the fallback embedding endpoint. - /// Env: UTEKE_EMBED_FALLBACK_API_KEY - pub api_key: String, - /// Base URL (e.g. "https://your-modal-app.modal.run"). - /// Env: UTEKE_EMBED_FALLBACK_BASE_URL - pub base_url: String, - /// Endpoint path appended to base_url. Empty = "/embeddings". - /// Env: UTEKE_EMBED_FALLBACK_ENDPOINT_PATH - pub endpoint_path: String, - /// Model name for the fallback embedding endpoint. - /// Env: UTEKE_EMBED_FALLBACK_MODEL - pub model: String, -} - -impl EmbedFallbackConfig { - /// The same settings as the core type, for `is_configured` and the env - /// overrides, so the rules live in one place (#1355). - fn to_core(&self) -> uteke_core::FallbackSettings { - uteke_core::FallbackSettings { - api_key: self.api_key.clone(), - base_url: self.base_url.clone(), - endpoint_path: self.endpoint_path.clone(), - model: self.model.clone(), - } - } - - /// Check if fallback is fully configured (api_key, base_url, AND model). - /// Warns on partial config — partial config will be rejected by the core library. - pub fn is_configured(&self) -> bool { - self.to_core().is_configured() - } - - /// Resolve with env var overrides. Env vars win over toml values. - fn resolve_with_env(self) -> Self { - let r = self.to_core().with_env_overrides(); - Self { - api_key: r.api_key, - base_url: r.base_url, - endpoint_path: r.endpoint_path, - model: r.model, - } - } -} - -/// 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)] #[serde(default)] @@ -206,10 +150,6 @@ impl Default for AgingConfig { } } -/// Recall / search threshold configuration: the core type, so the keys, -/// defaults and `UTEKE_RECALL_*` overrides live in one place (#1515). -pub type RecallConfig = uteke_core::RecallSettings; - // ── Top-level config ──────────────────────────────────────────────────────── /// Server configuration. @@ -260,12 +200,6 @@ impl Default for MaintenanceConfig { } } -/// Dream pipeline thresholds (#731): the core type (#1515). -pub type DreamConfig = uteke_core::DreamConfig; - -/// 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)] #[serde(default)] @@ -275,16 +209,15 @@ pub struct Config { pub vector: VectorConfig, pub embedding: EmbeddingConfig, pub extraction: ExtractionConfig, - pub embed_fallback: EmbedFallbackConfig, - pub tier: TierConfig, + /// `[tier]`, `[recall]`, `[dream]`, `[lifecycle]` and `[embed_fallback]`: + /// the engine sections, one schema shared with the server (#1515). + #[serde(flatten)] + pub engine: uteke_core::EngineSettings, pub logging: LoggingConfig, pub aging: AgingConfig, - pub recall: RecallConfig, pub server: ServerConfig, pub limits: LimitsConfig, pub maintenance: MaintenanceConfig, - pub dream: DreamConfig, - pub lifecycle: LifecycleConfig, /// Enable background update notification on startup. Default: true. /// Set to `false` to disable. #[serde(default = "default_true")] @@ -308,16 +241,12 @@ impl Default for Config { vector: VectorConfig::default(), embedding: EmbeddingConfig::default(), extraction: ExtractionConfig::default(), - embed_fallback: EmbedFallbackConfig::default(), - tier: TierConfig::default(), + engine: uteke_core::EngineSettings::default(), logging: LoggingConfig::default(), aging: AgingConfig::default(), - recall: RecallConfig::default(), server: ServerConfig::default(), limits: LimitsConfig::default(), maintenance: MaintenanceConfig::default(), - dream: DreamConfig::default(), - lifecycle: LifecycleConfig::default(), update_check: true, doctor_footer: true, } @@ -426,7 +355,7 @@ impl Config { // Recall thresholds, strategy and graph-rerank weights (UTEKE_RECALL_*, // UTEKE_GRAPH_*): validated in the core. - self.recall = self.recall.with_env_overrides(); + self.engine.recall = std::mem::take(&mut self.engine.recall).with_env_overrides(); // Vector engine override (#1168). Ignored when the requested engine // is not compiled in (resolution falls back with a warning in core). @@ -517,7 +446,7 @@ impl Config { } // Embed fallback (cloud API when local ONNX fails). All optional. - self.embed_fallback = self.embed_fallback.clone().resolve_with_env(); + self.engine.embed_fallback = self.engine.embed_fallback.clone().with_env_overrides(); self } @@ -855,9 +784,9 @@ mod tests { assert_eq!(cfg.embedding.model, "embeddinggemma-q4"); assert_eq!(cfg.embedding.backend, "onnx"); assert_eq!(cfg.embedding.max_seq_length, 2048); - assert_eq!(cfg.tier.hot_days, 7); - assert_eq!(cfg.tier.warm_days, 30); - assert!((cfg.tier.hot_boost - 0.1).abs() < f64::EPSILON); + assert_eq!(cfg.engine.tier.hot_days, 7); + assert_eq!(cfg.engine.tier.warm_days, 30); + assert!((cfg.engine.tier.hot_boost - 0.1).abs() < f64::EPSILON); assert_eq!(cfg.logging.level, "warn"); assert!(cfg.logging.file.is_empty()); assert!(!cfg.aging.enabled); @@ -898,9 +827,9 @@ max_cold_count = 5000 assert_eq!(cfg.embedding.model, "custom-model"); assert_eq!(cfg.embedding.backend, "openai"); assert_eq!(cfg.embedding.max_seq_length, 512); - assert_eq!(cfg.tier.hot_days, 3); - assert_eq!(cfg.tier.warm_days, 14); - assert!((cfg.tier.hot_boost - 0.2).abs() < f64::EPSILON); + assert_eq!(cfg.engine.tier.hot_days, 3); + assert_eq!(cfg.engine.tier.warm_days, 14); + assert!((cfg.engine.tier.hot_boost - 0.2).abs() < f64::EPSILON); assert_eq!(cfg.logging.level, "debug"); assert_eq!(cfg.logging.file, "/tmp/uteke.log"); assert!(cfg.aging.enabled); @@ -926,7 +855,7 @@ level = "info" assert_eq!(cfg.embedding.model, "embeddinggemma-q4"); assert_eq!(cfg.embedding.backend, "onnx"); assert_eq!(cfg.embedding.max_seq_length, 2048); - assert_eq!(cfg.tier.hot_days, 7); + assert_eq!(cfg.engine.tier.hot_days, 7); assert!(!cfg.aging.enabled); } @@ -947,7 +876,7 @@ model = "other-model" assert_eq!(merged.embedding.model, "other-model"); // Unchanged defaults assert_eq!(merged.embedding.max_seq_length, 2048); - assert_eq!(merged.tier.hot_days, 7); + assert_eq!(merged.engine.tier.hot_days, 7); } #[test] @@ -995,12 +924,15 @@ orphan_importance_threshold = 0.10 let merged = from_toml(toml); // recall (salience/recency_weight were the original missing fields) - assert!((merged.recall.salience_weight - 0.25).abs() < f32::EPSILON); - assert!((merged.recall.recency_weight - 0.20).abs() < f32::EPSILON); + assert!((merged.engine.recall.salience_weight - 0.25).abs() < f32::EPSILON); + assert!((merged.engine.recall.recency_weight - 0.20).abs() < f32::EPSILON); // embed_fallback - assert_eq!(merged.embed_fallback.api_key, "sk-test"); - assert_eq!(merged.embed_fallback.base_url, "https://embed.example.com"); - assert_eq!(merged.embed_fallback.model, "text-embed"); + assert_eq!(merged.engine.embed_fallback.api_key, "sk-test"); + assert_eq!( + merged.engine.embed_fallback.base_url, + "https://embed.example.com" + ); + assert_eq!(merged.engine.embed_fallback.model, "text-embed"); // extraction assert_eq!(merged.extraction.model, "gpt-4o"); assert_eq!(merged.extraction.max_facts, 30); @@ -1012,8 +944,8 @@ orphan_importance_threshold = 0.10 assert_eq!(merged.maintenance.auto_aging_interval_hours, 12); assert!(!merged.maintenance.auto_dream_enabled); // dream - assert!((merged.dream.dedup_threshold - 0.95).abs() < f32::EPSILON); - assert!((merged.dream.orphan_importance_threshold - 0.10).abs() < f64::EPSILON); + assert!((merged.engine.dream.dedup_threshold - 0.95).abs() < f32::EPSILON); + assert!((merged.engine.dream.orphan_importance_threshold - 0.10).abs() < f64::EPSILON); } #[test] @@ -1114,7 +1046,7 @@ namespace = "agent1" #[test] fn default_recall_config() { - let cfg = RecallConfig::default(); + let cfg = uteke_core::RecallSettings::default(); assert!((cfg.min_score - 0.0).abs() < f64::EPSILON); assert!((cfg.min_score_strict - 0.5).abs() < f64::EPSILON); // Fusion (vector×1.7 + hybrid×1 weighted RRF) is the default @@ -1130,8 +1062,8 @@ min_score = 0.45 min_score_strict = 0.7 "#; let cfg: Config = toml::from_str(toml).unwrap(); - assert!((cfg.recall.min_score - 0.45).abs() < f64::EPSILON); - assert!((cfg.recall.min_score_strict - 0.7).abs() < f64::EPSILON); + assert!((cfg.engine.recall.min_score - 0.45).abs() < f64::EPSILON); + assert!((cfg.engine.recall.min_score_strict - 0.7).abs() < f64::EPSILON); } #[test] @@ -1143,12 +1075,12 @@ min_score_strict = 0.8 "#; let merged = from_toml(toml); - assert!((merged.recall.min_score - 0.6).abs() < f64::EPSILON); - assert!((merged.recall.min_score_strict - 0.8).abs() < f64::EPSILON); + assert!((merged.engine.recall.min_score - 0.6).abs() < f64::EPSILON); + assert!((merged.engine.recall.min_score_strict - 0.8).abs() < f64::EPSILON); // Other config untouched assert_eq!(merged.store.path, "~/.codecora/uteke"); assert_eq!(merged.embedding.model, "embeddinggemma-q4"); - assert_eq!(merged.tier.hot_days, 7); + assert_eq!(merged.engine.tier.hot_days, 7); assert_eq!(merged.logging.level, "warn"); assert!(!merged.aging.enabled); assert!(!merged.server.enabled); @@ -1196,16 +1128,16 @@ port = 9999 assert_eq!(merged.embedding.model, "custom-embed"); assert_eq!(merged.embedding.backend, "onnx"); assert_eq!(merged.embedding.max_seq_length, 512); - assert_eq!(merged.tier.hot_days, 5); - assert_eq!(merged.tier.warm_days, 21); - assert!((merged.tier.hot_boost - 0.3).abs() < f64::EPSILON); + assert_eq!(merged.engine.tier.hot_days, 5); + assert_eq!(merged.engine.tier.warm_days, 21); + assert!((merged.engine.tier.hot_boost - 0.3).abs() < f64::EPSILON); assert_eq!(merged.logging.level, "trace"); assert_eq!(merged.logging.file, "/tmp/test.log"); assert!(merged.aging.enabled); assert_eq!(merged.aging.max_age_days, 60); assert_eq!(merged.aging.max_cold_count, 2000); - assert!((merged.recall.min_score - 0.4).abs() < f64::EPSILON); - assert!((merged.recall.min_score_strict - 0.65).abs() < f64::EPSILON); + assert!((merged.engine.recall.min_score - 0.4).abs() < f64::EPSILON); + assert!((merged.engine.recall.min_score_strict - 0.65).abs() < f64::EPSILON); assert!(merged.server.enabled); assert_eq!(merged.server.host, "0.0.0.0"); assert_eq!(merged.server.port, 9999); @@ -1280,17 +1212,17 @@ deprecated_ttl_days = 5 assert!(!merged.update_check); assert!(!merged.doctor_footer); assert_eq!(merged.aging.max_access_count, 7); - assert!(!merged.lifecycle.soft_delete_only); - assert_eq!(merged.lifecycle.min_age_days, 11); - assert_eq!(merged.lifecycle.deprecated_ttl_days, 5); + assert!(!merged.engine.lifecycle.soft_delete_only); + assert_eq!(merged.engine.lifecycle.min_age_days, 11); + assert_eq!(merged.engine.lifecycle.deprecated_ttl_days, 5); // Keys absent from the file keep their defaults. assert_eq!( - merged.lifecycle.max_deprecate_per_cycle, - defaults.lifecycle.max_deprecate_per_cycle + merged.engine.lifecycle.max_deprecate_per_cycle, + defaults.engine.lifecycle.max_deprecate_per_cycle ); assert_eq!( - merged.lifecycle.auto_aging_enabled, - defaults.lifecycle.auto_aging_enabled + merged.engine.lifecycle.auto_aging_enabled, + defaults.engine.lifecycle.auto_aging_enabled ); } @@ -1484,8 +1416,8 @@ max_seq_length = 128 unsafe { std::env::remove_var("UTEKE_RECALL_MIN_SCORE_STRICT"); } - assert!((cfg.recall.min_score - 0.7).abs() < f64::EPSILON); - assert!((cfg.recall.min_score_strict - 0.85).abs() < f64::EPSILON); + assert!((cfg.engine.recall.min_score - 0.7).abs() < f64::EPSILON); + assert!((cfg.engine.recall.min_score_strict - 0.85).abs() < f64::EPSILON); } #[test] @@ -1522,7 +1454,7 @@ max_seq_length = 128 assert_eq!(cfg.logging.level, "warn"); assert_eq!(cfg.server.host, "127.0.0.1"); assert_eq!(cfg.server.port, 8767); - assert!((cfg.recall.min_score - 0.0).abs() < f64::EPSILON); + assert!((cfg.engine.recall.min_score - 0.0).abs() < f64::EPSILON); } // ── #1078 P0 batch 3: score range guards, strategy validation, graph @@ -1538,7 +1470,7 @@ max_seq_length = 128 unsafe { std::env::remove_var("UTEKE_RECALL_MIN_SCORE"); } - assert!((cfg.recall.min_score - 0.0).abs() < f64::EPSILON); + assert!((cfg.engine.recall.min_score - 0.0).abs() < f64::EPSILON); } #[test] @@ -1551,7 +1483,7 @@ max_seq_length = 128 unsafe { std::env::remove_var("UTEKE_RECALL_MIN_SCORE"); } - assert!((cfg.recall.min_score - 0.0).abs() < f64::EPSILON); + assert!((cfg.engine.recall.min_score - 0.0).abs() < f64::EPSILON); } #[test] @@ -1562,7 +1494,10 @@ max_seq_length = 128 std::env::set_var("UTEKE_RECALL_STRATEGY", v); } let cfg = Config::default().apply_env_overrides(); - assert_eq!(cfg.recall.default_strategy, v, "strategy {v} rejected"); + assert_eq!( + cfg.engine.recall.default_strategy, v, + "strategy {v} rejected" + ); } unsafe { std::env::remove_var("UTEKE_RECALL_STRATEGY"); @@ -1580,7 +1515,7 @@ max_seq_length = 128 std::env::remove_var("UTEKE_RECALL_STRATEGY"); } // Invalid strategy keeps the fusion default - assert_eq!(cfg.recall.default_strategy, "fusion"); + assert_eq!(cfg.engine.recall.default_strategy, "fusion"); } #[test] @@ -1595,8 +1530,8 @@ max_seq_length = 128 std::env::remove_var("UTEKE_GRAPH_DENSITY_WEIGHT"); std::env::remove_var("UTEKE_GRAPH_AUTHORITY_WEIGHT"); } - assert!((cfg.recall.graph_density_weight - 0.5).abs() < f32::EPSILON); - assert!((cfg.recall.graph_authority_weight - 0.7).abs() < f32::EPSILON); + assert!((cfg.engine.recall.graph_density_weight - 0.5).abs() < f32::EPSILON); + assert!((cfg.engine.recall.graph_authority_weight - 0.7).abs() < f32::EPSILON); } #[test] @@ -1611,8 +1546,8 @@ max_seq_length = 128 std::env::remove_var("UTEKE_GRAPH_DENSITY_WEIGHT"); std::env::remove_var("UTEKE_GRAPH_AUTHORITY_WEIGHT"); } - assert!((cfg.recall.graph_density_weight - 0.1).abs() < f32::EPSILON); - assert!((cfg.recall.graph_authority_weight - 0.1).abs() < f32::EPSILON); + assert!((cfg.engine.recall.graph_density_weight - 0.1).abs() < f32::EPSILON); + assert!((cfg.engine.recall.graph_authority_weight - 0.1).abs() < f32::EPSILON); } #[test] @@ -1631,17 +1566,17 @@ max_seq_length = 128 std::env::remove_var("UTEKE_EMBED_FALLBACK_ENDPOINT_PATH"); std::env::remove_var("UTEKE_EMBED_FALLBACK_MODEL"); } - assert_eq!(cfg.embed_fallback.api_key, "fb-key"); - assert_eq!(cfg.embed_fallback.base_url, "https://fb.example"); - assert_eq!(cfg.embed_fallback.endpoint_path, "/v1/e"); - assert_eq!(cfg.embed_fallback.model, "fb-model"); - assert!(cfg.embed_fallback.is_configured()); + assert_eq!(cfg.engine.embed_fallback.api_key, "fb-key"); + assert_eq!(cfg.engine.embed_fallback.base_url, "https://fb.example"); + assert_eq!(cfg.engine.embed_fallback.endpoint_path, "/v1/e"); + assert_eq!(cfg.engine.embed_fallback.model, "fb-model"); + assert!(cfg.engine.embed_fallback.is_configured()); } #[test] fn embed_fallback_is_configured_matrix() { // Nothing set — not configured - let none = EmbedFallbackConfig { + let none = uteke_core::FallbackSettings { api_key: String::new(), base_url: String::new(), endpoint_path: String::new(), @@ -1650,7 +1585,7 @@ max_seq_length = 128 assert!(!none.is_configured()); // Partial (2 of 3 required) — still not configured - let partial = EmbedFallbackConfig { + let partial = uteke_core::FallbackSettings { api_key: "k".into(), base_url: "u".into(), endpoint_path: String::new(), @@ -1659,7 +1594,7 @@ max_seq_length = 128 assert!(!partial.is_configured()); // All three required set — configured (endpoint_path optional) - let full = EmbedFallbackConfig { + let full = uteke_core::FallbackSettings { api_key: "k".into(), base_url: "u".into(), endpoint_path: String::new(), diff --git a/crates/uteke-cli/src/main.rs b/crates/uteke-cli/src/main.rs index 19ff00c2..2eb682c4 100644 --- a/crates/uteke-cli/src/main.rs +++ b/crates/uteke-cli/src/main.rs @@ -217,20 +217,17 @@ fn main() { model: config.embedding.model.clone(), dims: config.embedding.dims, }, - config.tier, - config.recall.recall_config(), - config.recall.graph_rerank(), + config.engine.tier, + config.engine.recall.recall_config(), + config.engine.recall.graph_rerank(), // Runtime vector-engine preference from uteke.toml [vector] (#1168). // UTEKE_VECTOR_BACKEND env (higher precedence) is read inside core. Some(config.vector.backend.as_str()), ) { Ok(mut u) => { - // #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(config.dream); - // #928: apply lifecycle configuration from config - u.set_lifecycle_config(config.lifecycle); + // #719, #731, #928, #1355: Jaccard weight, dream thresholds, lifecycle + // and the embedding fallback, applied the same way as in the server. + u.apply_settings(&config.engine); // #1464: secret filter on the write path ([privacy] in uteke.toml) u.set_privacy_config(uteke_core::privacy::load_config()); u @@ -241,16 +238,6 @@ fn main() { } }; - // Configure embed fallback if enabled (zero-config: only when fields are set) - if config.embed_fallback.is_configured() { - uteke.set_fallback_settings(uteke_core::FallbackSettings { - api_key: config.embed_fallback.api_key.clone(), - base_url: config.embed_fallback.base_url.clone(), - endpoint_path: config.embed_fallback.endpoint_path.clone(), - model: config.embed_fallback.model.clone(), - }); - } - ctrlc::set_handler(|| { SHUTDOWN_REQUESTED.store(true, Ordering::SeqCst); eprintln!("\nInterrupt received, shutting down gracefully..."); diff --git a/crates/uteke-core/src/engine_settings.rs b/crates/uteke-core/src/engine_settings.rs new file mode 100644 index 00000000..133fd590 --- /dev/null +++ b/crates/uteke-core/src/engine_settings.rs @@ -0,0 +1,33 @@ +//! The engine sections of `uteke.toml` as one value (#1515): what the CLI, the +//! server and MCP read once and apply to a `Uteke` the same way. + +use crate::{DreamConfig, FallbackSettings, LifecycleConfig, RecallSettings, TierConfig, Uteke}; + +/// `[tier]`, `[recall]`, `[dream]`, `[lifecycle]` and `[embed_fallback]`. Every +/// section and every key is optional: what a file omits keeps the core +/// default. Embed it in a larger config with `#[serde(flatten)]`. +#[derive(Clone, Default, serde::Deserialize)] +#[serde(default)] +pub struct EngineSettings { + /// Hot/warm/cold tiers. Open-time: pass it to the constructor. + pub tier: TierConfig, + pub recall: RecallSettings, + pub dream: DreamConfig, + pub lifecycle: LifecycleConfig, + pub embed_fallback: FallbackSettings, +} + +impl Uteke { + /// Apply what can change after the store is open: the Jaccard weight, the + /// dream thresholds, the lifecycle config and, when it is complete (api + /// key, base URL and model), the embedding fallback. `tier` and the + /// recall thresholds that the constructor takes are not touched here. + pub fn apply_settings(&mut self, settings: &EngineSettings) { + self.set_jaccard_weight(settings.recall.jaccard_weight); + self.set_dream_config(settings.dream); + self.set_lifecycle_config(settings.lifecycle); + if settings.embed_fallback.is_configured() { + self.set_fallback_settings(settings.embed_fallback.clone()); + } + } +} diff --git a/crates/uteke-core/src/engine_settings_tests.rs b/crates/uteke-core/src/engine_settings_tests.rs index db3137e9..73d7225a 100644 --- a/crates/uteke-core/src/engine_settings_tests.rs +++ b/crates/uteke-core/src/engine_settings_tests.rs @@ -171,3 +171,73 @@ mod recall { assert!(r.graph_rerank_enabled); } } + +mod engine { + use crate::{EngineSettings, Uteke}; + + /// A larger config file embedding the engine sections with `flatten`, the + /// way the CLI and the server do. + #[derive(Default, serde::Deserialize)] + #[serde(default)] + struct File { + #[serde(flatten)] + engine: EngineSettings, + store: toml::Table, + } + + #[test] + fn the_five_sections_parse_next_to_other_sections() { + let f: File = toml::from_str( + "[store]\nnamespace = \"x\"\n\ + [tier]\nhot_days = 2\n\ + [recall]\nmin_score = 0.2\njaccard_weight = 0.1\n\ + [dream]\ndedup_threshold = 0.8\n\ + [lifecycle]\nsoft_delete_only = false\n\ + [embed_fallback]\napi_key = \"k\"\nbase_url = \"https://example.test\"\nmodel = \"m\"\n\ + [unknown_section]\nwhatever = 1\n", + ) + .unwrap(); + let e = f.engine; + assert_eq!(e.tier.hot_days, 2); + assert_eq!((e.recall.min_score, e.recall.jaccard_weight), (0.2, 0.1)); + assert!((e.dream.dedup_threshold - 0.8).abs() < f32::EPSILON); + assert!(!e.lifecycle.soft_delete_only); + assert!(e.embed_fallback.is_configured()); + assert!(f.store.contains_key("namespace")); + } + + #[test] + fn an_empty_file_is_all_defaults() { + let f: File = toml::from_str("").unwrap(); + assert_eq!(f.engine.tier.warm_days, 30); + assert_eq!(f.engine.recall.default_strategy, "fusion"); + assert!(f.engine.lifecycle.soft_delete_only); + assert!(!f.engine.embed_fallback.is_configured()); + } + + #[test] + fn apply_settings_sets_what_can_change_after_open() { + let mut u = Uteke::open_with_backend(":memory:", None).expect("open without embedder"); + let mut e = EngineSettings::default(); + e.recall.jaccard_weight = 0.25; + e.dream.contradict_max_memories = 7; + e.lifecycle.deprecated_ttl_days = 11; + e.embed_fallback.api_key = "k".into(); + e.embed_fallback.base_url = "https://example.test".into(); + e.embed_fallback.model = "m".into(); + u.apply_settings(&e); + assert_eq!(u.jaccard_weight, 0.25); + assert_eq!(u.dream_config.contradict_max_memories, 7); + assert_eq!(u.lifecycle_config().deprecated_ttl_days, 11); + assert_eq!(u.fallback_settings.base_url, "https://example.test"); + } + + #[test] + fn an_incomplete_fallback_is_not_applied() { + let mut u = Uteke::open_with_backend(":memory:", None).expect("open without embedder"); + let mut e = EngineSettings::default(); + e.embed_fallback.api_key = "only-a-key".into(); + u.apply_settings(&e); + assert!(u.fallback_settings.api_key.is_empty()); + } +} diff --git a/crates/uteke-core/src/lib.rs b/crates/uteke-core/src/lib.rs index 46ef7399..ce103c82 100644 --- a/crates/uteke-core/src/lib.rs +++ b/crates/uteke-core/src/lib.rs @@ -67,6 +67,7 @@ pub use recall_request::{ pub use remember_request::{ RememberInput, RememberPolicy, RememberRequest, RememberRequestError, metadata_from_cli_flags, }; +pub mod engine_settings; #[cfg(test)] mod isolation_tests; mod orphans; @@ -121,6 +122,7 @@ pub use timeline::{TimelineEvent, TimelineEventType}; pub use embed::Embedder; #[cfg(feature = "onnx")] pub use embed::OnnxEmbedder; +pub use engine_settings::EngineSettings; pub use error::{Error, ErrorClass, format_bytes}; pub use recall_settings::RecallSettings; pub use types::{ @@ -548,7 +550,8 @@ pub struct EmbeddingSettings { /// When configured, the [`FallbackEmbedder`] wraps the primary backend (e.g. /// ONNX) and falls back to an OpenAI-compatible cloud API on failure. /// All fields default to empty — fallback is disabled until explicitly configured. -#[derive(Clone, Default)] +#[derive(Clone, Default, serde::Deserialize)] +#[serde(default)] pub struct FallbackSettings { pub api_key: String, pub base_url: String, diff --git a/crates/uteke-server/src/main.rs b/crates/uteke-server/src/main.rs index 2123eb5a..3f1c7aef 100644 --- a/crates/uteke-server/src/main.rs +++ b/crates/uteke-server/src/main.rs @@ -286,28 +286,26 @@ fn main() { let privacy = uteke_core::privacy::load_config(); info!("Privacy filter: {:?}", privacy.mode); u.set_privacy_config(privacy); - // [embed_fallback] + UTEKE_EMBED_FALLBACK_* (#1355): only when complete. - if let Some(fallback) = resolve_embed_fallback(&config, |k| std::env::var(k).ok()) { - info!("Embedding fallback: enabled ({})", fallback.base_url); - u.set_fallback_settings(fallback); - } - // Apply dream pipeline thresholds from config (#731) - if let Some(dc) = config.dream { - u.set_dream_config(dc); - info!( - "Dream config loaded: dedup={:.2}, orphan_thresh={:.2}", - dc.dedup_threshold, dc.orphan_importance_threshold, - ); - } - - // Apply lifecycle config from uteke.toml [lifecycle] section (#928) - 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, lc.deprecated_ttl_days, lc.max_deprecate_percent, - ); + // Jaccard weight, dream thresholds, lifecycle and the embedding + // fallback, applied the same way as in the CLI. The fallback also + // honours UTEKE_EMBED_FALLBACK_* (#1355); it only applies when complete. + let mut engine = config.engine.clone(); + let fallback = resolve_embed_fallback(&config, |k| std::env::var(k).ok()); + if let Some(f) = &fallback { + info!("Embedding fallback: enabled ({})", f.base_url); } + engine.embed_fallback = fallback.unwrap_or_default(); + u.apply_settings(&engine); + info!( + "Dream config loaded: dedup={:.2}, orphan_thresh={:.2}", + engine.dream.dedup_threshold, engine.dream.orphan_importance_threshold, + ); + info!( + "Lifecycle config loaded: soft_delete_only={}, ttl={}d, max_deprecate={:.1}%", + engine.lifecycle.soft_delete_only, + engine.lifecycle.deprecated_ttl_days, + engine.lifecycle.max_deprecate_percent, + ); Arc::new(Mutex::new(u)) } Err(e) => { @@ -347,7 +345,7 @@ fn main() { // with a message blaming the request — warn loudly and fall // back to hybrid instead. Explicit request-level `strategy` // values remain strictly validated (HTTP 400). - let mut recall = config.recall.clone(); + let mut recall = Some(config.engine.recall.clone()); if let Some(r) = recall.as_mut() { if uteke_core::RecallStrategy::from_str_opt(&r.default_strategy).is_none() { warn!( @@ -393,7 +391,7 @@ 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_config = config.lifecycle.unwrap_or_default(); + let lifecycle_config = config.engine.lifecycle; 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); @@ -640,19 +638,16 @@ struct ServerFileConfig { embedding: Option, /// `[vector]`: which vector engine runs when both are compiled in (#1168). vector: Option, - /// `[embed_fallback]`: second embedding endpoint used when the primary fails (#1355). - embed_fallback: Option, - /// `[tier]`: hot/warm/cold memory tiers (#1355). - tier: Option, + /// `[tier]`, `[recall]`, `[dream]`, `[lifecycle]` and `[embed_fallback]`: the + /// engine sections, one schema shared with the CLI (#1515). + #[serde(flatten)] + engine: uteke_core::EngineSettings, server: Option, - recall: Option, extraction: Option, maintenance: Option, /// Deprecated: superseded by [lifecycle] section (#934). Kept for backward-compat deserialization. #[allow(dead_code)] aging: Option, - dream: Option, - lifecycle: Option, } /// `[embedding]` in uteke.toml, same keys as the CLI. Empty / missing values @@ -674,41 +669,28 @@ struct VectorFileSection { backend: Option, } -/// `[embed_fallback]` in uteke.toml, same keys as the CLI. `UTEKE_EMBED_FALLBACK_*` -/// env vars win over these (resolved in the core). -#[derive(serde::Deserialize, Default, Clone)] -struct EmbedFallbackFileSection { - api_key: Option, - base_url: Option, - endpoint_path: Option, - model: Option, -} - /// The fallback embedder to apply, or `None` when it is not fully configured /// (api_key, base_url AND model). Env overrides come from `lookup`. fn resolve_embed_fallback( config: &ServerFileConfig, lookup: impl Fn(&str) -> Option, ) -> Option { - let file = config.embed_fallback.clone().unwrap_or_default(); - let settings = uteke_core::FallbackSettings { - api_key: file.api_key.unwrap_or_default(), - base_url: file.base_url.unwrap_or_default(), - endpoint_path: file.endpoint_path.unwrap_or_default(), - model: file.model.unwrap_or_default(), - } - .with_overrides_from(lookup); + let settings = config + .engine + .embed_fallback + .clone() + .with_overrides_from(lookup); settings.is_configured().then_some(settings) } /// `[recall]` over the core defaults (the same settings the CLI reads). fn resolve_recall_tuning(config: &ServerFileConfig) -> uteke_core::RecallSettings { - config.recall.clone().unwrap_or_default() + config.engine.recall.clone() } /// Tier settings: `[tier]` over the core defaults (7 / 30 days, boost 0.1). fn resolve_tier(config: &ServerFileConfig) -> uteke_core::TierConfig { - config.tier.unwrap_or_default() + config.engine.tier } /// Backends `Uteke` can initialize lazily (same list as the CLI validates). @@ -884,7 +866,7 @@ mod config_overlay_tests { Some(&["https://app.example".to_string()][..]) ); assert!( - merged.recall.is_some(), + merged.engine.recall.min_score == 0.5, "project recall tuning still applies" ); } @@ -909,7 +891,7 @@ mod config_overlay_tests { "[recall]\nmin_score = 0.6\n", false, ); - let recall = merged.recall.expect("recall section"); + let recall = merged.engine.recall; assert_eq!(recall.min_score, 0.6); assert_eq!(recall.default_strategy, "vector"); } @@ -1128,7 +1110,7 @@ mod recall_tuning_tests { let cfg = parse( "[recall]\nmin_score = 0.4\ndefault_strategy = \"vector\"\njaccard_weight = 0.1\n", ); - let r = cfg.recall.expect("recall"); + let r = cfg.engine.recall; assert_eq!(r.min_score, 0.4); assert_eq!(r.default_strategy, "vector"); assert_eq!(r.jaccard_weight, 0.1);