WeakNode and Nested WeakNode (Interactive)

This notebook shows how to build Document Section Page hierarchies with WeakNode, how to inspect the graph state after each operation, and how to delete nodes with different strategies.

It includes an interactive visual graph editor powered by Cytoscape.js: the graph updates in real time as you add or delete nodes, with color-coded node types and a hierarchical layout.

[ ]:
import importlib.util

# Check if the package is installed
package_to_check = 'cvcdocdb'
spec = importlib.util.find_spec(package_to_check)

if spec is None:
    print(f'⚠️ {package_to_check} is not installed. Installing...')
    %pip install -q --upgrade cvcdocdb-tools
    print("✅ Installation complete. Kernel may need a restart.")
else:
    print(f'✅ {package_to_check} is already installed. Skipping installation.')

Overview

The notebook is organised as a step-by-step walkthrough followed by an interactive visual editor:

  1. Control panel — add documents, sections, pages, or delete selected nodes

  2. Visual graph — real-time Cytoscape.js rendering with color-coded node types

  3. State inspector — detailed text output after each operation

Node colors: 🟢 Document (strong) · 🔵 Section (weak) · 🟠 Page (nested weak)

Interactions: Scroll to zoom · Drag to pan · Drag nodes to rearrange

[ ]:
import os
import tempfile
import uuid
from IPython.display import display, clear_output, Javascript, HTML
import ipywidgets as widgets

try:
    from cvcdocdb import NetworkXGraph, Node, WeakNode
except ImportError:
    from cvcdocdb import NetworkXGraph
    from cvcdocdb.base import Node, WeakNode

persistence_path = os.path.join(tempfile.gettempdir(), f"cvcdocdb_tutorial_weaknodes_{uuid.uuid4().hex}.pkl")
graph = NetworkXGraph(persistence_path=persistence_path)
registry = {}

# ── Color palette for node types ──────────────────────────────────
NODE_COLORS = {
    "Document": "#4CAF50",
    "Section":  "#2196F3",
    "Page":     "#FF9800",
}

NODE_BORDERS = {
    "Document": "#2E7D32",
    "Section":  "#1565C0",
    "Page":     "#E65100",
}
[ ]:
# ── Core graph operations ─────────────────────────────────────────

def key_for_document(doc_id):
    return f"doc:{doc_id}"

def key_for_section(doc_id, section_id):
    return f"section:{doc_id}:{section_id}"

def key_for_page(doc_id, section_id, page_id):
    return f"page:{doc_id}:{section_id}:{page_id}"

def show_state(title="State"):
    print(f"\n=== {title} ===")
    print("nodes:", graph.get_nodes())
    print("edges:", graph.get_edges())
    debug = graph.debug()
    print("debug nodes:", debug.get("nodes", []))
    print("debug edges:", debug.get("edges", []))

def add_document(doc_id):
    k = key_for_document(doc_id)
    node = Node(pk={"doc": doc_id}, main_label="Document")
    graph.insertNode(node, replace=True)
    registry[k] = node
    return k

def add_section(doc_id, section_id):
    parent_key = key_for_document(doc_id)
    if parent_key not in registry:
        add_document(doc_id)
    parent = registry[parent_key]
    k = key_for_section(doc_id, section_id)
    node = WeakNode(parent=parent, pk={"section": section_id}, main_label="Section")
    graph.insertNode(node, insert_parent=True, replace=True)
    registry[k] = node
    return k

def add_page(doc_id, section_id, page_id):
    section_key = key_for_section(doc_id, section_id)
    if section_key not in registry:
        add_section(doc_id, section_id)
    parent = registry[section_key]
    k = key_for_page(doc_id, section_id, page_id)
    node = WeakNode(parent=parent, pk={"page": page_id}, main_label="Page")
    graph.insertNode(node, insert_parent=True, replace=True)
    registry[k] = node
    return k

