Source code for Karana.KUtils.visjs._locking_base

# Copyright (c) 2024-2026 Karana Dynamics Pty Ltd. All rights reserved.
#
# NOTICE TO USER:
#
# This source code and/or documentation (the "Licensed Materials") is
# the confidential and proprietary information of Karana Dynamics Inc.
# Use of these Licensed Materials is governed by the terms and conditions
# of a separate software license agreement between Karana Dynamics and the
# Licensee ("License Agreement"). Unless expressly permitted under that
# agreement, any reproduction, modification, distribution, or disclosure
# of the Licensed Materials, in whole or in part, to any third party
# without the prior written consent of Karana Dynamics is strictly prohibited.
#
# THE LICENSED MATERIALS ARE PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND.
# KARANA DYNAMICS DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING
# BUT NOT LIMITED TO WARRANTIES OF MERCHANTABILITY, NON-INFRINGEMENT, AND
# FITNESS FOR A PARTICULAR PURPOSE.
#
# IN NO EVENT SHALL KARANA DYNAMICS BE LIABLE FOR ANY DAMAGES WHATSOEVER,
# INCLUDING BUT NOT LIMITED TO LOSS OF PROFITS, DATA, OR USE, EVEN IF
# ADVISED OF THE POSSIBILITY OF SUCH DAMAGES, WHETHER IN CONTRACT, TORT,
# OR OTHERWISE ARISING OUT OF OR IN CONNECTION WITH THE LICENSED MATERIALS.
#
# U.S. Government End Users: The Licensed Materials are a "commercial item"
# as defined at 48 C.F.R. 2.101, and are provided to the U.S. Government
# only as a commercial end item under the terms of this license.
#
# Any use of the Licensed Materials in individual or commercial software must
# include, in the user documentation and internal source code comments,
# this Notice, Disclaimer, and U.S. Government Use Provision.

"""Tools for viewing LockingBase dependencies with vis-network.js."""

from collections import defaultdict
from collections.abc import Mapping
from pathlib import Path
import re
from tempfile import TemporaryDirectory

import Karana.Core as kc

from ._datatypes import (
    ArrowOptions,
    ArrowStyle,
    Edge,
    EdgeColorOptions,
    EdgeOptions,
    NetworkGraph,
    NetworkOptions,
    Node,
    NodeColorOptions,
    NodeFontOptions,
    NodeOptions,
    SmoothOptions,
)
from ._server import GraphServer

__all__ = ["LockingBaseGraphServer"]


_DOT_EDGE_PATTERN = re.compile(r"^\s*(\d+)\s*->\s*(\d+)\s*;", re.MULTILINE)
_DEFAULT_EDGE_COLOR = "#848484"
_TRANSITIVE_EDGE_COLOR = "#FF0000"
_TRANSITIVE_PATH_EDGE_COLOR = "#0072B2"
_HIGHLIGHT_COLOR = "#FF00FF"


def _lockingBases(base_container: kc.BaseContainer) -> list[kc.LockingBase]:
    """Return a stable snapshot of the container's LockingBase objects."""
    container_snapshot = list(base_container)
    locking_bases = []
    for object_id, _ in container_snapshot:
        locking_base = base_container.atLockingBase(object_id)
        if locking_base is not None:
            locking_bases.append(locking_base)
    return sorted(locking_bases, key=lambda locking_base: locking_base.id())


def _graphvizDownstreamEdges(locking_base: kc.LockingBase) -> set[tuple[int, int]]:
    """Read direct downstream edges using kdFlex's ID-based debug output."""
    with TemporaryDirectory(prefix="kdflex-lockingbase-") as temporary_directory:
        dot_path = Path(temporary_directory) / "dependencies.dot"
        kc.DebugManager.dumpDependencyGraphviz(
            locking_base,
            dot_path,
            downstream_depth=1,
            upstream_depth=0,
        )
        dot_text = dot_path.read_text(encoding="utf-8")

    return {
        (int(source_id), int(target_id))
        for source_id, target_id in _DOT_EDGE_PATTERN.findall(dot_text)
        if int(source_id) == locking_base.id()
    }


