Skip to main content

khive_pack_memory/
tunable.rs

1//! Brain-tunable parameter surface for the memory pack's recall scoring pipeline.
2//! See `crates/khive-pack-memory/docs/api/pack-integration.md`.
3
4use khive_brain_core::BalancedRecallState;
5use khive_brain_core::{PackTunable, ParameterDef, ParameterSpace};
6use khive_runtime::RuntimeError;
7use serde_json::Value;
8
9use crate::config::RecallConfig;
10use crate::MemoryPack;
11
12/// Brain tuning maps relevance, salience, and temporal posterior means to recall weights.
13///
14/// Projection is pure; applying a projected config validates it before replacing the active
15/// value. See `crates/khive-pack-memory/docs/api/pack-integration.md`.
16impl PackTunable for MemoryPack {
17    fn parameter_space(&self) -> ParameterSpace {
18        ParameterSpace {
19            parameters: vec![
20                ParameterDef {
21                    name: "memory::relevance_weight".into(),
22                    // Prior: relevance is the dominant signal (7:3), matching
23                    // BalancedRecallState's `relevance` posterior prior.
24                    prior_alpha: 7.0,
25                    prior_beta: 3.0,
26                    bounds: (0.0, 1.0),
27                },
28                ParameterDef {
29                    name: "memory::salience_weight".into(),
30                    // Prior: salience is secondary (2:8).
31                    prior_alpha: 2.0,
32                    prior_beta: 8.0,
33                    bounds: (0.0, 1.0),
34                },
35                ParameterDef {
36                    name: "memory::temporal_weight".into(),
37                    // Prior: temporal is weakest signal (1:9).
38                    prior_alpha: 1.0,
39                    prior_beta: 9.0,
40                    bounds: (0.0, 1.0),
41                },
42            ],
43        }
44    }
45
46    /// Project posterior means into a recall config, retaining active values when absent.
47    fn project_config(&self, state: &BalancedRecallState) -> Value {
48        let current = self.active_config();
49
50        let relevance = state.relevance.mean();
51        let salience = state.salience.mean();
52        let temporal = state.temporal.mean();
53
54        let projected = RecallConfig {
55            relevance_weight: relevance,
56            salience_weight: salience,
57            temporal_weight: temporal,
58            ..current
59        };
60
61        serde_json::to_value(projected).unwrap_or_else(|_| serde_json::json!({}))
62    }
63
64    /// Deserialize, validate, and atomically install a projected recall config.
65    fn apply_config(&self, config: Value) -> Result<(), RuntimeError> {
66        let new_cfg: RecallConfig = serde_json::from_value(config)
67            .map_err(|e| RuntimeError::InvalidInput(format!("invalid RecallConfig: {e}")))?;
68        new_cfg.validate()?;
69        *self.config.lock().unwrap() = new_cfg;
70        Ok(())
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77    use khive_brain_core::{BalancedRecallState, BetaPosterior};
78    use khive_runtime::KhiveRuntime;
79
80    fn make_pack() -> MemoryPack {
81        let rt = KhiveRuntime::memory().expect("in-memory runtime");
82        MemoryPack::new(rt)
83    }
84
85    fn balanced_state_with_means(
86        relevance_mean: f64,
87        salience_mean: f64,
88        temporal_mean: f64,
89    ) -> BalancedRecallState {
90        // Construct Beta posteriors whose means match the supplied values.
91        // Using ESS=10 for each: alpha = mean * 10, beta = (1-mean) * 10.
92        let to_posterior =
93            |mean: f64| -> BetaPosterior { BetaPosterior::new(mean * 10.0, (1.0 - mean) * 10.0) };
94        let mut state = BalancedRecallState::new(100);
95        state.relevance = to_posterior(relevance_mean);
96        state.salience = to_posterior(salience_mean);
97        state.temporal = to_posterior(temporal_mean);
98        state
99    }
100
101    #[test]
102    fn parameter_space_has_three_params() {
103        let pack = make_pack();
104        let space = pack.parameter_space();
105        assert_eq!(space.parameters.len(), 3);
106        let names: Vec<&str> = space.parameters.iter().map(|p| p.name.as_str()).collect();
107        assert!(names.contains(&"memory::relevance_weight"));
108        assert!(names.contains(&"memory::salience_weight"));
109        assert!(names.contains(&"memory::temporal_weight"));
110    }
111
112    #[test]
113    fn project_config_reads_posterior_means() {
114        let pack = make_pack();
115        let state = balanced_state_with_means(0.6, 0.3, 0.1);
116        let projected = pack.project_config(&state);
117
118        let cfg: RecallConfig = serde_json::from_value(projected).unwrap();
119        assert!((cfg.relevance_weight - 0.6).abs() < 1e-10);
120        assert!((cfg.salience_weight - 0.3).abs() < 1e-10);
121        assert!((cfg.temporal_weight - 0.1).abs() < 1e-10);
122    }
123
124    #[test]
125    fn project_config_with_default_priors_matches_expected_defaults() {
126        // Default BalancedRecallState priors: Beta(7,3)=0.7, Beta(2,8)=0.2, Beta(1,9)=0.1
127        let pack = make_pack();
128        let state = BalancedRecallState::new(100);
129        let projected = pack.project_config(&state);
130
131        let cfg: RecallConfig = serde_json::from_value(projected).unwrap();
132        assert!((cfg.relevance_weight - 0.70).abs() < 1e-10);
133        assert!((cfg.salience_weight - 0.20).abs() < 1e-10);
134        assert!((cfg.temporal_weight - 0.10).abs() < 1e-10);
135    }
136
137    #[test]
138    fn apply_config_updates_active_config() {
139        let pack = make_pack();
140        let new_cfg = RecallConfig {
141            relevance_weight: 0.5,
142            salience_weight: 0.3,
143            temporal_weight: 0.2,
144            ..RecallConfig::default()
145        };
146        let config_value = serde_json::to_value(&new_cfg).unwrap();
147        pack.apply_config(config_value)
148            .expect("apply_config succeeds");
149
150        let active = pack.active_config();
151        assert!((active.relevance_weight - 0.5).abs() < 1e-10);
152        assert!((active.salience_weight - 0.3).abs() < 1e-10);
153        assert!((active.temporal_weight - 0.2).abs() < 1e-10);
154    }
155
156    #[test]
157    fn apply_config_rejects_all_zero_weights() {
158        let pack = make_pack();
159        let bad_cfg = RecallConfig {
160            relevance_weight: 0.0,
161            salience_weight: 0.0,
162            temporal_weight: 0.0,
163            ..RecallConfig::default()
164        };
165        let config_value = serde_json::to_value(&bad_cfg).unwrap();
166        assert!(pack.apply_config(config_value).is_err());
167    }
168
169    #[test]
170    fn apply_config_rejects_malformed_json() {
171        let pack = make_pack();
172        let bad = serde_json::json!({ "relevance_weight": "not_a_number" });
173        assert!(pack.apply_config(bad).is_err());
174    }
175
176    #[test]
177    fn prior_for_relevance_weight_matches_balanced_recall_state_prior() {
178        // BalancedRecallState uses Beta(7,3) for relevance; ParameterDef must match.
179        let pack = make_pack();
180        let space = pack.parameter_space();
181        let def = space
182            .parameters
183            .iter()
184            .find(|p| p.name == "memory::relevance_weight")
185            .unwrap();
186        assert!((def.prior_alpha - 7.0).abs() < 1e-12);
187        assert!((def.prior_beta - 3.0).abs() < 1e-12);
188    }
189}