Skip to main content

remem/runtime_config/
user_auto_promote.rs

1use anyhow::{bail, Result};
2use toml_edit::{value, Array, DocumentMut, Item, Table};
3
4const DEFAULT_AUTO_PROMOTE_MIN_CONFIDENCE: f64 = 0.7;
5const STRICT_AUTO_PROMOTE_MIN_CONFIDENCE: f64 = 0.9;
6const DEFAULT_AUTO_PROMOTE_SOURCE_KIND: &str = "explicit_user_statement";
7const DEFAULT_AUTO_PROMOTE_REQUIRE_TEXT_SUPPORT: bool = true;
8const DEFAULT_AUTO_PROMOTE_STRICT: bool = false;
9const SUPPORTED_AUTO_PROMOTE_SOURCE_KINDS: &[&str] = &[
10    "explicit_user_statement",
11    "inferred_from_behavior",
12    "session_summary",
13    "third_party_statement",
14    "speculative_inference",
15];
16
17#[derive(Clone, Debug, PartialEq)]
18pub struct UserContextAutoPromoteConfig {
19    pub min_confidence: f64,
20    pub allowed_source_kinds: Vec<String>,
21    pub require_text_support: bool,
22    pub strict: bool,
23}
24
25#[derive(Clone, Debug, PartialEq)]
26pub struct AutoPromotePolicy {
27    pub min_confidence: f64,
28    pub allowed_source_kinds: Vec<String>,
29    pub require_text_support: bool,
30}
31
32impl AutoPromotePolicy {
33    pub fn relaxed_default() -> Self {
34        Self {
35            min_confidence: DEFAULT_AUTO_PROMOTE_MIN_CONFIDENCE,
36            allowed_source_kinds: default_source_kinds(),
37            require_text_support: DEFAULT_AUTO_PROMOTE_REQUIRE_TEXT_SUPPORT,
38        }
39    }
40
41    pub fn strict() -> Self {
42        Self {
43            min_confidence: STRICT_AUTO_PROMOTE_MIN_CONFIDENCE,
44            allowed_source_kinds: default_source_kinds(),
45            require_text_support: true,
46        }
47    }
48
49    pub fn allows_source_kind(&self, source_kind: &str) -> bool {
50        self.allowed_source_kinds
51            .iter()
52            .any(|allowed| allowed == source_kind)
53    }
54}
55
56impl UserContextAutoPromoteConfig {
57    pub fn effective_policy(&self) -> AutoPromotePolicy {
58        if self.strict {
59            return AutoPromotePolicy::strict();
60        }
61        AutoPromotePolicy {
62            min_confidence: self.min_confidence,
63            allowed_source_kinds: self.allowed_source_kinds.clone(),
64            require_text_support: self.require_text_support,
65        }
66    }
67}
68
69pub fn user_context_auto_promote_config() -> Result<UserContextAutoPromoteConfig> {
70    let mut doc = super::read_config_doc_or_default()?;
71    ensure_defaults(&mut doc)?;
72    user_context_auto_promote_config_from_doc(&doc)
73}
74
75pub(super) fn ensure_defaults(doc: &mut DocumentMut) -> Result<()> {
76    let user_context = super::top_table_mut(doc, "user_context")?;
77    let auto_promote = super::child_table_mut(user_context, "auto_promote")?;
78    set_f64_if_missing(
79        auto_promote,
80        "min_confidence",
81        DEFAULT_AUTO_PROMOTE_MIN_CONFIDENCE,
82    );
83    set_string_array_if_missing(
84        auto_promote,
85        "allowed_source_kinds",
86        &[DEFAULT_AUTO_PROMOTE_SOURCE_KIND],
87    );
88    super::set_bool_if_missing(
89        auto_promote,
90        "require_text_support",
91        DEFAULT_AUTO_PROMOTE_REQUIRE_TEXT_SUPPORT,
92    );
93    super::set_bool_if_missing(auto_promote, "strict", DEFAULT_AUTO_PROMOTE_STRICT);
94    Ok(())
95}
96
97fn user_context_auto_promote_config_from_doc(
98    doc: &DocumentMut,
99) -> Result<UserContextAutoPromoteConfig> {
100    let Some(table) = doc
101        .get("user_context")
102        .and_then(Item::as_table)
103        .and_then(|table| table.get("auto_promote"))
104        .and_then(Item::as_table)
105    else {
106        return Ok(default_config());
107    };
108
109    let strict = match table.get("strict") {
110        Some(item) => item
111            .as_bool()
112            .ok_or_else(|| anyhow::anyhow!("user_context.auto_promote.strict must be a boolean"))?,
113        None => DEFAULT_AUTO_PROMOTE_STRICT,
114    };
115    if strict {
116        return Ok(UserContextAutoPromoteConfig {
117            min_confidence: STRICT_AUTO_PROMOTE_MIN_CONFIDENCE,
118            allowed_source_kinds: default_source_kinds(),
119            require_text_support: true,
120            strict,
121        });
122    }
123
124    let min_confidence = match table.get("min_confidence") {
125        Some(item) => {
126            parse_auto_promote_confidence(item, "user_context.auto_promote.min_confidence")?
127        }
128        None => DEFAULT_AUTO_PROMOTE_MIN_CONFIDENCE,
129    };
130    let allowed_source_kinds = match table.get("allowed_source_kinds") {
131        Some(item) => parse_source_kinds(item)?,
132        None => default_source_kinds(),
133    };
134    let require_text_support = match table.get("require_text_support") {
135        Some(item) => item.as_bool().ok_or_else(|| {
136            anyhow::anyhow!("user_context.auto_promote.require_text_support must be a boolean")
137        })?,
138        None => DEFAULT_AUTO_PROMOTE_REQUIRE_TEXT_SUPPORT,
139    };
140    if !require_text_support {
141        bail!(
142            "user_context.auto_promote.require_text_support=false is not supported until queue support and non-retention source scanning are policy-aware"
143        );
144    }
145
146    Ok(UserContextAutoPromoteConfig {
147        min_confidence,
148        allowed_source_kinds,
149        require_text_support,
150        strict,
151    })
152}
153
154fn default_config() -> UserContextAutoPromoteConfig {
155    UserContextAutoPromoteConfig {
156        min_confidence: DEFAULT_AUTO_PROMOTE_MIN_CONFIDENCE,
157        allowed_source_kinds: default_source_kinds(),
158        require_text_support: DEFAULT_AUTO_PROMOTE_REQUIRE_TEXT_SUPPORT,
159        strict: DEFAULT_AUTO_PROMOTE_STRICT,
160    }
161}
162
163fn default_source_kinds() -> Vec<String> {
164    vec![DEFAULT_AUTO_PROMOTE_SOURCE_KIND.to_string()]
165}
166
167fn parse_auto_promote_confidence(item: &Item, field: &str) -> Result<f64> {
168    let value = item
169        .as_float()
170        .or_else(|| item.as_integer().map(|value| value as f64))
171        .ok_or_else(|| anyhow::anyhow!("{field} must be a number"))?;
172    if !(0.0..=1.0).contains(&value) {
173        bail!("{field} must be between 0.0 and 1.0, got {value}");
174    }
175    Ok(value)
176}
177
178fn parse_source_kinds(item: &Item) -> Result<Vec<String>> {
179    let array = item.as_array().ok_or_else(|| {
180        anyhow::anyhow!("user_context.auto_promote.allowed_source_kinds must be an array")
181    })?;
182    let mut values = Vec::new();
183    for (index, value) in array.iter().enumerate() {
184        let Some(raw) = value.as_str() else {
185            bail!(
186                "user_context.auto_promote.allowed_source_kinds[{}] must be a string",
187                index + 1
188            );
189        };
190        let trimmed = raw.trim().to_ascii_lowercase();
191        if trimmed.is_empty() {
192            bail!(
193                "user_context.auto_promote.allowed_source_kinds[{}] must not be empty",
194                index + 1
195            );
196        }
197        if !SUPPORTED_AUTO_PROMOTE_SOURCE_KINDS.contains(&trimmed.as_str()) {
198            bail!(
199                "user_context.auto_promote.allowed_source_kinds[{}] has unsupported source kind `{}`; expected one of: {}",
200                index + 1,
201                trimmed,
202                SUPPORTED_AUTO_PROMOTE_SOURCE_KINDS.join(", ")
203            );
204        }
205        if !values.iter().any(|existing| existing == &trimmed) {
206            values.push(trimmed);
207        }
208    }
209    if values.is_empty() {
210        bail!("user_context.auto_promote.allowed_source_kinds must not be empty");
211    }
212    Ok(values)
213}
214
215fn set_f64_if_missing(table: &mut Table, key: &str, value_f64: f64) {
216    if table.get(key).is_none() {
217        table[key] = value(value_f64);
218    }
219}
220
221fn set_string_array_if_missing(table: &mut Table, key: &str, values: &[&str]) {
222    if table.get(key).is_none() {
223        let mut array = Array::new();
224        for value in values {
225            array.push(*value);
226        }
227        table[key] = value(array);
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    fn with_user_context_config_path<T>(path: &std::path::Path, f: impl FnOnce() -> T) -> T {
236        let _guard = super::super::TEST_ENV_LOCK
237            .lock()
238            .expect("env lock should acquire");
239        let old = std::env::var("REMEM_CONFIG").ok();
240        unsafe { std::env::set_var("REMEM_CONFIG", path) };
241        let result = f();
242        match old {
243            Some(value) => unsafe { std::env::set_var("REMEM_CONFIG", value) },
244            None => unsafe { std::env::remove_var("REMEM_CONFIG") },
245        }
246        result
247    }
248
249    fn user_context_config_path(label: &str) -> std::path::PathBuf {
250        std::env::temp_dir().join(format!(
251            "remem-{label}-{}-{}.toml",
252            std::process::id(),
253            chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
254        ))
255    }
256
257    #[test]
258    fn default_config_exposes_user_context_auto_promote_defaults() {
259        let text = super::super::default_config_text();
260        assert!(text.contains("[user_context.auto_promote]"), "{text}");
261        assert!(text.contains("min_confidence = 0.7"), "{text}");
262        assert!(
263            text.contains("allowed_source_kinds = [\"explicit_user_statement\"]"),
264            "{text}"
265        );
266        assert!(text.contains("require_text_support = true"), "{text}");
267        assert!(text.contains("strict = false"), "{text}");
268    }
269
270    #[test]
271    fn auto_promote_config_uses_defaults_when_section_is_missing() -> Result<()> {
272        let path = user_context_config_path("user-context-auto-promote-missing");
273        with_user_context_config_path(&path, || -> Result<()> {
274            std::fs::write(&path, "version = 1\n")?;
275            let config = user_context_auto_promote_config()?;
276            assert_eq!(config, default_config());
277            assert_eq!(
278                config.effective_policy(),
279                AutoPromotePolicy {
280                    min_confidence: 0.7,
281                    allowed_source_kinds: vec!["explicit_user_statement".to_string()],
282                    require_text_support: true,
283                }
284            );
285            Ok(())
286        })?;
287        std::fs::remove_file(path)?;
288        Ok(())
289    }
290
291    #[test]
292    fn auto_promote_config_reads_valid_values() -> Result<()> {
293        let path = user_context_config_path("user-context-auto-promote-valid");
294        with_user_context_config_path(&path, || -> Result<()> {
295            std::fs::write(
296                &path,
297                "[user_context.auto_promote]\nmin_confidence = 0.75\nallowed_source_kinds = [\"explicit_user_statement\", \"inferred_from_behavior\", \"explicit_user_statement\"]\nrequire_text_support = true\nstrict = false\n",
298            )?;
299            let config = user_context_auto_promote_config()?;
300            assert_eq!(config.min_confidence, 0.75);
301            assert_eq!(
302                config.allowed_source_kinds,
303                vec![
304                    "explicit_user_statement".to_string(),
305                    "inferred_from_behavior".to_string()
306                ]
307            );
308            assert!(config.require_text_support);
309            assert!(!config.strict);
310            assert_eq!(
311                config.effective_policy(),
312                AutoPromotePolicy {
313                    min_confidence: 0.75,
314                    allowed_source_kinds: vec![
315                        "explicit_user_statement".to_string(),
316                        "inferred_from_behavior".to_string()
317                    ],
318                    require_text_support: true,
319                }
320            );
321            Ok(())
322        })?;
323        std::fs::remove_file(path)?;
324        Ok(())
325    }
326
327    #[test]
328    fn auto_promote_min_confidence_can_be_set_through_config_cli() -> Result<()> {
329        let path = user_context_config_path("user-context-auto-promote-cli-float");
330        with_user_context_config_path(&path, || -> Result<()> {
331            super::super::init_config()?;
332            super::super::set_config_value("user_context.auto_promote.min_confidence", "0.75")?;
333            let config = user_context_auto_promote_config()?;
334            let text = std::fs::read_to_string(&path)?;
335
336            assert_eq!(config.min_confidence, 0.75);
337            assert!(text.contains("min_confidence = 0.75"), "{text}");
338            assert!(!text.contains("min_confidence = \"0.75\""), "{text}");
339            Ok(())
340        })?;
341        std::fs::remove_file(path)?;
342        Ok(())
343    }
344
345    #[test]
346    fn strict_auto_promote_config_restores_old_policy() -> Result<()> {
347        let path = user_context_config_path("user-context-auto-promote-strict");
348        with_user_context_config_path(&path, || -> Result<()> {
349            std::fs::write(&path, "[user_context.auto_promote]\nstrict = true\n")?;
350            let config = user_context_auto_promote_config()?;
351            assert_eq!(
352                config,
353                UserContextAutoPromoteConfig {
354                    min_confidence: 0.9,
355                    allowed_source_kinds: vec!["explicit_user_statement".to_string()],
356                    require_text_support: true,
357                    strict: true,
358                }
359            );
360            assert_eq!(config.effective_policy(), AutoPromotePolicy::strict());
361            Ok(())
362        })?;
363        std::fs::remove_file(path)?;
364        Ok(())
365    }
366
367    #[test]
368    fn strict_auto_promote_config_ignores_other_policy_fields() -> Result<()> {
369        let path = user_context_config_path("user-context-auto-promote-strict-ignore");
370        with_user_context_config_path(&path, || -> Result<()> {
371            std::fs::write(
372                &path,
373                "[user_context.auto_promote]\nmin_confidence = 2.0\nallowed_source_kinds = [\"typo\"]\nrequire_text_support = \"false\"\nstrict = true\n",
374            )?;
375            let config = user_context_auto_promote_config()?;
376            assert_eq!(config.effective_policy(), AutoPromotePolicy::strict());
377            Ok(())
378        })?;
379        std::fs::remove_file(path)?;
380        Ok(())
381    }
382
383    #[test]
384    fn auto_promote_config_rejects_invalid_confidence() -> Result<()> {
385        for (label, value) in [
386            ("negative", "-0.1"),
387            ("too-high", "1.1"),
388            ("string", "\"0.7\""),
389        ] {
390            let path = user_context_config_path(&format!("user-context-auto-promote-{label}"));
391            with_user_context_config_path(&path, || -> Result<()> {
392                std::fs::write(
393                    &path,
394                    format!("[user_context.auto_promote]\nmin_confidence = {value}\n"),
395                )?;
396                let err = user_context_auto_promote_config()
397                    .expect_err("invalid min_confidence must fail closed");
398                assert!(err.to_string().contains("min_confidence"), "{err}");
399                Ok(())
400            })?;
401            std::fs::remove_file(path)?;
402        }
403        Ok(())
404    }
405
406    #[test]
407    fn auto_promote_config_rejects_invalid_source_kinds() -> Result<()> {
408        for (label, value) in [
409            ("string", "\"explicit_user_statement\""),
410            ("empty-array", "[]"),
411            ("empty-string", "[\"\"]"),
412            ("non-string", "[1]"),
413            ("unknown", "[\"typo\"]"),
414        ] {
415            let path =
416                user_context_config_path(&format!("user-context-auto-promote-source-{label}"));
417            with_user_context_config_path(&path, || -> Result<()> {
418                std::fs::write(
419                    &path,
420                    format!("[user_context.auto_promote]\nallowed_source_kinds = {value}\n"),
421                )?;
422                let err = user_context_auto_promote_config()
423                    .expect_err("invalid allowed_source_kinds must fail closed");
424                assert!(err.to_string().contains("allowed_source_kinds"), "{err}");
425                Ok(())
426            })?;
427            std::fs::remove_file(path)?;
428        }
429        Ok(())
430    }
431
432    #[test]
433    fn auto_promote_config_rejects_invalid_booleans() -> Result<()> {
434        for (label, key) in [("support", "require_text_support"), ("strict", "strict")] {
435            let path = user_context_config_path(&format!("user-context-auto-promote-bool-{label}"));
436            with_user_context_config_path(&path, || -> Result<()> {
437                std::fs::write(
438                    &path,
439                    format!("[user_context.auto_promote]\n{key} = \"true\"\n"),
440                )?;
441                let err = user_context_auto_promote_config()
442                    .expect_err("invalid boolean must fail closed");
443                assert!(err.to_string().contains(key), "{err}");
444                Ok(())
445            })?;
446            std::fs::remove_file(path)?;
447        }
448        Ok(())
449    }
450
451    #[test]
452    fn auto_promote_config_rejects_disabled_text_support_until_policy_safe() -> Result<()> {
453        let path = user_context_config_path("user-context-auto-promote-text-support-disabled");
454        with_user_context_config_path(&path, || -> Result<()> {
455            std::fs::write(
456                &path,
457                "[user_context.auto_promote]\nrequire_text_support = false\n",
458            )?;
459            let err = user_context_auto_promote_config()
460                .expect_err("disabled text support must fail closed until queue support is safe");
461            assert!(
462                err.to_string().contains("require_text_support=false"),
463                "{err}"
464            );
465            Ok(())
466        })?;
467        std::fs::remove_file(path)?;
468        Ok(())
469    }
470}