def _dependencyEdges(locking_bases: list[kc.LockingBase]) -> set[tuple[int, int]]:
    """Find direct dependency-to-dependent edges for the supplied objects."""
    edges: set[tuple[int, int]] = set()
    for lb in locking_bases:
        edges |= set((lb.id(), x.id()) for x in kc.DebugManager.getDownstreamDeps(lb))

    known_ids = {locking_base.id() for locking_base in locking_bases}
    return {
        (source_id, target_id)
        for source_id, target_id in edges
        if source_id in known_ids and target_id in known_ids
    }


def _transitiveEdges(edges: set[tuple[int, int]]) -> set[tuple[int, int]]:
    """Return edges whose endpoints are also connected by another path."""
    downstream: dict[int, set[int]] = defaultdict(set)
    for source_id, target_id in edges:
        downstream[source_id].add(target_id)

    redundant_edges = set()
    for source_id, target_id in edges:
        pending = list(downstream[source_id] - {target_id})
        visited = {source_id}
        while pending:
            candidate_id = pending.pop()
            if candidate_id == target_id:
                redundant_edges.add((source_id, target_id))
                break
            if candidate_id in visited:
                continue
            visited.add(candidate_id)
            pending.extend(downstream[candidate_id] - visited)

    return redundant_edges


def _reachableNodes(
    start_id: int,
    adjacency: Mapping[int, set[int]],
    excluded_edge: tuple[int, int],
    *,
    reverse: bool = False,
) -> set[int]:
    """Return nodes reachable without traversing the excluded edge."""
    visited = {start_id}
    pending = [start_id]
    while pending:
        current_id = pending.pop()
        for candidate_id in adjacency.get(current_id, set()):
            traversed_edge = (candidate_id, current_id) if reverse else (current_id, candidate_id)
            if traversed_edge == excluded_edge or candidate_id in visited:
                continue
            visited.add(candidate_id)
            pending.append(candidate_id)
    return visited


def _transitivePathEdges(
    edges: set[tuple[int, int]],
    transitive_edges: set[tuple[int, int]],
) -> set[tuple[int, int]]:
    """Return edges on alternate paths parallel to transitive edges."""
    downstream: dict[int, set[int]] = defaultdict(set)
    upstream: dict[int, set[int]] = defaultdict(set)
    for source_id, target_id in edges:
        downstream[source_id].add(target_id)
        upstream[target_id].add(source_id)

    path_edges = set()
    for transitive_edge in transitive_edges:
        source_id, target_id = transitive_edge
        reachable_from_source = _reachableNodes(
            source_id,
            downstream,
            transitive_edge,
        )
        can_reach_target = _reachableNodes(
            target_id,
            upstream,
            transitive_edge,
            reverse=True,
        )
        path_edges.update(
            edge
            for edge in edges - {transitive_edge}
            if edge[0] in reachable_from_source and edge[1] in can_reach_target
        )

    return path_edges


def _toNode(
    locking_base: kc.LockingBase,
    label_map: Mapping[int | str, str],
) -> Node:
    """Create a visjs node for a LockingBase."""
    healthy = locking_base.isHealthy()
    color = "#B7E4C7" if healthy else "#FFB4A2"
    label = label_map.get(
        locking_base.id(),
        label_map.get(locking_base.name(), locking_base.name()),
    )
    return Node(
        id=locking_base.id(),
        label=str(label),
        title=(
            f"{locking_base.typeString()}\n"
            f"{locking_base.name()} (id={locking_base.id()})\n"
            f"healthy={healthy}"
        ),
        options=NodeOptions(
            shape="box",
            size=25,
            borderWidth=2,
            color=NodeColorOptions(
                background=color,
                border=color,
                highlight={"border": _HIGHLIGHT_COLOR, "background": color},
            ),
            font=NodeFontOptions(color="#000000", size=12, face="Arial"),
        ),
    )


