holographic_memory/core/agency/
planner.rs1use std::cmp::Ordering;
14use std::collections::BinaryHeap;
15
16use fxhash::FxHashSet;
17
18use crate::core::triple_store::TripleStore;
19
20#[derive(Clone, Debug)]
22pub struct PlannedAction {
23 pub subject: String,
24 pub relation: String,
25 pub object: String,
26 pub depth: usize,
28 pub cost: f64,
31}
32
33#[derive(Clone, Debug)]
35pub struct Plan {
36 pub goal: String,
37 pub actions: Vec<PlannedAction>,
38 pub complete: bool,
40 pub total_cost: f64,
42}
43
44struct Subgoal {
46 target: String,
47 depth: usize,
48 cost: f64,
50}
51
52impl Eq for Subgoal {}
54
55impl PartialEq for Subgoal {
56 fn eq(&self, other: &Self) -> bool {
57 self.cost == other.cost
58 }
59}
60
61impl Ord for Subgoal {
62 fn cmp(&self, other: &Self) -> Ordering {
63 other
65 .cost
66 .partial_cmp(&self.cost)
67 .unwrap_or(Ordering::Equal)
68 }
69}
70
71impl PartialOrd for Subgoal {
72 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
73 Some(self.cmp(other))
74 }
75}
76
77pub struct Planner;
79
80impl Planner {
81 pub fn backward_chain(
96 triple_store: &TripleStore,
97 goal: &str,
98 causal_relations: &[&str],
99 max_depth: usize,
100 ) -> Plan {
101 let mut actions = Vec::new();
102 let mut visited = FxHashSet::default();
103 visited.insert(goal.to_string());
104
105 let mut heap = BinaryHeap::new();
107 heap.push(Subgoal {
108 target: goal.to_string(),
109 depth: 0,
110 cost: 0.0,
111 });
112
113 let mut all_complete = true;
114
115 while let Some(subgoal) = heap.pop() {
116 if subgoal.depth >= max_depth {
117 all_complete = false;
119 continue;
120 }
121
122 let candidates = triple_store.query(None, None, Some(&subgoal.target));
124 let filtered: Vec<_> = if causal_relations.is_empty() {
125 candidates
126 } else {
127 candidates
128 .into_iter()
129 .filter(|t| causal_relations.contains(&t.relation_id.as_str()))
130 .collect()
131 };
132
133 if filtered.is_empty() {
134 let as_subject = triple_store.query(Some(&subgoal.target), None, None);
136 if as_subject.is_empty() {
137 all_complete = false;
139 }
140 continue;
141 }
142
143 let evidence_count = filtered.len();
144 let action_cost = 1.0 / (1.0 + evidence_count as f64);
145
146 for t in &filtered {
147 actions.push(PlannedAction {
148 subject: t.subject_id.clone(),
149 relation: t.relation_id.clone(),
150 object: t.object_id.clone(),
151 depth: subgoal.depth,
152 cost: action_cost,
153 });
154
155 if !visited.contains(&t.subject_id) {
157 visited.insert(t.subject_id.clone());
158 heap.push(Subgoal {
159 target: t.subject_id.clone(),
160 depth: subgoal.depth + 1,
161 cost: subgoal.cost + action_cost,
162 });
163 }
164 }
165 }
166
167 actions.sort_by_key(|b| std::cmp::Reverse(b.depth));
169
170 let total_cost: f64 = actions.iter().map(|a| a.cost).sum();
171
172 Plan {
173 goal: goal.to_string(),
174 actions,
175 complete: all_complete,
176 total_cost,
177 }
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 #[test]
186 fn test_simple_backward_chain() {
187 let store = TripleStore::new();
188 store.add("action_a", "causes", "state_b", "c1");
190 store.add("state_b", "causes", "goal_c", "c2");
191
192 let plan = Planner::backward_chain(&store, "goal_c", &["causes"], 5);
193 assert!(!plan.actions.is_empty());
194 assert_eq!(plan.actions[0].subject, "action_a");
196 assert_eq!(plan.actions[0].object, "state_b");
197 assert_eq!(plan.actions[1].subject, "state_b");
199 assert_eq!(plan.actions[1].object, "goal_c");
200 }
201
202 #[test]
203 fn test_no_causal_chain() {
204 let store = TripleStore::new();
205 store.add("x", "likes", "y", "c1");
206
207 let plan = Planner::backward_chain(&store, "goal", &["causes"], 5);
208 assert!(plan.actions.is_empty());
209 assert!(!plan.complete);
210 }
211
212 #[test]
213 fn test_depth_limit() {
214 let store = TripleStore::new();
215 store.add("a", "causes", "b", "c1");
216 store.add("b", "causes", "c", "c2");
217 store.add("c", "causes", "d", "c3");
218 store.add("d", "causes", "goal", "c4");
219
220 let plan = Planner::backward_chain(&store, "goal", &["causes"], 2);
222 assert!(plan.actions.len() <= 3);
223 }
224
225 #[test]
226 fn test_cycle_prevention() {
227 let store = TripleStore::new();
228 store.add("a", "causes", "b", "c1");
230 store.add("b", "causes", "a", "c2");
231
232 let plan = Planner::backward_chain(&store, "a", &["causes"], 10);
233 assert!(plan.actions.len() <= 4);
235 }
236
237 #[test]
238 fn test_all_relations() {
239 let store = TripleStore::new();
240 store.add("heat", "produces", "steam", "c1");
241 store.add("steam", "enables", "power", "c2");
242
243 let plan = Planner::backward_chain(&store, "power", &[], 5);
245 assert_eq!(plan.actions.len(), 2);
246 }
247
248 #[test]
249 fn test_empty_store() {
250 let store = TripleStore::new();
251 let plan = Planner::backward_chain(&store, "anything", &[], 5);
252 assert!(plan.actions.is_empty());
253 }
254
255 #[test]
256 fn test_cost_estimation() {
257 let store = TripleStore::new();
258 store.add("x", "causes", "goal", "c1");
260 store.add("y", "causes", "goal", "c2");
261
262 let plan = Planner::backward_chain(&store, "goal", &["causes"], 5);
263 assert_eq!(plan.actions.len(), 2);
264
265 let expected_cost = 1.0 / 3.0;
267 for action in &plan.actions {
268 assert!(
269 (action.cost - expected_cost).abs() < 1e-9,
270 "expected cost {expected_cost}, got {}",
271 action.cost
272 );
273 }
274
275 let expected_total = 2.0 * expected_cost;
277 assert!(
278 (plan.total_cost - expected_total).abs() < 1e-9,
279 "expected total_cost {expected_total}, got {}",
280 plan.total_cost
281 );
282
283 let store2 = TripleStore::new();
285 store2.add("z", "causes", "goal2", "c3");
286 let plan2 = Planner::backward_chain(&store2, "goal2", &["causes"], 5);
287 assert_eq!(plan2.actions.len(), 1);
288 let single_cost = 1.0 / 2.0;
289 assert!(
290 (plan2.actions[0].cost - single_cost).abs() < 1e-9,
291 "expected cost {single_cost}, got {}",
292 plan2.actions[0].cost
293 );
294 }
295
296 #[test]
297 fn test_best_first_ordering() {
298 let store = TripleStore::new();
299 store.add("step1a", "causes", "mid", "c1");
302 store.add("alt1", "causes", "goal", "c2");
303 store.add("alt2", "causes", "goal", "c3");
304 store.add("mid", "causes", "goal", "c4");
305
306 store.add("step1b", "causes", "side", "c5");
309 store.add("side", "causes", "goal", "c6");
310
311 let plan = Planner::backward_chain(&store, "goal", &["causes"], 5);
312
313 let depth0: Vec<_> = plan.actions.iter().filter(|a| a.depth == 0).collect();
317 let depth1: Vec<_> = plan.actions.iter().filter(|a| a.depth == 1).collect();
318
319 assert!(!depth0.is_empty(), "should have depth-0 actions");
321
322 let expected_d0_cost = 1.0 / 5.0;
325 for a in &depth0 {
326 assert!(
327 (a.cost - expected_d0_cost).abs() < 1e-9,
328 "depth-0 cost should be {expected_d0_cost}, got {}",
329 a.cost
330 );
331 }
332
333 if !depth1.is_empty() {
335 for a in &depth1 {
336 assert!(
337 a.cost > expected_d0_cost,
338 "depth-1 cost {} should exceed depth-0 cost {expected_d0_cost}",
339 a.cost
340 );
341 }
342 }
343
344 let sum: f64 = plan.actions.iter().map(|a| a.cost).sum();
346 assert!(
347 (plan.total_cost - sum).abs() < 1e-9,
348 "total_cost {} should equal sum of action costs {sum}",
349 plan.total_cost
350 );
351 }
352}