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        // Add backtrace-based hierarchy first (default)
501        if self.has_backtrace_data() {
502            let (backtrace_hierarchy, backtrace_assignments) = self.create_backtrace_hierarchy();
503            hierarchy_choices.push(serde_json::json!({
504                "id": "backtrace",
505                "name": "Backtrace",
506                "children": backtrace_hierarchy
507            }));
508            node_assignments_choices.insert(
509                "backtrace".to_owned(),
510                serde_json::Value::Object(backtrace_assignments),
511            );
512        }
513
514        // Add location-based hierarchy
515        let (location_hierarchy, location_assignments) = self.create_location_hierarchy();
516        hierarchy_choices.push(serde_json::json!({
517            "id": "location",
518            "name": "Location",
519            "children": location_hierarchy
520        }));
521        node_assignments_choices.insert(
522            "location".to_owned(),
523            serde_json::Value::Object(location_assignments),
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                // Filter to user-relevant frames (skip runtime/allocator/tokio internals)
683                let user_frames: Vec<_> = elements
684                    .into_iter()
685                    .filter(|elem| {
686                        let fn_name = &elem.fn_name;
687                        let file = elem.filename.as_deref().unwrap_or("");
688                        // Skip allocator, tokio runtime, std internals
689                        !(fn_name.starts_with("alloc")
690                            || fn_name.contains("call_once")
691                            || fn_name.contains("{async_block")
692                            || fn_name == "main"
693                            || file.contains("/runtime/")
694                            || file.contains("/future/")
695                            || file.contains("/task/"))
696                    })
697                    .collect();
698                if user_frames.is_empty() {
699                    continue;
700                }
701
702                // Build hierarchy path from backtrace frames (reverse order for call stack)
703                let mut hierarchy_path = Vec::new();
704                let mut prev_fn = String::new();
705                for elem in user_frames.iter().rev() {
706                    let fn_short = Self::truncate_function_name(&elem.fn_name);
707                    let label = if let Some(filename) = &elem.filename {
708                        let file_short = Self::truncate_path(filename);
709                        // Only show function name when it changes from the parent
710                        if fn_short != prev_fn {
711                            if let Some(line) = elem.lineno {
712                                format!("{} — {}:{}", fn_short, file_short, line)
713                            } else {
714                                format!("{} — {}", fn_short, file_short)
715                            }
716                        } else if let Some(line) = elem.lineno {
717                            format!("{}:{}", file_short, line)
718                        } else {
719                            file_short
720                        }
721                    } else {
722                        fn_short.to_owned()
723                    };
724                    prev_fn = fn_short.to_owned();
725                    hierarchy_path.push(label);
726                }
727
728                // Create hierarchy nodes for this path
729                let mut current_path = String::new();
730                let mut parent_path: Option<String> = None;
731                let mut deepest_path = String::new();
732                // Deduplicate consecutive identical labels for cleanliness
733                let mut deduped: Vec<String> = Vec::new();
734                for seg in hierarchy_path {
735                    if deduped.last().map(|s| s == &seg).unwrap_or(false) {
736                        continue;
737                    }
738                    deduped.push(seg);
739                }
740                for (depth, label) in deduped.iter().enumerate() {
741                    current_path = if current_path.is_empty() {
742                        label.clone()
743                    } else {
744                        format!("{}/{}", current_path, label)
745                    };
746                    if !hierarchy_map.contains_key(&current_path) {
747                        hierarchy_map.insert(
748                            current_path.clone(),
749                            (label.clone(), depth, parent_path.clone()),
750                        );
751                    }
752                    deepest_path = current_path.clone();
753                    parent_path = Some(current_path.clone());
754                }
755
756                if !deepest_path.is_empty() {
757                    path_to_node_assignments
758                        .entry(deepest_path)
759                        .or_default()
760                        .push(node_id_str.to_owned());
761                }
762            }
763        }
764        // Build hierarchy tree and create proper ID mapping (deterministic)
765        let (mut hierarchy, mut path_to_id_map, id_remapping) =
766            self.build_hierarchy_tree_with_ids(&hierarchy_map);
767
768        // Create a root node for nodes without backtraces
769        let root_id = "bt_root";
770        let mut nodes_without_backtrace = Vec::new();
771
772        // Collect all node IDs
773        for node in self.nodes.iter() {
774            if let Some(node_id_str) = node["id"].as_str() {
775                nodes_without_backtrace.push(node_id_str.to_owned());
776            }
777        }
778
779        // Remove nodes that already have backtrace assignments
780        #[expect(
781            clippy::disallowed_methods,
782            reason = "nondeterministic iteration order, TODO(mingwei)"
783        )]
784        for node_ids in path_to_node_assignments.values() {
785            for node_id in node_ids {
786                nodes_without_backtrace.retain(|id| id != node_id);
787            }
788        }
789
790        // If there are nodes without backtraces, create a root container for them
791        if !nodes_without_backtrace.is_empty() {
792            hierarchy.push(serde_json::json!({
793                "id": root_id,
794                "name": "(no backtrace)",
795                "children": []
796            }));
797            path_to_id_map.insert("__root__".to_owned(), root_id.to_owned());
798        }
799
800        // Create node assignments using the actual hierarchy IDs
801        let mut node_assignments = serde_json::Map::new();
802        let mut pairs: Vec<(String, Vec<String>)> = path_to_node_assignments.into_iter().collect();
803        pairs.sort_by(|a, b| a.0.cmp(&b.0));
804        for (path, mut node_ids) in pairs {
805            node_ids.sort();
806            if let Some(hierarchy_id) = path_to_id_map.get(&path) {
807                for node_id in node_ids {
808                    node_assignments
809                        .insert(node_id, serde_json::Value::String(hierarchy_id.clone()));
810                }
811            }
812        }
813
814        // Assign nodes without backtraces to the root
815        for node_id in nodes_without_backtrace {
816            node_assignments.insert(node_id, serde_json::Value::String(root_id.to_owned()));
817        }
818
819        // CRITICAL FIX: Apply ID remapping to node assignments
820        // When containers are collapsed, their IDs change, but nodeAssignments still reference old IDs
821        // We need to update all assignments to use the new (collapsed) container IDs
822        let mut remapped_assignments = serde_json::Map::new();
823        for (node_id, container_id_value) in node_assignments.iter() {
824            if let Some(container_id) = container_id_value.as_str() {
825                // Check if this container ID was remapped during collapsing
826                let final_container_id = id_remapping
827                    .get(container_id)
828                    .map(|s| &**s)
829                    .unwrap_or(container_id);
830                remapped_assignments.insert(
831                    node_id.clone(),
832                    serde_json::Value::String(final_container_id.to_owned()),
833                );
834            }
835        }
836
837        (hierarchy, remapped_assignments)
838    }
839
840    /// Build a tree structure and return both the tree and path-to-ID mapping
841    fn build_hierarchy_tree_with_ids(
842        &self,
843        hierarchy_map: &HashMap<String, (String, usize, Option<String>)>,
844    ) -> (
845        Vec<serde_json::Value>,
846        HashMap<String, String>,
847        HashMap<String, String>,
848    ) {
849        // Assign IDs deterministically based on sorted path names
850        #[expect(
851            clippy::disallowed_methods,
852            reason = "nondeterministic iteration order, TODO(mingwei)"
853        )]
854        let mut keys: Vec<&String> = hierarchy_map.keys().collect();
855        keys.sort();
856        let mut path_to_id: HashMap<String, String> = HashMap::new();
857        for (i, path) in keys.iter().enumerate() {
858            path_to_id.insert((*path).clone(), format!("bt_{}", i.saturating_add(1)));
859        }
860
861        // Find root items (depth 0) and sort by name
862        #[expect(
863            clippy::disallowed_methods,
864            reason = "nondeterministic iteration order, TODO(mingwei)"
865        )]
866        let mut roots: Vec<(String, String)> = hierarchy_map
867            .iter()
868            .filter_map(|(path, (name, depth, _))| {
869                if *depth == 0 {
870                    Some((path.clone(), name.clone()))
871                } else {
872                    None
873                }
874            })
875            .collect();
876        roots.sort_by(|a, b| a.1.cmp(&b.1));
877        let mut root_nodes = Vec::new();
878        for (path, name) in roots {
879            let tree_node = Self::build_tree_node(&path, &name, hierarchy_map, &path_to_id);
880            root_nodes.push(tree_node);
881        }
882
883        // Apply top-down collapsing of single-child container chains
884        // and build a mapping of old IDs to new IDs
885        let mut id_remapping: HashMap<String, String> = HashMap::new();
886        root_nodes = root_nodes
887            .into_iter()
888            .map(|node| Self::collapse_single_child_containers(node, None, &mut id_remapping))
889            .collect();
890
891        // Update path_to_id with remappings
892        let mut updated_path_to_id = path_to_id.clone();
893        #[expect(
894            clippy::disallowed_methods,
895            reason = "nondeterministic iteration order, TODO(mingwei)"
896        )]
897        for (path, old_id) in path_to_id.iter() {
898            if let Some(new_id) = id_remapping.get(old_id) {
899                updated_path_to_id.insert(path.clone(), new_id.clone());
900            }
901        }
902
903        (root_nodes, updated_path_to_id, id_remapping)
904    }
905
906    /// Build a single tree node recursively
907    fn build_tree_node(
908        current_path: &str,
909        name: &str,
910        hierarchy_map: &HashMap<String, (String, usize, Option<String>)>,
911        path_to_id: &HashMap<String, String>,
912    ) -> serde_json::Value {
913        let current_id = path_to_id.get(current_path).unwrap().clone();
914
915        // Find children (paths that have this path as parent)
916        #[expect(
917            clippy::disallowed_methods,
918            reason = "nondeterministic iteration order, TODO(mingwei)"
919        )]
920        let mut child_specs: Vec<(&String, &String)> = hierarchy_map
921            .iter()
922            .filter_map(|(child_path, (child_name, _, parent_path))| {
923                if let Some(parent) = parent_path {
924                    if parent == current_path {
925                        Some((child_path, child_name))
926                    } else {
927                        None
928                    }
929                } else {
930                    None
931                }
932            })
933            .collect();
934        child_specs.sort_by(|a, b| a.1.cmp(b.1));
935        let mut children = Vec::new();
936        for (child_path, child_name) in child_specs {
937            let child_node =
938                Self::build_tree_node(child_path, child_name, hierarchy_map, path_to_id);
939            children.push(child_node);
940        }
941
942        if children.is_empty() {
943            serde_json::json!({
944                "id": current_id,
945                "name": name
946            })
947        } else {
948            serde_json::json!({
949                "id": current_id,
950                "name": name,
951                "children": children
952            })
953        }
954    }
955
956    /// Collapse single-child container chains (top-down)
957    /// When a container has exactly one child AND that child is also a container,
958    /// we collapse them by keeping the child's ID and combining names.
959    /// parent_name is used to accumulate names during recursion (None for roots)
960    /// id_remapping tracks which old IDs map to which new IDs after collapsing
961    fn collapse_single_child_containers(
962        node: serde_json::Value,
963        parent_name: Option<&str>,
964        id_remapping: &mut HashMap<String, String>,
965    ) -> serde_json::Value {
966        let serde_json::Value::Object(mut node_obj) = node else {
967            return node;
968        };
969
970        let current_name = node_obj
971            .get("name")
972            .and_then(|v| v.as_str())
973            .unwrap_or_default();
974
975        let current_id = node_obj
976            .get("id")
977            .and_then(|v| v.as_str())
978            .unwrap_or_default();
979
980        // Determine the effective name (combined with parent if collapsing)
981        // Use → to show call chain (parent called child)
982        let effective_name = if let Some(parent) = parent_name {
983            format!("{} → {}", parent, current_name)
984        } else {
985            current_name.to_owned()
986        };
987
988        // Check if this node has children (is a container)
989        if let Some(serde_json::Value::Array(children)) = node_obj.get("children") {
990            // If exactly one child AND that child is also a container
991            if children.len() == 1
992                && let Some(child) = children.first()
993            {
994                let child_is_container = child
995                    .get("children")
996                    .and_then(|v| v.as_array())
997                    .is_some_and(|arr| !arr.is_empty());
998
999                if child_is_container {
1000                    let child_id = child.get("id").and_then(|v| v.as_str()).unwrap_or_default();
1001
1002                    // Record that this parent's ID should map to the child's ID
1003                    if !current_id.is_empty() && !child_id.is_empty() {
1004                        id_remapping.insert(current_id.to_owned(), child_id.to_owned());
1005                    }
1006
1007                    // Collapse: recursively process the child with accumulated name
1008                    return Self::collapse_single_child_containers(
1009                        child.clone(),
1010                        Some(&effective_name),
1011                        id_remapping,
1012                    );
1013                }
1014            }
1015
1016            // Not collapsing: process children normally and update name if accumulated
1017            let processed_children: Vec<serde_json::Value> = children
1018                .iter()
1019                .map(|child| {
1020                    Self::collapse_single_child_containers(child.clone(), None, id_remapping)
1021                })
1022                .collect();
1023
1024            node_obj.insert("name".to_owned(), serde_json::Value::String(effective_name));
1025            node_obj.insert(
1026                "children".to_owned(),
1027                serde_json::Value::Array(processed_children),
1028            );
1029        } else {
1030            // Leaf node: just update name if accumulated
1031            node_obj.insert("name".to_owned(), serde_json::Value::String(effective_name));
1032        }
1033
1034        serde_json::Value::Object(node_obj)
1035    }
1036}
1037
1038/// Create JSON from Hydro IR with type names
1039pub fn hydro_ir_to_json(
1040    ir: &[HydroRoot],
1041    location_names: &SecondaryMap<LocationKey, String>,
1042) -> Result<String, Box<dyn std::error::Error>> {
1043    let mut output = String::new();
1044
1045    let config = HydroWriteConfig {
1046        show_metadata: false,
1047        show_location_groups: true,
1048        use_short_labels: true, // Default to short labels
1049        location_names,
1050    };
1051
1052    write_hydro_ir_json(&mut output, ir, config)?;
1053
1054    Ok(output)
1055}
1056
1057/// Save JSON to file using the consolidated debug utilities
1058pub fn save_json(
1059    ir: &[HydroRoot],
1060    location_names: &SecondaryMap<LocationKey, String>,
1061    filename: &str,
1062) -> Result<std::path::PathBuf, Box<dyn std::error::Error>> {
1063    let config = HydroWriteConfig {
1064        location_names,
1065        ..Default::default()
1066    };
1067
1068    super::debug::save_json(ir, Some(filename), Some(config))
1069        .map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
1070}