def _toEdge(
    source_id: int,
    target_id: int,
    transitive_edges: set[tuple[int, int]],
    transitive_path_edges: set[tuple[int, int]],
) -> Edge:
    """Create a visjs edge for a LockingBase dependency."""
    is_transitive = (source_id, target_id) in transitive_edges
    is_transitive_path = (source_id, target_id) in transitive_path_edges
    if is_transitive:
        color = _TRANSITIVE_EDGE_COLOR
        title = "Transitive dependency (removable)"
    elif is_transitive_path:
        color = _TRANSITIVE_PATH_EDGE_COLOR
        title = "Alternate path for a transitive dependency"
    else:
        color = _DEFAULT_EDGE_COLOR
        title = "Dependency"
    return Edge(
        from_=source_id,
        to=target_id,
        id=f"lockingbase-{source_id}-{target_id}",
        title=title,
        options=EdgeOptions(
            color=EdgeColorOptions(color=color, highlight=_HIGHLIGHT_COLOR),
            width=3 if is_transitive or is_transitive_path else 2,
            dashes=False,
            arrows=ArrowOptions(
                to=ArrowStyle(enabled=True, scaleFactor=1.2),
            ),
            smooth=SmoothOptions(type="cubicBezier", roundness=0.3),
        ),
    )


# The stored graph configuration mirrors FSMGraphServer, and the constructor
# deliberately exposes the corresponding graph/server options in one place.
# pylint: disable=too-many-instance-attributes,too-few-public-methods
class LockingBaseGraphServer(GraphServer):
    """Specialized GraphServer for viewing all LockingBase dependencies.

    Edges point from a dependency to its dependent, matching the direction
    established by ``dependency.addDependent(dependent)``.
    """

    def __init__(
        self,
        base_container: kc.BaseContainer | None = None,
        *,
        port: int = 0,
        title: str = "LockingBase dependencies",
        label_map: Mapping[int | str, str] | None = None,
        highlight_transitiveEdges: bool = True,
        network_options: NetworkOptions | None = None,
    ):  # pylint: disable=too-many-arguments
        """Create and bind a LockingBaseGraphServer.

        Parameters
        ----------
        base_container : kc.BaseContainer | None
            Container whose LockingBases should be displayed. The singleton
            BaseContainer is used by default.
        port : int
            Port to bind. Port 0 selects an arbitrary free port.
        title : str
            Graph title.
        label_map : Mapping[int | str, str] | None
            Optional labels keyed by a LockingBase ID or name. ID labels take
            precedence over name labels.
        highlight_transitiveEdges : bool
            If true, color a direct edge red when the same source and target
            are connected by another path, meaning the edge is removable by a
            transitive reduction. Edges on the alternate path are colored
            blue.
        network_options : NetworkOptions | None
            visjs network options. Default options are used when omitted.
        """
        self._base_container = (
            kc.BaseContainer.singleton() if base_container is None else base_container
        )
        self._title = title
        self._label_map = dict(label_map or {})
        self._highlight_transitive_edges = highlight_transitiveEdges
        self._network_options = NetworkOptions() if network_options is None else network_options
        self.nodes: dict[int, Node] = {}
        self.edges: dict[str, Edge] = {}

        self._buildGraph()
        super().__init__(graph=self.graph, port=port, buttons=[])

    def _buildGraph(self):
        """Build the graph from the current BaseContainer state."""
        locking_bases = _lockingBases(self._base_container)
        dependencies = _dependencyEdges(locking_bases)
        transitive_edges = (
            _transitiveEdges(dependencies) if self._highlight_transitive_edges else set()
        )
        transitive_path_edges = _transitivePathEdges(
            dependencies,
            transitive_edges,
        )

        self.nodes = {
            locking_base.id(): _toNode(locking_base, self._label_map)
            for locking_base in locking_bases
        }
        graph_edges = [
            _toEdge(
                source_id,
                target_id,
                transitive_edges,
                transitive_path_edges,
            )
            for source_id, target_id in sorted(dependencies)
        ]
        self.edges = {str(edge.id): edge for edge in graph_edges}
        self.graph = NetworkGraph(
            nodes=list(self.nodes.values()),
            edges=list(self.edges.values()),
            title=self._title,
            options=self._network_options,
        )

[docs] def refresh(self, *, highlight_transitiveEdges: bool | None = None): """Rebuild the dependency graph and broadcast it to connected clients. Parameters ---------- highlight_transitiveEdges : bool | None Optionally change transitive-edge highlighting before rebuilding. If omitted, the current setting is retained. """ if highlight_transitiveEdges is not None: self._highlight_transitive_edges = highlight_transitiveEdges self._buildGraph() self.updateClientGraphs()