Skip to main content

oxihuman_core/
decision_tree.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Simple boolean decision tree with leaf actions.
5
6#![allow(dead_code)]
7
8use std::collections::HashMap;
9
10/// A condition that evaluates to bool given a context.
11#[allow(dead_code)]
12pub type ConditionFn = fn(&HashMap<String, bool>) -> bool;
13
14/// A node in the decision tree.
15#[allow(dead_code)]
16#[derive(Debug, Clone)]
17pub enum DecisionNode {
18    /// Branch: condition key, true-branch index, false-branch index.
19    Branch {
20        condition_key: String,
21        true_branch: usize,
22        false_branch: usize,
23    },
24    /// Leaf: action label.
25    Leaf(String),
26}
27
28/// A binary decision tree backed by a flat Vec of nodes.
29#[allow(dead_code)]
30#[derive(Debug, Clone, Default)]
31pub struct DecisionTree {
32    nodes: Vec<DecisionNode>,
33}
34
35/// Create a new empty decision tree.
36#[allow(dead_code)]
37pub fn new_decision_tree() -> DecisionTree {
38    DecisionTree::default()
39}
40
41/// Add a leaf node and return its index.
42#[allow(dead_code)]
43pub fn dt_add_leaf(tree: &mut DecisionTree, action: &str) -> usize {
44    let idx = tree.nodes.len();
45    tree.nodes.push(DecisionNode::Leaf(action.to_string()));
46    idx
47}
48
49/// Add a branch node and return its index.
50#[allow(dead_code)]
51pub fn dt_add_branch(
52    tree: &mut DecisionTree,
53    condition_key: &str,
54    true_branch: usize,
55    false_branch: usize,
56) -> usize {
57    let idx = tree.nodes.len();
58    tree.nodes.push(DecisionNode::Branch {
59        condition_key: condition_key.to_string(),
60        true_branch,
61        false_branch,
62    });
63    idx
64}
65
66/// Evaluate the tree starting at `root` using the given boolean context.
67/// Returns the leaf action label, or `None` if the tree is empty or invalid.
68#[allow(dead_code)]
69pub fn dt_evaluate(
70    tree: &DecisionTree,
71    root: usize,
72    context: &HashMap<String, bool>,
73) -> Option<String> {
74    if tree.nodes.is_empty() {
75        return None;
76    }
77    let mut current = root;
78    loop {
79        match tree.nodes.get(current)? {
80            DecisionNode::Leaf(action) => return Some(action.clone()),
81            DecisionNode::Branch {
82                condition_key,
83                true_branch,
84                false_branch,
85            } => {
86                let cond = context.get(condition_key).copied().unwrap_or(false);
87                current = if cond { *true_branch } else { *false_branch };
88            }
89        }
90    }
91}
92
93/// Return the number of nodes in the tree.
94#[allow(dead_code)]
95pub fn dt_node_count(tree: &DecisionTree) -> usize {
96    tree.nodes.len()
97}
98
99/// Count only leaf nodes.
100#[allow(dead_code)]
101pub fn dt_leaf_count(tree: &DecisionTree) -> usize {
102    tree.nodes
103        .iter()
104        .filter(|n| matches!(n, DecisionNode::Leaf(_)))
105        .count()
106}
107
108/// Count only branch nodes.
109#[allow(dead_code)]
110pub fn dt_branch_count(tree: &DecisionTree) -> usize {
111    tree.nodes
112        .iter()
113        .filter(|n| matches!(n, DecisionNode::Branch { .. }))
114        .count()
115}
116
117/// Clear all nodes from the tree.
118#[allow(dead_code)]
119pub fn dt_clear(tree: &mut DecisionTree) {
120    tree.nodes.clear();
121}
122
123/// Get the action label if a node is a leaf.
124#[allow(dead_code)]
125pub fn dt_leaf_action(tree: &DecisionTree, idx: usize) -> Option<&str> {
126    match tree.nodes.get(idx)? {
127        DecisionNode::Leaf(a) => Some(a.as_str()),
128        _ => None,
129    }
130}
131
132/// Collect all leaf actions in tree order.
133#[allow(dead_code)]
134pub fn dt_all_actions(tree: &DecisionTree) -> Vec<String> {
135    tree.nodes
136        .iter()
137        .filter_map(|n| {
138            if let DecisionNode::Leaf(a) = n {
139                Some(a.clone())
140            } else {
141                None
142            }
143        })
144        .collect()
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    fn make_simple_tree() -> (DecisionTree, usize) {
152        let mut tree = new_decision_tree();
153        let leaf_a = dt_add_leaf(&mut tree, "action_a");
154        let leaf_b = dt_add_leaf(&mut tree, "action_b");
155        let root = dt_add_branch(&mut tree, "is_hot", leaf_a, leaf_b);
156        (tree, root)
157    }
158
159    #[test]
160    fn test_empty_tree() {
161        let tree = new_decision_tree();
162        assert_eq!(dt_node_count(&tree), 0);
163        assert_eq!(dt_leaf_count(&tree), 0);
164    }
165
166    #[test]
167    fn test_add_leaf() {
168        let mut tree = new_decision_tree();
169        let idx = dt_add_leaf(&mut tree, "fire");
170        assert_eq!(idx, 0);
171        assert_eq!(dt_node_count(&tree), 1);
172        assert_eq!(dt_leaf_count(&tree), 1);
173    }
174
175    #[test]
176    fn test_add_branch() {
177        let mut tree = new_decision_tree();
178        let a = dt_add_leaf(&mut tree, "a");
179        let b = dt_add_leaf(&mut tree, "b");
180        let root = dt_add_branch(&mut tree, "cond", a, b);
181        assert_eq!(dt_branch_count(&tree), 1);
182        assert_eq!(root, 2);
183    }
184
185    #[test]
186    fn test_evaluate_true_branch() {
187        let (tree, root) = make_simple_tree();
188        let mut ctx = HashMap::new();
189        ctx.insert("is_hot".to_string(), true);
190        let result = dt_evaluate(&tree, root, &ctx);
191        assert_eq!(result, Some("action_a".to_string()));
192    }
193
194    #[test]
195    fn test_evaluate_false_branch() {
196        let (tree, root) = make_simple_tree();
197        let mut ctx = HashMap::new();
198        ctx.insert("is_hot".to_string(), false);
199        let result = dt_evaluate(&tree, root, &ctx);
200        assert_eq!(result, Some("action_b".to_string()));
201    }
202
203    #[test]
204    fn test_evaluate_missing_key_defaults_false() {
205        let (tree, root) = make_simple_tree();
206        let ctx = HashMap::new();
207        let result = dt_evaluate(&tree, root, &ctx);
208        assert_eq!(result, Some("action_b".to_string()));
209    }
210
211    #[test]
212    fn test_leaf_action() {
213        let mut tree = new_decision_tree();
214        let idx = dt_add_leaf(&mut tree, "run");
215        assert_eq!(dt_leaf_action(&tree, idx), Some("run"));
216    }
217
218    #[test]
219    fn test_all_actions() {
220        let (tree, _) = make_simple_tree();
221        let actions = dt_all_actions(&tree);
222        assert_eq!(actions.len(), 2);
223        assert!(actions.contains(&"action_a".to_string()));
224        assert!(actions.contains(&"action_b".to_string()));
225    }
226
227    #[test]
228    fn test_clear() {
229        let (mut tree, _) = make_simple_tree();
230        dt_clear(&mut tree);
231        assert_eq!(dt_node_count(&tree), 0);
232    }
233
234    #[test]
235    fn test_evaluate_empty_returns_none() {
236        let tree = new_decision_tree();
237        let ctx = HashMap::new();
238        assert!(dt_evaluate(&tree, 0, &ctx).is_none());
239    }
240}