def delete_key(k, detach=True, propagation=False, on_delete="cascade"):
    node = registry.get(k)
    if node is None:
        raise KeyError(f"Unknown key: {k}")
    graph.deleteNode(node, detach=detach, propagation=propagation, on_delete=on_delete)
    registry.pop(k, None)
[ ]:
# ── Graph → Cytoscape conversion helpers ───────────────────────────

def _node_label(key, node):
    """Generate a human-readable label for display."""
    base = node.main_label.split(':')[0] if ':' in node.main_label else node.main_label
    pk_parts = []
    if hasattr(node, '_pk') and node._pk:
        for k, v in sorted(node._pk.items()):
            pk_parts.append(f"{k}={v}")
    return f"{base}({', '.join(pk_parts)})" if pk_parts else base

def _node_color(main_label):
    """Map node main label to a color."""
    base = main_label.split(':')[0] if ':' in main_label else main_label
    return NODE_COLORS.get(base, '#888888')

def _node_border(main_label):
    """Map node main label to a border color."""
    base = main_label.split(':')[0] if ':' in main_label else main_label
    return NODE_BORDERS.get(base, '#555555')

def _is_weak_node(node):
    """Check if a node is a WeakNode."""
    return isinstance(node, WeakNode)

def update_graph_visualization():
    """Extract nodes and edges from the graph and push to the Cytoscape.js visualization."""
    nodes = []
    edges = []

    # Collect nodes from registry
    for key, node in registry.items():
        nodes.append({
            "id": key,
            "label": _node_label(key, node),
            "color": _node_color(node.main_label),
            "border": _node_border(node.main_label),
            "weak": _is_weak_node(node),
        })

    # Collect edges from the graph
    try:
        all_edges = graph.get_edges()
        for edge in all_edges:
            src = getattr(edge, 'source', None)
            tgt = getattr(edge, 'target', None)
            if src and tgt:
                src_key = getattr(src, '_key', None) or str(src)
                tgt_key = getattr(tgt, '_key', None) or str(tgt)

                is_weak_edge = False
                if hasattr(edge, '_metadata') and edge._metadata:
                    is_weak_edge = edge._metadata.get('_propagate', False)

                edges.append({
                    "id": f"e_{src_key}_{tgt_key}",
                    "source": src_key,
                    "target": tgt_key,
                    "label": "cascade" if is_weak_edge else "",
                    "weak": is_weak_edge,
                })
    except Exception:
        pass

    # Push to Cytoscape.js
    js_code = f"updateGraph({nodes}, {edges});"
    display(Javascript(js_code))

print("Graph initialized at:", persistence_path)

Step-by-step walkthrough

Build a hierarchy and watch the visual graph update below:

[ ]:
add_document("DOC-001")
update_graph_visualization()
show_state("After adding Document")

add_section("DOC-001", 1)
update_graph_visualization()
show_state("After adding Section as WeakNode")

add_page("DOC-001", 1, 1)
update_graph_visualization()
show_state("After adding Page as WeakNode of WeakNode")
[ ]:
delete_key("section:DOC-001:1", detach=True, propagation=False, on_delete="cascade")
update_graph_visualization()
show_state("After deleting Section with ON DELETE CASCADE")

Interactive visual editor

Below is the interactive graph visualization (Cytoscape.js). Use the control panel to add or delete nodes, and watch the graph update in real time.

How to interact:

  • Scroll to zoom in/out

  • Drag the background to pan

  • Drag individual nodes to rearrange

  • Hover over nodes to see their details

  • Dashed blue edges indicate WeakRelation links (cascade delete)

[ ]:
# ── Cytoscape.js visualization ────────────────────────────────────

