Skip to main content

holographic_memory/core/agency/
planner.rs

1// Copyright 2024-2026 WritersLogic Contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Best-first backward-chaining planner over causal triples.
5//!
6//! Starting from a goal state, finds triples where the object matches the goal
7//! and recursively plans to achieve the subject. Produces an ordered list of
8//! actions (triples to make true), expanding the lowest-cost subgoal first.
9//!
10//! Cost estimation: each action's cost is `1.0 / (1 + evidence_count)` where
11//! `evidence_count` is the number of triples supporting the action.
12
13use std::cmp::Ordering;
14use std::collections::BinaryHeap;
15
16use fxhash::FxHashSet;
17
18use crate::core::triple_store::TripleStore;
19
20/// A single planned action: a triple that needs to be made true.
21#[derive(Clone, Debug)]
22pub struct PlannedAction {
23    pub subject: String,
24    pub relation: String,
25    pub object: String,
26    /// Depth in the backward chain (0 = directly achieves goal).
27    pub depth: usize,
28    /// Estimated cost. Lower means more evidence supports this action.
29    /// Computed as `1.0 / (1 + evidence_count)`.
30    pub cost: f64,
31}
32
33/// A complete plan: an ordered sequence of actions to achieve a goal.
34#[derive(Clone, Debug)]
35pub struct Plan {
36    pub goal: String,
37    pub actions: Vec<PlannedAction>,
38    /// Whether the plan reaches known facts for every leaf subgoal.
39    pub complete: bool,
40    /// Total cost of all actions in the plan.
41    pub total_cost: f64,
42}
43
44/// A subgoal waiting to be expanded in the best-first search.
45struct Subgoal {
46    target: String,
47    depth: usize,
48    /// Cumulative cost to reach this subgoal.
49    cost: f64,
50}
51
52/// Wrapper for min-heap ordering (BinaryHeap is max-heap by default).
53impl 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        // Reverse ordering so BinaryHeap acts as a min-heap on cost.
64        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
77/// Backward-chaining planner over the triple store.
78pub struct Planner;
79
80impl Planner {
81    /// Plan how to achieve a goal state by backward-chaining.
82    ///
83    /// Looks for triples where `object == goal` and treats the subject as a
84    /// subgoal. Uses best-first search, expanding the lowest-cost subgoal
85    /// first. Recurses up to `max_depth`.
86    ///
87    /// A plan is "complete" if every leaf subgoal either already exists as a
88    /// subject in the triple store or has no further causal chain.
89    ///
90    /// If the chain cannot reach known facts within `max_depth`, the best
91    /// partial plan found so far is returned with `complete == false`.
92    ///
93    /// `causal_relations`: only follow triples with these relation types
94    /// (e.g., "causes", "enables", "produces"). If empty, follows all relations.
95    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        // Best-first search using a min-heap on cost.
106        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                // Could not expand further; mark as incomplete.
118                all_complete = false;
119                continue;
120            }
121
122            // Find triples where object == target.
123            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                // No causal chain found; check if target is a known entity.
135                let as_subject = triple_store.query(Some(&subgoal.target), None, None);
136                if as_subject.is_empty() {
137                    // Leaf subgoal not grounded; plan is partial.
138                    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                // Enqueue the subject as a subgoal if not already visited.
156                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        // Sort actions by depth descending so leaf actions come first (execution order).
168        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        // A causes B, B causes C
189        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        // First action should be the leaf (action_a causes state_b)
195        assert_eq!(plan.actions[0].subject, "action_a");
196        assert_eq!(plan.actions[0].object, "state_b");
197        // Second should be the direct step (state_b causes goal_c)
198        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        // Depth 2 should not reach all the way back to "a"
221        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        // Circular: a causes b, b causes a
229        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        // Should not infinite loop
234        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        // Empty causal_relations means follow all
244        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        // Two triples supporting the same target -> lower cost per action.
259        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        // evidence_count = 2, so cost = 1.0 / (1 + 2) = 1/3
266        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        // total_cost should be 2 * (1/3)
276        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        // Single evidence -> higher cost.
284        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        // Path 1: high evidence (low cost) chain
300        //   step1a -> mid (3 triples support mid->goal, so cost = 1/4 each)
301        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        // Path 2: low evidence (high cost) chain
307        //   step1b -> side (1 triple supports side->goal, cost = 1/2)
308        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        // The depth-0 actions (direct to goal) should have lower cost than
314        // depth-1 actions further from the goal, due to 4 supporting triples
315        // for goal (cost = 1/5 each) vs fewer for subgoals.
316        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        // Depth-0 actions exist and come after depth-1 in execution order.
320        assert!(!depth0.is_empty(), "should have depth-0 actions");
321
322        // All depth-0 actions should have cost = 1/(1+4) = 0.2
323        // (4 triples target "goal": alt1, alt2, mid, side)
324        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        // Depth-1 actions target "mid" (1 triple) or "side" (1 triple): cost = 1/2
334        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        // Verify total_cost is consistent.
345        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}