From 66d5169b4110f09222b3e04b1ba6b71f1fa3dc02 Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Sun, 16 Nov 2025 19:03:56 +0100 Subject: [PATCH] Updated NetworkVis + unit tests --- src/myfasthtml/controls/VisNetwork.py | 44 +-- src/myfasthtml/core/network_utils.py | 238 ++++++++++++ tests/core/test_network_utils.py | 515 ++++++++++++++++++++++++++ 3 files changed, 775 insertions(+), 22 deletions(-) create mode 100644 src/myfasthtml/core/network_utils.py create mode 100644 tests/core/test_network_utils.py diff --git a/src/myfasthtml/controls/VisNetwork.py b/src/myfasthtml/controls/VisNetwork.py index 51a8418..d565469 100644 --- a/src/myfasthtml/controls/VisNetwork.py +++ b/src/myfasthtml/controls/VisNetwork.py @@ -1,3 +1,4 @@ +import json import logging from fasthtml.components import Script, Div @@ -51,18 +52,17 @@ class VisNetwork(MultipleInstance): self._state.update(state) def render(self): - # Prepare JS arrays (no JSON library needed) + + # Serialize nodes and edges to JSON + # This preserves all properties (color, shape, size, etc.) that are present js_nodes = ",\n ".join( - f'{{ id: {n["id"]}, label: "{n.get("label", "")}" }}' - for n in self._state.nodes + json.dumps(node) for node in self._state.nodes ) js_edges = ",\n ".join( - f'{{ from: {e["from"]}, to: {e["to"]} }}' - for e in self._state.edges + json.dumps(edge) for edge in self._state.edges ) # Convert Python options to JS - import json js_options = json.dumps(self._state.options, indent=2) return ( @@ -73,22 +73,22 @@ class VisNetwork(MultipleInstance): # The script initializing Vis.js Script(f""" - (function() {{ - const container = document.getElementById("{self._id}"); - const nodes = new vis.DataSet([ - {js_nodes} - ]); - const edges = new vis.DataSet([ - {js_edges} - ]); - const data = {{ - nodes: nodes, - edges: edges - }}; - const options = {js_options}; - const network = new vis.Network(container, data, options); - }})(); - """) + (function() {{ + const container = document.getElementById("{self._id}"); + const nodes = new vis.DataSet([ + {js_nodes} + ]); + const edges = new vis.DataSet([ + {js_edges} + ]); + const data = {{ + nodes: nodes, + edges: edges + }}; + const options = {js_options}; + const network = new vis.Network(container, data, options); + }})(); + """) ) def __ft__(self): diff --git a/src/myfasthtml/core/network_utils.py b/src/myfasthtml/core/network_utils.py new file mode 100644 index 0000000..7b73598 --- /dev/null +++ b/src/myfasthtml/core/network_utils.py @@ -0,0 +1,238 @@ +from collections.abc import Callable + + +def from_nested_dict(trees: list[dict]) -> tuple[list, list]: + """ + Convert a list of nested dictionaries to vis.js nodes and edges format. + + Args: + trees: List of nested dictionaries where keys are node names and values are + dictionaries of children (e.g., [{"root1": {"child1": {}}}, {"root2": {}}]) + + Returns: + tuple: (nodes, edges) where: + - nodes: list of dicts with auto-incremented numeric IDs + - edges: list of dicts with 'from' and 'to' keys + + Example: + >>> trees = [{"root1": {"child1": {}}}, {"root2": {"child2": {}}}] + >>> nodes, edges = from_nested_dict(trees) + >>> # nodes = [{"id": 1, "label": "root1"}, {"id": 2, "label": "child1"}, ...] + """ + nodes = [] + edges = [] + node_id_counter = 1 + node_id_map = {} # maps node_label -> node_id + + def traverse(subtree: dict, parent_id: int | None = None): + nonlocal node_id_counter + + for node_label, children in subtree.items(): + # Create node with auto-incremented ID + current_id = node_id_counter + node_id_map[node_label] = current_id + nodes.append({ + "id": current_id, + "label": node_label + }) + node_id_counter += 1 + + # Create edge from parent to current node + if parent_id is not None: + edges.append({ + "from": parent_id, + "to": current_id + }) + + # Recursively process children + if children: + traverse(children, parent_id=current_id) + + # Process each tree in the list + for tree in trees: + traverse(tree) + + return nodes, edges + + +def from_tree_with_metadata( + trees: list[dict], + id_getter: Callable = None, + label_getter: Callable = None, + children_getter: Callable = None +) -> tuple[list, list]: + """ + Convert a list of trees with metadata to vis.js nodes and edges format. + + Args: + trees: List of dictionaries with 'id', 'label', and 'children' keys + (e.g., [{"id": "root1", "label": "Root 1", "children": [...]}, ...]) + id_getter: Optional callback to extract node ID from dict + Default: lambda n: n.get("id") + label_getter: Optional callback to extract node label from dict + Default: lambda n: n.get("label", "") + children_getter: Optional callback to extract children list from dict + Default: lambda n: n.get("children", []) + + Returns: + tuple: (nodes, edges) where: + - nodes: list of dicts with IDs from tree or auto-incremented + - edges: list of dicts with 'from' and 'to' keys + + Example: + >>> trees = [ + ... { + ... "id": "root1", + ... "label": "Root Node 1", + ... "children": [ + ... {"id": "child1", "label": "Child 1", "children": []} + ... ] + ... }, + ... {"id": "root2", "label": "Root Node 2"} + ... ] + >>> nodes, edges = from_tree_with_metadata(trees) + """ + # Default getters + if id_getter is None: + id_getter = lambda n: n.get("id") + if label_getter is None: + label_getter = lambda n: n.get("label", "") + if children_getter is None: + children_getter = lambda n: n.get("children", []) + + nodes = [] + edges = [] + node_id_counter = 1 + + def traverse(node_dict: dict, parent_id: int | str | None = None): + nonlocal node_id_counter + + # Extract ID (use provided or auto-increment) + node_id = id_getter(node_dict) + if node_id is None: + node_id = node_id_counter + node_id_counter += 1 + + # Extract label + node_label = label_getter(node_dict) + + # Create node + nodes.append({ + "id": node_id, + "label": node_label + }) + + # Create edge from parent to current node + if parent_id is not None: + edges.append({ + "from": parent_id, + "to": node_id + }) + + # Recursively process children + children = children_getter(node_dict) + if children: + for child in children: + traverse(child, parent_id=node_id) + + # Process each tree in the list + for tree in trees: + traverse(tree) + + return nodes, edges + + +def from_parent_child_list( + items: list, + id_getter: callable = None, + label_getter: callable = None, + parent_getter: callable = None, + ghost_color: str = "#ff9999" +) -> tuple[list, list]: + """ + Convert a list of items with parent references to vis.js nodes and edges format. + + Args: + items: List of items (dicts or objects) with parent references + (e.g., [{"id": "child", "parent": "root", "label": "Child"}, ...]) + id_getter: Optional callback to extract node ID from item + Default: lambda item: item.get("id") + label_getter: Optional callback to extract node label from item + Default: lambda item: item.get("label", "") + parent_getter: Optional callback to extract parent ID from item + Default: lambda item: item.get("parent") + ghost_color: Color to use for ghost nodes (nodes referenced as parents but not in list) + Default: "#ff9999" (light red) + + Returns: + tuple: (nodes, edges) where: + - nodes: list of dicts with IDs from items, ghost nodes have color property + - edges: list of dicts with 'from' and 'to' keys + + Note: + - Nodes with parent=None or parent="" are treated as root nodes + - If a parent is referenced but doesn't exist in items, a ghost node is created + with the ghost_color applied + + Example: + >>> items = [ + ... {"id": "root", "label": "Root"}, + ... {"id": "child1", "parent": "root", "label": "Child 1"}, + ... {"id": "child2", "parent": "unknown", "label": "Child 2"} + ... ] + >>> nodes, edges = from_parent_child_list(items) + >>> # "unknown" will be created as a ghost node with color="#ff9999" + """ + # Default getters + if id_getter is None: + id_getter = lambda item: item.get("id") + if label_getter is None: + label_getter = lambda item: item.get("label", "") + if parent_getter is None: + parent_getter = lambda item: item.get("parent") + + nodes = [] + edges = [] + + # Track all existing node IDs + existing_ids = set() + + # First pass: create nodes for all items + for item in items: + node_id = id_getter(item) + node_label = label_getter(item) + + existing_ids.add(node_id) + nodes.append({ + "id": node_id, + "label": node_label + }) + + # Track ghost nodes to avoid duplicates + ghost_nodes = set() + + # Second pass: create edges and identify ghost nodes + for item in items: + node_id = id_getter(item) + parent_id = parent_getter(item) + + # Skip if no parent or parent is empty string or None + if parent_id is None or parent_id == "": + continue + + # Create edge from parent to child + edges.append({ + "from": parent_id, + "to": node_id + }) + + # Check if parent exists, if not create ghost node + if parent_id not in existing_ids and parent_id not in ghost_nodes: + ghost_nodes.add(parent_id) + nodes.append({ + "id": parent_id, + "label": str(parent_id), # Use ID as label for ghost nodes + "color": ghost_color + }) + + return nodes, edges diff --git a/tests/core/test_network_utils.py b/tests/core/test_network_utils.py new file mode 100644 index 0000000..7d1cfdc --- /dev/null +++ b/tests/core/test_network_utils.py @@ -0,0 +1,515 @@ +from myfasthtml.core.network_utils import from_nested_dict, from_tree_with_metadata, from_parent_child_list + + +class TestFromNestedDict: + def test_i_can_convert_single_root_node(self): + """Test conversion of a single root node without children.""" + trees = [{"root": {}}] + nodes, edges = from_nested_dict(trees) + + assert len(nodes) == 1 + assert nodes[0] == {"id": 1, "label": "root"} + assert len(edges) == 0 + + def test_i_can_convert_tree_with_one_level_children(self): + """Test conversion with direct children, verifying edge creation.""" + trees = [{"root": {"child1": {}, "child2": {}}}] + nodes, edges = from_nested_dict(trees) + + assert len(nodes) == 3 + assert nodes[0] == {"id": 1, "label": "root"} + assert nodes[1] == {"id": 2, "label": "child1"} + assert nodes[2] == {"id": 3, "label": "child2"} + + assert len(edges) == 2 + assert {"from": 1, "to": 2} in edges + assert {"from": 1, "to": 3} in edges + + def test_i_can_convert_tree_with_multiple_levels(self): + """Test recursive conversion with multiple levels of nesting.""" + trees = [ + { + "root": { + "child1": { + "grandchild1": {}, + "grandchild2": {} + }, + "child2": {} + } + } + ] + nodes, edges = from_nested_dict(trees) + + assert len(nodes) == 5 + assert len(edges) == 4 + + # Verify hierarchy + assert {"from": 1, "to": 2} in edges # root -> child1 + assert {"from": 1, "to": 5} in edges # root -> child2 + assert {"from": 2, "to": 3} in edges # child1 -> grandchild1 + assert {"from": 2, "to": 4} in edges # child1 -> grandchild2 + + def test_i_can_generate_auto_incremented_ids(self): + """Test that IDs start at 1 and increment correctly.""" + trees = [{"a": {"b": {"c": {}}}}] + nodes, edges = from_nested_dict(trees) + + ids = [node["id"] for node in nodes] + assert ids == [1, 2, 3] + + def test_i_can_use_dict_keys_as_labels(self): + """Test that dictionary keys become node labels.""" + trees = [{"RootNode": {"ChildNode": {}}}] + nodes, edges = from_nested_dict(trees) + + assert nodes[0]["label"] == "RootNode" + assert nodes[1]["label"] == "ChildNode" + + def test_i_can_convert_empty_list(self): + """Test that empty list returns empty nodes and edges.""" + trees = [] + nodes, edges = from_nested_dict(trees) + + assert nodes == [] + assert edges == [] + + def test_i_can_convert_multiple_root_nodes(self): + """Test conversion with multiple independent trees.""" + trees = [ + {"root1": {"child1": {}}}, + {"root2": {"child2": {}}} + ] + nodes, edges = from_nested_dict(trees) + + assert len(nodes) == 4 + assert nodes[0] == {"id": 1, "label": "root1"} + assert nodes[1] == {"id": 2, "label": "child1"} + assert nodes[2] == {"id": 3, "label": "root2"} + assert nodes[3] == {"id": 4, "label": "child2"} + + # Verify edges connect within trees, not across + assert len(edges) == 2 + assert {"from": 1, "to": 2} in edges + assert {"from": 3, "to": 4} in edges + + def test_i_can_maintain_id_sequence_across_multiple_trees(self): + """Test that ID counter continues across multiple trees.""" + trees = [ + {"tree1": {}}, + {"tree2": {}}, + {"tree3": {}} + ] + nodes, edges = from_nested_dict(trees) + + ids = [node["id"] for node in nodes] + assert ids == [1, 2, 3] + + +class TestFromTreeWithMetadata: + def test_i_can_convert_single_node_with_metadata(self): + """Test basic conversion with explicit id and label.""" + trees = [{"id": "root", "label": "Root Node"}] + nodes, edges = from_tree_with_metadata(trees) + + assert len(nodes) == 1 + assert nodes[0] == {"id": "root", "label": "Root Node"} + assert len(edges) == 0 + + def test_i_can_preserve_string_ids_from_metadata(self): + """Test that string IDs from the tree are preserved.""" + trees = [ + { + "id": "root_id", + "label": "Root", + "children": [ + {"id": "child_id", "label": "Child"} + ] + } + ] + nodes, edges = from_tree_with_metadata(trees) + + assert nodes[0]["id"] == "root_id" + assert nodes[1]["id"] == "child_id" + assert edges[0] == {"from": "root_id", "to": "child_id"} + + def test_i_can_auto_increment_when_id_is_missing(self): + """Test fallback to auto-increment when ID is not provided.""" + trees = [ + { + "label": "Root", + "children": [ + {"label": "Child1"}, + {"id": "child2_id", "label": "Child2"} + ] + } + ] + nodes, edges = from_tree_with_metadata(trees) + + assert nodes[0]["id"] == 1 # auto-incremented + assert nodes[1]["id"] == 2 # auto-incremented + assert nodes[2]["id"] == "child2_id" # preserved + + def test_i_can_convert_tree_with_children(self): + """Test handling of children list.""" + trees = [ + { + "id": "root", + "label": "Root", + "children": [ + { + "id": "child1", + "label": "Child 1", + "children": [ + {"id": "grandchild", "label": "Grandchild"} + ] + }, + {"id": "child2", "label": "Child 2"} + ] + } + ] + nodes, edges = from_tree_with_metadata(trees) + + assert len(nodes) == 4 + assert len(edges) == 3 + assert {"from": "root", "to": "child1"} in edges + assert {"from": "root", "to": "child2"} in edges + assert {"from": "child1", "to": "grandchild"} in edges + + def test_i_can_use_custom_id_getter(self): + """Test custom callback for extracting node ID.""" + trees = [ + { + "node_id": "custom_root", + "label": "Root" + } + ] + + def custom_id_getter(node): + return node.get("node_id") + + nodes, edges = from_tree_with_metadata( + trees, + id_getter=custom_id_getter + ) + + assert nodes[0]["id"] == "custom_root" + + def test_i_can_use_custom_label_getter(self): + """Test custom callback for extracting node label.""" + trees = [ + { + "id": "root", + "name": "Custom Label" + } + ] + + def custom_label_getter(node): + return node.get("name", "") + + nodes, edges = from_tree_with_metadata( + trees, + label_getter=custom_label_getter + ) + + assert nodes[0]["label"] == "Custom Label" + + def test_i_can_use_custom_children_getter(self): + """Test custom callback for extracting children.""" + trees = [ + { + "id": "root", + "label": "Root", + "kids": [ + {"id": "child", "label": "Child"} + ] + } + ] + + def custom_children_getter(node): + return node.get("kids", []) + + nodes, edges = from_tree_with_metadata( + trees, + children_getter=custom_children_getter + ) + + assert len(nodes) == 2 + assert nodes[1]["id"] == "child" + + def test_i_can_handle_missing_label_with_default(self): + """Test that missing label returns empty string.""" + trees = [{"id": "root"}] + nodes, edges = from_tree_with_metadata(trees) + + assert nodes[0]["label"] == "" + + def test_i_can_handle_missing_children_with_default(self): + """Test that missing children returns empty list (no children processed).""" + trees = [{"id": "root", "label": "Root"}] + nodes, edges = from_tree_with_metadata(trees) + + assert len(nodes) == 1 + assert len(edges) == 0 + + def test_i_can_convert_multiple_root_trees(self): + """Test conversion with multiple independent trees with metadata.""" + trees = [ + { + "id": "root1", + "label": "Root 1", + "children": [ + {"id": "child1", "label": "Child 1"} + ] + }, + { + "id": "root2", + "label": "Root 2", + "children": [ + {"id": "child2", "label": "Child 2"} + ] + } + ] + nodes, edges = from_tree_with_metadata(trees) + + assert len(nodes) == 4 + assert nodes[0]["id"] == "root1" + assert nodes[1]["id"] == "child1" + assert nodes[2]["id"] == "root2" + assert nodes[3]["id"] == "child2" + + # Verify edges connect within trees, not across + assert len(edges) == 2 + assert {"from": "root1", "to": "child1"} in edges + assert {"from": "root2", "to": "child2"} in edges + + def test_i_can_maintain_id_counter_across_multiple_trees_when_missing_ids(self): + """Test that auto-increment counter continues across multiple trees.""" + trees = [ + {"label": "Tree1"}, + {"label": "Tree2"}, + {"label": "Tree3"} + ] + nodes, edges = from_tree_with_metadata(trees) + + assert nodes[0]["id"] == 1 + assert nodes[1]["id"] == 2 + assert nodes[2]["id"] == 3 + + def test_i_can_convert_empty_list(self): + """Test that empty list returns empty nodes and edges.""" + trees = [] + nodes, edges = from_tree_with_metadata(trees) + + assert nodes == [] + assert edges == [] + + +class TestFromParentChildList: + def test_i_can_convert_single_root_node_without_parent(self): + """Test conversion of a single root node without parent.""" + items = [{"id": "root", "label": "Root"}] + nodes, edges = from_parent_child_list(items) + + assert len(nodes) == 1 + assert nodes[0] == {"id": "root", "label": "Root"} + assert len(edges) == 0 + + def test_i_can_convert_simple_parent_child_relationship(self): + """Test conversion with basic parent-child relationship.""" + items = [ + {"id": "root", "label": "Root"}, + {"id": "child", "parent": "root", "label": "Child"} + ] + nodes, edges = from_parent_child_list(items) + + assert len(nodes) == 2 + assert {"id": "root", "label": "Root"} in nodes + assert {"id": "child", "label": "Child"} in nodes + + assert len(edges) == 1 + assert edges[0] == {"from": "root", "to": "child"} + + def test_i_can_convert_multiple_children_with_same_parent(self): + """Test that one parent can have multiple children.""" + items = [ + {"id": "root", "label": "Root"}, + {"id": "child1", "parent": "root", "label": "Child 1"}, + {"id": "child2", "parent": "root", "label": "Child 2"} + ] + nodes, edges = from_parent_child_list(items) + + assert len(nodes) == 3 + assert len(edges) == 2 + assert {"from": "root", "to": "child1"} in edges + assert {"from": "root", "to": "child2"} in edges + + def test_i_can_convert_multi_level_hierarchy(self): + """Test conversion with multiple levels (root -> child -> grandchild).""" + items = [ + {"id": "root", "label": "Root"}, + {"id": "child", "parent": "root", "label": "Child"}, + {"id": "grandchild", "parent": "child", "label": "Grandchild"} + ] + nodes, edges = from_parent_child_list(items) + + assert len(nodes) == 3 + assert len(edges) == 2 + assert {"from": "root", "to": "child"} in edges + assert {"from": "child", "to": "grandchild"} in edges + + def test_i_can_handle_parent_none_as_root(self): + """Test that parent=None identifies a root node.""" + items = [ + {"id": "root", "parent": None, "label": "Root"}, + {"id": "child", "parent": "root", "label": "Child"} + ] + nodes, edges = from_parent_child_list(items) + + assert len(nodes) == 2 + assert len(edges) == 1 + assert edges[0] == {"from": "root", "to": "child"} + + def test_i_can_handle_parent_empty_string_as_root(self): + """Test that parent='' identifies a root node.""" + items = [ + {"id": "root", "parent": "", "label": "Root"}, + {"id": "child", "parent": "root", "label": "Child"} + ] + nodes, edges = from_parent_child_list(items) + + assert len(nodes) == 2 + assert len(edges) == 1 + assert edges[0] == {"from": "root", "to": "child"} + + def test_i_can_create_ghost_node_for_missing_parent(self): + """Test automatic creation of ghost node when parent doesn't exist.""" + items = [ + {"id": "child", "parent": "missing_parent", "label": "Child"} + ] + nodes, edges = from_parent_child_list(items) + + assert len(nodes) == 2 + # Find the ghost node + ghost_node = [n for n in nodes if n["id"] == "missing_parent"][0] + assert ghost_node is not None + + assert len(edges) == 1 + assert edges[0] == {"from": "missing_parent", "to": "child"} + + def test_i_can_apply_ghost_color_to_missing_parent(self): + """Test that ghost nodes have the default ghost color.""" + items = [ + {"id": "child", "parent": "ghost", "label": "Child"} + ] + nodes, edges = from_parent_child_list(items) + + ghost_node = [n for n in nodes if n["id"] == "ghost"][0] + assert "color" in ghost_node + assert ghost_node["color"] == "#ff9999" + + def test_i_can_use_custom_ghost_color(self): + """Test that custom ghost_color parameter is applied.""" + items = [ + {"id": "child", "parent": "ghost", "label": "Child"} + ] + nodes, edges = from_parent_child_list(items, ghost_color="#0000ff") + + ghost_node = [n for n in nodes if n["id"] == "ghost"][0] + assert ghost_node["color"] == "#0000ff" + + def test_i_can_create_multiple_ghost_nodes(self): + """Test handling of multiple missing parents.""" + items = [ + {"id": "child1", "parent": "ghost1", "label": "Child 1"}, + {"id": "child2", "parent": "ghost2", "label": "Child 2"} + ] + nodes, edges = from_parent_child_list(items) + + assert len(nodes) == 4 # 2 real + 2 ghost + ghost_ids = [n["id"] for n in nodes if "color" in n] + assert "ghost1" in ghost_ids + assert "ghost2" in ghost_ids + + def test_i_can_avoid_duplicate_ghost_nodes(self): + """Test that same missing parent creates only one ghost node.""" + items = [ + {"id": "child1", "parent": "ghost", "label": "Child 1"}, + {"id": "child2", "parent": "ghost", "label": "Child 2"} + ] + nodes, edges = from_parent_child_list(items) + + assert len(nodes) == 3 # 2 real + 1 ghost + ghost_nodes = [n for n in nodes if n["id"] == "ghost"] + assert len(ghost_nodes) == 1 + + assert len(edges) == 2 + assert {"from": "ghost", "to": "child1"} in edges + assert {"from": "ghost", "to": "child2"} in edges + + def test_i_can_use_custom_id_getter(self): + """Test custom callback for extracting node ID.""" + items = [ + {"node_id": "root", "label": "Root"} + ] + + def custom_id_getter(item): + return item.get("node_id") + + nodes, edges = from_parent_child_list( + items, + id_getter=custom_id_getter + ) + + assert nodes[0]["id"] == "root" + + def test_i_can_use_custom_label_getter(self): + """Test custom callback for extracting node label.""" + items = [ + {"id": "root", "name": "Custom Label"} + ] + + def custom_label_getter(item): + return item.get("name", "") + + nodes, edges = from_parent_child_list( + items, + label_getter=custom_label_getter + ) + + assert nodes[0]["label"] == "Custom Label" + + def test_i_can_use_custom_parent_getter(self): + """Test custom callback for extracting parent ID.""" + items = [ + {"id": "root", "label": "Root"}, + {"id": "child", "parent_id": "root", "label": "Child"} + ] + + def custom_parent_getter(item): + return item.get("parent_id") + + nodes, edges = from_parent_child_list( + items, + parent_getter=custom_parent_getter + ) + + assert len(edges) == 1 + assert edges[0] == {"from": "root", "to": "child"} + + def test_i_can_handle_empty_list(self): + """Test that empty list returns empty nodes and edges.""" + items = [] + nodes, edges = from_parent_child_list(items) + + assert nodes == [] + assert edges == [] + + def test_i_can_use_id_as_label_for_ghost_nodes(self): + """Test that ghost nodes use their ID as label by default.""" + items = [ + {"id": "child", "parent": "ghost_parent", "label": "Child"} + ] + nodes, edges = from_parent_child_list(items) + + ghost_node = [n for n in nodes if n["id"] == "ghost_parent"][0] + assert ghost_node["label"] == "ghost_parent"