CYTOSCAPE_HTML = """
<div id="cvcdocdb-graph" style="
    width: 100%;
    height: 520px;
    border: 1px solid #ddd;
    border-radius: 8px;
    background: #fafafa;
    overflow: hidden;
"></div>

<div id="graph-info" style="
    margin-top: 10px;
    padding: 8px 12px;
    background: #f5f5f5;
    border-radius: 4px;
    font-family: monospace;
    font-size: 13px;
    color: #555;
"></div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.30.3/cytoscape.min.js"></script>
<script>
var drmCy = null;

function initGraph() {
    if (drmCy) { drmCy.destroy(); }
    drmCy = cytoscape({
        container: document.getElementById('cvcdocdb-graph'),
        elements: [],
        style: [
            // Strong nodes (Documents) — rounded rectangles
            {
                selector: 'node.strong',
                style: {
                    'label': 'data(label)',
                    'width': 110,
                    'height': 48,
                    'shape': 'round-rectangle',
                    'background-color': 'data(color)',
                    'border-width': 3,
                    'border-color': 'data(border)',
                    'color': '#fff',
                    'text-valign': 'center',
                    'text-halign': 'center',
                    'text-wrap': 'wrap',
                    'text-max-width': 100,
                    'font-size': 12,
                    'font-weight': 'bold',
                }
            },
            // Weak nodes (Sections, Pages) — rectangles
            {
                selector: 'node.weak',
                style: {
                    'label': 'data(label)',
                    'width': 100,
                    'height': 40,
                    'shape': 'rectangle',
                    'background-color': 'data(color)',
                    'border-width': 3,
                    'border-color': 'data(border)',
                    'color': '#fff',
                    'text-valign': 'center',
                    'text-halign': 'center',
                    'text-wrap': 'wrap',
                    'text-max-width': 90,
                    'font-size': 11,
                    'font-weight': 'bold',
                }
            },
            // Hover effect
            {
                selector: ':hover',
                style: {
                    'border-width': 5,
                    'shadow-blur': 10,
                    'shadow-color': '#ccc',
                    'shadow-opacity': 0.5,
                }
            },
            // Strong edges (parent-child)
            {
                selector: 'edge.strong',
                style: {
                    'width': 2.5,
                    'line-color': '#bbb',
                    'target-arrow-color': '#bbb',
                    'target-arrow-shape': 'triangle',
                    'arrow-scale': 1.3,
                    'curve-style': 'bezier',
                    'label': 'data(label)',
                    'font-size': 9,
                    'color': '#999',
                    'text-rotation': 'autorotate',
                    'text-margin-y': -12,
                }
            },
            // Weak edges (WeakRelation with cascade) — dashed blue
            {
                selector: 'edge.weak',
                style: {
                    'width': 3.5,
                    'line-style': 'dashed',
                    'line-color': '#64B5F6',
                    'target-arrow-color': '#64B5F6',
                    'target-arrow-shape': 'triangle',
                    'arrow-scale': 1.4,
                    'curve-style': 'bezier',
                    'label': 'data(label)',
                    'font-size': 10,
                    'color': '#1976D2',
                    'font-weight': 'bold',
                    'text-rotation': 'autorotate',
                    'text-margin-y': -14,
                }
            },
        ],
        layout: { name: 'breadthfirst', directed: true, padding: 50, nodeSeparation: 30 },
        userZoomable: true,
        userPannable: true,
        userPannableUnpanned: true,
        selectionType: 'single',
        autounselectify: true,
        minZoom: 0.3,
        maxZoom: 3,
    });
}

function updateGraph(nodes, edges) {
    if (!drmCy) { initGraph(); }

    var elements = [];
    nodes.forEach(function(n) {
        elements.push({
            group: 'nodes',
            data: {
                id: n.id,
                label: n.label,
                color: n.color || '#888888',
                border: n.border || '#555555',
            },
            classes: n.weak ? 'weak' : 'strong',
        });
    });
    edges.forEach(function(e) {
        elements.push({
            group: 'edges',
            data: {
                id: e.id,
                source: e.source,
                target: e.target,
                label: e.label || '',
            },
            classes: e.weak ? 'weak' : 'strong',
        });
    });

    drmCy.elements().remove();
    drmCy.add(elements);

    var layout = drmCy.layout({
        name: 'breadthfirst',
        directed: true,
        padding: 50,
        nodeSeparation: 40,
        animate: true,
        animationDuration: 300,
    });
    layout.run();

    var info = document.getElementById('graph-info');
    if (info) {
        var typeCounts = {};
        nodes.forEach(function(n) {
            var base = n.label.split('(')[0].trim();
            typeCounts[base] = (typeCounts[base] || 0) + 1;
        });
        var summary = Object.entries(typeCounts)
            .map(function(e) { return e[0] + ': ' + e[1]; })
            .join('  ·  ');
        info.innerHTML = nodes.length + ' node(s), ' + edges.length + ' edge(s) — ' + summary;
    }
}

// Auto-initialize
initGraph();
updateGraph([], []);
</script>
"""

