Skip to main content

hydro_lang/viz/
json.rs

1use std::collections::{HashMap, HashSet};
2use std::fmt::Write;
3
4use serde::Serialize;
5use slotmap::{SecondaryMap, SparseSecondaryMap};
6
7use super::render::{
8    GraphWriteError, HydroEdgeProp, HydroGraphWrite, HydroNodeType, HydroWriteConfig,
9    write_hydro_ir_json,
10};
11use crate::compile::ir::HydroRoot;
12use crate::compile::ir::backtrace::Backtrace;
13use crate::location::{LocationKey, LocationType};
14use crate::viz::render::VizNodeKey;
15
16/// A serializable backtrace frame for JSON output.
17/// Includes compatibility aliases to match potential viewer expectations.
18#[derive(Serialize)]
19struct BacktraceFrame {
20    /// Function name (truncated)
21    #[serde(rename = "fn")]
22    fn_name: String,
23    /// Function name alias for compatibility
24    function: String,
25    /// File path (truncated)
26    file: String,
27    /// File path alias for compatibility
28    filename: String,
29    /// Line number
30    line: Option<u32>,
31    /// Line number alias for compatibility
32    #[serde(rename = "lineNumber")]
33    line_number: Option<u32>,
34}
35
36/// Node data for JSON output.
37#[derive(Serialize)]
38struct NodeData {
39    #[serde(rename = "locationKey")]
40    location_key: Option<LocationKey>,
41    #[serde(rename = "locationType")]
42    location_type: Option<LocationType>,
43    backtrace: serde_json::Value,
44}
45
46/// A serializable node for JSON output.
47#[derive(Serialize)]
48struct Node {
49    id: String,
50    #[serde(rename = "nodeType")]
51    node_type: String,
52    #[serde(rename = "fullLabel")]
53    full_label: String,
54    #[serde(rename = "shortLabel")]
55    short_label: String,
56    label: String,
57    data: NodeData,
58}
59
60/// A serializable edge for JSON output.
61#[derive(Serialize)]
62struct Edge {
63    id: String,
64    source: String,
65    target: String,
66    #[serde(rename = "semanticTags")]
67    semantic_tags: Vec<String>,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    label: Option<String>,
70}
71
72/// JSON graph writer for Hydro IR.
73/// Outputs JSON that can be used with interactive graph visualization tools.
74pub struct HydroJson<'a, W> {
75    write: W,
76    nodes: Vec<serde_json::Value>,
77    edges: Vec<serde_json::Value>,
78    /// location_id -> (label, node_ids)
79    locations: SecondaryMap<LocationKey, (String, Vec<VizNodeKey>)>,
80    /// node_id -> location_id
81    node_locations: SecondaryMap<VizNodeKey, LocationKey>,
82    edge_count: usize,
83    /// Map from raw location IDs to location names.
84    location_names: &'a SecondaryMap<LocationKey, String>,
85    /// Store backtraces for hierarchy generation.
86    node_backtraces: SparseSecondaryMap<VizNodeKey, Backtrace>,
87    /// Config flags.
88    use_short_labels: bool,
89}
90
91impl<'a, W> HydroJson<'a, W> {
92    pub fn new(write: W, config: HydroWriteConfig<'a>) -> Self {
93        Self {
94            write,
95            nodes: Vec::new(),
96            edges: Vec::new(),
97            locations: SecondaryMap::new(),
98            node_locations: SecondaryMap::new(),
99            edge_count: 0,
100            location_names: config.location_names,
101            node_backtraces: SparseSecondaryMap::new(),
102            use_short_labels: config.use_short_labels,
103        }
104    }
105
106    /// Convert HydroNodeType to string representation
107    fn node_type_to_string(node_type: HydroNodeType) -> &'static str {
108        super::render::node_type_utils::to_string(node_type)
109    }
110
111    /// Convert HydroEdgeType to string representation for semantic tags
112    fn edge_type_to_string(edge_type: HydroEdgeProp) -> String {
113        match edge_type {
114            HydroEdgeProp::Bounded => "Bounded".to_owned(),
115            HydroEdgeProp::Unbounded => "Unbounded".to_owned(),
116            HydroEdgeProp::TotalOrder => "TotalOrder".to_owned(),
117            HydroEdgeProp::NoOrder => "NoOrder".to_owned(),
118            HydroEdgeProp::Keyed => "Keyed".to_owned(),
119            HydroEdgeProp::Stream => "Stream".to_owned(),
120            HydroEdgeProp::KeyedSingleton => "KeyedSingleton".to_owned(),
121            HydroEdgeProp::KeyedStream => "KeyedStream".to_owned(),
122            HydroEdgeProp::Singleton => "Singleton".to_owned(),
123            HydroEdgeProp::Optional => "Optional".to_owned(),
124            HydroEdgeProp::Network => "Network".to_owned(),
125            HydroEdgeProp::Cycle => "Cycle".to_owned(),
126        }
127    }
128
129    /// Get all node type definitions for JSON output
130    fn get_node_type_definitions() -> Vec<serde_json::Value> {
131        // Ensure deterministic ordering by sorting by type string
132        let mut types: Vec<(usize, &'static str)> =
133            super::render::node_type_utils::all_types_with_strings()
134                .into_iter()
135                .enumerate()
136                .map(|(idx, (_, type_str))| (idx, type_str))
137                .collect();
138        types.sort_by(|a, b| a.1.cmp(b.1));
139        types
140            .into_iter()
141            .enumerate()
142            .map(|(color_index, (_, type_str))| {
143                serde_json::json!({
144                    "id": type_str,
145                    "label": type_str,
146                    "colorIndex": color_index
147                })
148            })
149            .collect()
150    }
151
152    /// Get legend items for JSON output (simplified version of node type definitions)
153    fn get_legend_items() -> Vec<serde_json::Value> {
154        Self::get_node_type_definitions()
155            .into_iter()
156            .map(|def| {
157                serde_json::json!({
158                    "type": def["id"],
159                    "label": def["label"]
160                })
161            })
162            .collect()
163    }
164
165    /// Get edge style configuration with semantic→style mappings.
166    fn get_edge_style_config() -> serde_json::Value {
167        serde_json::json!({
168            "semanticPriorities": [
169                ["Unbounded", "Bounded"],
170                ["NoOrder", "TotalOrder"],
171                ["Keyed", "NotKeyed"],
172                ["Network", "Local"]
173            ],
174            "semanticMappings": {
175                // Network communication group - controls line pattern AND animation
176                "NetworkGroup": {
177                    "Local": {
178                        "line-pattern": "solid",
179                        "animation": "static"
180                    },
181                    "Network": {
182                        "line-pattern": "dashed",
183                        "animation": "animated"
184                    }
185                },
186
187                // Ordering group - controls waviness
188                "OrderingGroup": {
189                    "TotalOrder": {
190                        "waviness": "straight"
191                    },
192                    "NoOrder": {
193                        "waviness": "wavy"
194                    }
195                },
196
197                // Boundedness group - controls halo
198                "BoundednessGroup": {
199                    "Bounded": {
200                        "halo": "none"
201                    },
202                    "Unbounded": {
203                        "halo": "light-blue"
204                    }
205                },
206
207                // Keyedness group - controls vertical hash marks on the line
208                "KeyednessGroup": {
209                    "NotKeyed": {
210                        "line-style": "single"
211                    },
212                    "Keyed": {
213                        "line-style": "hash-marks"
214                    }
215                },
216
217                // Collection type group - controls color
218                "CollectionGroup": {
219                    "Stream": {
220                        "color": "#2563eb",
221                        "arrowhead": "triangle-filled"
222                    },
223                    "Singleton": {
224                        "color": "#000000",
225                        "arrowhead": "circle-filled"
226                    },
227                    "Optional": {
228                        "color": "#6b7280",
229                        "arrowhead": "diamond-open"
230                    }
231                },
232            },
233            "note": "Edge styles are now computed per-edge using the unified edge style system. This config is provided for reference and compatibility."
234        })
235    }
236
237    /// Optimize backtrace data for size efficiency
238    /// 1. Remove redundant/non-essential frames
239    /// 2. Truncate paths
240    /// 3. Remove memory addresses (not useful for visualization)
241    fn optimize_backtrace(&self, backtrace: &Backtrace) -> serde_json::Value {
242        #[cfg(feature = "build")]
243        {
244            let elements = backtrace.elements();
245
246            // filter out obviously internal frames
247            let relevant_frames: Vec<BacktraceFrame> = elements
248                .map(|elem| {
249                    // Truncate paths and function names for size
250                    let short_filename = elem
251                        .filename
252                        .as_deref()
253                        .map(|f| Self::truncate_path(f))
254                        .unwrap_or_else(|| "unknown".to_owned());
255
256                    let short_fn_name = Self::truncate_function_name(&elem.fn_name).to_owned();
257
258                    BacktraceFrame {
259                        fn_name: short_fn_name.to_owned(),
260                        function: short_fn_name,
261                        file: short_filename.clone(),
262                        filename: short_filename,
263                        line: elem.lineno,
264                        line_number: elem.lineno,
265                    }
266                })
267                .collect();
268
269            serde_json::to_value(relevant_frames).unwrap_or_else(|_| serde_json::json!([]))
270        }
271        #[cfg(not(feature = "build"))]
272        {
273            serde_json::json!([])
274        }
275    }
276
277    /// Truncate file paths to keep only the relevant parts
278    fn truncate_path(path: &str) -> String {
279        let parts: Vec<&str> = path.split('/').collect();
280
281        // For paths like "/Users/foo/project/src/main.rs", keep "src/main.rs"
282        if let Some(src_idx) = parts.iter().rposition(|&p| p == "src") {
283            parts[src_idx..].join("/")
284        } else if parts.len() > 2 {
285            // Keep last 2 components
286            parts[parts.len().saturating_sub(2)..].join("/")
287        } else {
288            path.to_owned()
289        }
290    }
291
292    /// Truncate function names to remove module paths
293    fn truncate_function_name(fn_name: &str) -> &str {
294        // Remove everything before the last "::" to get just the function name
295        fn_name.split("::").last().unwrap_or(fn_name)
296    }
297}
298
299impl<W> HydroGraphWrite for HydroJson<'_, W>
300where
301    W: Write,
302{
303    type Err = GraphWriteError;
304
305    fn write_prologue(&mut self) -> Result<(), Self::Err> {
306        // Clear any existing data
307        self.nodes.clear();
308        self.edges.clear();
309        self.locations.clear();
310        self.node_locations.clear();
311        self.edge_count = 0;
312        Ok(())
313    }
314
315    fn write_node_definition(
316        &mut self,
317        node_id: VizNodeKey,
318        node_label: &super::render::NodeLabel,
319        node_type: HydroNodeType,
320        location_key: Option<LocationKey>,
321        location_type: Option<LocationType>,
322        backtrace: Option<&Backtrace>,
323    ) -> Result<(), Self::Err> {
324        // Create the full label string using DebugExpr::Display for expressions
325        let full_label = match node_label {
326            super::render::NodeLabel::Static(s) => s.clone(),
327            super::render::NodeLabel::WithExprs { op_name, exprs } => {
328                if exprs.is_empty() {
329                    format!("{}()", op_name)
330                } else {
331                    // This is where DebugExpr::Display gets called with q! macro cleanup
332                    let expr_strs: Vec<String> = exprs.iter().map(|e| e.to_string()).collect();
333                    format!("{}({})", op_name, expr_strs.join(", "))
334                }
335            }
336        };
337
338        // Always extract short label for UI toggle functionality
339        let short_label = super::render::extract_short_label(&full_label);
340
341        // If short and full labels are the same or very similar, enhance the full label
342        // Use saturating comparison to avoid underflow when full_label is very short
343        let full_len = full_label.len();
344        let enhanced_full_label = if short_label.len() >= full_len.saturating_sub(2) {
345            // If they're nearly the same length, add more context to full label
346            match short_label.as_str() {
347                "inspect" => "inspect [debug output]".to_owned(),
348                "persist" => "persist [state storage]".to_owned(),
349                "tee" => "tee [branch dataflow]".to_owned(),
350                "delta" => "delta [change detection]".to_owned(),
351                "spin" => "spin [delay/buffer]".to_owned(),
352                "send_bincode" => "send_bincode [send data to process/cluster]".to_owned(),
353                "broadcast_bincode" => {
354                    "broadcast_bincode [send data to all cluster members]".to_owned()
355                }
356                "source_iter" => "source_iter [iterate over collection]".to_owned(),
357                "source_stream" => "source_stream [receive external data stream]".to_owned(),
358                "network(recv)" => "network(recv) [receive from network]".to_owned(),
359                "network(send)" => "network(send) [send to network]".to_owned(),
360                "dest_sink" => "dest_sink [output destination]".to_owned(),
361                _ => {
362                    if full_label.len() < 15 {
363                        format!("{} [{}]", node_label, "hydro operator")
364                    } else {
365                        node_label.to_string()
366                    }
367                }
368            }
369        } else {
370            node_label.to_string()
371        };
372
373        // Convert backtrace to JSON if available (optimized for size)
374        let backtrace_json = if let Some(bt) = backtrace {
375            // Store backtrace for hierarchy generation
376            self.node_backtraces.insert(node_id, bt.clone());
377            self.optimize_backtrace(bt)
378        } else {
379            serde_json::json!([])
380        };
381
382        // Node type string for styling/legend
383        let node_type_str = Self::node_type_to_string(node_type);
384
385        let node = Node {
386            id: node_id.to_string(),
387            node_type: node_type_str.to_owned(),
388            full_label: enhanced_full_label,
389            short_label: short_label.clone(),
390            // Primary display label follows configuration (defaults to short)
391            label: if self.use_short_labels {
392                short_label
393            } else {
394                full_label
395            },
396            data: NodeData {
397                location_key,
398                location_type,
399                backtrace: backtrace_json,
400            },
401        };
402        self.nodes
403            .push(serde_json::to_value(node).expect("Node serialization should not fail"));
404
405        // Track node location for cross-location edge detection
406        if let Some(loc_key) = location_key {
407            self.node_locations.insert(node_id, loc_key);
408        }
409
410        Ok(())
411    }
412
413    fn write_edge(
414        &mut self,
415        src_id: VizNodeKey,
416        dst_id: VizNodeKey,
417        edge_properties: &HashSet<HydroEdgeProp>,
418        label: Option<&str>,
419    ) -> Result<(), Self::Err> {
420        let edge_id = format!("e{}", self.edge_count);
421        self.edge_count = self.edge_count.saturating_add(1);
422
423        // Convert edge properties to semantic tags (string array)
424        #[expect(
425            clippy::disallowed_methods,
426            reason = "nondeterministic iteration order, TODO(mingwei)"
427        )]
428        let mut semantic_tags: Vec<String> = edge_properties
429            .iter()
430            .map(|p| Self::edge_type_to_string(*p))
431            .collect();
432
433        // Get location information for styling
434        let src_loc = self.node_locations.get(src_id).copied();
435        let dst_loc = self.node_locations.get(dst_id).copied();
436
437        // Add Network tag if edge crosses locations; otherwise add Local for completeness
438        if let (Some(src), Some(dst)) = (src_loc, dst_loc)
439            && src != dst
440            && !semantic_tags.iter().any(|t| t == "Network")
441        {
442            semantic_tags.push("Network".to_owned());
443        } else if semantic_tags.iter().all(|t| t != "Network") {
444            // Only add Local if Network not present (complement for styling)
445            semantic_tags.push("Local".to_owned());
446        }
447
448        // Ensure deterministic ordering of semantic tags
449        semantic_tags.sort();
450
451        let edge = Edge {
452            id: edge_id,
453            source: src_id.to_string(),
454            target: dst_id.to_string(),
455            semantic_tags,
456            label: label.map(|s| s.to_owned()),
457        };
458
459        self.edges
460            .push(serde_json::to_value(edge).expect("Edge serialization should not fail"));
461        Ok(())
462    }
463
464    fn write_location_start(
465        &mut self,
466        location_key: LocationKey,
467        location_type: LocationType,
468    ) -> Result<(), Self::Err> {
469        let location_label = if let Some(location_name) = self.location_names.get(location_key)
470            && "()" != location_name
471        // Use default name if the type name is just "()" (unit type)
472        {
473            format!("{:?} {}", location_type, location_name)
474        } else {
475            format!("{:?} {:?}", location_type, location_key)
476        };
477        self.locations
478            .insert(location_key, (location_label, Vec::new()));
479        Ok(())
480    }
481
482    fn write_node(&mut self, node_id: VizNodeKey) -> Result<(), Self::Err> {
483        // Find the current location being written and add this node to it
484        if let Some((_, node_ids)) = self.locations.values_mut().last() {
485            node_ids.push(node_id);
486        }
487        Ok(())
488    }
489
490    fn write_location_end(&mut self) -> Result<(), Self::Err> {
491        // Location grouping complete - nothing to do for JSON
492        Ok(())
493    }
494
495    fn write_epilogue(&mut self) -> Result<(), Self::Err> {
496        // Create multiple hierarchy options
497        let mut hierarchy_choices = Vec::new();
498        let mut node_assignments_choices = serde_json::Map::new();
499
500        // Always add location-based hierarchy
501        let (location_hierarchy, location_assignments) = self.create_location_hierarchy();
502        hierarchy_choices.push(serde_json::json!({
503            "id": "location",
504            "name": "Location",
505            "children": location_hierarchy
506        }));
507        node_assignments_choices.insert(
508            "location".to_owned(),
509            serde_json::Value::Object(location_assignments),
510        );
511
512        // Add backtrace-based hierarchy if available
513        if self.has_backtrace_data() {
514            let (backtrace_hierarchy, backtrace_assignments) = self.create_backtrace_hierarchy();
515            hierarchy_choices.push(serde_json::json!({
516                "id": "backtrace",
517                "name": "Backtrace",
518                "children": backtrace_hierarchy
519            }));
520            node_assignments_choices.insert(
521                "backtrace".to_owned(),
522                serde_json::Value::Object(backtrace_assignments),
523            );
524        }
525
526        // Before serialization, enforce deterministic ordering for nodes and edges
527        let mut nodes_sorted = self.nodes.clone();
528        nodes_sorted.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str()));
529        let mut edges_sorted = self.edges.clone();
530        edges_sorted.sort_by(|a, b| {
531            let a_src = a["source"].as_str();
532            let b_src = b["source"].as_str();
533            match a_src.cmp(&b_src) {
534                std::cmp::Ordering::Equal => {
535                    let a_dst = a["target"].as_str();
536                    let b_dst = b["target"].as_str();
537                    match a_dst.cmp(&b_dst) {
538                        std::cmp::Ordering::Equal => a["id"].as_str().cmp(&b["id"].as_str()),
539                        other => other,
540                    }
541                }
542                other => other,
543            }
544        });
545
546        // Create the final JSON structure in the format expected by the visualizer
547        let node_type_definitions = Self::get_node_type_definitions();
548        let legend_items = Self::get_legend_items();
549
550        let node_type_config = serde_json::json!({
551            "types": node_type_definitions,
552            "defaultType": "Transform"
553        });
554        let legend = serde_json::json!({
555            "title": "Node Types",
556            "items": legend_items
557        });
558
559        // Determine the selected hierarchy (first one is default)
560        let selected_hierarchy = if !hierarchy_choices.is_empty() {
561            hierarchy_choices[0]["id"].as_str()
562        } else {
563            None
564        };
565
566        #[derive(serde::Serialize)]
567        struct GraphPayload<'a> {
568            nodes: Vec<serde_json::Value>,
569            edges: Vec<serde_json::Value>,
570            #[serde(rename = "hierarchyChoices")]
571            hierarchy_choices: &'a [serde_json::Value],
572            #[serde(rename = "nodeAssignments")]
573            node_assignments: serde_json::Map<String, serde_json::Value>,
574            #[serde(rename = "selectedHierarchy", skip_serializing_if = "Option::is_none")]
575            selected_hierarchy: Option<&'a str>,
576            #[serde(rename = "edgeStyleConfig")]
577            edge_style_config: serde_json::Value,
578            #[serde(rename = "nodeTypeConfig")]
579            node_type_config: serde_json::Value,
580            legend: serde_json::Value,
581        }
582
583        let payload = GraphPayload {
584            nodes: nodes_sorted,
585            edges: edges_sorted,
586            hierarchy_choices: &hierarchy_choices,
587            node_assignments: node_assignments_choices,
588            selected_hierarchy,
589            edge_style_config: Self::get_edge_style_config(),
590            node_type_config,
591            legend,
592        };
593
594        let final_json = serde_json::to_string_pretty(&payload).unwrap();
595
596        write!(self.write, "{}", final_json)
597    }
598}
599
600impl<W> HydroJson<'_, W> {
601    /// Check if any nodes have meaningful backtrace data
602    fn has_backtrace_data(&self) -> bool {
603        self.nodes.iter().any(|node| {
604            if let Some(backtrace_array) = node["data"]["backtrace"].as_array() {
605                // Check if any frame has meaningful filename or fn_name data
606                backtrace_array.iter().any(|frame| {
607                    let filename = frame["file"].as_str().unwrap_or_default();
608                    let fn_name = frame["fn"].as_str().unwrap_or_default();
609                    !filename.is_empty() || !fn_name.is_empty()
610                })
611            } else {
612                false
613            }
614        })
615    }
616
617    /// Create location-based hierarchy (original behavior)
618    fn create_location_hierarchy(
619        &self,
620    ) -> (
621        Vec<serde_json::Value>,
622        serde_json::Map<String, serde_json::Value>,
623    ) {
624        // Create hierarchy structure (single level: locations as parents, nodes as children)
625        let mut locs: Vec<(LocationKey, &(String, Vec<VizNodeKey>))> =
626            self.locations.iter().collect();
627        locs.sort_by(|a, b| a.0.cmp(&b.0));
628        let hierarchy: Vec<serde_json::Value> = locs
629            .into_iter()
630            .map(|(location_key, (label, _))| {
631                serde_json::json!({
632                    "key": location_key.to_string(),
633                    "name": label,
634                    "children": [] // Single level hierarchy - no nested children
635                })
636            })
637            .collect();
638
639        // Create node assignments by reading locationId from each node's data
640        // This is more reliable than using the write_node tracking which depends on HashMap iteration order
641        // Build and then sort assignments deterministically by node id key
642        let mut tmp: Vec<(String, serde_json::Value)> = Vec::new();
643        for node in self.nodes.iter() {
644            if let (Some(node_id), location_key) =
645                (node["id"].as_str(), &node["data"]["locationKey"])
646            {
647                tmp.push((node_id.to_owned(), location_key.clone()));
648            }
649        }
650        tmp.sort_by(|a, b| a.0.cmp(&b.0));
651        let mut node_assignments = serde_json::Map::new();
652        for (k, v) in tmp {
653            node_assignments.insert(k, v);
654        }
655
656        (hierarchy, node_assignments)
657    }
658
659    /// Create backtrace-based hierarchy using structured backtrace data
660    fn create_backtrace_hierarchy(
661        &self,
662    ) -> (
663        Vec<serde_json::Value>,
664        serde_json::Map<String, serde_json::Value>,
665    ) {
666        use std::collections::HashMap;
667
668        let mut hierarchy_map: HashMap<String, (String, usize, Option<String>)> = HashMap::new(); // path -> (name, depth, parent_path)
669        let mut path_to_node_assignments: HashMap<String, Vec<String>> = HashMap::new(); // path -> [node_ids]
670
671        // Process each node's backtrace using the stored backtraces
672        for node in self.nodes.iter() {
673            if let Some(node_id_str) = node["id"].as_str()
674                && let Ok(node_id) = node_id_str.parse::<VizNodeKey>()
675                && let Some(backtrace) = self.node_backtraces.get(node_id)
676            {
677                let elements = backtrace.elements().collect::<Vec<_>>();
678                if elements.is_empty() {
679                    continue;
680                }
681
682                // Do not filter frames for now
683                let user_frames = elements;
684                if user_frames.is_empty() {
685                    continue;
686                }
687
688                // Build hierarchy path from backtrace frames (reverse order for call stack)
689                let mut hierarchy_path = Vec::new();
690                for (i, elem) in user_frames.iter().rev().enumerate() {
691                    let label = if i == 0 {
692                        if let Some(filename) = &elem.filename {
693                            Self::extract_file_path(filename)
694                        } else {
695                            format!("fn_{}", Self::truncate_function_name(&elem.fn_name))
696                        }
697                    } else {
698                        Self::truncate_function_name(&elem.fn_name).to_owned()
699                    };
700                    hierarchy_path.push(label);
701                }
702
703                // Create hierarchy nodes for this path
704                let mut current_path = String::new();
705                let mut parent_path: Option<String> = None;
706                let mut deepest_path = String::new();
707                // Deduplicate consecutive identical labels for cleanliness
708                let mut deduped: Vec<String> = Vec::new();
709                for seg in hierarchy_path {
710                    if deduped.last().map(|s| s == &seg).unwrap_or(false) {
711                        continue;
712                    }
713                    deduped.push(seg);
714                }
715                for (depth, label) in deduped.iter().enumerate() {
716                    current_path = if current_path.is_empty() {
717                        label.clone()
718                    } else {
719                        format!("{}/{}", current_path, label)
720                    };
721                    if !hierarchy_map.contains_key(&current_path) {
722                        hierarchy_map.insert(
723                            current_path.clone(),
724                            (label.clone(), depth, parent_path.clone()),
725                        );
726                    }
727                    deepest_path = current_path.clone();
728                    parent_path = Some(current_path.clone());
729                }
730
731                if !deepest_path.is_empty() {
732                    path_to_node_assignments
733                        .entry(deepest_path)
734                        .or_default()
735                        .push(node_id_str.to_owned());
736                }
737            }
738        }
739        // Build hierarchy tree and create proper ID mapping (deterministic)
740        let (mut hierarchy, mut path_to_id_map, id_remapping) =
741            self.build_hierarchy_tree_with_ids(&hierarchy_map);
742
743        // Create a root node for nodes without backtraces
744        let root_id = "bt_root";
745        let mut nodes_without_backtrace = Vec::new();
746
747        // Collect all node IDs
748        for node in self.nodes.iter() {
749            if let Some(node_id_str) = node["id"].as_str() {
750                nodes_without_backtrace.push(node_id_str.to_owned());
751            }
752        }
753
754        // Remove nodes that already have backtrace assignments
755        #[expect(
756            clippy::disallowed_methods,
757            reason = "nondeterministic iteration order, TODO(mingwei)"
758        )]
759        for node_ids in path_to_node_assignments.values() {
760            for node_id in node_ids {
761                nodes_without_backtrace.retain(|id| id != node_id);
762            }
763        }
764
765        // If there are nodes without backtraces, create a root container for them
766        if !nodes_without_backtrace.is_empty() {
767            hierarchy.push(serde_json::json!({
768                "id": root_id,
769                "name": "(no backtrace)",
770                "children": []
771            }));
772            path_to_id_map.insert("__root__".to_owned(), root_id.to_owned());
773        }
774
775        // Create node assignments using the actual hierarchy IDs
776        let mut node_assignments = serde_json::Map::new();
777        let mut pairs: Vec<(String, Vec<String>)> = path_to_node_assignments.into_iter().collect();
778        pairs.sort_by(|a, b| a.0.cmp(&b.0));
779        for (path, mut node_ids) in pairs {
780            node_ids.sort();
781            if let Some(hierarchy_id) = path_to_id_map.get(&path) {
782                for node_id in node_ids {
783                    node_assignments
784                        .insert(node_id, serde_json::Value::String(hierarchy_id.clone()));
785                }
786            }
787        }
788
789        // Assign nodes without backtraces to the root
790        for node_id in nodes_without_backtrace {
791            node_assignments.insert(node_id, serde_json::Value::String(root_id.to_owned()));
792        }
793
794        // CRITICAL FIX: Apply ID remapping to node assignments
795        // When containers are collapsed, their IDs change, but nodeAssignments still reference old IDs
796        // We need to update all assignments to use the new (collapsed) container IDs
797        let mut remapped_assignments = serde_json::Map::new();
798        for (node_id, container_id_value) in node_assignments.iter() {
799            if let Some(container_id) = container_id_value.as_str() {
800                // Check if this container ID was remapped during collapsing
801                let final_container_id = id_remapping
802                    .get(container_id)
803                    .map(|s| &**s)
804                    .unwrap_or(container_id);
805                remapped_assignments.insert(
806                    node_id.clone(),
807                    serde_json::Value::String(final_container_id.to_owned()),
808                );
809            }
810        }
811
812        (hierarchy, remapped_assignments)
813    }
814
815    /// Build a tree structure and return both the tree and path-to-ID mapping
816    fn build_hierarchy_tree_with_ids(
817        &self,
818        hierarchy_map: &HashMap<String, (String, usize, Option<String>)>,
819    ) -> (
820        Vec<serde_json::Value>,
821        HashMap<String, String>,
822        HashMap<String, String>,
823    ) {
824        // Assign IDs deterministically based on sorted path names
825        #[expect(
826            clippy::disallowed_methods,
827            reason = "nondeterministic iteration order, TODO(mingwei)"
828        )]
829        let mut keys: Vec<&String> = hierarchy_map.keys().collect();
830        keys.sort();
831        let mut path_to_id: HashMap<String, String> = HashMap::new();
832        for (i, path) in keys.iter().enumerate() {
833            path_to_id.insert((*path).clone(), format!("bt_{}", i.saturating_add(1)));
834        }
835
836        // Find root items (depth 0) and sort by name
837        #[expect(
838            clippy::disallowed_methods,
839            reason = "nondeterministic iteration order, TODO(mingwei)"
840        )]
841        let mut roots: Vec<(String, String)> = hierarchy_map
842            .iter()
843            .filter_map(|(path, (name, depth, _))| {
844                if *depth == 0 {
845                    Some((path.clone(), name.clone()))
846                } else {
847                    None
848                }
849            })
850            .collect();
851        roots.sort_by(|a, b| a.1.cmp(&b.1));
852        let mut root_nodes = Vec::new();
853        for (path, name) in roots {
854            let tree_node = Self::build_tree_node(&path, &name, hierarchy_map, &path_to_id);
855            root_nodes.push(tree_node);
856        }
857
858        // Apply top-down collapsing of single-child container chains
859        // and build a mapping of old IDs to new IDs
860        let mut id_remapping: HashMap<String, String> = HashMap::new();
861        root_nodes = root_nodes
862            .into_iter()
863            .map(|node| Self::collapse_single_child_containers(node, None, &mut id_remapping))
864            .collect();
865
866        // Update path_to_id with remappings
867        let mut updated_path_to_id = path_to_id.clone();
868        #[expect(
869            clippy::disallowed_methods,
870            reason = "nondeterministic iteration order, TODO(mingwei)"
871        )]
872        for (path, old_id) in path_to_id.iter() {
873            if let Some(new_id) = id_remapping.get(old_id) {
874                updated_path_to_id.insert(path.clone(), new_id.clone());
875            }
876        }
877
878        (root_nodes, updated_path_to_id, id_remapping)
879    }
880
881    /// Build a single tree node recursively
882    fn build_tree_node(
883        current_path: &str,
884        name: &str,
885        hierarchy_map: &HashMap<String, (String, usize, Option<String>)>,
886        path_to_id: &HashMap<String, String>,
887    ) -> serde_json::Value {
888        let current_id = path_to_id.get(current_path).unwrap().clone();
889
890        // Find children (paths that have this path as parent)
891        #[expect(
892            clippy::disallowed_methods,
893            reason = "nondeterministic iteration order, TODO(mingwei)"
894        )]
895        let mut child_specs: Vec<(&String, &String)> = hierarchy_map
896            .iter()
897            .filter_map(|(child_path, (child_name, _, parent_path))| {
898                if let Some(parent) = parent_path {
899                    if parent == current_path {
900                        Some((child_path, child_name))
901                    } else {
902                        None
903                    }
904                } else {
905                    None
906                }
907            })
908            .collect();
909        child_specs.sort_by(|a, b| a.1.cmp(b.1));
910        let mut children = Vec::new();
911        for (child_path, child_name) in child_specs {
912            let child_node =
913                Self::build_tree_node(child_path, child_name, hierarchy_map, path_to_id);
914            children.push(child_node);
915        }
916
917        if children.is_empty() {
918            serde_json::json!({
919                "id": current_id,
920                "name": name
921            })
922        } else {
923            serde_json::json!({
924                "id": current_id,
925                "name": name,
926                "children": children
927            })
928        }
929    }
930
931    /// Collapse single-child container chains (top-down)
932    /// When a container has exactly one child AND that child is also a container,
933    /// we collapse them by keeping the child's ID and combining names.
934    /// parent_name is used to accumulate names during recursion (None for roots)
935    /// id_remapping tracks which old IDs map to which new IDs after collapsing
936    fn collapse_single_child_containers(
937        node: serde_json::Value,
938        parent_name: Option<&str>,
939        id_remapping: &mut HashMap<String, String>,
940    ) -> serde_json::Value {
941        let mut node_obj = match node {
942            serde_json::Value::Object(obj) => obj,
943            _ => return node,
944        };
945
946        let current_name = node_obj
947            .get("name")
948            .and_then(|v| v.as_str())
949            .unwrap_or_default();
950
951        let current_id = node_obj
952            .get("id")
953            .and_then(|v| v.as_str())
954            .unwrap_or_default();
955
956        // Determine the effective name (combined with parent if collapsing)
957        // Use → to show call chain (parent called child)
958        let effective_name = if let Some(parent) = parent_name {
959            format!("{} → {}", parent, current_name)
960        } else {
961            current_name.to_owned()
962        };
963
964        // Check if this node has children (is a container)
965        if let Some(serde_json::Value::Array(children)) = node_obj.get("children") {
966            // If exactly one child AND that child is also a container
967            if children.len() == 1
968                && let Some(child) = children.first()
969            {
970                let child_is_container = child
971                    .get("children")
972                    .and_then(|v| v.as_array())
973                    .is_some_and(|arr| !arr.is_empty());
974
975                if child_is_container {
976                    let child_id = child.get("id").and_then(|v| v.as_str()).unwrap_or_default();
977
978                    // Record that this parent's ID should map to the child's ID
979                    if !current_id.is_empty() && !child_id.is_empty() {
980                        id_remapping.insert(current_id.to_owned(), child_id.to_owned());
981                    }
982
983                    // Collapse: recursively process the child with accumulated name
984                    return Self::collapse_single_child_containers(
985                        child.clone(),
986                        Some(&effective_name),
987                        id_remapping,
988                    );
989                }
990            }
991
992            // Not collapsing: process children normally and update name if accumulated
993            let processed_children: Vec<serde_json::Value> = children
994                .iter()
995                .map(|child| {
996                    Self::collapse_single_child_containers(child.clone(), None, id_remapping)
997                })
998                .collect();
999
1000            node_obj.insert("name".to_owned(), serde_json::Value::String(effective_name));
1001            node_obj.insert(
1002                "children".to_owned(),
1003                serde_json::Value::Array(processed_children),
1004            );
1005        } else {
1006            // Leaf node: just update name if accumulated
1007            node_obj.insert("name".to_owned(), serde_json::Value::String(effective_name));
1008        }
1009
1010        serde_json::Value::Object(node_obj)
1011    }
1012
1013    /// Extract meaningful file path
1014    fn extract_file_path(filename: &str) -> String {
1015        if filename.is_empty() {
1016            return "unknown".to_owned();
1017        }
1018
1019        // Extract the most relevant part of the file path
1020        let parts: Vec<&str> = filename.split('/').collect();
1021        let file_name = parts.last().unwrap_or(&"unknown");
1022
1023        // If it's a source file, include the parent directory for context
1024        if file_name.ends_with(".rs") && parts.len() > 1 {
1025            let parent_dir = parts[parts.len() - 2];
1026            format!("{}/{}", parent_dir, file_name)
1027        } else {
1028            file_name.to_string()
1029        }
1030    }
1031}
1032
1033/// Create JSON from Hydro IR with type names
1034pub fn hydro_ir_to_json(
1035    ir: &[HydroRoot],
1036    location_names: &SecondaryMap<LocationKey, String>,
1037) -> Result<String, Box<dyn std::error::Error>> {
1038    let mut output = String::new();
1039
1040    let config = HydroWriteConfig {
1041        show_metadata: false,
1042        show_location_groups: true,
1043        use_short_labels: true, // Default to short labels
1044        location_names,
1045    };
1046
1047    write_hydro_ir_json(&mut output, ir, config)?;
1048
1049    Ok(output)
1050}
1051
1052/// Open JSON visualization in browser using the docs visualizer with URL-encoded data
1053pub fn open_json_browser(
1054    ir: &[HydroRoot],
1055    location_names: &SecondaryMap<LocationKey, String>,
1056) -> Result<(), Box<dyn std::error::Error>> {
1057    let config = HydroWriteConfig {
1058        location_names,
1059        ..Default::default()
1060    };
1061
1062    super::debug::open_json_visualizer(ir, Some(config))
1063        .map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
1064}
1065
1066/// Save JSON to file using the consolidated debug utilities
1067pub fn save_json(
1068    ir: &[HydroRoot],
1069    location_names: &SecondaryMap<LocationKey, String>,
1070    filename: &str,
1071) -> Result<std::path::PathBuf, Box<dyn std::error::Error>> {
1072    let config = HydroWriteConfig {
1073        location_names,
1074        ..Default::default()
1075    };
1076
1077    super::debug::save_json(ir, Some(filename), Some(config))
1078        .map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
1079}
1080
1081/// Open JSON visualization in browser for a BuiltFlow
1082#[cfg(feature = "build")]
1083pub fn open_browser(
1084    built_flow: &crate::compile::built::BuiltFlow,
1085) -> Result<(), Box<dyn std::error::Error>> {
1086    open_json_browser(built_flow.ir(), built_flow.location_names())
1087}