Skip to main content

slop_ai/
scaling.rs

1//! Tree scaling utilities: depth truncation, node-budget compaction,
2//! salience filtering, and subtree extraction.
3
4use crate::types::SlopNode;
5
6/// Options for preparing a tree for output to a consumer.
7#[derive(Debug, Clone, Default)]
8pub struct OutputTreeOptions {
9    /// Maximum depth to resolve. Nodes beyond this become stubs with summaries.
10    pub max_depth: Option<usize>,
11    /// Maximum total nodes. Lowest-salience subtrees are collapsed first.
12    pub max_nodes: Option<usize>,
13    /// Minimum salience threshold. Nodes below this are excluded.
14    pub min_salience: Option<f64>,
15    /// Only include nodes of these types.
16    pub types: Option<Vec<String>>,
17}
18
19/// Prepare a tree for output by applying filter → truncate → compact.
20pub fn prepare_tree(root: &SlopNode, options: &OutputTreeOptions) -> SlopNode {
21    let mut tree = root.clone();
22    if options.min_salience.is_some() || options.types.is_some() {
23        tree = filter_tree(&tree, options.min_salience, options.types.as_deref());
24    }
25    if let Some(max_depth) = options.max_depth {
26        tree = truncate_tree(&tree, max_depth as i32);
27    }
28    if let Some(max_nodes) = options.max_nodes {
29        tree = auto_compact(&tree, max_nodes);
30    }
31    tree
32}
33
34/// Extract a subtree by slash-separated node ID path (e.g. "/inbox/msg-42").
35pub fn get_subtree<'a>(root: &'a SlopNode, path: &str) -> Option<&'a SlopNode> {
36    if path.is_empty() || path == "/" {
37        return Some(root);
38    }
39    let segments: Vec<&str> = path
40        .trim_start_matches('/')
41        .split('/')
42        .filter(|s| !s.is_empty())
43        .collect();
44    let mut current = root;
45    for seg in segments {
46        let children = current.children.as_ref()?;
47        current = children.iter().find(|c| c.id == seg)?;
48    }
49    Some(current)
50}
51
52/// Collapse nodes beyond depth into **depth stubs**.
53///
54/// Depth stubs carry only `id`, `type`, and `meta` — no `properties`,
55/// `children`, `affordances`, or `content_ref`. For budget-driven collapsing
56/// that preserves `properties` and `affordances`, use [`auto_compact`]
57/// instead. See spec/core/state-tree.md §depth stub vs compacted node.
58pub fn truncate_tree(node: &SlopNode, depth: i32) -> SlopNode {
59    if depth <= 0 {
60        if let Some(children) = &node.children {
61            if !children.is_empty() {
62                let mut meta = node.meta.clone().unwrap_or_default();
63                meta.total_children = Some(children.len());
64                return SlopNode {
65                    id: node.id.clone(),
66                    node_type: node.node_type.clone(),
67                    properties: None,
68                    children: None,
69                    affordances: None,
70                    meta: Some(meta),
71                    content_ref: None,
72                };
73            }
74        }
75    }
76    match &node.children {
77        None => node.clone(),
78        Some(children) => {
79            let mut out = node.clone();
80            out.children = Some(
81                children
82                    .iter()
83                    .map(|c| truncate_tree(c, depth - 1))
84                    .collect(),
85            );
86            out
87        }
88    }
89}
90
91/// Collapse lowest-salience subtrees to fit within a node budget.
92/// Preserves root children and pinned nodes.
93pub fn auto_compact(root: &SlopNode, max_nodes: usize) -> SlopNode {
94    let total = count_nodes(root);
95    if total <= max_nodes {
96        return root.clone();
97    }
98
99    let mut candidates = Vec::new();
100    if let Some(children) = &root.children {
101        for (i, child) in children.iter().enumerate() {
102            collect_candidates(child, &[i], &mut candidates, false);
103        }
104    }
105
106    candidates.sort_by(|a, b| {
107        a.score
108            .partial_cmp(&b.score)
109            .unwrap_or(std::cmp::Ordering::Equal)
110    });
111
112    let mut tree = root.clone();
113    let mut node_count = total;
114
115    for candidate in &candidates {
116        if node_count <= max_nodes {
117            break;
118        }
119        let saved = collapse_at_path(&mut tree, &candidate.path);
120        node_count -= saved;
121    }
122
123    tree
124}
125
126/// Filter a tree by salience threshold and/or node types.
127/// The root node is never filtered.
128pub fn filter_tree(
129    node: &SlopNode,
130    min_salience: Option<f64>,
131    types: Option<&[String]>,
132) -> SlopNode {
133    let children = match &node.children {
134        None => return node.clone(),
135        Some(c) => c,
136    };
137
138    let filtered: Vec<SlopNode> = children
139        .iter()
140        .filter(|child| {
141            if let Some(ms) = min_salience {
142                let s = child.meta.as_ref().and_then(|m| m.salience).unwrap_or(0.5);
143                if s < ms {
144                    return false;
145                }
146            }
147            if let Some(t) = types {
148                if !t.iter().any(|ty| ty == &child.node_type) {
149                    return false;
150                }
151            }
152            true
153        })
154        .map(|child| filter_tree(child, min_salience, types))
155        .collect();
156
157    let mut out = node.clone();
158    out.children = if filtered.is_empty() {
159        None
160    } else {
161        Some(filtered)
162    };
163    out
164}
165
166/// Count total nodes in a tree.
167pub fn count_nodes(node: &SlopNode) -> usize {
168    1 + node
169        .children
170        .as_ref()
171        .map(|c| c.iter().map(count_nodes).sum())
172        .unwrap_or(0)
173}
174
175// --- Internal helpers ---
176
177struct CompactCandidate {
178    path: Vec<usize>,
179    score: f64,
180    #[allow(dead_code)]
181    child_count: usize,
182}
183
184fn collect_candidates(
185    node: &SlopNode,
186    path: &[usize],
187    candidates: &mut Vec<CompactCandidate>,
188    is_root_child: bool,
189) {
190    let children = match &node.children {
191        None => return,
192        Some(c) => c,
193    };
194    for (i, child) in children.iter().enumerate() {
195        let mut child_path = path.to_vec();
196        child_path.push(i);
197
198        let pinned = child.meta.as_ref().and_then(|m| m.pinned).unwrap_or(false);
199        let has_children = child.children.as_ref().is_some_and(|c| !c.is_empty());
200
201        if has_children && !is_root_child && !pinned {
202            let child_count = count_nodes(child) - 1;
203            let salience = child.meta.as_ref().and_then(|m| m.salience).unwrap_or(0.5);
204            let depth = child_path.len() as f64;
205            let score = salience - depth * 0.01 - child_count as f64 * 0.001;
206            candidates.push(CompactCandidate {
207                path: child_path.clone(),
208                score,
209                child_count,
210            });
211        }
212
213        collect_candidates(child, &child_path, candidates, false);
214    }
215}
216
217fn collapse_at_path(tree: &mut SlopNode, path: &[usize]) -> usize {
218    let mut node = tree;
219    for &idx in &path[..path.len() - 1] {
220        let children = match &mut node.children {
221            Some(c) if idx < c.len() => c,
222            _ => return 0,
223        };
224        node = &mut children[idx];
225    }
226
227    let last_idx = path[path.len() - 1];
228    let children = match &mut node.children {
229        Some(c) if last_idx < c.len() => c,
230        _ => return 0,
231    };
232
233    let target = &children[last_idx];
234    let saved = count_nodes(target) - 1;
235    let tc = target.children.as_ref().map_or(0, |c| c.len());
236
237    let mut meta = target.meta.clone().unwrap_or_default();
238    meta.total_children = Some(tc);
239    if meta.summary.is_none() {
240        meta.summary = Some(format!("{} children", tc));
241    }
242
243    children[last_idx] = SlopNode {
244        id: target.id.clone(),
245        node_type: target.node_type.clone(),
246        properties: target.properties.clone(),
247        children: None,
248        affordances: target.affordances.clone(),
249        meta: Some(meta),
250        content_ref: target.content_ref.clone(),
251    };
252
253    saved
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use crate::types::NodeMeta;
260
261    fn make_node(id: &str, node_type: &str) -> SlopNode {
262        SlopNode::new(id, node_type)
263    }
264
265    fn make_tree() -> SlopNode {
266        let mut root = make_node("root", "root");
267        let mut inbox = make_node("inbox", "view");
268        inbox.meta = Some(NodeMeta {
269            salience: Some(0.8),
270            ..Default::default()
271        });
272        let msg1 = make_node("msg-1", "item");
273        let msg2 = make_node("msg-2", "item");
274        inbox.children = Some(vec![msg1, msg2]);
275
276        let mut settings = make_node("settings", "view");
277        settings.meta = Some(NodeMeta {
278            salience: Some(0.1),
279            ..Default::default()
280        });
281        let mut general = make_node("general", "group");
282        general.children = Some(vec![make_node("theme", "item")]);
283        settings.children = Some(vec![general]);
284
285        root.children = Some(vec![inbox, settings]);
286        root
287    }
288
289    #[test]
290    fn test_count_nodes() {
291        let tree = make_tree();
292        assert_eq!(count_nodes(&tree), 7);
293    }
294
295    #[test]
296    fn test_get_subtree() {
297        let tree = make_tree();
298        let sub = get_subtree(&tree, "/inbox").unwrap();
299        assert_eq!(sub.id, "inbox");
300        let msg = get_subtree(&tree, "/inbox/msg-1").unwrap();
301        assert_eq!(msg.id, "msg-1");
302        assert!(get_subtree(&tree, "/nonexistent").is_none());
303    }
304
305    #[test]
306    fn test_truncate_tree() {
307        let tree = make_tree();
308        let truncated = truncate_tree(&tree, 1);
309        assert!(truncated.children.is_some());
310        let children = truncated.children.unwrap();
311        // inbox children should be collapsed
312        assert!(children[0].children.is_none());
313        assert_eq!(children[0].meta.as_ref().unwrap().total_children, Some(2));
314    }
315
316    #[test]
317    fn test_filter_tree() {
318        let tree = make_tree();
319        let filtered = filter_tree(&tree, Some(0.5), None);
320        let children = filtered.children.unwrap();
321        assert_eq!(children.len(), 1); // only inbox (salience 0.8)
322        assert_eq!(children[0].id, "inbox");
323    }
324
325    #[test]
326    fn test_auto_compact() {
327        let tree = make_tree();
328        // 7 nodes total, compact to 6 — settings/general subtree (2 nodes) collapses to 1
329        let compacted = auto_compact(&tree, 6);
330        assert!(count_nodes(&compacted) <= 6);
331        // settings should still exist but general should be a stub
332        let settings = get_subtree(&compacted, "/settings").unwrap();
333        let general = &settings.children.as_ref().unwrap()[0];
334        assert!(general.children.is_none());
335        assert!(general.meta.as_ref().unwrap().total_children.is_some());
336    }
337
338    #[test]
339    fn test_prepare_tree() {
340        let tree = make_tree();
341        let opts = OutputTreeOptions {
342            max_depth: Some(1),
343            min_salience: Some(0.5),
344            ..Default::default()
345        };
346        let prepared = prepare_tree(&tree, &opts);
347        let children = prepared.children.unwrap();
348        assert_eq!(children.len(), 1); // filtered to inbox
349        assert!(children[0].children.is_none()); // truncated at depth 1
350    }
351}