Skip to main content

plexus_substrate/activations/arbor/
views.rs

1/// Arbor View System
2///
3/// Provides the ability to create view trees that reference ranges of nodes
4/// from storage trees without copying data.
5///
6/// Key Concepts:
7/// - Storage Tree: Immutable, granular source of truth
8/// - View Tree: References nodes/ranges via external handles
9/// - Range Handle: External node pointing to [start → end] in another tree
10/// - Resolve Mode: Control whether to expand ranges or show placeholders
11
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use schemars::JsonSchema;
15use std::time::{SystemTime, UNIX_EPOCH};
16use std::collections::{HashMap, HashSet};
17
18use super::storage::ArborStorage;
19use super::types::{ArborError, NodeId, NodeType, TreeId, Tree};
20
21// ═══════════════════════════════════════════════════════════════════════════
22// VIEW TYPES
23// ═══════════════════════════════════════════════════════════════════════════
24
25/// Handle for referencing a range of nodes in another tree
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct RangeHandle {
28    /// Source tree containing the range
29    pub tree_id: TreeId,
30    /// First node in the range
31    pub start_node: NodeId,
32    /// Last node in the range (must be descendant of start)
33    pub end_node: NodeId,
34    /// How to collapse this range
35    pub collapse_type: CollapseType,
36}
37
38impl RangeHandle {
39    /// Convert to metadata Value that can be stored in arbor
40    pub fn to_metadata(&self) -> Value {
41        serde_json::json!({
42            "range_handle": {
43                "tree_id": self.tree_id,
44                "start_node": self.start_node,
45                "end_node": self.end_node,
46                "collapse_type": self.collapse_type,
47            }
48        })
49    }
50
51    /// Try to parse metadata as a RangeHandle
52    pub fn from_metadata(metadata: &Value) -> Option<Self> {
53        use super::types::ArborId;
54
55        let range_obj = metadata.get("range_handle")?;
56        let tree_id_str = range_obj.get("tree_id")?.as_str()?;
57        let start_node_str = range_obj.get("start_node")?.as_str()?;
58        let end_node_str = range_obj.get("end_node")?.as_str()?;
59
60        let tree_id = ArborId::parse_str(tree_id_str).ok()?;
61        let start_node = ArborId::parse_str(start_node_str).ok()?;
62        let end_node = ArborId::parse_str(end_node_str).ok()?;
63
64        let collapse_type = serde_json::from_value(
65            range_obj.get("collapse_type")?.clone()
66        ).ok()?;
67
68        Some(RangeHandle {
69            tree_id,
70            start_node,
71            end_node,
72            collapse_type,
73        })
74    }
75}
76
77/// Strategy for collapsing a range
78#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
79#[serde(tag = "type", rename_all = "snake_case")]
80pub enum CollapseType {
81    /// Merge all text content into a single concatenated string
82    TextMerge,
83    /// Keep structure but reference externally (placeholder)
84    StructureRef,
85    /// Custom collapse with user-defined strategy name
86    Custom { strategy: String },
87}
88
89/// Mode for resolving range handles during rendering
90#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
91#[serde(rename_all = "snake_case")]
92pub enum ResolveMode {
93    /// Fully resolve all range handles to their content
94    Full,
95    /// Show ranges as placeholders (e.g., "[range: 5 nodes]")
96    Placeholder,
97    /// Resolve ranges up to a maximum depth
98    Partial { max_depth: usize },
99}
100
101/// Specification for creating a range reference
102#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
103pub struct RangeSpec {
104    /// Source tree
105    pub tree_id: TreeId,
106    /// Start node of range
107    pub start_node: NodeId,
108    /// End node of range (must be descendant of start)
109    pub end_node: NodeId,
110    /// Collapse strategy
111    pub collapse_type: CollapseType,
112}
113
114/// Result of analyzing text runs in a tree
115#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
116pub struct TextRun {
117    /// First node in the run
118    pub start_node: NodeId,
119    /// Last node in the run
120    pub end_node: NodeId,
121    /// Number of nodes in the run
122    pub length: usize,
123    /// Total character count
124    pub char_count: usize,
125}
126
127// ═══════════════════════════════════════════════════════════════════════════
128// VIEW OPERATIONS
129// ═══════════════════════════════════════════════════════════════════════════
130
131impl ArborStorage {
132    /// Create a new view tree with metadata indicating it's a view
133    pub async fn view_create(
134        &self,
135        source_tree_id: &TreeId,
136        owner_id: &str,
137    ) -> Result<TreeId, ArborError> {
138        let now = SystemTime::now()
139            .duration_since(UNIX_EPOCH)
140            .unwrap()
141            .as_secs() as i64;
142
143        let metadata = serde_json::json!({
144            "is_view": true,
145            "source_tree": source_tree_id,
146            "created_at": now,
147        });
148
149        self.tree_create(Some(metadata), owner_id).await
150    }
151
152    /// Add a range reference as a text node with merged content and metadata
153    pub async fn view_add_range(
154        &self,
155        view_tree_id: &TreeId,
156        parent_node: &NodeId,
157        range_spec: RangeSpec,
158    ) -> Result<NodeId, ArborError> {
159        let range_handle = RangeHandle {
160            tree_id: range_spec.tree_id.clone(),
161            start_node: range_spec.start_node.clone(),
162            end_node: range_spec.end_node.clone(),
163            collapse_type: range_spec.collapse_type.clone(),
164        };
165
166        // Get merged content from the range
167        let range_content = self.range_get(
168            &range_spec.tree_id,
169            &range_spec.start_node,
170            &range_spec.end_node,
171            &range_spec.collapse_type,
172        ).await?;
173
174        // Create appropriate NodeEvent with merged content
175        use crate::activations::claudecode::NodeEvent;
176        let merged_content = match range_content {
177            RangeContent::Text { content, .. } => {
178                // Wrap merged text in a ContentText NodeEvent
179                let node_event = NodeEvent::ContentText { text: content };
180                serde_json::to_string(&node_event).unwrap_or_default()
181            }
182            RangeContent::Reference { .. } => {
183                // For structure refs, store a JSON representation
184                serde_json::to_string(&range_handle).unwrap_or_default()
185            }
186            RangeContent::Custom { metadata, .. } => {
187                // For custom, store metadata JSON
188                metadata.to_string()
189            }
190        };
191
192        let metadata = range_handle.to_metadata();
193
194        // Store merged content in the node with range metadata for provenance
195        self.node_create_text(
196            view_tree_id,
197            Some(*parent_node),
198            merged_content,  // Pre-computed merged content
199            Some(metadata),  // Metadata indicates this came from a range
200        )
201        .await
202    }
203
204    // ═══════════════════════════════════════════════════════════════════════════
205    // TREE TRAVERSAL HELPERS
206    // ═══════════════════════════════════════════════════════════════════════════
207
208    /// Build a map of parent_id -> Vec<child_ids> from the tree's parent pointers
209    fn build_child_map(tree: &Tree) -> HashMap<NodeId, Vec<NodeId>> {
210        let mut children: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
211
212        for (node_id, node) in &tree.nodes {
213            if let Some(parent_id) = &node.parent {
214                children.entry(*parent_id)
215                    .or_insert_with(Vec::new)
216                    .push(*node_id);
217            }
218        }
219
220        children
221    }
222
223    /// Perform depth-first traversal starting from a node
224    fn traverse_dfs_from(
225        current_id: &NodeId,
226        children: &HashMap<NodeId, Vec<NodeId>>,
227        visited: &mut Vec<NodeId>
228    ) {
229        visited.push(*current_id);
230
231        if let Some(child_ids) = children.get(current_id) {
232            for child_id in child_ids {
233                Self::traverse_dfs_from(child_id, children, visited);
234            }
235        }
236    }
237
238    /// Traverse an entire tree in depth-first order
239    fn traverse_tree_dfs(tree: &Tree) -> Vec<NodeId> {
240        // Find root (node with no parent)
241        let root_id = tree.nodes.iter()
242            .find(|(_, node)| node.parent.is_none())
243            .map(|(id, _)| *id);
244
245        let Some(root_id) = root_id else {
246            // Empty tree or no root found
247            return Vec::new();
248        };
249
250        let children = Self::build_child_map(tree);
251        let mut visited = Vec::new();
252        Self::traverse_dfs_from(&root_id, &children, &mut visited);
253        visited
254    }
255
256    // ═══════════════════════════════════════════════════════════════════════════
257    // VIEW OPERATIONS
258    // ═══════════════════════════════════════════════════════════════════════════
259
260    /// Detect consecutive text nodes and return runs longer than threshold
261    pub async fn view_detect_text_runs(
262        &self,
263        tree_id: &TreeId,
264        min_length: usize,
265    ) -> Result<Vec<TextRun>, ArborError> {
266        let tree = self.tree_get(tree_id).await?;
267
268        let mut runs = Vec::new();
269        let mut current_run: Option<(NodeId, NodeId, usize, usize)> = None; // (start, end, count, chars)
270
271        // Helper to check if a node is a text node
272        let is_text_node = |node_id: &NodeId| -> Option<usize> {
273            let node = tree.nodes.get(node_id)?;
274
275            match &node.data {
276                NodeType::Text { content } => {
277                    // Try parsing as JSON first (for Claude session format)
278                    if let Ok(parsed) = serde_json::from_str::<Value>(content) {
279                        if parsed.get("type")?.as_str()? == "content_text" {
280                            let text = parsed.get("text")?.as_str()?;
281                            return Some(text.len());
282                        }
283                    }
284
285                    // Otherwise treat as plain text
286                    // Skip empty text nodes (like root node)
287                    if content.is_empty() {
288                        None
289                    } else {
290                        Some(content.len())
291                    }
292                }
293                NodeType::External { .. } => None,
294            }
295        };
296
297        // Traverse tree in depth-first order
298        let node_ids = Self::traverse_tree_dfs(&tree);
299
300        for node_id in &node_ids {
301            if let Some(char_count) = is_text_node(node_id) {
302                match &mut current_run {
303                    Some((start, end, count, chars)) => {
304                        // Check if this node is a child of the previous end
305                        let node = tree.nodes.get(node_id).unwrap();
306                        if node.parent.as_ref() == Some(end) {
307                            // Extend the run
308                            *end = node_id.clone();
309                            *count += 1;
310                            *chars += char_count;
311                        } else {
312                            // End current run, start new one
313                            if *count >= min_length {
314                                runs.push(TextRun {
315                                    start_node: start.clone(),
316                                    end_node: end.clone(),
317                                    length: *count,
318                                    char_count: *chars,
319                                });
320                            }
321                            current_run = Some((node_id.clone(), node_id.clone(), 1, char_count));
322                        }
323                    }
324                    None => {
325                        // Start new run
326                        current_run = Some((node_id.clone(), node_id.clone(), 1, char_count));
327                    }
328                }
329            } else {
330                // Not a text node - end current run
331                if let Some((start, end, count, chars)) = current_run.take() {
332                    if count >= min_length {
333                        runs.push(TextRun {
334                            start_node: start,
335                            end_node: end,
336                            length: count,
337                            char_count: chars,
338                        });
339                    }
340                }
341            }
342        }
343
344        // Don't forget the last run
345        if let Some((start, end, count, chars)) = current_run {
346            if count >= min_length {
347                runs.push(TextRun {
348                    start_node: start,
349                    end_node: end,
350                    length: count,
351                    char_count: chars,
352                });
353            }
354        }
355
356        Ok(runs)
357    }
358
359    /// Create a view tree that collapses consecutive text runs
360    pub async fn view_collapse_text_runs(
361        &self,
362        source_tree_id: &TreeId,
363        min_run_length: usize,
364        owner_id: &str,
365    ) -> Result<(TreeId, Vec<TextRun>), ArborError> {
366        // Detect text runs
367        let runs = self.view_detect_text_runs(source_tree_id, min_run_length).await?;
368
369        // Create view tree
370        let view_tree_id = self.view_create(source_tree_id, owner_id).await?;
371
372        // Get source tree to traverse structure
373        let source_tree = self.tree_get(source_tree_id).await?;
374
375        // Build child map for traversal
376        let children = Self::build_child_map(&source_tree);
377
378        // Build sets for efficient lookup:
379        // 1. All nodes that are part of collapsed runs
380        // 2. Start nodes of each run (these become range references)
381        let mut collapsed_nodes: HashSet<NodeId> = HashSet::new();
382        let mut run_starts: HashMap<NodeId, &TextRun> = HashMap::new();
383
384        for run in &runs {
385            // Collect all nodes in this run by traversing from start to end
386            let mut current = run.start_node;
387            run_starts.insert(current, run);
388            collapsed_nodes.insert(current);
389
390            // Traverse children until we reach end_node
391            while current != run.end_node {
392                if let Some(child_ids) = children.get(&current) {
393                    // In a linear text chain, there should be only one child
394                    if let Some(&child_id) = child_ids.first() {
395                        current = child_id;
396                        collapsed_nodes.insert(current);
397                    } else {
398                        break;
399                    }
400                } else {
401                    break;
402                }
403            }
404        }
405
406        // Traverse source tree in DFS order and build view
407        // Track mapping from source node ID -> view node ID for parent relationships
408        let mut node_mapping: HashMap<NodeId, NodeId> = HashMap::new();
409        let view_tree = self.tree_get(&view_tree_id).await?;
410
411        // Map source root to view root
412        if let Some(source_root) = source_tree.nodes.iter()
413            .find(|(_, node)| node.parent.is_none())
414            .map(|(id, _)| *id)
415        {
416            node_mapping.insert(source_root, view_tree.root);
417        }
418
419        let traversal_order = Self::traverse_tree_dfs(&source_tree);
420
421        for source_node_id in traversal_order {
422            // Skip if we've already processed this as part of a collapsed run (but not the start)
423            if collapsed_nodes.contains(&source_node_id) && !run_starts.contains_key(&source_node_id) {
424                continue;
425            }
426
427            let source_node = source_tree.nodes.get(&source_node_id).unwrap();
428
429            // Find parent in view tree (map from source parent)
430            let view_parent = source_node.parent.as_ref()
431                .and_then(|source_parent| node_mapping.get(source_parent).copied());
432
433            // If this is the start of a collapsed run, add range reference
434            if let Some(run) = run_starts.get(&source_node_id) {
435                let range_spec = RangeSpec {
436                    tree_id: *source_tree_id,
437                    start_node: run.start_node,
438                    end_node: run.end_node,
439                    collapse_type: CollapseType::TextMerge,
440                };
441
442                if let Ok(view_node_id) = self.view_add_range(&view_tree_id, &view_parent.unwrap_or(view_tree.root), range_spec).await {
443                    // Map the start node to the range reference node
444                    node_mapping.insert(source_node_id, view_node_id);
445                }
446            } else {
447                // Copy the node to view tree (preserve structure for non-collapsed nodes)
448                let view_node_id = match &source_node.data {
449                    NodeType::Text { content } => {
450                        self.node_create_text(
451                            &view_tree_id,
452                            view_parent,
453                            content.clone(),
454                            source_node.metadata.clone(),
455                        ).await.ok()
456                    }
457                    NodeType::External { handle } => {
458                        self.node_create_external(
459                            &view_tree_id,
460                            view_parent,
461                            handle.clone(),
462                            source_node.metadata.clone(),
463                        ).await.ok()
464                    }
465                };
466
467                if let Some(view_node_id) = view_node_id {
468                    node_mapping.insert(source_node_id, view_node_id);
469                }
470            }
471        }
472
473        Ok((view_tree_id, runs))
474    }
475
476    /// Get merged content from a range of nodes
477    pub async fn range_get(
478        &self,
479        tree_id: &TreeId,
480        start_node: &NodeId,
481        end_node: &NodeId,
482        collapse_type: &CollapseType,
483    ) -> Result<RangeContent, ArborError> {
484        let tree = self.tree_get(tree_id).await?;
485
486        // Get path from start to end
487        let mut path_nodes = Vec::new();
488        let mut current = end_node.clone();
489
490        // Walk up from end to start (or root)
491        while current != *start_node {
492            path_nodes.push(current);
493
494            let node = tree.nodes.get(&current)
495                .ok_or_else(|| ArborError {
496                    message: format!("Node not found: {}", current)
497                })?;
498
499            if let Some(parent) = &node.parent {
500                current = *parent;
501            } else {
502                return Err(ArborError {
503                    message: "end_node is not a descendant of start_node".to_string()
504                });
505            }
506        }
507
508        path_nodes.push(start_node.clone());
509        path_nodes.reverse();
510
511        match collapse_type {
512            CollapseType::TextMerge => {
513                // Merge all text content
514                let mut merged_text = String::new();
515
516                for node_id in &path_nodes {
517                    if let Some(node) = tree.nodes.get(node_id) {
518                        if let NodeType::Text { content } = &node.data {
519                            // Try parsing as JSON first (for Claude session format)
520                            if let Ok(parsed) = serde_json::from_str::<Value>(content) {
521                                if parsed.get("type").and_then(|t| t.as_str()) == Some("content_text") {
522                                    if let Some(text) = parsed.get("text").and_then(|t| t.as_str()) {
523                                        merged_text.push_str(text);
524                                    }
525                                    continue;
526                                }
527                            }
528
529                            // Otherwise treat as plain text (skip empty content)
530                            if !content.is_empty() {
531                                merged_text.push_str(content);
532                            }
533                        }
534                    }
535                }
536
537                Ok(RangeContent::Text {
538                    content: merged_text,
539                    node_count: path_nodes.len(),
540                    node_ids: path_nodes,
541                })
542            }
543            CollapseType::StructureRef => {
544                Ok(RangeContent::Reference {
545                    tree_id: tree_id.clone(),
546                    start_node: start_node.clone(),
547                    end_node: end_node.clone(),
548                    node_count: path_nodes.len(),
549                })
550            }
551            CollapseType::Custom { strategy } => {
552                Ok(RangeContent::Custom {
553                    strategy: strategy.clone(),
554                    node_count: path_nodes.len(),
555                    metadata: serde_json::json!({
556                        "tree_id": tree_id,
557                        "start": start_node,
558                        "end": end_node,
559                    }),
560                })
561            }
562        }
563    }
564}
565
566/// Result of resolving a range
567#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
568#[serde(tag = "type", rename_all = "snake_case")]
569pub enum RangeContent {
570    /// Merged text content
571    Text {
572        content: String,
573        node_count: usize,
574        node_ids: Vec<NodeId>,
575    },
576    /// Structure reference (placeholder)
577    Reference {
578        tree_id: TreeId,
579        start_node: NodeId,
580        end_node: NodeId,
581        node_count: usize,
582    },
583    /// Custom collapse strategy
584    Custom {
585        strategy: String,
586        node_count: usize,
587        metadata: Value,
588    },
589}