display(HTML(CYTOSCAPE_HTML))
[ ]:
# ── Control panel ─────────────────────────────────────────────────

doc_w = widgets.Text(value="DOC-002", description="doc:", layout=widgets.Layout(width='150px'))
section_w = widgets.IntText(value=1, description="sec:", layout=widgets.Layout(width='70px'))
page_w = widgets.IntText(value=1, description="page:", layout=widgets.Layout(width='70px'))

delete_dropdown = widgets.Dropdown(options=[], description="delete:", layout=widgets.Layout(width='250px'))
detach_w = widgets.Checkbox(value=True, description="detach", layout=widgets.Layout(width='90px'))
prop_w = widgets.Checkbox(value=False, description="propagate", layout=widgets.Layout(width='100px'))
on_delete_w = widgets.Dropdown(
    options=["cascade", "restrict", "set_null"],
    value="cascade",
    description="on_delete:",
    layout=widgets.Layout(width='150px')
)
out = widgets.Output(layout=widgets.Layout(max_height='180px', overflow='auto'))

def refresh_delete_options(*_):
    opts = sorted(registry.keys())
    delete_dropdown.options = opts if opts else ["(none)"]

def act_add_doc(_):
    with out:
        clear_output(wait=True)
        k = add_document(doc_w.value)
        print(f"✓ Added: {k}")
    refresh_delete_options()
    update_graph_visualization()

def act_add_section(_):
    with out:
        clear_output(wait=True)
        k = add_section(doc_w.value, section_w.value)
        print(f"✓ Added: {k}")
    refresh_delete_options()
    update_graph_visualization()

def act_add_page(_):
    with out:
        clear_output(wait=True)
        k = add_page(doc_w.value, section_w.value, page_w.value)
        print(f"✓ Added: {k}")
    refresh_delete_options()
    update_graph_visualization()

def act_delete(_):
    with out:
        clear_output(wait=True)
        k = delete_dropdown.value
        if not k or k == "(none)":
            print("No node selected for deletion")
            return
        try:
            delete_key(k, detach=detach_w.value, propagation=prop_w.value, on_delete=on_delete_w.value)
            print(f"✓ Deleted: {k}")
        except Exception as err:
            print(f"✗ Delete error: {err}")
    refresh_delete_options()
    update_graph_visualization()

btn_doc = widgets.Button(description="+ Document", button_style="success", tooltip="Add a new Document")
btn_sec = widgets.Button(description="+ Section", button_style="info", tooltip="Add a Section under the current doc")
btn_page = widgets.Button(description="+ Page", button_style="warning", tooltip="Add a Page under the current section")
btn_del = widgets.Button(description="Delete", button_style="danger", tooltip="Delete the selected node")

btn_doc.on_click(act_add_doc)
btn_sec.on_click(act_add_section)
btn_page.on_click(act_add_page)
btn_del.on_click(act_delete)

refresh_delete_options()

display(widgets.VBox([
    widgets.HBox([doc_w, section_w, page_w]),
    widgets.HBox([btn_doc, btn_sec, btn_page]),
    widgets.HBox([delete_dropdown, detach_w, prop_w, on_delete_w, btn_del]),
    out,
]))

Cleanup

Close the graph and remove temporary files:

[ ]:
graph.close()
try:
    os.remove(persistence_path)
except FileNotFoundError:
    pass
print("Closed graph and removed temp persistence file")